Effective Modern C++

Modern C++中的range-for循环,auto关键字,unique_ptr,shared_ptr,右值的参数传递和std::move等特性,给开发者带来了很多便利, 关于新特性的注意事项和习惯用法还是得下功夫学习的。C++仍然是目前自己所掌握的语言中最复杂的一项,而且是独一档。当然C++也在 不断地自我完善,吸收新特性,以及紧随最新的语言趋势。总的来说,还是得保持空杯心态,进一寸有一寸的欢喜!


1. 理解模板型别推导

  • paramType是个指针或引用,如果expr具有引用性,先将其引用部分忽略,然后再进行模式匹配确定T的类型
  • paramType是一个万能引用(void f(T&& param)),如果expr是左值,则T和paramType都会被推导为左值引用, 如果expr是右值,则paramType会被推导为右值引用,T被推导为值类型
  • paramType是按值传递时,如果expr具有引用,则忽略其引用性,如果还具有const和volatile属性,也忽略
  • 在推导过程中,数组或函数型的会退化为相应的指针,除非它们被用来初始化引用

2. 理解auto型别推导

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

3. 理解decltype

  • 绝大多数情况下,decltype会得出变量或表达式的型别而不做任何修改
  • 对于型别为T的左值表达式,如果该表达式仅有一个名字,则型别是T,否则型别都是T&。 例如int x = 0;decltype(x)的结果为T,而decltype((x))的结果为T&
  • C++14支持decltype(auto),和auto一样会从表达式来推导,但使用的是decltype的推导规则

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

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

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

  • auto变量必须初始化,基本上对会导致兼容性和效率问题的型别不匹配现象免疫,还可以简化重构流程
  • auto型别的变量注意还需要适时加上const和&等修饰符

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

  • 隐形的代理型别导致auto根据初始化表达式推导出错误的类型,如std::vector<bool>的operator[],或者其他operator返回代理类型
  • 如果初始化时使用强制类型转换,则可以让auto得到想要的结果,如auto value = static_cast<bool>(features(w)[5])

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

  • 大括号初始化应用的语境最为广泛,可以阻止隐式转换,以及解析语法
  • 如有可能,大括号初始化会匹配带有std::initializer_list型别的构造函数,即使其他重载版本有更加匹配的形参表
  • 使用小括号或大括号调用的构造函数不同的例子是std::vector对象,小括号表示创建n个初始化为m的vector,大括号表示初始化的所有元素
  • 在模板的实现中创建对象时,使用大括号还是小括号应该在注释或文档中声明,以便调用者知晓

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

  • 相对于0或NULL,优先选用nullptr。nullptr的类型时std::nullptr_t可以隐式转换到所有指针类型,而且不会是整形
  • 即便使用了nullptr,也要避免在整形和指针型别之间重载

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

  • typedef不支持模板化,但别名声明直接支持
  • 别名模板可以让人避免写”::type”后缀,并且在模板内,对于内嵌typedef的引用经常要求加上typename前缀,因为编译器不确定struct的成员是否是typename
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
// 使用别名
MyAllocList<Widget> lw;

template<typename T>
struct MyAllocLista {
    typedef std::list<T, MyAlloc<T>> type;
};
// 使用typedef,只能在struct中嵌套声明
MyAllocList<Widge>::type lw;

10. 优先对枚举型别限定作用域

  • 限定作用域指的是使用enum class,又称为枚举类,而非enum
  • 限定作用域的枚举类型只能在枚举型别中可见,只能通过强制型别转换成其他型别
  • 枚举都支持底层型别指定,enum class默认为int,enum没有默认的类型
  • enum class始终支持前置声明,而enum需要显式指定底层型别才可以支持前置声明

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

  • 使未定义函数无法访问的方式优先使用删除函数,而非private
  • 任何函数都可以删除,包括非成员函数和模板特化

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

  • 为所有继承方法添加override声明
  • 成员函数引用修饰词使左值和右值的处理能够区分开来,如data() &和data() &&

