Effective Modern C++ 这本书一些自己觉得重要的段落的翻译。
Item 1:Understand template type deduction
理解模版类型的推导。
当复杂系统的使用者,不考虑系统是如何运作的,只考虑系统干了什么,这说明系统的设计有大问题,所以模版类型推断的出现是非常大的帮助。当程序员使用模版函数时,即使他自己本身也只有关于这个函数如何调用这个类型的参数的一个朦胧的概念,但他依然可以把这个类型的参数传递给函数,并获得满意的结果。
我有一个好消息和坏消息告诉你,但具体什么消息我就懒的翻译了。模版的类型推到是c++的auto的特性的基础,所以对与真正了解模版类型的推导是重要的。
给一个模版函数伪代码的小片段:
1 | template<typename T> |
在编译期间,编译器使用exper来推导出两个类型:T的类型和ParamType的类型,这两个类型经常是不同的,因为ParamType经常包含一些限定词,比如const,如下所示。
1 | template<typename T> |
这里,T被推导成int类型,但是ParamType的类型被推导为const int&。
我们很自然的联想到T被推导的类型和传递给模版函数的参数类型是一样的,比如上文x的类型是int,T也被推导成int,但有时候并不会如你所想。T被推导的类型不光取决于expr,而且还依赖于ParamType的构成,有如下三种情况:
- ParamType是一个指针或者引用,但不是universal reference(形如T&&)
- ParamType是一个universal reference
- ParamType既不是指针也不是引用
这三种情况我们会用上文的例子一一测试。
case 1
当ParamType是一个指针或者引用,但不是universal reference(形如T&&),类型的推导规则如下:
- 如果expr的类型是引用,忽略其引用
- 然后将expr的类型和ParamType进行模式匹配来推导T
1 | template<typename T> |
这里其实看这个例子就可以了,主要看第三个,可以看到r x类型是const int&,但是由于ParamType是一个引用,推导T时,就忽略了rx的引用,T的类型被推导为const int。
如果把ParamType的类型从T&改为const T&,会发生一点小变化。
1 | template<typename T> |
这里T被推导为int的原因是,因为param已经是是const type&了,所以没必要把T推导为const了。同样的,rx的&依然被忽略了。
param是指针类型的情况下,推导规则也是一样的。
case 2
考虑ParamType是一个universal reference的情况,其实这个搭配后续的完美转发一起介绍比较好,在深入应用c++11的笔记中有提到。
当ParamType声明称右值引用,但是纯如的参数是左值时会有些不同,这里简单介绍一下:
- 当expr是一个左值,T和ParamType都被推导成左值引用
- 当expr是一个右值,沿用case 1的规则
1 | template<typename T> |
case 3
第三种情况是ParamType既不是指针也不是引用。
这意味着,param将会是传进来的参数的一份新的副本,不论传进来的是什么,T的类型的推导规则如下:
- 如果expr是引用,则忽略&
- 如果忽略了&后,expr是一个const或者是volatile,同样忽略const和volatile
1 | template<typename T> |
这里我们注意到,const和volatile只有在ParamType是值时,才会忽略,在指针和引用时,const还是需要加入推导的过程的。但是我们考虑一下另一个情况,expr是一个指向const object的const pointer,同时expr被传递给一个值类型的param:
1 | template<typename T> |
当ptr传递给f时,会被拷贝给param,ptr本身将会被passed by value,通过case 3的规则,ptr 的constness会被忽略,param将会被推导为const char*,ptr指向的object的constness会被保留,ptr本身的constness在拷贝时会被忽略。
Array Arguments
主流的推导规则已经介绍了,但还是有一些情况有必要知道,那就是数组的类型和指针的类型是不一样的,即使有时候看上去他们是可以互换的。对这一情况最主要的证明是,在许多情况下,一个数组可以退化成指向他第一个元素的指针,如下所示:
1 | const char name[] = "J. P. Briggs"; // name's type is |
这里,可以看到ptrToName是由name初始化的,他们的类型分别是const char*和const char[13],可以看到是不一样的,但是因为退化规则,依然是可以编译通过的。
但是,当一个数组被传递给一个by-value的param时,会发生什么事呢?
1 | template<typename T> |
我们可以发现,没有默认的函数类型的形参是数组类型的,当然我们自定义的函数可以这么写,也是通过的,加入我们自定义一个函数,如下:
1 | void myFunc(int param[]); |
因为数组可以被当成指针来对待,这代表自定义函数也可以写成这样:
1 | void myFunc(int* param); // same function as above |
那么自然,当传递一个数组给模版函数推导时,数组也可以被当作指针对待。
1 | f(name); // name is array, but T deduced as const char* |
让我们的思路再转一个弯,如果是如下形式,会是怎么样呢?
1 | template<typename T> |
这里T被推导的类型是一个数组类型!它的类型包括数组的大小,这里T被推导成了const char[13],而ParamType被推导成了const char(&)[13]。
这也帮助模版可以推导数组中元素的个数,如下:
1 | // return size of an array as a compile-time constant. (The |
之后会提到constexpr会让结果在编译阶段就可以获得到,即结果可以用来参与声明了,使用如下:
1 | int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 }; // keyVals has 7 elements |
Function Arguments
在c++中,不光只有数组可以退化成指针,函数也可以,函数可以退化成函数指针,我们之前提到的数组推导的相关规则对函数也是通用的。
1 | void someFunc(int, double); // someFunc is a function; |
Item 2: Understand auto type deduction
上文我们已经聊过了auto的基础,接着来探讨一下auto的类型推导,其实之前在别的书上已经了解过了,之前的的笔记也有提到,先放出来,看看和之后翻译的是否相同:
- 当不声明为指针或引用时,auto的推导结果和初始化表达式抛弃引用和cv限定符后类型一致
- 当声明为指针或引用时,auto的推导结果将保持初始化表达式的cv属性
- auto的推导和函数模板参数的自动推导有相似之处
模版的推导包括模版,函数和参数,但是auto对这些都没有进行处理。但是没有关系,在模版类型推导和auto类型推导之间有非常直接的映射关系,并且非常有逻辑性。
在item 1中,模版类型的推导如下:
1 | template<typename T> |
在这里,编译器使用expr来推导T和ParamType的类型。
但是当一个变量使用auto进行声明时,auto扮演的角色就是模版中的T,而变量实际的类型就是ParamType。这就非常容易理解了,看一些例子:
1 | auto x = 27; |
这么一对比就十分清晰了。
之前对与模版函数的ParamType的不同有三种case,那么自然,使用auto进行声明的变量的实际类型的不同,也有三种case,三种case也相同,三种case的推导规则也相同,包括之前提到的数组和函数的退化,在使用auto中也是一样的。
如你所见,auto的类型推导和模版类型推导看上去结果是一样的,是一枚硬币的正反两面。
只有一种情况下是不同的,记下来进行介绍。
C++98给了我们两中语法选择,对int x进行初始化,假如给他赋值27:
1 | int x1 = 27; |
C++11支持额外两中语法:
1 | int x3 = { 27 }; |
但总而言之,结果就是初始化了一个int变量,它的值是27。
我们用auto来进行代替,如下所示:
1 | auto x1 = 27; |
令人惊讶的是,编译器执行之后,他们推导出的类型是不一样的。前两个结果很正常,一个int变量,值为27。但后两个定义了一个类型是std::initializer_list<int>
的变量,包含一个元素27。
这是因为auto的一个特殊规则导致的,当使用auto声明的变量由大括号包裹,推导的类型是std::initializer_list
,而T是不能推导为std::initializer_list<int>
的。
1 | auto x5 = { 1, 2, 3.0 }; // error! can't deduce T for |
所以在这个情况下,类型推导会失败,值得注意的是,这里发生了两种类型推导。一种是使用auto,x5’s的类型需要推导,因为有大括号,所以x5的类型推导为了std::initializer_list
,但是std::initializer_list
是一个模版,需要推导std::initializer_list<T>
中的的T,T的类型同样需要推导,这里就会发生失败,因为可以看到1,2,3.0不止包含了一个类型。
所以在初始化列表的这一情况下(大括号)下,对auto和模版函数的使用需要分开,如下:
1 | auto x = { 11, 23, 9 }; // x's type is |
所以,auto和模版两种类型推导之间的唯一区别就是auto把有大括号包裹的类型推断为std::initializer_list
,但是模版类型推导不会。
同时需要注意的是,在函数返回值和lambda参数时使用auto时,使用的是模版类型推导,而不是auto的类型推导(所以我个人目前的建议是,这两个情况尽量别用auto)。
Item 3: Understand decltype
decltype通常提取变量名的类型或者表达式的类型,C++11新增了decltype关键字,用来在编译时推导出一个表达式的类型: decltype(exp) exp表示一个表达式(expression):
1 | const int i = 0; // decltype(i) is const int |
这些没有什么可意外的。
在C++11中,decltype最常用的地方是声明一个返回值类型依赖于的参数的函数模版。假如我们需要编写一个支持提供下标访问元素([i])的容器的函数,函数返回值的类型要和直接使用[i]下标访问的返回值类型一样。
一个内部元素类型是T的容器使用operator[],通常返回一个T&,通常情况下,都是对的。然而,对于std::vector<bool>
,operator[]不会返回一个bool&,而是返回一个全新的对象,原因之后再说,这里需要注意的是,operator[]的返回值取决于容易本身。
有了decltype的帮助,实现起来就很容易了。在模版里最常用的就是用decltype配合auto来推导函数返回值的类型,虽然需要一些限制。
1 | // works, but requires refinement |
为什么这么写的原因在之前深入应用c++11的笔记中有提到,也是一步一步改过来的。
在这里,函数前面的auto不起到任何类型推断的作用,起到作用的是->之后的部分。
好消息是C++14之后,可以直接使用auto,而不需要后置的decltype来辅助推导函数的返回类型了,auto在这里确实起到了推导类型的最用,推导的是函数的返回值类型,也就是return之后的部分。
1 | // C++14 not quite correct |
这里会有一个问题,如下:
1 | std::deque<int> d; |
这里代码是编译不通过的,因为c[i]的类型是int&,然后auto的类型推导会去掉引用,所以返回值类型被推导为int,是一个右值,给右值赋值10肯定是不能编译通过的。
为了让代码如我们所想的一般工作,需要使用decltype来推导返回值的类型,让返回值值类型和c[i]表达式返回的值的类型相同。使用的方法如下,让我看着觉得比较别扭:
1 | // C++14 works, but still requires refinement |
翻译到这里我有点蚌不住了,这里的意思是auto代表哪个类型需要推导,而实际使用的推导规则使用的是decltype的。
decltype(auto)不光可以用在推导函数返回值的类型上,同样在声明变量时也可以用到:
1 | Widget w; |
接下来先介绍之前提到的使用decltype(auto)的限制,还是用之前的例子:
1 | template<typename Container, typename Index> |
这里传入的参数是一个容器的左值引用,不是const的,因为该函数需要修改容器,但这意味着我们无法传递右值给函数,因为右值不能传递给左值引用(除非是const 左值引用,但不是这里的情况)。
但是,传递给函数一个右值时需要考虑的。一个右值容器,作为一个临时对象,通常这个临时对象都会在调用authAndAccess函数结束后就销毁了,这代表着指向临时对象里元素的引用在函数结束后会变成悬垂引用。但是传递一个临时对象给函数还是有意义的,比如函数只是想对临时对象中的元素进行一个拷贝,如下:
1 | std::deque<std::string> makeStringDeque(); // factory function |
如果要支持上述的应用,这代表让函数authAndAccess接受左值和右值的实参都是必要的。这时候可以考虑重载,分别对参数是左值和右值时采取不同的方法,但这样我们就需要保留两个函数。避免保留两个函数的方法,就需要形参可以绑定左值和右值,这里就引出了universal references(我没找到合适的中文解释)和完美转发,后续会进一步介绍,改进后的函数如下,这是为什么是用完美转发之后再说,为了和条款25的建议是一致的:
1 | // c is now a universal reference |
对于变量的名字,虽然是一个左值,但是不会影响decltype的行为,变量是什么类型,就获得什么类型。对于左值表达式,decltype保证获取的类型一定是左值引用,但有一个例子需要进一步介绍一下:
1 | int x = 0; |
x是变量的名字,所以decltype(x)是int。但是对x包裹上一层括号就不一样了,decltype((x))是int&,这里x是一个左值,但是是变量的名字,则decltype(x)是int,但是(x)同样是左值,但不是变量的名字了,所以decltype((x))是int&,在变量名周围加上括号会改变decltype的结果。
这一点对与函数返回值的类型推导同样有影响,导致意想不到的后果,如下:
1 | decltype(auto) f1() |
auto的滥用是可怕的,如何正确得使用auto任重而道远。
Item 5: Prefer auto to explicit type declarations
优先使用auto而非显式类型声明。
这整个item介绍的,其实之前深入应用c++11的笔记有提到,我个人的理解如下(虽然可能和原文的有一些出入):
- 对于一些复杂的容器,或者容易的元素,类型使用显示声明会显得繁琐,这时候使用auto是很不错的
- 在模版编程过程中,考虑通解的情况下,auto和decltype可能会事半功倍
- 简化函数的定义
- 但我个人还是觉得能不用就不用(但前两种情况下使用还是不错的)
Item 6: Use the explicitly typed initializer idiom when auto deduces undesired types
当auto推导出非预期类型时应当使用显式的类型初始化。
对于这一点,就需要了解什么时候使用auto时,获得的类型并不是如我们所愿。举个例子,假如我们有一个函数,参数是Widget,返回值是std::vector<bool>
,每个bool代表Widget是否提供了相应的特性。
1 | std::vector<bool> features(const Widget& w); |
假设认为第5位代表Widget是否具有高优先级:
1 | Widget w; |
这段代码是没问题的,但假如我们用auto替代highPriority的显示声明。
1 | auto highPriority = features(w)[5]; // is w high priority? |
编译还是能通过的,但结构就不如愿了。因为这里auto推导出的类型不是bool(之前说过vector<bool>
返回的是bool,别的类型返回的是引用),推导出的类型std::vector<bool>::reference
(一个内嵌在std::vector<bool>
里的一个类)。
这些来这部分我直接搬了中文翻译……
std::vector<bool>::reference
存在是因为std::vector<bool>
是对 bool 数据封装的模板特化,一个bit对应一个 bool 。这就给 std::vector::operator[] 带来了问题,因为std::vector<T>
的 operator[] 应该返回一个 T& ,但是C++禁止bits的引用。没办法返回一个 bool& ,std::vector<T>
的 operator[] 于是就返回了一个行为上和 bool& 相似的对象。想 要这种行为成功,std::vector<bool>::reference
对象必须能在 bool& 的能处的语境中使用。
在std::vector<bool>::reference
对象的特性中,是他隐式的转换成 bool 才使得这种操作得以成功。(不是转换成 bool& ,而是 bool 。去解释详细的std::vector<bool>::reference
对象如何模拟一个 bool& 的行为有有些偏离主题,所以我们就只是简单的提一下这种隐式转换只是这种技术中的一部。)
在大脑中带上这种信息,再次阅读原先的代码:
1 | bool highPriority = features(w)[5]; // 直接显示highPriority的类型 |
这里,features 返回了一个std::vector<bool>
对象,在这里 operator[] 被调用。 operator[] 返回一个std::vector<bool>::reference
对象,这个然后隐式的转换成 highPriority 需要用来初始化的 bool 类型。于是就以features返回的std::vector<bool>
的第五个bit的数值来结束 highPriority 的数值,这也是我们所预期的。
和使用 auto 的 highPriority 声明进行对比:
1 | auto highPriority = features(w)[5]; // 推导highPriority的类型 |
调用 features 会返回一个临时的std::vector<bool>
对象。这个对象是没有名字的,但是对于这个讨论的目的,我会把它叫做 temp , operator[] 是在 temp 上调用的,std::vector<bool>::reference
返回一个由 temp 管理的包含一个指向一个包含bits的数据结构的指针,在word上面加上偏移定位到第五个bit。
highPriority 也是一个std::vector<bool>::reference
对象的一份拷贝,所以 highPriority 也在 temp 中包含一个指向word的指针,加上偏移定位到第五个bit。在这个声明的结尾, temp 被销毁,因为它是个临时对象。因此, highPriority 包含一个野指针,这也就是调用 processWidget 会造成未定义的行为的原因:
1 | processWidget(w, highPriority); // 未定义的行为,highPriority包含野指针 |
这中一个类的存在是为了模拟和对外行为和另一个类保持一致,被称为代理类。作为一个通用的法则,“不可见”的代理类不能和auto愉快的玩耍,因此要避免使用下面的代码形式:
1 | auto someVar = expression of "invisible" proxy class type; |
但是你怎么能知道代理类被使用呢?软件使用它们的时候并不可能会告知它们的存在。它们是不可见的,至少在概念上!这个就需要关注设计模式和文档了,没有别的特别好的方法。
显式的类型初始化原则涉及到使用 auto 声明一个变量,但是转换初始化表达式到 auto 想要的类型。下面就是一个强制 highPriority 类型是 bool 的例子:
1 | auto highPriority = static_cast<bool>(features(w)[5]); |
Item 7: Distinguish between () and {} when creating objects
通常情况下,使用括号,等号,大括号进行值的初始化,都只可以的,在多数情况下,等号搭配大括号也是可以的:
1 | int x(0); // initializer is in parentheses |
在本章节的剩余部分,我会忽略最后一种情况,因为C++通常把它看作单独使用大括号是一样的。
对等号的使用通常会误导C++的初学者。对于内置的int类型,是否使用等号的差别是显而易见的,但对于自定义的类型,区分初始化(initialization)和赋值(assignment)是重要的(括号和等号),因为不同情况下,调用的是不同的函数:
1 | Widget w1; // call default constructor |
对于初始化的语法,C++98有时候不能很好的表达。比如,初始化一个STL容器时,不能直接让容器包含一系列的元素集合。
为了处理这么多初始化语法带来的困惑,因为没有哪一种可以包含所有的初始化情况,C++11介绍了一种通解(uniform initialization)。这种通解就是基于大括号的,这也是为什么我更推荐braced initialization,uniform initialization是一种概念,而braced initialization是具体的实现。
有了大括号初始化的方式,容易的初始化就非常的得心应手:
1 | std::vector<int> v{ 1, 3, 5 }; // v's initial content is 1, 3, 5 |
大括号还可以用来给非静态数据成员定义默认值,这一方式C++11也可以用等号来解决,但小括号就不行:
1 | class Widget { |
另一方面,不允许拷贝的对象(比如std::atomic)就不能等号初始化,但是大括号和小括号是可以的:
1 | std::atomic<int> ai1{ 0 }; // fine |
这也可以很好理解为什么大括号的初始化方式可以被称为”uniform”。C++的三种初始化方式,只有大括号的方式可以在任何地方使用。
大括号初始化方式有一个新颖的特点就是,他禁止了内置数据类型的隐式窄转换,如果大括号中的表达式的值和声明的对象的类型不一致,会编译失败:
1 | double x, y, z; |
用小括号和等号就不会因为窄转换编译失败,因为这会打断太多历史遗留的代码:
1 | int sum2(x + y + z); // okay (value of expression truncated to an int) |
另一个大括号初始化值得注意的特点就是它对C++非常头痛的解析的免疫力。
这里我直接介绍书上的例子,就可以理解C++解析的头痛之处:
假如我们想获得一个Widget对象,切实参是10,如下定义即可。
1 | Widget w1(10); // call Widget ctor with argument 10 |
但假如我们定需要获得一个没有实参的对象Widget,如下定义就是错的,会返回一个函数w2,没有参数,返回值是Widget,属实是头大。
1 | Widget w2(); // most vexing parse! declares a function |
这时候大括号初始化就派上用处了,这样写就不会被误认为函数的声明。
1 | Widget w3{}; // calls Widget ctor with no args |
如上文所述,我们可以看到大括号初始化的这么多优点,为什么本单元的标题不是”Prefer braced initialization syntax”呢?
大括号初始化的缺点是因为一些伴随着他的出乎意料的行为。这些行为来源自大括号初始化器,std::initializer_lists
,构造器重载决议这三者异常纠结的关系,他们之间的关联会导致代码看上去会做一件事,但实际上做了别的事,比如item 2中提到的auto
1 | auto x = { 11, 23, 9 }; // x's type is |
对于构造器的调用,在参数不包括std::initializer_lists
的情况下,小括号和大括号的意思是一样的:
1 | class Widget { |
但是,如果一个或多个构造器的参数是std::initializer_lists
,使用大括号导致导致调用构造器时,就会更倾向于这种构造方式。特别是,如果有任何方式让编译器把大括号初始化的构造器改为使用std::initializer_lists
做参数的构造器,编译器会实现这个翻译过程。举个例子,如下所示:
1 | class Widget { |
w2和w4将会使用新新的构造器,尽管long double(比double精度再高点)相比于int,double在这里是更差的选择:
1 | Widget w1(10, true); // uses parens and, as before, |
甚至,一些需要使用拷贝构造函数和移动构造函数的情况,会被std::initializer_lists
构造器劫持:
1 | class Widget { |
编译器对于可以使用大括号初始化的偏执,有时候会有大问题,比如如下这个例子:
1 | class Widget { |
明明有更适合的构造器,但是偏不,一定要调用包含std::initializer_lists
的,这是会把10,5.0转换为bool类型的值,但是5.0不能直接转换成bool,需要窄转换,但是大括号中窄转换时禁止的,所以会报错。
但这种偏执在无路可走的情况下是不会触发的,毕竟强扭的瓜不甜,这是什么意思呢?如下所示:
1 | class Widget { |
可以看到这里没有调用包含std::initializer_lists
的构造器,因为int和bool无论如何也不能转换为string,那自然就不会调用了。
讲到这里,大部分情况也就介绍完了,但还有一个有意思的地方需要处理。假如调用空的大括号来初始化一个对象,他会使用默认的构造函数还是使用包含std::initializer_lists
的构造函数呢?
调用规则如下,看一下结果就好:
1 | class Widget { |
接受了这么多的关于大括号初始化的规则,你可能会怀疑这对我们日常接触的变成到底有多大的影响。超乎你的想象,有一个类我们就经常用到,那就是std::vector
。std::vector
同时拥有包含和不包含std::initializer_lists
的构造器,所以你是用小括号和大括号包裹数字进行初始化都是可以的:
1 | std::vector<int> v1(10, 20); // use non-std::initializer_list |
让我们回顾一下这些规则,从这些讨论中可以获得两个重要的启示。首先,对于一个类的作者而言,要让使用人员不管使用小括号还是大括号哪种调用方式,你因为是否包含std::initializer_lists
的重载函数都不会受到影响。换句话说,像std::vector
之前调用的方式在如今是被视为错误的设计,是需要避免的,还是尽可能的希望不管调用的方式是大括号还是小括号,调用的函数是一样的。
其实每当你需要给一个类添加一个函数的时候,如果是完全新的功能,都尽可能的不要让旧的案例因为新函数的添加,而放弃调用旧的函数,除非这两个函数的功能是重复的,这需要好好的审议。
其次,对于用户来说,在初始化新的对象时,你必须仔细选择小括号和大括号的调用方式。开发人员最终都会选择其中一种为默认的调用方式,另外一种在除非特定情况才会使用。有了上文的基础,对于初始化一个特性大小和初始值的vector,为什么要使用小括号也是一目了然的了。
对于模版的作者,这种问题更头大了,因为你不知道什么时候改用什么方式调用,举个例子:
1 | template<typename T, // type of object to create |
有两种方式可以调用,一种是小括号的,一种是大括号的:
1 | T localObject(std::forward<Ts>(params)...); // using parens |
选择小括号时,返回的是一个有10个20的vector,而使用大括号的话,只返回包含两个元素的vector。具体应该调用那种,作者也不知道,只有使用的人才知道。
这个问题标准库的std::make_unique
和std::make_shared
也遇到了,这些函数选择的方法就是内部调用的其实都是小括号,并在文档里告知了使用者。
Item 8: Prefer nullptr to 0 and NULL
0时int,不是指针,这是显而易见的。如果在上下文中,C++发现一个0只有一个指针指向他,他会把0翻译成空指针,但这只是权宜之策。C++原始的策略是0是一个int,而不是指针。
实际上,对于NULL来说也一样,只不过细节上有些不同,因为使用上允许把整书类型赋给NULL,不一定要是int。这种情况不常见,但不重要,因为此处的焦点不是NULL的确切类型,而是0和NULL都不是指针类型。
在C++98中,函数对于指针参数和整数参数的重载会带来意想不到的结果。传递0或者NULL给这类函数,永远都不会调用关于指针的重载:
1 | void f(int); // three overloads of f |
对于f(NULL)采取的行为的不确定性确实来自于NULL的类型,假如NULL被定义为0L,函数的调用将会不明确,因为long转化为int,bool,void*都是可以的。关于这一点,一件有趣的事情是,从源码上看起来我正在调用f(NULL)–空指针,但实际上执行的是f(一个整数类型)–不是空指针。这违反直觉的行为是导致C++98的程序员避免重载指针和整数类型。这个原则对于 C++11 依然有效,因为尽管有本条款的力荐,仍然还有一些开发者继续使用 0 和 NULL ,虽然 nullptr 是一个更好 的选择。
nullptr的优点是他不再是一个整数类型,但实话实说,他也不是一个指针类型,但可以把它理解成指向所有类型的指针。 nullptr 的类型实际上是std::nullptr_t
,std::nullptr_t
定义为 nullptr 的类型,这是一个完美的循环定义。std::nullptr_t
可以隐式的转换为所有的原始的指针类型,这使得nullptr 表现的像可以指向任意类型的指针。
调用重载函数f(nullptr)将会调用f(void*),因为nullptr不能被看作任何整数类型:
1 | f(nullptr); // calls f(void*) overload |
使用nullptr代替0或者NULL,不光可以避免重载函数的意外行为,同时,还可以让代码更明确,特别是有auto变量存在时,比如:
1 | auto result = findRecord( /* arguments */ ); |
直接看这段代码,我们无法清楚得知道findRecord返回的是什么,result可能是指针也可能是整数类型。毕竟, 0 (被用来测试 result 的)即可以当做指针也可以当做整数类型。另一方面,你如果看到下面的代码:
1 | auto result = findRecord( /* arguments */ ); |
这里就没有歧义了,result必须是一个指针类型。
nullptr在模版中的作用更大。假如你有一系列的函数需要特定的锁被锁上时才能调用,每个函数的参数都是一个不同类型的指针:
1 | int f1(std::shared_ptr<Widget> spw); // call these only when |
调用这些代码,希望传递空指针给他们,代码如下:
1 | std::mutex f1m, f2m, f3m; // mutexes for f1, f2, and f3 |
在前两个函数调用中没有使用 nullptr 是令人沮丧的,但是上面的代码是可以工作的,这才是最重要的。然而,代码中的重复模式——锁定互斥量,调用函数,解锁互斥量——才是更令人沮丧和反感的。避免这种重复风格的代码正是模板的设计初衷,因此,让我们使用模板化上面的模式:
1 | template<typename FuncType, |
要是之前的item好好学了,一定可以看懂他在干嘛。
有了这个模版,使用者可以按照如下的方式调用:
1 | auto result1 = lockAndCall(f1, f1m, 0); // error! |
但正如注释中写的,前两种是无法编译通过的。(接下来这段直接来自中文书籍)
在第一个调用中,当把 0 作为参数传给 lockAndCall ,模板通过类型推导得知它的类型。 0 的类型总是 int ,这就是对 lockAndCall 的调用实例化的时候的类型。不幸的是,这意味着在 lockAndCall 中调用 func ,被传入的是 int ,这个 f1 期望接受的参数std::share_ptr<Widget>
是不不兼容的。传入到 lockAndCall 的 0 尝试来表示一个空指针,但是正真不传入的是一个普通的 int 类型。尝试将 int 作为 std::share_ptr<Widget>
传给 f1 会导致一个类型冲突错误。使用 0 调用 lockAndCall 会失败,因为在模板中,一个 int 类型传给一个要求参数是std::share_ptr<Widget>
的函数。
对调用 NULL 的情况的分析基本上是一样的。当 NULL 传递给 lockAndCall 时,从参数 ptr 推导出的类型是整数类型,当 ptr ——一个 int 或者类 int 的类型——传给 f2 ,一个类型错误将会发生,因为这个函数期待的是得到一个std::unique_ptr<Widget>
类型的参数。
相反,使用 nullptr 是没有问题的。当 nullptr 传递给 lockAndCall , ptr 的类型被推导为std::nullptr_t
。当 ptr 被传递给 f3 ,有一个由std::nullptr_t
到 Widget* 的隐形转换,因为std::nullptr_t
可以隐式转换为任何类型的指针。
真正的原因是,对于 0 和 NULL ,模板类型推导出了错误的类型(他们的真正类型,而不是它们作为空指针而体现出的退化的内涵),这是在需要用到空指针时使用 nullptr 而非 0 或者 NULL 最引人注目的原因。使用 nullptr ,模板不会造成额外的困扰。另外结合 nullptr 在重载中不会导致像 0 和 NULL 那样的诡异行为的事实,胜负已定。当你需要用到空指针时,使用 nullptr 而不是 0 或者 NULL 。
Item 11: Prefer deleted functions to private undefined ones
优先使用delete关键字删除函数而不是private却又不实现的函数
如果你需要为其与开发者提供代码,同时你不希望他们调用某些函数,一般情况下,你就不会声明这些函数。没有函数声明,就没有函数调用。但有时候,有些函数已经声明了,但你不希望别人调用他们,这就不是一件容易的事了。
在这里,我们只讨论拷贝构造函数和operator=。
在C++98中,禁止调用上述的两种函数的方法,就是将他们声明在private下,并且不定义他们。比如,为了使 istream 和 ostream 类不能被复制, basic_ios 在 C++98 中是如下定义的(包括注释):
1 | template <class charT, class traits = char_traits<charT> > |
声明在private下,但是不定义意味着如果有代码有权利调用这些函数,依然会因为函数没有定义而失败。
在C++11中,有一种更好的方法来实现上述的作用,就是使用”= delete”,如下:
1 | template <class charT, class traits = char_traits<charT> > |
使用这种方式相比于老版本的private更进一步,任何代码都无法调用他们,即使是友元函数,一点调用,会直接编译失败,让我们不必在链接时才发现错误。
按照惯例,deleted函数被定义成public,而不是private,这么做是有原因的。当客户端程序尝试调用成员函数,C++会先检查访问权限,再检查deleted状态。当客户端程序尝试调用private下的deleted函数,有些编译器只会抱怨函数是private,即使函数的访问权限其实在这里并不会影响它能否被调用。所以,把函数声明在public下,可以获得更精确的报错信息。
deleted函数的一个很重要的优点是:任何函数都可以是deleted,但是只有成员函数可以被声明在private下。比如,我们拥有一个非成员函数如下:
1 | bool isLucky(int number); |
C留给C++的有一个遗产是,任何类型可以被看作是模糊的数字当发生向int类型的隐式转换,但有些调用虽然可以编译,但是没有任何意义:
1 | if (isLucky('a')) … // is 'a' a lucky number? |
如果幸运数字必须是整数,我们自然希望在编译期间禁止上述的这些调用。
一种实现方法,就是对这些重载函数添加deleted状态:
1 | bool isLucky(int number); // original function |
最后关于会禁止double类型和float类型可能会让人惊讶,因为毕竟这里只禁用了double,但是相比于从float转换到double,C++更喜欢把float转换成double,所以这里调用isLucky(float)时,实际上调用的是isLucky(double),就报错了。
即使deleted函数是不能被调用的,但他们也是你代码的一部分。因此,在重载解析的时候仍会将它们考虑进去。这也就是为什么有了上面的那些声明,对 isLucky 不被期望的调用会被拒绝:
1 | if (isLucky('a')) … // error! call to deleted function |
另一个deleted函数可以实现的技巧(private成员函数不能)是禁止需要被禁用的模版的实例化,举个例子:
1 | template<typename T> |
在指针中,有两种特殊的类型,一种是void*
,因为无法对它进行解引用,增加,减少;另一个是char*
,因为它通常代表C风格的字符串。这些特殊的情况通常需要特殊的的解决方法,所以在模版processPointer中,假设它希望拒绝这两种类型参数的调用,当然,const void*
和const char*
也需要被禁止。
这是很好实现的,如下:
1 | template<> |
但如果进一步思考,const volatile void*
和const volatile char*
也需要被禁止。
有趣的是,如果在类内定义函数模版,你想通过声明在private下来禁止他们的实例化,是做不到的,因为,因为赋予一个成员函数模板的某种特殊情况下拥有不同于 模板主体的访问权限是不可能,如下:
1 | class Widget { |
不能用private实现是因为,模版的特例话(specialization)必须被写在namespace的作用域中,不能写在class的作用域中,如下:
1 | class Widget { |
Item 12: Declare overriding functions override
对于重载函数的声明式,添加override关键字。
在C++面向对象编程的世界中,围绕着类,继承,虚函数。在这些概念中,最基础的内容就是派生类利用虚函数覆盖父类中对应函数。令人沮丧的是,虚函数的覆盖是非常容易引起错误的。这部分的语言特性甚至看上去是按照墨菲准则设计的,它不需要被遵从,但是要被膜拜。
因为覆盖“ overriding ”听上去像重载“ overloading ”,但是它们完全没有关系,我们要有一个清晰地认识,虚函数(覆盖的函数)可以通过基类的接口来调用一个派生类的函数:
1 | class Base { |
为了让覆盖发生(制定的函数),需要满足一些条件:
- 基类对应的函必须是虚函数
- 基类和派生类的函数名必须完全一致(除了在虚析构函数中)
- 基类和派生类的函数的参数类型必须完全一致
- 基类和派生类的constness必须完全一致
- 基类和派生类的返回值和异常抛出必须可以兼容
以上这些限制,C++98也包含了,C++11做了一些添加:
函数的引用限定必须一致。成员函数的引用限定是C++11宣传的比较少的特性之一,所以不用惊讶自己没有听过。它让成员函数只允许对左值使用或者是右值使用的限制成为了可能。成员函数不需要声明为虚就可以使用它们:
1 | class Widget { |
这些对覆盖的要求表明了一个小小的错误就会有大的不同。代码包含一些覆盖上的错误是很正正常的,但这意味着这不是你想要的效果。所以你不能依赖编译器来识别你是不是写了些错误。比如,下面的代码是合法的,第一眼看上去也是合理的,但是它不包含任何虚覆盖函数——没有一个派生类的函数绑定到基类的对应函数上。你能找到每种情况里面的问题所在吗?即为什么派生类中的函数没有覆盖基类中同名的函数。
1 | class Base { |
需要一些帮助么?
- mf1在基类中有const限定,在派生类中没有
- mf2在基类中形参是int,在派生类中不是
- mf3在在基类中只允许左值调用,在派生类中只允许右值调用
- mf4在基类中没有被声明位虚函数
你肯能会认为,在实际中,这些问题会引出编译器的警告,所以不用担心。可能你是对的,但也可能不是。在我测试的两个编译器中,有一个编译器接受了全部的代码,并且没有警告。
因为在派生类中声明覆盖的正确性是重要的,但又非常容易出错,C++11给了我们一个方法来显示得表明派生类中的对应函数是需要覆盖基类中的版本的:在声明中添加override关键词,如下:
1 | class Derived: public Base { |
这是编译不过去的,因为如果是这样的写法,编译器会老老实实得检查所有覆盖相关的问题。这就是你想要的,也是为什么我们需要在声明覆盖的函数时添加override关键字。正确的版本如下:
1 | class Base { |
注意这个例子,需要在基类中先给mf4函数添加上virtual关键字。大部分覆盖相关的错误都发生在派生类中,但是检查基类是否有错误也是必要的。
给需要覆盖的函数添加上override关键字,不光是可以告诉编译器哪些需要进行override检查,如果你考虑修改基类中的虚函数中的标识符,它还可以帮你衡量后果。你可以看看会产生多少后果,如果你在一个到处是override的类中修改对应函数的标识符。如果不使用override,你需要祈祷自己已经完成了合理的单元测试,因为如上文所述,不能完全依靠编译器来提供诊断。
C++11引入了两个contextual keywords
,override和final(给虚函数添加final关键字代表它不能被派生类覆盖;给类加上final关键字,代表它不能作为基类)。这两个关键字只有在特定的上下文中才会拥有它本身保留的特性。比如override,你必须要把它夹在需要的函数的末尾才能发挥它的用处。这意味着,如果你之前写的代码,定义了一个函数名字叫override,这无伤大雅,不会发生冲突,也不用去改变:
1 | class Warning { // potential legacy class from C++98 |
以上就是关于override需要介绍的了,但我们还需要介绍成员函数的引用标识符。
如果你想写一个函数,只接收左值参数,而且形参不需要const,声明可以如下:
1 | void doSomething(Widget& w); // accepts only lvalue Widgets |
那如果只希望接受右值参数,就可以这么写:
1 | void doSomething(Widget&& w); // accepts only rvalue Widgets |
成员函数的引用标识符其实就是表示调用它的*this
必须是左值还是右值,写在末尾的声明风格和const也是类似的。
对带引用标识符的成员函数的需求不常见,但也是有可能的。举个例子:
1 | class Widget { |
这样子的封装设计在如今已经不常见了,但看看客户程序调用时会发生什么:
1 | Widget w; |
因为Widget::data()的返回值是左值引用,所以我们可以定义一直左值变量vals1。
在比如我们现在有一个工厂函数用来创建Widgets:
1 | Widget makeWidget(); |
我们希望用Widget中的vector初始化一个变量:
1 | auto vals2 = makeWidget().data(); |
同理,这里的vals2也是由Widget中的values拷贝构造而得。但此时,makeWidget()函数返回的是一个Widget的临时对象,所以里面的vector是浪费时间的,我们更希望移动它,但是获得的vector是一个左值引用,C++需要编译器保证相关代码是可以执行拷贝的。
这里一个好方法就是当需要左值时调用返回左值的方法,当需要右值时,调用返回右值的方法:
1 | class Widget { |
改完之后,调用结果如下:
1 | auto vals1 = w.data(); |
Item 13: Prefer const_iterators to iterators
相比于iterators更倾向于const_iterators
const_iterator在STL中等价于指向const的指针(注意,不是指针本身时const)。
举一个例子,假如你希望从vector<int>
搜索第一次出现的1983(这一年”C++”替换”C + 类”而作为一个语言的名字),然iterator后在搜到的位置插入数值1998(这一年第一个ISO C++标准被接受)。如果在vector中并不存在1983,插入操作的位置应该是vector的末尾。在C++98中使用 iterator ,这会非常容易:
1 | std::vector<int> values; |
但在这里,iterator不是最好的选择,因为代码从未改变iterator只想的内容,所以使用,直接修改成const_iterator是完全没问题的,但在C++98中,有一个方法是不可以的:
1 | // typedefs |
因为value在C++98中是non-const容器,但是从non-const的容易中获取一个const_iterator没有一个简单的方法,所以就采用了cast(其实有其他方法,但这里使用了cast),但是不管使用哪种方式,从一个非 const 容器中得到一个 const_iterator 牵涉到太多。
即使你获得了const_iterators,情况也不会好转,因为在C++98中,插入或者是删除的空间必须由iterators特化,const_iterators是不能被接受的,而并没有合适的方法让const_iterators转换成iterators,甚至是使用 static_cast 也不行。甚至最暴力的 reinterpret_cast 也不成,(这不是C++98的限制,同时C++11也同样如此。 const_iterator 转换不成 iterator ,不管看似有多么合理。)所以上面的代码编译失败了。
还有一些方法可以生成类似 const_iterator 行为的 iterator ,但是它们都不是很明显,也不通用,本书中就不讨论了。除此之外,我希望我所表达的观点已经明确: const_iterator 在C++98中非常麻烦事,是万恶之源。那时候,开发者在必要的地方并不使用 const_iterator ,在C++98中 const_iterator 是非常不实用的。
但在C++11中,这一切都发生了改变。如今,const_iterators可以很方便的获取和使用。容器的成员函数cbegin,cend提供了const_iterators,即使容器本身是non-const,同时STL成员函数实际上也适用const_iterators来定位,所以上文的代码可以改成这样:
1 | std::vector<int> values; |
使用const_iterators在编写最大化泛型代码库时会遇到问题,举个例子:
1 | // in container, find first occurrence of targetVal, then insert insertVal there |
在C++14中是可以的,但在C++11中不行。从整体语言标准来看,C++11添加了begin,end的非成员函数,但是没有添加cbegin,cend,rend,crbegin,crend,但是C++14纠正了。
如果使用的是C++11,想实现上述的效果,在编写最大化泛型代码库时,没有任何一个库提供了缺失的非成员函数cbegin和cend,你可以自己实现它,比如:
1 | template <class C> |
你会惊讶地发现,非成员函数cbegin并没有调用成员函数cbegin,这样写的好处是,即使容器不提供cbegin成员函数,依然可以运作。
如果C是内置的数组类型,改模版函数依然可以运行。在这个例子中,容器会变为一个指向const数组的引用。C++11提供了内置数组的非成员函数begin的特化版本,会返回指向数组第一个元素的指针。const数组的第一个元素也是const,则返回的指针也是pointer-to-const,也就可以类比成数组的const_iterator。
Item 14: Declare functions noexcept if they won’t emit exceptions
在C++11中,如果一个函数保证不会抛出异常,就可以添上noexcept。
对于不会抛出异常的函数添上noexcept还有一个好处,它允许编译器生成更好的的代码。为了知道为什么,测试C++98和C++11中的差异是很有帮助的,如下:
1 | int f(int x) throw(); // no exceptions from f: C++98 style |
在运行期间,一旦f抛出异常,违反了f的异常声明,在C++98中,调用栈会对f放松,一旦有一些非相关行为在此出现,程序就会终止;但在C++11中,运行时的表现有些许不同:栈只可能在在程序终止钱才有可能放松。
概念介绍 Stack Unwinding in C++
Stack Unwinding is the process of removing function entries from function call stack at run time. The local objects are destroyed in reverse order in which they were constructed.
Stack Unwinding is generally related to Exception Handling. In C++, when an exception occurs, the function call stack is linearly searched for the exception handler, and all the entries before the function with exception handler are removed from the function call stack. So, exception handling involves Stack Unwinding if an exception is not handled in the same function (where it is thrown). Basically, Stack unwinding is a process of calling the destructors (whenever an exception is thrown) for all the automatic objects constructed at run time.
这么说比较抽象,举个例子:
1 | // CPP Program to demonstrate Stack Unwinding |
Explanation:
- When f1() throws exception, its entry is removed from the function call stack, because f1() doesn’t contain exception handler for the thrown exception, then next entry in call stack is looked for exception handler.
- The next entry is f2(). Since f2() also doesn’t have a handler, its entry is also removed from the function call stack.
- The next entry in the function call stack is f3(). Since f3() contains an exception handler, the catch block inside f3() is executed, and finally, the code after the catch block is executed.
继续看item14
上文进一步解释就是:如果,在运行期,一个异常逸出 的作用域,则 的异常规格袚违反。在 C+ +98 异常规格下,调用栈会开解至 的调用方,然后执行了一些与本条款无关的动作以后,程序执行中止。而在 C++II 异常规格下,运行期行为会稍有不同:程序执行中止之前,栈只是可能会开解。
开解调用栈,和可能开解调用栈,这一点点区别对千代码生成造成的影响之大可能出乎人们的意料。在带有 noexcept 声明的函数中,优化器不需要在异常传出函数的前提下,将执行期栈保持在可开解状态;也不蒂要在异常溢出函数的前提下,保证所有其中的对象以其被构造顺序的逆序完成析构。而那些以 “throw()” 异常规格声明的函数就享受不到这样的优化灵活性,和没有加异常规格声明的函数 样。这些可以总结为下述情况:
1 | RetType function(params) noexcept; // most optimizable |
仅仅这个就已经构成充分理由,让你给任何已知不会产生异常的函数加上 noexcept声明了。
优化诚可贵,正确价更高,第一步还是要求正确为主,本书接下来还举了一些例子,总结来说:noexcept 性质对于移动操作、 swap 、函数释放函数和析构函数最有价值;大多数函数都是异常中立的,不具各 noexcept 性质。
Item 15: Use constexpr whenever possible
只要有可能使用 constexpr, 就使用它。
当它应用千对象时,其实就是 个加强版的 const 但应用于函数时,却有着相当不同的意义。
概念上,constexpr表示一个值不光是const,而且在编译阶段就可得知。概念只是一部分,因为当constexpr用在函数上时,事情会更微妙,之后会进一步介绍,现在我就告诉你一个结论:你不能把constexpr函数的结果一定当成const,也不能说它编译阶段一定就确定了。
但我们先从constexpr对象开始,事实上constexpr对象,确实就是const的,而且在编译阶段也是确定的。
在编译阶段就已知的值拥有种种特权。比如,它们可能被放置在只读内存里,尤其对干嵌入式系统开发工程师来说,这可是非常重要的语言特性。更广泛的应用场景里,在编译阶段就已知的常最整型值可以用在 C++要求整型常盐表达式的语境中。这些语境包括数组的尺寸规格、整型模板实参(包括 std:: array 型别对象的长度)、枚举量的值、对齐规格等。
1 | int sz; // non-constexpr variable |
请注意, const 并未提供和 constexpr 同样的保证,因为 con st 对象不一定经由编译期已知值来初始化:
1 | int sz; // as before |
Simply put, all constexpr objects are const, but not all const objects are constexpr. 如果你需要确保变量需要在编译阶段就可以获得准确的值,你需要使用constexpr,而不是const。
constexpr 对象的使用场景中如果涉及 constexpr 函数,那就更加有意思了。这样的函数在调用时若传入的是编译期常扯,则产出编译期常量。如果传入的是直至运行期才知晓的值,则产出运行期值。按这样的说法,好像你无法预知它们的行为,但这么理解是不对的。正确的理解方式是以下这样:
- constexpr 函数可以用在要求编译期常最的语境中。在这样的语境中,若你传给constexpr 函数的实参值是在编译期已知的,则结果也会在编译期间计算出来。如果任何一个实参值在编译期未知,则你的代码将无法通过编译。
- When a constexpr function is called with one or more values that are not known during compilation, it acts like a normal function, computing its result at runtime. This means you don’t need two functions to perform the same operation, one for compile-time constants and one for all other values. The constexpr function does it all.(意思就是如果传入的实惨有一个或多个编译期是不知道的,那就和普通函数无异)。
给一个例子:
1 | // pow's a constexpr func that never throws |
如果base,exp是编译期就知道,那么pow的结果就是编译期就知道的const常量,但如果不是,那就是运行阶段在知道结果的普通函数,一举两得。
C++ll中constexpr 函数不得包含多千一个可执行语句,即一条return 语句。这个限制听上去限 极大,但其实没有那么大,因为我们还有两条技巧可以用来拓展constexpr 函数 表达力。首先,条件运算符 ?: 可以用千需要使用 if-else 语句;其次,用到环的地方可用递归代替。所以, pow 可以像下面这样实现:
1 | constexpr int pow(int base, int exp) noexcept |
这么写虽然可 运作,但是除非是忠实 函数式程序员,不会有人觉得这样的写法很漂亮。 C++ 中, 限制条件大大地放宽了,所以下面这样的实现 成为可能了:
1 | constexpr int pow(int base, int exp) noexcept // C++14 |
constexpr functions are limited to taking and returning literal types, which essentially means types that can have values determined during compilation. In C++11, all built-in types except void qualify, but user-defined types may be literal, too, because constructors and other member functions may be constexpr:
1 | class Point { |
其实意思就是constexpr的参数和返回值的类型需要时编译期可以计算出的,内置类型除了void都可以计算出,如果要让自定义的类型也要符合要求,那需要让对应的构造函数和成员函数也加上constexpr的关键字,如上面的这段代码。
这很让人激动。这就意味着,对象 mid ,尽管在其初始化过程中涉及了构造函数、访问器、还有个非成员函数的调用,却仍可以在只读内存中得以创建!这就意味着,传统上那条划在编译期完成的工作和运行期完成的工作之间相当严格的界线已经开始变得模糊。并且,有些传统上会在运行期完成的工作已经可以迁至编译期完成。迁过去的代码越多,你的软件就会运行得越快(不过,编译会用更久)。
在C++11中,上文的set函数不能加constexpr有两个原因,一是set会修改,违背了const特性,二是返回值是void,在C++11中也是不可以的。但是在C++14中,这些限制就解除了。
1 | class Point { |
constexpr 实际上宣告的是: ”但凡任何 C++要求在此使用 个常批表达式的语境,皆可以用我。” 一旦你把一个对象或函数声明成了 constexpr, 客户就可以将其用千这种语境。而万一你后来又感觉你对 onstexpr 运用不当,然后移除了它,这个动作就可以导致无穷无尽的客户代码被拒绝编译。
Item 16: Make const member functions thread safe
本章节主要结合中文原文进行少部分的翻译,大部分是原文。
在数学领域中,我们发现用一个类表示多项式是非常方便的。而在这个类中,若有 个函数能够计算多项式的根,即那些使得多项式求值结果为零的值,将会很有用。这样函数并不会造成多项式的值的改动,因此将它声明为 const 成员函数也很自然。
1 | class Polynomial { |
计算多项式的根也许代价高昂,所以我们不愿意执行这个计算,除非不得不做。即使不得不做,也当然不愿意做不止 次。这么 来,在不得不计算多项式的根时,我们就把这些根缓存起来,并以返回缓存值的手法来实现 roots 。以下是 种基本的做法:
1 | class Polynomial { |
从概念上说, roots 不会改变它操作的 Polynomi 对象,然而作为缓存活动的组成部分,它可能需要修改 rootVals 和rootsAreValid 的值。这是 mutable 的经典用例, 是它为何被加到数据成员声明中。
设想现在有两个线程同时在同一个Polynomial 对象上调用 roots:
1 | Polynomial p; |
对于只读操作来说是没问题的,但是内部可能会修改两个mutable属性的值,在没有同步完全的情况下,会出问题。
问题就在于, roots 被声明成了 const 函数,但却并非线程安全的。在C++l 中,需要修正线程安全性的缺失。
欲完成这个目标,最简单的办法也是最常见的:引入 mutex (意为互斥量,mutual exclusion 的简写):
1 | class Polynomial { |
值得关注的是,由于 std: :mutex 是个只移型别 (move-only type) (即只能移动但不能复制的型别),将 加入 Polynomial 的副作用就是 Polynomial 失去了可复制性。不过,它仍然可移动。
就一些特定情况而言,引入互斥址是杀鸡用牛刀之举。例如,如果要计算一个成员函数被调用的次数,使用 std:: atomic 型别的计数器成本更低:
1 | class Point { // 2D point |
与std: :mutex 样, std:: atomic 是只移型别。因此, Point callCount 存在
会使得 Point 变成只移型别。
由千对 std::atomic 型别的变量的操作与加上与解除互斥量相比,开销往往比较小,你也许应该尝试比惯常程度更重度地依靠 std:: atomic 型别的对象,例如,如果某类需要缓存计算开销较大的 int 型别的变最,则应该尝试使用一对std::atomic 型别的变量来取代互斥址。
1 | class Widget { |
这样做可行,但有时会做一些不必要的工作。考虑以下情况:
- 一个线程调用 Widget: :magicValue 时,观察到 cacheValid 值为 false, 于是执行了两个大开销的计算,并将其和赋值给了 cacheValue
- 与此同时,另一个线程也在调用 Widget: :magicValue 也观察到 cacheValid值为false 千是也执行了第一个线程刚刚完成的两次同样的大开销运算(此处”另一个线程”实际上有可能是另外若干个其他线程)。
这种行为与缓存的目标南辕北辙。颠倒对 cacheValid cacheValue 的赋值顺序可以消除该问题,但结果却更坏了:
1 | class Widget { |
上面的代码问题就更大了,之前可能会重复赋值,这次可能还没赋值就结束了,一个线程改成true以后,另一个线程发现是true,就直接使用了老版本的值。
这里我们学到的是:对千单个要求同步的变量或内存区域,使用std::atomic
足够了。但是如果有两个或更多个变盎或内存区域需要作为一整个单位进行操作时,就要动用互斥量了。
Item 17: Understand special member function generation
C++官方用语中,特种成员函数是指那些 C+ +会自行生成的成员函数。C++98有四种特种成员函数:默认构造函数、析构函数、复制构造函数,以及复制赋值运算符。C++11中,多了两种,移动构造函数和移动赋值运算符。
以上六种函数都只在需要时才生成默认的版本(前提你没自定义一些会发生冲突的函数)。
不过,当我提到移动操作在某个数据成员或基类部分上执行移动构造或移动赋值的时候,并不能保证移动操作真的会发生。按成员移动是由两部分组成的, 部分是在支持移动操作的成员上执行的移动操作,另一部分是在不支持移动操作的成员上执行的复制操作。
如果发生了复制操作的情况,移动操作就不会在已有声明的前提下被生成。这就是说,生成移动操作的精确条件,与复制操作有所不同。
- 两种复制操作是彼此独立的:声明了其中一个,并不会阻止编译器生成另一个。
- 两种移动操作并不彼此独立:声明了其中一个,就会阻止编译器生成另一个。
- 一旦显式声明了复制操作,这个类也就不再会生成移动操作了,反之亦然。
本文链接: http://woaixiaoyuyu.github.io/2022/04/24/Effective%20Modern%20C++%20%E7%BF%BB%E8%AF%91/
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!