第8章 概念与泛型编程
第8章 概念与泛型编程
简介
概念
概念的使用;
基于概念的重载;
有效代码;
概念的定义;
概念与 auto ;
概念与类型
泛型编程
概念的使用;
使用模板进行抽象
可变参数模板
折叠表达式;
转发参数
模板编译模型
建议
8.1 简介
模板是用来做什么的?换句话说,哪些编程技术因模板而变得更高效?模板提供了以下功能:
• 无需丢失信息即可将类型(以及值和模板)作为参数转发的能力。这意味着可以更灵活地表达,并为内联提供了极佳的机会,当前的实现充分利用了这一点。
• 在实例化时将来自不同上下文的信息交织在一起的机会。这暗示了优化的可能性。
• 将值作为模板参数转发的能力。这意味着进行编译时计算的机会。
换句话说,模板提供了一种强大的机制,用于编译时计算和类型操作,可以生成非常紧凑和高效的代码。请记住,类型(类)可以同时包含代码(§7.3.2)和值(§7.2.2)。
模板的第一个也是最常见的用途是支持泛型编程,即专注于设计、实现和使用通用算法的编程。在这里,“通用”意味着只要满足算法对其参数的要求,算法就可以被设计成接受各种类型的输入。结合概念,模板是C++支持泛型编程的主要手段。模板提供了(编译时)参数多态性。
8.2 概念
考虑第7.3.1节中的 sum() 函数:
template<typename Seq, typename Value>
Value sum(Seq s, Value v)
{
for (const auto& x : s)
v += x;
return v;
}
这个 sum() 函数要求:
• 其第一个模板参数是某种元素序列,
• 其第二个模板参数是某种数值类型。
具体来说, sum() 可以为一对参数调用:
• 一个 序列 , Seq ,它支持 begin() 和 end() 以便范围 for 循环可以工作(§1.7; §14.1)。
• 一个 算数类型 , Value ,它支持 += 运算符,以便序列中的元素可以被相加。
我们称这样的需求为 概念 (Concepts)。
符合这种简化的序列(也称为范围)要求(以及更多)的类型示例包括标准库中的 vector 、 list 和 map 。符合这种简化的算术类型要求(以及更多)的类型示例包括 int 、 double ,以及对于任何合理的定义下的 Matrix 。可以说, sum() 算法在两个维度上是泛型的:用于存储元素的数据结构的类型(“序列”)和元素的类型。概念提供了一种形式化的方法来描述模板参数所需满足的属性,从而帮助实现更精确的编译时错误检查、更好的代码理解和更易于重用的组件。虽然C++11及以前版本的直接支持有限,但概念在C++20中被正式引入,提供了更加严格的类型约束机制。
8.2.1 概念的使用
大多数模板参数必须满足特定要求,以确保模板能够正确编译并使生成的代码正常工作。也就是说,大多数模板应该是受约束的模板(§7.2.1)。类型名称介绍符 typename 提供的约束最少,仅要求参数是一个类型。通常,我们可以做得更好。再次考虑 sum() 函数:
template<Sequence Seq, Number Num>
Num sum(Seq s, Num v)
{
for (const auto& x : s)
v += x;
return v;
}
这样就清晰多了。一旦我们定义了概念 Sequence 和 Number 的含义,编译器就可以仅通过查看 sum() 的接口而非其实现来拒绝不合适的调用,从而改进了错误报告。
然而, sum() 接口的规范并不完整:我“忘记”说明我们应该能够将序列中的元素加到一个数值上。我们可以通过以下方式补充这一点:
template<Sequence Seq, Number Num>
requires Arithmetic<range_value_t<Seq>, Num>
Num sum(Seq s, Num n);
这里, range_value_t (§16.4.4)表示序列中元素的类型,它是从标准库中来的,用于命名 范围 (§14.1)中元素的类型。 Arithmetic<X,Y> 是一个概念,表明我们可以对类型 X 和 Y 的数值进行算术运算。这避免了我们不小心尝试计算 vector<string> 或 vector<int>* 的和,同时仍然接受 vector<int> 和 vector<complex<double>> 。通常,当算法需要不同类型的参数时,明确这些类型之间的关系是有益的。
在这个例子中,我们只需要 += 运算符,但为了简洁性和灵活性,我们不应该过度约束模板参数。特别是,我们可能有一天希望用 + 和 = 而不是 += 来表达 sum() ,那时我们会庆幸使用了一个一般性的概念(这里是 Arithmetic ),而不是一个狭窄的要求“拥有 += ”。
像第一个使用概念的 sum() 这样的部分规范非常有用。除非规范是完整的,否则一些错误直到实例化时才会被发现。然而,即使是部分规范也能表达意图,对于增量开发过程尤其重要,在这个过程中,我们最初可能没有认识到所有需要的要求。有了成熟的概念库,初始规范将接近完美。
不出所料, requires Arithmetic<range_value_t<Seq>,Num> 被称为 需求子句 (requirements-clause)。模板声明 template<Sequence Seq> 仅是显式使用 requires Sequence<Seq> 的一种简写。如果我喜欢冗长,我也可以等效地写出:
template<typename Seq, typename Num>
requires Sequence<Seq> && Number<Num> && Arithmetic<range_value_t<Seq>,Num>
Num sum(Seq s, Num n);
另一方面,我们也可以利用两种声明方式的等价性来写作:
template<Sequence Seq, Arithmetic<range_value_t<Seq>> Num>
Num sum(Seq s, Num n);
在还不能使用 概念 的代码库中,我们必须依赖命名约定和注释,例如:
template<typename Sequence, typename Number>
// requires Arithmetic<range_value_t<Sequence>,Number>
Number sum(Sequence s, Number n);
无论选择哪种表示法,重要的是要设计一个模板,对其参数施加语义上有意义的约束(§8.2.4)。
8.2.2 基于概念的重载
一旦我们正确地使用接口规范了模板,就可以基于它们的属性进行重载,就像我们对函数所做的那样。考虑一个稍微简化版的标准库函数 advance() ,它用于推进迭代器(§13.3):
template<forward_iterator Iter>
void advance(Iter p, int n) { // 向前移动迭代器p n个元素
while (n--)
++p; // 前向迭代器有++操作,但没有+或+=
}
template<random_access_iterator Iter>
void advance(Iter p, int n) { // 向前移动迭代器p n个元素
p += n; // 随机访问迭代器有+=操作
}
编译器会选择其最严格要求的参数满足的模板。在这种情况下,列表只提供前向迭代器,而向量提供随机访问迭代器,因此我们得到:
void user(vector<int>::iterator vip, list<string>::iterator lsp) {
advance(vip, 10); // 使用快速的advance()
advance(lsp, 10); // 使用慢速的advance()
}
与其他重载一样,这是一种编译时机制,不涉及运行时成本,如果编译器找不到最佳选择,则会给出歧义错误。基于概念的重载规则远比通用重载规则(§1.3)简单。首先考虑单个参数针对几个可选函数的情况:
- 如果参数不匹配概念,则无法选择该可选方案。
- 如果参数仅与一个可选方案的概念匹配,则选择该方案。
- 如果来自两个可选方案的参数都匹配一个概念,并且其中一个比另一个更严格(比起另一个,不仅满足所有要求,还有更多),则选择那个更严格的可选方案。
- 如果来自两个可选方案的参数对于一个概念来说都是同样好的匹配,则存在歧义。
为了让一个可选方案被选中,它必须:
- 为其所有参数提供匹配,
- 至少与其它可选方案相比,为所有参数提供至少同样好的匹配,
- 并且至少为一个参数提供更好的匹配。
8.2.3 有效代码
判断一组模板参数是否提供了模板对其模板参数的需求,最终归结为某些表达式是否有效。
使用 requires 表达式,我们可以检查一组表达式是否有效。例如,我们可能试图在不使用标准库概念 random_access_iterator 的情况下编写 advance() 函数:
template<forward_iterator Iter>
requires requires(Iter p, int i) { p[i]; p+i; } // Iter 支持下标运算和整数加法
void advance(Iter p, int n) // 将p向前移动n个元素
{
p += n;
}
不,这里的 requires requires 并非笔误。第一个 requires 启动需求子句,而第二个 requires 启动了 requires 表达式:
requires(Iter p, int i) { p[i]; p+i; }
requires 表达式是一个谓词,如果其中的语句是有效的代码则为真,如果不是则为假。我认为 requires 表达式是泛型编程的汇编代码。就像普通的汇编代码一样, requires 表达式极其灵活,不强加任何编程规范。它们以某种形式构成了大多数有趣泛型代码的基础,正如汇编代码构成大多数普通代码的基础一样。同样, requires 表达式不应出现在常规代码中,它们属于抽象的实现部分。如果你在代码中看到了 requires requires ,那可能太过底层,最终可能会成为问题。
在 advance() 中的 requires requires 使用故意显得不优雅且有些投机取巧。请注意,我“忘记”指定 += 操作以及所需的操作返回类型。因此,该版本的 advance() 在概念检查时会通过,但仍可能无法编译。你已经被警告过了!正确的随机访问版本的 advance() 更简单、更易读:
template<random_access_iterator Iter>
void advance(Iter p, int n)
// 将p向前移动n个元素
{
p += n; // 随机访问迭代器支持+=操作
}
优先使用语义明确且命名恰当的概念(§8.2.4),并在定义这些概念时主要采用 requires 表达式。
8.2.4 概念的定义
我们可以在库中找到有用的概念,比如标准库中的 forward_iterator (§14.5)。就像类和函数一样,通常从一个好的库中使用概念比编写一个新的概念更容易,但简单的概念并不难定义。标准库中的名称,如 random_access_iterator 和 vector ,都是小写的。在这里,我采用了一个约定,将我自己定义的概念的名称大写,如 Sequence 和 Vector 。
概念是一个编译时谓词,指定了一个或多个类型如何被使用。首先考虑最简单的例子之一:
template<typename T>
concept EqualityComparable =
requires (T a, T b) {
{ a == b } -> Boolean;// 使用==比较T
{ a != b } -> Boolean;// 使用!=比较T
};
EqualityComparable 是我们用来确保可以比较某个类型的值是否相等的概念。我们简单地说,给定该类型的两个值,它们必须能够使用 == 和 != 进行比较,并且这些操作的结果必须是布尔值。例如:
static_assert(EqualityComparable<int>); // 成功
struct S { int a; };
static_assert(EqualityComparable<S>); // 失败,因为结构体默认不提供==和!=操作
EqualityComparable 概念的定义与英语描述完全等价且不过分复杂。概念的值总是 bool 类型。
{...} 后跟的 -> 指定的结果必须是一个概念。不幸的是,标准库中没有布尔概念,所以我定义了一个(§14.5)。 Boolean 简单地表示一个可以用作条件的类型。
定义 EqualityComparable 以处理非齐次(nonhomogeneous)比较几乎一样容易:
template<typename T, typename T2 = T>
concept EqualityComparable =
requires (T a, T2 b) {
{ a == b } -> Boolean;
{ a != b } -> Boolean;
{ b == a } -> Boolean;
{ b != a } -> Boolean;
};
typename T2 = T 表示如果我们不指定第二个模板参数, T2 将与 T 相同; T 是一个 默认模板参数 。
我们可以这样测试 EqualityComparable :
static_assert(EqualityComparable<int, double>); // 成功
static_assert(EqualityComparable<int>);// 成功(T2 默认为 int)
static_assert(!EqualityComparable<int, string>); // 失败
这个 EqualityComparable 几乎与标准库中的 equality_comparable (§14.5)完全相同。
我们现在可以定义一个概念,要求数字间的算术运算有效。首先我们需要定义 Number :
template<typename T, typename U = T>
concept Number =
requires(T x, U y) {
x + y; x - y; x * y; x / y;
x += y; x -= y; x *= y; x /= y;
x = x; // 复制
x = 0; // 赋零值
};
这里不假设结果类型,但对于简单用途已经足够。给定一个参数类型, Number<X> 检查 X 是否具有作为 Number 所需的属性。给定两个参数, Number<X,Y> 检查这两个类型是否能一起使用所需的操作。据此,我们可以定义我们的 Arithmetic 概念(§8.2.1):
template<typename T, typename U = T>
concept Arithmetic = Number<T,U> && Number<U,T>;
为了更复杂的例子,考虑一个序列:
template<typename S>
concept Sequence =requires (S a) {
typename range_value_t<S>;// S 必须有一个value type
typename iterator_t<S>; // S 必须有一个iterator type
{ a.begin() } -> same_as<iterator_t<S>>;// S 必须有返回迭代器的 begin() 和 end()
{ a.end() } -> same_as<iterator_t<S>>;
requires input_iterator<iterator_t<S>>;// S 的迭代器必须是 input_iterator
requires same_as<range_value_t<S>, iter_value_t<S>>;
}
对于一个类型 S 来说,要成为一个 Sequence ,它必须提供一个值类型(其元素的类型;参见§13.1)和一个迭代器类型(其迭代器的类型)。这里,我使用了标准库关联类型 range_value_t<S> 和 iterator_t<S> (§16.4.4)来表达这一点。它还必须确保存在 begin() 和 end() 函数,它们返回 S 的迭代器,这是标准库容器的习惯用法(§12.3)。最后,S的迭代器类型至少要是 input_iterator ,元素的值类型和迭代器的值类型必须相同。
最难定义的概念是那些代表基本语言概念的概念。因此,最好使用一套成熟的库中的概念集。对于一个有用的集合,请参阅§14.5。特别地,有一个标准库概念允许我们绕过 Sequence 定义的复杂性:
template<typename S>
concept Sequence = input_range<S>;// 书写简单且通用
如果我将“ S 的值类型”限制为 S::value_type ,我本可以使用一个简单的 ValueType :
template<class S>
using ValueType = typename S::value_type;
这是一种有用的技术,可以简洁地表达简单的概念,并隐藏复杂性。标准库 value_type_t 的定义本质上相似,但由于它处理了没有名为 value_type 成员的序列(例如,内置数组),所以稍微复杂一些。
8.2.4.1 定义检查
为模板指定的概念用于在模板使用点检查参数。它们不用于检查模板定义中参数的使用情况。例如:
template<equality_comparable T>
bool cmp(T a, T b) {
return a < b;
}
在这里,概念保证了 == 的存在,但不保证 < 的存在:
bool b0 = cmp(cout, cerr);// 错误:ostream 不支持 ==
bool b1 = cmp(2, 3);// 正确:返回 true
bool b2 = cmp(2+3i, 3+4i);// 错误:complex<double> 不支持 <
概念的检查捕获了尝试传递 ostreams 的行为,但由于整型 int 和复数 complex<double> 支持 == ,因此接受 ints 和 complex<double> 。然而, int 支持 < ,所以 cmp(2, 3) 能够编译,而当 cmp() 的主体为不支持 < 的 complex<double> 类型实例化并检查时, cmp(2+3i, 3+4i) 被拒绝。
延迟模板定义的最终检查直到实例化时间带来了两个好处:
• 我们可以在开发期间使用不完整概念。这使我们在开发概念、类型和算法时能够积累经验,并逐步改善检查。
• 我们可以在不改变接口的情况下向模板插入调试、跟踪、遥测等代码。更改接口可能导致大规模的重新编译。
在开发和维护大型代码库时,这两点都非常重要。我们为此重要优势付出的代价是,某些错误(例如,在只保证了 == 的地方使用 < )会在编译过程的后期才被捕获(§8.5)。
8.2.5 概念与auto
关键字 auto 可用于指示对象应具有其初始化器的类型(§1.4.2):
auto x = 1; // x 是一个 int
auto z = complex<double>{1,2}; // z 是一个 complex<double>
然而,初始化不仅仅发生在简单的变量定义中:
auto g() { return 99; } // g() 返回一个 int
int f(auto x) { /* ... */ } // 接受任意类型的参数
int x = f(1);// 这里的 f() 接受一个 int
int z = f(complex<double>{1,2});// 这里的 f() 接受一个 complex<double>
关键字 auto 表示一个值的最不受限制的概念:它仅仅要求它必须是某种类型的值。使用 auto 参数使得函数变为函数模板。
有了概念,我们可以通过在 auto 前加上概念来强化所有此类初始化的要求。例如:
auto twice(Arithmetic auto x) { return x+x; }// 仅限数字
auto thrice(auto x) { return x+x+x; }// 适用于任何有 "+" 运算的对象
auto x1 = twice(7);// 正确:x1==14
string s = "Hello ";
auto x2 = twice(s);// 错误:字符串不是 Arithmetic
auto x3 = thrice(s);// 正确:x3=="Hello Hello Hello "
除了用于约束函数参数外,概念还可以约束变量的初始化:
auto ch1 = open_channel("foo");// 适用于open_channel()返回的任何类型
Arithmetic auto ch2 = open_channel("foo");// 错误: Channel不是 Arithmetic
Channel auto ch3 = open_channel("foo");// 正确:假设Channel是一个合适的概念 并且open_channel()返回Channel
这对于对抗 auto 的过度使用和记录使用泛型函数的代码需求非常有用。
为了可读性和调试,通常让类型错误尽可能靠近其源头被捕获。约束返回类型可以有所帮助:
Number auto some_function(int x)
{
// ...
return fct(x); // 除非fct(x)返回一个Number,否则这是错误的
// ...
}
自然,我们可以通过引入局部变量来实现这一点:
auto some_function(int x)
{
// ...
Number auto y = fct(x);// 除非fct(x)返回一个Number,否则这是错误的
return y;
// ...
}
然而,这种方法有点啰嗦,并且不是所有类型都能被廉价地复制。
8.2.6 概念与类型
一个类型
- 指定了可以隐式和显式应用于对象的操作集
- 依赖于函数声明和语言规则
- 指定了对象在内存中的布局方式
单一参数概念
- 指定了可以隐式和显式应用于对象的操作集
- 依赖于反射函数声明和语言规则的使用模式
- 对象的布局方式不做说明
- 使一组类型得以应用
因此,使用概念约束代码比使用类型约束提供了更大的灵活性。此外,概念可以定义多个参数之间的关系。我的理想是,最终大多数函数都将定义为模板函数,其参数由概念约束。不幸的是,这方面的符号支持还不完美:我们必须将概念作为一个形容词而非名词来使用。例如:
void sort(Sortable auto&);// 需要 'auto'
void sort(Sortable&);// 错误:概念名称后需要 'auto'
8.3 泛型编程
C++直接支持的 泛型编程 形式主要围绕着从具体、高效的算法中抽象出通用算法这一思想,这些通用算法可以与不同的数据表示相结合,以生成多种多样的实用软件[[Stepanov, 2009]]。代表基本操作和数据结构的这些抽象称为 概念 。
8.3.1 概念的使用
好的、有用的概念是基础性的,更多是被发现而不是被设计出来的。例如,整数和浮点数、序列,以及更广泛的数学概念,如环和向量空间。它们代表了一个应用领域的基本概念。这就是为什么它们被称为“概念”。为了有效泛型编程,识别并形式化到必要程度的概念可能是一个挑战。
对于基本使用,可以考虑 regular 概念(§14.5)。一个类型当其行为类似于 int 或 vector 时,就被认为是常规的。一个常规类型的对象
- 能够被默认构造。
- 能够被复制(具有复制的常规语义,产生两个独立且相等的对象),使用构造函数或赋值操作。
- 能够使用 == 和 != 进行比较。
- 不会因为过度巧妙的编程技巧而出现技术问题。
字符串是另一个常规类型的例子。像 int 一样,字符串也是 totally_ordered (§14.5)。也就是说,两个字符串可以使用 < , <= , > , >= ,以及 <=> 进行比较,并具有适当的语义。
概念不仅仅是一个语法概念,它本质上关乎语义。例如,不要定义 + 来进行除法运算;这不符合任何合理数字的要求。不幸的是,我们还没有任何语言层面的支持来表达语义,所以我们必须依靠专家知识和常识来获得语义上有意义的概念。不要定义语义上无意义的概念,比如 Addable 和 Subtractable 。相反,应依赖于领域知识来定义与应用领域中基本概念相匹配的概念。
8.3.2 使用模板进行抽象
优秀的抽象源自对具体实例的精心提炼。试图“抽象”以应对每一个可能的需求和技术并不是一个好主意;这样做往往会导致不优雅和代码膨胀。相反,应从实际使用的至少一个(最好多个)具体示例出发,并尝试消除非本质细节。例如以下代码:
double sum(const vector<int>& v)
{
double res = 0;
for (auto x : v)
res += x;
return res;
}
这显然是计算一系列数字之和的多种方法之一。
考虑是什么让这段代码不够通用:
- 为什么只处理 int 类型?
- 为什么只能应用于 vector 容器?
- 为什么累加到 double 类型中?
- 为什么初始值为 0 ?
- 为什么执行加法操作?
通过将具体类型转化为模板参数来回答前四个问题,我们得到了标准库中 accumulate 算法的最简单形式:
template<typename Iter, typename Val>
Val accumulate(Iter first, Iter last, Val init)
{
for (; first != last; ++first)
init = init + *first;
return init;
}
这里,我们做了如下抽象:
- 遍历的数据结构被抽象为一对迭代器,代表了一个序列(见第8.2.4节,第13.1节)。
- 累加器的类型成为了一个参数。
- 累加器的类型必须是算术类型。
- 累加器的类型需要与迭代器的值类型(即序列的元素类型)兼容。
- 初始值现在作为输入参数,累加器的类型就是这个初始值的类型。
快速检查或测量会显示,对于使用了多种数据结构的调用,生成的代码与手工编写的例子完全相同。例如:
void use(const vector<int>& vec, constlist<double>& lst)
{
auto sum = accumulate(vec.begin(), vec.end(), 0.0); // 累加到double
auto sum2 = accumulate(lst.begin(), lst.end(), sum);
// ...
}
从具体代码段(最好来自多个实例)进行泛化,同时保持性能不变的过程称为 提升 (lifting)。相反地,开发模板的最佳方式往往是:
- 首先 ,编写一个具体版本;
- 然后 ,调试、测试并测量它;
- 最后 ,将具体类型替换为模板参数。
自然地,重复使用 begin() 和 end() 是繁琐的,因此我们可以简化用户接口:
template<typename Range, typename Val = typename Range::value_type>
Val accumulate(const Range& r, Val res = Val{})
{
for (auto& x : r)
res += x;
return res;
}
range 是标准库中的一个概念,表示具有 begin() 和 end() 的序列(见第13.1节)。为了达到最大程度的通用性,我们还可以进一步抽象 += 操作;详情见第17.3节。
无论是基于迭代器对的版本还是基于 range 的 accumulate() 函数,都有其各自的用途:前者为了实现更大的通用性,后者为了简化常见用例的使用。
8.4 可变参数模板
一种能够接受任意数量任意类型的参数的模板。考虑一个简单的函数,用于输出任何具有 << 运算符的类型的值:
void user() {
print("first: ", 1, 2.2, "hello\n"s);// first: 1 2.2 hello
print("\nsecond: ", 0.2, 'c', "yuck!"s, 0, 1, 2, '\n');// second: 0.2 c yuck! 0 1 2
}
传统上,实现可变参数模板的方法是将第一个参数与剩余参数分开,然后递归地为参数列表的尾部调用可变参数模板:
template<typename T>
concept Printable = requires(T t) { cout << t; }; // 就这一种操作
void print() {} // 无参数时的行为:什么都不做
template<Printable T, Printable... Tail>
void print(T head, Tail... tail) {
cout << head << ' '; // 首先处理head元素
print(tail...); // 然后递归处理tail
}
这里的 Printable... 表明 Tail 是一个类型序列,而 Tail... 表明 tail 是一个值序列,这些值的类型对应于 Tail 中的类型。带有 ... 的参数被称为 参数包 (parameter pack)。因此, print() 可以接受任意数量任意类型的参数。
每次调用 print() 时,参数会被分为头部(第一个)和尾部(其余部分)。头部被打印后,再对尾部调用 print() 。最终,尾部会变为空,此时需要无参数版本的 print() 来处理这种情况。如果我们不想允许零参数的情况,可以使用编译时 if 来移除那次调用:
template<Printable T, Printable... Tail>
void print(T head, Tail... tail) {
cout << head << ' ';
if constexpr (sizeof...(tail) > 0) {
print(tail...);
}
}
这里使用了编译时 if (而不是普通的运行时if),以避免在最终没有参数时生成多余的 print() 调用。如此一来,就不必定义那个空的 print() 了。
可变参数模板的优势在于它们能接受任意给定的参数。但它们也存在一些缺点:
- 递归实现可能难以正确编写。
- 接口的类型检查是一个可能相当复杂的模板程序。
- 类型检查代码是特设的,而非标准定义的。
- 递归实现可能会在编译时间和编译器内存需求方面产生惊人的开销。
由于其灵活性,可变参数模板在标准库中被广泛应用,有时甚至过度使用。
8.4.1 折叠表达式
为了简化简单可变参数模板的实现,C++提供了一种对参数包元素进行有限形式迭代的方法。例如:
template<typename... T>
auto sum(T... v)
{
return (v + ... + 0);// 将所有v中的元素累加至0
}
这个 sum() 函数可以接受任意数量任意类型的参数:
int x = sum(1, 2, 3, 4, 5); // x 变为 15
int y = sum('a', 2.4, x); // y 变为 114 ('a'的ASCII值为97,2.4被截断取整)
sum() 函数体使用了一个折叠表达式:
return (v + ... + 0);// 将所有v中的元素累加至0
这里的 (v+...+0) 意味着从初始值 0 开始,将所有 v 中的元素相加。第一个参与加法操作的元素是最右边的(索引最高): (v[0]+(v[1]+(v[2]+(v[3]+(v[4]+0))))) 。也就是说,加法是从最右边的0开始的,这称为右折叠(right fold)。相反,我们也可以使用左折叠(left fold):
template<typename... T>
auto sum2(T... v)
{
return (0 + ... + v); // 将所有v中的元素累加至0,从左开始
}
现在,首先参与加法操作的是最左边的元素(索引最低): ((((((0+v[0])+v[1])+v[2])+v[3])+v[4]) 。即从最左边的0开始。
折叠是一种强大的抽象概念,它与标准库中的 accumulate() 函数有直接关联,在不同的语言和社区中有多种命名。在C++中,当前折叠表达式的应用主要限于简化可变参数模板的实现。折叠操作不仅仅局限于数值计算。考虑一个著名的例子:
template<typename... T>
void print(T&&... args)
{
(cout << ... << args) << '\n';// 打印所有参数
}
调用示例:
print("Hello!"s, ' ', "World ", 2017);// 打印: Hello! World 2017
这里, (cout << ... << args) 展示了如何利用折叠表达式串联输出多个参数。至于为什么提到2017年,是因为折叠表达式是在2017年被添加到C++标准中的(参见标准文档的相关章节)。
8.4.2 转发参数
通过接口将参数原封不动地转发(Forwarding)是可变模板的一个重要应用。设想一个网络输入通道的概念,其中实际的值传输方法是一个参数。不同的传输机制具有不同的一组构造函数参数。
template<typename Transport>
class InputChannel {
public:
// ...
template<typename... Args>
InputChannel(Args&&... transportArgs)
: _transport(forward<Args>(transportArgs)...)
{}
// ...
private:
Transport _transport;
};
这里使用了标准库中的 forward() 函数(见第16.6节),目的是将参数从 InputChannel 的构造函数原封不动地转发给 Transport 的构造函数。
关键点在于, InputChannel 的编写者可以在不知道构建特定 Transport 对象所需参数的情况下,构造一个 Transport 类型的对象。实现 InputChannel 的程序员只需要了解所有 Transport 对象共有的用户接口。
转发在那些需要高度通用性、低运行时开销的基础库中非常常见,这类库通常拥有非常通用的接口。
8.5 模板编译模型
在模板的使用点,会将传入的参数与模板的概念要求进行比对。此处发现的错误会立即报告。而那些无法在此阶段检查的,如未受约束模板参数的参数,则会推迟到为模板及其一组模板参数生成代码时进行检查,即“在模板实例化时”。
模板实例化时类型检查的一个不幸副作用是,类型错误可能在相当晚的阶段才会被检测到(参见8.2.4.1节)。此外,延迟检查常常导致错误信息极其难懂,因为编译器缺乏类型信息来提示程序员的意图,并且通常只有在结合程序中多个位置的信息后才能发现问题。
模板提供的实例化时类型检查,旨在验证模板定义中参数的使用情况。这为所谓的“鸭子类型”提供了一种编译时变体(“如果它走起来像鸭子,叫声也像鸭子,那它就是鸭子”)。或者,使用更专业的术语来说——我们操作的是值,而操作的存在和意义仅取决于其操作数的值。这与另一种观点不同,该观点认为对象有其类型,类型决定了操作的存在和意义。值“居住”在对象中。这是C++中对象(例如,变量)的工作方式,只有满足对象要求的值才能放入其中。使用模板在编译时进行的大部分操作并不涉及对象,而只涉及值。例外情况是 constexpr 函数(参见1.6节)中的局部变量,它们在编译器内部作为对象被使用。
要使用无约束模板,不仅需要声明,其定义也必须在其使用点可见。当使用头文件和 #include 指令时,这意味着模板的定义位于头文件中,而不是 .cpp 文件中。例如,标准头文件 <vector> 包含了 vector 的定义。
当我们开始使用模块(参见3.2.2节)时,这一点会发生变化。使用模块后,普通函数和模板函数的源代码组织方式可以相同。模块会被半编译成一种表示形式,以便快速导入和使用。可以将这种表示形式想象成一个易于遍历的图,其中包含了所有可用的作用域和类型信息,并且有一个符号表支持对单个实体的快速访问。
8.6 建议
- 模板提供了一种编译时编程的通用机制;§8.1。
- 设计模板时,要仔细考虑为其模板参数假设的概念(需求);§8.3.2。
- 设计模板时,使用具体版本进行初始实现、调试和测量;§8.3.2。
- 将概念作为设计工具来使用;§8.2.1。
- 为所有模板参数指定概念;§8.2;[CG: T.10]。
- 尽可能使用命名的概念(例如,标准库中的概念);§8.2.4,§14.5;[CG: T.11]。
- 如果你只需要在一个地方使用简单的函数对象,请使用lambda;§7.3.2。
- 使用模板来表达容器和范围;§8.3.2;[CG: T.3]。
- 避免使用没有实际语义的“概念”;§8.2;[CG: T.20]。
- 为一个概念要求一套完整的操作;§8.2;[CG: T.21]。
- 使用命名概念 §8.2.3。
- 避免使用 requires requires ;§8.2.3。
- auto 是最不受限制的概念 §8.2.5。
- 当你需要一个可以接受多种类型可变数量参数的函数时,请使用可变参数模板;§8.4。
- 模板提供了编译时的“鸭子类型”特性;§8.5。
- 使用头文件时,在每个使用它们的翻译单元中包含模板定义(不仅仅是声明);§8.5。
- 要使用模板,请确保其定义(不仅仅是声明)在作用域内;§8.5。
- 无约束模板提供了编译时的“鸭子类型”特性;§8.5。