在读了大半本Effective C++时,受益于其中许多代码规则,虽说不能让coding能力突飞猛进,至少你会开始对代码安全性有所思考,对任何开始接触C++具体项目的人,这都是一本首推的书。但对2025年的今天来说,仍然使用boost库的智能指针举例的00年代的书还是略显落后。庆幸的是,同作者Scott Meyers也意识到这个问题,在10年前出版了包含C++ 11/14特性的《Effetive Modern C++》,虽然C++ 17/20甚至26已经开始进入快车道,但对大多数场景C++11和14的影响绝不能称为老旧。

译文项目来自EffectiveModernCpp_CN,持续更新。

第一章 类型推导

条款01:理解模板类型推导

决定读Effective Modern C++的另外一个原因也来自条款一,大半年前我看了几篇模板编程和类型萃取的资料,写下了一篇文章:C++ Generic Programming:SFINAF与类型萃取,其中有不少当时也不敢肯定的结论,完全是通过一些零碎的demo总结出来的,但竟然在这本书的第一章的第一节得到印证,缘分也好,伏笔也罢,至少能让我相信,这本书将节省我一些独自写demo验证新特性的时间。

看这样的模板函数:

1
2
3
4
5
template<typename T>
void f(ParamType param);

//调用类似:
f(expr);
类型推导的地方有两个,一个是针对泛型T,另一个是针对形参param,这很重要,因为你的函数f内部可能就是针对这两种类型进行萃取。

这个问题看起来很抽象,实际上你肯定考虑过:

1
2
void func(int& c)
void func(int &c)
两种写法,你更喜欢哪种?这归咎于你喜欢把引用划给int类型还是变量c,这个答案没有对错之分,因为只是简单的类型写法,而在模板的推导中,划分给T还是划分给param是有严格的规则的,如果你喜欢看具体例子和讨论可以参考上述文章。

这里我们直接结合原书和之前的结论重新给出。

情况一:形参是非万能引用

结论1: 形参类型是左值引用右值引用指针类形式,那么T都是非引用,形参类型取决于匹配到什么类型;

左值引用情况:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T& param); // 左值引用

///左值情况:
int x=27; //x是int
const int cx=x; //cx是const int
const int& rx=x; //rx是指向作为const int的x的引用
f(x); //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int&
f(rx); //T是const int,param的类型是const int&
如果形参const,就T就不含const了,这就是匹配的效果:const不会匹配到T里面
1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(const T& param); // 含const的左值引用

int x=27; //x是int
const int cx=x; //cx是const int
const int& rx=x; //rx是指向作为const int的x的引用

f(x); //T是int,param的类型是const int&
f(cx); //T是int,param的类型是const int&
f(rx); //T是int,param的类型是const int&

右值引用情况:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void f(const T&& param); // 右值引用(含const就不是万能引用)

int x=27; //x是int
const int cx=x; //cx是const int
const int& rx=x; //rx是指向作为const int的x的引用

test(27);
test(std::move(x)); // T是int,param的类型是const int&&
test(std::move(cx)); // T是int,param的类型是const int&&
test(std::move(rx)); // T是int,param的类型是const int&&

指针情况也类似,T不会被解释成指针:

1
2
3
4
5
6
7
8
template<typename T>
void f(T* param); //param现在是指针

int x = 27; //同之前一样
const int *px = &x; //px是指向作为const int的x的指针

f(&x); //T是int,param的类型是int*
f(px); //T是const int,param的类型是const int*

这容易理解,原书没有特别提到,但是如果传入的是指针形参带const又如何呢,我们会发现:当形参带const时,T也不一定是非const特性的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<typename T>
void ff(const T& param){
if(std::is_same<T, const char*>::value)
cout << "std::is_same<T, const char*>::value" << endl;
if(std::is_same<decltype(param), const char* const &>::value)
cout << "std::is_same<decltype(param), const char* const &>::value" << endl;
if(std::is_same<T, int*>::value)
cout << "std::is_same<T, int*>::value" << endl;
if(std::is_same<decltype(param), int* const &>::value)
cout << "std::is_same<decltype(param), int* const &>::value" << endl;
}

int main() {
const char* pname = "EdemMo";
ff(pname); //触发#1和#2

int name1 = 27;
int* pname1 = &name1;
ff(pname1); //触发#3和#4

cout << "done" << endl;
return 0;
}
对于const char*字符串,T推导也是const char*,而不是char*;而传入同为指针的int*,T推导的是int*;当然基础好的会立马反应过来这根本是庸人自扰,const char*可不是普通的char*,const char*表示的是一个字符常量,说明字符内容是不可更改的,而形参T的const,传入指针时匹配指的是指针指向不可修改(这是指针常量范畴),因此它不会去除你的常量指针特性。同理,如果传入的类是const特性的常量指针,如const Widget*,那么这个常量特性也不会去除,条款04我们会看到印证这个特点的例子。从param表现来看也可知,当对const T&传入一个指针时,指针会被赋予指针常量的特性,它与原来指针本身的常量指针特性互不干扰。总而言之,当形参已经带const,不能一概认为T一定是被匹配为非const特性

情况二:形参是万能引用

结论2:当形参是万能引用形式时,传入实参为左值左值引用右值引用,最终T和形参类型都会被推导为左值引用类型(这也是类型推导T被推导成引用唯一情况),而传入实参是右值时,适用结论1,即T为非引用形参被推导为右值引用,我从之前的文章直接把demo抄过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename T>
void func(T&& num){ //万能引用形式
if(std::is_lvalue_reference<T>::value)
cout<<"is_lvalue_reference<T>"<<endl;
if(std::is_rvalue_reference<T>::value)
cout<<"is_rvalue_reference<T>"<<endl;
if(std::is_lvalue_reference<decltype(num)>::value)
cout<<"decltype is_lvalue_reference"<<endl;
if(std::is_rvalue_reference<decltype(num)>::value)
cout<<"decltype is_rvalue_reference"<<endl;
}

//左值、左值引用、右值引用:输出is_lvalue_reference<T>和decltype is_lvalue_reference
int a = 5;
int& b = a;
int&& c = 10;
func(a);
func(b);
func(c);

//右值类型:仅输出decltype is_rvalue_reference
func(5);
func(std::move(a));
func(std::move(b));
func(std::move(c));

情况三:值传递

结论3: 形参是值传递形式时,传入实参的引用特性、const/volatile特性均被忽略

这很容易理解,因为形参是实参的拷贝,实参的任何特性都不大可能影响拷贝的表现:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T param); //以传值的方式处理param

int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样

f(x); //T和param的类型都是int
f(cx); //T和param的类型都是int
f(rx); //T和param的类型都是int
同理,const char*传入时const特性会被去掉,但一种特殊的表示是const char* const expr,这样的实参传递给param,第二个const特性(指针常量)会被忽略、第一个const特性(常量指针)会被保留(即形参类型变成const char*)。

情况四:传入实参是数组

结论4:形参指针数组形式,传入数组退化成指针形参引用形式,T是数组类型,形参数组引用类型。

先看形参指针或者数组的形式,这两种声明是完全等效的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
void test(T* input){
if(std::is_same<T, const char>::value)
cout << "std::is_same<T, const char>::value" << endl;
if(std::is_same<decltype(input), const char*>::value)
cout << "std::is_same<decltype(input), const char*>::value" << endl;
}

template <typename T>
void ttest(T input[]){ //等效于指针声明
if(std::is_same<T, const char>::value)
cout << "std::is_same<T, const char>::value" << endl;
if(std::is_same<decltype(input), const char*>::value)
cout << "std::is_same<decltype(input), const char*>::value" << endl;
}

无论传入数组,还是指针,都退化成指针:即适用结论1,T将被推导成非引用,如const char,而形参自身被推导成指针,如const char*

1
2
3
4
const char name[] = "EdenMo";
const char* pname = name;
test(name);
test(pname); //均输出std::is_same<T, const char>::value、std::is_same<decltype(input), const char*>::value

