函数重载和函数模板

11.1 函数重载

函数重载的简介

函数重载允许我们创建多个具有相同名称的函数,只要每个相同名称的函数具有不同的参数类型(或者可以通过其他方式区分函数)。 每个共享名称(在同一范围内)的函数称为重载函数(有时简称为重载)。

上面的程序将正常编译。 尽管您可能期望这些函数会导致命名冲突,但这里的情况并非如此。 由于这些函数的参数类型不同,编译器能够区分这些函数,并将它们视为恰好共享名称的单独函数。

重载解析的简介

此外,当对已重载的函数进行函数调用时,编译器将尝试根据函数调用中使用的参数将函数调用与适当的重载相匹配。 这称为重载解析。

 

使其编译

为了使使用重载函数的程序能够编译,必须满足以下两点:

  • 每个重载函数都必须与其他函数区分开来。 我们在第 10.11 课讨论。

  • 对重载函数的每次调用都必须解析为重载函数。 我们在第 10.12 课——函数重载解析和模糊匹配中讨论编译器如何将函数调用与重载函数相匹配。

11.2 函数重载区分

前文我们引入了函数重载的概念,它允许我们创建多个具有相同名称的函数,只要每个同名函数具有不同的参数类型(或者可以通过其他方式区分函数)。

在本节中,我们将仔细研究重载函数是如何区分的。 未正确区分的重载函数将导致编译器发出编译错误。

   
Function propertyUsed for differentiationNotes
Number of parametersYes 
Type of parametersYesExcludes typedefs, type aliases, and const qualifier on value parameters. Includes ellipses.
Return typeNo 

作为旁白:

当编译器编译函数时,它会执行名称重整,这意味着函数的编译名称会根据各种标准(例如参数的数量和类型)进行更改(“重整”),以便链接时具有唯一的名称。

例如,某些原型 int fcn() 的函数可能会编译为名称 fcn_v,而 int fcn(int) 可能会编译为名称 fcn_i。 因此,虽然在源代码中,两个重载函数共享一个名称,但在编译代码中,这些名称实际上是唯一的。

对于如何重整名称没有标准化,因此不同的编译器会产生不同的重整名称。

11.3 函数重载解析和不明确的匹配

在上一课(11.2——函数重载区分)中,我们讨论了函数的哪些属性用于区分重载函数。 如果重载函数无法与同名的其他重载正确区分,则编译器将发出编译错误。

然而,拥有一组差异化的重载函数只是问题的一半。 当进行任何函数调用时,编译器还必须确保可以找到匹配的函数声明。

对于非重载函数(具有唯一名称的函数),只有一个函数可能与函数调用匹配。 该函数要么匹配(或者可以在应用类型转换后匹配),要么不匹配(并导致编译错误)。 对于重载函数,可能有许多函数可能与函数调用匹配。 由于函数调用只能解析其中一个,因此编译器必须确定哪个重载函数是最佳匹配。 将函数调用与特定重载函数相匹配的过程称为重载解析

11.4 删除函数

假设我们认为使用 char 或 bool 类型的值调用 printInt() 没有意义。 我们可以做什么?

使用=delete说明符删除函数

如果我们明确不想调用一个函数,我们可以使用 = delete 说明符将该函数定义为已删除。 如果编译器将函数调用与已删除的函数相匹配,则编译将因编译错误而停止。

使用此语法更新代码:

= delete 的意思是“我禁止这个”,而不是“这个不存在”。

删除所有不匹配的重载

删除一堆单独的函数重载是能行的,但可能很冗长。 我们可以通过使用函数模板(在即将到来的第 11.6 课——函数模板 中介绍)来完成此操作:

11.5 默认参数

默认参数是为函数参数提供的默认值。

也许令人惊讶的是,默认参数由编译器在调用点处理。 在上面的例子中,当编译器看到 print(3) 时,它会将这个函数调用重写为 print(3, 4),以便参数的数量与参数的数量相匹配。 重写后的函数调用将像往常一样工作。

何时使用默认参数

