找回密码
 立即注册

重新认识 C++:跨世纪的现代演进

2025-7-31 13:32

2024 年12 月 5 日,美国国家工程院、ACM、IEEE院士、C++ 之父Bjarne Stroustrup在「2024 全球 C++ 及系统软件技术大会」上发表了题为《重新认识 C++:跨世纪的现代演进》的演讲。屏幕上,演示文稿的第一页就令人印 ...

2024 年12 月 5 日,美国国家工程院、ACMIEEE 院士、C++ 之父 Bjarne Stroustrup在「2024 全球 C++ 及系统软件技术大会」上发表了题为《重新认识 C++:跨世纪的现代演进》的演讲。屏幕上,演示文稿的第一页就令人印象深刻:“C++ 几乎可以实现我们所期望的一切!

从构建操作系统到开发高性能游戏引擎,从支持人工智能框架到驱动航天器控制系统,C++ 一直是系统级软件开发的首选语言。然而,这位编程语言大师并不是在炫耀 C++ 的强大,而是要指出一个关键问题:“正因为它如此强大,我们更要谨慎选择正确的使用方式。就像 goto 语句——它无所不能,所以我们几乎从来不用它。同样的,虽然用 20 世纪 80 年代的方式写 C++ 也能完成任务,但这显然不是最佳选择。我们需要明确自己的真正需求,避免重蹈覆辙。”

Stroustrup 指出了一个常见的认知误区:人们往往把“熟悉”等同于“简单”。对很多开发者来说,见过千百遍的代码写法看起来简单,而新的特性和方法则显得复杂。

“我们必须努力避免这种思维定式,否则就会永远停留在 20 世纪。”他强调道,“今天,我想谈谈我所认为的当代 C++、现代 C++ 的基础是什么。我认为,当代的编程方式能让代码变得更简单、更安全、更高效,远胜于任何旧版本的 C++。(在这一语境下,“当代”往往指的是 C++20/23/26 等当前的版本)”

当代 C++ 的简洁之美

为了说明当代 C++的优势,Stroustrup 首先以他收到的一个来自《龙书》(Dragon Book,编译器设计领域的经典教材)作者、AWK 语言的创造者之一 Alfred V. Aho 的难题为例。这个例子展示了如何用 C++ 简洁地处理文本中的不重复行:

import std;using namespace std;

int main() // 输出输入流中的不重复行{ unordered_map<string,int> m; // 哈希表 for (string line; getline(cin,line); ) if (m[line]++ == 0) cout << line << '\n';}

“这段代码展示了几个重要特点,”Stroustrup 解释道,“首先,完全没有使用预处理器;其次,代码高效且容易理解;第三,如果需要进一步优化,也完全可以做到——但关键是,在开始优化之前,这段代码本身就已经相当高效了。”

“让我们试试另一种处理不重复行的方式。”他进一步提出,“为什么要一直输出行呢?也许我想要的是一个仅仅收集输入中不重复行的程序。”

这样一个简单函数就能轻松实现:

vector<string> collect_lines(istream& is) // 从输入中获取不重复行{ unordered_set<string> m; // 哈希表 for (string line; getline(is,line); ) m.insert(line); return vector(m.begin(),m.end());}auto lines = collect_lines(cin);

“C++ 的类型系统会自动推导出我们需要的是 string 的 vector,”Stroustrup 解释说,“而且返回时不需要复制,直接移动就行了。这样的实现既简洁又高效。”

“但这里的 vector 构造有点啰嗦。我希望 vector 能直接接受这个集合本身,”Stroustrup 说,“所以我写了一个函数,它可以接受任何范围并从中创建 vector。”于是他展示了一个更简洁的版本:

vector<string> collect_lines(istream& is) // 从输入中获取不重复行{ unordered_set<string> m; // 哈希表 for (string line; getline(is,line); ) m.insert(line); return make_vector(m);}auto lines = collect_lines(cin);

“标准库不需要提供所有功能。有时候自己写个简单函数就能解决问题,比如这个 make_vector。”他最终总结道,“也许在 C++ 的未来版本中,vector 会直接支持这种构造方式,那时这个函数就不需要了。”

