8 概念和泛型编程

8.1 引言

模板提供了哪些功能?模板应该用在什么地方?模板会使哪些程序设计技术更有效?

总而言之,模板为类型参数化和编译时计算提供了强有力的支撑,帮助程序员编写出更加简洁高效的代码。类可以包含代码和数据,其中的类型一旦被参数化,将变得更加灵活和通用。

模板最常见的应用场景就是泛型编程。泛型编程专注于通用算法的设计、实现和使用。通用意味着这些算法将同时支持很多种数据类型,只要这些数据类型能满足算法对其能力的要求即可。模板是C++语言支持泛型编程的强有力的工具,它为程序代码提供了编译期的参数级多态性。

8.2 概念

考虑如下sum函数:

能够传递给该函数的参数并不是完全随意的,它们必须满足如下条件:

对传递给该函数两个参数con和val的要求,其实是对实例化该模板的两个类型参数Container和Value的要求。这种对模板类型参数的能力要求,或者说约束,就叫作概念:

从以下两个维度上看,sum函数属于通用算法:

sum
所有类型
Sequence
Number
Container
Value
满足序列概念的类型
vector
deque
list
...
其它类型
满足数值概念的类型
int
double
Matrix
...

C++20:概念

8.2.1 概念的运用

关键字typename所表达的概念约束是最低程度的,它仅仅要求被它修饰的模板参数接受一个类型,而现实中的大多数模板参数所能接受的类型,都必须满足特定的条件才能被顺序编译并正确运行。也就是说,现实中的大多数模板其实都是受限模板,而真正意义上的有实用价值的无限模板,少到几乎可以忽略不计。重新考虑sum函数:

改进后的版本看起来清晰很多。模板函数sum的两个类型参数Seq和Num不再接受或者说不再表示任意的类型,它们只接受或表示满足Sequence和Number概念约束的类型。编译器一旦发现所传入的类型实参不满足由Sequence或Number所定义的概念约束,即报告错误,而不会再象以前那样,非要等到编译函数体代码时,才会发现问题。这使得有关错误的描述更加易于理解。

然而,仅做到这一步似乎还不够完美。Sequence概念保证了可以通过基于范围的for循环遍历seq中的元素,Number概念保证了对num对象执行“+=”操作是合法的,但还没有任何约束能够保证num和elem之间的算术运算是可行的。“Arithmetic<X,Y>”也是一个概念,它表示X和Y可以进行算术运算。例如:

标准库中的range_value_t用于提取参数容器中的元素类型,即elem的类型。包含requires关键字的子句正是为了保证在Num类型的num和range_value_t<Seq>类型的elem之间,可以进行算术运算。

完整的规格指定并不总是必须的,编译器该报错总是会报错的,尽管可能不太清晰,而且在实际开发中,有时可能很难从一开始就确知全部的类型需求。如果有一个成熟的概念库,那么完整的规格指定确实可以做到十全十美。

代码中的“requires Arithmetic<range_value_t<Seq>, Num>”称为requirements子句,是对概念的精准表达。而象“Sequence Seq”或“Number Num”的写法,其实是一种简化的概念表示。更复杂的写法可能是下面这样:

或者采用更简单的写法:

无论采用何种写法,为模板参数明确其必须满足的约束条件,总是有意义的。

8.2.2 基于概念的重载

模板函数和普通函数,也可以重载。如果两个模板函数的签名完全相同,但对类型参数的概念约束不同,也可以构成重载关系,编译器会根据类型实参,选择对概念约束满足程度最高的版本。例如下面的advance函数,用于将某个容器的顺序迭代器,步进指定数量的元素:

针对随机访问迭代器的重载版本:

标准库中的list只提供顺序迭代器,而vector则提供随机访问迭代器,因此:

编译器会自动为list中的顺序迭代器,选择针对顺序迭代器的慢速步进版本,而为vector中的随机访问迭代器,选择针对随机访问迭代器的快速步进版本。

与普通函数的重载匹配一样,模板函数的重载匹配在编译阶段完成,不占用运行时间。如果编译器无法找到最佳匹配,通常会报告二义性错误。基于概念的重载匹配比一般重载匹配效率高。基于概念的匹配逻辑如下:

