21 世纪的代码如何编写,C++ 之父 Bjarne 这样说!

C++ 的诞生至今已超过 45 年了。按照预期,它将不断发展以应对挑战,但许多开发者仍然以上个世纪的方式来使用 C++。从表达能力、性能、可靠性和可维护性的角度来看,这种做法并不理想。

本文作者 C++ 之父 Bjarne Stroustrup,他将介绍构建高性能、类型安全且灵活的 C++ 软件的关键概念,包括资源管理、生命周期管理、错误处理、模块化和泛型编程。

最后,他还会介绍如何确保代码符合现代标准,而不是依赖过时、不安全且难以维护的技术——这包括编码指南和代码规范。


1. 引言

C++ 是一门历史悠久的编程语言。然而,这也导致许多开发者、教师和学者忽视了 C++ 过去几十年的发展,仍然以上个世纪的视角来描述它——那个时代的电话还需要插在墙上,大多数代码都是简短的、底层的,并且运行缓慢。

如果你的操作系统保持了多年的兼容性,那么你今天仍然可以在现代计算机上运行 1985 年编写的 C++ 程序。稳定性——即与旧版本 C++ 的兼容性——对于那些维护数十年软件系统的组织来说至关重要。

然而,在几乎所有情况下,现代的 C++ 30 可以用更简单的方式表达这些旧式代码的思想,同时提供更好的类型安全保障,并在占用更少内存的情况下运行得更快。

本文将介绍现代 C++ 的关键机制,以支持这种高效的代码编写方式,并在最后的第 6 章节介绍如何强制执行这些现代 C++ 的最佳实践。

import std;                  // make all of the standard library available
using namespace std;


int main()                   // print unique lines from input
{        unordered_map<string,int> m;  // hash table
          for (string line; getline (cin,line); )
                  if (m[line]++ == 0)
                          cout<<line<<'\n';
}

行家们会认出,这个程序的核心逻辑与 AWK 代码 (!a[$0]++) 相似。

它使用了unordered_map(C++ 标准库中的哈希表实现)来存储唯一的行,并在首次遇到某行时进行输出。

for 语句用于限制循环变量 line 的作用域,仅在循环内部有效。

与旧式 C++ 代码相比,这段代码最显著的特点是没有显式使用:

  • 内存分配/释放

  • 手动指定大小

  • 显式错误处理

  • 类型转换(强制类型转换)

  • 指针

  • 不安全的下标访问

  • 预处理器指令(特别是没有 #include)

尽管如此,该程序在性能上仍然比传统 C++ 风格更高效,甚至比大多数开发者在有限时间内能编写出的代码更高效。如果需要更高的性能,它还可以进一步优化。C++ 的一个重要特点是,合理设计接口的代码可以根据特定需求进行调优,甚至可以利用专门的硬件,而不会影响其他代码,也通常不需要修改编译器。

接下来,我们来看这个程序的一个变体,它用于收集唯一的行,以便后续使用:

import std;                               
using namespace std;         // make all of the standard library available
vector<string> collect_lines(istream& is) // collect unique lines from input
{


          unordered_set s;               // hash table
          for (string line; getline(is,line); )
               s.insert(line);
          return vector{from_range, s};  // copy set elements into a vector


}
auto lines = collect_lines(cin);

2. C++ 的理念

我对 C++ 的目标可以概括为:

  • 直接表达思想

  • 静态类型安全

  • 资源安全(“无泄漏”)

  • 直接访问硬件

  • 高性能(高效)

  • 可扩展(零开销抽象)

  • 可维护(易理解代码)

  • 跨平台(可移植性)

  • 稳定(兼容性)

这些目标自 C++ 诞生以来从未改变,但 C++ 本身一直在演进,而现代 C++ 能比早期版本更好地实现这些特性。

实现这些理想的 C++ 代码,并不意味着仅仅使用所有最新特性。有些关键特性和技术由来已久,例如:

  • 具有构造函数和析构函数的类

  • 异常处理

  • 模板

  • std::vector

  • ……

同时,也有一些关键特性是近年来才引入的,例如:

  • 模块(§4)

  • 概念(Concepts)(用于指定泛型接口,§5.1)

  • Lambda 表达式(用于生成函数对象,§5.1)

  • Ranges 机制(§5.1)

  • constexpr 和 consteval(用于编译期计算,§5.2)

  • 并发支持和并行算法

  • 协程(尽管早期 C++ 设计中认为它们是核心部分,但 C++ 直到最近才支持它)

  • std::shared_ptr

  • ……

关键在于,将语言和库的特性作为一个有机整体来使用,以最适合解决问题的方式加以应用。

一种编程语言的价值体现在其应用的广度和质量上。C++ 的强大体现在它令人惊叹的广泛应用领域:基础软件、图形处理、科学计算、电影制作、游戏开发、汽车系统、语言实现(不仅限于 C++ 本身)、飞行控制、搜索引擎、浏览器、半导体设计与制造、太空探测器、金融、人工智能等等。不计其数的 C++ 代码已遍布全球,因此我们不能以破坏兼容性的方式更改 C++ 语言。然而,我们可以改变 C++ 的使用方式。

在接下来的内容中,我将重点关注以下几个方面:

  • 资源管理(包括生命周期控制和错误处理)

  • 模块(包括消除预处理器的影响)

  • 泛型编程(包括概念)

  • 指导方针与强制执行(我们如何确保所写的代码真正符合“21 世纪的 C++”)

当然,C++ 远不止提供这些功能,也有许多优秀的代码并未严格遵循上述方式。例如,我没有提及面向对象编程,因为许多开发者已经熟练掌握 C++ 的 OOP 方法。此外,极致高性能代码以及直接操作硬件的代码需要特殊的关注和技巧。C++ 提供的广泛并发支持甚至值得单独撰写一篇论文。然而,大多数优秀软件的核心在于 类型安全的接口,这些接口包含足够的信息,以便编译器优化,并在运行时检查无法在编译时保证的属性。

3. 资源管理

资源是指我们必须显式或隐式获取并在稍后释放(归还)的任何事物。例如,内存、锁、文件句柄、套接字、线程句柄、事务和着色器等。为了避免资源泄漏,我们必须避免手动/显式释放资源。人类——甚至是程序员——都很难记住归还他们借用的东西。

在 C++ 中管理资源的基本技术是将资源嵌入到一个句柄中,并保证在句柄的作用域结束时释放资源。为了可靠性,我们不能依赖 delete、free()、unlock() 等显式操作,它们应该被封装在资源句柄中。例如:

template<typename T>
class Vector { // vector of elements of type T
public:
Vector(initializer_list<T>); // constructor: acquire memory; initialize elements
~Vector(); // destructor: destroy elements; release memory
// …
private:
T* elem; // pointer to elements
int sz; // number of elements
};

在这里,Vector 是一个资源句柄(resource handle)。它将抽象级别从机器级的指针加元素计数提升到一个自动初始化(构造函数)和自动清理(析构函数)的完整类型。标准库中的 vector 也提供了比较、赋值、多种初始化方式、调整大小、迭代支持等功能。从语言技术的角度来看,它的行为类似于内置整数类型,尽管它是一个资源句柄并且具有完全不同的语义。我们可以这样使用它:

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}};
}

