划重点
01C++ 之父 Bjarne Stroustrup 在 2024年全球C++及系统软件技术大会上,发表了题为《重新认识C++:跨世纪的现代演进》的演讲。
02Stroustrup强调,C++的强大之处在于其灵活性,但使用者需要谨慎选择正确的使用方式,避免重蹈覆辙。
03他提出当代C++应以简洁、安全、高效为基石,同时保留对硬件的直接控制。
04为此,C++23引入了模块机制,打破了“包含”的魔咒,提升了编译速度。
05最后,Stroustrup呼吁开发者使用当代C++工具,而不是停留在古老部分,以提升应用程序质量。
以上内容由腾讯混元大模型生成,仅供参考
12 月 5 日,美国国家工程院、ACM、IEEE 院士、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 会直接支持这种构造方式,那时这个函数就不需要了。”
“从这些例子可以看出我最重视的一点:思想的直接表达。”Stroustrup 紧接着列出了他重视的每一项事物:
谈到 C++ 的发展历程,Stroustrup 指出:“一些关键特性和技术已有多年历史,比如带构造函数和析构函数的类、异常处理机制、模板、std::vector……等等。另一些则是较新的发展,如 constexpr 函数和 consteval 函数、lambda 表达式、模块、概念、std::shared_ptr……等等。关键在于将这些特性作为一个整体来运用。”
资源管理:C++ 的基石
“我们知道,相比归还东西,人们更倾向于获取东西,”Stroustrup 首先打了个生动的比方,“问任何一个图书管理员就知道了,人们借书后常常忘记还书。在大型软件中,如果我们必须显式地返还借用的资源,我们肯定会遗漏一些。”
Stroustrup 将资源定义为“任何必须获取并在之后释放(归还)的对象”。“这包括内存、string(字符串)、互斥锁、文件句柄、套接字、线程句柄、着色器等等很多东西,”他解释道。“从这个词的含义来看,在编程中我们要处理的很多东西都是资源。”
在 C++ 中,每个资源(resource)都应该有对应的句柄(handle)来管理它的生存期。句柄负责资源的访问和释放,这种机制是通过对生存期的严格控制来实现的。
为了解决这个问题,Stroustrup 提出了几个关键原则:
1. 避免手动释放资源——不要在应用程序代码中出现 free()、delete 等资源释放操作;
2. 使用资源句柄——每个对象都由负责访问和释放的句柄管理;
他用一段简单但富有启发性的代码来说明这些原则:
template<typename T>
class Vector { // T 类型元素的 vector
public:
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++ 异常可以产生更小、更快的代码。”
* Khalil 的演讲内容:https://www.youtube.com/watch?v=bY2FlayomlE
模块:打破“包含”的魔咒
谈到代码组织,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 倍。这就是促使人们将代码从旧风格改造为新风格的动力。虽然这个过程并不容易——毕竟我们有数十亿行现存的代码——但这种改进确实显著。”
在标准库方面,C++23 已经提供了模块化支持。最重要的是模块 std,它包含了完整的 std 命名空间:
import std; // 包含整个标准库
“这带来了惊人的改进,”Stroustrup 说,“以十分之一的时间提供十倍的信息——效率提升了 100 倍。这意味着我不用像以前那样等待编译时喝那么多咖啡了。”
他特别推荐观看 Danielle Eckert 在 2022 年 CppCon 上题为《Contemporary C++ in Action》的演讲,“这个演讲展示了现代 C++ 特性在实际项目中的应用,非常精彩。如果可能,你一定要去看一下这个视频。”
“模块化的概念早在 1994 年的《C++ 语言的设计和演化》中就已提出,”Stroustrup 补充道,“现在我们终于实现了!这个特性不仅让代码更清晰,也大大提升了开发效率。”
泛型编程与概念
“泛型编程(generic programming)是当代 C++ 的关键基础,”Stroustrup 如此介绍道,“这个想法最早可以追溯到 80 年代初。那时我就描述过这个概念,只是当时我以为可以用宏来实现——关于这点我错了,但对需要泛型编程这一点我是对的。现代 C++ 中的大量泛型编程思想都来自 Alex Stepanov。”
泛型编程为 C++ 带来了多方面的优势:代码更加简洁、思想表达更直观、实现零开销抽象、保证类型安全。它在标准库中无处不在:容器和算法、并发、内存管理、I/O、string 和正则表达式等。
Stroustrup 用一个简单的例子说明了基于概念的泛型编程:
void sort(Sortable_range auto& r);
vector<string> vs;
// ... 填充 vs ...
sort(vs);
array<int,128> ai;
// ... 填充 ai ...
sort(ai);
list<int> lsti;
// ... 填充 lsti ...
sort(lsti); // 编译错误:list 不支持随机访问
“这段代码展示了几个隐含的要求,”他解释道,“容器的类型、元素的类型、元素的数量、比较准则等。概念(concept)的作用就是明确指定对类型 r 的要求。当编译器看到 vector 时,它会问:‘这个 vector 有支持随机访问的元素序列吗? ’ 是的,有。 ‘这些元素是可以比较的吗?’ 是的。所以代码可以工作。但对于 list,因为它不支持随机访问,编译器会立即发现这个错误。”
Stroustrup 特别指出,“概念不等同于 ‘类型的类型’。概念是可以作用于多个类型的谓词。有些概念还可以接受值参数,可以混合类型和值。但概念本质上是函数,而不是像其他语言中那样仅仅是函数签名的集合。”
随后,他进一步解释了为什么 list 不支持随机访问是一个特意的设计:“如果在一个包含 50 万个元素的 list 中使用下标访问,会非常非常慢。所以这种限制实际上是在保护开发者避免写出性能糟糕的代码。”
更复杂的情况是,许多算法都需要多个模板参数类型,而且这些类型之间往往需要建立某种关系。Stroustrup 展示了一个来自标准库的例子:
template<ranges::input_range R,
indirect_unary_predicate<iterator_t<R>> Pred>
Iterator_t<R> ranges::find_if(R&& r, Pred p);
vector<string> numbers; // 存储数字的 string,如 "13" 和 "123.45"
// ... 填充 numbers ...
auto q = find_if(numbers,
[](const string& s) { return stoi(s) < 42; }); // lambda 表达式
“这里的概念实际上是编译期谓词(compile-time predicate),它们在编译期执行并返回布尔值。通常一个概念会基于其他概念构建,形成一个完整的类型约束体系。”
Stroustrup 表示,概念并不是什么新鲜事物:“每个成功的泛型库都包含某种形式的概念。它们存在于设计者的构思中,记录于技术文档中,体现在代码注释中。比如 C/C++ 的内置类型概念(算术类型和浮点类型)、STL 中的迭代器、序列和容器概念、数学概念(单子、群、环、域)、图论概念(边、顶点、图、有向无环图等)……”
讲到此处,Stroustrup 致敬了 C 语言之父丹尼斯·里奇(Dennis Ritchie, 1941-2011):
“实际上,我们只是为这些概念提供了语言层面的表示方法,让编译器能够理解和检查它们。这要求我们学会有效运用这种语言支持。”他说道,“编译期编程(compile-time programming)在当今的 C++ 中非常基础,这种技术也已经存在很长时间了。有趣的是,在 2010 年时,还有人断言编译时求值不仅毫无用处,而且根本无法实现。现在的发展已经证明,他们的判断完全错了。”
“使用概念还带来了许多实际好处:支持更好的程序设计,提升代码可读性和可维护性,避免过度使用无约束的 auto 和 typename,大幅改进错误信息。”他打了个比方:“还记得那个只有内置类型没有类(class)的年代吗?C++ 从未有过这种时期,但现在的 C 语言仍是这样。好在 C 现在也有了函数原型。”
“概念的引入还极大地简化了条件约束的表达。”Stroustrup 展示了一个例子:
template<typename T> class Ptr {
// ...
T* operator->() requires is_class<T>; // 仅当 T 是类时才提供 -> 运算符
};
template<typename T, typename U> class Pair {
// ...
template<convertible<T> TT, convertible<U> UU>
Pair(const TT&, const UU&); // 只为可转换为成员的类型提供构造函数
};
“传统的 enable_if 方案原始、丑陋、非通用,且容易出错,”他最终评价道,“而使用概念,我们可以用更简洁、更直观的方式表达这些约束。”
协程:状态保持!
“说到协程(coroutine),这其实是个有趣的故事,”Stroustrup 回忆道,“在 C++ 发展的最初十年,协程是我们的一个重要优势。但后来一些公司因为它不适合他们的机器架构而反对,结果我们失去了这个特性。现在,我们终于把它找回来了。”
协程的特点是能在多次调用之间保持其状态。Stroustrup 用一个生成斐波那契数列的例子来说明:
generator<int> fibonacci() // 生成 0,1,1,2,3,5,8,13 ...
{
int a = 0; // 初始值
int b = 1;
while (true) {
int next = a + b;
co_yield a; // 返回当前斐波那契数
a = b; // 更新状态
b = next;
}
}
for (auto v: fibonacci())
cout << v << '\n';
“这里的妙处在于状态的保持,”他解释道,“我们有计算的状态——a、b 和 next——它就这样不断地计算下一个斐波那契数。协程已经被嵌入到迭代器系统中,所以我们可以用简单的 for 循环来获取数列中的值。”
但这个例子还有一个小问题:“这是个无限序列,显然会带来问题,我们会遇到溢出。那么如何限制它只生成特定数量的值呢?”Stroustrup 展示了改进的版本:
template<int N>
generator<int> fibonacci() // 生成前 N 个斐波那契数
{
int a = 0;
int b = 1;
int count = 0;
while (count < N) {
int next = a + b;
co_yield a;
a = b;
b = next;
count++;
}
}
for (auto v: fibonacci<10>()) // 只生成十个数:0,1,1,2,3,5,8,13,21,34
cout << v << '\n';
“虽然标准库对协程的支持还不如我想要的那么完善,”Stroustrup 说,“但这个例子中使用的 generator 已经在 C++23 中可用了。如果你使用的是较早的编译器,还可以使用 Facebook 的 coro 库或其他任务库。这个例子很好地展示了模板和协程是如何和谐地协同工作的。”
“协程为我们提供了一种漂亮的方式来处理需要保持状态的计算,”他总结道。“它让代码更容易理解,也更容易维护。这正是我们一直追求的目标:简单的事情简单做。”
调优:“洋葱原则”
“对某些代码来说,调优是必要的,”Stroustrup 转入了性能优化的话题。“但我们都听过 ‘避免过早优化’ 这个建议。重要的是要在优化前后都进行性能测量,同时在设计接口时就要考虑优化空间。”
他提出了几个关键原则:
1. 接口设计需明确定义
2. 保持类型信息的完整性
3. 提供足够信息支持检查和优化
4. 管理复杂度:"简单的事情简单做!"
“我把这个叫做 ‘洋葱原则’,”Stroustrup 打了个生动的比方,“你可以把代码想象成洋葱的层。每当我们需要优化或处理特殊情况,我们就可能需要剥掉一层抽象。但要记住,每剥掉一层,你就会哭得更厉害。”
“为什么会这样?”他继续解释道,“因为每深入一层,你就有可能遇到更多的错误,必须写更多的代码,代码也更难理解。所以在真正需要之前,不要轻易剥掉一层抽象。这就是我对 ‘不要过早优化’ 的理解。”
此外,关于并发(concurrency),Stroustrup 指出这是一个需要单独讨论的重要话题。“你需要它来提高效率。在标准库中有广泛的、相当低层的并发支持:线程和锁、共享机制、并行算法、协作取消、Future 机制、协程等等。这些都是为了性能,但同时也带来了复杂性。”
指南和规格配置:走向未来
“不要停留在 20 世纪,”演讲走进尾声时,Stroustrup 直截了当地说,“但这说起来容易做起来难。大多数代码都包含一些旧的部分,升级这些代码既困难又耗时。虽然升级能带来巨大好处——比如引入模块能显著提升编译速度——但要摒弃次优技术确实很难,因为我们不仅要面对海量的历史代码,还要克服根深蒂固的编程习惯。”
“我们面临一个根本性的问题,我们不能改变语言本身——稳定性和兼容性是 C++ 的核心优势。但我们可以改变使用语言的方式。”
为了说明这一点,Stroustrup 分享了一个有趣的观察:“大约每两周,我都会收到一个有趣的请求。这个请求总是包含三个部分:首先是跟我说 ‘C++ 太复杂了,必须简化’——我同意,确实如此——但紧接着又跟我说 ‘顺便提一下,我们绝对需要增加这两个新特性’。而且还要保证 ‘别破坏我的代码,我可是有上百万行呢’。这三件事是没法同时做到的。”
“正是基于这种现实,”他继续说,“我们采取了一种务实的策略:通过一套灵活的指南规则体系来简化语言的使用,而不是改变语言本身。这些指南可以根据项目需求选择性采纳。比如 C++ Core Guidelines 就是一个很好的例子,而且已经有了实践工具的支持,如 Visual Studio、GCC 和 Clang-Tidy 都能帮助执行这些指南——这不是科幻小说,这些工具现在就可以使用。”
在标准委员会中,Stroustrup 和同事们正在推进一个更进一步的方案:规格配置(profile)。“每个规格配置是一套强制性的指南规则,”他解释道,“虽然现在还在制定中,但其目标很明确:让开发者能够根据需要选择不同类型的安全性级别和执行强度。这将帮助我们在保持语言强大的同时,使其更容易正确使用。”
Stroustrup 建议的初始规格配置包括:
1. 算法:全面的范围检查,禁止解引用 end() 迭代器;
2. 算术:检测上溢和下溢;
3. 类型转换:全部禁用;
4. 并发:消除死锁和数据竞争(这是个难点);
5. 初始化:所有对象必须初始化;
6. 失效:禁止通过已失效的指针访问(包括悬空指针);
7. 指针:禁止对内置指针使用下标操作(应使用 span、vector、string 等);
8. 范围:捕获范围错误;
9. RAII:所有资源必须由句柄管理;
10. 类型:涵盖初始化、范围、转换、失效和指针规则;
11. 联合体:禁止使用 union(应使用 variant 等);
他说:“我们需要那些底层的、复杂的、接近硬件的、容易出错的、专家级的特性,因为它们是高效实现高层功能的基础。很多底层特性在正确使用时都很有价值。但一旦我们有了这些基础,就可以在此之上建立更安全、更简单的编程模型。”
“我们想要的是「增强版 C++」——简单、安全、灵活、高效,而不是功能受限的子集。我们不能失去 C++ 最重要的特性:高性能和对硬件的直接控制。而且这些改进不会改变语言的本质,最终的代码仍然是符合 ISO C++ 标准的。”
Stroustrup 还总结了 C++ 的编程模型:
静态类型系统,同时支持内置类型和用户定义类型
支持值语义和引用语义
统一的资源管理机制(RAII)
高效的面向对象编程
灵活且高效的泛型编程
编译期编程
直接访问硬件和操作系统
通过库实现的并发支持(借助内部指令)
最终淘汰 C 预处理器
在演讲的最后,Stroustrup 展示了一张 C++ 用户数量增长的图表。
“C++ 在设计之初就考虑到语言会不断演进,”他说,“从 1979 年的 C with Classes,到 1998 年引入异常、模板和命名空间,再到 2011 年增加并发、lambda 表达式和智能指针,直到 2020 年带来概念、协程、模块等特性,C++ 一直在成长。”
“我从一开始就知道,我不可能独自在合理的时间内构建出我想要的完整语言,”他坦言道。“所以 C++ 在不断演进,现在有了很多真正有用的新特性。但关键是,我们要活在曲线的上方,使用当代 C++ 中可用的工具,而不是停留在最古老的部分。因为归根到底——编程语言的价值体现在其应用程序的质量之中。”