《Effective Modern C++》笔记
文章目录
- 绪论
- 第1章 型别推导
- 条款1:理解模板类型推导
- 情况1:ParamType 是个指针或引用,但不是万能引用
- 情况2:ParamType是万能引用
- 情况3:ParamType既非指针也非引用
- 数组实参
- 函数参数
- 条款2:理解auto型别推导
- 条款3:理解decltype
- 特殊情况
- 条款4:查看型别推导结果的方法
- IDE编辑器
- 编译器诊断信息
- 运行时输出
- 第2章 auto
- 条款5:优先使用auto,而非显示类型声明
- 条款6: 在`auto`推导非预期时显式声明类型
- 第3章 转向现代C++
- 条款7:在创建对象时注意区分()和{}
- 条款8:优先选用nullptr,而非0或NULL
- 条款9:优先选用`using`来替代`typedef`
- 条款10:优先选用限定作用域的枚举类型
- 条款11:优先选用删除函数,而非private未定义函数
- 条款12:将重写函数声明为`override`
- 条款13:优先选用const_iterator,而非iterator
- 条款14:如果函数永远不会抛出异常,则声明为`noexcept`
- 条款15:尽可能使用`constexpr`
- constexpr对象
- constexpr函数
- 条款16: 保证`const`成员函数线程安全
- 条款17: 理解特殊成员函数的生成机制
- 第4章 智能指针
- 条款18:使用std::unique_ptr管理具备专属所有权的资源
- 条款19:使用shared_ptr管理具备共享所有权的资源
- 奇异递归模板模式`enable_shared_from_this`
- std::shared_ptr开销
- 条款20:对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr
- 用途一 :带有cache的工厂函数
- 用途二 :观察者模式
- 用途三:解决循环引用问题
- 条款21:优先选用`std::make_unique`和`std::make_shared`而非直接`new`
- 优点一:不需要重复写一遍类型
- 优点二:异常安全性
- 优点三:避免多次内存分配,提升性能
- 缺点一:无法自定义删除器
- 缺点二:无法使用花括号初始化
- 缺点三:延长了对象销毁的时机
- 条款22:使用Pimpl惯用法时,在实现文件中定义特殊成员函数
- 第5章 右值引用、移动语义和完美转发
- 条款23:理解`std::move`和`std::forward`
- std::move
- std::forward
- 对比总结
- 条款24:区分万能引用和右值引用
- 条款25:将`std::move`用于右值引用,`std::forward`用于万能引用
- 条款26:避免将万能引用作为重载候选型别
- 条款27:熟悉将万能引用作为重载候选型别的替代方案
- 舍弃重载
- 通过const T&传递
- 传值
- 标签分派(Tag dispatch)
- 对接收万能引用的模板施加限制
- 权衡
- 条款28:理解引用折叠
- 条款29:假定移动操作不存在、成本高、未使用
- 条款30:熟悉完美转发的失败情形
- 大括号初始化式
- 用`0`或`NULL`作为空指针
- 仅有声明的整型static const成员变量
- 重载的函数名字和模板名字
- 位域
- 第6章 lambda表达式
- 条款31:避免隐式捕获模式
- 条款32:使用初始化捕获将对象移入闭包
- 条款33:对auto&&型别的形参使用`decltype`,以`sta:forward`之
- 条款34:优先选用lambda表达式,而非`std::bind`
- 第7章 并发API
- 条款35:优先选用基于任务而非基于线程的程序设计
- 条款36: 如果异步是必需的,就指定`std::launch::async`
- 条款37:令`std::thread`对象在所有路径退出时都不可join
- 条款38:注意线程句柄析构函数的各种行为
- 条款39:考虑用`std::future`来进行一次性事件通信
- 线程通信方式
- 条款40:使用`std::atomic`应对并发,使用`volatile`应对特殊内存
- std::atomic
- volatile
- 第8章 微调
- 条款41:针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑将其按值传递
- 条款42:考虑置入(`emplace`)而非插入
绪论
一种有启发性地判断一个表达式是左值的方法是检查是否可以取得该表达式的地址。如果可以取地址,它基本上就是一个左值。如果不行,通常来说是一个右值。
这种方法之所以说复用启发性,是因为他让你记得,表达式的型别与它是左值还是右值没有关系。
形参只能是左值,但是作为其初始化依据的实参即有可能是右值也有可能是左值。
第1章 型别推导
条款1:理解模板类型推导
函数模板 伪代码:
template <typename T>
void f(ParamType param);
一次调用:
f(expr);
编译器用expr
来推断两个类型:T
的类型 和 ParamType
的类型。ParamType
可以是下面几种情况:
T&
、T*
、const T&
,非万能引用的指针或引用类型;T&&
,万能引用;T
,非指针非引用。
情况1:ParamType 是个指针或引用,但不是万能引用
类型推导规则:
- 如果
expr
具有引用类型,就先忽略引用部分。 - 再用
ParamType
去模式匹配expr
,来确定T
的类型。
示例:
template <typename T>
void f(T& param); // param is a referenceint x = 27; // x is an int
const int cx = x; // cx is a const int
const int& rx = x; // rx is a reference to x as a const intf(x); // T is int, param is int&
f(cx); // T is const int, param is const int&
f(rx); // T is const int, param is const int&
向持有T&
型别的模板传入const对象是安全的:该对象的常量性(constness)会成为T的型别推导结果的组成部分。
第三个调用中rx
是引用,但T
不是,是因为rx
的引用属性在推断中被忽略了。
如果ParamType 是指针,则类似。
情况2:ParamType是万能引用
万能引用类形参的声明方式类似右值引用(即在函数模板中持有型别形参T时,万能引用的声明型别写作T&&)。
类型推导规则:
- 如果
expr
是左值,那么T
和ParamType
都被推断为左值引用。(这是模板类型推导中,T
被推断为引用类型的唯一情形) - 如果
expr
是右值,那么同情况1。
template <typename T>
void f(T&& param);int x = 27; // x is an int
const int cx = x; // cx is a const int
const int& rx = x; // rx is a reference to x as a const intf(x); // x is lvalue, so T is int&, param is also int&
f(cx); // cx is lvalue, so T is const int&, param is also const int&
f(rx); // rx is lvalue, so T is const int&, param is also const int&
f(27); // 27 is rvalue, so T is int, param is int&&
情况3:ParamType既非指针也非引用
如果ParamType
既不是指针也不是引用,那么f
就是传值调用,那么param
就是传入对象的副本,即一个全新的对象。
- 如果
expr
具有引用类型,就先忽略引用部分。 - 如果
expr
带const
或volatile
,也忽略。
template <typename T>
void f(T param);int x = 27; // x is an int
const int cx = x; // cx is a const int
const int& rx = x; // rx is a reference to x as a const intf(x); // T and param are both int
f(cx); // T and param are both int
f(rx); // T and param are both int
注意expr
如果是指向const
对象的指针,那么这个const
不能被忽略掉。
数组实参
由于数组形参声明会按照它们好像是指针形参那样加以处理,按值传递给函数模板的数组型别将被推导成指针型别(const char*):
template <typename T>
void f(T param);const char name[] = "J. P. Briggs"; // name is const char[13]f(name); // T is const char*
按引用传递给函数模板的数组型别会被推断为实际的数组:
template <typename T>
void f(T& param);f(name); // T is const char [13] and ParamType is const char (&)[13]
我们可以利用这点在编译期拿到一个数组的长度:
template <typename T, size_t N>
constexpr size_t arraySize(T (&)[N]) noexcept
{return N;
}
如条款15所释,将该函数声明为constexpr,能够使得其返回值在编译期就可用。
从而就可以在声明一个数组时,指定其尺寸和另一数组相同:
int keyVals[] = {1, 3, 7, 9, 11, 22, 35}; // keyVals has 7 elements
int mappedVals[arraySize(keyVals)]; // so does mappedVals
或者用更现代的方式:
std::array<int, arraySize(keyVals)> mappedVals;
函数参数
另一个会退化为指针的类型是函数。函数会退化为函数指针,且规则与数组相同。例如:
void someFunc(int, double); // someFunc's type is void(int, double)template <typename T>
void f1(T param);template <typename T>
void f2(T& param);f1(someFunc); // T and ParamType are both void (*)(int, double)
f2(someFunc); // T is void(int, double), ParamType is void (&)(int, double)
要点速记
- 在模板型别推导过程中,具有引用型别的实参会被当成非引用型别来处理。换言之,其引用性会被忽略。
- 对万能引用形参进行推导时,左值实参会进行特殊处理。
- 对按值传递的形参进行推导时,若实参型别中带有const或volatile饰词,则它们还是会被当作不带const或volatile饰词的型别来处理。
- 在模板型别推导过程中,数组或函数型别的实参会退化成对应的指针,除非它们被用来初始化引用。
条款2:理解auto型别推导
auto
使用的类型推断规则与模板的规则几乎一样。
auto x = 27;
const auto cx = x;
const auto& rx = x;
auto
就相当于上节中的T
,而x
、cx
、rx
的类型则是ParamType
。
回忆一下上节介绍的ParamType
的三种情况,同样可以应用在auto
上:
- Case1:
auto
类型是指针或引用,但不是万能引用。 - Case2:
auto
类型是万能引用。 - Case3:
auto
类型既不是指针也不是引用。
auto x = 27; // case3: int
const auto cx = x; // case3: const int
const auto&& rx = x; // case1: const int&&
auto&& uref1 = x; // case2: int&. x is lvalue
auto&& uref2 = cx; // case2: const int&. cx is lvalue
auto&& uref3 = 27; // case2: int&&. 27 is rvalue
以及针对数组和函数的规则:
const char name[] = "R. N. Briggs"; // name is const char[13]auto arr1 = name; // arr1 is const char*
auto& arr2 = name; // arr2 is const char(&)[13]void someFunc(int, double); // someFunc is void(int, double)auto func1 = someFunc; // func1 is void (*)(int, double)
auto& func2 = someFunc; // func2 is void (&)(int, double)
例外:
auto
会把所有的统一初始化式(花括号初始化式)当作std::initializer_list<T>
对待。
在int
的初始化中:
int x1 = 27;
int x2(27);
int x3 = {27};
int x4{27};
以上四种形式得到的x1
到x4
都是一个值为27
的int
。
但如果换成auto
,后两者的类型就有些出乎意料了:
auto x1 = 27; // x1 is int
auto x2(27); // 同上
auto x3 = {27}; // x3 is std::initializer_list<int>
auto x4{27}; // 同上
推导std::initializer_list<T>
,一共需要进行两次推导,包括推导T。
如果大括号里的值型别不一,则推导失败,无法通过编译:
auto x5 = {1, 2, 3.0}; // error!
如果把std::initializer_list<T>
传给一个函数模板,行为则不一样:
如果
param
的类型为T
,则报错:template <typename T> void f(T param); f({11, 23, 9}); // error! can't deduce T
如果
param
的类型为std::initializer_list<T>
,则可以:template <typename T> void f(std::initializer_list<T> param); f({11, 23, 9}); // T is int and param is std::initializer_list<int>
C++14允许auto
作为函数的返回类型,及lambda函数的参数类型,但这两种情况下的auto
实际应用的是模板的类型推断规则,而不是上面说的auto
规则!
auto createInitList()
{return {1, 2, 3}; // error! can't deduce type for {1, 2, 3}
}std::vector<int> v;
...
auto resetV = [&v](const auto& newValue) { v = newValue; }
...
resetV({1, 2, 3}); // error! can't deduce type for {1, 2, 3}
要点速记
- 在一般情况下,auto型别推导和模板型别推导是一模一样的,但是auto型别推导会假定用大括号括起的初始化表达式代表一个std::initializer_list,但模板型别推导却不会。
- 在函数返回值或lambda式的形参中使用auto,意思是使用模板型别推导而非auto型别推导。
条款3:理解decltype
通常decltype
返回表达式的精确类型,除了几种特殊情况。
C++11中,decltype
还可以用来表示一个需要推断出来的类型返回类型:
template <typename Container, typename Index> // requires refinement
auto authAndAccess(Container& c, Index i) -> decltype(c[i]) {authenticateUser();return c[i];
}
C++14中我们可以省掉尾部的返回类型:
template <typename Container, typename Index> // C++14, not quite correct
auto authAndAccess(Container& c, Index i) {authenticateUser();return c[i];
}
上面的形式的问题在于:auto
会抹去类型中的引用,导致我们想返回T&
,但实际却返回了T
。
所以实际上C++14我们需要写成decltype(auto)
:
template <typename Container, typename Index> // C++14, requires refinement
decltype(auto) authAndAccess(Container& c, Index i) {authenticateUser();return c[i];
}
通过decltype
保证返回变量的本来类型这一特性,保证不丢失CV
限制符和引用等,因此在C++14中可以通过decltype
和auto
来声明变量,保证变量的类型和赋值的类型一模一样。
Widget w;
const Widget& cw = w;
auto myWidget1 = cw; // myWidget1 is Widget
decltype(auto) myWidget2 = cw; // myWidget2 is const Widget&
万能引用
再回头看上面C++14版本的authAndAccess
,它的问题是参数类型为左值引用,因此无法接受右值参数。
我们当然可以用重载来实现同时支持左值和右值引用的目的。如果不用重载的话,就需要把参数类型改成万能引用了:Container&& c
。
万能引用可以接收左值,右值还有带const的值。
template<typename Container,typename Index>
decltype(auto) AccessContainer(Container&& c,Index i) {authenticateUser();return c[i];
}
完美转发
不过,上述方案仍然有不足之处。
如果用户传入的是一个右值,通过移动语义传递给了AccessContainer
的参数c
,但是因为参数c有了名称,所以变成了左值。如果想让它在传入右值时返回右值,得使用C++11的完美转发:std::forward<T>
。
template <typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i) {autherticateUser();return std::forward<Container>(c)[i];
}
详细介绍:https://cloud.tencent.com/developer/article/1387860
特殊情况
将decltype应用于一个名字之上,就会得出该名字的声明型别。名字其实是左值表达式,但如果仅有一个名字,decltype的行为保持不变。
不过,如果是比仅有名字更复杂的左值表达式的话,dectype就保证得出的型别总是左值引用。换言之,只要一个左值表达式不仅是一个型别为T的名字,它就得出一个T&型别。
举例:
int x = 0;
decltype(x) //得到的是int类型
decltype((x)) //得到的是int&类型
因此在使用decltype(auto)
时,改变return
后面的表达式形式,可能会改变返回的类型:
decltype(auto) f1() {int x = 0;...return x; // return int
}
decltype(auto) f2() {int x = 0;...return (x); // return int& to a local variable!
}
在正常情况下,decltype产生的型别和你期望的一直,尤其是应用于变量名时:它得出的就是该名字的声明型别(declared type)。
要点速记
- 绝大多数情况下,decltype会得出变量或表达式的型别而不作任何修改。
- 对于型别为T的左值表达式,除非该表达式仅有一个名字,否则,decltype总是得出型别T&。
- C++14支持decltype(auto),和auto一样,它会从其初始化表达式出发来推导型别,但是它的型别推导使用的是decltype的规则。
条款4:查看型别推导结果的方法
采用何种工具来查看型别推导结果,取决于你在软件开发过程的哪个阶段需要该信息。
我们将研究三个可能的阶段:撰写代码阶段、编译阶段和运行时阶段。
IDE编辑器
IDE中的代码编辑器通常会在你将鼠标指针悬停至某个程序实体,如变量、形参、函数等时,显示出该实体的型别。
要让这种方法奏效,代码就多多少少要处于一种可编译的状态。不过,一旦较为复杂的型别现身,IDE显示的信息就不太有用了。
编译器诊断信息
可以通过编译器在出错时给出的诊断消息来显示推断出的类型。
首先声明一个模板类,但不给出任何定义:
template <typename T>
class TD;
然后用你想显示的类型来实例化这个模板:
TD<decltype(x)> xType;
TD<decltype(y)> yType;
显然编译会出错,错误消息中就有我们想看到的类型:
error: aggregate 'TD<int> xType' has incomplete type and cannot be defined
error: aggregate 'TD<const int *> yType' has incomplete type and cannot be defined
不同的编译器可能会给出不同格式的诊断消息,但基本上都是能帮到我们的。
运行时输出
方法1: std::type_info::name
std::cout << typeid(x).name() << std::endl;
std::cout << typeid(y).name() << std::endl;
这种方法输出的类型可能不太好懂,比如在GNU和Clang中int
会缩写为i
,而const int*
会缩写为PKi
。
但有的时候std::type_info::name
的输出靠不住:
template <typename T>
void f(const T& param) {std::cout << typeid(T).name() << std::endl;std::cout << typeid(param).name() << std::endl;
}std::vector<Widget> createVec();
const auto vw = createVec();if (!vw.empty()) {f(&vw[0]);
}
在GNU下输出是:
PK6Widget
PK6Widget
意思是const Widget*
。
问题来了:根据Item1的规则,这里T
应该是const Widget*
,而param
应该是const Widget* const&
。
不幸的是,这就是std::type_info::name
的要求:它要像传值调用一个模板函数一样对待类型,即“去掉引用、const、volatile”。
所以const Widget* const&
最终变成了const Widget*
。
更准确的方法是方法二。
方法2: boost::typeindex::type_id_with_cvr
boost::typeindex::type_id_with_cvr
可以精确地输出类型。
template <typename T>
void f(const T& param) {std::cout << boost::typeindex::type_id_with_cvr<T>().pretty_name() << std::endl;std::cout << boost::typeindex::type_id_with_cvr<decltype(param)>().pretty_name() << std::endl;
}
GNU输出为:
Widget const*
Widget const* const&
第2章 auto
条款5:优先使用auto,而非显示类型声明
优点1: 避免忘记初始化
int x1; // potentially uninitialized
auto x2; // error! initializer required
auto x3 = 0; // fine, x3 is well-defined
优点2: 方便声明冗长的,或只有编译器知道的类型
template <typename It>
void dwim(It b, It e) {for (; b != e; ++b) {typename std::iterator_traits<It>::value_type currValue = *b;// orauto currValue = *b;}
}
以及:
auto derefUPLess = [](const std::unique_ptr<Widget>& p1,const std::unique_ptr<Widget>& p2){ return *p1 < *p2; }
C++14中我们还可以写成:
auto derefLess = [](const auto& p1, const auto& p2) { return *p1 < *p2; }
优点3: 比起std::function
,auto
体积更小,速度更快
std::function
可以用来声明一切可调用对象。
上例中derefUPLess
如果要手动声明的话,得用std::function
:
std::function<bool(const std::unique_ptr<Widget>&, const std::unique_ptr<Widget>&)>
derefUPLess =[](const std::unique_ptr<Widget>& p1,const std::unique_ptr<Widget>& p2){ return *p1 < *p2; }
一个重要的事实是std::function
与auto
是不同的。
使用auto声明的、存储着一个闭包的变量和该闭包是同一型别,从而它要求的内存量也和该闭包一致。
而使用std::function声明的、存储着一个闭包的变量是std::function的一个实例,所以不管给定的签名如何,它都占有固定尺寸的内存,而这个尺寸对于其存储的闭包而言并不一定够用。如果是这样的话,std::function的构造函数就会分配堆上的内存来存储该闭包。从结果上看,std::function对象一般都会比使用auto声明的变量使用更多内存。这个机制还会影响函数的inline。
结果就是std::function
几乎一定比auto
体积大,调用慢,还可能会抛out-of-memory
的异常。auto省事且高效。
优点4: 声明类型更准确
例1:
std::vector<int> v;
unsigned sz = v.size();
v.size()
实际返回的是std::vector<int>::size_type
,它是一个无符号整数类型,因此很多人习惯声明为unsigned
,但这是不准确的。
32位环境下unsigned
和std::vector<int>::size_type
都是uint32_t
,没问题。但64位环境下,前者还是32位的,后者却是64位的。
而用auto sz = v.size()
就能避免这个问题。
例2:
std::unordered_map<std::string, int> m;
...
for (const std::pair<std::string, int>& p: m) {...
}
上面的代码有一个大问题:std::unordered_map
的key是const
的,因此p
的类型应该声明为const std::pair<const std::string, int>&
。
问题还没结束,编译器会努力的为p
找到一个从std::pair<const std::string, int>
到std::pair<std::string, int>
的转换,而这样的转换是存在的:生成一个std::pair<std::string, int>
的临时对象。结果就是每次循环都会生成一个临时对象。
而用auto
就没有这个问题了:
for (const auto& p: m) {...
}
(常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未作限定。)
用auto
不光是效率上的问题,还有正确性的问题:如果我们取p
的地址,我们能百分百确定它是在m
中,而不用auto
,我们可能取到一个临时对象的地址。
优点5:方便重构
使用auto
还能帮助我们做重构。比如一个变量的初始类型是int
,有一天你想换成long
,那么用了auto
的地方自动就变掉了,但用了int
去声明的地方则要你一个一个的找出来。
可读性
有人担心auto
略去了类型,会影响我们对代码的理解,IDE的类型显示能力会缓和这个问题。并且,很多时间一个好的名字能解决这个问题,比如知道这个变量是容器、计数器,还是一个智能指针。
要点速记
- auto变量必须初始化,基本上对会导致兼容性和效率问题的型别不匹配现象免疫,还可以简化重构流程,通常也比显式指定型别要少打一些字。
- auto型别的变量都有着条款2和条款6中所描述的毛病。
条款6: 在auto
推导非预期时显式声明类型
一些场景下表达式的类型与我们想要的类型并不一致,我们依赖于隐式类型转换才能得到想要的类型。这个时候我们需要显式声明类型,如果用auto
就会得到非预期的类型。
一种常见场景是表达式返回一个代理类型( proxy ),比如std::vector::operator[]
返回std::vector::reference
,而不是我们预期的bool
。类的设计者预期我们会把返回值的类型声明为bool
,再通过reference::operator bool()
来做隐式转换。
std::vector<bool> features();
...
bool highPriority = features()[5]; // reference -> bool, not bool&
...
processWidget(w, highPriority);
很多C++库都用到了一种叫做“表达式模板”的技术以提高数值计算代码的效率,也会导致上面的问题。
一个例子:
Matrix sum = m1 + m2 + m3 + m4;
通常来说这会产生3个临时的Matrix
对象:每次operator+
产生1个。如果我们定义一个代理类作为Matrix::operator+
的返回值,这个类只会持有Matrix
的引用,不做实际的运算,直到调用=
时再去生成最终的Matrix
,就能避免这几个临时对象的产生。
这个例子中我们也没办法直接声明auto sum = ...
怎么避免出现auto var = expression of "invisible" proxy class type;
这种情况呢?
- 看文档,一般设计成这样的类会有特殊说明;
- 看头文件,看具体调用的返回值类型是不是符合预期;
- 用
static_cast
强制类型转换,保证返回值的类型符合预期。如下:auto highPriority = static_cast<bool>(features()[5]); auto sum = static_cast<Matrix>(m1 + m2 + m3 + m4);
一些依赖于基础类型的隐式转换的场景也可以用static_cast
:
double calcEpsilon();
float ep = calcEpsilon(); // implicitly convert double -> float
auto ep = static_cast<float>(calcEpsilon());
要点速记
- “隐形”的代理型别可以导致auto根据初始化表达式推导出“错误的"型别。
- 强制类型转换可以让auto推导出你想要的型别。
第3章 转向现代C++
条款7:在创建对象时注意区分()和{}
C++11中我们能用()
、{}
和=
来初始化一个变量:
int x(0);
int y = 0;
int z{0};
int z = {0}; //C++将该种语法与上式只有大括号语法同样处理
是否用等号对于基本类型来说没有任何区别,对于自定义类型则不一样:
Widget w1(2); //调用的默认构造函数
Widget w2 = w1; //调用的是拷贝构造函数
w1 = w2; //调用的赋值操作符
{}
是C++11引入的统一初始化:单一的、至少从概念上可以用于一切场合、表达一切意思的初始化。它的基础是大括号形式。
对比
它能表达一组值,来初始化STL容器:
std::vector<int> v{1, 3, 5};
它能用来给类的非static成员设定默认值(而
()
就不行):class Widget { ... private:int x{0}; // fineint y = 0; // also fineint z(0); // error! };
它和
()
都能用于初始化一个uncopyable的对象(而=
就不行):std::atomic<int> ai1{0}; // fine std::atomic<int> ai2(0); // also fine std::atomic<int> ai3 = 0; // error!
特性
{}
有一个新特性:它会阻止基本类型向下转换(禁止窄化类型转换):double x, y, z; ... int sum1{x + y + z}; // error! double -> int is prohibited int sum2(x + y + z); // ok int sum3 = x + y + z; // ok
它对于C++的最令人苦恼之解析语法免疫。具体表现为: 它不会被认为是声明 。
C++规定任何能够解析为声明的都要解析为声明, 这导致
()
在一些场景下会被视为函数声明,而{}
则不会:Widget w1(10); // call Widget ctor with 10 Widget w2(); // 被解析为一个函数声明 Widget w3{}; // 调用默认构造函数
缺陷
在类有
std::initializer_list
参数的构造函数时,{}
会有麻烦:{}
总会被认为是std::initializer_list
,即使解析出错。class Widget { public:Widget(int i, bool b);Widget(int i, double d);Widget(std::initializer_list<long double> il); // added... }; Widget w1(10, true); // call first ctor Widget w2{10, ture}; // NOTICE: now call third ctor(10 and true convert to long double) Widget w3(10, 5.0); // call second ctor Widget w4{10, 5.0}; // NOTICE: now call third ctor(10 and 5.0 convert to long double)
甚至通常拷贝和移动构造函数该被调用的地方,都会被劫持到
std::initializer_list
构造函数上:class Widget { public:Widget(int i, bool b);Widget(int i, double d);Widget(std::initializer_list<long double> il);operator float() const; // added... }; Widget w5(w4); // call copy ctor Widget w6{w4}; // call third ctor! w4 -> float -> long double Widget w7(std::move(w4)); // call move ctor Widget w8{std::move(w4)}; // call third ctor! w4 -> float -> long double
甚至在
{}
中的内容没办法完全匹配std::initializer_list
时:class Widget { public:Widget(int i, bool b);Widget(int i, double d);Widget(std::initializer_list<bool> il);... }; Widget w{10, 5.0}; // error! requires narrowing conversions
只有当{}
中的所有元素都没办法转换为std::initializer_list
需要的类型时,编译器才会去选择其它构造函数。比如上面的bool
改为std::string
,编译器找不到{10, 5.0}
中有能转换为std::string
的元素,就会去匹配我们希望的前两个构造函数了。
一个有趣的地方:如果{}
中没有元素,那么被调用的是默认构造函数,而不是一个空的std::initializer_list
。
如果你真的想传入一个空的std::initializer_list
,那么这样:
Widget w4({});
Widget w5{{}};
因此,向一个已有的类添加std::initialzier_list
构造函数要非常谨慎,这可能会导致用户的调用被劫持。
要点速记
- 大括号初始化可以应用的语境最为宽泛,可以阻止隐式窄化型别转换,还对最令人苦恼之解析语法免疫。
- 在构造函数重载决议期间,只要有任何可能,大括号初始化物就会与带有std::initializer_list型别的形参相匹配,即使其他重载版本有着貌似更加匹配的形参表。
- 使用小括号还是大括号,会造成结果大相径庭的一个例子是:使用两个实参来创建一个
std::vectork<T>
对象。 - 在模板内容进行对象创建时,到底应该使用小括号还是大括号会成为一个棘手问题。
条款8:优先选用nullptr,而非0或NULL
对比:
NULL
是0,0是int
,不是指针;nullptr
实际型别是std::nullptr_t
,可以隐式转换到所有裸指针,能安全地用在需要用指针的场合。
当C++在只能使用指针的语境中发现了一个0,它也会把它勉强解释为空指针,只是为了兼容C的语法。
- C++98中,这样的基本观点可能在指针型别和整型之间进行重载时可能会发生意外。如果向这样的重载函数传递0和NULL,是从来不会调用到要求指针型别的重载版本的。
- 模板中,模板型别推导会将0和NULL推导成“错误”型别(即它们的真实型别,而非退而求其次的表示空指针这个意义)。
要点速记
- 相对于0或NULL,优先选用nullptr。
- 避免在整型和指针型别之间重载。
条款9:优先选用using
来替代typedef
首先,using
在表达类型时更清晰:
typedef void(*FP)(int,const std::string&);
using FP = void(*)(int,const std::string&);
其次,using
能模板化,称为”别名模板”,而typedef
不能:
template <typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; // OKtemplate <typename T>
typedef std::list<T, MyAlloc<T>> MyAllockList; // error!
C++98中我们可以通过struct来绕过这个问题:
template <typename T>
struct MyAllocList {typedef std::list<T, MyAlloc<T>> type;
};MyAllocList<Widget>::type lw; // client code
MyAllocList<Widget>::type
代表一个依赖于模板类型形参(T)的型别,所以MyAllocList::type称为带依赖型别。但在模板里我们不能直接使用这个类型,要在前面加上typename:
template <typename T>
class Widget {private:typename MyAllocList<T>::type list;
};
因为,当编译器遇见Widget模板中的MyAllocList<T>::type
时,不能确定MyAllocList<T>::type
命名了一个型别,因为可能MyAllocList<T>::type
表示并非型别而是其他的什么东西。
比如:
template <typename T>
struct MyAllocList {static int type;
};
因此,需要在前面加typename。而使用了using则没有这种必要。
模板元编程(TMP) 时,经常需要从模板型别形参出发来创建其修正型别的需要。举例来说,给定某型别T,你可能会需要去除T的所有const和引用饰词,或者加上const…
C++11以型别特征(type trait)的形式给了程序员以执行此类变换的工具。型别特征是在头文件<type traits>
给出的一整套模板。
对给定待变换型别T,其结果型别为std::transformation<T>:type
,例如:
std::remove_const<T>::type // const T -> T
std::remove_reference<T>::type // T& and T&& -> T
std::add_lvalue_reference<T>::type // T -> T&
但每次使用都要在前面加上typename
,就是因为这些trait类都是通过typedef
定义出来的。
C++14中这些类都有了一个using
版本,直接是一个类型,使用时不需要加typename
:
std::remove_const_t<T>
std::remove_reference_t<T>
std::add_lvalue_reference_t<T>
要点速记
- typedef不支持模板化,但别名声明支持。
- 别名模板可以让人免写"::type"后缓,并且在模板内,对于内嵌typedef的引用经常要求加上typename前缀。
条款10:优先选用限定作用域的枚举类型
C++包含两种枚举:
不限定作用域(unscoped)的枚举类型(C++98):
enum Color {black, white, red}; auto white = false; // error! white already declared in this scope
C++的通用规则是name会从属于它所在的scope,只在这个scope内可见。但C++98的enum没有遵循这个规则,enum内定义的name,不只在这个enum内可见,而是在enum所在的整个scope内可见!
限定作用域(scoped)的枚举类型(C++11引入):
enum class Color {black, white, red}; auto white = false; // fine, no other "white" in this scope Color c = white; // error! Color c = Color::white; // fine auto c = Color::white; // also find, c is 'Color'
enum class
定义的枚举又被称为“枚举类”。
为什么推荐使用限定作用域枚举类型?
通过使用scoped的枚举类型,可以减少因为作用域的问题带来的命名污染。
此外,scoped的枚举类型还有另外一个优点就是强类型,不会隐式类型转换为整型(不过可以强制类型转换为整型),而unscoped的枚举类型可以隐式转换为整型。
enum Color {black,white,red}; Color c = red; if (c < 14.5) { //和浮点型进行比较..... }enum class CColor {black,white,red}; CColor cc = Color::red; if (cc < 14.5) { //编译出错,无法进行隐式类型转换。.... }
enum class还有一个特性:可以前向声明。C++98中的无界enum则不能前向声明。C++11中的无界enum可以在指定底层类型后前向声明。
enum Color; // error! enum class Color; // fine
为什么C++98只支持enum的定义,而不支持声明?因为C++中每个enum类都对应着一个底层整数类型,但C++98中我们没办法指定这个类型,也没办法确定这个类型。编译器会在看到enum的定义时确定它底层用什么整数类型来存储。而前向声明的一个基本要求是:知道对应类型的大小。如果我们没办法确定enum是用什么类型存储的,我们也就没办法知道enum的大小,也就没办法前向声明。
enum Status {good = 0,failed = 1,incomplete = 100,corrupt = 200,indeterminate = 0xFFFFFFFF };
编译器在看到上面的
Status
定义时,发现它的所有的值范围都在[-1, 200]之间,最合适的类型就是char
。如果我们增加一项audited = 500
,值范围就变成了[-1, 500],最合适的类型变成了short
!但C++98这种过于死板的规定也导致了:每当我们向
Status
中增加一项,所有引用了它的.cpp文件都需要被重新编译一次。C++11允许我们指定enum的底层类型,尤其是enum class在不指定时默认使用int。这就保证了我们能安全的前向声明enum和enum class。
enum class Status; // underlying type is int enum class Status: std::uint32_t; // underlying type is std::uint32_t enum Status; // error! enum Status: std::uint32_t; // underlying type is std::uint32_t
注意:如果要指定底层类型,需要在enum和enum class的声明和定义处都指定相同的底层类型。
非限定作用域的弱点:
C++11中我们使用std::tuple
时需要用到整数常量作为下标:
auto val = std::get<1>(uInfo);
如果用enum来代替硬编码的下标,对可读性有好处:
enum UserInfoFields {uiName, uiEmail, uiReputation};
auto val = std::get<uiEmail>(uInfo);
但换成enum class上面的代码就不行了:enum class没办法隐式转换为整数类型。但我们可以用static_cast
。
enum class UserInfoFields {uiName, uiEmail, uiReputation};
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);
这么写太长了,我们可能需要一个辅助函数。注意:std::get
是模板,它的参数需要是编译期常量,因此这个辅助函数必须是一个constexpr
函数。我们来通过std::underlying_type
实现一个将enum class的值转换为它的底层类型值的constexpr
函数:
template <typename E>
constexpr typename std::underlying_type<E>::type toUType(E e) noexcept {return static_cast<typename std::underlying_type<E>::type>(e);
}
C++14中我们可以用std::underlying_type_t
:
template <typename E>
constexpr std::underlying_type_t<E>::type toUType(E e) noexcept {return static_cast<std::underlying_type_t<E>>(e);
}
这样我们在使用std::tuple
时就可以:
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);
要点速记
- C++98风格的枚举型别,现在称为不限范围的枚举型别。
- 限定作用域的枚举型别仅在枚举型别内可见。它们只能通过强制型别转换以转换至其他型别。
- 限定作用域的枚举型别和不限范围的枚举型别都支持底层型别指定。限定作用域的枚举型别的默认底层型别是int,而不限范围的枚举型别没有默认底层型别,因此编译依赖性高。
- 限定作用域的枚举型别总是可以进行前置声明,而不限范围的枚举型别却只有在指定了默认底层型别的前提下才可以进行前置声明。
条款11:优先选用删除函数,而非private未定义函数
有时C++会为你自动生成一些函数,但你想要阻止其他人调用这些函数。
C++98中,为了避免编译器为我们生成拷贝构造函数和赋值函数,最佳实践是:将它们声明为private函数,且不定义。
C++11引入了=delete
更好的阻止了这些函数的生成。
为什么?
C++11的删除函数,错误提醒更友好。
deleted函数的一个重要优势在于,它不止用于成员函数(未定义的private函数只能是成员函数)!
例如我们有这么个函数:
bool isLucky(int number);
C++的隐式转换导致非整数的基本类型也能调用这个函数:
if (isLucky('a)) ...if (isLucky(true)) ...if (isLucky(3.5)) ...
C++11中我们可以将这些我们不想要的函数定义为deleted:
bool isLucky(int number);bool isLucky(char) = delete; bool isLucky(bool) = delete; bool isLucky(double) = delete;
这些deleted函数仍然会参与到重载决议中,再报错。
deleted函数的另一类用途是禁止模板的某个特化版本。
指针世界中有两个异类。一个是void指针,因为无法对其执行提领、自增、自减等操作。还有一个是char指针,因为它们基本上表示的是C风格的字符串,而不是指涉到单个字符的指针。
而特殊情况往往需要特殊处理,如果我们不想将其用于void*
和char*
,就将它们声明为deleted:template <> void processPointer<void>(void*) = delete;template <> void processPointer<char>(char*) = delete;
C++98中,我们没办法通过声明为private来禁止其他人调用模板成员函数的某个特化版本,因为模板成员函数的所有版本的访问权限都是一样的。
不过,=delete
后,自己也无法调用这些函数。所以像单例模式,不能使用=delete
。
要点速记
优先选用删除函数,而非private未定义函数。
任何函数都可以删除,包括非成员函数和模板具现。
条款12:将重写函数声明为override
重写需要满足几个条件:
- 基类中的函数必须是虚函数。
- 基类与子类中的函数必须同名(析构函数除外)。
- 基类函数与子类函数的参数类型必须相同。
- 基类函数与子类函数的const性必须相同。
- 子类函数的返回类型和异常规格必须与基类函数的兼容。
以上是C++98中对重写的要求,C++11又加了一条:
函数的引用限定符必须相同。
引用限定符是为了限制成员函数仅用于左值或右值。
class Widget { public:...void doWork() &; // applies only when *this is an lvaluevoid doWork() &&; // applies only when *this is an rvalue };
如果违反了这些条件,就不再是重写函数,但编译器并不会报错,因为完全合法。
因此,C++11增加了override
修饰符,可以显示标明一个函数是重写函数,否则就会报错。
class Base {
public:virtual void doWork();...
};class Derived: public Base {
public:virtual void doWork() override;...
};
引用限制符
下面说一下函数的引用限制符。我们在某些场景下需要知道对象是左值还是右值。
class Widget {
public:using DataType = std::vector<double>;...DataType& data() { return values; }...
private:DataType values;
};Widget w;
auto vals1 = w.data();
这里Widget::data
返回了一个左值,因此vals1
的初始化调用了vector
的拷贝构造函数。
假设我们有个函数Widget makeWidget()
,它返回一个临时的Widget
对象,在这个临时对象上调用data
就不太值了:
auto vals2 = makeWidget().data();
如果我们能在调用data
时知道*this是右值的话,就可以返回一个右值:
class Widget {
public:using DataType = std::vector<double>;...DataType& data() & { return values; }DataType&& data() && { return std::move(values); }...
private:DataType values;
};
这样vals2
的初始化就只需要调用vector
的移动构造函数。
要点速记
- 为意在重写的函数添加override声明。
- 成员函数引用饰词使得对于左值和右值对象(*this)的处理能够区分开来。
条款13:优先选用const_iterator,而非iterator
const_iterator指向const对象,只要有可能就应该使用const的标准实践表明,任何时候只要你需要一个迭代器而其指涉到的内容没有修改必要,你就应该使用const_iterator。
我们当然希望尽可能用它,但C++98对它的支持很不全面,首先难以创建,其次可使用的场景很受限。
例如,下面这段代码:
std::vector<int> values;
...
std::vector<int>::iterator it = std::find(values.begin(), values.end(), 1983);
values.insert(it, 1988);
这里有3个iterator:it
、values.begin()
、values.end()
,最好是能把它们替换为const_iterator。但:
- 非const的容器对象的
begin()
和end()
只能返回iterator,不能返回const_iterator。 vector::insert
的第一个参数只接受iterator,不接受const_iterator。
C++11做了几个改变,令const_iterator重新回到人们的视野中:
- STL中的几个容器类提供了
cbegin
和cend
成员函数,返回非const对象的const_iterator。 - 提供
std::cbegin
和std::cend
函数,返回参数的const_iterator,甚至支持数组。 - STL的几个容器类增加了多个接受const_iterator参数的成员函数重载版本,比如
insert
。
上面的第2条不太准确,实际上C++11只增加了begin
和end
这两个非成员函数,C++14则一口气增加了cbegin
、cend
、rbegin
、rend
、crbegin
和crend
这六个非成员函数。
前面的代码在C++11中是这样的:
std::vector<int> values;
...
auto it = std::find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1988);
如果我们想在C++11中就用到非成员版的cbegin
,大可以自己写一个:
template <typename C>
auto cbegin(const C& container) -> decltype(std::begin(container)) {return std::begin(container);
}
这里为什么返回的是std::begin(container)
?为什么不返回container.cbegin()
?
- 注意
container
的类型是const C&
,通常来说const对象的begin
与cbegin
都会返回const_iterator。而且还会有一些类只定义了begin
,没有定义cbegin
,这样调用begin
可以适用于更多的类型。 - 调用
std::begin
的话,对于定义了begin
成员函数的类,与调用成员版本的begin
是相同效果的;对于数组类型,它没有成员版本的begin
,但有std::begin
的一个特化版本,因此调用std::begin
能适用于更多的情况。
要点速记
- 优先选用constiterator,而非iterator。
- 在最通用的代码中,优先选用非成员函数版本的begin,end和 rbegin等,而非其成员函数版本。
条款14:如果函数永远不会抛出异常,则声明为noexcept
C++98中的异常规格是一个很难用的特性:你要总结出这个函数可能抛哪些异常,还包括它下层函数可能抛的异常,把这些异常类型写到异常规格中,一旦改了实现(或下层函数改了实现),你还要修改异常规格,由此导致函数签名发生变化,可能破坏一大堆用户代码。这其中编译器通常帮不上忙。总之大多数人都认同C++98的异常规格是一个设计失误,不值得花那么大的代价来使用它。
但人们发现,标记一个函数可能抛哪些异常通常没什么意义,还惹来一大堆麻烦,但标记一个函数会不会抛异常却很有意义。
因此C++11中我们可以标记一个不会抛异常的函数为noexcept
:
int f(int x) throw(); // f不会抛出异常,C++98 style
int f(int x) noexcept; // f不会抛出异常,C++11 style
如果,在运行期,一个异常逸出f的作用域,则f的异常规格被违反。
在C++98异常规格下,调用栈会展开至f的调用方,同时逆序析构对应局部对象,然后程序执行中止。
而在C++11异常规格下,运行期行为会稍有不同:程序执行中止之前,栈只是可能会展开。相比C++98,能得到更多优化。
更典型的场景:
当向std::vector型别对象中添加新元素时,可能会空间不够,即std::vector型别对象的尺寸(size)和其容量(capacity)相等的时刻。当这件事发生时,std::vector型别对象会分配一个新的、更大的内存块(chunk)来存储其元素,然后它把元素从现存的内存块转移到新的。在C++98中,这种转移的做法是先把元素逐个地从旧内存复制到新内存,然后将旧内存中的对象析构。这个做法使得push_back能够提供强异常安全保证(strong exception safety guarantee):如果在复制元素的过程中抛出了异常,则std::vector型别对象会保持原样不变,因为在旧内存中的元素直至所有的元素被成功复制入新内存以后,才会被执行析构。
而在C++11中,一个自然而然的优化,就是把针对std::vector型别对象元素的复制操作替换成移动操作。不幸的是,这样做是冒了违反push_back的强异常安全保证这一风险。如果n个元素已经从旧内存移出,而在移动第n+1个元素时抛出了异常,则push-back操作无法完成。不过,此时原始的std::vector型别对象已经被修改:n个元素已经从其中移出。恢复到原始状态可能不行,因为想要把对象逐个地移回原始内存这个动作本身就有可能产生异常。
为了保持强异常安全保证,std::vector::push_back利用“能移动则移动,必须复制才复制”(move if you can,but copy if you must)策略(通过校验move是否带有noexcept)。
此校验动作相当迂回。像std::vector::push_back这样的函数会调用std::moveif_noexcept
,后者是std::move的一个变体,它会在一定条件下强型转换至右值型别,取决于型别的移动构造函数是否带有noexcept声明。而std::move_if_noexcept
调用了std::is_nothrow_move_constructible
,后者的值是由编译器给的。
若参数的移动构造函数不抛异常,则 move_if_noexcept 获得到参数的右值引用,否则获得左值引用。它典型地用于组合移动语义和强异常保证。
noexcept
实际上有三种用法:
- 作为函数规格的单独的
noexcept
,即不抛异常的保证。 - 作为函数规格区域的
noexcept(bool-expression)
,如果bool-exp为true
,则与单独的noexcept
相同,否则与没有这个noexcept
相同。 - 表达式
noexcept(func-call-exp)
,如果func-call-exp为noexcept
则返回true
,否则返回false
。
一些泛型函数可以根据它们的参数来推断是否有noexcept
的保证。以swap
为例:
template <typename T, size_t N>
void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b)));template <typename T1, typename T2>
struct pair {...void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&noexcept(swap(second, p.second)));...
};
如果swap<T>
有noexcept
的保证,则swap<T, N>
也有,否则也没有。第二个例子是只有swap<T1>
和swap<T2>
都有noexcept
,pair::swap
才有noexcept
的保证。
看起来很美好,但是不是所有函数都要加上noexcept
呢?
- 优化很重要,但正确性更重要。只有真的不应该抛异常的函数才应该加上
noexcept
。 noexcept
是函数签名的一部分,所以如果一个接口当前不抛异常,但长远来看不确定会不会抛异常,那么也不建议加noexcept
。- 加了
noexcept
不代表这个函数不能抛异常,而是“如果这里抛了异常,程序就应该直接挂掉”,只有这样的函数,才应该加noexcept
。
一个声明为noexcept
的函数,如果内部调用了未声明为noexcept
的函数,编译器不会抛错,连警告都没有,原因是:
- 作者想表达的是“正常不会抛异常,如果这里抛了异常,程序就应该直接挂掉”,编译器需要尊重这种选择。
- 可能调用的函数是C函数,或是C++98中的确实不会抛异常的函数,这些函数显然没办法声明为
noexcept
。
要点速记
- noexcept声明是函数接口的组成部分,这意味着调用方可能会对它有依赖。
- 相对于不带noexcept声明的函数,带有noexcept声明的函数有更多机会得到优化。
- noexcept性质对于移动操作、swap、函数释放函数和析构函数最有价值。
- 大多数函数都是异常中立的,不具备noexcept性质。
条款15:尽可能使用constexpr
constexpr对象
修饰对象时,constexpr
表示一个值不仅是const
,而且是在编译期确定的,是真正的常量。
int sz; // non-constexpr variable
...
constexpr auto arraySize1 = sz; // error! sz's value not known at compilation
std::array<int, sz> data1; // error! same problem
constexpr auto arraySize2 = 10; // fine, 10 is a complie-time constat
std::array<int, arraySize2> data2; // fine, arraySize2 is constexpr
如果你想让编译器提供保证,让变量拥有一个值,用于要求编译期常量的语境,那么能达到这个目的的工具是constexpr,而非const。
constexpr函数
- constexpr函数可以用在要求编译期常量的语境中。在这样的语境中,若你传给一个constexpr函数的实参值是在编译期已知的,则结果也会在编译期间计算出来,否则无法通过编译。
- 在调用constexpr函数时,若传入的值有一个或多个在编译期未知,则它的运作方式和普通函数无异,亦即它也是在运行期执行结果的计算。
这意味着,如果函数执行的是同样的操作,仅仅应用的语境一个是要求编译期常量的,一个是用于所有其他值的话,那就不必写两个函数。constexpr函数就可以同时满足所有需求。
自定义类型对象,尽管在其初始化过程中涉及了构造函数、访问器、还有个非成员函数的调用,也可以是编译期常量:
class Point {
public:constexpr Point(double xVal = 0, double yVal = 0) noexcept: x(xVal), y(yVal){}constexpr double xValue() const noexcept { return x; }constexpr double yValue() const noexcept { return y; }
private:double x, y;
};constexpr Point p1(9.4, 27,7);
constexpr Point p2(28.8, 5.3);
constexpr Point midpoint(const Point& p1, const Point& p2) noexcept {return {(p1.xValue() + p2.xValue()) / 2,(p1.yValue() + p2.yValue()) / 2};
}constexpr auto mid = midpoint(p1, p2);
C++11中对声明为constexpr
的成员函数有两个限制:
- 隐式声明为
const
函数,因此不能修改对象本身。 - 必须返回一个字面值类型,因此不能返回
void
。
因此我们没办法为Point
声明下面两个constexpr
成员函数:
class Point {
public:...constexpr void setX(double newX) noexcept {x = newX;}constexpr void setY(double newY) noexcept {y = newY;}...
};
C++14中去掉了这两个限制,上面两个函数就可以用了,还可以这么用:
constexpr Point reflection(const Point& p) noexcept {Point result;result.setX(-p.xValue());result.setY(-p.yValue());return result;
}constexpr Point p1(9.4, 27.7);
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);constexpr auto reflectedMid = reflection(mid);
建议尽可能地用constexpr
,但凡任何C++要求使用一个常量表达式的语境,皆可以用。
但要注意:
constexpr
是函数签名的一部分,如果把constexpr
从函数签名中去掉,可能会使客户代码被拒绝编译。- 通常来说,constexpr函数里是不允许有I/O语句的。
constexpr
函数可以把原本运行期的运算移到编译期进行,这会加快程序的运行速度,但也会增加编译时间。
要点速记
- constexpr对象都具备const属性,并由编译期已知的值完成初始化。
- constexpr函数在调用时若传入的实参值是编译期已知的,则会产出编译期结果,否则为运行期结果。
条款16: 保证const
成员函数线程安全
假设我们有一个多项式类,它有一个计算根的成员函数:
class Polynomial {
public:using RootsType = std::vector<double>;...RootsType roots() const;
};
求根计算开销很大,我们可能想加个cache,不要每次都算:
class Polynomial {
public:using RootsType = std::vector<double>;...RootsType roots() const {if (!rootsAreValid) {...rootsAreValid = true;}return rootVals;}
private:mutable bool rootsAreValid{false};mutable RootsType rootVals{};
};
从概念上说,roots不会改变它操作的Polynomial对象,然而作为缓存活动的组成部分,它可能需要修改rootvals和rootsArevalid的值。这是mutable的经典用例。
假设有两个线程对同一个对象调用roots
,因为这是一个const
成员函数,通常意味着它是只读的,因此不需要有任何互斥手段。但实际上这两个线程都会去试图修改rootsAreValid
和rootVals
,导致竞态条件。
问题就在于roots
声明为const
,但又没有保证线程安全性。我们可以用C++11增加的mutex
来实现线程安全,另外一些情况,用原子类即可。
不过,对于根本不考虑并发调用的类型,它的成员函数的线程安全性并不重要,因此我们不需要为它的const
成员函数添加开销昂贵的同步机制。
要点速记
- 保证const成员函数的线程安全性,除非可以确信它们不会用在并发语境中。
- 运用std::atomic型别的变量会比运用互斥量提供更好的性能,但前者仅适用对单个变量的操作。
条款17: 理解特殊成员函数的生成机制
“特殊成员函数”指编译器自己会生成的成员函数。C++98中有4个这样的函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符。
class Widget {
public:Widget();~Widget();Widget(const Widget& rhs); Widget& operator=(const Widget& rhs);
};
当然,这些函数只会在需要时才生成。这些默认生成的成员函数都是public
且inline
的。除了派生类的析构函数外(前提是基类的析构函数为虚函数),其它情况下这些成员函数都是非虚的。
C++11中又增加了两个特殊成员函数:移动构造函数和移动赋值函数:
class Widget {
public:...Widget(Widget&& rhs); // 移动构造函数Widget& operator=(Widget&& rhs); // 移动赋值函数
};
这两个函数的生成规则和行为与对应的复制版本非常类似:只在需要时生成,行为是逐个移动非静态成员变量。
意思是,移动构造函数将依照其形参rhs的各个非静态成员对于本类的对应成员(包括基类部分如果有的话)执行移动构造,移动赋值同理。
但是,对于那些没有定义移动函数的类型(比如C++98中的类型),“移动”请求实际上是通过复制函数完成的。逐个移动的过程的核心是对每个非静态成员变量调用std::move
,并在重载决议时决定是调用移动函数还是复制函数。
两个复制函数是独立的,声明一个不会影响另一个的默认生成。
但是移动操作并不独立:声明了其中一个,就会阻止编译器生成另一个。这条规则的背后原因是,如果你声明了某个移动函数,就表明这个类型的移动操作不再是“逐一移动成员变量”的语义,即你不需要编译器默认生成的移动函数的语义,因此编译器也不会为你生成另一个移动函数。
进一步地,如果你声明了某个复制函数,编译器也不再生成这两个移动函数了。这条规则的背后原因与上一条类似:自定义的复制函数表示你不想要“逐一”复制的语义,那么很大概率上“逐一”移动你也不想要,那么编译器就不会为你生成移动函数。
反过来的规则也成立:如果你声明了移动函数,那么编译器就不会生成复制函数。
C++98中有所谓的“三法则”:如果你声明了复制构造函数、复制赋值函数或析构函数中的一个,你也应该定义另外两个。该原则的原因是如果你声明了其中任意一个函数,就表明你要自己管理资源,而这三个函数都会参与到资源管理中,因此如果声明就要全声明掉。STL中的每个容器类都声明了这三个函数。
三法则的一个推论就是,自定义了析构函数往往意味着逐一的复制语义并不适用于这个类,因此自定义析构函数也应该阻止编译器生成复制函数。但在C++98标准产生过程中,三法则还没有被广泛认可,因此C++98中自定义析构函数并不会影响编译器生成复制函数。C++11中为了兼容老代码,并没有改变这一条规则。但要注意的是自定义析构函数会阻止编译器生成移动函数。
因此移动函数的产生规则为,编译器只在以下三条都成立时才生成默认的移动构造函数和移动赋值函数:
- 该类未声明复制函数。
- 该类未声明移动函数。
- 该类未声明析构函数。
如果希望声明一个默认生成的特殊函数,在C++11中你可以标记其为“=default”,显式要求编译器生成一个这样的函数:
class Widget {
public:...~Widget();Widget(const Widget&) = default;Widget& operator=(const Widget&) = default;
};
这种手法往往对于多态基类会很有用,因为多态基类往往需要显示定义虚析构函数,否则无法通过基类指针正确delete子对象。
通常情况下,虚析构函数的默认实现就是正确的,而=default
则是表达这一点的很好方式。不过,一旦用户声明了析构函数,移动操作的生成就被抑制了,而如果可移动性是能够支持的,加上=default
就能够再次给予编译器以生成移动操作的机会。声明移动操作又会废除复制操作,所以如果还要可复制性,就再加一轮=default
来实现:
class Base {
public:virtual ~Base() = default;Base(Base&&) = default;Base& operator=(Base&&) = default;Base(const Base&) = default;Base& operator=(const Base&) = default;
};
事实上,即使编译器默认生成的复制和移动函数已经足够了,你仍然可以在类中显式声明这些函数为=default
,明确表达你的意图,且能避免一些微妙的缺陷。
一个例子:
class StringTable {
public:StringTable() {}...
private:std::map<int, std::string> values;
};
这样的类型,编译器隐式生成的复制函数、移动函数、析构函数已经足够用了。但如果有一天,你决定在这个类的构造和析构时打一条LOG:
class StringTable {
public:StringTable() {makeLogEntry("Creating StringTable object");}~StringTable() {makeLogEntry("Destroying StringTable object");}
private:std::map<int, std::string> values;
};
看起来很合理,但因此编译器不再为StringTable
生成移动函数,而生成的复制函数不受影响。这样一来,原始版本中可以调用移动构造函数或移动赋值函数的地方,现在都改为调用复制构造函数和复制赋值函数。程序没有报错,但性能却在无人注意时下降了。而如果我们一开始就显式声明这些函数为“=default”,既利用上了编译器生成的函数,又不会在无意间改变程序的行为。
C++11规定了以下特殊成员函数:
- 默认构造函数:与C++98相同。仅当类中不包含用户声明的构造函数时才生成。
- 析构函数:基本与C++98相同,但默认为
noexcept
。与C++98的机制相同,仅当基类的析构函数为虚的,派生类的析构函数才是虚的。 - 复制构造函数:与C++98的运行时行为相同。声明了移动函数会阻止生成复制构造函数。在已经存在复制构造函数或析构函数的条件下,仍然生成复制赋值运算符已经成为了被废弃的行为。
- 复制赋值函数:与C++98的运行时行为相同,其它特性同复制构造函数。
- 移动构造函数和移动赋值函数,执行逐一移动成员的操作,只有在未声明析构函数、复制函数、移动函数时才会自动生成。
所谓“废弃”(deprecated, or obsolescent) 的语言特性是指那些在目前的C++标准下仍然是合法的,但在新版标准中很有可能被去除或修改(或保留)的语言特性。
好的C++程序员要熟悉哪些特性是废弃的,并在实际编码时尽量避免使用这些特性。
此外,声明一个模板成员函数不会阻止编译器生成这些特殊函数,即如果Widget
里声明了模板成员函数:
class Widget {...template <typename T>Widget(const T& rhs);template <typename T>Widget& operator=(const T& rhs);
};
并不会阻止编译器继续生成特殊成员函数,即使这两个模板函数在T
为Widget
时函数签名与自动生成的复制函数完全相同。
要点速记
- 特种成员函数是指那些C++会自行生成的成员函数:默认构造函数、析构函数、复制操作,以及移动操作。
- 移动操作仅当类中未包含用户显式声明的复制操作、移动操作和析构函数时才生成。
- 复制构造函数仅当类中不包含用户显式声明的复制构造函数时才生成,如果该类声明了移动操作则复制构造函数将被删除。复制赋值运算符仅当类中不包含用户显式声明的复制赋值运算符才生成,如果该类声明了移动操作则复制赋值运算符将被删除。在已经存在显式声明的析构函数的条件下,生成复制操作已经成为了被废弃的行为。
- 成员函数模板在任何情况下都不会抑制特种成员函数的生成。
第4章 智能指针
裸指针缺陷:
- 裸指针的声明没办法告诉我们它指向的是单个对象还是数组。
- 没办法知道用完这个裸指针后要不要销毁它指向的对象。
- 没办法知道怎么销毁这个裸指针,是用
operator delete
还是什么其它自定义的途径。 - 参照原因1,没办法知道该用
delete
还是delete[]
,如果用错了,结果未定义。 - 很难保证调用路径上恰好销毁这个指针一次,可能内存泄露,也可能double free。
- 通常没办法知道裸指针是否是空悬指针,即是否指向已销毁的对象。
智能指针就是来解这些问题的,它们用起来像裸指针,但能避免以上的很多陷阱。
智能指针最终会析构所托管的资源,不过也有一些例外:大部分源自非正常程序终止。
如果一个异常传播开去,影响到某个线程的主函数(例如,初始化该程序的线程的main函数),或者违反了noexcept异常规格,则局部对象可能不会析构。
而如果调用了std:abort或某个退出函数(即std::_xit.std::exit或std::quickexit)的话,局部对象肯定不会析构。
条款18:使用std::unique_ptr管理具备专属所有权的资源
std::unique_ptr
特点:
专属所有权,只移动型别,不允许复制(有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr)。
unique_ptr<int> clone(int p){unique_ptr<int> ret(new int(p));//...return ret; }
默认情况下,
std::unique_ptr
与裸指针一样大,且对于绝大多数操作来说(包括解引用),它们编译后的指令都是完全一样的。
一个例子是工厂函数。假设有一个基类和三个派生类,通过一个工厂函数来返回某个派生类的std::unique_ptr
,这样调用方就不需要费心什么时候销毁返回的对象了:std::unique_ptr
会负责这件事。
class Investment {...};
class Stock: public Investment {...};
class Bond: public Investment {...};
class RealEstate: public Investment {...};template <typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params);auto pInvestment = makeInvestment(args);
默认地,析构通过delete运算符实现。但在构造std::unique_ptr
时,我们还可以传入一个自定义的销毁器,它会在std::unique_ptr
析构时被调用,来销毁对应的资源。比如我们可能不想只是delete obj
,还想输出一条日志:
//可变参数模板
template <typename... Ts>
auto makeInvestment(Ts&&... params) {auto delInvmt = [](Investment* pInvestment) {makeLogEntry(pInvestment);delete pInvestment;};std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);if (...) {pInv.reset(new Stock(std::forward<Ts>(params)...));}...return pInv;
}
解释:
delInvmt
是自定义的销毁器,在std::unique_ptr
析构时,自定义的销毁器会来完成释放资源必需的操作。这里用lambda表达式来实现delInvmt
,不仅更方便,性能还更好。自定义的销毁器的类型必须与
std::unique_ptr
的第二个模板参数相同,因此我们要用decltype(delInvmt)
来声明std::unique_ptr
。我们在创建具体的对象时,使用了
std::forward
将makeInvestment
的所有参数完美转发给对应的构造函数。
在使用默认析构器(即delete运算符)的前提下,你可以合理地认为std::unique_ptr和裸指针尺寸相同。自定义析构器现身以后,情况便有所不同了。
- 若析构器是函数指针,那么std::unique_ptr的尺寸一般会增加一到两个字长(word)。
- 而若析构器是函数对象,则带来的尺寸变化取决于该函数对象中存储了多少状态。无状态的函数对象(例如,无捕获的lambda表达式)不会浪费任何存储尺寸。这意味着当一个自定义析构器既可以用函数,又可以用无捕获的lambda表达式来实现时,lambda表达式是更好的选择。
std::unique_ptr
以两种形式提供:std::unique_ptr<T>
和std::unique_ptr<T[]>
,其中前者没有定义operator[]
,后者在默认析构时会调用delete[]
,且没有定义operator*
和operator->
。但在用到std::unique_ptr<T[]>
的地方,你可能需要想一下是不是std::vector
、std::array
、std::string
更合适。了解即可。
std::unique_ptr
另一个吸引人的地方在于,它可以作为std::shared_ptr
的构造参数,因此上面的工厂函数返回std::unique_ptr
就再正确不过了:调用者可以根据自己对所有权的需求来决定用std::unique_ptr
还是std::shared_ptr
,反正都支持。
std::shared_ptr<Investment> sp = makeInvestment( arguments );
要点速记
- std::unique-ptr是小巧、高速的、具备只移型别的智能指针,对托管资源实施专属所有权语义。
- 默认地,资源析构采用delete运算符来实现,但可以指定自定义删除器。有状态的删除器和采用函数指针实现的删除器会增加std::unique-ptr型别的对象尺寸。
- 将std::unique_ptr转换成std::shared_ptr是容易实现的。
条款19:使用shared_ptr管理具备共享所有权的资源
std::shared_ptr
内部有引用计数,被复制时,引用计数+1,有std::shared_ptr
析构时,引用计数-1,当引用计数为0时,析构持有的对象。
引用计数的存在会带来以下性能影响:
std::shared_ptr
的大小是裸指针的两倍:一个指针指向持有的对象,一个指针指向引用计数。- 引用计数的内存必须动态分配。因为
std::shared_ptr
的引用计数必须要独立在对象外面。用std::make_shared
能避免这次单独的内存分配。 - 引用计数的递增和递减必须是原子操作,因为在不同的线程中可能存在并发的读写器。因此引用计数的读写成本比较高。
注意,不是所有std::shared_ptr
的构造都会增加引用计数,移动构造就不会。因此移动构造一个std::shared_ptr
要比复制一个更快。
与std::unique_ptr
类似,std::shared_ptr
的默认销毁动作也是delete
,且也可以接受自定义的销毁器。但与std::unique_ptr
不同的是,std::shared_ptr
的销毁器类型不必作为它的模板参数之一:
auto loggingDel = [](Widget* pw) {makeLogEntry(pw);delete pw;
};std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);std::shared_ptr<Widget> spw(new Widget, loggingDel);
因此std::shared_ptr
要比std::unique_ptr
使用更灵活,比如不同销毁器的std::shared_ptr
可以放到同一个容器中,而std::unique_ptr
则不可以。
std::unique_ptr
的自定义删除器会导致智能指针的大小变大,而这个问题在shared_ptr
身上是不存在的,究其原因是因为对于shared_ptr来说,自定义删除器的信息和引用计数的信息是放在一起的称之为控制块,在shared_ptr中有一个指针指向这个控制块,这个指针的大小是不会因为自定义删除器而改变的:
一个对象的控制块由创建首个指涉到该对象的std::shared_ptr的函数来确定。控制块的创建遵循了以下规则:
std::make_shared
总会创建一个控制块,并且会和实际对象放在同一块内存分配空间中,节约了内存分配次数。- 通过一个独享所有权的指针(如
std::unique_ptr
)创建出的std::shared_ptr
总会创建一个控制块。 - 通过裸指针创建的
std::shared_ptr
会创建控制块。
一个推论就是:通过一个裸指针变量创建两个std::shared_ptr
,会创建两个控制块,进而导致这个裸指针会被析构两次!
从中我们可以得到两个教训:
- 不要直接用裸指针构造
std::shared_ptr
,尽量用std::make_shared
。当然在需要自定义的销毁器时不能用std::make_shared
。 - 非要用裸指针构造
std::shared_ptr
的话,尽量直接new,不要传入已有的裸指针变量。
奇异递归模板模式enable_shared_from_this
有一种场景下,我们可能无意间创建了对应同一指针的两个控制块。
std::vector<std::shared_ptr<Widget>> processedWidgets;
processedWidgets
表示所有处理过的Widget
。进一步假设Widget
有一个成员函数process
:
class Widget {
public:...void process() {...processedWidgets.emplace_back(this); // this is wrong!}
};
如果被调用process
的Widget
对象本身就被std::shared_ptr
所管理,上面那行代码会导致它又创建了一个新的控制块。这种情况下我们应该令Widget
继承自std::enable_shared_from_this
,它允许创建一个指向自身控制块的std::shared_ptr
:
class Widget: public std::enable_shared_from_this<Widget> {
public:...void process() {...processedWidgets.emplace_back(shared_from_this());}
};
这种基类是用派生类特化的模板的模式,称为“奇异递归模板模式”(The Curiously Recurring Template Pattern, CRTP)。
在调用shared_from_this
,它会寻找指向自身的控制块。如果此时这个对象没有被任何一个std::shared_ptr
持有,也就没有控制块,那么shared_from_this
的行为是未定义的(C++17 前为未定义行为,C++17 起抛出 std::bad_weak_ptr 异常)。
因此往往继承自std::enable_shared_from_this
的类都会把构造函数设为private
,并且只允许用户通过调用返回std::shared_ptr的工厂函数来创建对象:
class Widget: public std::enable_shared_from_this<Widget> {
public:template <typename... Ts>static std::shared_ptr<Widget> create(Ts&&... params);...void process();
private:... // ctors
};
std::shared_ptr开销
一个控制块通常只有几个word大,但其中会用到继承,甚至还有虚函数(用以确保所指涉到的对象被适当地析构)。这也意味着使用std::shared_ptr
也会有调用虚函数的开销。
举例:假设B继承A,则std::shared_ptr<A> p = std::make_shared<B>()
成立,std::make_shared<B>()
的结果可以隐式转换到 std::shared_ptr<A>
。
因为std::make_shared<B>()
会“记住”对象的类型,从它的结果复制而来的shared_ptr
也都会共享这一类型信息。所以最后调用delete时总是会用最初构造时所“记住”的类型。
此外,需要注意:
- 资源和指涉到它的std::shared_ptr之间的规约是“至死方休”型的。不能离异,不能废止,不能免除。
- unique_ptr可以提升到shared_ptr(通过移动构造),但反之不可以。
std::shared_ptr
不支持数组,不过也没有必要支持。
要点速记
- std::shared_ptr提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收。
- 与std::unique_ptr相比,std::shared_ptr的尺寸通常是裸指针尺寸的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作。
- 默认的资源析构通过delete运算符进行,但同时也支持定制删除器。删除器的型别对std::shared_ptr的型别没有影响。
- 避免使用裸指针型别的变量来创建std::shared_ptr指针。
条款20:对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr
有时候我们需要一种类似std::shared_ptr
,但又不参与这个共享对象的所有权的智能指针。这样它就需要能知道共享对象是否已经销毁了。这就是std::weak_ptr
,不是单独存在,是配合std::shared_ptr
使用的。
通常std::weak_ptr
都是通过std::shared_ptr
构造的,但它不会影响std::shared_ptr
的引用计数:
auto spw = std::make_shared<Widget>(); // ref count is 1
...
std::weap_ptr<Widget> wpw(spw); // ref count remains 1
...
spw = nullptr; // ref count toes to 0, wps 空悬
可以用expired()
来检测std::weak_ptr
指向的对象是否有效:
if (wpw.expired()) ...
另一个常用的操作是lock()
,它能原子地检测对象是否有效,以及返回这个对象的std::shared_ptr
:
std::shared_ptr<Widget> spw = wpw.lock(); // if wpw's expired, spw is null
与之类似的操作是用std::weak_ptr
构造一个std::shared_ptr
:
std::shared_ptr<Widget> spw(wpw);
区别在于,如果wpw
已经失效了,这次构造会抛std::bad_weak_ptr
的异常。
用途一 :带有cache的工厂函数
如果工厂函数std::unique_ptr<const Widget> loadwidget(WidgetID id)
成本高昂,则需要使用cache来缓存已经创建过的对象,但如果缓存太多对象会造成性能问题,一个合理的优化就是在元素无人使用后销毁。
这时最好的办法就是使用使用哈希映射存储weak_ptr。
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) {static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;auto objPtr = cache[id].lock();if (!objPtr) {objPtr = loadWidget(id);cache[id] = objPtr;}return objPtr;
}
如果存放shared_ptr
会导致对象的生存周期被拉长了,和cache一样长。
用途二 :观察者模式
第二个例子是设计模式中的“观察者模式”。它的一种典型实现是每个主题对象持有一组观察者的指针,每当主题对象有状态变化时依次通知每个观察者。这里主题对象不需要控制观察者的生命期,但需要知道观察者的指针是否还有效。用std::weak_ptr
就可以非常自然的实现出这样的特性。
用途三:解决循环引用问题
当A和C都持有B的std::shared_ptr
时,如果B也需要持有A的某种指针,该持有什么?
- 裸指针:如果A析构了,但C还在,B也就还在,此时B持有的A的裸指针就成了空悬指针,不好。
std::shared_ptr
:这样A与B就形成了循环依赖,引用计数永远不为0,永远不可能析构,。std::weak_ptr
:唯一的好选择。
不过使用std::weak_ptr来打破std::shared_ptr引起的可能环路不是特别常见的做法。在类似树这种严格继承谱系式的数据结构中,子结点通常只被其父节点拥有,当父节点被析构后,子结点也应被析构。因此,从父节点到子节点的链接可以用std::unique_ptr来表示,而由子节点到父节点的反向链接可以用裸指针安全实现,因为子节点的生存期不会比父节点的更长,所以不会出现子节点去提领父节点空悬指针的风险。
std::weak_ptr
的价值在于:在生命期不明确的场景,可以知道对象是否还有效。
在效率方面,std::weak_ptr
的大小与std::shared_ptr
是相同的,它们使用相同的控制块,区别在于std::weak_ptr
不会影响控制块中的引用计数,只会影响其中的弱引用计数。
要点速记
- 使用std::weakptr来代替可能空悬的std::shared_ptr。
- std::weak_ptr可能的用武之地包括缓存,观察者模式,以及避免std::shared_ptr循环引用。
条款21:优先选用std::make_unique
和std::make_shared
而非直接new
make系列函数有三个:std::make_unique
、std::make_shared
和std::allocate_shared
。
std::allocate_shared
的行为和std::make-shared
一样,只不过它的第一个实参是个用以动态分配内存的分配器对象。
使用make而非new有许多优点:
优点一:不需要重复写一遍类型
auto upw1(std::make_unique<Widget>());
std::unique_ptr<Widget> upw2(new Widget);auto spw1(std::make_shared<Widget>());
std::shared_ptr<Widget> spw2(new Widget);
重复撰写型别,就违背了软件工程的一个重要原则:代码元余应该避免。源代码中的重复会增加编译遍数,导致臃肿的目标代码。
优点二:异常安全性
有函数调用如下:
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!
上面这行代码有内存泄漏的风险,为什么?根据C++标准,在processWidget
的参数求值过程中,我们只能确定下面几点:
new Widget
一定会执行,即一定会有一个Widget
对象在堆上被创建。std::shared_ptr<Widget>
的构造函数一定会执行。computePriority
一定会执行。
new Widget
的结果是std::shared_ptr<Widget>
构造函数的参数,因此前者一定早于后者执行。除此之外,编译器不保证其它操作的顺序,即有可能执行顺序为:
new Widget
- 执行
computePriority
- 构造
std::shared_ptr<Widget>
如果第2步抛异常,第1步创建的对象还没有被std::shared_ptr<Widget>
管理,就会发生内存泄漏。
如果这里我们用std::make_shared
,就能保证new Widget
和std::shared_ptr<Widget>
是一起完成的,中间不会有其它操作插进来,即不会有不受智能指针保护的裸指针出现:
processWidget(std::make_shared<Widget>(), computePriority()); // no potential resource leak
优点三:避免多次内存分配,提升性能
std::shared_ptr<Widget> p(new Widget);
这行代码中进行了两次内存分配操作,一次是分配对象的内存,另外一次则是分配控制块所需要的内存。
而make_shared
会一次性分配对象和控制块所需要的内存。
不过,make函数也有一些缺点,有些情况无法代替new。
缺点一:无法自定义删除器
第一个缺点很明显那就是在使用make_shared创建的智能指针的时候我们是没办法自定义删除器的。
缺点二:无法使用花括号初始化
make函数初始化时使用了括号初始化,而不是花括号初始化。
比如std::make_unique<std::vector<int>>(10, 20)
创建了一个有着20个值为10的元素的vector
,而不是创建了{10, 20}
这么两个元素的vector
。
这是因为,在make系列函数里,对形参进行完美转发的代码使用的是圆括号而非大括号。
如果想实现花括号语意,替代方法:
使用
new
的方式来创建。使用auto型别推导,从大括号初始化物出发,创建一个std::initializer-list对象(参见条款2),然后将auto创建的对象传递给make系列函数:
//创建std::initializer list型别的对象 auto initList = {10, 20};//利用std::initializerlist型别的构造函数创建std::vector auto spv = std::make_shared<std::vector<int>>(initList);
对于std::unique_ptr而言,其make系列函数仅在这两种场景下会产生问题。而对于std::shared_ptr和其make还会有下面的问题。
缺点三:延长了对象销毁的时机
对象和控制块分配在一块内存上,减少了内存分配的次数,但也导致对象和控制块占用的内存只能一起回收掉。
控制块包含着std::weak_ptr
的引用计数:弱计数。如果还有std::weak_ptr
存在,控制块就要在,对象占用的内存也没办法回收。如果对象比较大,且最后一个std::shared_ptr
的析构和最后一个std::weak_ptr
的析构之间的时间间隔不能忽略,那么这种开销是不可忽视的。
使用new构造的对象不会有这个问题,对象内存在最后一个std::shared_ptr
析构时被释放。
如果我们因为前面这三个缺点而不能使用std::make_shared
,那么我们要保证,智能指针的构造一定要单独一个语句来保证异常安全性:
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority());
但这么写还不够高效,这里我们明确知道spw
就是给processWidget
用的,那么可以使用std::move
,将其转为右值,来避免对引用计数的修改和对智能指针的拷贝:
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(std::move(spw), computePriority());
move后spw已指向空指针,不会有析构两次的问题。
要点速记
- 相比于直接使用new表达式,make系列函数消除了重复代码、改进了异常安全性,并且对于std::make shared和std::allcoated shared而言,生成的目标代码会尺寸更小、速度更快。
- 不适于使用make系列函数的场景包括需要定制删除器,以及期望直接传递大括号初始化物。
- 对于std::shared_ptr,不建议使用make系列函数的额外场景包括:①自定义内存管理的类;②内存紧张的系统、非常大的对象、以及存在比指涉到相同对象的std::shared ptr生存期更久的std::weakptr。
条款22:使用Pimpl惯用法时,在实现文件中定义特殊成员函数
C++中常用Pimpl惯用法来保护头文件,避免在修改类的数据成员的时候导致依赖这个头文件的程序也需要重新编译,降低编译依赖性,常常被人们称为编译防火墙。
Pimpl惯用法的第一部分,是声明一个指针型别的数据成员,指涉到一个非完整型别;第二部分是动态分配和回收持有从前在原始类里的那些数据成员的对象,而分配和回收代码则放在实现文件中。
// widget.h
class Widget {public:Widget();~Widget();...private:struct Impl;Impl *pImpl;
};// widget.cpp
struct Widget::Impl {std::string name;std::vector<double> data;Gadget g1, g2, g3;
};Widget::Widget() : pImpl(new Impl)
{}Widget::~Widget()
{ delete pImpl; }
注意头文件出现的Impl
类型只是声明,没有定义,称为“不完整类型”,这样的类型只支持很少的操作,其中包括了我们需要的:声明一个不完整类型的指针。
有了智能指针后,最好用std::unique_ptr
代替new
和delete
,不过并不会一帆风顺。
// widget.h
class Widget {public:Widget();...private:struct Impl;std::unique_ptr<Impl> pImpl;
};// widget.cpp
struct Widget::Impl {std::string name;std::vector<double> data;Gadget g1, g2, g3;
};Widget::Widget() : pImpl(std::make_unique<Impl>())
{}
编译也没问题,但在用户要用时,会出现错误。
#include "widget.h"Widget w; // error!
该问题是由w被析构时(例如,离开作用域时)所生成的代码引起的。在那一时刻,析构函数被调用,在使用了std::unique_ptr
的类定义里,我们未声明析构函数,因为无须为其撰写代码。根据编译器生成特种成员函数的基本规则(参见条款17),编译器为我们生成了一个析构函数。在该析构函数内,编译器会插入代码来调用widget的数据成员pImpl。pImpl是个std::unique_ptr<widget::Impl>
型别的对象,即一个使用了默认析构器的std::unique_ptr
,默认析构器是在std::unique_ptr
内部使用delete运算符来针对裸指针实施析构的函数。然而,在实施delete运算符之前,典型的实现会使用C++11中的static_assert去确保裸指针未指涉到非完整型别。(static_assert为编译器assert)
这么一来,当编译器为Widget w的析构函数产生代码时,通常就会遇到一个失败的static_assert,从而导致了错误信息的产生。这个错误信息和w被析构的位置有关,因为Widget的析构函数与其他编译器产生的特种成员函数一样,基本上隐式inline的。
为解决这一问题,只需保证在生成析构std::unique_ptr<widget::Impl>
代码处,Widget::Impl
是个完整型别即可。只要型别的定义可以被看到,它就是完整的。而Widget::Impl
的定义位于widget.cpp中的。因此,成功编译的关键在于让编译器看到widget的析构函数的函数体(即,编译器将要生成代码来析构std::unique_ptr型别的数据成员之处)的位置在widget.cpp内部的widget::Impl定义之后。
// widget.h
class Widget {
public:Widget();~Widget();...
private:struct Implstd::unique_ptr<Impl> pImpl;
};// widget.cpp
...
Widget::Widget(): pImpl(std::make_unique<Impl>())
{}Widget::~Widget() {}
//或者 Widget::~Widget() = default;
移动函数
根据条款17,自定义的析构函数会阻止编译器生成移动构造函数和移动赋值函数,因此如果你想要Widget
有移动的能力,就要自己实现。并且,移动构造、移动赋值同样需要用到析构,因此也必须放在实现文件中定义,使用默认即可。
复制函数
复制函数不能像移动函数一样使用默认函数,因为①std::unique_ptr
不支持复制;②需要的是深拷贝,而不是浅拷贝。因此需要自己实现深拷贝。
最终实现:
// widget.h
class Widget {public:Widget();~Widget();Widget(const Widget& rhs);Widget& operator=(const Widget& rhs);Widget(Widget&& rhs); Widget& operator=(Widget&& rhs); private:struct Impl;std::unique_ptr<Impl> pImpl;
};
// widget.cpp
#include <string>
#include <vector>
#include "gadget.h"
#include "widget.h"struct Widget::Impl {std::string name;std::vector<double> data;Gadget g1, g2, g3;
};Widget::Widget() : pImpl(std::make_unique<Impl>())
{}Widget::~Widget() = default;Widget::Widget(const Widget& rhs) : pImpl(std::make_unique<Impl>(*rhs.pImpl)) {}Widget& Widget::operator=(const Widget& rhs) {*pImpl = *rhs.pImpl;return *this;
}Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
如果你把pImpl
的类型改为std::shared_ptr<Impl>
,你会发现上面所有这些注意事项,都不见了。你不需要手动实现析构函数、移动函数、构造函数,程序编译仍然可以通过(但是拷贝函数默认为浅拷贝)。
这种差异来自于std::unique_ptr
和std::shared_ptr
对自定义删除器的支持方式不同。std::unique_ptr
的目标是从体积到性能上尽可能与裸指针相同,因此它将销毁器类型作为模板参数的一部分,这样实现起来更高效,代价是欲使用编译器生成的特种函数(例如,析构函数或移动操作),就要求其指涉到的型别必须是完整型别。而std::shared_ptr
没有这种性能上的要求,因此它的删除器不是模板参数的一部分,性能会有一点点影响,但好处是在使用编译器生成的特种函数时,其指涉到的型别却并不要求是完整型别。
std::shared_ptr
在构造时就把销毁器保存在了控制块中,之后即使传递到了不知道元素完整类型的地方,它仍然能调用正确的销毁器来销毁元素指针。而std::unique_ptr
是依靠模板参数提供的类型信息来进行销毁,因此必须要知道元素的完整类型。
即便如此,unique_ptr仍然是最好的选择,因为widget和widget::Impl这样的类之间的关系是专属所有权。除非在其他情景下——存在共享所有权的情景下,从而shared_ptr成为合适的设计选项,此时不需要自行定义一系列函数。
要点速记
- Pimpl惯用法通过降低类的客户和类实现者之间的依赖性,减少了构建遍数。
- 对于采用std::unique_ptr来实现的pImpl指针,须在类的头文件中声明特种成员函数,但在实现文件中实现它们。即使默认函数实现有着正确行为,也必须这样做。
- 上述建议仅适用于std::unique_ptr,但并不适用std::shared_ptr。
第5章 右值引用、移动语义和完美转发
简介
- 移动语义使得编译器得以使用不那么昂贵的移动操作,来替换昂贵的复制操作。移动语义也使得创建只移型别对象成为可能,这些型别包括std::unique_ptr、
std::future和std::thread等。 - 完美转发使得人们可以撰写接受任意实参的函数模板,并将其转发到其他函数,目标函数会接受到与转发函数所接受的完全相同的实参。
右值引用就是把这两种看起来截然不同的功能联系起来的纽带,它是实现这两者的基础。
在本章的各节中,很重要的一点是牢记:形参永远是一个左值,即使它的类型是右值引用,即:
void f(Widget&& w);
w是左值,即使它的类型是右值引用。
条款23:理解std::move
和std::forward
实际上,std::move
不移动任何东西,std::forward
也不转发任何东西。它们在运行期不做任何事情,它们不产生一丁点可执行的代码。
std::move
和std::forward
仅仅是进行强制类型转换的函数。
std::move
无条件地将它的参数转换为右值,而std::forward
只在某些条件满足时进行这种转换。
std::move
C++14中std::move
的实现:
tmeplate <typename T>
decltype(auto) move(T&& param) {using ReturnType = remove_reference_t<T>&&;return static_cast<ReturnType>(param);
}
remove_reference_t用来去除引用,防止引用折叠。
std::move
只是将实参强制转换成了右值,为移动构造或移动赋值做铺垫。
在一个对象上应用std::move
就告知了编译器这个对象可以被移动,这就是std::move
得名的原因:它简化了对象是否可移动的表述。
事实上,右值只是通常会被移动。不过即使移动没有成功,也会偷偷的使用拷贝构造完成功能。
右值没有被移动的例子如下:
假设你在写一个表示注解的类,它的构造函数接受包含注解的std::string
参数,并将其复制给一个成员变量。根据Item41,你声明了一个传值的参数:
class Annotation {
public:explicit Annotation(std::string text);...
};
但构造函数里只需要读取text
,根据尽可能用const
的古老传统,你给text
加上了const
:
class Annotation {
public:explicit Annotation(const std::string text);...
};
为了避免复制,你依从Item41的建议,在text
上应用std::move
,产生一个右值:
class Annotation {
public:explicit Annotation(const std::string text): value(std::move(text)){...}...
private:std::string value;
};
上面的代码编译、链接、运行都没问题,只是text
没有移动赋值给value
,它是复制过去的。text
的类型是const string
,因此std::move(text)
产生的类型为const string&&
,因此value
的构造没办法应用移动操作,因为const
还在。
std::string
定义了复制构造函数和移动构造函数:
class string {
public:...string(const string& rhs);string(string&& rhs);...
};
显然const string&&
没办法传给string(string&& rhs)
,但能传给string(const string& rhs)
,因为常量左值引用可以绑定到常量右值(常量左值引用可以绑定到任何值类型:非常量/常量左值/右值)。因此value
的构造应用了复制构造函数,即使参数是右值引用。
这里我们学到两点经验:
- 不要把希望移动的变量声明为
const
。 std::move
不意味着移动任何东西,甚至不保证它转换的对象可移动。它只保证它的转换结果一定是右值。
std::forward
专门配合模板使用,一般不单独使用。
std::forward
与std::move
很类似,只是std::move
是无条件的转换,而std::forward
是有条件的转换。回忆std::forward
的典型用法,是在接受万能引用参数的函数模板中将参数转发给其它函数:
void process(const Widget& lval);
void process(Widget&& rval);template <typename T>
void logAndProcess(T&& param) {auto now = std::chrono::system_clock::now();makeLogEntry("Calling 'process'", now);process(std::forward<T>(param));
}Widget w;
logAndProcess(w); //传入左值
logAndProcess(std::move(w)); //传入右值
我们希望在param
类型为左值引用时调用process(const Widget& lval)
,在param
为右值引用时调用process(Widget&& rval)
。
但是,所有函数形参皆为左值,param亦不例外。他们实际上都会调用左值形参的重载版本。
因此我们需要一种方法在条件满足(logAndProcess
的实参为右值)时将其转换为右值。这就是std::forward
要做的,有条件的转换,即当且仅当它的参数是通过右值初始化时进行转换。
原理
forward何以知晓其实参是否通过右值完成初始化?例如,在上述代码中,forward是如何分辨param是通过左值还是右值完成了初始化的呢?
因为该信息是被编码到logAndProcess的模板形参T中的。该形参被传递给forward后,随即由后者将编码了信息恢复出来。具体的原理细节参见条款28。
对比总结
使用std::move所要传达的意思是无条件地向右值型别的强制型别转换,而使用std::forward则想说明仅仅对绑定到右值的引用实施向右值型别的强制型别转换。
前者是典型地为移动操作做铺垫,而后者仅仅是传递(转发)一个对象到另一个函数,而在此过程中无论该对象原始型别具备左值性(Ivalueness)和右值性(rvalueness),都保持原样。
条款24:区分万能引用和右值引用
“T&&”有两个含义:
- 第一个就是右值引用,它的主要作用是标记一个可以移动的对象;
- 第二个含义则既可能是右值引用也可能是左值引用,即看起来是“T&&”但实际上可能是“T&”。进一步地,“T&&”可能绑定在const或非const、volatile或非volatile对象上。理论上它可以绑定在任何对象上,被称为“万能引用”。
万能引用只发生在两个场景中:
第一个是函数模板:
template <typename T> void f(T&& param);
第二个是
auto
声明:auto&& var2 = var1;
它们的共同点就是需要类型推断。如果不需要类型推断,例如Widget&&
,这就不是普适引用,就只是一个右值引用。
万能引用首先是个引用,所以初始化时必需的。万能引用的初始化式决定了它是右值引用还是左值引:如果初始化式是右值,万能引用就对应到右值引用;如果初始化式是左值,万能引用就对应到左值引用:
template <typename T>
void f(T&& param); // universal referenceWidget w;
f(w); // 左值被传递给f,param的型别是widget&(即一个左值引用)
f(std::move(w)); // 右值被传递给f,param的型别是widget&&(即一个右值引用)
光有类型推断还不足够,万能引用要求引用的声明格式必须是T&&
,而不是std::vector<T>&&
或const T&&
这样的声明。
如果你在模板中看到了一个函数参数为T&&
,也不代表它一定是万能引用,因为这里可能根本不需要类型推断。例如:
template <class T, class Allocator = allocator<T>>
class vector {
public:void push_back(T&& x);...
};
这里push_back
的参数x
不是普适引用,因为编译器会先实例化vector
,之后你就发现push_back
根本没有涉及到类型推断。例子:
class vector<Widget, allocator<Widget>> {
public:void push_back(Widget&& x);...
};
与之相反,emplace_back
应用了类型推断:
template <class T, class Allocator = allocator<T>>
class vector {
public:template <class... Args>void emplace_back(Args&&... args);...
};
args
就是一个普适引用,因为它满足两个条件:
- 它的格式是
T&&
,当然这里是Args&&
。 - 它需要类型推断。
auto&&
必定是万能引用也是相同的原因。它在C++11中出现得越来越多,在C++14中出现得更多,因为lambda表达式可以声明auto&&
形参:
//记录任意函数调用所花费的时长
auto timeFuncInvocation =[](auto&& func, auto&&... params) {计时器启动;std::forward<decltype(func)>(func)(std::forward<decltype(params)>(params)...);计时器停止并记录流逝时间;};
其中,params
是0或多个普适引用,可以绑定到任意数量的任意类型上。
实际上,万能引用是一种抽象(或者可以说是谎言),其底层机理是“引用折叠”,见条款28。
要点速记
- 如果函数模板形参具备T&&型别,并且T的型别系推导而来,或如果对象使用auto&&声明其型别,则该形参或对象就是个万能引用。
- 如果型别声明并不精确地具备type&&的形式,或者型别推导并未发生,则type&&就代表右值引用。
- 若采用右值来初始化万能引用,就会得到一个右值引用。若采用左值来初始化万能引用,就会得到一个左值引用。
条款25:将std::move
用于右值引用,std::forward
用于万能引用
右值引用就表示对应的对象可以被移动,对于那些可以被移动的对象,我们可以用std::move
来让其它函数也能利用上它们的右值性:
class Widget {
public:Widget(Widget&& rhs): name(std::move(rhs.name)), p(std::move(rhs.p)){...}...
private:std::string name;std::shared_ptr<SomeDataStructure> p;
};
而万能引用则既可能代表一个左值,又可能代表一个右值,只有在它代表右值时,我们才能将它cast成右值,这就是std::forward
做的:
class Widget {
public:template <typename T>void SetName(T&& newName) {name = std::forward<T>(newName);}...
};
有人会说setName
不应该声明一个普适引用参数,因为普适引用不能带const。我们可以声明两个重载函数来代替上面的版本:
class Widget {
public:void setName(const std::string& newName) {name = newName;}void setName(std::string&& newName) {name = std::move(newName);}
};
这样的代码能工作,但有缺点:
首先,需要维护的代码量变多。
其次,可能有运行时性能损失。
考虑下面的调用:
w.setName("Adela Novak");
在万能引用版本的
setName
中,”Adela Novak”会被传到setName
中(T被推导为char*),直接用于构造name
,中间没有临时std::string
产生。而在重载版本的
setName
中,”Adela Novak”会先用于构造一个临时的std::string
,再传给右值版本的setName
,再通过std::move
赋值给name
,然后临时std::string
析构,整个过程多了一次std::string
的构造和析构。在不同的场景下这种性能差异可能有很大区别,但总的来说普适引用版本有机会比重载版本有更小的开销。
重载版本的最大问题,是代码的扩展性太差。
setName
只有一个参数,只需要两个重载版本,那如果有N个普适引用参数的函数呢?我们需要2N个重载版本,这显然不现实。更不用说变长参数了。
场景一
有些场景中,你可能会用到右值引用或普适引用的一个特性:它本身是个左值。这样我们在不想移动它时,直接使用这个引用本身,而在最终想要移动它们时,再用std::move
(对于右值引用)或std::forward
(对于普适引用)去移动它们。
template <typename T>
void setSignText(T&& text) {sign.setText(text); // use text, but don't modify itauto now = std::chrono::system_clock::now(); signHistory.add(now, std::forward<T>(text)); // conditionally cast text to rvalue
}
如果上面的text
类型是右值引用,就可以用std::move
。有些时候我们可能需要用std::move_if_noexcept
来替代std::move
,见条款14。
场景二
如果有一个按值返回的函数,其返回的对象是右值引用或普适引用,那么也可以用std::move
或std::forward
来获得更好的性能:
Matrix operator+(Matrix&& lhs, const Matrix& rhs) {lhs += rhs;return std::move(lhs);
}
如果上面我们写的是return lhs
,lhs
是个左值这一事实会强迫编译器将其复制入返回值存储位置。
如果Matrix
不支持移动,用std::move
也不会有副作用。等到Matrix
支持移动了,上面的代码马上就能享受到性能的提升。
std::forward
也有类似的用法:
template <typename T>
Fraction reduceAndCopy(T&& frac) {frac.reduce();return std::forward<T>(frac);
}
特殊情况
对于按值返回的函数,如果返回的对象是个local对象,有些人可能会想到用std::move
来避免复制:
// original version
Widget makeWidget() {Widget w;...return w;
}// some smart version
Widget makeWidget() {Widget w;...return std::move(w);
}
但这么做是错的!
因为编译器通常会进行“返回值优化(RVO)”,即编译器会在返回一个local对象时,如果函数的返回类型就是值类型,那么编译器可以直接将这个local对象构造在接收函数返回值的对象上,省掉中间的复制过程。换句话说,在RVO的帮助下,直接返回这个local对象要比返回它的右值更高效。
C++98中RVO只是一种优化,编译器可做可不做,我们不能有太高的预期。但C++11标准规定了这种场景下,编译器要么应用RVO优化,彻底省掉这次复制,要么返回这个local对象的右值。因此在C++11后,如果编译器没有进行RVO,上面的第一种写法和第二种写法是等效的。
上述情况与按值传递的函数形参类似。它们作为函数返回值时,不适合实施复制省略,但编译器必须在其返回时作为右值处理。以结果论,如果你的代码看起来是这样的:
Widget makewidget(Widget w) {//按值传递的形参,与函数返回值相同...return w;
}
但是编译器必须处理上面这段代码,以使它们与以下代码等价:
Widget makewidget(Widget w){//按值传递的形参,与函数返回值相同...return std::move(w);
}
这意味着,针对函数中按值返回的局部对象实施std::move的操作,不能给编译器帮上忙(如果不执行复制省略,就必须将局部对象作为右值处理,效果一样),却可能帮倒忙(可能会排除掉RVO的实施机会)。
仅有一种适合于针对局部变量实施std::move的情况:将其传递给某个函数,并且你确定自己不再会使用该变量。
要点速记
- 针对右值引用的最后一次使用实施std::move,针对万能引用的最后一次使用实施std::forward。
- 作为按值返回的函数的右值引用和万能引用,依上一条所述采取相同行为。
- 若局部对象可能适用于返回值优化,则请勿针对其实施std::move或std::forward。
条款26:避免将万能引用作为重载候选型别
重载万能引用会带来麻烦。麻烦的根源在于:根据C++的重载决议规则,万能引用版本总会被优先匹配。
为了性能上的考虑,logAndAdd
采用了普适引用作为参数类型。然后我们添加一个重载版本:
std::string nameFromIdx(int idx);
void logAndAdd(int idx) {auto now = std::chrono::system_clock::now();log(now, "logAndAdd");names.emplace(nameFromIdx(idx));
}std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");logAndAdd(22);
还是正常的。
short nameIdx;
...
logAndAdd(nameIdx); // error!
这次logAndAdd
匹配到了普适引用版本,而不是int
版本!
在这次重载决议中,short
到万能引用是一次完美匹配,而short
到int
却是一次提升匹配,因此万能引用版本更优先。
形参为万能引用的函数,是C++中最贪婪的。它们会在具现过程中,和几乎任何实参型别都会产生精确匹配(条款30描述了几种例外)。这就是为何把重载和万能引用这两者结合起来几乎总是馒主意:一旦万能引用成为重载候选,它就会吸引走大批的实参型别,远比撰写重载代码的程序员期望的要多。
类的构造函数如果重载了万能引用,情况会变得更糟:
class Person {
public:template <typename T>explicit Person(T&& n): name(std::forward<T>(n)) {}explicit Person(int idx): name(nameFromIdx(idx)) {}...
private:std::string name;
};
上面logAndAdd
出现的问题在Person
的构造函数中同样会出现。另外,根据条款17,某个类有模板构造函数不会阻止编译器为它生成复制和移动构造函数,即使这个模板构造函数可以实例化为与复制或移动构造函数相同的样子。因此Person
中的构造函数实际上有4个:
class Person {
public:template <typename T>explicit Person(T&& n): name(std::forward<T>(n)) {}explicit Person(int idx): name(nameFromIdx(idx)) {}Person(const Person& rhs);Person(Person&& rhs);...
private:std::string name;
};
这其中的匹配规则对正常人来说都很反直觉。比如:
Person p("Nancy");
auto cloneOfP(p); // won't compile!
在cloneOfP
的构造中,我们直觉上会认为调用的是Person
的复制构造函数,但实际上匹配到的却是普适引用版本。
编译器的理由如下:cloneOfP
的构造参数是一个非const左值p
,这会实例化出一个非const左值参数的版本:
class Person {
public:explicit Person(Person& n): name(std::forward<Person&>(n)) {}explicit Person(int idx);Person(const Person& rhs);...
};
p
到复制构造函数的参数需要加一个const,而到Person&
版本则是完美匹配。
假如我们将p
改为const对象,即const Person p("Nancy")
,那么情况又不一样了,这回模板参数变为const Person&
:
class Person {
public:explicit Person(const Person& n);Person(const Person& rhs);...
};
我们得到了两个完全相同的完美匹配的重载版本,调用有歧义,编译器无法决定用哪个,因此还是会报错。
在有继承的时候,情况更糟了:
class SpecialPerson: public Persion {
public:SpecialPerson(const SpecialPerson& rhs) // copy ctor: calls Person forwarding ctor!: Person(rhs){...}SpecialPerson(SpecialPerson&& rhs) // move ctor: calls Person forwarding ctor!: Person(rhs){...}
};
SpecialPerson
的两个构造函数都调用了Person
的普适引用版本构造函数。原因是rhs
的类型是const SpecialPerson&
和SpecialPerson&&
,到const Persion&
和Persion&&
总是要进行一次转换的,而到万能引用版本则还是完美匹配。
尽可能避免以把万能引用型别作为重载函数的形参选项。不过,你需要针对绝大多数的实参型别实施转发,只针对某些实参型别实施特殊处理,这时该怎么做呢?下节介绍了很多解决办法。
要点速记
- 把万能引用作为重载候选型别,几乎总会让该重载版本在始料未及的情况下被调用到。
- 完美转发构造函数的问题尤其严重,因为对于非常量的左值型别而言,它们一般都会形成相对于复制构造函数的更佳匹配,并且它们还会劫持派生类中对基类的复制和移动构造函数的调用。
条款27:熟悉将万能引用作为重载候选型别的替代方案
把万能引用型别作为重载函数的形参选项会导致形形色色的问题,独立函数和成员函数(构造函数尤其问题严重)都会。
本条款的目的就是为了解决这个重载问题,有如下方法。
舍弃重载
对于Item26的第一个例子logAndAdd
,一种做法是放弃重载,直接用两个不同的名字,比如logAndAddName
和logAndAddNameIdx
。当然这解不了Item26的第二个例子,即Person
的构造函数:你总不能改构造函数的名字。
通过const T&传递
另一种做法是回到C++98,传递const T&
,也意味着放弃了完美转发。这种方法在效率上是有损失的,但在完美转发和重载之间有矛盾时,损失一些效率来让设计变简单也许更有吸引力一些。
传值
一种不损失效率,又不增加设计复杂度的方法是,直接传值,不传引用。这种设计遵守了条款41的建议——当你知道肯定需要复制形参时,考虑按值传递对象。这里我们只是简单看下Person
类可以怎么实现:
class Person {
public:explicit Person(std::string n): name(std::move(n)) {}explicit Person(int idx): name(nameFromIdx(idx)) {}...
private:std::string name;
};
标签分派(Tag dispatch)
万能引用的问题是,在重载决议中,它几乎总是完美匹配的。我们知道重载决议是在所有参数上发生的,那么如果我们人为的增加一个Tag参数,用Tag参数来匹配,就能避免万能引用带来的问题。
首先是原始版本:
std::multiset<std::string> names;
template <typename T>
void logAndAdd(T&& name) {auto now = std::chrono::system_clock::now();log(now, "logAndAdd");names.emplace(std::forward<T>(name));
}
然后是一个接近正确的版本:
template <typename T>
void logAndAdd(T&& name) {logAndAddImpl(std::forward<T>(name), std::is_integral<T>());
}
这里的问题在于,当实参是左值时,T
会被推导为左值引用,即如果实参类型是int
,那么T
就是int&
,std::is_integral<T>()
就会返回false。这里我们需要把T
可能的引用性去掉:
template <typename T>
void logAndAdd(T&& name) {logAndAddImpl(std::forward<T>(name),std::is_integral<std::remove_reference_t<T>>());
}
然后logAndAddImpl
提供两个特化版本:
template <typename T>
void logAndAddImpl(T&& name, std::false_type) {auto now = std::chrono::system_clock::now();log(now, "logAndAdd");names.emplace(std::forward<T>(name));
}std::string nameFromIdx(int idx);
template <typename T>
void logAndAddImpl(T&& name, std::true_type) {logAndAdd(nameFromIdx(idx));
}
为什么用std::true_type
/std::false_type
而不用true/false
?前者是编译期值,后者是运行时值。我们需要利用的是重载决议(一种编译期现象)来选择正确的logAndAddImpl
重载版本。
注意这里我们都没有给logAndAddImpl
的第二个参数起名字,说明它就是一个Tag。这种方法常用于模板元编程。
重要的是Tag dispatch如何把万能引用和重载结合起来了:通过一个新增的Tag参数,改变原本的重载决议顺序。
对接收万能引用的模板施加限制
Tag dispatch的主旨就是存在一个不重载的函数作为入口,它会加上一个Tag参数,再分发给实现函数。但这种方法也没办法解决Item26中Person
的构造函数遇到的问题。编译器会自动为类生成复制和移动构造函数,因此你没办法完全控制入口。
注意这里:不是说有时候编译器生成的函数会绕过你的Tag dispatch,而是说它们没有保证经过Tag dispatch。这里你需要的是std::enable_if
。
std::enable_if
可以让模板实例只在条件满足时存在。在Person
的例子中,我们希望当传入的参数类型不为Person
时完美转发构造函数才存在。例子(注意语法):
class Person {
public:template <typename T,typename = typename std::enable_if<condition>::type>explicit Person(T&& n);
};
std::enable_if
只影响模板函数的声明,不影响它的实现。这里我们不深究std::enable_if
的细节,只要知道它应用了C++的”SFINAE”特性。
我们要的条件是T
不是Person
,可以用!std::is_same<Person, T>::value
。但这还不够准确,因为由左值初始化而来的普适引用,它的类型会被推断为左值引用,即T&
(参见Item28),而T&
与T
是不同的类型。
事实上我们在比较时需要去掉:
- 引用:
Person
、Person&
、Person&&
都要被认为是Person
。 const
或volatile
:const Person
、volatile Person
、const volatile Person
都要被认为是Person
。
标准库中对应的工具是std::decay
,它会把对象身上的引用和cv特性都去掉。(此外它在处理数组和函数类型时会把它们转为指针类型)。
最终结果:
class Person {
public:template <typename T,typename = typename std::enable_if<!std::is_same<Person,typename std::decay<T>::type>::value>>::type>explicit Person(T&& n);...
};
对于Person
的构造函数,上面的版本已经能解决了:在传入的参数类型为Person
时调用我们希望的复制和移动构造函数,而在其它时候调用完美转发函数。
Item26的最后一个例子是Person
的派生类SpecialPerson
:
class SpecialPerson: public Persion {
public:SpecialPerson(const SpecialPerson& rhs) // copy ctor: calls Person forwarding ctor!: Person(rhs){...}SpecialPerson(SpecialPerson&& rhs) // move ctor: calls Person forwarding ctor!: Person(std::move(rhs)){...}
};
看起来还没解决,原因是std::is_same<Person, SpecialPerson>::value
是false
。我们需要的是std::is_base_of
。注意当T
是自定义类型时,std::is_base_of<T, T>::value
返回true
,而如果T
是内置类型,则返回false
。所以我们需要做的就是把上面版本中的std::is_same
替换为std::is_base_of
:
class Person {
public:template <typename T,typename = typename std::enable_if<!std::is_base_of<Person,typename std::decay<T>::type>::value>>::type>explicit Person(T&& n);...
};
C++14中代码可以省一点:
class Person {
public:template <typename T,typename = std::enable_if_t<!std::is_base_of<Person, std::decay_t<T>>::value>>>explicit Person(T&& n);...
};
还没有结束,最后一个问题:如何区分整数类型和非整数类型。直接看最终版本:
class Person {
public:template <typename T,typename = std::enable_if_t<!std::is_base_of<Person, std::decay_t<T>>::value> &&!std::is_integral<std::remove_reference_t<T>>::value>>explicit Person(T&& n): name(std::forward<T>(n)){...}explicit Person(int idx): name(nameFromIdx(idx)){...}...
private:std::string name;
};
权衡
本节的前3种方法舍弃了重载万能引用的念头,后2种方法则另辟蹊径在重载函数中使用普适引用。这里需要一个权衡。
使用普适引用,从而使用完美转发,效率上更好。但它的缺点是:
- 有些参数类型无法完美转发,参见Item30。
- 如果传入参数不正确,错误信息不好理解。
对于缺点2,我们举个例子。假设我们给Person
的构造参数传入一个char16_t
构成的字符串:
Person p(u"Konrad Zuse");
如果用前3种方法,编译器会报错说”no conversion from const char16_t[12] to int or std::string”。
如果用基于完美转发的方法,编译器在转发过程中不会报错,只有到了用转发的参数构造std::string
时才会报错。这里的报错信息非常难理解。
有时候系统中的转发不止一次,参数可能跨越多层函数最终到达出错位置。这里我们可以用static_assert
来提前发现这类错误:使用std::is_constructible
来判断参数是否可以转发下去。
std::is_constructible<std::string, T>
能在编译期判定某个型别对象能否从另一型别(或另一组型别)的对象(或一组对象)除法完成构造。
class Person {
public:template <typename T,typename = std::enable_if_t<!std::is_base_of<Person, std::decay_t<T>>::value> &&!std::is_integral<std::remove_reference_t<T>>::value>>explicit Person(T&& n): name(std::forward<T>(n)){static_assert(std::is_constructible<std::string, T>::value,"Parameter n can't be used to construct a std::string");...}...
};
不幸的是,在本例中,static assert位于构造函数的函数体内,而转发代码属于成员初始化列表的一部分,位于它之前。在我使用的编译器中,产生自static_assert的漂亮、可读的错误信息仅会在通常的错误信息(那160多行)发生完之后才姗姗来迟。
要点速记
- 如果不使用万能引用和重载的组合,则替代方案包括使用彼此不同的函数名字、传递const T&型别的形参、传值和标签分派。
- 经由std::enableif对模板施加限制,就可以将万能引用和重载一起使用,不过这种技术控制了编译器可以调用到接受万能引用的重载版本的条件。
- 万能引用形参通常在性能方面具备优势,但在易用性方面一般会有劣势。
条款28:理解引用折叠
条款23曾经提及,实参在传递给函数模板时,推导出来的万能引用模板形参会将实参是左值还是右值的信息编码到结果型别中。
template<typename T>
void func(T&& param);
编码机制是直截了当的:如果传递的实参是个左值,T的推导结果就是个左值引用型别;如果传递的实参是个右值,T的推导结果就是个非引用型别。
template <typename T>
void func(T&& param);Widget widgetFactory(); // function returning rvalue
Widget w; // an lvalue
func(w); // T deduced to be Widget&
func(widgetFactory()); // T deduced to be Widget
在func(w)
中,T
的类型是Widget&
,那么func
的原型就是:
void func(Widget& && param);
声明引用的引用是被禁止的,但编译器却可以在特殊的语境中产生引用的引用,模板实例化就是这样的语境之一。
C++有单独的规则来把类型推断中出现的引用的引用转换为单个引用,称为“引用折叠”。折叠规则为:
如果任一引用为左值引用,则结果为左值引用。否则(即两个皆为右值引用),结果为右值引用。
T& & => T&
T& && => T&
T&& & => T&
T&& && => T&&
引用折叠就是std::forward
依赖的关键特性。一个简化的std::forward
实现:
template <typename T>
T&& forward(remove_reference_t<T>& param) {return static_cast<T&&>(param);
}
假设func
的实现中调用了std::forward
:
template <typename T>
void f(T&& fParam) {...someFunc(std::forward<T>(fParam));
}
当f
的实参是Widget
的左值时,T
会被推断为Widget&
,实例化的std::forward
版本就是std::forward<Widget&>
,代入进上面std::forward
的实现得到:
Widget& && forward(typename remove_reference<Widget&>::type param) {return static_cast<Widget& &&>(param);
}
将std::remove_reference<Widget&>::type
代换为Widget
,并应用引用折叠,得到:
Widget&& forward(Widget& param) {return static_cast<Widget&>(param);
}
由此可见,如果普适引用的实参是个左值,将std::forward
应用其上得到的还是个左值。
如果f
的实参是右值,那么T
就是Widget
,对应的std::forward
实现是:
Widget&& forward(Widget& param) {return static_cast<Widget&&>(param);
}
这里没有引用的引用,因此也不涉及引用折叠。函数返回的右值引用会被认为是一个右值,因此最终我们得到了一个右值。
所以说,万能引用并非一种新的引用型别,其实它就是满足了下面两个条件的语境中的右值引用:
- 型别推导的过程会区别左值和右值。T型别的左值推导结果为T&,而T型别的右值则推导结果为T。
- 会发生引用折叠。
引用折叠会在四种场景中发生:
- 模板实例化,也是最常见的场景。
auto
的类型推断。typedef
和别名声明(参见Item9)。decltype
的类型推断。
要点速记
- 引用折叠会在四种语境中发生:模板实例化、auto型别生成、创建和运用typedef和别名声明,以及decltype。
- 当编译器在引用折叠的语境下生成引用的引用时,结果会变成单个引用。如果原始的引用中有任一引用为左值引用,则结果为左值引用。否则,结果为右值引用。
- 万能引用就是在型别推导的过程会区别左值和右值,以及会发生引用折叠的语境中的右值引用。
条款29:假定移动操作不存在、成本高、未使用
许多型别并不能支持移动语义,C++11的编译器只会为没有声明复制操作、移动操作、析构函数的类生成移动函数,还有些类型禁止了移动函数。对于这些没有移动函数的类型,C++11对它们不会有什么帮助。
即使是支持移动的类型,移动带来的收益也没有你想象的大。C++11 STL的所有容器都支持移动,但不是每个容器的移动都很廉价。
大部分STL容器,它的数据都是分配在堆上的因此它的移动就很廉价:仅仅把那个指涉到容器内容的指针从源容器复制到目标容器,尔后把源容器包含的指针置空即可。常数时间复杂度。
但
std::array
的数据是直接分配在栈上的,移动时要移动每个元素。仍然是线性时间复杂度。另一个特殊情况,
std::string
提供了O(1)的移动和O(n)的复制,看起来移动要比复制更快。但很多使用了SSO(small string optimization)的std::string
实现的移动就不一定比复制高效了。采用了SSO以后,“小型”字符串(例如,容量不超过15个字符的字符串)会存储在的std::string
对象内的某个缓冲区内,而不去使用在堆上分配的存储。即使对支持高效移动的类型来说,有些看起来肯定会应用移动的地方最终调用的却是复制。Item14讲到STL的一些容器为了保证强异常安全性,只有在元素类型支持noexcept的移动时才会移动,否则会复制。
总而言之,在这样几个场景中,C++11的移动语义不会给你带来什么好处:
- 没有移动操作:待移动的对象未能提供移动操作。因此,移动请求就变成了复制请求。
- 移动未能更快:待移动的对象虽然有移动操作,但并不比其复制操作更快。
- 移动不可用:移动本可以发生的语境下,要求移动操作不可发射异常,但该操作未加上noexcept声明。
- 源对象是个左值:除了极少数例外(参见条款25中的例子),只有右值可以作为移动操作的源。
建议
- 假定移动操作不存在、成本高、未使用,适合于型别未知的情形(例如,撰写模板的时候)或者不稳定代码。
- 对于那些型别已知,且支持低成本的移动操作的代码,应该使用移动操作替代复制操作。
条款30:熟悉完美转发的失败情形
“转发”的含义是一个函数把自己的形参传递(转发)给另一个函数。
完美转发的含义是我们不仅转发对象,还转发其显著特征:型别、是左值还是右值,以及是否带有const或volatile饰词等。
完美转发必须使用万能引用,因为只有万能引用形参才会将传入的实参是左值还是右值这一信息加以编码。
template<typename... Ts>
void fwd(Ts&&... params) {f(std::forward<Ts>(params)...);
}
给定目标函数f和转发函数fwd,当以某特定实参调用f会执行某操作,而用同一实参调用fwd执行不同的操作,则称完美转发失败:
f(expression);
fwd(expression);
有若干种实参会导致该失败,如下。
大括号初始化式
void f(const std::vector<int>& v);
f({1, 2, 3}); // fine, "{1, 2, 3}" implicitly converted to std::vector<int>
fwd({1, 2, 3}); // error! doesn't compile
原因在于,编译器知道f
的形参类型,所以它知道可以把实参类型隐式转换为形参类型。但编译器不知道fwd
的形参类型,因此需要通过实参进行类型推断。这里完美转发会在发生以下情况时失败:
- 无法推断出
fwd
的某个参数类型。在此情况下,代码无法编译通过。 - 推断出错误类型。这里的“错误”可以是推断出的类型无法实例化
fwd
,也可以是fwd
的行为与f
不同。后者的一个可能原因是f
是重载函数的名字,推断的类型不对会导致调用错误的重载版本。
在fwd({1, 2, 3})
这个例子中,问题在于它是一个“非推导语境(non-deduced context)”,标准规定禁止将括号初始化器传递给未声明为std::initializer_list
的函数模板参数。
解决方案很简单,这里我们应用了Item2中提到的一个auto
特性:会优先推断接收的表达式为std::initializer_list
。
auto il = {1, 2, 3};
fwd(il);
用0
或NULL
作为空指针
见条款8,0
或NULL
会被推导为整形。不要用0
或NULL
作为空指针,用nullptr
。
仅有声明的整型static const成员变量
有这么个普适的规定:不需要给出类中的整型static const成员变量的定义,仅需声明之。
因为编译器会把这些成员直接替换为对应的整数值,从而就不必再为它们保留内存。(g++是这么做的,msvc并非如此)
class Widget {
public:static const std::size_t MinVals = 28;...
}; // no def for MinVals
...
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals);
如果没有任何地方取MinVals
的地址,编译器就没有必要给它安排一块内存,就可以直接替换为整数字面值。否则我们就要给MinVals
一个定义,不然程序会在链接阶段出错。
这里完美转发会有问题:
void f(std::size_t val);f(Widget::MinVals); // fine, treated as 28
fwd(Widget::MinVals); // error! shouldn't link
问题在于fwd
的参数类型是非const引用,这相当于取了MinVals
的地址,因此我们需要给它一个定义:
const std::size_t Widget::MinVals; // in Widget's .cpp file
MSVC中没有该问题,因为即使只有声明,MSVC也会为其保留内存。
重载的函数名字和模板名字
假设f
的参数是一个函数:
void f(int (*pf)(int));
或者
void f(int pf(int));
以及我们有两个重载函数:
int processVal(int value);
int processVal(int value, int priority);
现在我们把processVal
传给f
:
f(processVal);
令人惊讶的是,编译器知道该把processVal
的哪个版本传给f
。但fwd
就不同了:
fwd(processVal); // error! which processVal?
因为fwd
的参数没有类型,processVal
这个名字本身也没能给出一个确定的类型。
模板函数也有这样的问题:
template <typename T>
T workOnVal(T param) {...}fwd(workOnVal); // error! which workOnVal instantiation?
解决方案就是手动指定需要转发的那个重载版本或者实例:
//方法一
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr);
//方法二
fwd(static_cast<ProcessFuncType>(workOnVal));
位域
最后一种完美转发失效的情况是位域,代码如下:
struct IPv4Header {std::uint32_t version:4,IHL:4,DSCP:6,ECN:2,totalLength:16;.....
};void f(std::size_t sz);
IPv4Header h;
f(h.totolLength);
fwd(h.totolLength); //编译失败
上面的代码中位域h.totolLength
被传递给f的时候可以正常工作,当传递给fwd完美转发的时候编译失败,因为C++标准规定一个非const的引用无法引用一个位域字段。这个规定也是有原因的,因为位域可能只是一个int的部分字节,没有办法对其直接取址(C++硬性规定,可以指涉的最小实体是单个char)。
可以传递位域的仅有的形参种类就只有按值传递,以及,有点匪夷所思的常量引用。在常量引用形形参这种情况下,标准要求这时引用实际绑定到存储在某种标准整型(例如int)中的位域值的副本。常量引用不可能绑定到位域,它们绑定到的是“常规”对象,其中复制了位域的值。
既然没办法对一个位域进行引用那么可以通过拷贝位域的值后然后再进行完美转发,代码如下:
auto length = static_cast<std::uint16_t>(h.totolLength);
fwd(length);
要点速记
- 完美转发的失败情形,是源于模板型别推导失败,或推导结果是错误的型别。
- 会导致完美转发失败的实参种类有大括号初始化物、以值0或NULL表达的空指针、仅有声明的整型static const成员变量、模板或重载的函数名字,以及位域。
第6章 lambda表达式
相关术语:
“lambda表达式”就是一个表达式,是下面代码中第二行表达式:
std::find_if(container.begin(), container.end(), [](int val) { return 0 < val && val < 10; });
closure(闭包)是lambda表达式创建的一个运行时对象。根据不同的捕获模式,closure持有被捕获数据的拷贝或引用。在上面的例子中,在运行时我们通过lambda表达式创建了一个closure并作为第三个参数传给了
std::find_if
。closure class(闭包类)是一个closure的实现类。编译器会为每个lambda表达式生成一个唯一的闭包类,lambda表达式中的代码会成为这个类的成员函数的可执行代码。
lambda表达式和闭包类存在于编译器,闭包存在于运行期。
条款31:避免隐式捕获模式
C++11中有两种默认捕获模式:引用模式和值模式。(我的理解是指[&]、[=]两种隐式捕获)
默认的引用模式会导致空悬引用。默认的值模式会让你以为自己可以避免这个问题(实际上没有),以为你的closure是自独立的(实际可能并不独立)。
按引用捕获会导致闭包包含指涉到局部变量的引用,或者指涉到定义lambda式的作用域内的形参的引用。一旦由lambda式所创建的闭包越过了该局部变量或形参的生命期,那么闭包内的引用就会空悬。
从长远观点来看,显式地列出lambda式所依赖的局部变量或形参是更好的软件工程实践。
比起隐式引用捕获
[&]
,显示引用捕获[&divisor]
更清晰、不容易出错。将引用捕获改为值捕获,并不是一定能避免空悬指针。如果你值模式捕获了一个指针,结果还是一样的。
示例:
class Widget {public:void addFilter() const;private:int divisor; };void Widget::addFilter() const {filters.emplace_back([=](int value) { return value % divisor == 0; }); }
这段代码只能说大错特错。
捕获只会发生在lambda所在的作用域的非static的局部变量上(包括参数)。 在
Widget::addFilter
中,divisor
不是局部变量,它不能被捕获。这就是为什么 如果把隐式捕获改为显示捕获[divisor]
,编译失败。但最上面这段代码为什么可以编译成功?因为它捕获了
this
。下面是它的等价代码:filters.emplace_back([=](int value) { return value % this->divisor == 0; } );
对divisor的引用是通过this指针进行的。这就是值捕获指针的情况,因为this指针会失效,存在空悬指针的风险:
using FilterContainer = std::vector<std::function<bool(int)>>; FilterContainer filters;void doSomeWork() {auto pw = std::make_unique<Widget>();pw->addFilter(); }
正确做法是什么?将成员变量拷贝一份为局部变量,再捕获进去:
void Widget::addFilter() const {auto divisorCopy = divisor;filters.emplace_back([divisorCopy](int value) { return value % divisorCopy == 0; }); }
C++14中,更好的方式是使用初始化捕获(见Item32):
void Widget::addFilter() const {filters.emplace_back([divisor = divisor](int value) { return value % divisor == 0; }); }
隐式值捕获模式的另一个缺点是它让我们以为closure是自洽的,但它却不能确保这点。
因为closure不光依赖于局部变量,还会依赖静态存储区的对象(全局变量和static变量)。这些对象可以在lambda中使用,但无法被捕获:
void addDivisorFilter() {static int divisor = 1;filters.emplace_back([=](int value) { return value % divisor == 0; }); // captures nothing! refers to above static++divisor; }
粗心的读者会被
[=]
误导,以为所有变量都被捕获了。但实际上什么都没有被捕获。当调用++divisor
时,addDivisorFilter
创建的所有closure中的divisor
都增加了。
这些问题通过显式捕获都可以提前发现,而用了隐式模式,却被藏了起来。
要点速记
- 按引用的隐式捕获会导致空悬指针问题。
- 按值的隐式捕获极易受空悬指针影响(尤其是this),并会误导人们认为lambda式是自洽的。
条款32:使用初始化捕获将对象移入闭包
C++11并没有提供将对象移动到闭包中的方法,而C++14提供了一种方式,叫“初始化捕获”,能满足这一需求。
初始化捕获能让你指定:
- 由lambda生成的闭包类中的成员变量的名字。
- 一个表达式,用以初始化该成员变量。
一个例子:
class Widget;
...
auto pw = std::make_unique<Widget>();
... // confiture *pw
auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); }; //参数部分省略
捕获方括号内,位于“=”左侧的,是你所指定的闭包类成员变量的名字,而位于其右侧的则是其初始化表达式。
可圈可点之处在于,“=”的左右两侧处于不同的作用域。左侧作用域就是闭包类的作用域,而右侧的作用域则与lambda式加以定义之处的作用域相同。
如果在lambda前不需要修改*pw
,就可以省掉这个变量,直接放到初始化捕获式中:
auto func = [pw = std::make_unique<Widget>()] {...};
C++11的“捕获”概念在C++14中得到了显著的泛化。因此,初始化捕获还有另一美名,称为广义lambda捕获。
如果想在C++11中实现移动捕获,有两种方式:
自己实现一个闭包类,缺点是需要写的代码太多。
class IsValAndArch { public:using DataType = std::unique_ptr<Widget>;explicit IsValAndArch(DataType&& ptr): pw(std::move(ptr)) {}bool operator() const {return pw->isValidataed() && pw->isArchived();} private:DataType pw; }; auto func = IsValAndArch(std::make_unique<Widget>());
使用
std::bind
结合lambda:- 将对象移动的结果放到
std::bind
创建的函数对象中。 - 令lambda接受上面这个“被捕获”的对象的引用。
初始化捕获版本:
std::vector<double> data; ... auto func = [data = std::move(data)] {...};
std::bind
+lambda版本:std::vector<double> data; ... auto func = std::bind([](const std::vector<double>& data) {...},std::move(data) );
- 将对象移动的结果放到
要点速记
- 使用C++14的初始化捕获将对象移入闭包。
- 在C++11中,经由手工实现的类或std::bind去模拟初始化捕获。
条款33:对auto&&型别的形参使用decltype
,以sta:forward
之
C++14的一项引入注目的新功能就是泛型lambda,即lambda的参数可以用auto
来修饰。它的实现很直接:closure class的operator()
是个模板函数。
假设闭包类的函数调用运算符实现了完美转发:
class SomeClosureClass {
public:template <typename T>auto operator()(T&& x) const {return normalize(std::forward<T>(x);}...
};
则实际的lambda函数大致应该写成:
auto f = [](auto&& x) { return normalize(std::forward<???>(x)); };
这里的问题就是std::forward
的型别形参是什么。
Item28解释了左值参数传给普适引用后变成左值引用,而右值参数传给普适引用后变成右值引用。我们要的就是这个效果,而这就是decltype
能给我们的(参见Item3)。
Item28中同样解释了当右值参数传给普适引用后,我们得到的T
是无引用的,而delctype(x)
是带右值引用的,这会影响std::forward
吗?
看std::forward
的实现:
template <typename T>
T&& forward(remove_reference_T<T>& param) {return static_cast<T&&>(param);
}
将T
替换为Widget
,得到:
Widget&& forward(Widget& param) {return static_cast<Widget&&>(param);
}
将T
替换为Widget&&
,得到:
Widget&& && forward(Widget& param) {return static_cast<Widget&& &&>(param);
}
应用引用折叠,得到:
Widget&& forward(Widget& param) {return static_cast<Widget&&>(param);
}
与T
为Widget
的版本完全一样!这说明decltype
就是我们想要的。
因此我们的 完美转发lambda表达式 的代码为:
auto f = [](auto&& x) {return normalize(std::forward<decltype(x)(x)>);
};
C++14的lambda同样支持可变长形参:
auto f = [](auto&&... xs) {return normalize(std::forward<decltype(xs)>(xs)...);
};
条款34:优先选用lambda表达式,而非std::bind
本书称std::bind返回的函数对象为绑定对象。
C++14后,std::bind
已经可以被lambda函数完全代替,而且lambda的优势越来越大。
优点一:可读性
举个例子,假设我们有个函数用来设置声音警报:
using Time = std::chrono::steady_clock::time_point;
enum class Sound {Beep, Siren, Whistle};
using Duration = std::chrono::steady_clock::duration;
void setAlarm(Time t, Sound s, Duration d);
lambda版本:
auto setSoundL = [](Sound s) {using namespace std::chrono;using namespace std::literals;setAlarm(steady_clock::now() + 1h, s, 30s);
};
std::bind
版本:
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders; // needed for use of "_1"
auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);
lambda表达式明显有更高的可读性。
优点二:正确性
在lambda版本中,我们知道steady_clock::now() + 1h
是setAlarm
的参数,它会在调用setAlarm
时被求值。但在std::bind
中,这个表达式是std::bind
的参数,它是在我们生成bind对象时就被求值了,而此时我们还不知道什么时候才会调用setAlarm
!
解决方案就是把std::bind
中的表达式继续用std::bind
拆开,直到这个表达式的每项操作都是用std::bind
表示的:
auto setSoundB = std::bind(setAlarm,std::bind(std::plus<>(),std::bind(steady_clock::now),1h),_1,30s
);
这里的std::plus<>
是C++14新增的语法,即标准云算法模板的模板类型通常可以省略。在C++11中,不支持这种语法,必须写成std::plus<steady_clock::time_point>
。
如果setAlarm
还有重载版本,新问题又产生了。假设另一个版本是:
void setAlarm(Time t, Sound s, Duration d, Volume v);
对于lambda版本来说,工作正常,因为重载决议会选出正确的版本。而对于std::bind
版本来说,编译会失败,编译器不知道该用哪个版本,它得到的只有一个函数名字,而这个名字本身是二义的。为了让std::bind
能使用正确的版本,我们需要显式转换:
using SetAlarm3ParamType = void (*)(Time, Sound, Duration);
auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm),std::bind(std::plus<>(),std::bind(steady_clock::now),1h),_1,30s
);
优点三:高性能
在setSoundL的函数调用运算符中(即,lambda式所对应的闭包类的函数调用运算符中)调用setAlarm采用的是常规的函数唤起方式,这么一来,编译器就可以用惯常的手法将其内联。
而std::bind
的内部保存了setAlarm
的函数指针,后面会用这个函数指针来调用setAlarm
,这种调用方式很难有机会内联。
优点四:易用性
我们用lambda可以很轻松写出临时用的短函数,而用std::bind
就很困难:
auto betweenL = [lowVal, highVal](const auto& val) {return lowVal <= val && val <= highVal;
};auto betweenB = std::bind(std::logical_and<>(),std::bind(std::less_equal<>(), lowVal, _1),std::bind(std::less_equal<>(), _1, highVal)
);
接下来的差异是,我们很难搞清楚std::bind
中参数是如何传递的。
enum class CompLevel {Low, Normal, High};
Widget compress(const Widget& w, CompLevel lev);Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);
上面这段代码中,为了把w
传给compress
,我们要把w
保存到bind对象中,但它是怎么保存的?值还是引用?答案是传值(可以用std::ref
和std::cref
来传引用),但知道答案的唯一方式就是熟悉std::bind
是如何工作的,而在lambda中,变量的捕获方式是明明白白写在那的。
另一个问题是当我们调用bind对象时,它的参数是如何传给底层函数的?即_1
是传值还是传引用?答案是传引用,因为std::bind
会使用完美转发。
但在C++11中,std::bind
有两项本领是lambda做不到的(C++14的lambda都可以做到):
移动捕获:参见条款32。
多态函数对象:
std::bind
会完美转发它的参数,因此它可以接受任意类型的参数,因此它可以绑定一个模板函数:class PolyWidget { public:template<typename T>void operator()(const T& param) const;... };PolyWidget pw; auto boundPW = std::bind(pw, _1);boundPW(1930); boundPW(nullptr); boundPW("Rosebud");
C++11的lambda无法做到这点。但C++14的可以:
auto boundPW = [pw](const auto& param) {pw(param);};
要点速记
- lambda表达式比起使用
std::bind
而言,可读性更好、表达力更强,可能运行效率也更高。
第7章 并发API
条款35:优先选用基于任务而非基于线程的程序设计
在C++11中如果想异步的去运行一个doAsyncWork函数的话,你有两个选择。
第一种方式就是创建一个
std::thread
然后运行这个函数,这种方式被称为基于线程:int doAsyncWork(); std::thread t(doAsyncWork);
第二种方式可以通过
std::async
来运行doAsyncWork,这种策略我们称之为基于任务:auto fut = std::async(doAsyncWork); // 返回std::future模板类对象
task通常要比thread好,原因如下:
- 基于task的代码往往更少。
- 基于task更容易得到函数的返回值:调用future的get方法。
- future的get方法还能访问到函数抛出的异常,而thread中如果函数抛了异常,进程就挂掉了(经由调用
std::terminate
)。
它们之间更本质的差别在于,基于task的方法有着更高的抽象层次,而无需关心底层的线程管理。下面是C++中”线程”的三种意义:
- 硬件线程:真正的运算线程,目前每个CPU核可以提供一个或多个线程。
- 软件线程(OS线程):OS提供的线程,OS会负责管理和调度这些线程。通常OS线程可以远多于硬件线程。
std::thread
:C++标准库提供的线程类,底层对应一个OS线程。有些情况下std::thread
对象表示为"null",没有对应的OS线程,可能的原因有:①处于默认构造状态;②被移动过;③已经调用过join
(带调用的函数已结束);④已经调用过detach
(std::thread
对象与其底层软件线程的连接被切断了)。
OS线程数量是有上限的,超过上限时再创建会抛std::system_error
。
即使没有用尽线程,还是会发生超订(oversubscription)问题。也就是,就绪状态(即非阻塞)的软件线程超过了硬件线程数量的时候。这种情况发生以后,线程调度器(通常是操作系统的一部分)会为软件线程在硬件线程之上分配CPU时间片。当一个线程的时间片用完,另一个线程启动时,就会执行语境切换。这种语境切换会增加系统的总体线程管理开销,尤其在一个软件线程的这一次和下一次被调度器切换到不同的CPU内核上的硬件线程时会发生高昂的计算成本。
在那种情况下,
- 那个软件线程通常不会命中CPU缓存(即,它们几乎不会包含对于那软件线程有用的任何数据和指令);
- CPU内核运行的“新”软件线程还会“污染”CPU缓存上为“旧”线程所准备的数据,它们曾经在该CPU内核上运行过,并且很可能再次被调度到同一内核运行。
但基于task来开发,把这些问题丢给task,就能简单一点。而std::async
就是这么做的:
auto fut = std::async(doAsyncWork);
用std::async
就是把线程管理的难题交给了C++标准库,它会处理诸如out-of-threads的异常等问题。实际上std::async
不一定会去创建线程,它允许调度器把这个函数安排在需要它结果(调用fut.wait()
或fut.get()
)的线程执行。
用了std::async
后,负载均衡的问题仍然在,但现在需要处理它的不再是你了,而是调度器。调度器知道所有线程的情况,因此它处理负载均衡总会比人更好。
当然,std::async
没办法解决前面GUI线程的问题,因为调度器不知道你的哪个线程对响应时间的要求最低。此时你可以指定std::launch::async
来确保你的函数运行在另一个线程中。
但是仍有几种情况下,直接使用线程会更适合,它们包括:
- 需要访问底层线程实现的API。
std::thread
允许你通过native_handle
方法获得底层线程的句柄,而std::future
没有这样的方法。 - 需要且有能力为你的应用优化线程使用,如在特定硬件平台上绕过某个已知的性能缺陷。
- 需要在C++的并发API之上实现更高级线程技术,如为特定平台实现一个线程池。
除了上述几个特殊情况外,我们应该优先使用基于任务的方法来替换替换线程的方法。
要点速记
std::thread
的API未提供直接获取异步运行函数返回值的途径,而且如果那些函数抛出异常,程序就会终止。- 基于线程的程序设计要求手动管理线程耗尽、超订、负载均衡,以及新平台适配。
- 经由应用了默认启动策略的std::async进行基于任务的程序设计,大部分这类问题都能找到解决之道。
条款36: 如果异步是必需的,就指定std::launch::async
std::async
不保证你的函数一定是异步执行的,需要指定异步策略。有两个标准策略,都是std::launch
中的枚举值。假设f
是要通过std::async
调用的函数:
std::launch::async
:f
必须异步执行,比如在另一个线程。std::launch::deferred
:f
只在对应的future的get
或wait
被调用时才执行,且是同步执行。如果没有人调用对应的get
或wait
,f
就不会被执行。
std::async
的默认策略哪个都不是——是两个策略的or,即下面两个std::async
的行为是完全一致的:
auto fut1 = std::async(f);
auto fut2 = std::async(std::launch::async | std::launch::deferred, f);
默认策略允许调度器自己选择是在另一个线程执行还是在当前线程执行;是立即执行还是等到get
或wait
时执行。它有几个有趣的特性:
- 无法预测
f
是否与当前线程并发执行,因为调度器有可能选择std::launch::deferred
。 - 无法预测
f
是否在调用get
或wait
的另一个线程执行。 - 可能无法预测
f
是否会执行。
默认策略不太适合与TLS(线程局部存储 Thread Local Storage)一起用,因为你不知道f
到底在哪个线程执行,因此也就不知道f
中访问的TLS变量位于哪个线程。
C++11引入了TLS:
thread_local
。
它也会影响那些基于wait的循环中以超时为条件者,因为对被延迟的任务调用wait_for或者wait_until永远会返回std::future_status::deferred
。
wait_for/until返回值常量 | 解释 |
---|---|
future_status::deferred
|
要计算结果的函数仍未启动 |
future_status::ready
|
结果就绪 |
future_status::timeout
|
已经超时 |
进而导致下面这个看起来会结束的循环永远不结束:
using namespace std::literals;void f() {std::this_thread::sleep_for(1s);
}auto fut = std::async(f);
while (fut.wait_for(100ms) != std::future_status::ready) {...
}
解决方案很简单:检查future是不是deferred,如果是,就不进循环。
可以通过wait_for
返回值判断任务是否deferred :
auto fut = std::async(f);
if (fut.wait_for(0s) == std::future_status::deferred) {...
} else {while (fut.wait_for(100ms) != std::future_status::ready) {...}...
}
因此,当满足以下全部条件时,使用std::async
的默认策略才是好的:
- task不需要与调用
get
或wait
的线程并发执行。 - 无所谓访问哪个TLS变量。
- 要么能确保有人会调用future的
get
或wait
,要么f
执不执行都可以。 - 调用了
wait_for
或wait_until
的代码要保证能处理deferred。
如果没办法保证以上几点,你需要确保你的task运行在另一个线程中,就指定std::launch::async
:
auto fut = std::async(std::launch::async, f);
我们可以自己包装一个函数,确保使用std::launch::async
:
template <typename F, typename... Ts>
inline std::future<typename std::result_of<F(Ts...)>::type> reallyAsync(F&& f, Ts&&... params) {return std::async(std::launch::async, std::forward<F>(f), std::foward<Ts>(params)...);
}
C++14版本可以不用写那么复杂的返回类型:
template <typename F, typename... Ts>
inline auto reallyAsync(F&& f, Ts&&... params) {return std::async(std::launch::async, std::forward<F>(f), std::foward<Ts>(params)...);
}
要点速记
std::async
的默认启动策略既允许任务以异步方式执行,也允许任务以同步方式执行。- 如此的弹性会导致使用thread_local变量时的不确定性,隐含着任务可能永远不会执行,还会影响运用了基于超时的wait调用的程序逻辑。
- 如果异步是必要的,则指定
std::launch::async
。
条款37:令std::thread
对象在所有路径退出时都不可join
每个std::thread
对象都处于两种状态下:可join、不可join。可join的std::thread
对应一个可运行或运行中的底层线程,例如被阻塞、未调度或已运行完成的线程都是可join的。
而其它状态的std::thread
就是不可join的:
- 默认构造状态的
std::thread
:不对应底层线程。 - 被移动过的
std::thread
:底层线程现在由其它std::thread
管理。 - 已调用过
join
的std::thread
:底层线程已结束。 - 已调用过
detach
的std::thread
:detach
会切断std::thread
和底层线程的联系。
重点是:如果std::thread
的析构函数被调用时它是可join的,程序就会终止。
示例:
constexpr auto tenMillion = 10'000'000; //C++14单引号作数字分隔符
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {std::vector<int> goodVals;std::thread t([&filter, maxVal, &goodVals] {for (auto i = 0; i <= maxVal; ++i) {if (filter(i)) {goodVals.push_back(i);}}});auto nh = t.native_handle(); // 使用低层句柄设置优先级...if (conditionsAreSatisfied()) {t.join();performComputation(goodVals); // computation was performedreturn true;}return false; // computation was not performed
}
这里我们直接构造std::thread
而不用std::async
的原因在于,我们需要拿到底层线程的句柄来设置优先级。
上面这段代码,如果最后走到了false
分支,或中间抛了异常,就会遇到构造了一个可join的std::thread
的问题,程序就会终止。
线程t开始执行之后才去设置它的优先级,也是有问题的。改进方法:在开始设置t
为暂停状态,Item39会介绍如何做到这点。
为什么可join的std::thread
对象的析构函数会终止进程?原因在于另外两种处理方式会更糟:
- 隐式的
join
。这种情况下,std::thread
的析构函数会等待底层异步执行线程完成。可能造成难以追踪的性能问题。 - 隐式的
detach
。这样,std::thread
对象和底层线程间的联系被切断,当t
析构后,底层线程仍然在执行,尤其在dowork
栈帧弹出后,底层线程可能会继续修改goodVals
的内存,更加难以调试。
因此你有责任确保所有情况下的std::thread
都不可join。这可以通过包装一个RAII类来实现,同时它能够选择销毁时用detech还是join:
class ThreadRAII {
public:enum class DtorAction {join, detach};ThreadRAII(std::thread&& t, DtorAction a): action(a), t(std::move(t)) {}~ThreadRAII() {if (t.joinable()) {if (action == DtorAction::join) {t.join();} else {t.detach();}}}std::thread& get() {return t;}
private:DtorAction action;std::thread t;
};
几个值得注意的点:
- 构造函数只接受
std::thread
的右值,因为std::thread
只能移动不能复制。 - 构造函数的参数顺序与成员顺序相反,因为参数里把重要的放前面,不重要的放后面更符合直觉;而成员顺序里依赖少的放前面,依赖多的放后面更合理。
action
不如t
重要,因此参数里放后面;t的析构需要依赖action,因此成员顺序中放后面。 - 提供一个
get
接口避免了为ThreadRAII
实现一整套std::thread
的接口。 - 在
ThreadRAII
的析构函数中,在调用t.join()
或t.detach()
前,需要先调用t.joinable()
,因为有可能t
已经被移动过了。当ThreadRAII对象的析构函数被调用时,不应该有其他线程调用该对象的成员函数,因此不会有竞态条件。
应用ThreadRAII
到我们前面的代码中:
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion) {std::vector<int> goodVals;ThreadRAII t(std::thread([&filter, maxVal, &goodVals] {for (auto i = 0; i <= maxVals; ++i) {if (filter(i)) {goodVals.push_back(i);}}}),ThreadRAII::DtorAction::join);auto nh = t.get().native_handle();...if (conditionsAreSatisfied()) {t.get().join();performComputation(goodVals);return true;}return false;
}
当然ThreadRAII
还是有可能阻塞的问题,这种问题的“合适的”解决方案是和异步执行的lambda式通信,当我们已经不再需要它运行,它应该提前返回,但这已经超出本书范围。
当一个类型定义了析构函数,编译器就不会自动为它生成移动函数了。如果你想让ThreadRAII
可移动,需要显示声明两个默认(=default
)的移动函数。
要点速记
- 使
std::thread
型别对象在所有路径退出时皆不可join。 - 在析构时调用join可能导致难以调试的性能异常。
- 在析构时调用detach可能导致难以调试的未定义行为。
- 在成员列表的最后声明
std::thread
型别对象。
条款38:注意线程句柄析构函数的各种行为
上节介绍了可join的std::thread
对应一个可运行的底层线程,而未推迟的task也对应一个系统线程。因此,std::thread
对象和future对象都称为系统线程的句柄。
但是,std::thread
和future在析构的行为上非常不同。析构一个可join的std::thread
会导致程序终止,而析构一个future有时像是做了隐式的join
,有时像是做了隐式的detach
,有时两者都不是。总之它不会导致程序终止。
我们首先从这样一个发现开始:future就是执行者将结果返回给调用者的一个管道。执行者(通常是异步执行)将计算结果写到管道中(例如std::promise
对象),调用者再通过future拿到结果。
但结果保存在哪了?保存在std::promise
对象或者std::future
对象都不合适。答案是结果保存在std::promise
和future之外的共享状态。共享状态通常使用堆上的对象来表示。
与future关联的这个共享状态就决定了future的析构行为,具体来说:
- 对于通过
std::async
启动的未推迟的task,最后一个与之关联的future在析构时会阻塞,直到这个task完成。本质上,这个析构就是对task所在的线程调用了一次join。 - 其它future的析构都只是简单的析构这个对象。这些析构就是对底层线程调用了detach。对于被推迟的task,当它关联的最后一个future析构后,这个task就永远不会被执行了。
简单来说就是有一种正常行为和一个例外。正常行为就是future的析构只析构future对象,它既不会join也不会detach。而当以下条件都满足时,应用例外规则:
- future关联着由
std::async
创建的共享状态。 - task的启动策略是
std::launch::async
,包括调用std::async
时显式指定该策略,或者系统选择了该策略。 - 它是最后一个关联该共享状态的future。
这个特例存在的原因:C++标准委员会想避免隐式detach引起的问题(见Item37),但又不想像对待可join的std::thread
那样使用“程序终止”这么激进的策略,所以最终他们妥协了,决定隐式使用join。
std::future
的这种特殊的析构行为让我们的程序行为变得不可预测,没办法知道随便一个future的析构会不会阻塞。
当然,如果你知道某个future肯定不满足例外条件,你就能确定它的析构不会阻塞。例如,当我们使用std::packaged_task
时,它返回的future就不与std::async
创建的共享状态相关联,因此我们可以确定这样的future的析构是不会阻塞的。
std::packaged_task
与std::function
类似,都是对某个callable的对象的包装。区别在于std::packaged_task
会返回一个future。我们可以用std::packaged_task
创建一个std::thread
来运行callable对象,结果通过future得到。
{std::packaged_task<int()> pt(calcValue);auto fut = pt.get_future();std::thread t(std::move(pt));... // see below
}
要点速记
- 期值的析构函数在常规情况下,仅会析构期值的成员变量。
- 指涉到经由
std::aysnc
启动的未推迟任务的共享状态的最后一个期值会保持阻塞,直至该任务结束。
条款39:考虑用std::future<void>
来进行一次性事件通信
有时候,需要让一个任务通知另一个以异步方式运行的任务发生了特定的事件,原因可能是第二个任务在事件发生之前无法推进。这事件也许是某个数据结构完成初始化了,也许是某个计算阶段结束了,又也许是某个重要传感器值被检测到了等。
线程通信方式
1、条件变量
std::condition_variable cv;
std::mutex m;
通知方(检测任务):
cv.notify_one();
接收方(反应任务):
{std::unique_lock<std::mutex> lk(m);cv.wait(lk);
}
问题:
如果通知方在接收方调用wait之前就通知了条件变量,则接收方将一直阻塞在wait上。
接收方的wait语句无法应对虚假唤醒。
线程API的存在一个事实情况(很多语言中都如此,不仅仅是C++),即使没有通知条件变量,针对该条件变量等待的代码也可能被唤醒。这样的唤醒称为虚假唤醒。
正确的代码通过确认等待的条件确实已经发生,并将其作为唤醒后的首个动作来处理这种情况:
while( !事件发生 ){cv.wait(lk); }
C++中有另外一种写法:
cv.wait(lk,[]{ return事件是否确已发生; });
两种写法等价。
2、布尔标志位
使用共享原子布尔标志位。缺陷是需要轮询,浪费CPU。
3、条件变量 + 标志位
因为条件变量需要锁,所以布尔标志位不再需要为原子的。
std::condition_variable cv;
std::mutex m;
bool flag(false); // 非 std::atomic 型别
// 通知方
...
{std::lock_guard<std::mutex> g(m);flag = true;
}
cv.notify_one();
// 接收方
...
{std::unique_lock<std::mutex> lk(m);cv.wait(lk, [] { return flag; }); // use lambda to avoid spurious wakeups...
}
...
解决了前面的两种问题,适用于多次通信。但是如果只是单次通信,还有更简洁的方法,如下。
4、std::promise
Item38提到了std::promise
代表了一个通信通道的发送端,而future则代表了接收端。这样的通道可以用于任何需要将信息从一处传输到另一处的场合。
方案很简单,通知方要持有一个std::promise
对象,而接收方持有对应的future。通知方通过调用std::promise
的set_value
来写入一条消息,接收方通过future的wait
来等待消息。
无论是std::promise
、std::future
还是std::shared_future
都是模板类型,需要一个类型参数,也就是消息的类型。但我们只关心通知本身,不需要消息有类型,最合适的就是void
。
std::promise<void> p;
// 通知方
...
p.set_value();
// 接收方
...
p.get_future().wait();
唯一的缺陷是不能重复使用。
“一次性”的通信有时候也是很有用的。举个例子,有时候我们想==创建一个被暂停的线程==,需要等待一个事件后才开始工作,就可以用std::promise
来实现:
std::promise<void> p;void react();
void detect() {std::thread t([] {p.get_future().wait(); //暂停treact();});... //准备工作 p.set_value(); //取消暂停...t.join();
}
这里我们创建了一个暂停的线程,它会等待p
被赋值后才执行react
。需要一个暂停线程的地方很多,比如在线程真正工作前设置优先级(通过native_handle
使用底层API)。
扩充:把上面的通信过程由一对一改成一对多,把std::future
(不可复制)换成std::shared_future
(可复制)即可。
std::future::share()
把共享状态的所有权转移给了由其生成的std::shard_future
对象。
std::promise<void> p;void react();
void detect() {auto sf = p.get_future().share(); // std::shard_futurestd::vector<std::thread> vt;for (int i = 0; i < threadForRun; ++i) {vt.emplace_back(std::thread([sf] { // 必须值捕获以拷贝sf.wait();react();}));}... p.set_value();...for (auto& t: vt) {t.join();}
}
要点速记
- 如果仅为了实现平凡事件通信,基于条件变量的设计会要求多余的互斥量,这会给相互关联的检测和反应任务带来约束,并要求反应任务校验事件确已发生。
- 使用标志位的设计可以避免上述问题,但这一设计基于轮询而非阻塞。条件变量和标志位可以一起使用,但这样的通信机制设计结果不甚自然。
- 使用
std::promise
型别对象和期值就可以回避这些问题,但是一来这个途径为了共享状态需要使用堆内存,而且仅限于一次性通信。
条款40:使用std::atomic
应对并发,使用volatile
应对特殊内存
std::atomic
std::atomic
模板类的作用有两个:
原子操作
std::atomic
型别对象的所有的成员函数(读、写、修改、++ / – / += / -= / &= …)都保证被其他线程视为原子的。不包括operator<<
,只有在operator
操作读取原子量时,保证是原子的。限制代码重新排序优化
std::atomic
型别对象的运用会对代码可以如何重新排序施加限制,并且这样的限制之一就是,在源代码中,不得将任何代码提前至后续会出现std::atomic
变量的写入操作的位置(或使其他内核视作这样的操作会发生)。示例:
当一个线程完成一个重要计算后,使用原子布尔标志位通知另外一个线程:
std::atomic<bool> valAvailabel(false); auto imptValue = computeImportantValue(); valAvailabel = true;
虽然
valAvailabel
的赋值是在imptValue
赋值之前发生的,但是实际上并不一定是这样的。如果布尔变量并非std::atomic
,编译器可能会对这两个赋值语句进行重排序,即使编译器没有做这样的工作,硬件也可能会对这两个操作进行指令级别的重排。对代码重新排序优化通常可以消除冗余,加快运行。
注意事项
std::atomic
的复制操作被删除了,因为硬件无法原子地从一个atomic对象出发构造另一个atomic对象,复制赋值同理。另外,它也没有移动构造/赋值。
因此,下面的写法是错误的:
std::atomic<int> x;
auto y = x;
正确的写法应该是:
std::atomic<int> x;
std::atomic<int> y = x.load();
不过整个构造不是单一原子操作。
volatile
volatile
在某些语言中可以用于并发,但在C++中不能用于并发,它不保证原子的读写,也不保证指令的先后顺序。
它的用途是告诉编译器,正在处理的内存不具备常规行为(属于特殊内存)。
“常规”内存的特征是:如果你向某个内存位置写入了值,该值会一直保留在那里,直到它被覆盖为止。常规内存有很多优化:
首先我们来看下面这段代码:
int x = 10; auto y = x; std::cout << x;
上面的代码中,多次读取x的值,编译器为了优化会将x的值放在寄存器中,每次后面读取x的值时,直接从寄存器返回即可。
同理对于多次写一个内存位置的情况,编译器也会做优化,代码如下:
x = 10; x = 12;
编译器会进行优化,实际上只执行了
x = 12
这次操作,省略了x = 10
这一步。
此类优化仅在内存行为符合常规时才合法。“特殊”内存就是另一回事。可能最常见的特殊内存是用于内存映射I/O的内存。这种内存的位置实际上是用于与外部设备(例如,外部传感器、显示器、打印机和网络端口等)通信,而非用于读取或写入常规内存(即RAM)。
比如:
x = 10;
x = 12;
如果x对应于无线电发射器的控制端口,则有可能是代码在向无线电发出命令,不应该被优化。
而volatile
的用处就是告诉编译器,正在处理的是特殊内存。它的意思是 通知编译器“不要对在此变量上的操作做任何优化”。 所以,如果x对应于特种内存,则它应该加上volatile
声明饰词。
要点速记
std::atomic
用于多线程访问的数据(原子、屏蔽代码重排),且不用互斥量。它是撰写并发软件的工具。volatile
用于读写操作不能被优化掉的内存。它是在面对特殊内存时使用的工具。
第8章 微调
条款41:针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑将其按值传递
有些函数参数就是要被复制/移动的,举例:
class Widget {
public:void addName(const std::string& newName) {names.push_back(newName);}void addName(std::string&& newName) {names.push_back(std::move(newName));}...
private:std::vector<std::string> names;
};
为了效率,对右值进行了重载,后果是目标代码膨胀、某些工作量翻倍。
如果用万能引用来代替上面两个函数:
class Widget {
public:template <typename T>void addName(T&& newName) {names.push_back(std::forward<T>(newName));}...
};
代码省掉了一份,但又导致了其它问题。作为模板函数,addName
需要放到头文件里。在用户代码中不一定只有两个实例化版本(左值和右值),所有可以用于构造std::string
的类型都可能会实例化一个版本(参见Item25)。同时,还有一些参数类型没办法使用普适引用(参见Item30)。如果调用方传递错类型,编译错误信息会非常恐怖(参见Item27)。
是否有一种方法来撰写像addName这样的函数,针对左值实施的是复制,针对右值实施的是移动,而且无论在在源代码和目标代码中只有一个函数需要着手处理,还能避免万能引用的怪癖?有,那就是传值。
class Widget {
public:void addname(std::string newName) {names.push_back(std::move(newName));}
};
解析:
newName
与实参没有关系,因此如何修改newName
都不会影响到实参。- 这是
newName
最后一次被使用,因此移动它不会影响到后面程序的运行。
开销比起重载和万能引用,只是多了一次额外的移动,分析略。
回顾标题:针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑将其按值传递。
- 你只能是考虑要不要用传值方法。它确实有很多优点,但它也确实比其它版本多一次移动的开销。
- 只能对可复制的参数使用传值方法。对于只能移动的类型,我们只能移动构造形参,就不存在需要写两个重载版本的问题,直接舍弃左值引用,也就不需要使用传值方法了。
- 传值方法只适用于“移动非常廉价”的类型。
- 只有当参数的复制不可避免时,才需要考虑传值方法。假如有某个分支下我们不需要复制参数,那么重载版本根本不需要复制参数,而传值版本在调用那一刻已经复制完了,没办法省掉,此时重载版本完胜。
class Widget {
public:void addName(std::string newName) {if ((newName.length() >= minLen) && (newName.length() <= maxLen)) {names.push_back(std::move(newName));}}...
};
即使上面的条件都满足,也有场景不适用于传值方法。我们说复制时,不光是复制构造,还有复制赋值,开销分析更复杂:
class Password {
public:explicit Password(std::string pwd): text(std::move(pwd)) {}void changeTo(std::string newPwd) {text = std::move(newPwd);}
};
构造Password
显然是可以用传值方法的:
std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);
但在调用changeTo
时:
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
newPassword
是左值,因此newPwd
要进行复制构造,这里会分配内存。之后newPwd
移动赋值给text
时,text
会释放自己原有的内存块,转而使用newPwd
持有的内存块。因此changeTo
有两次内存操作,一次分配,一次释放。
但我们这个例子中,旧密码比新密码长,因此如果我们使用重载方法,就不会有内存分配或释放(直接复制到旧密码的内存块上):
void Password::ChangeTo(const std::string& newPwd) {text = newPwd;
}
因此在这个例子中,传值版本比重载版本的开销多了两次内存操作,很可能比字符串的移动开销大一个数量级。
但在旧密码比新密码短的例子中,重载版本也没办法避免掉两次内存操作,这时传值方法的优势又回来了。
由此可以看出,当有赋值时,可能影响结论的因素太多了,比如Password
这个例子中std::string
是否使用了SSO优化也会影响结论。
此外,对效率要求特别高的软件,不应该使用传值,即便只有一点额外移动成本也应该避免。
最终的建议是,总是采用重载或万能引用而非按值传递,除非已确凿地证明按值传递能够为所需的形参型别生成可接受效率的代码。
要点速记
- 对于可复制的、在移动成本低廉的并且一定会被复制的形参而言,按值传递可能会和按引用传递的具备相近的效率,并可能生成更少量的目标代码。
条款42:考虑置入(emplace
)而非插入
在容器中插入新元素:
std::vector<std::string> vs;
vs.push_back("xyzzy");
//等价于
vs.push_back(std::string("xyzzy"));
如果编译器发现实参类型与形参类型不匹配时,它会生成一些代码,构造一个临时对象。
C++11新增的emplace_back
方法就可以避免这个问题:
vs.emplace_back("xyzzy");
vs.emplace_back(50, 'x');
它会先分配空间,再在新空间上使用传入参数(通过完美转发)直接构造出std::string
。每个支持push_back
的容器也都支持emplace_back
,支持push_front
的容器也都支持emplace_front
,支持insert
的容器也都支持emplace
。
插入函数接受的是待插入对象,而置入函数接受的则是待插入对象的构造函数实参。这一区别就让置入函数得以避免临时对象的创建和析构,但插入函数就无法避免。置入函数也可以接受待插入对象,但此时两种函数效率没有区别。
虽然大多数场景下emplace
系列函数的确是优于传统的插入函数,但是可悲的是,存在某些场景下传统的插入函数要比emplace
快。后一种情况又不太好归类,这里适用一般的性能调优建议:欲确定置入或插入哪个运行得更快,需对两者实施基准测试。
有一个可以帮助你确定何种方式高效的启发式思路。 如果以下所有情况都成立,则emplaces
几乎肯定会比传统的插入要高效:
欲添加的值是以构造而非赋值方式加入容器。
下面这个例子中:
std::vector<std::string> vs; ... vs.emplace(vs.begin(), "xyzzy");
我们要在
vs
的头部新增一个对象。大多数实现会先用""xyzzy"
构造出一个临时的std::string
,再移动赋值给目标对象。这样emplace相比insert的优势就没有了。理论上基于节点的容器都会构造新元素,而大多数STL容器都是基于节点的。只有几个容器不基于节点:
std::vector
、std::deque
、std::string
(std::array
不基于节点,也没有emplace和insert方法)。在非基于节点的容器中,在非基于节点的容器中,可以可靠地说emplace_back
是使用构造非赋值来将新值就位的,而这一点对于std::deque
的emplace_front
来说也一样成立。传递的参数类型和容器的元素类型不同。
emplace
系列函数的优点就是在于避免临时对象的构造和析构,如果传递的参数类型和容器中的类型相同就不会产生临时对象了,那么很自然emplace
系列函数的优势也就无法体现了。容器不会因元素重复而拒绝添加。因为欲检测某值是否已经在容器中,置入的实现通常会使用该新值创建一个节点,以便将该节点的值与容器的现有节点进行比较。如果节点不存在,则链接进容器,否则实施析构。
在决定是否选用置入函数时,还有其他两个问题值得操心。
第一个是资源管理的问题。
假设你有一个容器:
std::list<std::shared_ptr<Widget>> ptrs;
Widget
需要的自定义销毁函数是:void killWidget(Widget* pWidget);
根据Item21,这种情况下我们没办法用
std::make_shared
了。insert版本是:ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget)); //或者 ptrs.push_back({new Widget, killWidget});
无论哪种情况,都要构造出一个临时对象。这不就是emplace能避免的吗?
ptrs.emplace_back(new Widget, killWidget);
但注意,临时对象带来的好处远比它的构造和析构成本要大得多:
假设容器扩张时抛出了异常,
- 如果使用插入函数,此时裸指针已经在智能指针的临时变量中,没有问题;
- 如果使用置入函数,完美转发会推迟资源管理对象的创建,直到它们能够在容器的内存中构造为止。此时裸指针丢失,内存泄漏。
坦率地说,绝不应该把像"new widget"这样的表达式传递给
emplace_back
,push_back
或者大多数其他函数,会导致异常安全问题。解决方法就是将裸指针在独立语句中转交给资源管理对象。std::shared_ptr<Widget> spw(new Widget, killWidget); ptrs.push_back(std::move(spw)); //或者 ptrs.emplace_back(std::move(spw));
此时,置入和插入没有区别。
第二个问题是它们与
explicit
构造函数的互动。假设你有一个正则表达式的容器:
std::vector<std::regex> regexes;
std::regex
有一个接受const char*
的explicit
构造函数。原因是std::regex
的构造成本太高了,std::regex
构造的时候会对传入的正则字符串进行预编译,是一个很耗时的动作,为此std::regex
禁止隐式构造,避免不必要的性能损失。下面这些写法,都会报错:
std::regex r1 = nullptr; regexes.push_back(nullptr);
而换种写法,不会报错:
std::regex r2(nullptr); regexes.emplace_back(nullptr);
用标准中的官方术语来说,用于初始化r1(采用等号)的语法对应于复制初始化。相对地,用于初始化r2的语法(使用括号,尽量也可以使用花括号代替)会产生直接初始化。复制初始化是不允许调用带有explicit声明饰词的构造函数的,但直接初始化允许。
置入函数使用的是直接初始化,而插入函数使用的是复制初始化。
这里得到的教训是,在使用置入函数时,要特别小心去保证传递了正确的实参,因为即使是带有
explicit
声明饰词的构造函数也会被编译器纳入考虑范围,因为它会尽力去找到某种方法来解释你的代码以使得它合法。
要点速记
- 置入函数应该一般不会比对应的插入函数低效。
- 从实践上说,置入函数在以下几个前提成立时,极有可能会运行得更快:①待添加的值是以构造而非賦值方式加入容器;@传递的实参型别与容器持有之物的型别不同;③容器不会由于存在重复值而拒绝待添加的值。
- 置入函数可能会执行在插入函数中会被拒绝的型别转换。
参考:
《Effective Modern C++》
https://fuzhe1989.github.io/tags/Effective-Modern-C/
https://blog.csdn.net/zhangyifei216/category_9266963.html
《Effective Modern C++》笔记相关推荐
- 《信贷的逻辑与常识》笔记
序 银行信贷风险管理的反思 现状与趋势 银行贷款的质量变化与经济周期.宏观调控政策等存在很高的相关性 现在银行不良贷款的增加主要是前几年经济快速增长时企业过度投资.银行过度放贷所带来的结果. 从历史情 ...
- AI公开课:19.02.27周逵(投资人)《AI时代的投资逻辑》课堂笔记以及个人感悟
AI公开课:19.02.27周逵(投资人)<AI时代的投资逻辑>课堂笔记以及个人感悟 目录 课堂PPT图片 精彩语录 个人感悟 课堂PPT图片 精彩语录 更新中-- 文件图片已经丢失-- ...
- 人工智能入门算法逻辑回归学习笔记
逻辑回归是一个非常经典的算法,其中也包含了非常多的细节,曾看到一句话:如果面试官问你熟悉哪个机器学习模型,可以说 SVM,但千万别说 LR,因为细节真的太多了. 秉持着精益求精的工匠精神不断对笔记进行 ...
- 【逻辑回归学习笔记】
算法描述 1.逻辑回归要做的事就是寻找分界面实现二分类. 2.问题假设:对一堆三角形和正方形分类. 3.数据输入:已知正方形和三角形的坐标和标签. 4.算法过程: 知识储备 1.分类和回归 ①分类的目 ...
- 逻辑回归函数学习笔记
继续逻辑回归学习,今日笔记记录. 1.逻辑回归和线性回归的关系:对逻辑回归的概率比取自然对数,则得到的是一个线性函数,推导过程如下. 首先,看逻辑回归的定义 其次,计算两个极端y/(1-y),其值为( ...
- 2.2 逻辑回归-机器学习笔记-斯坦福吴恩达教授
逻辑回归 上一节我们知道,使用线性回归来处理 0/1 分类问题总是困难重重的,因此,人们定义了逻辑回归来完成 0/1 分类问题,逻辑一词也代表了是(1) 和 非(0). Sigmoid预测函数 在逻辑 ...
- LVM逻辑卷分区笔记
磁盘的静态分区有其缺点:分区大小难评估,估计不准确,当分区空间不够用的时候,系统管理员可能需要先备份整个系统,清除磁盘空间,然后重新对磁盘进行分区,然后恢复磁盘数据到新分区,且需要停机一段时间进行恢复 ...
- 适合理工直男的钟平老师逻辑英语学习笔记
一切的一切都只是套路! --鲁迅 核心公式: En: (状语1) 主(定语1) 谓(状语2) (宾)(定语2) (状语1) Ch: (状语1) (定语1)主 (状语2)谓 (定 ...
- 【数字逻辑】学习笔记 第四章 Part2 常用组合逻辑电路与竞争、险象
文章目录 一.常用组合逻辑电路 1. 译码器 (1) 二进制译码器 74LS138(3/8译码器) a. 一般符号和图形符号 b. 74LS138功能表 c. 两片 `74LS138` 构成 `4-1 ...
- 线性回归、逻辑回归学习笔记
学习源代码 import numpy as np import matplotlib.pyplot as plt def true_fun(X): # 这是我们设定的真实函数,即ground trut ...
最新文章
- 车牌识别--Towards End-to-End License Plate Detection and Recognition: A Large Dataset and Baseline
- shell处理curl返回数据_shell神器curl用法笔记
- vc 限制软件的使用次数或时间
- pdns 错误解决[备忘]
- opengl游戏引擎源码_UE4渲染引擎模块简介(1)
- Android的多任务之路
- PAT_甲级_1002_C语言
- linux rabbitmq 远程登录
- DEM高程数据下载方法
- 国科大李保滨矩阵分析与应用2021回忆版
- 网络---协议(TCP/IP五层模型)
- python实现误差逆传播算法
- python 变量后加逗号的含义
- 英语好不好,不影响做外贸
- Numpy计算分位点及标记对应区间的下标
- element 配置全局样式 例如:为项目中所有el-dialog弹窗添加分割线
- 2021年5月系统集成项目管理工程师案例分析真题视频讲解(3)
- 复数的辐角的主值的计算公式
- Spring Boot 项目 - API 文档搜索引擎
- 用速腾16线激光雷达跑gmapping
热门文章
- 【腾讯Bugly干货分享】QFix探索之路—手Q热补丁轻量级方案
- 聊城大学计算机2014高数试题,高数真题14-15.docx
- android多个按钮美化,Android开发学习系列(一)——Android按钮圆角美化
- IDEA的使用大全(快捷键、TomCat、Maven......)
- Air202入坑指南4---UART2(简单使用)
- 二维数组 string[,]
- sa结构组网方式_5G独立组网SA模式下的驻网流程浅析
- long term recurrent convolutional networks for visual recognition and description
- java微服务开发(基础环境篇)
- .xin 是什么域名?个人能使用吗?