最有趣的事情来了,C语言存在数组退化指针机制,当你想完整保留推导的数组特性,C++的引用可以满足你:

1
2
template <typename T>
void func(T& param)
当你传入一个数组时,T会被推导成数组类型,如const char[N],而形参的类型将被推导成const char(&)[N](奇怪但有用);而当你传入一个指针时,T会被推导成指针类型,如const char*,而形参类型被推导成指针的引用,如const char*&(奇怪且无用),一个验证示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
void ttest(T& input){
///传入实参是数组:
if(std::is_same<T, const char[7]>::value) //#1
cout << "std::is_same<T, const char[7]>::value" << endl;
if(std::is_same<decltype(input), const char(&)[7]>::value) //#2
cout << "std::is_same<decltype(input), const char(&)[7]>::value" << endl;
///传入实参是指针:
if(std::is_same<T, const char*>::value) //#3
cout << "std::is_same<T, const char*>::value" << endl;
if(std::is_same<decltype(input),const char*&>::value) //#4
cout << "std::is_same<decltype(input),const char*&>::value" << endl;
}

const char name[] = "EdenMo";
const char* pname = name;
ttest(name); //触发#1和#2
ttest(pname); //触发#3和#4
这里我们至少了解多了两种类型:const char(&)[N]const char*&,后者基本没什么用,但是前者可以结合constexpr和模板,获取数组长度:
1
2
3
4
5
6
7
8
9
10
template <typename T, std::size_t N>
constexpr std::size_t getArraySize(T(&)[N]) noexcept{ //条款14会知:noexcept使得编译的代码更好
return N;
}

//调用:
int arr[] = {1,2,3,4,5};
int arr1[getArraySize(arr)]; //编译期定长
std::array<int,getArraySize(arr)> arr2;
cout << sizeof(arr1)/sizeof(int) << arr2.size() << endl; //5 5

情况五:传入实参是函数指针

函数和数组一样,都会退化成指针,因此没有什么新理论了,总结为:

模板形式 推导T类型 推导形参类型
void func(T param) void(*)(int,int) void(*)(int,int)
void func(T& param) void(int,int) void(&)(int,int)
void func(T* param) void(int,int) void(*)(int,int)

条款02:理解auto类型推导

把大量复杂的类型推导理论写在条款01是有缘由的,过去我一直错误认为模板推导仅用于泛型编程中,直到读完条款02,另一种理由是让你更胸有成竹地使用“auto”

auto推导原理模板推导是一致的,当你简单地写下auto赋值时,相当于使用了三种模板推导:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>   
void func(T param)
func(27);                   //相当于 auto x = 27;

template <typename T>  
void func1(const T param)
func1(x)                    //相当于 const auto cx = x;

template <typename T>
void func2(const T& param)
func2(x)                    //相当于 const auto& rx = x;
是的,auto推导的类型就对应T的类型变量自身的类型,相当于模板形参的类型,而等号右侧赋值的来源值,就相当于模板传入实参值

同理,auto像条款01的情况二一样调用万能引用

1
2
3
auto&& uref1 = x;
auto&& uref2 = cx;  //实参为左值,auto和uref1、uref2均是左值引用类型(int&)
auto&& uref3 = 27//实参为右值,auto是int类型,uref3是右值引用类型(int&&)

数组、函数指针表现也与模板推导一致:

1
2
3
4
5
6
7
const char name[] = "R. N. Briggs";
auto arr1 = name;               //值传递:指针退化,arr1的类型是const char*
auto& arr2 = name;              //引用传递:arr2类型是const char (&)[13] 

void func(int, double);
auto func1 = func;      //值传递:func1类型是void(*)(int,double)
auto& func2 = func;     //引用传递,func2类型是void(&)(int,double)
题外话,函数引用函数指针不同的是,函数引用不允许置空,也不允许二次赋值
1
2
3
4
5
6
void func(int, double);
auto& func2 = func;     //引用传递,func2类型是void(&)(int,double)

//错误:不允许置空或二次赋值!
func2 = nullptr;
func2 = func_else;

C++11的统一初始化

auto模板推导存在一个例外的区别:auto支持统一类型的花括号推导,而模板推导必须显式指明std::initializer_list<T>才能成功推导类型T;

C++ 11引入了花括号初始化方式,称为统一初始化(uniform initialization):

1
2
3
4
5
6
7
//C++98语法:
int x = 27;
int x(27);

//C++11 支持:
int x = {27};
int x{27};
而如果在统一初始化中使用auto,推导的类型是std::initializer_list<int>,而不是int(其中auto x{27};N3922后才修正为int):
1
2
3
4
5
auto x = 27
auto x(27);   //都是int

auto x{27};   //修正为int
auto x = {27};  //注意,为std::initializer_list<int>

auto可以使用列表推导,但是要求列表元素必须同一种数据类型:

1
2
auto list = {1, 2, 3, 4};    //ok,推导为std::initializer_list<int>
auto list1 = {1, 2, 3.3f};   //无法编译

而在模板中,无论列表元素类型相不相同,都无法直接使用列表推导,只能显式使用std::initializer_list<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T>
void test(T param){
    for(int x:param){
        cout << x << ", ";
    }
    cout << endl;
}

template <typename T>
void ttest(std::initializer_list<T> param){
    for(int x:param){
        cout << x << ", ";
    }
    cout << endl;
}

///调用:
    //test({1,2,3,4});   //错误:不存在模板
    ttest({1,2,3,4});    //ok

同样的,在C++14中,允许使用auto作为函数返回值类型,但是仍然使用模板推导规则进行,所以auto仍然不支持推导返回列表的类型:

1
2
3
4
//error code:
auto getArray(){
    return {1,2,3};
}
匿名函数也是同理:
1
2
3
4
5
6
7
    std::vector<int> vp;
    auto resetVp = [&vp](const auto& newvp){
        vp = newvp;
    };

    resetVp(std::initializer_list<int>{1,2,3,4});  //ok
    resetVp({1,2,3,4});                            //错误:不存在模板

std::initializer_list

再插点题外拓展,std::initializer_list是一种很轻量的结构,支持遍历打印,但不支持修改元素(增删改),适宜局部存储一些列表值、作为函数参数等,如:

1
2
3
4
5
void doSomething(std::initializer_list<int> lst){
    for(int x : lst){
        ...
    }
}
函数参数时,C++甚至会建议你直接使用它的拷贝语义;当然它也可以存储自定义的类,但是要求对应的类必须允许拷贝:
1
2
3
4
5
void doSomething(std::initializer_list<Person> lst){
    for(const auto& p : Person){
        ...
    }
}
将初始化列表作为返回值极度危险,离开函数域,初始化列表会被析构,确实需要应该使用vector等STL:
1
2
3
4
5
6
7
auto getArray1() {
    return std::vector<int>{1, 2, 3};     //good
}

auto getArray1() {
    return std::initializer_list<int>{1, 2, 3};   //危险!补药这么干啊......
}

条款03:理解decltype

decltype最常用的用法是推导变量的类型,相比于前两个条款的模板推导和auto推导,decltype只是简单地返回变量的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const int i = 0;                //decltype(i)是const int

bool f(const Widget& w);        //decltype(w)是const Widget&
                                //decltype(f)是bool(const Widget&)

struct Point{
    int x,y;                    //decltype(Point::x)是int
};                              //decltype(Point::y)是int

Widget w;                       //decltype(w)是Widget

if(f(w)){                       //decltype(f(w))是bool
    ......      
}             

template<typename T>            //std::vector的简化版本
class vector{
public:
    …
    T& operator[](std::size_t index);
    …
};

vector<int> v;                  //decltype(v)是vector<int>
if (v[0] == 0){                 //decltype(v[0])是int&
    ......
}              

其中值得注意的是,对于T类型的STL容器,使用operator[]往往会返回一个T&,但是例外是std::vector<bool>,其operator[]返回的不是bool&,在MSVC平台,其返回的是std::_Vb_reference<std::_Wrap_alloc<std::allocator<unsigned int>>>,而在类gcc平台,返回的是std::_Bit_reference&&,详见条款06;