13. 优先使用const iterator,而非iterator

  • 迭代时优先使用const_iterator,而非iterator
  • 在模板函数中,优先使用非成员函数版本的cbegin,cend和crbegin等,而非其成员函数版本

14. 只要函数不会抛异常,就为其增加noexcept声明

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

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

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

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

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

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

  • 特种成员函数之C++会自动生成的成员函数,包括默认构造函数,析构函数,复制操作,以及移动操作
  • 移动操作仅当用户未显示声明复制操作,移动操作和析构函数时才会自动生成
  • 复制操作和移动操作有其一则不会自动生成另一个。而复制操作中的复制构造函数和复制赋值运算符在自动生成中不互斥, 移动操作中的移动构造函数和移动赋值操作运算符在自动生成中也不互斥

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

  • std::unique_ptr具有专属所有权语义,只能对其进行移动操作,而不能复制。unique_ptr具有默认的删器,和裸指针有相同尺寸的大小
  • unique_ptr也可以指定自定义的删除器,无捕获的lambda表达式不会增加unique_ptr型别的对象尺寸,其他类型如函数指针或有捕获的lambda表达式回增加尺寸
  • unique_ptr很容易转换成shared_ptr

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

  • std::shared_ptr具有共享所有权语义,可以方便地进行生命周期的管理和回收
  • sharead_ptr通常是裸指针尺寸的两杯,还会带来控制块的开销,并要求原子化的引用计数操作
  • shared_ptr可以指定自定义的删除器,且删除器的型别对shared_ptr的型别没有影响,即可以不放到模板的型别特化中
  • 避免使用裸指针型别的变量来创建shared_ptr

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

  • 使用std::weak_ptr代替可能空悬的std::shared_ptr
  • weak_ptr可以用于缓存,观察者列表,以及避免shared_ptr的指针环路问题

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

  • 相比于直接使用new表达式,make系列函数消除了重复代码,改进了异常安全性,并且对于std::make_sharedstd::allcoated_shared而言, 生成的目标代码尺寸更小,速度更快。即对象和控制器的内存是一次分配的,而使用new表达式对象和控制器的内存是两次分配的
  • make不支持定制删除器,以及直接传递大括号初始化列表
  • 对于shared_ptr,不建议使用make系列函数的额外场景包括:自定义内存管理的类;内存紧张,对象尺寸较大且存在比shared_ptr生存期更久的 weak_ptr,因为控制块内存的释放需要等到所有shared_ptrweak_ptr都不再引用

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

  • Pimpl习惯用法通过降低类的使用者和类的实现之间的依赖性,减少了构建次数
  • 对于采用unique_ptr来实现的pImpl指针,须在类的头文件中声明特殊成员函数,且在实现文件中实现它们。即使默认函数有着正确的行为,也必须这么做
  • 关于类特殊成员函数的声明建议只适用于unique_ptr,并不适用于shared_ptr

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

  • std::move实施的是无条件的右值型别的强制型别转换。就其本身而言,不会执行移动操作
  • std::forword是有条件的右值型别强制型别转换,其条件是当传入的实参使用的是右值初始化。注意实参都是左值,即使其型别定义为右值引用, 但可以用右值初始化,也可以用左值初始化,该信息是被编码到模板形参T中的
  • std::movestd::forward在运行期都不会做任何操作

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

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

template<typename T>
void f(std::vector<T>&& param);  // 右值引用

template<typename T>
void f(T&& param);  // 万能引用

template<typename T>
void f(const T&& param);  // 右值引用,const都可以让万能引用变为右值引用

25. 针对右值引用执行std::move,针对万能引用执行std::forward

  • 针对右值引用的最后一次使用执行std::move,针对万能引用的最后一次使用执行std::forward
  • 若局部对象可能适用于RVO返回值优化,请勿对其使用std::movestd::forward

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

  • 把万能引用作为重载候选型别,总是会让该重载版本在始料未及的情况下被调用到
  • 完美转发构造函数的问题尤为严重,因为对于非常量的左值型别而言,它们一般都会形成相对于复制构造函数的更加匹配(例如不含const修饰), 并且它们还会劫持派生类对基类的复制和移动构造函数的调用

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

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

