C++中的重载。第一部分:函数和模板的重载
大家好!我是大聪明-PLUS!
C++ 是一门复杂而有趣的语言;要精通它几乎需要一生的时间。我曾想以这种方式学习它:选取语言的某个特定方面,或许是一个相当具体的方面,然后尽可能深入、细致地进行研究。当我积累了一定的资料后,我决定将它们介绍给 CSDN的用户。这就是我称之为“C++:深入探索”的系列文章的由来。该系列被标记为教程,但其目标读者是中级而非初学者。第一个主题是 C++ 中的重载。事实证明,这个主题相当庞大,因此我撰写了三篇文章。第一篇文章专门介绍函数和模板重载,第二篇介绍运算符重载,第三篇介绍运算符重载new/delete。那么,让我们开始深入探索吧。
介绍
广义上讲,函数重载是指同时使用多个同名函数的能力。编译器通过不同的参数集来区分它们。在函数调用时,编译器会分析参数类型,并确定应该调用哪个函数。
1. 总则
1.1 重载函数
如果函数(和函数模板)在同一作用域内声明且名称相同,则称它们为重载函数。=delete当参数相同时,重载函数不能具有不同的返回类型、异常说明符或已删除的函数说明符(`&&`)。
void Foo();
char Foo();
void Foo(int x);
void Foo(int x) noexcept;
void Foo(double x);
void Foo(double) = delete;
但是允许存在多个相同的声明,编译器会忽略这些副本。
还需要注意的是,编译器会对函数参数执行一些标准类型转换。对于数组类型,它会被解缓存为指针,因此
void Foo(int x[4]);
void Foo(int x[]);
void Foo(int *x);
非重载函数是同一回事。
函数类型的参数会被简化为指向函数的指针。
对于按值传递的参数,限定符const(和volatile)将被移除,因此
void Foo(int x);
void Foo(const int x);
非重载函数是同一回事。
1.2 函数搜索算法的一般方案
一般来说,函数搜索算法可以描述如下。在第一阶段,编译器搜索符合语言规则的、对给定调用有效的重载函数(候选函数)。对于模板,还会执行模板参数推导。对于这些函数,参数的数量必须与实参的数量匹配,并且实参类型必须与实参类型匹配(或者必须存在从实参类型到实参类型的隐式转换)。如果没有找到这样的函数,则搜索失败。如果恰好找到一个函数,则搜索成功。如果找到多个函数,则进入下一阶段:编译器尝试选择与实参匹配度最高的函数。此阶段称为重载解析。如果找到这样的函数,则重载解析成功;否则,将发生错误(对重载函数的调用不明确)。让我们来看一个例子:
void Foo(float x);
void Foo(double x);
Foo("meow")没有找到适合该调用的函数,Foo(42)两个函数都适合该调用,编译器无法选择最合适的函数,但调用Foo(3.14f)已Foo(3.14)成功解析。
选择最合适函数的规则(重载解析规则)如果要完整且正式地描述它们,可能会相当复杂且令人困惑(这些内容只有编译器开发人员才能完全理解)。然而,通常情况下,在许多实际应用中,这些规则是直观的,不会给程序员带来任何特殊问题。下面将介绍其中的一些规则。
通常,为了方便起见,我们会用“过载解析”这个术语来描述这两个阶段:寻找候选函数和选择最合适的函数。从现在开始,我们将沿用这个约定。
但成功解析重载函数并非万事大吉。重载解析完成后,还会检查所选函数是否对调用者可访问(即,它是 `undefined` 还是 `undefined` private)protected。如果成功,还会检查它是否为远程函数(即,它是否声明为 `undefined` =delete)。如果这些检查失败,编译将失败并报错。请注意,这些检查不会影响重载解析过程;它们总是在重载解析之后执行。
1.3 嵌套作用域中的电流范围和过载解析
如上所述,重载函数根据定义处于同一作用域内。作用域是嵌套的。由命名空间定义的作用域可以相互嵌套。任何命名空间都嵌套在全局命名空间内。派生类的作用域嵌套在其基类的作用域内,而基类的作用域又嵌套在某个命名空间的作用域内。局部作用域(代码块)嵌套在其他代码块内,然后再嵌套在类或命名空间的作用域内。
在解析重载时,编译器必须首先选择一个作用域来执行重载解析。这个作用域称为当前作用域。如果在当前作用域中找不到具有搜索名称的函数,则包含该函数的作用域将成为当前作用域。但是,如果在当前作用域中至少找到一个具有搜索名称的函数,则在该作用域中执行重载解析,而不会考虑包含该函数的作用域。当前作用域中的函数会隐藏包含该函数的作用域中同名的函数。需要强调的是,这与重载解析的结果无关;即使找不到匹配的函数、匹配的函数可能不明确、无法访问或已被删除,搜索也不会在包含该函数的作用域中继续进行。
1.3.1. 选择当前范围
函数调用时的上下文决定了函数的初始作用域以及可以跳转到以进行重载解析的可能上级作用域。以下是一些示例。
class X {/* ... */};
X x;
x.Foo();
X::Foo(42);
在这些情况下,初始作用域是类本身X。外层作用域仅限于基类X。
namespace N {/* ... */}
// ...
N::Foo();
在这种情况下,初始当前作用域将是命名空间N,并且根本不会发生到封闭作用域的导航。
::Foo();
在这种情况下,初始当前作用域将是全局命名空间;没有封闭作用域。
现在让我们考虑不带额外类或命名空间限定符的“裸”函数调用。
如果这样的调用发生在命名空间中,那么该命名空间将成为初始当前作用域,而封闭作用域将成为封闭命名空间。
如果这样的调用发生在类命名空间中(例如,初始化静态成员时),则相应的类将是初始当前作用域,封闭作用域将是基类,然后是封闭命名空间。
让这样的调用位于代码块中。
{
Foo();
}
在这种情况下,初始作用域就是这个代码块。(请记住,可以声明局部函数;详见下文。)外层作用域依次为:代码块本身、类(如果代码块位于成员函数中)以及外层命名空间。
1.3.2. 解决类中的重载问题
我们来看一个例子。
class B
{
// ...
public:
void Foo(int x);
};
class D : public B
{
// ...
public:
D();
void Foo(double x);
};
// ...
D d;
d.Foo(42);
两个可用函数中Foo会选择哪一个?正确答案是 `function` D::Foo(double),尽管 ` B::Foo(int)function` 更合适,并且在调用点可以访问。搜索从当前作用域(在本例中为类 `A` D)开始,并找到了一个具有对应名称的函数。封闭作用域(在本例中为类 `B` B)不会被考虑。找到的唯一函数D::Foo(double)可以使用给定的参数调用,因此重载解析成功。如果`function`D::Foo(double)被声明为私有、受保护或已删除,编译将会失败,但B::Foo(int)即使它在调用点可以访问,编译器仍然不会考虑它。只有当`function`D从类 `B` 中完全移除时,Foo编译器才会将类 `A` 设置为当前作用域B并选择 ` function` B::Foo(int)。
这些规则对程序员来说可能相当出乎意料。C++ 中的继承机制旨在尽可能清晰地界定派生类和基类之间的界限,但在这种情况下,这种透明度却有所欠缺。在不利条件下,这会导致难以检测的错误。例如,可能会出现无限递归。(但这并非最糟糕的情况;这种错误在运行时是可以立即检测到的。)
1.3.3 函数的本地声明
现在我们来了解一下 C++ 中一个不常用的特性:局部函数声明。函数可以在局部(代码块中)声明,例如:
{
void Foo();
void Foo(int x);
// ...
Foo(42);
// ...
}
局部声明的函数必须在全局命名空间中定义;C++ 不允许局部定义。如果一个函数在没有额外类或命名空间限定符的代码块中被调用,则重载解析的当前作用域就是该代码块。如果代码块包含局部函数声明,则来自封闭作用域的同名函数将被隐藏。如果代码块不包含任何局部函数声明(通常情况下都是如此),则当前作用域最终会移动到类(如果代码块位于类成员函数中),然后再移动到封闭的命名空间。
1.4 扩大范围以解决过载问题
using可以使用声明和指令来扩展重载解析的范围using。此外,在某些情况下,编译器会自动扩展重载解析的范围。
需要注意的是,扩大作用域可能会导致冲突,例如,如果将与当前函数具有相同参数集的函数添加到扩大的作用域中。
1.4.1 在类中使用 using 声明
以下是上一个例子的具体操作方法:
class B
{
// ...
public:
void Foo(int x);
};
class D : public B
{
// ...
public:
using B::Foo;
void Foo(double x);
};
// ...
D d;
d.Foo(42);
之后,重载解析将涉及Foo类作用域中的重载B,编译器将选择B::Foo(int)。
1.4.2. 在本地和命名空间中使用 using 声明
using如上一节所示,类名声明只能在派生类的作用域内使用。命名空间using名声明既可以在局部范围内使用,也可以在命名空间范围内使用。但是,必须谨慎使用,因为此类声明会隐藏外部作用域中的相应名称,如果声明的作用域using选择不当,可能会导致重载解析时出现不必要的作用域变更,而不是扩展。例如:
namespace N
{
void Foo(int x);
}
void Foo(const char* x);
// ...
void Test()
{
using N::Foo;
Foo(42);
Foo("meow");
}
在这种情况下,为了使两个版本Foo都能参与重载解析,using声明必须这样放置:
namespace N
{
void Foo(int x);
}
void Foo(const char* x);
// ...
using N::Foo;
void Test()
{
Foo(42);
Foo("meow");
}
1.4.3. 使用 using 指令
假设我们有一个命名空间N。指令
using namespace N;
这被称为using`-directive` 指令。N不带限定符的命名空间名称可以在其作用域内使用N::。`-directive` 指令中的函数也会参与重载解析N,这意味着using`-directive` 指令扩展了重载解析的作用域(并且,与using`-declaration` 不同,它不会隐藏任何内容)。然而,通常情况下,using应该非常谨慎地使用 `-directive` 指令;关于它已经有很多文章论述过了。
如果使用匿名命名空间,则在该命名空间内声明的函数将与在其外部命名空间中声明的同名函数一起参与重载解析。(实际上,可以通过一个隐藏的using指令访问匿名命名空间。)
namespace
{
void Foo(int x);
}
void Foo(const char* x);
// ...
Foo(42);
Foo("meow");
1.4.4. 根据参数类型进行搜索
有一种情况下,编译器会自动扩展当前作用域以解决重载问题。考虑在某个命名空间中声明一个类和一个函数:
namespace N
{
class X {/* ... */};
void Foo(const X& x);
}
让我们看一下代码(命名空间之外的代码N)
N::X x;
Foo(x);
在这种情况下,编译器在解析重载时会使用命名空间N,如果与当前作用域没有冲突,则会选择该命名N::Foo(const X&)空间。这称为参数依赖查找(ADL),也称为柯尼格查找。ADL 在运算符重载、标准库函数和其他情况下都发挥着重要作用。
2. 过载解决的一些规则
2.1 隐式类型转换和“关闭”类型参数
C++ 中存在相当多的隐式类型转换。这在某些情况下会导致问题,例如在重载解析过程中产生歧义。然而,在重载解析过程中,通过隐式转换相互转换的类型会被区分开来。一般规则是,不需要转换的类型优先。
其中一种有问题的类型是 `T` bool。为了与 C 兼容,`T` 会隐式转换bool为 `T` int,任何数值类型和指针也会隐式转换为 `T` bool。这可能导致许多难以检测的错误。但在简单情况下,重载解析会bool清晰地将两者区分开来int。
void Foo(int x);
void Foo(bool x);
// ...
int x = 6, y = 5;
Foo(x == y);
Foo(x = y);
但对于这种过载函数
void Foo(bool x, int y);
void Foo(int x, bool y);
含糊不清的呼叫更容易发出,例如:
Foo(1, 2);
枚举也带来了一些挑战,因为枚举类型会被隐式转换为整数类型。当枚举类型被重载时,枚举类型可以与整数类型清晰地区分开来int。
enum Qq { One = 1, Two };
void Foo(int x);
void Foo(Qq x);
// ...
Foo(One);
Foo(42);
例如,语义匹配和按位匹配类型在重载解析方面也存在差异int。long
void Foo(int x);
void Foo(long x);
// ...
Foo(42);
Foo(42L);
有时,在解决函数重载问题时,我们需要消除一些隐式转换。在这种情况下,可以使用已删除的函数。假设我们想要一些函数,它们可以接受整数参数,但不能接受浮点参数。这可以通过以下方式实现:
void Foo(long x);
void Foo(long long x);
void Foo(float) = delete;
void Foo(double) = delete;
void Foo(long double) = delete;
2.2 空指针
C++11引入了一种新类型——std::nullptr_t单值类型nullptr。这避免了将字面量零隐式转换为指针类型可能带来的问题。
void Foo(int x);
void Foo(void* x);
// ...
Foo(0);
Foo(nullptr);
在 C++98 中,你必须编写
Foo((void*)0);
但这并非它的全部优点。由于nullptr它有自己的类型,因此可以按值重载函数nullptr。
void Foo(void* x);
void Foo(std::nullptr_t);
// ...
void* x = nullptr;
Foo(x);
Foo(nullptr);
标准智能指针接口中使用了类似的重载。
2.3. 通用初始化和初始化列表
C++11 引入了使用花括号和新模板进行统一初始化的概念std::intializer_list<>。然而,正如常有的情况一样,一些歧义随之出现,需要通过额外的规则来解决。这些规则主要与构造函数的重载解析规则有关。以下是这些规则。
- 空花括号 —
{}, — 表示选择了默认构造函数。如果没有默认构造函数,则会发生错误。 - 对于花括号内的非空列表,首先会查找具有指定类型参数的构造函数
std::intializer_list<>。如果不存在,或者列表元素不适合该构造函数std::intializer_list<>,则会查找另一个适合该构造函数的构造函数。但是,如果可以使用隐式窄化转换(即损失数值精度的转换)将列表元素转换为该构造函数所需的类型std::intializer_list<>,则会发生错误。
让我们来看一些标准向量的例子std::vector<T>,它有一个带有类型参数的构造函数std::intializer_list<T>。
以下是第一个例子:
std::vector<int> v1(3, 1), v2{3, 1};
在这种情况下v1,是一个大小为 3 的向量,其元素等于 1。v2是一个大小为 2 的向量,其元素等于 3 和 1,因此选择了带有类型参数的构造函数std::intializer_list<int>,尽管还有另一个接受列表元素的构造函数。
另一个例子:
std::vector<const char*> u1(3, "meow"), u2{3, "meow"};
在这种情况下u1,和u2是相同的,都是大小为 3 的向量。对于u2列表元素来说,不适合使用带有类型参数的构造函数std::intializer_list<const char*>,因此选择了与相同的构造函数u1。
第三个例子:
std::vector<bool> b1(3, true), b2{3, true};
在这种情况下,b1一个大小为 3 的向量,其元素等于true。然而b2,它无法编译,因为带有类型参数的构造函数需要从到 的std::intializer_list<bool>窄化转换。intbool
2.4. 具有可变参数的函数
参数数量可变的函数(即...参数列表位于函数末尾的函数)只有在不存在参数数量固定的合适函数时才会被考虑作为过载解析的候选函数。即使存在一个参数数量固定的合适函数,也会排除参数数量可变的函数被纳入过载解析的考虑范围。
2.5. 函数模板
请注意,函数模板可以针对给定的模板参数进行完全特化,但不能进行部分特化。为了避免部分特化,可以使用重载模板——即同名但参数不同的函数模板。
函数模板及其完全特化版本可以与非模板函数一起参与重载。完全模板特化版本参与重载的方式相当特殊(甚至可以说它们不参与重载)。详情如下。
2.5.1. 装弹通用规则
在重载解析过程中,首先考虑非模板函数和模板实例化。优先考虑参数类型完全匹配的情况,即不需要隐式参数类型转换的情况。如果存在多个符合条件的情况,则优先考虑非模板函数。如果没有选择非模板函数,则优先考虑更具体的模板实例化。
如果选择了模板实例化,则会检查是否存在针对实例化参数的推断类型的该模板的完全特化。如果存在这样的特化,则选择它。请注意,完全特化是在模板被选择之后最后考虑的。
C++11 引入了可变参数模板。如果对于给定的函数调用,可变参数模板实例化和常规模板实例化都有效,则常规模板实例化始终被认为更具体,因此在重载解析期间会被优先选择。
2.5.2. SFINAE 原则
如果使用函数模板,可能会出现编译器无法推断特定调用中模板参数类型的情况。例如:
template<typename T>
void Foo(const T* x);
// ...
Foo(42);
在这种情况下,如果当前作用域包含参数已成功推断的重载模板或重载的非模板函数,则不会发生错误;此类模板会被“静默地”排除在重载解析之外。这被称为 SFINAE 原则,即“替换失败并非错误”。
2.5.3. 过载解析示例
让我们来看一个关于重载函数、模板和完全模板特化的例子。
void Foo(int x);
template<typename T>
void Foo(T x);
template<>
void Foo<double>(double x);
template<>
void Foo<const char*>(const char* x);
template<typename T>
void Foo(const T* x);
template<typename T>
class U {/* ... */};
template<typename T>
void Foo(U<T> u);
让我们看看根据上述规则,以下调用是如何解决过载问题的:
Foo(42);
Foo(3.14);
Foo(42L);
Foo("meow");
Foo(U<int>());
在第一次调用中,存在一个完全匹配的非模板函数和一个完全匹配的实例化:模板 1 用于int。选择非模板函数。
在第二次调用中,非模板函数与目标函数不完全匹配;需要进行从目标函数double到模板函数的转换int。存在一个完全匹配项:模板 1 用于目标函数double,并且是模板 1 用于目标函数的完全特化版本double,因此选择该特化版本。
在第三次调用中,非模板函数与目标函数不完全匹配;需要进行转换long。int存在一个完全匹配项:模板 1 long,因此选择该模板。
在第四次调用中,非模板函数完全不适用;有两个完全合适的实例化方式:模板 1 用于const char*,模板 2 用于char。选择模板 2 作为更专门化的实例,因此,const char*不考虑模板 1 的完全专门化。
在第五次调用中,非模板函数完全不适用;有两个完全合适的实例化方式:模板 1U<int>和模板 3。int选择模板 3 作为更专门的实例化方式。
2.5.4. 管理模板过载
我们来看看重载函数和模板:
void Foo(int x);
template<typename T>
void Foo(T x);
非模板函数仅对类型为`nil`、` intnil` 、`nil`的参数生效;对于其他整数参数(例如`nil`、`nil` 、`nil` 等),则会选择模板版本。在这种情况下,模板版本被称为“贪婪”版本。这并非总是我们期望的行为;例如,我们通常希望所有整数参数都使用第一个函数。有几种方法可以解决这个问题。一种方法是为所有整数类型添加重载函数,但这相当繁琐。另一种方法是禁用模板。为此,需要将模板重写如下:int&const intconst int&longshortunsigned int
template<
typename T,
typename S = std::enable_if_t<!std::is_integral<T>::value>>
void Foo(T x);
现在对于整数参数,此模板无法实例化,并且根据 SFINAE 原则,它将在重载解析期间被排除,因此将选择一个非模板函数并执行必要的隐式参数转换。
最后,还可以选择使用条件指令和运算符,而完全不使用重载:
template<typename T>
void FooInt(T x);
template<typename T>
void FooEx(T x);
template<typename T>
void Foo(T x)
{
if constexpr (std::is_integral<T>::value)
{
FooInt(x);
}
else
{
FooEx(x);
}
}
template<typename T>
void Foo(T x)
{
std::is_integral<T>::value
? FooInt(x)
: FooEx(x);
}
所述选项需要包含头文件<type_traits>。
2.6. “相关”类型参数的重载解析规则
在本节中,我们将考虑函数参数具有“相关”类型时的重载规则:类型本身、引用、对常量的引用、右值引用。
为了描述这些规则,我们需要使用所谓的论证类别。就我们目前的详细程度而言,四个类别就足够了:
- 左值——命名非常量变量;
- 常量(不可变)左值被称为常量变量;
- 右值是匿名的临时非常量变量或左值,它们已经应用了转换。
std::move(); - 常量(不可变)右值是匿名的临时常量变量。
这两个常量类别通常可以被视为一个单一类别——常量。
现在我们来考虑一下所讨论的参数类型的有效论证类别。
假设该参数是引用类型:
void Foo(T& x);
在这种情况下,唯一有效的论证类别是左值。
假设参数为右值引用类型:
void Foo(T&& x);
在这种情况下,唯一有效的参数类别是右值。
让参数的类型可以是常量引用或类型本身:
void Foo(const T& x);
void Foo(T x);
2.6.1. 按引用、常量引用和值传递参数
允许函数重载如下:
void Foo(T& x);
void Foo(const T& x);
在这种情况下,第一个函数将用于左值(尽管第二个函数也是可以接受的),第二个函数将用于其他类别。
现在,让我们按如下方式重载这些函数:
void Foo(T& x);
void Foo(T x);
对于常量和右值,将选择第二个函数;但对于左值,选择将不明确。
允许函数重载如下:
void Foo(const T& x);
void Foo(T x);
对于任何论点,选择都将是模棱两可的。
对于非静态成员函数,限定符const允许根据隐藏参数的常量性来重载函数this。
class X
{
public:
X();
void Foo();
void Foo() const;
void DoSomething() const;
void DoSomethingElse();
};
void X::DoSomething() const
{
// ...
Foo(); // Foo() const
// ...
}
void X::DoSomethingElse()
{
// ...
Foo(); // Foo()
// ...
}
// ...
X x;
x.Foo(); // Foo()
const X cx;
cx.Foo(); // Foo() const
非常量成员函数可以对右值对象调用,这意味着它们可以修改右值。但是,不允许通过引用非常量成员函数来向右值函数传递参数。
class X
{
public:
X();
void Swap(X& other) noexcept;
// ...
};
// ...
X x;
// ...
X().Swap(x);
x.Swap(X());
修改右值对象的能力看似有些奇怪,甚至毫无意义。但事实并非如此;有时它能带来优势。本例演示了使用右值对象和状态交换函数彻底清除对象的常用方法。(请记住,所有移动语义都基于修改右值对象。)然而,修改右值对象可能会引发各种问题。为了避免这种情况,按值返回对象的函数会将返回类型声明为 const。
2.6.2. R值参考
C++11 最重要的创新之一是移动语义。为了实现它,引入了一种特殊类型——右值引用。右值引用是一种普通的 C++ 引用,区别在于初始化规则以及解析带有右值引用参数的函数重载的规则。程序员必须完全了解以下规则;否则,重载结果可能出乎意料,编译器会默默地将移动操作转换为复制操作,从而失去移动操作的所有优势。
允许函数重载如下:
void Foo(T&& x);
void Foo(const T& x);
在这种情况下,对于右值参数,将选择第一个函数(尽管第二个函数也是可以接受的),对于其余类别,将选择第二个函数。
允许函数重载如下:
void Foo(T&& x);
void Foo(T x);
在这种情况下,对于左值和常量参数,将选择第二个函数;但对于右值参数,选择将不明确,也就是说,不会选择第一个函数。
允许函数重载如下:
void Foo(T&& x);
void Foo(T& x);
在这种情况下,第一个函数将用于右值参数,第二个函数将用于左值参数,而对于常量参数,重载解析将失败。
请注意,第四类——常量右值——在使用按值返回声明为 const 的对象时可能变得重要。(原因在前一节中已讨论。)如果此类型是可移动的,则不能将其声明为 const,因为这会破坏所有移动语义。
C++11 中与右值引用相关的另一个新特性是用于非静态成员函数的引用限定符。这些限定符允许基于隐藏参数的左值/右值类别进行重载this。
class X
{
public:
X();
void Foo() &;
void Foo() &&;
// ...
};
// ...
X x;
x.Foo ();
X().Foo();
需要注意的一点是:命名右值引用本身就是一个左值。定义带有右值引用参数的函数时必须考虑到这一点。这些参数是左值,如果它们被用作内部调用的参数,则很可能需要进行类型转换std::move(),否则在重载解析期间将不会选择带有右值引用参数的版本。
2.6.3. 通用链接
在模板中
template<typename T>
void Foo(T&& x);
函数参数的类型不是右值引用,而是所谓的通用引用。此类模板允许任何类别的参数。对于左值参数,类型推断为 `int`;对于常量,类型x推断为 `int`;对于右值参数,类型推断为 `int` 。参数本身是左值,因此如果它被用作某个内部调用的参数,通常会被转换为右值。如果参数类型为 `int` ,则会将参数转换为右值。具有通用引用的模板是贪婪的,这意味着它们在重载解析期间具有很高的优先级。如何处理贪婪模板将在第 2.5 节中讨论。
3. 其他与超负荷相关的主题
3.1 不完整类型参数
在 C++ 中,某些情况下,编译器只需要知道给定的名称是用户自定义类型(类、结构体、联合体、枚举)的名称,而无需完整的类型声明。在这种情况下,可以使用不完整声明,也称为前向声明。使用不完整声明的类型称为不完整类型。重载机制也适用于不完整类型。
class X;
class Y;
void Foo(X* x);
void Foo(Y* y);
// ...
X* px;
// ...
Foo(px); // void Foo(X* x);
X在这种情况下,可能无法获得完整的类声明,但重载解析仍然有效。
3.2 函数指针的初始化
初始化函数指针时,也可以使用重载函数。
void Foo(int x);
void Foo(const char* x);
// ...
void (*pF)(int) = Foo; // Foo(int)
重载解析的工作原理更为简单:为了成功初始化,参数和返回值必须完全匹配。作用域(隐藏、扩展)的规则相同,但 ADL 不起作用。
namespace N
{
class X {/* ... */};
void Foo(const X& x);
}
// ...
void (*pF)(const N::X&) = Foo;
void (*pF)(const N::X&) = N::Foo; // OK
using N::Foo;
void (*pF)(const N::X&) = Foo; // OK
类型转换还可以从重载函数中进行选择。
void Foo(int x);
void Foo(const char* x);
// ...
auto pf = static_cast<void(*)(int)>(Foo); // Foo(int)
这里也要求参数和返回值完全匹配,而 ADL 无法做到这一点。
现在让我们考虑使用关键字定义函数指针的情况auto。
auto pf = Foo;
如果Foo只有一个候选函数名为 `<T>`,则编译过程不会出错,类型pf会被推断为指向现有 `<T>` 的指针Foo。但是,如果存在其他名为 `<T>` 的重载函数Foo,则会发生错误;编译器无法仅根据名称确定类型并做出选择。
当从(非模板)参数的类型推断出函数式类型时,模板中也会出现类似的情况。例如,函数式模板实例化的初始化std::function<>,以及几乎所有带有函数式类型参数的算法,例如 `function` std::sort()。要解决这个问题,必须使用显式类型转换。
在初始化指向类成员函数的指针时,也可以使用重载成员函数。
class X
{
// ...
void Foo(int a);
void Foo(const char* a);
};
// ...
void (X::*pF)(int) = &X::Foo; // X::Foo(int)
3.3. 重载和默认值
从程序员的角度来看,使用重载函数和带有默认参数的函数可能非常相似。
重载函数
void Foo(int x);
void Foo() { Foo(0); }
可以用一个带有默认参数的函数来代替
void Foo(int x = 0);
在使用 `__init__` 的代码中Foo(),这种差异几乎无法察觉。但从编译器的角度来看,这种差异非常显著,程序员如果尝试获取函数地址,就会注意到这一点。
void (*pF1)(int) = Foo;
void (*pF0)() = Foo;
对于重载函数,这段代码是正确的。
在一定范围内,可以混合使用重载和默认参数,编译器也会处理这个问题,但这很难称得上是良好的编码风格。
3.4 重载虚函数
重载虚函数应谨慎对待。重载解析在编译时进行,因此使用的是调用虚函数的变量的静态类型。这与虚函数的动态特性不符,可能导致一些意想不到的问题(例如,继承的基类函数的“丢失”,参见 1.3 节)。[Dewhurst] 对此情况有更详细的描述。然而,谨慎对待并不意味着完全避免使用虚函数。如果我们设计一个多态类层次结构,其根部是一个接口类(一个几乎全部由纯虚成员函数组成的抽象类),所有重载都在该接口类中实现,并且对派生类的访问也仅通过该接口类进行,那么就不会出现问题。这种构建多态类层次结构的模型被广泛应用。在附录 A 中,我们将展示如何使用虚函数重载来实现著名的访问者模式。
3.5 元编程
重载解析发生在编译时,因此这种机制被广泛应用于元编程(即在编译时执行的编程代码)中也就不足为奇了。元编程被广泛应用于模板编写,包括标准库模板。如果没有元编程,几乎不可能编写出通用、灵活且高效的模板。
如您所知,迭代器分为五类。类别标识符是空标签类型,例如 `<int> std::input_iterator_tag`。此类型可通过typedef迭代器的内部类型 `<int>`访问iterator_category。如果您需要在模板中根据迭代器类别提供不同的行为,则需要为每种标签类型编写重载函数,将调用放置在适当的位置,并将该类型的实例作为参数传递iterator_category。然后,编译器将选择与迭代器类别匹配的函数。以下是一个示例:
void DisplayIterCat(std::input_iterator_tag tag)
{
std::cout << "Input iterator\n";
}
template<class Iter>
void DisplayIterCat(Iter it)
{
DisplayIterCat(typename Iter::iterator_category());
}
注意关键字的使用typename。这是必要的,以便iterator_category将其视为内部类型Iter。
在 C++ 中,使用模板可以非常轻松地将编译时已知的整数常量转换为类型。标准库为此提供了一个专门的模板:
template<typename T, T val>
struct integral_constant;
T当此模板被实例化时,会为编译时已知的每种整数类型及其值获取一个唯一的类型T。最常用的类型有:
typedef integral_constant<bool, true> true_type;
typedef integral_constant<bool, false> false_type;
当出现不同类型的情况时,可以使用过载。
这是元编程中使用的另一种技术。考虑以下表达式:
sizeof(Foo(expr))
这个表达式在编译时求值。它expr本身并不求值,只确定其类型。之后会进行重载解析,但Foo不会调用重载函数;只确定返回类型,因此Foo无需定义重载函数。因此,重载用于将类型映射到数值。
C++17 引入了 `\begin{meta}` 指令if constexpr (),极大地简化了元编程,使其更加熟悉和便捷。然而,大量代码(包括标准库中的代码)并未使用此指令,而且不太可能进行重写。
4. 结果
超负荷工作是一种强大的工具,但必须深思熟虑、谨慎使用。超负荷工作也充满陷阱,因此评估自身优势并避免不必要的麻烦至关重要。
不要仅仅因为编译器允许就使用重载。过度重载会降低代码的可读性,并使其显得过于通用。在很多情况下,使用能够反映操作具体性质的名称会更好。
尽量避免使用需要复杂且尚未完全理解的重载解析算法的重载函数和模板。
你不应该在嵌套作用域中声明同名函数——这不叫函数重载。
应用程序
附录A. 双重派遣和访客模式
让我们有两个多态类层次结构,其中一个有基类A,另一个V有函数。
void DoDblDispatchOper(A* a, V* v);
变量 `a`a和v`b` 实际上可以指向相应层次结构中任何派生类的对象,并且函数必须动态地提供一个取决于 `a`a和`b` 实际类型的操作v,也就是说,它必须从某个操作矩阵中进行选择。这称为双重分派。虚函数提供单重派派;重载是一个静态操作,因此实现双重分派需要额外的工作。访问者模式(Gang of Four [GoF] 模式之一)提出了一种解决此问题的方法。
让我们有两个多态类层次结构,第一个基于接口类IAcceptor,第二个基于接口类IVisitor。
class IVisitor;
class IAcceptor
{
public:
virtual void Accept(IVisitor* visitor) = 0;
// ...
};
class A1;
class A2;
// ...
class IVisitor
{
public:
virtual void Visit(A1* a) = 0;
virtual void Visit(A2* a) = 0;
// ...
};
class A1 : public IAcceptor
{
void Accept(IVisitor* visitor) override
{
visitor->Visit(this);
}
// ...
};
class A2 : public IAcceptor
{
void Accept(IVisitor* visitor) override
{
visitor->Visit(this);
}
// ...
};
// ...
class V1 : public IVisitor {/* ... */};
class V2 : public IVisitor {/* ... */};
// ...
接口类会根据层次结构中的每个类IVisitor重写一个虚函数。派生类中重写的虚函数构成了一个运算矩阵,必须从中选择一个元素。Visit()IAcceptorVisit()
虚拟功能
void IAcceptor::Accept(IVisitor* visitor);
在所有派生类中,它都以相同的方式重新定义,主体由一条指令组成:
visitor->Visit(this);
由于每个类this都有不同的类型,因此会执行重载解析并选择合适的版本Visit()。然后,标准的虚函数选择机制接管Visit(),并选择实际类型的重写版本visitor。
最后,
void DoDblDispatchOper(IAcceptor* acceptor, IVisitor* visitor)
{
acceptor->Accept(visitor);
}
好了,双调度功能已准备就绪。
附录 B. 用自定义版本替换标准函数
有时需要用自定义实现替换标准库函数。最常见的例子是用于交换两个对象状态的函数(或者更确切地说,是函数模板)。
template<typename T>
void swap(T& a, T& b);
这个函数应用非常广泛;例如,标准库中的许多算法都使用它。然而,对于某些类型,标准实现的效率可能很低,因此自然而然地会产生一个问题:如何用更优化的用户自定义版本来替换它。当std::swap()模板在每次实例化时都会被编译时,这种替换就成为可能,而重载机制在这种情况下起着关键作用。为了确保模板std::swap()在编译期间使用除 `[function]` 之外的、专门为该类定义的函数,必须采取几个步骤。我们首先考虑普通类(而非模板)的情况。
1. 在类中定义一个成员函数Swap()(名称不重要),该函数实现状态交换。
class X
{
public:
void Swap(X& other) noexcept;
// ...
};
为确保此函数不会抛出异常,在 C++11 中,此类函数必须声明为noexcept。
2. 在与类相同的命名空间中X(通常在同一个头文件中,有时在类体中),定义一个自由(非成员)函数swap()如下(名称和签名很重要):
inline void swap(X& a, X& b) noexcept { a.Swap(b); }
之后,由于 ADL 的存在,该函数将能够参与拥塞解决,并且std::swap()在这种情况下,它将被选为具有最佳匹配。
3. 确定完全专业化std::swap()方向X
namespace std
{
template<>
void swap<X>(X& a, X& b) noexcept { a.Swap(b); }
};
该标准禁止向命名空间添加std函数、模板或其他任何内容。例外情况是来自std.
那么问题来了,为什么需要第三步呢?答案是,它可以防止某些错误。我们来看一个例子:用户命名空间实现了一个使用状态交换函数的模板。
namespace N
{
template<typename T>
// ...
T x, y;
// ...
swap(x, y);
// ...
很自然地会认为,如果T定义了 `[defines]` swap(),则会使用此状态交换函数;否则,将使用标准函数。然而,在给定的版本中,std::swap()解析时不会考虑重载函数;它会被隐藏,因此这种做法完全错误。
现在状态交换可以这样进行:
std::swap(x, y);
在这种情况下,定制化swap()将不予考虑,而完全专业化则std::swap()成为首选。但这也不是完全正确的选择。
完全正确的答案是:
using std::swap;
swap(x, y);
此选项确保如果用户定义了状态交换函数,则会调用该函数。否则,std::swap()如果定义了完全特化,则会调用该特化。在所有其他情况下,将调用通用模板实例化。启用此选项后,不再需要std::swap()完全特化。std::swap()
但如果标准库(即命名空间中的库std)错误地使用了完全特化,则会有所帮助。
std::swap(x, y);
而不是正确的那个
swap(x, y);
现在让我们考虑一下需要为类模板定义状态交换函数的情况。
template<typename T>
class X
{
// ...
public:
void Swap(X& other) noexcept;
};
template<typename T>
void swap(X<T>& a, X<T>& b) noexcept { a.Swap(b); }
但是std::swap()我们不能再进行特化了;要做到这一点,我们必须向命名空间添加函数模板std,这是标准所禁止的,因此将没有安全网,错误的代码可能无法正常工作。
使用关键字,可以将friend定义swap()移动到模板内部:
template<typename T>
class X
{
// ...
void Swap(X& other) noexcept;
friend void swap(X& a, X& b) noexcept { a.Swap(b); }
};
定义变得更加简洁,成员函数Swap()可以是私有的或受保护的。
更多推荐
所有评论(0)