C++11 允许自动推导单一lambda语句的返回类型,如:

1
2
3
4
5
//这并不完美
template <typename Container, typename Index>
auto authAndAccess_decltype(Container& c, Index i)->decltype(c[i]){
    return c[i];
}
但是这种写法高度依赖decltype推导,其推导对象必须是个变量,不能是表达式(算术表达式或者条件表达式),也不能是复杂的类型等;

C++ 14起则支持直接使用auto推导,如:

1
2
3
4
5
//这也不完美
template <typename Container, typename Index>
auto authAndAccess_auto(Container& c, Index i){
    return c[i];
}
但是这种用法存比authAndAccess_decltype有了更严重缺陷,即推导引用丢失。

所以你不能给返回值赋值,根据auto和模板推导可知,返回的operator[]大概一个T&,而赋予一个auto值,相当于值递,忽略引用,因此auto和形参接受实参c[i]后,实际上都被推导成T,这是一种右值,不能被赋值:

1
2
3
std::deque<int>dp{1};
authAndAccess_decltype(dp, 0) = 27; //ok
authAndAccess_auto(dp,0) = 27; //无法编译
原书没有说明的疑问是为什么T是右值,int显然不是右值,但是这里的写法类似:
1
2
3
4
5
6
int getx(){
    int x = 27;
    return x;
}

getx() = 27; //绝对错误!
返回的就是一种int右值,因此authAndAccess_auto不能通过编译,如果你需要真正返回一个可赋值对象,按模板编译,你需要使用auto&,此时auto推导的是int,但是会产生一个T&左值对象:
1
2
3
4
5
template <typename Container, typename Index>
auto& authAndAccess_auto_c(Container& c, Index i){     //引用返回
    return c[i];
}
authAndAccess_auto_c(dp,0) = 27; //ok

decltype(auto)

C++14有另外一种写法,满足了我们所有期待,可以这样:

1
2
3
4
5
//这接近完美
template<typename Container, typename Index>
decltype(auto) authAndAccess_decltypeauto(Container& c, Index i){
    return c[i];
}
离谱之间又带着一丝合理,因为auto恰好能完美推导结果类型(无论它是表达式还是条件关系式),而decltype恰好根据该类型返回一个T&(如同在->decltype(c[i])做的那样)。

除了用作函数返回值,decltype(auto)可以作为类别声明使用,如:

1
2
3
4
Widget w;
const Widget& cw = w;
auto myWidget1 = cw;                    //myWidget1的类型为Widget
decltype(auto) myWidget2 = cw;          //myWidget2的类型是const Widget&
当然,在此例中,auto&也能够保留cwconst引用特性,但是你传入一个原始类型时,你必须又换成auto,而传入一个右值,或者一个来自完美转发的右值返回,你又得换成auto&&,否则你甚至无法完成编译,显然decltype(auto)比较强大且无脑。

对C++ 11,最后比较完美的右值容器模板应该为:

1
2
3
4
template<typename Container, typename Index>
decltype(auto) authAndAccess_decltypeauto_uni(Container&& c, Index i){
    return std::forward<Container>(c)[i];
}
在C++ 11,它必须写成:
1
2
3
4
template<typename Container, typename Index>
auto authAndAccess_decltypeauto_uni(Container&& c, Index i)->decltype(std::forward<Container>(c)[i]){
    return std::forward<Container>(c)[i];
}

decltype古怪细节

最后是decltype的一点古怪细节,正如上文所述,decltype大部分场景下返回简单的类型,但是可能会被一个括号干扰:

1
2
3
4
5
6
7
8
9
10
11
12
13
int x = 27;
decltype(x);   //int
decltype((x));  //int&

decltype(auto) f1(){          //ok
    int x = 0;
    return x;                 //decltype(x)是int,所以f1返回int
}

decltype(auto) f2(){          //极其危险!
    int x = 0;
    return (x);               //decltype((x))是int&,所以f2返回int&
}
C++11认为(x)写法是一个左值,因此如果结合了decltype(auto),那么f2将返回一个局部变量的引用,是危险的行为,应该警惕。

条款04:学会查看类型推导的结果

结论1:注意编译器IDE提供的类型推导有可能是错误的。

对于简单类型,typeid返回类型名称的经过编译器处理的mangled name(经修饰的名称),例如下面代码gcc会分别返回iPKi,而MSVC会直白地返回int和int const*

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;

int main() {
const int x = 27;
auto y = &x;

cout << typeid(x).name() << endl;
cout << typeid(y).name() << endl;

cout << "done" << endl;
return 0;
}
C/C++/Qt 修炼手册的__cxa_demangle类型名转换一节我就阐述过如何将mangled name转换成正常类型名,遗憾的是这只对大部分常用类型有效。

但是如果是一些比较复杂的类型代码耦合产生的对象,如运行这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <vector>

using namespace std;

class Widget{
public:
Widget(int num){
_num = num;
}
~Widget(){}
private:
int _num;
};

std::vector<Widget> produce(std::vector<int>& vp){ //通过工厂模式返回一组对象
std::vector<Widget> res;
for(const auto& pos : vp){
Widget* temp = new Widget(pos);
res.emplace_back(*temp);
delete temp;
}
return res;
}

template<typename T>
void f(const T& param){
cout << "T type: " << typeid(T).name() << endl;
cout << "param type: " << typeid(param).name() << endl;
}

int main() {
std::vector<int> vp{1,2,3};
const auto widgetVp = produce(vp);

if(!widgetVp.empty()){
f(&widgetVp[0]);
}

Widget* wid = new Widget(10);
f(wid);
delete wid;

cout << "done" << endl;
return 0;
}
得到输出:
1
2
3
4
T type: PK6Widget
param type: PK6Widget
T type: P6Widget
param type: P6Widget
其中使用工厂模式获取构造对象时,PK6Widget指的是Pointer to const Widget(const Widget*),而使用正常堆区构造,P6Widget指的是Pointer to Widget(Widget*),无论哪一个,两个结果模板类型推导理论相悖的,至少Tparam推导结果不应该相同。对于工厂模式,向左值引用传入一个左值常量指针即const Widget*,T应该是const Widget*,对应的param应该是const Widget* const&(此处为什么不是Widget*const Widget*&,在条款01我已经讲过了)。可见,编译器的输出并非是完全可信的。

使用boost库能推导比较准确的结果:

1
2
3
4
5
6
7
#include <boost/type_index.hpp>

template<typename T>
void f(const T& param){
cout << "T type: " << boost::typeindex::type_id_with_cvr<T>().pretty_name() << endl;
cout << "param type: " << boost::typeindex::type_id_with_cvr<decltype(param)>().pretty_name() << endl;
}

输出结果:

1
2
3
4
5
T type: Widget const*
param type: Widget const* const&

T type: Widget*
param type: Widget* const&
这个结果是完美符合模板推导理论的。

第二章 auto

本章会覆盖描述关于auto必要的若干使用场景。

条款05:优先考虑auto而非显式类型声明

必须初始化

1
2
int x;   //可能声明一个未定义变量
auto x; //错误,不可能这样声明

闭包

闭包是一个抽象的概念,通常可以理解为Lambda函数,其特点就是能够捕获函数内的其他变量和环境,但是它的返回类型只有编译器才知道,这些特点使得我们专注于函数的功能,而无需太过纠结我们究竟想返回什么样的类型才算正确,例如:

1
2
3
4
5
6
7
8
template <typename It>
void dwim(It b, It e){
while(b != e){
typename std::iterator_traits<It>::value curValue = *b; //curValue的类型来自迭代器指向的元素类型
//who cares?I choose auto:
auto curValue = *b;
}
}

另一方面,因为闭包不会显式说明要返回的类型,auto符合这种惰性习惯,如:

1
2
3
4
5
6
7
8
9
//C++ 11:
auto derefUPLess = [](const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2){
return *p1 < *p2;
}

//C++ 14中更离谱:
auto derefUPLess = [](const auto& p1, const auto& p2){
return *p1 < *p2;
}
现在你收到一个任务,将所有Lambda函数换成普通函数形式,你会发现束手无策,但C++ 11提供了std::functionstd::bind,是的,这两种形式允许保存或者生成一个闭包(或者任何可调用对象),例如使用前者,你必须写成:
1
2
3
std::function<bool(const std::unique_ptr<Widget>& p1, const std::unique_ptr<Widget>& p2)> func = 
[](const std::unique_ptr<Widget> &p1, const std::unique_ptr<Widget> &p2){
return *p1 < *p2; };
这种写法导致占用空间更大(有可能使用额外堆区空间)、速度更慢、且可能发生内存泄漏,与auto方法对比毫无优势。尽管是std::bind,在条款34也会发现根本比不上一个Lambda

意料外的拷贝

auto还可以防止因为某些类型认识的错误,导致冗余的对象创建和销毁,如遍历一个哈希表:

1
2
3
4
std::unordered_map<std::string, int> m;
for(const std::pair<std::string, int>&p : m){
...
}
咋一看好像很勤奋,但是哈希表的pair的键根本不是std::string,而是const std::string,所以为了完成这个类型转换,会特意地拷贝m中的对象作为临时对象,将引用绑定到该临时对象上,当使用完会被销毁,而这冗余的拷贝本来可以通过使用auto避免。

条款06:auto推导若非己愿,使用显式类型初始化惯用法

前几个条款怂恿开发者无脑使用auto,这个条款是悬崖勒马,一些情况下不掌握推导具体情况而使用有可能踩坑,其中一个坑来自std::vector<bool>,这个容器特点在条款03已经提过。

std::vector<bool>不同于其他std::vector<T>,在C++98时期就被认为一种设计失误。开始时他们希望std::vector<bool>实际占据更少的空间,事实上也做到了,容器内的每个bool对象只占据1比特,而不是1字节。但是代价却很大,因为不能对其取地址,其引用返回的也不是纯粹的bool&,这样的代码是一种错误:

1
2
3
4
//这无法编译:
std::vector<bool> bvp(8,false);
bool* bp = &bvp[0];
bool& bpr = bvp[0];
所以也注定了它不能转换为C风格数组:
1
2
//error:
bool* cbp = bvp.data();

但是C++ 11引入auto后,使得这样的代码通过称为可能:

1
2
std::vector<bool> bvp(8,false);
auto a = bvp[4];
根据条款03,一般STL的operator[]返回对象的引用,std::vector<bool>是一种例外,其返回的是一个std::vector<bool>::reference对象(这个类定义于std::vector<bool>中),为了模拟bool&特性,这个reference类中必然含有一个指针以及记录bit的偏移量,这个指针会在语句声明完后销毁(成为一个悬垂指针),所以任何函数再引用这个auto定义的a变量,会导致UB

所以首先应该使用std::deque<bool>或其他类型STL替代std::vector<bool>,再者使用显式声明:

1
2
std::vector<bool> bvp(8,false);
bool a = bvp[4]; //还可以

std::vector<bool>不是唯一,std::bitsetstd::bitset::reference亦是如此,这种引入代理模范原来特性的,称为代理类,这两个例子的代理都是不可见的(基本不可能看到显式声明一个std::vector<bool>::reference),另外一些代理类是可见的,比如智能指针就完全模拟了指针特性,而且代理类往往可以作为原类的初始化条件,但要注意这些隐含的代理类结合auto都可能导致UB,应该避免。

第三章 拥抱现代C++

条款07:区别使用()和{}初始化对象

花括号初始化是很通用了,例如圆括号不能初始化普通成员,等于号无法初始化不能拷贝的成员(如std::atomic),如:

1
2
3
4
5
6
7
8
9
10
11
12
class Person{
...
private:
int x{0}; //ok
std::actomic<int> y{0}; //ok

int x1(0); //error!
std::actomic<int> y1(0); //ok

int x2 = 0; //ok
std::actomic<int> y2 = 0; //error!
};
注意,不要把所有等号都认为是赋值,等号也可以是初始化概念,和括号、花括号等效,例如:
1
2
3
Widget x;
Widget y = x; //触发的是拷贝构造函数
x = y; //触发的是拷贝赋值函数

使用花括号也能触发构造函数,还能解决圆括号混淆调用构造还是作函数声明的歧义:

1
2
3
4
Widget w();    //既可以是无参构造、也可以是函数声明

Widget w1{};
Widget w2{10}; //均ok

但使用花括号没有好到值得无脑推广,例如,它禁止变窄转换(narrowing conversion),而等号圆括号没有这个问题:

1
2
3
4
5
double x, y, z;

int sum1{ x + y + z }; //error!
int sum2(x + y +z); //ok
int sum3 = x + y + z; //ok

再者,使用花括号初始化,如果构造函数含有std::initializer_list形参,会导致构造函数的选择产生倾向性!例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

using namespace std;

class Widget{
public:
Widget(int i, bool b){
cout << "This is Widget(int i, bool b)" << endl;
}
Widget(int i, double b){
cout << "This is Widget(int i, double b)" << endl;
}

Widget(std::initializer_list<long double> ld){
cout << "This is std::initializer_list<long double> ld" << endl;
}
};

int main(){
Widget w1(10, false); // This is Widget(int i, bool b)
Widget w2(10, 9.99f); // This is Widget(int i, double b)
Widget w3({false, 9.99f}); // This is std::initializer_list<long double> ld

Widget w4{10, false}; //全是This is std::initializer_list<long double> ld
Widget w5{10, 9.99f};
Widget w6{false, 9.99f};

return 0;
}
而如果使用std::initializer_list<T>时却引入了变窄转换,构造就会失败:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget{
public:
Widget(int i, bool b){
cout << "This is Widget(int i, bool b)" << endl;
}
Widget(int i, double b){
cout << "This is Widget(int i, double b)" << endl;
}

Widget(std::initializer_list<bool> ld){
cout << "This is std::initializer_list<long double> ld" << endl;
}
};

Widget w{10, 5.0}; //编译失败! 均不能变窄转换到bool

最后,使用花括号和圆括号的最大区别还是在设计上,例如std::vector<int>(10,20)std::vector<int>{10,20}是完全不同的含义,创建一个接受任意对象参数的可变模板,你就需要面对这样的问题:

1
2
3
4
5
6
template <typename T, typename... Ts>
void doSomeWork(Ts&&... params){
//create local T like this:
T localObj(std::forward<Ts>(params)...); //#1 //你选择使用哪种定义?
T localObj{std::forward<Ts>(params)...}; //#2
}
当类似这样调用:使用#1会产生10个对象,使用#2会产生2个对象:
1
doSomeWork<std::vector<int>>(10,20) ;
对于这样的要求,std::make_sharedstd::make_unique(见条款21)的解决方法是使用圆括号。

条款08:优先考虑使用nullptr而非NULL或0

NULL0其实都不是真正的指针类型f(0)f(NULL)都不太可能调用指针类型形参函数,而可能会调用整形形参的,NULL的一种实现是被定义为0L

1
2
3
void f(void*);
void f(int);
void f(bool);
f(NULL)会导致二义性,对应long的参数,既可能调用int版本,也可能调用bool版本,但不太可能调用指针版本。

这是一种反直觉,所以C98里常常需要避免同时重载整形和指针类型。

nullptrstd::nullptr_t类型,是真正的指针类型,会完美对应void f(void*)重载,而不会被解释为整形,此外,nullptr可以隐式转换到所有指针类型(包括智能指针),例如一个例子:

1
2
3
4
5
6
7
8
9
10
11
int f1(std::shared_ptr<Widget> spw){
return 10;
}

double f2(std::shared_ptr<Widget> spw){
return true;
}