在这个例子中,constants 被初始化为四个数学和物理常数,designers 存储了三位著名的编程语言设计者的名字,而 vp 由一个生产者-消费者对(producer-consumer pair)初始化。所有这些对象都通过构造函数初始化,并在作用域结束时由析构函数释放。

构造函数和析构函数的初始化与释放是递归的。例如,vp 的构造和析构并不简单,因为它涉及 Vector、pair、string(字符的句柄)以及 jthread(操作系统线程的句柄)。然而,这一切都是自动处理的。

这种使用构造-析构函数对(通常称为 RAII——“资源获取即初始化”)的方法不仅保证了资源的释放,还最小化了资源占用,与依赖垃圾回收的内存管理等技术相比,它提供了显著的性能优势。

3.1. 控制对象生命周期

控制表示资源的对象的生命周期对于实现简单高效的资源管理至关重要。C++ 提供了四种生命周期控制操作,定义在类(这里命名为 X)中:

  • 构造:在对象首次使用前调用,建立类的不变性(如果有的话)。名称:构造函数 X(optional_arguments)

  • 析构:在对象最后一次使用后调用,释放所有资源(如果有的话)。名称:析构函数 ~X()

  • 拷贝:创建一个与另一个对象相同值的新对象,例如 a = b 应该意味着 a == b(对于常规类型)。名称:拷贝构造 X(const X&) 和拷贝赋值 X::operator=(const X&)

  • 移动:在对象之间转移资源,通常用于跨作用域的对象转移。名称:移动构造 X(X&&) 和移动赋值 X::operator=(X&&)

例如,我们可以扩展 Vector 以支持这些生命周期操作:

template<typename T>
class Vector { // vector of elements of type T
public:
Vector(); // default constructor: make an empty vector
Vector(initializer_list<T>); // constructor: acquire memory; initialize elements
Vector(const Vector& a); // copy constructor: copy a into *this
Vector& operator=(const Vector& a); // copy assignment: copy a into *this
Vector(Vector&& a); // move constructor: move a into *this
Vector& operator=(Vector&& a); // move assignment: move a into *this
~Vector(); // destructor: destroy elements; release memory
// …
};

赋值运算符必须释放目标对象所持有的任何资源。移动操作必须将所有资源转移到目标对象,并确保源对象不再持有它们。

3.2. 消除冗余拷贝

基于上述框架,让我们重新审视第 1 节中的 collect_lines 示例。首先,我们可以对其进行一定程度的简化:

vector<string> collect_lines(istream& is) // collect unique lines from input
{
unordered_set s {from_range,istream_iterator<string>{is}); // initialize s from is
return vector{from_range,s};
}
auto lines = collect_lines(cin);

istream_iterator{is} 使我们能够将输入流 is 视为一个元素范围,而无需繁琐地对流进行显式输入操作。

在这里,vector 被移动(move)出 collect_lines(),而不是拷贝(copy)。

