1. 基本示例
#include <iostream>
#include <string>
template<typename T>
T max(T a, T b)
{
return b < a ? a: b;
}
int main() {
int i = 42;
std::cout << "max(7, i): " << ::max(7, i) << '\n';
double f1 = 3.4;
double f2 = -6.7;
std::cout << "max(f1, f2): " << ::max(f1, f2) << '\n';
std::string s1 = "mathematics";
std::string s2 = "math";
std::cout << "max(s1, s2): " << ::max(s1, s2) << '\n';
return 0;
}
代码中,对max0的使用都用:限定。这是为了确保在全局命名空间中找到max0模板。因为标准库中有一个std::max(0,在某些情况下可以使用,但这里可能会产生歧义。
-
模板参数:
typename T
定义了一个类型参数T
,它是泛型的占位符,表示在调用模板函数时将被替换为实际类型的变量。typename
关键字用来声明类型参数。在某些上下文中,可以省略typename
关键字 [[#可以省略 typename]]。
-
模板函数定义:
- 模板函数定义使用尖括号
<
和>
包围类型参数。 - 模板函数
max
的定义如下:template<typename T> T max(T a, T b) { return b < a ? a : b; }
- 这里
T
是类型参数,它将在每次调用时被替换为实际类型。
- 模板函数定义使用尖括号
-
类型参数的约束[[#关于 类型参数的约束]]:
- 类型参数
T
必须支持<
运算符,因为max
函数使用了这个运算符来比较a
和b
。 - 类型
T
的值还必须是可以复制的,因为函数需要返回一个T
类型的值。(C++17之前,类型T也必须可复制才能传递参数。C++17以后,即使复制构造函数和移动构造函数都无效,也可以传递临时变量(右值 ))
- 类型参数
-
模板实例化:
- 当函数
max
被调用时,编译器会根据传入的实际类型实例化模板。 - 例如,如果调用
max(3, 5)
,那么T
将被实例化为int
类型。
- 当函数
-
模板的通用性:
- 模板函数
max
可以接受任何支持<
运算符的类型,包括基本类型(如int
,double
)和用户定义的类型(如std::string
或自定义类)。
- 模板函数
1.1 两阶段编译
模板处理的两个阶段
-
解析阶段 (Parsing):在这个阶段,编译器读取源代码并构建抽象语法树(AST)。在这个过程中,模板定义的语法正确性会被检查,但是模板的具体实例还没有被创建。这意味着在这个阶段,编译器无法评估依赖于模板参数的表达式的值。
-
实例化阶段 (Instantiation):当模板被用来创建特定类型的实例时,这个过程被称为模板实例化。在这个阶段,编译器会创建具体的函数或类,并且能够评估模板参数的实际值。模板实例化时,所有
依赖于模板参数的表达式都会被评估
。
#include <iostream>
#include <type_traits>
// 模板函数
template<typename T>
void print(T value)
{
static_assert(std::is_integral<T>::value, "T must be an integral type"); // 静态断言
std::cout << value << '\n';
}
int main()
{
print(5); // 实例化为 int 类型,正常工作
// 下面这一行会在实例化时失败,因为 float 不满足 is_integral 断言
print(3.14f); // 编译错误:T must be an integral type
return 0;
}
错误分析:
-
解析阶段:
- 如果模板定义本身存在语法错误,例如缺少分号或者使用了未定义的标识符等,那么编译器会在解析阶段报错。
- 在上述示例中,
print
函数模板的定义是正确的,因此解析阶段不会产生任何错误。
-
实例化阶段:
- 当模板被具体类型实例化时,如果实例化导致的代码有错误,那么编译器会在实例化阶段报错。
- 在上述示例中,当我们尝试用浮点数
3.14f
调用print
时,编译器会尝试实例化模板为float
类型。由于float
不满足std::is_integral<T>::value
的静态断言,因此这里会产生一个编译错误。
要确定模板编译错误发生在解析阶段还是实例化阶段,最直接的方法是查看编译器给出的错误信息以及错误发生的上下文。通常,编译器会报告错误发生的文件名、行号以及错误的具体描述。
让我们根据之前提供的示例代码进一步讨论:
#include <iostream>
#include <type_traits>
template<typename T>
void print(T value)
{
static_assert(std::is_integral<T>::value, "T must be an integral type");
std::cout << value << '\n';
}
int main()
{
print(5); // 实例化为 int 类型,正常工作
print(3.14f); // 实例化时失败,因为 float 不满足 is_integral 断言
return 0;
}
错误分析:
-
解析阶段:
- 如果模板定义本身存在语法错误,例如缺少分号或者使用了未定义的标识符等,那么编译器会在解析阶段报错。
- 在上述示例中,
print
函数模板的定义是正确的,因此解析阶段不会产生任何错误。
-
实例化阶段:
- 当模板被具体类型实例化时,如果实例化导致的代码有错误,那么编译器会在实例化阶段报错。
- 在上述示例中,当我们尝试用浮点数
3.14f
调用print
时,编译器会尝试实例化模板为float
类型。由于float
不满足std::is_integral<T>::value
的静态断言,因此这里会产生一个编译错误。
如何识别错误阶段:
-
静态断言错误:
- 当模板被实例化并且触发了
static_assert
时,编译器通常会报告一个编译错误,并指出错误发生在哪个文件的哪一行。 - 错误信息可能会显示该行的代码以及静态断言失败的原因。
- 例如,对于上面的例子,错误信息可能是这样的:
这里的错误明确指出了错误发生在error: static assertion failed: T must be an integral type static_assert(std::is_integral<T>::value, "T must be an integral type"); ^~~~~~~~~~~~~~~~~~~~~~~~~~
static_assert
行,因此我们可以判断这是在实例化阶段发生的错误。
- 当模板被实例化并且触发了
-
语法错误:
- 如果模板定义中存在语法错误,编译器会在解析阶段报错。
- 例如,如果我们在模板定义中遗漏了一个分号,编译器会在模板定义所在的行报告错误。
- 错误信息会显示缺失分号的位置,并提示这是一个语法错误。
- 例如,如果我们在
print
函数模板中遗漏了一个分号,编译器可能会报告如下错误:error: expected ';' before '}' token } ^
1.2 模板参数的推导
您的描述非常准确地概括了 C++ 中模板参数推导的一些重要规则。让我们详细探讨一下这些规则及其背后的逻辑。
按引用声明参数时的模板参数推导规则
当模板函数的参数是按引用声明时(例如 T const&
或 T&
),模板参数 T
的推导更加严格。这意味着所有按同一模板参数声明的实参必须具有相同的类型。例如:
template<typename T>
T max(T const& a, T const& b) {
return b < a ? a : b;
}
int i = 17;
int const c = 42;
// 下面的调用都是合法的,因为所有实参都是 int 类型。
max(i, c); // T 推导为 int
max(c, c); // T 推导为 int
int& ir = i;
max(i, ir); // T 推导为 int
按值声明参数时的模板参数推导规则
当模板函数的参数是按值声明时(例如 T
),模板参数 T
的推导稍微宽松一些。它允许进行简单的转换,例如:
- 忽略
const
或volatile
限定符。 - 引用转换为其所引用的类型。
- 原始数组或函数转换为相应的指针类型(数组退化的解决方案[[#数组退化的问题]])。
对于使用相同模板参数 T
声明的两个参数,转换后的类型必须匹配。例如:
template<typename T>
T max(T a, T b) {
return b < a ? a : b;
}
int i = 17;
int const c = 42;
int arr[4];
// 下面的调用都是合法的,因为所有实参经过转换后都是 int 或 int* 类型。
max(i, c); // T 推导为 int
max(&i, arr); // T 推导为 int*
反面案例
现在我们来看一些非法的调用示例:
// 下面的调用是非法的,因为 int 和 double 不能相互转换。
max(4, 7.2); // ERROR: T 不能同时推导为 int 和 double
// 下面的调用是非法的,因为 char const* 和 std::string 不能相互转换。
std::string s;
max("hello", s); // ERROR: T 不能同时推导为 char const* 和 std::string
分析
-
max(4, 7.2);
- 这个调用是非法的,因为
4
是int
类型,而7.2
是double
类型。虽然int
可以隐式转换为double
,但模板参数推导不允许这种类型的转换。在这种情况下,T
不能同时为int
和double
。
- 这个调用是非法的,因为
-
max(“hello”, s);
- 这个调用同样是非法的,因为
"hello"
是char const*
类型,而s
是std::string
类型。char const*
是 C 风格字符串,而std::string
是 C++ 的字符串类。这两种类型之间没有简单的转换,所以T
不能同时为char const*
和std::string
。
- 这个调用同样是非法的,因为
总结
- 当参数按引用声明时,所有实参必须具有相同的类型。
- 当参数按值声明时,允许进行简单的转换,但转换后的类型必须匹配。
- 如果实参的类型不兼容,或者不能通过简单的转换匹配,那么调用将导致编译错误。
1.2.1 默认参数的类型推导规则
- 默认参数的目的是提供一种方便的方式来省略某些参数的值,而不是用来推导模板参数的类型。
template <typename T>
void f(T = "") {
};
int main()
{
f(1);// 传入的参数 推导出了 T 是 int
f(); // 企图 调用 默认参数的 f("") 推导 T 的类型
}
为了解决这个问题,可以在模板参数列表中为模板形参声明一个默认实参,如下所示:
template<typename T = std::string>
void f(T t = "")
{
std::cout << t << '\n';
}
#include <iostream>
#include <string>
template<typename T = std::string>
void f(T t = "")
{
std::cout << t << '\n';
}
int main() {
f<int>(1); // 明确指定模板参数为 int
f(); // 使用默认模板参数 std::string
f(1); // 推导出 T 为 int
f("Hello"); // 推导出 T 为 std::string
return 0;
}
1.3 多类型的模板参数
当然可以,让我们按照您提供的内容重新排版和组织一下:
函数模板的类型参数
函数模板可以有两组不同的类型参数:
-
模板参数:用尖括号声明在函数模板名之前。
template<typename T> // T 是模板参数
-
调用参数:在函数模板名称后面的圆括号中声明。
T max(T a, T b) // a 和 b 是调用参数
实际上,可以有任意多种模板参数。例如,可以为两种不同类型的调用参数定义 max0
模板:
template<typename T1, typename T2>
T1 max(T1 a, T2 b) {
return b < a ? a : b;
}
你可以这样调用它:
auto m = ::max(4, 7.2); // OK, 返回类型与第一个参数类型一样
问题描述
将不同类型的参数传递给 max0
模板会引发一个问题。如果使用其中一种形参类型作为返回类型,那么其他参数可能需要向这种类型进行转换。因此,返回类型取决于调用参数的顺序。例如:
66.66
和42
的最大值将是double
类型的66.66
。42
和66.66
的最大值将是int
类型的66
。
解决方案
C++ 提供了几种方法来处理这个问题:
-
为返回类型引入第三个模板参数:
- 这种方法允许你显式地指定返回类型,从而更好地控制模板函数的行为。
-
让编译器找出返回类型:
- C++14 引入了
auto
关键字来推导返回类型,这样你可以让编译器根据函数体的内容自动推导返回类型。
- C++14 引入了
-
将返回类型声明为两个参数类型的“公共类型”:
- 使用
std::common_type_t
来获取两个类型的公共类型,这是一个类型别名,它表示两个类型可以互相转换的类型。
- 使用
示例代码
下面是一个完整的示例,演示了这三种方法:
#include <iostream>
#include <type_traits>
// 方法1: 为返回类型引入第三个模板参数
template<typename T1, typename T2, typename TReturn>
TReturn max1(TReturn a, T2 b) {
return b < a ? a : b;
}
// 方法2: 让编译器找出返回类型
template<typename T1, typename T2>
auto max2(T1 a, T2 b) -> decltype((b < a ? a : b)) {
return b < a ? a : b;
}
// 方法3: 将返回类型声明为两个参数类型的“公共类型”
template<typename T1, typename T2>
std::common_type_t<T1, T2> max3(T1 a, T2 b) {
return b < a ? a : b;
}
int main() {
auto m1 = ::max1<double, int, double>(4, 7.2); // 返回 double 类型
std::cout << "m1: " << m1 << '\n';
auto m2 = ::max2(4, 7.2); // 返回 double 类型
std::cout << "m2: " << m2 << '\n';
auto m3 = ::max3(4, 7.2); // 返回 double 类型
std::cout << "m3: " << m3 << '\n';
return 0;
}
解释
-
方法1:使用第三个模板参数
TReturn
来指定返回类型。这种方式需要显式指定返回类型,适用于你已经知道返回类型的情况。(后面会介绍函数实参类型推导,并不需要全部指定类型) -
方法2:使用
auto
和decltype
来让编译器推导返回类型。这种方式简洁,但可能不如显式指定类型那样直观。 -
方法3:使用
std::common_type_t
来获取两个类型可以互相转换的公共类型。这是 C++17 引入的类型特质,可以自动选择两个类型之间的公共类型。
总结
- 方法1:适用于你想要显式指定返回类型的情况。
- 方法2:适用于你想让编译器推导返回类型的情况。
- 方法3:适用于你想自动选择两个类型之间的公共类型作为返回类型的情况。
方法二的局限性[[#方法二的局限性]]
方法三的注意事项
问题
关于 类型参数的约束
当我们定义 std::max
使用 a < b ? b : a
时,可能会出现问题的情况如下:
当 a
和 b
相等时
如果 a
和 b
相等,那么 a < b
为假,因此表达式会返回 a
。这本身没有问题,因为 a
和 b
是相等的,所以返回哪一个都是一样的。
当 b
不小于 a
但 a
也不小于 b
时
这种情况通常发生在用户定义的类型上,其中 <
运算符可能没有按照预期的方式定义,或者没有定义 >
运算符。假设我们有一个类 MyType
,它定义了 <
运算符,但没有定义 >
运算符:
struct MyType {
bool operator<(const MyType&) const { /* ... */ }
};
如果 b
不小于 a
但 a
也不小于 b
,这意味着 a < b
为假,而 b < a
也为假。此时,如果我们使用 a < b ? b : a
的形式,当 a < b
为假时,我们会返回 a
。然而,如果我们使用 b < a ? a : b
的形式,当 b < a
为假时,我们也会返回 b
。这两种情况都会返回一个值,但问题是:
- 如果
a
和b
是不可比较的,即a < b
和b < a
都为假,那么使用a < b ? b : a
的形式可能会导致不确定的结果,特别是如果a
和b
实际上是不可比较的或者具有特殊的比较逻辑。 - 使用
b < a ? a : b
的形式更加稳健,因为它不依赖于>
运算符的存在,并且在大多数情况下都能正确地返回两个值中的较大者。
示例
假设我们有一个类 MyType
,它定义了 <
运算符,但没有定义 >
运算符。并且假设 a
和 b
实例之间没有定义明确的顺序关系(即 a < b
和 b < a
都为假):
struct MyType {
bool operator<(const MyType& other) const {
// 假设这里的实现使得 a < b 和 b < a 都为假
return false;
}
};
MyType a;
MyType b;
// 使用 a < b ? b : a
std::max(a, b); // 可能返回 a 或 b,取决于 a < b 的结果
// 使用 b < a ? a : b
std::max(a, b); // 返回 b,因为 b < a 为假
在第一个例子中,std::max(a, b)
的行为取决于 a < b
的结果,这可能是不确定的。而在第二个例子中,无论 a
和 b
是否可比较,std::max(a, b)
总是返回 b
,因为 b < a
为假。
总结
- 使用
b < a ? a : b
更加稳健,因为它不依赖于>
运算符的存在。 - 对于内置类型和大多数用户定义的类型,使用
b < a ? a : b
和a < b ? b : a
的区别不大。 - 在某些特殊情况下,如不可比较的类型或只有
<
运算符定义的类型,b < a ? a : b
更能保证函数的行为正确性。
可以省略 typename
在 C++ 中,typename
关键字用于声明类型参数,尤其是在模板上下文中。但在某些情况下,你可以省略 typename
关键字。以下是为什么可以省略 typename
的原因:
1. 上下文清晰
在模板参数列表中,当你定义类型参数时,上下文已经很清楚地表明你正在声明一个类型,因此 typename
关键字通常是可选的。例如:
template<typename T>
T max(T a, T b)
{
return b < a ? a: b;
}
在这个例子中,typename
可以省略,因为模板参数列表的上下文已经清楚地表明 T
是一个类型参数。
你可以写为
#include <iostream>
#include <string>
template<typename T>
typename T max( typename T a, typename T b)
{
return b < a ? a: b;
}
int main() {
int i = 42;
std::cout << "max(7, i): " << ::max(7, i) << '\n';
double f1 = 3.4;
double f2 = -6.7;
std::cout << "max(f1, f2): " << ::max(f1, f2) << '\n';
std::string s1 = "mathematics";
std::string s2 = "math";
std::cout << "max(s1, s2): " << ::max(s1, s2) << '\n';
return 0;
}
当在模板成员函数中引用模板参数时,使用 typename
关键字可以帮助避免混淆。这是因为 C++ 编译器需要明确知道你在引用一个类型,而不是一个变量或函数。
#include <iostream>
#include <vector>
#include <list>
#include <set>
using std::cout;
using std::endl;
template<class Container>
void Print(const Container& ctnr)
{
Container::const_iterator it = ctnr.begin();
//typename Container::const_iterator it = ctnr.begin();
while (it != ctnr.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
int main()
{
// 使用 vector
std::vector<int> vec = { 1, 2, 3, 4, 5 };
Print(vec);
// 使用 list
std::list<char> lst = { 'a', 'b', 'c' };
Print(lst);
// 使用 set
std::set<double> st = { 1.1, 2.2, 3.3 };
Print(st);
return 0;
}
发生编译错误是因为在模板实例化之前,编译器无法确定 Container::const_iterator 是嵌套类型,还是静态成员变量,亦或是静态成员函数。
除此之外,还有另一种解决办法,即:
auto it = ctnr.begin();
确实,从 C++11 开始,你可以使用 auto
关键字来自动推导迭代器的类型,这可以避免显式使用 typename
关键字。这种方法更加简洁,也更容易阅读。
数组退化的问题
在 C++ 中,传统的 C 风格数组在很多情况下会退化为指向数组首元素的指针。这种退化在某些场景下是非常有用的(数组退化为指针后,可以利用指针算术来访问数组元素;动态分配的数组(使用 new[]
创建)可以像指针一样处理),但它也意味着数组的大小信息丢失了,这可能会带来一些问题,尤其是在模板编程中。
#include <iostream>
#include <array>
// 模板函数,接受一个 std::array 并打印其元素
template<typename T, std::size_t N>
void print_array(const std::array<T, N>& arr) {
for (const auto& element : arr) {
std::cout << element << ' ';
}
std::cout << '\n';
}
int main() {
// 创建一个 std::array
std::array<int, 5> my_array = { 1, 2, 3, 4, 5 };
// 调用模板函数
print_array(my_array);
return 0;
}
在这个示例中,我们使用 std::array
保持了数组的完整类型信息。模板函数 print_array
可以通过模板参数 T
和 N
获取数组的类型和大小。
#include <iostream>
#include <initializer_list>
// 模板函数,接受一个 std::initializer_list 并打印其元素
template<typename T>
void print_initializer_list(const std::initializer_list<T>& list) {
for (const auto& element : list) {
std::cout << element << ' ';
}
std::cout << '\n';
}
int main() {
// 创建一个 std::initializer_list
std::initializer_list<int> my_list = { 1, 2, 3, 4, 5 };
// 调用模板函数
print_initializer_list(my_list);
return 0;
}
std::initializer_list
本身包含了元素的数量信息,因此在使用它时不需要显式传递大小。- 它非常适合用于处理可变数量的元素,可以方便地用于模板函数的参数列表中。
方法二的局限性
当然可以。让我们通过一个具体的示例来演示使用 std::decay
的必要性。
问题背景
当我们定义一个函数模板,其中返回类型通过 decltype
和 auto
推导时,如果没有使用 std::decay
,可能会遇到返回引用类型的问题。这是因为 decltype
会保留表达式的类型,包括引用类型。如果其中一个模板参数是引用类型,那么返回类型也会是引用类型。
示例代码
假设我们有一个函数模板 max
,它比较两个参数并返回较大的一个。我们将使用 decltype
和 auto
来推导返回类型,并通过一个具体的示例来说明使用 std::decay
的必要性。
#include <iostream>
#include <type_traits>
// 定义 max 函数模板
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> decltype(true ? a : b) {
return b < a ? a : b;
}
int main() {
int i = 42;
int const& ir = i; // ir 引用 i
// 使用 auto 作为返回类型
auto a = ir; // a 的类型为 int
// 调用 max 函数模板
auto m = max(ir, 7.2); // 返回类型为 int const&, 这里有问题
std::cout << "Max: " << m << '\n';
return 0;
}
解释
-
没有使用
std::decay
:decltype(true ? a : b)
确定三元操作符的结果类型。- 如果
a
或b
是引用类型,那么返回类型也会是引用类型。 - 在这个示例中,
ir
是int const&
类型,因此max
函数模板的返回类型是int const&
。
-
问题:
- 当返回类型是引用类型时,这可能会导致问题。例如,如果
m
被赋值给一个非引用类型的变量,那么编译器可能会发出警告或错误,因为不能将引用赋值给非引用类型。
- 当返回类型是引用类型时,这可能会导致问题。例如,如果
解决方案:使用 std::decay
为了确保返回类型不是引用类型,我们可以使用 std::decay
来去除返回类型的引用限定。这可以避免上述问题,并确保返回类型始终是值类型。
修改后的示例代码
下面是修改后的示例,其中使用了 std::decay
:
#include <iostream>
#include <type_traits>
// 定义 max 函数模板
template<typename T1, typename T2>
auto max(T1 a, T2 b) -> typename std::decay<decltype(true ? a : b)>::type {
return b < a ? a : b;
}
int main() {
int i = 42;
int const& ir = i; // ir 引用 i
// 使用 auto 作为返回类型
auto a = ir; // a 的类型为 int
// 调用 max 函数模板
auto m = max(ir, 7.2); // 返回类型为 int, 而不是 int const&
std::cout << "Max: " << m << '\n';
return 0;
}
解释
-
使用
std::decay
:typename std::decay<decltype(true ? a : b)>::type
确保返回类型不是引用类型。- 即使
a
或b
是引用类型,std::decay
也会去除引用限定。
-
避免问题:
- 使用
std::decay
后,无论a
和b
的类型是什么,max
函数模板的返回类型都将是一个值类型。 - 这样可以避免返回引用类型带来的问题。
- 使用
注意:
-
使用
typename
关键字:typename std::decay<decltype(true ? a : b)>::type
中的typename
关键字用于访问类型特质的结果类型。- 这是因为
std::decay
的结果是一个类型特质,需要typename
来访问它的type
成员。
方法三的注意事项
c++ 11中需要这样写
#include <type_traits>
// 使用 std::common_type 来确定两个类型 T1 和 T2 的公共类型
template<typename T1, typename T2>
typename std::common_type<T1, T2>::type max(T1 a, T2 b) {
return b < a ? a : b;
}
后续还会对 common_type 研究