本节将测试您对C++标准最新且最先进特性的掌握程度。我们将从C++最基本的功能之一 —— 函数及其重载开始。
函数重载是 C++ 中一个非常直接的概念:多个不同的函数可以拥有相同的名称。函数重载的本质就在于此 —— 当编译器看到表示函数调用的语法(格式为 f(x))时,必须存在一个以上名为 f 的函数。如果这种情况发生,就构成了重载,此时必须进行重载解析,以确定应该调用这些函数中的哪一个。
从一个简单的例子开始:
// Example 01
void f(int i) { cout << "f(int)" << endl; } // 1
void f(long i) { cout << "f(long)" << endl; } // 2
void f(double i) { cout << "f(double)" << endl; } // 3
f(5); // 1
f(5l); // 2
f(5.0); // 3
在这里,我们为同一个名称 f 定义了三个函数,并进行了三次函数调用。这些函数的签名都不同(即参数类型不同)。这是一个必要条件 —— 重载的函数必须在参数上有所区别。不可能存在两个参数完全相同,但仅返回类型或函数体不同的重载函数。另外虽然本例中的函数是普通函数,但同样的规则也完全适用于重载的成员函数,因此不会单独特别关注成员函数。
回到我们的例子,在每一行中调用的是哪个 f() 函数呢?要理解,需要了解 C++ 中重载函数是如何解析的。重载解析的具体规则相当复杂,并且在不同版本的 C++ 标准之间存在细微差异,但在大多数情况下,这些规则的设计,旨在让编译器在最常见的情况下做出符合预期的行为。我们预期 f(5) 会调用接受整型参数的重载函数,因为 5 是一个 int 类型的值,事实也的确如此。同样地,5l 的类型是 long,因此 f(5l) 会调用第二个重载函数。最后,5.0 是一个浮点数,会调用最后一个重载函数。
这并不难理解,对吧?但如果参数类型与函数参数的类型不完全匹配时会发生什么?这时,编译器就必须考虑类型转换。例如,5.0 这个字面量的类型是 double。如果用一个 float 类型的参数来调用 f() 会发生什么:
f(5.0f);
现在需要将 float 类型的参数转换为 int、long 或 double 类型。同样,C++ 标准对此有明确的规则,但不出所料,转换为 double 是优先选择,因此会调用接受 double 参数的重载函数。
再看看使用另一种整数类型的情况,比如 unsigned int:
f(5u);
现在有两个选择:将 unsigned int 转换为有符号的 int,或者转换为有符号的 long。
虽然有人可能会认为转换为 long 更安全,但标准认为这两种转换的优先级非常接近,以至于编译器无法做出选择。因此,这次调用无法通过编译,重载解析会认为是模糊的;错误信息通常会明确指出。如果在代码中遇到此类问题,就必须通过显式类型转换来帮助编译器,将参数转换为能明确匹配某个重载版本的类型。通常最简单的方法是,将参数转换为你希望调用的那个重载函数所期望的参数类型:
unsigned int i = 5u;
f(static_cast<int>(i));
目前为止,我们讨论的都是参数类型不同但参数数量相同的情况。当然,如果同一函数名对应的不同声明其参数数量不同,在调用时只需考虑那些能够接受所需参数数量的函数即可。以下是两个同名但参数数量不同的函数示例:
void f(int i) { cout << "f(int)" << endl; } // 1
void f(long i, long j) { cout << "f(long2)" << endl; } // 2
f(5.0, 7);
重载解析非常简单 —— 需要一个能接受两个参数的函数,而这样的函数只有一个,两个参数都需要转换为 long 类型。但如果存在多个具有相同参数数量的函数时,会发生什么?让我们来看下面这个例子:
// Example 02
void f(int i, int j) { cout << "f(int, int)" << endl; }// 1
void f(long i, long j) { cout << "f(long2)" << endl; } // 2
void f(double i) { cout << "f(double)" << endl; } // 3
f(5, 5); // 1
f(5l, 5l); // 2
f(5, 5.0); // 1
f(5, 5l); // ?
最明显的情况是 —— 如果所有参数的类型都与某个重载函数的参数类型完全匹配,就调用该重载函数。接下来,如果没有完全匹配的情况,每个参数都可能发生类型转换。让我们分析第三个调用 f(5, 5.0)。第一个参数 int 与第一个重载函数完全匹配,但如有必要,也可以转换为 long。第二个参数 double 与两个重载函数都不匹配,但都可以通过转换来适配。第一个重载函数是更优的选择 —— 需要的参数转换次数更少。
最后,再看最后一行的情况。第一个重载可以通过对第二个参数进行转换来调用;第二个重载也可以通过对第一个参数进行转换来调用。这种情况再次构成了重载歧义,该行代码无法通过编译。需通常认为“转换次数更少的重载胜出”这一说法并不总是成立;在更复杂的情况下,即使某个重载所需的转换次数更少,也可能出现歧义(一般规则是:如果存在一个重载函数,在每个参数上都有最佳的转换,则该重载胜出;否则,调用就是歧义的)。要解决这种歧义,必须通过类型转换(通常使用强制转换,或在本例中改变数值字面量的类型)来修改某些参数的类型,从而使期望的重载函数成为唯一或更优的选择。
第三个重载函数完全未考虑,它的参数数量与所有函数调用的参数数量都不匹配。不过情况并不总是这么简单 —— 函数可以有默认参数,所以实参的数量并不总是必须与形参的数量完全一致:
// Example 03
void f(int i) { cout << "f(int)" << endl; } // 1
void f(long i, long j) { cout << "f(long2)" << endl; } // 2
void f(double i, double j = 0) { // 3
cout << "f(double, double = 0)" << endl;
}
f(5); // 1
f(5l, 5); // 2
f(5, 5); // ?
f(5.0); // 3
f(5.0f); // 3
f(5l); // ?
现在有三个重载函数。第一个和第二个永远不会产生混淆,其参数数量不同。然而,第三个重载函数既可以用一个参数调用,也可以用两个参数调用;在只传入一个参数的情况下,第二个参数默认为零。
第一个调用最简单:传入一个参数,其类型与第一个重载函数的参数类型完全匹配,因此调用第一个重载。
第二个调用让我们回想起之前见过的情况:传入两个参数,其中第一个参数与某个重载函数的参数类型完全匹配,但第二个参数需要进行类型转换。另一个重载函数则需要对两个参数都进行转换,因此第二个函数定义是更优的匹配,会选中调用。
第三个调用看似也很直接:传入两个整数参数。但这种简单性具有欺骗性 —— 有两个重载函数都可以接受两个参数,并且在这两种情况下,两个参数都需要进行类型转换。虽然从 int 转换为 long 看起来比转换为 double 更合理,但 C++ 并不这样认为。因此,这次调用是歧义的,无法通过编译。
接下来的调用 f(5.0) 只有一个参数,可以转换为第一个单参数重载函数的 int 类型。但更优的匹配是第三个重载函数,因为该参数无需转换即可匹配其 double 类型参数。因此,第三个重载函数会调用。
如果我们将参数类型从 double 改为 float,就得到下一个调用。此时,float 转换为 double 比转换为 int 更优,因此调用第三个重载函数(利用其默认参数)。在重载解析中,使用默认参数本身不视为一种转换,因此不会增加“惩罚”。
最后一个调用再次出现歧义:double 和 int 认为具有同等的转换权重,因此第一个和第三个重载函数的匹配程度相同,编译器无法抉择。第二个重载函数虽然对第一个参数提供了完全匹配,但它需要两个参数,而当前调用只提供了一个参数,因此不会纳入考虑范围。
我们只讨论了普通的 C++ 函数,尽管我们所学的内容同样适用于成员函数。现在,需要将模板函数也纳入考虑范围。
除了参数类型已知的普通函数外,C++ 还具有模板函数。当调用这些函数时,其参数类型会根据调用位置的实参类型进行推导。模板函数可以与非模板函数同名,多个模板函数之间也可以同名,因此需要了解在存在模板的情况下如何进行重载解析。
// Example 04
void f(int i) { cout << "f(int)" << endl; } // 1
void f(long i) { cout << "f(long)" << endl; } // 2
template <typename T>
void f(T i) { cout << "f(T)" << endl; } // 3
f(5); // 1
f(5l); // 2
f(5.0); // 3
函数名 f 可以指代这三个函数中的一个,其中一个是模板函数。每次调用时,都会从这三个函数中选择最佳的重载版本。在特定函数调用的重载解析过程中,考虑的函数集合称为重载集。
第一次调用 f() 与重载集中的第一个非模板函数完全匹配 —— 实参类型是 int,而第一个函数的签名正是 f(int)。如果在重载集中找到了与调用完全匹配的非模板函数,总会视为最佳的重载选项。
模板函数也可以实例化为完全匹配的形式 —— 将模板参数替换为具体类型的过程称为模板参数替换(或类型替换)。如果将 T 替换为 int,就能得到另一个与调用完全匹配的函数。然而,一个完全匹配的非模板函数,比模板函数的完全匹配具有更高的优先级。
第二次调用的处理方式类似,与重载集中的第二个函数完全匹配,因此调用该函数。
最后一次调用的实参类型是 double,可以转换为 int 或 long,也可以替换模板参数 T,从而使模板实例化后与调用完全匹配。由于不存在完全匹配的非模板函数,因此将模板函数实例化为完全匹配的版本成为次优选择,会选中调用。
但当存在多个模板函数,模板参数都可以替换以匹配调用的实参类型时,会发生什么情况呢?
// Example 05
void f(int i) { cout << "f(int)" << endl; } // 1
template <typename T>
void f(T i) { cout << "f(T)" << endl; } // 2
template <typename T>
void f(T* i) { cout << "f(T*)" << endl; } // 3
f(5); // 1
f(5l); // 2
int i = 0;
f(&i); // 3
第一次调用再次与非模板函数完全匹配,成功解析。
第二次调用可以通过类型转换匹配第一个非模板重载,或者如果将正确的类型 long 替换给模板参数 T,则可以与第二个重载完全匹配。
最后一个重载(f(T*))无法匹配前两次调用 —— 没有替换方式能使 T* 类型的参数匹配 int 或 long 类型的实参。
然而,对于第三次调用 f(5),可以匹配第三个重载(f(T*)),前提是将 T 替换为 int。但问题在于,如果将 T 替换为 int*,也可以匹配第二个重载(f(T))。那么,应该选择哪个模板重载呢?
答案是选择更特化的那个。第一个重载 f(T) 可以通过实例化匹配单参数函数调用,而第二个重载 f(T*) 只能匹配指针类型的参数调用。因此,f(T*) 是更具体、限制更严格的重载,会视为更优的匹配而被选中。
这是一个专属于模板的概念 —— 不再仅基于“更优的转换”(通常指更少或更简单的转换)来选择,而是选择更难实例化的重载(即更特化的模板)。
这个规则在空指针上似乎会“失效”:f(NULL) 可以调用第一个重载 f(int) 或第二个重载 f(T),而 f(nullptr) 会调用第二个重载 f(T)。指针重载 f(T*) 却从未调用,尽管 NULL 和 nullptr 理论上都是空指针。
然而,这实际上是编译器严格遵守规则的结果。在 C++ 中,NULL 实际上是一个整数零,通常定义为宏:
#define NULL 0 // 或 0L
根据 NULL 是被定义为 0 还是 0L,会分别调用 f(int) 或 f(T)(其中 T 推导为 long)。而 nullptr 常量,尽管其名称中包含 “ptr”,但实际上它是类型为 nullptr_t 的常量值。它可以转换为任意指针类型,但它本身并不属于指针类型。正因如此,在处理接受不同类型指针的函数时,通常会专门声明一个带有 nullptr_t 参数的重载函数。
最后,还有一种函数几乎可以匹配同名的函数调用,那就是接受可变参数的函数:
// Example 06
void f(int i) { cout << "f(int)" << endl; } // 1
void f(...) { cout << "f(...)" << endl; } // 2
f(5); // 1
f(5l); // 1
f(5.0); // 1
struct A {};
A a;
f(a); // 2
第一个重载可以用于前三个函数调用 —— 它与第一次调用完全匹配,而对于另外两次调用,也存在相应的类型转换,使其能够适配第一个重载函数 f() 的签名。
本例中的第二个函数(可变参数函数)可以用任意数量、任意类型的参数进行调用。视为最后的选择 —— 只要存在一个参数类型明确的函数,能够通过适当的类型转换(包括用户自定义的转换)与调用相匹配,就会优先选择该函数,而不是可变参数函数。
这包括用户自定义的类型转换,例如:
struct B {
operator int() const { return 0; }
};
B b;
f(b); // 1
只有当不存在能够避免调用 f(...) 可变参数函数的类型转换时,才会调用该函数。
现在我们了解了重载解析的优先顺序:
精确匹配的非模板函数:首先,选择一个与实参完全匹配的非模板函数。
精确匹配的模板函数:如果重载集中没有完全匹配的非模板函数,则选择一个模板函数,其模板参数可以通过替换为具体类型得到完全匹配。
更特化的模板函数:如果有多个模板函数都能通过参数替换实现完全匹配,则优先选择更特化(限制更严格)的那个。
需要类型转换的非模板函数:如果上述模板匹配也失败了,则选择一个非模板函数,其参数类型可以通过类型转换(包括用户自定义转换)与实参匹配。
可变参数函数:最后,如果以上所有方式都失败了,但存在一个同名的可变参数函数,则调用该函数。
需要注意的是,某些转换视为“简单的”,并包含在“完全匹配”的概念中。例如,从 T 到 const T 的转换就属于这种情况。
每一步中,如果存在多个同样好的选项(即无法区分优劣),则重载认为是有歧义的,程序将因此格式错误,导致编译失败。
模板函数中的类型替换过程决定了模板函数参数的最终类型,以及与函数调用实参的匹配程度。这个过程有时会导致一些意想不到的结果,需要更详细地探讨。