条款5:优先选用 auto, 而非显式型别声明

ZaynPei Lv6

在现代C++开发中, auto 关键字通过要求初始化、正确推导类型以及适应代码重构,避免了许多由于显式类型声明而导致的微妙的正确性和效率问题, 应当作为大部分情况下的首选项来使用。

std::function

std::function 是 C++11 引入的一个非常强大且核心的工具,位于 头文件中。

它是一个通用的、多态的函数包装器。它的核心价值在于,它可以用一个统一的类型来存储、传递和调用任何符合特定函数签名的“可调用对象”。

std::function 是什么?

在 C++ 中,“可调用对象”(Callable Object)有多种形式,例如:

  • 普通函数
  • 函数指针
  • Lambda 表达式
  • 函数对象(Functor,即重载了 operator() 的类实例)
  • 类的成员函数(需要绑定到特定对象实例上)

std::function 是一个类模板,它提供了一个统一的类型来“包装”所有这些不同类型的可调用对象,只要它们的调用签名(即参数列表和返回值类型)相匹配, 例如:

1
2
3
4
5
#include <functional>

// 定义一个 std::function 变量 'func'
// 它可以持有任何“接受一个 int 和一个 double,并返回一个 bool”的可调用对象
std::function<bool(int, double)> func;

为什么需要 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
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
std::function<bool(int)> myFunc;

// 1. 普通函数
bool isEven(int x) {
return x % 2 == 0;
}

myFunc = isEven; // 这里 myFunc 直接持有了函数 isEven 的指针。
std::cout << "函数调用: " << myFunc(10) << std::endl; // 输出 1 (true)

// 2. Lambda 表达式(可以带状态)
int divisor = 3;
auto isDivisible = [divisor](int x) -> bool {
return x % divisor == 0;
};

myFunc = isDivisible; // 这是 std::function 最强大的用途之一。它能够存储 Lambda 表达式,包括 Lambda 捕获的状态(这里捕获了 divisor)。
std::cout << "Lambda 调用: " << myFunc(9) << std::endl; // 输出 1 (true)

// 3. 函数对象
struct IsGreaterThan {
int limit;
IsGreaterThan(int val) : limit(val) {}

bool operator()(int x) const {
return x > limit;
}
};

IsGreaterThan comparator(100);
myFunc = comparator; // std::function 会在内部存储 comparator 对象的一个副本(或移动后的版本)。
std::cout << "Functor 调用: " << myFunc(101) << std::endl; // 输出 1 (true)

最后, 需要注意的是 std::function 不是零成本抽象。为了实现类型擦除存储任意可调用对象(特别是带状态的 Lambda),它通常需要在堆上进行动态内存分配(尽管许多实现带有“小缓冲区优化”(SBO) 来避免对小型对象的堆分配)。

且调用 std::function 通常涉及一次间接函数调用(类似于虚函数调用),这比直接函数调用或模板化的函数调用要慢。

auto可以解决未初始化变量问题

在 C++ 中,显式声明类型允许变量不被初始化,这可能导致不确定的行为。而 auto 通过类型推导工作,它必须从变量的初始化物中推导类型。这就带来一个巨大的优势:使用 auto 声明的变量必须被初始化,否则代码将无法通过编译。

1
2
3
int x; // 有潜在的未初始化风险
auto x; // 编译错误!必须有初始化物
auto x = 0; // 没问题,x 被明确定义
这从根本上消除了一整类由于忘记初始化而导致的问题。

auto处理冗长及编译器才知的类型

auto 可以极大地简化代码,特别是当类型名称非常冗长或难以书写时。例如,在 C++98 中获取迭代器所指向的值的类型非常繁琐, 而使用 auto,代码变得清晰简洁:

1
2
3
typename std::iterator_traits<It>::value_type currValue = *b;

auto currValue = *b;
更重要的是,有些类型只有编译器知道,开发者根本无法显式写出,最典型的例子就是 lambda 表达式生成的闭包 (closure) 类型。auto 是存储闭包的理想且唯一直接的方式

当然, 虽然可以使用 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
2
std::vector<int> v;
unsigned sz = v.size(); // 显式类型,但可能不正确

这种写法存在移植性问题。例如,在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 的陷阱:

在遍历 std::unordered_map 时,很多程序员会写出如下代码:

1
2
3
4
std::unordered_map<std::string, int> m;
for (const std::pair<std::string, int>& p : m) {
// ...
}
这段代码看起来完全合理,但其实是错误的。std::unordered_map 中键值对的实际类型是 std::pair<const std::string, int>(因为键是 const 的),这与循环变量 p 的类型 std::pair<std::string, int> 并不匹配。

为了让代码通过编译,编译器会在循环的每次迭代中,复制哈希表中的元素来创建一个临时对象,然后将引用 p 绑定到那个临时对象上。这会导致不必要的复制开销,而且如果对 p 取址,得到的将是一个指向临时对象的指针。

使用 auto 可以轻松解决这个微妙的错误:

1
2
3
for (const auto& p : m) {
// ...
}
auto 会被正确推导为 std::pair<const std::string, int>,引用 p 会被直接绑定到 map 中的元素,既高效又正确。

同时, 使用 auto 的代码更具适应性。如果一个函数的返回类型发生了变化(例如,从 int 改为 long),调用该函数并将结果存储在 auto 变量中的客户端代码,在下次编译时会自动更新变量类型。如果代码显式指定了 int,则需要开发者手动查找并修改所有调用点。

最后, 虽然有些开发者担心 auto 会降低代码的可读性(因为无法一眼看出类型),但现代 IDE 通常可以显示推导出的类型。而且在很多情况下,了解变量的抽象概念(例如它是一个“容器”或“计数器”)比知道它的精确类型更重要,这可以通过良好的变量命名来传达。