谈到 C++ 的发展历程,Stroustrup 指出:“一些关键特性和技术已有多年历史,比如带构造函数和析构函数的类、异常处理机制、模板、std::vector……等等。另一些则是较新的发展,如 constexpr 函数和 consteval 函数、lambda 表达式、模块、概念、std::shared_ptr……等等。关键在于将这些特性作为一个整体来运用。

“不要盲目使用所有新特性,也不要局限于仅使用新特性,”他强调道,“如果想了解最新特性和未来发展方向的更多细节,可以参考相关的技术讨论视频。我更关注的是如何将语言作为一个整体来开发好的软件。因为最终编程语言的价值体现在其应用程序的质量之中。

资源管理:C++ 的基石

我们知道,相比归还东西,人们更倾向于获取东西,”Stroustrup 首先打了个生动的比方,“问任何一个图书管理员就知道了,人们借书后常常忘记还书。在大型软件中,如果我们必须显式地返还借用的资源,我们肯定会遗漏一些。”

Stroustrup 将资源定义为“任何必须获取并在之后释放(归还)的对象”。“这包括内存、string(字符串)、互斥锁、文件句柄、套接字、线程句柄、着色器等等很多东西,”他解释道。“从这个词的含义来看,在编程中我们要处理的很多东西都是资源。”

在 C++ 中,每个资源(resource)都应该有对应的句柄(handle)来管理它的生存期。句柄负责资源的访问和释放,这种机制是通过对生存期的严格控制来实现的。

为了解决这个问题,Stroustrup 提出了几个关键原则:

  1. 避免手动释放资源——不要在应用程序代码中出现 free()、delete 等资源释放操作;
  2. 使用资源句柄——每个对象都由负责访问和释放的句柄管理;
  3. 基于作用域管理——所有资源句柄都属于特定作用域,可以在作用域间转移;

他用一段简单但富有启发性的代码来说明这些原则:

