《Effective Modern C++》全书内容提炼总结

前言
C++程序员都应该是对性能执着的人,想要彻底理解C++11和C++14,不可止步于熟悉它们引入的语言特性(例如,auto型别推导、移动语义、lambda表达式,以及并发支持)。挑战在于高效地运用这些特性,从而使你的软件具备正确性、高效率、可维护性和可移植性。这正是本书意欲达成的定位,它不只是教我们应该怎么做,更多的是告诉我们背后发生了什么。
一、类型推导
C++98有一套类型推导的规则:用于函数模板的规则。C++11修改了其中的一些规则并增加了两套规则,一套用于auto,一套用于decltype。C++14扩展了auto和decltype可能使用的范围。
1. 理解模板类型推导
C++11的auto/decltype类型推导其实就是基于模板类型推导实现的,模板类型推导分成三个情景,下面我们分别介绍:
- 模板类型是指针或引用情形
举个例子,这是我们的模板,
1 | template<typename T> |
我们声明了这些变量,
1 | int x=27; //x是int |
下面是不同调用T被推导出的类型,
1 | f(x); //T是int,param的类型是int& |
如果我们将f的形参类型T&改为const T&,情况有所变化,但不会变得那么出人意料。
1 | template<typename T> |
如果param是一个指针,本质上也一样,
1 | template<typename T> |
其实规则就是模板类型与传递参数类型作模式匹配,重复部分会被忽略,得到的T类型。
- 模板类型是一个右值引用(&&)
1 | template<typename T> |
规则是参数是左值则被推导成左值的引用,参数是右值则被推导成参数本身类型。
- 模板类型既不是指针也不是引用
1 | template<typename T> |
这是参数传值得形式,param是实参的一个副本,推导时,参数的const,引用会被忽略。
请记住:
- 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略。
- 对于通用引用(右值引用)的推导,左值实参会被特殊对待。
- 对于传值类型推导,
const和&或volatile实参会被认为是non-const的和non-volatile的。- 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用。
2. 理解auto类型推导
如果你已经读过前面item1的模板类型推导,那么你几乎已经知道了auto类型推导的大部分内容,至于为什么不是全部是因为有一个例外。
先看下面相同部分,也分三个情形:
- 情形一:类型说明符是一个指针或引用
- 情形二:类型说明符是一个右值引用(&&)
- 情形三:类型说明符既不是指针也不是引用
1 | auto x = 27; //情景三(x既不是指针也不是引用) |
讨论完相同点接下来就是不同点,前面我们已经说到auto类型推导和模板类型推导有一个例外使得它们的工作方式不同,接下来我们要讨论的就是那个例外。
先看下面这个简单示例,截至目前我们对一个变量的初始化有4中形式,如下:
1 | auto x1 = 27; //类型是int,值是27 |
当用auto声明的变量使用花括号进行初始化,auto类型推导推出的类型则为std::initializer_list。而对于模板类型推导这样行不通:
1 | auto x = { 11, 23, 9 }; //x的类型是std::initializer_list<int> |
然而如果在模板中指定T是std::initializer_list<T>而留下未知T,模板类型推导就能正常工作:
1 | template<typename T> |
因此auto类型推导和模板类型推导的真正区别在于,auto类型推导假定花括号推导为std::initializer_list类型而模板类型推导不会这样(确切的说是不知道怎么办)。
知道了上面这个不同,我们接下来看C++14允许auto用于函数返回值,而且C++14的lambda函数也允许在形参声明中使用auto。但是在这些情况下auto实际上使用模板类型推导的那一套规则在工作,而不是auto类型推导。所以下面代码不会通过编译:
1 | auto createInitList() |
1 | std::vector<int> v; |
请记住:
auto类型推导通常和模板类型推导相同,但是auto类型推导假定花括号初始化代表std::initializer_list,而模板类型推导不这样做。- 在C++14中
auto允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是auto类型推导。
3. 理解decltype
在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。
1 | // 返回值decltype(auto)实际上我们可以这样解释它的意义:auto说明符表示这个类型将会被推导,decltype说明decltype的规则将会被用到这个推导过程中。 |
注意decltype(exp) var;有个特例就是如果exp是被()包围,那么推导的类型就是exp的引用。
1 | decltype(auto) f2() |
请记住:
decltype总是不加修改的产生变量或者表达式的类型。- 对于
T类型的不是单纯的变量名的左值表达式,decltype总是产出T的引用即T&。- C++14支持
decltype(auto),就像auto一样,推导出类型,但是它使用decltype的规则进行推导。
4. 学会查看类型推导结果
查看类型推导结果一般是下面这几种方式:
- 编辑器IDE断点或报错日志查看
- 运行时输出,使用
typeid(x).name() - 使用Boost TypeIndex库
但是有时你要注意,它们不一定都正确,看下面示例:
1 |
|
上面模板函数的T和param的类型输出都是int const *,这明显是错误的,它们不应该是相同的,T应该是int。
请记住:
- 类型推断可以从IDE看出,从编译器报错看出,从Boost TypeIndex库的使用看出。
- 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的。
二、auto
auto很简单,使用它可以存储类型,但是有时它也会犯一些错误,而且比之手动声明一些复杂类型也会存在一些性能问题。所以我们有必要知道auto的里里外外。
5. 优先考虑auto而非显式类型声明
下面列举些优先使用auto的原因:
- auto声明变量必须初始化,不会存在未初始化的变量。
1 | int x1; //潜在的未初始化的变量 |
- 更加简洁、简单。
1 | template<typename It> |
- 避免一些潜在类型转换问题。
1 | // 传统写法,这个把int类型赋值给了一个unsigned类型。 |
当然使用auto也会带来一些问题,如对源码可读性的影响和item2/6讨论的一些点。而我的观点是在理解的前提下,能用auto的地方就都用它吧。
请记住:
6. auto推导若非己愿,使用显式类型强转
auto在item5我们建议是尽可能使用,但是有个特殊情况需要注意,就是对于返回代理类的场景,auto推导就不正确了。代理类是对现有类型的一种封装,使这个原始类型在特定场景操作更加方便,如智能指针就是原始指针的代理类。
1 | std::vector<bool> features(const Widget& w); |
所以像上面这种代理类型就不能使用auto去推导了,一般两种方式处理:
- 不使用auto类型推导,明确指明类型
1 | bool highPriority = features(w)[5]; //这会隐式将std::vector<bool>::reference类型转换为bool类型 |
- 使用auto,加类型强转
1 | auto highPriority = static_cast<bool>(features(w)[5]); // highPriority也会推导为bool类型 |
请记住:
- 不可见的代理类可能会使
auto从表达式中推导出“错误的”类型。- 显式类型初始器惯用法(static_cast)强制
auto推导出你想要的结果。
三、移步现代C++
说起知名的特性,C++11/14有一大堆可以吹的东西,auto,智能指针,移动语义,lambda,并发 。每个都是如此的重要,这章将覆盖这些内容。掌握这些特性是必要的,要想成为高效率的现代C++程序员需要小步迈进。
7. 区别使用()和{}创建对象
对于一个变量的初始化,有下面这么多的形式:
1 | int x(0); //使用圆括号初始化 |
而像容器要初始化元素,使用{}很直观
1 | std::vector<int> v{ 1, 3, 5 }; //v初始内容为1,3,5 |
还有现在可以直接给非静态的数据成员指定默认初始值,但是()形式不行。
1 | class Widget{ |
再者,std::atomic对象初始化不能用=形式。
1 | std::atomic<int> ai1{ 0 }; //没问题 |
再看一个示例,{}形式初始化还能检测类型变窄问题。
1 | double x, y, z; |
还有如要调用不带参数构造函数,使用()会认为是一个声明,使用{}则没这种问题。
1 | Widget w2(); //最令人头疼的解析!声明一个函数w2,返回Widget |
综上所述,使用{}初始化是适应性最全面,而且还能避免类型变窄和令人头疼的解析。所以大部分情况我是推荐使用统一的{}风格来初始化对象的。
不过有个场景你需要了解,**{}初始化返回的其实是std::initializer_list模板类型对象**,在item2我们有详细介绍。所以{}作为实参调用重载函数接口,一定会优先匹配带std::initializer_list参数的接口。
1 | class Widget { |
1 | Widget w1(10, true); //使用圆括号初始化,调用第一个构造函数 |
不过如果{}参数类型与std::initializer_list类型无法隐式转换,则会去匹配其它重载方法。
1 | class Widget { |
最后,一个关于容器使用()与{}初始化的区别要注意,这其实是设计的缺陷,我们自己设计应该尽量避免。
1 | std::vector<int> v1(10, 20); //使用非std::initializer_list构造函数 |
请记住:
- 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性。
- 在构造函数重载决议中,编译器会尽最大努力将括号初始化与
std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择。- 对于数值类型的
std::vector来说使用花括号初始化和圆括号初始化会造成巨大的不同。- 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。
8. 优先考虑nullptr而非0和NULL
这点就没太多讨论的了,传统C++98用NULL表示空指针,其实就是一个宏定义,本质就是0,这会与整型0产生歧义。在c++11我们解决了这个问题,用nullptr来表示任意类型的空指针。
请记住
优先考虑
nullptr而非0和NULL。
9. 优先考虑别名声明而非typedef
关于为什么优先考虑使用using别名声明,我列出了下面几点原因:
- 更直观,便于理解
1 | typedef |
1 | // 尤其是函数指针取别名时 |
- 在模板中,using可以模板化但typedef不能,这时使用using明显容易很多。
1 | template<typename T> //MyAllocList<T>是 |
使用typedef,你就只能从头开始:
1 | template<typename T> //MyAllocList<T>是 |
请记住
优先考虑使用using取别名替代typedef
10. 优先考虑限域枚举而非未限域枚举
不限域枚举作用域属于包含enum的作用域,可能导致命名污染。
1 | enum Color { black, white, red }; //black, white, red在Color所在的作用域 |
再来看看限域枚举,就解决了这个问题。
1 | enum class Color { black, white, red }; //black, white, red 限制在Color域内 |
使用限域enum可以减少命名空间污染。而且限域enum还有第二个吸引人的优点:在它的作用域中,枚举名是强类型,不接受隐式转换。未限域enum中的枚举名会隐式转换为整型。
1 | enum class Color { black, white, red }; //Color现在是限域enum |
如果你非要使用,就只能显式类型强转了。
1 | if (static_cast<double>(c) < 14.5) { //奇怪的代码, |
还有限域enum可以前置声明,它默认底层类型是int,当然你也可以指定修改。而非限域enum底层类型默认根据枚举值选最大的那个类型作为底层类型,需要定义了才能确认底层类型,所以它不支持前置声明。但是它也可以手动指定类型,这样之后也可以前置声明了,这里本质是要提前确定底层类型。
1 | enum class Status; //前置声明,默认底层类型是int |
1 | // 手动指定底层类型,非限域enum指定也是一样 |
请记住
- C++98的
enum即非限域enum。- 限域
enum的枚举名仅在enum内可见。要转换为其它类型只能使用cast。- 非限域/限域
enum都支持底层类型说明语法,限域enum底层类型默认是int。非限域enum没有默认底层类型。- 限域
enum总是可以前置声明。非限域enum仅当指定它们的底层类型时才能前置。
11. 优先考虑使用deleted函数而非使用未定义的私有声明
在C++98时,我们要屏蔽某些成员函数不让调用,一般做法是声明为私有且不去实现它们。
1 | template <class charT, class traits = char_traits<charT> > |
如果内部或友元函数调用了它们就会在链接时引发缺少函数定义的错误。
在C++11中有一种更好的方式,用= delete将函数标记为删除,这样如果有地方调用了它,直接在编译时就会报错,比传统方式报错提前了而且报错信息更加友好。
1 | template <class charT, class traits = char_traits<charT> > |
通常,deleted函数被声明为public而不是private,这是有原因的,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private的错误,这样报错信息就不那么准确了。
deleted函数还有一个重要的优势是任何函数都可以标记为deleted,比如假设我们有这样一个函数,
1 | bool isLucky(int number); |
只有传int时才是有意思的,其它类型调用默认会隐式转换,但是可能没有意义。
1 | if (isLucky('a')) … //字符'a'是幸运数? |
如果幸运数必须是整型,我们该禁止这些调用通过编译。
1 | bool isLucky(int number); //原始版本 |
请记住:
- 比起声明函数为
private但不定义,使用deleted函数更好。- 任何函数都能被删除,包括非成员函数和模板实例。
12. 使用override声明重写函数
所谓重写即派生类的虚函数重写基类同名函数。令人遗憾的是虚函数重写可能一不小心就错了。
比如,下面的代码是完全合法的,咋一看,还很有道理,但是它没有任何虚函数重写——没有一个派生类函数重写了基类函数。你能识别每种情况的错误吗,换句话说,为什么派生类函数没有重写同名基类函数?
1 | class Base { |
mf1在Base基类声明为const,但是Derived派生类没有这个常量限定符mf2在Base基类声明为接受一个int参数,但是在Derived派生类声明为接受unsigned int参数mf3在Base基类声明为左值引用限定,但是在Derived派生类声明为右值引用限定mf4在Base基类没有声明为virtual虚函数
所以从上就可以看出,我们要重写虚函数,传统方式其实很容易犯错。不过现在C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override。
1 | class Derived: public Base { |
这样,如果不是重写,就会编译报错。
最后,与override对应的还有一个关键字final,向虚函数添加final可以防止派生类重写。也能用于类,这时这个类不能用作基类。
请记住:
- 为重写函数显示加上
override,防止想重写而实际没重写错误。
13. 优先考虑const_iterator而非iterator
const_iterator 和 iterator 是用于访问容器元素的两种不同类型的迭代器(其实就是指向容器元素的指针)。它们的主要区别在于是否允许修改容器中的元素。
这里优先考虑const_iterator与const的使用目的是一样的,就是当你不需要修改容器元素时,你就应该用const_iterator,防止意外修改,这种做法有助于防止一些常见的编程错误,提高代码的可读性和可维护性。
请记住:
- 优先考虑
const_iterator而非iterator
14. 如果函数不抛异常请使用noexcept
如果你能明确函数不会抛异常,把它标记为noexcept,可以帮助编译器更好的生成优化的代码。
请记住:
noexcept是函数接口的一部分,这意味着调用者可能会依赖它。noexcept函数较之于non-noexcept函数更容易优化。noexcept对于移动语义,swap,内存释放函数和析构函数非常有用(这些操作如果失败一般是不可逆的,所以要保证它们不抛异常)。- 大多数函数是可能抛也可能不抛异常,所以大部分函数一般不加
noexcept
15. 尽可能的使用constexpr
关键就是它保证对象或函数返回值在编译期间求值或计算,提高了运行效率。
请记住:
- 所有
constexpr对象都是const,但不是所有const对象都是constexpr,它被在编译期可知的值初始化。- 当传递编译期可知的值时,
constexpr函数可以产出编译期可知的结果。constexpr对象和函数可以使用的范围比non-constexpr对象和函数要大。constexpr是对象和函数接口的一部分(这和上面noexcept一样)。
16. 让const成员函数线程安全
主要是讨论多线程环境下如何保证线程安全的话题,其实与const没太多关系。。。
这里对于保证线程安全提供了两种思路:
- 使用C++11引入的智能锁
std::lock_guard,创建时上锁,离开创建的作用域后自动解锁。
1 | class Polynomial { |
- 单一变量互斥使用
std::atomic开销更小,性能更高。
1 | class Point { //2D点 |
请记住:
- 确保
const成员函数线程安全,除非你确定它们永远不会在并发上下文(concurrent context)中使用。- 使用
std::atomic变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。
17. 理解特殊成员函数的生成
特殊成员函数是指C++自己生成的函数。C++98有四个:默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。C++11新增了两个:移动构造函数和移动赋值运算符。
这些函数仅在需要的时候才会生成,生成的特殊成员函数是隐式public且inline。
1 | class Widget { |
请记住:
- 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
- 移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
- 拷贝构造函数仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete。拷贝赋值运算符仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete。当用户声明了析构函数,拷贝操作的自动生成会被废弃。
- 成员函数模板不抑制特殊成员函数的生成。
四、智能指针
原始指针是强大的工具,当然,另一方面几十年的经验证明,只要注意力稍有疏忽,这个强大的工具就会攻击它的主人。
智能指针是解决这些问题的一种办法。智能指针包裹原始指针,它们的行为看起来像被包裹的原始指针,但避免了原始指针的很多陷阱。你应该更倾向于智能指针而不是原始指针。几乎原始指针能做的所有事情智能指针都能做,而且出错的机会更少。
在C++11中存在四种智能指针:std::auto_ptr,std::unique_ptr,std::shared_ptr, std::weak_ptr。
std::auto_ptr是来自C++98的已废弃遗留物,它是一次标准化的尝试,后来变成了C++11的std::unique_ptr。要正确的模拟原生指针需要移动语义,但是C++98没有这个东西。取而代之,std::auto_ptr拉拢拷贝操作来达到自己的移动意图。这导致了令人奇怪的代码(拷贝一个std::auto_ptr会将它本身设置为null!)和令人沮丧的使用限制(比如不能将std::auto_ptr放入容器)。
std::unique_ptr能做std::auto_ptr可以做的所有事情以及更多。它能高效完成任务,而且不会扭曲自己的原本含义而变成拷贝对象。在所有方面它都比std::auto_ptr好。现在std::auto_ptr唯一合法的使用场景就是代码使用C++98编译器编译。除非你有上述限制,否则你就该把std::auto_ptr替换为std::unique_ptr而且绝不回头。
18. 对于独占资源使用std::unique_ptr
std::unique_ptr智能指针没有拷贝操作,独占资源,它很轻量级,大小等同于原始指针。在工厂函数返回指针对象的场景中常用,它可以很方便的转换为std::shared_ptr共享指针。
请记住:
std::unique_ptr是轻量级、快速的、只可移动的管理专有资源的智能指针。- 将
std::unique_ptr转化为std::shared_ptr非常简单。
19. 对于共享资源使用std::shared_ptr
std::shared_ptr共享智能指针允许多个std::shared_ptr对象指向同一块内存资源。
std::shared_ptr通过引用计数来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr指向该资源。std::shared_ptr通常是构造函数递增引用计数值,析构函数递减值,拷贝赋值运算符做前面这两个工作。(如sp1和sp2是std::shared_ptr并且指向不同对象,赋值“sp1 = sp2;”会使sp1指向sp2指向的对象。直接效果就是sp1引用计数减1,sp2引用计数加1。)如果std::shared_ptr在计数值递减后发现引用计数值为零,没有其他std::shared_ptr指向该资源,它就会销毁资源。
std::shared_ptr相对std::unique_ptr更耗性能,它大小更大,内部多了引用计数对象,并且计数的增减必须保证是原子性的,而原子操作通常比非原子操作要慢。
下面是一个非常简单的示例,实际的 shared_ptr 实现比这要复杂得多,因为它需要考虑线程安全、循环引用等。
1 | template <typename T> |
接下来我们看一个这样的使用示例:
1 | auto pw = new Widget; //pw是原始指针 |
用一个原始指针来创建两个shared_ptr对象,这很糟糕,*pw有两个引用计数值,每一个最后都会变成零,然后最终导致*pw销毁两次,第二个销毁会产生未定义行为。
针对这种情况我们的建议是永远不要直接使用原始指针来创建std::shared_ptr,而应该使用std::make_shared来创建,如果你非要使用原始指针创建,请使用new出来的结果。
1 | std::shared_ptr<Widget> spw1(new Widget); //直接使用new的结果 |
请记住:
std::shared_ptr为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。- 较之于
std::unique_ptr,std::shared_ptr对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。- 避免从原始指针变量上创建
std::shared_ptr。
20. 当std::shared_ptr可能相互引用时使用std::weak_ptr
std::weak_ptr通常与std::shared_ptr配合一起使用,可以shared_ptr对象赋值给weak_ptr对象,它不会导致计数器加减,类似一个弱引用。主要用于解决多个std::shared_ptr对象相互引用无法释放的问题。
1 |
|
请记住:
std::weak_ptr的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr环状结构。std::weak_ptr的效率和std::shared_ptr相当,它们控制块基本相同,虽然它不影响计数,但是它内部有另一个计数对象。
21. 优先考虑使用std::make_unique和std::make_shared而非直接使用new
为什么优先考虑使用make_xx,优点:
- 比使用new创建
xx_ptr代码更简洁,消除了重复代码。
1 | auto upw1(std::make_unique<Widget>()); //使用make函数 |
- 能避免函数多个实参因构造顺序,某个参数构造异常导致的内存泄露问题。
1 | // 接口原型 |
- 它效率比new更高。
1 | // 直接使用new需要为Widget进行一次内存分配,为控制块再进行一次内存分配。 |
当然有一些特殊场景使用make_xx并不是很好,需要注意:
- 它不能自定义删除器,而直接使用new可以。
1 | // 自定义删除器 |
- vector希望用{}花括号初始化。(make_xx默认是用(),但是也可以通过auto转换后再使用)
1 | // 创建了有10个值为20的数组 |
- 自定义内存管理场景,内存系统很大,需要自己控制。~当然这点很边缘,实际用到很少。
综上,我整体还是推荐优先使用make_xx来创建智能指针,只是一些特殊场景需要我们了解注意下。
请记住:
- 和直接使用
new相比,make函数消除了代码重复,提高了异常安全性。对于std::make_shared和std::allocate_shared,生成的代码更小更快。- 不适合使用
make函数的情况包括需要指定自定义删除器和希望用花括号初始化。- 对于
std::shared_ptr,其他不建议使用make函数的情况包括: (1)有自定义内存管理的类;(2)特别关注内存的系统,非常大的对象,以及std::weak_ptr比对应的std::shared_ptr活得更久。
22. 用智能指针形式使用Pimpl惯用法
什么是Pimpl惯用法?它是减少类实现和类使用者之间编译依赖的一种技巧。通过将类数据成员替换成一个指向包含具体实现的类(或结构体)的指针,并将数据成员移动到这个实现类去,而这些数据成员的访问将通过指针间接访问。
1 | class Widget() { //定义在头文件“widget.h” |
类Widget的数据成员包含有类型std::string,std::vector和Gadget, 定义有这些类型的头文件在类Widget编译的时候,必须被包含进来,这意味着类Widget的使用者必须要#include <string>,<vector>以及gadget.h。 这些头文件将会增加类Widget使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget使用者也必须要重新编译。 这也就是我们为什么建议封装类时,头文件要只包含必要的include的原因:减少依赖,提高编译速度。
如何解决这个问题,我们先用传统C++98方式使用Pimpl技巧:
1 | class Widget //仍然在“widget.h”中 |
1 |
|
这样我们就把类的数据成员封装到它的实现里面了,减少了自己头文件里的包含文件,这样这个类提供给别人使用时,依赖也就更少了,提高了编译速度。
下面再来看看使用现代C++如何实现Pimple:
1 |
|
1 |
|
1 |
|
使用std::unique_ptr当然是自然合理的,而且效率更高,但是你也看到,需要把特殊函数都定义一遍,感觉麻烦的话,可以用std::shared_ptr,它就不需要定义特殊函数了,编译时也不会报错。(原因是std::unique_ptr与std::shared_ptr内部默认的析构行为不一样导致)
1 | class Widget { //在“widget.h”中 |
1 | Widget w1; |
请记住:
- Pimpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
- 对于
std::unique_ptr类型的pImpl指针,需要在头文件的类里声明特殊的成员函数,在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。- 而
std::shared_ptr则不需要声明特殊函数,没有谁好,看自己选择。
五、右值引用,移动语义,完美转发
移动语义:一般用于拷贝函数,传参由原本的构造复制操作改为右值移动操作。
完美转发:传递一个对象到另外一个函数,保留它原有的左值属性或右值属性。
右值引用:它是使移动语义和完美转发变得可能的基础语言机制。
在本章的这些小节中,非常重要的一点要牢记形参永远是左值,即使它的类型是一个右值引用。比如,
1 | void f(Widget&& w); // 形参w是一个左值 |
23. 理解std::move和std::forward
std::move底层只是做类型转换,返回一个右值,无条件转换。移动语义一般就是通过它来传递右值调用特殊的移动拷贝函数,但是有种情况即使你传递了右值也不一定会调用移动函数。
1 | class string { |
这是因为,移动函数只能接收非const的右值引用参数,但是这个const的右值可以绑定到const的引用上,所以会调用拷贝构造函数。
接下来,我们再看std::forward这个,它也是做转换,不过是有条件的。它转换的条件是:它的实参用右值初始化时,转换为一个右值。理解这个看下面示例:
1 | void process(const Widget& lvalArg); |
1 | Widget w; |
请记住:
std::move执行得到右值无条件的转换,但就自身而言,它不移动任何东西。std::forward只有当它的参数被绑定到一个右值时,才将参数转换为右值。std::move和std::forward在运行期什么也不做,只是转换。
24. 区别通用引用和右值引用
什么是通用引用,它表现上像右值引用(即T&&),但是它可以绑定到左值上,也可以绑定到右值上。此外,它还可以绑定到const或者non-const的对象上。它们可以绑定到几乎任何东西上。这种空前灵活的引用值得拥有自己的名字,我把它叫做通用引用。还有一些C++社区的成员已经开始将这种通用引用称之为转发引用。
通用引用常见的场景:
1 | template<typename T> |
这两种情况的共同之处就是都存在类型推导,它们是左值引用还是右值引用取决于它们的初始值是左值还是右值。
1 | template<typename T> |
除此之外,如果类型声明的形式不是标准的type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用。
请记住:
- 如果一个函数模板形参的类型为
T&&,并且T需要被推导得知,或者如果一个对象被声明为auto&&,这个形参或者对象就是一个通用引用。- 如果类型声明的形式不是标准的
type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用。- 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用。
25. 对于右值引用使用std::move,对于通用引用std::forward
这条没什么说的,就是在右值引用上使用std::move,在通用引用上使用std::forward。不要反正来。
26. 避免重载通用引用
使用通用引用做参数很优雅,外部参数可以是左值,右值,它自动匹配。
1 | template<typename T> |
但是注意不要重载通用引用形参的函数,为什么?引用通用引用参数匹配比你想象的要广泛,如果不是精准匹配你添加的重载版本,那它就会优先匹配通用引用版本。
1 | //新的重载版本 |
如果客户是想调用整数版本,但是传参却给了一个short类型,那么就会有问题了,它会匹配通用引用。
1 | short nameIdx; |
而且,构造函数中也注意不要用通用引用完美转发形式,它会劫持一些默认生成函数。
1 | class Person { |
请记住:
- 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
- 完美转发构造函数是糟糕的实现,因为对于non-
const左值,它们比拷贝构造函数更优先匹配,而且会劫持派生类对基类的拷贝和移动构造函数的调用。
27. 熟悉重载通用引用的替代品
上节讲了为什么要避免重载通用引用形参的函数,不过有些场景可能需要重载,那么我们有那些合理的替代方案了?
放弃重载,直接定义不同的函数名。
不使用通用引用参数,使用const T&替代。
使用传值方式。
使用tag dispatch
前面都是常规方式,去掉了通用引用,如果你还是想用通用引用又要重载,可以参考本条实现,还是沿用item26例子。
1 | // 对外还是用通用引用 |
1 | // 非整数版本,std::false_type是编译期的false类型 |
1 | // 整数版本,std::true_type是编译期的true类型 |
约束使用通用引用的模板
使用tag dispatch方式无法应对带通用引用形参的构造函数,因为有些对构造函数的调用也被编译器自动生成的函数处理,绕过了分发机制。这时我们可以使用
std::enbale_if来有条件的约束通用引用模板。
1 | class Person { |
请记住:
- 通用引用和重载的组合替代方案包括使用不同的函数名,通过
const T&传递形参,按值传递形参,使用tag dispatch。- 通过
std::enable_if约束模板,允许组合通用引用和重载使用,但它也控制了编译器在哪种条件下才使用通用引用重载。- 通用引用参数通常具有高效率的优势,但是可用性就值得斟酌。
28. 理解引用折叠
概念性东西,没啥实际指导意义,了解即可。
29. 认识移动操作的缺点
存在几种情况,C++11的移动语义并无优势:
- 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
- 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快(短字符串string)。
- 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为
noexcept(容器操作)。
请记住:
- 假定移动操作不存在,成本高,未被使用。
- 在已知的类型或者支持移动语义的代码中,就不需要上面的假设。
30. 熟悉完美转发失败的情况
请记住:
- 当模板类型推导失败或者推导出错误类型,完美转发会失败。
- 导致完美转发失败的实参种类有花括号初始化,作为空指针的
0或者NULL,仅有声明的整型static const数据成员,模板和重载函数的名字,位域。
六、Lambda表达式
这个题在以前的C++11语言特性中已经列举比较清楚了,这里不再详细探讨,就列举下注意建议。
31. 避免使用默认捕获模式
请记住:
- 默认的按引用捕获可能会导致悬空引用。
- 默认的按值捕获对于悬空指针很敏感(尤其是
this指针),并且它会误导人产生lambda是独立的想法。
32. 使用初始化捕获来移动对象到闭包中
在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptr或std::future)要进入到闭包里,如果你要复制的对象复制开销非常高,但移动的成本却不高。
c++14:
1 | std::vector<double> data; //要移动进闭包的对象 |
c++11:
1 | std::vector<double> data; //同上 |
请记住:
- 使用C++14的初始化捕获将对象移动到闭包中。
- 在C++11中,通过手写类或
std::bind的方式来模拟初始化捕获。
33. 对于std::forward的auto&&形参使用decltype
1 | auto f = |
请记住:
- 对
auto&&形参使用decltype以std::forward它们。
34. 优先考虑lambda表达式而非std::bind
std::bind常规使用:
1 |
|
请记住:
- 与使用
std::bind相比,lambda更易读,更具表达力并且可能更高效。- 只有在C++11中,
std::bind可能对实现移动捕获或绑定带有模板化函数调用运算符的对象时会很有用。
七、并发API
C++11开始把并发整合到语言标准库中了,开发者首次通过标准库可以写出跨平台的多线程程序。
35. 优先考虑基于任务的编程而非基于线程的编程
我们要异步执行一个函数,通常有两种方式:
其一,基于线程的方式,通过创建std::thread执行,
1 | int doAsyncWork(); |
其二,基于任务的方式,通过std::async,
1 | auto fut = std::async(doAsyncWork); |
那么为什么优先考虑基于任务的编程而非基于线程的编程了?
下面列举几点观点:
- 基于任务的方式有返回值,返回一个
std::future对象,有get方法,可以使用它来获取异步操作的返回值,而基于线程的方式则不行。 - 基于线程的方式创建线程,系统资源是有限的,需要手动管理好线程,可能会遇到资源超额的麻烦,而使用
std::async则可以很大程度避免这类问题,它不需要我们手动管理,C++标准库的开发者已经帮我们很好的考虑了。
不过,仍然存在一些场景直接使用std::thread会更有优势:
- 你需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads或者Windows threads)来实现的,系统级API通常会提供更加灵活的操作方式(举个例子,C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问,
std::thread对象提供了native_handle的成员函数,而std::future(即std::async返回的东西)没有这种能力。 - 你需要且能够优化应用的线程使用。举个例子,你要开发一款已知执行概况的服务器软件,部署在有固定硬件特性的机器上,作为唯一的关键进程。
- 你需要实现C++并发API之外的线程技术,比如,C++标准中未实现的线程池技术。
请记住:
std::threadAPI不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。- 基于线程的编程方式需要手动处理线程耗尽、资源超额、负责均衡、平台适配性管理。
- 通过带有默认启动策略的
std::async进行基于任务的编程方式会解决大部分问题。
36. 如果有异步的必要请指定std::lauch::async
std::async异步执行有两种方式,通过std::launch这个限域enum枚举名来指定,原型:
1 | auto future = std::async(std::launch::async | std::launch::deferred, func); |
std::launch::async启动策略意味着f必须异步执行,即在不同的线程。std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。
而如果直接使用的默认方式auto fut = std::async(f); 不指定,那么它的行为这两种都有可能,所以这样会存在不可预测的结果。
请记住:
std::async的默认启动策略是异步和同步执行兼有的。- 这个灵活性导致访问
thread_locals的不确定性,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait的程序逻辑。- 如果异步执行任务非常关键,则指定
std::launch::async。
37. std::threads最后一定要调用join或detach
至于为什么要在std::threads创建使用完后调用join或detach,这与底层设计相关,这里不深入讨论。
我们只需要知道这个原则就好,那么怎么让你一定能遵守这个规则不犯错了?那就只能把这个操作交给RAII对象实现:
1 | class ThreadRAII { |
这样对象析构时就会自动调用了。
请记住:
- 在所有路径上保证
thread最终是不可结合的(即析构前都调用了join或detach)。- 声明类数据成员时,最后声明
std::thread对象。(因为std::thread对象可能会在初始化结束后就立即执行函数了,所以在最后声明是一个好习惯。)
38. 关注不同线程句柄析构行为
了解下差不多了~没啥实质用!
39. 考虑对于一次性的事件通信使用std::promise
类似这种场景:某个线程等待另一个线程事件发生才继续执行。通常的方式有:
- 使用条件变量
1 |
|
1 | main() signals data ready for processing |
在很多情况下,使用条件变量进行任务通信非常合适,不过条件变量的使用必须配合互斥锁。
- 使用
std::atomic原子锁
1 | // 检测任务 |
1 | // 反应任务 |
这种方法不存在基于条件变量的设计的缺点。不需要互斥锁,比互斥锁高效。不好的一点是反应任务中轮询的开销。在任务等待flag被置位的时间里,任务基本被阻塞了,但是一直在运行。这样,反应线程占用了可能给另一个任务使用的硬件线程,每次启动或者完成它的时间片都增加了上下文切换的开销,并且保持核心一直在运行状态,本来可以停下来省电。一个真正阻塞的任务不会发生上面的任何情况。这也是基于条件变量的优点,因为wait调用中的任务真的阻塞住了。
- 使用
std::promise和std::future
这种应该是最优雅的,通过std::promise的set_value发送信号,std::future的get阻塞等待信号。不过注意这对收发只能使用一次就释放了。
1 |
|
1 | 传入数据(int):233 |
请记住:
- 对于简单的事件通信,基于条件变量的设计需要一个多余的互斥锁,对检测和反应任务的相对进度有约束,并且需要反应任务来验证事件是否已发生。
- 基于flag的设计避免的上一条的问题,但是是基于轮询,而不是阻塞。
- 条件变量和flag可以组合使用,但是产生的通信机制很不自然。
- 使用
std::promise和future的方案避开了这些问题,但是这个方法使用了堆内存存储共享状态,同时有只能使用一次通信的限制。
40. 对于并发使用std::atomic,volatile用于特殊内存区
对于std::atomic的使用,上一节已经有使用示例了,用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
而volatile则是告诉编译器不要做内存优化,如:
1 | auto y = x; //读x |
编译器会对这种的代码做优化,最终生成如:
1 | auto y = x; //读x |
但是有时我们有这样的场景,x是对应外边IO通信的内存映射,每个值都是一条独立有含义的指令,如果优化掉了,相当于指令丢失了,这时可以用volatile告诉编译器“不要对这块内存执行任何优化”。
1 | volatile int x; |
这样,最后生成代码如:
1 | auto y = x; //读x |
请记住:
std::atomic用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。volatile用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。
八、微调
41. 对于那些可移动总是被拷贝的形参使用传值方式
这个题本质就是分析拷贝构造、移除构造、模板实例化膨胀这些效率的综合考虑。
1 | class Widget { //方法1:对左值和右值重载 |
考虑这两种调用方式:
1 | Widget w; |
方式1:左值一次拷贝,右值一次移动。
方式2:左值一次拷贝,右值一次移动。
方式3:左值一次拷贝一次移动,右值两次移动。
方式3不管是左值还是右值都多了一次移动,但是它的代码更简洁,只需要一个接口。对于这种移动开销小的可以考虑。
请记住:
- 对于可拷贝,移动开销低,而且无条件被拷贝的形参,按值传递效率基本与按引用传递效率一致,而且易于实现,还生成更少的目标代码。
- 按值传递会引起切片问题,所说不适合基类形参类型。(就是派生类对象传递给基类形参,派生类特有部分数据会截断丢失)
42. 容器添加考虑优先使用emplace_back而非push_back
一般emplace_back不会比push_back效率低,因为某些场景它可以少了临时对象的构造与析构。
1 | std::vector<std::string> vs; //std::string的容器 |
下面我们看看这个push_back都发送了什么:
- 一个
std::string的临时对象从字面量“xyzzy”被创建。这个对象没有名字,我们可以称为temp。temp的构造是第一次std::string构造。因为是临时变量,所以temp是右值。 temp被传递给push_back的右值重载函数,绑定到右值引用形参x。在std::vector的内存中一个x的副本被创建。这次构造也是第二次构造——在std::vector内部真正创建一个对象。(将x副本拷贝到std::vector内部的构造函数是移动构造函数,因为x在它被拷贝前被转换为一个右值,成为右值引用)- 在
push_back返回之后,temp立刻被销毁,调用了一次std::string的析构函数。
而使用emplace_back,它内部使用的完美转发,直接执行的第2步,没有临时对象的生成。
1 | vs.emplace_back("xyzzy"); //直接用“xyzzy”在vs内构造std::string |
所以建议容器相关操作优先使用emplace_xx的方式,原则上,它不会比push_xx方式效率差。
请记住:
- 原则上,置入函数有时会比插入函数高效,并且不会更差。