C++ 模板白痴指南 - 第 1 部分






4.88/5 (188投票s)
深入 C++ 模板的细节。
序言
大多数 C++ 程序员都因 C++ 模板的复杂性而避开它们。反对使用模板的理由
- 难以学习和适应。
- 编译器错误信息模糊且冗长。
- 不值得付出努力。
承认模板确实在学习、理解和适应方面有些困难。然而,使用模板获得的优势将远远大于其弊端。模板不仅仅是泛型函数或类。我将详细阐述它们。
虽然 C++ 模板和 STL(标准模板库)在技术上是近亲,但本文我只关注模板的核心层面。本系列文章的后续部分将涵盖更多关于模板的高级和有趣内容,以及一些关于 STL 的知识。
目录
语法大戏
正如你可能知道的,模板主要使用尖括号:小于号 (<
) 和大于号 (>
) 运算符。对于模板,它们总是以这种形式一起使用
< Content >
其中 Content
可以是
class T
/typename T
- 一个数据类型,它映射到
T
- 一个整型规范
- 一个整型常量/指针/引用,它映射到上面提到的规范。
对于第 1 点和第 2 点,符号 T
只是一个数据类型,它可以是任何数据类型——基本数据类型 (int
, double
等),或者是用户自定义类型 (UDT)。
让我们来看一个例子。假设你写了一个函数,它打印一个数字的两倍
void PrintTwice(int data)
{
cout << "Twice is: " << data * 2 << endl;
}
可以这样调用,传入一个 int
PrintTwice(120); // 240
现在,如果你想打印一个 double
的两倍,你需要重载这个函数,如下所示
void PrintTwice(double data)
{
cout << "Twice is: " << data * 2 << endl;
}
有趣的是,类类型 ostream
(cout
对象的类型)为 operator <<
提供了多个重载——为所有基本数据类型。因此,相同/相似的代码可以用于 int
和 double
,而且我们的 PrintTwice
重载函数不需要任何更改——是的,我们只是复制粘贴了它。如果我们使用 printf
函数之一,这两个重载看起来会是这样
void PrintTwice(int data)
{
printf("Twice is: %d", data * 2 );
}
void PrintTwice(double data)
{
printf("Twice is: %lf", data * 2 );
}
这里的重点不是 cout
或 print
在控制台上显示,而是代码——它是完全相同的。这是 C++ 语言提供的精彩功能:模板!的许多情况之一!
模板有两种类型
- 函数模板
- 类模板
C++ 模板是一种编程模型,它允许将任何数据类型“插入”到代码(模板化代码)中。没有模板,你需要为所有需要的数据类型一次又一次地复制相同的代码。而且,如前所述,这需要代码维护。
总之,这是使用模板的简化版 PrintTwice
void PrintTwice(TYPE data)
{
cout<<"Twice: " << data * 2 << endl;
}
在这里,实际的 TYPE
类型将由编译器根据传递给函数的实参来推导(确定)。如果 PrintTwice
被调用为 PrintTwice(144);
,它将是一个 int
;如果你将 3.14
传递给此函数,TYPE
将被推导为 double
类型。
你可能会对 TYPE
是什么感到困惑,编译器将如何确定这是一个函数模板?TYPE
类型是否使用 typedef
关键字在某处定义了?
不,我的朋友!在这里我们使用 template
关键字来告知编译器我们正在定义一个函数模板。
函数模板
这是模板化的函数 PrintTwice
template<class TYPE>
void PrintTwice(TYPE data)
{
cout<<"Twice: " << data * 2 << endl;
}
代码的第一行
template<class TYPE>
告诉编译器这是一个函数模板。 TYPE
的实际含义将由编译器根据传递给此函数的实参来推导。在这里,TYPE
的名称被称为模板类型参数。
例如,如果我们像这样调用函数
PrintTwice(124);
TYPE
将被编译器替换为 int
,编译器将实例化此模板函数为
void PrintTwice(int data)
{
cout<<"Twice: " << data * 2 << endl;
}
如果我们像这样调用函数
PrintTwice(4.5547);
它将实例化另一个函数为
void PrintTwice(double data)
{
cout<<"Twice: " << data * 2 << endl;
}
这意味着,在你的程序中,如果你使用 int
和 double
参数类型调用 PrintTwice
函数,编译器将生成此函数的两个实例
void PrintTwice(int data) { ... }
void PrintTwice(double data) { ... }
是的,代码被复制了。但这两个重载是由编译器实例化的,而不是由程序员。真正的优势在于你无需复制粘贴相同的代码,或手动维护不同数据类型的代码,或为将来出现的新数据类型编写新的重载。你只需提供一个函数的模板,其余的将由编译器管理。
代码大小会增加,因为现在有了两个函数定义。代码大小(在二进制/汇编级别)几乎相同。实际上,对于N种数据类型,将创建相同的函数N个实例(即重载函数)。有一些高级的编译器/链接器优化可以在实例化的函数相同时,或者函数体的一部分相同时,在一定程度上减小代码大小。我现在就不讨论了。
但是,从积极的一面来看,当你手动定义N个不同的重载(例如 N=10
)时,这N个不同的重载无论如何都会被编译、链接并打包到二进制文件(可执行文件)中。然而,使用模板,只有必需的函数实例才会被包含在最终的可执行文件中。使用模板,重载函数的副本可能少于 N,也可能多于 N——但正好是必需副本的数量——不多也不少!
而且,对于非模板实现,编译器必须编译所有这 N 个副本——因为它们在你的源代码中!当你将模板与通用函数关联时,编译器只会为必需的数据类型集进行编译。这基本上意味着如果不同数据类型的数量少于N,编译速度会更快!
一个完全有效的论点是,编译器/链接器很可能会进行所有可能的优化来从最终映像中删除未使用的非模板函数实现。但是,请再次理解,编译器必须编译所有这些重载(进行语法检查等)。使用模板,编译只会针对必需的数据类型进行——你可以称之为“按需编译”。
现在已经有很多纯文本内容了!你可以回来重新阅读它。我们继续前进。
现在,让我们编写另一个返回给定数字两倍的函数模板
template<typename TYPE>
TYPE Twice(TYPE data)
{
return data * 2;
}
你应该注意到我使用了 typename
,而不是 class
。不,如果函数返回某些内容,则不一定需要使用 typename
关键字。对于模板编程,这两个关键字几乎是相同的。之所以有两个关键字用于相同目的,是有历史原因的,我讨厌历史。
但是,有些情况下你只能使用较新的关键字——typename
。(当某个类型定义在另一个类型中,并且依赖于某个模板参数时——这个讨论留到另一部分)。
继续。当我们像这样调用函数时
cout << Twice(10);
cout << Twice(3.14);
cout << Twice( Twice(55) );
将生成以下函数集
int Twice(int data) {..}
double Twice(double data) {..}
两点
- 在上面代码片段的第三行,
Twice
被调用了两次——第一个调用的返回值/类型是第二个调用的参数/类型。因此,两个调用都是int
类型(因为参数类型TYPE
和返回类型相同)。 - 如果一个模板函数为特定数据类型进行了实例化,如果该函数再次被调用以相同的类型调用,编译器将重用相同的函数实例——无论是在代码中的何处调用模板函数,无论是在同一函数中,还是在不同函数中,或者在另一个源文件中(同一项目/构建中)。
让我们编写一个返回两个数字之和的函数模板
template<class T>
T Add(T n1, T n2)
{
return n1 + n2;
}
首先,我只是将模板类型参数的名称——TYPE
——替换为符号 T
。在模板编程中,你通常会使用 T
——但这是一种个人选择。你应该更好地使用一个能反映类型参数含义的名称,以提高代码的可读性。这个符号可以是任何符合 C++ 语言变量命名规则的名称。
其次,我重用了模板参数 T
——用于两个参数(n1
和 n2
)。
让我们稍微修改 Add
函数,它会将相加结果存储在局部变量中,然后返回计算出的值。
template<class T>
T Add(T n1, T n2)
{
T result;
result = n1 + n2;
return result;
}
我使用了类型参数 T
在函数体内部。你可能会问(你应该问):“编译器如何知道 result
的类型,当它尝试编译/解析 Add
函数时?”
嗯,在查看函数模板 (Add
) 的主体时,编译器不会检查 T
(模板类型参数)是否正确。它只会检查基本语法(例如分号、关键字的正确使用、大括号的匹配等),并报告这些基本检查的错误。再次强调,这取决于编译器如何处理模板代码——但它不会报告由模板类型参数引起的任何错误。
仅为完整性起见,我将重申,编译器不会检查(目前仅对函数 Add
有关)
T
是否有默认构造函数(因此T result;
是有效的)T
是否支持operator +
的使用(因此
是有效的)n1+n2
T
是否有可访问的拷贝/移动构造函数(因此return
语句才能成功)
本质上,编译器需要分两个阶段编译模板代码:一次用于基本语法检查;然后是每次实例化函数模板时——届时它将针对模板数据类型执行实际的代码编译。
如果你没有完全理解这个两阶段编译过程,那完全没关系。随着你阅读本教程,你将获得牢固的理解,然后你可以回来稍后阅读这些理论部分!
指针、引用和数组与模板
首先是一个代码示例(不用担心——这是一个简单的代码片段!)
template<class T>
double GetAverage(T tArray[], int nElements)
{
T tSum = T(); // tSum = 0
for (int nIndex = 0; nIndex < nElements; ++nIndex)
{
tSum += tArray[nIndex];
}
// Whatever type of T is, convert to double
return double(tSum) / nElements;
}
int main()
{
int IntArray[5] = {100, 200, 400, 500, 1000};
float FloatArray[3] = { 1.55f, 5.44f, 12.36f};
cout << GetAverage(IntArray, 5);
cout << GetAverage(FloatArray, 3);
}
对于第一次调用 GetAverage
,其中传递了 IntArray
,编译器将这样实例化函数
double GetAverage(int tArray[], int nElements);
同样对于 float
。返回值保留为 double
,因为数字的平均值在逻辑上可以适合 double
数据类型。请注意,这只是为了这个例子——实际进入 T
的数据类型可能是一个类,它可能无法转换为 double
。
你应该注意到,一个函数模板可能具有模板类型参数,以及非模板类型参数。函数模板的所有参数不一定必须来自模板类型。int nElements
就是这样一个函数参数。
请清楚地注意并理解,模板类型参数只是 T
,而不是 T*
或 T[]
——编译器足够智能,可以从 int[]
(或 int*
)推断出类型 int
。在上面给出的例子中,我使用了 T tArray[]
作为函数模板的参数,并且 T
的实际数据类型将根据这个智能地确定。
大多数情况下,你会遇到并需要使用如下初始化
T tSum = T();
首先,这不是模板特定的代码——这是 C++ 语言本身的一部分。它本质上意味着:调用此数据类型的默认构造函数。对于 int
,它将是
int tSum = int();
这实际上将变量初始化为 0
。同样,对于 float
,它会将变量设置为 0.0f
。虽然尚未涵盖,但如果用户定义的类类型来自 T
,它将调用该类的默认构造函数(如果可调用,否则会报错)。正如你所理解的,T
可能是任何数据类型,我们不能简单地用整数零 (0
) 初始化 tSum
。在实际情况中,它可能是一个字符串类,它将其初始化为空字符串 (""
)。
由于模板类型 T
可能是任何类型,它也必须具有 += operator
。我们知道,它对所有基本数据类型(int
、float
、char
等)都可用。如果实际类型(对于 T
)没有可用的 +=
运算符(或任何可能),编译器将报错,指出实际类型没有此运算符,或任何可能的转换。
同样,类型 T
必须能够将其自身转换为 double
(参见 return
语句)。我稍后将涵盖这些细节。只是为了更好地理解,我将重新列出所需支持来自类型 T
(现在仅适用于 GetAverage
函数模板)
- 必须有一个可访问的默认构造函数。
- 必须可以调用
+= operator
。 - 必须能够将其自身转换为
double
(或等效值)。
对于 GetAverage
函数模板原型,你可以使用 T*
而不是 T[]
,并且意思相同
template<class T>
GetAverage(T* tArray, int nElements){}
因为调用者将传递一个数组(在栈上或堆上分配),或者一个 T
类型变量的地址。但是,你应该知道,这些规则属于 C++ 的规则手册,而不是模板编程特有的!
继续。让我们邀请“引用”这个演员进入模板编程电影。现在很容易理解,你只需使用 T&
作为基础类型 T
的函数模板参数
template<class T>
void TwiceIt(T& tData)
{
tData *= 2;
// tData = tData + tData;
}
它计算参数的两倍值,并将其存入同一个参数的值中。你可以简单地这样调用
int x = 40;
TwiceIt(x); // Result comes as 80
请注意,我使用了 operator *=
来获得参数 tData
的两倍。你也可以使用 operator +
来达到相同的效果。对于基本数据类型,这两个运算符都可用。对于类类型,并非所有运算符都可用,你可能需要让类实现所需的运算符。
在我看来,让类定义 operator +
是更合乎逻辑的。原因很简单——对于大多数 UDT(用户定义类型)来说,执行 T+T
比实现 *= operator
更合适。问问自己:如果某个类 String
或 Date
实现或被要求实现以下运算符,意味着什么?
void operator *= (int); // void return type is for simplicity only.
此时,你现在清楚地理解,模板参数类型 T
可以从 T&
、T*
或 T[]
推断出来。.
因此,将 const
属性添加到到达函数模板的参数是可能且非常合理的,该参数不会被函数模板更改。请轻松理解,这很简单
template<class TYPE>
void PrintTwice(const TYPE& data)
{
cout<<"Twice: " << data * 2 << endl;
}
请注意,我修改了模板参数 TYPE
为 TYPE&
,并为其添加了 const
。一些读者或大多数读者已经意识到了这个更改的重要性。对于那些没有意识到的人
TYPE
类型可能很大,需要更多的栈空间(调用栈)。它包括double
,需要 8 字节*
,某些结构或类,这需要更多的字节才能放在栈上。它本质上意味着——将创建一个给定类型的新对象,调用拷贝构造函数,并将其放入调用栈,然后在函数结束时调用析构函数。
添加引用 (&
) 避免了这一切——传递的是同一对象的引用。- 函数不会更改传递的参数,因此添加
const
到它。它向函数调用者保证,此函数(此处为PrintTwice
)不会更改参数的值。如果函数本身试图修改(常量)参数的内容,它还会确保编译器报错。
*
在 32 位平台上,函数参数至少需要 4 字节,并且是 4 字节的倍数。这意味着 char
或 short
在调用栈中需要 4 字节。例如,一个 11 字节的对象在栈中需要 12 字节。一个 11 字节的对象,例如,需要 12 字节的栈空间。同样,对于 64 位平台,需要 8 字节。一个 11 字节的对象需要 16 字节。
double
类型的参数需要 8 字节。所有指针/引用在 32 位/64 位平台上分别占用 4 字节/8 字节,因此在 64 位平台上,传递
double
或 double&
的意义相同。同样,我们也应该修改其他函数模板,如下所示
template<class TYPE>
TYPE Twice(const TYPE& data) // No change for return type
{
return data * 2;
}
template<class T>
T Add(const T& n1, const T& n2) // No return type change
{
return n1 + n2;
}
template<class T>
GetAverage(const T tArray[], int nElements)
// GetAverage(const T* tArray, int nElements)
{}
请注意,不可能在返回类型中添加引用和 const
,除非我们打算返回传递给函数模板的原始对象的引用(或指针)。下面的代码对此进行了说明
template<class T>
T& GetMax(T& t1, T& t2)
{
if (t1 > t2)
{
return t2;
}
// else
return t2;
}
这就是我们利用返回引用的方式
int x = 50;
int y = 64;
// Set the max value to zero (0)
GetMax(x,y) = 0;
请注意,这仅用于说明,你很少会看到或编写这样的代码。但是,你可能会看到这样的代码,并且可能需要编写,如果返回的对象是某个 UDT 的引用。在这种情况下,成员访问运算符点 (.
) 或箭头 (->
) 将跟在函数调用后面。总之,这个函数模板返回在大于号竞赛中获胜的对象的引用。这绝对需要类型 T
定义 operator >
。
你应该注意到,我没有给传递的两个参数中的任何一个添加 const
。这是必需的;因为函数返回 T
的非 const 引用。如果它是这样的
T& GetMax(const T& t1, const T& t2)
在 return
语句处,编译器会抱怨 t1
或 t2
不能转换为非 const。如果我们也在返回类型中添加 const
(const T& GetMax(...)
),那么调用站点上的以下行将无法编译
GetMax(x,y) = 0;
因为常量对象无法被修改!你肯定可以强制进行 const/non-const 类型转换,无论是在函数内部还是在调用站点。但这是一个不同的方面,一个糟糕的设计,并且是不推荐的方法。
函数模板中的多个类型
到目前为止,我只覆盖了一个类型的模板类型参数。使用模板,你可以有多个模板类型参数。它看起来是这样的
template<class T1, class T2, ... >
其中 T1
和 T2
是函数模板的类型名称。你可以使用除 T1
、T2
之外的任何其他特定名称。请注意,上面“...
”的使用并不意味着这个模板规范可以接受任意数量的参数。它只是说明模板可能具有任意数量的参数。
(根据 C++11 标准,模板允许可变数量的参数——但现在这个话题超出了范围。)
让我们来看一个简单的例子,它接受两个模板参数
template<class T1, class T2>
void PrintNumbers(const T1& t1Data, const T2& t2Data)
{
cout << "First value:" << t1Data;
cout << "Second value:" << t2Data;
}
我们可以简单地这样调用它
PrintNumbers(10, 100); // int, int
PrintNumbers(14, 14.5); // int, double
PrintNumbers(59.66, 150); // double, int
每次调用都要求对传递(或说推导)的第一个和第二个类型进行单独的模板实例化。因此,编译器将填充以下三个函数模板实例
// const and reference removed for simplicity
void PrintNumbers(int t1Data, int t2Data);
void PrintNumbers(int t1Data, double t2Data);
void PrintNumbers(double t1Data, int t2Data);
请注意,第二个和第三个实例化不相同,因为 T1
和 T2
将推导出不同的数据类型(int
、double
和 double
、int
)。编译器不会执行任何自动转换,就像它可能为普通函数调用那样——例如,一个接受 int
的普通函数可以传递 short
,反之亦然。但是使用模板,如果你传递 short
——它就是绝对的 short
,而不是(提升到)int
。所以,如果你传递 (short
, int
)、(short
, short
)、(long
, int
)——这将导致 PrintNumbers
的三个不同实例化!
同样,函数模板可能具有 3 个或更多类型参数,每个参数都将映射到函数调用中指定的参数类型。例如,以下函数模板是合法的
template<class T1, class T2, class T3>
T2 DoSomething(const T1 tArray[], T2 tDefaultValue, T3& tResult)
{
...
}
其中 T1
指定了调用者将通过的数组类型。如果未传递数组(或指针),编译器将报错。类型 T2
用作返回类型以及第二个参数,该参数按值传递。类型 T3
作为引用(非 const 引用)传递。上面给出的这个函数模板示例只是随意选择的,但它是一个有效的函数模板规范。
到目前为止,我已经提升并详细介绍了多个模板参数。但出于某种原因,我将降低到单参数函数。对此有一个原因,你很快就会理解。
假设有一个函数(非模板函数),它接受一个 int
参数
void Show(int nData);
你这样调用它
Show( 120 ); // 1
Show( 'X' ); // 2
Show( 55.64 ); // 3
- 调用1 是完全有效的,因为函数接受
int
参数,并且我们正在传递120
。 - 调用2 是有效的,因为我们传递了一个
char
,它将被编译器提升为int
。 - 调用3 将需要降级该值——编译器必须将
double
转换为int
,因此将传递55
而不是55.64
。是的,这会触发相应的编译器警告。
一个解决方案是修改函数,使其接受 double
,这样所有三种类型都可以传递。但这将不支持所有类型,并且可能不适合 double
,或无法转换为 double
。因此,你可以编写一组重载函数,接受适当的类型。有了知识,现在你将欣赏模板的重要性,并会要求将其写成函数模板而不是
template<class Type>
void Show(Type tData) {}
当然,假设 Show
的所有现有重载都做了同样的事情。
那么,如果你想将 int
传递给函数模板 Show
,但希望编译器像传递 double
一样进行实例化怎么办?
// This will produce (instantiate) 'Show(int)'
Show ( 1234 );
// But you want it to produce 'Show(double)'
目前来看,这似乎是不合逻辑的。但要求这样的实例化有一个有效的原因,你很快就会理解并欣赏!
无论如何,首先看看如何要求这种荒谬的事情
Show<double> ( 1234 );
这会实例化以下模板函数(如你所知)
void Show(double);
通过这种特殊的语法 (Show<>()
),你要求编译器为显式传递的类型实例化 Show
函数,并要求编译器不要通过函数参数推导类型。
函数模板 - 模板函数
重要!函数模板和模板函数之间存在区别。
函数模板是围绕 template
关键字括起来的函数体,它不是一个实际的函数,不会被编译器完全编译,并且不被链接器负责。至少需要一个特定数据类型的调用来实例化它,并将其纳入编译器和链接器的职责范围。因此,函数模板 Show
的实例被实例化为 Show(int)
或 Show(double)
。
模板函数?简单来说,就是当你调用它,或者使其为特定数据类型实例化时产生的“函数模板的实例”。函数模板的实例实际上是一个有效的函数。
函数模板的实例(也称为模板函数)在编译器和链接器的名称修饰系统下,不是普通函数。这意味着,函数模板的实例
template<class T>
void Show(T data)
{ }
对于模板参数 double
,它不是
void Show(double data){}
而是实际上
void Show<double>(double x){}
很长一段时间,我没有揭示这一点,只是为了简单起见,现在你知道了!使用你的编译器/调试器找出函数模板的实际实例化,并在调用栈或生成的代码中查看函数的完整原型。
因此,现在你知道了这两个之间的映射
Show<double>(1234);
...
void Show<double>(double data); // Note that data=1234.00, in this case!
显式模板实参指定
回到多模板参数的讨论。
我们有以下函数模板
template<class T1, class T2>
void PrintNumbers(const T1& t1Data, const T2& t2Data)
{}
并且有以下函数调用,导致此函数模板的 3 个不同实例
PrintNumbers(10, 100); // int, int
PrintNumbers(14, 14.5); // int, double
PrintNumbers(59.66, 150); // double, int
如果你只需要一个实例——两个参数都为 double
——怎么办?是的,你愿意传递 int
并让它们提升为 double
。结合你刚刚获得的理解,你将这样调用这个函数模板
PrintNumbers<double, double>(10, 100); // int, int
PrintNumbers<double, double>(14, 14.5); // int, double
PrintNumbers<double, double>(59.66, 150); // double, int
这将只产生以下模板函数:
void PrintNumbers<double, double>(const double& t1Data, const T2& t2Data)
{}
这种从调用站点传递模板类型参数的概念,被称为显式模板实参指定。
为什么你需要显式类型指定?好吧,有多种原因
- 你只想传递特定类型,而不让编译器仅根据实际参数(函数参数)智能地推导一个或多个模板实参类型。
例如,有一个函数模板 max
,它接受两个参数(仅通过一个模板类型参数)
template<class T>
T max(T t1, T t2)
{
if (t1 > t2)
return t1;
return t2;
}
你尝试这样调用它
max(120, 14.55);
这将导致编译器错误,提到 T
模板类型存在歧义。你要求编译器从两种类型中推导出一种类型!一个解决方案是更改 max
模板,使其接受两个模板参数——但你不是那个函数模板的作者。
这时你使用显式实参指定
max<double>(120, 14.55); // Instantiates max<double>(double,double);
毫无疑问,请注意并理解我只为第一个模板参数传递了显式指定,第二个类型是从函数调用的第二个参数推导出来的。
- 当函数模板具有模板类型时,但不是来自其函数参数。
一个简单的例子:
template<class T>
void PrintSize()
{
cout << "Size of this type:" << sizeof(T);
}
你不能简单地这样调用函数模板
PrintSize();
因为这个函数模板将需要模板类型参数指定,并且编译器无法自动推导。正确的调用应该是
PrintSize<float>();
这将使用 float
模板参数实例化 PrintSize
。
- 当函数模板具有无法从参数推导的返回类型,或者当函数模板没有任何参数时。
示例
template<class T>
T SumOfNumbers(int a, int b)
{
T t = T(); // Call default CTOR for T
t = T(a)+b;
return t;
}
它接受两个 int
,并将它们相加。虽然在 int
中相加是合适的,但这个函数模板提供了一个机会,以调用者所需的任何类型来计算总和(使用 operator+
)。例如,要在 double
中获取结果,你可以这样调用
double nSum;
nSum = SumOfNumbers<double>(120,200);
最后两个只是为了完整性而简化的示例,只是为了给你提示显式模板实参指定在哪里适用。在更具体的情况下,这种明确性将是必需的,并在下一部分中介绍。
函数模板中的默认实参
对于知道模板领域中默认模板类型指定的读者——这不是关于默认模板类型实参。默认模板类型,无论如何,不允许与函数模板一起使用。对于不知道的读者,请不要担心——这一段不是关于默认模板类型指定的。
正如你所知,C++ 函数可能具有默认参数。默认性只能从右到左进行,这意味着,如果需要为第 n 个参数设置默认值,则 (n+1)th 个参数也必须是默认的,依此类推,直到函数的最后一个参数。
一个简单的例子来说明这一点
template<class T>
void PrintNumbers(T array[], int array_size, T filter = T())
{
for(int nIndex = 0; nIndex < array_size; ++nIndex)
{
if ( array[nIndex] != filter) // Print if not filtered
cout << array[nIndex];
}
}
这个函数模板将打印,正如你所猜到的,除了被第三个参数:filter
过滤掉的数字之外的所有数字。最后一个可选的函数参数,默认为 T
类型的默认值,对于所有基本类型来说意味着零。因此,当你这样调用它时
int Array[10] = {1,2,0,3,4,2,5,6,0,7};
PrintNumbers(Array, 10);
它将被实例化为
void PrintNumbers(int array[], int array_size, int filter = int())
{}
filter
参数将被渲染为:int filter = 0
。
显然,当你这样调用它时
PrintNumbers(Array, 10, 2);
第三个参数获得值 2
,而不是默认值 0
。
应该清楚地理解
- 类型
T
必须具有默认构造函数。当然,还有函数体可能需要的类型T
的所有运算符。 - 默认参数必须可以从模板接受的其他非默认类型推导出来。在
PrintNumbers
示例中,array
的类型将有助于推导filter
。
如果不是,你必须使用显式模板实参指定来指定默认参数的类型。
当然,默认参数不一定必须是类型 T
的默认值(对不起这个双关语)。这意味着,默认参数不一定总是需要依赖于类型 T
的默认构造函数
template<class T>
void PrintNumbers(T array[], int array_size, T filter = T(60))
在这里,默认函数参数不使用类型 T
的默认值。相反,它使用值 60
。这肯定要求类型 T
具有接受 int
(对于 60
)的拷贝构造函数。
最后,函数模板的“函数模板”部分在此文章部分结束。我希望你喜欢阅读并掌握了函数模板的这些基础知识。下一部分将涵盖模板编程更令人着迷的方面。
类模板
你通常会设计和使用类模板,而不是函数模板。总的来说,你使用类模板来定义一个抽象类型,其行为是通用的,并且是可重用的、可适应的。虽然有些文本会通过数据结构(如链表、栈、队列等容器)的例子开始,但我将从非常简单的、易于理解的例子开始。
让我们看一个简单的类,它设置、获取并打印存储的值
class Item
{
int Data;
public:
Item() : Data(0)
{}
void SetData(int nValue)
{
Data = nValue;
}
int GetData() const
{
return Data;
}
void PrintData()
{
cout << Data;
}
};
一个初始化 Data
为 0
的构造函数,Set 和 Get 方法,以及一个打印当前值的方法。用法也很简单
Item item1;
item1.SetData(120);
item1.PrintData(); // Shows 120
对你来说肯定没有什么新鲜的!但是当你需要其他数据类型的类似抽象时,你需要复制整个类的代码(或至少是所需的方法)。这会导致代码维护问题,增加源代码和二进制级别的代码量。
是的,我能感受到你的智慧,我将要提到 C++ 模板!相同类的模板化版本,以类模板的形式如下
template<class T>
class Item
{
T Data;
public:
Item() : Data( T() )
{}
void SetData(T nValue)
{
Data = nValue;
}
T GetData() const
{
return Data;
}
void PrintData()
{
cout << Data;
}
};
类模板声明以与函数模板相同的语法开始
template<class T>
class Item
请注意,class
关键字使用了两次——第一次用于指定模板类型规范(T
),第二次用于指定这是一个 C++ 类声明。
为了将 Item
完全变成一个类模板,我将所有 int
的实例替换为 T
。我还使用了 T()
语法来调用 T
的默认构造函数,而不是在构造函数的初始化列表中硬编码 0
(零)。如果你完整阅读了函数模板部分,你就知道原因了!
用法也同样简单
Item<int> item1;
item1.SetData(120);
item1.PrintData();
与函数模板实例化不同,函数本身的参数有助于编译器推导模板类型参数,而对于类模板,你必须在尖括号中显式传递模板类型。
上面显示的代码片段导致类模板 Item
被实例化为 Item<int>
。当你使用 Item
类模板创建另一个具有不同类型的对象时,如下所示
Item<float> item2;
float n = item2.GetData();
这将导致 Item<float>
被实例化。知道类模板的两个实例——Item<int>
和 Item<float>
——之间绝对没有任何关系,这一点很重要。对于编译器和链接器来说,这两个是不同的实体——或者说,不同的类。
第一个使用类型 int
的实例化生成以下方法
Item<int>::Item()
构造函数- 类型为
int
的SetData
和PrintData
方法
同样,第二个使用类型 float
的实例化将生成
Item<float>::Item()
构造函数float
类型的GetData
方法
正如你所知,Item<int>
和 Item<float>
是两个不同的类/类型;因此,下面的代码将不起作用
item1 = item2; // ERROR : Item<float> to Item<int>
由于两种类型不同,编译器不会调用可能的默认赋值运算符。如果 item1
和 item2
是相同类型(例如都是 Item<int>
),编译器将愉快地调用赋值运算符。虽然对于编译器来说,int
和 float
之间的转换是可能的,但对于不同的 UDT 转换则不是,即使底层数据成员相同——这是简单的 C++ 规则。
此时,清楚地理解只有以下方法集将被实例化
Item<int>::Item()
- 构造函数void Item<int>::SetData(int)
方法void Item<int>::PrintData() const
方法Item<float>::Item()
- 构造函数float Item<float>::GetData() const
方法
以下方法不会进行第二阶段编译
int Item<int>::GetData() const
void Item<float>::SetData(float)
void Item<float>::PrintData() const
那么,什么是第二阶段编译?嗯,正如我之前已经阐述的,模板代码将被编译以进行基本语法检查,无论它是否被调用/实例化。这被称为第一阶段编译。
当你实际调用,或以某种方式触发它被调用时,为特定类型(或类型)调用函数/方法——那时它才会得到第二阶段编译的特殊处理。只有通过第二阶段编译,代码才会被完全编译,针对它被实例化的类型。
虽然我可以更早地阐述这一点,但这个地方是合适的。你如何找出函数是否正在进行第一阶段和/或第二阶段编译?
让我们做一些奇怪的事情
T GetData() const
{
for())
return Data;
}
for
的末尾有一个额外的括号——这是不正确的。当你编译它时,你会收到一系列错误,无论它是否被调用。我使用 Visual C++ 和 GCC 编译器都检查过,两者都抱怨。这验证了第一阶段编译。
让我们稍微改变一下
T GetData() const
{
T temp = Data[0]; // Index access ?
return Data;
}
现在不调用任何类型的 GetData
方法就进行编译——编译器不会有任何抱怨。这意味着,此时,这个函数没有获得第二阶段编译的处理!
一旦你调用
Item<double> item3;
item2.GetData();
你将收到编译器错误,指出 Data
不是一个可以附加 operartor []
的数组或指针。这证明只有选定的函数才能获得第二阶段编译的特殊权限。并且这个第二阶段编译将为类/函数模板的每个唯一类型单独发生。
一件有趣的事情是,你可以做
T GetData() const
{
return Data % 10;
}
这将成功编译 Item<int>
,但对 Item<float>
会失败
item1.GetData(); // item1 is Item<int>
// ERROR
item2.GetData(); // item2 is Item<float>
因为 operator %
不适用于 float
类型。这很有趣,不是吗?
类模板中的多个类型
我们的第一个类模板 Item
只有一个模板类型。现在让我们构建一个有两个模板类型参数的类。同样,可能有稍微复杂的类模板示例,我想保持简单。
有时,你需要一些原生结构来保存一些数据成员。为此创建一个唯一的 struct
似乎有些不必要和不必要的工作。你很快就会厌倦为具有少量成员的不同结构命名。此外,它会增加代码长度。无论你的观点如何,我都将其作为一个例子,并派生一个具有两个成员的类模板。
STL 程序员会发现这相当于 std::pair
类模板。
假设你有一个结构 Point
,
struct Point
{
int x;
int y;
};
它有两个数据成员。此外,你可能还有一个结构 Money
struct Money
{
int Dollars;
int Cents;
};
这两个结构在数据成员方面几乎相同。与其重写不同的结构,不如将它们放在一个地方,这也能促进
- 带有给定类型的一个或两个参数的构造函数,以及一个拷贝构造函数。
- 比较相同类型两个对象的方法。
- 在两种类型之间交换
- 等等。
你可能会说你可以使用继承模型,在那里你可以定义所有必需的方法,并让派生类对其进行自定义。这合适吗?你选择的数据类型呢?它可能是 int
、string
或 float
、某个类作为类型。总之,继承只会使设计复杂化,并且不允许 C++ 模板提供的插件功能。
这时我们使用类模板!只需为两种类型定义一个类模板,并包含所有必需的方法。让我们开始吧!
template<class Type1, class Type2>
struct Pair
{
// In public area, since we want the client to use them directly.
Type1 first;
Type2 second;
};
现在,我们可以使用 Pair
类模板来派生任何具有两个成员的类型。一个例子
// Assume as Point struct
Pair<int,int> point1;
// Logically same as X and Y members
point1.first = 10;
point1.second = 20;
请理解,first
和 second
的类型现在分别是 int
和 int
。这是因为我们使用这些类型实例化了 Pair
。
当我们像这样实例化它时
Pair<int, double> SqRoot;
SqRoot.first = 90;
SqRoot.second = 9.4868329;
first
将是 int
类型,second
将是 double
类型。清楚地理解 first
和 second
是数据成员,而不是函数,因此没有假设函数调用的运行时开销。
注意:在本篇文章的这一部分,所有定义都在类声明体内部。在下一部分,我将解释如何在单独的实现文件中实现方法,以及相关的。因此,所有显示的方法定义都应假定在 class ClassName{...};
内部。
以下给出的默认构造函数将根据 Type1
和 Type2
的数据类型将两个成员初始化为其默认值
Pair() : first(Type1()), second(Type2())
{}
以下是参数化构造函数,接受 Type1
和 Type2
来初始化 first
和 second
的值
Pair(const Type1& t1, const Type2& t2) :
first(t1), second(t2)
{}
以下是拷贝构造函数,它将从另一个完全相同类型的 Pair
对象复制一个 Pair
对象
Pair(const Pair<Type1, Type2>& OtherPair) :
first(OtherPair.first),
second(OtherPair.second)
{}
请注意,对于此拷贝构造函数的参数,必须指定 Pair<>
的模板类型参数。以下规范将没有意义,因为 Pair
不是非模板类型
Pair(const Pair& OtherPair) // ERROR: Pair requires template-types
这里有一个使用参数化构造函数和拷贝构造函数的例子
Pair<int,int> point1(12,40);
Pair<int,int> point2(point1);
重要的是要注意,如果你更改了 point2
或 point1
对象中的任何模板类型参数,你将无法使用 point1
对象将其复制构造。以下将是一个错误
Pair<int,float> point2(point1); // ERROR: Different types, no conversion possible.
虽然 float
到 int
之间存在可能的转换,但 Pair<int,float>
到 Pair<int,int>
之间不存在可能的转换。拷贝构造函数不能接受其他类型作为可复制对象。有一个解决方案,但我将在下一部分讨论。
同样,你可以实现比较运算符来比较两个相同 Pair
类型的对象。以下是等于运算符的实现
bool operator == (const Pair<Type1, Type2>& Other) const
{
return first == Other.first &&
second == Other.second;
}
请注意,我使用了 const
属性,用于参数和方法本身。请完全理解上面方法定义的第一个!
就像拷贝构造函数调用一样,你必须将完全相同的类型传递给这个比较运算符——编译器不会尝试转换不同的 Pair
类型。例如
if (point1 == point2) // Both objects must be of same type.
...
为了牢固理解到目前为止的概念,请自行实现以下方法
- 所有其余的 5 个关系运算符
- 赋值运算符
Swap
方法- 修改两个构造函数(除拷贝构造函数外),并将它们合并为一个,使其可以接受 0、1 或 2 个参数。这意味着,只实现一个可以接受 0、1 或 2 个参数的构造函数。
Pair
类是两个类型的示例,并且可以用来代替定义多个只有两个数据成员的结构。缺点是需要记住 first
和 second
分别代表什么(X 还是 Y?)。但是当你很好地定义模板实例化时,你将始终知道并适当地使用 first
和 second
成员。
忽略这一个缺点,你将在实例化类型中获得所有功能:构造函数、拷贝构造函数、比较运算符、swap 方法等。而且,你将获得所有这些,而无需重写你需要为各种双成员结构编写的代码。此外,如你所知,只有必需的方法集才会被编译和链接。类模板中的一个 bug 修复将自动反映到所有实例化中。是的,对类模板的轻微修改也可能引发一系列其他类型的错误,如果修改未能符合现有用法。
同样,你可以有一个类模板 tuple
,它允许三个(或更多)数据成员。请尝试自己实现具有三个成员(first
、second
、third
)的类 tuple
template<class T1, class T2, class T3>
class tuple
非类型模板实参
好的,我们已经看到,类模板,就像函数模板一样,可以接受多个类型参数。但是类模板也允许一些非类型模板参数。在本部分,我将只详细介绍一种非类型:整数。
是的,类模板可以接受一个整数作为模板参数。首先是一个样本
template<class T, int SIZE>
class Array{};
在这个类模板声明中,int SIZE
是一个非类型参数,它是一个整数。
- 只有整型数据类型可以作为非类型整数参数,它包括
int
、char
、long
、long long
、unsigned
变体和enum
。像float
和double
这样的类型是不允许的。 - 当实例化时,只能传递编译时常量整数。这意味着
100
、100+99
、1<<3
等是允许的,因为它们是编译时常量表达式。涉及函数调用的参数,如abs(-120)
,是不允许的。
作为模板参数,浮点数/双精度数等可能被允许,如果它们可以转换为整数。
很好。我们可以像这样实例化类模板 Array
Array<int, 10> my_array;
所以呢?SIZE
参数的目的是什么?
嗯,在类模板中,你可以在任何可以使用整数的地方使用这个非类型整数参数。它包括
- 为类的静态 const 数据成员赋值。
template<class T, int SIZE>
class Array
{
static const int Elements_2x = SIZE * 2;
};
[类声明的前两行将不再显示,假设一切都在类的体内部。]
由于允许在类声明内部初始化一个静态整数常量,我们可以使用非类型整数参数。
- 为方法的默认值指定。
(虽然 C++ 也允许任何非常量作为函数的默认参数,我这里只举例说明。)
void DoSomething(int arg = SIZE);
// Non-const can also appear as default-argument...
- 定义数组的大小。
这一点很重要,非类型整数参数通常用于此目的。所以,让我们来实现类模板 Array
,利用 SIZE
参数。
private:
T TheArray[SIZE];
T
是数组的类型,SIZE
是大小(整数)——就这么简单。由于数组位于类的私有区域,我们需要定义几个方法/运算符。
// Initialize with default (i.e. 0 for int)
void Initialize()
{
for(int nIndex = 0; nIndex < SIZE; ++nIndex)
TheArray[nIndex] = T();
}
当然,类型 T
必须有一个默认构造函数和一个赋值运算符。我将在下一部分(要求)涵盖函数模板和类模板的这些内容。
我们还需要实现数组元素访问运算符。一组重载的索引访问运算符,另一个则获取值(类型为 T
)
T operator[](int nIndex) const
{
if (nIndex>0 && nIndex<SIZE)
{
return TheArray[nIndex];
}
return T();
}
T& operator[](int nIndex)
{
return TheArray[nIndex];
}
请注意,第一个重载(声明为 const
)是 get/read 方法,并且有一个检查以查看索引是否有效,否则返回类型 T
的默认值。
第二个重载返回元素的引用,可以由调用者修改。没有索引有效性检查,因为它必须返回一个引用,因此无法返回局部对象(T()
)。然而,你可以检查索引参数,返回默认值,使用断言和/或抛出异常。
让我们定义另一个方法,它将逻辑上对 Array
的所有元素求和
T Accumulate() const
{
T sum = T();
for(int nIndex = 0; nIndex < SIZE; ++nIndex)
{
sum += TheArray[nIndex];
}
return sum;
}
正如你所能理解的,它需要目标类型 T
提供 operator +=
。另外请注意,返回类型是 T
本身,这是合适的。所以,当使用某些字符串类实例化 Array
时,它将在每次迭代中调用 +=
并返回组合的字符串。如果目标类型没有定义这个 +=
运算符,并且你调用了这个方法,就会出现错误。在这种情况下,你可以——要么不调用它;要么在目标类中实现所需的运算符重载。
模板类作为类模板的实参
虽然这是一个模糊的陈述,容易引起一些歧义,但我将尽我最大的努力来消除这些模糊之处。
首先,回顾一下模板函数和函数模板之间的区别。如果神经元帮助将正确的信息传递到你大脑的缓存中,你现在会回忆起模板函数是函数模板的实例。如果你的大脑的搜索子系统没有响应,请重新加载信息!
类模板的实例是模板类。因此,对于以下类模板
template<class T1, class T2>
class Pair{};
这个模板的实例化是一个模板类
Pair<int,int> IntPair;
清楚地理解 IntPair
不是模板类,不是类模板的实例化。它是一个特定实例化/类模板的对象。模板类/实例化是 Pair<int,int>
,它会生成另一个类类型(编译器,我们的好朋友,就是这样做的,你知道吗!)。本质上,这就是编译器在这种情况下会生成的模板类
class Pair<int,int>{};
对于模板类,有一个更精确的定义,选择这单行代码以便于理解。详细的阐述将在本系列的下一期中提供。
现在,让我们回到重点。如果你将一个模板类传递给某个类模板怎么办?我的意思是,以下陈述是什么意思?
Pair<int, Pair<int,int> > PairOfPair;
这有效吗?如果有效,它意味着什么?
首先,这是完全有效的。其次,它实例化了两个模板类
Pair<int,int>
- APair<int, Pair<int,int> >
-- B
编译器将实例化 A 和 B 两种类型,如果由于这两个模板类的任何一种类型而产生任何错误,编译器都会报告。为了简化这种复杂的实例化,你可以这样做
typedef Pair<int,int> IntIntPair;
...
Pair<int, IntIntPair> PairOfPair;
你可以像这样为 PairOfPair
对象分配 first
和 second
成员
PairOfPair.first = 10;
PairOfPair.second.first = 10;
PairOfPair.second.second= 30;
请注意,最后两行的 second
成员类型是 Pair<int,int>
,因此它具有相同的成员集可供进一步访问。这就是为什么 first
和 second
成员可以以级联的方式使用。
现在你(希望)理解类模板(Pair
)接受模板类(Pair<int,int>
)作为参数并产生最终的实例化!
一个有趣的实例化,在这个讨论中,将是 Array
与 Pair
!你知道 Pair
接受两个模板类型参数,而 Array
接受一个类型参数和一个大小(整数)参数。
Array< Pair<int, double>, 40> ArrayOfPair;
这里 int
和 double
是 Pair
的类型参数。因此,Array
的第一个模板类型(粗体标记)是 Pair<int,double>
。第二个参数是常量 40
。你能回答这个问题吗:Pair<int,double>
的构造函数会被调用吗?它会在什么时候被调用?在你回答之前,我先反转实例化,如下
Pair<int, Array<double, 50>> PairOfArray;
哇!这是什么意思?
嗯,它的意思是:PairOfArray
是 Pair
的一个实例化,它接受第一个类型是 int
(用于 first
成员),第二个类型(second
)是一个 Array
。其中 Array
(Pair
的第二个类型)是 50 个 double
类型的元素!
别杀我!慢慢地、清楚地理解这些模板的基本概念。一旦你有了非常清晰的理解,你就会热爱模板!
在这里,我再次使用了一个模板类(Array<double,50>
)作为其他类型(Pair<int,...>
)的实例的参数。
好吧,但是上面的右移运算符(>>
)在做什么?嗯,那不是运算符,只是 Array
类型规范的结尾,后面跟着 Pair
类型规范的结尾。一些旧的编译器要求我们在两个大于号之间加一个空格,以避免错误或混淆。
Pair<int, Array<double, 50> > PairOfArray;
目前,几乎所有现代 C++ 编译器都足够智能,能够理解这用于结束模板类型规范,因此你无需担心。因此,你可以自由地使用两个或多个 >
符号来结束模板规范。
请注意,传递模板类(实例化)在 C++ 术语中并不是什么特别的事情——它只是一个类模板可以接受的类型。
最后,这里给出了两个对象的用法示例。首先是构造函数。
Array< Pair<int, double>, 40> ArrayOfPair;
这将导致 Pair
的构造函数被调用 40 次,因为 Array
类模板中有一个常量大小的数组声明
T TheArray[SIZE];
这意味着
Pair<int,double> TheArray[40];
因此,所需的 Pair
构造函数的调用次数。
对于以下对象构造
Pair<int, Array<double, 50>> PairOfArray;
Pair
的构造函数将使用 0
(使用 int()
表示法)初始化第一个参数,并使用 Array()
表示法调用 Array
的构造函数,如下所示
Pair() : first(int()), second(Array())
{}
由于编译器提供了 Array
类模板的默认构造函数,它将被调用。如果你不理解这里写的内容,请提高你的 C++ 技能。
分配 ArrayOfPair
的一个元素
ArrayOfPair[0] = Pair<int,double>(40, 3.14159);
在这里,你调用 Array::operator[]
的非 const 版本,它将返回 TheArray
中 Array
第一个元素的引用。如你所知,该元素类型为 Pair<int,double>
。赋值运算符右侧的表达式只是调用 Pair<int,double>
的构造函数并传递所需的两个参数。赋值完成!
类模板中的默认模板实参
首先,我将消除“默认参数”一词的任何歧义。在函数模板部分也使用了相同的短语。在该子部分中,默认参数指的是函数参数本身,而不是函数模板的类型参数。无论如何,函数模板不支持模板参数的默认参数。作为附注,请注意类模板的方法可以接受默认参数,就像任何普通函数/方法一样。
另一方面,类模板支持模板参数的类型/非类型参数的默认参数。举个例子
template<class T, int SIZE=100>
class Array
{
private:
T TheArray[SIZE];
...
};
我只是修改了类模板 Array
第一行的 SIZE
。第二个模板参数,一个整数常量规范,现在设置为 100
。这意味着,当你以这种方式使用它时
Array<int> IntArray;
它本质上意味着
Array<int, 100> IntArray;
编译器将在实例化这个类模板时自动放置它。当然,你可以通过显式传递第二个模板参数来指定自定义数组大小
Array<int, 200> IntArray;
请记住,当你显式传递类模板声明中指定的相同参数的默认参数时,它只会实例化一次。我的意思是,创建的以下两个对象将只实例化一个类:Array<int,100>
Array<int> Array1;
Array<int,100> Array2;
当然,如果你更改了类模板定义中的默认模板参数,使用一个不同于 100
的值,它将导致两次模板实例化,因为它们将是不同的类型。
你可以使用 const
或 #define
来自定义默认参数
const int _size = 120;
// #define _size 150
template<class T, int SIZE=_size>
class Array
当然,使用 _size
符号而不是硬编码常量值意味着相同。但是使用符号将简化默认规范。无论你如何为整数(这是一个非类型模板参数)指定默认模板参数,它必须是一个编译时常量表达式。
你通常不会为非类型整数参数使用默认规范,除非你使用模板进行高级操作,如元编程、静态断言、SFINAE 等,这绝对需要一个单独的部分。更常见的是,你会看到并实现类模板的默认参数,这些参数是数据类型。一个例子
template<class T = int>
class Array100
{
T TheArray[100];
};
它定义了一个类型为 T
、大小为 100
的数组。这里,类型参数默认为 int
。这意味着,如果你在实例化 Array100
时不指定类型,它将映射到 int
。以下是如何使用它的例子
Array100<float> FloatArray;
Array100<> IntArray;
在第一次实例化中,我传递了 float
作为模板类型,在第二次调用中,我将其保留为默认值(为 int
),使用了 <>
表示法。虽然在模板编程中有更多此表示法的用法,我将在后面的部分介绍,但它在这里也非常必需。如果你尝试使用类模板,如下
Array100 IntArray;
它将导致编译器错误,说 Array100
需要模板参数。因此,你必须使用空的尖括号集(<>
)来实例化类模板,如果所有模板参数都是默认的,并且你想使用默认值。
需要记住的重要一点是,一个名为 Array100
的非模板类也不允许存在。定义一个非模板类,如下所示,与模板类(以上或以下)一起,将使编译器感到不适
class Array100{}; // Array100 demands template arguments!
现在,让我们将类型和非类型参数都混合到我们的类 Array
中
template<class T = int, int SIZE=100>
class Array
{
T TheArray[SIZE];
...
};
最后,类型和大小参数都使用 int
和 100
分别标记为默认。清楚地理解第一个 int
是用于 T
的默认规范,第二个 int
是用于非模板常量规范。为了简单和提高可读性,你应该将它们放在不同的行上
template<class T = int,
int SIZE=100>
class Array{};
现在,请运用你的智能来解析以下实例化的含义
Array<> IntArray1;
Array<int> IntArray2;
Array<float, 40> FlaotArray3;
就像函数模板中的显式规范一样,只指定尾部模板参数是不允许的。以下是错误
Array<, 400> IntArrayOf500; // ERROR
作为最后的说明,请记住以下两个对象创建只会实例化一个类模板,因为它们本质上是完全相同的
Array<> IntArray1;
Array<int> IntArray2
Array<int, 100> IntArray3;
将一个类型默认为另一个类型
也可以将类型/非类型参数默认为另一个类型参数。例如,我们可以修改 Pair
类,以便如果未显式指定第二个类型,则第二个类型将与第一个类型相同。
template<class Type1, class Type2 = Type1>
class Pair
{
Type1 first;
Type2 second;
};
在这个修改后的类模板 Pair
中,Type2
现在默认为 Type1
类型。一个实例化示例
Pair<int> IntPair;
正如你所能猜到的,这与
Pair<int,int> IntPair;
但可以节省你输入第二个参数。也可以让 Pair
的第一个参数也默认为
template<class Type1=int, class Type2 = Type1>
class Pair
{
Type1 first;
Type2 second;
};
这意味着,如果你不传递任何模板参数,Type1
将是 int
,因此 Type2
也将是 int
!
以下用法
Pair<> IntPair;
实例化以下类
class Pair<int,int>{};
当然,也可以让非类型参数默认为另一个非类型参数。一个例子
template<class T, int ROWS = 8, int COLUMNS = ROWS>
class Matrix
{
T TheMatrix[ROWS][COLUMNS];
};
但是,依赖的模板参数必须位于它所依赖的参数的右侧。以下将导致错误
template<class Type1=Type2, class Type2 = int>
class Pair{};
template<class T, int ROWS = COLUMNS, int COLUMNS = 8>
class Matrix
类的方法作为函数模板
虽然这不适合绝对初学者,但既然我涵盖了函数模板和类模板——这个概念的阐述对于本系列的第一部分来说是合乎逻辑的。
考虑一个简单的例子
class IntArray
{
int TheArray[10];
public:
template<typename T>
void Copy(T target_array[10])
{
for(int nIndex = 0; nIndex<10; ++nIndex)
{
target_array[nIndex] = TheArray[nIndex];
// Better approach:
//target_array[nIndex] = static_cast<T>(TheArray[nIndex]);
}
}
};
类 IntArray
是一个简单的非模板类,有一个包含 10 个元素的整数数组。但是 Copy
方法被设计为一个函数模板(方法模板?)。它接受一个模板类型参数,该参数将由编译器自动推导。以下是使用方法
IntArray int_array;
float float_array[10];
int_array.Copy(float_array);
正如你所猜到的,IntArray::Copy
将用类型 float
进行实例化,因为我们正在向它传递一个 float 数组。为了避免混淆和更好地理解,只需将 int_array.Copy
视为 Copy
,而将 IntArray::Copy<float>(..)
视为 Copy<float>(..)
。类的方法模板不过是一个嵌入在类中的普通函数模板。
请注意,我在所有地方使用了 10
作为数组大小。有趣的是,我们也可以修改类
template<int ARRAY_SIZE>
class IntArray
{
int TheArray[ARRAY_SIZE];
public:
template<typename T>
void Copy(T target_array[ARRAY_SIZE])
{
for(int nIndex = 0; nIndex<ARRAY_SIZE; ++nIndex)
{
target_array[nIndex] = static_cast<T>(TheArray[nIndex]);
}
}
};
这使得类 IntArray
和方法 Copy
成为模板编程领域的更好候选者!
正如你可能已经聪明地猜到的,Copy
方法不过是一个数组转换例程,它将 int
转换为任何类型,只要从 int
到给定类型的转换是可能的。这是类方法可以作为函数模板编写的有效情况之一,它们接受其自身的模板参数。请修改这个类模板,使其能够处理任何类型的数组,而不仅仅是 int
。
当然,方法模板的“显式模板实参指定”也是可能的。考虑另一个例子
template<class T>
class Convert
{
T data;
public:
Convert(const T& tData = T()) : data(tData)
{ }
template<class C>
bool IsEqualTo( const C& other ) const
{
return data == other;
}
};
这可以这样使用
Convert<int> Data;
float Data2 = 1 ;
bool b = Data.IsEqualTo(Data2);
它使用 float
参数实例化 Convert::IsEqualTo
。如下所示的显式规范将使用 double
实例化它
bool b = Data.IsEqualTo<double>(Data2);
令人惊叹的事情之一是,借助模板,你可以通过在模板之上定义转换运算符来做到这一点!
template<class T>
operator T() const
{
return data;
}
这将使得在可能的情况下,将 Convert
的类模板实例转换为任何类型。考虑以下用法示例
Convert<int> IntData(40);
float FloatData;
double DoubleData;
FloatData = IntData;
DoubleData = IntData;
这将实例化以下两个方法(完全限定名称)
Convert<int>::operator<float> float();
Convert<int>::operator<double> double();
一方面,它提供了良好的灵活性,因为无需编写额外的代码,Convert
就可以将其自身(特定实例化)转换为任何数据类型——只要在编译级别可能进行转换。如果转换不可能,例如从 double
到字符串类型,它将引发错误。
但另一方面,它也可能因无意中插入错误而带来麻烦。你可能不希望调用转换运算符,但它(编译器生成的代码)会在你不知情的情况下被调用。
结尾
你刚刚看到了模板提供的强大功能和灵活性的一个小小 glimpse。下一部分将涵盖更多高级和有趣的。我谦虚而热切地请求所有读者更多地玩模板。尝试首先在一个方面(例如仅函数模板)获得牢固的理解,而不是匆忙地跳到其他概念。最初在你的测试项目/代码库中进行,而不是在任何现有/工作/生产代码中。
以下是我们所涵盖内容的摘要
- 为了避免不必要的代码重复和代码维护问题,特别是当代码完全相同时,我们可以使用模板。模板是比使用 C/C++ 宏或基于 void 指针运行的函数/类更好的方法。
- 模板不仅类型安全,而且减少了不应被引用(编译器未生成)的不必要代码膨胀。
- 函数模板用于放置不属于类的代码,并且对于不同的数据类型是相同/几乎相同的。在大多数地方,编译器会自动确定类型。否则,你必须指定类型,或者你也可以自己指定显式类型。
- 类模板使得将任何数据类型包装到特定实现成为可能。它可以是数组、字符串、队列、链表、线程安全的原子实现等。类模板确实促进了默认模板类型规范,而函数模板不支持这一点。
希望你喜欢这篇文章,并且消除了模板是复杂、不必要、奇怪的心理障碍。第二部分将很快到来!
历史
- 首次发布:2011 年 10 月 8 日 - 涵盖了 C++ 模板基础,函数模板。
- 首次修订:2011 年 10 月 9 日 - 类模板和类模板中的多类型,非模板类型
- 第三次修订:2011 年 10 月 11 日 - 模板类作为类模板的实参
- 第四次修订:2011 年 10 月 12 日 - 类模板中的默认模板实参
- 第五次修订:2011 年 10 月 13 日 - 方法作为函数模板,结束语。
- 第六次修订:2012 年 3 月 8 日 - 基本修正。