简介

如果你是一位经验丰富的 C++ 程序员并且多少跟我差不多,那你在初次接触 C++11 的时候就会想,“是的,是的,我知道的。它还是 C++,就是多了点东西。”但随着你了解越多,你会为改变的幅度之巨感到震惊。auto 声明,基于 range 的 for 循环,lambda 表达式,还有右值引用完全改变了 C++ 的面貌,这还不说新的并发特性。然后还有一些惯常的改进。0typedef 出局,nullptr 和别名声明新晋。枚举现在应该是限域的了。相比内置的指针类型,现在要更倾向去使用智能指针。移动对象通常也好过于拷贝它们。

有很多C++11的东西要学,先不提C++14了。

更重要的是,要学习怎样高效地使用新机能。如果你需要关于”现代“C++的特性的基础信息,学习资源有很多,但是你想找一些指南,教你怎样应用这些特性来写出正确、高效、可维护、可移植的程序,那就相当有挑战性了。这就是这本书的切入点。它不致力于介绍C++11和C++14的特性,而致力于它们的高效应用。

书中这些信息被打碎成不同指导方针,称为条款。想理解类型推导的不同形式?或者想知道什么时候该用(或者不该用)auto声明?你对为什么const成员函数应当线程安全,怎样使用std::unique_ptr实现Pimpl惯用法,为何要避免lambda表达式用默认捕获模式,或者std::atomicvolatile的区别感兴趣吗?答案都在这里。而且,答案无关于平台,顺应于标准。这本书是关于可移植C++的。

本书的条款是指导方针,而不是规则,因为指导方针也有例外。每个条款中最关键的部分不是提出的建议,而是建议背后的基本原理。一旦你阅读了它,你就明白你的程序的情况是否违反了条款的指导意见。本书的真正目的不是告诉你应该做什么不应该做什么,而是帮你深入理解C++11和C++14中各种东西是如何工作的。

术语和惯例

为了保证我们互相理解,对一些术语达成共识非常重要,首先有点讽刺的是,“C++”。有四个C++官方版本,每个版本名字后面带有相应ISO标准被采纳时的年份:C++98,C++03,C++11和C++14。C++98和C++03只有技术细节上的区别,所以本书统称为C++98。当我提到C++11时,我的意思是C++11和C++14,因为C++14是C++11的超集,当我写下C++14,我只意味着C++14。如果我仅仅提到C++,说明适用于所有的语言版本。

我使用的词我意思中的语言版本
C++所有版本
C++98C++98和C++03
C++11C++11和C++14
C++14C++14

因此,我可能会说C++重视效率(对所有版本正确),C++98缺少并发的支持(只对C++98和C++03正确),C++11支持lambda表达式(对C++11和C++14正确),C++14提供了普遍的函数返回类型推导(只对C++14正确)。

最遍布C++11各处的特性可能是移动语义了,移动语义的基础是区分右值和左值表达式。那是因为右值表明这个对象适合移动操作,而左值一般不适合。概念上(尽管不经常在实际上用),右值对应于从函数返回的临时对象,而左值对应于你可以引用的(can refer to)对象,或者通过名字,或者通过指针或左值引用。

对于判断一个表达式是否是左值的一个有用的启发就是,看看能否取得它的地址。如果能取地址,那么通常就是左值。如果不能,则通常是右值。这个启发的好处就是帮你记住,一个表达式的类型与它是左值还是右值无关。也就是说,有个类型T,你可以有类型T的左值和右值。当你碰到右值引用类型的形参时,记住这一点非常重要,因为形参本身是个左值:

class Widget {
public:
    Widget(Widget&& rhs);   //rhs是个左值,
    …                       //尽管它有个右值引用的类型
};

在这里,在Widget移动构造函数里取rhs的地址非常合理,所以rhs是左值,尽管它的类型是右值引用。(由于相似的原因,所有形参都是左值。)

那一小段代码揭示了我通常遵循的惯用法:

  • 类的名字是Widget。每当我想指代任意的用户定义的类型时,我用Widget来代表。除非我需要展示类中的特定细节,否则我都直接使用Widget而不声明它。

  • 我使用形参名rhs(“right-hand side”)。这是我喜欢的移动操作(即移动构造函数和移动赋值运算符)和拷贝操作(拷贝构造函数和拷贝赋值运算符)的形参名。我也在双目运算符的右侧形参用它:

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

    我希望你并不奇怪,我用lhs表示“left-hand side”。

  • 我在部分代码或者部分注释用特殊格式来吸引你的注意。(译者注:但是因为markdown没法在代码块中表明特殊格式,即原书使用的颜色改变和斜体注释,所以大部分情况下只能作罢,少部分地方会有额外说明。)在上面Widget移动构造函数中,我高亮了rhs的声明和“rhs是个左值”这部分注释。高亮代码不代表写的好坏。只是来提醒你需要额外的注意。

  • 我使用“”来表示“这里有一些别的代码”。这种窄省略号不同于C++11可变参数模板源代码中的宽省略号(“...”)。这听起来不太清楚,但实际并不。比如:

    template<typename... Ts>                //这些是C++源代码的
    void processVals(const Ts&... params)   //省略号
    {
        …                                   //这里意思是“这有一些别的代码”
    }
    

    processVals的声明表明在声明模板的类型形参时我使用typename,但这只是我的个人偏好;关键字class可以做同样的事情。在我展示从C++标准中摘录的代码的情况下,我使用class声明类型形参,因为那就是标准中的做法。

当使用另一个同类型的对象来初始化一个对象时,新的对象被称为是用来初始化的对象(译者注:initializing object,即源对象)的一个副本copy),尽管这个副本是通过移动构造函数创建的。很抱歉地说,C++中没有术语来区别一个对象是拷贝构造的副本还是移动构造的副本(译者注:此处为了区别拷贝这个“动作”与拷贝得到的“东西”,将copy按语境译为拷贝(动作)和副本(东西),此处及接下来几段按此方式翻译。在后面的条款中可能会不加区别地全部翻译为“拷贝”。):

void someFunc(Widget w);        //someFunc的形参w是传值过来

Widget wid;                     //wid是个Widget

someFunc(wid);                  //在这个someFunc调用中,w是通过拷贝构造函数
                                //创建的副本

someFunc(std::move(wid));       //在这个someFunc调用中,w是通过移动构造函数
                                //创建的副本

右值副本通常由移动构造产生,左值副本通常由拷贝构造产生。如果你仅仅知道一个对象是其他对象的副本,构造这个副本需要花费多大代价是没法说的。比如在上面的代码中,在不知道是用左值还是右值传给someFunc情况下,没法说来创建形参w花费代价有多大。(你必须还要知道移动和拷贝Widget的代价。)

在函数调用中,调用地传入的表达式称为函数的实参argument)。实参被用来初始化函数的形参parameter)。在上面第一次调用someFunc中,实参为wid。在第二次调用中,实参是std::move(wid)。两个调用中,形参都是w。实参和形参的区别非常重要,因为形参是左值,而用来初始化形参的实参可能是左值或者右值。这一点尤其与完美转发perfect forwarding)过程有关,被传给函数的实参以原实参的右值性(rvalueness)或左值性(lvalueness),再被传给第二个函数。(完美转发讨论细节在Item30。)

设计优良的函数是异常安全exception safe)的,意味着他们至少提供基本的异常安全保证(即基本保证basic guarantee)。这样的函数保证调用者在异常抛出时,程序不变量保持完整(即没有数据结构是毁坏的),且没有资源泄漏。有强异常安全保证的函数确保调用者在异常产生时,程序保持在调用前的状态。