template<typename T>class Vector { // T 类型元素的 vectorpublic: Vector(initializer_list<T>); // 构造函数:分配内存并初始化元素 ~Vector(); // 析构函数:销毁元素并释放内存 // ...private: T* elem; // 指向元素的指针 int sz; // 元素数量};

void fct(){ Vector<double> constants {1, 1.618, 3.14, 2.99e8}; Vector<string> designers {"Strachey", "Richards", "Ritchie"}; // ... Vector<pair<string,jthread>> vp { {"producer",prod}, {"consumer",cons}};}

“这就是 C++ 的基石:构造函数(constructor)和析构函数(destructor),”Stroustrup 说道,“如果需要获取任何资源,那是构造函数的工作;如果需要归还资源,那是析构函数的工作。这里我们将抽象层次从机器级的指针和大小提升到了更高的层次。我们把它包装成一个类型,这个类型行为正确,有赋值操作,有访问函数,并且能正确清理。”

他特别指出了资源管理机制的递归性:“string 拥有一些字符,这里的 pair 拥有一个 string 和一个 jthread。jthread 拥有对操作系统线程的引用。这些都是递归进行的。神奇之处在于最后的闭合花括号——那里是所有东西都被隐式而可靠地清理的地方。”

为了做好资源管理,Stroustrup 强调了对生存期的控制:

构造:首次使用前建立对象的不变量(如果有的话);

析构:最后使用后释放所有资源(如果有的话);

拷贝:a = b 意味着 a == b,且它们是独立的对象;

移动:在作用域间转移资源所有权;

“这些机制让我们能够开发出更安全、更可靠的代码,”他总结道,“因为资源管理不再依赖于程序员的记忆力,而是由语言机制自动保证。

错误处理的策略

“在确保资源安全的基础上,我们还需要有明确的错误处理(error handling)策略,”Stroustrup 随即转入了另一个重要话题。他指出,C++ 中有两种主要的错误处理方式,它们各有适用场景:

“对于那些常见且可在局部处理的失败情况,使用错误码(error code)是合适的,这种方式避免了使用效率低下且丑陋的 try-catch 结构。”他解释了第一种情况,“但问题是,我们经常忘记检查错误码,这可能导致错误的结果继续传播。而且,这种方式不适用于构造函数和运算符。比如说,当你写 Matrix x = y + z 这样的表达式时,就没有地方放置错误返回语句和测试。”

“另一方面,对于那些罕见且无法在局部处理的错误,异常处理(exception handling)是更好的选择。”Stroustrup 继续说道,“错误可以沿调用链向上传播,避免陷入 ‘错误码地狱’。未捕获的异常会导致程序终止,而不是产生错误结果。重要的是,这种机制必须与 RAII(资源获取即初始化)配合使用,依赖作用域资源句柄。”

他用一个具体的例子说明了这个观点:

void fct(jthread& prod, string name){ ifstream in { name }; if (!in) { /* ... */ } // 预期可能发生错误

vector<double> constants {1, 1.618, 3.14, 2.99e8}; // 内存可能耗尽 vector<string> designers {"Strachey", "Richards", "Ritchie"}; // 嵌套构造

jthread cons { receiver }; pair<string,jthread&> pipeline[] { {"producer", prod}, {"consumer", cons}}; // ...}

“想象一下,如果只使用单一的错误处理方式,这段代码会变得多么复杂,”Stroustrup 说,“每个操作都可能失败:文件打开可能失败,内存分配可能失败,构造过程可能失败。使用异常处理,我们可以集中处理这些错误,而不是在每个可能的失败点都编写检查代码。

Stroustrup 还提到了一个最新的研究发现:“即便对小型系统,异常处理也可能比错误码更高效。我们最近看到一个很好的演示,展示了在小型固件中使用 C++ 异常可以产生更小、更快的代码。”

“关键是要记住,”他强调,“错误处理不是要选择唯一正确的方式,而是要根据具体情况选择最合适的方式。有时是错误码,有时是异常,重要的是要有一个明确的策略。即便对小型系统,异常处理机制也可能比错误码更高效。Khalil Estell 最近在 CppCon 2024 上的演示*展示了在小型固件中使用 C++ 异常可以产生更小、更快的代码。”

模块:打破“包含”的魔咒

谈到代码组织,Stroustrup 首先指出了一个困扰 C++ 开发者多年的问题:“头文件包含的顺序依赖问题一直是个麻烦。#include "a.h" 后跟 #include "b.h",可能与顺序颠倒后的结果完全不同。这种基于文本的包含机制会导致:包含具有传递性、相同的代码被重复编译多次、容易引发宏定义冲突等问题。”

相比之下,C++20 引入的模块(modules)机制则完全不同:

import a;import b;

“这与顺序无关,”Stroustrup 解释道,“写成下面这样,效果完全一样。”

import b;import a;

“import 不具有传递性,模块化的代码更加干净,而且能显著提升编译速度——这不是百分比级的提升,而是数量级的提升。

紧接着,他兴奋地宣布:“经过几十年,我们终于在 C++ 中实现了模块。我们不必再使用 include 了!这是我长期计划的一部分——逐步淘汰 C 预处理器。预处理器会给工具带来麻烦,因为工具看到的和程序员看到的是不一样的。”

他分享了来自一家德国嵌入式系统公司的实际案例。该公司有一个传统的设备信息库 libgalil,通过头文件包含机制,最终会展开成约 50 万行代码(其中 15 万行是空行)。即便以现代编译器的速度,处理这样的代码也需要 1.5 秒。然而,当他们将其改造为模块后,预处理后只有 5 行代码,编译仅用了 62 毫秒,实现了 25 倍的速度提升。

“当然,你不能期待在所有情况下都能获得 25 倍的提升,”Stroustrup 说,“但根据经验,使用具名模块通常能让编译速度提高 7-10 倍。这就是促使人们将代码从旧风格改造为新风格的动力。虽然这个过程并不容易——毕竟我们有数十亿行现存的代码——但这种改进确实显著。”


路过

雷人

握手

鲜花

鸡蛋