bool f3(Widget*){
return true;
}
这种情况,传入nullptrNULL0或者都可以通过编译,但是我们将其模板化,完整代码为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <mutex>
#include <memory>

using namespace std;

using MuxGuard = std::lock_guard<std::mutex>;
class Widget{

};

int f1(std::shared_ptr<Widget> spw){
return 10;
}

double f2(std::shared_ptr<Widget> spw){
return true;
}

bool f3(Widget*){
return true;
}

//传入一个互斥量,每个函数参数都是指针,但指针类型不同
template <typename FunType, typename MutexType, typename PtrType>
decltype(auto) lockAndCall(FunType func, MutexType& mutex, PtrType ptr){ //看不懂返回值,你需要回顾前三条条款
MuxGuard mtx(mutex);
return func(ptr);
}

int main(){
std::mutex mtx;
lockAndCall(f1, mtx, nullptr);
lockAndCall(f2, mtx, nullptr);
lockAndCall(f3, mtx, nullptr);

//均无法编译:
// lockAndCall(f1, mtx, 0);
// lockAndCall(f2, mtx, 0);
// lockAndCall(f3, mtx, 0);

// lockAndCall(f1, mtx, NULL);
// lockAndCall(f2, mtx, NULL);
// lockAndCall(f3, mtx, NULL);

return 0;
}
可见只有nullptr能完美推导成指针,而另外两种都会被推导为int导致无法找到函数模板而无法通过编译。

条款09:优先考虑using而非typedef声明别名

typedefusing都能声明一个别名,如:

1
2
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>
但using在两个方面是更优越的选择,其一是函数指针更加直观,其二是模板使用更加流畅。

函数指针

using声明的函数指针更加直观:

1
2
3
typedef void(*FP)(int, const std::string&);

using FP = void(*)(int, const std::string&);

函数模板

C/C++/Qt 修炼手册就提过,using很容易结合模板使用,而typedef无法定义泛型的模板别名,实际上不准确,typedef结合struct或者class就可以定义模板别名,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
using MyAllocList = std::list<T,MyAlloc<T>>; //定义一个泛型列表,自定义分配器MyAlloc

template <typename T>
struct MyAllocList_T{ //需要定义一个模板类作为类型
typedef std::list<T,MyAlloc<T>> type;
}

//using调用:
MyAllocList<Person> p;

//typedef:
MyAllocList_T<Person>::type p;
看的出来能和模板结合,这并不是typedef本身特性,而是模板类的帮助,type的类型要依赖于模板泛型T的推导,因此MyAllocList_T<T>::type被称为依赖类型依赖类型的一个特点是模板内部你要加上typename声明这是一个类型含义,而不是对象,如:
1
2
3
4
5
template <typename T>
class Widget{
private:
typename MyAllocList<T>::type list; //模板内部 的 类型前要加typename
};

编译器有必要根据typename来标识对象类型,例如一种特化版本的,MyAllocList_T<T>::type被定义成对象:

1
2
3
4
5
template<>
class Widget<Wine>{
private:
WineType type; //god! Widget<Wine>::type is an object !
};

使用using无需考虑是否要加"typename"声明,因为编译器会将其视作非依赖类型。

在函数模板上两种方法的区别也影响到了模板元编程中类型萃取的一些特性,如去除引用、常量特性的一些关键字:

1
2
3
std::remove_const<T>::type   // const T to T
std::remove_reference<T>::type //T& T&& to T
std::add_lvalue_reference<T>::type // T to T&
这些在C++11 引入,底层都是使用类似typedef方法实现的,因此它们全是依赖类型,在模板内部都得加上typename声明类型。

因此在C++14,std::transformation<T>::type全都成为了std::transformation_t<T>,尽管不支持C++14,使用using定义等效的定义是简单的:

1
2
template <class T>
using remove_const_t = typename remove_const<T>::type;

条款10:优先考虑限域枚举而非未限域枚举

限域

众所周知,C++ 11 enum class比起C++98的enum,首要是支持限域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum class Color{
black,
white,
red
};

enum class Cow{
black,
white,
red
};

Color c = Color::red;
Cow c = Cow::red; //OK

int red = 10; //变量叫red也ok

强类型

enum class声明的枚举类型属于强类型,意味着它不会被隐式地转换成整型或者浮点型

1
2
3
4
5
6
7
8
enum Color{black, white, red};
enum class Colorc{black, white, red};

Color c;
if(c < 9.9f){} //ok,隐式转换了

Colorc cc;
if(static_cast<double>(cc) < 9.9f){} //必须显示声明才能编译

但毕竟并非所有的隐式转换都是有害的,有时非限域类型更加方便,例如在tuple使用里面:

1
2
3
std::tuple<std::string, std::string> t{"Eden", "White"};

cout << std::get<0>(t) << std::get<1>(t);
但为了记住是名字放左、颜色放右,你会引入枚举做这件事,例如非限域枚举
1
2
3
enum Define{name=0, Color = 1};
std::tuple<std::string, std::string> t{"Eden", "White"};
cout << std::get<Define::name>(t) << std::get<Define::Color>(t) << endl;
这个代码能正常工作是因为Define枚举隐式转换成了整形,而如果使用enum class,就必须将显式转换声明出来:
1
std::get<static_cast<std::size_t>(Define::name)>(t);
如果想避免粗犷的转换,更复杂的方法是通过模板函数,更一般化的,枚举真实类型可以不使用笼统的std::size_t,C++ 11是支持通过std::underlying_type获取枚举类型的真实底层类型的,但要求这个类型产生必须在编译期完成,所以模板函数使用constexpr,如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <tuple>

using namespace std;

template <typename T>
constexpr typename std::underlying_type<T>::type toUtype(T e) noexcept{ //也可以替换成std::underlying_type_t<T>
return static_cast<typename std::underlying_type<T>::type>(e);
}

int main(){
enum class Define{name=0, Color = 1};
std::tuple<std::string, std::string> t{"Eden", "White"};
cout << std::get<toUtype(Define::name)>(t) << std::get<toUtype(Define::Color)>(t) << endl;

return 0;
}

前置声明

enum class默认使用int类型存储,因此能直接前置声明,而enum必须显式指明底层类型才可以:

1
2
3
4
enum class Status; //默认就是int

enum Status; //错误,无法获知大小
enum Status : std::uint8_t; //ok
前置声明的好处是在引入新的枚举子项后,需要重新编译所有含定义的头文件。

条款11:优先考虑使用delete而使用未定义的私有声明

本条款是对读书笔记:Effective C++(第三版)的条款06作更新说明,在C++ 11以后,直接使用delete比将函数声明为private适用性更广,例如你无法将某个函数模板的特化单独列为private,而可以直接delete:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Widget {
public:

template<typename T>
void processPointer(T* ptr)
{ … }

private:
template<> //错误!
void processPointer<void>(void*);
};

class Widget {
public:

template<typename T>
void processPointer(T* ptr)
{ … }

};
template<> //还是public,
void Widget::processPointer<void>(void*) = delete; //但是已经被删除了

条款12: 使用override声明重写函数

重写一个函数,必须满足以下条件

  1. 基类必须是virtual虚函数

  2. 基类派生类函数名称必须一致析构函数除外);

  3. 基类派生函数形参类型必须一致;

  4. 基类派生类的constness常量性必须一致,如void doWork() const;

  5. 基类派生类返回值异常说明必须兼容;

  6. (C++11新增)函数的引用限定符(referecne qualifiers)必须一致,函数的引用调用符指的是函数名后的&&&,其中&或者const &限定函数只能被左值调用,&&只能被右值调用,const&&能被左右值对象调用,如下:

    1
    2
    3
    4
    void doWork() &;
    void doWork() &&;
    void doWork() const &;
    void doWork() const &&;

    有时有必要区分左右值调用的对象,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Widget{
    public:
    using DataType = std::vector<double>;
    ...

    DataType& data(){
    return values;
    }
    private:
    DataType values;
    };

    当使用类似工厂模式的对象获取返回值时,会变成:

    1
    auto vals2 = makeWidget().data();
    这样的代码会从makeWidget产生的右值对象中的vector又拷贝出一个vector,纯属浪费(因为根本不可能直接使用右值对象本身的vector),因此完全可以将其利用起来避免一次拷贝,在Widget中新增一个右值限定符:
    1
    2
    3
    4
    5
    6
    7
    8
    DataType data() &&{
    return std::move(values); //此时触发移动拷贝
    }

    //左值版本也修改成左值限定符:
    DataType& data() &{
    return values;
    }