当函数需要一个具有合理默认值的值,但您希望让调用者根据需要进行覆盖时,默认参数是一个很好的选择。

作者注:

由于用户可以选择是否提供特定的参数值或使用默认值,因此提供默认值的参数有时称为可选参数。 但是,术语“可选参数”也用于指代其他几种类型的参数(包括通过地址传递的参数和使用 std::Optional 的参数),因此我们建议避免使用该术语。

多个默认参数

一个函数可以有多个带有默认参数的参数:

C++不支持例如 print(,,3)的函数调用语法( x 和 y 用默认参数,而为 z 提供显式值)。这有两个主要后果 :

  1. 如果为参数指定了默认参数,则所有后续参数(右侧)也必须指定为默认参数。

  2. 如果多个参数具有默认参数,则越靠左边的参数应该是越有可能由用户显式设置的参数。

默认参数不能重新声明

一旦声明,默认参数就不能重新声明(在同一文件中)。 这意味着对于具有前向声明和函数定义的函数,默认参数可以在前向声明或函数定义中声明,但不能同时在两者中声明。

最佳实践是在前向声明中而不是在函数定义中声明默认参数,因为前向声明更有可能被其他文件看到(特别是在头文件中)。

默认参数和函数重载

具有默认值的参数既有可能区分函数重载,但也可能会导致潜在的不明确的函数调用。 两个例子:

总结:默认参数提供了一种有用的机制来指定用户可能想要或不想覆盖的参数值。 它们在 C++ 中经常使用,您将经常看到它们。

11.6 函数模板

考虑为下面的函数支持int, double, long, long double 甚至是您自己创建的新类型等。且实现代码与 int 版本 max 的实现代码完全相同:

必须为我们想要支持的每组参数类型创建具有相同实现的重载函数,这样维护起来令人头疼,容易出错,并且明显违反了 DRY(don’t repeat yourself)原则。这里还有一个不太明显的挑战:使用 max 函数的程序员可能希望使用 max 函数的作者没有预料到的参数类型来调用它(因此作者没有为其编写重载函数)。

我们真正缺少的是某种编写 max 单一版本的方法,它可以处理任何类型的参数(甚至是编写 max 代码时可能没有预料到的类型)。 普通功能根本无法胜任这里的任务。 幸运的是,C++ 支持另一个专门为解决此类问题而设计的功能。

欢迎来到 C++ 模板的世界。

C++模板的介绍

在 C++ 中,模板系统旨在简化创建能够使用不同数据类型的函数(或类)的过程。

我们不是手动创建一堆几乎相同的函数或类(每组不同类型一个),而是创建一个模板。 就像普通的定义一样,模板描述了函数或类的样子。 与普通定义(必须指定所有类型)不同,在模板中我们可以使用一种或多种占位符(placeholder)类型。 占位符类型表示在编写模板时未知的某种类型,但稍后将提供。

一旦定义了模板,编译器就可以使用模板根据需要生成任意数量的重载函数(或类),每个重载函数(或类)使用不同的实际类型!

最终结果是相同的——我们最终得到了一堆几乎相同的函数或类(每组不同类型一个)。 但我们只需要创建和维护一个模板,编译器就会为我们完成所有艰难的工作。

关键见解:编译器可以使用单个模板来生成一系列相关的函数或类,每个函数或类使用一组不同的类型。

小讲堂:由于实际类型直到在程序中使用模板时(而不是在编写模板时)才确定,因此模板的作者不必尝试预测可能使用的所有实际类型。 这意味着模板代码可以与编写模板时甚至不存在的类型一起使用! 稍后当我们开始探索 C++ 标准库时,我们将看到它如何派上用场,其中绝对充满了模板代码

关键见解:模板可以使用在编写模板时甚至不存在的类型。 这有助于使模板代码既灵活又面向未来!

函数模板

函数模板是一种类函数的定义,用于生成一个或多个重载函数,每个重载函数具有一组不同的实际类型。 这将使我们能够创建可以与许多不同类型一起使用的函数。