除了基于概念的匹配逻辑以外,还要考虑调用参数的匹配逻辑。最终被选择的重载版本,需要满足以下条件:

例如:

重载匹配概念参数1参数2参数3
版本1强约束,不匹配强匹配强匹配强匹配
版本2弱约束,匹配强匹配强匹配强匹配
版本3强约束,匹配不匹配匹配强匹配
版本4强约束,匹配匹配匹配匹配
版本5强约束,匹配弱匹配匹配强匹配

综合考虑概念和参数的匹配情况,编译器最终选择了版本5。

8.2.3 有效代码

模板中的代码是否有效,往往与传递给模板参数的具体类型是否满足特定要求有关。requires表达式用于检查这些代码的有效性。例如:

这是前面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 ...”是一种刻意的、不优雅的黑客写法。正确的针对随机访问迭代器的重载版本更简单,可读性也更好。例如:

只要有可能,尽量使用定义良好的命名概念,比如random_access_iterator等,而requires表达式仅用于定义这些概念。

8.2.4 定义概念

标准库预定义了很多有用的概念,如forward_iterator、random_access_iterator等。在定义模板类和模板函数时,直接使用现成的概念,显然比自己写一个全新的,要容易得多。但在某些特殊的场合,自己定义概念还是有必要的。标准库中的概念通常采用全小写,以下划线分隔单词的命名风格,而自定义概念则更倾向于采用单词首字母大写,无分隔符的命名风格,如Sequence、Number等,以示区分。

概念的本质是一个代表类型约束的谓词,在编译时计算其值——true或者false,true表示类型满足约束,false表示不满足。

概念
true
满足
false
不满足
谓词
约束
类型

例如:

EqualityComparable是一个保证类型可做相等性和不等性比较的概念。只有同时支持“==”和“!=”操作符,且表达式返回布尔值的类型,才满足此概念所表达的约束。例如:

概念EqualityComparable的定义与它命名一样——相等性可比较——用于检测一个类型是否满足“相等性可比较”的约束。concept关键字后面紧跟概念的名字。等号(=)后面是一个requires表达式。花括号中的代码有效,概念的值为true,否则为false。概念的值一定是bool类型的。

对“{ ... }”返回值的类型约束在“->”后面指定,它必须也是一个概念。这里要求它必须是布尔类型。表示布尔类型的概念定义如下:

要想让EqualityComparable概念支持不同类型对象间的相等性和不等性比较,可将其定义修改为:

其中“typename T2 = T1”的写法表示,如果没有为T2提供类型实参,那么T2就取传递给T1的类型实参。这里的T1称为T2的默认模板参数。以下对修改后的EqualityComparable概念的测试代码:

这里的EqualityComparable与标准库的equality_comparable几乎没有区别。

以下是一个关于数值(Number)的概念:

这里对表达式返回值的类型没有做出限制,但对于一般性使用而言已经足够了。给定一个类型参数时,“Number<X>”检查X是否具备Number所描述的种种特性。给定两个类型参数时,“Number<X, Y>”检查X和Y是否可以共同执行Number所描述的各种操作。

以此为基础,还可以定义一个表示算术(Arithmetic)的概念:

下面是一个更复杂的例子,表示序列(Sequence)的概念:

一个类型要想成为序列(Sequence)必须同时满足以下三个条件:

最难定义的概念往往是那些表示语言基础元素的底层概念。在现成概念库的基础上定义新概念会简单得多。比如在标准库input_range概念的基础上定义Sequence概念:

任何满足“typename range_value_t<S>”条件的类型,都会在其内部用value_type表示元素的类型。因此可以通过下面的写法为其定义别名:

这里的ValueType与标准库的value_type_t类似,但后者要更复杂一些,因为它还需要处理不包含value_type成员类型的情况,比如内置数组。

8.2.4.1 定义时检查

模板中的概念,是用来检查实例化模板时,所提供的类型实参的,并不用于检查模板的定义。例如:

这里的equality_comparable概念,要求传递给模板参数T的实参类型,必须支持基于“==”操作符的相等性比较。但在该模板函数的定义中,实际上是通过该类型的“<”操作符做小于比较。二者间的矛盾并不会被编译器察觉。直到该模板函数被真正实例化(调用)时,编译器才会发现不对劲。例如:

在概念检查阶段,ostream不能通过检查,因其不满足equality_comparable概念对“==”操作符的要求,但int和complex<doduble>可以通过检查,因为它们都支持“==”操作符。但在实际编译函数体代码时,编译器发现complex<doduble>并不支持“<”操作符,故而报告错误。

将最终检查从模板定义推迟到模板实例化时,有两个好处:

以上两点对于开发和维护大型代码来说非常重要。获得这个重要好处的代价,仅仅是一部分错误可能会在比较晚的时候才被编译器发现并报告出来,比如上面例子中complex<double>支持“==”操作符(满足概念),但不支持“<”操作符(不满足使用)的情形。

8.2.5 概念与auto

关键字auto表示一个对象的类型与其初始化描述符(初始值)的类型相同。例如:

然而,初始化描述符并不仅仅出现在简单的变量定义中。例如:

又如:

关键字auto表示对类型的最小约束,只要是类型即可,具体什么类型不限。在参数中使用auto,会将一个函数变成模板函数。例如上面的fun函数与下面的形式等价:

既然可以通过概念限制模板的类型参数。例如:

那么也应该可以通过概念限制auto关键字所能表示的类型。例如:

通过概念约束auto关键字所能表示的类型,可以强化对初始化需求的限定。例如:

概念除了可以约束函数参数的类型以外,还可以约束函数返回值的类型。例如:

甚至可以在常规的变量定义中约束其类型。例如:

在泛型编程中,使用概念可以有效遏制auto关键字的滥用,并为数据在类型方面的限制,提供可读性良好的注脚。同时,与类型有关的编译错误,也会因概念的使用,而与导致错误的源头更加接近,易于排查和调试。

C++17:泛型值模板参数(auto模板参数)

8.2.6 类型与概念

类型是对对象的描述,而概念则是对类型的描述。一个类型可以定义多个对象,它们都符合该类型所描述的特征。一个概念则囊括了多个类型,它们都满足该概念所限定的约束。例如:

对象
类型
概念
v1
v2
l1
l2
vector
list
Sequence

又如:

对象
类型
概念
n1
n2
d1
d2
int
double
Number

类型与概念的区别与联系如下表所示:

类型概念
指定可被应用于一个对象的操作集
无论是隐式指定的还是显式指定的
指定可被应用于一个类型的操作集
无论是隐式指定的还是显式指定的
依赖于语言规则依赖于使用模式
与对象的内存结构有关与对象的内存结构无关
对一组具有相同特征的对象的抽象对一组满足相同约束的类型的抽象

基于概念编写的程序比基于类型编写的程序更加灵活,而且概念还可以表达几个不同类型间互操作的规则。理想的情况是,绝大多数函数都应该是受概念约束的模板函数,只是目前的语言支持还不够完善,只能把概念当作形容词,而无法将其当作名词。例如:

理想中的代码应该是这样的:

8.3 泛型编程

C++直接支持的泛型编程,紧紧围绕这样的核心思维:从具体、高效的逻辑中抽象出来,从而获得可与不同数据表示相结合的泛型算法,以生成各种有用的软件。表示基本操作和数据结构的抽象被称为概念。

8.3.1 概念的使用

基本的、有用的、好用的概念,更多是被发现而非发明出来的。比如整数、浮点数、序列,以及更通用的数学概念,比如环、向量空间等。它们都是某个应用领域中的基础概念,这就是它们被称为“概念”的原因。识别出概念,并将其形式化到可用于泛型编程的程度,是一个挑战。

比如regular(规则)可被视为一种概念。符合该概念的类型具备如下特性:

标准库中的string类型就十分满足regular概念的要求。类似地,totally_ordered(完全有序)也是一个概念,属于该概念的类型需要在满足regular概念的前提下,同时支持“<”、“<=”、“>”、“>=”和“<=>”等操作符。string类型显然也满足totally_ordered概念的要求。

