一个游戏引擎是一个巨大而复杂的软件系统。面向对象的软件工程和类库设计方法能够给这样的大型的软件系统提供很好的支持。这个附录将提供面向对象结构设计的基本问题的一个回顾。另外,在游戏引擎设计中涉及到的一些面向对象设计的问题将会着重谈到,其中包括命名规则、名域、运行时期的类型识别、运行时期的类型识别,单独或者多重继承、模板(参数化的数据类型,泛型?)公共对象、引用计数、流处理、开始和关闭机制。
A.面向对象的软件构造
发表于1988年的meyer的论文是一篇面向对象软件工程的优秀参考,对堆栈、字符串、列表、队列、映射、集合、树、图这些抽象数据类型的足够深刻而广泛的读物是发表于1987年的Booch(估计是国外的一个论坛)
A.1.1软件质量
软件工程的目标是从用户和程序员两个角度上提高软件的质量。期望的软件质量表现在下面两个方面
- 从外部看:软件够快、可用性强、很容易使用。最终的用户很看重这些品质。最终用户包括将要使用代码的小组成员,所以易用度是非常重要的。
- 内部来讲:软件是可读的,模块化的,结构化的,程序员非常注重这些品质。
外部质量相对来讲更重要,因为软件构造的最终目标是面向客户需要的。然而内部质量是提高外部质量的关键。面向对象的软件设计是处理内部问题的,但最终对下面的外部问题将有很大的益处.
A.1.2模块性
模块是原子的、一致的、强壮的、组织好了的包。这还不能够真正定义模块,但我们都清楚他到底是什么。下面的规则将会帮助你确定哪一种软件构造方式更模块化。
- 可分解性:设计方法必需帮助吧一个问题分解成若干个子问题,每个子问题独立解决。
Ø 示例:自顶向下设计
Ø 反例:初始模块
- 组合性:这种设计方法支持软件产品的元素可以任意组合构成新的产品。
Ø 示例:数学函数库
Ø 反例:组合的GUI和数据库函数库。
- 可理解性:模块可以被很容易的被人阅读和重组成新模块的设计方法。
Ø 示例:一个数学函数库有许多的导出功能,但是没有其他的导入连接文件。
Ø 范例:序列化的非独立模块,模块A依靠模块B,模块B依靠模块C,模块C需要。。。。。
- 可持续性。一个确定问题的小的改表仅仅导致一个模块的改变,改变不应影响软软件的体系结构。
Ø 示例:符号常量(没有代码号),统一索引的原则。
Ø 反例:因为数据标示以后会变,所以导致不能够对用户隐藏这些数据标示。
- 可保护性:这种设计方法产生这样一种结构。这种结构的作用是使程序在反常情况下仅仅在一两个模块类发生。
Ø 例如:输入和输出的确认,就是抽象数据类型里的预处理和后处理的概念。
Ø 反例:不规则异常,异常是由一个代码模块产生另一个代码模块处理,而另一个模块可能是远程的,但是这种机制违反了把反常情况限制的规则。
五条规则引出了五条用来保证模块性的法则。每个法则所保证的规则在气候的括号中指出。
- 语言化的模块单元:模块就像语言的中句子的成风一样可组合。(分解性,组合性,可保护性)
- 少一点的接口,每一个模块与外界的交互要尽可能的少(连续性、可保护新)
- 小接口,如果两个模块必需通信,他们的交换信息必需尽可能的少,这叫做:低耦合(连续性,保护性)
- 清楚的接口:两个模块无论何时通信,都必须与模块的内容清晰分开,这叫做直接耦合。(分解性,组合性,连续性,可理解性)
- 信息隐藏:所有关于模块的信息必需私有化、除非这个信息被宣布是公共的。
开放封闭的原则
这是一个模块分解性上的最终要求,它意味着一个模块必须同时又是封闭的又是开放的。
- 开放模块:这个模块是可扩展的,例如:它可以给现有的数据结构增加新的域或者对现有的数据结构添加新的操作。
- 封闭模块:这个模块是可被别的模块使用的。这需要这个模块又一个定义完美的稳定的接口,特别是在稳定方面,例如:这样的模块必需可以被编写为一个函数库。
咋一看:开放性和封闭性必然是相反的,如果一个模块的公共接口保持一致,但是内部是先生改变,这种模块就可认为是既开放又封闭的。然而最好的修正是增加新的功能,继承的概念是封闭和开放的最好实现方式。
A.1.3重用性规则
在软件工程中,重用性是一个基本的问题,为什么化那么多的时间设计和代码一个系统,而实际上,它有可能早已经在其他什么地方存在了。这个问题并没有一个简单的答案。已经写好的分类列表,处理堆栈或者其他基本的数据结构操作代码是很容易找到的,但是这个问题中往往含有其他的因素,一些公司提供你所需要的库,但是想要使用却需要买一个授权,如果提供商的代码有问题,你必须找他们自己来改,这也许是里所不想的。
至少在你的本地环境里,你可以努力使你自己的代码模块重用最大化,这儿有一些问题就是里的数据模块必须能够产生可重用的单元。
- 数据类型的变化:模块必需适用于各种类型的数据机构,模般或者参数化的数据类型可以帮你解决这个问题。
- 数据结构和机制上的变化:一套机制所进行的操作可能依赖于它的底层数据结构,模块必须能够处理不同的底层数据结构,重载可以帮你解决这个问题。
- 相关的常规操作,模块必须有对处理底层数据结构常规操作的接口。
- 代理独立性:一个模块必须能够使一个用户使用其操作但无需知道它的内部实现机制和底层数据结构。例如:
x_is_in_table_t=search(x,t);
这是一个在t表里寻找x的函数调用,它的返回值是布尔型的。如果将要被查找得的表可能是多重类型的(例如:链表,树,文件等等),我们希望不要有下面这样的大量的控制结构:
if ( t is of A)
apply search algorithm A
else if (t is of type
B)
apply search a algorithm B
else if…..
无论在模块还是在客户代码中,重载和多态可以解决这个问题。
- 子模块的公共性,提取代码重的公共部分是非常重要的!避免相似代码的重复,因为在这种相似代码块中当一个小小的改变发生的时候,所有的相似代码块都要改变,这将会化很多时间来维护,而一个抽象的接口却不暴露它内部的数据结构。
A.1.4函数(或过程)和数据
函数和数据,首先考虑那个那?回答这个问题的关键点在于软件的可扩展性上,具体的讲:就是连续性。在软件的整个生命周期里,因为系统的需求是规律性变化的,所以函数的变化相对较多。然而函数所操作的数据要保持一致性,变化非常小。这是设计基于的对象的模块的一种面向对象方法。
一个经典的设计模式就是自顶向下的设计方法。首先确定系统的抽象功能,然后逐级分解,使功能逐渐跟易于实现,这种方法逻辑性强,比较好组织。激励了程序员。但是也有缺点,具体如下:
- 这种方法忽略了软件要发展的本性。于是连续性上就出了问题,自顶向下的设计方法在短时间内提供了方便,但是一旦系统改变就得全盘重来,所以它留下了长期的灾难。
- 软件系统的概念由一个函数来实现这是不合适的。例如操作系统久久是不能够由一个函数来实现的典型例子。真正的系统没有顶。
- 这种设计方法不能提供任何可重用性,设计者一般都是基于现有要求分解功能,子系统仅仅能够对应于现有系统,当系统变化的时候,子系统没法用喽。
A.1.5面向对象的设计
面向对象的设计方式导致了面向对象的软件的体系结构的系统和子系统而不是函数。主要关键如下:
- 如何发现个对象。一个组织良好的软件系统可以被看作是一个可操作的模型或是个整体的一部分。软件的对象仅仅代表了一个真实世界对象。
- 如何描述一个对象。描述对象的标准方法是抽象的数据类型,一个抽象数据类型的定义包括类型(它是一个抽象数据类型的参数)、方法(对象所能提供的操作)、预处理(这个必须在所有操作之前进行)、后处理(所有操作之后执行)公共规则(方法的行为规则)。
面向对象的设计通常也将软件系统的构造可以看作是抽象数据类型实现的结构化集合。仔细说就是:
- 基于对象的结构化模块,系统是在数据结构的基础上模块化的。
- 数据抽象。对象可以描述为一个抽象数据类型的实现。
- 自动的存储管理:无用的对象被底层语言系统自动的析构,而不需要程序员的干涉。
类:每一个非简单类型都是一个模块,每一个高级的模块都是一个类型,这种实现被视为是一类一模块的范例
- 继承:一个类看作定义为另一个的扩展或限制体。
- 参数化和动态绑定,程序实体可以涉及多个类,一个操作可以在不同的类中有不同的实现。
- 多重和多级继承,可以给很多类定义一个继承类,也可以给一个类定义多个继承类。
一个语言是否支持上面这部分提到这些因素还是个问题。具体的讲:smaltalk 和ada是具有以上所有因素的,然而他们功效不行,这本书中面向对象的代码是c++的,但它却不是一个完全面向对象的代码,c++做范例非常好,在需要效率的地方提供很大的方便,一个公共的c++的谬误就是它的效率却无法和c比。记住一个编译器是一个巨大的软件系统,并且如同所有其他大型系统一样也是敏感的,实现的往往都不好。目前这一代的c++编译器可以产生紧凑而快速的代码(参照Ellis和Stroustrup的c++参考书,1994)。Lippman的范例扩展集(1991)展示了c++的精彩特性。
A.2代码风格,命名传统与命名空间
软件工程的一个目标是代码必须是可读的,在一个很多程序员分工合作的大型软件开发环境里,每一个程序员都想使用自己的一套代码风格,这包括标识符命名,空格的使用,对齐及前后缩放的样式,花括号注疏的位置等等,如果一个小组的代码又要供组内成员看,又要供组外客户看,那么我建议你最好使你们的代码保持完全一直的风格。风格不够一致的代码就与客户的要求相脱离了,你的客户是要理解你的代码,然后把它用到他自己的程序中去。从管理上强制规定的代码风格是一种选择,但是要考虑到大家意见的纷争。目前很多的c++程序员都是现学c的,在那个时候,他们代码风格就固定了。但是那其中很多的习惯却与面向对象的思想格格不入,这些程序员使用着他们最开始学习时学到的风格。
命名传统是非常重要的,特别当一个代码阅读者想要从一个由多程序员写的多文件的代码中搞出他所想要得东西的时候。本书的附带光盘中附带的代码中使用了一种很有效的命名传统,它可以使读者很容易的区分成员名,本地变量名,全局变量名,包括它是否是静态的,这样一来你就很容易找到相应变量的定义和作用范围,而且标识符的名字中包括类型信息。嵌入信息不象微软的匈牙利命名法那样冗长。但是对于代码的阅读和理解来说:那些已经非常有效了。
作为一个游戏引擎来说:就像其他大型的库一样,不可避免的要和其他团队的软件库集成,所以就有可能导致类名或全局变量名的冲突。有可能发生这样的情况:你定义你自己的矩阵类叫:Matrix,但是其他人也可能定义这样名称的类,c++的命名空间可以解决这样的问题,这种方法在多库协作的过程中非常通用,就是在类名和全局变量前面加一个独一无二的前缀。命名空间的构造隐含地破坏了类名,自己手工选择地前缀更使这种破坏很具体。
随书附带的代码民命规则是这样的:类名和全局变量名都以Mgc为前缀。函数名都首字母大写;如果多个单词构成一个名字,每一个单词的首字母大写,例如:设定一个类代表字符串,一个取得其长度的成员函数就叫GetLength.函数的标识符命名也使用相同的规则,但是有前缀。非静态的数据成员以m_为前缀,静态的数据成员以ms_为前缀。那个m代表成员(member),s代表静态的(static),一个静态的本地变量以s_起头,一个全局的变量以g_起头,一个全局静态变量以gs_起头,变量的类型也被考虑在内,也是标示符的前缀但是是跟在成员或全局变量的下划线后面。表A,一个不同类型命名代码规则的列表,一般的标示符命名不使用下划线,除了有前缀在它前面的情况下,类常量也是以大写字母起头,并且可能包括下划线,各种编码规则是可以结合使用的,如下例:
unsigned int* auiArray= new int[16];
void ReallocArray(int iQuantity,unsigned int*&rauiArray)
{
delete[]rauiArray;
rauiArray=new
unsigned int[iQuantity];
}
short sValue;
short& rsValue-sValue;
short* psValue=&sValue;
class MgcSomeClass
{
public:
MgcSomeClass();
MgcSomeClass(const
MgcSomeClass& rkObject);
protected:
enum{NOTHING,SOMETHING,SOMETHING_ELSE};
unsigned int
m_eSomeFlag;
typedef enum{ZERO,ONE,TWO}Counter;
Counter m_eCounter;
};
下表中没有列到的命名规则可以由原文件推断得出。
类型 |
代码 |
类型 |
代码 |
char |
c |
unsigned char |
uc |
short |
s |
unsigned short |
us |
int |
i |
usigned int |
ui |
long |
l |
usigned long |
ul |
float |
f |
double |
d |
pointer |
p |
smart pointer |
sp |
reference |
r |
array |
a |
enumerate type |
e |
class variable |
k |
template |
t |
function ponier |
o |
void |
v |
|
|
表A.1各种类型的标识符命名规则 |
A.3运行时动态信息
多态性提供了函数功能的抽象,一个多态的函数不用考虑调用对象的真实类型,但是你需要知道参数化对象的类型,或者你需要决定是否这个类型源于一个确定的类型,例如同一个鸡肋指针指向一个子类,这个过程叫做动态类型映射,实时类型信息提供了一种在程序运行时确定这种信息的方法。
A.3.1单继承层次的系统
一个单继承的面向对象的系统有一个直接的树构成,在这个树里面,节点代表类,边代表继承关系,假设节点V0代表类C0,节点V1代表类C1,如果C1是C0的子类,那么在V0和V1之间就有一条边,代表C1、C0两者之间的继承关系,这种直接的边表示了父子类之间is-a的关系,图A.1展示了一个简单的单继承的层次关系。
树的根是多边形,长方形是一个多边形,正方形是一个长方形,也是一个多边形,三角形是一个多边形,等边三角形是一个三角形,非等边三角形也是一个多边形,但是正方形不是一个三角形,非等边三角形也不是一个等边三角形。
一个运行时信息系统是一个这种树的实现,基本的运行时信息数据类型存储着一个程序在运行时可能需要的类型判定信息并且存储着一个基类的连接,以备程序确定一个类是否继承于另一个类,这种最简单的代表不存储类的类的信息,仅仅有一个对基类的连接,然而,这的确是非常有用的,虽然仅仅存储一个字符串名字,实际上,这个字符串将会被用在以后被描述到的流式系统中,这个字符串在以后DEBUG的过程中用于快速确定类的类型是非常有用的。
class MgcRTTI
{
public:
MgcRTTI (const char* acName,
const MgcRTTI* pkBaseRTTI)
:
m_kName(acName)
{
m_pkBaseRTTI
= pkBaseRTTI;
}
const MgcRTTI* GetBaseRTTI ()
const { return m_pkBaseRTTI; }
const MgcString& GetName
() const { return m_kName; }
private:
const MgcRTTI* m_pkBaseRTTI;
const MgcString m_kName;
};
在一个继承层次树中的基类MgcObject必须包含有对RTTI系统的基本支持,其最小的机构如下:
class MgcObject
{
public:
static const
MgcRTTI ms_kRTTI;
vritual const
MgcRTTI* GetRTTI()const
{
return &ms_kRTTI;
}
bool IsExactlyClass(const
MgcRTTI*pkQueryRTTI)const
{
return(GetRTTI()==pkQueryRTTI);
}
bool IsExactlyClass(const MgcRTTI*pkQueryRTTI)const
{
return(GetRTTI()==pkQueryRTTI);
}
bool IsDerivedFromClass(const MgcRTTI*pkQueryRTTI)const
{
const MgcRTTI*pkRTTI=GetRTTI();
while(pkRTTI)
{
if(pkRTTI==pkQueryRTTI)
return true;
pkRTTI=pkRTTI->GetBaseRTTI();
}
return flase;
}
void* DynamicCast(const MgcRTTI*pkQueryRTTI)
{
return(IsDerivedFromClass(pkQueryRTTI)?this:0);
}
};
在继承树结构中的每一个子类都有一个静态的MgcRTTI并且其最小结构如下:
class MgcDerivedClass:public MgcBaseClass
{
public:
static const
MgcRTTI ms_kRTTI;
virtual const
MgcRTTI*Get RTTI()const
{
return &ms_KRTTI;
}
};
无论MgcBaseClass在那儿,或者有什么而继承,注意:独一无二的标示都是可以的,因为静态的MgcRTTI成员都有自己不同的运行时内存地址,所以衍生子类的原文件必须含有:
const MgcRTTI MgcDerivedClass::ms_kRTTI("MgcDerivedClass",
&MgcBaseClass::ms_kRTTI);
A.3.2多继承的层次系统
一个非继承的面向对象的多继承系统可由一个非循环的图表构成,在这个图表中节点代表类,便代表继承关系,假设节点Vi代表类Ci,对于i=1,2,3,如果C2继承于C1和C0,这样V2就有两条边各自指向V1和V0来代表这种多继承的关系。图A.2展示了一个多继承层次关系,一个多继承前提下的运行时信息系统就是一个这种直接非继承图标关系的实现。单继承系统的运行时信息系统有一个指向基类的指针,而多继承系统则需要一个制向所有基类的指针列表。最简单的实现不存储类的信息,仅仅存储那个指向基类的指针,为了支持判定基类的数目,c风格的省略号在构造函数种被用到,因此需要标准的参数支持,对于大多数编译器来说,要包括stdarg.h这个文件提供操作参数解析的宏命令。
class MgcRTTI
{
public:
MgcRTTI(const
char*acName,unsigned int uiNumBaseClasses.....);
m_kName(acName)
{
if(uiNumBaseClasses==0)
{
m_uiNumBaseClasses=0;
m_apkBaseRTTi=0;
}
else
{
m_uiNumBaseClasses=uiNumBaseClasses;
m_apkBaseRTTi=new const MgcRTTI*[uiNumBaseClasses];
va_list list;
va_start(list,uiNumBaseClasses);
for(unsigned int i=0;i<uiNumBaseClasses;i++)
m_apkBaseRTTI[i]=va_arg(list,const MgcRTTI*);
va_end(list);
}
}
~MgcRTTI()
{
delete[] m_apkBaseRTTI;
}
unsigned int GetNumBaseClasses()const
{
return m_uiNumBaseClasses;
}
const MgcRTTI* GetBaseRTTI(unsigned int uiIndex)const
{
return m_apkBaseRTTI[uiIndex];
}
private:
unsigned int
m_uiNumBaseClasses;
const MgcRTTI**
m_apkBaseRTTI;
const MgcString
m_kName;
};
在单继承层次系统树种的根类提供了一种成员函数,这个成员函数用来搜索被探测的树来决定一个类和另一个类是否是同继承关系,在多继承层次树中有一个技术问题就是可能会有不止一个节点没有边,也就是这种继承会有多个根类,为了解决问题,会提供一个单个得根类,它的任务就是为这个继承表中的任何系统提供接口。
多继承系统中的根类器结构构造很像单继承树的根类结构,除了其中成员函数的实现,IsDerivedFromClass就是用来处理RTTI的基类指针列表
bol MgcObject::IsDerivedFromClass(const MgcRTTI*
pkQueryRTTI)const
{
const MgcRTTI*pkRTTI=GetRTTI();
if(pkRTTI==pkQueryRTTI)
return true;
for(unsigned
int i=0;i<pkRTTI->GetNumBaseClasses();i++)
{
if(IsDerivedFromClass(pkRTTI->GetBaseRTTI(i)))
return true;
}
return false;
}
基类仍旧提供相同的静态成员RTTI函数一个操作其类地址的成员函数,例如,考虑一下下例:
class MgcDerived::public MgcBase0,MgcBasel
{
public:
static const
MgcRTTI ms_kRTTI;
virtual const
MgcRTTI* GetRTTI()const
{
return &ms_kRTTI;
}
};
MgcBase0和MgcBase1或者是MgcObject类的对象,或者由MgcObject衍生,衍生自这个类的源文件必需含有
const MgcRTTI MgcDerived::ms_kRTTI("MgcDerived",2,&MgcBase::ms_kRTTI,&Mgc_kRTTI,&MgcBase1::ms_kRTTI);
A.3.3宏支持
程序中用到宏可以有效的解决代码的冗长问题,下面这个宏可以提供给单继承和多继承系统。
macros in MgcRTTI.h
#define MgcDeclareRTTI\
public:\
static:\
static const
MgcRTTI ms_kRTTTI;\
virtual const
MgcRTTI*GetRTTI()cosnt {return &ms_kRTTI;}
#define MgcImplementRootRTTI(rootclassname)\
const MgcRTTI rootclassname::ms_kRTTI(#rootclassname,0)
macros in MgcObject.h and MgcObjectM.h
#define MgcisExactlyClass(classname,pObject)\
pObject?Pobject->IsExactlyClass(&classname::ms_kRTTI):flase)
#define MgcIsDerivedFromClass(classname,pObject)\
pObject?pObject->IsDerivedFromClass(&classname::ms_kRTTI):false)
#define MgcStaticCast(classname,pObject)\
((classname*)pObejct)
#define MgcDynamicCast(classname.pObject)\
pObject?(classname*)pObject->DynameicCast(&classname::ms_kRTTI):0)
宏MgcDeclareRTTI被放置在头文件的类声明里,注意:生命域是public,所以任何其它跟在这个宏调用后面的类声明如果需要的话需要定义其他的生命域
下面这个是个提供给单继承系统的宏:
#define MgcImplementRTTI(classname,baseclassname)\
const MgcRTTI
classname::ms_kRTTI(#classname,&baseclassname::ms_kRTTI);
并且它必须在类定义的原文件中使用,对于多继承系统来讲:这样的宏是不可能的,因为c风格的宏不允许大量的参数。
A.4模板
模板,有时也叫做:参数化数据类型,用来在具有相同结构的类之间共享代码。这方面典型的例子是对象的堆栈,对于一个有限的堆栈来讲:对它的操作包括入栈(push)、出栈(pop)、判定是否为空(isempty)、判定是否已满(isfull)以及读栈顶元素(读栈顶元素但不使其出栈),这些操作与堆栈存储对象是什么类型是没有关系的,一个堆栈可以被实现用于存储整数型和浮点型,各自使用树组存储堆栈元素,唯一的不同是整数的堆栈使用了一个整数树组,而浮点的堆栈使用了一个浮点的树组。模板的使用可以使编译器根据程序需求的类型产生对象代码。
template <class T>calss Stack
{
public:
Stack(int iStackSize)
{
m_iStackSize=sStackSize;
m_iTop=-1;
m_akStack=new T[iStackSize];
}
~Stack(){delete[]
m_akStack;}
bool Push(const
T&rkElement)
{
if(m_iTop<m_iStackSize)
{
m_akStack[++m_iTOp]=rkElement;
return true;
}
return false;
}
bool Pop(T&
rkElement)
{
if(m_iTop>=0)
{
rkElement=m_akStack[m_iTop--];
rreturn true;
}
return false;
}
bool GetTop(T&
rkElement)const
{
if(m_iTop>=0)
{
rkElement=m_akStack[m_iTop];
return true;
}
return false;
}
bool IsEmpty()const{return
m_iTop==-1;}
bool IsFull()const(return
m_iTop==m_iStackSize-1;}
protected:
int m_iStsckSize;
int m_iTop;
T* m_akStack;
};
宏可以产生针对多类型的代码,但是它对由此产生的副作用不敏感。尽管它也可以实现同时支持整形和浮点型两种类型的堆栈,但是它会在代码的维护方面产生问题,如果一个文件改变,其他的文件都会发生相关的变化,当那儿有大量的类型共享代码的时候,这种问题还会加剧,模板提供了一种讲这些改变定位在一个文件的方法。
模板为堆栈,链表、数组这些抽象数据类型的容器类提供了一个不错的选择,标准的模板库可以集成到一个游戏引擎中,当处理这种容器类对象的的时候一个问题时必须明确的,就是它必然会有一定的边界效应,特别是当它构造和析构的时候。如果一个标准模板库类对象要改变它的大小,它会一次型申请一组存储空间,把旧的容器类的内容复制进行的容器内存内,然后释放原先的内存。这种机制有一个隐含的假设:底层数据是本地的。如果数据是由动态申请的(堆里的)类对象,则这种内存复制会造成内存泄漏,隐含的边界效应(side
effect)就发生喽!,这对于下一节的共享对象和引用计数是非常明确的,如果标准模板库不支持这些边界效应,则游戏引擎需要实现自己的容器类型。
A.5共享对象和引用计数
在游戏引擎当中,对象的共享是天生的。包含大量数据的模型共享以后以适应最少的内存,渲染状态也可以共享,具体的讲,例如:纹理图片在渲染体之间共享。在一个游戏因轻重,如果靠人工(manully)管理共享对象必然会导致遗漏一些对象(对象的泄漏)或者是析构了一些正在被使用的对象,因此一个更为自动的共享对象的管理是共享对象的定制管理,大多数流行的系统都是给根类对象增加一个引用计数器,一个对象的被另一个对象引用一次,它的引用计数器就加1,一旦引用计数器减至0,这个对象在系统类就不再被引用了,所以就要被删除了。
引用计数器的细节可以公开,以便于调整引用计数器的程序对其负责。这种机制给程序员正确的管理对象添加了极大的信心。另一个选择是实现一个智能指针系统区在内部调整里的引用计数器,以便在程序在需要特殊的处理时进行干涉。因此对于程序员来讲:管理对象的负担被大大减轻了!
除了实时运行信息以外,根类MgcObject现在包括了下面的代码来支持引用计数:
public:
MgcObject(){m_uiReferences=0;ms_uiTotalObjects++;}
~MgcObject(){ms_uiTotalObjects--;}
void IncrementReferences(){m_uiReferences++;}
void DecrementReferences(){if(--m_uiReferences==0)delete
this;}
unsigned int
GetReferneces(){return m_uiReferences;}
statci unsigned int
GetTotalObjects(){return ms_uiTotalObjects;}
private:
unsigned int
m_uiReferences;
static unsigned
int ms_uiTotalObjects;
静态的计数器跟踪对象现在在系统中的全部数目,程序执行的初始值是0.
智能指针系统在整个基础上被实现,使用了模板。
template <class T>
class MgcPointer
{
public:
// construction and destruction
MgcPointer (T* pkObject = 0);
MgcPointer (const MgcPointer&
rkPointer);
~MgcPointer ();
// implicit conversions
operator T* () const;
T& operator* () const;
T* operator-> () const;
// assignment
MgcPointer& operator= (const
MgcPointer& rkReference);
MgcPointer& operator= (T*
pkObject);
// comparisons
bool operator== (T* pkObject)
const;
bool operator!= (T* pkObject)
const;
bool operator== (const MgcPointer&
rkReference) const;
bool operator!= (const MgcPointer&
rkReference) const;
protected:
// the shared object
T* m_pkObject;
};
//---------------------------------------------------------------------------
template <class T>
inline MgcPointer<T>::MgcPointer (T* pkObject)
{
m_pkObject = pkObject;
if ( m_pkObject )
m_pkObject->IncrementReferences();
}
//---------------------------------------------------------------------------
template <class T>
inline MgcPointer<T>::MgcPointer (const MgcPointer&
rkPointer)
{
m_pkObject = rkPointer.m_pkObject;
if ( m_pkObject )
m_pkObject->IncrementReferences();
}
//---------------------------------------------------------------------------
template <class T>
inline MgcPointer<T>::~MgcPointer ()
{
if ( m_pkObject )
m_pkObject->DecrementReferences();
}
//---------------------------------------------------------------------------
template <class T>
inline MgcPointer<T>::operator T* () const
{
return m_pkObject;
}
//---------------------------------------------------------------------------
template <class T>
inline T& MgcPointer<T>::operator* ()
const
{
return *m_pkObject;
}
//---------------------------------------------------------------------------
template <class T>
inline T* MgcPointer<T>::operator-> ()
const
{
return m_pkObject;
}
//---------------------------------------------------------------------------
template <class T>
inline MgcPointer<T>& MgcPointer<T>::operator=
(const MgcPointer& rkPointer)
{
if ( m_pkObject != rkPointer.m_pkObject
)
{
if (
m_pkObject )
m_pkObject->DecrementReferences();
m_pkObject
= rkPointer.m_pkObject;
if (
m_pkObject )
m_pkObject->IncrementReferences();
}
return *this;
}
//---------------------------------------------------------------------------
template <class T>
inline MgcPointer<T>& MgcPointer<T>::operator=
(T* pkObject)
{
if ( m_pkObject != pkObject )
{
if (
m_pkObject )
m_pkObject->DecrementReferences();
m_pkObject
= pkObject;
if (
m_pkObject )
m_pkObject->IncrementReferences();
}
return *this;
}
//---------------------------------------------------------------------------
template <class T>
inline bool MgcPointer<T>::operator== (T*
pkObject) const
{
return ( m_pkObject == pkObject
);
}
//---------------------------------------------------------------------------
template <class T>
inline bool MgcPointer<T>::operator!= (T*
pkObject) const
{
return ( m_pkObject != pkObject
);
}
//---------------------------------------------------------------------------
template <class T>
inline bool MgcPointer<T>::operator== (const
MgcPointer& rkPointer) const
{
return ( m_pkObject == rkPointer.m_pkObject
);
}
//---------------------------------------------------------------------------
template <class T>
inline bool MgcPointer<T>::operator!= (const
MgcPointer& rkPointer) const
{
return ( m_pkObject != rkPointer.m_pkObject
);
}
//---------------------------------------------------------------------------
赋值操作符在在调整引用计数前必需比较指针所指的值,以防止违法操作。
MgcPointer<MgcObject>spPointer=newMgcObject;
spPointer=spPointer;
MgcObject的构造函数设置引用为0.MgcPointer的构造函数设置计数为1.如果初始对照在赋值操作符里不出现,那么对DecrementReference的引用计数将会把引用计数减为0,那就删除了这个对象了。从而,指针rkPointer,M_pkObject都指向了一块不属于程序的内存,赋值的结构就会使指针指向非法的内存块,而IncrementReferences的调用则会写向一个非法的内存块,尽管这样的错误在程序中不一定发生,但是这种情况可能会由于指针的混淆而加剧。
为了方便,MgcObject或者是其它由它继承的类将会避免模板符号的冗长,支持定义智能指针的宏是:
#define MgcSmartPointer(classname) \
class classname; \
typedef MgcPointer<classname>
classname##Ptr
为了客户代码的方便,每一个类可以把这个定义放在它的头文件中,例如:MgcObject.h将会包括MgcObject和其状态的类定义
MgcSmartPointer(MgcObject);
这就确定了MgcObjectPtr的类型。这种在宏中支持类名的向前定义,支持了智能指针类型的向前定义。
这儿可能会需要构造一个智能指针指向一个指针或者智能指针,例如:类MgcNode,这是场景图型的内部代理,它继承自MgcSpatial,页节点代理了场景图形,多态型允许赋值:
MgcNode*pkNode=<some node in scen graph>;
MgcSpatial*PkObject=pkNode;
从理论上讲:MgcNodePtr类型的智能指针继承自MgcSpatialPtr,但是语言并不支持这一点,但是继承发生的话,在智能指针类的内部隐含的操作符转换允许这样的附带效应,例如:
//this code is valid.
MgcNodePtr spNode=<some node in secne graph>;
MgcSpatialPtr spObject=spNode;
//this code is not valid when class A is not derived
from class B
MgcAPtr spAObject =new A;
MgcBptr spBObject =spAObject;
这种隐含的转换也支持智能指针与空指针的对比、就像常规的指针那样:
MgcNodePtr soNode=<some node in scene graph>;
MgcSpatialPtr spChild=spChild->GetChildAt(2);
if(spChild)
{
<do something
with spChild>;
}
下面是一个简单展示智能指针的使用和清空的例子。这个MgcNode类为它的子类存储了一个智能指针的数组.
MgcNodePtr spNode=<some node in scene graph>;
MgcNode* pkNode=new MgcNode;//pkNode references=0
MgcNodePtr spChild=new MgcNode;//pkNode references=1
spNode->attchChild(spChild);//pkNode references=2
spNode->detachChild(spChild);//pkNode references=1
spChild=0;//pkNode references=0//desroy it
这展示了如何正确的结束一个智能指针的使用,在这段代码中那个删除spChild的调用做得非常好。如果spChild指向的对象有一个正的引用计数,明确的调用了析构函数强迫性删除,另外一个指向相同对象的对象就有了个悬挂的指针,如果代替智能指针操作而是引用计数被减小,如果那儿有另外一个对象指着这个对象,所指的对象就不会被破坏,因此像下面这样的代码是安全的:
MgcNodePtr spNode=<some node in scene graph>;
MgcNode* pkNode new MgcNode;//pkNode references=0
MgcNodeptr spChild =new MgcNode;//pkNode references=1
spNode->AttachChild(spChild);//pkNode references=2
spChild=0//pkNode references=1,
//no destruction
注意:在这段代码种没有给智能指针赋值为0的操作,这个智能指针的析构被调用,pkNode的引用计数仍旧被减少为0.
当使用智能指针的时候,下面这些另外规则也必须坚持。智能指针仅仅提供给动态对象的。不能指向对象堆栈。例如:
void MyFunction()
{
MgcNode kNode;
//kNode references=0
MgcNodePtr
spNode=&kNode;
//kNode references=1
spNode=0;
//kNode references=0
//kNode is deleted
}
这个操作注定要失败,因为kNode在堆栈里,在收回堆栈内存的时候隐含的删除了对象,对象不在堆里面。
使用智能指针作为函数参数或是函数返回值也有缺陷,下面的这个例子说明了这种危险:
void MyFunction(MgcNodePtr spNode)
{
<do nothing>;
}
MgcNode* pkNode=new MgcNode;
MyFunction(pkNode);
//pkNodeNow points to invalid memory
在给pknode指派内存时有0个引用,MyFunction这个函数调用通过类的复制构造函数在堆栈中增加了一个MgcNodePtr的实例,在从函数中返回时,这个MgcNodePtr的实例被析构,在这个过程中,pkNode有0个引用,它也将要被破坏,所以下面的代码才是安全的:
MgcNode*pkNode=new MgcNode;//PkNode references=0
MgcNodePtr spNode=pkNode;//pkNode references=1
MyFunction(spNode);//pkNode references increase
to 2,
//then decrease to 1
//pkNode references=1 at this point
相关的问题如下:
MgcNodePtr MyFunction()
{
MgcNode pkReturnNode=new
MgcNode;//references=0;
return pkReturnNode;
}
MgcNode* pkNode=MyFunction();
//pkNode now points to invalid memory
通过编译器,作为函数返回值的一个暂时的MgcNodePtr的一个实例隐含的生成了。因为拷贝构造函数生成这个实例,所以pkNode的引用计数是1,这种暂时的实例在下面不再需要,会被隐含的析构,pkNode的引用计数又变为0,所以它也被析构,下面的代码将会是安全的。
MgcNodePtr spNode= myFunction();
//spNode.m_pkObject has one reference
这个暂时的实例增加了pkReturnNode的引用计数至1。拷贝构造函数又被用来制造一个spNode节点,所以引用计数被增加到2,所以当暂时对象被析构的时候,引用计数仍为1.
A.6流处理技术
游戏引擎需要存储的一致性,游戏的内容一般是由一个建模工具生成必需导出一种游戏程序能导入的格式,游戏程序本身也需要存储自己的数据以被一段时间之后重新读出。数据的流式处理意味着在连个媒体之间映射数据的过程,典型的是在硬盘存储器和内存之间,在这一部分我们将要讨论内存和硬盘之间的数据传输,但是这一种方法直接提供内存块的传输(并支持通过网络的数据传输)一个处理流的类便产生了---MgcStreaM.
A.6.1数据存储
通常被存储到硬盘上的数据是场景图形数据。尽管可以在访问时遍历一个场景图型并且存储没一个场景中物体,但是这样就出现了两方面的复杂化。一方面是:一些物体使可以被场景种不同的图形所共享的。在这种情况下,一个物体有可能被存储两次。另一方面:物体可能含有指向您一个物体的指针。这样情况主要发生在场景图中节点的赋子关系的情况,详细的说就是:一个存储的场景图型必须能够重新读进内存,并且这其中所有的物体关系也必须可读。抽象的看这个问题:就是一个场景图形就是一个物体(类型是MgcObject)的抽象图(估计是指数据结构的图,译者按),图中的节点代表物体,弧代表物体之间的指针关系。每一个物体又有抽象的成员,具体的讲就是本地数据类型(整形、浮点型、字符串型等等)。这个图必需也要被存储在硬盘上,以便以后可以被修改。,这就意味着图中的节点和弧必需以某种合理的形式存储下来。而且每个图只能被存储一次,存储这样的一个场景图的过程与存与生成并存储一个图中对象链表到硬盘是等价的,并且对象之间所有的关系也要存储下来。如果图中有多连接的单元,那么每一个单元也会被遍历存储,对多个抽象对象存储的支持是很容易实现的。类MgcStream能够生成一个顶层的存储对象的链表,典型情况是:这是一些场景图形的根元素,但是也可能是其他对象的的需要存储的状态属性。为了支持对这种文件的读,并且的到一个相同的顶级元素的链表,在每一个顶级元素所相关的抽象图被写进硬盘之前,一个信息标记块必需被写入。一个简单的方法就是写进一个字符串。
标明所遍历的图中的不相同对象的数目并且要把每个访问到的对象到要插进访问到的对象链表中,为了I/O的速度,一种理想的数据结构就是哈希表,把图遍历一遍,依旧可以建立一个哈西表,访问哈西表就像访问链表一样,然后再把对象的数据存进硬盘。为了支持读入,对象的运行时类型信息首先被写入,为了支持文件块的快速读写,接着写入的就是所存储数据的byte数。尽管不是所有的数据都需要被写入,但任何本地数据类型都要用标准c++的流操作符处理。对象成员不是本地数据类型的或者是不是MgcObject类的使用他们自己的流操作符处理写入,一些数据成员使可以由其他数据成员导出的,那么就意味着图中的对象在成员之间存在相关性。这张图中仅仅根项目需要被写入,一旦重新读入,相关的对象成员就可被恰当的构造。
一个对象指针数据成员可以被存储为一个无符号整型内存地址,因为一个内存地址就是图中一个弧的表示,然而,当存储后的文件再被读入的时候,这个指针值就不再是合法的内存地址了,这个问题的处理方法在下一节里讨论。
A.6.2数据的读入
读入的过程比写的过程更复杂。因为原先在硬盘上存储的指针值是非法的,每一个对象首先必须在内存中创建,然后天入从硬盘中读出的数据,然后对象之间例如父子关系这样的连接关系必须被建立。不考虑硬盘的指针值的非法性,然后关于图的运行时类型信息就要被读入,每一个对象的地址。与一个硬盘中的指针指相对应,所以那个存储不同对象的哈西表又可以被用来跟踪硬盘指针值(叫做链结id)和对象的动态内存地址之间的相关性。当所有的对象都在内存中间里的时候,这个哈西表就完成了,这个哈西表可以像链表一样迭代访问,每一个对象的链结id将要被动态内存地址所取代.这也是链结编译器生成的obj文件的原理。
下面是从一个流里面读出一个对象的步骤:动态类型信息首先被读入,以便于确定对象的类型。然后是文件块的大小既byte数目被读出。创建一个对象所需要的全部信息现在就知道了。一个合适的构造函数和任何设置方法被调用来创造这个被读入的对象,仅仅依靠运行时类型信息来确定使用那个构造函数是不够的。因此,每个类要提供一个静态的工厂模式函数,然后MgcStream对象保持一个这种函数的哈西表;哈西表的键是运行时类型信息,那个工厂模式的函数则充当一个构造器,利用已经读入的对象的内存块,生成一个正确类型的对象,并利用那个内存块里面的信息初始化这个对象。
A.6.3流技术支持:
MgcStream在高层支持下面这些接口:
class MgcStream
{
public:
// construction and destruction
MgcStream ();
~MgcStream ();
// The objects to process, each
object representing an entry into a
// connected component of the
abstract graph.
bool Insert (MgcObject* pkTopLevel);
void RemoveAll ();
unsigned int GetObjectCount ();
MgcObject* GetObjectAt (unsigned
int uiIndex) const;
bool IsTopLevel (MgcObject* pkObject);
// file loads and saves
bool Load (const char* acFilename);
bool Save (const char* acFilename);
// memory loads and saves
bool Load (char* acBuffer, int
iSize);
bool Save (char*& racBuffer,
int& riSize);
// linking support
class Link
{
public:
Link
(MgcObject* pkObject);
}
};
MgcTStorage类代表了一个模板化大小可变得数组存储类。在一个程序中,如果要存储一个文件到硬盘需要:
MgcStream kStream;
for(each pkObject worth saving)
kStream.Insert(pkObject);
kStream.Save("myfile.mff");
kStream.removeAllObjects();
在一个程序中读入一个文件到内存如下:
Stream kStream;
kStream.Load("myfile.mff");
for(unsigned int uiIndex=0;uiIndex<kStream.GetObjectCount();uiIndex++)
MgcObject*pkObject=kStream.GetObjectAt(uiIndex);
<application-specific handling of the object
goes here>;
kStream.remove AllObjects();
这个RemoveAllObjects()函数清空了流对象,以备于以后出存或读入对象。基类MgcObject提供了一个堆相对于自身的流支持。
public:
//support for loading
static MgcObject*Factory(MgcStram&rkStream);
virtual void Load(MgcStream& rkStream,MgcStream::Link*pkLink);
virtual void Link(MgcStream& rkStream, MgcStream::Link*pkLInk);
//'support for saving
virtual Register(MgcStream&rkStream);
virtual void Save(MgcStream&rkStream);
流的Save()函数迭代所有的顶级对象,并且调用每一个对象的注册方法。这个过程的第一步是:遍历抽象图及把图中不同的对象添加进由流所维护的哈西表中,便历之后,流对象迭代访问哈西表,然后调用每一个对象的Save()方法。
流的Load()方法读文件,然后通过读运行是类型信息和块大小一次读一个对象。静态的工厂模式函数通过hash表被查找到然后被调用。然后这个工厂模式函数创造一个对象然后调用它的load()函数,对象指针和链结id都在流对象的的哈西表里,所有的对象都读入以后,通过哈西表的迭代器调用每一个函数的link函数从而用内存地址代替链结id。在这个过程中任何顶级对象都被放在流对象的这种对象地链表中,以便于程序操作他们。
需要处理的一个麻烦是: 链结id在对象被读入和对象在链结两个时刻的一致性。咋一看,链结id可以存储在对象的成员MgcObject指针里,但是这种方法当共享对象和智能指针出现的时候就不行喽。如果一个成员有一个智能指针的成员,则链结id的赋值操作则隐含地强制调用了引用计数的操作,因为链结id不是一个合法的内存地址,任何对其成员函数的调用都是不合法的并且将会出错,所以链结id必需被独立地存储为一个规则指针。MgcStream类定义了一个嵌套的类来支持这种带有一个(用来对目前所处理的对象进行跟踪的)索引的MgcObject链结,当一个对象的工厂模式函数被调用的时侯,MgcStream::Link制造一个数组,并且传递给Load函数,任何链结ID都在这个数组中存储,当基类的lOAD函数被调用的时候,链结ID就和流对象里哈西表你的对象联系在类一起。当LINK()程序段运行的时候,链结ID被船体给所有的LINK函数被用作在寻找它自身的替代品--动态内存地址。
下面这段伪代码讲要说明流是怎样实现这个功能的,首先假设MgcDerived继承自MgcBase.
MgcObjejct *MgcDerived::Factory(MgcStream rkStream)
{
MgcDerived*
pkDerived=new MgcDerived;
MgcStream::Link*pLink=newMgcStreamLink;
pkDerived->Load(rkStream,pLink);
return pkDerived;
}
void MgcDerived::Load(MgcStream&rkStream,MgcStream::Link*plLink)
{
MgcBase::Load(rkStream,pkLink);
//load the
mmeber data for 'this 'here.any MgcObject*member arre
loaded into plLink.M_tObject for use as link IDs.
}
void MgcDerived::Link(MgcStream&rkStream,MgcStream::Link*pkLink)
{
MgcBase::Link(rkStrem,pkLink);
//Link he MgcObject*member
for this 'here',this is
//generally
the complicated part of the process since link resolution
could require calling
//member function
of 'this'to establish the connections between the loaded
objects and'this'
bool MgcDerived::Register(MgcStream& rkStream)
{
if(!MGcBase::Register(rkStream)
{
//'this'si shared and was alrady registered by
another owner
return false;
}
for each MgcObject
pointer 'member'of 'this' do member. Register(rkStream);
}
void MgcDerived::Save(MgcStreamrkStream)
{
MgcBase::Save(rkStream);
//save the
member data for 'this'here. any MgcObject*members
//have their
pointer values written.The values are used as
//link IDs
when the file si loaded at later date
A.7打开与关闭
系统中的很多类都需要在程序主函数开始前初始化并且在程序主函数结束以后有一定的终止操作。例如:一个矩阵类就可能存贮一个静态常量作为它的初始矩阵,下面的代码讲可以使静态数据成员在主函数之前初始化。
//in matrix.h
class Matrix
{
public:
Matrix(float
fM00.float fM01, float fM02,
float fM10.float fM11, float fM12,
float fM20.float fM21, float fM22)
{
//intiallization
of m_aafM[][]goes here
}
static const
Matrix IDENTITY;
protected:
float m_aafM[3][3];
};
//in matrix.cpp
#include"matrix.h"
const Matrix Matrix::IDENTITY(1,0,0,0,1,0,0,0,1);
编译器将会生成在主函数之前执行的Matrix构造函数以此来保证等一矩阵时在程序之前就准备好了的,如果在程序主函数之前需要初始化动态内存,那么在主程序结束之后还得释放,下面的代码说明了c++实现这个的一种自动方式。这种机制也可以被用来任何静态数据。
//in point.h
class Point
{
public:
Point(float
fx,float fy,float fz);
{
m_fx=fx;m_fy=fy;m_fz=fz;
}
static void
Intialize()
{
ms_uiQuantity=DEFAULT_QUANTITY;
ms_akHandyBuffer=new Point[ms_uiQuantity];
ZERO.m_fx=0;
ZERO.m_fy=0;
ZERO.M_FZ=0;
}
static void
Terminate()
{
delete[]ms_akHandyBUffer;
}
static const
point ZERO;
protected:
float m_fx,m_fy,m_fz;
enum{DEFAULT_QUANTITY=32};
static Point*ms_akHandyBuffer;
friend class
_PointInitTerm;
};
//inpoint.cpp
#include"point.h"
const Point Point::ZERO;//just declare storage,
no initialization
class _PointInitTerm
{
public:
_pointInitTerm(){Point::Initialize();}
~_PointIintTerm(){Point::Terminate();}
};
static_pointerInitTerm _forceInitTerm;
编译器将会在主函数之前为_forceInitTerm生成一个构造函数调用,而在主函数之后生成一个解析函数调用。
这种开始和关闭的机制是自动的,但是有一个类之间的相关性需要补救,例如:假设类A有一个静态的成员在主函数之前被初始化,类B有一成员必需被初始化为来自类A的一个数值,则初始化代码则在那个类的源文件中。编译器同时处理两个源文件,但是生成的主程序前的代码不支持任何具体的指令。
//in A.h
class A
{
public:
static void
Initialize(){<initialize OBJECT here>;}
static void
Terminnate(){<any cleanup goes here>;}
static A OEBJECT;
private:
friend class
_AInitTerm;
};
//in A.cpp
#inlude "A.h"
A A::OBJECT;
class _AInitTerm
{
public:
_AInitTerm(){A::Initialize();}
~_AInitTerm(){A::Terminate();}
};
static _AInitTerm _forceInitTerm;
//in b.h
#include"A.h"
class B
{
public:
static void
Initialize(){DEPENDENT_OBJECT=A::OBJECT;}
static void
Terminate(){<any cleanup goes here>;}
static A DEPENDENT
_OBJECT;
private:
friend class_BInitTerm;
};
//in B.cpp
#include "B.h"
A B::DEPENDENT_OBJECT;
class _BInitTerm
{
public:
_BinitTerm(){B::Initialize();}
~_BIinitTerm(){B::Terminate();}
};
static _BInitTerm _forceInitTerm;
在主程序之前:如果A::OBJECT的初始化在B::DEPENNDENT_OBJECT初始化之前被调用则不会出问题,然而如果顺序相反,则B::DEPENDENT_OBJECT将会使用为A::OBJECT准备好的内存空间中并且将会是里即可存取的空间,因为这个对象是静态的。
这种类之间的相关性问题可以用定位的方式解决,这种方式使需要预处理的初始化操作调用它自身正确调用所需要的初始化操作,但是这个方法又导致另一种问题:一个类的预处理初始化操作只能被调用一次。解决方案是加入一个BOOL标识符来标明那个初始操作是否已发生,如果它第二次被调用,就直接返回。例示如下:
//in A.h
class A
{
public:
static void
Initialize()
{
static s_bInitialized=false;
if(s_bInitialized)return;
s_bInitialized=true;
<initialize OBJECT here>;
}
static void
Terminate(){<any cleanup goes here>;}
static A OBJECT;
private:
friend class
_AInitTerm;
};
出处:http://blog.csdn.net/jaopen/archive/2005/04/18/352531.aspx
译自《3D Game Engine Design》的附录A。 |