《Effective Modern C++》读书笔记


一、型别推导

1. 理解模板型别推导

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

2. 理解auto型别推导

  1. 在一般情况下,auto型别推导和模板型别推导是一摸一样的,但是auto型别推导会假定用大括号括起的初始化表达式代表一个std::initializer_list,但模板型别推导却不会。
  2. 在函数返回值或lambda式的形参中使用auto,意思是使用模板型别推导而非auto型别推导。

3. 理解decltype

  1. 绝大多数情况下,decltype会得出变量或表达式的型别而不作任何修改。
  2. 对于型别为T的左值表达式,除非该表达式仅有一个名字,decltype总是得出型别T&。

    int i = 0, *p = &i;
    decltype(i)     // -> int
    decltype((i))   // -> int&
    decltype(*p)    // -> int&
  3. C++14支持decltype(auto),和auto一样,它会从其初始化表达式出发来推导型别,但是它的型别推导使用的是decltype的规则。

4. 掌握查看型别推导结果的方法

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

二、auto

5. 优先选用auto,而非显示型别声明

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

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

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

三、转向现代C++

7. 在创建对象时注意区分()和{}

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

几个容易混淆的例子

Widget w1{};    // 调用默认构造函数
Widget w2();    // 声明了一个名为w2的函数
Widget w3({});  // 调用带有std::initializer_list型别形参的构造函数
Widget w4{{}};  // 同上

8. 优先选用nullptr,而非0或NULL

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

9. 优先选用别名声明,而非typedef

  1. typedef不支持模板化,但别名声明支持。
  2. 别名模板可以让人免写”::type”后缀,并且在模板内,对于内嵌typedef的引用经常要求加上typename前缀。

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

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

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

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

12. 为意在改写的函数添加override声明

  1. 为意在改写的函数添加override声明。
  2. 成员函数引用饰词使得对于左值和右值对象(*this)的处理能够区分开来。

13. 优先选用const_iterator,而非iterator

  1. 优先选用const_iterator,而非iterator。
  2. 在最通用的代码中,优先选用非成员函数版本的begin、end和rbegin等,而非其成员函数版本。

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

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

关于条款14的补充说明

  • 在带有noexcept声明的函数中,优化器不需要在异常传出函数的前提下,将执行期栈保持在可开解状态;也不需要在异常溢出函数的前提下,保证所有其中的对象以其被构造顺序的逆序完成析构。而那些以”throw()”异常规格声明的函数就享受不到这样的优化灵活性,和没有加异常规格声明的函数一样。

  • 在C++98中,允许内存释放函数(即operator delete或operator delete[])和析构函数发射异常,被认为是一种差劲的编程风格。而在C++11中,这种风格规则被升级成了一条语言规则。默认地,内存释放函数和所有的析构函数(无论是用户定义的,还是编译器自动生成的)都隐式地具备noexcept性质。

15. 只要有可能使用constexpr,就使用它

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

关于constexpr函数的说明

  • constexpr函数可以用在要求编译期常量的语境中。在这样的语境中,若你传给一个constexpr函数的实参值是在编译期已知的,则结果也会在编译期间计算出来。如果任何一个实参值在编译期未知,则你的代码将无法通过编译。
  • 在调用constexpr函数时,若传入的值有一个或多个在编译期未知,则它的运作方式和普通函数无异,亦即它也是在运行期执行结果的计算。这意味着,如果函数执行的是同样的操作,仅仅应用的语境一个是要求编译期常量的,一个是用于所有其他值的话,那就不必写两个函数。constexpr函数就可以同时满足所有需求。

关于constexpr在C++11和C++14中的不同

constexpr函数仅限于传入和返回字面型别(literal type),意思就是这样的型别能够持有编译期可以决议的值。在C++11中,所有的内建型别,除了void,都符合这个条件。但是用户自定义型别同样可能也是字面型别,因为它的构造函数和其他成员函数可能也是constexpr函数。

class Point {
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept
    : x(xVal), y(yVal)
    {}

    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }

    void setX(double newX) noexcept { x = newX; }
    void setY(double newY) noexcept { y = newY; }

private:
    double x, y;
};

在C++11中,有两个限制使得Point的成员函数setX和setY无法声明为constexpr。

  1. 首先,它们修改了操作对象。在C++11中,constexpr函数都隐式地被声明为const的了。这里说的并非函数返回值的const属性,而是指成员函数的const饰词,这意味着该成员函数不能修改其操作对象(严格地说是不能修改其非mutable数据成员)。
  2. 其次,它们的返回型别是void。而在C++11中,void并不是个字面型别。

不过这两个限制在C++14中都被解除了,所以在C++14中,就连设置器也可以声明为constexpr。