创建函数模板时,我们对任何参数类型、返回类型或稍后要指定的函数体中使用的类型使用占位符类型,也称为类型模板参数(type template parameters)或模板类型(template types)。

函数模板最好通过示例来教授,因此让我们将上面示例中的普通 max(int, int) 函数转换为函数模板。 这非常简单,我们将解释一路上发生的事情。

创建模板化 max 函数
  1. 请注意,我们在此函数中使用了三次 int 类型:一次用于参数 x,一次用于参数 y,一次用于函数的返回类型。要创建函数模板,我们要做两件事。 首先,我们将用类型模板参数替换特定类型。 在这种情况下,因为我们只有一种需要替换的类型(int),所以我们只需要一个类型模板参数(我们将其称为 T)。这是我们使用单一模板类型的新函数:

    这是一个好的开始——但是,它不会编译,因为编译器不知道 T 是什么! 而且这仍然是一个普通函数,而不是函数模板。

  2. 我们将告诉编译器这是一个函数模板,并且 T 是一个类型模板参数,它是任何类型的占位符。 这是使用所谓的模板参数声明来完成的。 模板参数声明的范围仅限于后面的函数模板(或类模板)。 因此,每个函数模板(或类)都需要有自己的模板参数声明。

    在模板参数声明中,我们从关键字 template 开始,它告诉编译器我们正在创建一个模板。 接下来,我们在尖括号 (<>) 内指定模板将使用的所有模板参数。 对于每个类型模板参数,我们使用关键字 typename 或 class,后跟类型模板参数的名称(例如 T)。

  1. 我们在第 11.8 课中讨论如何创建具有多种模板类型的函数模板

  2. 在这种情况下,typename 和 class 关键字没有区别。 您经常会看到人们使用 class 关键字,因为它是较早引入到语言中的。 但是,我们更喜欢较新的 typename 关键字,因为它更清楚地表明类型模板参数可以替换为任何类型(例如基本类型),而不仅仅是类类型。

不管你信不信,我们已经完成了! 我们创建了 max 函数的模板版本,它现在可以接受不同类型的参数。

因为该函数模板有一个名为 T 的模板类型,所以我们将其称为 max。 在下一课中,我们将了解如何使用 max 函数模板生成一个或多个具有不同类型参数的 max() 函数。

命名模板参数

略 : ① 简单情况约定用T ② 如果类型模板参数具有某些要求,可以用(T前缀+)大写字母开头的名字

11.7 函数模板实例化

本节,我们将重点介绍如何使用函数模板。

使用函数模板

函数模板实际上并不是函数——它们的代码不会直接编译或执行。 相反,函数模板只有一项工作:生成函数(被编译和执行)。

要使用 max 函数模板,我们可以使用以下语法进行函数调用:

这看起来很像普通的函数调用——主要区别是在尖括号中添加了类型(称为模板实参),它指定将用于代替模板类型 T 的实际类型。

上述程序里,我们的函数模板将用于生成两个函数:一次将 T 替换为 int,另一次将 T 替换为 double。 所有实例化之后,程序将如下所示:

将隐式实例化写为显式形式

模板参数推导

如果参数的类型与我们想要的实际类型匹配,我们不需要指定实际类型 - 相反,我们可以使用模板参数推导来让编译器在函数调用中从参数类型中推导应该使用的实际类型

例如,不用进行这样的函数调用:

我们可以改为执行以下操作之一:

请注意底例(std::cout << max(1, 2) << '\n';)中的语法看起来与普通函数调用相同! 最佳实践: 在大多数情况下,我们将使用这种正常的函数调用语法来调用从函数模板实例化的函数。

原因如下:

具有非模板参数的函数模板
实例化函数可能并不总是可以编译
实例化函数可能并不总是在语义上有意义

我们可以告诉编译器不允许使用某些参数实例化函数模板。 这是通过使用函数模板专门化来完成的,用到了 = delete 来删除函数。

在多文件中使用函数模板

考虑以下程序,该程序无法正常工作

