条款 5:优先选用 auto

使用 auto 会带来很多好处,比如:

  • 不需要关注一些复杂的类型声明,比如模板函数和 lambda 表达式类型
  • 不会忘记初始化变量(auto 要求必须初始化)
  • 一些不希望发生的隐式类型转换
  • 代码类型变化时,不需要在多处修改类型,auto 自适应

使用 auto 来取代显式指定类型的建议,一个很大的反对声音,就是无法从代码中直观地看出一个变量的类型。针对这种强有力地反对声浪,实际上有多种理由来反驳,比如说:

  • 大多数现代编程语言,基本都支持类型推导的特性
  • 一个好的名字,比一个类型要重要的多。比如我们不需要知道迭代器的类型,但只要从名字中看出这是一个迭代器,就可以
  • 一些现代化的编辑器,都支持显示类型,这包括 vim/emacs 这些有年代感的软件
  • 默认使用 auto,但在一些特殊情况,比如需要明确指定类型时,显式声明类型

有关于 auto 的讨论,网络上有很多,我这里不一一列举。下面看几个书中的例子。

案例 1:使用 auto 替代函数对象

以下代码,我们希望使用一个类型来接受 lambda 表达式,通常的做法是使用 auto,让编译器去推导实际的类型,但另外,也可以通过一个 std::function 来接受 lambda 表达式。注意,lambda 表达式的类型并不是 std::function,它只有编译器知道。因为 std::function 可以接受任何可调用对象,所以也就可以接受 lambda 表达式。

1
2
3
4
5
6
7
8
9
10
11
// 最常见的写法,使用 auto 类型 
auto derefLess = [](const auto &p1, const auto &p2) { return *p1 < *p2; }

// 使用 std::function 的写法
// 必须指定一种形参类型,这里选择 std::unique_ptr<Widget>
std::function<bool(const std::unique_ptr<Widget>&,
const std::unique_ptr<Widget>&)>
derefLess2 = [](const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2) {
return *p1 < *p2;
}

使用 std::function 的版本,首先看着非常啰嗦。除此之外,还有一些背后的差异。

第一点是,std::function 比 auto 的版本,更占用内存。auto 在类型推导后,和它的实际类型的占用空间是一致的,而 std::function 是一个模板函数,它的大小是固定的(不依实际类型而变化),除了其本身,还包含固定大小的闭包变量。如果固定的内存并不够用,编译器就会生成在堆上分配额外空间来存储闭包变量,也就导致了更多的内存开销。

第二点是,std::function 通常不会内联,所以结合第一点,也就会更慢。

案例 2:遗漏类型修饰导致性能开销

以下代码中,我们在一个循环中对 unordered_map 的容器做遍历:

1
2
3
std::unordered_map<std::string, int> m; 

for (const std::pair<std::string, int> &p : m) { ... }

实际上,变量 m 的类型写错了,因为字典的键值应该是 const,所以其类型应该是 std::unordered_map<const std::string, int>

这种错误编译器可以处理,它会在遍历键值对时,主动一个个将键值类型转换为 const std::string,实现方法是重新拷贝一份,再将拷贝后的 const 版本和 p 绑定在一起。
这就带来了性能开销。

使用 auto 既安全又简洁地解决了这个问题:

1
2
3
std::unordered_map<std::string, int> m;

for (const auto &p : m) { ... }

条款 6:当 auto 推导不符合时,使用显式类型的初始化

大多数情况下,auto 推导出来的类型,和实际我们希望它应该是的类型是一致的。
但有一种情况是例外。讨论以下示例代码:

1
2
3
4
5
6
7
// 这是一个函数声明,返回 vector<bool> 
std::vector<bool> features(const Widget &w);

// 这是对函数的使用
Widget w;
bool res = features(w)[5]; // 获取第 5 个 bit 的 bool
processWidget(w, res); // 使用返回的 bool

这个代码没有任何问题,但如果我们将使用 features 的代码改为:

1
2
auto res = features(w)[5]; 
processWidget(w, res); // 未定义行为

问题就出现了。

表面原因可能很容易理解,如果了解过一些 std::vector 的知识,就会知道,获取 std::vector<bool> 的元素,并不会直接返回 bool。C++ STL 中,std::vector<bool> 的 operator[] 操作,返回的是一个特别的类型,std::vector<bool>::reference。也就是说,auto 类型推导的结果并不是 bool,而是 std::vector<bool>::reference 类型。

这是因为,通常 operator[] 操作返回的应该是某个元素的引用,而 C++ 中不允许对位进行引用,所以设计了这样一种结构。类似的问题也存在于 std::bitset 中。

由于 std::vector<bool>::operator[] 返回的是 std::vector<bool>::reference 类型,而后者的实现,有可能是一个指向 word 字内存的引用和一个字中位的偏移(std::vector<bool> 中使用 word 字来保存每个 bit)。

另外,我们注意到,features(w) 返回的实际是一个临时对象,那么,当临时对象被销毁时,std::vector<bool>::reference 中的内存引用就会失效,这个失效即发生在 auto res 被初始化后,从而导致下一行代码出现悬挂引用。

如果不使用 auto,使用 bool 指定实际类型,就不会有这个问题,原因是 std::vector<bool>::reference 实现了向 bool 的隐式类型转换。std::vector<bool>::reference 这种结构被称为代理类,在 C++ 实践中其实广泛存在,最常见的就是 std::shared_ptr<T>和 std::unique_ptr<T>。如果使用 auto 来推导返回代理类类型的对象类型,便会发生上述问题。

既想使用 auto 还想解决这个问题的办法,只能是使用显式的类型转换:

1
auto res = std::static_cast<bool>(features(w)[5]);

这种写法相比于最开始 bool res = features(w)[5] 的写法,是更值得推荐的,原因是,它可以显式表达出来这种类型转换,不容易引起错误。事实上,static_cast 总是应该被推荐的,任何隐式类型转换,都可能引入潜在的问题,而迷惑程序员。

这可能是唯一一个会阻碍你使用 auto 的理由,我认为这也是 C++ 设计的一个遗憾,能做的就是,在编码时,使用 auto 但不要忽视类型,C++ 依然是一个强类型语言。另外,对这种常见的代理类要有足够的敏感度,比如获取 std::vector<bool> 的元素,获取 std::bitset 的位,获取智能指针的原始裸指针。

陷阱:auto 固然好用,但不是万能的,使用 auto 时,时刻还需要留意背后推导的类型是什么。


这一章比较短,也没有什么难以理解的内容,更多的是一种编程习惯。
本系列其他文章:

  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/1932162651242340425