class Point {
public:
    ...

    constexpr void setX(double newX) noexcept { x = newX; } // C++14
    constexpr void setY(double newY) noexcept { y = newY; } // C++14

    ...
};

关于条款15的补充说明

  • 所有constexpr对象都是const对象,而并非所有的const对象都是constexpr对象。如果你想让编译器提供保证,让变量拥有一个值,用于要求编译期常量的语境,那么能达到这个目的的工具是constexpr,而非const。

  • 在C++11中,constexpr函数不得包含多于一个可执行语句,即一个return语句。在C++14中,限制条件大大地放宽了。

16. 保证const成员函数的线程安全性

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

17. 理解特种成员函数的生成机制

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

大三律

如果你声明了复制构造函数、复制赋值运算符,或析构函数中的任何一个,你就得同时声明所有这三个。

它植根于这样的思想:如果有改写复制操作的需求,往往意味着该类需要执行某种资源管理,而这就意味着:

  1. 在一种复制操作中进行的任何资源管理,也极有可能在另一种复制操作中也需要进行;
  2. 该类的析构函数也会参与到该资源的管理中(通常是释放之)。

移动操作的生成条件(如果需要生成)仅当一下三者同时成立

  1. 该类未声明任何复制操作

    声明复制操作(无论是复制构造还是复制赋值)的行为表明了对象的常规复制途径(按成员复制)对于该类并不适用。编译器从而判定,既然按成员复制不适用于复制操作,则按成员移动极有可能也不适用于移动操作。

  2. 该类未声明任何移动操作

    假设你声明了一个移动构造函数,你实际上表明移动操作的实现方式将会与编译器生成的默认按成员移动的移动构造函数多少有些不同。而若是按成员进行的移动构造操作有不合用之处的话,那么按成员进行的移动赋值运算符极有可能也会有不合用之处。

  3. 该类未声明任何析构函数

    • 大三律的一个推论是,如果存在用户声明的析构函数,则平凡的(trivial)按成员复制的也不适用于该类。根据这个推论,又能得出进一步的结论,如果声明了析构函数,则复制操作就不该被自动生成,因为它们的行为不可能正确。不过在C++98标准被接受的时代,这样的论证过程没有得到充分的重视,所以在C++98中,用户声明的析构函数即使存在,也不会影响编译器生成复制操作的意愿。这种情况在C++11仍然得到了保持,但原因仅仅在于,如果要对复制操作的生成条件施加更严格的限制,就会破坏太多的遗留代码了。
    • 由于大三律背后的理由仍然成立,再结合声明了复制操作就会阻止隐式生成移动操作的事实,就推动了C++11中的这样一个规定:只要用户声明了析构函数,就不会生成移动操作。

关于条款17的补充说明

  • 当移动操作在某个数据成员或基类部分上执行移动构造或移动赋值的时候,并不能保证移动操作真的会发生。”按成员移动”实际上更像是按成员的移动请求,因为那些不可移动的型别(即那些并未为移动操作提供特殊支持的型别,这包括了大多数C++98的遗留型别)将通过其复制操作实现”移动”。每个按成员进行的”移动”操作,其核心在于把std::move应用于每一个移动源对象,其返回值被用于函数重载决议,最终决定是执行一个移动还是复制操作,这个流程将在条款23中详述。在本条款中,只需记住,按成员移动是由两部分组成,一部分是在支持移动操作的成员上执行移动操作,另一部分实在不支持移动操作的成员上执行复制操作。

四、智能指针

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

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

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

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

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

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

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

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

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

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

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

在阅读本章中的条款时,一定要把这一点铭记在心:形参总是左值,即使其型别是右值引用。即,给定函数形如:

void f(Widget&& w);

形参w是个左值。即使它的型别是个指涉到Widget型别对象的右值引用(如果你对此感觉意外,请参阅本书第一章就写着的关于左值和右值的概述)。

23. 理解std::move和std::forward

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

24. 区分万能引用和右值引用

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

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

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

RVO的条件

  • 局部对象型别和函数返回值型别相同。

    Widget makeWidget() {
        Widget w;
        ...
        return std::move(w);
    }

    std::move(w)返回的不是局部对象w,而是w的引用,std::move(w)的结果。返回一个局部对象的引用并不满足实施RVO的前提条件,因此编译器必须把w移入函数的返回值存储位置。

  • 返回的就是局部对象本身。

即使实施RVO的前提条件满足,但编译器选择不执行复制省略的时候,返回对象必须作为右值处理。这么一来,就等于标准要求:当RVO的前提条件允许时,要么发生复制省略,要么std::move隐式地被实施于返回的局部对象上。

26. 避免依万能引用型型别进行重载

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

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

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

28. 理解引用折叠

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

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

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