概念不仅仅是一个语法记法,它还是描述语义的基本要素。例如将一种表示数字的类型的“+”操作符,实现为除法运算,是不能被接受的。因为那样显然违背了数字概念的要求。但如果真的这样做了,至少在目前阶段,编译器并不会察觉到有任何不妥。概念语义的正确性,完全取决于程序编写者的专业知识和对大众共识的接纳程度。例如象Addable(可加的)和Subtractable(可减的)这样的概念。何为可加?怎么又算可减?针对不同的应用场景可能会有完全不同的解释,这严重依赖于应用程序相关领域的知识和常识。

8.3.2 使用模板实现抽象

好的抽象往往脱胎于具体案例。因此,不建议在还没有准备好具体需求和技术之前就试图进行抽象,那样做不但会尽失优雅,还有可能导致代码膨胀。建议先从实际的具体需求出发,编写最朴素的代码,然后再逐步将与具体细节无关的部分抽象出来。例如:

显然,上述代码用来计算整数向量中的元素之和已经足够了,但是什么让它不够通用呢?

前4个问题可以通过把具体类型换成类型参数来解决,这样就得到了标准库accumulate算法的简化版本。例如:

这里做了如下改进:

通过简单的测试,验证其泛型特性:

从一段或几段具体代码中抽象出一段泛型代码,同时保持其原有的性能,这种行为叫作提升。模板开发的最佳实践通常是这样的:

当然,参数表中两个迭代器型的参数略显啰嗦,为此,可将该函数的接口做更进一步的简化。例如:

相应的测试代码如下:

这里的forward_range是标准库预定义的概念,表示一个支持通过begin和end函数,获取起始和终止迭代器的序列。只用这样的序列,才能通过基于范围的for循环,遍历其中的元素。

如果想获得更进一步的抽象,可以考虑把“+=”操作符也抽象出来。

对于accumulate模板函数而言,无论是一对迭代器的版本,还是范围的版本,都很有用。一对迭代器的版本更通用,而范围的版本更简单。

8.4 可变参数模板

定义模板时可以令其接受任意数量、任意类型的模板实参,这样的模板称为可变参数模板。假如需要实现一个简单的函数,打印任意数量个可被“<<”操作符插入输出流的数据。例如:

传统的做法是先将第一个参数剥离出来,然后用递归的方式处理其余的参数,注意递归终止时的特殊处理。例如:

这里加了省略号的Printable,表示Others是包含多个可打印类型的序列,而加了省略号的Others,则表示others是与该序列中的各个类型相对应的值序列,最后加了省略号的others,表示这个值序列中的每个值。总之,位于省略号后面的参数称为参数包。“Printable... Others”中的Others称为模板参数包,而“Others... others”则称为调用参数包。

每次调用print函数都把参数分为第一个参数和其余的参数。对第一个参数,直接通过与之匹配的“<<”操作符将其插入标准输出流。而对其余的参数,则继续调用print函数。最终,others参数包将为空,这时将调用不带参数的print函数,以处理无参数的情况。借助编译时if也可以解决这个问题。例如:

在这里使用编译时if而不是运行时if,可以避免对无参print函数的调用,即无须定义不带参数的print函数。

可变参数模板的强大之处在于,它们可以接受任意数量和类型的参数,但其缺点也是显而易见的,比如:

可变参数模板具有很强的灵活性,其在标准库中的应用十分广泛,甚至偶尔被过度使用。

C++11:可变参数模板

8.4.1 折叠表达式

为了简化可变参数模板的实现,C++提供了有限形式的迭代器,用于遍历参数包中的参数。例如:

这个sum函数可以接受任意数量和类型的参数。例如:

在sum函数的函数体中使用了形如“(numbers + ... + 0.0)”的折叠表达式。该表达式会被编译器扩展为类似“(numbers[0]+(numbers[1]+(numbers[2]+(numbers[3]+(numbers[4]+0.0)))))”的形式。这种形式的折叠称为右折叠。当然,还有左折叠。例如:

其中的左折叠表达式“(0.0 + ... + numbers)”被编译器扩展为“(((((0.0+numbers[0])+numbers[1])+numbers[2])+numbers[3])+numbers[4])”的形式。两种折叠形式在本例中的计算结果是相同的。

折叠是非常有用的抽象。它与标准库的accumulate函数相关。该函数在不同的编程语言及社区中有不同的名字。在C++中,折叠表达式目前仅限于简化可变参数模板的实现。当然,折叠表达式并非只能处理算术运算。例如:

其中的左折叠表达式“(cout << ... << args)”被编译器扩展为“(((((cout<<args[0])<<args[1])<<args[2])<<args[3])<<args[4])”。测试代码如下:

折叠表达式语法自C++17标准引入。

C++17:折叠表达式

8.4.2 完美转发参数

在如下模板函数中:

类型为“T&&”的引用r称为通用引用,若实参为左值,则r被推断为左值引用,若实参为右值,则r被推断为右值引用。forward函数可将传递给它的左值引用或右值引用型参数,处理为左值或右值。若r为左值引用,则“forward<T>(r)”为左值,若r为右值引用,则“forward<T>(r)”为右值。传递给fun函数的左值或右值实参,被原封不动地,以左值或右值的形式转发给了foo函数,这就叫完美转发。

foo
fun
参数
T&& r
左值引用
右值引用
forward
左值
右值
左值
右值

在使用可变参数模板时,保证参数在传递过程中的左右值属性不变,有时可能会非常重要。例如:

输入通道(InputChannel)通过构造函数接受用户传入的不定数量和类型的参数,然后原封不动地传递给其Transport类型的传输子对象m_transport的构造函数。在这个过程中需要保证每个参数的左右值属性不变,为此使用了完美转发。

这里需要注意一点,InputChannel只负责构造Transport对象,而无须了解Transport构造函数的参数类型。InputChannel的实现者只需要知道Transport对象的公共接口即可。

完美转发在基础库中非常常见。在这些库中,通用性和低运行时开销是必须的,并且常常需要非常通用的接口。

8.5 模板编译模型

在使用模板时,编译器根据概念的定义检查模板参数,此时发现的错误将被立即报告,而那些无法发现的错误则被推迟到代码生成阶段,即模板被实例化时再报告,比如无约束的模板参数即是如此。

在实例化阶段报告类型错误的缺点在于,这个错误报告的时间有点晚,而对太晚检查出的错误,通常难以提供足够友好的描述信息。此时的编译器已无法获知程序员的实际意图,只能从程序的多个地方拼凑和猜测出问题所在。

为模板提供实例化时的类型检查,重点关注模板定义中对参数的使用情况。这种机制依赖于被称为鸭子类型(如果有种东西,走路象鸭子,叫声也象鸭子,那它就是鸭子)的编译时特性,即操作的效果和含义取决于被操作对象的值。这与通常所说的“对象的类型决定它的行为”不同,在那里,操作的效果和含义取决于被操作对象的类型。在C++中,值存在于对象内部,这是C++对象(即变量)的工作机制。某个值只有符合对象所属类型的要求,才能被放到该对象中。但对模板而言,编译器真正关心的是对象的值而非其类型。此处只有一个例外,constexpr函数中的局部变量,在编译器中,被作为有类型的对象看待。

要实例化一个模板,不能只看它的声明,还要看它的定义是否完整地呈现在当前作用域的可见范围内。如果使用头文件(即“#include”)机制,那么所有的模板函数必须在头文件(.h)中给出定义,而不能将定义写在源文件(.cpp)中。例如标准库的<vector>头文件即包含了对vector模板的完整定义。

这一点直到引入模块后才得以改进。使用模块时,模板函数可以和普通函数一样被定义在源文件中。而模块在被呈现时,会先被部分编译,以供import使用。模块的这种呈现形式,更象是一个易于检索的视图,其中包含所有被export的作用域和类型信息,并体现在符号表中。借助符号表,编译器可以快速找到并使用模块中的每个实体,当然也包括模板函数的定义。

8.6 建议