本次教程内容:
- 对象的继承
- 指针的用法
- 虚函数
- deque容器
面向对象编程的一个重点,就是在设计知识和责任的分配。
小Pa很高兴地飞来找小Q:我对贪吃蛇有了一个新的想法。
小Q:哦,说说看。
小Pa:以前的贪吃蛇游戏,是一种只有失败,没有成功的游戏。给人感觉不好,我希望把它改造成闯关模式,让它有一个成功的机制。
小Q:确实是个有趣的想法,不过上一个版本的贪吃蛇的代码还存在问题,比如蛇自己咬自己的时候不算失败。现在还在修改,你先去画闯关贪吃蛇的地图。等一等再来聊细节。
小Pa:OK
客户能够理解看到的错误,但她们看不到代码结构的不良所造成的潜在危害才是最大的。小Q说的是真的,他最后确实会修改贪吃蛇操作上的错误,提交一个完美的贪吃蛇版本。但他首先必须重整代码的结构。
本来小Q一直没有代码整合的思路,加上贪吃蛇的功能后,代码显得更加凌乱。但也因此给了小Q带来一个启发:不同类型的地图,有不同的操作逻辑,很明显这个逻辑应当放在的地图对象中。
地图上事件的触发,都是与具体地图有关的,事件列表显然也应当放在每个具体的地图对象中。
唯一会产生的问题是,地图事件触发后的行为,有时不局限于一个具体的地图,应该放在地图管理器上执行。
所以,不但地图管理器能看到地图,地图也应该看到地图管理器。
小Q对这个重构思路感到很满意。
虽然还有个别细节未理清,但这个思路能够让现在的map.cpp代码进一步地拆分,可以有效的分解复杂度。原来的map.cpp被分为4部分,下图展示所有源代码之间的依赖关系。
我们来看一下结构关系的重点:
基本地图,我们称它为基础类,它具有一般的地图都必须具备的功能,如显示,如载入,等等。
在它的基础上,我们又建立了两个地图类,一个是RPG地图(MapRpg),一个是贪吃蛇地图(MapSnake),我们称它们为子类。
在构建地图的时候,我们根据地图的模式,创建了不同的子类。
void addMap(string aName, int ax= 0, int ay= 1, int aMode= 1){
aName= lowCase(aName);
if (mapList.count(aName)== 0){
BasicMap *pmap;
if (aMode== 1){
pmap= new MapRpg();
} else {
pmap= new MapSnake();
}
pmap->mm= this;
pmap->x= ax;
pmap->y= ay;
pmap->mode= aMode;
pmap->loadMap(aName);
mapList.insert(make_pair(aName, pmap));
}
}
我们注意这里的*pmap 这个变量。
这是一种特殊的变量,称为指针,它本身固然保存在一个房间中,同时,这个房间中存储的内容是另外某个房间的地址,并且可以标注房间的类型。
BasicMap *pmap;
这句话的意思是说pmap是一个指针,它将来会是指向一个基础地图类的房间。刚刚定义pmap变量的时候,它实际上并不真正地指向一个房间。
new 是针对指针的一个特殊指令,它的含义是按这个指针所标注的类型,真正分配一个具体的房间,然后用这个指针指向这个房间的地址。
但是在分配具体房间的时候,我们其实并没有分配基础地图(BasicMap)类型的房间,而是根据情况分配了它的子类RPG地图或贪吃蛇地图。
还有一点值得指出的是,一般的访问对象的方法,可以用点“.”来访问,而访问指针所指向的对象,则必须用一个特别的标记“->”加以区分。
为什么进行这一套操作,这就是当指针和类的继承相配合时带来的灵活性。
这里必须引入虚函数的概念。子类的继承,如果只是可以免费用基类的函数,其实并没有多大意义,一个函数库就可以解决。真正让我们必须使用“继承”这种复杂机制的理由是虚函数的重载带来的便利性。
看看BasicMap类中的几个虚函数:
virtual void handleKey(int aChar)= 0;
virtual void autoMove()= 0;
virtual void executeAction(Event &evt){
mm->executeAction(evt);
};
virtual void initMap(Event evt){}
虚函数用virtual标志,凡是有这样标志的函数,当子类对它们进行了覆盖后,对它们的调用会直接关联到子类中。
我们重点看handleKey和autoMove两个函数,它们的写法是纯虚函数,因为它的格式上都是=0;这样的虚函数,在子类中必须重新定义才能使用。
有了这样的定义后,地图管理器的键盘监听函数,就变成了这样:
void listen(){
int a;
while(1){
if (kbhit()){
a= getch();
currentMap->handleKey(a);
} else {
currentMap->autoMove();
}
}
}
收到了按键,就让当前地图监听,否则就让当前地图做自动移动的处理。逻辑简洁,不用再加根据地图模式做判断这样的操作,从地图管理器的角度,甚至可以不知道地图有不同模式这一事实。以后我们如果增加新的地图模式,地图管理器中的逻辑不用做任何改变。
再回来看上一段代码中的executeAction和initMap它们是虚函数,但不是纯虚函数。因为它们有函数体,这样即使子函数不做任何处理,这两个函数都有一个默认动作,比如虽然initMap的函数体是空的,就代表默认不用做任何行动。而executeAction的默认操作是直接调用mm指针所指向的同名操作。
我们看看mm是什么:
BasicMapManager *mm;
它是一个指向地图管理器基础类(BasicMapManager)的指针。地图管理器基础类的代码如下:
struct BasicMapManager{
HeroInfo hero;
virtual bool executeAction(Event evt) {}
};
设置地图管理器基类的作用,是为了让地图对象能“看到”地图管理器的同时,又不用看到太多的信息。这里只保留了一个开放的属性hero和一个虚函数executeAction。
当然,英雄对象放在这里是否合理?值得进一步探讨。但为了减少修改量,这部分仍然保留在此处。而executeAction则是保留用于执行一些超过地图类能力的事件,如地图跳转,播放音乐,退出程序等。
严格来讲,地图跳转由地图管理器操作是合理,但播放音乐和退出程序是否应当由地图管理器操作?也是一个值得继续探讨的问题。但代码重构属于比较复杂的操作,应当先抓大结构的调整,待结构稳定后再调整细节。
(吐槽:c++毕竟是早期的语言,虽然实现了面向对象的种种支持,但是支持的很繁琐,不友好。特别是指针这一事物,用法过于灵活,使用不当会造成很难查找的错误。很多编程语言已经基本放弃了指针这一概念。我们在c++中使用指针,也必须加以严格的限制,应当只在属不得不用的情况下才使用它们。遗憾的是,在c++中,这种情况还不少。)
到这里,结构的整理基本完成了,下面改帮助小Pa修改贪吃蛇中的几个错误了。
上节课的最后列出了贪吃蛇存在的几个问题,其中新出糖果与蛇身重叠的问题,以及自己咬自己不导致失败这两个问题,之所以前一版本难以解决,是因为我们使用了队列这种数据结构(queue)。
队列这种结构的逻辑非常适合贪吃蛇,所以我们首先想到了用队列。但队列的一个问题是严格限制我们不能直接访问队列中除了首尾之外的内容。这是一种知识限制的做法,有助于提升代码逻辑的合理性。但与我们的需求不一致,只能放弃。
经过衡量,我们换用一种与队列类似的结构(deque):双端队列。虽然我们用到它的双端特性,但它允许我们访问队列中除了首尾之外的内容,这是我们选用它的关键点。
看一下新的英雄代码:
struct HeroInfo{
int x, y;
string mark;
int color;
int length;
int dirx, diry;
deque<Point>train;
void moveTo(int ax, int ay){
x= ax;
y= ay;
}
void snakeTo(int ax, int ay){
x= ax;
y= ay;
train.push_back(make_point(ax, ay));
}
bool atBody(int ax, int ay){
for (int i=0; i< train.size(); i++){
if (train[i].x== ax && train[i].y== ay){
return true;
}
}
return false;
}
void clear(){
train.clear();
length= 1;
}
};
增加了atBody函数,用于判断一个点是否在蛇身上。这个函数对于实现上面两个需求是必不少的。另外我们注意到,清空队列只能用逐个出队的方式,而这个双端队列则支持clear的直接清空模式。
这个符号“&&”,含义是“或者”,代表左右两个条件都满足时,才算重合。
在生成糖果位置时,我们用随机+检测是否在蛇身+检测是否在墙上的机制来确定新的糖果坐标。
Point getRandPoint(){
int x1, y1, cc=0;
do {
x1= rand()%(w/2- 2)* 2+ 2;
y1= rand()%(h- 3)+ 1;
cc++;
} while(mm->hero.atBody(x1, y1) || mapInfo[y1][x1]!= ' ');
if (cc>1){
// 这里的音效,用于检测前面操作是否真的发生了
Beep(300, 100);
}
return make_point(x1, y1);
}
因为糖果加身的事情很少发生,须经多次测试才能遇到,所以这里加入音效提示,起到辅助调试的作用。
这个符号“||”,含义是“或者”,代表左右两个条件满足其一时,即应重新换一个位置。
&& 与 || 是针对bool值的操作,这类操作形成的表达式就是逻辑表达式。
为了修改另一个问题,我们必须调整另外一个结构:事件。
为了方便从触发找到事件,以前的事件列表我们用映射(map)来实现。但带来一个问题是,事件的触发无法修改。在前一版的贪吃蛇中,在生成新的糖果时已经感到了局限性。我们采取的做法是删除旧的糖果事件,并生成一个新事件。如果说对于糖果事件,这种操作还勉强能说得通。但如果是RPG中的NPC移动,都是靠删除新建这样的机制,则让人很难接受。
map结构的另一个问题是,虽然能够方便地从触发找到事件,但从动作类型找事件的功能仍须自己实现,几乎抵消了它的优势。
所以,虽然map结构看似合理,并且确实有一些优点,但仍然必须舍弃。我们换用vector来保存事件,并自己实现通过遍历所有事件来查找的机制。
由于现在的事件的存储下放到了地图中,每个地图的事件列表其实是很小的,所以这样做对性能可以说毫无影响。
解决隐藏糖果的问题,就是在进入地图的载入事件中(我们最后把它改名为load事件),查找地图中的糖果事件。如果找不到,说明是第一次打开此地图,于是我们随机创建一个新的糖果事件。如果找到,则修改此糖果的坐标。这样保证了不会出现隐藏糖果的问题。
初始化糖果的逻辑在initSugar函数:
void initSugar(){
// 首先查看以前是否存在糖果事件
bool found= false;
for (int i=0; i< eventList.size(); i++){
if (eventList[i].action== "sugar"){
// 找到事件
newSugar(eventList[i]);
found= true;
}
}
if (!found){
// 无糖果事件,则新建一个
Event evt1;
evt1={"move", "0,0", "sugar", ""};
addEvent(evt1);
newSugar(evt1);
}
}
寻找糖果,做了遍历循环,算法逻辑如下:
- 遍历:所有事件
- 条件:动作=sugar
- 处理:更新此事件
在无糖果的时候,先产生一个固定位置的糖果事件,然后使用同一个函数newSugar来更新它,避免重复逻辑。newSugar的代码:
void newSugar(Event &evt1){
// 移动糖果
Point p1= getRandPoint();
evt1.triggerParm[0]= int2str(p1.x);
evt1.triggerParm[1]= int2str(p1.y);
showSugar(p1.x, p1.y);
}
为了直接修改事件的内容,我们采用在参数中传地址的方式(加&),直接修改了对象的内容。
最后,让我们考虑小的闯关贪吃蛇机制,我们增加一个长度事件,在每次移动时都进行检测,可以在长度达到一个目标后,执行跳转动作。同时,对于地图跳转事件,我们再增加一个参数,这个参数可以被贪吃蛇地图解读为初始的蛇身长度。这样就保持各地图直接的连续性。
这个解决办法,实现了在事件框架内解决问题,支持了新的游戏内容,让修改达到最小,并且让客户的设计体验保持一致。
课程小结:
代码经过这一个拆解后,逻辑上有所改善。初步可以做到在一个清晰的接口上做相对独立的开发工作,让系统的扩展性得到了一定幅度的提升。至少现在可以进行一定程度上的分工合作了。
上图
但分工合作往往是也意味着系统架构僵化的开始。因为一旦系统不同模块由不同人来负责,再想调整结构所面临的沟通成本就会大大增加。
本教程每节课的源代码,统一下载地址
链接:https://pan.baidu.com/s/1q4aoYesre1PHaCoV8gkhDQ
提取码:8den