在读了大半本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*

情况二:形参是万能引用

结论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;

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:
1
2
3
4
5
auto x = 27
auto x(27);   //都是int

auto x = {27}; 
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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,其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
5
6
7
Widget w;

const Widget& cw = w;

auto myWidget1 = cw;                    //myWidget1的类型为Widget

decltype(auto) myWidget2 = cw;          //myWidget2的类型是const Widget&
当然,在此例中,auto&也能够保留cw的const和引用特性,但是你传入一个原始类型时,你必须又换成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:学会查看类型推导结果

未完待续