函数重载和函数模板
1. 函数重载简介
函数重载允许我们创建多个同名的函数,只要每个同名函数具有不同的参数类型(或者函数可以以其他方式区分)。共享一个名称(在同一范围内)的每个函数称为重载函数(有时简称为重载)。请考虑以下函数:
1 | int add(int x, int y) |
要重载我们的 add()
函数,我们可以简单地声明另一个采用双精度参数的 add()
函数:
1 | double add(double x, double y) |
只要编译器可以区分每个重载函数,就可以重载函数。如果无法区分重载函数,则会导致编译错误。此外,当调用一个被重载的函数时,编译器会根据函数调用中使用的参数尝试将函数调用与适当的重载进行匹配。这称为 重载解析(overload resolution)。下面是一个简单的示例:
1 |
|
输出:
1 | 3 |
为了使使用重载函数的程序进行编译,必须满足以下两个条件:
-
每个重载函数都必须与其他函数区分开来。
-
对重载函数的每次调用都必须解析为重载函数。
如果重载函数没有区分,或者对重载函数的函数调用无法解析为重载函数,则将导致编译错误。
2. 函数重载的区分
如何区分重载函数
函数属性 | 用于区分 | 备注 |
---|---|---|
参数数量 | 是 | |
参数类型 | 是 | 不包括typedef、类型别名和值参数上的const限定符。包括省略号。 |
返回类型 | 否 |
函数的返回类型不用于区分重载函数。
对于成员函数,还会考虑其他函数级别限定符:
函数级别限定符 | 用于重载 |
---|---|
const 或 volatile | 是 |
引用限定符 | 是 |
只要每个重载函数具有不同数量的参数,就会对重载函数进行区分。例如:
1 | int add(int x, int y) |
只要每个重载函数的参数类型列表不同,也可以区分函数。例如:
1 | int add(int x, int y); // integer version |
由于类型别名 (或 typedef) 不是非重复类型,因此使用类型别名的重载函数与使用别名类型的重载没有区别。例如,以下所有重载都不会进行区分(并且将导致编译错误):
1 | typedef int Height; // typedef |
对于按值传递的参数,也不考虑 const 限定符。因此,以下函数不被区分:
1 | void print(int); |
省略号参数被认为是一种唯一的参数类型:
1 | void foo(int x, int y); |
在区分重载函数时,不考虑函数的返回类型。考虑这样一种情况:你想编写一个返回随机数的函数,但你需要一个将返回 int 的版本,以及另一个将返回 double 的版本。您可能会想这样做:
1 | int getRandomValue(); |
这样会发生编译错误,解决此问题的最佳方法是给函数起不同的名称:
1 | int getRandomInt(); |
3. 函数重载解析和匹配不明确
3.1 函数重载解析
对于非重载函数(具有唯一名称的函数),只有一个函数可能与函数调用匹配。该函数要么匹配(或者在应用类型转换后可以匹配),要么不匹配(如果不匹配则会导致编译错误)。而对于重载函数,可能有多个函数与函数调用匹配。由于一个函数调用只能解析为其中的一个,编译器必须确定哪个重载函数是最佳匹配。将函数调用与特定重载函数匹配的过程称为重载解析(overload resolution)。
在函数参数类型和函数参数类型完全匹配的简单情况下,这(通常)很简单:
1 |
|
但是,如果函数调用中的参数类型与任何重载函数中的参数类型不完全匹配,会发生什么情况呢?例如:
1 |
|
当调用一个重载函数时,编译器会按照一系列规则逐步确定哪个(如果有的话)重载函数是最佳匹配。在每一步中,编译器都会对函数调用中的参数应用一系列不同的类型转换。对于每一个应用的转换,编译器都会检查是否有任何重载函数与之匹配。在所有不同的类型转换都应用并检查完匹配情况后,该步骤完成。结果将是以下三种可能情况之一:
-
未找到匹配的函数:编译器将继续执行序列中的下一步。
-
找到一个匹配的函数:该函数被认为是最佳匹配。匹配过程至此完成,后续步骤将不再执行。
-
找到多个匹配的函数:编译器将发出**匹配不明确(ambiguous match)**的编译错误。我们稍后将进一步讨论这种情况。
具体的步骤如下:
-
编译器尝试查找完全匹配项。这分两个阶段进行。
-
首先,编译器将查看是否存在一个重载函数,其中函数调用中的参数类型与重载函数中的参数类型完全匹配。
-
其次,编译器将对函数调用中的参数应用一些简单转换。简单转换(trivial conversions) 是一组特定的转换规则,这些规则将修改类型(不修改值)以查找匹配项。这些包括:
- 左值到右值的转换
- 限定转换(例如,非常量到常量的转换)
- 非引用到引用的转换
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18void foo(const int)
{
}
void foo(const double&) // double& is a reference to a double
{
}
int main()
{
int x { 1 };
foo(x); // x trivially converted from int to const int
double d { 2.3 };
foo(d); // d trivially converted from double to const double& (non-ref to ref conversion)
return 0;
}在上面的例子中,我们调用了
foo(x)
,其中x
是一个int
。编译器会简单地将x
从int
转换为const int
,然后匹配foo(const int)
。我们也调用了foo(d)
,其中d
是double
值。编译器会简单地将d
从double
转换为const double&
,然后匹配foo(const double&)
。通过普通转换进行的匹配被视为完全匹配。这意味着以下程序会导致匹配不明确的错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void foo(int)
{
}
void foo(const int&) // int& is a reference to a int
{
}
int main()
{
int x { 1 };
foo(x); // ambiguous match with foo(int) and foo(const int&)
return 0;
} -
-
如果未找到完全匹配项,编译器将尝试通过对参数应用数值提升来查找匹配项。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void foo(int)
{
}
void foo(double)
{
}
int main()
{
foo('a'); // promoted to match foo(int)
foo(true); // promoted to match foo(int)
foo(4.5f); // promoted to match foo(double)
return 0;
} -
如果通过数值提升未找到匹配项,则编译器将尝试通过对参数应用数值转换来查找匹配项。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void foo(double)
{
}
void foo(std::string)
{
}
int main()
{
foo('a'); // 'a' converted to match foo(double)
return 0;
} -
如果通过数值转换未找到匹配项,则编译器将尝试通过任何用户定义的转换找到匹配项。虽然我们还没有介绍用户定义的转换,但某些类型(例如 classes)可以定义到可以隐式调用的其他类型的转换。下面是一个例子,只是为了说明这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// We haven't covered classes yet, so don't worry if this doesn't make sense
class X // this defines a new type called X
{
public:
operator int() { return 0; } // Here's a user-defined conversion from X to int
};
void foo(int)
{
}
void foo(double)
{
}
int main()
{
X x; // Here, we're creating an object of type X (named x)
foo(x); // x is converted to type int using the user-defined conversion from X to int
return 0;
} -
如果通过用户定义的转换未找到匹配项,编译器将查找使用省略号的匹配函数。
-
如果此时未找到匹配项,编译器将放弃并发出有关找不到匹配函数的编译错误。
3.2 匹配不明确
对于非重载函数,每个函数调用都将解析为一个函数,或者找不到匹配项,编译器将发出编译错误:
1 | void foo() |
对于重载函数,可能出现第三种情况:匹配不明确(ambiguous match)。当编译器在同一步中找到两个或更多函数都可以匹配时,就会发生匹配不明确的情况。当这种情况发生时,编译器将停止匹配,并发出编译错误,指出它遇到了不明确的函数调用(ambiguous function call)。
由于匹配不明确是编译时错误,因此需要在程序编译之前消除匹配不明确。有几种方法可以解决匹配不明确:
-
通常,最好的方法是简单地定义一个新的重载函数,该函数采用你尝试调用函数的类型的参数。然后 C++ 将能够找到函数调用的精确匹配项。
-
或者,显式转换不明确的参数以匹配要调用的函数的类型。例如,要让
foo(0)
匹配上面的例子中的foo(unsigned int)
,你可以这样做:1
2int x{ 0 };
foo(static_cast<unsigned int>(x)); // will call foo(unsigned int)如果你的参数是文本值,则可以使用文本后缀来确保将文本值解释为正确的类型:
1
foo(0u); // will call foo(unsigned int) since 'u' suffix is unsigned int, so this is now an exact match
如果函数有多个参数,编译器会依次对每个参数应用匹配规则。最终选择的函数应该满足以下条件:
-
每个参数的匹配程度至少与所有其他重载函数一样好。
-
至少有一个参数的匹配程度优于所有其他重载函数。
如果找到这样的函数,它显然是最佳选择。如果找不到这样的函数,则调用将被视为模棱两可(或不匹配)。 例如:
1 |
|
4. 删除函数
在某些情况下,可以编写在使用特定类型的值调用时行为不理想的函数。请考虑以下示例:
1 |
|
输出:
1 | 5 |
如果我们有一个明确不希望可调用的函数,我们可以使用 = delete
说明符将该函数定义为已删除。如果编译器将函数调用与已删除的函数匹配,则编译将停止并出现编译错误。以下是使用此语法的上述内容的更新版本:
1 |
|
删除一堆单独的函数重载工作正常,但可能会很冗长。有时,我们希望仅使用类型与函数参数完全匹配的参数来调用某个函数。我们可以使用函数模板来实现此目的,如下所示:
1 |
|
5. 默认参数
默认参数是为函数参数提供的默认值。例如:
1 | void print(int x, int y=10) // 10 is the default argument |
进行函数调用时,调用方可以选择为具有默认参数的任何函数参数提供参数。如果调用方提供参数,则使用函数调用中的参数值。如果调用方未提供参数,则使用默认参数的值。请注意,你必须使用等号来指定默认参数。使用括号或大括号初始化不起作用:
1 | void foo(int x = 5); // ok |
当函数需要一个具有合理默认值的值,但你希望让调用者根据需要覆盖该值时,默认参数是一个很好的选择。例如,以下是几个函数原型,其中默认参数可能是常用的:
1 | int rollDie(int sides=6); |
一个函数可以有多个带有默认参数的参数:
1 |
|
输出:
1 | Values: 1 2 3 |
截至 C++23,C++ 不支持类似 print(,,3)
的函数调用语法(这种语法意图是在为 z
提供显式值的同时使用 x
和 y
的默认参数)。这带来了以下三个主要影响:
-
在函数调用中,任何显式提供的参数都必须是最左侧的参数(不能跳过具有默认值的参数)。例如:
1
2
3
4
5
6
7
8
9
10void print(std::string_view sv="Hello", double d=10.0);
int main()
{
print(); // okay: both arguments defaulted
print("Macaroni"); // okay: d defaults to 10.0
print(20.0); // error: does not match above function (cannot skip argument for sv)
return 0;
} -
如果为参数指定了默认参数,则所有后续参数(右侧)也必须为默认参数指定。不允许出现以下情况:
1
void print(int x=10, int y); // not allowed
-
如果多个参数具有默认参数,则最左边的参数应该是用户最有可能显式设置的参数。
一旦声明,默认参数就不能在同一翻译单元中重新声明。这意味着对于具有前向声明和函数定义的函数,默认参数可以在前向声明或函数定义中声明,但不能同时在两者中声明。
1 |
|
默认参数也必须在翻译单元中声明,然后才能使用:
1 |
|
最佳做法是在前向声明中声明默认参数,而不是在函数定义中声明默认,因为前向声明更有可能被其他文件看到并在使用之前包含在内(特别是如果它位于头文件中)。
foo.h
1 |
|
main.cpp
1 |
|
具有默认参数的函数可能会重载。例如,允许执行以下操作:
1 |
|
对 print()
的函数调用实际上调用 print(char)
,其作用就像用户显式调用 print(' ')
一样。
现在考虑这个案例:
1 | void print(int x); // signature print(int) |
默认值并不属于函数的签名(signature)的一部分,因此这些函数声明被视为不同的重载(differentiated overloads)。在 C++ 中,函数的签名包括函数名和参数类型列表,但不包括默认参数值。
默认参数很容易导致模棱两可的函数调用:
1 | void foo(int x = 0) |
在此示例中,编译器无法判断 foo()
应解析为 foo(0)
还是 foo(0.0)
。
下面是一个稍微复杂一些的示例:
1 | void print(int x); // signature print(int) |
6. 函数模板
函数模板是一个类似于函数的定义,用于生成一个或多个重载函数,每个函数都有一组不同的实际类型。这将允许我们创建可以与许多不同类型的函数一起使用。用于生成其他函数的初始函数模板称为主模板,从主模板生成的函数称为实例化函数。当我们创建主函数模板时,我们将占位符类型(技术上称为类型模板参数,非正式地称为模板类型)用于我们希望稍后由模板用户指定的函数体中使用的任何参数类型、返回类型或类型。
通过示例来说明:
这是 max()
的 int
版本:
1 | int max(int x, int y) |
要为 max()
创建一个函数模板,我们将做两件事。首先,我们将用类型模板参数替换我们稍后想要指定的任何实际类型。在这种情况下,因为我们只有一个需要替换的类型 (int
),所以我们只需要一个类型模板参数(我们称之为 T
):
1 | T max(T x, T y) // won't compile because we haven't defined T |
这是一个好的开始 —— 但是,它不会编译,因为编译器不知道 T
是什么!而这仍然是一个普通的函数,而不是一个函数模板。其次,我们将告诉编译器这是一个模板,而 T
是一个类型模板参数,它是任何类型的占位符。这两者都是使用模板参数声明完成的,该声明定义了随后将使用的任何模板参数。模板参数声明的范围严格限于后面的函数模板(或类模板)。因此,每个函数模板或类模板都需要自己的模板参数声明。
1 | template <typename T> // this is the template parameter declaration defining T as a type template parameter |
就像我们经常在琐碎的情况下(例如 x
)使用单个字母来表示变量名称一样,当模板参数以微不足道或明显的方式使用时,通常使用单个大写字母(以 T
开头)是惯例。我们不需要给 T
一个复杂的名称,因为它显然只是被比较值的占位符类型,而 T
可以是任何可以比较的类型(比如 int
、double
或 char
,但不是 nullptr
)。我们的函数模板通常会使用这种命名约定。如果类型模板参数具有必须满足的非明显用法或特定要求,则此类名称有两种常见约定:
-
以大写字母开头(例如
Allocator
)。标准库使用此命名约定。 -
以
T
为前缀,然后以大写字母开头(例如TAllocator
)。这样可以更轻松地看到类型是类型模板参数。
7. 函数模板实例化
函数模板实际上不是函数 – 它们的代码不是直接编译或执行的。相反,函数模板只有一个工作:生成函数(编译和执行)。要使用 max<T>
函数模板,我们可以使用以下语法进行函数调用:
1 | max<actual_type>(arg1, arg2); // actual_type is some actual type, like int or double |
这看起来很像普通的函数调用 —— 主要区别在于在尖括号中添加类型(称为模板参数),它指定了将用于代替模板类型 T
的实际类型。让我们通过一个简单的示例来了解一下:
1 |
|
当编译器遇到函数调用 max<int>(1, 2)
时,它将确定 max<int>(int, int)
的函数定义尚不存在。因此,编译器将隐式使用我们的 max<T>
函数模板来创建一个。从函数模板(具有模板类型)创建特定类型的函数(具有具体类型)过程称为函数模板实例化(function template instantiation),简称实例化。当由于函数调用而实例化一个函数时,这被称为隐式实例化(implicit instantiation)。从模板实例化出的函数在技术上被称为特化(specialization),但在日常用语中,通常称之为函数实例(function instance)。产生特化的模板被称为主模板(primary template)。函数实例在所有方面都是普通的函数,意味着它们遵循标准的函数调用约定,并且可以像普通函数一样被调用和使用。
实例化函数的过程很简单:编译器实质上是克隆主模板,并将模板类型 (T
) 替换为我们指定的实际类型 (int
)。因此,当调用 max<int>(1, 2)
时,编译器会根据模板类型 T
的 int
特化版本,生成如下的函数实例:
1 | template<> // ignore this for now |
下面是与上面相同的示例,显示了编译器在所有实例化完成后实际编译的内容:
1 |
|
让我们再举一个例子:
1 |
|
这与前面的示例类似,但这次我们的函数模板将用于生成两个函数:一次将 T
替换为 int
,另一次将 T
替换为 double
。在所有实例化之后,程序将如下所示:
1 |
|
这里需要注意的另一件事是:当我们实例化 max<double>
时,实例化的函数具有 double
类型的参数。因为我们提供了 int
参数,所以这些参数将被隐式转换为 double
。在大多数情况下,我们想要用于实例化的实际类型将与函数参数的类型匹配。例如:
1 | std::cout << max<int>(1, 2) << '\n'; // specifying we want to call max<int> |
在这个函数调用中,我们指定了要用 int
替换 T
,但我们也用 int
参数调用了这个函数。如果参数的类型与我们想要的实际类型匹配,我们不需要指定实际类型 —— 相反,我们可以使用模板参数推导让编译器从函数调用中的参数类型中推断出应该使用的实际类型。例如,不要像这样进行函数调用:
1 | std::cout << max<int>(1, 2) << '\n'; // specifying we want to call max<int> |
我们可以改为执行以下操作之一:
1 | std::cout << max<>(1, 2) << '\n'; |
无论哪种情况,编译器都会看到我们没有提供实际类型,因此它将尝试从函数参数中推断出实际类型,这将允许它生成一个 max()
函数,其中所有模板参数都与提供的参数的类型匹配。在此示例中,编译器将推断出,将函数模板 max<T>
与实际类型 int
结合使用,允许其实例化函数 max<int>(int, int)
,以便两个函数参数 (int
) 的类型与提供的参数的类型 (int
) 匹配。
这两种情况之间的区别与编译器如何从一组重载函数中解析函数调用有关。在第一种情况(带有空尖括号),编译器在确定要调用的重载函数时,将仅考虑 max<int>
模板函数重载。在第二种情况(没有尖括号)中,编译器将同时考虑 max<int>
模板函数重载和 max
非模板函数重载。
正常的函数调用语法将首选非模板函数,而不是从模板实例化的同样可行的函数。
例如:
1 |
|
请注意,最下面写的语法看起来与普通函数调用相同!在大多数情况下,这种普通的函数调用语法将是我们用来调用从函数模板实例化的函数的语法。这有几个原因:
-
语法更简洁。
-
我们很少同时拥有匹配的非模板函数和函数模板。
-
如果我们确实有一个匹配的非模板函数和一个匹配的函数模板,我们通常会更喜欢调用非模板函数。
最后一点可能并不明显。函数模板具有适用于多种类型的实现,但因此它必须是泛型的。非模板函数仅处理特定的类型组合。它可以具有比函数模板版本更优化或更专门针对这些特定类型的实现。例如:
1 |
|
在调用从函数模板实例化的函数时,应优先使用常规函数调用语法(除非你需要优先使用函数模板版本而不是匹配的非模板函数)。
可以创建同时具有模板参数和非模板参数的函数模板。类型模板参数可以与任何类型匹配,非模板参数的工作方式类似于普通函数的参数。例如:
1 | // T is a type template parameter |
此函数模板具有模板化的第一个参数,但第二个参数使用 double
类型固定。
实例化的函数可能并不总是编译, 请考虑以下程序:
1 |
|
编译器将有效地编译并执行以下内容:
1 |
|
这将产生结果:
1 | 2 |
但是,如果我们尝试这样的事情呢?
1 |
|
当编译器尝试解析 addOne(hello)
时,它不会找到与 addOne(std::string)
匹配的非模板函数,但它会找到 addOne(T)
的函数模板,并确定它可以从中生成 addOne(std::string)
函数。因此,编译器将生成并编译以下内容:
1 |
|
但是,这将生成编译错误,因为当 x
是 std::string
时,x + 1
没有意义。这里明显的解决方案是不要使用 std::string
类型的参数调用 addOne()
。编译器将成功编译实例化的函数模板,只要它在语法上有意义。但是,编译器无法检查此类函数在语义上是否确实有意义。例如:
1 |
|
也许令人惊讶的是,因为 C++ 在语法上允许向字符串文字添加整数值,上面的示例编译并产生以下结果:
1 | ello, world! |
编译器将实例化和编译在语义上没有意义的函数模板,只要它们在语法上有效即可。您有责任确保使用有意义的参数调用此类函数模板。
我们可以告诉编译器,应该不允许使用某些参数实例化函数模板。这是通过使用函数模板专用化来完成的,它允许我们为一组特定的模板参数重载函数模板,以及 = delete
,它告诉编译器对函数的任何使用都应该发出编译错误。
1 |
|
就像普通函数一样,函数模板可以具有非模板参数的默认参数。从模板实例化的每个函数都将使用相同的默认参数。
1 |
|
输出:
1 | 5aaa |
在函数模板中使用静态局部变量时,从该模板实例化的每个函数都将具有静态局部变量的单独版本。如果静态局部变量是常量,这很少成为问题。但是,如果静态局部变量是被修改的变量,则结果可能与预期不符。例如:
1 |
|
输出:
1 | (1) 12 |
您可能一直期待最后一行打印 3) 14.5
.但是,这是编译器实际编译和执行的内容:
1 |
|
请注意,printIDAndValue<int>
和 printIDAndValue<double>
都有自己名为 id
的独立静态局部变量,而不是它们之间共享的变量。
由于模板类型可以替换为任何实际类型,因此模板类型有时称为泛型类型。由于模板可以不可知地编写特定类型,因此使用模板编程有时称为泛型编程。C++ 通常非常注重类型和类型检查,相比之下,泛型编程让我们专注于算法的逻辑和数据结构的设计,而不必太担心类型信息。
8. 具有多种模板类型的函数模板
在前面我们编写了一个函数模板来计算两个值的最大值:
1 |
|
现在考虑以下类似的程序:
1 |
|
您可能会惊讶地发现此程序无法编译。相反,编译器将发出一堆(可能看起来很疯狂的)错误消息。
在我们的函数调用 max(2, 3.5)
中,我们传递了两种不同类型的参数:一个 int
和一个 double
。由于我们在不使用尖括号来指定实际类型的情况下进行函数调用,因此编译器将首先查看 max(int, double)
是否存在非模板匹配项。发现无法找到。接下来,编译器将查看它是否可以找到函数模板匹配项(使用模板参数推导)。但是,这也将失败,原因很简单: T
只能表示单个类型。没有允许编译器将函数模板 max<T>(T, T)
实例化为具有两种不同参数类型的函数的 T
类型。换句话说,由于函数模板中的两个参数都是 T
类型,因此它们必须解析为相同的实际类型。由于找不到非模板匹配项和模板匹配项,因此函数调用无法解析,并且我们会收到编译错误。你可能想知道为什么编译器不生成函数 max<double>(double, double)
,然后使用数值转换将 int
参数类型转换为 double
。答案很简单:类型转换仅在解决函数重载时进行,而不是在执行模板参数推导时进行。
幸运的是,我们可以通过(至少)三种方式来解决这个问题。
-
第一种解决方案是将参数转换为匹配类型的负担交给调用方。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
T max(T x, T y)
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max(static_cast<double>(2), 3.5) << '\n'; // convert our int to a double so we can call max(double, double)
return 0;
}但是,此解决方案很笨拙且难以阅读。
-
提供显示类型模板参数。如果我们编写了一个非模板的
max(double, double)
函数,那么我们将能够调用max(int, double)
并让隐式类型转换规则将我们的int
参数转换为double
,以便解析函数调用:1
2
3
4
5
6
7
8
9
10
11
12
13
double max(double x, double y)
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max(2, 3.5) << '\n'; // the int argument will be converted to a double
return 0;
}然而,当编译器进行模板参数推导时,它不会执行任何类型转换。幸运的是,如果我们显式指定要使用的模板类型参数,就不需要使用模板参数推导了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <typename T>
T max(T x, T y)
{
return (x < y) ? y : x;
}
int main()
{
// we've explicitly specified type double, so the compiler won't use template argument deduction
std::cout << max<double>(2, 3.5) << '\n';
return 0;
} -
我们问题的根源在于,我们只为函数模板定义了单个模板类型 (
T
),然后指定两个参数必须属于同一类型。解决这个问题的最好方法是重写我们的函数模板,使我们的参数可以解析为不同的类型。我们现在使用两个(T
和U
),而不是使用一个模板类型参数T
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T, typename U> // We're using two template type parameters named T and U
T max(T x, U y) // x can resolve to type T, and y can resolve to type U
{
return (x < y) ? y : x; // uh oh, we have a narrowing conversion problem here
}
int main()
{
std::cout << max(2, 3.5) << '\n'; // resolves to max<int, double>
return 0;
}由于我们定义了模板类型
T
的x
,以及模板类型U
的y
,因此x
和y
现在可以独立解析它们的类型。当我们调用max(2, 3.5)
时,T
可以是int,U
可以是double
。编译器会很高兴地为我们实例化max<int, double>(int, double)
。但是,此示例无法正常工作。如果编译并运行该程序(关闭“将警告视为错误”),它将产生以下结果:1
3
这是怎么回事?
2
和3.5
的最大值怎么能是3
?但是,我们函数的声明返回类型是T
。当T
为int
且U
为double
时,函数的返回类型为int
。我们的值3.5
正在收缩转换为int
值3
,从而导致数据丢失(并可能出现编译器警告)。那么我们如何解决这个问题呢?将返回类型设为U
并不能解决问题,因为max(3.5, 2)
将U
作为int
并且会出现相同的问题。在这种情况下,返回类型推导(通过auto
)可能很有用 – 我们将让编译器从 return 语句中推断出返回类型应该是什么:1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T, typename U>
auto max(T x, U y) // ask compiler can figure out what the relevant return type is
{
return (x < y) ? y : x;
}
int main()
{
std::cout << max(2, 3.5) << '\n';
return 0;
}如果我们需要一个可以前向声明的函数,我们必须明确返回类型。由于我们的返回类型需要是
T
和U
的通用类型,因此我们可以使用std::common_type_t
来获取T
和U
的通用类型以用作我们的显式返回类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T, typename U>
auto max(T x, U y) -> std::common_type_t<T, U>; // returns the common type of T and U
int main()
{
std::cout << max(2, 3.5) << '\n';
return 0;
}
template <typename T, typename U>
auto max(T x, U y) -> std::common_type_t<T, U>
{
return (x < y) ? y : x;
}
C++20 引入了 auto
关键字的新用法:当 auto
关键字在普通函数中用作参数类型时,编译器会自动将函数转换为函数模板,每个 auto 参数都成为独立的模板类型参数。这种创建函数模板的方法称为缩写函数模板。例如:
1 | auto max(auto x, auto y) |
在 C++20 中是以下内容的简写:
1 | template <typename T, typename U> |
如果您希望每个模板类型参数都是独立的类型,则最好采用此格式,因为删除模板参数声明行会使你的代码更加简洁和可读。当你希望多个 auto
参数为同一类型时,没有一种简洁的方法可以使用缩写函数模板。也就是说,没有一个简单的缩写函数模板,如下所示:
1 | template <typename T> |
就像函数可能重载一样,函数模板也可能重载。此类重载可以具有不同数量的模板类型或不同数量的函数参数:
1 |
|
9. 非类型模板参数
非类型模板参数是一种具有固定类型的模板参数,作为占位符用于接收在模板实参中传入的 constexpr
(编译时常量)值。。非类型模板参数可以是以下任何类型:
-
整型
-
枚举类型
-
std::nullptr_t
-
浮点类型(自 C++20 起)
-
指向对象的指针或引用
-
指向函数的指针或引用
-
指向成员函数的指针或引用
-
文本类类型(自 C++20 起)
在之前讨论 std::bitset
时,我们看到了第一个非类型模板参数的例子:
1 |
|
在 std::bitset
的情况下,非类型模板参数用于告诉 std::bitset
我们希望它存储多少位。
下面是一个使用 int
非类型模板参数的函数的简单示例:
1 |
|
输出:
1 | 5 |
在第 3 行,我们有模板参数声明。在尖括号内,我们定义了一个名为 N
的非类型模板参数,该参数将成为 int
类型值的占位符。在 print()
函数中,我们使用 N
的值。在第 11 行,我们调用了函数 print()
,它使用 int
值 5
作为非类型模板参数。当编译器看到此调用时,它将实例化一个如下所示的函数:
1 | template <> |
与 T
通常用作第一个类型模板参数的名称非常相似,N
通常用作 int
非类型模板参数的名称。
从 C++20 开始,函数参数不能是 constexpr。对于普通函数、constexpr 函数甚至 consteval 函数来说都是如此。
假设我们有一些这样的函数:
1 |
|
运行时,对 getSqrt(-5.0)
的调用将在运行时断言。虽然这总比没有好,因为 -5.0
是一个文本值(并且隐式 constexpr),如果我们能static_assert
以便在编译时捕获诸如此类的错误,那就更好了。但是,static_assert
需要一个常量表达式,并且函数参数不能是 constexpr
。但是,如果我们将函数参数更改为非类型模板参数,那么我们可以完全按照我们的意愿来做:
1 |
|
此版本编译失败。当编译器遇到 getSqrt<-5.0>()
时,它将实例化并调用一个如下所示的函数:
1 | template <> |
从 C++17 开始,非类型模板参数可以使用 auto
让编译器从模板参数中推断出非类型模板参数:
1 |
|
输出:
1 | 5 |
10. 在多个文件中使用函数模板
请考虑以下程序,该程序无法正常工作:
main.cpp
1 |
|
add.cpp
1 | template <typename T> |
因为 addOne
是一个模板,所以这个程序不工作,我们得到一个链接器错误。在 main.cpp
中,我们调用 addOne<int>
和 addOne<double>
。但是,由于编译器无法看到函数模板 addOne
的定义,因此它无法在 main.cpp
中实例化这些函数。不过,它确实看到了 addOne
的 前向声明,并假设这些函数存在于其他位置,稍后将链接进来。当编译器开始编译 add.cpp
时,它将看到函数模板 addOne
的定义。但是,此模板在 add.cpp
中没有使用,因此编译器不会实例化任何内容。最终结果是,链接器无法将对 addOne<int>
和 addOne<Double>
的调用main.cpp
连接到实际函数,因为这些函数从未实例化。
如果 add.cpp
实例化了这些函数,程序将可以很好地编译和链接。但是这样的解决方案很脆弱,应该避免:如果 add.cpp
中的代码后来被更改,使这些函数不再实例化,则程序将再次无法链接。或者,如果main.cpp
调用了未在 add.cpp
中实例化的不同版本的 addOne
(例如 addOne<float>
),我们会遇到同样的问题。
解决此问题的最传统方法是将所有模板代码放在头文件 (.h) 中,而不是源 (.cpp) 文件中:
add.h
1 |
|
main.cpp
1 |
|
这样,任何需要访问模板的文件都可以 #include
相关的标头,并且模板定义将由预处理器复制到源文件中。然后,编译器将能够实例化所需的任何函数。