30. 熟悉完美转发的失败情形

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

六、lambda表达式

31. 避免默认捕获模式

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

32. 使用初始化捕获将对象移入闭包

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

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

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

    auto f = [](auto&&... params) {
        return func(normalize(std::forward<decltype(params)>(params)...));
    }

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

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

七、并发API

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

  1. std::thread的API未提供直接获取异步运行函数返回值的途径,而且如果那些函数抛出异常,程序就会终止。
  2. 基于线程的程序设计要求手动管理线程耗尽、超订、负载均衡,以及新平台适配。
  3. 经由应用了默认启动策略的std::async进行基于任务的程序设计,大部分这类问题都能找到解决知道。

36. 如果异步是必要的,则指定std::launch::async

  1. std::async的默认启动策略既允许任务异步方式执行,也允许任务以同步方式执行。
  2. 如此的弹性会导致使用thread_local变量时的不确定性,隐含着任务可能永远不会执行,还会影响运用了基于超时的wait调用的程序逻辑。
  3. 如果异步是必要的,则指定std::launch::async。

37. 使std::thread型别对象在所有路径皆不可联结(unjoinable)

  1. 使std::thread型别对象在所有路径皆不可联结。
  2. 在析构时调用join可能导致难以调试的性能异常。
  3. 在析构时调用detach可能导致难以调试的未定义行为。
  4. 在成员列表的最后声明std::thread型别对象。

不可联结的std::thread型别对象

  1. 默认构造的std::thread。 此类std::thread没有可以执行的函数,因此也没有对应的底层执行线程。
  2. 已移动的std::thread。 移动操作的结果是,一个std::thread所对应的底层执行线程(若有)被对应到另一个std::thread。
  3. 已联结的std::thread。 联结后,std::thread型别对象不在对应至已结束运行的底层执行线程。
  4. 已分离的std::thread。 分离操作会把std::thread型别对象和它对应的底层执行线程之间的连接断开。

关于条款37的补充说明

  • 如果可联结线程对象的析构函数被调用,则程序的执行就终止了。

38. 对变化多端的线程句柄析构函数行为保持关注

  1. 期值(std::future/std::shared_future)的析构函数在常规情况下,仅会析构期值的成员变量。
  2. 指涉到经由std::async启动的未推迟任务的共享状态的最后一个期值会保持阻塞,直至该任务结束。

39. 考虑针对一次性事件通信使用以void为模板型别实参的期值

  1. 如果仅为了实现平凡事件通信,基于条件变量的设计会要求多余的互斥量,这会给相互关联的检测和反应任务带来约束,并要求反应任务校验事件确已发生。
  2. 使用标志位的设计可以避免上述问题,但这一设计基于轮训而非阻塞。
  3. 条件变量和标志位可以一起使用,但这样的通信机制设计结果不甚自然。
  4. 使用std::promise型别对象和期值就可以回避这些问题,但是一来这个途径为了共享状态需要使用堆内存,而且仅限于一次性通信。

40. 对并发使用std::atomic,对特种内存使用volatile

  1. std::atomic用于多线程访问的数据,且不用互斥量。它是撰写并发软件的工具。
  2. volatile用于读写操作不可以被优化掉的内存。它是在面对特种内存时使用的工具。

    给y加上volatile可以避免编译器将代码:

    std::atomic<int> y(x.load());
    y.store(x.load());

    优化成:

    register = x.load();
    std::atomic<int> y(register);
    y.store(register);

八、微调

41. 针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑将其按值传递

  1. 对于可复制的、在移动成本低廉的并且一定会被复制的形参而言,按值传递可能会和按引用传递的具备相近的效率,并且可能生成更少量的目标代码。
  2. 经由构造复制形参的成本可能比经由赋值复制形参高出很多。
  3. 按值传递肯定会导致切片问题,所以基类型别特别不适用于按值传递。

42. 考虑置入而非插入

  1. 从原理上说,置入函数(如:emplace_back)应该有时比对应的插入函数(如:push_back)高效,而且不应该有更低效的可能。
  2. 从实践上说,置入函数在以下几个前提成立时,极有可能会运行得更快:(1)待添加的值是以构造而非赋值方式加入容器;(2)传递的实参型别与容器持有之物的型别不同;(3)容器不会由于存在重复值而拒绝待添加的值。
  3. 置入函数可能会执行在插入函数中会被拒绝的型别转换。

    复制初始化是不允许调用带有explicit声明饰词的构造函数的,但直接初始化就允许。

    std::regex r1 = nullptr;    // 错误!无法通过编译 (复制初始化)
    std::regex r2(nullptr);     // 能编译 (直接初始化)

文章作者: Kiba Amor
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Kiba Amor !
  目录