现代 C++(即 C++11/C++14)远不止是修修补补。考虑所有的功能,这简直是一门语言的脱胎换骨。

绪论

一个对象是右值意味着能够对其实施移动语义,而左值则一般不然。
有一种甄别表达式是否左值的实用方法富有启发性,那就是检查能否取得该表达式的地址。如果可以取得,那么该表达式基本上可以断定是左值。如果不可以,则其通常是右值。

第一章 - 型别推导

条款 1:理解模板型别推导

  • 在模板型别推导过程中,具有引用型别的实参会被当成非引用型别来处理。换言之,其引用型会被忽略。
  • 对万能引用形参进行推导时,左值实参会进行特殊处理。
  • 对按值传递的形参进行推导时,若实惨型别中带有constvolatile饰词,则它们还是虎背当作不带constvolatile饰词的型别来处理。
  • 在模板型别推导过程中,数组或函数型别的实参会退化成对应的指针,除非它们被用来初始化引用。

条款 2:理解 auto 型别推导

1
2
auto x3 = { 27 };
auto x4{ 27 };

以上两条语句,auto 推导出来的型别为std::initializer_list<int>
但是,如果向对应的模板传入一个同样的初始化表达式,型别推导就会失败,代码将不能通过编译:

1
2
3
4
template<typename T>
void f(T param);

f({ 27 }); // 错误!无法推导 T 的型别
  • 在一般情况下,auto 型别推导和模板型别推导时一模一样的,但是 auto 型别推导会假定用大括号括起来的初始化表达式代表一个std::initializer_list,但模板型别推导却不会。
  • 在函数返回值或 lambda 式的形参中使用 auto,意思是使用模板型别推导而非 auto 型别推导。

条款 3:理解 decltype

  • 绝大多数情况下,decltype 会得出变量或表达式的型别尔不做任何修改。
  • 对于型别为 T 的左值表达式,除非该表达式仅有一个名字,decltype 总是得出型别 T&。
  • C++14 支持 decltype(auto),和 auto 一样,它会从其初始化表达式出发来推导型别,但是它的型别推导使用的是 decltype 的规则。

条款 4:掌握查看型别推导结果的方法

  • 利用 IDE 编辑器、编译器错误消息和 Boost.TypeIndex 库常常能够查看到推导而得的型别。
  • 有些工具产生的结果可能会无用,或者不准确。所以,理解 C++ 型别推导规则是必要的。

第二章 - auto

条款 5:优先选用 auto,而非显式型别声明

  • auto 变量必须初始化,基本上对会导致兼容性和效率问题的型别不匹配现象免疫,还可以简化重构流程,通常也比显式指定型别要少打一些字。
  • auto 型别打变量都有着条款 2 和条款 6 中所描述的毛病

条款 6:当 auto 推导的型别不符合要求时,使用带显式型别的初始化物习惯用法

std::vector<bool>operator[]返回的是个std::vector<bool>::reference型别的对象。这是因为std::vector<bool>做过特化,用了一种压缩形式表示其持有的bool元素,每个bool元素用一个比特来表示。

  • “隐形”的代理型别可以导致 auto 根据初始化表达式推导出“错误的”型别。
  • 带显式型别的初始化物习惯用法强制 auto 推导出你想要的型别。

第三章 - 转向现代 C++

条款 7:在创建对象时注意区分(){}

大括号同样也可以用来为非静态成员指定默认初始化值,这项能力也可以使用“=”的初始化语法,却不能使用小括号:

1
2
3
4
5
6
7
8
class Widget {
...

private:
int x{ 0 }; // 可行
int y = 0; // 也可行
int z(0); // 不可行!
};

不可复制的对象可以采用大括号和小括号来进行初始化,却不能使用“=”:

1
2
3
std::atomic<int> ai1{ 0 };  // 可行
std::atomic<int> ai2(0); // 可行
std::atomic<int> ai3 = 0; // 不可行!

