条款5:优先选用 auto, 而非显式型别声明
在现代C++开发中, auto 关键字通过要求初始化、正确推导类型以及适应代码重构,避免了许多由于显式类型声明而导致的微妙的正确性和效率问题, 应当作为大部分情况下的首选项来使用。
std::function
std::function 是 C++11 引入的一个非常强大且核心的工具,位于
它是一个通用的、多态的函数包装器。它的核心价值在于,它可以用一个统一的类型来存储、传递和调用任何符合特定函数签名的“可调用对象”。
std::function 是什么?
在 C++ 中,“可调用对象”(Callable Object)有多种形式,例如:
- 普通函数
- 函数指针
- Lambda 表达式
- 函数对象(Functor,即重载了 operator() 的类实例)
- 类的成员函数(需要绑定到特定对象实例上)
std::function 是一个类模板,它提供了一个统一的类型来“包装”所有这些不同类型的可调用对象,只要它们的调用签名(即参数列表和返回值类型)相匹配, 例如:
1 |
|
为什么需要 std::function?
在 C++11 之前,存储不同类型的可调用对象非常困难,因为它们各自拥有完全不同的、不兼容的 C++ 类型:函数指针的类型是 R(*)(Args…); 每个 Lambda 表达式都有一个由编译器生成的、独一无二的匿名闭包类型; 每个函数对象都有其自定义的类类型(例如 MyFunctor)。
如果没有 std::function,我们无法创建一个变量或一个容器(如 std::vector)来同时持有上述这些不同类型的对象,即使它们的调用签名完全相同。
而 std::function 提供了单一的、具体的类型(例如
std::function<void(int)>),它可以持有任何符合
void(int) 签名的可调用物。它实现了“类型擦除”(Type
Erasure)机制,隐藏了底层可调用对象的原始类型,只暴露统一的调用接口。
使用示例
假设我们需要一个可以执行“接受一个 int 并返回 bool”操作的包装器, 我们可以让 myFunc 持有以下任何一种对象。
1 | std::function<bool(int)> myFunc; |
最后, 需要注意的是 std::function 不是零成本抽象。为了实现类型擦除和存储任意可调用对象(特别是带状态的 Lambda),它通常需要在堆上进行动态内存分配(尽管许多实现带有“小缓冲区优化”(SBO) 来避免对小型对象的堆分配)。
且调用 std::function 通常涉及一次间接函数调用(类似于虚函数调用),这比直接函数调用或模板化的函数调用要慢。
auto可以解决未初始化变量问题
在 C++ 中,显式声明类型允许变量不被初始化,这可能导致不确定的行为。而 auto 通过类型推导工作,它必须从变量的初始化物中推导类型。这就带来一个巨大的优势:使用 auto 声明的变量必须被初始化,否则代码将无法通过编译。
1 | int x; // 有潜在的未初始化风险 |
auto处理冗长及编译器才知的类型
auto 可以极大地简化代码,特别是当类型名称非常冗长或难以书写时。例如,在 C++98 中获取迭代器所指向的值的类型非常繁琐, 而使用 auto,代码变得清晰简洁:
1 | typename std::iterator_traits<It>::value_type currValue = *b; |
当然, 虽然可以使用 std::function 来存储闭包,但这与 auto 相比存在显著差异:
- auto:使用 auto 声明的、存储闭包的变量与该闭包是同一类型,它占用的内存量与闭包完全相同。
- std::function:这是一个模板的实例,它占有固定尺寸的内存。如果这个尺寸对于它要存储的闭包不够用,std::function 的构造函数会分配堆内存来存储该闭包。
因此,std::function 的方法通常比 auto 方法占用更多内存,速度更慢(可能导致堆分配和间接函数调用),而 auto 则避免了这些开销。
auto避免“类型捷径”带来的正确性与效率问题
这是优先选用 auto 最有力的论据之一。开发者在显式指定类型时,有时会不经意间写下“错误”的类型,这可能导致隐式类型转换,从而引发正确性或效率问题。auto 可以保证推导出正确的类型。
一个示例是下面的v.size() 与 unsigned 的陷阱: 标准规定
std::vector::size() 返回的类型是
std::vector<int>::size_type。但在实际编码中,很多程序员会“偷懒”地将其存储在
unsigned 中。
1 | std::vector<int> v; |
这种写法存在移植性问题。例如,在32位 Windows 上,unsigned 和
std::vector<int>::size_type 都是32位;但在64位
Windows 上,unsigned 仍然是32位,而 size_type
却是64位。如果容器中的元素超过 232 − 1,这段代码在64位系统上就会表现异常。
使用 auto 则完全避免了这个问题,它保证推导出正确的类型:
1 | auto sz = v.size(); // 正确,sz 的类型是 std::vector<int>::size_type |
在遍历 std::unordered_map 时,很多程序员会写出如下代码:
1 | std::unordered_map<std::string, int> m; |
std::pair<const std::string, int>(因为键是
const 的),这与循环变量 p 的类型
std::pair<std::string, int> 并不匹配。
为了让代码通过编译,编译器会在循环的每次迭代中,复制哈希表中的元素来创建一个临时对象,然后将引用 p 绑定到那个临时对象上。这会导致不必要的复制开销,而且如果对 p 取址,得到的将是一个指向临时对象的指针。
使用 auto 可以轻松解决这个微妙的错误:
1 | for (const auto& p : m) { |
std::pair<const std::string, int>,引用 p
会被直接绑定到 map 中的元素,既高效又正确。
同时, 使用 auto 的代码更具适应性。如果一个函数的返回类型发生了变化(例如,从 int 改为 long),调用该函数并将结果存储在 auto 变量中的客户端代码,在下次编译时会自动更新变量类型。如果代码显式指定了 int,则需要开发者手动查找并修改所有调用点。
最后, 虽然有些开发者担心 auto 会降低代码的可读性(因为无法一眼看出类型),但现代 IDE 通常可以显示推导出的类型。而且在很多情况下,了解变量的抽象概念(例如它是一个“容器”或“计数器”)比知道它的精确类型更重要,这可以通过良好的变量命名来传达。