vector 的移动构造函数的最坏情况成本是 6 次字的拷贝:三次用于复制表示(representation),三次用于将原始表示清零。即使 vector 里有 100 万个元素,这个成本依然不会变化。

甚至这个小成本在很多情况下也可以被消除。自 1983 年左右,编译器就已经能够在目标对象(这里是 lines)中直接构造返回值(这里是 vector{from_range, s};)。这种优化被称为 “拷贝省略(copy elision)”。

然而,set 中的 string 仍然被拷贝到 vector 中,这可能会带来较高的成本。理论上,编译器可以推断出在创建 vector 之后,我们不会再使用 s,从而直接移动 string 元素。然而,目前的编译器还不够智能,因此我们必须显式地请求移动:

vector<string> collect_lines(istream& is) // collect unique lines from input
{
unordered_set s {from_range,istream_iterator<string>{is}); // initialize s from is
return vector{from_range,std::move(s)}; // move elements
}

仍然存在的冗余拷贝是从输入缓冲区到 set 内部 string 元素的字符拷贝。如果这是个问题,我们可以进一步优化。然而,这涉及的只是常规的低级优化技术,因此超出了本文讨论范围。这类代码通常更复杂,但 C++ 代码只要接口定义清晰,就始终可以进行优化。此外,请记住:不要在没有测量证明有必要的情况下进行优化。

3.3 资源与错误处理

C++ 的一个核心目标是资源安全——即不发生资源泄漏。这意味着在错误情况下也必须防止资源泄漏。基本规则是:

  1. 不能泄漏资源(Don’t leak a resource)

  2. 不能让资源处于无效状态(Don’t leave a resource in an invalid state)

因此,当检测到无法在本地处理的错误时,在退出函数之前,必须:

  • 确保每个访问的对象都处于有效状态

  • 释放函数负责的所有对象

  • 让调用链上更高层的函数来处理资源相关的问题

这意味着“裸指针(raw pointers)” 不能可靠地用作资源句柄。考虑一个可能持有资源(如内存、锁、文件句柄等)的类型 Gadget:

void f(int n, int x)
{
Gadget g {n}; 
Gadget* pg = new Gadget{n}; // explicit new: don’t!
// …
if (x<100) throw std::runtime_error{"Weird!"}; // leaks *pg; but not g
if (x<200) return; // leaks *pg; but not g
// …
}

显式使用 new 将 Gadget 放置到堆上是一个问题,因为它的结果存储在“裸指针”中,而不是存储在带有适当析构函数的资源句柄中。相比之下,本地对象通常比显式使用 new 更简单且更快速。

为了构建可靠的系统,我们需要明确的错误处理策略。一般来说,最佳做法是区分可以在本地处理的错误和只能在调用链上层处理的错误:

  • 对于常见且可以本地处理的错误,使用错误代码和检查

  • 对于罕见(“异常”)且无法本地处理的错误,使用异常

否则,我们会陷入“错误代码地狱”(error-code hell),每一层调用者都必须记得检查错误代码。相比之下,未捕获异常的结果是程序终止,而不是错误数据传播。

在某些关键应用中,立即终止不可接受。在这种情况下,我们必须始终检查错误返回代码并在某处(例如 main())捕获所有异常,然后执行适当的恢复操作。

许多人可能会惊讶地发现,即使在小型系统中,基于异常的错误处理在许多情况下比基于错误代码的方式更便宜、更快。

基于异常的错误处理无法与裸指针资源句柄配合使用。为了实现简单、可靠和可维护的错误处理,我们必须依赖异常和 RAII,同时对可本地处理的错误使用错误代码。例如:

void fct(jthread& prod, jthread& cons, string name)
{
ifstream in { name };
if (!in) { /* … */ } // possible failure expected
// …
vector<double> constants {1, 1.618, 3.14, 2.99e8};
vector<string> designers {"Strachey", "Richards", "Ritchie"};
auto dmr = "Dennis M. " + designers[2];
// …
pair<string,jthread&> pipeline[] { {"producer", prod}, {"consumer", cons}};
 // …
}

在这个(人工设计但并不罕见)的例子中,如果不能依赖异常,我们需要增加多少错误检查?代码涉及内存分配、嵌套构造、运算符重载和系统资源获取。

遗憾的是,异常并未被普遍接受并在所有适当的场景中使用。许多开发者坚持使用单一错误处理方式(要么全部抛异常,要么全部返回错误码),但这并不符合真实世界的代码需求。

四、模块化


C++ 从 C 继承的预处理器基本上被广泛使用,但它是工具开发和编译器性能的一个主要障碍。在现代 C++ 中,用于表示常量、函数和类型的宏已经被具有正确类型和作用域的常量、编译时求值的函数和模板所取代。然而,预处理器在表达一种弱形式的模块化方面仍然是必不可少的。库和其他单独编译代码的接口以包含 C++ 源代码并通过 #include 引入的文件形式表示。

4.1. 头文件

#include 指令将来自“头文件”的源代码复制到当前翻译单元中。而且,这意味着

#include "a.h"
#include "b.h"

