为什么需要模板:把类型空出来当参数
上一章咱们给 Canvas 装元素,用的是 vector<unique_ptr<Shape>>;调 emplace 的时候,它头上还顶着一行 template <typename ConcreteShape, typename... Args>。这两样东西当时拿来就用,现在可以点破:vector 是模板,emplace 头上顶着的那行,就是模板的声明。您已经用了一整章模板,只是没追问过:一份代码,凭什么装 int 也行、装 std::string 也行?
好问题,好问题。先剧个透:欢迎来到泛型编程的世界!
那咱们就从一个看着不起眼的需求入手吧!
请您起手写一个栈:能装 int;再要一个装 double 的,再要一个装 std::string 的。push、pop、top 三件事,逻辑一个字都不差。
哦莫!建议bro您写一下,我是建议您体会一下,想一想写 C 的日子,您学完之后的内容方才深刻啊(喜)
您会怎么办?最省事的办法摆在眼前:写完 int 版,复制两份,把类型名换掉。
能跑,但栈要装几种类型,就得维护几份代码:咱们哪天发现 pop 少判了空栈,得挨个文件改过去,漏一个就多一个 bug;标准库要是也这么写,光是 vector 就得配上几百份几乎相同的代码。重复的根源其实只有一条:类型被写死在了代码里。
这类问题有个名字:泛型编程
三个栈这种“逻辑一份、类型多种”的困境,不是咱们运气差撞上的,它普遍到有专门的名字:泛型编程(generic programming)。
这个名字是 David Musser 和 Alexander Stepanov 在 1980 年代叫响的,他们给的定义很实在:从具体、高效的算法里做抽象,得到对一整类类型都成立的算法。落到咱们这两个例子上:写 smallest,逻辑真正用到的只有“两个东西能比大小”;写 Stack,真正用到的只有“元素能拷贝”。把这些要求留下,把具体类型抽掉,一份逻辑就对所有满足要求的类型成立。泛,广泛;型,类型,名字说的就是这个意思。
这套思路比 C++ 的模板老得多。函数式语言 ML 在 1970 年代就写出了对任意类型都成立的函数,比如那个对任何元素的列表都能算长度的 length;Ada 语言 1983 年定型时就带着 generic 机制,Musser 和 Stepanov 最早那批泛型算法库,拿的就是 Ada。C++ 这边的答案是 1990 年前后定稿的模板(template),动机不复杂:让容器和算法写一份,类型随便填。Stepanov 后来到惠普实验室,和 Meng Lee 一起用模板写出了 STL(Standard Template Library,标准模板库),1994 年 7 月被标准委员会投票收进草案,1998 年随第一个正式标准落地;Java 和 C# 各自的泛型,要到 2004、2005 年才跟上。您上一章天天用的 vector,就是从 STL 一路传下来的。
这样看来,泛型编程可真不是 C++ 的特权,它更接近一套思路。咱们讲的模板,是 C++ 为这套思路造的语言机制;Java、C# 讲的“泛型”,则是它们各自的机制,说的是同一套想法的不同实现。还有个背景供您参考:Stepanov 对面向对象是出了名的不客气,公开说过“我觉得 OOP 在技术上是站不住的!”。
把类型当参数传进去
思路有了,名字有了,剩下的问题是怎么写。C++ 落地这套思路的语法就叫模板,咱们上手写一个最小的:
template <typename T>
T smallest(T a, T b)
{
return (a < b) ? a : b;
}template <typename T> 声明这里有一个类型参数 T,函数体里所有 T,都要等咱们用到这个函数时才替换成真实类型;填入真实类型、生成具体代码的这个动作,叫实例化(instantiation)。语法的每个细节下一篇展开,这里咱们真跑一遍,看它是不是一份逻辑伺候了三种类型:
std::cout << smallest(3, 7) << '\n';
std::cout << smallest(2.5, 1.5) << '\n';
std::cout << smallest(std::string("banana"), std::string("apple")) << '\n';输出:
3
1.5
apple咱们一次写完,int、double、std::string 三个版本全有了,复制粘贴一行都没用上。函数能这么干,类也能,Stack<int>、Stack<std::string> 的写法下一篇讲。眼下有一件更要紧的事,得现在就验证:模板生成的这些类型,彼此是什么关系?
一份代码变成一堆类型
咱们把栈写成类模板,再故意把上一章的老习惯带过来:拿一个类型的指针,去指向另一个类型,看编译器答不答应。
template <typename T>
class Stack {
public:
void push(const T& v) { data_.push_back(v); }
private:
std::vector<T> data_;
};
int main()
{
Stack<int> a;
Stack<double> b;
Stack<int>* p = &b; // 编译器会同意吗?
}GCC 的答复:
distinct.cpp:15:21: error: cannot convert ‘Stack<double>*’ to ‘Stack<int>*’ in initialization
15 | Stack<int>* p = &b;
| ^~不同意。编译器在告诉咱们:Stack<int> 和 Stack<double> 是两个互不相干的类型,跟 int 和 double 本身一个性质。它们都出自同一份 Stack 模板,编译器照着这份模板,给每种类型各生成了一份独立的代码。上一章的继承是让一堆类型变成一家子,Circle 是一种 Shape;模板正相反,让一份代码变成一堆类型,生成的类型之间没有任何亲缘。所以在 Stack<int> 里,元素就是连续排布的裸 int,没有虚表指针,没有间接跳转,访问它和访问普通数组没有区别。
两条路各管各的事
看到这里您可能想问:上一章的多态不也是“一套接口伺候多种类型”吗,写栈这件事,虚函数为什么插不上手?咱们按上一章的思路真试一次就明白了。给所有栈一个共同基类,接口这么定:
class StackBase {
public:
virtual void push(const ???& value) = 0; // ??? 处写什么?
};push 的参数类型必须写死。写 int,std::string 就进不来;写个万能基类 Object,就等于要求每种元素类型都从它派生。Circle 能这么办,因为它本来就是咱们自己写的类,加一行 : public Shape 就行。int 呢?咱们试着让它加入类层次:
class Shape {
public:
virtual ~Shape() = default;
};
class IntShape : public int { // 让 int 加入类层次?
};真跑一下,GCC 的报错是这样的:
dead1.cpp:6:25: error: expected class-name before ‘int’
6 | class IntShape : public int {
| ^~~int 是内置类型,它根本不是类,谁也继承不了;把 int 换成 double 再试,同样的报错再来一遍,继承这条路对内置类型整个关着。硬走也有办法:写一个基类 Box,再造 class IntBox : public Box 把值裹进去压栈,取出来再拆。
恭喜,您发现了 Java 的思路。但是您也知道,这简直就是依托大分啊,完全背离了 C++ 的一贯原则,笔者相信您很难接受得了——尽管,没人阻止您这样写。
这个写法让每个元素都得在堆上单独构造一个对象,身上多背一个虚表指针,每次取值都走一遍虚函数。更难受的是类型有多少种,壳就得写多少个,IntBox、DoubleBox、StringBox 一路排下去——咱们本来想消灭重复代码,这一绕,重复反而更多了。这套壳方案有个出名的实例:Java 里 List<int> 不存在、只有 List<Integer>,等于把包壳定成了语言规则。
堵住的根源值得咱们停下来想清楚:继承解决的是“同一个类型家族里,运行时挑不同的实现”,而栈这个问题正好反着——要变的就是参数本身的类型。类型这个东西,在继承体系里找不到位置。
所以虚函数和模板是两条各管各的路:虚函数在运行时挑实现,模板在编译期生成实现。什么时候用哪条,判据其实很清楚:具体类型要到运行时才知道,用虚函数,Canvas 解析到什么图形画什么,写代码时确实没法预知;类型在写代码时就定了,纯粹是逻辑重复,用模板,Stack<int> 在您敲下这行代码的那一刻就定了。
模板这种做法有个流传很广的名字,叫编译期多态(compile-time polymorphism),也叫静态多态。名字想说的是:同一段调用,能落到不同的实现上,smallest(3, 7) 和 smallest 的字符串版本,调用处长得一模一样。但“多态”两个字容易把咱们带偏,好像 Stack<int> 和 Stack<double> 之间也有 Circle 和 Shape 那种家族关系——没有,刚才编译器已经给出了答复。继承的多态是一份接口、一堆实现,运行时挑一个;模板这边,一份代码在编译期按类型各生成一份,类型之间没有家族。
| 虚函数多态 | 模板泛型 | |
|---|---|---|
| 挑选/生成实现的时机 | 运行时 | 编译期 |
| 类型之间的关系 | 必须同出一个基类 | 互不相干,撑得住这份逻辑就行 |
内置类型(int、double) | 进不来,只能包壳 | 直接支持 |
| 开销形态 | 每次调用一次间接跳转,还挡内联 | 零分派开销,但每种类型各生成一份代码 |
| 典型场景 | 插件、GUI 控件树、运行时解析输入 | 容器、通用算法、工具函数 |
两条路不是二选一,实际工程里它们经常配合。咱们回头看上一章的 Canvas:它持有 vector<unique_ptr<Shape>>,vector 本身是模板,管“容器装什么类型”;装进去的 Shape* 走虚函数,管“画布怎么画”。一个类里,两套机制各干各的活。
接下来三篇咱们把模板的零件逐个拆开:函数模板把类型推导和几十行报错的读法讲清楚,类模板带咱们亲手实现一个泛型栈,特化则管“通用版本照顾不到的类型”该怎么单独安排。学完这三篇,vector、string 这些天天在用的东西是怎么造出来的,您就能看明白了——第 10 章的 STL,全靠这一章打底。
练习
练习 1:判断走哪条路
下面三个场景,请您各选虚函数或模板,并说一句理由:一个能装任意元素类型的顺序表;图形编辑器里可在运行时启用的插件式工具;解析 JSON 文件,值可能是字符串、数字或嵌套对象。
练习 2:找找上一篇的模板
请您回到上一篇 OOP 实战的代码,把模板出现过的位置都找出来(至少 4 处)。提示:emplace 头上那行只是最显眼的一个。
练习 3:讲给朋友听
请您不写代码,口头解释:为什么 Shape* 可以指向 Circle,而 Stack<int>* 不能指向 Stack<double>?能把这两种关系的区别讲明白,这一篇就算过关了。