当我提到“函数对象”时,我通常指的是某个支持operator()成员函数的类型的对象。换句话说,这个对象的行为像函数一样。偶尔我用稍微更普遍一些的术语,表示可以用非成员函数语法调用的任何东西(即“fuctionName(arguments)”)。这个广泛定义包括的不仅有支持operator()的对象,还有函数和类似C的函数指针。(较窄的定义来自于C++98,广泛点的定义来自于C++11。)将成员函数指针加进来的更深的普遍化产生了我们所知的可调用对象callable objects)。你通常可以忽略其中的微小区别,简单地认为函数对象和可调用对象为C++中可以用函数调用语法调用的东西。

通过lambda表达式创建的函数对象称为闭包closures)。没什么必要去区别lambda表达式和它们创建的闭包,所以我经常把它们统称lambdas。类似地,我几乎不区分函数模板function templates)(即产生函数的模板)和模板函数template functions)(即从函数模板产生的函数)。类模板class templates)和模板类template classes)同上。

C++中的许多东西都可被声明和定义。声明declarations)引入名字和类型,并不给出比如存放在哪或者怎样实现等的细节:

extern int x;                       //对象声明

class Widget;                       //类声明

bool func(const Widget& w);         //函数声明

enum class Color;                   //限域enum声明(见条款10)

定义definitions)提供存储位置或者实现细节:

int x;                              //对象定义

class Widget {                      //类定义
    …
};

bool func(const Widget& w)
{ return w.size() < 10; }           //函数定义

enum class Color
{ Yellow, Red, Blue };              //限域enum定义

定义也有资格称为声明,所以我倾向于只有声明,除非这个东西有个定义非常重要。

我定义一个函数的签名signature)为它声明的一部分,这个声明指定了形参类型和返回类型。函数名和形参名不是签名的一部分。在上面的例子中,func的签名是bool(const Widget&)。函数声明中除了形参类型和返回类型之外的元素(比如noexcept或者constexpr,如果存在的话)都被排除在外。(noexceptconstexprItem1415叙述。)“签名”的官方定义和我的有点不一样,但是对本书来说,我的定义更有用。(官方定义有时排除返回类型。)

新的C++标准保持了旧标准写的代码的有效性,但是偶尔标准化委员会废弃deprecate)一些特性。这些特性在标准化的“死囚区”中,可能在未来的标准中被移除。编译器可能警告也可能不警告这些废弃特性的使用,但是你应当尽量避免使用它们。它们不仅可能导致将来对移植的头痛,也通常不如来替代它们的新特性。例如,std::auto_ptr在C++11中被废弃,因为std::unique_ptr可以做同样的工作,而且只会做的更好。

有时标准说一个操作的结果有未定义的行为undefined behavior)。这意味着运行时表现是不可预测的,不用说你也想避开这种不确定性。有未定义行为的行动的例子是,在std::vector范围外使用方括号(“[]”),解引用未初始化的迭代器,或者引入数据竞争(即有两个或以上线程,至少一个是writer,同时访问相同的内存位置)。

我将那些比如从new返回的内置指针(build-in pointers)称为原始指针raw pointers)。原始指针的“反义词”是智能指针smart pointers)。智能指针通常重载指针解引用运算符(operator->operator*),但在Item20中解释看std::weak_ptr是个例外。

在源代码注释中,我有时将“constructor”(构造函数)缩写为ctor,将“destructor”(析构函数)缩写为dtor。(译者注:但译文中基本都完整翻译了而没使用缩写。)

报告bug,提出改进意见

我尽力将本书写的清晰、准确、富含有用的信息,但是当然还有些去做得更好的办法。如果你找到了任何类型的错误(技术上的,叙述上的,语法上的,印刷上的等),或者有些建议如何改进本书,请给我发电子邮件到emc++@aristeia.com。新的印刷给了我改进《Modern Effective C++》的机会,但我也不能解决我不知道的问题!

要查看我所知道的事情,参见本书勘误表页,http://www.aristeia.com/BookErrata/emc++-errata.html 。