因为有可能人工破坏或者忘记是否符合6个重写条件,因此本条款的核心是建议派生类的重写都需加上override关键字,当重写失败时编译器会报错,而且override仅在函数名后会被解析为关键字,意味着作为函数名称时也不会报错:

1
2
3
void override(){  //ok
...
}

条款13:优先考虑使用const_iterators而非iterators

const_iteratorconst含义同pointer-to-const,指向常量的指针(即常量指针),当我们没必要修改迭代器指向的值时可以考虑使用。

在C++98中,const_iterator的支持是非常不完善的,这样的代码可能不会通过编译

1
2
3
4
5
6
typedef std::vector<int>::iterator IterT;
typedef std::vector<int>::const_iterator ConstIterT;

std::vector<int> value{1, 2, 3};
ConstIterT it = std::find(static_cast<IterT>(value.begin()), static_cast<IterT>(value.end()), 4);
value.insert(static_cast<IterT>(it), 4);
莫说static_cast,就算是reinterpret_cast,也无法找到const_iterator转换到iterator的方法,因此会导致编译失败;

而C++ 11的findinsert都很好地兼容了const_iterator,尽管是non-const容器,通过cbegincendcrbegincrend等:

1
2
auto it = std::find(value.cbegin(), value.cend(), 4);
value.insert(it, 4);

C++ 11唯一没弥补的遗憾仅支持std::beginstd::end,不支持cbegincendrbeginrendcrbegincrend等,在C++ 14才完善了这块的支持,但是这块的实现也不难,例如通过std::begin间接支持std::cbegin

1
2
3
4
template <class C>
auto cbegin(const C& container)->decltype(std::begin(container)){
return std::begin(container);
}
因为const模板将产生constnesss特性的container,这样的容器返回的是一个const_iterator,C++ 11支持下(加入编译参数"-std=c++11"),仅可以写成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

template<typename T>
auto cbegin(const T& container)->decltype(std::begin(container)){
return std::begin(container);
}

template<typename T>
auto cend(const T& container)->decltype(std::end(container)){ //不支持C++14,也无法写成decltype(auto)
return std::end(container);
}

template <typename T>
void getInterator(const T& container){
auto begin = cbegin(container); //C++ 14则可以直接使用std::cbegin和std::cend
auto end = cend(container);
cout << *begin << *(--end) << endl;
}

int main(){
std::vector<int> value{1, 2, 3};

getInterator(value); //输出1 3

cout << "done" << endl;
return 0;
}

条款14:如果函数不抛出异常请使用noexcept

条款提到了三个方面,当确信函数不会抛出异常时,应该使用noexcept声明:

  1. 编译器生成的代码更加优化:不同于C++98的异常声明throw()行为,C++ 11的noexcept在展开调用栈和可能展开调用栈行为很不同,直白的说,使用noexcept,C++ 11可能会直接调用std::terminate()终止程序,不会保证资源被反序析构,意味着运行时调用栈不用处于可展开状态,而C++ 98的则需要保证,因此灵活性优化实际不如C++ 11 :

    1
    2
    int f(int x) throw();   //C++98写法,较少优化
    int f(int x) noexcept; //C++11,极尽所能的优化

  2. 移动操作的必要;许多STL接口遵循"如果可以则移动,如果必要则复制"策略,例如std::vector::push_backstd::vector::reservestd::deque::insert等,这些接口要求被处理对象声明noexcept时,才会真正进行移动操作,否则可能出现如果移动第n+1个对象抛出异常,前n个对象既无法复原、也无法继续移动的窘境。

  3. noexcept依赖成员组件noexcept特性;例如一个Widget数组是否noexcept,取决于组成这个数组的Widget对象是否noexcept,如果低层次对象不保证noexcept,那么高层次抽象也不会保证noexcept;

但注意,不应该为了追求noexcept性能,盲目将函数声明为noexcept,大部分函数是异常中立函数(exception-neutral),这些函数自己不会抛出异常,但是其内部的函数可能会丢出异常。此外,析构函数内存释放函数(operator delete/operator delete[])本身是隐式noexcept的。

条款15:尽可能地使用constexpr

对于变量而言,constexpr的含义就是const特性+编译期确定特性,当一个变量编译期确定且不再修改时,应该声明为constexpr。

constexpr函数则有点特殊,请记住:

  1. constexpr的函数实参如果编译期确定,那么将在编译期运行;如果constexpr的实参并非编译期可知,并且处于需求编译期常量上下文,那么代码编译会失败;

  2. 尽管constexpr函数的值编译期不可知,constexpr会退化成普通函数,不会导致编译失败;

咋一看1和2好像非常矛盾,编译期不可知时究竟编译成不成功,关键在于constexpr的函数是否被用于需求编译期常量的上下文,例如:

1
2
3
4
5
6
int x = 5;
constexpr int square(int n){
return n*n;
}

int y = square(x); //ok
这样的constexpr是ok的,因为最多就是按特性2退化成普通函数,运行时才计算平方,但是一旦进入了一些需求编译期上下文,典型的例子如作为数组大小、作模板参数、case标签、赋值给constexpr变量、作枚举类型值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//全都报错!
std::array<int, square(x)>

template <int N>
struct Person(){
...
};
Person<square(x)> p;

case square(x):

constexpr int y = square(x);

enum class Color{
Blue = square(x),
...
}
C++ 11的两个关于constexpr限制是其一不能用于修改成员变量的成员函数,因为使用constexpr的成员函数是隐式const的;其二是void类型不属于返回字面值类型函数,不应该使用constexpr,但在C++ 14以后,这样的限制被取消了,因此这样写也是正确的:
1
2
3
4
5
6
7
8
class Person{
public:
constexpr void setNum(double newNum) noexcept{ //ok
num = newNum;
}
private:
num = 0;
};

条款16:让const成员函数线程安全

此条款强调const成员函数可能只是考虑其一般并发使用,因为不常修改成员变量,而如果需要引入缓存机制来修改变量,也是可以,变量加mutable即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Widget {
public:

int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; //有点问题
cacheValid = true;
return cachedValid;
}
}

private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};
为什么要修改成员变量,还需要使用const函数呢,本质是因为Widget对象确定,魔数即确定了,只有对象改变时这个缓存才需要刷新。

重点不是这个,虽知std::atomic开销低于互斥锁,但是两个原子量需要赋值更新时是有问题的,上面的代码可能出现赋值更新,但是因为没有马上置为true,另一个线程会重新进行计算;先置true,再赋值,问题更大,完全可能返回一个未完成赋值的变量。

因此两个单元及以上的线程安全,还是建议使用互斥锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Widget {
public:

int magicValue() const
{
std::lock_guard<std::mutex> guard(mtx);
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; //有点问题
cacheValid = true;
return cachedValid;
}
}

private:
mutable std::mutex mtx;
mutable bool cacheValid{ false };
mutable int cachedValue;
};

条款17:理解特殊成员函数的生成

在C++ 98中,编译器自动生成的四个函数是默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符,它们是隐式public、inline且非虚的,唯一的例外是作为派生类且继承父类的虚析构函数。

C++ 11又引入了两种生成函数:移动构造函数和移动赋值运算符函数,如:

1
2
3
4
5
class Widget{
public:
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
};
与拷贝操作不同,若干种行为会导致不会自动生成移动构造或者移动赋值函数:

  1. 用户单独定义移动构造或者移动赋值,都会导致两个移动函数不会自动生成,拷贝也不会生成;而在拷贝中,定义拷贝构造不影响拷贝赋值的生成,定义拷贝赋值也不影响拷贝构造的生成;

  2. 用户单独定义了拷贝构造或拷贝赋值,移动函数也不会生成;

  3. 用户单独定义了析构函数,移动函数不会生成,但拷贝函数仍然可以生成,但被废弃;根据Rule of Three原则,析构函数、拷贝构造、拷贝赋值运算符函数,三个函数任意定义一个,应当同时自定义另外两个,现在也应该提供移动函数的定义。

如果你自定义了析构函数,还想继续使用默认生成的拷贝或者移动函数,可以使用default,最常用是在多态类中:

1
2
3
4
5
6
7
8
9
10
class Base{
public:
virtual ~Base() = default; //虚析构也是属于你自定义的

//因此应当声明default:
Base(Base&&) = default; //支持移动
Base& operator=(Base&&) = default;
Base(const Base&) = default; //支持拷贝
Base& operator=(const Base&) = default;
};
实际上,尽管满足自动生成调节,使用default声明都是一种好的习惯。

第四章 智能指针

原始指针存在若干问题:

  1. 无法确认指针是单个对象还是数组对象,需要区分使用delete还是delete[],用错都是UB;

  2. 无法确认指针是否野指针/悬空指针;

  3. 需要知道使用什么析构还是delete直接删除;

  4. 没delete、多次delete都会造成问题;

C++ 11存在四种智能指针:std::unique_ptr、std::shared_ptr、std::weak_ptr和std::auto_ptr;std::auto_ptr,但最后一种属于C++98末期的产物,C++ 11后可以被std::unique_ptr完美替代。

条款18:独占资源使用std::unique_ptr

std::unique_ptr典型的使用是工厂函数返回一个指针,该工厂下含有三个子类: 工厂子类

调用者只需使用该对象,std::unique_ptr在自己销毁时会释放资源:

1
2
3
4
5
6
7
template<typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params){ //参数为构造指针所需实参
...
}

//调用:
auto pInvestment = makeInvestment(arguments); //
再者,std::unique_ptr支持自定义删除器,例如自定义一个析构前打印日志的删除器,类似:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
auto delInvmt = [](Investment* pInvestment){
makeLogEntry(pInvestment); //打印日志
delete pInvestment;
}

