前言

先开一小节介绍下我写这个的缘由。

很多年前,看过《Effective C++》,是一本好书,把一些 C++ 的细节抽出来深入探索,能学到好多东西。不过,这本书太老了,里边一些内容需要更新,著书时应该 C++14 还没出。前段时间工作有点空,就看了《Effective Modern C++》,可以认为是前者的重构版,引入了一些更新的 C++ 规范,如果你两本书都没看过,那只推荐去看《Effective Modern C++》。

然而,虽然两本书的原作者都是 Scott Meyers,但译者却不是同一个人,《Effective C++》的译者侯捷老师的翻译非常出色,然而,我在阅读《Effective Modern C++》时,却感到晦涩难懂。我不知道是我的理解能力不足,还是译者的中文水平有限,阅读时让我不得不找出英文原文,对照着理解一些中文版云里雾里的内容。

网络上除了官方出版的这个中译版,还有一些积极地团体对该著作重新翻译,所以我大不可能去做重复的事情。以下这个系列文章,只是我阅读笔记的整理。整理笔记可以梳理思路,发现理解的漏洞,并温习知识,甚至,还能发现一些书中的小错误,是大有脾益的。

如果你打算阅读这份笔记,我很欣慰,但我还是建议你如果有时间有精力,去阅读原著。我的笔记中会省略掉很多重复的、用于前后衔接的、以及众所周知的内容,而只整理出作者想要强调的部分。该系列书籍的内容采用罗列一些常用的 C++ 技巧而成文,每一条都称为一个条款,我将以相同的结构整理。我也适当地调整了条款的标题,正文的结构顺序和示例代码(通常改地更简单),并将值得关注的细节摘出高亮,唯一的目的就是让重点更突出,更快速地吸收知识。

摘录的重点分为技巧陷阱谬误,技巧是指可以学习并应用在工程实践中小 tip,陷阱是指容易在工程实践中犯错,导致程序 bug 的要点,谬误呢,是可能对 C++ 知识的错误理解。这些内容除了在正文中出现时列出,我还会在最后一章中单独列出,作为一个 check list 使用。

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

模板是泛型的一种体现。编译器在对模板做实例化时,其中一部分工作就是将泛型推导为实际的类型。
对于不同的情况,C++ 规范要求的模板类型推导方式略有差别,这也就导致了可能产生的误解和陷阱。

为什么要了解模板类型推导?这里给出书中一个有意思的例子。
提问,如果在声明一个数组 A 时,通过另一个未知长度的数组 B 的长度,作为数组 A 的长度,应该怎么做?
答案是可以利用模板类型推导,来静态推导出数组 B 的长度。
代码如下:

1
2
3
4
5
6
7
8
// 这个模板函数可以返回任意一个数组的长度 
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept {
return N;
}
// 假设 arrayB 是一个未知长度的数组(注意是数组,不能是指针)
// arrayA 声明长度一定和 arrayB 相同
int arrayA[arraySize(B)];

技巧:使用模板类型推导方法,可以在函数内推导出数组实参的长度。

情况 1:ParamType 是指针或引用,但不是万能引用

一个简单的模板示例代码如下,后续会基于这个示例代码做分析:

1
2
3
4
5
// 模板函数声明 
template<typename T> void f(ParamType param);

// 模板函数的调用
f(expr);

模板函数声明中,T 和 ParamType 可能是相同类型,也可能是不同类型,ParamType 是看到的代码所显示的类型,而 T 是编译器实际推导的类型。expr 为实参,param 为形参。

情况 1 的类型推导规则是:

  1. 去掉指针或引用类型
  2. 剩余部分做模式匹配

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T> void f(T& param); 

int x = 27;
const int cx = x;
const int &rx = x;

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

template<typename T> void g(const T& param);

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

情况 2:ParamType 是万能引用

这里不介绍什么是万能引用,它的形参声明方式类似右值引用。
类型推导规则是:

  1. 如果实参是左值,T 和 param 都会推导为左值引用。这是模板类型推导中,唯一一种会推导出引用类型的情况
  2. 如果实参是右值,则按情况 1 处理

比如:

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

int x = 27;
const int cx = x;
const int &rx = x;

f(x); // T 类型是 int&, param 类型是 int&
f(cx); // T 类型是 const int&,param 类型是 const int&
f(rx); // T 类型是 const int&, param 类型是 const int&
f(27); // 因为 27 是右值,按规则 1 处理,T 类型是 int,param 类型是 int&&

情况 3:ParamType 非指针和引用

按值传递的类型推导规则是:

  1. 如果实参有指针和引用类型,去掉指针或引用类型
  2. 如果是 cv 类型(const 或 volatile,下同),忽略 cv 类型。这是唯一会被忽略的情况

比如:

1
2
3
4
5
6
7
8
9
template<typename T> void f(T param); 

int x = 27;
const int cx = x;
const int &rx = x;

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

实参类型退化

数组类型和指针类型在编译器里是不相同的,我们经常混淆两者,是因为数组类型会在一些场合退化为指针类型,比如传参时。

但如果我们传参时,就是想传入数组类型(带有数组长度),而不是指针类型,其实也是有办法的,前边已经给出示例,也就是用引用类型来传递。

谬误:不要混淆数组类型和指针类型,它是 C++ 兼容 C 语言的一些遗迹。

另外,函数类型通过传参后,默认也会退化为函数指针。如果想要保留推导为函数类型,也可以按引用传递。

条款 2:理解 auto 类型推导

绝大多数 auto 类型推导的规则,和模板类型推导的规则是完全一致的,它也同样遵守 条款1:理解模板类型推导 涉及到的 3 个规则。

只有一个例外。

当使用 C++11 中引入的初始化列表来初始化 auto 类型时,不符合上述规则。

先看个例子:

1
2
3
4
5
6
7
8
9
10
11
// 以下显式声明为 int 的变量,它们的类型显然都是 int 
int x1 = 27;
int x2(27);
int x3 = {27}; // 这是统一初始化方式
int x4{27}; // 这个也是统一初始化方式

// 但以下使用 auto 来声明的变量,类型不一致
auto x1 = 27; // 类型为 int
auto x2(27); // 类型为 int
auto x3 = {27}; // 类型为 std::initializer_list<int>,值为 {27},注意,带大括号
auto x4{27}; // 同 x3

这条规则是: auto 类型推导时,变量的初始化表达式是通过大括号括起来时,auto 的推导结果类型是 std::initializer_list

而我们注意到,std::initializer_list 是一个模板,所以初始化表达式下的 auto 类型推导,实际发生了两次类型推导,第一次是推导变量类型是 std::initializer_list,第二次是推导初始化列表类型的模板类型,比如上例中的 int。所以,下边这种初始化会推导失败:

1
2
// 编译失败,因为 std::initizalizer_list 的模板参数类型推导失败(int 还是 double?) 
auto x5 = {1, 2, 3.0};

类似的,将初始化表达式传递给一个模板函数,模板函数的模板类型也会推导失败:

1
2
3
4
5
6
7
8
9
template<typename T> void f(T param); 

// 编译失败
f({1, 2, 3});

template<typename T> void g(std::initializer_list<T> param);

// 可以推导成功,T 的类型是 int
g({1, 2, 3});

陷阱:C++11 中,auto 推导初始化列表类型时,会推导为 std::initializer_list,其本意可能并非如此。

另外,C++14 中支持了使用 auto 来推导函数返回值或 lambda 表达式的形参类型。然而其本质依然是模板类型推导,所以如果函数返回值和 lambda 表达式参数给定的是初始化列表,同样无法完成推导。

条款 3:理解 decltype

decltype 是一个古老的语法,它可以获得一个名字(变量、函数等)或者表达式的类型。
在工程应用中,它常用于指代那些函数返回值类型需要依赖参数类型来决定的模板函数。比如说:

1
2
3
4
template<typename Container, typename Index> 
auto access(Container &c, Index i) -> decltype(c[i]) {
return c[i];
}

这个函数返回一个任意容器中一个元素的类型,类型推导通过 decltype 和函数返回值尾序声明的语法来完成。

