一般而言,比起C程序来说,C++游戏程序是可重用和可维护的。可这真的有价值吗?复杂的C++可以在速度上与传统的C程序相提并论吗?
如果有一个好的编译器,再加上对语言的了解,真的有可能用C++写出一些有效率的游戏程序来。本文描述了典型的几种你可以用来加速游戏的技术。它假设你已经非常肯定使用C++的好处,并且你也对优化的基本概念相当熟悉。
第一个经常让人获益的基本概念显然是剖析(profiling)的重要性。缺乏剖析的话,程序员将犯两种错误,其一是优化了错误的代码:如果一个程序的主要指标不是效率,那么一切花在使其更高效上的时间都是浪费。靠直觉来判断哪段代码的主要指标是效率是不可信的,只有直接去测量。第二个概念是程序员经常“优化”到降低了代码的速度。这在C++是一个典型问题,一个简单的指令行可能会产生巨大数量的机器代码,你应当经常检查你的编译器的输出,并且剖析之。
1、对象的构造与析构
对象的构造与析构是C++的核心概念之一,也是编译器背着你产生代码的一个主要地方。未经认真设计的程序经常花费不少时间在调用构造函数,拷贝对象以及初始化临时对象等等。幸运的是,一般的感觉和几条简单的规则可以让沉重的对象代码跑得和C只有毫厘之差。
除非需要否则不构造。
最快的代码是根本不运行的代码。为什么要创建一个你根本不去使用的对象呢?在后面的代码中:
voide Function(int arg)
{
Object boj;
If(arg==0)
Return;
...
}
即便arg为0,我们也付出了调用Object的构造函数的代价。特别是如果arg经常是0,并且Object本身还分配内存,这种浪费会更加严重。显然的,解决方案就是把obj的定义移到判断之后。
小心在循环中定义复杂变量,如果在循环中按照除非需要否则不构造的原则构造了复杂的对象,那么你在每一次循环的时候都要付出一次构造的代价。最好在循环外构造之以只构造一次。如果一个函数在内循环中被调用,而该函数在栈内构造了一个对象,你可以在外部构造并传递一个应用给它。
1.1 采用初始化列表
考虑下面的类:
class Vehicle
{
public
Vehicle(const std::string &name)
{
mName=name
}
private:
std::string mName;
}
因为成员变量会在构造函数本体执行前构造,这段代码调用了string mName的构造函数,然后调用了一个=操作符,来拷贝其值。这个例子中的一个典型的不好之处在于string的缺省构造函数会分配内存,但实际上都会分配大大超过实际需要的空间。接下来的代码会好些,并且阻止了对=操作符的调用,进一步的来说,因为给出了更多的信息,非缺省构造函数会更有效,并且编译器可以在构造函数函数体为空的情况下将其优化掉。
class Vehicle
{
public
Vehicle(const std::string &name):mName(name)
{ }
private:
std::string mName;
}
1.2 要前自增不要后自增(即要++I不要I++)
当写x=y++时产生的问题是自增功能将需要制造一个保持y的原值的拷贝,然后y自增,并把原始的值返回。后自增包括了一个临时对象的构造,而前自增则不要。对于整数,这没有额外的负担,但对于用户自定义类型,这就是浪费,你应该在有可能的情况下运用前自增,在循环变量中,你会常遇到这种情形。
不使用有返回值的操作符 在C++中经常看到这样写顶点的加法:
Vector operator+(const Vector &v1,const Vector &v2)
这个操作将引起返回一个新的Vector对象,它还必须被以值的形式返回。虽然这样可以写v=v1+v2这样的表达式,但象构造临时对象和对象的拷贝这样的负担,对于象顶点加法这样常被调用的事情来说太大了一点。有时候是可以好好规划代码以使编译器可以把临时对象优化掉(这一点就是所谓的返回值优化)。但是更普遍的情形下,你最好放下架子,写一点难看但更快速的代码:
void Vector::Add(const Vector &v1,const Vector &v2)
注意+=操作符并没有同样的问题,它只是修改第一个参数,并不需要返回一个临时对象,所以,可能的情况下,你也可以用+=代替+。
1.3 使用轻量级的构造函数
在上一个例子中Vector的构造函数是否需要初始化它的元素为0?这个问题可能在你的代码中会有好几处出现。如果是的话,它使得无论是否必要,所有的调用都要付初始化的代价。典型的来说,临时顶点以及成员变量就会要无辜的承受这些额外的开销。
一个好的编译器可以很好的移除一些这种多余的代码,但是为什么要冒这个险呢?作为一般的规则,你希望构造函数初始化所有的成员变量,因为未初始化的数据将产生错误。但是,在频繁实例化的小类中,特别是一些临时对象,你应该准备向效率规则妥协。首选的情况就是在许多游戏中有的vector和Matrix类,这些类显然应当提供一些方法置0和识别,但它的缺省构造函数却应当是空的。
这个概念的推论就是你应当为这种类提供另一个构造函数。如果我们的第二个例子中的Vebicle类是这样写的话:
class Vehicle
{
public:
vehicle()
{
}
void SetName(const std::string &name)
{
mName=name;
}
private:
std::string mName
};
我们省去了构造mName的开销,而在稍后用SetName方法设置了其值。相似的,使用拷贝构造函数将比构造一个对象然后用=操作符要好一些。宁愿这样来构造:Vebicle V1(V2)也不要这样来构造:
Vehicle v1;v1=v2;
如果你需要阻止编译器帮你拷贝对象,把拷贝构造函数和操作符=声明为私有的,但不要实现其中任何一个。这样,任何企图对该对象的拷贝都将产生一个编译时错误。最好也养成定义单参数构造函数的习惯,除非你是要做类型转换。这样可以防止编译器在做类型转换时产生的隐藏的临时对象。
1.4 预分配和Cache对象
一个游戏一般会有一些类会频繁的分配和释放,比如武器什么的。在C程序中,你会分配一个大数组然后在需要的时候使用。在C++中,经过小小的规划以后,你也可以这样干。这个方法是不要一直构造和析构对象而是请求一个新而把旧的返回给Cache。Cache可以实现成一个模板,它就可以为所有的有一个缺省构造函数的类工作。Cache模板的Sample可以在附带的CD中找到。
你也可以在需要时分配一些对象来填充Cache,或者预先分配好。如果你还要对这些对象维护一个堆栈的话(表示在你删除对象X之前,你先要删除所有在X后面分配的对象),你可以把Cache分配在一个连续的内存块中。
2、内存管理
C++应用程序一般要比C程序更深入到内存管理的细节。在C中,所有的分配都简单的通过malloc和free来进行,而C++则还可以通过构造临时对象和成员变量来隐式的分配内存。很多C++游戏程序需要自己的内存管理程序。 由于C++游戏程序要执行很多的分配,所以要特别小心堆的碎片。一个方法是选择一条复杂的路:要么在游戏开始后根本不分配任何内存,要么维护一个巨大的连续内存块,并按期释放(比如在关卡之间)。在现代机器上,如果你想对你的内存使用很警惕的话,很严格的规则是没必要的。
第一步是重载new和Delete操作符,使用自己实现的操作符来把游戏最经常的内存分配从malloc定向到预先分配好的内存块去,例如,你发现你任何时候最多有10000个4字节的内存分配,你可以先分配好40000字节,然后在需要时引用出来。为了跟踪哪些块是空的,可以维护一个由每一个空的块指向下一个空的块的列表free list。在分配的时候,把前面的block移掉,在释放的时候,把这个空块再放到前面去。图1描述了这个free list如何在一个连续的内存块中,与一系列的分配和释放协作的情形。
图1 A linked free list
你可以很容易的发现一个游戏是有着许多小小的生命短暂的内存分配,你也许希望为很多小块保留空间。为那些现在没有使用到的东西保留大内存块会浪费很多内存。在一定的尺寸上,你应当把内存分配交给一支不同的大内存分配函数或是直接交给malloc()。
3、虚函数
C++游戏程序的批评者总是把矛头对准虚函数,认为它是一个降低效率的神秘特性。概念性的说,虚函数的机制很简单。为了完成一个对象的虚函数调用,编译器访问对象的虚函数表,获得一个成员函数的指针,设置调用环境,然后跳转到该成员函数的地址上。相对于C程序的函数调用,C程序则是设置调用环境,然后跳转到一个既定的地址上。一个虚函数调用的额外负担是虚函数表的间接指向;由于事先并不知道将要跳转的地址,所以也有可能造成处理器不能命中Cache。
所有真正的C++程序都对虚函数有大量的使用,所以主要的手段是防止在那些极其重视效率的地方的虚函数调用。这里有一个典型的例子:
Class BaseClass
{
public:
virtual char *GetPointer()=0;
};
Class Class1: public BaseClass
{
virtual char *GetPointer();
};
Class Class2:public BaseClass
{
virtual char *GetPointer();
};
void Function(BaseClass *pObj)
{
char *ptr=pObj->GetPointer();
}
如果Function()极其重视效率,我们应当把GetPointer从一个虚函数改成内联函数。一种方式是给BaseClass增加一个新的保护的数据成员,在每一个类中设置该成员的值,在GetPointer这个内联函数中返回该成员给调用者:
Class BaseClass
{
public:
inline char GetPointerFast()
{
return mpPointer;
}
protected:
inline void SetPointer(char *pData)
{
mpData = pData;
}
private:
char *mpData;
};
void Function(BaseClass *pObj)
{
char *ptr= pObj->GetPointerFast();
}
一个更激进的方法是重新规划你的类继承树,如果Class1和Class2只有一点点不同,那么可以把它们捆绑到同一个类中去,而用一个Flag来表明它将象Class1还是象Class2一样工作,同时在BaseClass中把纯虚函数去掉。这样的话,也可以象前面的例子一样把GetPointer写成内联。这种变通看起来不是很高雅,但是在缺少Cache的机器上跑内循环时,你可能会很愿意为了去掉虚函数调用而把事情做得更加难看。
虽然每一个新的虚函数都只给每个类的虚表增加了一个指针的尺寸(通常是可以忽略的代价),第一个虚函数还是在每一个对象上要求了一个指向虚表的指针。这就是说你在很小的、频繁使用的类上使用任何虚函数而造成了额外的负担,这些都是不能接受的。由于继承一般都要用到一个或几个虚函数(至少有一个虚的析构函数),所以你没必要在小而频繁使用的对象上使用任何继承。
4、代码尺寸
编译器因为C++产生冗长的代码而臭名昭著。由于内存有限,而小的东西往往是快的,所以使你的可执行文件尽可能的小是非常重要的。首先可以做的事情是拿一个编译器来研究。如果你的编译器会在可执行文件中保存Debug信息的话,那么把它们移除掉。(注意MS VC会把Debug信息放在可执行文件外部,所以没关系)异常处理会产生额外的代码,尽可能的去除异常处理代码。确保连接器配置为去除无用的函数和类。开启编译器的最高优化级别,并尝试设置为尺寸最小化而不是速度最大化 — 有时候,由于Cache命中的提高,会产生更好的运行效果。(注意在使用这项设置时检查instrinsic功能是否也处于打开状态)去掉所有Debug输出状态下的浪费空间的字符串,使编译器把多个相同的字符串捆绑成一个实例。
内联通常是造成大函数的首犯。编译器可以自由的选择注意或忽略你写的inline关键字,而且它们还会背着你制造一些内联。这是另一个要你保持轻量级的构造函数的原因,这样堆栈中的对象就不会因为有大量的内联代码而膨胀。同时也要小心重载运算符,即使是最简短的表达式如m1=m2*m3如果m2和m3是矩阵的话,也可能产生大量的内联代码。一定要深入了解你的编译器对于内联的设置。
启用运行时类型信息(RTTI)需要编译器为每一个类产生一些静态信息。RTTI一般来说是缺省启用的,这样我们的代码可以调用dynamic_cast以及检测一个对象的类型,考虑完全禁止使用RTTI和dynamic_cast以节省空间(进一步的说,有时候dynamic_cast在某些实现中需要付出很高的代价)另一方面,当你真的需要有基于类型的不同行为的时候,增加一个不同行为的虚函数。这是更好的面向对象设计(注意static_cast与这不同,它的效率和C语言的类型转换一样)。
5、标准类库(STL)
标准类库是一套实现了常见的结构和算法的模板,例如dynamic arrays(称为vector),set,map等等。使用STL可以节省你很多时间来写和调试那些容器。和之前谈到的一样,如果希望系统的效率最大化,你必须要注意你的STL的具体实现的细节。
为了能够对应于最大范围的应用,STL标准在内存分配这个领域保持了沉默。在STL容器中的每一个操作都有一定的效率保证,例如,给一个set进行插入操作只要O(log n)的时间,但是,对一个容器的内存使用没有任何保证。
让我们来仔细了解游戏开发中的一个非常普遍的问题:你希望保存一组对象,(我们会称其为对象列表,虽然不一定要保存在STL的列表中)通常你会要求每个对象在这个表有且仅有一个,这样你就不用担心一个偶然产生的在容器中插入一个已存在单元的操作了。STL的set忽略副本,所有的插入、删除和查询的速度都是O(log n),这是不是就是很好的选择呢?
虽然在set上的大多数操作的速度都是O(log n),但是这里面依然存在着潜在的危机。虽然容器的内存使用依赖于实现,但很多实现还是在红黑树的基础上实现的。在红黑树上,树的每一个节点都是容器的一个元素。常见的实现方法是在每一个元素被加入到树时,分配一个节点,而当每个元素被移出树时,释放一个节点。根据你插入和删除的频繁程度,在内存管理器上所花费的时间将或多或少的影响你通过使用set而获得的好处。
另外一个解决方案是使用vector来存储元素,vector保证在容器的末端添加元素有很高的效率。这表示实际上vector只在很偶然的情况下才重新分配内存,也就是说,当满的时候扩容一倍。当使用vector来保存一个不同元素列表的时候,你首先要检查元素是否已经存在,如果没有,那么加入。而对整个vector检查一遍需要花费O(n)的时间,但是但实际牵涉到的部分应该比较少,这是因为vector的每个元素都在内存中连续存放,所以检查整个vector实际上是一个易于cache的操作。检查整个set将造成cache不命中,这是因为在红黑树上分别存放的元素可能散布在内存的各个角落。同时,我们也注意到set必须额外维护一组标记以设置整个树。如果你要保存的是对象的指针,set可能要花费vector所要花费的内存的3到4倍。
Set的删除操作消耗时间O(log n),看起来是很快,如果你不考虑可能对free()的调用的话。Vector的删除操作消耗O(n),这是因为从被删除的那个元素开始到结尾处的元素,每一个元素都要被拷贝到前一个位置上。如果元素都只是指针的话,那么这个拷贝将可以依靠一个简单的memcpy()来完成,而这个函数是相当快的。(这也是为什么通常都把对象的指针储存在STL的容器中的一个原因,而不是储存对象本身。如果你直接保存了对象本身,将会在很多操作中造成许多额外的构造函数的调用,例如删除等)。
set和map通常来说麻烦大于有用,如果你还没有意识到这一点的话,考虑遍历一个容器的代价,例如:
for(Collection::iterator it = Collection.begin(); it != Collection.end(); ++it)
如果Collection是vector,那么++it就是一个指针自增。但是当Collection是一个set或者是一个map的话,++it包括了访问红黑树上的下一个节点。这个操作相当复杂而且很容易造成cache不命中,因为树的节点几乎遍布内存的各处。
当然,如果你要在容器中保存大量的元素,并且进行许多的成员请求,那么set的O(log n)的效率完全可以抵消那些内存方面的消耗。近似的,如果你偶尔才使用容器,那么这里的效率差别就非常的小。你应该做一些效率评估以了解多大的n会使set变得更快。也许你会惊奇的发现在游戏的大多数典型应用下vector的所有效率都比set要高。
这还不是STL内存使用的全部。一定要了解当你使用clear方法时,容器是否真的释放掉了它的内存。如果没有,就可能产生内存碎片。比如,如果你开始游戏的时候建立了一个空的vector,在游戏过程中增加元素,然后在游戏restart时调用clear,这时vector未必释放它的全部内存。这个空的vector,可能依然占据了堆中的内存,并使其变成碎片。如果你真的需要这样来实现游戏的话,对这个问题有两种解法。一是你可以在创建vector时调用reserve(),为你可能需要的最大数量的元素保留足够的空间。如果这不可行的话,你可以强迫vector完全释放内存:
Vector V;
// … elements are inserted into V here
Vector().swap(v);// causes v to free its memory
Set、list以及map都没有这个问题,这是因为他们为每个元素分别分配和释放内存。
6、高级特性
编程语言的某些特性你可能没必要用到。看上去简单的特性可能会导致低下的效率。而看起来复杂的特性没准执行得很好。C++的这些黑暗角落异常依赖于编译器。当你要使用它们时,必须了解它们的代价。
C++的string就是一个看起来不错的例子,但是在效率极其重要的场合应该避免使用,考虑下面的代码。
Void Function(const std::string &str)
{
}
Function("hello");
对Function()的调用包括了对给定const char*参数的构造函数的调用。在普遍的实现中,这个构造函数执行了一个malloc(),一个strlen(),以及一个memcpy(),而析构函数立刻上来做了一些无意义的事情。(由于该例子中的string没有被更多的应用)然后又跟了一个free()。这里的内存分配完全是浪费,因为字符串“hello”早就在程序的数据段中了。我们早就有它在内存中的副本了。如果Function定义了一个const char*的参数,那么完全没有了上面所说的那些额外的调用。这就是为了使用方便的string而付出的高昂代价。