或许她有一不同的解释

#include "b.h"
#include "a.h"

这就是微妙 bug 的来源。

#include 是传递性的。也就是说,如果 a.h 包含 #include "c.h",那么 c.h 的文本也会成为每个使用 #include "a.h" 的源文件的一部分。这是微妙 bug 的来源。由于头文件通常会在几十个或上百个源文件中被 #include,这也意味着大量的重复编译。

4.2. 模块

使用头文件来伪装模块化的问题早在 C++ 诞生之前就已被认识到,但定义一个替代方案并将其引入数十亿行代码并非易事。然而,C++ 现在提供了模块,能够提供真正的模块化。导入模块是顺序无关的,因此

import a;
import b;
import b;
import a;

是等效的。

模块的相互独立性意味着代码质量的提高。它使得微妙的依赖性错误变得不可能。这是一个非常简单的模块定义示例:

export module map_printer; // we are defining a module
import iostream; // we import modules needed for the implementation
import containers;
using namespace std;
export // this template is the only entity exported
template<Sequence S>
void print_map(const S& m) {
for (const auto& [key,val] : m) // access key and value pair from m
cout << key << " -> " << val << '\n';
}

由于 import 不是传递性的,map_printer 的使用者不会获得实现 print_map 所需的细节。

一个模块只需要编译一次,无论它被导入多少次。这意味着编译时间有了显著的改善。一位用户报告:

#include <libgalil/DmcDevice.h> // 457440 lines after preprocessing
int main() { // 151268 non-blank lines
Libgalil::DmcDevice("192.168.55.10"); // 1546 milliseconds to compile
}
这意味着编译近 50 万行代码只需 1.5 秒。非常快!然而,编译器做了太多工作。
import libgalil; // 5 lines after preprocessing


int main() { // 4 non-blank lines
     Libgalil::DmcDevice("192.168.55.10"); // 62 milliseconds to compile
}

这是一个 25 倍的加速。我们不能期望在所有情况下都如此,

但 import 相比 #include 的速度提升通常在 7 到 10 倍之间。如果你将该库 #include 在 25 个源文件中,编译将花费 1.5 秒 25 次,而使用 import 总共只需 1.5 秒。

完整的标准库已经被做成了模块。看看传统的“Hello World!”程序:

#include <iostream>
int main()
{
std::cout << "Hello, World!\n";
}

在我的笔记本上,它编译用了 0.87 秒。将 #include<iostream.h> 替换为 import std; 后,尽管提供的信息多了至少 10 倍,编译时间降至 0.08 秒。

重组大量代码并不容易或便宜,但在模块的情况下,从代码质量和编译时间的角度来看,收益是显著的。

为什么我要在这个唯一的案例中解释“旧方式”?因为 #include 无处不在,它几乎自 C 语言诞生以来就一直存在,许多开发者难以想象没有它的 C++。

5、泛型编程


泛型编程是现代 C++ 的核心基础。自“C with Classes”被更名为“C++”之前,它就已经存在,但直到最近(C++20),语言支持才逐渐接近理想状态。

泛型编程,即使用参数化的类型和函数进行编程,能够带来以下优势:

  • 更简洁、更易读的代码

  • 更直接的思想表达

  • 零开销的抽象

  • 类型安全

C++ 通过模板(Templates)来支持泛型编程,并广泛应用于标准库中,包括:

  • 容器和算法

  • 并发支持:线程、锁等

  • 内存管理:分配器、资源句柄(如 vector 和 list)、资源管理指针等

  • 输入/输出(I/O)

  • 字符串和正则表达式

  • 以及更多领域

我们可以编写适用于所有符合要求的参数类型的代码。例如,下面是一个 sort 函数,它可以接受所有符合 ISO C++ 标准中“可排序范围”定义的类型:

void sort(Sortable_range auto& r);
vector<string> vs;
// … fill vs …
sort(vs);


array<int,128> ai;
// … fill ai …
sort(ai);

编译器有足够的信息来验证 vs 和 ai 的类型是否满足 Sortable_range 的要求;即,是否具有可以比较和交换的值的随机访问范围。如果参数不合适,错误会在使用点被编译器捕获。例如:

list<int> lsti;
// … fill lsti …
sort(lsti); // error: a list doesn’t offer random access
根据 C++ 标准,list 不是可排序范围,
因为它不提供随机访问。

5.1. 概念

概念(Concept) 是一种编译时谓词(predicate),即由编译器执行并返回布尔值的函数。它主要用于表达模板参数的约束条件。

一个概念通常是由其他概念组合而成的。例如,下面是 sort 函数所需的 Sortable_range 概念:

template<typename R>
concept Sortable_range =
random_access_range<R> // has begin()/end(), ++, [], +, …
&& sortable<iterator_t<R>>; // can compare and swap elements

它表示如果类型 R 是 random_access_range,并且其迭代器类型是 sortable,那么 R 就是一个 Sortable_range。其中,random_access_range 和 sortable 是标准库中定义的概念。

一个概念(Concept)可以接受一个或多个参数,并且可以基于语言的基本属性构建。为了直接在语言层面指定某个类型的属性(而不是通过其他概念),我们使用“使用模式”(use patterns)。例如:

template<typename T, typename U = T>
concept equality_comparable = requires(T a, U b) {
{a==b} -> Boolean;
{a!=b} -> Boolean;
{b==a} -> Boolean;
{b!=a} -> Boolean;
}

{…} 中的构造必须有效,并且返回与概念指定的类型匹配的结果。例如,在这里,列出的使用模式(例如 a==b)必须返回可以作为 bool 使用的值。

通常,正如在排序示例中那样,检查一个类型是否符合一个概念是隐式完成的,但我们也可以使用 static_assert 显式进行检查:

static_assert(equality_comparable<int,double>); // succeeds
static_assert(equality_comparable<int>); // succeeds (U is defaulted to int)
static_assert(equality_comparable<int,string>); // fails
equality_comparable 概念是在标准库中定义的。我们不需要自己定义它,但它是一个很好的例子。

我们希望编写适用于所有合适参数类型的代码。然而,许多(可能是大多数)算法需要多个模板参数类型。这意味着我们需要表达这些模板参数之间的关系。例如:

template<input_range R, indirect_unary_predicate<iterator_t<R> Pred>
 Iterator_t<R> find_if(R&& r, Pred p);

这表示 find_if 接受一个输入范围 r 和一个谓词 p,该谓词可以应用于通过 r 的迭代器间接访问的结果。例如:

vector<string> numbers; // strings representing numbers; e.g., “13” and “123.45”
// … fill numbers …
auto q = find_if(numbers, [](const string& s) { return stoi(s)<42; });

find_if 调用的第二个参数是一个 Lambda 表达式。它生成一个函数对象,在 find_if 的实现中对参数 s 调用时执行 stoi(s)<42。Lambda 表达式(通常称为“lambda”)在现代 C++ 中已经证明是极其有用和受欢迎的。

我们一直以来都有概念。每个成功的泛型库都有某种形式的概念:在设计者的脑海中、在文档中或在注释中。这些概念通常代表了应用领域的基本概念。例如:

  • C/C++ 内建类型:算术和浮动

  • C++ 标准库:迭代器、序列和容器

  • 数学:单子、群、环和域

  • 图:边和顶点、图、DAG 等

C++20 并没有引入概念的想法;它只是为概念提供了直接的语言支持。概念是一个编译时谓词。使用概念比不使用它更容易。然而,就像每个新构造一样,我们必须学会有效地使用它们。

5.2. 编译时求值

概念是编译时函数的一个例子。在现代 C++ 中,任何足够简单的函数都可以在编译时求值:

  • constexpr:可以在编译时求值

  • consteval:必须在编译时求值

  • concept:在编译时求值,可以接受类型作为参数

这适用于内建类型和用户定义的类型。例如:

constexpr auto jul = weekday(December/24/2024); // Tuesday

为了允许 consteval 和 constexpr 函数及概念在编译时求值,它们不能:

  • 有副作用

  • 访问非本地数据

  • 有未定义行为(UB)

然而,它们可以使用广泛的设施,包括标准库中的大部分内容。

因此,这些函数是 C++ 版本的纯函数思想,现代 C++ 编译器几乎包含了一个完整的 C++ 解释器。编译时求值对于性能也是一种福音。

6. 指南与执行


现代编程风格带来了巨大的好处。然而,升级代码往往困难且成本高昂。那么,我们如何实现代码的现代化?避免使用次优技术并不容易,旧习难改。人们常常将熟悉误认为简单。此外,互联网上和一些教学材料中充斥着大量令人困惑且过时的信息。同时,旧代码通常提供的是过时风格的接口,从而进一步助长了旧有编程方式的使用。因此,我们需要指导来帮助我们编写更现代化、更优雅的代码。

稳定性/兼容性 是 C++ 的一个重要特性。考虑到全球 C++ 代码量已达数十亿行,新特性和新技术只能逐步采用。因此,我们无法直接改变语言本身,但可以改变它的使用方式。人们(可以理解地)希望 C++ 变得更简单,但同时也渴望新功能,并且坚持认为他们现有的代码必须能够继续运行。

为了帮助开发者专注于高效使用现代 C++,并避免语言中过时的“黑暗角落”,已经制定了一系列编程指南。在这里,我主要关注 C++ Core Guidelines,因为它是目前最具雄心的指南之一 。

一套好的编程指南应该有一套相对统一的语言哲学,并针对特定的使用场景。我个人的主要目标是 确保 C++ 代码的类型安全和资源安全,也就是说:

  • 每个对象都必须按照其定义进行使用

  • 任何资源都不能被泄露

这不仅涵盖了人们常说的“内存安全”,还涉及更广泛的资源管理。这并不是 C++ 的新目标 [C++24],但我们已经积累了多年的经验,证明在现代 C++ 代码中是可以做到的,尽管目前的执行仍不够完善。

编程指南的优缺点:

✅ 现有指南可用(例如 C++ Core Guidelines)
✅ 可以选择性地执行部分规则
❌ 执行仍不够完整