大括号初始化有一项新特性,就是它禁止内建型别之间进行隐式窄化型别转换(narrowing conversion)。而使用小括号和“=”的初始化则不会进行窄化型别转换检查。

  • 大括号初始化可以应用的语境最为宽泛,可以阻止隐式窄化型别转换,还对最令人苦恼之解析语法免疫。
  • 在构造函数重载决议期间,只要有任何可能,大括号初始化物就会与带有std::initializer_list型别的形参相匹配,即是其他重载版本有着貌似更加匹配的形参表。
  • 使用小括号还是大括号,会造成结果大相径庭的一个例子是:使用两个实参来创建一个std::vector<数值型别>对象。
  • 在模板内容进行对象创建时,到底应该使用小括号还是大括号会称为一个棘手的问题。

条款 8:优先选用 nullptr,而非 0 或 NULL

  • 相对于0NULL,优先选用nullptr
  • 避免在整形和指针型别之间重载。

条款 9:优先选用别名声明,而非 typedef

别名声明(alias declaration):
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;

别名模板(alias template):

1
2
3
4
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

MyAllocList<Widget> lw; //客户代码
  • typedef不支持模板化,但别名声明支持。
  • 别名模板可以让人免写“::type”后缀,并且在模板内,对于内嵌typedef的引用经常要求加上typename前缀。

条款 10:优先选用限定作用域的枚举型别,而非不限作用域的枚举型别

  • C++98 风格的枚举型别,现在称为不限范围的枚举型别。
  • 限定作用域的枚举型别仅在枚举型别内可见。它们只能通过强制型别转换以转换至其他型别。
  • 限定作用域的枚举型别和不限范围的枚举型别都支持底层型别指定。限定作用域的枚举型别的默认底层型别是int,而不限范围的枚举型别没有默认底层型别。
  • 限定作用域的枚举型别总是可以进行前置声明,而不限范围的枚举型别却只有在指定了默认底层型别的前提下才可以进行前置声明。

条款 11:优先选用删除函数,而非 private 未定义函数

  • 优先选用删除函数,而非private未定义函数。
  • 任何函数都可以删除,包括非成员函数和模板具现。

条款 12:为意在改写的函数添加 override 声明

void mf1() const override;

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
using DataType = std::vector<double>;
...

DataType& data() & // 对于左值 Widgets 型别,返回左值
{ return values; }

DataType data() && // 对于右值 Widgets 型别,返回右值
{ return std::move(values); }
...
}
  • 为意在改写的函数添加override声明。
  • 成员函数引用饰词使得对于左值和右值对象(*this)的处理能够区分开来。

条款 13:优先选用 const_iterator,而非 iterator

容器的成员函数cbegincend都返回const_iterator型别。

  • 优先选用const_iterator,而非interator
  • 再最通用的代码中,优先选用非成员函数版本的beginendrbegin等,而非其成员函数版本。

条款 14:只要函数不会发射异常,就为其加上 noexcept 声明

  • noexception声明是函数接口的组成部分,这意味着调用房可能会对它有依赖。
  • 相对于不带noexception声明的函数,带有noexception声明的函数有更多机会得到优化。
  • noexception性质对于移动操作、swap、函数释放函数和析构函数最有价值。
  • 大多数函数都是异常中立的,不具备noexception性质。

条款 15:只要有可能使用 constexpr,就使用它

1
2
3
4
5
6
constexpr int pow(int base, int exp) noexcept { // C++14
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;

return result;
}
  • constexpr对象都具备const属性,并由编译期已知的值完成初始化。
  • constexpr函数在调用时若传入的实参值时编译期已知的,则会产出编译期结果。
  • 比起非constexpr对象或constexpr函数而言,constexpr对象或是constexpr函数可以用在一个作用域更广的语境中。

条款 16:保证 const 成员函数的线程安全

  • 保证const成员函数的线程安全性,除非可以确信它们不会用在并发语境中。
  • 运用std::atomic型别的变量会比御用互斥量提供更好的性能,但前者仅适用对单个变量或内存区域的操作。

