返回介绍

4.2 C++11:简化使用

发布于 2024-08-19 12:44:37 字数 77171 浏览 0 评论 0 收藏 0

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

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

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

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

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

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

  • §4.2.1:auto
  • §4.2.2:范围 for
  • §4.3.1:lambda 表达式

这三个特性属于 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)二者的组合。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文