Effective Modern Cpp
为什么const
成员函数应当线程安全?怎样使用std::unique_ptr
实现Pimpl惯用法?为何要避免lambda表达式用默认捕获模式?std::atomic
与volatile
的区别是什么?
基础简介
C++中的许多东西都可被声明和定义。声明(declarations)引入名字和类型,并不给出比如存放在哪或者怎样实现等的细节:
1 |
|
定义(definitions)提供存储位置或者实现细节:
1 |
|
定义也有资格称为声明。
定义一个函数的签名(signature)为它声明的一部分,这个声明指定了形参类型和返回类型。函数名和形参名不是签名的一部分。在上面的例子中,func
的签名是bool(const Widget&)
。函数声明中除了形参类型和返回类型之外的元素(比如noexcept
或者constexpr
,如果存在的话)都被排除在外。
另外
std::auto_ptr
在C++11中被废弃,因为std::unique_ptr
可以做同样的工作,而且只会做的更好。
如果一个操作的结果有未定义的行为(undefined
behavior)。这意味着运行时表现是不可预测的。比如,在std::vector
范围外使用方括号(“[]
”),解引用未初始化的迭代器,或者数据竞争(即有两个或以上线程,至少一个是writer,同时访问相同的内存位置)。
还有,智能指针通常重载指针解引用运算符(operator->
和operator*
),但
std::weak_ptr
是个例外。
1 类型推导
C++11修改了一些类型推导规则并增加了两套规则,一套用于auto
,一套用于decltype
。C++14扩展了auto
和decltype
可能使用的范围。
条款1:理解模板类型推导
C++最重要最吸引人的特性auto
是建立在模板类型推导的基础上的。
考虑像这样一个函数模板:
1 |
|
更具体一点是:
1 |
|
编译器对 f
函数的参数进行两个类型推导:一个是针对T
的,另一个是针对ParamType
的。这两个类型通常是不同的,因为ParamType
包含一些修饰,比如const
和引用修饰符。
然后这样进行调用:
1 |
|
T
被推导为int
,ParamType
却被推导为const int&
。
T
的类型推导不仅取决于expr
的类型,也取决于ParamType
的类型。有三种情况:
ParamType
是一个指针或引用,但不是通用引用(参见 Item24,它不同于左值引用和右值引用)ParamType
是一个通用引用ParamType
既不是指针也不是引用
ParamType
是一个指针或引用,但不是通用引用
f(expr)
的类型推导会这样进行:
- 如果
expr
的类型是一个引用,忽略引用部分 - 然后
expr
的类型与ParamType
进行模式匹配来决定T
如果这是我们的模板,
1 |
|
我们声明这些变量,
1 |
|
在不同的调用中,对param
和T
推导的类型会是这样:
1 |
|
即使rx
的类型是一个引用,T
也会被推导为一个非引用
,这是因为rx
的引用性(reference-ness)在类型推导中会被忽略。
当param
是reference-to-const
,const
不再被推导为T
的一部分:
1 |
|
同之前一样,rx
的reference-ness在类型推导中被忽略了。
如果param
是一个指针(或者指向const
的指针)而不是引用,情况本质上也一样:
1 |
|
ParamType
是一个通用引用
形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参T
,那么通用引用声明形式就是T&&
)。
如果
expr
是左值,T
和ParamType
都会被推导为左值引用。这是模板类型推导中唯一一种
T
被推导为引用的情况。虽然
ParamType
被声明为右值引用类型,但是最后推导的结果是左值引用。
如果
expr
是右值,就使用正常的(也就是上一节中的)推导规则
1 |
|
通用引用的类型推导规则是不同于普通的左值或者右值引用的。尤其是,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是对非通用引用时不会区分。
ParamType
既不是指针也不是引用
当ParamType
既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:
1 |
|
这意味着无论传递什么param
都会成为它的一份拷贝——一个完整的新对象。事实上param
成为一个新对象这一行为会影响T
如何从expr
中推导出结果。
- 如果
expr
的类型是一个引用,忽略这个引用部分 - 如果忽略
expr
的引用性(reference-ness)之后,expr
是一个const
,那就再忽略const
。如果它是volatile
,也忽略volatile
(volatile
对象不常见,它通常用于驱动程序的开发中)
因此:
1 |
|
即使cx
和rx
表示const
值,param
也不是const
。这是有意义的。param
是一个完全独立于cx
和rx
的对象——是cx
或rx
的一个拷贝。
认识到只有在传值给形参时才会忽略const
(和volatile
)这一点很重要。对于reference-to-const
和pointer-to-const
形参来说,expr
的常量性const
ness在推导时会被保留。
例如:
1 |
|
当ptr
作为实参传给f
,组成这个指针的每一比特都被拷贝进param
。ptr
自身的值会被传给形参。
在类型推导中,这个指针指向的数据的常量性const
ness将会被保留,但是当拷贝ptr
来创造一个新指针param
时,ptr
自身的常量性const
ness将会被忽略。
数组实参
1 |
|
在这里const char*
指针ptrToName
会由name
初始化,而name
的类型为const char[13]
,这两种类型(const char*
和const char[13]
)是不一样的,但是由于数组退化为指针的规则,编译器允许这样的代码。
但要是一个数组传值给一个模板会怎样?会发生什么?
1 |
|
数组与指针形参这样的等价是C语言的产物,C++又是建立在C语言的基础上,它让人产生了一种数组和指针是等价的的错觉。
因为数组形参会视作指针形参,所以传值给模板的一个数组类型会被推导为一个指针类型。这意味着在模板函数f
的调用中,它的类型形参T
会被推导为const char*
:
1 |
|
但是在C++中,虽然函数不能声明形参为真正的数组,但是可以接受指向数组的引用!所以我们修改f
为传引用:
1 |
|
我们这样进行调用,
1 |
|
T
被推导为了真正的数组!这个类型包括了数组的大小,在这个例子中T
被推导为const char[13]
,f
的形参(对这个数组的引用)的类型则为const char (&)[13]
。
可声明指向数组的引用的能力,使得我们可以创建一个模板函数来推导出数组的大小:
1 |
|
将一个函数声明为constexpr
使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:
1 |
|
当然作为一个现代C++程序员,你自然应该想到使用std::array
而不是内置的数组:
1 |
|
至于arraySize
被声明为noexcept
,会使得编译器生成更好的代码。
函数实参
在C++中不只是数组会退化为指针,函数类型也会退化为一个函数指针。对于数组类型推导的全部讨论都可以应用到函数类型推导和退化为函数指针上来。结果是:
1 |
|
这个实际上没有什么不同,只是函数退化为指针。
结论
- 在模板类型推导时,有引用的实参会被视为无引用,引用会被忽略
- 对于通用引用的推导,左值实参会被特殊对待
- 对于传值类型推导,
const
和/或volatile
实参会被认为是non-const
的和non-volatile
的 - 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用模板参数类型。
2 理解 auto
类型推导
模板类型推导使用下面这个函数模板
1 |
|
在f
的调用中,编译器使用expr
推导T
和ParamType
的类型。
当一个变量使用auto
进行声明时,auto
扮演了模板中T
的角色,变量的类型说明符扮演了ParamType
的角色。
考虑这个例子:
1 |
|
这里x
的类型说明符是auto
自己,另一方面,在这个声明中:
1 |
|
类型说明符是const auto
。另一个:
1 |
|
类型说明符是const auto&
。
在这里例子中要推导x
,rx
和cx
的类型,编译器的行为看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:
1 |
|
在使用auto
作为类型说明符的变量声明中,类型说明符代替了ParamType
:
1 |
|
1 |
|
1 |
|
auto
类型推导和模板类型推导几乎一样的工作。
如果你想声明一个带有初始值27的int
,C++98提供两种语法选择:
1 |
|
C++11由于也添加了用于支持统一初始化(uniform initialization)的语法:
1 |
|
总之,这四种不同的语法只会产生一个相同的结果:变量类型为int
值为27。
使用 auto
进行的类型推导,其结果却不一样:
1 |
|
这就造成了auto
类型推导不同于模板类型推导的特殊情况。当用auto
声明的变量使用花括号进行初始化,auto
类型推导推出的类型则为std::initializer_list
。如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:
1 |
|
对于花括号的处理是auto
类型推导和模板类型推导唯一不同的地方。
然而如果在模板中指定T
是std::initializer_list<T>
而留下未知T
,模板类型推导就能正常工作:
1 |
|
在C++11编程中一个典型的错误就是偶然使用了
std::initializer_list<T>
类型的变量。
但是对于C++14故事还在继续,C++14允许auto
用于函数返回值并会被推导。
而且C++14的lambda函数也允许在形参声明中使用auto
。但是在这些情况下auto
实际上使用模板类型推导的那一套规则在工作,而不是auto
类型推导,所以说下面这样的代码不会通过编译:
1 |
|
同样在C++14的 lambda 函数中这样使用auto也不能通过编译:
1 |
|
结论
auto
类型推导通常和模板类型推导相同,但是auto
类型推导假定花括号初始化代表std::initializer_list
,而模板类型推导不这样做- 在C++14中
auto
允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是auto
类型推导
3 decltype
decltype
,给它一个名字或者表达式decltype
就会告诉你这个名字或者表达式的类型。通常,它会精确的告诉你你想要的结果。
decltype
只是简单的返回名字或者表达式的类型:
1 |
|
1 |
|
在C++11中,decltype
最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。
对一个T
类型的容器使用operator[]
通常会返回一个T&
对象,比如std::deque
就是这样。
但是std::vector
有一个例外,对于std::vector<bool>
,operator[]
不会返回bool&
,它会返回一个全新的对象(MSVC的STL实现中返回的是std::_Vb_reference<std::_Wrap_alloc<std::allocator<unsigned int>>>
对象)。
decltype 获取返回值类型的示例
使用decltype
计算返回类型的一个例子是:
1 |
|
函数名称前面的auto
不会做任何的类型推导工作。相反的,他只是暗示使用了C++11的尾置返回类型语法,即在函数形参列表后面使用一个”->
“符号指出函数的返回类型,尾置返回类型的好处是我们可以在函数返回类型中使用函数形参相关的信息。在authAndAccess
函数中,我们使用c
和i
指定返回类型。
在C++14标准下我们可以忽略尾置返回类型,只留下一个auto
。使用这种声明形式,auto标示这里会发生类型推导。更准确的说,编译器将会从函数实现中推导出函数的返回类型。
1 |
|
从返回对象进行修改
上述代码出现的一个问题是:
operator[]
对于大多数T
类型的容器会返回一个T&
,但是
条款1
解释了在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:
1 |
|
在这里d[5]
本该返回一个int&
,但是模板类型推导会剥去引用的部分,因此产生了int
返回类型。函数返回的那个int
是一个右值,上面的代码尝试把10赋值给右值int
,C++11禁止这样做,所以代码无法编译。
要想让authAndAccess
像我们期待的那样工作,我们需要使用decltype
类型推导来推导它的返回值,即指定authAndAccess
应该返回一个和c[i]
表达式类型一样的类型。
因此我们可以这样写authAndAccess
:
1 |
|
现在authAndAccess
将会真正的返回c[i]
的类型。现在事情解决了,一般情况下c[i]
返回T&
,authAndAccess
也会返回T&
,特殊情况下c[i]
返回一个对象,authAndAccess
也会返回一个对象。
decltype(auto)
的使用不仅仅局限于函数返回类型,当你想对初始化表达式使用decltype
推导的规则,你也可以使用:
1
2
3
4
5
6
7
8
Widget w;
const Widget& cw = w;
auto myWidget1 = cw; //auto类型推导
//myWidget1的类型为Widget
decltype(auto) myWidget2 = cw; //decltype类型推导
//myWidget2的类型是const Widget&
形参传递问题
authAndAccess
声明:
1 |
|
容器通过传引用的方式传递非常量左值引用(lvalue-reference-to-non-const),因为返回一个引用允许用户可以修改容器。
但是这意味着在不能给这个函数传递右值容器,右值不能被绑定到左值引用上,除非这个左值引用是一个const(lvalue-references-to-const)。
一个右值容器,是一个临时对象,通常会在authAndAccess
调用结束被销毁,这意味着authAndAccess
返回的引用将会成为一个悬置的(dangle)引用。
为了使authAndAccess
的引用可以绑定左值和右值,可以使用通用引用。所以我们这样声明:
1 |
|
这行代码中还有一个问题:
在这个模板中,我们不知道我们操纵的容器的类型是什么,也就是说不知道它使用的索引对象(index objects)的类型。
对一个未知类型的对象使用传值通常会造成不必要的拷贝,对程序的性能有极大的影响,还会造成对象切片行为。
但是只针对 STL
容器(比如std::string
,std::vector
和std::deque
的operator[]
),这样处理是合理的。
为了保持参数本身的左右值属性,还需要进行
std::forward
:
1 |
|
C++11版本:
1 |
|
将decltype
应用于变量名会产生该变量名的声明类型。虽然变量名都是左值表达式,但这不会影响decltype
的行为。但是对于一些表达式,其类型推导结果,可能出现&引用类型:
1 |
|
对于名字来说,x
是一个左值,C++11定义了表达式(x)
也是一个左值。decltype((x))
是int&
。用小括号覆盖一个名字可以改变decltype
对于名字产生的结果。
因此,当使用decltype(auto)
的时候一定要加倍的小心,在表达式中看起来无足轻重的细节将会影响到decltype(auto)
的推导结果。
结论
decltype
产生变量或者表达式的类型- 对于
T
类型的不是单纯的变量名的左值表达式,decltype
总是产出T
的引用即T&
- C++14支持
decltype(auto)
,推导出类型,但是它使用decltype
的规则进行推导,而不是auto
4 查看类型推导结果
三种方案:
- IDE编辑器获得类型推导的结果
- 在编译期间获得结果
- 在运行时获得结果
IDE
IDE之所以能提供这些信息是因为一个C++编译器(或者至少是前端中的一个部分)运行于IDE中。如果这个编译器对你的代码不能做出有意义的分析或者推导,它就不会显示推导的结果。
编译
可以首先声明一个类模板但不定义。就像这样:
1 |
|
尝试实例化这个类模板就会引出一个错误消息,因为这里没有用来实例化的类模板定义。为了查看x
和y
的类型,只需要使用它们的类型去实例化TD
:
1 |
|
出现 undefined template TD<xxx>。
运行时
使用printf
的方法使类型信息只有在运行时才会显示出来(尽管不建议使用printf
)。
1 |
|
这种方法对一个对象如x
或y
调用typeid
产生一个std::type_info
的对象,然后std::type_info
里面的成员函数name()
来产生一个C风格的字符串(即一个const char*
)表示变量的名字。
调用std::type_info::name
不保证返回任何有意义的东西,但是库的实现者尝试尽量使它们返回的结果有用。
举个例子,GNU和Clang环境下x
的类型会显示为"i
",y
会显示为"PKi
"。"i
"表示"int
",""PK
"表示"pointer
to konst
const
"(指向常量的指针)。
如果传递的是一个引用,那么引用部分(reference-ness)将被忽略,如果忽略后还具有const
或者volatile
,那么常量性const
ness或者易变性volatile
ness也会被忽略。
std::type_info::name
的结果并不总是可信的,因为std::type_info::name
规范批准像传值形参一样来对待这些类型。Boost
TypeIndex库(Boost.TypeIndex)是更好的选择。
例如:
1 |
|
boost::typeindex::type_id_with_cvr
获取一个类型实参(我们想获得相应信息的那个类型),它不消除实参的const
,volatile
和引用修饰符(因此模板名中有“with_cvr
”)。结果是一个boost::typeindex::type_index
对象,它的pretty_name
成员函数输出一个std::string
,包含我们能看懂的类型表示。
基于这个f
的实现版本,再次考虑那个使用typeid
时获取param
类型信息出错的调用:
1 |
|
在GNU和Clang的编译器环境下,使用Boost.TypeIndex版本的f
最后会产生下面的(准确的)输出:
1 |
|
在Microsoft的编译器环境下,结果也是极其相似:
1 |
|
结论
- 类型推断可以使用IDE,使用编译器报错,使用Boost.TypeIndex库
- 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的
5
优先考虑auto
而非显式类型声明
从程序员的角度来说,如果按照符合规定的流程走,那auto
类型推导的一些结果是错误的。当这些情况发生时,引导auto
产生正确的结果是很重要的。
auto
变量从初始化表达式中推导出类型,所以我们必须初始化。
1 |
|
如果使用C++14,将会变得更酷,因为lambda表达式中的形参也可以使用auto
:
1 |
|
实例化std::function
并声明一个对象这个对象将会有固定的大小。这个大小可能不足以存储一个闭包,这个时候std::function
的构造函数将会在堆上面分配内存来存储,这就造成了使用std::function
比auto
声明变量会消耗更多的内存。
通过std::function
调用一个闭包几乎无疑比auto
声明的对象调用要慢。换句话说,std::function
方法比auto
方法要更耗空间且更慢,还可能有out-of-memory异常。并且正如上面的例子,比起写std::function
实例化的类型来,使用auto
要方便得多。
考虑以下问题:
1 |
|
v.size()
的标准返回类型是std::vector<int>::size_type
,但是只有少数开发者意识到这点。std::vector<int>::size_type
实际上被指定为无符号整型。上述的代码,会造成一些有趣的结果。
举个例子,在Windows
32-bit上std::vector<int>::size_type
和unsigned
是一样的大小,但是在Windows
64-bit上std::vector<int>::size_type
是64位,unsigned
是32位。这意味着这段代码在Windows
32-bit上正常工作,但是当把应用程序移植到Windows
64-bit上时就可能会出现一些问题。
所以使用auto
可以确保你不需要浪费时间:
1 |
|
考虑下面的代码:
1 |
|
看起来好像很合情合理的表达,但是这里有一个问题:
std::unordered_map
的key是const
的,所以hash
table中的std::pair
的类型不是std::pair<std::string, int>
,而是std::pair<const std::string, int>
。
编译器会努力的找到一种方法把std::pair<const std::string, int>
(即hash
table中的东西)转换为std::pair<std::string, int>
(p
的声明类型)。它会成功的,因为它会通过拷贝m
中的对象创建一个临时对象,是m
中元素的类型。然后把p
的引用绑定到这个临时对象上。在每个循环迭代结束时,临时对象将会销毁。
所以不只是让p
指向m
中各个元素的引用而已。
使用auto
可以避免这些很难被意识到的类型不匹配的错误:
1 |
|
这样无疑更具效率,且更容易书写。而且,这个代码有一个非常吸引人的特性,如果你获取p
的地址,你确实会得到一个指向m
中元素的指针。在没有auto
的版本中p
会指向一个临时变量,这个临时变量在每次迭代完成时会被销毁。
讲究!
有时候,显式的指定类型可能会导致你不想看到的类型转换。如果你使用auto
声明目标变量你就不必担心这个问题。
然而auto
也不是完美的。每个auto
变量都从初始化表达式中推导类型,有一些表达式的类型和我们期望的大相径庭,比如在
理解auto类型推导
小结的内容。
另外,一个适当的变量名称就能体现大量的抽象类型信息,所以不用考虑 auto 带来的信息不可见性。
结论
auto
变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。- 注意
auto
可能出现一些类型推导不一致的问题。
6 auto
遇上代理类型,使用显式类型初始化
假如我有一个函数,参数为Widget
,返回一个std::vector<bool>
,这里的bool
表示Widget
是否提供一个独有的特性。
1 |
|
更进一步假设第5个bit表示Widget
是否具有高优先级,我们可以写这样的代码:
1 |
|
这个代码没有任何问题。它会正常工作,但是如果我们使用auto
代替highPriority
的显式指定类型做一些看起来很无害的改变:
1 |
|
情况变了。所有代码仍然可编译,但是行为不再可预测:
1 |
|
因为 features(w)[5] 调用
operator[]
不会返回容器中元素的引用,取而代之它返回一个std::vector<bool>::reference
的对象。
调用features
将返回一个std::vector<bool>
临时对象,这个对象没有名字,为了方便我们的讨论,我这里叫他temp
。operator[]
在temp
上调用,它返回的std::vector<bool>::reference
包含一个指向存着这些
bits
的指针(temp
管理这些bits)。highPriority
是这个std::vector<bool>::reference
的拷贝,所以highPriority
也包含一个指针,指向temp
中的管理
bits
。在这个语句结束的时候temp
将会被销毁,因为它是一个临时变量。因此highPriority
包含一个悬挂(dangling)指针,如果用于processWidget
调用中将会造成未定义行为:
1 |
|
代理类问题
std::vector<bool>::reference
是一个代理类(proxy
class)的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类。
C++标准模板库中的智能指针也是用代理类实现了对原始指针的资源管理行为。
一些代理类被设计于用以对客户可见。比如std::shared_ptr
和std::unique_ptr
。其他的代理类则或多或少不可见,比如std::vector<bool>::reference
就是不可见代理类的一个例子,还有std::bitset::reference
等。
一些C++库也是用了表达式模板(expression
templates)的黑科技。这些库通常被用于提高数值运算的效率。给出一个矩阵类Matrix
和矩阵对象m1
,m2
,m3
,m4
,举个例子,这个表达式
1 |
|
可以使计算更加高效,只需要使让operator+
返回一个代理类代理结果
Sum<Matrix, Matrix>
而不是返回结果本身。
作为一个通则,不可见的代理类通常不适用于auto
。这样类型的对象的生命期通常不会活过一条语句,所以创建那样的对象是危险的。
显式类型初始化器(the explicitly typed initialized idiom)
1 |
|
这里,features(w)[5]
还是返回一个std::vector<bool>::reference
对象,但是这个转型使得表达式类型为bool
,然后auto
才被用于推导highPriority
。
1 |
|
结论
- 不可见的代理类可能会使
auto
从表达式中推导出“错误的”类型 - 显式类型初始化器强制
auto
推导出你想要的结果
7
区分()
和{}
创建对象
C++11使用统一初始化(uniform initialization)
1 |
|
C++11允许"="初始化不加花括号也拥有这种能力,括号初始化也能被用于为非静态数据成员指定默认初始值:
1 |
|
另一方面,不可拷贝的对象(例如std::atomic
)可以使用花括号初始化或者圆括号初始化,但是不能使用"="初始化:
1 |
|
内置类型间的隐式变窄转换 (narrowing conversion)
括号表达式还有一个少见的特性,即它不允许内置类型间隐式的变窄转换(narrowing conversion)。如果一个使用了括号初始化的表达式的值,不能保证由被初始化的对象的类型来表示,代码就不会通过编译:
1 |
|
使用圆括号和"="的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容老旧代码:
1 |
|
被误认为是声明
C++规定任何可以被解析为一个声明的东西必须被解析为声明。
1 |
|
但是如果你尝试使用相似的语法调用Widget
无参构造函数,它就会变成函数声明:
1 |
|
由于函数声明中形参列表不能带花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象就没有问题:
1 |
|
initializer_list关联问题
在构造函数调用中,只要不包含std::initializer_list
形参,那么花括号初始化和圆括号初始化都会产生一样的结果:
1 |
|
然而,如果有一个或者多个构造函数的声明包含一个std::initializer_list
形参,那么使用括号初始化语法的调用更倾向于选择带std::initializer_list
的那个构造函数。
1 |
|
w2
和w4
将会使用新添加的构造函数,即使另一个非std::initializer_list
构造函数和实参更匹配:
1 |
|
编译器一遇到括号初始化就选择带std::initializer_list
的构造函数的决心是如此强烈,以至于就算带std::initializer_list
的构造函数不能被调用,它也会硬选。
1 |
|
这里,编译器会直接忽略前面两个构造函数(其中第二个构造函数是所有实参类型的最佳匹配),然后尝试调用std::initializer_list<bool>
构造函数。调用这个函数将会把int(10)
和double(5.0)
转换为bool
,由于会产生变窄转换(bool
不能准确表示其中任何一个值),括号初始化拒绝变窄转换,所以这个调用无效,代码无法通过编译。
只有当没办法把括号初始化中实参的类型转化为std::initializer_list
时,编译器才会回到正常的函数决议流程中。
1 |
|
空的花括号意味着没有实参,不是一个空的std::initializer_list
:
1 |
|
1 |
|
作为一个类库使用者,你必须认真的在花括号和圆括号之间选择一个来创建对象。大多数开发者都使用其中一种作为默认情况,只有当他们不能使用这种的时候才会考虑另一种。
结论
- 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析(括号解析为函数)有天生的免疫性
- 在构造函数重载决议中,编译器会尽最大努力将括号初始化与
std::initializer_list
参数匹配,即便其他构造函数看起来是更好的选择 - 对于数值类型的
std::vector
来说使用花括号初始化和圆括号初始化会造成巨大的不同 - 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是需要仔细考虑
8 优先 nullptr
在C++98中,对指针类型和整型进行重载意味着可能导致奇怪的事情。如果给下面的重载函数传递0
或NULL
,它们绝不会调用指针版本的重载函数:
1 |
|
而f(NULL)
的不确定行为是由NULL
的实现不同造成的。如果NULL
被定义为0L
(指的是0
为long
类型),这个调用就具有二义性,因为从long
到int
的转换或从long
到bool
的转换或0L
到void*
的转换都同样好。
nullptr
的优点是它不是整型。它也不是一个指针类型,但是你可以把它认为是所有类型的指针。nullptr
的真正类型是std::nullptr_t
,在一个完美的循环定义以后,std::nullptr_t
又被定义为nullptr
。std::nullptr_t
可以隐式转换为指向任何内置类型的指针。
使用nullptr
调用f
将会调用void*
版本的重载函数,因为nullptr
不能被视作任何整型:
1 |
|
看下面的例子:
1 |
|
如果你不知道findRecord
返回了什么,那么你就不太清楚到底result
是一个指针类型还是一个整型。但是换一种假设如果你看到这样的代码:
1 |
|
这就没有任何歧义:result
的结果一定是指针类型。
再考虑下面例子:
假如你有一些函数只能被合适的已锁互斥量调用。每个函数都有一个不同类型的指针:
1 |
|
如果这样传递空指针:
1 |
|
令人遗憾前两个调用没有使用nullptr
,但是代码可以正常运行。
模板化这个调用流程:
1 |
|
1 |
|
可以写这样的代码调用lockAndCall
模板:
1 |
|
代码虽然可以这样写,但是就像注释中说的,前两个情况不能通过编译。
当0
被传递给lockAndCall
模板,模板类型推导会尝试去推导实参类型,0
的类型总是int
。
这意味着lockAndCall
中func
会被int
类型的实参调用,这与f1
期待的std::shared_ptr<Widget>
形参不符。把int
类型看做std::shared_ptr<Widget>
类型给f1
自然是一个类型错误。在模板lockAndCall
中使用0
之所以失败是因为在模板中,传给的是int
但实际上函数期待的是一个std::shared_ptr<Widget>
。
当nullptr
传给lockAndCall
时,ptr
被推导为std::nullptr_t
。当ptr
被传递给f3
的时候,隐式转换使std::nullptr_t
转换为Widget*
,因为std::nullptr_t
可以隐式转换为任何指针类型。
结论
- 优先考虑
nullptr
- 避免重载指针和整型
9 优先 alias
而不是
typedef
typedef
即可:
1 |
|
但typedef
是C++98的东西。
C++11也提供了一个别名声明(alias declaration):
1 |
|
由于这里给出的typedef
和别名声明做的都是完全一样的事情。
使用别名模板,会容易很多:
1 |
|
使用typedef
,你就只能从头开始:
1 |
|
如果你想使用在一个模板内使用typedef
声明一个链表对象,而这个对象又使用了模板形参,你就不得不在typedef
前面加上typename
:
1 |
|
这里MyAllocList<T>::type
使用了一个类型,这个类型依赖于模板参数T
。
如果使用别名声明定义一个MyAllocList
,就不需要使用typename
(同时省略麻烦的“::type
”后缀):
1 |
|
C++11在type
traits(类型特性)中给了你一系列工具去实现类型转换,如果要使用这些模板请包含头文件<type_traits>
。里面有许许多多type
traits,也不全是类型转换的工具,也包含一些可预测接口的工具。给一个你想施加转换的类型T
,结果类型就是std::
transformation<T>::type
,比如:
1 |
|
注释仅仅简单的总结了类型转换做了什么,所以不要太随便的使用。在你的项目使用它们之前,你最好看看它们的详细说明书。
这些别名声明有一个通用形式:对于C++11的类型转换std::
transformation<T>::type
在C++14中变成了std::
transformation_t
。举个例子或许更容易理解:
1 |
|
C++11的的形式在C++14中也有效。其简单实现形式是:
1 |
|
结论
typedef
不支持模板化,但是别名声明支持。- 别名模板避免了使用“
::type
”后缀,而且在模板中使用typedef
还需要在前面加上typename
- C++14提供了C++11所有type traits转换的别名声明版本
10 限域`enum
1 |
|
因为限域enum
是通过“enum class
”声明,所以它们有时候也被称为枚举类(enum
classes)。
使用限域enum
来减少命名空间污染,这是一个足够合理使用它而不是它的同胞未限域enum
的理由。
其实限域enum
还有第二个吸引人的优点:在它的作用域中,枚举名是强类型。未限域enum
中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型)。因此下面这种歪曲语义的做法也是完全有效的:
1 |
|
在enum
后面写一个class
就可以将非限域enum
转换为限域enum
,接下来就是完全不同的故事展开了。现在不存在任何隐式转换可以将限域enum
中的枚举名转化为任何其他类型:
1 |
|
在C++中所有的enum
都有一个由编译器决定的整型的底层类型。对于非限域enum
比如Color
,
1 |
|
编译器可能选择char
作为底层类型,因为这里只需要表示三个值。然而,有些enum
中的枚举值范围可能会大些,比如:
1 |
|
这里值的范围从0
到0xFFFFFFFF
。除了在不寻常的机器上(比如一个char
至少有32bits的那种),编译器都会选择一个比char
大的整型类型来表示Status
。
为了高效使用内存,编译器通常在确保能包含所有枚举值的前提下为enum
选择一个最小的底层类型。
为此,C++98只支持enum
定义(所有枚举名全部列出来);enum
声明是不被允许的。编译器才能在使用之前为每一个enum
选择一个底层类型。
不能前置声明enum
也是有缺点的。最大的缺点莫过于它可能增加编译依赖。系统中某个枚举类型的头文件包含在多个文件中。如果引入一个新状态值,那么可能整个系统都得重新编译。
C++11中的前置声明enum
s可以解决这个问题。
1 |
|
即使Status
的定义发生改变,包含这些声明的头文件也不需要重新编译。
默认情况下,限域枚举的底层类型是int
:
1 |
|
如果默认的int
不适用,你可以重写它:
1 |
|
不管怎样,编译器都知道限域enum
中的枚举名占用多少字节。
要为非限域enum
指定底层类型,你可以同上,结果就可以前向声明:
1 |
|
底层类型说明也可以放到enum
定义处:
1 |
|
假设我们有一个tuple保存了用户的名字,email地址,声望值:
1 |
|
虽然注释说明了tuple各个字段对应的意思,但当你在另一文件遇到下面的代码那之前的注释就不是那么有用了:
1 |
|
在 get 时,显示写明1随代表的字段。
用非限域enum
将名字和字段编号关联起来以避免上述需求:
1 |
|
之所以它能正常工作是因为UserInfoFields
中的枚举名隐式转换成std::size_t
。
对应的限域enum
版本就很啰嗦了:
1 |
|
为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t
值,但这有一点技巧性。
将枚举名变换为std::size_t
值的函数必须在编译期产生这个结果。
它该是一个constexpr
函数模板,因为它应该能用于任何enum
。
底层类型可以通过std::underlying_type
这个type
trait获得。
1 |
|
在C++14中,toUType
还可以进一步用std::underlying_type_t
代替typename std::underlying_type<E>::type
打磨:
1 |
|
还可以再用C++14 auto
打磨一下代码:
1 |
|
不管它怎么写,toUType
现在允许这样访问tuple的字段了:
1 |
|
结论
- 限域
enum
的枚举名仅在enum
内可见。要转换为其它类型只能使用cast。 - 非限域/限域
enum
都支持底层类型说明语法,限域enum
底层类型默认是int
。非限域enum
没有默认底层类型。 - 限域
enum
总是可以前置声明。非限域enum
仅当指定它们的底层类型时才能前置。
11 使用 delete 而不是私有化其声明
1 |
|
deleted函数不能以任何方式被调用,即使你在成员函数或者友元函数里面调用deleted函数也不能通过编译。这是较之C++98行为的一个改进,C++98中不正确的使用这些函数在链接时才被诊断出来。
通常,deleted函数被声明为public
而不是private
。这也是有原因的。当客户端代码试图调用成员函数,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private
的错误。
deleted函数还有一个重要的优势是任何函数都可以标记为deleted,而只有成员函数可被标记为private
。
1 |
|
另一个deleted函数用武之地(private
成员函数做不到的地方)是禁止一些模板的实例化。假如你要求一个模板仅支持原生指针:
1 |
|
如果你想做得更彻底一些,你还要删除const volatile void*
和const volatile char*
重载版本,另外还需要一并删除其他标准字符类型的重载版本:std::wchar_t
,std::char16_t
和std::char32_t
。
类模板在命名空间作用域中,删除特定实例化(private 是做不到的):
1 |
|
结论
- 使用delete函数更好
- 任何函数都能 delete,包括非成员函数和模板实例
12
使用override
声明重写函数
派生类的虚函数重写基类同名函数,很可能一不小心就错了。
1 |
|
要想重写一个函数,必须满足下列要求:
- 基类函数必须是
virtual
- 基类和派生类函数名必须完全一样(除非是析构函数)
- 基类和派生类函数形参类型必须完全一样
- 基类和派生类函数常量性
const
ness必须完全一样 - 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
除了这些C++98就存在的约束外,C++11又添加了一个:
- 函数的引用限定符(reference qualifiers)必须完全一样。它可以限定成员函数只能用于左值或者右值:
1 |
|
对于下面的例子:
1 |
|
C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override
。还是上面那个例子,我们可以这样做:
1 |
|
代码不能编译,当然了,因为这样写的时候,编译器会显示所有与重写有关的问题。这也是你想要的,以及为什么要在所有重写函数后面加上override
。
没有override
,你只能寄希望于完善的单元测试。
C++11引入了两个上下文关键字(contextual
keywords),override
和final
(向虚函数添加final
可以防止派生类重写。final
也能用于类,这时这个类不能用作基类)。
函数引用限定符
reference
qualifiers。如果我们想写一个函数只接受左值实参,我们声明一个non-const
左值引用形参:
1 |
|
如果我们想写一个函数只接受右值实参,我们声明一个右值引用形参:
1 |
|
成员函数的引用限定可以很容易的区分一个成员函数被哪个对象(即*this
)调用。它和在成员函数声明尾部添加一个const
很相似,暗示了调用这个成员函数的对象(即*this
)是const
的。
考虑下面一个例子:
1 |
|
客户端代码:
1 |
|
Widget::data
函数的返回值是一个左值引用(准确的说是std::vector<double>&
),
因为左值引用是左值,所以vals1
是从左值初始化的。因此vals1
由w.values
拷贝构造而得。
现在假设我们有一个创建Widget
s的工厂函数,
1 |
|
我们想用makeWidget
返回的Widget
里的std::vector
初始化一个变量:
1 |
|
Widget
是makeWidget
返回的临时对象(即右值),所以将其中的std::vector
进行拷贝纯属浪费。最好是移动,但是因为data
返回左值引用,C++的规则要求编译器不得不生成一个拷贝。
指明当data
被右值Widget
对象调用的时候结果也应该是一个右值。现在就可以使用引用限定:
1 |
|
data
重载的返回类型是不同的,左值引用重载版本返回一个左值引用(即一个左值),右值引用重载返回一个临时对象(即一个右值)。这意味着现在客户端的行为和我们的期望相符了:
1 |
|
结论
- 为重写函数加上
override
- 成员函数引用限定,区别对待左值对象和右值对象(即
*this
)
13 优先考虑
const_iterator
而不是 iterator
STL
const_iterator
等价于指向常量的指针(pointer-to-const
)。它们都指向不能被修改的值。标准实践是能加上const
就加上。
只是需要注意,C++11 和 C++98 对 const_iterator
的支持不一样。
没办法简简单单的从non-const
容器中获取const_iterator
。
1 |
|
因为向 insert
传入const_iterator
不能通过编译,所以我们将const_iterator
转换为iterator
的。
上面的代码仍然可能无法编译,因为没有一个可移植的从const_iterator
到iterator
的方法,即使使用static_cast
也不行。
所有的这些都在C++11中改变了,现在const_iterator
既容易获取又容易使用。容器的成员函数cbegin
和cend
产出const_iterator
,甚至对于non-const
容器也可用,那些之前使用iterator指示位置(如insert
和erase
)的STL成员函数也可以使用const_iterator
了。
1 |
|
C++11 的一个缺陷是,对于 非成员函数,没有类似的 cbegin,cend 函数支持。C++14补上了这一空白。
非成员函数也叫 自由函数free function,即一个函数,只要不是成员函数就可被称作free function。
举个例子,我们可以泛化下面的findAndInsert
:
1 |
|
它可以在C++14工作良好,但是很遗憾,C++11不在良好之列。
如果你使用C++11,并且想写一个最大程度通用的代码,而你使用的STL没有提供缺失的非成员函数cbegin
,你可以简单的写下你自己的实现。比如,下面就是非成员函数cbegin
的实现:
1 |
|
结论
- 优先考虑
const_iterator
而非iterator
- 在最大程度通用的代码中,优先考虑非成员函数版本的
begin
,end
,rbegin
等,而非同名成员函数
14
如果函数不抛出异常请使用noexcept
调用者可以查看函数是否声明为noexcept
,这个可以影响到调用代码的异常安全性(exception
safety)和效率。就其本身而言,函数是否为noexcept
和成员函数是否const
一样重要。当你知道这个函数不会抛异常而没加上noexcept
,那这个接口说明就有点差劲了。
noexcept
允许编译器生成更好的目标代码。
两种表达方式如下:
1 |
|
如果在运行时,f
出现一个异常,那么就和f
的异常说明冲突了。在C++98的异常说明中,调用栈(the
call
stack)会展开至f
的调用者,在一些与这地方不相关的动作后,程序被终止。C++11异常说明的运行时行为有些不同:调用栈只是可能在程序终止前展开。
展开调用栈和可能展开调用栈两者对于代码生成(code
generation)有非常大的影响。在一个noexcept
函数中,当异常可能传播到函数外时,优化器不需要保证运行时栈(the
runtime
stack)处于可展开状态;也不需要保证当异常离开noexcept
函数时,noexcept
函数中的对象按照构造的反序析构。而标注
“throw()
”
异常声明的函数缺少这样的优化灵活性,没加异常声明的函数也一样。可以总结一下:
1 |
|
这是一个充分的理由使得你当知道它不抛异常时加上noexcept
。
另外对于一些容器数据结构的构造,如果可以就移动,如果必要则复制。对于这个函数只有在知晓移动不抛异常的情况下用C++11的移动操作替换C++98的复制操作才是安全的。
但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept
。
像是 std::vector::push_back
之类的函数调用std::move_if_noexcept
,这是个std::move
的变体,根据其中类型的移动构造函数是否为noexcept
的.
std::move_if_noexcept
查阅std::is_nothrow_move_constructible
这个type
trait.
swap
函数是noexcept
的另一个绝佳用地。swap
是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中。它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。有趣的是,标准库的swap
是否noexcept
有时依赖于用户定义的swap
是否noexcept
。比如,数组和std::pair
的swap
声明如下:
1 |
|
这些函数视情况noexcept
:它们是否noexcept
依赖于noexcept
声明中的表达式是否noexcept
。
一些函数很自然的不应该抛异常,尤其是移动操作和swap
。使其noexcept
有重大意义,只要可能就应该将它们实现为noexcept
。
对于一些函数,使其成为noexcept
是很重要的,它们应当默认如是。在C++98,允许内存释放(memory
deallocation)函数(即operator delete
和operator delete[]
)和析构函数抛出异常是糟糕的代码设计,C++11将这种作风升级为语言规则。
默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式noexcept
。因此它们不需要声明noexcept
。
析构函数非隐式noexcept
的情况仅当类的数据成员(包括继承的成员还有继承成员内的数据成员)明确声明它的析构函数可能抛出异常(如声明“noexcept(false)
”)。
如果一个对象的析构函数可能被标准库使用(比如在容器内或者被传给一个算法),析构函数又可能抛异常,那么程序的行为是未定义的。
有时候, 一些库函数, C++98的函数, 即使是决不抛出异常的, 也没有标识为
noexcept
.
因为从C标准库移动到了std
命名空间,也可能缺少异常规范,std::strlen
就是一个例子,它没有声明noexcept
.
另外C++98异常规范和C++11不同.
结论
noexcept
是函数接口的一部分,这意味着调用者可能会依赖它noexcept
函数较之于non-noexcept
函数更容易优化noexcept
对于移动语义,swap
,内存释放函数和析构函数非常有用- 大多数函数是异常中立的(可能抛也可能不抛异常)而不是
noexcept
15 尽量使用 constexpr
从概念上来说,constexpr
表明一个值不仅仅是常量,还是编译期可知的。这个表述并不全面,因为当constexpr
被用于函数的时候,事情就有一些细微差别了。
但是,并不能保证constexpr
函数的结果是const
,也不能保证它们的返回值是在编译期可知的。
和const
一样,constexpr
是编译期可知的。技术上来讲,它们的值在翻译期(translation)决议,所谓翻译(translation)不仅仅包含是编译(compilation)也包含链接(linking)。
编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。对于那些嵌入式系统的开发者,这个特性是相当重要的。更广泛的应用是
“其值编译期可知” 的常量整数会出现在需要
“整型常量表达式(integral constant
expression)的上下文中:包括数组大小,整数模板参数(包括std::array
对象的长度),枚举名的值,对齐修饰符(alignas(val)
),等等。
1 |
|
注意const
不提供constexpr
所能保证之事,因为const
对象不需要在编译期初始化它的值。
1
2
3
4int sz;
…
const auto arraySize = sz; //没问题,arraySize是sz的const复制
std::array<int, arraySize> data; //错误,arraySize值在编译期不可知
简而言之,所有constexpr
对象都是const
,但不是所有const
对象都是constexpr
。如果你想编译器保证一个变量有一个值,这个值可以放到那些需要编译期常量(compile-time
constants)的上下文的地方,你需要的工具是constexpr
而不是const
。
注意,I/O语句一般不被允许出现在constexpr
函数里。
constexpr 限制
因为constexpr
函数必须能在编译期值调用的时候返回编译期结果,就必须对它的实现施加一些限制。这些限制在C++11和C++14标准间有所出入。
C++11中,constexpr
函数的代码不超过一行语句:一个return
。听起来很受限,但实际上有两个技巧可以扩展constexpr
函数的表达能力。第一,使用三元运算符“?:
”来代替if
-else
语句,第二,使用递归代替循环。因此pow
可以像这样实现:
1 |
|
在C++11中,有两个限制使得Point
的成员函数setX
和setY
不能声明为constexpr
。第一,它们修改它们操作的对象的状态,
并且在C++11中,constexpr
成员函数是隐式的const
。第二,它们有void
返回类型,void
类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Point
的setter(赋值器)也能声明为constexpr
:
1 |
|
现在也能写这样的函数:
1 |
|
C++14 constexpr
在C++14中,constexpr
函数的限制变得非常宽松了,所以下面的函数实现成为了可能:
1 |
|
constexpr
函数限制为只能获取和返回字面值类型,这基本上意味着那些有了值的类型能在编译期决定。在C++11中,除了void
外的所有内置类型,以及一些用户定义类型都可以是字面值类型,因为构造函数和其他成员函数可能是constexpr
:
1 |
|
Point
的构造函数可被声明为constexpr
,因为如果传入的参数在编译期可知,Point
的数据成员也能在编译器可知。因此这样初始化的Point
就能为constexpr
:
1 |
|
类似的,xValue
和yValue
的getter(取值器)函数也能是constexpr
,因为如果对一个编译期已知的Point
对象(如一个constexpr
Point
对象)调用getter,数据成员x
和y
的值也能在编译期知道。这使得我们可以写一个constexpr
函数,里面调用Point
的getter并初始化constexpr
的对象:
1 |
|
mid
对象通过调用构造函数,getter和非成员函数来进行初始化过程就能在只读内存中被创建出来。
constexpr
对象和constexpr
函数可以使用的范围比non-constexpr
对象和函数大得多。使用constexpr
关键字可以最大化你的对象和函数可以使用的场景。
还有个重要的需要注意的是constexpr
是对象和函数接口的一部分。加上constexpr
相当于宣称“我能被用在C++要求常量表达式的地方”。
结论
constexpr
对象是const
,它被在编译期可知的值初始化- 当传递编译期可知的值时,
constexpr
函数可以产出编译期可知的结果 constexpr
对象和函数可以使用的范围比non-constexpr
对象和函数要大constexpr
是对象和函数接口的一部分
16 让 const
成员函数线程安全
考虑下面的例子,计算多项式的根,多项式的根在多项式确定时,根一般是确定的,声明为
const
。
1 |
|
roots
是const
成员函数,那就表示着它是一个读操作。在没有同步的情况下,让多个线程执行读操作是安全的。
但是,在roots
中,这些线程中的一个或两个可能尝试修改成员变量rootsAreValid
和rootVals
。这就意味着在没有同步的情况下,这些代码会有不同的线程读写相同的内存,这就是数据竞争(data
race)的定义。这段代码的行为是未定义的。
问题就是roots
被声明为const
,但不是线程安全的。
解决这个问题最普遍简单的方法就是——使用mutex
(互斥量):
1 |
|
std::mutex
既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的
在某些情况下,互斥量的副作用显会得过大。例如,如果你所做的只是计算成员函数被调用了多少次,使用std::atomic
修饰的计数器通常会是一个开销更小的方法(当然是否更小,取决于你使用的硬件和标准库对互斥量的实现)。
1 |
|
与
std::mutex
类似的,实际上std::atomic
既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的
但是只使用 std::atomic
存在以下问题:
1 |
|
仍然可能出现重复计算。考虑:
- 一个线程调用
Widget::magicValue
,将cacheValid
视为false
,执行这两个昂贵的计算,并将它们的和分配给cachedValue
。 - 此时,第二个线程调用
Widget::magicValue
,也将cacheValid
视为false
,因此执行刚才完成的第一个线程相同的计算。(这里的“第二个线程”实际上可能是其他几个线程。)
这种行为与使用缓存的目的背道而驰。将cachedValue
和CacheValid
的赋值顺序交换可以解决这个问题,但结果会更糟。
假设cacheValid
是false,那么:
- 一个线程调用
Widget::magicValue
,刚执行完将cacheValid
设置true
的语句。 - 在这时,第二个线程调用
Widget::magicValue
,检查cacheValid
。看到它是true
,就返回cacheValue
,即使第一个线程还没有给它赋值。因此返回的值是不正确的。
对于需要同步的是单个的变量或者内存位置,使用std::atomic
就足够了。不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量。
结论
- 确保
const
成员函数线程安全(先得明白什么是线程不安全),除非你确定它们永远不会在并发上下文(concurrent context)中使用。 - 使用
std::atomic
变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。
17 理解特殊成员函数的生成
C++98有四个:默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。默认构造函数仅在类完全没有构造函数的时候才生成。
C++11特殊成员函数俱乐部迎来了两位新会员:移动构造函数和移动赋值运算符。它们的签名是:
1 |
|
移动操作仅在需要的时候生成,如果生成了,就会对类的non-static数据成员执行逐成员的移动。
逐成员移动的核心是对对象使用std::move
,然后函数决议时会选择执行移动还是拷贝操作。记住如果支持移动就会逐成员移动类成员和基类成员,如果不支持移动就执行拷贝操作就好了。
拷贝构造与移动构造生成方式
如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造函数,代码用到拷贝构造函数时编译器就会生成它。
两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。如果你给类声明了,比如,一个移动构造函数,就表明对于移动操作应怎样实现,与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有些问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止移动赋值运算符的生成,声明移动赋值运算符同样阻止编译器生成移动构造函数。
如果一个类显式声明了拷贝操作,编译器就不会生成移动操作。这种限制的解释是如果声明拷贝操作(构造或者赋值)就暗示着平常拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。
Rule of Three
如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。
如果一个类显式声明了拷贝操作,编译器就不会生成移动操作。所以,C++11不会为那些有用户定义的析构函数的类生成移动操作。
仅当下面条件成立时才会生成移动操作(当需要时):
- 类中没有拷贝操作
- 类中没有移动操作
- 类中没有用户定义的析构
假设编译器生成的函数行为是正确的(即逐成员拷贝类non-static数据是你期望的行为),你的工作很简单,C++11的= default
就可以表达你想做的:
1 |
|
就算编译器乐于为你的类生成拷贝和移动操作,生成的函数也如你所愿,你也应该手动声明它们然后加上= default
。这看起来比较多余,但是它让你的意图更明确,也能帮助你避免一些微妙的bug。
声明析构有潜在的副作用:它阻止了移动操作的生成。然而,拷贝操作的生成是不受影响的。所以手动声明为
=default
是有意义的。
C++11 处理规则
- 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成。
- 析构函数:基本上和C++98相同;稍微不同的是现在析构默认
noexcept
(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。 - 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。但是,当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃。
- 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值non-static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。但是,当用户声明了拷贝构造或者析构,该函数自动生成已被废弃。
- 移动构造函数和移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。
结论
- 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
- 移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
- 拷贝构造函数仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete。
- 拷贝赋值运算符仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete。当用户声明了析构函数,拷贝操作的自动生成已被废弃。
- 成员函数模板不抑制特殊成员函数的生成。
18 对独占资源使用
std::unique_ptr
默认情况下,std::unique_ptr
大小等同于原始指针,而且对于大多数操作(包括取消引用),他们执行的指令完全相同。这意味着你甚至可以在内存和时间都比较紧张的情况下使用它。如果原始指针够小够快,那么std::unique_ptr
一样可以。
std::unique_ptr
体现了专有所有权(exclusive
ownership)语义。
一个non-null
std::unique_ptr
始终拥有其指向的内容。移动一个std::unique_ptr
将所有权从源指针转移到目的指针。(源指针被设为null)
拷贝一个std::unique_ptr
是不允许的,因为如果你能拷贝一个std::unique_ptr
,你会得到指向相同内容的两个std::unique_ptr
,每个都认为自己拥有(并且应当最后销毁)资源,销毁时就会出现重复销毁。
因此,std::unique_ptr
是一种只可移动类型(move-only
type)。当析构时,一个non-null
std::unique_ptr
销毁它指向的资源。默认情况下,资源析构通过对std::unique_ptr
里原始指针调用delete
来实现。
Investment
继承关系的工厂函数可以这样声明:
1 |
|
调用者应该在单独的作用域中使用返回的std::unique_ptr
智能指针:
1 |
|
std::unique_ptr
将保证指向内容的析构函数被调用,销毁对应资源。
这个规则也有些例外。大多数情况发生于不正常的程序终止。
如果一个异常传播到线程的基本函数,比如程序初始线程的main
函数外,或者违反noexcept
说明,局部变量可能不会被销毁;如果std::abort
或者退出函数(如std::_Exit
,std::exit
,或std::quick_exit
)被调用,局部变量一定没被销毁。
自定义删除器
1 |
|
上述代码中:
delInvmt
是从makeInvestment
返回的对象的自定义的删除器。- 删除器类型必须作为第二个类型实参传给
std::unique_ptr
。 - 尝试将原始指针(比如
new
创建)赋值给std::unique_ptr
通不过编译,因为是一种从原始指针到智能指针的隐式转换。这种隐式转换会出问题,所以C++11的智能指针禁止这个行为。这就是通过reset
来让pInv
接管通过new
创建的对象的所有权的原因。 - 使用
new
时,我们使用std::forward
把传给makeInvestment
的实参完美转发出去。 - 自定义删除器的一个形参,类型是
Investment*
,不管在makeInvestment
内部创建的对象的真实类型(如Stock
,Bond
,或RealEstate
)是什么,它最终在lambda表达式中,作为Investment*
对象被删除。这意味着我们通过基类指针删除派生类实例,为此,基类Investment
必须有虚析构函数。
C++14中,存在返回类型推导,写法更为简单:
1 |
|
当使用默认删除器时(如delete
),你可以合理假设std::unique_ptr
对象和原始指针大小相同。
但是当自定义删除器时,情况可能不再如此。函数指针形式的删除器,通常会使std::unique_ptr
的从一个字(word)大小增加到两个。这可能导致
std::unique_ptr
对象变得过大。
1 |
|
对于函数对象形式的删除器来说,变化的大小取决于函数对象中存储的状态多少,无状态函数(stateless function)对象(比如不捕获变量的lambda表达式)对大小没有影响,这意味当自定义删除器可以实现为函数或者lambda时,尽量使用lambda。
1 |
|
std::unique_ptr
是C++11中表示专有所有权的方法,但是其最吸引人的功能之一是它可以轻松高效的转换为std::shared_ptr
:
1 |
|
这就是std::unique_ptr
非常适合用作工厂函数返回类型的原因的关键部分。
工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std::shared_ptr
)是否更合适。
结论
std::unique_ptr
是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针- 默认情况,资源销毁通过
delete
实现,但是支持自定义删除器。有状态的删除器(捕获变量的lambda表达式)和函数指针(带参数)会增加std::unique_ptr
对象的大小。所以是一般使用无状态的lambda
表达式 - 将
std::unique_ptr
转化为std::shared_ptr
非常简单
std::shared_ptr
通过引用计数(reference
count)来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr
指向该资源。如果std::shared_ptr
在计数值递减后发现引用计数值为零,没有其他std::shared_ptr
指向该资源,它就会销毁资源。
引用计数暗示着性能问题:
std::shared_ptr
大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个指向资源的引用计数值的原始指针。- 引用计数的内存几乎使用动态分配。
std::make_shared
创建std::shared_ptr
可以避免引用计数的动态分配,但是还存在一些std::make_shared
不能使用的场景,这时候引用计数就会动态分配。 - 递增递减引用计数必须是原子性的,因为多个reader、writer可能在不同的线程。比如,指向某种资源的
std::shared_ptr
可能在一个线程执行析构(于是递减指向的对象的引用计数),在另一个不同的线程,std::shared_ptr
指向相同的对象,但是执行的却是拷贝操作(因此递增了同一个引用计数)。原子操作通常比非原子操作要慢,所以即使引用计数通常只有一个word大小,你也应该假定读写它们是存在开销的。
移动std::shared_ptr
会比拷贝它要快:拷贝要求递增引用计数值,移动不需要。移动赋值运算符同理,所以移动构造比拷贝构造快,移动赋值运算符也比拷贝赋值运算符快。
与 std::unique_ptr
的区别
std::shared_ptr
使用delete
作为资源的默认销毁机制,但是它也支持自定义的删除器。这种支持有别于std::unique_ptr
。对于std::unique_ptr
来说,删除器类型是智能指针类型的一部分。对于std::shared_ptr
则不是:
1 |
|
std::shared_ptr
的设计更为灵活。考虑有两个std::shared_ptr<Widget>
,每个自带不同的删除器(比如通过lambda表达式自定义删除器):
1 |
|
因为 pw1
和 pw2
有相同的类型,所以它们都可以放到存放那个类型的对象的容器中:
1 |
|
它们也能相互赋值,也可以传入一个形参为std::shared_ptr<Widget>
的函数。但是自定义删除器类型不同的std::unique_ptr
就不行,因为std::unique_ptr
把删除器视作类型的一部分。
另一个不同于std::unique_ptr
的地方是,指定自定义删除器不会改变std::shared_ptr
对象的大小。不管删除器是什么,一个std::shared_ptr
对象都是两个指针大小。这是个好消息,但是它应该让你隐隐约约不安。自定义删除器可以是函数对象,函数对象可以包含任意多的数据。它意味着函数对象是任意大的。
引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块(control
block)。每个std::shared_ptr
管理的对象都有个相应的控制块。控制块除了包含引用计数值外还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制块也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,一个次级引用计数weak
count。
对于一个创建指向对象的std::shared_ptr
的函数来说不可能知道是否有其他std::shared_ptr
早已指向那个对象,所以控制块的创建会遵循下面几条规则:
std::make_shared
总是创建一个控制块。它创建一个要指向的新对象,所以可以肯定std::make_shared
调用时对象不存在其他控制块。- 当从独占指针(即
std::unique_ptr
或者std::auto_ptr
)上构造出std::shared_ptr
时会创建控制块。独占指针没有使用控制块,所以指针指向的对象没有关联控制块。(作为构造的一部分,std::shared_ptr
侵占独占指针所指向的对象的独占权,所以独占指针被设置为null) - 当从原始指针上构造出
std::shared_ptr
时会创建控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr
,你将假定传递一个std::shared_ptr
或者std::weak_ptr
。作为构造函数实参,而不是原始指针。用std::shared_ptr
或者std::weak_ptr
作为构造函数实参创建std::shared_ptr
不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。
从原始指针上构造超过一个std::shared_ptr
会造成未定义行为,因为指向的对象有多个控制块关联。多个控制块意味着多个引用计数值,多个引用计数值意味着对象将会被销毁多次(每个引用计数一次)。那意味着像下面的代码是有问题的:
1 |
|
使用智能指针而不是原始指针。
使用 std::shared_ptr
的建议是:第一,避免传给std::shared_ptr
构造函数原始指针。通常替代方案是使用std::make_shared
,不过用std::make_shared
就没办法使用自定义删除器。第二,如果你必须传给std::shared_ptr
构造函数原始指针,直接传new
出来的结果,不要传指针变量。
1 |
|
创建spw2
也会很自然的用spw1
作为初始化参数(即用std::shared_ptr
拷贝构造函数),那就没什么问题了:
1 |
|
this 指针:避免创建多余的控制块
std::enable_shared_from_this
。如果你想创建一个用std::shared_ptr
管理的类,这个类能够用this
指针安全地创建一个std::shared_ptr
,std::enable_shared_from_this
就可作为基类的模板类。Widget
将会继承自std::enable_shared_from_this
:
1 |
|
这个标准名字就是奇异递归模板模式(The Curiously Recurring Template Pattern(CRTP))。
std::enable_shared_from_this
定义了一个成员函数,成员函数会创建指向当前对象的std::shared_ptr
却不创建多余控制块。这个成员函数就是shared_from_this
,无论在哪当你想在成员函数中使用std::shared_ptr
指向this
所指对象时都请使用它。这里有个Widget::process
的安全实现:
1 |
|
从内部来说,shared_from_this
查找当前对象控制块,然后创建一个新的std::shared_ptr
关联这个控制块。设计的依据是当前对象已经存在一个关联的控制块。
要想符合设计依据的情况,必须已经存在一个指向当前对象的std::shared_ptr
(比如调用shared_from_this
的成员函数外面已经存在一个std::shared_ptr
)。如果没有std::shared_ptr
指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this
通常抛出一个异常。
要想防止客户端在存在一个指向对象的std::shared_ptr
前先调用含有shared_from_this
的成员函数,继承自std::enable_shared_from_this
的类通常将它们的构造函数声明为private
,并且让客户端通过返回std::shared_ptr
的工厂函数创建对象。以Widget
为例,代码可以是这样:
1 |
|
确保需要先调用 create,才能调用 process。
控制块通常只占几个word大小,自定义删除器和分配器可能会让它变大一点。通常控制块的实现比你想的更复杂一些。它使用继承,甚至里面还有一个虚函数(用来确保指向的对象被正确销毁)。这意味着使用std::shared_ptr
,会带来使用虚函数带来的成本。
使用默认删除器和默认分配器,使用std::make_shared
创建std::shared_ptr
,产生的控制块只需三个word大小。它的分配基本上是无开销的。
对std::shared_ptr
解引用的开销不会比原始指针高。执行需要原子引用计数修改的操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子指令来说,原子指令开销较大,但是它们仍然只是单个指令上的。对于每个被std::shared_ptr
指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。
如果独占资源可行或者可能可行,用std::unique_ptr
是一个更好的选择。它的性能表现更接近于原始指针,并且从std::unique_ptr
升级到std::shared_ptr
也很容易,因为std::shared_ptr
可以从std::unique_ptr
上创建。
从 std::shared_ptr
转换到 std::unique_ptr
是不行的
。当你的资源由std::shared_ptr
管理,现在又想修改资源生命周期管理方式是没有办法的。即使引用计数为一,你也不能重新修改资源所有权,改用std::unique_ptr
管理它。资源和指向它的std::shared_ptr
的签订的所有权协议是“除非死亡否则永不分开”。不能分离,不能废除,没有特许。
std::shared_ptr
不能处理的另一个东西是数组。和std::unique_ptr
不同的是,std::shared_ptr
的API设计之初就是针对单个对象的,没有办法std::shared_ptr<T[]>
。
std::shared_ptr
没有提供operator[]
,所以数组索引操作需要借助怪异的指针算术。另一方面,std::shared_ptr
支持转换为指向基类的指针,这对于单个对象来说有效,但是当用于数组类型时这是容易出问题。(出于这个原因,std::unique_ptr<T[]>
API禁止这种转换。)
更重要的是,C++11已经提供了很多内置数组的候选方案(比如std::array
,std::vector
,std::string
)。所以,声明一个指向数组的智能指针几乎总是糟糕的设计。
结论
std::shared_ptr
为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。- 较之于
std::unique_ptr
,std::shared_ptr
对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。 std::shared_ptr
默认资源销毁是通过delete
,但是也支持自定义删除器。但是删除器的类型不是std::shared_ptr
的类型的一部分。- 避免从原始指针变量上创建
std::shared_ptr
。
一个真正的智能指针应该跟踪所指对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对std::weak_ptr
最精确的描述。
std::weak_ptr
不能解引用,也不能测试是否为空值。因为std::weak_ptr
不是一个独立的智能指针。它是std::shared_ptr
的增强。
std::weak_ptr
通常从std::shared_ptr
上创建。当从std::shared_ptr
上创建std::weak_ptr
时两者指向相同的对象,但是std::weak_ptr
不会影响所指对象的引用计数。
1 |
|
悬空的std::weak_ptr
被称作已经expired(过期)。你可以用它直接做测试:
1 |
|
但是通常你期望的是检查std::weak_ptr
是否已经过期,如果没有过期则访问其指向的对象。不过,将检查是否过期和解引用分开会引入竞态条件:在调用expired
和解引用操作之间,另一个线程可能对指向这对象的std::shared_ptr
重新赋值或者析构,并由此造成对象已析构。这种情况下,你的解引用将会产生未定义行为。
你需要的是一个原子操作检查std::weak_ptr
是否已经过期,如果没有过期就访问所指对象。这可以通过从std::weak_ptr
创建std::shared_ptr
来实现,具体有两种形式可以从std::weak_ptr
上创建std::shared_ptr
,具体用哪种取决于std::weak_ptr
过期时你希望std::shared_ptr
表现出什么行为。
一种形式是std::weak_ptr::lock
,它返回一个std::shared_ptr
,如果std::weak_ptr
过期这个std::shared_ptr
为空:
1 |
|
另一种形式是以std::weak_ptr
为实参构造std::shared_ptr
。这种情况中,如果std::weak_ptr
过期,会抛出一个异常:
1 |
|
一个例子
考虑一个工厂函数,它基于一个唯一ID从只读对象上产出智能指针。根据条款18的描述,工厂函数会返回一个该对象类型的std::unique_ptr
:
1 |
|
如果调用loadWidget
是一个昂贵的操作(比如它操作文件或者数据库I/O)并且重复使用ID很常见,一个合理的优化是再写一个函数除了完成loadWidget
做的事情之外再缓存它的结果。另一个合理的优化可以是当Widget
不再使用的时候销毁它的缓存。
对于可缓存的工厂函数,返回std::unique_ptr
不是好的选择。调用者应该接收缓存对象的智能指针,调用者也应该确定这些对象的生命周期,但是缓存本身也需要一个指针指向它所缓存的对象。缓存对象的指针需要知道它是否已经悬空,因为当工厂客户端使用完工厂产生的对象后,对象将被销毁,关联的缓存条目会悬空。所以缓存应该使用std::weak_ptr
,这可以知道是否已经悬空。这意味着工厂函数返回值类型应该是std::shared_ptr
,因为只有当对象的生命周期由std::shared_ptr
管理时,std::weak_ptr
才能检测到悬空。
一个简版的实现:
1 |
|
fastLoadWidget
的实现仍有以下问题:缓存可能会累积过期的std::weak_ptr
,这些指针对应了不再使用的Widget
(也已经被销毁了)。
另一个例子
考虑第二个用例:观察者设计模式(Observer design pattern)。
此模式的主要组件是subjects(状态可能会更改的对象)和observers(状态发生更改时要通知的对象)。在大多数实现中,每个subject都包含一个数据成员,该成员持有指向其observers的指针。这使subjects很容易发布状态更改通知。
subjects对控制observers的生命周期(即它们什么时候被销毁)没有兴趣,但是subjects对确保另一件事具有极大的兴趣,那事就是一个observer被销毁时,不再尝试访问它。一个合理的设计是每个subject持有一个std::weak_ptr
s容器指向observers,因此可以在使用前检查是否已经悬空。
最后一个例子
考虑一个持有三个对象A
、B
、C
的数据结构,A
和C
共享B
的所有权,因此持有std::shared_ptr
:
假定从B指向A的指针也很有用。应该使用哪种指针?
有三种选择:
- 原始指针。使用这种方法,如果
A
被销毁,但是C
继续指向B
,B
就会有一个指向A
的悬空指针。而且B
不知道指针已经悬空,所以B
可能会继续访问,就会导致未定义行为。 std::shared_ptr
。这种设计,A
和B
都互相持有对方的std::shared_ptr
,导致的std::shared_ptr
环状结构(A
指向B
,B
指向A
)阻止A
和B
的销毁。甚至A
和B
无法从其他数据结构访问了(比如,C
不再指向B
),每个的引用计数都还是1。如果发生了这种情况,A
和B
都被泄漏:程序无法访问它们,但是资源并没有被回收。std::weak_ptr
。这避免了上述两个问题。如果A
被销毁,B
指向它的指针悬空,但是B
可以检测到这件事。尤其是,尽管A
和B
互相指向对方,B
的指针不会影响A
的引用计数,因此在没有std::shared_ptr
指向A
时不会导致A
无法被销毁。
但是,需要注意使用
std::weak_ptr
打破std::shared_ptr
循环并不常见。在严格分层的数据结构比如树中,子节点只被父节点持有。当父节点被销毁时,子节点就被销毁。从父到子的链接关系可以使用std::unique_ptr
很好的表征。从子到父的反向连接可以使用原始指针安全实现,因为子节点的生命周期肯定短于父节点。因此没有子节点解引用一个悬空的父节点指针这样的风险。
从效率角度来看,std::weak_ptr
与std::shared_ptr
基本相同。两者的大小是相同的,使用相同的控制块。构造、析构、赋值操作涉及引用计数的原子操作。
虽然,
std::weak_ptr
不参与对象的共享所有权,因此不影响指向对象的引用计数。实际上在控制块中还是有第二个引用计数,std::weak_ptr
操作的是第二个引用计数。
结论
- 用
std::weak_ptr
替代可能会悬空的std::shared_ptr
。 std::weak_ptr
的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr
环状结构。
std::make_shared
是C++11标准的一部分,但是,std::make_unique
是从C++14开始加入标准库。
一个基础版本的std::make_unique
是很容易自己写出的,如下:
1 |
|
这种形式的函数不支持数组和自定义析构(见条款18)
std::make_unique
和std::make_shared
是三个make函数 中的两个:接收任意的多参数集合,完美转发到构造函数去动态分配一个对象,然后返回这个指向这个对象的指针。第三个
make
函数是std::allocate_shared
。它行为和std::make_shared
一样,只不过第一个参数是用来动态分配内存的allocator对象。
使用 std::make_unique
的理由一
例如:
1 |
|
我高亮了关键区别:使用new
的版本重复了类型,但是make
函数的版本没有。
重复写类型和软件工程里面一个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致目标代码冗余,并且通常会让代码库使用更加困难。
它经常演变成不一致的代码,而代码库中的不一致常常导致bug。
使用 std::make_unique
的理由二
在调用processWidget
时使用了new
而不是std::make_shared
:
1 |
|
内存泄漏的原因在于:
在运行时,一个函数的实参必须先被计算,这个函数再被调用,所以在调用processWidget
之前,必须执行以下操作,processWidget
才开始执行:
- 表达式“
new Widget
”必须计算,例如,一个Widget
对象必须在堆上被创建 - 负责管理
new
出来指针的std::shared_ptr<Widget>
构造函数必须被执行 computePriority
必须运行
而编译器不保证按照顺序生成代码。
虽然“new Widget
”必须在std::shared_ptr
的构造函数被调用前执行,因为new
出来的结果作为构造函数的实参,但computePriority
可能在这之前,之后,或者之间执行。也就是说,编译器可能按照这个执行顺序生成代码:
- 执行“
new Widget
” - 执行
computePriority
- 运行
std::shared_ptr
构造函数
在运行时computePriority
产生了异常,那么第一步动态分配的Widget
就会泄漏。因为它永远都不会被第三步的std::shared_ptr
所管理了。
使用std::make_shared
可以防止这种问题。调用代码看起来像是这样:
1 |
|
在运行时,std::make_shared
和computePriority
其中一个会先被调用。
使用 std::make_unique
的理由三
使用std::make_shared
允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:
1 |
|
显然,这段代码需要进行内存分配,但它实际上执行了两次。
每个std::shared_ptr
指向一个控制块,其中包含被指向对象的引用计数,还有其他东西。这个控制块的内存在std::shared_ptr
构造函数中分配。因此,直接使用new
需要为Widget
进行一次内存分配,为控制块再进行一次内存分配。
如果使用std::make_shared
代替:
1 |
|
一次分配足矣。这是因为std::make_shared
分配一块内存,同时容纳了Widget
对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。此外,使用std::make_shared
避免了对控制块中的某些簿记信息的需要,潜在地减少了程序的总内存占用。
需要自定义删除器时
make
函数都不允许指定自定义删除器,但是std::unique_ptr
和std::shared_ptr
有构造函数这么做。有个Widget
的自定义删除器:
1 |
|
创建一个使用它的智能指针只能直接使用new
:
1 |
|
对于make
函数,没有办法做同样的事情。
不支持花括号调用
std::initializer_list
常规的用花括号创建的对象更倾向于使用std::initializer_list
作为形参的重载形式,而用小括号创建对象将调用不用std::initializer_list
作为参数的的重载形式。
但是,在这些调用中,
1 |
|
生成的智能指针指向带有10个元素的std::vector
,每个元素值为20。
如果你想用花括号初始化指向的对象,你必须直接使用new
。
一个变通的方法:使用auto
类型推导从花括号初始化创建std::initializer_list
对象,然后将auto
创建的对象传递给make
函数。
1 |
|
对于std::unique_ptr
,只有这两种情景(自定义删除器和花括号初始化)使用make
函数有点问题。对于std::shared_ptr
和它的make
函数,还有2个问题。都属于边缘情况,但是一些开发者常碰到。
类重载了operator new
和operator delete
例如,Widget
类的operator new
和operator delete
只会处理sizeof(Widget)
大小的内存块的分配和释放。因为std::allocate_shared
需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。
与直接使用new
相比,std::make_shared
在大小和速度上的优势源于std::shared_ptr
的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(即析构函数被调用)。但是,因为控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,对象占用的内存才被释放。
控制块除了引用计数,还包含簿记信息。引用计数追踪有多少std::shared_ptr
s指向控制块,但控制块还有第二个计数,记录多少个std::weak_ptr
s指向控制块。第二个引用计数就是weak
count。当一个std::weak_ptr
检测它是否过期时,它会检测指向的控制块中的引用计数(而不是weak
count)。
如果引用计数是0(即对象没有std::shared_ptr
再指向它,已经被销毁了),std::weak_ptr
就已经过期。
但是只要std::weak_ptr
s引用一个控制块(即weak
count大于零),该控制块必须继续存在。只要控制块存在,包含它的内存就必须保持分配。
所以,通过std::shared_ptr
的make
函数分配的内存,直到最后一个std::shared_ptr
和最后一个指向它的std::weak_ptr
已被销毁,才会释放。
所以,如果对象类型非常大,而且销毁最后一个std::shared_ptr
和销毁最后一个std::weak_ptr
之间的时间很长,那么在销毁对象和释放它所占用的内存之间可能会出现延迟。
例如,下面这种情况,明显,直接只用new
,对象的释放会立即执行。
1 |
|
直接只用new
,一旦最后一个std::shared_ptr
被销毁,ReallyBigType
对象的内存就会被释放:
1 |
|
一个优化的例子
考虑前面的 processWidget
函数,现在我们指定一个自定义删除器:
1 |
|
下面这个是非异常安全的调用:
1 |
|
还是实参调用的顺序问题。
一个优化方式如下:
1 |
|
但是有一个性能问题,实参在前一个非异常安全调用中,std::shared_ptr
形参是传值,从右值构造只需要移动。
而优化后,传递左值构造需要拷贝。对std::shared_ptr
而言,这种区别是有意义的,因为拷贝std::shared_ptr
需要对引用计数原子递增,移动则不需要对引用计数有操作。
所以,更高效安全的版本是:
1 |
|
结论
- 和直接使用
new
相比,make
函数消除了代码重复,提高了异常安全性。对于std::make_shared
和std::allocate_shared
,生成的代码更小更快。 - 不适合使用
make
函数的情况包括需要指定自定义删除器和希望用花括号初始化。 - 对于
std::shared_ptr
s,其他不建议使用make
函数的情况包括:(1) 有自定义内存管理的类;(2) 特别关注内存的系统,非常大的对象,以及std::weak_ptr
s比对应的std::shared_ptr
s活得更久。
22 当使用 Pimpl Idiom,请在实现文件中定义特殊成员函数
Pimpl Idiom 将类数据成员替换成一个指向包含具体实现的类(implementation class)(或结构体)的指针,并将原本放在主类(primary class)的相关数据成员们移动到实现类(implementation class)去,而这些数据成员的访问将通过指针间接访问。
举个例子:
1 |
|
因为类Widget
的数据成员包含有类型std::string
,std::vector
和Gadget
,
定义有这些类型的头文件在类Widget
编译的时候,必须被包含进来,这意味着类Widget
的使用者必须要#include <string>
,<vector>
以及gadget.h
。
这些头文件将会增加类Widget
使用者的编译时间,并且让这些使用者依赖于这些头文件。
如果一个头文件的内容变了,类Widget
使用者也必须要重新编译。
标准库文件<string>
和<vector>
不是很常变,但是gadget.h
可能会经常修订。
在C++98中使用Pimpl惯用法,可以把Widget
的数据成员替换成一个原始指针,指向一个已经被声明过却还未被定义的结构体:
1 |
|
因为类Widget
不再提到类型std::string
,std::vector
以及Gadget
,Widget
的使用者不再需要为了这些类型而引入头文件。
这可以加速编译,并且意味着,如果这些头文件中有所变动,Widget
的使用者不会受到影响。
一个已经被声明,却还未被实现的类型,被称为未完成类型(incomplete
type)。 Widget::Impl
就是这种类型。
下一步是对 实现类(implementation class) 的内存管理:
在Widget.cpp
里:
1 |
|
它使用了原始指针,原始的new
和原始的delete
,一切都让它如此的...原始。
使用智能指针
如果我们想要的只是在类Widget
的构造函数动态分配Widget::impl
对象,在Widget
对象销毁时一并销毁它,
std::unique_ptr
是最合适的工具。
1 |
|
实现文件也可以改成如下:
1 |
|
问题出现了
以上的代码能编译,但是,最普通的Widget
用法却会导致编译出错:
1 |
|
在对象w
被析构时(例如离开了作用域),问题出现了。
在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr
,所以我们没有声明析构函数。编译器会自动为我们生成一个析构函数。
在这个析构函数里,编译器会插入一些代码来调用类Widget
的数据成员pImpl
的析构函数。
问题就在于,此时pImpl
的析构函数调用默认的删除器。默认删除器是一个函数,它使用delete
来销毁内置于std::unique_ptr
的原始指针。然而,在使用delete
之前,通常会使默认删除器使用C++11的特性static_assert
来确保原始指针指向的类型不是一个未完成类型。
需要确保在编译器生成销毁std::unique_ptr<Widget::Impl>
的代码之前,
Widget::Impl
已经是一个完成类型(complete type)。
当编译器“看到”它的定义的时候,该类型就成为完成类型了。 但是
Widget::Impl
的定义在widget.cpp
里。
成功编译的关键,就是在widget.cpp
文件内,让编译器在“看到”
Widget
的析构函数实现之前(也即编译器插入的,用来销毁std::unique_ptr
这个数据成员的代码段之前),先定义Widget::Impl
。
修改方法就是,在widget.h
里只声明类Widget
的析构函数,但不要在这里定义它:
1 |
|
在widget.cpp
文件中,在结构体Widget::Impl
被定义之后,再定义析构函数:
1 |
|
如果你想强调编译器自动生成的析构函数会做和你一样正确的事情,你可以直接使用“= default
”定义析构函数体
1 |
|
移动
编译器自动生成的移动操作对其中的std::unique_ptr
进行移动。但是,声明一个类Widget
的析构函数会阻止编译器生成移动操作,所以你需要这样做:
1 |
|
问题在于:
编译器生成的移动赋值操作符,在重新赋值之前,需要先销毁指针pImpl
指向的对象。然而在Widget
的头文件里,pImpl
指针指向的是一个未完成类型。
移动构造函数的情况有所不同。
移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl
的代码。
这些都需要,Impl
是一个完成类型。
解决方法:
把移动操作的定义移动到实现文件里
1 |
|
1 |
|
拷贝
对于 struct Impl
中数据成员,可以使用默认拷贝函数,完成拷贝动作。
1 |
|
1 |
|
利用了编译器会为我们自动生成结构体Impl
的复制操作函数的机制,而不是逐一复制结构体Impl
的成员,自动生成的复制操作能自动复制每一个成员。
如果我们使用std::shared_ptr
而不是std::unique_ptr
来做pImpl
指针,本条款的建议不再适用。
不需要在类Widget
里声明析构函数,没有了用户定义析构函数,编译器将会成移动操作,并且将会如我们所期望般工作。widget.h
里的代码如下,
1 |
|
这是#include
了widget.h
的客户代码,
1 |
|
这些都能编译,并且工作地如我们所望:w1
将会被默认构造,它的值会被移动进w2
,随后值将会被移动回w1
,然后两者都会被销毁(指向的Widget::Impl
对象一并也被销毁)。
std::unique_ptr
和std::shared_ptr
在pImpl
指针上的表现上的区别的深层原因在于,他们支持自定义删除器的方式不同。
对std::unique_ptr
而言,删除器的类型是这个智能指针的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。
这种更高效率的后果之一就是std::unique_ptr
指向的类型,在编译器的生成特殊成员函数(如析构函数,移动操作)被调用时,必须已经是一个完成类型。
而对std::shared_ptr
而言,删除器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完成类型。
结论
- Pimpl 惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
- 对于
std::unique_ptr
类型的pImpl
指针,需要在头文件的类里声明特殊成员函数,并在实现文件中struct Impl
定义之后来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。 - 以上的建议只适用于
std::unique_ptr
,不适用于std::shared_ptr
。
23
理解std::move
和std::forward
Intro
移动语义使编译器有可能用廉价的移动操作来代替昂贵的拷贝操作。正如拷贝构造函数和拷贝赋值操作符给了你控制拷贝语义的权力,移动构造函数和移动赋值操作符也给了你控制移动语义的权力。移动语义也允许创建只可移动(move-only)的类型,例如
std::unique_ptr
,std::future
和std::thread
。完美转发使接收任意数量实参的函数模板成为可能,它可以将实参转发到其他的函数,使目标函数接收到的实参与被传递给转发函数的实参保持一致。
右值引用是连接这两个截然不同的概念的胶合剂。它是使移动语义和完美转发变得可能的基础语言机制。
但是,std::move
并不移动任何东西,完美转发也并不完美。移动操作并不永远比复制操作更廉价。构造“type&&
”也并非总是代表一个右值引用。
非常重要的一点是要牢记形参永远是左值,即使它的类型是一个右值引用。比如,假设
1 |
|
形参w
是一个左值,即使它的类型是一个rvalue-reference-to-Widget
。
std::move
和std::forward
不会做什么
std::move
不移动(move)任何东西,std::forward
也不转发(forward)任何东西。在运行时,它们不做任何事情。它们不产生任何可执行代码,一字节也没有。
std::move
C++11的std::move
的示例实现。它并不完全满足标准细则,但是它已经非常接近了。
1 |
|
该函数返回类型的&&
部分表明std::move
函数返回的是一个右值引用,但是,如果类型T
恰好是一个左值引用,那么T&&
将会成为一个左值引用。
所以,使用 std::remove_reference
,得到
ReturnType。这保证了std::move
返回的真的是右值引用。
std::move
在C++14中可以被更简单地实现。
1 |
|
因此,std::move
将它的实参转换为一个右值,这就是它的全部作用。
const
的限制
1 |
|
这段代码可以编译,可以链接,可以运行。
text
通过std::move
被转换到右值,但是text
被声明为const std::string
,所以在转换之前,text
是一个左值的const std::string
,而转换的结果是一个右值的const std::string
那么,string 对 value
赋值时,调用的是哪个构造函数?
1 |
|
右值不能被传递给std::string
的移动构造函数,因为移动构造函数只接受一个指向non-const
的std::string
的右值引用。
该右值却可以被传递给std::string
的拷贝构造函数,因为lvalue-reference-to-const
允许被绑定到一个const
右值上。因此,std::string
在成员初始化的过程中调用了拷贝构造函数。
可以总结出两点:
第一,不要在你希望能移动对象的时候,声明他们为const
。
第二,std::move
不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。
std::forward
与std::move
总是无条件的将它的实参为右值不同,std::forward
是有条件的转换。
最常见的情景是一个模板函数,接收一个通用引用形参(T&&),并将它传递给另外的函数:
1 |
|
考虑两次对logAndProcess
的调用,一次左值为实参,一次右值为实参:
1 |
|
std::forward
将保留实参的值类型,传递到 process
函数,调用正确的函数重载。
对比
考虑一个类,我们希望统计有多少次移动构造函数被调用了。我们只需要一个static
的计数器,它会在移动构造的时候自增。假设在这个类中,唯一一个非静态的数据成员是std::string
,一种经典的移动构造函数(即,使用std::move
)可以被实现如下:
1 |
|
如果要用std::forward
来达成同样的效果,代码可能会看起来像:
1 |
|
std::forward
不但需要一个函数实参(rhs.s
),还需要一个模板类型实参std::string
。
std::move
的使用代表着无条件向右值的转换,而使用std::forward
只对绑定了右值的引用进行到右值转换。这是两种完全不同的动作。前者是典型地为了移动操作,而后者只是传递(亦为转发)一个对象到另外一个函数,保留它原有的左值属性或右值属性。
结论
std::move
执行到右值的无条件的转换,但就自身而言,它不移动任何东西。std::forward
只有当它的参数被绑定到一个右值时,才将参数转换为右值。std::move
和std::forward
在运行期什么也不做。
24 区分通用引用与右值引用
为了声明一个指向某个类型T
的右值引用,你写下了T&&
。但是,这不一定是一个右值引用:
1 |
|
“T&&
”有两种不同的意思。第一种,当然是右值引用。它们只绑定到右值上,并且它们主要的存在原因就是为了识别可以移动操作的对象。
“T&&
”的另一种意思是,它既可以是右值引用,也可以是左值引用。它们的二重性使它们既可以绑定到右值上(就像右值引用),也可以绑定到左值上(就像左值引用)。
此外,它们还可以绑定到const
或者non-const
的对象上,也可以绑定到volatile
或者non-volatile
的对象上,甚至可以绑定到既const
又volatile
的对象上。它们可以绑定到几乎任何东西。它叫做通用引用(universal
references)。
一些C++社区的成员已经开始将这种通用引用称之为转发引用(forwarding references)
通用引用,其特性是引用折叠决定的。
初始化
通用引用是引用,所以它们必须被初始化。一个通用引用的初始值决定了它是代表了右值引用还是左值引用。如果初始值是一个右值,那么通用引用就会是对应的右值引用,如果初始值是一个左值,那么通用引用就会是一个左值引用。对那些是函数形参的通用引用来说,初始值在调用函数的时候被提供:
1 |
|
对一个通用引用而言,类型推导是必要的,但是其必须是
T&&
形式,如果是
std::vector<T>&&
的形式,那就变成了
右值引用。
而如果传入左值,那么是不能传入右值参数的。
一个例子
考虑如下push_back
成员函数,来自std::vector
:
1 |
|
push_back
函数的形参当然有一个通用引用的正确形式,然而,在这里并没有发生类型推导。类型推导发生在
vector 实例化时。
作为对比,std::vector
内的概念上相似的成员函数emplace_back
,却确实包含类型推导:
1 |
|
类型参数(type
parameter)Args
是独立于vector
的类型参数T
的,所以Args
会在每次emplace_back
被调用的时候被推导。
所以,此时是一个通用引用。
auto&&
类型声明为auto&&
的变量是通用引用,因为会发生类型推导,并且它们具有正确形式(T&&
)。auto
类型的通用引用不如函数模板形参中的通用引用常见,但是它们在C++11中常常突然出现。而它们在C++14中出现得更多,因为C++14的lambda表达式可以声明auto&&
类型的形参。
举个例子,如果你想写一个C++14标准的lambda表达式,来记录任意函数调用的时间开销,你可以这样写:
1 |
|
结论
- 如果一个函数模板形参的类型为
type&&
,并且type
需要被推导得知,或者如果一个对象被声明为auto&&
,这个形参或者对象就是一个通用引用。 - 如果类型声明的形式不是标准的
type&&
,或者如果类型推导没有发生,那么type&&
代表一个右值引用。 - 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用。
25
对右值引用使用std::move
,对通用引用使用std::forward
在参数传递时,std::forward
是有条件的传递,会根据参数的类型,传递实际的参数形式,右值还是右值,左值还是左值。
std::move
是无条件的将其变为右值。
有的时候,并不一定需要对象的移动操作。区分移动和拷贝是有必要的。遇到下面这种情况:
1 |
|
如果不用 通用引用,那么实现会变得冗长,尤其是参数数量较多的时候。
使用通用引用 + 完美转发 std::forward,那么实现会优雅得多。
并且对于变参函数模板:
1 |
|
对于这种函数,对于左值和右值分别重载就不能考虑了:通用引用是仅有的实现方案。对这种函数,我向你保证,肯定使用std::forward
传递通用引用形参给其他函数。
返回值的情况
如果你在按值返回的函数中,返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move
或者std::forward
。
1 |
|
通过在return
语句中将lhs
转换为右值(通过std::move
),lhs
可以移动到返回值的内存位置。如果省略了std::move
调用,
1 |
|
lhs
是个左值的事实,会强制编译器拷贝它到返回值的内存空间。
假定Matrix
支持移动操作,并且比拷贝操作效率更高,在return
语句中使用std::move
的代码效率更高。
如果Matrix
不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix
的拷贝构造函数拷贝。
使用通用引用和std::forward
的情况类似。考虑函数模板reduceAndCopy
收到一个未reduce(unreduced)对象Fraction
,将其规约,并返回一个reduce
(规约,好难听的名字) 后的副本。
如果原始对象是右值,可以将其移动到返回值中(避免拷贝开销),但是如果原始对象是左值,必须创建副本,因此如下代码:
1 |
|
如果std::forward
被忽略,frac
就被无条件复制到reduceAndCopy
的返回值内存空间。
注意,对于函数内部的局部变量,这是不成立的。
1 |
|
他们想要“优化”代码,把“拷贝”变为移动:
1 |
|
因为有 RVO
的存在,makeWidget
的“拷贝”版本实际上不拷贝任何东西。在返回的地址上,进行对象的构造。
但是 move 版本,不支持 RVO。
返回的已经不是局部对象w
,而是w
的引用——std::move(w)
的结果。
结论
- 对于传入函数的形参,在函数内最后一次使用时,在右值引用上使用
std::move
,在通用引用上使用std::forward
。 - 对按值返回的函数要返回的右值引用使用
std::move
,和通用引用使用std::forward
。 - 如果局部对象可以被返回值优化消除,就绝不使用
std::move
或者std::forward
。
26 避免重载通用引用
弊端一
比如,下面的例子:
1 |
|
如果有以下调用:
1 |
|
由于没有 short
类型的重载,但是有通用引用存在,所以name
形参绑定到要传入的short
上,然后name
被std::forward
给names
(一个std::multiset<std::string>
)的emplace
成员函数,然后又被转发给std::string
构造函数。std::string
没有接受short
的构造函数,所以logAndAdd
调用里的multiset::emplace
调用里的std::string
构造函数调用失败。
所有这一切的原因就是对于short
类型通用引用重载优先于int
类型的重载。这导致了代码执行出错。
使用通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参。
通用引用的实现会匹配比开发者预期要多得多的实参类型。
弊端二
有以下Person
类:
1 |
|
函数模板能实例化产生与拷贝和移动构造函数一样的签名。如果拷贝和移动构造被生成,Person
类看起来就像这样:
1 |
|
如果通过 non-const
左值类型的Person
来拷贝构造一个新的对象,完美转发的构造函数会优先匹配。
如果通过 const
左值类型的Person
来拷贝构造一个新的对象,拷贝构造函数会优先匹配,因为这是精确匹配。
如果在继承关系中,会有以下行为:
1 |
|
派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数。
派生类将SpecialPerson
类型的实参传递给其基类,然后通过模板实例化和重载解析规则作用于基类Person
。最终,代码无法编译,因为std::string
没有接受一个SpecialPerson
的构造函数(只有完美转发构造函数初始化了
name
)。
结论
- 对通用引用形参的函数进行重载时,通用引用函数可匹配的类型,几乎总会比你期望的多得多。
- 完美转发构造函数是糟糕的实现,因为对于non-
const
左值,它们会优先于拷贝构造函数匹配,而且会劫持派生类对于基类的拷贝和移动构造函数的调用。
27 通用引用重载的替代方法
一个直接的思路,放弃重载,另外声明一个函数签名。
另一种思路,放弃重载,但是使用 lvalue-refrence-to-const
的方式,参数类型变为 const T&。
放弃重载的另一种思路是,直接传值 +
std::mov
,的方式。
另外两种方案,保留了重载,但是都有局限,效率更高但是并不是万能的。这两种方案是:tag dispatch
和 enable_if 约束模板
。
tag dispath
实现形式:
1 |
|
原来函数模板不变,但是将实际的函数调用,进行了分发(dispatch)。
之所以 remove_reference,是因为 T 可能被推导为左值 T&,这不是
type trait
std::is_integral
识别为真的类型。
分发实现为:
1 |
|
这里的 std::false_type
和 std::true_type
,是 T 分别在不满足 std::is_integral
和满足
std::is_integral
的情况下的父类。
enable_if 约束模板
tag dispath 并不能解决父类的通用引用重载函数的问题(见上一条款 26)。
enable_if 约束模板,基于以下机制:
默认情况下,所有模板是启用的(enabled),但是使用std::enable_if
可以使得仅在std::enable_if
指定的条件满足时模板才启用。不满足条件,模板就是被禁止(disabled)的。
首先解决传入 Person 类对象,导致通用引用重载中,string 用 Person 对象初始化报错的问题:
1 |
|
使用 type trait
is_same
,可以在传入 Person
对象时,禁用模板。但是,传入子类对象,同样时不允许的,所以使用了
std::is_base_of
。
std::decay<T>
去掉了对于T
的引用,const
,volatile
修饰。
再结合对传入 int 类型参数的限制,可以的得到下面的代码:
1 |
|
其中:
1
2
template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type; // (since C++14)
这里的 static_assert
断言虽然可以识别,T
类型是否可以构建 string,但是这发生在
name(std::forward<T>(n))
之后。所以,报错先于断言。
结论
- 通用引用重载的替代方法:使用不同的函数名,通过lvalue-reference-to-
const
传递形参,按值传递形参,使用tag dispatch,使用enable_if
约束模板。 - 通用引用参数通常具有高效率的优势,但是其使用需要仔细分析。
28 引用折叠
目的就是禁止你生成引用的引用。
存在两种类型的引用(左值和右值),所以有四种可能的引用组合(左值的左值,左值的右值,右值的右值,右值的左值)。
这些组合的的结果:如果两个中任一引用为左值引用,则结果为左值引用。否则(即,如果引用都是右值引用),结果为右值引用。
组合 | 结果 |
---|---|
左值的右值 | 左值 |
左值的左值 | 左值 |
右值的左值 | 左值 |
右值的右值 | 右值 |
std::forword实现
std::forward
应用在通用引用参数上,所以经常能看到这样使用:
1 |
|
因为fParam
是通用引用,类型参数T
的类型根据f
被传入实参(即用来实例化fParam
的表达式)是左值还是右值来决定。
std::forward
的作用是当且仅当传给f
的实参为右值时(此时T
为非引用类型),才将fParam
转化为一个右值。
std::forward
可以这样实现:
1 |
|
在C++14中,std::remove_reference_t
的存在使得实现变得更简洁:
1 |
|
假设传入到f
的实参是Widget
的左值类型。T
被推导为Widget&
,然后调用std::forward
将实例化为std::forward<Widget&>
。
Widget&
带入到上面的std::forward
的实现中:
1 |
|
std::remove_reference<Widget&>::type
这个type
trait产生Widget
,所以std::forward
成为:
1 |
|
根据引用折叠规则,返回值和强制转换可以化简,最终版本的std::forward
调用就是:
1 |
|
当左值实参被传入到函数模板f
时,std::forward
被实例化为接受和返回左值引用。
如果传入右值,那么结果会是这样:
1 |
|
auto
在auto的写法中,规则是类似的。声明
1 |
|
用一个左值初始化w1
,因此为auto
推导出类型Widget&
。把Widget&
代回w1
声明中的auto
里,产生了引用的引用,
1 |
|
应用引用折叠规则,就是
1 |
|
结果就是w1
是一个左值引用。
下面这个声明,
1 |
|
使用右值初始化w2
,为auto
推导出非引用类型Widget
。把Widget
代入auto
得到:
1 |
|
没有引用的引用,这就是最终结果,w2
是个右值引用。
通用引用
通用引用不是一种新的引用,它实际上是满足以下两个条件下的右值引用:
- 类型推导区分左值和右值。
T
类型的左值被推导为T&
类型,T
类型的右值被推导为T
。 - 发生引用折叠。
结论
- 引用折叠发生在四种情况下:模板实例化,
auto
类型推导,typedef
与别名声明的创建和使用,decltype
。 - 当编译器在引用折叠环境中生成了引用的引用时,结果就是单个引用。带有左值引用的引用折叠,结果就是左值引用。否则就是右值引用。
- 通用引用就是引用折叠的结果。
29 移动操作的缺点
升级C++11之前的代码
C++11倾向于为缺少移动操作的类生成它们,但是只有在没有声明复制操作,移动操作,析构函数的类中才会生成移动操作。
另外数据成员或者某类型的基类禁止移动操作,编译器不生成移动操作的支持。
所以,对于没有明确支持移动操作的类型,并且不符合编译器默认生成的条件的类,没有理由期望C++11会比C++98进行任何性能上的提升。
移动大对象
1 |
|
std::array
没有这种指针实现,数据就保存在std::array
对象中:
1 |
|
移动还是遍历了所有元素。
移动小字符串
std::string
提供了常数时间的移动操作和线性时间的复制操作。
许多字符串的实现采用了小字符串优化(small string
optimization,SSO)。“小”字符串(比如长度小于15个字符的)存储在了std::string
的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不比复制操作更快(并不会执行指针的复制,而是将字符串完全从一个位置拷贝到另一个位置,再清空原来的内存)。
结论
C++11的移动语义并无优势:
没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
移动不会更快:要移动的对象提供的移动操作并不比复制速度更快。
移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为
noexcept
。源对象是左值:除了极少数的情况外,只有右值可以作为移动操作的来源。
30 完美转发失败的情况
完美转发(perfect
forwarding)意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const
还是volatile
。
有以下函数:
1 |
|
讨论下面函数调用失败的情况:
1 |
|
花括号初始化器
假定f
这样声明:
1 |
|
在这个例子中,用花括号初始化调用f
通过编译,
1 |
|
但是传递相同的列表初始化给fwd不能编译
1 |
|
当通过调用函数模板fwd
间接调用f
时,编译器不再把调用地传入给fwd
的实参和f
的声明中形参类型进行比较。
而是推导传入给fwd
的实参类型,然后比较推导后的实参类型和f
的形参声明类型。
编译器不允许在对fwd
的调用中推导表达式{ 1, 2, 3 }
的类型,因为fwd
的形参没有声明为std::initializer_list
。对于fwd
形参的推导类型被阻止,编译器只能拒绝该调用。
但是,使用花括号初始化的auto
的变量的类型推导是成功的。这种变量被视为std::initializer_list
对象,在转发函数应推导出类型为std::initializer_list
的情况,这提供了一种简单的解决方法——使用auto
声明一个局部变量,然后将局部变量传进转发函数:
1 |
|
0
或者NULL
当你试图传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
。
只有声明的 static const 数据成员
下面的代码:
1 |
|
使用MinVals
调用f
是可以的,因为编译器直接将值28代替MinVals
:
1 |
|
不过如果我们尝试通过fwd
调用f
,事情不会进展那么顺利:
1 |
|
代码可以编译,但是不应该链接。
尽管代码中没有使用MinVals
的地址,但是fwd
的形参是通用引用。
而引用,在编译器生成的代码中,通常被视作指针。
在程序的二进制底层代码中(以及硬件中)指针和引用是一样的。在这个水平上,引用只是可以自动解引用的指针。在这种情况下,通过引用传递MinVals
实际上与通过指针传递MinVals
是一样的,因此,必须有内存使得指针可以指向。
链接时,链接不到内存,就会报错。
只要给整型static const
提供一个定义,就可以解决问题了,比如这样:
1 |
|
注意定义中不要重复初始化。如果在两个地方都提供了初始化,编译器就会报错,提醒你只能初始化一次。
重载函数的名称和模板名称
假设有了一个重载函数,processVal
:
1 |
|
传递给 f
是没问题的,因为编译器是可以基于现有信息判断调用哪一个重载函数的。
但是,fwd(processVal);
不行。
单用processVal
是没有类型信息的,所以就不能类型推导,完美转发失败。
需要这样使用:
1 |
|
对于模板,有相似的问题。一个函数模板不代表单独一个函数,它表示一个函数族:
1 |
|
要让像fwd
的完美转发函数接受一个重载函数名或者模板名,方法是指定要转发的那个重载或者实例。
位域
IPv4的头部有如下模型:(假定位域是按从最低有效位(least significant bit,lsb)到最高有效位(most significant bit,msb)
1 |
|
如果声明我们的函数f
(转发函数fwd
的目标)为接收一个std::size_t
的形参,则使用IPv4Header
对象的totalLength
字段进行调用没有问题:
1 |
|
如果通过fwd
转发h.totalLength
给f
呢,那就是一个不同的情况了:
1 |
|
问题在于fwd
的形参是引用,而h.totalLength
是non-const
位域,这是C++不允许的行为。
位域可能包含了一个字的任意部分(比如32位int
的3-5位),但是这些东西无法直接寻址。在硬件层面引用和指针是一样的,所以没有办法创建一个指向任意bit的指针。
传递位域给完美转发的方法就是,创建副本然后利用副本调用完美转发。在IPv4Header
的例子中,可以如下写法:
1 |
|
结论
- 导致完美转发失败的实参种类有:花括号初始化,作为空指针的
0
或者NULL
,仅有声明的static const
数据成员,模板和重载函数的名字,位域。
30 Lambda 表达式
闭包(enclosure)是lambda创建的运行时对象。依赖捕获模式,闭包持有被捕获数据的副本或者引用。
闭包类(closure class)是从中实例化闭包的类。每个lambda都会使编译器生成唯一的闭包类。lambda中的语句成为其闭包类的成员函数中的可执行指令。
lambda通常被用来创建闭包,该闭包仅用作函数的实参。闭包通常可以拷贝,所以可能有多个闭包对应于一个lambda。
但是对于闭包,需要明白的一点是:区分什么存在于编译期(lambdas 和闭包类),什么存在于运行时(闭包)以及它们之间的相互关系。
避免使用默认的捕获模式
按默认引用捕获会导致闭包中包含了对某个局部变量或者形参的引用。如果该lambda创建的闭包生命周期超过了局部变量,那么闭包中的引用将会变成悬空引用。
另外,成员函数中,使用捕获需要明白 this
指针的存在,直接捕获成员变量是会出错的。
一个解决方案是:
1 |
|
在C++14中,一个更好的捕获成员变量的方式时使用通用的lambda捕获:
1 |
|
如果是 static 成员,那么默认捕获行为将什么也不会捕获。
结论
- 默认的按引用捕获可能会导致悬空引用。
- 默认的按值捕获对于悬空指针很敏感(尤其是
this
指针),并且它会误导人产生lambda是独立的想法。
31 使用 init capture 来移动对象到闭包
在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptr
或std::future
)要进入到闭包里,使用C++11是无法实现的。到了C++14就另一回事了,它能支持将对象移动到闭包中。
init capture
C++14中,这是使用初始化捕获将std::unique_ptr
移动到闭包中的方法:
1 |
|
“pw = std::move(pw)
”的意思是“在闭包中创建一个数据成员pw
,并使用将std::move
应用于局部变量pw
的结果来初始化该数据成员”。
在C++11中,无法捕获表达式的结果。 因此,初始化捕获的另一个名称是通用lambda捕获(generalized lambda capture)。
lambda表达式只是生成一个类和创建该类型对象的一种简单方式而已。没什么是你用lambda可以做而不能自己手动实现的。
C++14的示例代码可以用C++11重新编写,如下所示:
1 |
|
使用 bind
的解决方法:
C++11的等效代码如下,其中我强调了相同的关键事项:
1 |
|
默认情况下,从lambda生成的闭包类中的operator()
成员函数为const
的。在lambda主体内把闭包中的所有数据成员渲染为const
。
但是,bind对象内部的移动构造的data
副本不是const
的,因此,为了防止在lambda内修改该data
副本,lambda的形参应声明为reference-to-const
。
如果将lambda声明为mutable
,则闭包类中的operator()
将不会声明为const
,并且在lambda的形参声明中省略const
也是合适的:
1 |
|
结论
- 使用C++14的初始化捕获将对象移动到闭包中。
- 在C++11中,可以通过手写类或
std::bind
的方式来模拟初始化捕获。
33
对auto&&
形参使用decltype
1 |
|
这里的???
该是什么?
在泛型lambda中,没有可用的类型参数T
。在lambda生成的闭包里,模版化的operator()
函数中的确有一个T
,但在lambda里却无法直接使用它。
如果一个左值实参被传给通用引用的形参,那么形参类型会变成左值引用。传递的是右值,形参就会变成右值引用。这意味着在这个lambda中,可以通过检查形参x
的类型来确定传递进来的实参是一个左值还是右值,decltype
就可以实现这样的效果。
传递给lambda的是一个左值,decltype(x)
就能产生一个左值引用;如果传递的是一个右值,decltype(x)
就会产生右值引用。
在调用std::forward
时,惯例决定了类型实参是左值引用时来表明要传进左值,类型实参是非引用就表明要传进右值。
在前面的lambda中,如果x
绑定的是一个左值,decltype(x)
就能产生一个左值引用。
然而如果x
绑定的是一个右值,decltype(x)
就会产生右值引用,而不是常规的非引用。
但是decltype(x)
就会产生右值引用传入
std::forward
后,引用折叠后的结果和传入非引用的结果是相同的。
所以decltype(x)
完美解决了问题。
C++14中的lambda也可以是可变形参的,最后的实现如下:
1 |
|
34
优先考虑lambda而非std::bind
优先lambda而不是std::bind
的最重要原因是lambda更易读。
但是,在C++11中,可以在两种情况下使用std::bind
是合理的:
- 移动捕获。C++11的lambda不提供移动捕获,但是可以通过结合lambda和
std::bind
来模拟。 - 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参
例如:
1 |
|
std::bind
可以如下绑定一个PolyWidget
对象:
1 |
|
boundPW
可以接受任意类型的对象了:
1 |
|
这一点无法使用C++11的lambda做到。
但是,在C++14中,可以通过带有auto
形参的lambda轻松实现:
1 |
|
结论
- 与使用
std::bind
相比,lambda更易读,更具表达力并且可能更高效。 - 只有在C++11中,
std::bind
对实现移动捕获,或者绑定函数模板,会很有用。
并发API
C++11的伟大成功之一是将并发整合到语言和库中。
35 优先考虑基于任务的编程而非基于线程的编程
如果开发者想要异步执行doAsyncWork
函数,通常有两种方式。其一是通过创建std::thread
执行doAsyncWork
,这是应用了基于线程(thread-based)的方式:
1 |
|
其二是将doAsyncWork
传递给std::async
,一种基于任务(task-based)的策略:
1 |
|
这种方式中,传递给std::async
的函数对象被称为一个任务(task)。
基于任务的方法通常比基于线程的方法更优:
- 代码量更少
- 如果 task 的返回值是必需的,那么 thread-based
的方式将无能为力。而基于任务的方法就简单了,因为
std::async
返回的future提供了get
函数(从而可以获取返回值)。 - 如果
doAsycnWork
发生了异常,get
函数就显得更为重要,因为get
函数可以提供抛出异常的访问,而基于线程的方法,如果doAsyncWork
抛出了异常,程序会直接终止(通过调用std::terminate
)。
区别
基于线程与基于任务最根本的区别在于,基于任务的抽象层次更高。基于任务的方式使得开发者从线程管理的细节中解放出来,对此在C++并发软件中总结了“thread”的三种含义:
- 硬件线程(hardware threads)是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
- 软件线程(software threads)(也被称为系统线程(OS threads、system threads))是操作系统管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程。当软件线程被阻塞的时候(比如 I/O、同步锁或者条件变量),操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
std::thread
是C++执行过程的对象,并作为软件线程的句柄(handle)。
软件线程是有限的资源。如果开发者试图创建大于系统支持的线程数量,会抛出std::system_error
异常。
即使没有超出软件线程的限额,仍然可能会遇到资源超额(oversubscription)的麻烦。当前准备运行的(即未阻塞的)软件线程大于硬件线程的数量时,线程调度器会将软件线程时间切片,分配到硬件上。
当一个软件线程的时间片执行结束,会让给另一个软件线程,此时发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销。当软件线程安排到与上次时间片运行时不同的硬件线程上,这个开销会更高。
使用std::async
将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度。
通过向std::async
传递std::launch::async
启动策略来保证想运行函数在不同的线程上执行,处理不同线程响应优先级的问题。
线程调度器使用系统级线程池(thread pool)来避免资源超额的问题,并且通过工作窃取算法(work-stealing algorithm)来提升了跨硬件核心的负载均衡。实现更为繁琐。
直接使用
std::thread
编程,处理线程耗尽、资源超额、负责均衡问题的责任就压在了你身上,更不用说你对这些问题的解决方法与同机器上其他程序采用的解决方案配合得好不好了。
基于任务的设计为开发者避免了手动线程管理的痛苦,并且自然提供了一种获取异步执行程序的结果(即返回值或者异常)的方式。当然,仍然存在一些场景直接使用std::thread
会更有优势:
- 你需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads或者Windows
threads)来实现的,系统级API通常会提供更加灵活的操作方式(举个例子,C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问,
std::thread
对象提供了native_handle
的成员函数,而std::future
(即std::async
返回的东西)没有这种能力。 - 你需要且能够优化应用的线程使用。
- 你需要实现C++并发API之外的线程技术,比如,实现未支持的平台的线程池。
结论
std::thread
API不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。- 基于线程的编程方式需要手动的线程耗尽、资源超额、负责均衡、平台适配性管理。
- 通过带有默认启动策略的
std::async
进行基于任务的编程方式会解决大部分问题。
36
如有必要指定std::launch::async
std::launch::async
启动策略意味着f
必须异步执行,即在不同的线程。std::launch::deferred
启动策略意味着f
仅当在std::async
返回的future上调用get
或者wait
时才执行。这表示f
推迟到存在这样的调用时才执行。当get
或wait
被调用,f
会同步执行,即调用方被阻塞,直到f
运行结束。如果get
和wait
都没有被调用,f
将不会被执行。
std::async
的默认启动策略,如果你不显式指定一个策略,不是上面中任意一个。相反,是求或在一起的。下面的两种调用含义相同:
1 |
|
因此默认策略允许f
异步或者同步执行。
这导致了三种结果:
- 无法预测
f
是否会与t
并发运行,因为f
可能被安排延迟运行。 - 无法预测
f
是否会在与某线程相异的另一线程上执行,这个某线程在fut
上调用get
或wait
。如果对fut
调用函数的线程是t
,无法预测f
是否在异于t
的另一线程上执行。 - 无法预测
f
是否执行。
所以,以下循环看似应该最终会终止,但可能实际上永远运行:
1 |
|
如果f
与调用std::async
的线程并发运行(即,如果为f
选择的启动策略是std::launch::async
),这里没有问题(假定f
最终会执行完毕),但是如果f
是延迟执行,fut.wait_for
将总是返回std::future_status::deferred
。这永远不等于std::future_status::ready
,循环会永远执行下去。
改进的方式如下:
1 |
|
一个总是使用 std::launch::async
的函数实现如下:
C++11版本如下:
1 |
|
在C++14中,reallyAsync
返回类型的推导能力可以简化函数的声明:
1 |
|
结论
std::async
的默认启动策略是异步和同步执行兼有的。std::async
的默认启动策略,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait
的程序逻辑。- 如果异步执行任务非常关键,则指定
std::launch::async
。
37 使std::thread
是
unjoinable 的
每个std::thread
对象处于两个状态之一:可结合的(joinable)或者不可结合的(unjoinable)。
可结合状态的std::thread
对应于正在运行或者可能要运行的异步执行线程。比如,对应于一个阻塞的(blocked)或者等待调度的线程的std::thread
是可结合的;对应于运行结束的线程的std::thread
也可以认为是可结合的。
不可结合的 std::thread
包括:
- 默认构造的
std::thread
s。这种std::thread
没有函数执行,因此没有对应到底层执行线程上。 - 已经被移动走的
std::thread
对象。移动的结果就是一个std::thread
原来对应的执行线程现在对应于另一个std::thread
。 - 已经被
join
的std::thread
。在join
之后,std::thread
不再对应于已经运行完了的执行线程。 - 已经被
detach
的std::thread
。detach
断开了std::thread
对象与执行线程之间的连接。
如果发生 std::thread
析构,而 std::thread
是 joinable,那么会造成程序终止。析构时发生的
隐式join
可能还会访问已经被回收的值。隐式detach
,可能出现访问或者修改没有所有权的内存的行为。
解决方法,使用 RAII 对象类管理,保证每当在执行跳至块之外时,调用局部对象的析构函数。
1 |
|
结论
- 析构时
join
会导致难以调试的表现异常问题。 - 析构时
detach
会导致难以调试的未定义行为。 - 声明类数据成员时,最后声明
std::thread
对象。
38 future析构行为
结论
- future的正常析构行为就是销毁future本身的数据成员。
- 使用
std::async
启动的 future,引用了共享状态(std::shared_future
)的最后一个future的析构函数会阻塞住,直到任务完成。
39 简单事件通信
一个任务通知另一个异步执行的任务发生了特定的事件很有用,因为第二个任务要等到这个事件发生之后才能继续执行。事件也许是一个数据结构已经初始化,也许是计算阶段已经完成,或者检测到重要的传感器值。
使用条件变量
如果我们将检测条件的任务称为检测任务(detecting task),对条件作出反应的任务称为反应任务(reacting task),策略很简单:反应任务等待一个条件变量,检测任务在事件发生时改变条件变量。代码如下:
1 |
|
检测任务中的代码不能再简单了:
1 |
|
如果有多个反应任务需要被通知,使用notify_all
代替notify_one
。
线程API的存在一个事实(不只是C++),等待一个条件变量的代码即使在条件变量没有被通知时,也可能被唤醒,这种唤醒被称为虚假唤醒(spurious wakeups)。
正确的代码通过确认要等待的条件确实已经发生来处理这种情况,并将这个操作作为唤醒后的第一个操作。C++条件变量的API使得这种问题很容易解决,因为允许把一个测试要等待的条件的lambda(或者其他函数对象)传给wait
。因此,可以将反应任务wait
调用这样写:
1 |
|
使用 condition variable 的示例:
1 |
|
反应任务代码如下:
1 |
|
原子变量轮询
使用原子变量的示例:
当检测线程识别到发生的事件,将flag置位:
1 |
|
就其本身而言,反应线程轮询该flag。当发现flag被置位,它就知道等待的事件已经发生了:
1 |
|
这里多出了轮询的开销。
promise + future
检测任务使用std::promise<void>
,反应任务使用std::future<void>
或者std::shared_future<void>
。当感兴趣的事件发生时,检测任务设置std::promise<void>
,反应任务在future上wait
。
尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对std::promise<void>
调用set_value
“写入”了void
数据。
1 |
|
检测任务代码很简洁:
1 |
|
反应任务代码也同样简单:
1 |
|
像使用flag的方法一样,此设计不需要互斥锁,无论在反应线程调用wait
之前检测线程是否设置了std::promise
都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。
与基于条件变量的方法一样,反应任务在调用wait
之后是真被阻塞住的,不会一直占用系统资源。
但是以上代码中,std::promise
和future之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。
std::promise
只能设置一次。std::promise
和future之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。
假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用void
的future就是一个可行方案。
通过share
获得的shared_future
要被在反应线程中运行的lambda按值捕获:
1 |
|
结论
- 三种简单事件通信:使用条件变量、使用原子变量、使用
promise + future
promise + future
的方式,在单次事件通信时,更有优势
40
并发使用std::atomic
,特殊内存使用volatile
如下使用std::atmoic
的代码:
1 |
|
在这些语句执行过程中,其他线程读取ai
,只能读取到0,10,11三个值其中一个。假设只有这个线程会修改ai
,没有其他可能的值。
使用volatile
在多线程中实际上不保证任何事情:
1 |
|
代码的执行过程中,如果其他线程读取vi
,可能读到任何值,比如-12,68,4090727——任何值!这份代码有未定义行为,因为这里的语句修改vi
,所以如果同时其他线程读取vi
,同时存在多个readers和writers读取没有std::atomic
或者互斥锁保护的内存,这就是数据竞争的定义。
指令排序
代码执行本身,即使编译器没有重排顺序,底层硬件也可能重排(或者可能使它看起来运行在其他核心上),因为有时这样代码执行更快。
然而,
std::atomic
会限制这种重排序,保持了指令执行的有序性。
1 |
|
编译器不仅要保证imptValue
和valAvailable
的赋值顺序,还要保证生成的硬件代码不会改变这个顺序。结果就是,将valAvailable
声明为std::atomic
确保了必要的顺序——其他线程看到的是imptValue
值的改变不会晚于valAvailable
。
将valAvailable
声明为volatile
不能保证上述顺序:
1 |
|
这份代码编译器可能将imptValue
和valAvailable
赋值顺序对调。
结论
std::atomic
用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。volatile
用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。
其他优化
41 对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递
三个版本的addName
:
1 |
|
我将前两个版本称为“按引用方法”,因为都是通过引用传递形参。
仍然考虑这两种调用方式:
1 |
|
现在分别考虑三种实现中,给Widget
添加一个名字的两种调用方式,拷贝和移动操作的开销。
重载:无论传递左值还是传递右值,调用都会绑定到一个叫
newName
的引用上。从拷贝和移动操作方面看,这个过程零开销。左值重载中,newName
拷贝到Widget::names
中,右值重载中,移动进去。 开销总结:左值一次拷贝,右值一次移动。
使用通用引用:同重载一样,调用也绑定到
addName
这个引用上,没有开销。由于使用了std::forward
,左值std::string
实参会拷贝到Widget::names
,右值std::string
实参移动进去。 对
std::string
实参来说,开销同重载方式一样:左值一次拷贝,右值一次移动。按值传递:无论传递左值还是右值,都必须构造
newName
形参。如果传递的是左值,需要拷贝的开销,如果传递的是右值,需要移动的开销。在函数的实现中,newName
总是采用移动的方式到Widget::names
。 开销总结:左值实参,一次拷贝一次移动,右值实参两次移动。
对于特殊的场景,可拷贝且移动开销小的类型,传递给总是会拷贝他们的一个函数,切片也不需要考虑,。这时,按值传递就提供了一种简单的实现方式,效率接近传递引用的函数,但是避免了传引用方案的缺点。
当移动的开销较低,额外的一次移动才能被开发者接受,但是当移动的开销很大,执行不必要的移动就类似执行一个不必要的拷贝,也就是避免不必要的拷贝。
对于可拷贝形参使用按值传递。不符合此条件的的形参必须有只可移动的类型(move-only types)。
只会在目标代码中生成一个函数。避免了通用引用的种种问题。
只对总是被拷贝的形参考虑按值传递。因为传引用可以避免这个不必要的开销。
结论
对于可拷贝,移动开销低,而且无条件被拷贝的形参,按值传递效率基本与按引用传递效率一致,而且易于实现,还生成更少的目标代码。
某些情况下,通过构造拷贝形参可能比通过赋值拷贝形参开销大的多。
按值传递会引起切片问题,所说不适合基类形参类型。
42 emplacement 而不是 insertion
编译器处理的下面的调用:
1 |
|
为了在std::string
容器中创建新元素,调用了std::string
的构造函数,但是这份代码并不仅调用了一次构造函数,而是调用了两次,而且还调用了std::string
析构函数。下面是在push_back
运行时发生了什么:
- 一个
std::string
的临时对象从字面量“xyzzy
”被创建。这个对象没有名字,我们可以称为temp
。temp
的构造是第一次std::string
构造。因为是临时变量,所以temp
是右值。 temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造——也是第二次构造——在std::vector
内部真正创建一个对象。- 在
push_back
返回之后,temp
立刻被销毁,调用了一次std::string
的析构函数。
使用传递给它的任何实参直接在std::vector
内部构造一个std::string
。没有临时变量会生成:
1 |
|
emplace_back
使用完美转发,因此只要你没有遇到使用完美转发的限制,就可以传递任何实参以及组合到emplace_back
。
emplace_back
:
- 值是通过构造函数添加到容器,而不是直接赋值。
- 传递的实参类型与容器的初始化类型不同。
- 容器不拒绝重复项作为新值。
1 |
|
资源管理
假定你有一个盛放std::shared_ptr<Widget>
s的容器,
1 |
|
使用push_back
的代码如下:
1 |
|
也可以像这样:
1 |
|
不管哪种写法,在调用push_back
前会生成一个临时std::shared_ptr
对象。push_back
的形参是std::shared_ptr
的引用,因此必须有一个std::shared_ptr
。
用emplace_back
应该可以避免std::shared_ptr
临时对象的创建,但是在这个场景下,临时对象值得被创建。考虑如下可能的时间序列:
- 在上述的调用中,一个
std::shared_ptr<Widget>
的临时对象被创建来持有“new Widget
”返回的原始指针。称这个对象为temp
。 push_back
通过引用接受temp
。在存储temp
的副本的list节点的内存分配过程中,内存溢出异常被抛出。- 随着异常从
push_back
的传播,temp
被销毁。作为唯一管理这个Widget
的std::shared_ptr
,它自动销毁Widget
,在这里就是调用killWidget
。
这样的话,即使发生了异常,没有资源泄漏。
考虑使用emplace_back
代替push_back
:
1 |
|
- 通过
new Widget
创建的原始指针完美转发给emplace_back
中,list节点被分配的位置。如果分配失败,还是抛出内存溢出异常。 - 当异常从
emplace_back
传播,原始指针是仅有的访问堆上Widget
的途径,但是因为异常而丢失了,那个Widget
的资源(以及任何它所拥有的资源)发生了泄漏。
在这个场景中,生命周期不良好,这个失误不能赖std::shared_ptr
。使用带自定义删除器的std::unique_ptr
也会有同样的问题。
解决方法是:
1 |
|
emplace_back
的版本如下:
1 |
|
无论哪种方式,都会产生spw
的创建和销毁成本。
与explicit
的构造函数的交互
相似的初始化语句导致了多么不一样的结果:
1 |
|
在标准的官方术语中,用于初始化r1
的语法(使用等号)是所谓的拷贝初始化。相反,用于初始化r2
的语法是(使用小括号,有时也用花括号)被称为直接初始化。
emplace_back
使用直接初始化,这意味着可能使用explicit
的构造函数。
push_back
使用拷贝初始化,所以不能用explicit
的构造函数。因此:
1 |
|
获得的经验是,当你使用emplace_back
时,请特别小心确保传递了正确的实参,因为即使是explicit
的构造函数也会被编译器考虑,编译器会试图以有效方式解释你的代码。
结论
- 原则上,
emplace_back
有时会比push_back
高效,并且不会更差。 - 实际上,当以下条件满足时,
emplace_back
更快:(1)值被构造到容器中,而不是直接赋值;(2)传入的类型与容器的元素类型不一致;(3)容器不拒绝已经存在的重复值。 emplace_back
可能执行push_back
拒绝的类型转换。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!