条款 17:理解特种成员函数的生成机制

  • 特种成员函数是指那些 C++ 会自行生成的成员函数:默认构造函数、析构函数、复制操作,以及移动操作。
  • 移动操作仅当类中未包含用户显式声明的复制操作、移动操作和析构函数时才会生效。
  • 复制构造函数仅当类中不包含用户显式声明的复制构造函数时才生成,如果该类声明了移动操作则复制构造函数将被删除。复制赋值运算符仅当类中不包含用户显式声明的复制赋值运算符才生成,如果该类声明了移动操作则复制赋值运算符将被删除。在已经存在显式声明的析构函数的条件下,生成复制操作已经称为了被废弃的行为。
  • 成员函数模板在任何情况下都不会抑制特种成员函数的生成。

第四章 - 智能指针

C++11 中共有四种智能指针:std::auto_ptrstd::unique_ptrstd::shared_ptrstd::weak_ptr

条款 18:使用 std::unique_ptr 管理具备专属所有权的资源

  • std::unique_ptr是小巧、高速的、具备只移型别的智能指针,对托管资源实施专属所有权语义。
  • 默认地,资源析构采用delete运算符来实现,但可以指定自定义删除器。有状态的删除器和采用函数指针实现的删除器会增加std::unique_ptr型别的对象尺寸。
  • std::unique_ptr转换成std::shared_ptr时容易实现的。

条款 19:使用 std::shared_ptr 管理具备共享所有权的资源

  • std::shared_ptr提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收。
  • std::unique_ptr相比,std::shared_ptr的尺寸通常是裸指针的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作。
  • 默认的资源析构通过delete运算符进行,但同时也支持定制删除器。删除器的型别对std::shared_ptr的型别没有影响。
  • 避免使用裸指针型别的变量来创建std::shared_ptr

条款 20:对于类似 std::shared_ptr 但有可能空悬的指针使用 std::weak_ptr

  • 使用std::weak_ptr来代替可能悬空的std::shared_ptr
  • std::weak_ptr可能的用武之地包括缓存,观察者列表,以及避免std::shared_ptr指针环路。

条款 21:优先选用 std::make_unique 和 std::make_shared,而非直接使用 new

  • 相比于直接使用new表达式,make系列函数消除了重复代码、改进了异常安全性,并且对于std::make_sharedstd::allocate_shared而言,生成的目标代码会尺寸更小、速度更快。
  • 不适于使用make系列函数的场景包括需要定制删除器,以及期望直接传递大括号初始化物。
  • 对于std::shared_ptr,不建议使用make系列函数的额外场景包括:
    • 自定义内存管理的类;
    • 内存紧张的系统、非常大的对象、以及存在比指涉到相同对象的std::shared_ptr生存期更久的std::weak_ptr

条款 22:使用 Pimpl 习惯用法时,将特殊成员函数的定义放到实现文件中

  • Pimpl 惯用法通过降低类的客户和类实现者之间的依赖性,减少了构建遍数。
  • 对于采用std::unique_ptr来实现的pImpl指针,需在类的头文件中声明特种成员函数,但在实现文件中实现它们。即是默认函数实现有着正确行为,也必须这样做。
  • 上述建议仅适用于std::unique_ptr,但并不适用std::shared_ptr

第五章 - 右值引用、移动语义和完美转发

  • 移动语义是的编译器得以使用不那么昂贵的移动操作,来替换昂贵的复制操作。
  • 完美转发使得人们可以撰写接受任意实参的函数模板,并将其转发到其他函数,目标函数会接受到与转发函数所接受的完全相同的实参。
  • 右值引用是将这两个风马牛不相及的语言特性胶合起来的底层语言机制,正是它使得移动语义和完美转发成为了可能。

形参总是左值,即是其型别是右值引用。

条款 23:理解 std::move 和 std::forward

  • std::move实施的是无条件的向右值型别的强制型别转换。就其本身而言,它不会执行移动操作。
  • 仅当传入的实参被绑定到右值时,std::forward才针对该实参实施向右值型别的强制型别转换。
  • 在运行期,std::movestd::forward都不会做任何操作。

