第7章 模板
第7章 模板
简介
参数化类型
- 约束模板参数
- 值模板参数
- 模板参数推导
参数化操作
- 函数模板
- 函数对象
- Lambda表达式
模板机制
- 变量模板
- 别名
- 编译时 if
建议
7.1 简介
需要使用向量的人不太可能总是需要一个 double 向量。向量是一个通用的概念,独立于 double 的理念。因此,向量的元素类型应当独立表示。模板是一种我们通过一系列类型或值参数化的类或函数。我们利用模板来表示那些最好理解为通用概念的想法,通过指定参数(如将 double 作为向量的元素类型)来生成特定的类型和函数。
本章主要关注语言机制。第8章随后介绍编程技术,而库章节(第10至18章)提供了许多示例。
7.2 参数化类型
我们可以通过将其设为模板并将类型 double 替换为类型参数,将我们的 double 向量类型(§5.2.2)泛化为任意类型的向量。例如:
template<typename T>
class Vector {
private:
T* elem; // elem 指向类型为 T 的 sz 个元素的数组
int sz;
public:
explicit Vector(int s); // 构造函数:建立不变性(防止隐形转换),获取资源
~Vector() { delete[] elem; } // 析构函数:释放资源
// ... 复制和移动操作 ...
T& operator[](int i); // 适用于非const Vector
const T& operator[](int i) const; // 适用于const Vector(§5.2.1)
int size() const { return sz; }
};
template<typename T> 前缀使 T 成为其前缀声明的类型参数。这是C++版的数学表达式“对所有 T ”或者更精确地说是“对所有类型 T ”。如果你想要表达数学中的“对于所有满足 P(T) 的 T ”,则可以使用概念(§7.2.1,§8.2)。使用 class 引入类型参数与使用 typename 是等价的,在较旧的代码中,我们经常看到 template<class T> 作为前缀。
成员函数也可以类似地定义:
template<typename T>
Vector<T>::Vector(int s)
{
if (s < 0)
throw length_error{"Vector constructor: negative size"};
elem = new T[s];
sz = s;
}
template<typename T>
//第一个const限制了返回值的元素不可修改,而最后一个const则保证了成员函数不会修改类对象状态(mutable除外)。
const T& Vector<T>::operator[](int i) const
{
if (i < 0 || size() <= i)
throw out_of_range{"Vector::operator[]"};
return elem[i];
}
给出这些定义后,我们可以这样定义 Vector 实例:
Vector<char> vc(200); // 200个字符的向量
Vector<string> vs(17); // 17个字符串的向量
Vector<list<int>> vli(45); // 45个整数列表的向量
这里的 >> 在 Vector<list<int>> 中用于终止嵌套的模板参数列表,它并不是错误放置的输入操作符。
我们可以这样使用 Vector :
void write(const Vector<string>& vs)// 某些字符串的向量
{
for (int i = 0; i != vs.size(); ++i)
cout << vs[i] << '\n';
}
为了支持我们的 Vector 使用范围 for 循环,我们必须定义合适的 begin() 和 end() 函数:
template<typename T>
T* begin(Vector<T>& x)
{
return &x[0]; // 返回第一个元素的地址
}
template<typename T>
T* end(Vector<T>& x)
{
return &x[0] + x.size();//返回最后一个元素之后的位置的地址
}
有了这些,我们可以编写:
void write2(Vector<string>& vs)// 某些字符串的向量
{
for (auto& s : vs)
cout << s << '\n';
}
类似地,我们可以将列表、向量、映射(即关联数组)、无序映射(即哈希表)等定义为模板(第12章)。
模板是一种编译时机制,因此与手工编写的代码相比,使用它们不会产生额外的运行时开销。实际上,为 Vector<double> 生成的代码与第5章中 Vector 版本生成的代码相同。此外,为标准库中的 vector<double> 生成的代码可能会更好(因为其实现上投入了更多努力)。
模板加上一组模板参数称为 实例化 或 特化 (specialization)。在编译过程的后期,在实例化阶段,会为程序中使用的每个实例生成代码(§8.5)。
7.2.1 约束模板参数
通常情况下,模板只对满足特定条件的模板参数有意义。例如,一个 Vector 类通常会提供复制操作,如果这样做,就必须要求其元素是可复制的。也就是说,我们必须要求 Vector 的模板参数不仅仅是一个 typename ,而是一个满足元素所需属性的 Element ,比如这样:
template<typename Element T>
class Vector {
private:
T* elem; // elem 指向一个类型为 T 的 sz 个元素的数组
int sz;
// ...
};
这里的 template<typename Element T> 前缀相当于数学中的“对于所有满足 Element 条件的 T ”,也就是说, Element 是一个谓词,用于检查 T 是否具有作为 Vector 元素所需的所有属性。这样的谓词被称为 概念 (Concept,§8.2)。一个指定了概念的模板参数被称为 约束参数 (constrained argument),而包含约束参数的模板则被称为 约束模板 (constrained template)。
标准库中元素类型的所需条件稍微复杂一些(§12.2),但对于我们的简单 Vector 来说, Element 可以类似于标准库中的概念 copyable (§14.5)。
尝试使用不满足其需求的类型来实例化模板会报编译时错误。例如:
Vector<int> v1; // 正确:我们可以复制int
Vector<thread> v2; // 错误:我们不能复制标准线程(§18.2)
因此,概念允许编译器在使用点进行类型检查,提供了比无约束模板参数更早且更优质的错误消息。C++ 在 C++20 之前并未正式支持概念,所以旧代码使用的是无约束模板参数,并将需求留给了文档。然而,从模板生成的代码会进行类型检查,因此即使是无约束模板参数代码,其类型安全性也与手写代码一样高。对于无约束参数,这种类型检查必须等到涉及的所有实体的类型都可用时才能进行,这可能在编译过程的后期、实例化时间(§8.5)非常不愉快地发生,并且错误信息往往难以理解。
概念检查是一个纯粹的编译时机制,生成的代码与无约束模板一样优质。
7.2.2 值模板参数
除了类型参数,模板还可以接受值参数。例如:
template<typename T, int N>
struct Buffer {
constexpr int size() { return N; }
T elem[N];
// ...
};
值参数在多种情境下都非常有用。比如, Buffer 让我们能够创建任意大小的缓冲区,而无需使用自由存储(动态内存):
Buffer<char,1024> glob; // 全局字符缓冲区(静态分配)
void fct(){
Buffer<int,10> buf; // 局部整数缓冲区(位于栈上)
// ...
}
不幸的是,由于一些技术上的原因,字符串字面量目前还不能直接作为模板的值参数。然而,在某些情境下,能够使用字符串值进行参数化至关重要。幸运的是,我们可以使用一个数组来持有字符串的内容:
template<char* s>
void outs() { cout << s; }
char arr[] = "Weird workaround!";
void use(){
outs<"straightforward">(); // 错误(当前情况下)
outs<arr>(); // 输出: Weird workaround!
}
在C++中,通常总能找到变通的方法;我们并不需要对每种使用场景都直接提供支持。
7.2.3 模板参数推导
在定义一个类型为模板的实例时,我们必须指定其模板参数。考虑使用标准库模板 pair :
pair<int,double> p = {1, 5.2};
明确指定模板参数类型可能会很繁琐。幸运的是,在很多情况下,我们可以简单地让 pair 的构造函数从初始化器中推导出模板参数:
pair p = {1, 5.2};
// p 是一个pair<int,double>
容器提供了另一个例子:
template<typename T>
class Vector {
public:
Vector(int);
Vector(initializer_list<T>); // 初始化列表构造函数
// ...
};
Vector v1 {1, 2, 3};// 从初始化器的元素类型推导 v1 的元素类型:int
Vector v2 = v1;// 从 v1 的元素类型推导 v2 的元素类型:int
auto p = new Vector{1, 2, 3};// p 是 Vector<int>*
Vector<int> v3(1);// 这里我们需要明确元素类型(没有提及元素类型)
显然,这简化了表示法并能消除因重复键入模板参数类型而引起的烦恼。然而,它并非万能的。像所有其他强大的机制一样,推导也可能带来意外。考虑以下情况:
Vector<string> vs {"Hello", "World"}; // 正确:Vector<string>
Vector vs1 {"Hello", "World"}; // 正确:推导为 Vector<const char*> (惊不惊喜?)
Vector vs2 {"Hello"s, "World"s}; // 正确:推导为 Vector<string>
Vector vs3 {"Hello"s, "World"}; // 错误:初始化器列表不是同构的
Vector<string> vs4 {"Hello"s, "World"}; // 正确:元素类型是明确的
C风格字符串字面量的类型是 const char*(§1.7.1)。如果这不是对 vs1 的预期,我们必须明确元素类型或使用 s 后缀使其成为一个适当的字符串(§10.2)。
如果初始化器列表中的元素类型不同,我们就无法推导出一个唯一的元素类型,因此会得到一个歧义错误。
有时,我们需要解决这种歧义。例如,标准库中的 vector 有一个构造函数,它接受一对迭代器来限定一个序列的范围,同时还有一个初始化构造函数,可以接受一对值。考虑以下情况:
template<typename T>
class Vector {
public:
Vector(initializer_list<T>);// 初始化列表构造函数
template<typename Iter>
Vector(Iter b, Iter e);// [b:e) 迭代器对构造函数
struct iterator { using value_type = T; /* ... */ };//即 using Vector<T>::iterator::value_type = T
iterator begin();
};
// ...
Vector v1 {1, 2, 3, 4, 5};// 元素类型是 int
Vector v2(v1.begin(),v1.begin()+2);// 一对迭代器还是两个值?(当然是迭代器)
Vector v3(9,17);// 错误:歧义(编译器无法确定应该调用哪个构造函数)
我们可以通过使用概念(§8.2)来解决这个问题,但是标准库和许多其他重要的代码体是在我们拥有概念的语言支持几十年前编写的。对于这些情况,我们需要一种方式来表达“一对相同类型的值应被视为迭代器”。在 Vector 声明后添加一个 推导指南 (deduction guide)就能做到这一点:
template<typename Iter>
Vector(Iter,Iter) -> Vector<typename Iter::value_type>;
现在我们有:
Vector v1 {1, 2, 3, 4, 5};// 元素类型是 int
Vector v2(v1.begin(),v1.begin()+2);// 迭代器对:元素类型是 int
Vector v3 {v1.begin(),v1.begin()+2};// 元素类型是 Vector<int>::iterator
花括号 {} 初始化语法始终优先考虑初始化列表构造函数(如果存在),所以 v3 是一个迭代器的向量: Vector<Vector<int>::iterator> 。
圆括号 () 初始化语法(§12.2)在我们不想使用 initializer_list 时是常规做法。
推导指南的影响往往是微妙的,因此最好是设计类模板以避免需要推导指南。
喜欢缩略语的人将“class template argument deduction”简称为CTAD。
7.3 参数化操作
模板的用途远不止简单地用元素类型参数化容器。特别是,它们在标准库中广泛用于类型和算法的参数化(§12.8, §13.5)。
表达由类型或值参数化的操作有三种方式:
- 函数模板(Function Template)
- 函数对象(Function Object):一个可以携带数据并像函数一样被调用的对象
- Lambda表达式:函数对象的一种简写表示法
7.3.1 函数模板
我们可以编写一个函数,计算任何范围 for 能够遍历的序列(例如,一个容器)的元素值之和,如下所示:
template<typename Sequence, typename Value>
Value sum(const Sequence& s, Value v)
{
for (auto x : s)
v += x;
return v;
}
模板参数 Value 和函数参数 v 的存在是为了允许调用者指定累加器(用于累积总和的变量)的类型和初始值:
void user(Vector<int>& vi, list<double>& ld, vector<complex<double>>& vc)
{
int x = sum(vi, 0);// 整型向量的和(int相加)
double d = sum(vi, 0.0);// 整型向量的和(double相加)
double dd = sum(ld, 0.0);// double列表的和
auto z = sum(vc, complex{0.0, 0.0});// complex<double>向量的和
}
将 int 类型数值相加到 double 类型中的目的是为了能够优雅地处理那些超过最大 int 类型数值限制的和。请注意 sum<Sequence, Value> 模板参数类型是如何从函数参数中推导出来的。幸运的是,我们不需要显式指定这些类型。
此 sum() 函数是标准库 accumulate() 函数的一个简化版本(§17.3)。
函数模板可以是成员函数,但不能是虚成员函数。编译器无法了解程序中所有此类模板的实例化情况,因此无法生成虚函数表(vtbl,§5.4)。
7.3.2 函数对象
函数对象 (Function Objects,有时也称为 函子 ,functor)是一种特别有用的模板类型,它用于定义可像函数那样被调用的对象。例如:
template<typename T>
class Less_than {
const T val; // 要比较的值
public:
Less_than(const T& v) : val{v} {} // 构造函数初始化val
bool operator()(const T& x) const { return x < val; } // 调用操作符
};
这个 operator() 函数实现了 应用操作符 (application operator),即' () ',也被称为“函数调用”或简称为“调用”。
我们可以为某些参数类型定义 Less_than 类型的命名变量:
Less_than<int> lti {42}; // lti(i) 将会使用 < 比较 i 和 42(i<42)
Less_than<string> lts {"Backus"s}; // lts(s) 将会使用 < 比较 s 和 "Backus"(s<"Backus")
Less_than<string> lts2 {"Naur"}; // "Naur" 是C风格字符串,所以我们需要 <string> 来获得正确的<
我们可以像调用函数一样调用这样的对象:
void fct(int n, const string& s)
{
bool b1 = lti(n); // 如果 n<42,则b1为true
bool b2 = lts(s); // 如果 s<"Backus",则b2为true
// ...
}
函数对象广泛应用于算法的参数,例如自定义计数函数 count ,它计算容器中满足特定谓词(函数对象)条件的元素数量。
template<typename C, typename P>
int count(const C& c, P pred) {
int cnt = 0;
for (const auto& x : c)
if (pred(x)) ++cnt;
return cnt;
}
这个 count 函数模板是一个简化版的标准库算法 count_if 。
依据概念(§8.2),我们可以形式化 count() 函数对其参数的假设,并在编译时检查这些假设。
谓词 是我们可以调用以返回真或假的东西。例如:
void f(const vector<int>& vec, const list<string>& lst, int x, const string& s) {
cout << "number of values less than " << x << ": " << count(vec, Less_than<int>{x}) << '\n';
cout << "number of values less than " << s << ": " << count(lst, Less_than<string>{s}) << '\n';
}
在这里, Less_than{x} 构造了一个类型为 Less_than<int> 的对象,该对象的调用操作符会将传入的 int 类型变量 x 与其内部存储的值进行比较;而 Less_than{s} 则构造了一个与字符串 s 的值进行比较的对象。
函数对象的美妙之处在于它们携带着用于比较的值。我们无需为每个值(以及每种类型)编写独立的函数,也无需引入不雅的全局变量来保存值。此外,对于像 Less_than 这样简单的函数对象,内联很容易实现,因此调用 Less_than 比间接函数调用效率高得多。携带数据的能力加上其高效性,使得函数对象作为算法参数时特别有用。
用于指定通用算法关键操作意义的函数对象(如 count() 中的 Less_than )有时被称为 策略对象 ( policy objects)。
7.3.3 Lambda 表达式
在§7.3.2中,我们独立于使用场景定义了 Less_than 。这样做有时会显得不便。因此,C++提供了一种隐式生成函数对象的记法,即Lambda表达式。Lambda表达式让我们能够在使用现场便捷地定义小型的、一次性的功能逻辑,无需事先定义一个完整的函数对象类。在上面的例子中,我们可以改写为使用Lambda表达式:
void f(const Vector<int>& vec, const list<string>& lst, int x, const string& s)
{
cout << "number of values less than " << x
<< ": " << count(vec, [&](int a){ return a < x; })
<< '\n';
cout << "number of values less than " << s
<< ": " << count(lst, [&](const string& a){ return a < s; })
<< '\n';
}
这里的 [&](int a){ return a<x; } 就是一个 Lambda表达式 。它生成了一个类似于 Less_than<int>{x} 的函数对象。 [&] 是 捕获列表 (capture list),它指定 Lambda 体内使用的所有局部名称(比如 x )将通过引用访问。如果我们只想捕获 x ,可以这样写: [&x] 。如果希望生成的对象获得 x 的一个副本,则可以写作: [x] 。不捕获任何内容用 [] 表示,通过引用捕获所有局部使用的名称用 [&] ,通过值捕获所有局部使用的名称用 [=] 。
对于定义在成员函数内部的 Lambda ,使用 [this] 可以按引用捕获当前对象,从而在 Lambda 体内引用类的成员。如果希望获得当前对象的一个副本,则使用 *[this] 。
如果需要捕获多个特定的对象,可以在捕获列表中一一列出。例如,在使用 expect() (§4.5)时出现的 [i,this] 就是这样一个例子。Lambda表达式极大提高了代码的灵活性和简洁度,使得函数对象的使用更为直观和方便。
7.3.3.1 Lambda作为函数参数
Lambda表达式的使用既方便又紧凑,但有时也可能导致代码不够清晰。对于非琐碎的操作(比如,不仅仅是简单的表达式),我更倾向于命名该操作,以便更清楚地说明其目的,并使其在程序的多个位置可重用。
在§5.5.3中,我们注意到为向量中的指针元素执行操作(如 draw_all() 和 rotate_all() )而编写多个函数是令人烦恼的。函数对象,尤其是Lambda,可以帮助我们将容器的遍历与针对每个元素所需执行的操作规范分离。
首先,我们需要一个函数,用于对指针容器中每个元素所指向的对象应用一个操作:
template<typename C, typename Oper>
void for_each(C& c, Oper op) // 假定C是指针的容器(§8.2.1)
{
for (auto& x : c)
op(x); // 将每个元素的引用传递给op()
}
这是标准库 for_each 算法的一个简化版本(§13.5)。
现在,我们可以编写一个 user() 函数的版本,而无需编写一系列 _all 函数:
void user()
{
vector<unique_ptr<Shape>> v;
while (cin)
v.push_back(read_shape(cin));
for_each(v, [](unique_ptr<Shape>& ps) { ps->draw(); });//draw_all()
for_each(v, [](unique_ptr<Shape>& ps) { ps->rotate(45); });//rotate_all(45)
}
通过引用传递 unique_ptr<Shape> 给Lambda,这样 for_each() 就不必处理生命周期问题了。
与函数一样,Lambda也可以是泛型的。例如:
template<class S>
void rotate_and_draw(vector<S>& v, int r)
{
for_each(v, [](auto& s) { s->rotate(r); s->draw(); });
}
在这里,像变量声明一样, auto 意味着接受任何类型的值作为初始化器(在调用中,参数被认为初始化了形式参数)。这使得带有 auto 参数的Lambda成为一个模板,即 泛型Lambda 。必要时,我们可以使用概念(§8.2)来约束参数。例如,我们可以定义一个 Pointer_to_class 以要求 ***** 和 -> 可用来书写:
for_each(v, [](Pointer_to_class auto& s) { s->rotate(r); s->draw(); });
我们可以用任意类型的对象容器调用这个泛型的 rotate_and_draw() ,只要这些对象可以 draw() 和 rotate() 。例如:
void user()
{
vector<unique_ptr<Shape>> v1;
vector<Shape*> v2;
// ...
rotate_and_draw(v1, 45);
rotate_and_draw(v2, 90);
}
为了实现更严格的检查,我们可以定义一个 Pointer_to_Shape 概念,来指定作为 Shape 使用的类型所需具备的属性。这将允许我们使用并非从 Shape 类派生的形状。
7.3.3.2 初始化中的Lambda表达式
使用Lambda表达式,我们可以将任何语句转换为表达式。这主要用来提供一个计算值作为参数值的操作,但这个能力是通用的。考虑一个复杂的初始化场景:
enum class Init_mode { zero, seq, cpy, patrn }; // 初始化选项
void user(Init_mode m, int n, vector<int>& arg, Iterator p, Iterator q) {
vector<int> v;
// 复杂的初始化代码:
switch (m) {
case zero:
v = vector<int>(n); break; // 初始化n个元素为0
case cpy:
v = arg; break;
};
// ...
if (m == seq)
v.assign(p, q); // 从序列[p:q)复制
// ...
}
这是一个格式化的示例,但不幸的是并不罕见。我们需要从一系列备选方案中选择一个来初始化数据结构(这里为 v ),并且针对不同的方案需要执行不同的计算。这样的代码往往很混乱,被认为“为了效率”而必不可少,同时也是 bug 的来源:
- 变量可能在其获得预期值之前就被使用了。
- “初始化代码”可能与其他代码混合,使得理解起来变得困难。
- 当“初始化代码”与其他代码混合时,更容易忘记处理某些情况。
- 这实际上不是初始化,而是赋值(§1.9.2)。
作为替代,我们可以将其转换为用作初始化器的Lambda表达式:
void user(Init_mode m, int n, vector<int>& arg, Iterator p, Iterator q) {
vector<int> v = [&] {
switch (m) {
case zero:
return vector<int>(n); // 初始化n个元素为0
case seq:
return vector<int>{p, q}; // 从序列[p:q)复制
case cpy:
return arg;
}
}();
// ...
}
我仍然“忘记”了一个 case ,但现在这个问题更容易被发现了。在许多情况下,编译器会发现该问题并发出警告。
7.3.3.3 Finally
析构函数为对象使用后(RAII; §6.3)的自动清理提供了通用机制,但如果需要进行一些与单个对象无关的清理,或者与没有析构函数的对象关联的清理(例如,因为它是一个与C程序共享的类型)该怎么办?我们可以定义一个函数 finally() ,它接受一个操作,在退出作用域时执行。
void old_style(int n) {
void* p = malloc(n * sizeof(int)); // C风格分配内存
auto act = finally([&]{ free(p); });//在作用域结束时调用lambda
// ...
// 当作用域结束时,p会被自动释放
}
这种方法是特设的,但远比试图在函数的所有退出点正确且一致地调用 free(p) 要好。
finally() 函数实现很简单:
template <class F>
[[nodiscard]] auto finally(F f) {
return Final_action{f};
}
我使用了 [[nodiscard]] 属性来确保用户不会忘记将生成的 Final_action 复制到其动作意图执行的作用域中。
提供必要析构函数的 Final_action 类可以这样定义:
template <class F>
struct Final_action {
explicit Final_action(F f) : act(f) {}
~Final_action() { act(); } // 在对象销毁时执行传入的函数
F act;
};
Core Guidelines Support Library(GSL)中有一个 finally() 函数,并且有一个提案建议为标准库提供一个更复杂的 scope_exit 机制。
7.4 模板机制
为了定义优秀的模板,我们需要一些语言支持设施:
• 类型相关的值: 变量模板 (§7.4.1)。
• 类型和模板的别名: 别名模板 (§7.4.2)。
• 编译时选择机制: if constexpr (§7.4.3)。
• 用于查询类型和表达式属性的编译时机制: requires 表达式(§8.2.3)。
此外, constexpr 函数(§1.6)和 static_assert (§4.5.2)经常参与模板的设计与使用。 这些基本机制主要是构建通用、基础抽象的工具。
7.4.1 变量模板
当我们使用一个类型时,经常会需要该类型的常量和值。在使用类模板时,情况也是如此:当我们定义一个 C<T> 时,我们经常需要类型为 C<T> 以及依赖于 T 的其他类型的常量和变量。以下是一个来自流体动力学模拟领域的例子[Garcia,2015]:
template <class T>
constexpr T viscosity = 0.4;
template <class T>
constexpr space_vector<T> external_acceleration = { T{}, T{-9.8}, T{} };
auto vis2 = 2 * viscosity<double>;
auto acc = external_acceleration<float>;
在这里, space_vector 表示一个三维向量。
有趣的是,大多数变量模板似乎都是常量。然而,很多普通变量也常常如此。术语并没有跟上我们对不可变性的理解。
当然,我们可以使用任意适当类型的表达式作为初始化器。例如:
template<typename T, typename T2>
constexpr bool Assignable = is_assignable<T&, T2>::value;// is_assignable 是一个类型特征(§16.4.1)
template<typename T>
void testing() {
static_assert(Assignable<T&, double>, "不能将double赋值给T");
static_assert(Assignable<T&, string>, "不能将string赋值给T");
}
经过一些重要的演变,这个想法成为了概念定义(§8.2)的核心。
标准库利用变量模板提供了数学常数,比如 pi 和 log2e (§17.9)。
7.4.2 别名
令人惊讶的是,经常需要为类型或模板引入一个同义词。例如,标准头文件 <cstddef> 包含对别名 size_t 的定义,可能是这样:
using size_t = unsigned int;
实际上,被命名的类型 size_t 依赖于实现,所以在另一个实现中, size_t 可能是一个 unsigned long 。拥有别名 size_t 使得程序员能够编写可移植的代码。
对于参数化类型来说,为其模板参数相关的类型提供一个别名是非常常见的做法。例如:
template<typename T>
class Vector {
public:
using value_type = T;
// ...
};
实际上,每个标准库容器都提供了 value_type 作为其元素类型的名称(第12章)。这允许我们编写适用于遵循此约定的每个容器的代码。例如:
template<typename C>
using Value_type = typename C::value_type;// C中元素的类型
template<typename Container>
void algo(Container& c)
{
Vector<Value_type<Container>> vec; // 在这里保存结果
// ...
}
这个 Value_type 是标准库 range_value_t (§16.4.4)的一个简化版本。别名机制可以用来通过绑定一些或全部模板参数来定义一个新的模板。例如:
template<typename Key, typename Value>
class Map {
// ...
};
template<typename Value>
using String_map = Map<string,Value>;
String_map<int> m;
// m 是一个 Map<string,int>
7.4.3 编译时if
考虑编写一个操作,该操作可以通过两个函数 slow_and_safe(T) 或 simple_and_fast(T) 中的任意一个来实现。这类问题在基础代码中普遍存在,其中通用性和最佳性能至关重要。如果涉及到类层次结构,基类可以提供通用的 slow_and_safe 操作,而派生类可以用 simple_and_fast 实现进行覆盖。
或者,我们可以使用编译时 if :
template<typename T>
void update(T& target)
{
// ...
if constexpr(is_trivially_copyable_v<T>)
simple_and_fast(target); // 用于“普通旧数据”
else
slow_and_safe(target); // 用于更复杂的类型
// ...
}
这里的 is_trivially_copyable_v<T> 是一个类型谓词(§16.4.1),它告诉我们一个类型是否可以被简单地复制。
编译时 if 只会检查选定分支。这种解决方案提供了最优的性能和优化的局部性(locality)。
重要的是, if constexpr 不是一个文本处理机制,不能用来打破语法、类型和作用域的常规规则。例如,下面是一个试图条件性地在一个 try 块中包装调用的天真且失败的尝试:
template<typename T>
void bad(T arg)
{
if constexpr(!is_trivially_copyable_v<T>)
try {
// 哎呀,if的范围超出了这一行
g(arg);
if constexpr(!is_trivially_copyable_v<T>) // 语法错误
} catch(...) { /* ... */ }
}
允许这样的文本操作会严重损害代码的可读性,并给依赖现代程序表示技术(如“抽象语法树”)的工具带来问题。
许多这样的尝试性技巧也是不必要的,因为存在不违反作用域规则的更干净的解决方案。例如:
template<typename T>
void good(T arg)
{
if constexpr (is_trivially_copyable_v<T>)
g(arg);
else
try {
g(arg);
}
catch (...) { /* ... */ }
}
7.5 建议
- 使用模板表达适用于多种参数类型的算法;§7.1;[CG: T.2]。
- 使用模板表达容器;§7.2;[CG: T.3]。
- 利用模板提升代码的抽象级别;§7.2;[CG: T.1]。
- 模板是类型安全的,但对于无约束模板,检查发生得太晚;§7.2。
- 让构造函数或函数模板推导类模板参数类型;§7.2.3。
- 将函数对象作为算法的参数使用;§7.3.2;[CG: T.40]。
- 如果只需要在一处使用简单的函数对象,则使用lambda表达式;§7.3.2。
- 虚函数成员不能是模板成员函数;§7.3.1。
- 对于没有析构函数但需要“清理操作”的类型,使用 finally() 提供RAII(资源获取即初始化)支持;§7.3.3.3。
- 利用模板别名简化表示法并隐藏实现细节;§7.4.2。
- 使用 if constexpr 在不增加运行时开销的情况下提供备选实现;§7.4.3。