模板提供了哪些功能?模板应该用在什么地方?模板会使哪些程序设计技术更有效?
模板提供了,在不损失信息的前提下,将类型、值或者模板作为参数传递给其它实体的能力。这意味着,模板可以表达的内容具有更大的灵活性,同时为内联优化提供了绝佳的机会,当前的实现充分利用了这一点
模板有助于将不同的上下文信息捏合在一起,并进行有针对性的优化
将数值作为传递给模板的参数,充分发挥C++语言的编译时计算能力
总而言之,模板为类型参数化和编译时计算提供了强有力的支撑,帮助程序员编写出更加简洁高效的代码。类可以包含代码和数据,其中的类型一旦被参数化,将变得更加灵活和通用。
模板最常见的应用场景就是泛型编程。泛型编程专注于通用算法的设计、实现和使用。通用意味着这些算法将同时支持很多种数据类型,只要这些数据类型能满足算法对其能力的要求即可。模板是C++语言支持泛型编程的强有力的工具,它为程序代码提供了编译期的参数级多态性。
考虑如下sum函数:
xxxxxxxxxx
71template<typename Container, typename Value>
2Value sum(const Container& con, Value val) {
3 for (const auto& elem : con)
4 val += elem;
5
6 return val;
7}
能够传递给该函数的参数并不是完全随意的,它们必须满足如下条件:
第一个参数con必须是某种形式的容器,且要支持基于范围的for循环,即存在适当定义的begin和end函数
第二个参数val必须是可以计算的数值,至少要支持“+=”操作符,并能通过该操作符实现累加
对传递给该函数两个参数con和val的要求,其实是对实例化该模板的两个类型参数Container和Value的要求。这种对模板类型参数的能力要求,或者说约束,就叫作概念:
Container需要满足的概念约束可被简单表述为序列(Sequence),即支持范围循环的容器,如vector、deque和list等
Value需要满足的概念约束可被简单表述为数值(Number),即支持常规的算术运算,如int、double,甚至Matrix(所有合理定义的矩阵都应该支持算术运算)等
从以下两个维度上看,sum函数属于通用算法:
结构的类型具有一般性,不只限于特定的容器,满足序列概念约束的任何容器都可以
元素的类型具有一般性,不只限于特定的数据,满足数值概念约束的任何数据都可以
C++20:概念
关键字typename所表达的概念约束是最低程度的,它仅仅要求被它修饰的模板参数接受一个类型,而现实中的大多数模板参数所能接受的类型,都必须满足特定的条件才能被顺序编译并正确运行。也就是说,现实中的大多数模板其实都是受限模板,而真正意义上的有实用价值的无限模板,少到几乎可以忽略不计。重新考虑sum函数:
xxxxxxxxxx
71template<Sequence Seq, Number Num>
2Num sum(const Seq& seq, Num num) {
3 for (const auto& elem : seq)
4 num += elem;
5
6 return num;
7}
改进后的版本看起来清晰很多。模板函数sum的两个类型参数Seq和Num不再接受或者说不再表示任意的类型,它们只接受或表示满足Sequence和Number概念约束的类型。编译器一旦发现所传入的类型实参不满足由Sequence或Number所定义的概念约束,即报告错误,而不会再象以前那样,非要等到编译函数体代码时,才会发现问题。这使得有关错误的描述更加易于理解。
然而,仅做到这一步似乎还不够完美。Sequence概念保证了可以通过基于范围的for循环遍历seq中的元素,Number概念保证了对num对象执行“+=”操作是合法的,但还没有任何约束能够保证num和elem之间的算术运算是可行的。“Arithmetic<X,Y>”也是一个概念,它表示X和Y可以进行算术运算。例如:
xxxxxxxxxx
81template<Sequence Seq, Number Num>
2 requires Arithmetic<range_value_t<Seq>, Num>
3Num sum(const Seq& seq, Num num) {
4 for (const auto& elem : seq)
5 num += elem;
6
7 return num;
8}
标准库中的range_value_t用于提取参数容器中的元素类型,即elem的类型。包含requires关键字的子句正是为了保证在Num类型的num和range_value_t<Seq>类型的elem之间,可以进行算术运算。
完整的规格指定并不总是必须的,编译器该报错总是会报错的,尽管可能不太清晰,而且在实际开发中,有时可能很难从一开始就确知全部的类型需求。如果有一个成熟的概念库,那么完整的规格指定确实可以做到十全十美。
代码中的“requires Arithmetic<range_value_t<Seq>, Num>”称为requirements子句,是对概念的精准表达。而象“Sequence Seq”或“Number Num”的写法,其实是一种简化的概念表示。更复杂的写法可能是下面这样:
xxxxxxxxxx
81template<typename Seq, typename Num>
2 requires Sequence<Seq> && Number<Num> && Arithmetic<range_value_t<Seq>, Num>
3Num sum(const Seq& seq, Num num) {
4 for (const auto& elem : seq)
5 num += elem;
6
7 return num;
8}
或者采用更简单的写法:
xxxxxxxxxx
71template<Sequence Seq, Arithmetic<range_value_t<Seq>> Num>
2Num sum(const Seq& seq, Num num) {
3 for (const auto& elem : seq)
4 num += elem;
5
6 return num;
7}
无论采用何种写法,为模板参数明确其必须满足的约束条件,总是有意义的。
模板函数和普通函数,也可以重载。如果两个模板函数的签名完全相同,但对类型参数的概念约束不同,也可以构成重载关系,编译器会根据类型实参,选择对概念约束满足程度最高的版本。例如下面的advance函数,用于将某个容器的顺序迭代器,步进指定数量的元素:
xxxxxxxxxx
51template<forward_iterator Iter>
2void advance(Iter& it, int n) { // 慢速步进
3 while (n--)
4 ++it; // 顺序迭代器支持“++”操作,不支持针对整数的“+=”操作
5}
针对随机访问迭代器的重载版本:
xxxxxxxxxx
41template<random_access_iterator Iter>
2void advance(Iter& it, int n) { // 快速步进
3 it += n; // 随机访问迭代器支持针对整数的“+=”操作
4}
标准库中的list只提供顺序迭代器,而vector则提供随机访问迭代器,因此:
xxxxxxxxxx
91list<int> li{ 1, 2, 3, 4, 5 };
2list<int>::iterator lit{ li.begin() };
3::advance(lit, 4); // 慢速步进
4cout << *lit << endl; // 5
5
6vector<int> vi{ 1, 2, 3, 4, 5 };
7vector<int>::iterator vit{ vi.begin() };
8::advance(vit, 4); // 快速步进
9cout << *vit << endl; // 5
编译器会自动为list中的顺序迭代器,选择针对顺序迭代器的慢速步进版本,而为vector中的随机访问迭代器,选择针对随机访问迭代器的快速步进版本。
与普通函数的重载匹配一样,模板函数的重载匹配在编译阶段完成,不占用运行时间。如果编译器无法找到最佳匹配,通常会报告二义性错误。基于概念的重载匹配比一般重载匹配效率高。基于概念的匹配逻辑如下:
如果类型实参无法匹配某个重载版本的特定概念,那么编译器不会选择该版本
如果类型实参可以匹配某个重载版本的特定概念,且是唯一匹配,那么编译器会选择该版本
如果类型实参同时匹配两个重载版本的特定概念,但其中一个概念比另一个概念的约束性更强,即是另一个概念的完整子集,那么编译器会选择约束性强的版本
如果类型实参同时匹配多个重载版本的特定概念,且这些概念的约束性没有明显的强弱之分,那么编译器会报告二义性错误
除了基于概念的匹配逻辑以外,还要考虑调用参数的匹配逻辑。最终被选择的重载版本,需要满足以下条件:
所有参数都要匹配
至少有一个参数的匹配程度,不比其它版本更弱
至少有一个参数的匹配程度,相比其它版本更强
例如:
重载匹配 | 概念 | 参数1 | 参数2 | 参数3 |
---|---|---|---|---|
版本1 | 强约束,不匹配 | 强匹配 | 强匹配 | 强匹配 |
版本2 | 弱约束,匹配 | 强匹配 | 强匹配 | 强匹配 |
版本3 | 强约束,匹配 | 不匹配 | 匹配 | 强匹配 |
版本4 | 强约束,匹配 | 匹配 | 匹配 | 匹配 |
版本5 | 强约束,匹配 | 弱匹配 | 匹配 | 强匹配 |
综合考虑概念和参数的匹配情况,编译器最终选择了版本5。
模板中的代码是否有效,往往与传递给模板参数的具体类型是否满足特定要求有关。requires表达式用于检查这些代码的有效性。例如:
xxxxxxxxxx
51template<forward_iterator Iter>
2 requires requires(Iter& it, int n) { it[n]; it + n; }
3void advance(Iter& it, int n) { // 快速步进
4 it += n; // 随机访问迭代器支持针对整数的“+=”操作
5}
这是前面advance函数针对随机访问迭代器重载版本的另一种写法。这里没有强调模板的类型参数Iter必须满足random_access_iterator概念的约束,相反只要满足forward_iterator概念即可,但必须支持下标运算和对整数的加法运算,这是通过一个形如“requires(Iter& it, int n) { it[n]; it + n; }”的requires表达式指明的。注意这里使用了两个requires关键字。第一个requires引导requirements子句,表示这是一个概念约束。第二个requires引导requires表达式。requires表达式是一个谓词,花括号中的代码有效,其值为true,否则为false。requirements子句中的谓词为true,表示满足概念约束。
事实上,requires表达式的写法过于底层,极有可能遗漏某些必要的检查,比如这里就没有强调对“it += n”的有效性检查。这将导致即使通过了概念检查,也未必就能通过编译。就如同在高级语言代码中嵌入汇编代码一样,requires表达式不应出现在常规代码中,而更应隐藏于抽象的具体实现中,比如random_access_iterator概念的定义内部。前面代码中的“requires requires ...”是一种刻意的、不优雅的黑客写法。正确的针对随机访问迭代器的重载版本更简单,可读性也更好。例如:
xxxxxxxxxx
41template<random_access_iterator Iter>
2void advance(Iter& it, int n) { // 快速步进
3 it += n; // 随机访问迭代器支持针对整数的“+=”操作
4}
只要有可能,尽量使用定义良好的命名概念,比如random_access_iterator等,而requires表达式仅用于定义这些概念。
标准库预定义了很多有用的概念,如forward_iterator、random_access_iterator等。在定义模板类和模板函数时,直接使用现成的概念,显然比自己写一个全新的,要容易得多。但在某些特殊的场合,自己定义概念还是有必要的。标准库中的概念通常采用全小写,以下划线分隔单词的命名风格,而自定义概念则更倾向于采用单词首字母大写,无分隔符的命名风格,如Sequence、Number等,以示区分。
概念的本质是一个代表类型约束的谓词,在编译时计算其值——true或者false,true表示类型满足约束,false表示不满足。
例如:
xxxxxxxxxx
51template<typename T>
2concept EqualityComparable = requires(T a, T b) {
3 { a == b } -> Boolean; // 借助“==”操作符对T类型对象做相等性比较
4 { a != b } -> Boolean; // 借助“!=”操作符对T类型对象做不等性比较
5};
EqualityComparable是一个保证类型可做相等性和不等性比较的概念。只有同时支持“==”和“!=”操作符,且表达式返回布尔值的类型,才满足此概念所表达的约束。例如:
xxxxxxxxxx
31cout << boolalpha << EqualityComparable<int> << endl; // true
2class Dummy {};
3cout << EqualityComparable<Dummy> << endl; // false
概念EqualityComparable的定义与它命名一样——相等性可比较——用于检测一个类型是否满足“相等性可比较”的约束。concept关键字后面紧跟概念的名字。等号(=)后面是一个requires表达式。花括号中的代码有效,概念的值为true,否则为false。概念的值一定是bool类型的。
对“{ ... }”返回值的类型约束在“->”后面指定,它必须也是一个概念。这里要求它必须是布尔类型。表示布尔类型的概念定义如下:
xxxxxxxxxx
91template<typename B>
2concept Boolean = requires(B x, B y) {
3 { x = true };
4 { x = false };
5 { x = (x == y) };
6 { x = (x != y) };
7 { x = !x };
8 { x = (x = y) };
9};
要想让EqualityComparable概念支持不同类型对象间的相等性和不等性比较,可将其定义修改为:
xxxxxxxxxx
71template<typename T1, typename T2 = T1>
2concept EqualityComparable = requires(T1 a, T2 b) {
3 { a == b } -> Boolean;
4 { a != b } -> Boolean;
5 { b == a } -> Boolean;
6 { b != a } -> Boolean;
7};
其中“typename T2 = T1”的写法表示,如果没有为T2提供类型实参,那么T2就取传递给T1的类型实参。这里的T1称为T2的默认模板参数。以下对修改后的EqualityComparable概念的测试代码:
xxxxxxxxxx
31cout << boolalpha << EqualityComparable<int> << endl; // true
2cout << EqualityComparable<int, double> << endl; // true
3cout << EqualityComparable<int, string> << endl; // false
这里的EqualityComparable与标准库的equality_comparable几乎没有区别。
以下是一个关于数值(Number)的概念:
xxxxxxxxxx
61template<typename U, typename V = U>
2concept Number = requires(U u, V v) {
3 u + v; u - v; u * v; u / v;
4 u += v; u -= v; u *= v; u /= v;
5 u = u; u = 0;
6};
这里对表达式返回值的类型没有做出限制,但对于一般性使用而言已经足够了。给定一个类型参数时,“Number<X>”检查X是否具备Number所描述的种种特性。给定两个类型参数时,“Number<X, Y>”检查X和Y是否可以共同执行Number所描述的各种操作。
以此为基础,还可以定义一个表示算术(Arithmetic)的概念:
xxxxxxxxxx
21template<typename U, typename V = U>
2concept Arithmetic = Number<U, V> && Number<V, U>;
下面是一个更复杂的例子,表示序列(Sequence)的概念:
xxxxxxxxxx
111template<typename S>
2concept Sequence = requires(S seq) {
3 typename range_value_t<S>; // S必须拥有元素类型
4 typename iterator_t<S>; // S必须拥有迭代器类型
5
6 { seq.begin() } -> same_as<iterator_t<S>>; // S必须有begin函数,该函数必须返回一个迭代器
7 { seq.end() } -> same_as<iterator_t<S>>; // S必须有end函数,该函数必须返回一个迭代器
8
9 requires input_iterator<iterator_t<S>>; // S的迭代器必须是输入迭代器
10 requires same_as<range_value_t<S>, iter_value_t<S>>; // S的迭代器指向元素
11};
一个类型要想成为序列(Sequence)必须同时满足以下三个条件:
提供元素类型和迭代器类型。这里借助标准库的关联类型“range_value_t<S>”和“iterator_t<S>”表示
提供begin和end两个成员函数,且都返回迭代器
其迭代器必须是输入迭代器,且指向容器中的元素
最难定义的概念往往是那些表示语言基础元素的底层概念。在现成概念库的基础上定义新概念会简单得多。比如在标准库input_range概念的基础上定义Sequence概念:
xxxxxxxxxx
21template<typename S>
2concept Sequence = input_range<S>; // 足够通用,书写简单
任何满足“typename range_value_t<S>”条件的类型,都会在其内部用value_type表示元素的类型。因此可以通过下面的写法为其定义别名:
xxxxxxxxxx
21template<typename S>
2using ValueType = S::value_type; // 容器中元素的类型
这里的ValueType与标准库的value_type_t类似,但后者要更复杂一些,因为它还需要处理不包含value_type成员类型的情况,比如内置数组。
模板中的概念,是用来检查实例化模板时,所提供的类型实参的,并不用于检查模板的定义。例如:
xxxxxxxxxx
41template<equality_comparable T>
2bool less(T a, T b) {
3 return a < b;
4}
这里的equality_comparable概念,要求传递给模板参数T的实参类型,必须支持基于“==”操作符的相等性比较。但在该模板函数的定义中,实际上是通过该类型的“<”操作符做小于比较。二者间的矛盾并不会被编译器察觉。直到该模板函数被真正实例化(调用)时,编译器才会发现不对劲。例如:
xxxxxxxxxx
31bool b1 = less(cout, cerr); // 编译错误,ostream不支持“==”操作符(不满足概念)
2bool b2 = less(1234, 5678); // 通过编译,int既支持“==”操作符(满足概念),也支持“<”操作符(满足使用)
3bool b3 = less(1+2i, 3+4i); // 编译错误,complex<double>支持“==”操作符(满足概念),但不支持“<”操作符(不满足使用)
在概念检查阶段,ostream不能通过检查,因其不满足equality_comparable概念对“==”操作符的要求,但int和complex<doduble>可以通过检查,因为它们都支持“==”操作符。但在实际编译函数体代码时,编译器发现complex<doduble>并不支持“<”操作符,故而报告错误。
将最终检查从模板定义推迟到模板实例化时,有两个好处:
在开发过程中可以使用不完整的概念,在积累经验的同时,渐进式地完善检查
将用于调试、跟踪和测试的代码插入模板,而不影响其接口,避免大量重编译
以上两点对于开发和维护大型代码来说非常重要。获得这个重要好处的代价,仅仅是一部分错误可能会在比较晚的时候才被编译器发现并报告出来,比如上面例子中complex<double>支持“==”操作符(满足概念),但不支持“<”操作符(不满足使用)的情形。
关键字auto表示一个对象的类型与其初始化描述符(初始值)的类型相同。例如:
xxxxxxxxxx
21auto x = 1; // 1是int类型的,因此x也是int类型的
2auto y = complex<double>{ 2, 3 }; // 2+3i是complex<double>类型的,因此y也是complex<double>类型的
然而,初始化描述符并不仅仅出现在简单的变量定义中。例如:
xxxxxxxxxx
71auto foo() {
2 return 1; // foo函数的返回值是int类型的
3}
4
5auto bar() {
6 return complex<double>{ 2, 3 }; // bar函数的返回值是complex<double>类型的
7}
又如:
xxxxxxxxxx
31void fun(auto arg) { // fun函数的参数arg可以是任意类型的
2 ...
3}
xxxxxxxxxx
21fun(1); // fun函数的参数arg是int类型的
2fun(complex<double>{ 2, 3 }); // fun函数的参数arg是complex<double>类型的
关键字auto表示对类型的最小约束,只要是类型即可,具体什么类型不限。在参数中使用auto,会将一个函数变成模板函数。例如上面的fun函数与下面的形式等价:
xxxxxxxxxx
41template<typename T>
2void fun(T arg) {
3 ...
4}
既然可以通过概念限制模板的类型参数。例如:
xxxxxxxxxx
41template<Arithmetic T> // T必须满足Airthmetic概念的约束,即必须是一种算术类型
2void fun(T arg) {
3 ...
4}
那么也应该可以通过概念限制auto关键字所能表示的类型。例如:
xxxxxxxxxx
31void fun(Arithmetic auto arg) { // fun函数的参数arg必须是满足Airthmetic概念约束的算术类型
2 ...
3}
通过概念约束auto关键字所能表示的类型,可以强化对初始化需求的限定。例如:
xxxxxxxxxx
31auto twice(Arithmetic auto x) { // 只接受算术类型的对象作为参数
2 return x + x;
3}
xxxxxxxxxx
31auto thrice(auto x) {
2 return x + x + x; // 任何支持“+”操作符的对象都可作为参数
3}
xxxxxxxxxx
31auto y1 = twice(7); // 通过编译,x=7,auto=int,int满足Arithmetic,y1=14
2auto y2 = twice("7"s); // 编译错误,x="7"s,auto=string,string不满足Arithmetic
3auto y3 = thrice("7"s); // 通过编译,x="7"s,auto=string,string支持“+”操作符,y3="777"
概念除了可以约束函数参数的类型以外,还可以约束函数返回值的类型。例如:
xxxxxxxxxx
31Arithmetic auto arith(void) {
2 return "7"s; // 编译错误,auto=string,string不满足Arithmetic
3}
甚至可以在常规的变量定义中约束其类型。例如:
xxxxxxxxxx
21Arithmetic auto y4 = 7; // 编译通过,auto=int,int满足Arithmetic,y4=7
2Arithmetic auto y5 = "7"s; // 编译错误,auto=string,string不满足Arithmetic
在泛型编程中,使用概念可以有效遏制auto关键字的滥用,并为数据在类型方面的限制,提供可读性良好的注脚。同时,与类型有关的编译错误,也会因概念的使用,而与导致错误的源头更加接近,易于排查和调试。
C++17:泛型值模板参数(auto模板参数)
类型是对对象的描述,而概念则是对类型的描述。一个类型可以定义多个对象,它们都符合该类型所描述的特征。一个概念则囊括了多个类型,它们都满足该概念所限定的约束。例如:
又如:
类型与概念的区别与联系如下表所示:
类型 | 概念 |
---|---|
指定可被应用于一个对象的操作集 无论是隐式指定的还是显式指定的 | 指定可被应用于一个类型的操作集 无论是隐式指定的还是显式指定的 |
依赖于语言规则 | 依赖于使用模式 |
与对象的内存结构有关 | 与对象的内存结构无关 |
对一组具有相同特征的对象的抽象 | 对一组满足相同约束的类型的抽象 |
基于概念编写的程序比基于类型编写的程序更加灵活,而且概念还可以表达几个不同类型间互操作的规则。理想的情况是,绝大多数函数都应该是受概念约束的模板函数,只是目前的语言支持还不够完善,只能把概念当作形容词,而无法将其当作名词。例如:
xxxxxxxxxx
11void sort(Sortable auto&); // auto关键字不可省略
理想中的代码应该是这样的:
xxxxxxxxxx
11void sort(Sortable&);
C++直接支持的泛型编程,紧紧围绕这样的核心思维:从具体、高效的逻辑中抽象出来,从而获得可与不同数据表示相结合的泛型算法,以生成各种有用的软件。表示基本操作和数据结构的抽象被称为概念。
基本的、有用的、好用的概念,更多是被发现而非发明出来的。比如整数、浮点数、序列,以及更通用的数学概念,比如环、向量空间等。它们都是某个应用领域中的基础概念,这就是它们被称为“概念”的原因。识别出概念,并将其形式化到可用于泛型编程的程度,是一个挑战。
比如regular(规则)可被视为一种概念。符合该概念的类型具备如下特性:
拥有默认构造函数
支持完整意义上的拷贝语义
可进行相等性(==)和不等性(!=)的比较判断
不会因某些过于聪明的编程技巧而陷入技术泥潭
标准库中的string类型就十分满足regular概念的要求。类似地,totally_ordered(完全有序)也是一个概念,属于该概念的类型需要在满足regular概念的前提下,同时支持“<”、“<=”、“>”、“>=”和“<=>”等操作符。string类型显然也满足totally_ordered概念的要求。
概念不仅仅是一个语法记法,它还是描述语义的基本要素。例如将一种表示数字的类型的“+”操作符,实现为除法运算,是不能被接受的。因为那样显然违背了数字概念的要求。但如果真的这样做了,至少在目前阶段,编译器并不会察觉到有任何不妥。概念语义的正确性,完全取决于程序编写者的专业知识和对大众共识的接纳程度。例如象Addable(可加的)和Subtractable(可减的)这样的概念。何为可加?怎么又算可减?针对不同的应用场景可能会有完全不同的解释,这严重依赖于应用程序相关领域的知识和常识。
好的抽象往往脱胎于具体案例。因此,不建议在还没有准备好具体需求和技术之前就试图进行抽象,那样做不但会尽失优雅,还有可能导致代码膨胀。建议先从实际的具体需求出发,编写最朴素的代码,然后再逐步将与具体细节无关的部分抽象出来。例如:
xxxxxxxxxx
61double sum(const vector<int>& vec) {
2 double res = 0;
3 for (auto elem : vec)
4 res += elem;
5 return res;
6}
显然,上述代码用来计算整数向量中的元素之和已经足够了,但是什么让它不够通用呢?
为什么元素的类型必须是int?
为什么容器的类型必须是vector?
为什么结果的类型必须是double?
为什么结果的初值必须是0?
为什么必须用加法?
前4个问题可以通过把具体类型换成类型参数来解决,这样就得到了标准库accumulate算法的简化版本。例如:
xxxxxxxxxx
61template<forward_iterator Iter, Arithmetic<iter_value_t<Iter>> Val>
2Val accumulate(Iter begin, Iter end, Val res) {
3 for (auto it = begin; it != end; ++it)
4 res += *it;
5 return res;
6}
这里做了如下改进:
遍历数据结构的行为被抽象为一对迭代器,而序列也用这对迭代器表示
结果的类型被参数化
迭代器目标的类型,即序列中元素的类型,必须可以和结果的类型进行算术运算
结果的初值变成可输入的
通过简单的测试,验证其泛型特性:
xxxxxxxxxx
21vector<int> vec{ 1, 2, 3, 4, 5 };
2cout << accumulate(begin(vec), end(vec), 0.0) << endl; // 15
xxxxxxxxxx
21list<double> lst{ 1.1, 2.2, 3.3, 4.4, 5.5 };
2cout << accumulate(begin(lst), end(lst), 0) << endl; // 15
从一段或几段具体代码中抽象出一段泛型代码,同时保持其原有的性能,这种行为叫作提升。模板开发的最佳实践通常是这样的:
首先,编写一个针对具体类型的,非模板化实现
其次,测试、调试,保证功能正确,优化其性能
最后,将具体类型替换为参数化类型,遂得模板
当然,参数表中两个迭代器型的参数略显啰嗦,为此,可将该函数的接口做更进一步的简化。例如:
xxxxxxxxxx
61template<forward_range Ran, Arithmetic<range_value_t<Ran>> Val>
2Val accumulate(const Ran& ran, Val res) {
3 for (auto elem : ran)
4 res += elem;
5 return res;
6}
相应的测试代码如下:
xxxxxxxxxx
21vector<int> vec{ 1, 2, 3, 4, 5 };
2cout << accumulate(vec, 0.0) << endl; // 15
xxxxxxxxxx
21list<double> lst{ 1.1, 2.2, 3.3, 4.4, 5.5 };
2cout << accumulate(lst, 0) << endl; // 15
这里的forward_range是标准库预定义的概念,表示一个支持通过begin和end函数,获取起始和终止迭代器的序列。只用这样的序列,才能通过基于范围的for循环,遍历其中的元素。
如果想获得更进一步的抽象,可以考虑把“+=”操作符也抽象出来。
对于accumulate模板函数而言,无论是一对迭代器的版本,还是范围的版本,都很有用。一对迭代器的版本更通用,而范围的版本更简单。
定义模板时可以令其接受任意数量、任意类型的模板实参,这样的模板称为可变参数模板。假如需要实现一个简单的函数,打印任意数量个可被“<<”操作符插入输出流的数据。例如:
xxxxxxxxxx
21print("first:", 1, 2.2, "hello"s);
2print("second:", 0.2, 'c', "yuck!"s, 0, 1, 2);
传统的做法是先将第一个参数剥离出来,然后用递归的方式处理其余的参数,注意递归终止时的特殊处理。例如:
xxxxxxxxxx
141template<typename T>
2concept Printable = requires(T t) {
3 cout << t;
4};
5
6void print() {
7 cout << endl; // 处理无参数的情况
8}
9
10template<Printable First, Printable... Others>
11void print(First first, Others... others) {
12 cout << first << ' '; // 首先操作第一个参数
13 print(others...); // 然后操作其余的参数
14}
这里加了省略号的Printable,表示Others是包含多个可打印类型的序列,而加了省略号的Others,则表示others是与该序列中的各个类型相对应的值序列,最后加了省略号的others,表示这个值序列中的每个值。总之,位于省略号后面的参数称为参数包。“Printable... Others”中的Others称为模板参数包,而“Others... others”则称为调用参数包。
每次调用print函数都把参数分为第一个参数和其余的参数。对第一个参数,直接通过与之匹配的“<<”操作符将其插入标准输出流。而对其余的参数,则继续调用print函数。最终,others参数包将为空,这时将调用不带参数的print函数,以处理无参数的情况。借助编译时if也可以解决这个问题。例如:
xxxxxxxxxx
81template<Printable First, Printable... Others>
2void print(First first, Others... others) {
3 cout << first << ' '; // 首先操作第一个参数
4 if constexpr (sizeof...(others) > 0)
5 print(others...); // 然后操作其余的参数
6 else
7 cout << endl;
8}
在这里使用编译时if而不是运行时if,可以避免对无参print函数的调用,即无须定义不带参数的print函数。
可变参数模板的强大之处在于,它们可以接受任意数量和类型的参数,但其缺点也是显而易见的,比如:
递归实现可能需要一些技巧,容易出错
可能需要一个精心设计的模板程序,才能方便地对接口的类型进行有效检查
类型检查代码是临时的,而非标准定义的
递归实现在编译时的开销可能非常昂贵,会占用大量的编译器资源
可变参数模板具有很强的灵活性,其在标准库中的应用十分广泛,甚至偶尔被过度使用。
C++11:可变参数模板
为了简化可变参数模板的实现,C++提供了有限形式的迭代器,用于遍历参数包中的参数。例如:
xxxxxxxxxx
41template<Number... Numbers>
2double sum(Numbers... numbers) {
3 return (numbers + ... + 0.0); // 将numbers中的所有参数与0累加
4}
这个sum函数可以接受任意数量和类型的参数。例如:
xxxxxxxxxx
31cout << sum(1, 2, 3, 4, 5) << endl; // 15
2cout << sum(1.1, 2.2, 3.3, 4.4, 5.5) << endl; // 16.5
3cout << sum(1, 2l, 3ll, 4.4f, 5.5) << endl; // 15.9
在sum函数的函数体中使用了形如“(numbers + ... + 0.0)”的折叠表达式。该表达式会被编译器扩展为类似“(numbers[0]+(numbers[1]+(numbers[2]+(numbers[3]+(numbers[4]+0.0)))))”的形式。这种形式的折叠称为右折叠。当然,还有左折叠。例如:
xxxxxxxxxx
41template<Number... Numbers>
2double sum(Numbers... numbers) {
3 return (0.0 + ... + numbers); // 将0与numbers中的所有参数累加
4}
其中的左折叠表达式“(0.0 + ... + numbers)”被编译器扩展为“(((((0.0+numbers[0])+numbers[1])+numbers[2])+numbers[3])+numbers[4])”的形式。两种折叠形式在本例中的计算结果是相同的。
折叠是非常有用的抽象。它与标准库的accumulate函数相关。该函数在不同的编程语言及社区中有不同的名字。在C++中,折叠表达式目前仅限于简化可变参数模板的实现。当然,折叠表达式并非只能处理算术运算。例如:
xxxxxxxxxx
41template<Printable... Args>
2void print(Args&&... args) {
3 (cout << ... << args) << endl;
4}
其中的左折叠表达式“(cout << ... << args)”被编译器扩展为“(((((cout<<args[0])<<args[1])<<args[2])<<args[3])<<args[4])”。测试代码如下:
xxxxxxxxxx
21print("first: ", 1, ' ', 2.2, " hello"s);
2print("second: ", 0.2, ' ', 'c', " yuck! "s, 0, ' ', 1, ' ', 2);
折叠表达式语法自C++17标准引入。
C++17:折叠表达式
在如下模板函数中:
xxxxxxxxxx
41template<typename T>
2void fun(T&& r) {
3 foo(forward<T>(r));
4}
类型为“T&&”的引用r称为通用引用,若实参为左值,则r被推断为左值引用,若实参为右值,则r被推断为右值引用。forward函数可将传递给它的左值引用或右值引用型参数,处理为左值或右值。若r为左值引用,则“forward<T>(r)”为左值,若r为右值引用,则“forward<T>(r)”为右值。传递给fun函数的左值或右值实参,被原封不动地,以左值或右值的形式转发给了foo函数,这就叫完美转发。
在使用可变参数模板时,保证参数在传递过程中的左右值属性不变,有时可能会非常重要。例如:
xxxxxxxxxx
131template<concepts::InputTransport Transport>
2class InputChannel {
3public:
4 InputChannel(Transport::Args&&... args)
5 : m_transport(forward<Transport::Args>(args)...) {
6 ...
7 }
8 ...
9
10private:
11 Transport m_transport;
12 ...
13};
输入通道(InputChannel)通过构造函数接受用户传入的不定数量和类型的参数,然后原封不动地传递给其Transport类型的传输子对象m_transport的构造函数。在这个过程中需要保证每个参数的左右值属性不变,为此使用了完美转发。
这里需要注意一点,InputChannel只负责构造Transport对象,而无须了解Transport构造函数的参数类型。InputChannel的实现者只需要知道Transport对象的公共接口即可。
完美转发在基础库中非常常见。在这些库中,通用性和低运行时开销是必须的,并且常常需要非常通用的接口。
在使用模板时,编译器根据概念的定义检查模板参数,此时发现的错误将被立即报告,而那些无法发现的错误则被推迟到代码生成阶段,即模板被实例化时再报告,比如无约束的模板参数即是如此。
在实例化阶段报告类型错误的缺点在于,这个错误报告的时间有点晚,而对太晚检查出的错误,通常难以提供足够友好的描述信息。此时的编译器已无法获知程序员的实际意图,只能从程序的多个地方拼凑和猜测出问题所在。
为模板提供实例化时的类型检查,重点关注模板定义中对参数的使用情况。这种机制依赖于被称为鸭子类型(如果有种东西,走路象鸭子,叫声也象鸭子,那它就是鸭子)的编译时特性,即操作的效果和含义取决于被操作对象的值。这与通常所说的“对象的类型决定它的行为”不同,在那里,操作的效果和含义取决于被操作对象的类型。在C++中,值存在于对象内部,这是C++对象(即变量)的工作机制。某个值只有符合对象所属类型的要求,才能被放到该对象中。但对模板而言,编译器真正关心的是对象的值而非其类型。此处只有一个例外,constexpr函数中的局部变量,在编译器中,被作为有类型的对象看待。
要实例化一个模板,不能只看它的声明,还要看它的定义是否完整地呈现在当前作用域的可见范围内。如果使用头文件(即“#include”)机制,那么所有的模板函数必须在头文件(.h)中给出定义,而不能将定义写在源文件(.cpp)中。例如标准库的<vector>头文件即包含了对vector模板的完整定义。
这一点直到引入模块后才得以改进。使用模块时,模板函数可以和普通函数一样被定义在源文件中。而模块在被呈现时,会先被部分编译,以供import使用。模块的这种呈现形式,更象是一个易于检索的视图,其中包含所有被export的作用域和类型信息,并体现在符号表中。借助符号表,编译器可以快速找到并使用模块中的每个实体,当然也包括模板函数的定义。
模板为编译时编程提供了通用机制
设计模板时,需要谨慎考虑为模板参数设定的概念,即需求
设计模板时,首先针对具体类型实现、调试和测试,然后再考虑哪些类型可以被参数化
概念是一种设计工具
为所有模板参数指定概念
尽可能使用已命名的概念,例如标准库中的概念
如果只在某处需要一个简单的函数对象,不妨使用匿名函数,即Lambda表达式
用模板表示容器和范围
避免使用不含有效语义的概念
一个概念需要一套完整的操作
使用具名概念,要好过“requires requires”这样的用法
auto是约束最少的概念
当函数参数的数量和类型都无法确定时,建议使用可变参数模板
模板提供了编译时的“鸭子类型”
使用头文件时,需要用“#include”包含模板的完整定义,而不仅仅是声明
使用模板时,要确保它的定义,而不仅仅是声明,位于当前作用域的可见范围内