基于指南,我们还需要更强制性的规范:

  • Profile(配置文件) 是一组可执行的、连贯的编程规则 [1,30]

  • 目前 WG21 及其他组织正在研究更严格的规范 [31,10]

  • 目前尚未正式推出完整版本,只有实验性和部分实现 [16,7,15]

在思考 C++ 时,我们需要牢记,C++ 不仅仅是一门编程语言,它还是一个生态系统,其中包括编译器、标准库、工具链、教学资源等。尤其是,C++ 开发者所依赖的功能远不止 C 语言所提供的那些能力。

6.1. 指南 

简单地对 C++ 进行“子集化”并不可行。我们仍然需要那些低级、复杂、贴近硬件、容易出错、仅限专家使用的特性,以便高效实现更高级的功能,并在必要时启用底层功能。因此,C++ Core Guidelines 采用了一种名为 “subset-of-superset”(子集中的超集)的方法:

  1. 首先,通过少量库抽象来扩展语言:充分利用标准库的部分功能,并添加一个轻量级的库,使指南的使用更加方便和高效(Guidelines Support Library,GSL)。

  2. 接下来,进行子集化:禁止使用低级、低效且容易出错的特性。

最终,我们得到的是**“加强版 C++”**——既简单、安全、灵活又高效,而不是一个被大幅度削弱的子集,也不是一个依赖大量运行时检查的系统。同时,这种方式不会创建带有全新或不兼容特性的语言,最终仍然是 100% 符合 ISO 标准的 C++。当然,在必要时,我们仍然可以启用和使用那些复杂且具有风险的底层特性。

不同的应用领域有不同的需求,因此需要不同的指南集。但在最初阶段,C++ Core Guidelines 主要关注核心规则。这些规则旨在使所有 C++ 开发者受益,其中包括:

  • 禁止未初始化变量

  • 禁止超出范围访问或空指针(nullptr)违规

  • 禁止资源泄漏

  • 禁止悬空指针(dangling pointers)

  • 禁止类型违规

  • 禁止无效化(例如,修改后导致对象或迭代器无效)

目前,有两本书遵循这些指南(除非是用于示范错误示例):

📖 《A Tour of C++》(面向有经验的程序员)

📖 《Programming: Principles and Practice using C++》(面向初学者)

此外,还有两本书更深入地探讨了 C++ Core Guidelines 的各个方面 。

6.2. 示例规则

指针本身并不包含执行范围检查所需的相关信息。然而,范围检查对于内存安全和类型安全至关重要,因为我们不能允许应用程序代码读取或覆盖超出指针所指对象范围的内存。因此,我们必须使用一种能够携带足够信息以进行范围检查的抽象,例如 数组(array)、向量(vector)或 span。

考虑一种常见的编码风格:使用一个指针加上一个整数,该整数据称表示指针指向的元素数量:

void f(int* p, int n)
{
for (int i = 0; i<n; i++)
do_something_with(p[n]);
}
int a[100];
// …
f(a,100); // OK? (depends on the meaning of n in the called function)
f(a,1000); // likely disaster

这是一个使用数组来表示大小的非常简单的例子。由于大小存在,在调用时检查是可能的(尽管几乎从未做过),并且通常一个(指针,整数)对会通过较长的调用链传递,使得验证变得困难或不可能。

这个问题的解决方案是将大小与指针紧密绑定(如同Vector;§3.1)。这就是 span 所做的事情:

void f(span<int> a) // a span holds a pointer and the number of elements pointed to
{
     for (int& x: s) // now we can use a range-for
        do_something_with(x);
}
int a[100];
// …
f(a); // type and element count deduced
f({a,1000}); // asking for trouble, but marked syntactically and easily checkable

使用 span 是 “让简单的事情变得简单” 原则的一个很好的例子。与“旧风格”相比,使用 span 的代码更加简洁、安全,并且通常运行更快。

span 类型是在核心指南支持库中作为一个范围检查类型引入的。不幸的是,当它被添加到标准库时,范围检查的保证被移除了。显然,强制执行此规则的配置文件(§6.4)必须进行范围检查。每个主要的C++实现都有确保这一点的方法(例如,GCC标准库硬化15,Google空间安全7,微软Visual Studio的静态分析器16)。不幸的是,尚未有标准且可移植的方式来要求这一点。

6.3. 示例规则

不要使用失效的指针 一些容器,特别是 vector,可以重新定位它们的元素。如果容器外部获取了指向某个元素的指针,并在重新定位后继续使用它,就可能发生灾难。考虑:

void f(vector<int>& vi)
{
vi.push_back(9); // may relocate vi’s elements
}
void g()
{
vector<int> vi { 1,2 };
auto p = vi.begin(); // point to first element of vi
f(vi);
*p = 7; // error: p is invalid
}

考虑到适当的 C++ 使用规则(§6.1),本地静态分析可以防止失效。事实上,自 2019 年起,核心指南生命周期检查的实现已经实现了这一点。防止失效和悬挂指针的使用是完全静态的(编译时)。不涉及运行时检查。

这不是详细描述如何进行此分析的地方。然而,下面是模型的大致框架:

这些规则适用于所有直接指向对象的实体,如指针、资源管理指针、引用和指针容器。例如,int*、int&、vector<int*>、unique_ptr、jthread 持有的  int 以及一个通过引用捕获 int 的 lambda。

禁止在 delete 后使用(显然),并依赖于 RAII(§3)。不允许指针逃逸其所指对象的作用域。这意味着,只有当指针指向某些静态内容、指向自由存储区(即堆和动态内存)或作为参数传递时,才能从函数中返回指针。假设一个函数(例如,vector::push_back())接受非const参数会导致失效。如果已经获取了其元素的指针,则禁止调用该函数。仅接受const参数的函数不能导致失效,且为了避免大量的假阳性并保持本地分析,我们可以在函数声明中注释[[profiles::non_invalidating]]。当我们看到该函数的定义时,可以验证这个注释。因此,这是一个安全的注释,而不是一个“信任我”注释。当然,必须解决许多细节,但这些在实验和当前发布的实现中都已得到验证。

6.4. 执行

配置文件 指南很好且有用,但在大型代码库中始终如一地遵循它们几乎是不可能的。因此,执行是至关重要的。当前,执行规则以防止缺失初始化、范围错误、nullptr解引用和使用悬挂指针的措施已经可用,并且已证明在大型代码库中是可负担的16,7,15。

然而,关键的基础规则必须是标准的一部分——成为C++的定义的一部分,并且必须以标准的方式在代码中请求这些规则,以实现不同组织开发的代码之间的互操作性,并在多个平台和教学中使用。

我们将提供保证的强制执行的连贯指南规则集合称为“配置文件”。根据目前计划,标准中的初始配置文件集(基于多年来使用的核心指南配置文件)是10,1:

类型 – 每个对象初始化;没有强制转换;没有联合体;生命周期 – 不通过悬挂指针访问;检查指针解引用是否为nullptr;没有显式的new/delete;边界 – 所有下标操作都进行范围检查;没有指针算术;算术 – 没有溢出或下溢;没有值变化的有符号/无符号转换;这基本上就是§6.1中描述的“核心中的核心”。更多的配置文件将在时间和实验中逐步推出33。例如:

算法 – 所有范围,不允许解引用 end() 迭代器;并发 – 消除死锁和数据竞争(难度较大);RAII – 每个资源都由句柄管理(不仅仅是通过new/delete管理的资源)。并非所有配置文件都会成为 ISO 标准。我预计将会为特定应用领域定义配置文件,例如动画、飞行软件和科学计算。

执行主要是静态的(编译时),但有一些重要的检查必须是运行时的(例如,下标操作和指针解引用)。

必须显式请求为翻译单元应用配置文件。例如:

[[profile::enforce(type)]] // no casts or uninitialized objects in this TU

在必要时,可以为语句(包括复合语句)抑制配置文件。例如:

[profile::suppress(lifetime))] this->succ = this->succ->succ;

抑制验证保证的需求主要是为了实现提供保证所需的抽象(例如span、vector和string_view),以保证范围检查,并直接访问硬件。

由于 C++ 需要直接操作硬件,我们不能将基本抽象的实现“外包”给其他语言。也不能——因为它的广泛应用范围和多个独立实现——简单地将所有基础抽象的实现(例如,涉及链接结构的所有抽象)交给编译器。

7.未来


我不愿意对未来做预测,部分原因是这本身就具有风险,尤其是因为 C++ 的定义由一个庞大的 ISO 标准委员会控制,且该委员会的运作是基于共识的。根据我最后一次查看,会员名单上有 527 个条目。这表明了热情、广泛的兴趣和广泛的专业知识,但这并不理想于编程语言设计,而 ISO 规则也不可能做出剧烈的修改。在其他一些议题上,目前正在进行以下工作:

  • 异步计算的通用模型

  • 静态反射

  • SIMD

  • 合同系统

  • 函数式编程风格的模式匹配

  • 通用单位系统(例如SI系统)

这些的实验版本都已经可用。

一个严肃的问题是如何将各种不同的思想整合成一个连贯的整体。语言设计涉及在一个无法了解所有相关因素的空间中做出决策,并且在这里,公认的结果不能在几十年内做出重大改变。这与大多数软件产品开发和计算机科学的学术研究有所不同。几十年来几乎所有的语言设计努力都失败了,这证明了这个问题的严重性。

8. 总结


C++ 是为演化而设计的。当我开始时,我不仅没有足够的资源来设计和实现我理想中的编程语言,而且我也意识到,必须通过使用反馈将我的理想转化为实际的现实。C++ 就是这样不断演进的,同时始终保持着它的基本目标²⁴。

现代 C++(C++23)比以往任何版本都更接近这些理想,包括对更好的代码质量、类型安全、表达能力、性能的支持,以及对更广泛应用领域的支持。

然而,这种演进的方式也带来了一些严重的问题。许多人仍然停留在过时的 C++ 观念中。今天,我们仍然可以看到无数关于“神话般的 C/C++ 语言”的讨论,这通常意味着将 C++ 视为 C 的一个小扩展,结合了 C 的所有糟糕特性以及 C++ 复杂特性的荒谬误用。其他来源则将 C++ 描述为设计 Java 的失败尝试。此外,在包管理和构建系统等领域,工具的支持也滞后于社区对旧风格使用方式的关注。