条款 24:区分万能引用和右值引用

  • 如果函数模板形参具备T&&型别,并且T的型别系推导而来,或如果对象使用auto&&声明其型别时,则该形参或对象就是个万能引用。
  • 如果型别声明并不精确地具备type&&的形式,或者型别推导并未发生,则type&&就代表右值引用。
  • 若采用右值来初始化万能引用,就会得到一个右值引用。若采用左值来初始化万能引用,就会得到一个左值引用。

条款 25:针对右值引用实施 std::move,针对万能引用实施 std::forward

编译器若要再一个按值返回的函数里省略对局部对象的复制(或者移动),则需要满足两个前提条件:

  • 局部对象型别和函数返回值型别相同;
  • 返回的就是局部对象本身。

即使实施RVO的前提条件满足,但编译器选择不执行复制省略的时候,返回对象必须作为右值处理(相当于隐式地增加了std::move)。

  • 针对右值引用的最后一次使用实施std::move,针对万能引用的最后一次使用std::forward
  • 作为按值返回的函数的右值引用和万能引用,依上一条所述采取相同行为。
  • 若局部对象可能适用于返回值优化,则请勿针对其实施std::movestd::forward

条款 26:避免依万能引用型别进行重载

  • 把万能引用作为重载候选型别,几乎总会让该重载版本在始料未及的情况下被调用到。
  • 完美转发构造函数的问题尤其严重,因为对与非常量的左值型别而言,他们一般都会形成相对于复制构造函数的更佳匹配,并且他们还会劫持派生类中对基类的复制和移动构造函数的使用。

条款 27:熟悉依万能引用型别进行重载的替代方案

  • 如果不使用万能引用和重载的组合,则替代方案包括使用彼此不同的函数名字、传递const T&型别的形参、传值和标签分派。
  • 经由std::enable_if对模板施加限制,就可以将万能引用和重载一起使用,不过这种技术控制了编译器可以调用到接受万能引用的重载版本的条件。
  • 万能引用形参通常在性能方面具备优势,但在易用性方面一般会有劣势。

条款 28:理解引用折叠

  • 引用折叠会在四中语境中发生:模板实例化、auto型别生成、创建和运用typedef和别名声明,以及decltype
  • 当编译器在引用折叠的语境下生成引用的引用时,结果会变成单个引用。如果原始的引用中有任一引用为左值引用,则结果为左值引用。否则,结果为右值引用。
  • 万能引用就是在型别推导的过程会区别左值和右值,以及会发生引用折叠的语境中的右值引用。

条款 29:假定移动操作不存在、成本高、未使用

  • 假定移动操作不存在、成本高、未使用。
  • 对于那些型别或对于移动语义的支持情况已知的代码,则无需作以上假定。

条款 30:熟悉完美转发的失败情形

  • 完美转发的失败情形,时源于模板型别推导失败,或推导结果时错误的型别。
  • 会导致完美转发失败的实参种类有大括号初始化物、以值0NULL表达的空指针、仅有声明的整形static const成员变量、模板或重载的函数名字以及位域。

第六章 - lambda 表达式

条款 31:避免默认捕获模式

  • 按引用的默认捕获会导致悬空指针问题。
  • 按值的默认捕获极易受悬空指针影响(尤其是this),并会误导人们认为lambda式是自洽的。

条款 32:使用初始化捕获将对象移入闭包

  • 使用 C++14 的初始化捕获将对象移入闭包。
  • 在 C++11 中,经由手工实现的类或std::bind去模拟初始化捕获。

条款 33:对 auto&& 型别的形参使用 decltype,以 std::forward 之

  • auto&&型别的形参使用decltype,以std::forward之。

条款 34:优先选用 lambda 式,而非 std::bind

  • lambda式比起使用std::bind而言,可读性更好、表达力更强,可能运行效率也更高。
  • 仅在 C++11 中,std::bind在实现移动捕获,或是绑定到具备模板化的函数调用运算符的对象的场合中,可能尚有余热可以发挥。

第七章 - 并发 API

条款 35:优先选用基于任务而非基于线程的程序设计