Fork me on GitHub

读书笔记 effective c++ Item 44 将与模板参数无关的代码抽离出来

1. 使用模板可能导致代码膨胀

使用模板是节省时间和避免代码重用的很好的方法。你不需要手动输入20个相同的类名,每个类有15个成员函数,相反,你只需要输入一个类模板,然后让编译器来为你实例化20个特定的类和300个你需要的函数。(只有在被使用的情况下类模版的成员函数才会被隐式的实例化,所以只有在300个函数被实际用到的情况下才会生成300个成员函数。)函数模板同样吸引人。你不用手动实现许多函数,你只需要实现一个函数模板,然后让编译器来做余下的事情。

然而在有些时候,如果你不小心,使用模板会导致代码膨胀(code bloat):产生重复代码或者数据的二进制文件,或者两者都有。结果可能是源码看起来合身整齐,但是目标代码(object code)臃肿松弛。臃肿松弛很不好,因此你需要知道如果避免这样的二进制浮夸。

2. 共性和可变性分析

 你的主要工具有着很威风的名字:共性和可变性分析(commonality and variability analysis),但是这个概念很平常。即使在你的编程生涯中从未实现过一个模板,你也总是会做这样的分析。

2.1 函数和类中的代码重复分析

当你正在实现一个函数,你意识到函数实现的某些部分同另外一个函数实现基本上是相同的 ,你会重复这些代码么?当然不会。你将两个函数的公共代码提取出来,放进第三个函数中,然后在两个函数中调用这个新函数。总结一下就是,你对两个函数进行分析,找到相同和不同的部分,将相同的部分移到一个新的函数中去,将不同的部分保留在原来的函数中。类似的,如果你正在实现一个类,你意识到类中的一部分另一个类中的一部分是相同的,你不应该重写相同的部分。相反,你可以将相同的部分移到一个新类中,然后使用继承或者组合(Item 32,Item 38,Item 39)让原始类访问共同的特性。原始类中不同的部分仍然保留在原来的位置。

2.2 模板中的代码重复分析及消除重复方法

当实现模板的时候,你也会做相同的分析,你会使用相同的方式来阻止重复,但是这里有一个让你伤痛的地方。在非模板(non-template)代码中,重复是显示的:你可以看到在函数之间或者类之间会有代码重复。在模板代码中,重复是隐式的:只有一份模板源码,所以你必须训练你自己当一个模板被实例化多次的时候,你能够感觉到重复会不会发生

2.2.1 消除代码膨胀第一关——去掉非类型参数

例如,假设你想为固定大小的矩阵实现一个模板,需要支持矩阵的转置。

1 template<typename T, // template for n x n matrices of
2 std::size_t n> // objects of type T; see below for info
3 class SquareMatrix { // on the size_t parameter
4 public:
5 ...
6 
7 void invert();                         // invert the matrix in place
8 
9 };     

                                     

这个模板带了一个类型参数,T,但是也带了一个类型size_t的参数,一个非类型(non-type)参数。非类型参数比类型参数少了共性,但是它们是完全合法的,并且在这个例子中,它们也能非常自然。

现在考虑下面的代码:

 1 SquareMatrix<double, 5> sm1;
 2 
 3 ...
 4 
 5 sm1.invert();
 6 
 7 // call SquareMatrix<double, 5>::invert
 8 
 9 SquareMatrix<double, 10> sm2;
10 
11  
12 
13 ...
14 
15  
16 
17 sm2.invert();
18 
19 // call SquareMatrix<double, 10>::invert
20 
21  

 

在这里将会实例化invert的两份拷贝。这两个函数并不相同,因为一个在5*5的矩阵上工作,另外一个在10*10的矩阵上工作,但是如果不考虑常量5和10,这两个函数将会是一样的。这是使得包含模板的代码出现膨胀的典型方式。