C++ 的模型可以总结为:

  • 静态类型系统

  • 对内建类型和用户定义类型的平等支持

  • 值语义和引用语义

  • 系统化和通用的资源管理(RAII)

  • 高效的面向对象编程

  • 灵活且高效的泛型编程

  • 编译时编程

  • 直接使用机器和操作系统资源

  • 通过库支持的并发(由内在函数支持)

C++ 语言和标准库是这一模型的具体表现,是用于开发软件的生态系统中的关键部分。编程语言的价值体现在它所能支持的应用质量上。

9. 参考


1.B. Stroustrup and H. Sutter (editors): C++ Core Guidelines.

2.W. Childers et al: Reflection for C++26. WG21 P2996R6. 2024.

3.D. Engert: A (Short) Tour of C++ Modules. CppCon 2021.

4.D. Engert: Contemporary C++ in Action. CppCon 2022.

5.GCC exception performance .

6.G. Dos Reis and B. Stroustrup: Specifying C++ Concepts. POPL06. 2006.

7.A. Rebert et al: Retrofitting spacial safety to hundreds of millions of lines of C++. 2024.

8.H. Sutter: Lifetime safety: Preventing common dangling. WG21 P1179.

9.H. Sutter: Pattern matching using is and as. WG P392R3. 2024.

10.H. Sutter: Core safety profiles for C++26. WG21 D3081R1. 2024.

11.P1179R1. 2019-11-22.

12.J. Berne, T. Doumler, and A. Krzemieński: Contracts for C++. P2900R9. 2024.

13.J. Davidson and K. Gregory: Beautiful C++: 30 Core Guidelines for Writing Clean, Safe, and Fast Code. 2021. ISBN 978-0137647842.

14.K. Estell: C++ Exceptions for Smaller Firmware. CppCon 2024.

15.K.Varlamov and L Dionne: Standard library hardening. WG21 P3471R0. 2024.

16.K. Reed: Lifetime Profile Update in Visual Studio 2019 Preview 2. 2019.

17.L. Baker et al: A plan for std::execution for C++26. WG21 P3109R0.

18.M. Kretz: std::simd — data-parallel types. WG21 P21928R9. 2024.

19.M. Park: Pattern Matching: match Expression. WG21 P2688R2. 2024.

20.M. Pusz et al: Quantities and units library. WG21 P3045R2. 2024.

21.R. Grimm: C++ Core Guidelines Explained. Addison-Wesley. 2022. ISBN 978-0136875673.

22.B. Stroustrup: Classes: An Abstract Data Type Facility for the C Language. SIGPLAN Notices, January 1982.

23.B. Stroustrup: A History of C++: 1979-1991. ACM SIGPLAN. March 1993.

24.B. Stroustrup: The Design and Evolution of C++. Addison Wesley, ISBN 0-201-54330-3. 1994.

25.B. Stroustrup: A rationale for semantically enhanced library languages. LCSD05. October 2005.

26.B. Stroustrup: Evolving a language in and for the real world: C++ 1991-2006. ACM SIGPLAN. June 2007.

27.B. Stroustrup, H. Sutter, and G. Dos Reis: A brief introduction to C++’s model for type- and resource-safety. Isocpp.org. October 2015. Revised December 2015.

28.B. Stroustrup: Thriving in a Crowded and Changing World: C++ 2006–2020. ACM SIGPLAN. June 2020.

29.B. Stroustrup: Minimal module support for the standard library. WG21 P2412r0. 2021.

30.B. Stroustrup: A Tour of C++ (3rd Edition). Addison-Wesley. 2022. ISBN 978-0-13-681648-5.

31.B. Stroustrup and G. Dos Reis: Design Alternatives for Type-and-Resource Safe C++. WG21 P2687R0. 2022.

32.B. Stroustrup: Programming: Principle and Practice using C++. Addison-Wesley. 2024.ISBN 978-0-13-830868-1.

33.B. Stroustrup: A framework for Profiles development. WG21 P3274R0. 2024.

34.B. Stroustrup: Profile invalidation – eliminating dangling pointers. WG21 P3346R0.

35.B.W. Kernighan and D.M. Ritchie: The C Programming Language. Prentice-Hall. 1978. ISBN 01-13-110163-3.

10.推荐阅读


图片

《C++实战》

吴咏炜 | 著

一本面向实战的现代 C++ 指南,由作者结合 30 余年 C++ 编程经验倾力打造。书中聚焦开发者日常高频使用的语言特性,重点讲解惯用法(而非罗列语言里的琐碎细节),展示代码示例及其技术原理,旨在帮助大家又快又好地使用 C++。作者精选了对象生存期与 RAII、移动语义、标准模板库(STL)、视图、智能指针、错误处理、并发与异步编程等核心主题,深入浅出地剖析语言特性,并针对实际开发中的常见问题提供解决方案。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值