但它存在一个问题,从 条款2:理解 auto 类型推导 中,我们知道,auto 作为模板函数返回值时,会忽略引用类型,所以,这个函数只能返回一个右值。如果我们希望操作容器中的原始元素,就无能为力了。

1
2
std::vector<int> vec{1, 2}; 
access(vec, 1) = 0; // 错误,右值不能被赋值

推导返回引用类型

上边的问题,在 C++14 中,可以利用 decltype 做到,一种比较奇怪的语法。如:

1
2
3
4
template<typename Container, typename Index> 
decltype(auto) access(Container &c, Index i) {
return c[i];
}

使用 decltype(auto) 可以在 auto 类型推导的规则下,不忽略引用性,从而使模板函数的返回值为一个引用类型。

1
2
std::vector<int> vec{1, 2}; 
access(vec, 1) = 0; // 正常编译

巧用万能引用实现传递原始引用类型

上述办法可以让模板函数返回左值,但为了同时可以支持左值返回值和右值返回值类型,只能通过重载写两套代码。

但如果你了解万能引用,那么就知道他可以只写一套代码:

1
2
3
4
template<typename Container, typename Index> 
decltype(auto) access(Container&& c, Index i) {
return std::forward<Container>(c)[i];
}

这是 C++14 的版本,C++11 中不支持 decltype(auto),可以改为:

1
2
3
4
template<typename Container, typename Index> 
auto access(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i]) {
return std::forward<Container>(c)[i];
}

技巧:使用万能引用和完美转发来实现传递引用性。

另外一个话题

抛开上边的话题,单独聊一下 decltype(auto),它的使用需要注意一个细节。

decltype 对简单变量的名字(变量名字是左值表达式)做推导时,会给出类型本身(非引用类型),但如果是复杂一些的左值表达式,比如把变量名字放在小括号里,就会推导出左值引用类型。这可能导致一些潜在的问题。

比如说:

1
2
3
4
5
6
7
8
9
10
11
// f1 的返回值类型是 int 
decltype(auto) f1() {
int x = 0;
return x;
}

// f2 的返回值类型是 int&
decltype(auto) f2() {
int x = 0;
return (x); // 因为 (x) 是一个表达式
}

陷阱:用 decltype 推导复杂的左值表达式时,会推导出左值引用类型。

条款 4:掌握查看类型推导结果的方法

除了使用编辑器的功能来显示名字的类型之外,C++ 提供了运行时的类型信息以及可操作的方法,即 typeid()

大多数情况下,它运行地很好。但需要注意有些场景下,它可能输出让你意外的结果。

比如:

1
2
3
4
template<typename T> void f(const T& param) {  
std::cout << "T = " << typeid(T).name() << std::endl;
std::cout << "param = " << typeid(param).name() << std::endl;
}

无论是哪个编译器,两个名字的类型都被推导为相同类型。但从代码中直观的看,param 类型应该总会比 T 多一个常量引用修饰才对。

编译器是正确的,实现符合 C++ 标准。所以这只能在使用时多留意。

boost 库提供了一个替代工具,可以推导出直观的类型,使用 boost::typeindex::type_id_with_cvr 来代替 typeid 即可。

陷阱:谨慎使用 typeid 来推导类型,可能会得到不预期的结果。


第一章结束了,如果有什么疑问或错误,请不吝指出。作者把类型推导作为第一章的内容,实话说有点劝退 C++ 新人,不过,这部著作确实也不适合新人阅读,适当的门槛也没问题。当然,最重要的是,类型推导实在太重要了,它一定程度上,可以作为 modern C++ 的基础去看待,后边很多问题都和类型推导有关系。

后续章节的内容也都准备好,整理格式后会陆续发出。

  1. Effective Modern C++ 读书笔记:类型推导
  2. Effective Modern C++ 读书笔记:auto
  3. Effective Modern C++ 读书笔记:转向现代C++
  4. Effective Modern C++ 读书笔记:智能指针
  5. Effective Modern C++:右值引用、移动语义和完美转发
  6. Effective Modern C++:lambda 表达式
  7. Effective Modern C++:并发 API
  8. Effective Modern C++:微调

本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1916895541884858605