模板是节省时间和避免代码重复的绝佳方式。你不需要编写20个相似的类,每个类包含15个成员函数,只需编写一个类模板,然后让编译器为你实例化所需的20个特定类和300个函数即可(类模板的成员函数只有在使用时才会被隐式实例化,所以只有当你确实用到全部300个成员函数时,它们才会被生成)。函数模板也同样吸引人。你不需要编写许多函数,只需编写一个函数模板,剩下的交给编译器。技术可真了不起呀?
是的,嗯……有时候吧。如果你不够小心,使用模板可能会导致代码膨胀(code bloat):即二进制文件中出现了重复(或几乎重复)的代码、数据,或两者皆有。结果可能是源码看起来精干利落,但目标代码却臃肿肥硕。臃肿肥硕从来都不时髦,所以你需要知道如何避免这种二进制文件的虚胖。
你的主要工具叫做共性与变性分析(commonality and variability analysis),但这个概念本身并不高深。即使你一生中从未写过模板,你其实也一直在做这样的分析。
当你编写一个函数时,如果意识到该函数的某部分实现与另一个函数的实现本质上相同,你会直接复制代码吗?当然不会。你会把公共代码从两个函数中提取出来,放到第三个函数中,然后让另外两个函数都去调用这个新函数。也就是说,你分析这两个函数,找出其中共有的部分和变化的部分,把共有部分移到新函数中,而把变化的部分留在原函数中。同样,如果你在编写一个类,发现类的某些部分与另一个类的部分相同,你也不会复制那些共有部分。相反,你会把共有部分移到新类中,然后通过继承或组合(参见条款32、38和39)让原来的类能够使用这些共有功能。原类中那些不同的部分——即变化的部分——仍然留在原来的位置。
在编写模板时,你做的是同样的分析,用同样的方式避免重复,但有一个不同之处。在非模板代码中,重复是显式的:你可以看到两个函数或两个类之间存在重复。而在模板代码中,重复是隐式的:模板源码只有一份,所以你必须训练自己去感知当模板被多次实例化时可能发生的重复。
例如,假设你要编写一个固定大小的方阵模板,它除了其他功能外还支持矩阵求逆:

这段模板接受一个类型参数 T,同时还接受一个 size_t 类型的参数——一个非类型参数。非类型参数不如类型参数常见,但它们完全合法,而且如本例所示,它们可以非常自然。
现在考虑这段代码:

这里将实例化两份 invert。这两个函数并不完全相同,因为一个作用于 5×5 矩阵,另一个作用于 10×10 矩阵,但除了常量 5 和 10 之外,两个函数是一样的。这是模板引发代码膨胀的典型方式。
如果你看到两个函数,除了一个用 5 而另一个用 10 之外逐字符相同,你会怎么做?你的直觉会是创建一个接受一个值作为参数的函数版本,然后用 5 或 10 调用这个带参数的函数,而不是复制代码。你的直觉是正确的!下面是针对 SquareMatrix 做到这一点的初次尝试:

如你所见,参数化的 invert 版本位于基类 SquareMatrixBase 中。与 SquareMatrix 一样,SquareMatrixBase 是一个模板,但与 SquareMatrix 不同的是,它仅在矩阵中对象的类型上模板化,而不是在矩阵的大小上。因此,所有持有给定类型对象的矩阵将共享一个单一的 SquareMatrixBase 类。它们从而共享该类 invert 版本的一份副本。
SquareMatrixBase::invert 的用途仅仅是作为派生类避免代码复制的一种方式,所以它是 protected 而非 public。调用它的额外成本应该为零,因为派生类的 invert 使用内联函数调用基类版本(内联是隐式的——参见条款30)。这些函数使用了 this-> 标记法,因为否则,正如条款43所解释的,模板化基类(如 SquareMatrixBase<T>)中的函数名称会被派生类隐藏。还请注意,SquareMatrix 与 SquareMatrixBase 之间的继承是 private 的。这准确地反映了这样一个事实:基类的存在仅仅是为了方便派生类的实现,而不是为了表达 SquareMatrix 与 SquareMatrixBase 之间的概念上的 is-a 关系(关于 private 继承的信息,参见条款39)。
到目前为止,一切顺利,但我们还有一个棘手的问题没有解决。SquareMatrixBase::invert 如何知道要对什么数据进行操作?它从参数中知道矩阵的大小,但它如何知道特定矩阵的数据在哪里?大概只有派生类知道。派生类如何将该信息传递给基类,以便基类能够进行求逆运算呢?
给 SquareMatrixBase::invert 增加另一个参数是一种可能的方式,也许是一个指向矩阵数据所在内存块起始位置的指针。这可行,但很可能 invert 并不是 SquareMatrix 中唯一可以按与大小无关的方式编写并移入 SquareMatrixBase 的函数。如果有几个这样的函数,它们都需要一种方法来找到存储矩阵值的内存。我们可以给它们都增加一个额外参数,但这样我们会反复告诉 SquareMatrixBase 相同的信息。这似乎不太对。
另一种方式是让 SquareMatrixBase 存储一个指向矩阵值内存的指针。既然它要存这个,那不妨也把矩阵大小存下来。最终的设计如下:

这让派生类可以决定如何分配内存。有些实现可能会选择将矩阵数据直接存储在 SquareMatrix 对象内部:

这种类型的对象不需要动态内存分配,但对象本身可能非常大。另一种选择是将每个矩阵的数据放在堆上:

无论数据存储在哪里,从代码膨胀的角度来看,关键结果是:现在许多——也许是所有——SquareMatrix 的成员函数都可以简单地内联调用基类版本,而这些基类版本与所有持有相同类型数据的其他矩阵(无论其大小如何)共享。同时,不同大小的 SquareMatrix 对象是不同的类型,所以即使 SquareMatrix<double, 5> 和 SquareMatrix<double, 10> 对象使用 SquareMatrixBase<double> 中相同的成员函数,也绝不可能将一个 SquareMatrix<double, 5> 对象传递给一个期望 SquareMatrix<double, 10> 的函数。不错吧?
这段设计固然不错,但也并非没有代价。将矩阵大小作为模板参数硬编码到函数中的 invert 版本,很可能比共享版本(大小作为函数参数传递或存储在对象中)生成更好的代码。例如,在尺寸特定的版本中,大小是编译期常量,因而可以进行常量传播等优化,包括将常量作为立即数整合到生成的指令中。这在尺寸无关的版本中无法做到。
另一方面,为多种矩阵尺寸只保留一个 invert 版本能减小可执行文件的大小,这可能降低程序的工作集(working set)大小,并改善指令缓存的引用局部性。这些因素能使程序运行得更快,其好处可能会超过尺寸特定版本中那些失去的优化所带来的损失。哪种效果会占主导?唯一的方法是对两种方式进行测试,并在你的特定平台和代表性数据集上观察行为。
另一个效率考量涉及对象的大小。如果你不够小心,将尺寸无关的函数版本上移到基类中可能会增加每个对象的总大小。例如,在我刚才展示的代码中,每个 SquareMatrix 对象在 SquareMatrixBase 类中都有一个指向其数据的指针,即使每个派生类已经有办法访问数据。这使每个 SquareMatrix 对象至少增加了一个指针的大小。可以修改设计来避免这些指针,但同样,需要做出权衡。例如,让基类存储一个指向矩阵数据的 protected 指针会导致条款22中所描述的封装性损失。它还可能导致资源管理复杂化:如果基类存储了一个指向矩阵数据的指针,但该数据可能是动态分配的,也可能物理上存储在派生类对象内部(如我们所见),那么如何确定该指针是否应该被删除?这些问题都有答案,但你对它们考虑得越精细,事情就越复杂。到了某个程度,稍微复制一点代码反倒显得是一种仁慈了。
本条款只讨论了由非类型模板参数引发的代码膨胀,但类型参数同样可能导致代码膨胀。例如,在许多平台上,int 和 long 具有相同的二进制表示,因此 vector<int> 和 vector<long> 的成员函数很可能完全相同——这正是代码膨胀的定义。有些链接器会合并相同的函数实现,但有些不会,这意味着在某些环境中,同时用 int 和 long 实例化的模板可能会造成代码膨胀。类似地,在大多数平台上,所有指针类型具有相同的二进制表示,因此持有指针类型的模板(如 list<int*>、list<const int*>、list<SquareMatrix<long, 3>*> 等)通常应该能够为每个成员函数使用单一的底层实现。通常,这意味着实现那些使用强类型指针(即 T* 指针)的成员函数时,应该让它们去调用另一个无类型指针(即 void* 指针)的函数。标准 C++ 库的某些实现对于 vector、deque 和 list 等模板就是这样做的。如果你担心模板中的代码膨胀,你很可能也想开发出做同样事情的模板。
切记:
1.模板会生成多个类和多个函数,因此任何不依赖于模板参数的模板代码都会导致代码膨胀。
2.由于非类型模板参数导致的代码膨胀,通常可以通过将模板参数替换为函数参数或类数据成员来消除。
3.由于类型参数导致的代码膨胀,可以通过为具有相同二进制表示的实例化类型共享实现来减少。

37万+

被折叠的 条评论
为什么被折叠?