template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> makeInvestment(Ts&&... params){
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if(/*一个Stock对象应被创建*/){
pInv.reset(new Stock(std::forward<Ts>(params)...));
}else if(/*一个Bond对象应被创建*/){
pInv.reset(new Bond(std::forward<Ts>(params)...));
}else if(/*一个RealEstate对象应被创建*/){
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
此处父类指针根据不同需要指向了不同的派生类实例,也要求了父类必须使用虚析构virtual ~Investment(),以调用子类析构来析构子类对象。

且注意,一般而言std::unique_ptr和一般的指针占用大小相当,但是如果使用了自定义删除器,智能指针的大小可能产生大尺寸膨胀。

再者,std::unique_ptr也可以指向原始数组指针,类似std::unique_ptr<T[]>,但因为智能指针结合vector等使用更加现代化,使用数组指针唯一的用处是意欲获取C风格数组对象的所有权。

最后std::unique_ptr能隐式且快速转换成共享指针:

1
std::shared_ptr<Investment> sp = makeInvestment(arguments);

条款19:对于共享资源使用std::shared_ptr

std::unique_ptr不支持拷贝语义,仅支持移动语义,而std::shared_ptr允许拷贝,拷贝的结果是增加指针的引用计数,引用计数大部分实现都是使用原始指针指向计数资源,因此std::shared_ptr大小是原始指针的两倍。

除了这些差异,一个细节是std::shared_ptr声明自定义删除器时删除器类型不属于智能指针的类型,这点和std::unique_ptr有别:

1
2
3
4
5
6
7
8
9
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::shared_ptr的一部分,共享指针的内存包含原始指针和控制块指针,自定义删除器内容和引用计数、后面会提到的弱引用计数都是该控制块的一部分,其内存分布如下: 共享指针内存

控制块的创建遵循以下规则: 1. 使用std::make_shared构造std::shared_ptr,总是会创建新的控制块;

  1. 从std::unique_ptr构造std::shared_ptr也会创建新的控制块,且独享指针会被置null代表控制权转移;

  2. 从原始指针上构造std::shared_ptr,会创建新的控制块;因此不建议使用原始指针构造共享指针,两次构造会导致一个原始指针含两个引用计数,从而销毁两次,造成UB;

    1
    2
    3
    auto pw = new Widget;
    std::shared_ptr<Widget> spw1(pw, loggingDel);
    std::shared_ptr<Widget> spw2(pw, loggingDel); //二次构造,危险!

  3. 从std::shared_ptr或者std::weak_ptr构造std::shared_ptr,不会创建新的控制块,因为它可以依赖传递而来的控制块;

由于第三点原因,尤其推荐使用std::make_shared构造共享指针,但如果使用自定义删除器就无法使用std::make_share了,可以写成:

1
2
std::shared_ptr<Widget> spw1(new Widget, loggingDel);  //在智能指针里面new
td::shared_ptr<Widget> spw2(spw1); //继承spw1的控制块
另一个意外是this指针,这个在之前的文章提过,直接返回或者存储this指针是危险的行为:
1
2
3
4
5
6
7
8
class Widget{
public:
void process(){
processedWidgets.emplace_back(this); //危险!
}
private:
std::vector<std::shared_ptr<Widget>> processedWidgets;
};
这样的代码可以通过编译,但是如果类外含有同一个对象的this指针,可能会造成上述UB问题,所以需要使用std::enable_shared_from_this:
1
2
3
4
5
6
class Widget:public std::enable_shared_from_this<Widget>{
public:
void process(){
processedWidgets.emplace_back(shared_from_this());
}
};
要注意,使用shared_from_this()时,必须保证已经存在一个std::shared_ptr指向该对象,否则会因找不到控制块导致UB。

最后要知道的是,std::shared_ptr不支持原始数组即std::shared_ptr<T[]>,也不能直接转换到std::unique_ptr;

条款20:当std::shared_ptr可能悬空时使用std::weak_ptr

std::weak_ptr不是独立的智能指针,而是std::shared_ptr的强化。

std::weak_ptr的第一个作用是在不增加std::shared_ptr引用数时,检查std::shared_ptr指向的对象是否仍然存在,如果指针悬空被称为弱引用过期,如:

1
2
3
4
5
6
7
auto spw = std::make_shared<Widget>();
std::weak_ptr<Widget> wpw(spw);
spw = nullptr; //弱引用过期

if(wpw.expired()){
...
}
但弱引用指针不能直接解引用对象,因为判断过期、解引用是有可能引入竞态条件的,弱引用指针的做法是如果资源有效,返回一个std::shared_ptr对象:
1
std::shared_ptr<Widget> spw1 = wpw.lock();
如果wpw过期,这样的std::shared_ptr会成为nullptr,因此std::weak_ptr可以被用于缓存机制,在条款18中,我们说返回std::unique_ptr是常用做法,但是如果构造对象的代价过大,缓存这样的对象可能会更加性能化,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
std::shared_ptr<const Widget> fasrLoadWidget(WidgetId id){
static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto it = cache.find(id);
if(it != cache.end()){
auto objPtr = it->second.lock();
if(objPtr)
return objPtr;
cache.erase(it); //清理过期弱引用
}
auto objPtr = loadWidget(id);
cache[id] = objPtr;
return objPtr;
}

另一个弱引用典型使用是解除循环引用,在此前文章提过,不赘述;

条款21:优先考虑std::make_unique和std::make_shared而非使用new

使用make而不是new有若干好处:

  1. make的异常安全性更好,例如在一个类的构造中:

    1
    2
    3
    processWidget(std::shared_ptr<Widget>(new Widget), computePriority());

    processWidget(std::make_shared<Widget>(), computePriority());
    构造会分成三步进行,首先new出Widget内存,然后执行computePriority计算优先级,最后绑定共享指针构造Widget,二三步的顺序可能对换,无论如何,后面两步发生异常时,new方法会导致内存泄漏,而对于make方法,如果先执行共享指针构造,那么将保证指针中对象有效,如果先执行computePriority函数,如果异常make方法能保证Widget资源析构,无论如何不会造成内存泄露;

  2. 对make_shared而言,产生更少内存开销;make_shared分配的内存是一次性的,而new方法对象和控制块内存是分离的,更加占用程序空间。

但make方法不能替代所有场景的new方法,例如对于make_unique,至少两种场景受到限制:

  1. 前述条款所述,自定义删除器时需要使用new;

  2. 在条款7也提过,当使用花括号初始化时,你只能使用new或者曲线使用make,因为圆括号在容器中是n个m对象,而不是n和m对象:

    1
    2
    3
    4
    auto spv = std::make_shared<std::vector<int>>(new std::vector<int>{10, 20});

    auto initList = {10, 20};
    auto spv = std::make_shared<std::vector<int>>(initList);

对于make_shared,除了这两个场景,还有额外两个场景不能使用make:

  1. 不要使用make_shared去重载operator new和operator delete,后者new和delete都是默认sizeof(T)空间,使用make方法改变了内存布局和大小,在一些情况下会出现不兼容问题;

  2. 不希望使用连续空间时;虽然make_shared使用连续空间分配提高了程序性能和内存表现,但是其真正释放的时机取决于弱引用计数(通常情况下取决于是否还有weak_ptr指针引用这块内存),而new方法,只要引用计数为0(没有shared_ptr引用),对象内存优先释放,仅留下控制块内存等待弱引用归零。

因此,如何使用new、又避免内存泄露就成了下个问题,其实就是正常人的写法:

1
2
std::shared_ptr<Widget> spw(new Widget);  //先构造指针
processWidget(spw, computePriority()); //后构造对象
当然,如果智能指针完全为了构造对象,可以作move传入,移动优于拷贝,性能更优:
1
processWidget(std::move(spw), computePriority());

条款22:当使用Pimpl与std::unique_ptr,在实现文件定义特殊成员函数

Pimpl(Pointer to implementation)惯用法是在类的头文件定义中使用结构体或者类指针指向其他类或成员变量,以减少引入的头文件以减少编译时间,例如一个Widget类的定义:

1
2
3
4
5
6
7
8
9
class Widget{
public:
Widget();
~Widget();
...
private:
struct Impl; //成员类都放在这里面
Impl* pImpl;
}
在cpp文件定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl{
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
};

Widget::Widget():pImpl(new Impl){}
Widget::~Widget(){delete pImpl;}

C++ 11 Pimpl必须定义析构

换到C++ 11,只需要使用智能指针替换原始指针,但注意:Pimpl惯用法必须声明析构函数(可以空实现)或者使用default行为,因为std::unique_ptr禁止默认析构函数删除一个不完整类型(此处头文件的Impl仅含声明,缺省定义就算一种不完整类型),会触发static_assert,因此实例化Widget无法通过编译,要通过编译,必须让函数在析构前看到结构体Impl的完整定义,因此需要写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

class Widget{
public:
Widget();
~Widget();
...
private:
struct Impl; //成员类都放在这里面
std::unique_ptr<Impl> pImpl;
}

//cpp文件:widget.cpp
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl{
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
};

Widget::Widget():pImpl(std::make_unique<Impl>()){}
Widget::~Widget(){}
//或者
Widget::~Widget() = default;

C++ 11 Pimpl移动实现必须放在实现文件

切记条款17,定义析构函数会导致移动函数无法自动生成,其中重要原因之一是移动函数本身会含有销毁成员pImpl的代码事件,因此直接在头文件中定义移动函数的默认实现也有问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Widget{
public:
Widget();
~Widget();

//错误代码:移动函数析构了不完整类型
Widget(Widget&& rhs) = default;
Widget& operator=(Widget&& rhs) = default;

//正确写法:仅声明,default或者空实现放在cpp文件:
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);

private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};

C++ 11 Pimpl含move-only类型需手动实现拷贝

因为使用了std::unique_ptr这种类指针,编译器不会自动生成拷贝函数,就算生成也未必是我们想要的深拷贝,因此我们在Widget类中最好也定义自己的拷贝函数:

1
2
3
4
5
6
7
8
Widget::Widget(const Widget& rhs)   //拷贝构造函数
: pImpl(std::make_unique<Impl>(*rhs.pImpl)){}

Widget& Widget::operator=(const Widget& rhs) //拷贝operator=
{
*pImpl = *rhs.pImpl;
return *this;
}
以上三条建议仅适用于std::unique_ptr,std::shared_ptr无需遵循,本质是因为std::unique_ptr的删除器是智能指针的一部分,以生成运行时数据结构较小、速度更快的代码,因此不允许出现不完整类型,而std::shared_ptr没有这个限制;

第五章 右值引用 移动语义 完美转发

条款23:理解std::move和std::forward

实际上,std::move并不移动什么,而std::forward也不会转发什么,它们并不产生任何执行代码,它们本质只是一种执行转换的函数模板。而且,使用std::move和std::forward都不会保证必然调用移动操作而不是拷贝操作。

std::move

例如C++ 11的一种std::move实现:

1
2
3
4
5
template<typename T>
typename remove_reference<T>::type&& move(T&& param){
using ReturnType = typename remove_reference<T>::type&&;
return static_cast<ReturnType>(param);
}
这个函数接受一个万能引用,返回一个右值引用。根据条款1的模板推导理论,传入左值、左值引用和右值引用,T都会被推导为左值引用,所以此处我们还需要去除这个引用特性,并加上两个取地址符代表返回一个右值引用对象。

移动函数对象不允许const特性

注意移动函数和拷贝函数不同,是不带const特性的:

1
2
3
4
5
6
7
8
9
class string{
public:
...
string(const string& rhs);
string& operator=(const string& rhs);

string(string&& rhs);
string& operator=(string&& rhs);
};
所以尽管你使用了std::move,但带上了const特性,调用的还是拷贝函数:
1
2
3
4
5
6
7
class Person{
public:
//实际上还是使用拷贝函数
explict Person(const string text):value(std::move(text)){...}
private:
string value;
};

std::forward<T>

std::move唯一能保证的事情是一定会返回一个右值引用,std::forward的特性是保留左右值特性,当传入右值时调用右值版本函数:

1
2
3
4
5
6
7
8
void process(const Widget& lvalue);
void process(Widget&& rvalue);

template<typename T>
void logAndProcess(T&& param){
log(...);
process(std::forward<T>(param));
}

条款24:区分万能引用和右值引用

只有两种情况被归结于万能引用:

1
2
3
4
5
template<typename T>
void f(T&& param); //万能引用

Widget&& var1 = Widget();
auto&& var = var1; //万能引用
其共同点是T和var都需要被类型推导,在C++14中,auto代表的万能引用可能广用于lambda函数中,例如一个计算函数调用时间开销的lambda:
1
2
3
4
5
auto timeFuncInvocation = [](auto&& func, auto&&... params){
record start time...
std::forward<decltype(func)>(func(std::forward<decltype(params)>)(params)...);
record end time...
}
函数timeFuncInvocation可以对任意数目、任意类型的近乎任意的函数进行计时,条款30会讨论哪些函数例外,此处为什么使用std::forward来调用,会在条款33进一步介绍。

另外一些类型推导可能被误认为是万能引用,实际上都是普通右值引用而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//STL T&&不算T&&
template <typename T>
void f(std::vector<T>&& param); //右值引用

//const特性不行
template <typename T>
void f(const T&& param); //右值引用

//模板是类确定而不是函数确定
template <typename T>
class Person
{
public:
void f(T&& x); //右值引用

};

条款25:对右值引用使用std::move,对万能引用使用std::forward<T>

未完待续