28. 理解引用折叠

  • 引用折叠会在四种语境中发生:模板实例化,auto型别生成,创建和使用typedef和别名声明,以及decltype
  • 当出现双重引用时,有任一引用为左值引用,则引用折叠为左值引用,当两个引用全部为右值引用时,则引用折叠为右值引用
  • 万能引用就是在型别推导的过程会区分左值和右值,以及会发生引用折叠语境中的右值引用。注意是非对称的,对于万能引用, 传入左值,则T推导为左值引用(即T& &&),参数型别是左值引用;传入右值,则T推导为右值非引用型(即T &&),参数型别仍是右值引用

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

  • 假定移动操作不存在,成本高,未使用,默认移动函数的生成需要在复制和析构未声明的情况下
  • 对于明确支持移动语义的型别,无需作上述假定

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

  • 完美转发的失败情形,是源于模板型别推导失败,或推导结果是错误的型别
  • 导致完美转发失败的实参种类由大括号初始化物,以值0或NULL表达的空指针,仅有声明的整形static const成员变量, 模板函数或有重载的函数名字,以及位域(因为无法创建任意比特的指针,指针最小的指涉实体是char)

31. 避免默认捕获模式

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

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

  • 优先使用C++14的初始化捕获将对象使用移动语义移入闭包
  • 在C++11中,经由手动实现的类或std::bind可以模拟C++14的初始化捕获
std::vector<double> data;
// C++14
auto func = [data = std::move(data) {}]
// C++11使用bind实现相同语义
auto func = std::bind([](const std::vector<double>& data) {}, std::move(data)

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

  • 对auto&&万能引用型别的形参使用decltype作为std::forward的范型类型

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

  • lambda式比起使用std::bind而言,可读性更好,表达力更强,更有可能得到内联
  • C++11的lambda式未提供移动捕获和auto的形参推导,C++14对此提供了支持

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

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

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

  • std::async的默认启动策略是std::launch::async|std::launch::deferred,既允许任务以异步方式执行,也允许任务以同步方式执行(即等到get或wait返回值future的时候才执行)
  • 这样会导致使用thread_local变量的不确定性,隐含着任务可能永远不会执行,还会影响基于超时的wait调用的程序逻辑
  • 如果异步是必要的,请明确指定启动策略为std::launch::async

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

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

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

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

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

  • 如果仅为了实现普通的事件通信,基于条件变量的设计会额外需要一个互斥锁,并要求反应任务校验事件确实发生,即避免虚假唤醒
  • 使用标志位的设计可以避免上述问题,但这一设计基于轮询而非阻塞
  • 条件变量和标志变量可以一起使用,这样可以使用阻塞,且避免虚假唤醒的问题
  • 使用std::promise变量和期值可以避免这些问题,但是共享状态需要使用堆内存,且仅限于一次通信
// 条件变量和标志变量一起使用
std::condition_variable cv;
std::mutex m;
bool flag = false;
// 检测任务
{
    std::lock_guard<std::mutext> g(m);
    flag = true;
}
cv.notify_one();
// 反应任务
{
    std::unique_lock<std::mutext> lk(m);
    cv.wait(lk, [] {return flag})
}

// srd::promise的使用
std::promise<void> p;
// 检测任务
p.set_value();
// 反应任务
p.get_future().wait();

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

  • std::atomic用于多线程访问的数据,且不用互斥量。用于并发的原子性。
  • volatile用于读写操作不可以被优化掉的内存。用于特种内存。

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

  • 对于可复制的,在移动成本低廉的并且一定会被复制的形参而言,按值传递可能会和按引用传递具备相近的效率,并可能产生更少的目标代码, 例如传入std::string,然后push_back到std::vector中,对于左值,在push_back中产生一次复制,对于右值,产生一次移动
  • 经由构造复制形参的成本可能比经由赋值复制形参高出很多
  • 按值传递肯定会导致切片问题,所以基类型别特别不适用于按值传递

42. 考虑置入而非插入

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