标题上说“将与参数无关的代码抽离template”,这里的参数既可以指类型,也可以是非类型,我们先来看看非类型的情况。
假定我们要为矩阵写一个类,这个矩阵的行列元素个数相等,是一个方阵,因而我们可以对之求逆运算。因为方阵的元素可以有多种类型,同时方阵的维数(方阵大小)也可以不同,像下面这样,我们使用了模板:
template <class T, size_t n>
class SquareMatrix
{
public:
void Invert();
};
int main()
{
SquareMatrix<int, 10> a;
SquareMatrix<int, 5> b;
}
模板既可以指定类型,也可以指定其他参量,比如这里的size_t,用来表示方阵的维数。程序看起来是没有问题的,编译也是通过的,但从代码优化上看,还是有空间可以做的。a和b虽然都是元素为int型的方阵,但因为方阵维数不同,因而生成了两个Invert函数,这两个Invert函数的代码只是在循环个数上不同(取觉于方阵维数n),但算法思想完全是一样的。
为了防止编译器生成冗余的代码,我们可以将Invert抽离这里,变成一个以n为参数的函数,像下面这样:
class SquareMatrixBase
{
public:
SquareMatrixBase(T* p) : DataPointer(p){}
void Invert(size_t n){}
private:
T* DataPointer;
};
template <class T, size_t n>
class SquareMatrix: private SquareMatrixBase<T>//private 继承
{
public:
SquareMatrix() : SquareMatrixBase(Data)
{}
void Invert()
{
SquareMatrixBase::Invert(n);//调父类函数
}
private:
T Data[n * n];
};
这里我们定义了一个BaseSquareMatrix类,这个类可以视为一个工具类,因为SquareMatrix是private继承于它的,我们把算法一致、只与维数n相关的算法函数都放在BaseSquareMatrix中,且将n作为它的Invert()函数的形参,为了使BaseSquareMatrix可以访问数据,从而求逆运算,我们声明了它的成员指针,这个指针将会在构造时指向子类的数据存储空间。
子类SquareMatrix没有大变化,只是在Invert的时候,用了一句代码就搞定了——调用父类的Invert函数,并把维数n传过去。
这样在main函数中,针对类型都是int,但矩阵维数不同的情况,Invert中生成的冗余代码非常少(只有一句话),父类也因为模板参数与维数无关,所以也只生成一份。从这个角度来看,将与参数无关的代码抽离template起到了减少代码冗余度的优化作用。(???但是在子类中依然有size 模板参数)
目前只是说将与类型无关的代码进行抽离(造一个父类,把算法相同的部分提取出来,放到父类),对于不同类型,其实也可以通过void*来实。比如STL,要想令我们使用list<int*>,list<const int*>生成的代码冗余度保持很低,这就需要在相应的函数里面,将之链到父类的一个公共实现版本里面,这个版本的形参是void*。
最后总结一下:
1. Template生成多个classes与多个函数,所以任何template代码都不该与某个造成膨胀的template参数产生相依关系。
2. 因非类型模板参数而造成的代码膨胀,往往可以消除,做法是以函数参数或者class成员变量替换template参数。
3. 因类型而造成的代码膨胀,也可以降低,做法是让带有完全相同二进制表述的具现类型共享实现码。
- 模板是个好东西,你可以在保证类型安全的同时用一份代码同时兼容不同类型。 但模板提供的是编译期的多态, 即使你的代码看起来非常简洁短小,生成的二进制文件也可能包含大量的冗余代码。 因为模板每次实例化都会生成一个完整的副本,所以其中与模板参数无关的部分会造成代码膨胀(code bloat)。
把模板中参数无关的代码重构到模板外便可以有效地控制模板产生的代码膨胀。 另外代码膨胀也可以由类型模板参数产生:
- 对于非类型模板参数产生的代码膨胀,用函数参数或数据成员来代替模板参数即可消除冗余;
- 对于类型模板参数产生的代码膨胀,可以让不同实例化的模板类共用同样的二进制表示。
抽取公共代码
在避免代码冗余的问题上,抽取公共代码(commonality and variability analysis)是我们每天都在用的方法。 当你写几个函数时,会把其中的公共部分抽取到另一个函数;当你声明类时,也会把它们的公共部分抽取到父类中。
于是你希望在模板编程中也用该办法来避免代码重复,但模板和非模板代码在这一点上是不同的:
- 非模板的代码中,冗余的显式的(explicit)。只要有重复代码你都会看到它;
- 模板代码中,冗余是隐式的(implicit)。模板代码只有一份,模板被实例化时产生的冗余需要你的直觉才能感受到。
模板产生的代码膨胀
现在来看一个模板是怎样引发代码膨胀的。比如要实现一个固定大小的矩阵,它支持转置运算。
template<typename T, int n>
class Square{
public:
void invert();
};
其中的
int n
是一个非类型参数,它也是一种合法的模板参数~
然后可能会这样使用该模板:
Square<double, 5> s1;
Square<double, 10> s2;
s1.invert(); s2.invert();
Square
模板会实例化两个类:Square<double, 5>
和Square<double, 10>
,它们拥有相同的invert
方法。 这是模板产生代码膨胀的典型场景。
抽取父类模板
结局模板产生的代码膨胀,仍然是用抽取公共代码的办法。如果你真的看到了二进制代码中两个相同的invert
函数, 你的直觉肯定是把它抽取到另一个类中:
template<typename T>
class SquareBase{
protected:
void invert(int size);
};
template<typename T, int n>
class Square:private SquareBase<T>{
private:
using SquareBase<T>::invert;
public:
void invert(){ this->invert(n); }
}
因为invert
函数定义在基类中,所以它只会在二进制代码中出现一次,即SquareBase<double>::invert
。该函数由两个子类共享。 上述代码中有些细节还值得一提:
SquareBase::invert
是供子类用的,所以声明为private
而不是public
;- 调用父类
invert
的代价为零,因为Square::invert
是隐式的inline函数,见Item 30; - 使用
this->
前缀是因为,SquareBase
里的名称在子类模板Square
里是隐藏的,见Item 43; - 使用
private
继承是因为,Square
is implemented in terms ofSquare
,见Item 39。
数据存储问题
既然我们决定由父类来做invert
操作,那么父类怎么访问数据呢?因为数据本来是在子类中的。 当然我们可以在调用SquareBase::invert
时把内存地址也一起告知父类, 但如果矩阵类中有很多函数都需要这些信息呢?我们可能需要调用每个函数时都把这些信息传递给父类函数。 既然这样,何不把数据地址直接放在父类中?既然父类存放了数据,那么就把矩阵大小也一并存放了吧!
template<typename T>
class SquareBase{
protected:
SquareBase(int _n, T *_data): n(_n), data(_data){}
void setData(T *_data){
data = _data;
}
private:
int n;
T* data;
};
父类中存储了矩阵数据的位置(data
)以及大小(n
),子类仍然可以决定如何分配地址空间。 可以存放在子类中作为成员属性,也可以动态申请内存。
权衡
不管数据是怎样分配和访问的,我们消除代码重复的方案是确定的:将公共部分抽取到父模板类中。 这样做的好处便是避免了代码膨胀,减小了二进制文件和”working set”的大小,有利于提高指令缓存的命中率, 从而达到更高的代码执行效率。但提取公共部分到新的模板类也造成了一些问题:
- 如果
int n
硬编码在模板参数中的话,编译器能做更多的优化,比如常量传播等。但int n
作为函数参数,这些优化就没有了。 - 添加类的层级会导致对象大小的增加。至少多存储了一个
T* data
指针。
实践中到底是否应该抽取公共代码出来取决于你的应用场景,在上述的优劣中进行权衡。
本问讨论的是非类型模板参数,对于类型模板参数,代码膨胀的问题也是存在的,比如
int
和long
在多数平台都是一样的底层实现,然而模板却会实例化为两份,因为它们类型不同。List<int *>
,List<const int *>
,List<double *>
的底层实现也是一样的。但因为指针类型不同,也会实例化为多份模板类。