条款 26 - 避免依万能引用型别进行重载

ZaynPei Lv6

这一讲开门见山: 将一个接受万能引用参数的函数或模板与其他函数进行重载,是一个非常危险的设计,因为它几乎总是会导致意想不到的行为和难以理解的编译错误。

核心问题:万能引用的“贪婪”

万能引用(在模板中声明为 T&&)之所以危险,是因为它过于“贪婪”。根据C++的模板类型推导和重载决议规则,万能引用可以为几乎任何类型的实参生成一个精确匹配 (Exact Match) 的函数实例。

在重载决议中,精确匹配的优先级非常高,高于那些需要类型提升(如 short 到 int)、类型转换(如派生类到基类)或添加 const 的匹配。这意味着,一旦万能引用版本的重载成为候选,它就会像磁铁一样“吸走”大量本意是想调用其他重载版本的函数调用。

示例一:logAndAdd —— 万能引用与常规函数的重载

这个例子展示了最直接的问题。假设我们有一个高效的、使用万能引用的 logAndAdd 函数,同时为了方便,我们又重载了一个接受 int 索引的版本, 当我们用一个 short 类型的变量来调用它时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 全局数据
std::multiset<std::string> names;

// 重载版本 1: 万能引用
template<typename T>
void logAndAdd(T&& name) {
names.emplace(std::forward<T>(name));
}

// 重载版本 2: int
void logAndAdd(int idx) {
names.emplace(nameFromIdx(idx));
}

short nameIdx;
// ...
logAndAdd(nameIdx); // 错误!调用的是万能引用版本
首先, 程序员的意图非常明确,short 是一个整数类型,应该调用 logAndAdd(int idx) 这个版本。而重载决议过程却不是这样的. 它会尝试下列两种匹配

对于尝试匹配 logAndAdd(int):short 到 int 的转换是类型提升 (Promotion),这是一个合法的匹配,但不是精确匹配。

而对于匹配 logAndAdd(T&&):编译器可以为万能引用实例化一个版本,其中 T 被推导为 short&。这个 logAndAdd(short&) 的实例对于 short 类型的实参来说是精确匹配 (Exact Match)。

由于精确匹配的优先级高于类型提升,编译器选择了万能引用版本的 logAndAdd。也就是说, 在万能引用版本的函数体内,代码尝试用一个 short 去构造 names(一个 std::multiset)中的 std::string。由于不存在从 short 到 std::string 的构造函数,代码最终在 emplace 这一步编译失败。这个错误信息通常非常复杂,因为它发生在模板的深层实例化中,让程序员难以定位问题的根源。

示例二:Person 构造函数 —— 完美转发构造函数的危险

这个例子揭示了一个更微妙、更危险的问题。当一个类的构造函数被重载为万能引用时(通常被称为“完美转发构造函数”),它会“劫持”类的拷贝和移动机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public:
// 完美转发构造函数
template<typename T>
explicit Person(T&& n) : name(std::forward<T>(n)) {}

explicit Person(int idx) : name(nameFromIdx(idx)) {}

// 编译器会自动生成拷贝和移动构造函数
};

Person p("Nancy");
auto cloneOfP(p); // 或者Person cloneOfP(p), 意图是调用拷贝构造函数
当客户端代码尝试拷贝一个 Person 对象时, 程序员希望调用编译器生成的拷贝构造函数 Person(const Person&)。此时重载决议过程如下:

  • 匹配拷贝构造函数:实参 p 的类型是 Person(一个非 const 左值)。为了匹配拷贝构造函数的参数 const Person&,需要为 p 添加 const 限定符。这是一个合法的匹配,但不是精确匹配。
  • 匹配完美转发构造函数:编译器可以为万能引用构造函数实例化一个版本,其中 T 被推导为 Person&。这个 Person(Person&) 的实例对于 Person 类型的左值实参 p 来说是精确匹配。

同样,精确匹配胜出。编译器选择了完美转发构造函数,而不是拷贝构造函数。在完美转发构造函数体内,代码尝试用 p(一个 Person 对象)来初始化 name(一个 std::string 成员)。这当然会失败,并产生一堆令人费解的错误信息。

在这个例子里, 完美转发构造函数破坏了类的基本功能(拷贝)。它同样会劫持派生类对基类的拷贝和移动调用,导致继承体系出现问题。

总而言之, 在设计函数和类时,应极力避免将接受万能引用的版本与其他版本进行重载。在下一讲条款27会详细介绍如何处理那些确实需要类似重载行为的场景,例如使用标签分派、static_assert 或 C++20 的 concepts 等技术来约束模板,从而安全地实现期望的功能。