如果你看到两个函数,它们的所有字符都是相同的,除了一个版本使用5而另外一个版本使用10,你接下来会做什么?你的直觉是会创建一个带一个参数的函数版本,然后以5或者10为入参调用这个函数而不是重复代码。你的直觉能够很好的为你服务!这是实现SquareMatrix的第一关:

 1 template<typename T> // size-independent base class for
 2 class SquareMatrixBase { // square matrices
 3 protected:
 4 ...
 5 void invert(std::size_t matrixSize); // invert matrix of the given size
 6 ...
 7 };
 8 template<typename T, std::size_t n>
 9 class SquareMatrix: private SquareMatrixBase<T> {
10 private:
11 using SquareMatrixBase<T>::invert; // make base class version of invert
12 // visible in this class; see Items 33
13 // and Item 43
14 public:
15 ...
16 void invert() { invert(n); } // make inline call to base class
17 }; // version of invert

 

正如你所看到的,带参数的invert版本被放在基类SquareMatrixBase中。像SquareMatrix一样,SquareMatrixBase是一个模板,但是与SquareMatrix不同的是,它在矩阵中只对对象类型进行模板化。因此,包含一个给定类型对象的所有矩阵将会分享一个单一的SquareMatrixBase类。这样它们会分享SquareMatrixBase类的invert版本的单一拷贝。(你不能将其声明为inline,因为一旦被inline了,每个SquareMatrix::invert的实例都会得到SquareMatrixBase::invert代码的一份拷贝(看Item 30),你会发现你有回到了对象代码重复的原点。)

