怎么我重复写了这么多同样的代码?可能它们被运用于不同的类和数据类型,所以无法复用,这时模板就允许我们写作用于不同类的通用代码了。
1.寻找通用代码
在详述模板之前,思考一个简单而常见的例子:列表,成百上千的它们被用在游戏里的各处,那我们该怎么实现呢?以下是几种可能性:
1.方法一:包含在类中
这很符合直觉,简简单单加了一个指针和几个插入和删除元素的函数,就做好了。
class GameEntity
{
public:
// All GameEntity functions here
GameEntity * GetNext();
void RemoveFromList() ;
void InsertAfter(GameEntity * pEntity);
private:
// GameEntity data
GameEntity * m_pNext;
};
虽然能跑,但是这个设计很糟糕(别骂了)。当项目越来越大,同样的代码就不得不在所有类中重写一遍来实现各种各样的列表。拷贝时极易忘记某步骤,导致程序崩溃的风险大大增高。即使千辛万苦后改正了所有错误,在迭代时也是噩梦。如果要改成双重列表呢?就算完成了迭代,这样的工作量也会让你奄奄一息。别忘了,不同类的列表可能有不同的接口,毕竟其他程序员总有不同的想法,比如函数命名不一样。由于找不到标准化的接口,连基础的迭代和元素计数功能都难以调用,更别提知道内部的实现方法了。
2. 方法二:使用宏
使用预处理宏,把LIST_DECL加到头文件里、LIST_IMPL(MyClassName)加到cpp文件里:
// In GameEntity.h
class GameEntity
{
public:
// All GameEntity functions here
LIST _DECL(GameEntity) ;
private:
// GameEntity data
};
// In GameEntity.cpp
LIST_IMPL(GameEntity) ;
现在,所有的列表都有了统一的接口,就可以轻而易举地使用了;当需要更改功能时,直接迭代宏就可以。但它也不完美:预处理宏因为难以开发、维护、尤其是调试而臭名昭著:无从得知错误在宏中的具体位置。
3. 方法三:继承
或许可以建一个个管理列表的基类?它包含了列表的数据、向前和向后的指针、负责插入和删除的函数等。现在任何需要列表的类可以直接继承甚至是多例继承它。
但假设某类的本身和它的基类都继承自这个类,死亡钻石就出现啦!即使运行正常,还是要处理模糊性和在访问列表时指定基类。好在调试时不如前一个方案复杂,而且可以多态的处理列表元素:由于各个类都继承于ListElem基类,所以可以使用指针和引用来访问列表。即使这个方案几近完美,但是它的主要问题是没有完场列表功能和类本身的解耦,而且不支持多个列表、多个树、或多个其他的数据结构同时包含某对象。所以,我们务必要把列表从它容纳的对象中剥离开。
4.方法四:虚指针列表
把列表从它的内容解耦,首先要把它当做单纯的、承接我们添加的元素的容器。一种方案是,用虚指针指代元素:添加元素时,先转换成虚指针;元素被取出时,在转回原有类。列表类的代码仅负责复制虚指针,于是不用在意原类型是什么(只要不大于在大多数平台上为32比特的虚指针尺寸)。
此方法中,类型安全被牺牲了。记住元素类型的重任交到了我们头上,编译器不会给我们兜底。幸运的话,转为错误类型的后果是运行时崩溃,不然将是成对的假数据和奇怪的程序行为。跟踪这种bug非常难,这是个极大的缺陷。
这样构造列表的好处是,我们可以面对列表类而非列表元素了,在这个层面上全局操控和查询(例如元素计数和清空列表)会更合理。
一个小问题:由于列表元素和对象本身分离,导致有两次内存分配:对象本身和指向对象和列表节点。而方案一中,列表嵌合在对象自身时,只需要一次即可。如果运行平台的内存分配速度较慢或有内存碎片化问题,那这个缺陷是不可忽视的。
于是我们转向模板来寻找更好的方法。
2.模板
模板使我们写的指定代码不必捆绑于某个类或数据类型,即通用。使用这段代码时,模板会被初始化为指定类。模板有两种:类模板和函数模板;区别在于被模板化的代码类型。
1.类模板
假设有个矩形类,它被应用于很多对象上:窗口坐标、图形用户界面的组件尺寸、视口位置和尺寸等等。符合直觉的实现方法可能是这样的:
class Rect
{
public:
Rect(int px1, int py1, int px2, int py2) {
x1 = pxl; yl = pyl; x2 = px2; y2 = py2; }
int GetWidth() { return x2-x1; }
int GetHeight() { return y2-y1; }
int x1;
int y1;
int x2;
int y2;
}5
它虽然不够好,但也够用了。但有一天,需要把它应用于0.0到1.0的坐标体系下,问题就来了:它只能接受整型的数值。既然无法复用,那能不能复制出来,把所有的int改成float再把新类叫RectFloat?相信你已经意识到复制粘贴只会带来麻烦了。使用模板吧!于是直到被初始化,矩形类才会决定使用的类型。
template<class T>
class Rect
{
public:
Rect (T px1,T py1,T px2,T py2){
x1 = px1;y1 = py1;x2 = px2;y2 = py2;}
T GetWidth() { return x2-x1; }
T GetHeight() { return y2-y1; }
T x1;
T y1;
T x2;
T y2;
};
T指代模板将依赖的类型。要创建使用整型的矩形,可以用这个代码:
Rect<int> myIntRectangle (1,10,2,20);
当编译器面对它时,会重读原模版并用int取代所有的T,动态编译这个新类。
不但如此,还能用各种各样的类型来创建矩形——只要能用减法运算。否则,就会得到编译时报错。
需要提醒,初始化模板到指定类时,模板代码应当对编译器可见,也就是所有模板代码都该在头文件里,否则不可见且不会被编译。后文会讲这个的后果。
其实C++设计之初有措施避免模版代码不得不都在头文件中:通过关键字export标记cpp文件中的模版实现代码,使其对编译器可见,也就减少了过度的依赖关系。遗憾的事大多数现在的编译器无法实现着用的功能,希望将来会有。
2. 函数模版
和类模版相似,唯一的区别在于不需要显式的初始化它,而是自动由参数的类型所决定。一个简单的例子:
template<class T>
void swap(T & a, T & b)
{
T tmp(a);
a=b;
b = tmp;
}
int a = 5;
int b = 10;
swap(a, b); // Integer version is instantiated
float fa = 3.1416;
flaot fb = 1.0;
swap(fa, fb); // Float version is instantiated
// The following is illegal. Compile error.
swap(a, fb);
需要注意,swap()函数的两个参数应当是同样的类型,否则会有编译器报错。即使某个参数可以被隐式转化为正确的类型也同理。
3. 回顾:用模版实现列表
我们要建立两个类:模版化的列表元素类和列表本身类。
template<class T>
Class ListNode
{
public:
ListNode(T) ;
T & GetData();
ListNode * GetNext();
private:
T m_data;
};
template<class T>
class List
{
public:
ListNode<T> * GetHead();
void PushBack(T);
//...
private:
ListNode<T> * m_pHead;
};
List<int> listOfIntegers;
List<string> listOfStrings;
可以说这是最优解了,更好的是你永远都不用写同样的代码,因为C++标准库已经有了各种各样的数据类型和算法,可以直接拿来用:
std::list<int> myListOfIntegers;
第九章和第十章,我们会详解标准库。
3. 缺点
1. 复杂性
显然,模版相比方案一,可读性稍差一些,更是出了名的难以调试,虽然相比预处理宏要好多了,但是也没好到哪去。有些编译器会对最微小的拼写错误报出最难以理解的错。
2.依赖
正如前文,必须在头文件中实现类使得类之间的耦合增加,使用模版的类会自动包含实现模版的头文件,这些依赖增加了改动代码时的编译时间。对于体量巨大的项目而言,3-4分钟的编译时间会是极大的问题。
3. 代码膨胀
前文的例子中,编译器会对动态的给每个类型的列表都生成一套新代码,虽然这些代码并不多,但如果有多个模版的结合呢?代码量会以乘法增长。这时,你可以试着移动模版函数中的通用代码到普通函数中,省去每次对它的复制。
4. 编译器支持
最后,你要关注你的编译器和平台对模版使用的支持情况。大多数编译器对模版的支持是基础的,缺乏进阶功能,诸如部分模版特殊化。如果你要使用强依赖于模版的第三方库,来自编译器的支持就更为重要了。
4. 何时用
和继承一样,总是小心使用,也要考虑团队成员的能力,不要为了高级而高级,它只是个尝试帮助你的工具。
最好的使用情景之一是:包含不同类对象的数据结构。虽然标准库涵盖了大多数数据结构,但是诸如树容器、优先队列等就没有——那不妨试试用模版来写一个。
Boost库强烈依赖模版,用于给标准库扩充,如果你在标准库里没找到想用的工具,可以在Boost里也找找。只有当到处都找不到时,再尝试自己写吧。
一个很好的建议:仿照标准库的风格,比如Boost库,来写作模版代码。这样你的代码会更好的整合进项目,对熟悉标准库的程序员而言也更好上手,甚至可以和标准库相辅相成。
其他的应用场景还有:管理/工厂类(负责生成和跟踪对象的类)、资源加载类、单例、对象序列化等。
5. 模版特殊化
写通用代码的问题在于,我们根本不知道处理的是什么对象。如果是巨大的对象,就该尽可能避免拷贝,改为用指针;但对于比指针还小的对象,就该拷贝其本身,用指针反而浪费。模版特殊化,就允许我们对某个类或某些类加些自定义内容,比如对优化下对常用类(比如指针或字符串)的处理,"优化"可以指更高效的实现或更少的内存。
1.完全模版特殊化
用于某一指定类的特殊模版版本。
回顾下前文的模版列表,假设大多数的列表存储的都是游戏实体对象,而它们每个的大小只有32比特,大大小于了列表节点被分配的空间,造成了浪费。而且每个列表节点还有两个指针(数据大小*3),再加上是动态分配所以会有额外开销,浪费的更多了。
现在,使用模版特殊化,指定一个在连续内存块上存储元素的特殊版本。虽然在中间部分的插入和删除元素可能更耗时,但由于游戏实体类不大,所以能用时间换节省2/3的空间是值得的。
新代码不会取代原模版类,而是补充了它。新代码必须在原代码的后面。对特殊化模版的函数实现则会覆写原有的函数。
当然,除了上面的声明,还需要补充这些管理指定类元素函数的实现,别忘了标记ListNode<GameEntityHandle>来和特殊化的列表结合。
template<>
class List<GameEntityHandle>
{
public:
ListNode< GameEntityHandle > * GetHead();
void PushBack();
private:
GameEntityHandle * m_pData;
};
2.部分模板特殊化
假设我们的列表中的元素绝大多数都是指针,它们和游戏实体对象一样小,所以也受益于模板特殊化。此时我们使用部分模板特殊化,来创建模板指针列表。
template<class T>
class List<T *>
{
public:
ListNode<T *> * GetHead();
void PushBack();
private:
T * m_pData;
};
最后的结果是这样。
// Normal templated list is used
List<Matrix4x4> matrixList;
// Fully-specialized list, because it stores GameEntityHandles
List<GameEntityHandle> handleList;
// Partially-specialized list, because it stores pointers
List<Matrix4x4 *> matrixPtrList;
第四章 完