7 模板

7.1 引言

显然,人们并不总是将类型为double的元素保存在动态数组中。动态数组是一个独立于浮点数的通用概念,因此,动态数组的元素类型也应该被独立地表示,而不应该和动态数组绑定在一起。模板是一个类或函数,其中包含参数化的类型或值。模板可用于表示那些与具体类型无关的通用概念,然后通过传入具体的类型或值作为实参,将其具体化(实例化)为普通的类或函数,以用于创建对象或调用执行。

模板化的类或函数
传入
实参
具体化
实例化
普通的类或函数
具体的类型或值
参数化的类型或值
具体的类型或值
创建对象或调用执行

C++中的模板主要涉及语言机制、编程技术和标准库,三个方面。

7.2 参数化类型

在之前编写的,存储double型元素的,动态数组的基础上,略加修改,将其中的具体类型double,替换为一个参数化的类型T,并在template关键字的后面,借助typename指明该类型参数,即可得到一个泛化的,可存储任意类型元素的动态数组模板类,及与之相关的模板函数。例如:

模板类和模板函数的前面,都带有一个形如“template<typename T>”的前缀,它声明T是一个类型形参,它是数学上“T”,即“任意类型T”的C++表达。在声明类型参数时,使用class和使用typename是等价的,在旧式代码中经常使用“template<class T>”这样的模板前缀。

如果将模板类成员函数的定义,放到模板类的外部,可以写成这样:

有了这些定义,就可以如下方式创建存储不同类型元素的动态数组:

“Vector<Vector<int>>”中的“>>”是嵌套模板实参的结束尖括号,并非错用的流提取操作符。

Vector的使用代码可能会是这样:

为了让一个容器支持基于范围的for循环,需要为之定义适当的begin和end函数。例如:

在此基础上,可以写出如下代码:

事实上,每个标准库容器,如vector、list、map、unordered_map等,都是带有参数化类型的类模板,可用于存储不同类型的元素。

模板是一种编译时机制,因此与手工编码相比,它并不会产生任何额外的运行时开销。这里的Vector<double>和之前编写的Vector相比,在性能上没有差别。标准库的vector<double>,生成的代码会更优,它包含更多优化。

运行时
编译时
实例化
实例化
实例化
实例化
实例化
实例化
对象
对象
对象
对象
类模板

模板化的类或函数结合模板实参变成具体类或函数的过程,称为模板的实例化或特例化。模板的实例化在编译阶段完成。编译器会为模板的每个实例生成一份独立的代码。

7.2.1 受限模板参数

虽然在语法上,模板声明中的“template<typename T>”可被解释为“T”,即“任意类型T”,但事实上,真正能用于实例化模板的类型实参还是很有限的。因为在大多数情况下,模板仅对满足特定条件的模板参数有意义。例如动态数组Vector支持复制,这就要求其中的元素必须也能复制。这就意味着,Vector的模板参数仅仅是一个typename还不够,还不足以表达它应该满足的条件。为此,更好的写法应该是这样的:

这里的“template<Element T>”表示“(T)P(T)”,即“任意满足P(T)的类型T”。Element是一个谓词,表示用于实例化该模板的类型实参所必须满足的条件。这种谓词称为概念。用概念声明的模板参数称为受限模板参数。拥有受限模板参数的模板称为受限模板。

为了强调Vector中的元素必须是可复制的,这里的Element可以取标准库预定义的概念copyable。在实例化模板时,为其提供任何不满足概念要求的类型实参,将导致编译错误。例如:

因此,概念允许编译器在实例化模板时执行类型检查,并给出更好的错误信息。C++在C++20之前并没有官方支持概念,因此旧代码只能以文档或注释的形式,将对类型参数的限制告知模板用户。一旦用户在实例化模板时,使用了违反这些限制的类型实参,编译器给出的错误信息可能会非常复杂,甚至难以理解。

与模板的实例化过程一样,基于概念的类型检查也在编译阶段完成,产生的代码与非受限模板一样好。

7.2.2 模板值参数

除了类型参数外,模板还可以带有数值形式的参数。例如:

值参数在很多场合都非常有用。例如Buffer允许创建任意大小的缓冲区,而不需要动态内存分配。例如:

不幸的是,由于隐晦的技术原因,字符串字面量不能作为模板的值参数,但使用字符数组表示的字符串是可以的。例如:

在C++中通常会有间接的解决方案,因此不需要对所有情况都提供直接支持。

7.2.3 模板参数推导

将一个类模板实例化为一个类时,应该提供模板实参。例如:

但也可以在初始化时,由构造函数自动推导出模板的参数。例如:

又如:

注意v4和v5的区别。v4没有指定元素类型,构造函数匹配接受初始值列表的版本,7是列表中唯一的元素,类型参数T被推导为int。v5显式指定了元素类型,无需推导类型参数,构造函数匹配接受元素数量的版本,7个元素均被初始化为整数0。

显然,基于类型推导的类模板实例化,写法更简单,更有助于消除因重复输入模板的类型参数而导致的拼写错误。然而,象其它许多强有力的机制一样,类型推导的结果并不总是尽如人意。例如:

注意,C风格字符串字面量的数据类型是const char*。若希望容器元素的类型为string,要么显式指定类型实参:

要么使用string类型的字符串字面量,填充初始值表:

如果初始值表中的元素类型不一致,就无法推导出唯一的元素类型,编译器会报告二义性错误。

注意,以下两种初始化形式的区别:

使用圆括号初始化语法,构造函数倾向于选择非初始值列表的版本,即“Vector(int)”,但使用该构造函数无法推导模板参数的类型,因此编译器只能选择初始值列表的版本,即“Vector(const initializer_list<T>&)”,并将7作为列表中唯一的元素,类型参数T被推导为int。

使用花括号初始化语法,构造函数倾向于选择初始值列表的版本,即“Vector(const initializer_list<T>&)”,除非没有这样的构造函数,再寻找其它可用版本。

如果为类模板的构造函数指定了类型推导指引,则可根据该指引完成模板参数的类型推导。例如:

这时,非初始值列表版本的构造函数“Vector(int)”,也具备了对模板参数的类型推导能力。因此,使用圆括号初始化语法,优先匹配该构造函数,并将模板的类型参数T按照指引推导为double,7个元素均被初始化为双精度数0。

类型推导指引的效果有时候会很微妙,如果能避免使用,最好还是尽量避免使用。

类模板的模板参数推导,可被缩写为CTAD。

C++17:类模板参数的类型推导

7.3 参数化操作

除了参数化容器元素的数据类型以外,模板还有其它用途。比如它们被广泛用于参数化标准库中的类型与算法。

要想将一个操作过程中的类型或值参数化,通常有三种方法:

7.3.1 模板函数

可以定义一个对任何容器求元素之和的函数,只要该容器支持基于范围的for循环即可。例如:

模板参数Value和调用参数val分别表示累加和的类型和初值。任何标准库容器,包括前面定义的Vector容器,都可以使用该函数计算元素之和,仅仅因为它们都支持基于范围的for循环。例如:

请注意,sum<Container,Value>模板函数是如何根据调用参数的类型,推导出模板参数的类型的。这里的sum函数可以看作是标准库accumulate函数的简化版本。

模板函数也可以作为类的成员函数,但不能同时也是虚函数。编译器不可能知道模板型成员函数的所有实例,因此也就不可能将其入口地址填入类的虚函数表(vtbl)。

7.3.2 函数对象

函数对象,亦称仿函数,是一种特殊的模板类,其特殊性在于,它所创建的对象可以象函数一样被调用,而之所以能做到这一点,是因为它提供了对小括号形式的,函数调用操作符的重载定义。例如:

名为“operator()”的成员函数,实现了小括号形式的函数调用操作符。LessThan模板类的任何实例化对象,都可以象函数一样被调用,而实际被调用的就是这个函数调用操作符函数。例如:

可以通过任何支持小于号(<)操作符的类型实例化该模板类,然后象调用函数一样调用所得到的对象。

函数对象被广泛用作算法的参数。例如,统计容器中满足谓词(令其为true)的元素个数:

这其实是标准库count_if函数的简化版本。可以象下面这样统计容器中满足谓词条件的元素数:

“LessThan{ 5 }”创建了一个“LessThan<int>”类型的对象,count函数通过“pred(elem)”调用了该对象的“operator()”函数,将vec中的每个元素与5进行比较,若其小于5则“++cn”,最后返回vec中比5小的元素数4。类似地,“LessThan{ "abc"s }”创建了一个“LessThan<string>”类型的对象,count函数通过“pred(elem)”调用了该对象的“operator()”函数,将lst中的每个元素与“abc”进行比较,若其小于“abc”则“++cn”,最后返回lst中比“abc”小的元素数2。

函数对象的迷人之处在于它携带了用于比较的值(5或“abc”),且其类型是参数化的(int或string),不需要为每种类型的每个值编写一个独立的函数,也不需要借助令人不快的全局变量来减少函数调用的参数,相当于把任意类型的某个参数(5或“abc”)提前绑定到函数内部。另外,象LessThan这样的简单函数对象,在编译时很容易实现内联,因此对LessThan对象的调用效率非常高。既能携带参数化类型的数据,又能表现出足够的运行效率,这使得函数对象非常适合作为标准库算法的参数。

函数对象通常用于实现通用算法的核心逻辑,因此也叫做策略对象。

7.3.3 匿名函数

函数对象和普通函数共同的问题是,使用函数的代码与实现函数的代码彼此分离。如果希望在使用函数的同时给出函数的定义,可以使用匿名函数。匿名函数,亦称Lambda表达式,其本质也是一个函数对象,即可被当做函数调用的对象,不同之处在于,这里既不需要定义实现小括号操作符的类,也不需要显式地创建对象,编译器会隐式地完成这些工作。例如:

其中,“[](int n) { return n < 5; }”和“[](const string& s) { return s < "abc"; }”即为匿名函数(Lambda表达式),它们会被编译器自动处理为类似“LessThan{ 5 }”和“LessThan{ "abc"s }”的对象。

匿名函数中的“[]”称为捕获列表,用于指定哪些局部变量可被匿名函数内部的代码访问:

C++11:匿名函数

C++20:通过“[*this]”按值捕获当前对象

7.3.3.1 匿名函数作为函数参数

使用匿名函数方便又简洁,但有时也略显晦涩。对于复杂操作(比如多于一行代码的函数体)而言,还是更倾向于使用普通的有名函数,这样可以更清晰地表达出它的意图,并能在程序的多个地方调用它。

回顾之前编写的代码:

这些代码有一个共同的特征,就是遍历容器中的每一个元素,并对其执行某种操作,移动或绘制。类似这样的逻辑,借助函数对象,甚而匿名函数,可以将遍历的过程,与需要执行的操作分开,进而获得统一的形式。例如:

这里的forEach函数可以看作是标准库for_each函数的简化版本,其第二个参数是一个函数对象。例如:

使用“unique_ptr<Shape>”型的引用作为匿名函数的参数,巧妙地规避了对象的生命周期问题。甚至可以将匿名函数写成泛型形式。例如:

或者将这部分与具体类型无关的代码封装成一个模板函数。例如:

这里的auto表示匿名函数可以接受任意类型的参数。含有auto型参数的匿名函数也是模板,称为泛型匿名函数。如果需要,也可以借助概念,为这样的参数增加一个约束条件。例如,可以定义PointerToClass概念,表示它需要支持解引用(*)和间接成员访问(->)操作:

任何提供draw和move等成员函数的对象的“指针”所组成任意类型的集合,都可作为调用moveAndDraw函数的参数。例如:

如果需要更严格的类型检查,甚至可以定义PointerToShape概念,作为匿名函数参数的约束条件,以强调其目标对象必须提供draw和move等成员函数。即使不是Shape类的派生类,只要满足此约束,亦可用于此匿名函数。

C++14:泛型匿名函数

7.3.3.2 匿名函数与初始化

借助匿名函数,可以将任何语句变成表达式。最常见的场合,是将某种操作作为传递给函数的参数,或从函数中返回的值,同时捕获当前上下文中的数据。此外,它还有另一个作用,就是优化初始化逻辑。下面的代码包含了一个复杂的初始化过程:

这是一个风格化的例子,不幸的是,它并非特例。函数实现者希望从一系列初始化方案中,选择一种,用于初始化局部变量v。不同的方案代表了不同的初始化策略。这种代码经常会写得一团糟,哪怕只提供基本的功能,也难免会出现一堆Bug:

下面是借助匿名函数,实现变量初始化的改进版本:

很明显,改进后的版本将采用各种方案的初始化过程集中在一个匿名函数中,调用该函数,并用该函数的返回值初始化局部变量v。这比上一个版本,以散落于纷繁代码中的赋值语句,为变量设定初值的做法高明得多。与上一个版本相同,这里依然遗漏了一个关于patrn初始化方案的case分支,但这个问题很容易被发现。在很多情况下,编译器可以发现大部分问题并给出警告。

7.3.3.3 作用域终结函数

析构函数提供了一种通用的解决方案,用于在作用域结束时,隐式释放所有域内分配的动态资源。但如果需要释放的动态资源与析构函数无关,比如那些游离于任何对象的动态资源,又当如何呢?定义一个专门负责清理工作的匿名函数,并令其在控制流离开作用域时被执行,也许是个不错的方法。例如:

这样做总比在作用域的所有出口分支,手动执行“free(p)”,要好得多。实现finally函数的方法很简单。例如:

这里使用了“[[nodiscard]]”属性修饰,强制finally函数的调用者必须保存该函数的返回值,因为这是该函数实现其功能的关键,而FinalAction则是一个类,它的定义可能类似下面这个样子:

在C++ Core Guidelines支持库(GSL)中提供了一份finally函数的实现,同时包含了针对标准库的提案,其中描述了更精巧的scope_exit机制。

7.4 模板机制

要想设计出好的模板,还需要以下一些支撑性的语言基础设施:

此外,constexpr函数和static_asserts断言,也经常出现在模板的设计和使用中。

这些语言机制是构建通用型基础抽象的主要工具。

7.4.1 模板变量

任何具体类型,都可用于常量的定义。例如:

能否象模板那样,将这些常量(如viscosity和externalAcceleration)的类型中的具体类型(如double和float)参数化呢?这就要用到模板变量语法。例如:

这里的viscosity和externalAcceleration虽然都是常量,但它们依然称为模板变量。

一般而言,可以用任何类型符合的表达式作为模板变量的初始值。例如:

以此判断能否将后面类型的数据赋值给前面类型的变量。例如:

这个想法最终成为概念定义的核心。

标准库通过模板变量定义了很多数学常数,如pi、log2e等。

C++14:模板变量

7.4.2 别名

给类型或者模板定义一个别名常常非常有用。例如标准库头文件<cstddef>就为unsigned int类型定义了别名size_t,类似下面这样:

名为size_t的实际类型属于实现定义,有些C++实现可能会将其定义为“unsigned long”。类型别名有助于编写可移植的C++代码。

在有参类型(比如模板)的内部,通常会为类型参数定义别名。例如:

事实上,标准库中所有表示容器的模板类,都无一例外地用value_type作为其元素类型的别名。这就允许程序员编写出,适用于所有符合该约定的容器的通用代码。例如:

这里的ValueType是标准库range_value_t的简化版本。利用别名机制,甚至可以通过绑定模板的部分或全部参数,定义新模板或类。例如:

C++11:为类型和模板定义别名

7.4.3 编译时if

假定针对某个操作可以有两种不同的实现,一个安全但耗时较长,另一个速度虽快但安全性略差。传统的做法是将其封装为两个独立的函数,前者名为slowSafe,而后者名为simpleFast。也可以将前者定义为基类中的虚函数,而在派生类中以后者的实现覆盖之。除此而外,还可以有第三种做法,就是使用编译时if。例如:

其中is_trivially_copyable_v是一个类型谓词,用于判断某种类型是否支持低代价的拷贝。

在编译阶段,由编译器执行对该类型谓词的判断,并选择性地编译if或else分支。这个方案在提供最佳性能的同时,保证了最佳的局部性。

与传统意义上的条件编译不同,“if constexpr”并非文本处理机制,因此不能用于打破语法、类型和作用域的正常规则。例如下面的写法是不会通过编译的:

源自条件编译的黑魔法终将淡出人们的视线。不会令作用域失效的,更清晰的做法,源自朴素的编译时if。例如:

C++17:编译时if

7.5 建议