C++模板傻瓜指南 - 第二部分






4.83/5 (48投票s)
让我们更深入地研究C++模板!
提升...
在本系列的第一部分中,我详细阐述了 C++ 模板的以下方面。
- C++ 模板的语法
- 函数模板和类模板
- 接受一个或多个参数的模板
- 接受非类型整数参数和默认参数的模板
- 两阶段编译过程
- 可以容纳任何数据类型(s)的通用
Pair
和Array
类。
在本部分中,我将尝试传达更多关于模板的有趣概念,它的重要性以及与 C++ 语言其他特性的结合,还会涉及 STL。不,你完全不需要了解 STL,我也不会深入探讨 STL。在我开始阅读这一部分之前,我恳请你重新温习一下你对模板的理解!
基础类型的要求
有类模板和函数模板,它们作用于给定的类型(模板参数类型)。例如,一个函数模板会为类型 T
求两个值(或整个数组)的和 - 但这个函数模板要求为给定类型(类型 T
)提供并可访问 operator+
。类似地,一个类要求目标类型具有构造函数、赋值运算符和其他所需运算符的集合。
要求:函数模板
让我用一种更简单优雅的方式开始解释这个主题。下面的函数将在控制台(使用 std::cout
)显示给定类型的值。
template<typename T> void DisplayValue(T tValue) { std::cout << tValue; }
以下一组调用将成功
DisplayValue(20); // <int> DisplayValue("This is text"); // <const char*> DisplayValue(20.4 * 3.14); // <double>
由于 ostream
(cout
的类型)为所有基本类型重载了 operator <<
;因此它适用于 int
、char*
和 double
类型。这里会隐式调用其中一个 operator<<
的重载。
现在,让我们定义一个包含两个成员的新结构体。
struct Currency { int Dollar; int Cents; };
并尝试将此类型用于 DisplayValue
函数模板。
Currency c; c.Dollar = 10; c.Cents = 54; DisplayValue(c);
对于此调用,您的编译器将产生大量错误,因为当为 Currency
类型实例化 DisplayValue
时,以下行无法编译。
std::cout << tValue; // tValue is now of Currency type
Visual C++ 将开始报告错误,开头是:
GCC 编译器将开始报告:
16: instantiated from here
2: error: no match for 'operator<<' in 'std::cout << tValue'
错误各不相同,但它们的意思相同:ostream::operator <<
的重载中没有一个可以用于 Currency
。两个编译器都报告了这个简单的错误,至少有100行!错误并非完全是 ostream
、Currency
或模板的原因,而是所有这些的组合。此时,您有以下几种选择。
- 不要为
Currency
类型调用DisplayValue
,而是编写另一个函数DisplayCurrencyValue
。这通常是大多数程序员在发现无法用原始DisplayValue
解决Currency
类型问题后会做的事情。这样做违背了 C++ 模板的整体目的和强大之处。不要这样做! - 修改
ostream
类,并添加一个接受Currency
类型的新成员(即operator<<
)。但您没有这种自由,因为ostream
在 C++ 标准头文件中。不过,通过一个全局函数,它将接受ostream
和Currency
类型,您可以做到这一点。 - 修改您自己的类
Currency
,以便cout<<tValue
能够成功。
简而言之,您需要促成以下之一:
ostream::operator<<(Currency value);
(简化语法)ostream::operator<<(std::string value);
ostream::operator<<(double value);
第一种非常可行,但语法有点复杂。自定义函数的定义将接受 ostream
和 Currency
类型,这样 cout<<currency_object;
就可以工作。
第二种需要理解 std::string
,而且会更慢、更复杂。
第三种符合当前的要求,并且是其他两种中最简单的。这意味着 Currency
在被要求时会被转换为 double
,然后转换后的数据将被传递给 cout
调用。
这是解决方案:
struct Currency { int Dollar; int Cents; operator double() { return Dollar + (double)Cents/100; } };
请注意,整个结果将以 double
值呈现。因此,对于 Currency{12, 72}
对象,此重载运算符(函数 operator double()
)将返回 12.72.
现在编译器将很高兴,因为存在一个从 Currency
到 ostream::operator<<
所接受的一种类型的可能转换。
因此,对于 Currency
类型,以下调用:
std::cout << tValue;
将被展开为:
std::cout << tValue.operator double(); // std::cout << (double)tValue;
因此,以下调用将起作用:
Currency c; DisplayValue(c);
您可以看到,一个简单的函数调用在编译器的帮助下调用了多个操作。
- 为
Currency
类型实例化DisplayValue
。 - 调用
Currency
类的复制构造函数,因为类型T
是按值传递的。 - 在尝试查找
cout<<operator
调用的最佳匹配时,将调用转换运算符Currency::
operator double
。
只需为 Currency
类添加您自己的复制构造函数代码,并进行步进调试,看看一个简单的调用是如何触发多个操作的!
好了!现在,让我们回顾一下本系列上一篇文章中的 PrintTwice
函数模板。
template<typename TYPE> void PrintTwice(TYPE data) { cout<<"Twice: " << data * 2 << endl; }
加粗的部分很重要。当您这样调用它时:
PrintTwice(c); // c is of Currency type
PrintTwice
中的表达式 data * 2
将起作用,因为 Currency
类型提供了此可能性。cout
语句将由编译器渲染为:
cout<<"Twice: " << data.operator double() * 2 << endl;
当您从 Currency
类中删除重载的 operator double()
时,编译器会抱怨它没有 operator*
,或者没有任何可以评估此表达式的可能性。如果您更改 Currency
类(结构体)为:
struct Currency { int Dollar; int Cents; /*REMOVED : operator double();*/ double operator*(int nMultiplier) { return (Dollar+(double)Cents/100) * nMultiplier; } };
编译器将很高兴。但这将导致 DisplayValue
对 Currency
实例化失败。原因很简单:cout<<tValue
那时将无效。
如果您同时提供双精度转换和乘法运算符呢?PrintTwice
的调用会因为 Currency
类型而失败吗?可能的答案是肯定的,编译会失败,因为编译器将在以下调用处产生歧义。
cout<<"Twice: " << data * 2 << endl; // Ambiguity: What to call - conversion operator or operator* ?
但不,编译不会失败,它将调用 operator*
。如果编译器找不到最佳候选者,则可能会调用任何其他可能的转换例程。这里解释的大部分内容来自 C++ 规则手册,并非明确属于模板的范畴。我为了更好地理解而进行了解释。
通常建议类不应过度暴露转换运算符,而应为它们提供相关的函数。例如,string::c_str()
返回 C 风格的字符串指针;但其他实现(如 CString
)会通过提供转换运算符隐式地提供必要的转换。但对于大多数情况,建议类不应暴露不必要的运算符,只是为了使其与任何代码都能工作。它应该只提供合理的隐式转换。
同样适用于模板和底层类型,底层类型可以提供转换运算符(例如转换为 int
或 string
),但不应提供过多的转换。因此,对于 Currency
,只有双精度(和/或字符串)转换就足够了——没有必要提供(二进制)operator*
重载。
继续。 以下函数模板:
template<typename T> T Add(T n1, T n2) { return n1 + n2; }
将要求 T
具有接受同类型参数并返回同类型的 operator+
。在 Currency
类中,您会这样实现:
Currency operator+(Currency); // No consts, for simplification
有趣的是,如果您不在 Currency
中实现 operator+
,而保留类中的 double
转换运算符;并调用 Add
函数:
Currency c1, c2, c3; c3 = Add(c1,c2);
您会得到一个有点奇怪的错误:
原因有二:
n1 + n2
导致对传递的两个对象调用operator double()
(隐式转换)。因此,n1+n2
成为简单的double+double
表达式,最终结果为double
。- 由于最终值为
double
类型,并且要从函数Add
返回;编译器将尝试将此double
转换为Currency
。由于没有接受double
的构造函数,因此出现错误。
在这种情况下,Currency
可以提供一个接受 double
的构造函数(一个转换构造函数)。这很有意义,因为 Currency
现在同时提供 TO 和 FROM double
的转换。仅为了 Add
函数模板而提供 operator+
并没有太大意义。
但是,我一点也不反对提供许多或所有必需的隐式转换或重载运算符。实际上,如果模板类/函数设计成这样,您应该提供底层类型的所有必需内容。例如,一个进行复杂数字计算的类模板应该提供底层类型的所有数学运算符。
我看到你们中的大多数人只是在阅读这篇文章,而没有尝试做任何事情。所以,这里有一个练习给您。找出类型要求,并为以下函数模板(直接来自第一部分)在 Currency
类中满足这些要求。(译者注:此处原文链接丢失)
template<typename 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; }
嘿,别懒!快点!满足 Currency
类型对 GetAverage
的要求。模板不是理论性的,而是非常实用的!不要继续往下读,直到您理解到这里为止的所有文字。
要求:类模板
正如您应该知道的,类模板将比函数模板更普遍。STL、Boost 和其他标准库中的类模板比函数模板多。相信我,您编写的函数模板数量会比您将要制作的类模板少。
类模板将对底层类型提出更多要求。尽管如此,这主要取决于您从类模板本身那里需要什么。例如,如果您不调用类模板实例化的 min
/max
等效方法,那么底层类型不必提供相关的关系运算符。
大多数类模板将要求底层类型提供以下内容:
- 默认构造函数
- 复制构造函数
- 赋值运算符
根据类模板本身(即类模板的目的),它可能还要求:
- 析构函数
- 移动构造函数和移动赋值运算符
(以及右值引用)
出于简单起见,我至少在本篇文章中不涵盖第二组基本要求。让我先从第一组对底层类型的基本要求理解开始,然后阐述更多的类型要求。
请注意,当底层类型提供所有必需的方法时,这些方法必须是可访问的(即公共的),才能被类模板使用。例如,Currency
类中受保护的赋值运算符将无法帮助某个要求它的集合类。将一个集合分配给另一个(都是 Currency
类型)的集合将因 Currency
的受保护性质而无法编译。
让我们回顾一下我在第一部分中用于此小节的类集。下面是一个 Item
类模板,用于容纳任何类型。
template<typename T> class Item { T Data; public: Item() : Data( T() ) {} void SetData(T nValue) { Data = nValue; } T GetData() const { return Data; } void PrintData() { cout << Data; } };
只要您为特定类型实例化 Item
,强制要求就是在类型 T
中具有默认构造函数。因此,当您执行:
Item<int> IntItem;
类型 int
必须具有默认构造函数(即 int::int()
),因为此类模板构造函数将调用底层类型的默认构造函数。
Item() : Data( T() ) {}
我们都知道 int
类型具有其默认构造函数,该构造函数将变量初始化为零。当您为另一种类型实例化它,而该类型不具有默认构造函数时,编译器将感到不满。为了理解这一点,让我们创建一个新类:
class Point { int X, Y; public: // No default constructor Point(int x, int y); };
如您所知,以下调用将无法编译(注释掉的代码可以编译)。
Point pt; // ERROR: No default constructor available // Point pt(12,40);
同样,以下实例化也将失败:
Item<Point> PointItem;
当您直接使用 Point
而不使用非默认构造函数时,编译器会报告它,您将能够纠正它——因为源(行)将正确报告。
但是,当您将其用于 Item
类模板时,编译器会在 Item
实现附近报告错误。对于上面提到的 PointItem
示例,Visual C++ 在以下行报告:
Item () : Data( T() ) {}
并报告错误为:
: while compiling class template member function 'Item<T>::Item(void)'
用
[
T=Point
]
: see reference to class template instantiation 'Item<T>' being compiled
用
[
T=Point
]
这是 C++ 模板错误报告中非常小的错误消息。通常,第一个错误会说明整个问题,而最后一个错误引用(“请参阅类模板实例化...”)显示错误的实际原因。根据您的编译器,错误可能很容易理解,也可能很难找到实际原因。发现和修复错误/错误的快速性和能力将取决于您在模板编程方面的经验。
因此,要将 Point
类用作 Item
的模板类型参数,您必须在其本身中提供默认构造函数。是的,一个接受零个、一个或两个参数的构造函数也可以工作。
Point(int x = 0, int y = 0 );
但是,构造函数必须是公共的(或通过其他方式可访问)。
好了。 让我们看看 SetData
和 GetData
方法是否适用于 Point
类型。是的,它们都可以工作,因为 Point
具有(编译器提供的)复制构造函数和赋值运算符。
如果您在 Point
类(Item
的底层类型)中实现了赋值运算符,那么该特定实现将被 Item
调用(是的,编译器将生成相关代码)。原因很简单:Item::SetData
正在分配值。
void SetData(T nValue) // Point nValue { Data = nValue; // Calling assignement operator. }
如果您将 Point
类的赋值运算符的实现放在私有/受保护区域。
class Point { ... private: void operator=(const Point&); };
并调用 Item::SetData
。
Point pt1(12,40), pt2(120,400); Item<Point> PointItem; PointItem.SetData(pt1);
这将导致编译器错误,开头可能类似:
编译器会指向错误的位置(在 SetData
内部),以及错误的实际来源(从 main
调用)。实际的错误消息和消息序列将取决于编译器,但大多数现代编译器会尝试提供详细的错误,以便您找到错误的实际来源。
这个例子表明,底层类型 T
的特殊方法会被调用,具体取决于如何/什么调用被执行。请记住,特殊成员(构造函数、赋值运算符等)可能会被隐式调用——在从函数返回时、将值传递给函数时、使用表达式时等等。因此,建议底层类型(类型 T
)在类的可访问区域实现这些特殊方法。
这个例子也演示了不同的类和函数是如何在一个函数调用中涉及的。在这种情况下,只涉及 Point
类、Item
类模板和 main
函数。
类似地,当您调用 Item<Point>::GetItem
方法时,将调用(Point
类的)复制构造函数。原因很简单——GetItem
返回存储数据的副本。调用 GetItem
可能不总是调用复制构造函数,因为 RVO/NRVO、移动语义等可能起作用。但您应该始终使底层类型的复制构造函数可访问。
对于 Point
类型,Item<>::
PrintData
方法将不能成功,但对于 Currency
类型可以成功,因为 Item
的底层类型是 Currency
。Point
类没有到 cout<<
调用的转换或任何可能的调用。请帮个忙——让 Point
类可以 cout
!
声明与实现的 E6 离
到目前为止,我一直在同一个源文件中展示模板代码的整个实现。将源文件视为一个头文件,或与包含 main
函数的同一个文件中的实现。任何 C/C++ 程序员都知道,我们将声明(或说,接口)放在头文件中,并将相应的实现放在一个或多个源文件中。头文件将被两个文件包含——相应的实现文件,以及该接口的一个或多个客户端。
在编译单元级别,将编译给定的实现文件,并为其生成一个目标文件。在链接时(即生成最终的可执行文件/DLL/SO 时),链接器将收集所有生成的目标文件并生成最终的二进制映像。一切都好,一切都顺利,除非链接器遇到缺失符号或重复定义的符号。
来到模板,情况并非完全如此。为了更好地理解,让我先给您一些代码。
Sample.H
template<typename T> void DisplayValue(T tValue);
Sample.CPP
template<typename T> void DisplayValue(T tValue) { std::cout << tValue; }
Main.cpp
#include "Sample.H" int main() { DisplayValue(20); DisplayValue(3.14); }
根据您使用的编译器和 IDE,您将把两个 CPP 文件用于构建。令人惊讶的是,您会遇到链接器错误,例如:
unresolved external symbol "void __cdecl DisplayValue<int>(int)" (??$DisplayValue@H@@YAXH@Z)
(.text+0xfc): undefined reference to `void DisplayValue<double>(double)'
仔细查看错误,您会发现链接器找不到以下例程的实现:
void DisplayValue<int> (int); void DisplayValue<double> (double);
尽管您已经通过源文件 **Sample.CPP** 提供了模板函数 DisplayValue
的实现。
好吧,这是秘密。您知道模板函数仅在您使用特定数据类型进行调用时才会被实例化。编译器单独编译 Sample.CPP
文件,其中包含 DisplayValue
的定义。Sample.CPP
的编译是在单独的**翻译单元**中完成的。Main.cpp 的编译是在另一个翻译单元中完成的。这些翻译单元会生成两个目标文件(例如 Sample.obj 和 Main.obj)。
当编译器处理 Sample.CPP
时,它找不到对 DisplayValue
的任何引用/调用,并且它不会为任何类型实例化 DisplayValue
。原因很简单,正如之前解释的——*按需编译*。由于 Sample.CPP
的翻译单元不要求任何实例化(对于任何数据类型),因此它不会为函数模板执行第二阶段编译。不会为 DisplayValue<>
生成目标代码。
在另一个翻译单元中,Main.CPP 被编译并生成目标代码。在编译此单元时,编译器看到了 DisplayValue<>
的有效接口声明,并毫无问题地执行其工作。由于我们用两种不同的类型调用了 DisplayValue
,编译器会智能地生成以下声明:
void DisplayValue<int>(int tValue); void DisplayValue<double>(double tValue);
并且根据其正常行为,编译器*假定*这些符号在其他翻译单元(即目标代码)中定义,并将进一步的责任委托给链接器。这样,就会生成 Sample.obj 和 Main.obj 文件,但它们都不包含 DisplayValue
的实现——因此链接器会产生一系列错误。
对此的解决方案是什么?
最简单的解决方案,适用于所有现代编译器,是使用**包含模型**。另一种模型,大多数主要编译器供应商不支持,是**分离模型**。
到目前为止,每当我用同一个文件中的代码解释模板内容时,我都使用了包含模型。简单来说,您将所有模板相关代码放在一个文件中(通常是头文件)。客户端只需包含给定的头文件,所有代码将在一个翻译单元中编译。但是,它仍然遵循按需编译过程。
对于上面给出的示例,Sample.H 将包含 DisplayValue
的定义(实现)。
Sample.H
// BOTH: Interface and Implementation template<typename T> void DisplayValue(T tValue) { std::cout << tValue; }
Main.CPP 只需包含此头文件。编译器将很高兴,链接器也将很高兴。如果愿意,您也可以将所有声明放在前面,然后将所有函数的定义放在同一文件的后面。例如:
template<typename T> void DisplayValue(T tValue); template<typename T> void DisplayValue(T tValue) { std::cout << tValue; }
它具有以下优点:
- 所有声明和所有实现的逻辑分组。
- 如果模板函数
A
需要使用B
,而B
也需要使用A
,则不会出现编译器错误。您已经声明了另一个函数的原型。 - 类方法的非内联。到目前为止,我已在类的声明体中详细介绍了整个模板类。方法的实现分离将在后面讨论。
由于您可以逻辑上划分接口和实现,因此您也可以形象地将它们划分为 Dot-H 和 Dot-CPP 文件。
template<typename T> void DisplayValue(T tValue); #include "Sample.CPP"
Sample.H 提供 DisplayValue
的原型,并在文件末尾包含 Sample.CPP。别担心,这是完全有效的 C++,并且可以使用您的编译器。请注意,您的项目/构建现在不得将 Sample.CPP 添加到编译过程中。
客户端(Main.CPP)将包含头文件,该头文件将 Sample.CPP 的代码添加到自身中。在这种情况下,仅**一个**翻译单元(针对 Main.cpp)即可完成工作。
分离类实现
该部分需要读者更多的关注。在类外部实现方法需要完整的类型规范。例如,让我们在类定义外部实现 Item::SetData
。
template<typename T> class Item { ... void SetData(T data); }; template<typename T> void Item<T>::SetData(T data) { Data = data; }
注意在提及要定义方法的类时使用的表达式 Item<T>
。使用 Item::SetData
实现 SetData
方法将不起作用,因为 Item
不是一个简单的类,而是类模板。符号 Item
不是一个类型,而是 Item<>
的某个实例化是类型,因此表达式为 Item<T>
。
例如,当您使用类型 short
实例化 Item
并为其使用 SetData
方法时。
Item<short> si; si.SetData(20);
编译器将生成如下源代码:
void Item<short>::SetData(short data) { Data = data; }
这里,形成的类名是 Item<short>
,并且 SetData
是为该类类型定义的。
让我们在类体外部实现其他方法:
template<typename T> T Item<T>::GetData() const { return Data; } template<typename T> void Item<T>::PrintData() { cout << Data; }
清楚地注意到,所有情况下都需要 template<typename T>
,并且也需要 Item<T>
。在实现 GetData
时,返回类型是 T
本身(应该如此)。在 PrintData
实现中,尽管 T
未被使用,但仍然需要 Item<T>
规范。
最后,这是在类外部实现的构造函数:
template<typename T> Item<T>::Item() /*: Data( T() ) */ { }
这里,符号 Item<T>
是类,而 Item()
是该类的方法(即构造函数)。我们不需要(或不能,取决于编译器)在此上下文中将 Item<T>::Item<T>()
用于同一目的。构造函数(和析构函数)是类的特殊成员**方法**,而不是类**类型**,因此它们不应在此上下文中用作类型。
为了简单起见,我注释掉了 Data
的默认初始化,以及类型 T
的默认构造函数调用。您应该取消注释注释掉的部分,并理解其含义。
如果模板类有一个或多个**默认**类型/非类型作为模板参数,我们只需要在声明类时指定它们。
template<typename T = int> // Default to int class Item { ... void SetData(T data); }; void Item<T>::SetData() { }
我们不能/不需要在实现阶段指定默认模板参数。
void Item<T = int>::SetData() {} // ERROR
这将完全是一个错误。规则和推理与接受默认参数的 C++ 函数非常相似。我们只在声明函数接口时指定默认参数,而不是在(单独)实现函数时。例如:
void Allocate(int nBytes = 1024); void Allocate(int nByte /* = 1024* / ) // Error, if uncommented. { }
在类外实现方法模板
为此,首先考虑一个代码片段中的简单示例:
Item<int> IntItem; Item<short> ShortItem; IntItem.SetData(4096); ShortItem = IntItem;
讨论的重点是加粗的那一行。它试图将 Item<int>
对象分配给 Item<short>
实例,这是不可能的,因为它们是不同的类型。当然,我们可以在一个对象上使用 SetData
,在另一个对象上使用 GetData
。但如果我们希望赋值能够正常工作呢?
为此,您可以实现一个自定义赋值运算符,它本身将基于模板。这将被归类为方法模板,并且已经在第一部分进行了介绍。本次讨论仅限于类外实现。总之,这是**类内**实现:
template<typename U> void operator = (U other) { Data = other.GetData(); }
其中 U
是其他类的类型(Item
的另一个实例化)。为了简单起见,我没有为 other
参数使用 const
和引用规范。当发生 ShortItem = IntItem
时,将生成以下代码:
void operator = (Item<int> other) { Data = other.GetData(); }
请注意,other.GetData()
返回 int
,而不是 short
,因为源对象 other
的类型是 Item<int>
。如果您使用不可转换的类型(如 int*
到 int
)调用此赋值运算符,则会导致编译器错误,因为这两种类型不是隐式可转换的。在模板代码中不应使用任何类型的类型转换来进行此类转换。让编译器向模板的客户端报告错误。
还有一件非常有趣的事情值得一提。如果您将上述赋值运算符编写成这样:
template<typename U> void operator = (U other) { Data = other.Data; }
它根本无法编译——编译器会抱怨 Data
是私有的!您可能会想为什么?
原因非常简单:这个类(Item<short>
)和另一个类(Item<int>
)实际上是**两个不同的类**,它们之间没有任何联系。根据标准的 C++ 规则,只有同一个类才能访问当前类的私有数据。由于 Item<int>
是另一个类,它不向 Item<short>
类授予私有访问权限,因此出现错误!这就是为什么我不得不使用 GetData
方法!
总之,这是我们如何**在**类声明**之外**实现一个方法模板。
template<typename T> template<typename U> void Item<T>::operator=( U other ) { Data = other.GetData(); }
请注意,我们需要使用 template
关键字两次进行模板规范——一次用于类模板,一次用于方法模板。以下将不起作用:
template<typename T, class U> void Item<T>::operator=( U other )
原因很简单——类模板 Item
**不**接受两个模板参数;它只接受一个。或者,如果我们从另一个角度来看——方法模板(即赋值运算符)不接受两个模板参数。类和方法是两个独立的模板实体,需要单独分类。
您还应该注意到 <
在前面,然后是 class T
><
class U>
。根据 C++ 语言的*从左到右*解析逻辑进行研究,我们看到类 Item
在前面,然后是方法。您可以将定义视为(请看制表符):
template<typename T> template<typename U> void Item<T>::operator=( U other ) { }
两个模板规范的分离绝对**不**依赖于类和方法声明中使用的原始参数名称。我的意思是,U
和 T
可以互换位置,并且仍然可以编译。您也可以使用任何您喜欢的名称——除了 T
或 U
。
template<typename U> template<typename T> void Item<U>::operator=( T other ) { }
但是,参数的**顺序**必须匹配。但是,正如您所理解的,为了可读性,建议使用相同的名称。
读懂并理解了吗?好吧,那么是时候通过编写一些模板代码来测试自己了!我只需要以下代码能工作:
const int Size = 10; Item<long> Values[Size]; for(int nItem = 0; nItem < Size; ++nItem) Values[nItem].SetData(nItem * 40); Item<float> FloatItem; FloatItem.SetAverageFrom(Values, Size);
方法模板 SetAverageFrom
将计算传递的 Item<>
数组的平均值。是的,参数(Values
)可以是任何底层类型的 Item
数组。**在类体外部实现它!** 无论您是谁——C++ 模板的超级天才,还是认为这项任务极其困难,您都必须这样做——为什么欺骗自己?
此外,如果 Values
是底层类型为 Currency
的数组,您会怎么做?
大多数模板实现将只使用包含模型,而且通常只在一个头文件中,并且所有代码都是内联的!例如,STL 使用头文件式、内联实现技术。少数库使用*包含其他东西*的技术——但它们只要求客户端包含头文件,并且它们在包含模型之上运行。
对于大多数与模板相关的代码,内联并不会造成伤害,原因如下:
一、模板代码(类、函数、整个库)通常简短而简洁,例如实现一个调用底层类型 operator <
的类模板 less
。或者一个集合类,它将数据放入集合并从中读取数据,而无需执行太多繁重的工作。大多数类只会执行小型且仅必需的任务,例如调用函数指针/仿函数,执行字符串相关操作;而不会进行密集计算、数据库或文件读取、向网络发送数据包、准备下载缓冲区以及其他密集工作。
二、内联只是程序员对编译器的*请求*,编译器会自行决定内联/不内联代码。这完全取决于代码的复杂性、(方法)调用的频率、周围的其他可能优化等等。链接器和剖面引导优化(PGO)在代码优化、内联等方面也起着重要作用。因此,将所有代码放在类定义内部不会造成任何危害。
三、并非所有代码都会被编译——只有被实例化的代码才会被编译,并且*这个*理由由于前面两点而更重要。所以,不要担心代码内联!
当一组类模板和几个*辅助*函数模板在一起时,代码就像编译器的一个算术表达式。例如,您将在 vector<int>
上使用 std::count_if
算法,传递一个调用某些比较运算符的仿函数。所有这些,当组合成一个单一的语句时,可能看起来很复杂,并且似乎是处理器密集型的。但事实并非如此!整个表达式,即使涉及不同的类模板和函数模板,对编译器来说就像一个简单的表达式——尤其是在 Release 构建中。
另一种模型,**分离模型**,基于 `export` 关键字,大多数编译器仍然不支持。GCC 和 Visual C++ 编译器都不支持这个关键字——尽管这两个编译器都说这是一个保留关键字,而不是简单地抛出不相关的错误。
一个逻辑上符合这个*建模*伞的类是**显式实例化**。我将推迟这个概念,稍后将详细阐述。一个重要的事情——显式实例化和**显式特化**是两个不同的方面!两者稍后都将讨论。
模板与 C++ 的其他方面
逐渐地,当您对模板、C++ 中模板的力量有了扎实的理解并对模板充满热情时,您就会清楚地认识到,使用模板,您可以构建自己的语言子集。您可以以您喜欢的方式对 C++ 语言进行编程,使其执行某些任务。您可以使用并*滥用*语言本身,并要求编译器为您生成**源代码**!
幸运的是,或者不幸的是,本节并非关于如何滥用语言并让编译器为您执行*劳动*工作。本节讲述了继承、多态、运算符重载、RTTI 等其他概念如何与模板结合。
类模板、友元
任何资深程序员都会知道 friend
关键字的真正重要性。任何新手或*按部就班*的凡人可能会憎恶 friend
关键字,认为它破坏了封装,而第三类人会说“*取决于*”。无论您的观点如何,但我认为,如果明智地在需要的地方使用 friend
关键字,它是有用的。各种类的自定义分配器;维护两个不同类之间关系的类;或类的内部类都是成为 friend
的良好候选。
首先,我给您一个例子,其中 friend
关键字与模板几乎不可或缺。如果您还记得 Item<>
类中的基于模板的赋值运算符,您一定也回忆起我不得不使用另一个类型的其他对象的 GetData
(Item<>
的另一个变体)。这是定义(类内):
template<typename U> void operator = (U other) { Data = other.GetData(); }
原因很简单:Item<T>
和 Item<U>
将是不同的类型,其中 T
和 U
可以是 int
和 short
,例如。一个类不能访问另一个类的私有成员。如果您为常规类实现赋值运算符,您将直接访问另一个对象的数据。您将如何访问另一个类(讽刺的是,同一个类!)的数据?
由于类模板的两个特化属于同一个类,我们能否让它们成为**友元**?我的意思是,能否让 Item<T>
和 Item<U>
成为彼此的友元,其中 T
和 U
是两种不同的数据类型(可转换)?
逻辑上,就像:
class Item_T { ... friend class Item_U; };
这样 Item_U
就可以访问类 Item_T
的 Data
(私有数据)!请记住,在现实中,Item_T
和 Item_U
不仅仅是两个类类型,而是类模板 Item
之上的任何一组两个实例化。
自友元似乎合乎逻辑,但如何实现呢?以下将完全不起作用:
template<typename t> class Item { ... friend class Item; };
由于 Item
是类模板而不是常规类,因此在此上下文中符号 Item
是无效的。GCC 报告如下:
error: 'int Item
有趣的是,最初它说它自己是隐式友元,然后它抱怨私有访问。Visual C++ 编译器更宽容,并静默编译,并使其成为友元。无论哪种方式,代码都不兼容。我们应该使用与类模板兼容的代码,并指示 Item
是类模板。由于目标类型未知,我们无法用任何特定数据类型替换 T
。
以下应该使用:
template <class U> friend class Item; // No template stuff around 'Item'
它前向声明了类,并暗示 Item
是类模板。编译器现在满意,没有警告。现在,以下代码可以毫无问题地工作:
template<typename U> void operator = (U other) { Data = other.Data; // other (i.e. Item<U> has made 'me' friend. }
除了这种自友元概念外,friend
关键字在许多其他情况下也很有用,与模板结合使用。当然,它包括常规的友元情况,例如连接一个*模型*和一个*框架*类;或者一个*管理器*类被其他*工作者*类声明为友元。但是在模板的情况下,模板化外部类的内部类可能需要将外部类声明为友元。另一种情况是模板化基类将被派生类声明为友元。
目前,我没有更多现成易懂的例子来演示 friend
关键字的用法,这些用法是类模板特有的。
类模板、运算符重载
类模板使用运算符重载的思路会比常规类更频繁。例如,一个比较器类会使用一个或多个关系运算符。一个集合类会使用索引运算符来实现通过索引或键访问元素的*获取*或*设置*操作。如果您还记得上一部分中的类模板 Array
,我使用了索引运算符:
template<typename T, int SIZE> class Array { T Elements[SIZE]; ... public: T operator[](int nIndex) { return Elements[nIndex]; } };
作为另一个例子,回顾上一部分中讨论的类模板 Pair
。所以,例如,如果我这样使用这个类模板:
int main() { Pair<int,int> IntPair1, IntPair2; IntPair1.first = 10; IntPair1.second = 20; IntPair2.first = 10; IntPair2.second = 40; if(IntPair1 > IntPair2) cout << "Pair1 is big."; }
这根本行不通,需要类模板 Pair
实现 operator >
。
// This is in-class implementation bool operator > (const Pair<Type1, Type2>& Other) const { return first > Other.first && second > Other.second; }
虽然,这在第一部分(针对 operator ==
)中已经讨论过,但我只添加了这个词以与正在阐述的概念相关。
除了这些常规的可重载运算符,如关系运算符、算术运算符等,模板还使用其他很少使用的可重载运算符:箭头运算符(- >
)和指针解引用运算符(*
)。一个基于类模板的简单智能指针实现,说明了其可用性。
template<typename Type> class smart_ptr { Type* ptr; public: smart_ptr(Type* arg_ptr = NULL) : ptr(arg_ptr) {} ~smart_ptr() { // if(ptr) // Deleting a null-pointer is safe delete ptr; } };
类模板 smart_ptr
将持有一个任何类型的指针,并在析构函数中安全地删除分配的内存。用法示例:
int main() { int* pHeapMem = new int; smart_ptr<int> intptr(pHeapMem); // *intptr = 10; }
我已将内存*释放*的责任委托给了 smart_ptr
对象(intptr
)。当 intptr
的析构函数被调用时,它将删除分配的内存。请注意,main 函数的第一行只是为了更好地说明。smart_ptr
的构造函数可以这样调用:
smart_ptr<int> intptr(new int);
注意:这个类(smart_ptr
)仅用于说明目的,其功能不等同于任何标准智能指针实现(auto_ptr
, shared_ptr
, weak_ptr
等)。
智能指针允许任何类型用于安全可靠的内存释放。您也可以使用任何 UDT。
smart_ptr<Currency> cur_ptr(new Currency);
当前块(即 - {}
)结束时,将调用 smart_ptr<>
的析构函数,并调用其上的 delete
运算符。由于类型在编译时已知(实例化是编译时!),将调用正确类型的析构函数。如果您放置了 Currency
的析构函数,那么一旦 cur_ptr
不再存在,它就会被调用。
回到正轨;您将如何实现以下功能:
smart_ptr<int> intptr(new int); *intptr = 10;
当然,您将实现指针解引用(一元)运算符:
Type& operator*() { return *ptr; }
明确理解,上述定义是非 const 实现,这就是为什么它返回所持有对象引用的原因(*ptr,而不是 ptr)。仅仅因为这个原因,允许将值 10 分配给它。
如果它被实现为 const
方法,它将不允许分配成功。它通常会返回一个非引用对象,或者所持有对象的 const 引用。
// const Type& operator*() const Type operator*() const { return *ptr; }
以下代码片段显示了其用法:
int main() { smart_ptr<int> intptr(new int); *intptr = 10; // Non-const show_ptr(intptr); } // Assume it implemented ABOVE main void show_ptr(const smart_ptr<int>& intptr) { cout << "Value is now:" << *intptr; // Const }
您可能希望返回 co
来节省程序堆栈的少量字节,而不是从 nst Type&
const
函数返回。但总的来说,类模板通常返回值类型。它使设计保持简单,避免了如果底层类型在实现中存在 const/non-const 错误而可能出现的任何错误。它还避免了即使是小型类型(如 int
或 Currency
)的不必要引用创建,这会比值类型返回更重。
有趣的是,您可以将 show_ptr
函数本身模板化,以便它可以显示 smart_ptr
对象下的任何底层类型的值。关于本身就接受另一个模板的模板函数/类的东西还有很多,但这需要一个单独的讨论领域。为了保持讨论简单明了,这里是修改后的 show_ptr
:
template<typename T> void show_ptr(const smart_ptr<T>& ptr) { cout << "Value is now:" << *ptr; // Const }
对于 Currency
对象,该函数将调用 Currency::operator double
,以便 cout
可以工作。您是否醒着,还是需要回顾一下关于 cout
和 Currency
的内容?如果感到困惑,请再次阅读那部分内容。
继续,让我们看看当您尝试这样做时会发生什么。
smart_ptr<Currency> cur_ptr(new Currency); cur_ptr->Cents = 10; show_ptr(cur_ptr);
加粗的这行,逻辑上是正确的,但会失败。原因很简单——cur_ptr
不是指针,而是一个普通变量。只有当左边的表达式是**指向结构体**(或类)的指针时,才能调用箭头运算符。但是,正如您所见,您正在使用 smart_ptr
作为 Currency
类型的指针包装器。因此,这应该在美学上起作用。本质上,这意味着您需要重载 smart_
ptr
类中的箭头运算符!
Type* operator->() { return ptr; } const Type* operator->() const { return ptr; }
由于我尊重您对 C++ 语言的舒适度,我认为没有必要解释这两种不同的重载实现。实现此运算符后,cur
赋值将起作用!_ptr->Cents
总的来说,operator ->
只返回一个**指针**(某个结构体/类的)。但并非绝对必要——operator->
也可以返回特定类类型的引用/值。它并非真正有用,概念很深奥,很少这样实现,我认为不值得讨论。
请理解,重载的 operator->
在 smart_ptr
中不会对 smart_ptr<int>
产生任何编译时错误,仅仅因为不能对 int
应用箭头运算符。原因很简单,您不会在 smart_ptr<int>
对象上调用此运算符,因此编译器不会(尝试)编译 smart_ptr<>::operator->()
!
到目前为止,您一定已经认识到运算符重载在 C++ 和模板领域的重要性。在模板领域,运算符相关的内容还有很多,它确实有助于基于模板的开发、编译器支持、早期绑定等。
类模板、继承
在讨论模板类与继承的可用性之前,我想强调涉及的继承的不同模式。不,这与多重继承、多层继承、虚拟继承或混合继承,或基类具有虚函数无关。模式仅仅围绕单重继承。
- 类模板继承常规类
- 常规类继承类模板
- 类模板继承另一个类模板
在基于类的模板设计中,除了单重继承,**多重**继承将比多层、混合或虚拟继承更频繁。让我先从单重继承开始。
您知道类模板是类的模板,它将根据它接受的类型(s)和其他参数进行实例化。实例化将产生一个模板类,或者更确切地说,是该类的**特化**。这个过程称为实例化,结果称为特化。
继承时,您将继承什么——一个类模板(Item<T>
),还是特化(Item<int>
)?
这两种不同的模型看起来相同,但完全不同。让我举个例子。
class ItemExt : public Item<int> { }
在这里,您看到普通类 ItemExt
正在继承一个*特化*(Item<int>
),并且不支持 Item
的任何其他实例化。这意味着什么?您可能会问。
首先考虑这一点:空类 ItemExt
本身可以被归类为:
typedef Item<int> ItemExt;
无论哪种方式(typedef 或继承),当您使用 ItemExt
时,您不需要(或者说,不能)指定类型。
ItemExt int_item;
int_item
只是 Item<int>
类型的一个派生类对象。这意味着您无法使用派生类 ItemExt
创建其他底层类型的对象。ItemExt
的实例将始终是 Item<int>
,即使您向派生类添加新方法/成员。新类可能提供其他功能,如打印值或与其他类型进行比较等,但类不允许模板的灵活性。我的意思是,您不能这样做:
ItemExt<bool> bool_item;
因为 ItemExt
不是一个类模板,而是一个常规类。
如果您正在寻找这种继承,您可以这样做——这完全取决于您的需求和设计观点。
另一种继承类型是*模板继承*,您将继承类模板本身并将其模板参数传递给它。例如,第一个:
template<typename T> class SmartItem : public Item<T> { };
类 SmartItem
是另一个类模板,它继承自 Item
模板。您将用某个类型实例化 SmartItem<>
,并将相同的类型传递给类模板 Item
。所有这些都将在编译时发生。如果您用 char
类型实例化 SmartItem
,那么 Item<char>
和 SmartItem<char>
将被实例化!
作为*模板继承*的另一个例子,让我们从类模板 Array
继承:
template<size_t SIZE> class IntArray : public Array<int, SIZE> { }; int main() { IntArray<20> Arr; Arr[0] = 10; }
请注意,我使用了 int
作为第一个模板参数,SIZE
作为基类 Array
的第二个模板参数。参数 SIZE
是 IntArray
的唯一参数,是基类 Array
的第二个参数。这是允许的,是一个有趣的特性,并且借助编译器可以自动生成代码。然而,IntArray
始终是 int
数组,但程序员可以指定数组的大小。
类似地,您也可以这样继承 Array
:
template<typename T> class Array64 : public Array<T, 64> { }; int main() { Array64<float> Floats; Floats[2] = 98.4f; }
虽然在上面给出的例子中,派生类本身没有做任何额外的事情,但继承是*非常*需要的。如果您认为以下基于模板的 typedef
可以达到相同效果,那就错了!
template<typename T> typedef Array<T, 64> Array64; typedef<size_t SIZE> typedef Array<int, SIZE> IntArray;
不允许基于模板的 typedef
。虽然我看不到编译器无法提供此功能的任何原因。在模板之上,typedef
的行为可能因上下文(即基于模板参数)而异。但在全局级别不允许基于模板的 typedef。
虽然,这并非特指模板继承讨论,您也可以在不使用继承的情况下实现 typedef。但即使那样,您也需要定义一个新类。
template<size_t SIZE> struct IntArrayWrapper { typedef Array<int, SIZE> IntArray; };
用法略有不同:
IntArrayWrapper<40>::IntArray Arr; Arr[0] = 10;
选择完全取决于需求、灵活性、可读性、一些编码标准和个人选择。在我看来,第二个版本非常麻烦。
但是,如果需要继承,并且您将在基本模板之上提供额外的功能,并且/或者基类和派生类之间存在**“is-a”**关系,那么您应该使用模板继承模型。
请注意,在几乎所有情况下,基于模板的类都不会有虚函数;因此,使用继承不会增加额外惩罚。继承只是一个数据类型建模,在简单情况下,派生类也将是 POD(纯旧数据)。虚函数与模板将在稍后描述。
到目前为止,我详细介绍了两种继承模式:
- 常规类继承类模板
- 类模板继承另一个类模板
我还阐述了模板继承(您将模板参数传递给基类)与实例化继承(您继承非常特定的模板实例化类型,称为特化)之间的区别。请注意,IntArray
和 Array64
都将被归类为模板继承,因为至少有一个模板参数保留了特化,并且只有在派生类型使用特定参数实例化时才会发生特化。
请注意,只有 ItemExt
是“*常规类继承类模板*”的示例。所有其他给出的例子都是“*类-模板继承类-模板*”。
现在是第三种类型。一个*类模板可以继承一个常规类*吗?
谁说不行?为什么不行?
我找不到或制作出任何基类(绝对)基础、没有模板痕迹的例子。实际上,表示非模板基类与派生类之间的“is-a”关系是不合理的,并且将是一个糟糕的设计。起初,我想举一个例子,其中基类是单向链表,而派生类,基于模板,将是某种更智能的(例如双向)链表。
糟糕的例子
class SinglyLinkedList { // Assume this class implements singly linked list // But uses void* mechanism, where sizeof data is // specified in constructor. }; template<class T> class SmartLinkedList : public SinglyLinkedList { };
现在,您可以说 SmartLinkedList<>
对象**是一个 SinglyLinkedList
**,这完全违背了模板的目的。基于模板的类不应依赖于非模板类。模板是围绕某些数据类型的算法、编程模型、数据结构的抽象。
事实上,模板完全**避免**了**OOP** 的继承特性。它通常用一个类来表示大部分抽象。我并不是说模板不会使用继承。事实上,模板的许多特性依赖于 C++ 的继承特性——但它*不会*像面向对象编程的经典意义那样使用*特性继承*。
类模板将使用规则的继承、建模的继承、设计的继承等等。一个例子是创建一个基类,它具有私有的复制构造函数和赋值运算符,而没有任何数据成员。现在,您可以继承这个*规则*类,并使所有所需的类不可复制!
在我展示一些真正有趣的模板技术之前,我将完成 C++ 的所有主要方面!
函数指针和回调
正如您所知,函数指针是 C/C++ 语言中实现*动态多态*的机制之一。并非必然,但通常与回调功能结合使用——您将某个用户定义的函数设置为回调,以后将调用该函数。实际要调用的函数是在运行时确定的,因此会发生特定可调用函数的**后期绑定**。
为了理解,让我们考虑一个简单的代码片段。
typedef void (*DisplayFuncPtr)(int); void RenderValues(int nStart, int nEnd, DisplayFuncPtr func) { for(;nStart<=nEnd; ++nStart) func(nStart); // Display using the desired display-function } void DisplayCout(int nNumber) { cout << nNumber << " "; } void DisplayPrintf(int nNumber) { printf("%d ", nNumber); } int main() { RenderValues(1,40, DisplayCout); // Address-Of is optional RenderValues(1,20, &DisplayPrintf); return 0; }
在此代码中,DisplayFuncPtr
提供了所需函数的原型,仅用于提高可读性。函数 RenderValues
将使用给定函数显示数字。我从 main 函数中用不同的回调(DisplayCout
和 DisplayPrintf
)调用了此函数。后期绑定发生在以下语句。
func(nStart);
这里 func
可以指向两个 Display 函数中的任何一个(或其他 UDF)。这种动态绑定有几个问题:
- 回调函数的原型必须完全匹配。如果您将
void DisplayCout(int)
更改为void DisplayCout(float)
,编译器将不满。
error C2664: 'RenderValues' : cannot convert parameter 3 from 'void (__cdecl *)(double)' to 'DisplayFuncPtr' - 即使
func
的返回值未被RenderValues
使用,编译器也不会允许任何返回非 void 的回调函数。 - 这让我非常困扰!**调用约定**也必须匹配。如果函数指定
cdecl
作为回调函数,那么用 stdcall (__stdcall
) 实现的函数将不允许。
由于函数指针和回调来自 C 语言本身,编译器必须强制执行这些限制。编译器就是不能允许不正确的函数,以避免调用堆栈损坏。
这是克服所有上述问题的基于模板的解决方案。
template<typename TDisplayFunc> void ShowValues(int nStart, int nEnd, TDisplayFunc func) { for(;nStart<=nEnd; ++nStart) func(nStart); // Display using the desired display-function }
您可以愉快地将任何函数提供给 ShowValues
模板化函数:
void DisplayWithFloat(float); int DisplayWithNonVoid(int); void __stdcall DisplayWithStd(int); ... ShowValues(1,20, DisplayWithFloat); ShowValues(1,40, DisplayWithNonVoid); ShowValues(1,50, DisplayWithStd);
是的,您会收到第一个函数的 float
到 int
转换警告。但返回类型和调用约定无关紧要。事实上,任何*可以*用 int
参数调用的函数都将在此情况下被允许。您可以修改第三个函数,它接受 double 并返回一个指针。
int* __stdcall DisplayWithStd(double);
原因很简单。TDisplayFunc
的实际类型在编译时确定,取决于传递的参数类型。在函数指针实现的情况下,只有**一个**实现。但在函数模板的情况下,将有 ShowValues
的不同实例化,这取决于您用它实例化的唯一函数原型。
除了上面提到的常规 C 风格函数指针/回调方法之外,以下内容也不允许作为显示函数的参数:
- **仿函数**,即函数对象——一个类可以实现具有所需签名的
operator()
。例如:
struct DisplayHelper { void operator()(int nValue) { } };
以下代码是非法的。
DisplayHelper dhFunctor; RenderValues(1,20,dhFunctor); // Cannot convert...
但是,当您将 dhFunction
(一个仿函数,又名函数对象)传递给函数模板 ShowValues
时,编译器将不会提出任何投诉。正如我之前所说,TDisplayFunc
可以是任何可以用 int
参数调用的类型。
ShowValues(1,20, dhFunctor);
- **Lambda 表达式**——局部定义的函数(C++11 特性)。Lambda 表达式也不允许作为 C 风格函数的函数指针参数。以下是错误的。
RenderValues(1,20, [](int nValue) { cout << nValue; } );
但对于 ShowValues
函数模板来说,这是完全有效的。
ShowValues(1,20, [](int nValue) { cout << nValue; });
当然,使用 lambda 需要**符合 C++11 标准的**编译器(VC10 及以上,GCC 4.5 及以上)。
**有趣的是**,函数模板可以以不同的方式编写——您不需要传递仿函数作为函数参数。相反,您可以将其作为模板参数本身传递。
template<typename TDisplayFunc> void ShowValuesNew(int nStart, int nEnd) { TDisplayFunc functor; // Create functor here for(;nStart<=nEnd; ++nStart) functor(nStart); } ... ShowValuesNew<DisplayHelper>(1,20); // 1 template, 2 function arguments
在这种情况下,我将**结构体** DisplayHelper
作为模板类型参数传递。函数本身现在只接受两个参数。仿函数的创建现在由模板函数本身完成。唯一的缺点是您现在只能传递具有 operator()
定义的**结构体或类**。您不能将普通函数传递给 ShowValuesNew
。但是,您可以使用 decltype
关键字传递 lambda 的类型。
auto DispLambda = [](int nValue) { printf("%d ", nValue); }; ShowValuesNew<decltype(DispLambda)>(1,20);
由于任何 lambda 的类型大约是 std::function
,这是一个类类型,因此允许创建对象(TDisplayFunc
functor;
)。
到目前为止,您已经意识到函数指针方法非常受限。唯一的优点是减小代码大小,并且有可能将函数放入某个库中,然后通过传递不同的回调来调用该函数。可调用的回调是真正的后期绑定。由于核心函数定义在一个地方,编译器没有太多自由度根据传递的函数(回调)来优化代码,尤其是当核心函数驻留在其他库(DLL/SO)中时。当然,如果核心函数很大,并且需要/可以接受受限的性质,您将使用函数指针方法。
另一方面,基于模板的方法提倡早期绑定。早期绑定是基于模板编程的核心和灵魂。如前所述,模板代码通常不会像大型数据处理、游戏引擎、批量图像处理、安全子系统那样密集和庞大,而是作为所有这些系统的辅助。因此,早期绑定的性质实际上有助于优化代码,因为一切都在编译器的控制之下。
模板与虚函数
虚函数和模板不兼容——它们属于不同的领域。原因很简单——一个是后期绑定,另一个是早期绑定。请牢记,模板是编译时概念,与其他托管语言(如 C#)中的泛型不同。泛型的类型是在运行时确定的,取决于它如何实例化。但在模板的情况下,类型仅在编译时确定。模板和泛型还有许多其他区别、优缺点,但我将推迟解释。
为了逻辑上理解这种分离,请考虑以下代码。
class Sample { public: template<class T> virtual void Processor() // ERROR! { } };
它要求方法模板 Sample::Processor<T>
是虚函数,这没有任何意义。示例类如何使用和继承?因此,例如,如果您创建一个新类 SampleEx
并从 Sample
继承它,并尝试实现这个虚函数。您会覆盖哪个特化?例如 Processor<int>
还是 Processor<string>
?
由于 Processor
方法可能存在无限特化可以被覆盖,具体取决于方法模板 Processor
被调用的方式(通过任何派生类的基类)——virtual
关键字失去了意义。编译器无法为这种设计创建虚函数表。此外,如果基类将给定的方法模板声明为纯虚函数,编译器就无法强制派生类实现所有这些无限的实现!
微软的 ATL 库在其继承原则之上使用了基于模板的设计——但没有使用虚函数。出于性能原因,它使用了模板,而不是虚函数——这意味着 ATL 更多地使用了静态绑定,而不是动态绑定。
如何在不使用虚函数的情况下利用具有继承的基于模板的类?同时又让基类知道并调用派生类的成员函数?
在我阐述这个特性之前,请记住,这样的类在没有派生类的情况下是不完整的。我说的不是抽象类或纯虚函数。如您所知,类模板只有在与特定类型实例化时才会编译——这个规则适用于类中的所有成员函数。同样,基类在没有其“同谋”——派生类——的情况下是不完整的。这也意味着这类类无法从库中导出,而普通类(即使是抽象的)可能可以从库中导出。
让我从正常的继承模型开始——一个具有纯虚函数的基类和一个实现它的派生类。
class WorkerCore { public: void ProcessNumbers(int nStart, int nEnd) { for (;nStart<=nEnd; ++nStart) { ProcessOne(nStart); } } virtual void ProcessOne(int nNumber) = 0; }; class ActualWorker : public WorkerCore { void ProcessOne(int nNumber) { cout << nNumber * nNumber; } }; ... WorkerCore* pWorker =new ActualWorker; pWorker->ProcessNumbers(1,200);
您知道 WorkerCore
类是抽象的,并且该类型的指针可以指向派生类。ProcessOne
是实际工作的函数。与实际函数(在 ProcessNumbers
中)的绑定取决于this
指针实际指向哪里。这非常充分地利用了语言的后期绑定特性。
对于这项琐碎的任务,您不希望有沉重的运行时开销——您会选择早期绑定。这时,精妙的特性,模板,就会派上用场!仔细理解以下代码。
template<class TDerived> class WorkerCoreT { public: void ProcessNumbers(int nStart, int nEnd) { for (;nStart<=nEnd; ++nStart) { TDerived& tDerivedObj = (TDerived&)*this; tDerivedObj.ProcessOne(nStart); } } }; class ActualWorkerT : public WorkerCoreT<ActualWorkerT> { public: void ProcessOne(int nNumber) { cout << nNumber * nNumber; } };
首先理解粗体部分
- 基类中的
TDerived
:指定派生类的实际类型。派生类在继承时必须指定它。 ProcessNumbers
中的类型转换:由于我们只有WorkerCoreT
实际上是一个TDerived
对象,我们可以安全地将this
转换为TDerived
。然后使用对象引用调用ProcessOne
方法。<ActualWorkerT>
规范:派生类本身告诉基类“我在这里”。这行很重要,否则TDerived
的类型将是错误的,类型转换也是如此。
需要知道的重要一点是 ProcessOne
不是虚函数,甚至不是基类中的常规成员。基类只是假定它存在于派生类中,并对其进行调用。如果派生类中不存在 ProcessOne
,编译器将简单地报错
-
'ProcessOne' : is not a member of 'ActualWorkerT'
尽管涉及类型转换,但没有运行时开销,没有运行时多态性,没有函数指针的麻烦等等。所述函数存在于派生类中,可以从基类访问,并且不限于 void (int)
。正如在函数指针部分所述,它可以是 int (float)
,或者任何其他可以用 int
参数调用的内容。
唯一的问题是,类型为 WorkerCoreT
的指针不能简单地指向派生类并成功调用 ProcessOne
。您可以证明这样做没有意义——要么选择早期绑定,要么选择后期绑定,但不能同时选择两者。
STL - 简介
STL 代表标准模板库,它是 C++ 标准库的一部分。从程序员的角度来看,尽管它是 C++ 库(可选)的一部分,但大多数其他特性(类、函数)都依赖于 STL 本身。正如“template”一词所暗示的,STL 主要基于 C++ 模板——有类模板和函数模板。
STL 包含一组集合类,用于表示数组、链表、树、集合、映射等。它还包含用于操作容器类的辅助函数(如查找最大值、总和或特定元素),以及其他辅助函数。迭代器是允许遍历集合类的重要类。首先,我将给出一个简单的例子。
vector<int> IntVector;
这里 vector 是一个类模板,其功能等同于数组。它接受一个(必需的)参数——类型。以上语句将 IntVector
声明为类型为 int
的 vector<>
。几点说明
vector
以及 STL 的其他元素,都属于std
命名空间。- 要使用 vector,您需要包含
vector
头文件(而不是 vector.h) vector
将其元素存储在连续内存中——这意味着任何元素都可以直接访问。是的,与数组非常相似。
进阶示例
#include <vector> int main() { std::vector<int> IntVector; IntVector.push_back(44); IntVector.push_back(60); IntVector.push_back(79); cout << "Elements in vector: " << IntVector.size(); }
关于粗体标记的内容
- 要使用
vector
类必须包含的头文件。 - 命名空间说明:
std
。 vector::push_back
方法用于向vector
添加元素。最初 vector 中没有元素,您使用push_back
插入。其他技术也存在,但push_back
是最重要的。- 要确定 vector 的当前大小(不是容量),我们使用
vector::size
方法。因此,程序将显示 3。
如果您要实现 vector,您将这样实现
template<typename Type> class Vector { Type* pElements; // Allocate dynamically, depending on demands. int ElementCount; // Number of elements in vector public: Vector() : pElements(NULL), ElementCount(0) {} size_t size() const { return ElementCount; }; void push_back(const Type& element); // Add element, allocate more if required. };
这没什么高深的,您都知道。push_back
的实现将是分配额外的内存(如果需要),并将元素设置/添加到指定位置。这就引出了一个显而易见的问题:每次新元素插入时分配多少内存?这时就出现了容量主题。
vector
还有一个不常用的方法:capacity
。vector
的容量是当前分配的内存(以元素计数),可以通过此函数检索。初始容量,或每次 push_back
分配的额外内存取决于实现(VC 或 GCC 或其他编译器供应商如何实现)。`capacity` 方法将始终返回大于等于 `size` 方法返回的值。
我请求您实现 push_back
和 capacity
方法。您可以添加任何您想添加的数据成员或方法。
vector 的一个主要优点是它可以像标准数组一样使用;除了数组的大小(即 vector 的元素计数)不是固定的。它可能会变化。您可以将其视为动态分配的数组,您会分配所需的内存(如果需要则重新分配),跟踪数组的大小,检查内存分配失败,并在结束时需要释放内存。std::vector
能够处理所有这些,但仅限于满足“此类模板的要求”的所有数据类型。
说了 vector 在功能上等同于数组,下面的代码是有效的。(是的,为此代码工作,vector 中至少需要有 3 个元素)。
IntVector[0] = 59; // Modify First element cout << IntVector[1]; // Display Second element int *pElement = &IntVector[2]; // Hold Third element cout << *pElement; // Third
这清楚地意味着 vector
重载了operator[]
,它就像
Type& operator[](size_t nIndex) { return pElements[nIndex]; } const Type& operator[](size_t nIndex) { return pElements[nIndex]; }
我没有在此显示基本验证。重要的是要注意基于 const
的两个重载。std::vector
类也有这两个重载——一个返回实际元素的引用,另一个返回const 引用。前者允许修改存储的实际元素(参见上面的“修改第一个元素”注释),后者不允许修改。
那么如何显示 vector
的所有元素呢?嗯,对于 vector<int>
,下面的代码会起作用
for (int nIndex = 0 ; nIndex < IntVector.size(); nIndex++) { cout << "Element [" << nIndex << "] is " << IntVector[nIndex] << "\n"; }
这里没有什么重要的解释,直到我阐述这种集合迭代代码的缺点。无论如何,我们可以利用函数模板特性来编写一个可以显示任何类型 vector
的函数。这里是
template<typename VUType> // Vector's Underlying type! void DisplayVector(const std::vector<VUType>& lcVector) { for (int nIndex = 0 ; nIndex < lcVector.size(); nIndex++) { cout << "Element [" << nIndex << "] is " << lcVector[nIndex] << "\n"; } }
现在,这个模板化的 vector 迭代代码可以显示任何 vector
—— vector<float>
、vector<string>
或 vector<Currency>
,只要 cout
可以显示该类型,或者底层类型可以使其可 cout。请自己理解粗体标记的内容!
以下代码仅为更好地掌握和理解而添加。
... IntVector.push_back(44); IntVector.push_back(60); IntVector.push_back(79); DisplayVector(IntVector); // DisplayVector<int>(const vector<int>&);
DisplayVector
的实现是否适用于所有类型的容器,如 set 和 map?不适用!我很快会讲到。
STL 中的另一个容器是 set
。set<>
只会存储类型为 T
的唯一元素。您需要包含 <set>
头文件才能使用它。一个例子
std::set<int> IntSet; IntSet.insert(16); IntSet.insert(32); IntSet.insert(16); IntSet.insert(64); cout << IntSet.size();
用法与 vector
非常相似,只是您需要使用 insert
方法。原因很简单且有理有据:新元素可能放置在 set
的任何位置,而不仅仅是末尾——您不能强制一个元素插入到末尾。
此代码片段的输出将是 3,而不是 4。值 16 被插入两次,set
将忽略第二次插入请求。IntSet
中只有 16、32 和 64。
好了,本文不是关于 STL 的,而是关于模板的。我简要介绍了 set
类也是有原因的,我将解释。您可以在网上找到关于 STL 的相关文档、文章、示例代码等。使用以下关键词搜索您喜欢的内容:vector
, map
, set
, multimap
, unordred_map
, count_if
, make_pair
, tuple
, for_each
等。
让我把注意力引回到我必须阐述的主题。
如何遍历 set
的所有元素?以下代码对于 set
将不起作用。
for (int nIndex = 0; nIndex < IntSet.size(); nIndex) { cout << IntSet[nIndex]; // ERROR! }
与 vector
类不同,set
不定义 operator[]
。您不能根据索引访问任何元素——set
中不存在索引。set
中的元素顺序是升序的:从小到大。存在弱严格排序、比较器类等,但让我们在当前主题中考虑他(升序)作为默认行为。
因此,在某些时候,如果 set<int>
的元素是(40,60,80),然后您插入 70,元素序列将变为(40, 60, 70, 80)。因此,从逻辑上讲,索引不适用于 set
。
这时就出现了 STL 的另一个重要方面:迭代器。所有容器类都支持迭代器,以便可以遍历集合的元素。不同类型的迭代器由各种类表示。首先,我将向您展示一个遍历标准数组的示例代码。
int IntArray[10] = {1,4,8,9,12,12,55,8,9}; for ( int* pDummyIterator = &IntArray[0]; // BEGIN pDummyIterator <= &IntArray[9]; // Till LAST element pDummyIterator++) { cout << *pDummyIterator << " "; }
通过简单的指针算术,代码显示了数组所有元素的值。同样,迭代器可以用来遍历vector
vector<int>::iterator lcIter; for (lcIter = IntVector.begin(); lcIter != IntVector.end(); ++lcIter) { cout << (*lcIter); }
仔细理解粗体标记的内容
iterator
是一个类。具体来说是vector<int>
内部的一个typedef
。因此,类型为vector<int>::iterator
的变量只能迭代vector<int>
,而不能迭代vector<float>
或set<int>
。迭代器究竟是如何typedef
的,不应该影响您或任何 STL 程序员。begin
和end
是返回相同类型iterator
的方法。当您在vector<Currency>
实例上调用begin
或end
时,它将返回vector<Currency>::iterator
。begin
返回一个指向容器第一个元素的迭代器。可以将其视为&IntArray[0]
。- 方法
end
返回一个指向容器倒数第二个元素的迭代器。可以将其视为&IntArray[SIZE]
,其中IntArray
的大小为SIZE
。您知道,对于大小为 10 的数组,&IntArray[10]
(逻辑上)将指向&IntArray[9]
的下一个元素。 - 表达式
++lcIter
调用迭代器对象上的operator++
,它将迭代器移动到集合的下一个元素。这与++ptr
指针算术非常相似。 - 循环从指向
begin
的iterator
开始,一直到指向end
。
- 表达式
*lcIter
调用迭代器上的单目operator*
,它返回当前指向的元素的引用/const 引用。例如,在上面的示例中,它简单地返回int
。
您可能无法轻易、快速地掌握这个复杂的迭代器概念。您应该经常玩转迭代器——让编译器给您一些奇怪的错误,让您的程序崩溃或干扰您的调试器并导致断言。您遇到的错误和断言越多,学到的就越多!
完全相同的方式,您可以迭代一个 set
set<int>::iterator lcIter; for (lcIter = IntSet.begin(); lcIter != IntSet.end(); ++lcIter) { cout << (*lcIter); }
如果我让您编写迭代循环
vector<float>
set<Currency>
vector<Pair>
很快您就会意识到您只需要更改容器类型和/或底层类型,其余代码保持不变!您可能会想编写一个函数模板,它将容器和底层类型作为其模板类型参数。比如
template<typename Container, typename Type> void DisplayCollection(const Container<Type>& lcContainer) { Container<Type>::iterator lcIter; for (lcIter = lcContainer.begin(); lcIter != lcContainer.end(); ++lcIter) { cout << (*lcIter); } }
这在逻辑上似乎是正确的,但这不行。与 DisplayVector
函数类似,此函数尝试采用 lcContainer
参数,其中 Container
是集合类,其底层类型是 Type
。理解它为什么不行并不容易,但理解它为什么不行也并非那么难。
DisplayVector
的语法是
template<typename VUType> void DisplayVector(const std::vector<VUType>& lcVector)
其中传递给函数的实际类型是完整表达式:vector<VUType>&
。传递的类型不是仅仅:vector&
DisplayCollection
的语法类似于
template<typename Container, typename Type> void DisplayCollection(const Container<Type>& lcContainer)
这里传递给函数的(非模板)类型是完整的:Container<Type>&
。假设我们可以这样调用它
vector<float> FloatVector; DisplayCollection<vector, float>(FloatVector);
传递给模板的(第一个)类型仅仅是:vector
,它不是一个完整的类型。vector
的某种特化(如 vector<float>
)将使其有资格成为完整的类型。由于第一个模板(类型)参数不能归类为类型,因此我们不能那样使用它。尽管存在传递类模板本身(如vector
)的技术,并使其成为基于其他参数/方面的完整类型。无论如何,这是修改后的 DisplayCollection
原型
template<typename Container> void DisplayCollection(const Container& lcContainer);
是的,就是这么简单!但实现现在需要一些更改。所以,让我们逐步实现。
template<typename Container> void DisplayCollection(const Container& lcContainer) { cout << "Items in collection: " << lcContainer.size() << "\n"; }
所有 STL 容器都实现了 size
方法,并且它们都返回 size_t
。因此,无论传递什么容器(set
, map
, deque
等)——size
方法都能正常工作。
集合的迭代
Container::const_iterator lcIter; for (lcIter = lcContainer.begin(); lcIter != lcContainer.end(); ++lcIter) { cout << (*lcIter); }
需要学习的一些内容
由于参数(lcContainer
)是以 const
限定符传递的,因此在该函数中它被视为不可变对象。这意味着您不能向容器插入、删除或(重新)分配任何内容。如果传递的是 vector
,lcContainer.push_back
将会报错,因为对象是 const 的。此外,这意味着您不能使用可变迭代器对其进行迭代。
- 使用
iterator
类,您可以更改内容。因此,它被称为可变迭代器。 - 当您不需要更改,或者不能使用可变迭代器时,请使用
const_iterator
。当对象/容器本身是 const(不可变)时,您必须使用const_iterator
。 - 重要!
const_iterator
的对象与iterator
的常量对象不同。
这意味着:const_iterator != const iterator
——注意空格!
当我调用相同的方法:begin
和 end
时,编译器将如何返回 iterator
或 const_iterator
?
这是个有效的问题,答案很简单
class SomeContainerClass { iterator begin(); iterator end(); const_iterator begin() const; const_iterator end() const; };
当对象是 const
时,会调用方法的 const
版本——简单的 C++ 规则!
现在,还有一点需要考虑。上面的代码在所有编译器上都无法编译(特别是以下行)
Container::const_iterator lcIterVisual C++ 2008 可以正常编译,但 GCC 报告以下错误
为了理解原因,请考虑以下类
class TypeNameTest { public: static int IteratorCount; typedef long IteratorCounter; }; int main() { TypeNameTest::IteratorCounter = 10; // ERROR TypeNameTest::IteratorCount var; //ERROR }
使用 ClassName::Symbol 符号,我们可以访问 typedef
符号和静态定义的符号。对于简单的类,编译器能够区分,程序员能够解决,并且没有潜在的歧义。
但在模板函数的情况下,其中底层类型本身依赖于模板类型参数(例如,const_iterator
基于 Container
),编译器必须被告知指定的符号实际上是一个类型,而不是给定类的静态符号。这时我们使用 typename
关键字来将符号归类为类型。
因此,我们应该(必须)使用 typename 关键字
typename Container::const_iterator lcIter; // const_iterator is a type, not static symbol in Container
为什么我们不能使用 class
关键字,而 VC++ 却能正常编译?
啊!编译器供应商及其遵循一些非标准的倾向。VC++ 不需要 typename
,GCC 需要它(并会告知您!)。GCC 甚至接受 class
代替 typename
,而 VC 不接受 class
。但值得庆幸的是,两者都遵循标准并且都接受 typename
关键字!
新的 C++ 标准(C++11)带来了一些缓解,特别是在处理 STL、模板和复杂的迭代器定义时。auto
关键字。迭代循环可以修改为
for (auto lcIter = lcContainer.begin(); lcIter != lcContainer.end(); ++lcIter) { cout << (*lcIter); }
lcIter
的实际类型将在编译时自动确定。您可以阅读有关 auto
关键字的资料,您喜欢的作者的书,或者参考我的文章。
模板与库开发
如您所知,模板代码并不直接进入目标文件,它只有在用适当的模板参数实例化时才会被编译(第二个编译阶段)。由于实际的代码生成(即特化)仅通过使用适当的模板参数实例化函数/类模板来完成,因此无法通过库导出函数/类模板。
当您尝试通过库(.LIB
、.DLL
或 .SO
)导出函数模板(如 DisplayCollection
)时,编译器和链接器可能会显示它已导出给定的函数。链接器可能会发出警告或错误,提示某些符号(例如 DisplayCollection
)未导出或未找到。由于库本身中没有调用函数模板,因此没有生成实际代码,因此实际上没有导出任何内容。
当您以后在其他项目中使用了该库,您将收到一系列链接器错误,提示某些符号未找到。要回忆起这个问题,请再次阅读本节。
因此,不可能在不披露源代码并(通常通过头文件)提供源代码的情况下从库中导出模板代码。尽管如此,完全有可能仅公开模板化内容的源代码,而不是与 void 指针和 sizeof
关键字进行交互的核心库内容。由于核心内容可能不是基于模板的,因此核心库实际上可以通过从库中导出而保持私有。
外部模板,这是一项尚未成为 C++ 标准一部分的特性,尚未被主流编译器支持。
一些库可能使用显式实例化功能为特定的模板参数导出整个类。
显式实例化
如果您要公开基于模板的库,无论是通过仅头文件实现,还是通过包装器模式实现(隐藏核心,但通过模板公开功能),此功能都特别重要。使用显式实例化,您可以指示编译器(从而指示链接器)为特定的模板参数生成代码。这意味着您要求模板的特化,而无需实际在代码中实例化它。考虑一个简单的例子。
template class Pair<int, int>;
此语句仅要求编译器使用 <int,int>
参数为 Pair
类的所有方法实例化 Pair
。这意味着编译器将生成以下代码
- 数据成员——
first
和second
。 - 所有三个构造函数(如本文和上一篇文章所述)。
- 运算符
>
和==
(如所述)。
要验证这一点,您可以使用适当的工具查看生成的二进制文件(可执行文件、DLL/SO)。在 Windows 上,您可以使用 Dependency Walker 查看是否生成了代码。有一个更简单的方法可以断言编译器/链接器是否实际执行了显式实例化——让编译器在失败时中断。例如
template struct Pair<Currency, int>;
将 first
的类型设置为 Currency
。编译器将尝试生成所有方法的代码,并会在 ==
运算符处失败,提示它(Currency
)没有定义运算符
bool operator == (const Pair<Type1, Type2>& Other) const { return first == Other.first && // ERROR Currency.operator== isn't available. second == Other.second; }
它在此方法上失败,仅仅是因为它出现在任何其他失败的方法之前(在此情况下,在 operator<
之前)。
这只是一个检查编译器是否实际生成所有方法的示例。但主要问题是:您为什么要利用这个特性?
例如,您公开了一个字符串类(如 std::string
或 CString
)。而该类是基于一个模板参数——字符类型——ANSI 或 Unicode。String
类模板的一个非常简单的定义
template<typename CharType> class String { CharType m_str[1024]; public: CharType operator[](size_t nIndex) { return m_str[nIndex]; } size_t GetLength() const { size_t nIndex = 0; while(m_str[nIndex++]); return nIndex; } };和一个非常简单的用法示例
String<char> str; str.GetLength();
您知道它只会产生以下内容
String<char>::m_str
String<char>::String
- 编译器提供的默认构造函数。String<char>::GetLength
方法
如果您要将 String
放入某个库中,您可以将整个 String
类放入头文件中,然后分发头文件。这里的问题根本不是关于私有内容、封装等,而是关于不同生成的可执行文件大小的不必要增加。
将会有成千上万个二进制文件(DLL、SO、可执行文件),几乎所有这些文件都会使用 String
类。如果能将它们打包到一个库中,岂不是更好?是的,我指的是非模板的传统方法?
要做到这一点,只需为所有您打算通过库导出的类型要求显式实例化。
template class String<char>; template class String<wchar_t>;
为了程序员的方便,您可以为不同的 String 类型定义 typedef。std::string 类型实际上就是这样定义和导出的。
typedef basic_string<char, ... > string; typedef basic_string<wchar_t, ... > wstring; // Explicit Instantiation template class /*ATTR*/ basic_string<char, ...>; template class /*ATTR*/ basic_string<wchar_t, ... >;
基类是 basic_string
,这是一个类模板。出于简单起见,此处未显示一些参数,并且供应商可能对 basic_string
的其余模板参数具有不同的签名。第二组显示了这些类型的显式实例化。注释掉的部分 /*ATTR*/
——将取决于编译器供应商。它可能是一个表达式,表示这些实例化实际上会进入正在编译的库,或者它们仅充当仅头文件。在 VC++ 实现中,这两个实例化实际上在一个 DLL 中。