如果 addOne 是非模板函数,则此程序可以正常工作:在 main.cpp 中,编译器会对 addOne 的前向声明感到满意,并且链接器会将 main.cpp 中对 addOne() 的调用连接到该函数 定义在add.cpp中。

但是因为 addOne 是一个模板,所以这个程序不起作用,我们得到一个链接器错误...error LNK2019: unresolved external symbol "int __cdecl addOne<int>(int) ...

这里编译器的行为是:

在 main.cpp 中,我们调用 addOne 和 addOne。 但是,由于编译器看不到函数模板 addOne 的定义,因此无法在 main.cpp 中实例化这些函数。 不过,它确实看到了 addOne 的前向声明,并且会假设这些函数存在于其他地方,并将在稍后链接。

当编译器去编译add.cpp时,它会看到函数模板addOne的定义。 但是,add.cpp 中没有使用此模板,因此编译器不会实例化任何内容。 最终结果是链接器无法将对 main.cpp 中的 addOne 和 addOne 的调用连接到实际函数,因为这些函数从未实例化。

最佳实践:解决此问题的最传统方法是将所有模板代码放入头文件 (.h),而不是源文件 (.cpp):

您可能想知道为什么这不会导致违反单一定义规则 (one-definition rule)。 ODR 规定类型、模板、内联函数和内联变量允许在不同文件中具有相同的定义。 因此,如果将模板定义复制到多个文件中(只要每个定义相同),就没有问题。

但是实例化函数本身又如何呢? 如果一个函数在多个文件中实例化,如何不导致违反 ODR? 答案是从模板隐式实例化的函数是隐式内联的。 如您所知,内联函数可以在多个文件中定义,只要每个文件中的定义相同即可。

关键见解:

模板定义不受单一定义规则的约束,该规则要求每个程序只需要一个定义,因此将相同的模板定义#included 到多个源文件中不是问题。 从函数模板隐式实例化的函数是隐式内联的,因此它们可以在多个文件中定义,只要每个定义都是相同的。

模板本身不是内联的,因为内联的概念仅适用于变量和函数。

泛型编程

由于模板类型可以替换为任何实际类型,因此模板类型有时称为泛型类型。 由于模板的编写可以与特定类型无关,因此使用模板进行编程有时称为泛型编程。 C++ 通常非常关注类型和类型检查,相比之下,泛型编程让我们专注于算法逻辑和数据结构设计,而不必过多担心类型信息。

总结

一旦习惯了编写函数模板,您就会发现它们实际上并不比编写具有实际类型的函数花费更长的时间。 函数模板可以通过最大限度地减少需要编写和维护的代码量来显着减少代码维护和错误。

函数模板确实有一些缺点,如果我们不提及它们,那就太失职了。 首先,编译器将为每个函数调用创建(并编译)一个具有唯一参数类型集的函数。 因此,虽然函数模板编写起来很紧凑,但它们可能会扩展为大量代码,从而导致代码膨胀和编译时间变慢。 函数模板的更大缺点是它们往往会产生看起来疯狂的、几乎无法阅读的错误消息,这些错误消息比常规函数更难破译。 这些错误消息可能非常令人生畏,但是一旦您了解了它们想要告诉您的内容,它们所指出的问题通常就很容易解决。

与模板为编程工具包带来的强大功能和安全性相比,这些缺点相当小,因此在需要类型灵活性的任何地方都可以自由使用模板! 一个好的经验法则是首先创建普通函数,然后如果您发现需要不同参数类型的重载,则将它们转换为函数模板。

11.8 具有多种模板类型的函数模板

下面的程序会编译失败,

在函数调用 max(2, 3.5) 中,我们传递两种不同类型的参数:一种 int 和一种 double。 因为我们在不使用尖括号来指定实际类型的情况下进行函数调用,所以编译器将首先查看 max(int, double) 是否存在非模板匹配。 它不会找到一个。

接下来,编译器将查看是否可以找到函数模板匹配(使用模板参数推导)。 然而,这也会失败,原因很简单:T 只能代表单一类型。 T 没有任何类型允许编译器将函数模板 max(T, T) 实例化为具有两种不同参数类型的函数。 换句话说,由于函数模板中的两个参数都是 T 类型,因此它们必须解析为相同的实际类型。

