目录
23.1 引言和概观(Introduction and Overview)
23.2 一个简单的字符串模板(A Simple String Template)
23.2.1 模板的定义(Defining a Template)
23.2.2 模板实例化(Template Instantiation)
23.4 类模板成员(Class Template Members)
23.4.3 类成员另名(Member Type Aliases)
23.4.4 static成员另名(static Members)
23.4.6.1 模板和构造函数(Templates and Constructors)
23.4.6.2 模板和virtual(Templates and virtual)
23.5 函数模板 (Function Templates )
23.5.1 函数模板参数(Function Template Arguments)
23.5.2 函数模板参数推导(Function Template Arguments Deduction)
23.5.2.1 参考推导(Reference Deduction)
23.5.3 函数模板重载(Function Template Overloading)
23.5.3.1 歧义消除(Ambiguity Resolution)
23.5.3.2 参数替换失败(Argument Substitution Failure)
23.5.3.3 重载和派生(Overloading and Derivation)
23.5.3.4 重载和非推导参数(Overloading and Non-Deduced Parameters)
23.7 源码组织 (Source Code Organization)
模板(Template)
23.1 引言和概观(Introduction and Overview)
模板以使用类型作为参数的编程形式直接支持泛型编程(§3.4)。C++ 模板机制允许类型或值作为类、函数或类型别名定义中的参数。模板提供了一种表示各种通用概念的直接方法以及将它们组合起来的简单方法。生成的类和函数在运行时和空间效率方面可以与手写的、不太通用的代码相匹配。
模板仅依赖于其参数类型中实际使用的属性,并且不要求用作参数的类型显式相关。特别是,用于模板的参数类型不必是继承层级结构的一部分。内置类型是可以接受的,并且非常常见于模板参数。
模板提供的组合是类型安全的(任何对象都不能以与其定义不一致的方式隐式使用),但遗憾的是,模板对其参数的要求不能简单直接地在代码中说明(§24.3)。Explosion
每一个主要的标准库抽象形式都表示为一个模板(例如,string、ostream、regex、compex、list、map、unique_ptr、thread、future、tuple和function),关键操作也是如此(例如,string比较、(控制台)输出运算符 <<、compex算术运算、list插入和删除以及 sort())。这使得本书的库章节(第 IV 部分)成为依赖它们的模板和编程技术的丰富示例来源。
这里介绍模板,主要关注设计、实现和使用标准库所需的技术。标准库比大多数软件需要更高程度的通用性、灵活性和效率。因此,可用于设计和实现标准库的技术在设计各种问题的解决方案时都是有效和高效的。这些技术使实现者能够将复杂的实现隐藏在简单的界面后面,并在用户有特定需求时向用户展示复杂性。
模板及其基本使用技巧是本章及后面六章的重点。本章重点介绍最基本的模板功能和使用它们的基本编程技巧:
§23.2 简单字符串模板:通过字符串模板示例介绍定义和使用类模板的基本机制。
§23.3 类型检查:适用于模板的类型等价性和类型检查的基本规则。
§23.4 类模板成员:如何定义和使用类模板的成员。
§23.5 函数模板:如何定义和使用函数模板。如何解决函数模板和普通函数的重载问题。
§23.6 模板别名:模板别名提供了一种强大的机制,用于隐藏实现细节并清理用于模板的符号。
§23.7 源代码组织:如何将模板组织到源文件中。
第 24 章“泛型编程”介绍了泛型编程的基本技术,并探讨了概念(对模板参数的要求)的基本思想:
§24.2 算法和提升:从具体示例开发泛型算法的基本技术示例。
§24.3 Concepts(布尔谓词):介绍并讨论 concept 的基本概念(notion),即模板可以对其模板参数施加的一组要求。
§24.4 使概念具体化:介绍使用以编译时谓词表示的概念的技术。
第 25 章“特化”讨论了模板参数传递和具体化的概念:
§25.2 模板参数和参数:什么可以作为模板参数:类型、值和模板。如何指定和使用默认模板参数。
§25.3 特化:针对特定一组模板参数的模板的特殊版本(称为特化)可以由编译器从模板生成,也可以由程序员提供。
第 26 章 实例化介绍了与模板特化(实例)生成和名称绑定相关的问题:
§26.2 模板实例化:编译器何时以及如何从模板定义生成特化以及如何手动指定它们的规则。
§26.3 名称绑定:确定模板定义中使用的名称引用哪个实体的规则。
第 27 章 模板和层级结构讨论了模板支持的通用编程技术与类层级结构支持的面向对象技术之间的关系。重点是如何结合使用它们:
§27.2 参数化和层级结构:模板和类层次结构是表示相关抽象集的两种方式。我们如何在它们之间进行选择?
§27.3 类模板的层级结构:为什么简单地将模板参数添加到现有的类层次结构通常不是一个好主意?
§27.4 模板参数作为基类:介绍为类型安全和性能而组合接口和数据结构的技术。
第 28 章“元编程”集中介绍了模板作为生成函数和类的手段的使用:
§28.2 类型函数:以类型为参数或返回类型为结果的函数。
§28.3 编译时控制结构:如何表达类型函数的选择和递归,以及一些使用它们的经验法则。
§28.4 条件定义:enable_if:如何使用(几乎)任意谓词有条件地定义函数和重载模板。
§28.5 编译时列表:元组:如何构建和访问具有(几乎)任意类型元素的列表。
§28.6 可变参数模板:如何(以静态类型安全的方式)定义采用任意类型的任意数量模板参数的模板。
§28.7 SI单位示例:此示例结合使用简单的元编程技术与其他编程技术来提供一个计算库,这些计算库(在编译时)检查是否正确使用了米、千克和秒单位制。
第 29 章“矩阵设计”演示了如何结合使用各种模板功能来解决具有挑战性的设计任务:
§29.2 矩阵模板:如何定义具有灵活且类型安全的初始化、订阅和子矩阵的 N 维矩阵。
§29.3 矩阵算术运算:如何在 N 维矩阵上提供简单的算术运算。
§29.4 矩阵实现:一些有用的实现技术。
§29.5 求解线性方程:简单矩阵使用的一个例子。
模板在本书早期就已引入(§3.4.1,§3.4.2)并使用,所以我假设你对它们有一定的熟悉。
23.2 一个简单的字符串模板(A Simple String Template)
考虑一个字符串。字符串是一个包含字符并提供下标、连接和比较等操作的类,我们通常将这些操作与“字符串”的概念联系起来。我们希望为许多不同类型的字符提供这种行为。例如,有符号字符、无符号字符、中文字符、希腊字符等的字符串在各种情况下都很有用。因此,我们希望以对特定字符类型的最小依赖来表示“字符串”的概念。字符串的定义依赖于字符可以复制的事实,仅此而已(§24.3)。因此,我们可以通过从§19.3 中获取 char 字符串并将字符类型作为参数来创建更通用的字符串类型:
template<typename C>
class String {
public:
String();
explicit String(const C∗);
String(const String&);
String operator=(const String&);
// ...
C& operator[](int n) { return ptr[n]; } // 非验证元素访问
String& operator+=(C c); // c加到字符串尾
// ...
private:
static const int short_max = 15; // 为了短字符串优化
int sz;
C∗ ptr; // 指向 sz 大小的 C 指针。
};
template<typename C> 前缀指定声明的模板,并且将在声明中使用类型参数 C 。引入后,C 的使用方式与其他类型名称完全相同。C 的范围扩展到以 template<typename C> 为前缀的声明的末尾。您可能更喜欢更短且等效的形式 template<class C>。无论哪种情况,C 都是类型名称;它不必是类的名称。数学家会将 template<typename C> 识别为传统的“对于所有 C”的变体,或者更具体地说是“对于所有类型 C”或甚至“对于所有 C,使得 C 是一种类型”。如果按照这个思路思考,您会注意到 C++ 缺乏一个完全通用的机制来指定模板参数 C 的所需属性。也就是说,我们不能说“对于所有 C,使得 ...”,其中“...”是一组对 C 的要求。换句话说,C++ 没有提供直接的方式来说明模板参数 C 应该是哪种类型(§24.3)。
类模板的名称后接一个用 < > 括起来的类型,是类的名称(由模板定义),可以像其他类名一样使用。例如:
String<char> cs;
String<unsigned char> us;
String<wchar_t> ws;
struct Jchar { /* ... */ }; // Japanese character
String<Jchar> js;
除了其名称的特殊语法外,String<char> 的工作方式与使用 §19.3 中的 String 类定义完全相同。将 String 设为模板允许我们为任何类型字符的 String 的 char 提供我们已有的 String 的功能。例如,如果我们使用标准库 map 和 String 模板,§19.2.1 中的字数统计示例将变为:
int main() // 基于输入统计每一个单词出现的次数
{
map<String<char>,int> m;
for (String<char> buf; cin>>buf;)
++m[buf];
// ... 输出结果 ...
}
我们的日文类型的版本 Jchar 将是:
int main() // 基于输入统计每一个单词出现的次数
{
map<String<Jchar>,int> m;
for (String<Jchar> buf; cin>>buf;)
++m[buf];
// ... 输出结果 ...
}
标准库提供了与模板化的 String(§19.3,§36.3)类似的模板类 basic_string。在标准库中,string 是 basic_string<char>(§36.3)的同义词:
using string = std::basic_string<char>;
这使得我们可以编写如下字数统计程序:
int main() // 基于输入统计每一个单词出现的次数
{
map<string,int> m;
for (string buf; cin>>buf;)
++m[buf];
// ... 输出结果 ...
}
一般来说,类型别名(§6.5)对于缩短从模板生成的类的长名称很实用。另外,我们通常不愿意知道类型定义的细节,而别名可以让我们隐藏类型是从模板生成的这一事实。
23.2.1 模板的定义(Defining a Template)
从类模板生成的类是完全普通的类。因此,使用模板并不意味着任何超出等效“手写”类所使用的运行时机制。事实上,使用模板可以减少生成的代码,因为只有在使用该成员时才会生成类模板成员函数的代码(§26.2.1)。
除了类模板,C++ 还提供函数模板(§3.4.2,§23.5)。我将在类模板的上下文中介绍模板的大部分“机制”,并将函数模板的详细讨论推迟到 §23.5。模板是关于如何在给定合适模板参数的情况下生成某些内容的规范;用于执行该生成的语言机制(实例化(§26.2)和特化(§25.3))并不关心生成的是类还是函数。因此,除非另有说明,否则模板规则同样适用于类模板和函数模板。模板也可以定义为别名(§23.6),但不提供其他合理的构造,例如命名空间模板。
有些人会在类模板(class template)和模板类(template class)这两个术语之间做出语义上的区分。我不会这样做;那样太过微妙:请考虑这两个术语可以互换。同样,我认为函数模板(function template)可以与模板函数(template function)互换。
在设计类模板时,在将特定类(例如 String)转换为模板(例如 String<C>)之前对其进行调试通常是一个好主意。通过这样做,我们可以在具体示例的上下文中处理许多设计问题和大多数代码错误。所有程序员都熟悉这种调试,大多数人处理具体示例比处理抽象概念更好。之后,我们可以处理可能因泛化而产生的任何问题,而不会被更常见的错误所困扰。同样,在尝试理解模板时,在尝试完全理解模板的通用性之前,想象它对特定类型参数(例如 char)的行为通常很有用。这也符合这样的理念:通用组件应该作为一个或多个具体示例的泛化来开发,而不是简单地从第一原理进行设计(§24.2)。
类模板的成员的声明和定义与非模板类完全相同。模板成员不需要在模板类本身内定义。在这种情况下,必须在其他地方提供其定义,就像非模板类成员一样(§16.2.1)。模板类的成员本身就是由其模板类的参数参数化的模板。当此类成员在其类之外定义时,必须将其明确声明为模板。例如:
template<typename C>
String<C>::String() // String<C>的构造函数
:sz{0}, ptr{ch}
{
ch[0] = {}; // 合适字符类型的终止符 0
}
template<typename C>
String& String<C>::operator+=(C c)
{
// ... 在字符串尾追加字符c
return ∗this;
}
模板参数(例如 C )是参数,而不是特定类型的名称。但是,这并不影响我们使用名称编写模板代码的方式。在 String< C > 的作用域内,使用 < C > 限定对于模板本身的名称来说是多余的,因此 String< C >::String 是构造函数的名称。
正如程序中只能有一个函数定义类成员函数一样,程序中也只能有一个函数模板定义类模板成员函数。但是,特化(§25.3)使我们能够在给定特定模板参数的情况下为模板提供替代实现。对于函数,我们还可以使用重载为不同的参数类型提供不同的定义(§23.5.3)。
无法重载类模板名称,因此如果在某个范围内声明了类模板,则不能在该范围内声明其他具有相同名称的实体。例如:
template<typename T>
class String { /* ... */ };
class String { /* ... */ }; // 错 :定义两次
用作模板参数的类型必须提供模板所需的接口。例如,用作 String 参数的类型必须提供通常的复制操作(§17.5,§36.2.2)。请注意,不要求同一模板参数(parameter)的不同参数(arguments)应通过继承关联。另请参阅 §25.2.1(模板类型参数),§23.5.2(模板参数推导)和 §24.3(模板参数要求)。
23.2.2 模板实例化(Template Instantiation)
从模板加上模板参数列表生成类或函数的过程通常称为模板实例化(§26.2)(译注:创建类或函数对象)。指定了模板参数列表的模板版本称为特化(译注:实例化时指定具体参数类型,例如 int)。
一般来说,确保为每个使用的模板参数列表生成模板的特化是实现的工作(而不是程序员的工作)。例如:
String<char> cs; //注:生成对像 cs 称为模板实例化,指定具体类型char称为特化
void f()
{
String<Jchar> js;
cs = "It's the implementation's job to figure out what code needs to be generated";
}
为此,实现产生了类 String<char> 和 String<Jchar>、它们的析构函数和默认构造函数以及 String<char>::operator=(char∗) 的声明。其他成员函数未使用,也不会生成。生成的类是完全普通的类,它们遵循类的所有常规规则。同样,生成的函数是普通函数,它们遵循函数的所有常规规则。
显然,模板提供了一种从相对较短的定义生成大量代码的强大方法。因此,需要谨慎行事,以避免几乎相同的函数定义 (§25.3) 充斥内存。在另一方面,可以编写模板来实现生成代码无法实现的质量。特别是,使用模板与简单内联相结合的组合可用于消除许多直接和间接函数调用。例如,这就是在高度参数化的库中将关键数据结构的简单操作(例如 sort() 中的 < 和矩阵计算中标量的 + ) 减少为单个机器指令的方式。因此,不谨慎使用模板导致生成非常相似的大型函数会导致代码膨胀,而使用模板来启用微小函数的内联可以导致与其他方法相比代码显著缩减(和加速)。具体来说,为简单的 < 或 [] 生成的代码通常是单个机器指令,它比任何函数调用都快得多,并且比调用函数并接收其结果所需的代码要小得多。
23.3 类型检查(Type Checking)
模板实例化采用模板和一组模板参数并从中生成代码。由于实例化时有如此多的信息可用,因此将模板定义和模板参数类型的信息编织在一起提供了极大的灵活性,并可以产生无与伦比的运行时性能。遗憾的是,这种灵活性也意味着类型检查的复杂性和准确报告类型错误的困难。
类型检查是在模板实例化生成的代码上进行的(就像程序员手动扩展模板一样)。生成的代码可能包含许多模板的用户从未听说过的内容(例如模板实现的详细信息的名称),并且通常在构建过程的后期才会出现。程序员看到/写的内容与编译器类型检查的内容之间的不匹配可能是一个大问题,我们需要设计我们的程序以尽量减少其后果。
模板机制的根本弱点在于无法直接表达对模板参数的要求。例如,我们不能说:
template<Container Cont, typename Elem>
requires Equal_comparable<Cont::value_type ,Elem>() //要求类型 types和Elem
int find_index(Cont& c, Elem e); // 在 c 中查找 e 的索引
即,在 C++ 中,我们无法直接说 Cont 应该是一种可以充当容器的类型,而 Elem 类型应该是一种允许我们将值与 Cont 元素进行比较的类型。我们正在努力在未来版本的 C++ 中实现这一点(不会损失灵活性,不会损失运行时性能,也不会显著增加编译时间 [Sutton,2011]),但现在我们只能放弃。
有效处理与模板参数传递相关的问题的第一步是建立一个讨论需求的框架和词汇表。将一组模板参数要求视为谓词。例如,我们可以将“C 一定是容器”视为一个谓词(predicate)(译注:即下一个判断),它以类型 C 作为参数,如果 C 是容器(但是我们可能已经定义了“容器”)则返回 true,如果不是则返回 false。例如,Container<vector<int>>() 和 Container<list<string>>() 应该为 true,而 Container<int>() 和 Container<shared_ptr<string>>() 应该为 false。我们将这样的谓词称为(基于模板参数的)concept 。布尔谓词在 C++ 中还不是语言构造;它是一个我们可以用于推理基于模板参数的要求的概念(notion),在注释中使用,有时还可以用我们自己的代码来支持(§24.3)。
首先,将概念视为设计工具:将 Container<T>() 指定为一组注释,说明类型 T 必须具有哪些属性才能使 Container<T>() 为真。例如:
• T 必须具有下标运算符 ([])。
• T 必须具有 size() 成员函数。
• T 必须具有成员类型 value_type,即其元素的类型。
请注意,此列表不完整(例如,[] 将什么作为参数,它返回什么?)并且未能解决大多数语义问题(例如,[] 实际上做什么?)。但是,即使是部分要求也可能很有用;即使是部分要求也可能很有用;即使是非常简单的东西,我们也可以手动检查我们的用法并发现明显的错误。例如,Container<int>() 显然是错误的,因为 int 没有下标运算符。我将回到概念的设计(§24.3),考虑在代码中支持概念的技术(§24.4),并给出一组有用概念的示例(§24.3.2)。现在,请注意,C++ 不直接支持概念,但这并不意味着概念不存在:对于每个工作模板,设计者都会为其参数考虑一些概念。Dennis Ritchie有句名言:“C 是一种强类型、弱检查的语言。”你也可以对 C++ 的模板说同样的话,只是模板参数要求(布尔谓词)的检查实际上是完成了的,但它在编译过程中完成得太晚了,而且抽象程度太低,没有帮助。
23.3.1 类型等价(Type Equivalence)
给定一个模板,我们可以通过提供模板参数来生成类型。例如:
String<char> s1;
String<unsigned char> s2;
String<int> s3;
using Uchar = unsigned char;
using uchar = unsigned char;
String<Uchar> s4;
String<uchar> s5;
String<char> s6;
template<typename T, int N> // §25.2.2
class Buffer;
Buffer<String<char>,10> b1;
Buffer<char,10> b2;
Buffer<char,20−10> b3;
当对模板使用同一组模板参数时,我们总是引用相同的生成类型。但是,在这种情况下,“相同”是什么意思?别名不会引入新类型,因此 String<Uchar> 和 String<uchar> 与 String<unsigned char> 是同一类型。相反,因为 char 和 unsigned char 是不同的类型(§6.2.3),所以 String<char> 和 String<unsigned char> 是不同的类型。
编译器可以评估常量表达式(§10.4),因此 Buffer<char,20-10> 被识别为与Buffer<char,10> 相同的类型。
通过不同的模板参数从单个模板生成的类型是不同的类型。特别是,从相关参数生成的类型不会自动关联。例如,假设 Circle 是一种 Shape:
Shape∗ p {new Circle(p,100)}; //Circle* 转换为 Shape*
vector<Shape>∗ q {new vector<Circle>{}}; // 错: 无 vector<Circle>* 向 vector<Shape>* 的转换
vector<Shape> vs {vector<Circle>{}}; // 错: 无 vector<Circle> 到 vector<Shape> 的转换
vector<Shape∗> vs {vector<Circle∗>{}}; // 错: 无 vector<Circle*> 到 、、vector<Shape*> 的转换
如果允许此类转换,则会导致类型错误(§27.2.1)。如果需要在生成的类之间进行转换,程序员可以定义它们(§27.2.2)。
23.3.2 错误检测(Error Detection)
模板定义后,会与一组模板参数结合使用。定义模板时,会检查定义中的语法错误,还可能检查可以独立于特定模板参数集检测的其他错误。例如:
template<typename T>
struct Link {
Link∗ pre;
Link∗suc //语法错误: 缺失分号
T val;
};
template<typename T>
class List {
Link<T>∗ head;
public:
List() :head{7} { } // 错误: 用 int 初始化指针
List(const T& t) : head{new Link<T>{0,o,t}} { } // 错: 未定义修饰符 o
// ...
void print_all() const;
};
编译器可以在定义时或稍后使用时捕获简单的语义错误。用户通常更喜欢早期检测,但并非所有“简单”错误都易于检测。在这里,我犯了三个“错误”:
• 一个简单的语法错误:在声明末尾遗漏了一个分号。
• 一个简单的类型错误:无论模板参数是什么,指针都不能用整数 7 初始化。
• 名称查找错误:标识符 o(当然错误输入 0 了 )不能作为 Link<T> 构造函数的参数,因为作用域中没有这样的名称。
模板定义中使用的名称必须在作用域内,或者以某种合理明显的方式依赖于模板参数(§26.3)。依赖模板参数 T 的最常见和最明显的方式是明确使用名称 T、使用 T 的成员以及采用类型 T 的参数。例如:
template<typename T>
void List<T>::print_all() const
{
for (Link<T>∗ p = head; p; p=p−>suc) // p 依赖 T
cout << ∗p; //<< 依赖 T
}
与模板参数使用相关的错误只有在使用模板时才能检测到。例如:
class Rec {
string name;
string address;
};
void f(const List<int>& li, const List<Rec>& lr)
{
li.print_all();
lr.print_all();
}
li.print_all() 检查无误,但 lr.print_all() 给出类型错误,因为 Rec 没有定义 << 输出运算符。最早可以检测到的与模板参数相关的错误是在模板首次用于特定模板参数时。该点称为实例化的第一个点 (§26.3.3)。允许实现推迟到基本上所有检查之后,直到程序链接,对于某些错误,链接时间也是可以进行完整检查的最早时间点。无论何时进行检查,都会检查同一组规则。自然,用户更喜欢早期检查。
23.4 类模板成员(Class Template Members)
与类完全一样,模板类可以有多种类型的成员:
• 数据成员(变量和常量);§23.4.1
• 成员函数;§23.4.2
• 成员类型别名;§23.6
• 静态成员(函数和数据);§23.4.4
• 成员类型(例如,成员类);§23.4.5
• 成员模板(例如,成员类模板);§23.4.6.3
此外,类模板可以声明友函数,就像“普通类”一样;§23.4.7。
类模板成员的规则就是其生成的类的规则。也就是说,如果你想知道模板成员的规则是什么,只需查找普通类成员的规则(第 16 章,第 17 章和第 20 章);这将回答大多数问题。
23.4.1 数据成员(Data Members)
对于“普通类”,类模板可以具有任何类型的数据成员。非 static 数据成员可以在其定义(§17.4.4)或构造函数(§16.2.5)中初始化。例如:
template<typename T>
struct X {
int m1 = 7;
T m2;
X(const T& x) :m2{x} { }
};
X<int> xi {9};
X<string> xs {"Rapperswil"};
非 static 数据成员可以是const 的,但遗憾的是不能是 constexpr 的。
23.4.2 成员函数(Member Functions)
和“普通类”一样,类模板的非 static 成员函数可以在类内或类外定义。例如:
template<typename T>
struct X {
void mf1() { /* ... */ } // 类内定义
void mf2();
};
template<typename T>
void X<T>::mf2() { /* ... */ } // 类外定义
类似地,模板的成员函数可以是虚拟的,也可以不是。但是,虚拟成员函数不能同时是成员函数模板(§23.4.6.2)。
23.4.3 类成员另名(Member Type Aliases)
成员类型别名,无论是使用 using 还是 typedef(§6.5)引入,在类模板的设计中都发挥着重要作用。它们以一种易于从类外部访问的方式定义类的相关类型。例如,我们将容器的迭代器和元素类型指定为别名:
template<typename T>
class Vector {
public:
using value_type = T;
using iterator = Vector_iter<T>; // Vector_iter 在它处定义
// ...
};
模板参数名称 T 只能由模板本身访问,因此为了让其他代码引用元素类型,我们必须提供别名。
类型别名在泛型编程中发挥着重要作用,它允许类的设计者为不同类(和类模板)中的类型提供具有通用语义的通用名称。作为成员别名的类型名称通常称为关联类型。value_type 和iterator名称借用自标准库的容器设计(§33.1.3)。如果某个类缺少所需的成员别名,则可以使用特征(trait)进行补偿(§28.2.4)。
23.4.4 static成员另名(static Members)
未在类中定义的 static 数据或函数成员在程序中必须具有唯一定义。
例如:
template<typename T>
struct X {
static constexpr Point p {100,250}; // Point 必面为一个文字量类型(§10.4.3)
static const int m1 = 7;
static int m2 = 8; // 错: 非 const
static int m3;
static void f1() { /* ... */ }
static void f2();
};
template<typename T> int X<T>::m1 = 88; // 错: 两个初始化器
template<typename T> int X<T>::m3 = 99;
template<typename T> void X::<T>::f2() { /* ... */ }
对于非模板类,文字量类型的 const 或 conexpr 静态数据成员可以在类内初始化,无需在类外定义(§17.4.5,§iso.9.2)。
静态成员仅在使用时才需要定义(§iso.3.2,§iso.9.4.2,§16.2.12)。例如:
template<typename T>
struct X {
static int a;
static int b;
};
int∗ p = &X<int>::a;
如果程序中仅提及 X<int>,则我们会得到 X<int>::a 的“未定义”错误,但不会得到 X<int>::b 的“未定义”错误。
23.4.5 成员类型(Member Types)
对于“普通类”,我们可以将类型定义为成员。通常,这种类型可以是类或枚举。例如:
template<typename T>
struct X {
enum E1 { a, b };
enum E2; // 错 : 未知底层类型
enum class E3;
enum E4 : char;
struct C1 { /* ... */ };
struct C2;
};
template<typename T>
enum class X<T>::E3 { a, b }; // 需要
template<typename T>
enum class X<T>::E4 : char { x, y }; // 需要
template<typename T>
struct X<T>::C2 { /* ... */ }; // 需要
成员枚举的类外定义仅允许用于我们知道其底层类型的枚举(§8.4)。
与往常一样,非 class enum 的枚举器放在枚举的作用域内;也就是说,对于成员枚举,枚举器位于其类的作用域内。
23.4.6 成员模板(Member Template)
类或类模板可以具有本身就是模板的成员。这使我们能够以令人满意的控制度和灵活性来表示相关类型。例如,复数最好表示为某些标量类型的值对:
template<typename Scalar>
class complex {
Scalar re, im;
public:
complex() :re{}, im{} {} // 默认构造函数
template<typename T>
complex(T rr, T ii =0) :re{rr}, im{ii} { }
complex(const complex&) = default; // 复制构造函数
template<typename T>
complex(const complex<T>& c) : re{c.real()}, im{c.imag()} { }
// ...
};
这允许复数类型之间进行数学上有意义的转换,同时禁止不良的窄化转换(§10.5.2.6):
complex<float> cf; // 默认值
complex<double> cd {cf}; // OK: float 转换为 double
complex<float> cf2 {cd}; // 错: 无隐式的 double->float 转换
complex<float> cf3 {2.0,3.0}; // 错: 无隐式的 double->float 转换
complex<double> cd2 {2.0F,3.0F}; // OK: float 转换为 double
class Quad {
// 无到 int 的转换
};
complex<Quad> cq;
complex<int> ci {cq}; // 错: 无 Quad 到 int 的转换
给定此 complex 定义,我们可以从 complex<T2> 构造 complex<T1>,或者从一对 T2 值构造 complex<T1>,当且仅当我们可以从 T2 构造 T1 时。这似乎是合理的。
请注意,complex<double> 到 complex<float> 情况下的窄化错误直到 complex<float> 的模板构造函数实例化时才会被捕获,而且仅仅是因为我在构造函数的成员初始化器中使用了 {} 初始化语法 (§6.3.5)。该语法不允许窄化。
使用(旧)() 语法会让我们面临窄化错误。例如:
template<typename Scalar>
class complex { // 旧风格
Scalar re, im;
public:
complex() :re(0), im(0) { }
template<typename T>
complex(T rr, T ii =0) :re(rr), im(ii) { }
complex(const complex&) = default; // 复制构造函数
template<typename T>
complex(const complex<T>& c) : re(c.real()), im(c.imag()) { }
// ...
};
complex<float> cf4 {2.1,2.9}; // ouch! 窄化
complex<float> cf5 {cd}; // ouch! 窄化
我认为这是在使用 {} 符号进行初始化时保持一致的另一个原因。
23.4.6.1 模板和构造函数(Templates and Constructors)
为了尽量减少混淆的可能性,我明确添加了一个默认的复制构造函数。省略它不会改变定义的含义:complex 仍将获得默认的复制构造函数。由于技术原因,模板构造函数永远不会用于生成复制构造函数,因此如果没有明确声明的复制构造函数,则会生成默认的复制构造函数。同样,复制赋值、移动构造函数和移动赋值(§17.5.1,§17.6,§19.3.1)必须定义为非模板运算符,否则将生成默认版本。
23.4.6.2 模板和virtual(Templates and virtual)
成员模板不能是virtual的。例如:
class Shape {
// ...
template<typename T>
virtual bool intersect(const T&) const =0; // 错 : virtual 模板
};
这肯定是无效的。如果允许,则不能使用传统的用于实现 virtual 函数的虚函数表技术(§3.2.3)。每次有人使用新参数类型调用 intersect() 时,链接器都必须向 Shape 类的虚拟表添加一个新条目。以这种方式使链接器的实现复杂化被认为是不可接受的。特别是,处理动态链接需要与最常用的实现技术截然不同的实现技术。
23.4.6.3 使用嵌套(Use of Nesting)
一般来说,尽可能将信息局部化。这样,名称更容易找到,也不太可能干扰程序中的其他内容。这种思路导致将类型定义为成员。这样做通常是一个好主意。但对于类模板的成员,我们必须考虑参数化是否适合成员类型。形式上,模板的成员依赖于模板的所有参数。如果成员的行为实际上没有使用每个模板参数,这可能会产生不良的副作用。一个著名的例子是链接列表的链接类型。考虑:
template<typename T, typename Allocator>
class List {
private:
struct Link {
T val;
Link∗ succ;
Link∗ prev;
};
// ...
};
这里,Link 是 List 的一个实现细节。因此,它似乎是在 List 的作用域内定义并保持私有的最佳类型的完美示例。这是一种流行的设计,通常效果很好。但令人惊讶的是,与使用非本地 Link 类型相比,它可能意味着性能成本。假设 Link 的任何成员都不依赖于 Allocator 参数,并且我们需要 List<double ,My_allocator> 和 List<double ,Your_allocator>。现在 List<double ,My_allocator>::Link 和 List<double ,Your_allocator>::Link 是不同的类型,因此使用它们的代码(没有巧妙的优化器)不可能相同。也就是说,当 Link 仅使用 List 的两个模板参数之一时,将其作为成员意味着一些代码膨胀。这导致我们考虑一种 Link 不是成员的设计:
template<typename T, typename Allocator>
class List;
template<typename T>
class Link {
template<typename U, typename A>
friend class List;
T val;
Link∗ succ;
Link∗ prev;
};
template<typename T, typename Allocator>
class List {
// ...
};
我将 Link 的所有成员设为私有,并授予 List 访问权限。除了将名称 Link 设为非本地之外,这保留了 Link 是 List 的实现细节的设计意图。
但是,如果嵌套类不被视为实现细节怎么办?也就是说,如果我们需要一种适用于各种用户的关联类型怎么办?考虑一下:
template<typename T, typename A>
class List {
public:
class Iterator {
Link<T>∗ current_position;
public:
// ... 常规迭代器运算...
};
Iterator<T,A> begin();
Iterator<T,A> end();
// ...
};
这里,成员类型 List<T,A>::Iterator(显然)不使用第二个模板参数 A。但是,由于 Iterator 是一个成员,因此形式上依赖于 A(编译器不知道任何相反的情况),我们不能编写一个函数来处理List,而不管它们是如何使用分配器构造的:
void fct(List<int>::Iterator b, List<int>::Iterator e) // 错 : List取 2 个参数
{
auto p = find(b,e,17);
// ...
}
void user(List<int,My_allocator>& lm, List<int,Your_allocator>& ly)
{
fct(lm.begin(),lm.end());
fct(ly.begin(),ly.end());
}
相反,我们需要编写一个依赖于分配器参数的函数模板:
void fct(List<int,My_allocator>::Iterator b, List<int,My_allocator>::Iterator e)
{
auto p = find(b,e,17);
// ...
}
但是,这会破坏我们的 user():
void user(List<int,My_allocator>& lm, List<int,Your_allocator>& ly)
{
fct(lm.begin(),lm.end());
fct(ly.begin(),ly.end()); // 错 : fct 取 List<int,My_allocator>::Iterators
}
我们可以制作一个模板并为每个分配器生成单独的特化。但是,这会为每次使用 Iterator 生成一个新的特化,因此这可能会导致严重的代码膨胀 [Tsafrir,2009]。同样,我们通过将 Link 移出类模板来解决问题:
template<typename T>
struct Iterator {
Link<T>∗ current_position;
};
template<typename T, typename A>
class List {
public:
Iterator<T> begin();
Iterator<T> end();
// ...
};
这使得每个具有相同第一个模板参数的 List 的迭代器在类型方面都可以互换。在这种情况下,这正是我们想要的。我们的 user() 现在按定义工作。如果 fct() 被定义为函数模板,那么 fct() 的定义将只有一个副本(实例化)。我的经验法则是“在模板中的避免嵌套类型,除非它们真正依赖于每个模板参数。”这是一般规则的一个特例,以避免代码中不必要的依赖关系。
23.4.7 友元(Friends)
如 §23.4.6.3 所示,模板类可以将函数指定为友函数。考虑 §19.4 中的 Matrix 和 Vector 示例。通常,Matrix 和 Vector 都是模板:
template<typename T> class Matrix;
template<typename T>
class Vector {
T v[4];
public:
friend Vector operator∗<>(const Matrix<T>&, const Vector&);
// ...
};
template<typename T>
class Matrix {
Vector<T> v[4];
public:
friend Vector<T> operator∗<>(const Matrix&, const Vector<T>&);
// ...
};
友函数名称后面需要有 <>,以明确表明该友函数是模板函数。如果没有 <>,则会假定为非模板函数。然后可以定义乘法运算符来直接从 Vector 和 Matrix 访问数据:
template<typename T>
Vector<T> operator∗(const Matrix<T>& m, const Vector<T>& v)
{
Vector<T> r;
// ... 使用 m.v[i] 和 v.v[i] 直接访问元素 ...
return r;
}
友元不会影响模板类的定义作用域,也不会影响模板的使用作用域。相反,友函数和运算符是通过基于其参数类型的查找来找到的(§14.2.4,§18.2.5,§iso.11.3)。与成员函数一样,友函数只有在使用时才会实例化(§26.2.1)。
与其他类一样,类模板可以将其他类指定为友元。例如:
class C;
using C2 = C;
template<typename T>
class My_class {
friend C; // OK: C 是一个类
friend C2; // OK: C2 是类 C 的别名
friend C3; // 错:作用域内无类 C3
friend class C4; // OK: 引入一个新的类 C4
};
当然,有趣的情况是友元依赖于模板参数的情况。例如:
template<typename T>
class my_other_class {
friend T; //我的参数是我的友元!
friend My_class<T>; // 具有相应参数的 My_class 是我的友元
friend class T; // 错: 重复的“类”
};
与以往一样,友元既不可继承也不可传递(§19.4)。例如,尽管 My_class<int> 是友元,并且 C 是 My_class<int> 的友元,但 C 并未成为 My_other_class<int> 的友元。
我们不能直接将模板设为类的友元,但我们可以将友元声明设为模板。例如:
template<typename T, typename A>
class List;
template<typename T>
class Link {
template<typename U, typename A>
friend class List;
// ...
};
遗憾的是,没有办法说 Link<X> 应该只是 List<X> 的友元。
友元类的设计目的是允许表示紧密相关概念的小集群。复杂的友元关系模式几乎肯定是一个设计错误。
23.5 函数模板 (Function Templates )
对于许多人来说,模板的第一个也是最明显的用途是定义和使用容器类,例如 vector (§31.4),list (§31.4.2) 和 map (§31.4.3)。不久之后,就需要使用函数模板来操作此类容器。对 vector 进行排序就是一个简单的例子:
template<typename T> void sort(vector<T>&); // declaration
void f(vector<int>& vi, vector<string>& vs)
{
sort(vi); // sort(vector<int>&);
sort(vs); // sort(vector<string>&);
}
调用函数模板时,函数参数的类型决定使用哪个版本的模板;也就是说,模板参数是从函数参数推导出来的(§23.5.2)。
当然,函数模板必须在某处定义(§23.7):
template<typename T>
void sort(vector<T>& v) // definition
// Shell sort (Knuth, Vol. 3, pg. 84)
{
const size_t n = v.siz e();
for (int gap=n/2; 0<gap; gap/=2)
for (int i=gap; i<n; i++)
for (int j=i−gap; 0<=j; j−=gap)
if (v[j+gap]<v[j]) { // swap v[j] and v[j+gap]
T temp = v[j];
v[j] = v[j+gap];
v[j+gap] = temp;
}
}
请将此定义与 §12.5 中定义的 sort() 进行比较。此模板化版本更简洁、更短,因为它可以依赖有关其排序元素类型的更多信息。通常,它也更快,因为它不依赖于指向函数的指针来进行比较。这意味着不需要间接函数调用,并且内联简单的 < 很容易。
进一步简化是使用标准库模板 swap() (§35.5.2)将操作简化为其自然形式:
if (v[j+gap]<v[j])
swap(v[j],v[j+gap]);
这不会引入任何新的开销。更好的是,标准库 swap() 使用移动语义,因此我们可能会看到加速(§35.5.2)。
在此示例中,运算符 < 用于比较。但是,并非每个类型都有 < 运算符。这限制了此版本 sort() 的使用,但通过添加参数可以轻松避免此限制(参见 §25.2.3)。例如:
template<typename T, typename Compare = std::less<T>>
void sort(vector<T>& v) // definition
// Shell sort (Knuth, Vol. 3, pg. 84)
{
Compare cmp; // 创建一个 Compare 对象
const size_t n = v.siz e();
for (int gap=n/2; 0<gap; gap/=2)
for (int i=gap; i<n; i++)
for (int j=i−gap; 0<=j; j−=gap)
if (cmp(v[j+gap],v[j]))
swap(v[j],v[j+gap]);
}
我们现在可以使用默认比较操作(<)进行排序或者提供我们自己的比较操作:
struct No_case {
bool operator()(const string& a, const string& b) const; // 忽略大小写的比较
};
void f(vector<int>& vi, vector<string>& vs)
{
sort(vi); // sor t(vector<int>&)
sort<int,std::greater<int>>(vi); // sort(vector<int>&) 大于
sort(vs); // sort(vector<str ing>&)
sort<string,No_case>(vs); // sort(vector<str ing>&) 使用 No_case
}
遗憾的是,只能指定尾随模板参数的规则导致我们在指定比较操作时必须指定(而不是推断)元素类型。
函数模板参数的明确指定在§23.5.2 中解释。
23.5.1 函数模板参数(Function Template Arguments)
函数模板对于编写可应用于各种容器类型的通用算法至关重要(§3.4.2,§32.2)。从函数参数推导出调用的模板参数的能力至关重要。
只要函数参数列表唯一地标识模板参数集,编译器就可以从调用中推断出类型和非类型参数。例如:
template<typename T, int max>
struct Buffer {
T buf[max];
public:
// ...
};
template<typename T, int max>
T& lookup(Buffer<T,max>& b, const char∗ p);
Record& f(Buffer<string,128>& buf, const char∗ p)
{
return lookup(buf,p); // 使用 lookup(),其中 T 是 string 而 i 是 128
}
这里,lookup() 的 T 被推断为 string,max 被推断为 128。
请注意,类模板参数永远不会被推导。原因是,类的多个构造函数提供的灵活性使得这种推导在许多情况下无法进行,在更多情况下则难以理解。相反,特化(§25.3)提供了一种机制,用于在模板的替代定义之间进行隐式选择。如果我们需要创建一个推导类型的对象,我们通常可以通过调用一个函数来进行推导(和创建)。例如,考虑标准库的 make_pair() 的一个简单变体(§34.2.4.1):
template<typename T1, typename T2>
pair<T1,T2> make_pair(T1 a, T2 b)
{
return {a,b};
}
auto x = make_pair(1,2); // x 是一个 pair<int,int>
auto y = make_pair(string("New York"),7.7); // y 是一个 pair<string,double>
如果模板参数不能从函数参数中推导出来(§23.5.2),我们必须明确指定它。这与为模板类明确指定模板参数的方式相同(§25.2,§25.3)。例如:
template<typename T>
T∗ create(); //创建一个 T 并返回一个指向它的指针
void f()
{
vector<int> v; // 类, 模板参数 int
int∗ p = create<int>(); // 函数, 模板参数 int
int∗ q = create(); // 错: 不能推导模板参数
}
使用显式指定来为函数模板提供返回类型的做法非常常见。它允许我们定义对象创建函数系列(例如 create())和转换函数系列(例如 §27.2.2)。static_cast、dynamic_cast 等的语法(§11.5.2,§22.2.1)与显式限定的函数模板语法相匹配。
在某些情况下,可以使用默认模板参数来简化显式限定(§25.2.5.1)。
23.5.2 函数模板参数推导(Function Template Arguments Deduction)
编译器可以从具有以下结构组成的类型的模板函数参数推导出类型模板参数 T 或 TT,以及非类型模板参数 I(§iso.14.8.2.1):
这里,args_TI 是一个参数列表,可以通过递归应用这些规则来确定 T 或 I,而 args 是一个不允许推导的参数列表。如果不能以这种方式推导所有参数,则调用会产生歧义。例如:
template<typename T, typename U>
void f(const T∗, U(∗)(U));
int g(int);
void h(const char∗ p)
{
f(p,g); // T 是 char, U 是 int
f(p,h); // 错: 不能推导 U
}
查看 f() 第一次调用的参数,我们很容易推断出模板参数。查看 f() 的第二次调用,我们发现 h() 与模式 U(∗)(U) 不匹配,因为 h() 的参数和返回类型不同。
如果模板参数可以从多个函数参数推导而来,则每次推导的结果必须是同一类型。否则,调用将出错。例如:
template<typename T>
void f(T i, T∗ p);
void g(int i)
{
f(i,&i); //OK
f(i,"Remember!"); // 错, 歧义: T 是 int 或 T 是 const char?
}
23.5.2.1 参考推导(Reference Deduction)
对左值和右值采取不同的操作可能很实用。考虑一个用于保存 {整数,指针} 对的类:
template<typename T>
class Xref {
public:
Xref(int i, T∗ p) // 存储一个推针: Xref 是 owner
:index{i}, elem{p}, owner{true}
{ }
Xref(int i, T& r) // 存储一个指向 r 的指针, 由其它拥有
:index{i}, elem{&r}, owner{false}
{ }
Xref(int i, T&& r) // 将 r 移入 Xref, Xref 是 owner
:index{i}, elem{new T{move(r)}}, owner{true}
{ }
˜Xref()
{
if(owned) delete elem;
}
// ...
private:
int index;
T∗ elem;
bool owned;
};
因此:
string x {"There and back again"};
Xref<string> r1 {7,"Here"}; // r1 拥有字符串 "Here" 的一个副本
Xref<string> r2 {9,x}; // r2 是对 x 和引用
Xref<string> r3 {3,new string{"There"}}; // r3 拥有字符串 "There"
这里,r1 选择 Xref(int,string&&),因为 x 是右值。类似地,r2 选择Xref(int,string&),因为 x 是左值。
左值和右值通过模板参数推导来区分:类型 X 的左值被推导为 X&,而右值则被推导为 X 。这不同于将值绑定到非模板参数右值引用(§12.2.1),但对于参数转发(§35.5.1)尤其有用。考虑编写一个工厂函数,在自由存储中创建 Xref 并向它们返回 unique_ptr:
template<typename T>
T&& std::forward(typename remove_reference<T>::type& t) noexcept; // §35.5.1
template<typename T>
T&& std::forward(typename remove_reference<T>::type&& t) noexcept;
template<typename TT, typename A>
unique_ptr<TT> make_unique(int i, A&& a) // make_shared 的简单变体 (§34.3.2)
{
return unique_ptr<TT>{new TT{i,forward<A>(a)}};
}
我们希望 make_unique<T>(arg) 从 arg 构造一个 T,而不会进行任何虚假复制。为此,必须保持左值/右值区别。考虑:
auto p1 = make_unique<Xref<string>>(7,"Here");
“Here” 是一个右值,因此调用 forward(string&&),传递一个右值,从而调用 Xref(int,string&&) 从包含“Here”的字符串移动。
更有趣(微妙)的情况是:
auto p2 = make_unique<Xref<string>>(9,x);
这里,x 是左值,因此调用 forward(string&),传递一个左值:forward() 的 T 被推导为 string&,因此返回值变为 string& &&,即 string&(§7.7.3)。因此,对左值 x 调用 Xref(int,string&),因此复制了 x 。
遗憾的是,make_unique() 不是标准库的一部分,但它仍然受到广泛支持。使用可变参数模板进行转发(§28.6.3),定义可以接受任意参数的 make_unique() 相对容易。
23.5.3 函数模板重载(Function Template Overloading)
我们可以声明多个同名的函数模板,甚至可以声明同名的函数模板和普通函数的组合。当调用重载函数时,需要进行重载解析以找到要调用的正确函数或函数模板。例如:
template<typename T>
T sqrt(T);
template<typename T>
complex<T> sqrt(complex<T>);
double sqrt(double);
void f(complex<double> z)
{
sqrt(2); // sqrt<int>(int)
sqrt(2.0); // sqrt(double)
sqrt(z); // sqrt<double>(complex<double>)
}
函数模板是函数概念的泛化,同样,在存在函数模板的情况下,解析规则是函数重载解析规则的泛化。基本上,对于每个模板,我们都会找到最适合函数参数集的特化。然后,我们将通常的函数重载解析规则应用于这些特化和所有普通函数(§iso.14.8.3):
[1] 查找将参与重载解析的函数模板特化集(§23.2.2)。通过考虑每个函数模板并确定如果范围内没有其他同名函数模板或函数,将使用哪些模板参数(如果有)。对于调用 sqrt(z),这会产生 sqrt<double>(complex<double>) 和 sqrt<complex<double>>(complex<double>) 候选。另请参阅 §23.5.3.2。
[2] 如果可以调用两个函数模板,并且其中一个比另一个更专业(§25.3.3),则在以下步骤中仅考虑最专业的模板函数。对于 sqrt(z) 调用,这意味着sqrt<double>(complex<double>) 优于sqrt<complex<double>>(complex<double>):任何与 sqrt<T>(complex<T>) 匹配的调用也匹配 sqrt<T>(T)。
[3] 像对待普通函数一样,对这组函数以及任何普通函数进行重载解析 (§12.3)。如果函数模板的参数已通过模板参数推导确定 (§23.5.2),则该参数不能同时应用提升、标准转换或用户定义转换。对于 sqrt(2),sqrt<int>(int) 是精确匹配,因此它优于sqrt(double)。
[4] 如果函数和特化同样匹配,则该函数是首选。因此,对于 sqrt(2.0),sqrt(double) 优于 sqrt<double>(double)。
[5] 如果未找到匹配项,则调用会出错。如果最终得到两个或更多同样好的匹配项,则调用会产生歧义并出错。
例如:
template<typename T>
T max(T,T);
const int s = 7;
void k()
{
max(1,2); // max<int>(1,2)
max('a','b'); // max<char>(’a’,’b’)
max(2.7,4.9); // max<double>(2.7,4.9)
max(s,7); // max<int>(int{s},7) (使用平凡转换)
max('a',1); // 错误: 歧义: max<char,char>() 或 max<int,int>()?
max(2.7,4); // 错误: 歧义: max<double,double>() 或 max<int,int>()?
}
最后两个调用的问题在于,直到模板参数被唯一确定之后,我们才应用提升和标准转换。没有规则告诉编译器优先选择一种解决方案而不是另一种解决方案。在大多数情况下,语言规则将微妙的决定权留给程序员可能是件好事。令人惊讶的歧义错误的替代方案是意外解决方案带来的令人惊讶的结果。人们对重载解析的“直觉”差异很大,因此不可能设计一套完全直观的重载解析规则。
23.5.3.1 歧义消除(Ambiguity Resolution)
我们可以通过明确的限定来解决这两个歧义:
void f()
{
max<int>('a',1); // max<int>(int(’a’),1)
max<double>(2.7,4); // max<double>(2.7,double(4))
}
或者,我们可以添加适当的声明:
inline int max(int i, int j) { return max<int>(i,j); }
inline double max(int i, double d) { return max<double>(i,d); }
inline double max(double d, int i) { return max<double>(d,i); }
inline double max(double d1, double d2) { return max<double>(d1,d2); }
void g()
{
max('a',1); // max(int(’a’),1)
max(2.7,4); // max(2.7,4)
}
对于普通函数,适用普通重载规则(§12.3),并且使用内联可确保不会产生额外的开销。
max() 的定义很简单,所以我们可以直接实现比较,而不是调用 max() 的特化。但是,使用模板的显式特化是定义此类解析函数的一种简单方法,并且可以通过避免在多个函数中使用几乎相同的代码来帮助维护。
23.5.3.2 参数替换失败(Argument Substitution Failure)
在为函数模板寻找一组参数的最佳匹配时,编译器会考虑该参数是否可以按照完整函数模板声明(包括返回类型)所要求的方式使用。例如:
template<typename Iter>
typename Iter::value_type mean(Iter first, Iter last);
void f(vector<int>& v, int∗ p, int n)
{
auto x = mean(v.begin(),v.end()); // OK
auto y = mean(p,p+n); // error
}
这里,x 的初始化成功,因为参数匹配,并且 vector<int>::iterator 有一个名为 value_type 的成员。y 的初始化失败,因为即使参数匹配,int∗ 也没有一个名为 value_type 的成员,所以我们不能说:
int∗::value_type mean(int∗,int∗); //int* 没有一个称为 value_type 的成员
但是,如果 mean() 有另一种定义会怎样?
template<typename Iter>
typename Iter::value_type mean(Iter first, Iter last); // #1
template<typename T>
T mean(T∗,T∗); //#2
void f(vector<int>& v, int∗ p, int n)
{
auto x = mean(v.begin(),v.end()); // OK: call #1
auto y = mean(p,p+n); // OK: call #2
}
这有效:两个初始化都成功了。但是为什么我们在尝试将 mean(p,p+n) 与第一个模板定义匹配时没有收到错误?参数完全匹配,但通过替换实际模板参数(int∗),我们得到了函数声明:
int∗::value_type mean(int∗,int∗); // int* 没有称为 value_type 的成员
当然,这是垃圾:指针没有成员 value_type。幸运的是,考虑这种可能的声明本身并不是错误。有一条语言规则 (§iso.14.8.2) 规定这种替换失败不是错误。它只会导致模板被忽略;也就是说,模板不会为重载集贡献特化。完成后,mean(p,p+n) 匹配被调用的声明 #2。
如果没有“替换错误不代表失败”规则,即使有无错误的替代方案(例如 #2),我们也会遇到编译时错误。此外,此规则还为我们提供了一个在模板中进行选择的通用工具。基于此规则的技术在 §28.4 中进行了描述。特别是,标准库提供了 enable_if 来简化模板的条件定义(§35.4.2)。
该规则以无法发音的首字母缩略词 SFINAE(Substitution Failure Is Not An Error)而闻名。SFINAE 通常用作动词,其中“F”发音为“v”:“我 SFINAE 消除了那个构造函数。”这听起来相当令人印象深刻,但我倾向于避免使用这种行话。“构造函数因替换失败而被消除”对大多数人来说更清楚,并且对英语的破坏更小。
因此,如果在生成候选函数以解析函数调用的过程中,编译器发现自己生成的模板特化毫无意义,则该候选不会进入重载集合。如果模板特化会导致类型错误,则认为它是无意义的。在此,我们仅考虑声明;除非实际使用,否则不会考虑(或生成)模板函数定义和类成员的定义。例如:
template<typename Iter>
Iter mean(Iter first, Iter last) // #1
{
typename Iter::value_type = ∗first;
// ...
}
template<typename T>
T∗ mean(T∗,T∗); //#2
void f(vector<int>& v, int∗ p, int n)
{
auto x = mean(v.begin(),v.end()); // OK: call #1
auto y = mean(p,p+n); // 错误 : 歧义
}
对于 mean(p,p+n),mean() #1 的声明没有问题。由于类型错误,编译器不会启动实例化该 mean() 的主体并将其消除。
此处的结果是一个歧义错误。如果没有 mean() #2,则将选择声明 #1,并且我们将遭遇实例化时错误。因此,一个函数可能被选为最佳匹配,但仍然无法编译。
23.5.3.3 重载和派生(Overloading and Derivation)
重载解析规则确保函数模板与继承正确交互:
template<typename T>
class B { /* ... */ };
template<typename T>
class D : public B<T> { /* ... */ };
template<typename T> void f(B<T>∗);
void g(B<int>∗ pb, D<int>∗ pd)
{
f(pb); // f<int>(pb) of course
f(pd); // f<int>(static_cast<B<int>*>(pd));
// 使用 D<int>* 到 B<int>* 的标准转换
}
在这个例子中,函数模板 f() 接受任意类型 T 的 B<T>∗。我们有一个类型为 D<int>∗ 的参数,因此编译器很容易推断,通过选择 T 为 int,该调用可以唯一地解析为 f(B<int>∗) 的调用。
23.5.3.4 重载和非推导参数(Overloading and Non-Deduced Parameters)
不参与模板参数推导的函数参数将被视为非模板函数的参数。特别是,通常的转换规则适用。考虑:
template<typename T, typename C>
T get_nth(C& p, int n); // 取第n个元素
此函数大概返回类型 C 的容器的第 n 个元素的值。由于 C 必须从调用中的 get_nth() 的实际参数推导而来,因此转换不适用于第一个参数。但是,第二个参数完全是普通的,因此会考虑所有可能的转换。例如:
struct Index {
operator int();
// ...
};
void f(vector<int>& v, short s, Index i)
{
int i1 = get_nth<int>(v,2); // 准确匹配
int i2 = get_nth<int>(v,s); // 标准转换: short 转 int
int i3 = get_nth<int>(v,i); // 用户义定转: Index 转 int
}
这种符号有时被称为显式特化(§23.5.1)。
23.6 模板别名 (Template Aliases )
我们可以使用 using 语法或 typedef 语法(§6.5)为类型定义别名。using 语法更通用,因为它可用于为模板定义别名,并绑定其部分参数。考虑:
template<typename T, typename Allocator = allocator<T>> vector;
using Cvec = vector<char>; // 两个参数都是有约束的
Cvec vc = {'a', 'b', 'c'}; // vc 是一个 vector<char,allocator<char>>
template<typename T>
using Vec = vector<T,My_alloc<T>>; //使用我的 allocator的vector(第二个参数有约束)
Vec<int> fib = {0, 1, 1, 2, 3, 5, 8, 13}; // fib 是一个vector<int,My_alloc<int>>
一般来说,如果我们绑定模板的所有参数,我们会得到一个类型,但如果我们只绑定一些参数,我们会得到一个模板。请注意,我们在别名定义中使用时得到的始终是一个别名。也就是说,当我们使用别名时,它完全等同于对原始模板的使用。例如:
vector<char,alloc<char>> vc2 = vc; // vc2 和 vc 类型相同
vector<int,My_alloc<int>> verbose = fib; // verbose 和 fib 类型相同
别名和原始模板的等价性意味着,如果您特化模板,则在使用别名时您会(正确地)获得特化。例如:
template<int>
struct int_exact_traits { // 思想: int_exact_traits<N>::type 是恰好有N位的类型
using type = int;
};
template<>
struct int_exact_traits<8> {
using type = char;
};
template<>
struct int_exact_traits<16> {
using type = short;
};
template<int N>
using int_exact = typename int_exact_traits<N>::type; //为方便记法而定义类型
int_exact<8> a = 7; // int_exact<8> 是一个具有8位的int
如果特化没有通过别名使用,我们就不能声称 int_exact 只是int_exact_traits<N>::type 的别名;它们的行为会有所不同。另一方面,你无法定义别名的特化。如果你能够这样做,人类读者很容易对特化的内容感到困惑,因此没有提供用于特化别名的语法。
23.7 源码组织 (Source Code Organization)
使用模板组织代码有三种相当明显的方法:
[1] 在编译单元中使用模板之前,包含模板定义。
[2] 在编译单元中使用模板之前,仅包含模板声明。在编译单元的稍后部分(可能在使用模板之后)包含模板定义。
[3] 在编译单元中使用模板之前,仅包含模板声明。在其他编译单元中定义模板。
由于技术和历史原因,不提供选项 [3],即单独编译模板定义及其使用。迄今为止最常见的方法是将您使用的模板的定义包含(通常是 #include)到您使用它们的每个编译单元中,并依靠你的实现来优化编译时间并消除目标代码重复。例如,我可能会在头文件 out.h 中提供一个模板 out():
// file out.h:
#include<iostream>
template<typename T>
void out(const T& t)
{
std::cerr << t;
}
我们将在需要 out() 的地方 #include 此头文件。例如:
// file user1.cpp:
#include "out.h"
// use out()
和
#include "out.h"
// use out()
也就是说,out() 的定义及其所依赖的所有声明都 #included 在几个不同的编译单元中。编译器负责在需要时(仅)生成代码并优化读取冗余定义的过程。此策略将模板函数视为与内联函数相同的方式。
这种策略的一个明显问题是,用户可能会意外地依赖仅为 out() 定义而包含的声明。可以通过采取方法 [2]“稍后包含模板定义”、使用命名空间、避免使用宏以及通常减少包含的信息量来限制这种危险。理想的做法是尽量减少模板定义对其环境的依赖。
为了在我们的简单 out() 示例中使用“稍后包含模板定义”方法,我们首先将 out.h 拆分为两个。声明放入 .h 文件中:
// file outdecl.h:
template<typename T>
void out(const T& t);
定义放入 out.cpp:
// file out.cpp:
#include<iostream>
template<typename T>
void out(const T& t)
{
std::cerr << t;
}
用户现在同时 #inclue 两者:
// file user3.cpp:
#include "out.h"
// use out()
#include "out.cpp"
这最大限度地降低了模板实现对用户代码产生不良影响的可能性。遗憾的是,这也增加了用户代码中的某些内容(例如宏)对模板定义产生不良影响的可能性。
与以往一样,非 inline、非模板函数和 static 成员(§16.2.12)必须在某些编译单元中具有唯一定义。这意味着最好不要将这些成员用于许多编译单元中包含的模板。如 out() 所示,模板函数的定义可能会在不同的编译单元中复制,因此请注意可能会巧妙地改变定义含义的上下文:
// file user1.cpp:
#include "out.h"
// use out()
和
// file user4.cpp:
#define std MyLib
#include "out.c"
// use out()
这种偷偷摸摸且容易出错的宏使用方式会更改 out 的定义,导致 user4.cpp 的定义与 user1.cpp 的定义不同。这是一个错误,但实现可能无法发现这个错误。这种错误在大型程序中很难检测到,因此请小心,尽量减少模板的上下文依赖性,并对宏保持高度警惕(§12.6)。
如果你需要对实例化上下文有更多的控制,则可以使用显式实例化和 extern template(§26.2.2)。
23.7.1 链接(Linkage)
模板的链接规则是生成的类和函数的链接规则(§15.2、§15.2.3)。这意味着,如果类模板的布局或内联函数模板的定义发生变化,则必须重新编译使用该类或函数的所有代码。
对于在头文件中定义并“随处”包含的模板,这可能意味着大量的重新编译,因为模板往往在头文件中包含大量信息,比使用 .cpp 文件的非模板代码更多。特别是,如果使用动态链接库,必须注意确保模板的所有用途都得到一致定义。
有时,通过将复杂模板库的使用封装在具有非模板接口的函数中,可以最大限度地减少对复杂模板库的更改。例如,我可能想使用支持多种类型的通用数值库来实现一些计算(例如,第 29 章,§40.4,§40.5,§40.6)。但是,我经常知道用于计算的类型。例如,在程序中,我可能始终使用 double 和 vector<double>。在这种情况下,我可以定义:
double accum(const vector<double>& v)
{
return accumulate(v.begin(),v.end(),0.0);
}
鉴于此,我可以在我的代码中使用 accum() 的简单非模板声明:
double accum(const vector<double>& v);
对 std::accumulate 的依赖已消失在 .cpp 文件中,而我的其他代码看不到该文件。此外,我只在该 .cpp 文件中遭受 #include<numeric> 的编译时开销。
请注意,我借此机会简化了 accum() 接口(与 std::accumulate() 相比)。通用性是优秀模板库的一个关键属性,但可视为特定应用程序的复杂性来源。
我怀疑我不会将这种技术用于标准库模板。这些模板多年来一直很稳定,并且为实现所熟知。特别是,我没有费心尝试封装 vector<double>。然而,对于更复杂、深奥或经常更改的模板库,这种封装可能很实用。
23.8 建议 (Advice)
[1] 使用模板来表达适用于多种参数类型的算法;§23.1。
[2] 使用模板来表达容器;§23.2。
[3] 请注意,template<class T> 和 template<typename T> 是同义词;§23.2。
[4] 定义模板时,首先设计和调试非模板版本;然后通过添加参数进行概括;§23.2.1。
[5] 模板是类型安全的,但检查发生得太晚;§23.3。
[6] 设计模板时,请仔细考虑其模板参数所假定的概念(要求);§23.3。
[7] 如果类模板应该是可复制的,请为其提供非模板复制构造函数和非模板复制赋值;§23.4.6.1。
[8] 如果类模板应该是可移动的,请为其提供非模板移动构造函数和非模板移动赋值;§23.4.6.1。
[9] 虚函数成员不能是模板成员函数;§23.4.6.2。
[10] 仅当类型依赖于类模板的所有参数时,才将其定义为模板的成员;
§23.4.6.3。
[11] 使用函数模板推断类模板参数类型;§23.5.1。
[12] 重载函数模板以获得各种参数类型的相同语义;§23.5.3。
[13] 使用参数替换失败为程序提供合适的函数集;§23.5.3.2。
[14] 使用模板别名简化符号并隐藏实现细节;§23.6。
[15] 没有单独的模板编译:#include 模板定义在每个使用它们的编译单元中;§23.7。
[16] 使用普通函数作为无法处理模板的代码的接口;§23.7.1。
[17] 分别编译大型模板和具有非平凡上下文依赖关系的模板;§23.7。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup