Effective C++ 学习笔记 7:模板与泛型编程
- Effective C++ 学习笔记 1:让自己习惯 C++
- Effective C++ 学习笔记 2:构造、析构、赋值运算
- Effective C++ 学习笔记 3:资源管理
- Effective C++ 学习笔记 4:设计与声明
- Effective C++ 学习笔记 5:实现
- Effective C++ 学习笔记 6:继承与面向对象设计
- Effective C++ 学习笔记 7:模板与泛型编程
- Effective C++ 学习笔记 8:定制 new 和 delete
- Effective C++ 学习笔记 9:杂项讨论
条款 41:了解隐式接口和编译期多态
Understand implicit interfaces and compile-time polymorphism.
隐式接口和编译器多态这两个概念,是通过C++ 的模板(和重载机制)而引入的。
首先说编译期多态。一种实现是重载,即在编译期间表现出的多态,在编译期就可以静态绑定到实际的对象和函数中。另一种就是模板,如函数模板和类模板。
一个简单的函数模板实现:
1 | template<typename T> |
这样一个函数,在编译期间,通过传入的模板参数 T,来实现静态绑定,这也叫编译期多态。
运行期多态不必多说,由 virtual 继承实现。
隐式接口是由模板引入的。和它对应的是显式接口,也就是如类结构中的 public 声明。隐式接口是通过表达式合法性约束实现的。
还以上边例子来说,函数 fun 中的参数 T 类型是一个隐式接口,它约束了其必须有 size(),有拷贝赋值操作,有 swap 函数等。这些约束所组成了接口约束,便是隐式接口约束。
和显示接口一样,如果约束不是合法的,它们都会在编译期间被提示出来。
总结
- classes 和 templates 都支持接口(interfaces)和多态(polymorphism)。
- 对 classes 而言,接口是显式的(explicit),以函数签名为中心。多态则是通过 virtual 函数发生在运行期。
- 对 templates 而言,接口是隐式的(implicit),以有效表达式为依据。多态则是通过 template 具现化和函数重载解析发生在编译期。
条款 42:了解 typename 的双重意义
Understand the two meanings of typename.
typename 的使用,一般是大家不太注意到的地方,我习惯于在模板上用 typename,本条款对 typename 进行了详细的说明。
首先,以下两种写法,是完全一致的:
1 | template<class T> class Widget; |
class 替代 typename 是旧版写法。
话题 1:typename 作为嵌套从属名称时导致的问题
下例中:
1 | template<typename C> |
虽然我们知道 C::const_iterator 一定是个类型,那只是我们的直觉,编译器可能会有其他想法。比如,若 C 刚好有个 const_iterator 的静态成员,而同时 x 又是一个全局变量时,编译器可以认为这个操作是静态变量 C::const_iterator 和全局变量 x 的乘法操作。
typename 参与某个类型的声明时,被称为从属关系;typename 作为有嵌套关系时,就像 C::const_iterator,被称作嵌套从属关系,如果没有嵌套,则是非嵌套从属关系。当 typename 是嵌套从属关系时,可能会出现上边例子的问题。
所以, C++ 中要求,对于 typename 作为嵌套从属关系时,必须使用 typename 作为前缀来声明这是一个类型,如果不使用 typename 声明,则表示一个非类型。
1 | template<typename C> |
注意上例最后一条,typename 只用来修饰有嵌套从属关系的语句,如果不是嵌套从属关系,则不应该加。
话题 2:有一个例外
对于 base classes list 内的嵌套从属关系、成员初始化列表中的嵌套从属关系,不能加 typename 修饰。
1 | template<typename T> |
总结
- 声明 template 参数时,前缀关键字 class 和 typename 可以互换。
- 请使用关键字 typename 标识嵌套从属关系名称;但不得在 base class list 或 成员初始化列表内用它标识嵌套从属关系。
条款 43:学习处理模板化基类内的名称(重要)
Know how to access names in templatized base classes.
先解释下什么叫模板化基类。我们定义一个模板化的类,然后用另一个模板化的类基于前一个类来派生,那么前者就叫做模板化基类(templatized base class),同理后者就叫做模板化派生类。
1 | class Ca { |
以上例子没问题,现在,我们将 MS 作为模板化基类:
1 | template<typename C> |
上边代码,编译器不允许通过,原因是编译器找不到一个 sC() 的实现。虽然我们看起来知道 sC() 一定是在 C 的某个具现化类中,但编译器不允许,因为也许不一定在呢,编译器不会刻意进入可能的模板化基类中查看 sC() 的合法性。
话题 1:引用模板化基类中定义名称的解决方式
为了解决这个问题,有三种办法:
第一种是使用 this 指针:
1 | template<typename C> |
第二种是使用 using 来声明:(在之前 条款 33 中也使用过这种操作)
1 | template<typename C> |
虽然这里用了 using,但和条款 33 中的作用是不同的,当时的作用是将被隐藏的基类内成员暴露出来。
第三种是显式引用:
1 | template<typename C> |
这种办法的缺憾是,如果 sC() 是一个 virtual 函数,这样做破坏了 virtual 的多态性。
三种办法的本质是一样的,就是告诉编译器,这个模板化派生类中的 sC() 函数一定是模板化基类中包含了的。
如果我们的这个对编译器的承诺没有兑现,比如用了以上三种办法之一来调用 sC(),但最后具现化 D 的对象之后,传入的模板化参数并没有在模板化基类中包含 sC(),那么编译器也会在调用时进行报错。
话题 2:全特化模板类
另外,本条款还提到了全特化模板类。如果我们还想把某个不包含 sC() 的基类作为模板参数传入 D,目前显然是不可以的,但我们可以实现一个全特化模板的 MS,比如:
1 | class Cc { |
模板化派生类就不写了,编译器会根据传入的模板参数类型,选择对应的特例化版本的 MS。
总结
- 可在模板化派生类内通过
this->指针指出模板化基类内的成员名称,或者通过一个明确的方式指出这样一个模板化基类的成员名称。
条款 44:将与参数无关的代码抽离 templates
Factor parameter-independent code out of templates.
之所以这么做,是因为要避免代码膨胀。
当我们在编写非模板代码时,我们很容易做到这一点,比如两个类似的函数中具有一些与输入参数不相关的相同逻辑代码,我们可能会考虑将其抽成第三个函数,然后在这两个函数中调用第三个函数。本条款中的代码抽离 templates 也是同理,只是 templates 中的公共代码只会有一份,所以相对更不容易发现。
举例如下:
我们设计一个存储矩阵点阵的模板,其可以指定不同的类型的点和矩阵大小:
1 | template<typename T, // T 是点的类型,它是个类型参数 type parameter |
如注释中所述,非类型参数虽然用的少,但它也是合法的,模板参数的不同取值会让编译器在编译期生成不同的代码,因为这里的非类型参数 n 在两个模板实例化中分别取值 5 和 10,所以编译器会实例化两份不同的实现。
但是,invert() 这个操作的实现是不依赖于矩阵大小的,所以生成两份实现代码导致代码膨胀了。
原书中最后给出的建议是,通过将这份重复的代码外提,也就是将 invert 外提,但为了保证封装性,将 invert 放到一个父类中,而用派生类来传入非类型模板参数。具体的讨论不再赘述,改进代码可以如下:
1 | template<typename T> // 基类也是模板类 |
这种方法将矩阵数据放到类内,可能导致对象内存比较大,可以通过动态分配内存的方式将数据放到堆上,代码不展示了,可以参考原书。
以上讨论的是非类型参数导致的模板代码膨胀。类型参数也可能导致代码膨胀,比如很多平台上 int 和 long 具有相同 二进制表示,但有些编译器并不会自动将其处理成一种类型,比如 vector<int> 和 vector<long> 具有相同的功能,但还是会生成两份代码。这就需要我们手动的去处理,在实现模板类时,让他们调用到同一个函数。标准库中的 vector, list 等已经针对这种情况作了处理。
总结
- Templates 生成多个 classes 和多个函数,所以任何的 template 代码都不应该与某个造成膨胀的 template 参数产生相依关系。
- 因非类型模板参数而造成的代码膨胀,往往可以消除,做法是以函数参数或 class 成员变量来替换 template 参数。
- 因类型模板参数而造成的代码膨胀,往往可降低,做法是让带有完全相同二进制表示的实例化类型共享一份实现代码。
条款 45:运用成员函数模板接受所有兼容类型
Use member function templates to accept “all compatible types”.
本条款的关键词是 成员函数模板。
我们要讨论的主题是智能指针一类类型的实现。智能指针,本质上是具有指针属性的对象,同时它还弥补了一些指针本身不易处理的特性,比如自动回收 heap 资源。
因为指针是支持类型转换的,比如:
1 | class Top { ... }; |
我们希望智能指针也具有这种特性。
但是,因为智能指针其实是模板类实例化的对象,所以会比较麻烦,模板类实例化时,会丢失模板参数类型的继承关系,比如:
1 | template<typename T> |
上例中,pt1 和 pt2 虽然是依据 具有继承关系的两个类 Middle, Bottom 实例化得到的,但它们却已经丢失了继承关系。
话题 1:泛化 copy 构造函数
一种办法是,使用模板成员函数,实现泛化的 copy 构造函数。
1 | template<typename T> |
这种实现,可以使用一个 template<U>SmartP 的类型对象来生成一个 template<T>SmartP 的对象。我们称这种为泛化的 copy 构造函数。
但是,这依然存在问题,如果类型 U 本身不可以用来构造 T,比如用基类对象来构造派生类对象,那实际的构造就没有意义。解决办法是利用指针本身的属性:
1 | template<typename T> |
这样,就可以让普通指针的构造合法性来约束我们自定义的智能指针的合法性了。
话题 2:其他要点
模板成员函数除了作为泛化 copy 构造函数以外,还可以用于其他地方,比如泛化赋值操作。
模板 copy 构造函数和普通 copy 构造函数不同,是两个函数。
总结
- 请使用模板成员函数来生成 “可接受所有兼容类型” 的函数。
- 如果你声明模板成员函数用来实现 “泛化 copy 构造” 或 “泛化赋值操作”,你还需要声明正常的 copy 构造函数和 copy 赋值操作符。
条款 46: 需要类型转换时请为模板定义非成员函数
Define non-member functions inside templates when type conversions are desired.
需要先回顾 条款 24,其中举了一个例子,说明当要对一个函数的所有实参做指向某个类的隐式类型转换时,不要将这个函数作为类成员函数或 friend 函数,而是作为普通函数。
如果将这个类换成模板类,将有另一个需要讨论的问题。
1 | template<typename T> |
之所以出现编译错误,是因为 a * 2 需要隐式调用 const R operator* (const R l, const R r),然而,在编译器做这个隐式调用时,它还无法对模板类 R<T> 做模板实例化,如果 R<T> 没有具现化,那么就无法调用到以该类型作为实参类型的函数(这个函数也没有实例化),从而报错。
这时,通过将函数声明为 friend 反而可以简化这个问题:
1 | template<typename T> |
此时的实例化路径是这样的,因为 a * 2 中的 a 是一个 R<int> 类型,所以可以推断模板参数是 int 类型,进而,需要实例化 R<int> 这个类,而我们已经将要调用的 operator* 声明为 friend,所以它也会被实例化。最终,编译器就可以找到其声明。
如果你担心 friend 函数可能被编译器做隐式的 inline 而影响代码体积,也可以用一个 helper 函数来把重要的内容放到类外来定义,并在 operator* () 中调用这个 helper 函数。
以上是这条条款的主要内容。另外插播一个语法,模板类内的函数中,如果使用该模板类类型,可以不写出模板参数:
1 | template<typename T> |
总结
- 当我们编写模板类时,它所提供的与此模板相关的函数需要支持将所有参数都隐式转换的需求时,请将那些函数定义为模板类内的 friend 函数。
条款 47:请使用 traits classes 表现类型信息 (重要)
Use traits classes for information about types.
这种写法在 C++ 标准库中被很广泛的应用,也是一种很巧妙的管理类型信息的方式。
该条款以 C++ 标准库中的一个叫 advance 的函数来讲解。这个函数用于将一个指定类型(模板参数)的迭代器移动给定的距离,其声明如下:
1 | template<typename IterT, typename DistT> |
我们的目的是实现这个函数。
迭代器在 C++ 中有 5 种类型,C++ 通过 struct 类型来声明这些类别:
- 输入(只读)迭代器 input_iterator_tag {} :正向单次移动,只读且只能读一次,如 istream 迭代器
- 输出(只写)迭代器 output_iterator_tag {} :正向单次移动,只写且只能写一次,如 ostream 迭代器
- 读写迭代器 forward_iterator_tag {} :正向单次移动,可读写任意次,如 iostream 迭代器
- 双向读写迭代器 bidirectional_iterator_tag {} :可双向单次移动,可读写任意次,如 set, map, list 等数据结构的迭代器
- 随机访问迭代器 random_access_iterator_tag {} :可双向任意移动,可读写任意次,如 string, vector 等数据结构的迭代器
所以,我们希望能实现的 advance 结构类似于:
1 | template<typename IterT, typename DistT> |
这里需要处理的问题就是如何判断 iter 类型的代码。
另外,我们希望除了支持用户自定义类型的迭代器以外,还要支持内置指针类型,所以,事实上我们无法将类型信息放到自定义类型内部,只能放到外部。
话题 1: traits 技术
以下进入正题,C++ 中有一种写法,叫 traits 技术,它不是关键字,只是约定俗成的一种实现方案,这个技术的一个要求是,作用于内置类型和作用于自定义类型的功能要一致,比如 advance 也可以处理普通指针的移动操作。
标准的实现将我们 advance 中的迭代器类型信息通过 traits 技术来实现。
首先,需要定义一个针对迭代器的 traits:
1 | template<typename IterT> |
然后,我们分别处理用户自定义类型和内置指针。
用户自定义类型
我们对每个类型内部,声明一个 typedef 指定为 iterator_category,比如:
1 | template< ... > |
而 iterator_traits 的实现比较简单:
1 | template<typename IterT> |
内置指针类型
对于第二种,内置指针的情况,我们用偏特化实例来完成:
1 | template<typename IterT> |
总结一下,实现 traits 技术需要的几个组成部分:
- 想好你的类型(比如本例的 iterator)需要获取的相关信息(比如本例的 iterator 类型);
- 为这个信息取个名字(比如本例的 iterator_category);
- 提供一个 template 来实现 traits (比如本例的 iterator_traits)以及一组 template(内部通过 typedef 来说明信息),不要忘了使用偏特化手段完成对内置类型的支持;
完成 advance
现在,让我们使用上边完成的 trait 来实现 advance。
我们的 iterator 类型判断可以这么写:
1 | if (typeid(typename std::iterator_traits<IterT>::iterator_category) == typeid(std::random_access_iterator_tag)) { |
这行代码有编译错误,我们会在条款 48 中解释。同时,话题 2 中将为这个问题提供一种标准解决方案,这也是 条款 48 中的议题的引子。
话题 2:将判断提前到编译期
刚才的类型判断,因为是控制流中的内容,所以实际上是在运行期判断的。因为类型信息在编译期就可知,所以如果能放到编译期来完成会更好。
一种办法是使用重载函数,因为编译器在调用不同重载函数时,就是通过不同类型来选择不同函数的。
将针对不同类型的实现代码放到不同的重载函数中,然后把类型信息传给不同的重载函数,最后一个统一入口来调用重载函数即可。
实现如下:
1 | template<typename IterT, typename DistT> |
注意第三个实现,因为实际上 bidirectional_iterator_tag 是继承 input_iterator_tag 实现的,所以按多态的设计,也可以接受派生类型,但为了避免出现问题,这里做了额外的判断。更多的重载函数在这里省略。
最后的 advance 函数如下:
1 | template<typename IterT, typename DistT> |
Traits 技术广泛用于标准库,包括我们本例中的 iterator_traits。
另外,traits 并不一定以 _traits 结尾,比如 numeric_limits 用来表示一个类型的最大最小值,is_fundamental 用来判断类型是否是内置类型,is_array 用来判断类型是否是数组,is_base_of 用来判断一个类型是否是另一个类型的基类。
总结
- Traits class 使得类型相关的信息在编译期可用。它们使用 template 和偏特化 template 来实现。
- 通过重载技术,能够让 traits 的判断在编译期完成。
条款 48:认识 template 元编程
Be aware of template metaprogramming.
首先我们开门见山的指出,条款 47 中的 traits 技术,就是一种模板元(TMP)编程技术。
_元编程的概念是只能够通过本语言的编译器生成能够编译的本语言的代码,也就是 “生成程序的程序” 的技术_。在 C++ 中,使用模板来实现这种技术。一个模板元编程的程序,可以经编译器编译,在编译期就能够生成可以编译的代码,这样做的好处有 2 个:
- 可以实现一些普通编程方式完成不了(或很困难)的设计。
- 将一些程序工作从运行期转移到编译期,从而使一些错误在编译期就暴露,也可以减小可执行文件体积和运行空间,提高运行效率。
当然,它有个坏处,就是相比与普通编程方式,它的编译时间变长了。
话题 1:之前留下的 bug
简单解释一下第二点好处。条款 47 中,我们留了个 bug,就是在实现将自定义类型和内置类型使用 if … else 时,这块代码会有编译错误。这个错误是,当一个自定义类型的迭代器类型并不支持 += 操作时,模板具现化后的代码中依然会存在针对这个迭代器类型的 += 操作,比如我们以 template<std::list<int>::iterator, int> 为例,注意到 std::list 类型是双向读写迭代器,它不支持任意读写操作:
1 | if (typeid(typename std::iterator_traits<std::list<int>::iterator>::iterator_category) == typeid(std::random_access_iterator_tag)) { |
因为 iter += d 的代码依然会存在(编译器一定会将运行期可能发生的所有分支全部生成代码,即使有些分支在我们的输入下始终不会执行),所以编译器会检查到这个 += 操作不合法,从而报错。
这个问题就是用模板元编程解决的,也就是条款 47 中使用模板函数重载的方式实现了隐式的 if 判断,这就是一种元编程,它输入模板函数重载的代码,生成了模板具现化后的代码(针对 advance 的例子就是,对不同的类型只选择生成出对应那个操作的代码,而不是所有分支及 if … else 判断)。
话题 2:图灵完全
_模板元编程 技术是图灵完全的,这意味着它能够做任何事情_。
一个最简单的模板元编程的例子是用模板元编程计算阶乘。普通编程习惯性的写法是使用循环,但模板元编程中没有循环,它是通过 递归 来实现的,叫做递归模板具现化(recursive template instantiation)。
1 | template<unsigned n> // 一般情况下 |
这个例子就是模板元编程的代码,其中用到了 条款 2 中提到的 enum hack 的手法,它实现了声明一个没有类型、不占用运行内存、编译期替换但可变值的量(#define 和 const 的一种结合)。
这个 Factorial<5>::value 的值,在编译期就通过元编程机制计算出来了,所以他不占内存还运行很快。
话题 3:适用角度
模板元编程在有些地方很易于使用:
- 确保类型正确。对于一些度量单位,应用必须要求类型不能出错,使用模板元编程技术在编译期分析错误。
- 优化复杂计算。比如矩阵的连乘,如果不使用模板元编程技术在编译器计算,在运行期会多分配很多中间临时矩阵,对于矩阵复杂计算还会浪费很多重复的时间。
- 客户自定义的实现品。文中叫这个为 policy-based design,这个技术超越了编程工艺领域如设计模式和智能指针,更广义的成为 generative programming (殖生式编程)的一个基础。
- TMP 也许永远不是主流,但对于一些应用,比如程序库开发,几乎是必须使用的。
总结
- 模板元编程可将工作由运行期移动到编译期,因而得以实现早期的错误侦测和更高的执行效率。
- 模板元编程能够实现 “基于政策的选择组合” (based on combinations of policy choices) 的客户定制代码,也可以用来避免生成对某些特殊类型不适合的代码。
封面图片来自:Photo by Eren Yıldız on Unsplash
本文同步发布在 CSDN 账号下:Effective C++ 学习笔记 第七章:模板与泛型编程



