由于未找到非模板匹配,并且未找到模板匹配,因此函数调用无法解析,并且我们收到编译错误。

您可能想知道为什么编译器不生成函数 max(double, double),然后使用数值转换将 int 参数类型转换为 double。 答案很简单:类型转换仅在解决函数重载时完成,而不是在执行模板参数推导时完成。

这种类型转换的缺乏是有意为之的,至少有两个原因。

首先,它有助于使事情变得简单:我们要么找到函数调用参数和模板类型参数之间的精确匹配,要么找不到。 其次,它允许我们设计需要确保两个或多个参数具有相同类型的函数模板。

我们必须找到另一个解决方案。 幸运的是,我们可以通过(至少)三种方式解决这个问题。

1. 使用 static_cast 将实参转换为匹配类型

第一个解决方案是让调用者承担将参数转换为匹配类型的负担。 例如:

然而,这个解决方案很笨拙并且难以阅读。

2.提供显式类型模板参数

幸运的是,如果我们指定要使用的显式类型模板参数,则不必使用模板参数推导:

在上面的例子中,我们调用 max(2, 3.5)。 因为我们已经明确指定 T 应替换为 double,所以编译器不会使用模板参数推导。 相反,它只会实例化函数 max(double, double),然后对任何不匹配的参数进行类型转换。 我们的 int 参数将隐式转换为 double。

虽然这比使用 static_cast 更具可读性,但如果我们在对 max 进行函数调用时根本不需要考虑类型,那就更好了。

3.具有多个模板类型参数的函数模板

问题的根源(root cause)在于我们只为函数模板定义了单一模板类型 (T),然后指定两个参数必须是同一类型。

解决这个问题的最好方法是重写我们的函数模板,使我们的参数可以解析为不同的类型。 我们现在将使用两个(T 和 U),而不是使用一个模板类型参数 T:

缩写函数模板 C++20

C++20引入了auto关键字的新用法:当auto关键字在普通函数中用作参数类型时,编译器会自动将函数转换为函数模板,每个auto参数成为独立的模板类型参数。 这种创建函数模板的方法称为缩写函数模板。

是 C++20 中以下内容的简写:

如果您希望每个模板类型参数都是独立类型,可以首选此形式,因为它更加简洁和可读。但当您希望多个自动参数为同一类型时,则没有一个简单的缩写函数模板可以实现这样的功能。

11.9 非类型模板参数

在前面的课程中,我们讨论了如何创建使用类型模板参数的函数模板。 类型模板参数充当作为模板实参传入的实际类型的占位符。

虽然类型模板参数是迄今为止最常用的模板参数类型,但还有另一种值得了解的模板参数:非类型模板参数。

非类型模板参数

~是constexpr 值的占位符。例子如下:

最佳实践:就像 T 通常用作第一个类型模板参数的名称一样,N 通常用作 int 非类型模板参数的名称。

非类型模版的用途

从 C++20 开始,函数参数不能为 constexpr。 对于普通函数、constexpr 函数(这是有道理的,因为它们必须能够在运行时执行),甚至可能令人惊讶的是 consteval 函数,都是如此。

例如,类类型 std::bitset 使用非类型模板参数来定义要存储的位数,因为位数必须是 constexpr 值。

作者注:必须使用非类型模板参数来规避函数参数不能为 constexpr 的限制并不好。 有很多不同的提案正在评估中,以帮助解决此类情况。 我预计我们可能会在未来的 C++ 语言标准中看到更好的解决方案。

11.x 第十一章总结

函数模板可能看起来相当复杂,但它们是使代码与不同类型的对象一起工作的非常强大的方法。 我们将在以后的章节中看到更多模板内容,所以请做好准备。

复合类型:引用和指针

12.1 复合数据类型简介

在第 4.1 课——基本数据类型简介中,我们介绍了基本数据类型,它们是 C++ 作为核心语言的一部分提供的基本数据类型。

