【C++】《给游戏开发者的C++》笔记 第四章 模板

怎么我重复写了这么多同样的代码?可能它们被运用于不同的类和数据类型,所以无法复用,这时模板就允许我们写作用于不同类的通用代码了。

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. 方法三:继承

或许可以建一个个管理列表的基类?它包含了列表的数据、向前和向后的指针、负责插入和删除的函数等。现在任何需要列表的类可以直接继承甚至是多例继承它。faacaf5c522d4f4095dd2388d312b9c9.png

但假设某类的本身和它的基类都继承自这个类,死亡钻石就出现啦!即使运行正常,还是要处理模糊性和在访问列表时指定基类。好在调试时不如前一个方案复杂,而且可以多态的处理列表元素:由于各个类都继承于ListElem基类,所以可以使用指针和引用来访问列表。即使这个方案几近完美,但是它的主要问题是没有完场列表功能和类本身的解耦,而且不支持多个列表、多个树、或多个其他的数据结构同时包含某对象。所以,我们务必要把列表从它容纳的对象中剥离开。

4.方法四:虚指针列表

把列表从它的内容解耦,首先要把它当做单纯的、承接我们添加的元素的容器。一种方案是,用虚指针指代元素:添加元素时,先转换成虚指针;元素被取出时,在转回原有类。列表类的代码仅负责复制虚指针,于是不用在意原类型是什么(只要不大于在大多数平台上为32比特的虚指针尺寸)。9806e5179bad44259cfa09a682a9712e.png

此方法中,类型安全被牺牲了。记住元素类型的重任交到了我们头上,编译器不会给我们兜底。幸运的话,转为错误类型的后果是运行时崩溃,不然将是成对的假数据和奇怪的程序行为。跟踪这种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; 

第四章 完

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值