SquareMatrixBase::invert只被用来在派生类中防止代码重复,所以是protected而不是public的。调用它的额外开销应该是0,因为派生类的inverts调用基类版本使用了inline函数。(inline是隐式的 见Item 30)同时注意SquareMatrix和SquareMarixBase之间的继承是private的。这精确的反映出一个事实:使用基类的唯一原因是帮助派生类的实现,并非表达出SquareMatrixSquareMatrixBase之间的“is-a”关系。(有关private继承的信息,见Item 39

2.2.2 消除代码膨胀第二关——派生类如何告知基类数据在哪里

到现在为止看上去都很好,但是还有一个我们没有处理的棘手的问题。SquareMatrixBase::invert如何知道在什么数据上进行操作?它从参数中得知矩形的大小,但是它如何知道为特殊矩阵提供的数据在哪里?大概只有派生类才会知道。派生类如何同基类进行通讯才能让基类执行invert?

一个可能的方法是向SquareMatrixBase::invert中添加另外一个参数,可能是一个指向一块内存的指针,内存中存放矩形数据。这种方法可以工作,但是十有八九,invert不是存在于SquareMatrix中的能够以独立于size的方式重写的,并且移入SquareMatrixBase中的唯一函数。如果有几个这样的函数,我们就需要一种方法能够找到存放矩形数据的内存,我们可以为所有的函数添加一个额外的参数,但是如此以来我们就重复告诉了SquareMatrixBase同样的信息。这看上去是错误的。

一个替换方法是让SquareMatrixBase存储一个指向存放矩形数据的内存的指针。这同存放矩形大小有相同的效果。结果如下:

 1 template<typename T>
 2 class SquareMatrixBase {
 3 protected:
 4 SquareMatrixBase(std::size_t n, T *pMem) // store matrix size and a
 5 : size(n), pData(pMem) {} // ptr to matrix values
 6 
 7 void setDataPtr(T *ptr) { pData = ptr; }   // reassign pData
 8 
 9 ...                                                            
10 
11 private:                                                  
12 
13  
14 
15 std::size_t size;           // size of matrix
16 
17 T *pData;       // pointer to matrix values
18 
19 
20 };

 

这就让派生类来决定如何分配内存。一些实现会在SquareMatrix对象内部存储矩形数据:

1 template<typename T, std::size_t n>
2 class SquareMatrix: private SquareMatrixBase<T> {
3 public:
4 SquareMatrix() // send matrix size and
5 : SquareMatrixBase<T>(n, data) {} // data ptr to base class
6 ...
7 private:
8 T data[n*n];
9 };

 

这种类型的对象没有必要做动态内存分配,但是对象本身可能会非常大。一个替换的方法是为每个矩形在堆上存放数据:

 1 template<typename T, std::size_t n>
 2 class SquareMatrix: private SquareMatrixBase<T> {
 3 public:
 4 SquareMatrix() // set base class data ptr to null,
 5 : SquareMatrixBase<T>(n, 0), // allocate memory for matrix
 6 pData(new T[n*n]) // values, save a ptr to the
 7 { this->setDataPtr(pData.get()); } // memory, and give a copy of it
 8 
 9 ...                                              // to the base class
10 
11 private:
12 boost::scoped_array<T> pData;          // see Item 13 for info on
13 
14  
15 
16 };                                   // boost::scoped_array

 

2.2.3 消除代码膨胀前后效率对比

不管将数据存放在哪里,从代码膨胀的角度来说,关键结果是现在很多(可能是所有的)SquareMatrix的成员函数可以简单的inline调用基类的(non-inline)函数版本,所有持有相同类型数据的矩形共享基类中的函数,不管size是多少。同时,不同size的SquareMatrix对象属于不同类型,所以即使SquareMatrix<double,5>和SquareMatrix<double,10>对象在SquareMatrixBase<double>中使用相同的成员函数,把一个SquareMatrix<double,5>对象传给一个需要SquareMatrix<double,10>的函数是没有机会的。好还是不好呢。

好是好,但是需要付出代价。矩形size大小固定的invert版本比按函数参数传递size大小(或者存储在对象中)的invert版本可能产生更好的代码。例如,在指定size的版本中,sizes是编译期常量,因此是常量传播优化的合格者,也可以把其放入生成指令中作为直接操作数。这在同size无关的版本中无法做到。

从另外一个方面,为不同size的矩阵只提供一个invert版本可以减小可执行程序的大小,这能减少程序的工作集大小,并且能够强化指令高速缓存的引用集中化。这些东西能够使得程序运行速度更快,并且相对size指定的版本才能做出的优化,它可能会做出更好的补偿。哪种方法效果更好?唯一的方法是两种方法都试一下,在你的特定平台和有代表性的数据集上观察它们的行为。

另外一个有关效率的需要考虑的地方是有关对象的大小。如果你不介意,将size大小无关的版本向上移动到基类中会增加每个对象的大小。例如,在我刚刚展示的代码中,每个SquareMatrix对象有一个指向SquareMatrixBase类中数据的指针。即使每个派生类中已经有取得数据的方法,这也为每个SquareMatrix对象至少增加一个指针的大小。我们可以修改设计来去掉指针,但是这也是需要付出代价的。例如,让基类存储一个指向数据的protected指针,但会导致封装性的降低(Item 22).它同样能导致资源管理并发症:如果基类存储了指向矩阵数据的指针,但是数据既有可能是动态分配的也可能存储在派生类对象中(正如我们看到的),如何决定是不是需要delete指针?这样的问题是有答案的,但是你做的越精细事情就变得越复杂。从某种意义上讲,有一点代码重复开始开起来有点幸运了。

2.3 如何处理类型模板参数导致的代码膨胀

这个条款仅仅讨论了由于非类型模板参数导致的代码膨胀,但是类型参数同样可以导致代码膨胀。例如,在许多平台中,int和long有着相同的二进制表示,所以在成员函数中使用vector<int>和vector<long>看起来会一样,这正是代码膨胀的定义。一些连接器会把相同的代码实现整合到一起,但是有一些不会,这就意味着由模板实例化的int和long版本会在一些环境中导致代码膨胀。类似的,在大多数平台上,所有的指针类型有着相同的二进制表示,所以带指针类型的模板(例如,list<int*>,list<const*>,list<SquareMatrix<long,3>*>等等)应该通常能够为每个成员函数使用一个单一的底层实现。特别的,这就意味着实现一个强类型指针(T* 指针)的成员函数时,让它们调用一个无类型指针的函数(void*指针)。一些标准C++库的实现为模板就是这么做的(如vector,deque,和list)。如果你关心在你的模板中出现的代码膨胀问题,你可能就会想开发出做相同事情的模板。

3. 总结

  • 模板会产生多个类和多个函数,所以任何模板不应该依赖于会导致代码膨胀的模板参数。
  • 非类型模板参数导致的代码膨胀通常情况下可以将模板参数替换为函数参数或者类数据成员来清除。
  • 由类型参数导致的代码膨胀也可以被降低,方式是为实例化类型共享相同的二进制表示。
posted @ 2017-04-07 22:01  HarlanC  阅读(996)  评论(0编辑  收藏  举报