到目前为止,我们在程序中已经大量使用了这些基本类型,尤其是 int 数据类型。 虽然这些基本类型对于简单的使用非常有用,但当我们开始做更复杂的事情时,它们并不能满足我们的全部需求。

复合数据类型

复合数据类型(有时也称为组合数据类型)是可以从基本数据类型(或其他复合数据类型)构造的数据类型。 每种复合数据类型也有其独特的属性。

我们可以使用复合数据类型来优雅地解决一些基本类型难以解决的挑战。

C++ 支持以下复合类型:

您已经经常使用一种复合类型:函数(functions)。例如,考虑这个函数:

该函数的类型为 void(int, double)。 请注意,该类型由基本类型组成,因此是复合类型。 当然,函数也有自己的特殊行为(例如可调用)。

12.2 值类别(左值和右值)

在我们讨论第一个复合类型(左值引用)之前,我们先绕道讨论一下什么是左值

前面5.4节,介绍过自增/自减运算符的副作用:一个函数或表达式如果存在超过它生命的影响,则被称为有副作用的。

在上面的程序中,表达式 ++x 递增 x 的值,并且即使在表达式完成计算后该值仍保持更改。

表达式的属性

为了帮助确定表达式应如何计算以及可以在何处使用它们,C++ 中的所有表达式都有两个属性:类型(a type)和 值类别(a value category)

表达式的类型

表达式的类型等同于由计算表达式得出的值、对象或函数的类型。

请注意,表达式的类型必须在编译时确定(否则类型检查和类型推导将不起作用)——但是,表达式的值可以在编译时(如果表达式是 constexpr)或运行时确定 (如果表达式不是 constexpr)。

表达式的值类别

表达式(或子表达式)的值类别指示表达式是否解析为值、函数或某种类型的对象。

在 C++11 之前,只有两种值类别:左值和右值。

在 C++11 中,添加了三个值类别(glvalue、prvalue 和 xvalue)。我们将在以后的章节中介绍移动语义(以及附加的三个值类别)。

左值和右值表达式

左值,lvalue(发音为“ell-value”,是“left value”或“locator value”的缩写)是计算结果为可识别(identifiable)对象或函数(或位字段)的表达式。

C++ 标准使用了术语“identify”。 具有身份的实体(例如对象或函数)可以与其他类似实体区分开来(通常通过比较实体的地址)。

具有身份🆔的实体可以通过标识符、引用或指针进行访问,并且通常有比单个表达式或语句更长的生命周期。

自从语言中引入常量以来,左值有两种子类型:modifiable / non-modifiable

  1. 可修改左值

  2. 不可修改左值(因为左值是 const 或 constexpr)

右值,rvalue(发音为“arr-value”,是“right value”的缩写)是一个不是左值的表达式。 右值表达式计算结果为一个值。 常见的右值包括字面量(C 风格的字符串字面量除外,它们是左值)以及按值返回的函数和运算符的返回值。 右值是不可识别的(意味着它们必须立即使用),并且仅存在于使用它们的表达式的范围内。

您可能想知道为什么 return5()x + 1static_cast<int>(d) 是右值:答案是因为这些表达式生成不是可识别对象的临时值。

these expressions produce temporary values that are not identifiable objects.

关键见解

💡 小Tips:如果您不确定表达式是左值还是右值,请尝试使用运算符&获取其地址,这要求其操作数是左值。 如果 &(expression); 可以编译过,表达式必须是左值。

左值到右值的转换

让我们再看一下这个例子:

如果 x 是计算结果为变量 x 的左值表达式,那么 y 如何最终得到值 5

答案是,在需要右值但提供了左值的上下文中,左值表达式将隐式转换为右值表达式。 int 变量的初始值设定项应为右值表达式。 因此,左值表达式 x 经历了左值到右值的转换,计算结果为值 5,然后用该值来初始化 y。

关键见解

识别左值和右值表达式的经验法则:

或者更通俗的理解:

12.3 左值引用