C++ 2006–2020

 主页   资讯   文章   代码   电子书 

4. C++11:感觉像是门新语言

C++11 [Becker 2011] 发布后,其实现相对来说很快就出现了。这导致了极大的热情,增加了使用,有大量新人涌入 C++ 世界,并进行了大量的实验。C++11 的三个完整或几乎完整的实现在 2013 年面世。我当时的评论被广泛认为是准确的——C++11 感觉像是一门新的语言 [Stroustrup 2014d]。为什么 C++11 在帮助程序员方面做得如此出色?又是如何做到的?

C++11 引入了大量令人眼花缭乱的语言特性,包括:

  • 内存模型——一个高效的为现代硬件设计的底层抽象,作为描述并发的基础(§4.1.1
  • autodecltype——避免类型名称的不必要重复(§4.2.1
  • 范围 for——对范围的简单顺序遍历(§4.2.2
  • 移动语义和右值引用——减少数据拷贝(§4.2.3
  • 统一初始化—— 对所有类型都(几乎)完全一致的初始化语法和语义(§4.2.5
  • nullptr——给空指针一个名字(§4.2.6
  • constexpr 函数——在编译期进行求值的函数(§4.2.7
  • 用户定义字面量——为用户自定义类型提供字面量支持(§4.2.8
  • 原始字符串字面量——不需要转义字符的字面量,主要用在正则表达式中(§4.2.9
  • 属性——将任意信息同一个名字关联(§4.2.10
  • lambda 表达式——匿名函数对象(§4.3.1
  • 变参模板——可以处理任意个任意类型的参数的模板(§4.3.2
  • 模板别名——能够重命名模板并为新名称绑定一些模板参数(§4.3.3
  • noexcept——确保函数不会抛出异常的方法(§4.5.3
  • overridefinal——用于管理大型类层次结构的明确语法
  • static_assert——编译期断言
  • long long——更长的整数类型
  • 默认成员初始化器——给数据成员一个默认值,这个默认值可以被构造函数中的初始化所取代
  • enum class——枚举值带有作用域的强类型枚举

以下是主要的标准库组件列表(§4.6):

  • unique_ptrshared_ptr——依赖 RAII(§2.2.1)的资源管理指针(§4.2.4
  • 内存模型和 atomic 变量(§4.1.1
  • threadmutexcondition_variable 等——为基本的系统层级的并发提供了类型安全、可移植的支持(§4.1.2
  • futurepromisepackaged_task,等——稍稍更高级的并发(§4.1.3
  • tuple——匿名的简单复合类型(§4.3.4
  • 类型特征(type trait)——类型的可测试属性,用于元编程(§4.5.1
  • 正则表达式匹配(§4.6
  • 随机数——带有许多生成器(引擎)和多种分布(§4.6
  • 时间——time_pointduration§4.6
  • unordered_map 等——哈希表
  • forward_list——单向链表
  • array——具有固定常量大小的数组,并且会记住自己的大小
  • emplace 运算——在容器内直接构建对象,避免拷贝
  • exception_ptr——允许在线程之间传递异常

还有更多,但这些是最重要的变化。所有这些都在 [Stroustrup 2013] 中进行了描述,许多信息可以在网上获得(例如 [Cppreference 2011–2020])。

这些表面上互不相干的扩展怎么能组成一个连贯的整体?这怎么可能真正地改变我们写代码的方式,使之变得更好呢?C++11 做到了这一点。在相对较短的时间里(算 5 年吧),大量的 C++ 代码被升级到 C++11(并进一步升级到 C++14 和 C++17),而且 C++ 在会议和博客上的呈现也完全改变了。

这种在语言的“感觉”和使用风格上的巨大变化,并不是由某位大师级工匠指导的传统的精心设计过程的结果,而是海量建议经由一大批不断变化的个人层层决策过滤后的结果。

在我的 HOPL3 论文 [Stroustrup 2007] 中,我正确地描述了 C++11 语言的许多特性。值得注意的例外是“概念”,我会在(§6)中进行讨论。我将不再赘述细节,而是根据它们所解决的程序员需求来描述功能的“主题”分类。我认为这种看待提案的方式是 C++11 成功的根源:

  • §4.1:支持并发
  • §4.2:简化使用
  • §4.3:改进对泛型编程的支持
  • §4.4:增加静态类型安全
  • §4.5:支持对库的开发
  • §4.6:标准库组件

这些“主题”并不是不相干的。事实上,我猜想 C++11 之所以成功,是因为它相互关联的功能彼此加成,形成了一张精细的网络,可以处理真正的需求。每一个主题里都有我喜欢的特性。我怀疑,我在写作(例如 [Stroustrup 1993, 1994, 2007])和演讲中明确表述了 C++ 的目标,也帮助设计保持了合理的重点。对我来说,衡量每个新特性的一个关键指标是它是否使 C++ 更接近它的理想,例如,是否通过引入该特性能让对内建类型和用户定义类型的支持更加相似(§2.1)。

纵观 C++11,我们可以看到有些改进建议在 2002 年左右就被提出,有不少库也出现得很早,经常是作为 Boost 的一部分 [Boost 1998–2020]。然而,直到 2013 年才有完整的 C++11 实现。在 2020 年,一些组织仍在为升级到 C++11 而苦恼,因为代码库巨大,程序员不思进取,教学方式陈旧,以及编译器严重过时(尤其是在嵌入式系统领域)。不过 C++17 的采用速度明显快于 C++98 和 C++11;并且,早在 2018 年,C++20 的一些主要特性就已经投入生产使用。

直到 2018 年,我仍能看到 C++98 前的编译器被用于教学。我认为这是对学生的虐待,剥夺了他们接触学习我们 20 多年的进展的机会。

对标准委员会、主要编译器厂商以及大多数 C++ 的积极支持者来说已是遥远的过去的东西,对许多人来说,仍然是现在——甚至是未来。其结果是,人们对 C++ 到底是什么仍然感到困惑。只要 C++ 继续演化,这种困惑就会持续下去。

4.1 C++11:并发支持

C++11 必须支持并发。这既是显而易见的,也是所有主要用户和平台供应商的共同需求。C++ 一直在大多数软件工业的基础中被重度使用,而在二十一世纪的头十年,并发性变得很普遍。利用好硬件并发至关重要。和 C 一样,C++ 当然一直支持各种形式的并发,但这种支持那时没有标准化,并且一般都很底层。机器架构正在使用越来越精巧的内存架构,编译器编写者也在应用越来越激进的优化技术,这让底层软件编写者的工作极为困难。机器架构师和优化器编写者之间亟需一个协定。只有有了明确的内存模型,基础库的编写者才能有一个稳定的基础和一定程度的可移植性。

并发方面的工作从 EWG 中分离出来,成为由 Hans-J. Boehm(惠普,后加入谷歌)领导的专家成员组成的并发组。它有三项职责:

此外,并行算法(§8.5)、网络(§8.8.1)和协程(§9.3.2)是单独分组处理的,并且(正如预期)还没法用于 C++11。

4.1.1 内存模型

最紧迫的问题之一,是在一个有着多核、缓存、推测执行、指令乱序等的世界里精确地规定访问内存的规则。来自 IBM 的 Paul McKenney 在内存保证方面的课题上非常活跃。来自剑桥大学的 Mark Batty 的研究 [Batty et al. 2013, 2012, 2010, 2011] 帮助我们将这一课题形式化,见 P. McKenney、M. Batty、C. Nelson、H. Boehm、A. Williams、S. Owens、S. Sarkar、P. Sewell、T. Weber、M. Wong、L. Crowl 和 B. Kosnik 合作的论文 [McKenney et al. 2010]。它是 C++11 的一个庞大而至关重要的部分。

在 C11 中,C 采用了 C++ 的内存模型。然而,就在 C 标准付诸表决前的最后一刻,C 委员会引入了不兼容的写法,而此时 C++11 标准修改的最后一次机会已经过去。这成了 C 和 C++ 实现者和用户的痛苦。

内存模型很大程度上是由 Linux 和 Windows 内核的需求驱动的。目前它不只是用于内核,而且得到了更加广泛的使用。内存模型被广泛低估了,因为大多数程序员都看不到它。从一阶近似来看,它只是让代码按照任何人都会期望的方式正常工作而已。

最开始,我想大多数委员都小瞧了这个问题。我们知道 Java 有一个很好的内存模型 [Pugh 2004],并曾希望采用它。令我感到好笑的是,来自英特尔和 IBM 的代表坚定地否决了这一想法,他们指出,如果在 C++ 中采用 Java 的内存模型,那么我们将使所有 Java 虚拟机的速度减慢至少两倍。因此,为了保持 Java 的性能,我们不得不为 C++ 采用一个复杂得多的模型。可以想见而且讽刺的是,C++ 此后因为有一个比 Java 更复杂的内存模型而受到批评。

基本上,C++11 模型基于之前发生(happens-before)关系 [Lamport 1978],并且既支持宽松的内存模型,也支持顺序一致 [Lamport 1979] 的模型。在这些之上,C++11 还提供了对原子类型和无锁编程的支持,并且与之集成。这些细节远远超出了本文的范围(例如,参见 [Williams 2018])。

不出所料,并发组的内存模型讨论有时变得有点激烈。这关系到硬件制造商和编译器供应商的重大利益。最困难的决定之一是同时接受英特尔的 x86 原语(某种全存储顺序,Total Store Order(TSO)模型 [TSO Wikipedia 2020] 加上一些原子操作)和 IBM 的 PowerPC 原语(弱一致性加上内存屏障)用于最底层的同步。从逻辑上讲,只需要一套原语,但 Paul McKenney 让我相信,对于 IBM,有太多深藏在复杂算法中的代码使用了屏障,他们不可能采用类似英特尔的模型。有一天,我真的在一个大房间的两个角落之间做了穿梭外交。最后,我提出必须支持这两种方式,这就是 C++11 采用的方式。当后来人们发现内存屏障和原子操作可以一起使用,创造出比单单使用其中之一更好的解决方案时,我和其他人都感到非常高兴。

稍后,我们增加了对基于数据依赖关系的一致性支持,通过属性(§4.2.10)在源代码中表示,比如 [[carries_dependency]]

C++11 引入了 atomic 类型,上面的简单操作都是原子的:

atomic<int> x;
void increment()
{
    x++;  // 不是 x = x + 1
}

显然,这些都是广泛有用的。例如,使用原子类型使出名棘手的双重检查锁定优化变得极为简单:

mutex mutex_x;
atomic<bool> init_x;  // 初始为 false
int x;

if (!init_x) {
    lock_guard<mutex> lck(mutex_x);
    if (!init_x) x = 42;
    init_x = true ;
}  // 在此隐式释放 mutex_x(RAII)

// ... 使用 x ...

双重检查锁定的要点是使用相对开销低的 atomic 保护开销大得多的 mutex 的使用。

lock_guard 是一种 RAII 类型(§2.2.1),它确保会解锁它所控制的 mutex

Hans-J. Boehm 将原子类型描述为“令人惊讶地流行”,但我不能说我感到惊讶。我没 Hans 那么专业,对简化更为欣赏。C++11 还引入了用于无锁编程的关键运算,例如比较和交换:

template<typename T>
class stack {
    std::atomic<node<T>*> head;
public:
    void push(const T& data)
    {
        node<T>* new_node = new node<T>(data);
        new_node->next = head.load(std::memory_order_relaxed);
        while(!head.compare_exchange_weak(new_node->next, new_node,
              std::memory_order_release, std::memory_order_relaxed)) ;
    }
    // ...
};

即使有了 C++11 的支持,我仍然认为无锁编程是专家级的工作。

4.1.2 线程和锁

在内存模型之上,我们还提供了线程加锁的并发模型。我认为线程加锁级别的并发是应用程序使用并发的最差模型,但是对于 C++ 这样的语言来说,它仍然必不可少。不管它还是别的什么,C++ 一直是一种能够与操作系统直接交互的系统编程语言,可用于内核代码和设备驱动程序。因此,它必须支持系统最底层支持的东西。在此基础上,我们可以建立各种更适合特定应用的并发模型。就我个人而言,我特别喜欢基于消息的系统,因为它们可以消除数据竞争,而数据竞争可能产生极为隐晦的并发错误。

C++ 对线程和锁级别编程的支持是 POSIX 和 Windows 所提供的线程和锁的类型安全变体。在 [Stroustrup 2013] 有所描述,在 Anthony Williams 的书 [Williams 2012, 2018] 中有更为深入的探讨:

  • thread——系统的执行线程,支持 join()detach()
  • mutex——系统的互斥锁,支持 lock()unlock() 和保证 unlock() 的 RAII 方式
  • condition_variable——系统中线程间进行事件通信的条件变量
  • thread_local——线程本地存储

与 C 版本相比,类型安全使代码更简洁,例如,不再有 void** 和宏。考虑一个简单的例子,让一个函数在不同的线程上执行并返回结果:

class F {  // 传统函数对象
public:
    F(const vector<double>& vv, double* p) : v{vv}, res{p} { }
    void operator()();        // 将结果放入 *res
private:
    const vector<double>& v;  // 输入源
    double* res;              // 输出目标
};

double f(const vector<double>& v);  // 传统函数

void g(const vector<double>& v, double* res); // 将结果放入 *res

int comp(vector<double>& vec1, vector<double>& vec2, vector<double>& vec3)
{
    double res1;
    double res2;
    double res3;
    // ...
    thread t1 {F{vec1,res1}};            // 函数对象
    thread t2 {[&](){res2=f(vec2);}};    // lambda 表达式
    thread t3 {g,ref(vec3),&res3};       // 普通函数

    t1.join();
    t2.join();
    t3.join();

    cout << res1 << ' ' << res2 << ' ' << res3 << '\n';
}

类型安全库支持的设计主要依赖变参模板(§4.3.2)。例如,std::thread 的构造函数就是变参模板。它可以区分不同的可执行的第一个参数,并检查它们后面是否跟有正确数量正确类型的参数。

类似地,lambda 表达式(§4.3.1)使 <thread> 库的许多使用变得更加简单。例如,t2 的参数是访问周围局部作用域的一段代码(lambda 表达式)。

在发布标准的同时,让新特性在标准库中被接受和使用是很困难的。有人提出这样做过于激进,可能会导致长期问题。引入新的语言特性并同时使用它们无疑是有风险的,但它通过以下方式大大增加了标准的质量:

  • 给用户一个更好的标准库
  • 给用户一个很好的使用语言特性的例子
  • 省去了用户实现底层功能的麻烦
  • 迫使语言特性的设计者应对现实世界的困难应用

线程和锁模型需要使用某种形式的同步来避免竞争条件。C++11 为此提供了标准的 mutex(互斥锁):

mutex m;  // 控制用的互斥锁
int sh;   // 共享的数据

void access ()
{
    unique_lock<mutex> lck {m};   // 得到互斥锁
    sh += 7;                      // 操作共享数据
} // 隐式释放互斥锁

unique_lock 是一个 RAII 对象,确保用户不会忘记在这个 mutex 上调用 unlock()

这些锁对象还提供了一种防止最常见形式的死锁的方法:

void f()
{
    // ...
    unique_lock<mutex> lck1 {m1,defer_lock};  // 还未得到 m1
    unique_lock<mutex> lck2 {m2,defer_lock};
    unique_lock<mutex> lck3 {m3,defer_lock};
    // ...
    lock(lck1,lck2,lck3);  // 获取所有三个互斥锁
    // ... 操作共享数据 ...
}   // 隐式释放所有互斥锁

这里,lock() 函数“同时”获取所有 mutex 并隐式释放所有互斥锁(RAII(§2.2.1))。C++17 有一个更优雅的解决方案(§8.4)。

线程库是由 Pete Becker(Dinkumware)在 2004 年首次为 C++0x 提出的 [Becker 2004],它基于 Dinkumware 对 Boost.Thread [Boost 1998–2020] 所提供的接口的实现。在同一次会议上(华盛顿州 Redmond 市,2004 年 9 月)提出了第一个关于内存模型的提案 [Alexandrescu et al. 2004],这可能不是巧合。

最大的争议是关于取消操作,即阻止线程运行完成的能力。基本上,委员会中的每个 C++ 程序员都希望以某种形式实现这一点。然而,C 委员会在给 WG21 的正式通知 [WG14 2007] 中反对线程取消,这是唯一由 WG14(ISO C 标准委员会)发给 WG21 的正式通知。我指出,“但是 C 语言没有用于系统资源管理和清理的析构函数和 RAII”。管理 POSIX 的 Austin Group 派出了代表,他们 100% 反对任何形式的这种想法,坚称取消既没有必要,也不可能安全进行。事实上 Windows 和其他操作系统提供了这种想法的变体,并且 C++ 不是 C,然而POSIX 人员对这两点都无动于衷。在我看来,恐怕他们是在捍卫自己的业务和 C 语言的世界观,而不是试图为 C++ 提出最好的解决方案。缺乏标准的线程取消一直是一个问题。例如,在并行搜索(§8.5)中,第一个找到答案的线程最好可以触发其他此类线程的取消(不管是叫取消或别的名字)。C++20 提供了停止令牌机制来支持这个用例(§9.4)。

4.1.3 期值(future)

一个类型安全的、标准的、类似 POSIX/Windows 的线程库是对正在使用的不兼容的 C 风格库的重大改进,但这仍然是 1980 年代风格的底层编程。一些成员,特别是我,认为 C++ 迫切需要更现代、更高层次的东西。举例来说,Matt Austern(谷歌,之前代表 SGI)和我主张消息队列(“通道”)和线程池。这些意见没有什么进展,因为有反对意见说没有时间来做这些事情。我恳求并指出,如果委员会中的专家不提供这样的功能,他们最终将不得不使用“由我的学生匆匆炮制的”功能。委员会当然可以做得比这好得多。“如果你不愿意这样做,请给我一种方法,就一种方法,在没有显式同步的情况下在线程之间传递信息!”

委员会成员分为两派,一派基本上想要在类型系统上有改进的 POSIX(尤其是 P.J. Plauger),另一派指出 POSIX 基本上是 1970 年代的设计,“每个人”都已经在使用更高层次的功能。在 2007 年的 Kona 会议上,我们达成了一个妥协:C++0x(当时仍期望会是 C++09)将提供 promisefuture,以及异步任务的启动器 async(),允许但不需要线程池。和大多数折中方案一样,“Kona 妥协”没有让任何人满意,还导致了一些技术问题。然而,许多用户认为它是成功的——大多数人不知道这当时是一种妥协——这些年来,已经出现了一些改进。

最后,C++11 提供了:

  • future——一个句柄,通过它你可以从一个共享的单对象缓冲区中 get() 一个值,可能需要等待某个 promise 将该值放入缓冲区。
  • promise——一个句柄,通过它你可以将一个值 put() 到一个共享的单对象缓冲区,可能会唤醒某个等待 futurethread
  • packaged_task——一个类,它使得设置一个函数在线程上异步执行变得容易,由 future 来接受 promise 返回的结果。
  • async()——一个函数,可以启动一个任务并在另一个 thread 上执行。

使用这一切的最简单方法是使用 async()。给定一个普通函数作为参数,async() 在一个 thread 上运行它,处理线程启动和通信的所有细节:

double comp4(vector<double>& v)
// 如果 v 足够大则会产生多个任务
{
    if (v.size()<10000)    // 值得用并发机制吗?
        return accum(v.begin(),v.end(),0.0);
    auto v0 = &v[0];
    auto sz = v.size();

    auto f0 = async(accum,v0,v0+sz/4,0.0);         // 第一部分
    auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0);    // 第二部分
    auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0);  // 第三部分
    auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0);    // 第四部分

    return f0.get()+f1.get()+f2.get()+f3.get();    // 收集结果
}

async 将代码包装在 packaged_task 中,并管理 future 及其传输结果的 promise 的设置。

值或异常都可以通过这样一对 future/promise 从一个 thread 传递到另一个 thread。例如:

X f(Y); // 普通函数

void ff(Y y, promise<X>& p)     // 异步执行 f(y)
{
    try {
        X res = f(y);           // ... 给 res 计算结果 ...
        p.set_value(res);
    }
    catch (...) {               // 哎呀:没能计算出 res
        p.set_exception(current_exception());
    }
}

为简单起见,我没有使用参数的完美转发(§4.2.3)。

对应 futureget() 现在要么得到一个值,要么抛出一个异常——与 f() 的某个等效同步调用完全一样。

void user(Y arg)
{
    auto pro = promise<X>{};
    auto fut = pro.get_future();
    thread t {ff,arg,ref(pro)}; // 在不同线程上运行 ff
    // ... 做一会别的事情 ...
    X x = fut.get();
    cout << x.x << '\n';
    t.join();
}

int main()
{
    user(Y{99});
}

标准库的 packaged_task 自动化了这个过程,可以将普通函数包装成一个函数对象,负责 promise/future 的自动配置并处理返回和异常。

我曾希望这会产生一个由线程池支持的工作窃取(work-stealing)的实现,但我还是失望了。

另见(§8.4)。

4.2 C++11:简化使用

C++ 是“专家友好”的。我想我是第一个将这句话用作委婉的批评,并且在 C++ 中推行“简单的事情简单做!”的口号的人。当然,主要面向工业应用的语言就应该对专家友好,但是一门语言不能只对专家友好。大多数使用编程语言的人并不是专家——他们也不想精通该语言的方方面面,而只是想把工作做到足够好,不会因为语言而分心。编程语言的存在,是为了能够表达应用程序的创意,而不是把程序员变成语言律师。语言的设计应该尽力让简单的事情能够简单地做。语言要给专家使用的话,则必须额外确保,没有啥基本事项是不可能做的,并且代价也不会过于高昂。

当讨论潜在的 C++ 语言扩展和标准库组件时,另外一个准则是“教起来容易吗?”这个问题现在已经很普遍了,它最早是由 Francis Glassborow 和我倡导的。“教起来容易”的思想起源于 C++ 的早期,可以在《C++ 语言的设计和演化》[Stroustrup 1994] 中找到。

当然,新事物的拥护者不可避免地认为他们的设计简单、易用、足够安全、高效、易于传授,及对大多数程序员有用。反对者则倾向于怀疑他们的部分甚至全部说法。但是,确保对 C++ 提议的每个特性都经历这样的讨论是很重要的:可以通过面对面会议,可以通过论文 [WG21 1989–2020],也可以通过电子邮件。在这些讨论中,我经常指出,我大部分时间也是个新手。也就是说,当我学习新的特性、技巧或应用领域时,我是一个新手,我会用到从语言和标准库中可以获得的所有帮助。一个结果是,C++11 提供了一些特别的功能,旨在简化初学者和非语言专家对 C++ 的使用。

每一项新特性都会让一些人做某些事时更加简单。“简化使用”的主题聚焦于这样一些语言特性,它们的主要设计动机是让已知的惯用法使用起来更加简单。下面列举其中的一些:

  • §4.2.1auto——避免类型名称的不必要重复
  • §4.2.2:范围 for——简化范围的顺序遍历
  • §4.2.3:移动语义和右值引用——减少数据拷贝
  • §4.2.4:资源管理指针——管理所指向对象生命周期的“智能”指针(unique_ptrshared_ptr
  • §4.2.5:统一初始化——对所有类型都(几乎)完全一致的初始化语法和语义
  • §4.2.6nullptr——给空指针一个名字
  • §4.2.7constexpr 函数——编译期被估值的函数
  • §4.2.8:用户定义字面量——为用户自定义类型提供字面量支持
  • §4.2.9:原始字符串字面量——转义字符(\)不被解释为转义符的字面量,主要用在正则表达式中
  • §4.2.10:属性——将任意信息同一个名字关联
  • §4.2.11:与可选的垃圾收集器之间的接口
  • §4.3.1:lambda 表达式——匿名函数对象

在 C++11 开始得到认真使用后,我就开始在旅行时做一些不那么科学的小调查。我会问各地的 C++ 使用者:你最喜欢哪些 C++11 的特性?排在前三位的一直都是:

这三个特性属于 C++11 中新增的最简单特性,它们并不能提供任何新的基础功能。它们做的事情,在 C++98 中也能做到,只是不那么优雅。

我认为这意味着不同水平的程序员都非常喜欢让惯常用法变简洁的写法。他们会高兴地放弃一个通用的写法,而选择一个在适用场合中更简单明确的写法。有一个常见的口号是,“一件事只应有一种说法![^1]”这样的“设计原则”根本不能反映现实世界中的用户偏好。我则倾向于依赖洋葱原则 [Stroustrup 1994]。你的设计应该是这样的:如果要完成的任务是简单的,那就用简单的方法做;当要完成的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下了一层洋葱。剥得越深,流泪就越多。

请注意,这里简单并不意味着底层void*、宏、C 风格字符串和类型转换等底层功能表面上学起来简单,但使用它们来产出高质量、易维护的软件就难了。

4.2.1 autodecltype

C++11 中最古老的的新特性,是能够在初始化的时候就给对象指定一个确定的类型。例如:

auto i = 7;          // i 是个整数
auto d = 7.2;        // d 是个双精度浮点数
auto p = v.begin();  // p 是 v 的迭代器类型
                     // (begin() 返回一个迭代器)

auto 是一个方便的静态特性,它允许从初始化表达式中推导出对象的静态类型。如果要用动态类型的变量,应该使用 variant 或者 any§8.3)。

我早在 1982/83 年冬天就实现了 auto,但是后来为了保持 C 兼容性而不得不移除了这一特性。

在 C++11 中,大家提出用 typeof 运算符代替已经流行的 typeof 宏和编译器扩展。不幸的是,不同 typeof 宏在处理引用时并不兼容,因而采用任何一种都会严重破坏现有代码。引入一个新的关键字总是困难的,因为如果它简短而且意义明确,那它一定已经被使用了成千上万次。如果建议的关键字又丑又长,那大家就会讨厌它。

Jaakko Järvi,Boost 库最多产的贡献者之一,那时是我在得州农工大学的同事。他当时领导了 typeof 的讨论。我们意识到语义的问题可以概括为:“一个引用的 typeof 到底是引用自身,还是所引用的类型?”同时,我们还感觉到,typeof 有点冗长而且容易出错,比如:

typeof(x+y) z = y+x;

在这里,我以为我重复计算了 x+y,但其实并没有(潜在的不良影响),但不管怎么样,我为什么要把任何东西重复写两遍呢?这时候我意识到,我其实在 1982 年就解决过这个问题,我们可以“劫持”关键字 auto 来消除这种重复:

auto z = y+x;  // z 获得 y+x 的类型

在 C 和早期的 C++ 中,auto 曾表示“在自动存储(比如栈上)上分配”,但是从来没有被用过。我们查看了数百万行的 C 和 C++ 代码,确认了 auto 只在一些测试集和错误中用到过,于是我们就可以回收这个关键字,用作我 1982 年的意思,表示“获取初始化表达式的类型”。

剩下的问题是,我们要在某些场景中把引用的类型也推导为一个引用。这在基于模板的基础库中并不少见。我们提出了用 decltype 运算符来处理这种保留引用的语义:

template<typename T> void f(T& r)
{
    auto v = r;            // v 是 T
    decltype(r) r2 = r;    // r2 是 T&
    // ...
}

为什么是 decltype?可惜,我已经不记得是谁建议了这个名字了,但是我还记得原因:

  • typeof 已经不能用了,因为那样会破坏很多老代码
  • 我们找不到其他优雅、简短、且没有被用过的名字了
  • decltype 足够好记(“declared type”的简写);但也足够古怪,因而没有在现有代码中用过
  • decltype 还算比较短

提议 decltype 的论文写于 2003 年 [Järvi et al. 2003b],而通过投票接受到标准中的论文写于 2006 年 [Järvi et al. 2007]。Jaakko Järvi 做了让 decltype 通过委员会评审的大部分细节的工作,Doug Gregor、Gabriel Dos Reis、Jeremy Siek 和我也帮过忙,并且在一些论文中作为合著作者出现。事实证明,澄清 decltype 的确切语义比我在这里说的要难得多。花费数年在一个看上去很简单的特性细节上的情况并不少见——部分原因是特性的固有复杂性,部分原因则是,需要最后批准的人可真不少,他们需要同意每个细节的设计和具体说明都已经让人满意了。

我认为 auto 是个纯粹的简化特性,而 decltype 的主要目的,则是让基础库可以使用复杂的元编程。然而,从语言使用的技术角度来看,它们是密切相关的。

我探索过推广 auto 到另外两个显而易见的场景 [Stroustrup and Dos Reis 2003b]:作为返回类型和参数类型。这显而易见,因为在 C++ 中,参数传递和值返回被定义为初始化。但在 2003 年,当我第一次向委员会提出这些想法时,演化工作组的成员们毫不掩饰地表现出恐惧的神情。考虑下面的例子:

auto f(auto arg)
{
    return arg;
}

auto x = f(1);                // x 是 int
auto s = f(string("Hello"));  // s 是 string

当我向委员会提出这个想法时,我收到了超过我的任何其他提案的负面反馈。我形容当时的情景“就像贵妇见到了老鼠一样”,他们叫嚷着:“咦咿……!”。然而,故事还没结束。C++17 后来对 lambda 表达式(§4.3.1)的参数和返回值都支持了 auto,而对普通的函数,C++17 只支持返回值的 auto。作为概念的一部分(§6.4),C++20 为函数参数添加了 auto 支持,至此才完全实现了我在 2003 年提出的建议。

C++11 中添加了一种弱化的 auto 用法,把返回类型的说明放到参数后面。例如,在 C++98 中,我们会这样写:

template<typename T>
vector<T>::iterator vector<T>::begin() { /* ... */ }

重复出现的 vector<T>:: 令人厌烦,当时也没法表达返回类型依赖于参数类型(这在一些泛型编程中很有用)。C++11 弥补了这个问题,并提高了代码的可读性:

template<typename T>
auto vector<T>::begin() -> iterator { /* ... */ }

这样,在多年努力后,我们终于有了 auto。它立即就变得非常流行,因为它让程序员不用再拼写冗长的类型名称,也不需要在泛型代码中考虑类型的细节。例如:

for (auto p = v.begin(); p != v.end(); ++p) ...  // 传统的 STL 循环

它允许人们对齐名字:

class X {
public:
    auto f() -> int;
    auto gpr(int) -> void;
    // ...
};
void use(int x, char* p)
{
    auto x2 = x*2;   // x2 是 int
    auto ch = p[x];  // ch 是 char
    auto p2 = p+2;   // p2 是 char*
    // ...
}

还曾经有论文主张尽量多地使用 auto [Sutter 2013b]。有句话很经典:每个有用的新特性,一开始都会被滥用和误用。一段时间后,部分开发者找到了平衡点。把这种平衡的用法阐述为最佳实践,是我(和很多其他人)致力于编程指南(§10.6)的原因之一。对于 auto,我收到了很多评论,说当人们将它和没有明显类型的初始化表达式放一起使用时可读性不好。因此,C++ 核心指南 [Stroustrup and Sutter 2014–2020](§10.6)有了这条规则:

ES.11:使用 auto 来避免类型名称的多余重复

我的书 [Stroustrup 2013, 2014d] 中也有类似的建议。考虑下面的例子:

auto n = 1;  // 很好:n 是 int
auto x = make_unique<Gadget>(arg);  // 很好:x 是 std::unique_ptr<Gadget>
auto y = flopscomps(x,3);           // 不好:flopscomps() 返回的是啥东西?

这仍然无法百分百地确定如何在每种情况下应用该规则,但有规则总比没有规则要好得多,并且代码会比使用绝对规则“不许使用 auto!”和“永远使用 auto!”更加可读。真实世界的编程往往需要更多的技巧,不会像展示语言特性的例子这样简单。

如果 flopscomps() 不是泛型计算的一部分,那么最好显式地声明想要的类型。我们需要等到 C++ 20 才能用概念来约束返回类型(§6.3.5):

Channel auto y = flopscomps(x,3);   // y 可以当做 Channel 使用

那么,针对 auto 的工作值得吗?它是一个很小的功能,对于简单的情况,一天就可以实现,但却花了 4 年的时间才在委员会通过。它甚至都不算新颖:很多语言 40 年前就有这样的功能了,甚至带类的 C 在 35 年前就有这样的功能!

对 C++ 标准委员会通过哪怕是最小的功能所需的时间,以及常伴其间的痛苦讨论,经常让我感到绝望。但是另一方面,把事情做好之后,成千上万的程序员会从中受益。当某件事做得很好时,最常见的评论是:“这很明显啊!怎么你们要花那么久?”

4.2.2 范围 for

范围 for 是用来顺序遍历一个序列中所有元素的语句。例如:

void use(vector<int>& v, list<string>& lst)
{
    for (int x : v) cout << x << '\n';
    int sum = 0;
    for (auto i : {1,2,3,5,8}) sum+=i; // 初始化列表是一个序列
    for (string& s : lst) s += ".cpp"; // 使用引用允许遍历时修改
}

它最初是由 Thorsten Ottosen(丹麦奥尔堡大学)提出的,理由是“基本上任何现代编程语言都内置了某种形式的 for each” [Ottosen 2005]。我通常不认为“别人都有了”是个好的论据,但在这一情况下,真正的要点是,简单的范围循环可以简化一种最常见的操作,并提供了优化的机会。所以,范围 for 完美符合我对 C++ 的总体设计目标。它直接表达应该做什么,而不是详细描述如何做。它的语法简洁,语义明晰。

由于更简单和更明确,范围 for 语句消除了一些“微不足道”然而常见的错误:

void use(vector<int>& v, list<string>& lst)
{
    for (int i=0; i<imax; ++i)
        for (int j=0; i<imax; ++j) ...  // 错误的嵌套循环

    for (int i=0; i<=max; ++i) ...      // 多循环了一次的错误
}

尽管范围 for 够简单了,它在这些年还是有些变化。Doug Gregor 曾建议使用 C++0x 中的概念来修改范围 for,方案优雅并且得到了批准 [Ottosen et al. 2007]。我还记得他在我在得州的办公室里写这个提案的场景,但很遗憾,后来因为删除了 C++0x 的概念(§6),我们不得不回退了那些修改。在 2016 年,它还做过一点小修改,以配合 Ranges TS(§9.3.5)所支持的无限序列。

4.2.3 移动语义

在 C 和 C++ 中,要从函数获得大量的数据,传统做法是在自由存储区(堆、动态内存)上分配空间,然后传递指向该空间的指针作为函数参数。比如,对于工厂函数和返回容器(例如 vectormap)的函数就需要如此。这对开发者来说看起来很自然,而且相当高效。不幸的是,它是显式使用指针的主要来源之一,导致了写法上的不便、显式的内存管理,以及难以查找的错误。

多年来,很多专家使用“取巧”的办法来解决这个问题:把句柄类作为简单数值(常称为值类型)来传递,例如:

Matrix operator+(const Matrix&, const Matrix&);

void use(const Matrix& m1, const Matrix& m2)
{
    Matrix m3 = m1+m2;
    // ...
}

这里 operator+ 让我们可以使用常规的数学记法,同时也是一个工厂函数返回大对象的示例。

通过 const 引用把 Matrix 传递给函数,一直是传统而高效的做法。而问题在于,如何以传值来返回 Matrix 而不用拷贝所有的元素。早在 1982 年,我曾通过一种优化方案来部分解决这一问题,即干脆将返回值分配在调用函数的栈帧上。它工作得很好,但它只是优化技术,不能处理更复杂的返回语句。而用户在按值返回“大对象”时,需要确保绝不会进行大量的数据复制。

要做到这一点,需要观察到“大对象”通常是在自由存储区上的数据的一个句柄。为了避免复制大量的数据,我们只需要确保在实现返回时,构造函数复制的只是句柄,而不是所有元素。C++11 对这个问题的解决方案如下所示:

class Matrix {
    double* elements;    // 指向所有元素的指针
    // ...
public:
    Matrix (Matrix&& a)  // 移动构造
    {
        elements = a.elements;  // 复制句柄
        a.elements = nullptr;   // 现在 a 的析构函数不用做任何事情了
    }
    // ...
};

当用于初始化或赋值的源对象马上就会被销毁时,移动就比拷贝要更好:移动操作只是简单地把对象的内部表示“窃取”过来。&& 表示构造函数是一个移动构造函数Matrix&& 被称为右值引用。当用于模板参数时,右值引用的符号 && 被叫做转发引用,这是由 John Spicer 在 2002 年的一次会议上,同 Dave Abrahams 和 Howard Hinnant 一起提出的。

这个 Matrix 的例子有个有意思的地方:如果 Matrix 的加法返回指针的话,那传统的数学记号(a+b)就不能用了。

移动语义蕴含着性能上的重大好处:它消除了代价高昂的临时变量。例如:

Matrix mx = m1+m2+m3;  // 不需要临时变量
string sx = s1+s2+s3;  // 不需要临时变量

这里我添加了 string 的例子,因为移动语义立刻就被添加到了所有的标准库容器上,这可以让一些 C++98 的程序拿来不做任何代码修改就获得性能提升。

允许类的设计者定义移动操作后,我们就有了完整的对对象生命周期和资源管理的控制,这套控制始于 1979 年对构造函数和析构函数的引入。移动语义是 C++ 资源管理模型的重要基石 [Stroustrup et al. 2015],正是这套机制使得对象能够在不同作用域之间可以简单而高效地进行移动。

早期对参数传递、完美转发和智能指针强调颇多,可能掩盖了这个重要的一般性观点。Howard Hinnant、Dave Abrahams 和 Peter Dimov 在 2002 年提出了移动语义的一般化版本 [Hinnant et al. 2004, 2002]:

右值引用可以用于给现有类方便地添加移动语义。意思是说,拷贝构造函数和赋值运算符可以根据实参是左值还是右值来进行重载。当实参是右值时,类的作者就知道他拥有对该实参的唯一引用。

一个突出的例子是生成“智能指针”的工厂函数:

template <class T, class A1>
std::shared_ptr<T> factory(A1&& a1)
{
    return std::shared_ptr<T>(new T(std::forward<A1>(a1)));
}

现已进入标准库的函数 forward 告诉编译器将实参视为右值引用,因此 T 的移动构造函数(而不是拷贝构造函数)会被调用,来窃取该参数。它本质上就是个右值引用的类型转换。

在 C++98 中,没有右值引用,这样的“智能指针”很难实现。在 C++11 中,解决方案就简单了 [Hinnant et al. 2006]:[^2]

template <class T>
class clone_ptr
{
private:
    T* ptr;
public:
    // ...
    clone_ptr(clone_ptr&& p)            // 移动构造函数
        : ptr(p.ptr)    // 拷贝数据的表示
    {
        p.ptr = 0;      // 把源数据的表示置空
    }
    clone_ptr& operator=(clone_ptr&& p) // 移动赋值
    {
        std::swap(ptr, p.ptr);
        return *this;   // 销毁目标的旧值
    }
};

很快,移动语义技术就被应用到了标准库的所有容器类上,像 vectorstringmapshared_ptrunique_ptr 的确智能,但它们仍然是指针。我更喜欢强调移动构造和移动赋值,它们使得(以句柄表示的)大型对象在作用域间能够高效移动。

右值引用的提案在委员会中涉险过关。有人认为右值引用和移动语义多半来不及进入 C++11,因为这些概念很新,而我们那时连合适的术语都没有。部分由于术语上的问题 [Miller 2010],右值引用这一术语在核心语言和标准库中的使用就有了分歧,从而使得标准草案中出现了不一致。在 2010 年 3 月的匹兹堡会议上,我参与了核心工作组(CWG)的讨论,在午饭休息的时间,在我看来“我们陷入了僵局,或者混乱之中,也许兼而有之”。我没有去吃午饭,而是对问题进行了分析,并得出结论,这里只涉及到两个基本概念:有标识符(identity),及可被移动。从这两个原语出发,我推导出了传统的左值和右值类别 [Barron et al. 1963],以及解决我们的定义问题所需要的三个新类别。在核心工作组回来之后,我提出了我的解决方案。它很快就得到了接受,这样我们就在 C++11 中保留了移动语义 [Stroustrup 2010a]。

4.2.4 资源管理指针

C++11 提供了“智能指针”(§4.2.4):

  • shared_ptr——代表共享所有权
  • unique_ptr——代表独占所有权(取代 C++98 中的 auto_ptr

添加这些表示所有权的资源管理“智能指针”对编程风格有很大的影响。对很多人来说,这意味着不再有资源泄漏,悬空指针的问题也显著减少。在自动化资源管理和减少裸指针使用的努力中,它们是最明显的部分了(§4.2.3)。

shared_ptr 是传统的计数指针:指向同一对象的所有指针共享一个计数器。当最后一个指向对象的共享指针被销毁时,被指向的对象也会被销毁。这是一种简单、通用且有效的垃圾收集形式。它能正确地处理非内存资源(§2.2.1)。为了正确处理环形数据结构,还需要有 weak_ptr;不过,这往往不是最好的做法。人们常常简单地使用 shared_ptr 来安全地从工厂函数返回数据:

shared_ptr<Blob> make_Blob(Args a)
{
    auto p = shared_ptr<Blob>(new Blob(a));
    // ... 把很多好东西填到 *p ...
    return p;
}

当把对象移出函数时,引用计数会从 1 变到 2 再变回 1。在多线程程序中,这通常是涉及到同步的缓慢操作。另外,粗率地使用和/或实现引用计数,会增加分配和回收的开销。

正如预期的那样,shared_ptr 很快就流行起来,并在有些地方被严重滥用。因此,后来我们提供了不引入额外开销的 unique_ptrunique_ptr 对它所指的对象拥有独占的所有权,并会在自身被销毁的时候把指向的对象也简单地 delete 掉。

unique_ptr<Blob> make_Blob(Args a)
{
    auto p = unique_ptr<Blob>(new Blob(a));
    // ... 把很多好东西填到 *p ...
    return p;
}

shared_ptrweak_ptr 是 Peter Dimov 的工作成果 [Dimov et al. 2003]。Howard Hinnant 贡献的 unique_ptr 是对 C++98 的 auto_ptr 的改进 [Hinnant et al. 2002]。考虑到 unique_ptrauto_ptr 的即插即用式的替代品,这提供了从标准中(最终)删除有缺陷的功能的难得机会。资源管理指针跟移动语义、完美转发及右值引用的工作密切相关(§4.2.3)。

资源管理指针被广泛地用于持有对象,以便异常(及类似的情况)不会导致资源泄漏(§2.2)。例如:

void old_use(Args a)
{
    auto q = new Blob(a);
    // ...
    if (foo) throw Bad();  // 会泄漏
    if (bar) return;       // 会泄漏
    // ...
    delete q;    // 容易忘
}

显式使用 newdelete 的旧方式容易出错,在现代 C++ 中已经不推荐使用(例如,C++ 核心指南(§10.6))。现在我们可以这样写:

void newer_use(Args a)
{
    auto p = unique_ptr<Blob>(new Blob(a));
    // ...
    if (foo) throw Bad();  // 不会泄漏
    if (bar) return;       // 不会泄漏
    // ...
}

这种写法更简短、更安全,迅速就流行开去。不过,“智能指针”仍然被过度使用:“它们的确智能,但它们仍然是指针。”除非我们确实需要指针,否则,简单地使用局部变量会更好:

void simplest_use(Args a)
{
    Blob b(a);
    // ...
    if (foo) throw Bad(); // 不会泄漏
    if (bar) return;      // 不会泄漏
    // ...
}

智能指针用于表示资源所有权的主要用途是面向对象编程,其中指针(或引用)用于访问对象,而对象的确切类型在编译时并不知道。

4.2.5 统一初始化

出于历史原因,C++ 有多种初始化的写法,而它们的语义有惊人的不同。

从 C 语言中,C++ 继承了三种初始化形式,并添加了第四种形式:

int x;              // 默认初始化(仅适用于静态变量)
int x = 7;          // 值初始化
int a[] = {7,8};    // 聚合初始化
string s;           // 由默认构造函数初始化
vector<int> v(10);  // 由构造函数初始化

用于初始化的概念既取决于要初始化的对象的类型,也取决于初始化的上下文。这是一团乱麻,而且人们也认识到这一点。比如,为什么可以用列表初始化内建数组,但却不能初始化 vector

int a[] = {7,8};        // 可以
vector<int> v = {7,8};  // 应该可以工作(显然,但是没有)

上一个例子令我非常不舒服,因为它违反了 C++ 的根本设计目标,即为内建类型和用户定义的类型提供同等的支持。特别是,因为对数组初始化有比 vector 更好的支持,这会鼓励人们使用容易出错的内建数组。

当 C++0x 的工作从 2002 年开始的时候,Daniel Gutson、Francis Glassborow、Alisdair Meredith、Bjarne Stroustrup 和 Gabriel Dos Reis 曾进行了许多讨论和提议,来解决其中一些问题。在 2005 年,Gabriel Dos Reis 和我提出了统一初始化的写法,该写法可用于每种类型,并且在程序中的任何地方都具有相同的含义 [Stroustrup and Dos Reis 2005b]。这种写法有望大大简化用户代码并消除许多不易察觉的错误。该表示法是基于使用花括号的列表写法。举例来说:

int a = {5};            // 内建类型
int a[] {7,8};          // 数组
vector<int> v = {7,8};  // 具有构造函数的用户定义的类型

花括号({})对于单个值是可选的,并且花括号初始化器列表之前的 = 也是可选的。为了统一起见,在许多 C++98 不允许使用花括号或者 = 初始化的地方都接受花括号样式的初始化:

int f(vector<int>);
int i = f({1,2,3});  // 函数参数

struct X {
    vector<int> v;
    int a[];
    X() : v{1,2}, a{3,4} {}  // 成员初始化器
    X(int);
    // ...
}

vector<int>* p = new vector<int>{1,2,3,4};  // new 表达式
X x {};  // 默认初始化

template<typename T> int foo(T);
int z = foo(X{1});  // 显式构造

其中许多的情形,例如为使用 new 创建的对象提供初始化器列表,干脆就没法使用以前的方式来完成。

可惜,对于这一理想,我们仅仅达到不完全的近似,我们有的方案只能算大致统一。有些人发现,使用 {…} 很别扭,除非 是同质对象的列表,而其他人则坚持 C 语言中对聚合和非聚合的区分,并且许多人担心没有显式类型标记的列表会导致歧义和错误。例如,以下写法被认为是危险的,不过最终还是被接受了:

struct S { string s; int i; };

S foo(S s)
{
    // ...
    return {string{"foo"},13};
}

S x = foo({string{"alpha"},12.3});

在一种情况下,对统一写法的追求被一种惯用法击败。考虑:

vector<int> v1(10);          // 10 个元素
vector<int> v2 {10};         // 10 个元素还是 1 个值为 10 的元素?
vector<int> v3 {1,2,3,4,5};  // 拥有 5 个元素的 vector

使用像 vector<int> v1(10) 的指定大小的初始化器的代码有数百万行,而从基本原则上来说,vector<int> v2 {10} 确实是模棱两可的。假如是在一门新的语言中,我不会使用普通的整数来表示大小,我会为此指定一种特定的类型(比如 SizeExtent);举例来说:

vector<int> v1 {Extent{10}};  // 10 个元素,默认值为 0
vector<int> v2 {10};          // 1 个元素,值为 10

但是,C++ 并不是一门新语言,因此我们决定,在构造函数中进行选择时优先选择初始化器列表解释。这使 vector<int> v2 {10} 成为具有一个元素的 vector,并且使 {…} 初始化器的解释保持一致。但是,当我们想要避免使用初始化器列表构造函数时,这就迫使我们使用 (…) 写法。

初始化的问题之一正在于,它无处不在,因此基本上所有程序和语言规则的问题都会在初始化上下文中体现出来。考虑:

int x = 7.2;  // 传统的初始化
int y {7.2};  // 花括号初始化

从大约 1974 年将浮点数引入 C 语言以来,x 的值就是 7;也就是说,7.2 被隐式截断,从而导致信息丢失。这是错误的来源。花括号初始化不允许窄化转换(此处为截断)。很好,但是升级旧代码变得更加困难:

double d = 7.2;
int x = d;   // 可以:截断
int y {d};   // 错误

这是一个常见问题的例子。人们想要一条简单的升级路径,但是除非需要做出一些努力和更改,否则一次非常简单的升级的结果是,旧的问题和错误得以保留。改善一门广泛使用的语言比我们一般想像的要难。

经过许多激烈的辩论和许多修改(并非其中每一项我都认为是改进),统一初始化在 2008 年被批准进入 C++0x [Stroustrup 2008b]。

与以往一样,写法是一个有争议的问题,但是最终我们同意有一个标准库类型的 initializer_list 用作初始化器列表构造函数的参数类型。举例来说:

template<typename T> class vector {
public:
    vector(initializer_list<T>);  // 初始化器列表构造函数
    // ...
};

vector<int> v3 {1,2,3,4,5};  // 具有 5 个元素的 vector

令人遗憾的是,统一初始化({}——初始化)的使用并不像我期望的那样广泛。人们似乎更喜欢熟悉的写法和熟悉的缺陷。我似乎陷入了 N+1 问题:你有 N 个不兼容和不完整的解决方案,因此添加了一个新的更好的解决方案。不幸的是,原始的 N 个解决方案并没有消失,所以你现在有了 N+1 个解决方案。公平地说,有一些细微的问题超出了本文的范围,这些问题只是在 C++14、C++17 和 C++20 中被逐步补救。我的印象是,泛型编程和对更简洁的表示法的推进正在慢慢增加统一初始化的吸引力。所有标准库容器(如 vector)都有初始化器列表构造函数。

4.2.6 nullptr

在 C 和 C++ 中,如果将字面量 0 赋值给指针或与指针比较时它表示空指针。更令人困惑的是,如果将任何求值为零的整数常量表达式赋值给指针或与指针比较时它也表示空指针。例如:

int* p = 99-55-44; // 空指针
int* q = 2;        // 错误:2 是一个 int,而不是一个指针

这使很多人感到烦恼和困惑,因此有一个标准库宏 NULL(从 C 中采用),它在标准 C++ 中定义为 0。某些编译器会对 int* p = 0 提出警告;但是我们仍然没法为函数针对指针和整数重载而避免 0 的歧义。

这很容易通过给空指针命名来解决,但是不知何故没有人能提出一份人们能达成一致的提议。在 2003 年的某个时候,我正通过电话参加一个会议,讨论如何给空指针命名。如 NULLnullnilnullptr0p 等建议名都是备选方案。照旧,那些简短而“漂亮”的名字已经被使用了成千上万次,因此不能在不破坏数百万行代码的情况下使用。我听了数十次这样的讨论,有点厌烦了,只是在似听非听。人们说到 null pointer, null ptr, nullputter 的变体。我醒过来说:“你们都在说 nullptr。我想我没有在代码中看到过它”。

Herb Sutter 和我写下了该提案 [Sutter and Stroustrup 2003],该提案在 2007 年相对容易地通过了(仅仅进行了四次小修订后),所以现在我们可以说:

int p0 = nullptr;
int* p1 = 99-55-44;  // 可以,为了兼容性
int* p2 = NULL;      // 可以,为了兼容性

int f(char*);
int f(int);

int x1 = f(nullptr); // f(char*)
int x2 = f(0);       // f(int)

我对 nullptr 的发音是“null pointer”。

我仍然认为如能将宏 NULL 定义为 nullptr 可以消除一类重要的问题,但委员会认为这一改变过于激进。

4.2.7 constexpr 函数

在 2003 年,Gabriel Dos Reis 和我提出了用于在 C++ 中进行常量表达式求值的一种根本不同且明显更好的机制 [Dos Reis 2003]。人们当时使用(无类型的)宏和贫乏的 C 语言定义的常量表达式。另一些人则开始使用模板元编程来计算值(§10.5.2)。“这既乏味又容易出错” [Dos Reis and Stroustrup 2010]。我们的目标是

  • 让编译期计算达到类型安全
  • 一般来说,通过将计算移至编译期来提高效率
  • 支持嵌入式系统编程(尤其是 ROM)
  • 直接支持元编程(而非模板元编程(§10.5.2))
  • 让编译期编程与“普通编程”非常相似

这个想法是简单的:允许在常量表达式中使用以 constexpr 为前缀的函数,还允许在常量表达式中使用简单用户定义类型,叫字面量类型。字面量类型基本上是一种所有运算都是 constexpr 的类型。

考虑这样一个应用,为了提高效率、支持 ROM 或可靠性,我们想使用一套单位制 [Dos Reis and Stroustrup 2010]:

struct LengthInKM {
    constexpr explicit LengthInKM(double d) : val(d) { }
    constexpr double getValue() { return val; }
private:
    double val;
};

struct LengthInMile {
    constexpr explicit LengthInMile(double d) : val(d) { }
    constexpr double getValue() { return val; }
    constexpr operator LengthInKM() { return LengthInKM(1.609344 * val); }
private:
    double val;
};

有了这些,我们可以制作一个常量表,而不必担心单位错误或转换错误:

LengthInKM marks[] = { LengthInMile(2.3), LengthInMile(0.76) };

传统的解决方案要么需要更多的运行时间,要么需要程序员在草稿纸上算好值。我对单位制的兴趣是由 1999 年的火星气候探测者号的失事激发的,事故原因是单位不匹配没有被发现 [Stephenson et al. 1999]。

constexpr 函数可以在编译期进行求值,因此它无法访问非本地对象(它们在编译时还不存在),因此 C++ 获得了一种纯函数。

为什么我们要求程序员应该使用 constexpr 来标记可以在编译期执行的函数?原则上,编译器可以弄清楚在编译期可以计算出什么,但是如果没有标注,用户将受制于各种编译器的聪明程度,并且编译器需要将所有函数体“永远”保留下来,以备常量表达式在求值时要用到它们。我们选择 constexpr 一词是因为它足够好记,但又“足够奇怪”而不会破坏现有代码。

在某些地方,C++ 需要常量表达式(例如,数组边界和 case 标签)。另外,我们可以通过将变量声明为 constexpr 来要求它在编译期被初始化:

constexpr LengthInKM marks[] = { LengthInMile(2.3), LengthInMile(0.76) };

void f(int x)
{
    int y1 = x;
    constexpr int y2 = x;   // 错误:x 不是一个常量
    constexpr int y3 = 77;  // 正确
}

早期的讨论集中在性能和嵌入式系统的简单示例上。直到后来(大约从 2015 年开始),constexpr 函数才成为元编程的主要支柱(§10.5.2)。C++14 允许在 constexpr 函数中使用局部变量,从而支持了循环;在此之前,它们必须是纯函数式的。C++20(最终,在首次提出后约 10 年)允许将字面类型用作值模板参数类型 [Maurer 2012]。因此,C++20 将非常接近最初的目标(1979 年),即在可以使用内建类型的地方也都可以使用用户定义的类型(§2.1)。

constexpr 函数很快变得非常流行。它们遍布于 C++14、C++17 和 C++20 标准库,并且不断有相关建议,以求在 constexpr 函数中允许更多的语言构造、将 constexpr 应用于标准库中的更多函数,以及为编译期求值提供更多支持(§9.3.3)。

但是,constexpr 函数进入标准并不容易。它们一再被认为是无用和无法实现的。实现 constexpr 函数显然需要改进较老的编译器,但是很快,所有主要编译器的作者都证明了“无法实现”的说法是错误的。关于 constexpr 的讨论是有史以来最激烈、最不愉快的。让初始版本通过标准化流程 [Dos Reis and Stroustrup 2007] 花费了四年的时间,而完整地完成又花了十二年的时间。

4.2.8 用户定义字面量

“用户定义字面量”是一个非常小的功能。但是,它合乎我们的总体目标,即让用户定义类型得到和内建类型同等的支持。内建类型有字面量,例如,10 是整数,10.9 是浮点数。我试图说服人们,对于用户定义类型,显式地使用构造函数是等价的方式;举例来说,complex<double>(1.2,3.4) 就是 complex 的字面量等价形式。然而,许多人认为这还不够好:写法并不传统,而且不能保证构造函数在编译期被求值(尽管这还是早年间的事)。对于 complex,人们想要 1.2+3.4i

与其他问题相比,这似乎并不重要,所以几十年来什么都没有发生。2006 年的一天,David Vandevoorde(EDG)、Mike Wong(IBM)和我在柏林的一家中餐馆吃了一顿丰盛的晚餐。我们在餐桌边聊起了天,于是一个设计浮现在一张餐巾纸上。这个讨论的起因是 IBM 的一项十进制浮点提案中对后缀的需求,该提案最终成了一个独立的国际标准 [Klarer 2007]。在大改后,该设计在 2008 年成为用户定义字面量(通常称为 UDL)[McIntosh et al. 2008]。当时让 UDL 变得有趣的重要发展是 constexpr 提案的进展(§4.2.7)。有了它,我们可以保证编译期求值。

照例,找到一种可接受的写法是一个问题。我们决定使用晦涩的 operator"" 作为字面量运算符(literal operator)的写法是可以接受的,毕竟 "" 是一个字面量。然后,""x 是后面跟着后缀 x 的字面量。这样一来,要定义一个用于 complex 数的 Imaginary 类型,我们可以定义:

constexpr Imaginary operator""i(long double x) { return Imaginary(x); }

现在,3.4i 是一个 Imaginary,而 1.2+3.4icomplex<double>(1.2,3.4)。任务完成!

这一功能的语言技术细节相当古怪,但我认为对于一个相对很少使用的特性来说,这是合理的。即使在大量使用 UDL 时,字面量运算符的定义也很少。最重要的是后缀的优雅和易用性。对于许多类型,重要的是可以在编译时完成从内建类型到用户定义类型的转换。

很自然,人们使用 UDL 来定义许多有用的类型的字面量,有些来自标准库(例如,s 代表 秒,s 代表 std::string)。关于支持二进制字面量的讨论,Peter Sommerlad(HSR)提出了我认为的“最佳滥用规则”奖的候选方案:适当地定义 operator""_01(long int),于是 101010_01 就成了个二进制字面量!当惊讶和笑声平息下来后,委员会决定在语言本身里定义二进制字面量并使用 0b 作为前缀,表示“binary”(例如 0b101010),类似于使用 0x 表示“hexadecimal”(例如 0xDEADBEEF)。

4.2.9 原始字符串字面量

这是一个罕见的简单特性,它的唯一目的是为容易出错的写法提供一种替代方法。和 C 一样,C++ 使用反斜杠作为转义字符。这意味着要在字符串字面量中表示反斜杠,你需要使用双反斜杠(\\),当你想在字符串中使用双引号时,你需要使用 \"。然而,通常的正则表达式模式广泛使用反斜杠和双引号,所以模式很快变得混乱和容易出错。考虑一个简单的例子(美国邮政编码):

regex pattern1 {"\\w{2}\\s*\\d{5}(-\\d{4})?"}; // 普通字符串字面量

regex pattern2 {R"(\w{2}\s*\d{5}(-\d{4})?)"};  // 原始字符串字面量

这两种模式是相同的。原始字符串字面量 R"(…)" 的括号可以精调以容纳更复杂的模式,但是当你使用正则表达式(§4.6)时,最简单的版本就足够了,而且非常方便。当然,提供原始字符串字面量是一个小细节,但是(类似于数字分隔符(§5.1)),深受需要大量使用字面量的人们的喜爱。

原始字符串字面量是 Beman Dawes 在 2006 年 [Dawes 2006] 基于使用 Boost.Regex [Maddock 2002] 的经验而提出来的。

4.2.10 属性

在程序中,属性提供了一种将本质上任意的信息与程序中的实体相关联的方法。例如:

[[noreturn]] void forever()
{
    for (;;) {
        do_work();
        wait(10s);
    }
}

属性 [[noreturn]] 通知编译器或其他工具 forever() 永远不会返回,这样它就可以抑制关于缺少返回的警告。属性用 [[…]] 括起来。

属性最早是在 2007 年由库工作组的负责人 Alisdair Meredith [Meredith 2007] 提出来的,目的是消除专有属性写法(例如 __declspec__attribute__)之间的不兼容性,这种不兼容性会使库实现更加复杂。对此,Jens Maurer 和 Michael Wong 对问题进行了分析,并提出了 [[…]] 语法,方案是基于 Michael 为 IBM 的 XL 编译器所做的实现 [Maurer and Wong 2007]。除了对大量不可移植的实践进行标准化之外,这还将允许用更少的关键字来完成语言扩展,而新的关键字总是有争议的。

该提案提到了可能的使用:覆盖虚函数的明确语法,动态库,用户控制的垃圾收集,线程本地存储,控制对齐,标识“简旧数据”(POD)类,default 和 delete 的函数,强类型枚举,强类型 typedef,无副作用的纯函数,final 覆盖,密封类,对并发性的细粒度控制,运行期反射支持,及轻量级契约编程主持。在早期的讨论中还提到了更多。

“属性”当然是一个使某些事情变得更简单的特性,但我不确定它是否鼓励了良好的设计,或者它简化的“事情”总是能产生最大的好处。我可以想象属性打开了闸门,放进来一大堆不相关的、不太为人们了解的、次要的特性。任何人都可以为编译器添加一个属性,并游说各处采用它,而不是向 WG21 提出一个特性。许多程序员就是喜欢这些小特性。不需要引入关键字和修改语法能降低门槛。但同样能造成不可避免的对特性交互关注度不够,造成重叠而不兼容的类似特性出现在不同的编译器中。这种情况在私有扩展中已经发生过了,但我认为它们是不可避免的、局部的,而且往往是暂时的。

为了限制潜在的损害,我们决定属性应该意味着不改变程序的语义。也就是说,忽略属性,编译器不会有任何危害。多年来,这条“规则”几乎奏效。大多数标准属性——尽管不是全部——没有语义效果,即使它们有助于优化和错误检测。

最后,大多数最初那些建议的对属性的使用都通过普通的语法和语言规则来解决。

C++11 增加了标准属性 [[noreturn]][[carries_dependency]]

C++17 增加了 [[fallthrough]][[nodiscard]][[maybe_unused]]

C++20 增加了 [[likely]][[unlikely]][[deprecated(message)]][[no_unique_address]][[using: …]]

我仍然看到属性扩散是一个潜在的风险,但到目前为止,水闸还没有打开。C++ 标准库大量使用了属性;[[nodiscard]] 属性尤其受欢迎,特别用来防止由于没有使用本身是资源句柄的返回值而造成的潜在资源泄漏。

属性语法被用于(失败的)C++20 契约设计(§9.6.1)。

4.2.11 垃圾收集

从 C++ 的早期开始,人们就考虑可选的垃圾收集(对于“可选”有各种定义)[Stroustrup 1993, 2007]。经过一番争论,C++11 为 Mike Spertus 和 Hans-J. Boehm 设计的保守垃圾收集器提供了一个接口 [Boehm and Spertus 2005; Boehm et al. 2008]。然而,很少有人留意到这一点,更少有人使用了垃圾收集(尽管有好的收集器可用)。设计的方法是 [Boehm et al. 2008]:

同时支持垃圾收集实现和基于可达性的泄漏检测器。这是通过把“隐藏指针”的程序定为未定义行为来实现的;举例来说,将指针与另一个值进行异或运算,然后将它转换回普通指针并对其进行解引用就是一种隐藏行为。

这项工作造福了 C++ 语义的精确规范,并且 C++ 中也存在一些对垃圾收集的使用(例如,在 Macaulay2 中 [Eisenbud et al. 2001; Macaulay2 2005–2020])。然而,垃圾收集器不处理非内存资源,而 C++ 社区通常选择使用资源管理指针(§4.2.4)和 RAII(§2.2.1)二者的组合。

4.3 C++11:改进对泛型编程的支持

泛型编程(及其产物模板元编程(§10.5.2))在 C++ 98 中迅速轻松地获得了成功。它的使用对语言造成了严重的压力,而不充分的语言支持导致了巴洛克式矫揉造作的编程技巧和可怕的错误消息。这证明了泛型编程和元编程的实用性,许多明智的程序员为了获得其好处而甘愿承受其痛苦。这些好处是

  • 超越以 C 风格或面向对象风格所可能获得的灵活性
  • 更清晰的代码
  • 更细的静态类型检查粒度
  • 效率(主要来自内联、让编译器同时查看多处的源代码,以及更好的类型检查)

C++11 中支持泛型编程的主要新特性有:

在 C++11 中,概念本应是改进支持泛型编程的核心,但这并没有发生(§6.2.6)。我们不得不等到 C++20(§6.4)。

4.3.1 lambda 表达式

BCPL 允许将代码块作为表达式,但是为了节省编译器中的空间,Dennis Ritchie 没有在 C 中采用这个特性。我在这点上遵循了 C 的做法,但是添加了 inline 函数,从而(重新)得到在没有函数调用的开销下执行代码的能力。不过,这仍然不能提供以下能力

  • 把代码写在需要它的那个准确位置上(通常作为函数参数)。
  • 从代码内部访问代码的上下文。

在 C++98 的开发过程中,曾有人提议使用局部函数来解决第二点,但被投票否决了,因为这可能成为缺陷的来源。

C++ 不允许在函数内部定义函数,而是依赖于在类内部定义的函数。这使得函数的上下文可以表示为类成员,因而函数对象变得非常流行。函数对象只是一个带有调用运算符(operator()())的类。这曾是一种非常高效和有效的技术,我(和其他人)认为有名字的对象比未命名的操作更清晰。然而,只有当我们可以在某样东西使用的上下文之外给它一个合理的名称,特别是如果它会被使用多次时,这种清晰度上的优势才会表现出来。

2002 年,Jaakko Järvi 和 Gary Powell 编写了 Boost.Lambda 库 [Järvi and Powell 2002] 这让我们可以写出这样的东西

find_if(v.begin(), v.end(), _1<i);  // 查找值小于 i 的元素

这里,_1 是代码片段 _1<i 的某个第一个实参的名称,而 i 是表达式所在作用域(enclosing scope)中的一个变量。_1<i 展开为一个函数对象,其中 i 被绑定到一个引用,_1 成为 operator()() 的实参:

struct Less_than {
    int& i;
    Less_than(int& ii) :i(ii) {}  // 绑定到 i
    bool operator()(int x) { return x<i; }  // 跟参数比较
}

lambda 表达式库是早期模板元编程的典范(§10.5.2),非常方便和流行。不幸的是,它的效率并不特别高。多年来,我追踪了它相对于手工编码的同等实现的性能,发现它的开销是后者的 2.5 倍且这种差距相当一致。我不能推荐一种方便但却很慢的东西。这样做会损害 C++ 作为产生高效代码的语言的声誉。显然,这种慢在一定程度上是由于优化不当造成的,但出于这个和其他原因,我们有一群人在 Jaakko Järvi 领导下决定将 lambda 表达式作为一种语言特性 [Willcock et al. 2006] 来提出。举例来说:

template<typename Oper>
void g(Oper op)
{
    int xx = op(7);
    // ...
}

void f()
{
    int y = 3;
    g(<>(int x) -> int {return x + y;});  // 以 lambda 表达式作为参数调用 g()
}

这里,xx 会变成 3+7

<> 是 lambda 表达式引导器。我们不敢提出一个新的关键词。

这一提议引起了相当多的兴奋和许多热烈的讨论:

  • 语法应该是富有表现力的还是简洁的?
  • lambda 表达式可以从哪个作用域引用什么名字?[Crowl 2009]。
  • 从 lambda 表达式生成的函数对象应该是可变的吗?默认情况下不是。
  • lambda 表达式能是多态的吗?到 C++14 才可以(§5.4)。
  • lambda 表达式的类型是什么?独有的类型,除非它基本上是一个局部函数。
  • lambda 表达式可以有名字吗?不。如果你需要一个名字,就把它赋给一个变量。
  • 名称是由值绑定还是由引用绑定?你来选择。
  • 变量可以移动到 lambda 表达式中(相对于复制)吗?到 C++14 才可以(§5)。
  • 语法是否会与各种非标准扩展发生冲突?(不严重)。

到 2009 年 lambda 表达式被批准时,语法已经发生了变化,变得更加合乎惯例 [Vandevoorde 2009]:

void abssort(float* x, unsigned N)
{
    std::sort(x, x+N,
        [](float a, float b) { return std::abs(a) < std::abs(b); }
             );
}

<> 切换到 [] 是由 Herb Sutter 建议并由 Jonathan Caves 实现的。这种变化在一定程度上是由于需要一种简单的方法来指定 lambda 表达式可以使用周围作用域中的哪些名称。Herb Sutter 回忆道:

我的并行算法项目需要 lambda 表达式,这是我的动机……看到 EWG 所采用的 lambda 表达式那实在丑到爆的用法,以及从语法一致性/干净性的角度来看极为糟糕的设计(例如,捕获出现在两个分开的位置,语法元素使用不一致,顺序错误——因为“构造函数”元素应该先出现然后才是调用“运算符”元素,以及其他一些小问题)。

默认情况下,lambda 表达式不能引用在本地环境的名字,所以它们只是普通的函数。然而,我们可以指定 lambda 表达式应该从它的环境中“捕获”一些或所有的变量。回调是 lambda 表达式的一个常见用例,因为操作通常只需要写一次,并且操作会需要安装该回调的代码上下文中的一些信息。考虑:

void test()
{
    string s;
    // ... 为 s 计算一个合适的值 ...
    w.foo_callback([&s](int i){ do_foo(i,s); });
    w.bar_callback([=s](double d){ return do_bar(d,s); });
}

[&s] 表示 do_foo(i,s) 可以使用 ss 通过引用来传递(“捕获”)。[=s] 表示 do_bar(d,s) 可以使用 ss 是通过值传递的。如果回调函数在与 test 相同的线程上被调用,[&s] 捕获可能效率更高,因为 s 没有被复制。如果回调函数在不同的线程上被调用,[&s] 捕获可能是一个灾难,因为 s 在被使用之前可能会超出作用域;这种情况下,我们想要一份副本。一个 [=] 捕获列表意味着“将所有局部变量复制到 lambda 表达式中”。而一个 [&] 捕获列表意味着“lambda 表达式可以通过引用指代所有局部变量”,并意味着 lambda 表达式可以简单地实现为一个局部函数。事实证明,捕获机制的灵活性非常有价值。捕获机制允许控制可以从 lambda 表达式引用哪些名称,以及如何引用。这是对 1990 年代人们担心局部函数容易出错的一种回答。

lambda 表达式的实现基本上是编译器构建一个合适的函数对象并传递它。捕获的局部变量成为由构造函数初始化的成员,lambda 表达式的代码成为函数对象的调用运算符。例如,bar_callback 变成:

struct __XYZ {
    string s;
    __XYZ(const string& ss) : s{ss} {}
    int operator()(double d) { return do_bar(d,s); }
};

lambda 表达式的返回类型可以从它的返回语句推导出来。如果没有 return 语句,lambda 表达式就不会返回任何东西。

我把 lambda 表达式归类为对泛型编程的支持,因为最常见的用途之一——也是主要的动机——是用作 STL 算法的参数:

// 按降序排序:
sort(v.begin(),v.end(),[](int x, int y) { return x>y; });

因此,lambda 表达式显著地增加了泛型编程的吸引力。

在 C++11 之后,C++14 添加了泛型 lambda 表达式(§5.4)和移动捕获(§5)。

4.3.2 变参模板

2004 年,Douglas Gregor、Jaakko Järvi 和 Gary Powell(当时都在印第安纳大学)提出了变参模板 [Gregor et al. 2004] 的特性,用来:

直接解决两个问题:

  • 不能实例化包含任意长度参数列表的类模板和函数模板。
  • 不能以类型安全的方式传递任意个参数给某个函数

这些都是重要目标,但我起初发现其解决方案过于复杂,记法太过晦涩,按我的品味其编程风格又太递归。不过在 Douglas Gregor 于 2004 年做的精彩演示之后,我改变了主意并全力支持这项提案,帮助它在委员会顺利通过。我被说服的部分原因是变参模板和当时的变通方案在编译时间上的对比测量。编译时间过长的问题随模板元编程的大量使用(§10.5.2)变得越来越严重,对此变参模板是一项重大(有时是 20 倍)改进。可惜,变参模板越变越流行,也成了 C++ 标准库中必需的部分,以至编译时间的问题又出现了。不过,成功的惩罚(在当时)还是在遥远的将来。

变参模板的基本思路是,递归构造一个参数包,然后在另一个递归过程来使用它。递归技巧是必须的,因为参数包中的每个元素都有它自己的类型(和大小)。

考虑 printf 的一种实现,能够处理可由标准库 iostream 的输出运算符 << 输出的每种类型 [Gregor 2006]:

为了创建类型安全的 printf(),我们采用以下策略:写出字符串直至碰到第一个格式说明符,按格式打印相应的值,然后递归调用 printf() 来打印字符串剩下部分和其余各值。

template<typename T, typename... Args>
void printf(const char* s, const T& value, const Args&... args)
{
    while (*s) {
        if (*s == '%' && *++s != '%') { // 忽略 % 后的字符:
                                        // 我们已经知道要打印的类型了!
            std::cout << value;
            return printf(++s, args...);
        }
        std::cout << *s++;
    }
    throw std::runtime error("extra arguments provided to printf");
}

这里 <typename T, typename... Args> 指定了一个传统的列表,有头(T)和尾(Args)。每次调用会处理头,然后以尾为参数来调用自身。普通字符会被简单打印,而格式符 % 则表示某个参数要被打印了。Doug(当时他住在印第安纳州)提供了一个测试例子:

const char* msg = "The value of %s is about %g (unless you live in %s).\n";
printf(msg, std::string("pi"), 3.14159, "Indiana");

结果会打印

The value of pi is about 3.14159 (unless you live in Indiana).

这个实现的好处之一是,和标准的 printf 不同,用户定义的类型也和内建类型一样会得到正确处理。通过使用 << 也避免了类型指示符和参数类型之间的不匹配,比如 printf("%g %c","Hello",7.2)

这个 printf 所展示的技巧是 C++20 format§9.3.7)的基础之一。

变参模板的缺点是容易导致代码膨胀,因为 N 个参数意味着模板的 N 次实例化。

4.3.3 别名

C 定义类型别名的机制是靠 typedef。例如:

typedef double (*pf)(int);   // pf 是一个函数指针,该函数接受一个 int
                             // 返回一个 double

这是有点诘屈聱牙,但是类型别名在 C 和 C++ 代码中非常有用,使用非常普遍。从最初有 C++ 模板的时候,人们就一直考虑是否可以有 typedef 模板;如果可以,它们应该是什么样子。2002 年时,Herb Sutter 提出一个方案 [Sutter 2002]:

template<typename A, typename B> class X { /* ... */ };
template<typename T> typedef X<T,int> Xi;  // 定义别名
Xi<double> Ddi;                            // 相当于 X<double, int>

在此基础之上,又经历了冗长的邮件列表讨论,Gabriel Dos Reis(当时在法国国立计算机及自动化研究院)和 Matt Marcus(Adobe)解决了特化相关的若干棘手问题,并引入 David Vandevoorde 称之为别名模板的简化语法 [Dos Reis and Marcus 2003]。例如:

template<typename T, typename A> class MyVector { /* ... */};
template<typename T> using Vec = MyVector<T, MyAlloc<T> >;

其中的 using 语法,即要引入的名字总是出现在前面,则是我的建议。

我和 Gabriel Dos Reis 一道把这个特性推广成一个(几乎)完整的别名机制,并最终得到接受 [Stroustrup and Dos Reis 2003c]。即便不涉及模板,它也给了人们一种记法上的选择:

typedef double (*analysis_fp)(const vector<Student_info>&);

using analysis_fb = double (*)(const vector<Student_info>&);

类型和模板别名是某些最有效的零开销抽象及模块化技巧的关键。别名让用户能够使用一套标准的名字而同时让各种实现使用各自(不同)的实现技巧和名字。这样就可以在拥有零开销抽象的同时保持方便的用户接口。考虑某通讯库(使用了 Concepts TS [Sutton 2017] 和 C++20 的简化记法)中的一个实例:

template<InputTransport Transport, MessageDecoder MessageAdapter>
class InputChannel {
public:
    using InputMessage = MessageAdapter::InputMessage<Transport::InputBuffer>;
    using MessageCallback = function<void(InputMessage&&)>;
    using ErrorCallback = function<void(const error_code&)>;
    // ...
};

概念和别名对于规模化地管理这样的组合极有价值。

InputChannel 的用户接口主要由三个别名组成,InputMessageMessageCallbackErrorCallback,它们由模板的参数初始化而来。

InputChannel 需要初始化它的传输层,该传输层由一个 Transport 对象表示。然而,InputChannel 不应该知道传输层的实现细节,所以它不应直接初始化它的 Transport 成员。变参模板(§4.3.2)就派上了用场:

template<InputTransport Transport, MesssageDecoder MessageAdapter>
class InputChannel {
public:
    template<typename... TransportArgs>
        InputChannel(TransportArgs&&... transportArgs)
            : _transport {forward<TransportArgs>(transportArgs)... }
        {}
    // ...
    Transport _transport;
}

如果没有变参模板,就得定义出一个通用接口来初始化传输层,或者得把传输层暴露给用户。

这个漂亮的例子展示了如何把 C++11 的特性(加上概念)组合起来以优雅的零开销方案解决一个困难问题。

4.3.4 tuple

C++98 有个 pair<T,U> 模板;它主要用来返回成对的值,比如两个迭代器或者一个指针加上一个成功标志。2002 年时,Jaakko Järvi 在参考 Haskell、ML、Python 和 Eiffel 后,提议把这个思路进一步推广,变成 tuple(元组)[Järvi 2002]:

元组是大小固定而成员类型可以不同的容器。作为一种通用的辅助工具,它们增加了语言的表现力。举几个元组类型一般用法的例子:

  • 作为返回类型,用于需要超过一个返回类型的函数
  • 编组相关的类型或对象(如参数列表中的各条目)成为单个条目
  • 同时赋多个值

对于特定的设计意图,定义一个类,并在里面对成员进行合理命名、清晰表述成员间的语义关系,通常会是最好的做法。Alisdair Meredith 在委员会内力陈以上观点,劝阻在接口中过度使用未命名的类型。然而,当撰写泛型代码时,把多个值打包到一个元组中作为一个实体进行处理往往能简化实现。元组对于不值得命名、不值得设计类的一些中间情况特别有用。

比如,考虑一个只需返回三个值的矩阵分解:

auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
    Matrix U, V;
    Vector S;
    // ...
    return make_tuple(U,S,V);
};

void use()
{
    Matrix A, U, V;
    Vector S;
    // ...
    tie(U,S,V) = SVD(A); // 使用元组形式
}

在这里,make_tuple() 是标准库函数,可以从参数中推导元素类型来构造 tupletie() 是标准库函数,可以把 tuple 的成员赋给有名字的变量。

使用 C++17 的结构化绑定(§8.2),上面例子可简化为:

auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
    Matrix U, V;
    Vector S;
    // ...
    return {U,S,V};
};

void use()
{
    Matrix A;
    // ...
    auto [U,S,V] = SVD(A); // 使用元组形式和结构化绑定
}

进一步的记法简化被提议加入 C++20 [Spertus 2018],但没来得及成功通过:

tuple SVD(const Matrix& A) // 从返回语句中推导出元组模板参数
{
    Matrix U, V;
    Vector S;
    // ...
    return {U,S,V};
};

为什么 tuple 不是语言特性?我不记得当时有人这么问过,尽管一定有人想到过这一点。长期以来(自 1979 年),我们的策略就是,如果能合理地将新特性以库的形式加入 C++,就不要以语言特性加入;如果不能,就要改进抽象机制使其成为可能。这一策略有显而易见的优势:

  • 通常对一个库做试验比对一个语言特性做试验更容易,这样我们就更快地得到更好的反馈。
  • 库可以早在所有编译器升级到支持新特性之前就得到严肃使用。
  • 抽象机制(类,模板等)上的改进,能在眼前问题之外提供帮助。

tuple 以 Boost.Tuple 为基础构建,其实现之巧妙也足以让众人引以为傲。在这一特性上,并没有出现运行期效率方面的理由,使我们去偏向一个语言实现而不是库实现。这让人颇为敬佩。

参数包就是一个拥有编译器支持接口的元组的例子(§4.3.2)。

元组大量用于 C++ 和其他语言(例如 Python)交互的程序库里。

4.4 C++11:提高静态类型安全

依赖静态类型安全有两大好处:

  • 明确意图
    • 帮助程序员直接表达想法
    • 帮助编译器捕获更多错误
  • 帮助编译器生成更好的代码。

第二点是第一点的结果。受 Simula 的启发,我对 C++ 的目标是要提供一个灵活可扩展的静态类型系统。目的不仅是类型安全,还要能够直接表达细粒度的区别,例如物理单位检查(§4.2.7)。一段只用了内建类型如整型和浮点型写成的程序,也算是类型安全但却没有由此带来特别的安全优势。那样的代码没有直接表达应用中的概念。特别需要指出,int 或者 string 几乎可以表达任何东西,所以传递这样的值就完全没有给出被传递值的任何语义信息。

C++11 中与类型安全直接相关的改进有:

  • 对于线程和锁的类型安全接口——避免 POSIX 和 Windows 在并发代码中对 void** 及宏的依赖(§4.1.2)
  • 范围 for——避免错误地指定范围(§4.2.2
  • 移动语义——解决指针的过度使用问题(§4.2.3
  • 资源管理指针(unique_ptrshared_ptr§4.2.4))
  • 统一初始化——让初始化更通用,更一致,更安全(§4.2.5
  • constexpr——消除多处(无类型和无作用域的)宏的使用(§4.2.7
  • 用户定义的字面量——让用户定义类型更像内建类型(§4.2.8
  • enum class——消除一些涉及整型常量的弱类型做法
  • std::array——避免内建数组不安全地“退化”成指针

委员会一直收到建议,应当通过禁止不安全特性(例如,废弃像内建数组和类型转换这样的 C 风格特性)来改善类型安全。然而,移除特性(“取缔”它们)的尝试一再失败,因为用户无视移除的警告并坚持要求实现的提供者继续支持这些特性。一个更可行的方式似乎是给用户提供使用指南和实施指南的手段,同时保持标准本身继续和先前的版本兼容(§10.6)。

4.5 C++11:支持对库的开发

设计 C++ 基础库,往往要在性能和易用性方面同 C++ 及其他语言的内置功能。这时,查找规则、重载决策、访问控制、模板实例化规则等特性之中的微妙之处会组合起来,产生强大的表达能力,但同时也暴露出可怕的复杂性。

4.5.1 实现技巧

有些实现技巧实属“黑魔法”,不应当暴露给非专家。大部分程序员可以愉快地编写多年好的 C++ 代码,而不用了解这些复杂手段和神秘技巧。遗憾的是,初学者们一拥而上去研究这些最可怕的特殊代码,并从给别人(经常是错误地)解释它们的过程中得到巨大的自豪感。博主和演讲者们通过显摆令人提心吊胆的例子抬高他们的名望。这是 C++ 语言复杂性名声的一个主要来源。在其他语言中,要么不提供这样的优化机会,要么手段被藏在了优化器内部。

我不能在此深入细节,就只提一个技巧,它在 C++11 的发展中作为关键技巧出现,并在基于模板的库(包括 C++ 标准库)中广为使用。它以奇怪的缩写为人所知:SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)。

你如何表达一个当且仅当某个谓词为真时才有的操作?概念为 C++20 提供了这样的支持(GCC 自 2015 年开始支持),但在 21 世纪早期,人们不得不依赖于晦涩的语言规则。例如:

template<typename T, typename U>
struct pair {
    T first;
    U second;
    // ...
    enable_if<is_copy_assignable<T>::value
              && is_copy_assignable<U>::value,pair&>::type
        operator=(const pair&);
    //...
};

这样,当且仅当 pair 的两个成员都有拷贝赋值操作时 pair 才有拷贝赋值操作。这超乎寻常的丑陋,但它对于定义和实现基础库也超乎寻常的有用——在概念还没有出现时。

要点在于,如果成员都有拷贝赋值,enable_if<…,pair&>::type 会成为一个普通的 pair&,否则它的实例化就会失败(因为 enable_if 没有为赋值提供一个返回类型)。这里 SFINAE 就起作用了:替换失败不是错误;失败的结果就如同整条声明不曾出现一样。

这里的 is_copy_assignable 是一个 type trait(类型特征),C++11 提供了数十个这样的特征以便程序员在编译期询问类型的属性。

enable_if 元函数由 Boost 开创并成为 C++11 的一部分。一个大致合理的实现:

template<bool B, typename T = void>
struct enable_if {}; // false 的情况:里面没有 type

template<typename T>
struct enable_if<true, T> { typedef T type; }; // type 是 T

SFINAE 的精确规则非常微妙而难以驾驭,但是在用户的不断压力下,它们在 C++11 的发展过程中变得越来越简单和通用。SFINAE 的一个附带收获是,它从内部显著改善了编译器,因为编译器必须能够从失败的模板实例化中进行无副作用的回退。这就大大阻止了编译器对非本地状态的使用。

4.5.2 元编程支持

二十一世纪的头十年对于 C++ 元编程来说有点像是无法无天的美国西部拓荒时代,新的技巧和应用在仅有基本模板机制支持的情况下被不断尝试。那些基本机制被反复使用到令人痛苦。错误信息可谓糟糕透顶,编译时间经常奇慢无比,编译器资源(如内存、递归深度和标识符长度)会轻易耗尽。同时,人们纷纷重新发现同样的问题,并重新发明一些基本技巧。显然,我们需要更好的支持。改进尝试采用了两条(至少理论上)互补的路径:

  • 语言:概念(§6),编译期函数(§4.2.7),lambda 表达式(§4.3.1),模板别名(§4.3.3),以及更精确的模板实例化规范(§4.5.1)。
  • 标准库tuple§4.3.4),类型特征(§4.5.1),以及 enable_if§4.5.1)。

遗憾的是,概念在 C++11(§6.2)中失败了,这给(通常复杂得可怕而且容易出错的)权宜之计留下了生存空间,典型情况会涉及类型特征和 enable_if§4.5.1)。

4.5.3 noexcept 规约

起初的异常设计没有办法表明某个异常可能会从某函数中抛出。我仍然认为那才是正确的设计。为了让异常为 C++98 接纳,我们不得不加入异常规约,来列举一个函数会抛出那些异常 [Stroustrup 1993]。使用异常规约可选,并会在运行期进行检查。正如我担心的那样,这带来了维护的问题,在展开路径上对异常反复检查增加的运行期开销,还有源代码膨胀。在 C++11 中,异常规约被废弃 [Gregor 2010],而到了 C++17,我们终于(一致同意)移除了异常规约这个特性。

一直有人希望能够在编译时检查函数会抛出什么异常。从类型理论的角度,在小规模程序中,在有高速编译器和对代码完全控制的情况下,那当然行得通。委员会一再拒绝这种想法,原因是它不能扩展到由数十(或更多)组织维护的百万行代码规模的程序上 [Stroustrup 1994]。参见(§7.4)。

没有异常规约,库实现者们就要面对一个性能问题:在许多重要场合,一个库实现者需要知道一个拷贝操作是否会抛异常。如果会,就必须拿到一份拷贝以避免留下一个无效对象(这样会违犯异常保证 [Stroustrup 1993])。如果不会,我们可以直接写入到目标中。在这种场合,性能的差别可以非常显著,而最简单的异常规约 throw(),什么也不抛出,在此可以帮助判断。于是,在异常规约被弃之不用并最终从标准中移除的时候,我们基于 David Abrahams 和 Doug Gregor 的提案 [Abrahams et al. 2010; Gregor 2010; Gregor and Abrahams 2009] 引入了 noexcept 概念。

一个 noexcept 函数仍会被动态检查。例如:

void do_something(int n) noexcept
{
    vector<int> v(n);
    // ...
}

如果 do_something() 抛异常,程序会被终止。这样操作恰好非常接近零开销,因为它简单地短路了通常的异常传播机制。参见(§7.3)。

还有一个条件版本的 noexcept,用它可以写出这样的模板,其实现依赖于某参数是否会抛异常。这是最初促成 noexcept 的用例。例如,下面代码中,当且仅当 pair 的两个元素都有不抛异常的移动构造函数时,pair 的移动构造函数才会声明不抛异常:

template<typename First, typename Second>
class pair {
    // ...
    template <typename First2, typename Second2>
    pair(pair<First2, Second2>&& rhs)
        noexcept(is_nothrow_constructible<First, First2&&>::value
              && is_nothrow_constructible<Second, Second2&&>::value)
    : first(move(rhs.first)),
      second(move(rhs,second))
    {}
    // ...
};

其中的 is_nothrow_constructible<> 是 C++11 标准库的类型特征(type traits)之一(§4.5.1)。

在这相对底层和非常通用的层级写出最优代码可不简单。在基础层面上,懂得到底该按位拷贝,该移动,还是该按成员拷贝,会带来非常大的区别。

4.6 C++11:标准库组件

C++ 跟其他现代语言比一直有个小巧的标准库。此外,大多标准库组件都很基础,而不是试图处理应用层面的任务。不过,C++11 增加了几个关键的库组件来支持特定任务:

  • thread——基于线程和锁的并发
  • regex——正则表达式
  • chrono——时间
  • random——随机数产生器和分布

和大量的商业支持程序库相比,这显然小得可怜,但这些组件质量很高,并且跟之前的标准 C++ 相比数量也多多了。

设计这些组件,是要服务于一些特定任务。在这些任务中,它们为程序员提供了重大帮助。遗憾的是,这些库来自不同背景,体现在接口风格上,就出现了差异;除了要灵活和高性能之外它们没有一致的整体设计哲学。C++11 在合入一个组件方面没有明晰的标准(C++98 有一些 [Stroustrup 1994])。更准确地说,我们只是从现有的、已被社区证明成功的组件中接收组件进来。很多组件来自 Boost(§2.3)。

如果你需要使用正则表达式,标准库中新加入的 regex 就是个巨大改进了。类似,加入无序容器(哈希表),如 unordered_map,为很多程序员省去了大量繁琐的工作,使之可以产出更好的程序。然而,这些库组件并没有对人们组织代码的方式产生重大影响,所以我在此不对这些库组件的细节展开讨论。

regex 库主要是 John Maddock 的工作 [Maddock 2002]。

哈希表不巧错过了 C++98 的截止时间,因而出现在了 C++0x 的第一批提案之中 [Austern 2002]。它们被称做无序的(例如 unordered_map),是为了区别于老的、有序的标准容器(例如 map),也是因为较明显的名字(例如 hash_map)已经在 C++11 之前被其他库大量使用了。另外,unordered_map 也可以说是个更好的名字,因为它指出了类型提供什么,而不是它是如何实现的。

random 库提供了分布函数和随机数产生器,其复杂性被誉为“每个随机数库都想长成的样子”。但它对初学者或者一般用户(常需要随机数)并不易用。它在 2002 年由 Jens Maurer [Maurer 2002] 提出,并在 2006 年经由费米国家实验室的一群人修订 [Brown et al. 2006],随即被接受。

相比之下,Howard Hinnant 的 chrono 库 [Hinnant et al. 2008] 处理时间点和时间间隔,在提供复杂功能的同时仍保持了易用性。例如:

using namespace std::chrono;  // 在子命名空间 std::chrono
auto t0 = system_clock::now();
do_work();
auto t1 = system_clock::now();
cout << duration_cast<milliseconds>(t1-t0).count() << "msec\n";

其中的 duration_cast 把依赖于时钟的“嘀嗒”节拍数转换为程序员选用的时间单位。

使用如此简单的代码,你可以让大一学生都能感受到不同算法和数据结构的代价差异。chronothread 库提供了时间支持(§4.1.2)。

到了 C++20,chrono 得到进一步增强,加入了处理日期和时区的功能(§9.3.6)。C++20 也允许把上面的例子简化为:

cout << t1-t0 << '\n';

这就会把 t0t1 之间的时间差自动以合适的单位进行输出。

[^1]: 译注:参考 Python 在 PEP 20—The Zen of Python 中的不同态度:“应该有且仅有一种明显的完成任务的方式(There should be one—and preferably only one—obvious way to do it)。”

[^2]: 译注:下面的代码引自 2006 年的论文,但 operator= 的实现不符合现代惯用法:一般要么把参数设为 clone_ptr p,这就成了一个可以同时适配拷贝或移动的通用赋值函数;要么在函数体内进行一次移动构造,先 clone_ptr temp(std::move(p));std::swap(ptr, temp.ptr);。否则,当传递的实参是 std::move 的结果(xvalue)而不是真正的临时对象(prvalue)时,代码的行为会不符合预期。当然,就如下面 Bjarne 讨论到的,在 2006 年应该还没有 xvalue 和 prvalue 的概念。