有哪些在游戏开发中常用的单例模式到的设计模式

WantGame ? 游戏编程设计模式
我的图书馆
WantGame ? 游戏编程设计模式
‘游戏编程设计模式’ 分类的存档
/singleton.html
本章是个奇葩。本书的其他章节都是告诉你怎么用一个设计模式。这一章告诉你怎么不用。
除了崇高的理想,GOF在书中对Singleton模式的描述,弊大于利。他们强调这种模式应该保守地使用,但这一条在游戏工业中并不奏效。
像其他模式一样,在不适当的地方使用Singleton,就像用夹板处理子弹伤口一样。由于它被大量地滥用,这章的大部分内容将告诉你避免使用Singleton,但是一开始,我们还是要先从Singleton本身开始。
Singleton模式
设计模式中这样总结Singleton:
确保一个类只有一个实例,并且提供一个全局的指针指向它。
我们在“并且”这个位置将句子分成两个部分,按部分解读。
限制一个类只有一个实例
我们会有这样的时候,当一个类有多个实例的时候,就会出错。一个常见的例子就是当一个类同一个维护了自己全局状态的外部系统交互时。
例如,一个类封装了一个基本的文件系统。因为文件操作都需要一段时间,我们的的类实现了异步操作。也就是说多个操作可以同时运行,所以他们必须相互隔离。如果我们开始一个创建文件的调用,同时另一个调用删除同一个文件,我们的封装类必须保证他们之间不冲突。
为了这样的效果,一个调用必须能够访问他的前一个操作。如果用户可以自由的创建我们这个类的实例,一个实例并不能知道其他实例中的操作。
使用Singleton,它提供了一种方法,在编译期就保证只有一个该类的实例。
提供一个全局的访问指针
游戏中,会有很多不同的系统使用我们的文件包装类:日志,内容加载,游戏状态存储,等等。如果这些系统都不能创建他们自己的文件系统,他们怎么获取到那个唯一的文件系统呢?
Singleton给出了一个解决方案,除了创建一个唯一的实例,它还提供了一个全局能够使用的方法,去获取这个实例。用这种方式,任何人在任何地方都能抓到想要的实例。总而言之,类的实现就像这样:
class FileSystem
& static FileSystem& instance()
&&& // Lazy initialize.
&&& if (instance_ == NULL) instance_ = new FileSystem();
&&& return *instance_;
& FileSystem() {}
& static FileSystem* instance_;
静态成员 instance_ 维护了类的实例,私有的构造函数保证了只有一个实例。公有静态instance()提供了所有代码都能使用的访问实例的入口。同时,也能够实现这个实例在第一次使用的时候才实例化。
更时尚的写法是这样的:
class FileSystem
& static FileSystem& instance()
&&& static FileSystem *instance = new FileSystem();
&&& return *
& FileSystem() {}
C++ 11 规定局部静态变量的初始化只会执行一次,即时是在并发的情况下。所以,假设你在使用现代的C++编译器,这段代码是线程安全的,但第一段不是。
为什么用它?
看起来Singleton有优势,我们的文件系统包装类可以用在任意我们想用到的地方,并且不需要传来传去。这个类本身很智能地确保了我们不会因为创建了一坨实例而带来的麻烦。它还有几个很好的特性:
1、不用的时候不会创建。能够节省内存和CPU总是好的。单例只有在第一次访问的时候被初始化,如果游戏中没有用过,它永远不会被初始化。
2、它是在运行时初始化的。Singleton的另一个常用的替代方案是用类的静态成员。我喜欢简洁的办法,所以我尽可能的使用静态类去代替Singleton。但是静态成员有一个限制,那就是自动初始化。编译器在main()函数调用之前初始化这些静态变量。也就意味着一旦程序运行了,我们就不能用已知信息初始化了(例如,从文件中加载配置)。它还意味着,它们不能相互依赖——编译器并没有确定静态变量的初始化顺序。
延迟初始化,解决了这两个问题。单例被尽可能的推迟了初始化,所以到那个时候把它需要的信息准备好就可以了。只要他们不存在循环依赖,一个单例甚至可以在初始化的时候引用另一个。
3、你可以子类化单例。这是一个很强大但又经常被忽视的能力。来看我们要让我们的文件系统跨平台。为了实现它,我们将文件系统定义为抽象的接口,并且为每一个平台去实现一份接口。基类这样:
class FileSystem
& virtual ~FileSystem() {}
& virtual char* readFile(char* path) = 0;
& virtual void& writeFile(char* path, char* contents) = 0;
然后我们为多个平台定义子类。
class PS3FileSystem : public FileSystem
& virtual char* readFile(char* path)
&&&&// Use Sony file IO API...
& virtual void writeFile(char* path, char* contents)
&&& // Use sony file IO API...
class WiiFileSystem : public FileSystem
& virtual char* readFile(char* path)
&&&&// Use Nintendo file IO API...
& virtual void writeFile(char* path, char* contents)
&&& // Use Nintendo file IO API...
然后,我们把FileSystem改成单例。
class FileSystem
& static FileSystem& instance();
& virtual ~FileSystem() {}
& virtual char* readFile(char* path) = 0;
& virtual void& writeFile(char* path, char* contents) = 0;
protected:
& FileSystem() {}
如何创建实例是最智能的部分:
FileSystem& FileSystem::instance()
& #if PLATFORM == PLAYSTATION3
&&& static FileSystem *instance = new PS3FileSystem();
& #elif PLATFORM == WII
&&& static FileSystem *instance = new WiiFileSystem();
& return *
通过简单地转换编译器,我们把系统文件的包装类绑定到正确的类型上。我们的具体业务逻辑代码可以通过FileSystem::instance()访问文件系统,而不需要为不同平台写特定的代码。平台相关的代码可以被封装在文件系统实现类内部。
它最多也就解决这些问题了。我们得到了一个文件系统包装类,它很可靠。它全局可用,所以在任何地方都可以使用它。是时候提交代码,然后喝上一杯庆祝了。
我们为什么后悔使用它
从短期来看,Singleton模式比较好用。就像其他设计模式一样,我们也需要把眼光放长远。一旦我们在代码中写进一些不需要的单例,那我们实在为自己找麻烦。
它是全局变量
当游戏还是几个人在车库里写出来的时候,硬件的运用要比象牙塔中的软件工程师那些狗屁原则重要的多。老式的C语言或者汇编程序员无所顾忌地使用了使用全局和静态变量,并做出了好游戏。随着游戏越来越大,越来越复杂,结构和可维护性开始成了瓶颈。我们在游戏开发中遇到的困难再也不是因为硬件限制,而是因为受限与生产力。
所以,我们改用了C++,也开始接受一些程序员先贤们难以理解的智慧。其中有一课讲的就是全局变量有如下几个坏处。
1、它们会让代码很难分析。想一下如果我们在一个别人写的函数里找一个bug。如果这个函数没有用全局变量,我们可以通过理解函数体和传进的参数来推测问题的来源。
现在,如果在函数中间调了一个 SomeClass::GetSomeGlobalData()。为了弄明白发生了什么,我们必须翻遍代码库去找什么与这个全局变量相关。当你在凌晨三点,在上百万代码中找一个导致静态变量值错误的调用时,你会恨死全局变量。
2、它会导致紧耦合。你团队中的一个新程序员对游戏中漂亮的、可维护的、松耦合体系不熟悉。但是他被分配了一个任务:让卵石在地上崩裂的时候播放声音。你和我都知道物理代码不能和声音代码耦合到一起,但是他只是想完成任务。不幸的是,AudioPlayer的实例是全局可见的。所以一个小小 #include后,我们的小伙子就搞坏了我们小心翼翼维护的结构。
如果没有一个全局的AudioPlayer实例,即时他#include了这个头文件,他也做不了什么。这样的不同会给他一个明确的信息,那就是这两个模块是不应该知道彼此的。他需要找到另一种方法解决这个问题。通过对实例的访问控制,可以控制耦合度。
3、对并发性不友好。游戏运行于单核设备的年代差不多结束了。今天的代码至少要运行在多线程下,即时它并没有得到并发的所有优势。当我们让一些代码全局可见,我们就创建了一些所有线程都可以看到并且能修改的内存,无论他们是否知道有别的线程也在做同样的事情。这会导致死锁,竞争,和其他一些很难修复的线程同步bug。
这些问题足够把我们从使用全局变量中吓跑,Singleton模式也一样,但是那并没有告诉我们如何设计游戏。怎样不实用全局变量架构游戏。
这个问题有很多答案(本书的大部分内容某种意义上正是这个问题的答案),但是它们不明显或者说不容易实现。大部分时间,我们必须先把游戏放一边。Singleton模式看起来像灵丹妙药。它在一本面向对象设计模式的书里,所以它具有结构合理性,对吗?它让我们多年来都使用这种方式设计软件。
不幸的是,它只是一味安慰剂,而不是治愈之药。如果你读一遍全局变量引起的问题的列表,你会发现Singleton并没有解决任何问题。那是因为Singleton就是一个全局变量,只不过被封装在一个类里而已。
它解决了两个问题即使剩一个
GOF的描述中“并且”这个次莫名其妙。这个模式到底是要解决一个问题还是两个?我们只解决一个会怎么样呢?一个单例的确很有用,谁说非要所有人都能访问呢?同样,全局访问的确很方便,但是让一个类有多个实例也是一样呀。
第二个问题,便捷的访问,几乎就是为什么我们使用Singleton模式的理由。考虑一下日志类。游戏中的大多数模块会从输出诊断日志中得到好处。然而,如果把一个Log类实例当成参数传给所有的函数,会让这个方法看起来很混乱,并且扰乱人的注意力。
前面我们使用Log类的Singleton模式解决了这个问题。每一个函数能狗直接从类中得到一个实例。但是一旦我们这样做了,我们就无意地接收了一个约束。突然,我们就不能创建更多的log对象了。
一开始,这并不是问题。我们只写一个log文件。所以我们只需要一个实例。然后随着开发周期的深入,我们遇到了麻烦。团队中的每一个人都在用log输出他们的日志,然后大量的日志被记得到处都是。程序员需要通篇看这些文本才能找到他们真正关心的那部分。
我们想解决这个问题,把日志分成多个文件。为了做到这一点,我们需要根据游戏中的模块划分日志:网络,UI,音频,玩法。但是我们做不到。不只是因为我们的Log类不允许我们创建多个实例,而且设计上也限制了每一个调用的地方都必须这样调用:
Log::instance().write("Some event.");
为了让我们的Log类支持多个实例(就像最开始的那样),我们必须既修改Log类本身,又修改每一行调用它的地方。我们方便的访问变得不再方便了。
延迟初始化剥夺了你的控制权
在PC桌面开发中,有虚拟内存并且对性能要求不高,延迟初始化是一个很聪明的做法。但游戏开发不同。初始化系统会消耗时间:申请内存,加载资源,等等。如果初始化音频系统消耗了几百毫秒的时间,我们就需要控制它了。如果我们让它在第一次播放声音的时候初始化,它会发生在游戏中的一个事件中,会导致掉帧并且影响游戏体验。
另外,游戏通常会严格控制内存在堆中的分布,以避免碎片化。如果初始化音频系统申请了一块堆,我们就希望知道初始化什么时候进行,这样才能控制这些内存在堆中的位置。
由于这些原因,我见过的大多数游戏不允许有延迟初始化。相反,他们像这样实现Singleton模式:
class FileSystem
& static FileSystem& instance() { return instance_; }
& FileSystem() {}
& static FileSystem instance_;
这样解决了延迟初始化的问题,但是付出了几个单例的特色为代价。这些特色使得Singleton模式比一行全局变量要好。用一个静态实例,我们再也不能使用多态,并且这个类必须在静态初始化的时候被构建。而且,当实例使用的内存不需要的时候,我们也不能释放。
这不是在创建单例,实际上我们这里只是使用了一个简单的静态类。不是什么坏事,但是如果你需要静态类,为什么不去掉instance()方法,而直接用静态函数代替呢?调用Foo::bar()比调用Foo::instance().bar()简单多了,并且它让你清楚你真正使用的是静态内存。
我们应该怎么做?
如果我的目的到此为止,你在下次遇到问题使用Singleton时会三思而行。但是你仍然有一个问题。应该用啥呢?它取决于你要干什么,我有几个选项供你考虑,但是首先...
看一下你是否真的需要类
我发现游戏中的很多单例类都是各种“managers”,这些朦胧的类都只是用来管理其他对象的。我看过的代码中,几乎每一个类都有一个manager:怪物,怪物Manager,例子,例子Manager,声音,声音manager。有的时候,为了搞特殊,他们跑出了“System”,“Engine”这些字眼,但其实意思是一样的。
有时这些管理类是有用的,但他们经常反映的是使用者对OOP的陌生。看这两个不恰当的类:
class Bullet
& int getX() const { return x_; }
& int getY() const { return y_; }
& void setX(int x) { x_ = }
& void setY(int y) { y_ = }
& int x_, y_;
class BulletManager
& Bullet* create(int x, int y)
&&& Bullet* bullet = new Bullet();
&& bullet-&setX(x);
&&& bullet-&setY(y);
& bool isOnScreen(Bullet& bullet)
&&& return bullet.getX() &= 0 &&
&&&&&&&&&& bullet.getX() & SCREEN_WIDTH &&
&&&&&&&&&& bullet.getY() &= 0 &&
&&&&&&&&&& bullet.getY() & SCREEN_HEIGHT;
& void move(Bullet& bullet)
&&& bullet.setX(bullet.getX() + 5);
也许这个例子没有太大的说服力,但是你能看到大量的代码使用了这样的设计思想,只要你忽略掉那些细节。如果你看到这些代码,很自然的你会想到BulletManager应该是一个单例。然后,所有有子弹的东西都需要这个Manager,你需要多少个BulletManager实例呢?
事实上,答案是0个。这里就是我们怎样为Manager类解决单例问题:
class Bullet
& Bullet(int x, int y) : x_(x), y_(y) {}
& bool isOnScreen()
&&& return x_ &= 0 && x_ & SCREEN_WIDTH &&
&&&&&&&&&& y_ &= 0 && y_ & SCREEN_HEIGHT;
& void move() { x_ += 5; }
& int x_, y_;
信春哥得永生。没有Manager,就没有麻烦。设计巨烂的单例往往是帮助别的类加功能。如果可以,请把那些函数都移到类中。毕竟OOP的思想是让对象只关注自己的事情。
除了这些manager外,我们使用Singleton的地方仍有其他问题,每一个问题,都有一些解决方案供参考。
限制一个类只有一个实例
这是Singleton为你提供的一半功能。在我们的文件系统例子中,我们必须严格地保证类只有一个实例。然而,这并不必然限制我们的实例只能提供公用的、全局的访问。我们可能希望访问权限限定在一定的区域,甚至是只有一个类能访问的私有实例。在这种情况下,提供一个全局公开的访问指针,就破坏了结构。
我们想要一种方式能够确保单一实例,又不提供全局访问。有很多实现方法,这里有一个:
class FileSystem
& FileSystem()
&&& assert(!instantiated_);
&&& instantiated_ =
& ~FileSystem() { instantiated_ = }
& static bool instantiated_;
bool FileSystem::instantiated_ =
这个类允许所有人构建它,但是如果你要构建多次,它会触发断言并且失败。一旦先在正确的代码里创建了实例,我们就能确保其他代码不会得到这个实例或者自行创建它。这个类保证了它关心的要求单一实例的特性,但是它不指定如何使用它。
这种实现的劣势是对多个实例的检测只能在运行时。而Singleton模式就很自然地通过类结构在编译期就确保了单个实例。
提供一个方便访问实例的入口
方便地访问是我们使用单例的最主要的原因。这样我们很容易地可以在很多不同的地方拿到这个对象。但这种是有代价的,就是它也让在错误的地方拿到对象更加容易。
一个通用的准则是我们希望变量的作用域越小越好。对象的作用域越小,我们处理它的时候,需要关注的地方就越少。在我们鲁莽地决定用全局作用域的单例对象之前,让我们考虑一下代码中得到对象的其他方式:
1、传进去。最简单的办法,通常也是最好的。为你的函数传一个它需要的对象作为参数,很简单。在我们因为太麻烦放弃这个方案前,值得认真考虑一下。
我们看一个渲染对象的函数。为了渲染,它需要访问一个提供图像设备和渲染状态的对象。通常,我们简单地把它传到所有的渲染函数中,这个参数被命名为context。
话说回来,有一些对象并不属于这个方法。例如,一个处理AI函数需要写日志文件,但是日志不是它的核心任务。如果在参数列表中出现一个Log会很奇怪,所以对于这些例子,我们就要想其他招了。
2、从基类中获取。很多游戏架构都采用了浅切广的继承体系,经常只有一层继承。例如,你可能有一个基类GameObject,所有游戏中的敌人和其他对象都继承自它。这种结构下,大量的代码会出现在子类中。也就意味着所有这些类都能够访问一个相同的东西:他们的GameObject基类。我们可以用到这个特点:
class GameObject
protected:
& Log& getLog() { return log_; }
& static Log& log_;
class Enemy : public GameObject
& void doSomething()
&&& getLog().write("I can log!");
这确保了不是GameObject的不能访问它的Log对象,但是所有继承类都能使用getLog()。这种让继承对象在一些他们的Protected方法中实现自己的方法,会在Subclass Sandbox章节中展开。
3、从已有的全局变量中获取。消除所有全局变量的目的是值得钦佩的,但是实际上很难实现。大多数项目代码仍然需要一些全局变量对象,像一个Game或者World对象,提供了一些游戏状态。
我们可以用打包的方式减少全局类的数量,不去用单例实现Log,FileSystem,和AudioPlayer,这样做:
class Game
& static Game& instance() { return instance_; }
& // Functions to set log_, et. al. ...
& Log&&&&&&&&& getLog()&&&&&&&& { return *log_; }
& FileSystem&& getFileSystem()& { return *fileSystem_; }
& AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
& static Game instance_;
& Log&&&&&&&& *log_;
& FileSystem& *fileSystem_;
& AudioPlayer *audioPlayer_;
这样,只有Game一个全局变量。函数可以通过这样的方式获得其他系统:
Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);
如果后来结构要改成支持多个Game实例(也许是因为工作流或者测试的目的),Log,FileSystem和AudioPlayer都不受影响,他们甚至都不知道有何不同。这种办法的确定,当然就是太多的代码需要跟Game关联。如果一个类只需要播放声音,我们的例子中依然需要他去知道Game这个类,这样才能获得AudioPlayer对象。
我们使用混合方案解决这个问题。那些知道Game对象的代码,可以直接从中访问AudioPlayer。那些不知道的,我们提供了另一个访问AudioPlayer的方法,在这里解释。
3、从Service Locator中获取。到此为止,我们规定全局类就是一些通常意义上的混合类,如Game。另一个做法是定义一个类,他的基本功能就是为一些对象提供全局访问入口。这个模式被叫做Service Loctor,我们单独拿出一章来叙述。
单例还剩什么?
问题还在,我们应该在什么地方使用真正的Singleton模式?说实话,我从来没有在游戏中完全按照GOF的描述来实现Singleton。为了确保单个实例,我通常简单地使用静态类。如果不管用,我会用一个静态标志去在运行时检查是否只有一个类的实例被构建。
本书的其他一些章节也有帮助,Subclass Sandbox模式提供了访问一个类实例的属性,而又不需要把他做成全局变量。Service Locator模式使得对象全局可见,但是它让你更灵活的配置这个对象。
原文链接:/prototype.html
Prototype(原型)
我第一次听说“Prototype”这个词是在设计模式中。今天我发现大家都在说这个词,但是已经不是在谈设计模式了。我们会在这里展开讨论,但是我也会给你介绍一些别的有趣的地方,以及一些其他的衍生概念。但让我们先领略一遍经典的内容。
原型设计模式、
假设我们在做一款类似《圣铠传奇》的游戏。在英雄的周围会产生很多生物和魔鬼,企图分享他的肉。这些恶心的晚餐伙伴是通过“卵”进入场景的,并且不同的敌人用的是不同的卵。 在这个例子中,我们为每一种怪物创建一个类-Ghost,Demon,Sorcerer等等,像这样:
class Monster
// Stuff...
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};
一个卵的构造函数实例化一个特定的怪物类型。为了支持游戏中所有的怪物,我们简单粗暴得为每一种怪物写一个卵类,构成了这个并行的类结构。
像这样实现他们:
class Spawner
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
class GhostSpawner : public Spawner
virtual Monster* spawnMonster()
return new Ghost();
class DemonSpawner : public Spawner
virtual Monster* spawnMonster()
return new Demon();
除非你是按代码行数付费的,否则堆砌这么多垃圾可不时间令人预约的事。大量的类,大量的冗余代码,大量的副本,大量的复制,大量的重复…
原型模式提供了一个解决方案。主要思想是一个对象可以产生其他跟他相似的对象。如果你有一个Ghost,你可以用它产生更多的Ghost。如果你有一个Demon,你可以产生其他Demon。任何一个怪物都可以被当作一个原型怪物,用来产生其他的分身。
为了实现这个,我们提供一个基类,Monster,和一个虚函数clone():
class Monster
virtual ~Monster() {}
virtual Monster* clone() = 0;
// Other stuff…
每一个怪物的子类都可以实现一个方法,返回一个跟自己类型状态一模一样的新对象。例如:
class Ghost : public Monster {
Ghost(int health, int speed)
: health_(health),
speed_(speed)
virtual Monster* clone()
return new Ghost(health_, speed_);
int health_;
int speed_;
一旦所有的怪物都支持了这个方法,我们不再需要为每一个怪物类提供一个卵类,我们只需要提供一个:
class Spawner
Spawner(Monster* prototype)
: prototype_(prototype)
Monster* spawnMonster()
return prototype_-&clone();
Monster* prototype_;
它内部会保存一个怪物,被隐藏起来。怪物的主要目的是用它作为一个模版产生出更多一样的怪物来。就像蜂王从来不离开蜂巢。
为了创建一个ghost卵,我们创建一个ghost对象最为原型,然后创建一个卵,保存这个原型:
Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);
这个模式的优雅之处在于它不只是克隆原型类,它还可以克隆怪物的状态。这就意味着我们可以通过创建相应的原型精灵,来产生快精灵、弱精灵、慢精灵…
我发现了这个模式的优雅和给人惊醒之处,我没有企图自己提出这个模式,但我也没法想象如果不知道它怎么办。
现在,我不需要为每一种怪物产生一个“卵”了,这很好。但是我必须在每一个怪物类中实现clone方法。这其实跟那些“卵”的代码数量差不多。 当你坐下来试着写这些clone()方法时,依然会有一写讨厌的语义坑。我们是做深度克隆还是浅克隆?换一种说法,如果怪物拿了一个叉子,那在克隆怪物的时候需要克隆叉子吗? 另外,看起来这个模式并没有在这个人为制造的问题中省去多少代码,而且这本身就是一个人为制造的问题。我们必须意识到,我们要为每一种怪物写一个类,现在这种方法已经不被大多数游戏工程师采用了。 我们大多数人都会感受到,像这样大量的继承关系管理起来很痛苦,这就是我们为什么要用Component 和 TypeObject模式去组合成不同的类型,而不是只封装在在私有的类中。
即时我们用不同的为每一种怪物定义不同的类,我们也有其他方法来处理这些猫腻。我们不为每一种怪物定义不同的卵类,我们定义卵函数:
Monster* spawnGhost()
return new Ghost();
这样比用一个整类来创建怪物要轻量的多。然后卵类就可以简单地存储一个函数指针:
typedef Monster* (*SpawnCallback)();
class Spawner
Spawner(SpawnCallback spawn)
: spawn_(spawn)
Monster* spawnMonster()
return spawn_();
SpawnCallback spawn_;
这样,创建一个Ghost,你可以这样做:
Spawner* ghostSpawner = new Spawner(spawnGhost);
现在,大部分C++开发者都很熟悉模版了,我们的卵类需要根据不同类型生成相应的实例,但是我们不希望为每一种类写代码。一个很自然的解决方案就是用模版支持的类型参数:
class Spawner
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
template &class T&
class SpawnerFor : public Spawner
virtual Monster* spawnMonster() { return new T(); }
这样使用:
Spawner* ghostSpawner = new SpawnerFor&Ghost&();
First-Class类型
前面介绍的两种解决方案都需要一个Spawner类,这个类将被一个类型参数化。在C++中,类型并不是一般意义上的First-Class,所以需要一些操作。如果你在用一种动态语言像Javascript,Python,或者Ruby,一些类可以像对象一样传递的语言,你可以直接得到这种效果。
当你需要产生一个卵,你只需要传递一个要被实例化的怪物类,运行时下的对象就可以很容易地倍创建了。
总而言之,说实话,我还没有发现一个用原型模式是最优选择的例子。也许你会有不同的经验,但是现在我们先搁置一边,来讨论点别的:作为语言范例的原型。
原型语言范例
许多人以为“面向对象编程”等价于“类”。OOP的定义也充满了争议,但是没有异议的是,OOP定义了将数据和代码包含到一起的“对象”的概念。跟像C和Scheme这类结构化语言相比,OOP最大的特色就是把状态和行为紧紧地绑到一起。 你可能一位类是实现这种绑定的一种甚至是唯一的途径,但是少数人像Dave Ungar 和 Randall Smith却有不同看法。他们在8os上发明了一种新的语言Self,OOP能做的它都能做,但是没有类。
在一些场景下,Self比基于类的语言(我们叫它类基语言)更符合面向对象的思想。我们认为OOP把状态和行为连接到了一起,但是类基语言却割裂了他们。 考虑一下你喜欢的类基语言的语义,为了访问一个对象的状态,你需要在对象占用的内存中查找,状态是包含在对象中的。 为了调用方法,你在对象的类中查找,并且找到。行为被包含在类中了。这就意味着我们需要通过间接的方式获取一个方法,这样,属性和方法就是不等价的。
Self消除了这个问题,你可以在对象中找到任何想要的东西,对象既包含状态又包含行为,你可以让一个对象含有一个它专属的方法。
如果Self只做到这,那就太难用了,有一句说一句,类基语言提供了一个很好的机制去复用代码,避免复制粘贴。为了获得同样的效果,Self使用了delegation。要访问一个属性或者调用一个方法,我们先从对象本身找,如果它有,我们直接用。如果没有,我们在它的父对象中找,所谓父对象就是另外一个对象的引用。当我们在第一个对象中寻找一个属性失败,我们就在他的父对象中找,找不到,再下一个父对象,一直这样找下去。 父对象让我们可以在多个对象中重复使用行为和状态,所以我们已经实现了类的一部分功能。类的另一个关键功能是给我们提供了一个创建实例的方式。当你需要某一个东西,你可以用new Thingamabob()来实现,不管你的编程语言语法如何,一个类就是一个产生他自己实例的工厂。 如果没有类,我们怎么创建实例呢?特别是我们怎么创建一堆有相同样式的实例呢?就像这个设计模式一样,Self中我们使用克隆的方式。 在Self语言中,每一个对象都天生支持Prototype设计模式。任意一个对象都可以被克隆。为了创建一堆相似的对象,你可以: 1、把一个对象捏成你想要的形状。你可以先克隆一个系统内建的Object然后为它设置属性和方法。 2、可劲的克隆吧,要多少有多少。 这样,我们就可以使用Prototype模式的优雅,又不需要自己实现clone函数,系统都已经内建支持了。 自从我学习了它,我就意识到它是多么地漂亮、聪明、简洁,我开始创建一种基于prototype的语言以获得更多的经验。
用起来怎么样?
我非常愿意去摆弄一种纯粹的基于Prototype的语言,但是当我真的开始搞,我发现一个不不想看到的事实:真的很难用。 的确,这种语言实现起来很容易,但是这是因为他把复杂度推给了用户。当我开始尝试使用它,我发现我失去了class给予的结构化。由于语言本身不具备结构,我光让库这一层的组织工作就搞的死去活来。 或许我的主要经验都在基于类的语言上,所以我的大脑已经习惯了这种编程思路。但是我预感,大多数人都跟我差不多。 基于类的语言还有另一个加分项。你看很多游戏都有一些很明显的主角类,一坨坨的敌人、物品、技能,都很明确。你不会看到游戏中有一种特殊的怪,就像“取巨魔和哥布林的中间特征,然后混合一点蛇进去”。 尽管Prototype是一种很酷的编程模式,并且我希望更多的人能了解它。但我并不希望我们每天都用它。我见过的完全按照prototype形式组织的代码,就像浆糊一样,进去拔不出来。
JavaScript怎么样?
好吧,如果基于Trototype的语言这么不友好,我们怎么解释JavaScript?这样一种语言每天被成千上万的人用。地球上运行他的电脑比运行其他语言的都多。 Brendan Eich,JavaScript的作者,从Self中汲取了灵感,许多JavaScript的语义都是基于Prototype的。每一个对象都有任意数量的属性,包括数据和“方法”(其实是像数据一样存储的函数)。一个对象也可以包含另外一个对象,叫做“原型”,他是另外一些数据的托管。 但是,不管怎样,我相信JavaScript实际上更像是基于类的语言,而不是基于原型的。JavaScript与Self一点很大的不同就是JavaScript没有基于原型语言中最常见的操作克隆。 JavaScript中没有方法能够克隆对象,最接近的一个是Object.create(),可以让你克隆一个它托管的对象。即使这个也是在ECMAScript5中才加入,也就是JavaScript诞生14年后。不用克隆,让我给你演示一下JavaScript中如何定义类型和创建对象。我们从一个构造函数开始:
function Weapon(range, damage) {
this.range =
this.damage =
这几行代码创建了一个对象,并且初始化了数据。你可以这样调用:
var sword = new Weapon(10, 16);
这个new 操作符用一个指向一个空对象的this调用了Weapon()函数体。函数体为其添加了一些属性,然后自动返回一个被填充的对象。 new还做了一些其他的事情。当他创建一个空白对象后,绑定了一个托管的原型对象。你可以直接用Wapon.prototype访问这个对象。 既然在构造函数中添加了状态,为了添加方法,你往往需要为原型对象添加方法。就像这样:
Weapon.prototype.attack = function(target) {
if (distanceTo(target) & this.range) {
console.log("Out of range!");
target.health -= this.
这为Weapon的原型添加了一个attack属性,这个属性的值是一个函数。从现在开始,每一个通过new Weapon()返回的对象,都托管了Weapon.prototype,你现在可以这样调用sword.attack()这个函数。它看起来是这样的:
让我们回顾一下: & 1、创建对象的方法是通过“new”操作符调用一个代表一种类型的构造函数。 2、状态储存在实例本身中。 3、行为通过一层间接的托管到原型中,原型存储了很多方法,这些方法是通过这个共造函数构造的对象共用的。 & 你一定觉得我疯了,但这跟我前面描述的类很像。你可以在Javascript中写基于原型的代码(除了克隆),但是语言本身的语义鼓励你使用基于类的特性。 我个人认为,这是很好的。就像我前面说的那样,我发现在基于原型的语言上加倍下注,会让代码很难理解,所以我希望JavaScript的核心机制更偏向于基于类。
数据模型的原型
我在喋喋不休的说为啥不喜欢原型,这让这一章读起来很蛋疼。我想这本书应该是喜剧而不是悲剧,所以,我们用一个我认为原型,更确切的说是托管更有用的使用场景来结束这一章吧。 如果你比较一下一个游戏中代码的数量和数据的数量,你会发现数据的比例从开始就一直稳步的增长。早期的游戏几乎全用程序实现,这样才能适应磁盘和老游戏机。今天的游戏,代码只是一个驱动游戏的引擎,而游戏的主体是用数据定义的。 这很好,但是把大量的游戏内容推进数据文件,并没有很好地解决大项目的组织问题。从某种意义上讲,更增大的复杂度。我们用编程语言的原因正式因为它提供了管理复杂度的工具。 我们不去复制粘贴代码到十个地方,我们把它写进一个函数然后通过名字调用。我们不去把一个方法拷贝到一堆类中,我们可以把它写进一个单独的类中,让那一堆类去继承它。 当你的游戏数据到达一定的规模,你就开始希望有相似的特性了。数据模型是一个很深的话题,我不想在这里讨论,但是我想抛砖引玉,让你考虑一下自己游戏中使用原型和托管去重用数据。 让我们为前面提到的游戏定义一个数据模型。游戏设计师需要在文件中为一些怪物和道具定义属性。 一个常用的方式是使用JSON。数据节点是基本的地图、属性包,或者其他的一些内容。因为没有哪个程序员愿意去开发一个已经有的游戏。 所以,哥布林在游戏中会被定义成这个样子:
"name": "goblin grunt",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"]
这非常简单明了,即时最不擅长文本的设计者也可以掌握。所以你就扔进了一堆大哥布林家族的兄弟姐妹进去:
"name": "goblin wizard",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"],
"spells": ["fire ball", "lightning bolt"]
"name": "goblin archer",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"],
"attacks": ["short bow"]
现在,如果这是一段代码,我们的审美会陷入煎熬。这些节点有大量重复的内容,一个受过良好训练的程序员会很讨厌它。它既浪费了空间又浪费了作者的时间。你必须小心地阅读,去看是否是真的一样。维护起来很头疼。如果我们决定让所有的哥布林血更厚,我们需要记住更新三个哥布林的血量,太他妈蛋疼了。 如果是代码,我们可以创建一个抽象的goblin并在三个哥布林中重用。但是愚蠢的JSON并不懂,所以,我们需要让他更聪明点。 我们将为一个对象声明一个”prototype”属性,然后它定义并托管了另一个对象。所有在第一个对象中没有的属性,可以在它的prototype中找。 这样,我们就可以简化goblin的JSON数据:
"name": "goblin grunt",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"]
"name": "goblin wizard",
"prototype": "goblin grunt",
"spells": ["fire ball", "lightning bolt"]
"name": "goblin archer",
"prototype": "goblin grunt",
"attacks": ["short bow"]
从现在起wizard和archer把grunt作为了他们的原型,我们再也不需要在他们三个里面重复health,resists和weaknesses这几个属性。我们加入数据模型的逻辑也很简单——基本的单委托。但是我们已经搞定了很多复用问题。 一个有趣的事情是,我们没有顶一个第四个”base goblin”抽象原型作为这三个哥布林的原型。而是,我们我们选了一个最简单的作为原型。 这在基于原型的语言系统中很自然,因为任意一个对象都可以被克隆去创建一个新的相同对象。我觉得在这里也很自然。这样对游戏中的数据是一个很好的特性,因为我们经常需要在游戏世界中一些一次性的特殊数据。 考虑到游戏中的boss和其他特有的物品。他们经常要重用一些游戏中常见的属性,原型委托就是一个很好的定义方式。那柄魔法断头剑,其实就是一柄有攻击加成的长剑,我们就可以直接扩展成:
"name": "Sword of Head-Detaching",
"prototype": "longsword",
"damageBonus": "20"
你游戏引擎的数据模型有一个额外的能力,就是能够让设计师在你游戏世界中的武器和动物中加入一些变数。这些变数会丰富游戏,给玩家惊喜。
Observer模式
原文连接:/observer.html
你再也找不出一台电脑,里面没有用MVC架构构建的软件。MVC的底层就是Observer 模式。Observer模式应用如此之广泛,以至于java将其纳入核心库(java.util.Observer),C#甚至已经把它固化到语言中(event 关键词)。
Observer是经典GOF设计模式中,最被广泛使用和熟知的模式之一。但是游戏开发界却被诡异地隔绝了,所以对你来说,可能比较陌生。如果你还没有染指过,让我来带你看一个激动人心的例子。
话说我们要在游戏中加入成就系统。它表现为许多奖章,当玩家完成了一些特定的目标时就会获得。例如“杀掉100个猴怪”,“放下吊桥”,或者“只用一个Dead Weasel(死黄鼠狼?)过关”。
我们有如此多的成就牵扯到游戏中各种各样的行为,所以很难实现的很干净。稍不小心,成就系统的代码就会在某个一男的角落跟我们的功能代码纠缠在一起。的确,“放下吊桥”会与物理引擎联系在一起,但是我们真的希望在碰撞检测算法中的线性代数代码里面调用像unlockFallOffBridge() 这样的具体业务逻辑的函数吗?
通常我们希望游戏中一块功能的代码,能够很好得集中到一起。但难点在于成就系统是被一大堆其他游戏逻辑触发的。我们怎样才能避免成就代码散布在这些逻辑之中呢?
这正是Observer模式要解决的问题。他可以让代码只需要广播发生的事件,而不需要关心谁来接收。
举个例子,我们有一段物理相关的代码,来处理重力和一个光滑平面上锤向尽头的槽。为了实现“放下吊桥”,我们可以直接把成就代码写在那里,但那会显得很丑陋。所以我们换个写法:
void Physics::updateEntity(Entity& entity)
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if (wasOnSurface && !entity.isOnSurface())
notify(entity, EVENT_START_FALL);
所有的这些就是在说:“啊,我不知道谁关心这件事,但是它发生了,顺其自然吧。”
成就系统是自己进行注册的,所以无论何时物理代码抛出这个事件,成就系统都会收到。然后它可以检测落下来的是不是桥。如果是,它就会解锁相应的成就,并播放一些特效和音效。所有的这些都不会影响物理代码。
事实上,我们可以修改一些成就,或者干脆去掉成就系统,都不需要改变一行物理代码。它依然会抛出事件,并不在意已经无人倾听的事实。
它是如何工作的
如果你不知道如何实现这个模式,你可以试着根据前面的描述猜一下,但是为了更简单点,我们迅速过一下。
我们从一个大鼻子类开始,它很想知道其他对象发生了什么有意思的事情。这些好奇的对象是用这个接口定义的:
class Observer
virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
所有实现这些接口的类都成了Observer. 在我们的例子中,那是成就系统,所以我们得到了这些:
class Achievements : public Observer
virtual void onNotify(const Entity& entity, Event event)
switch (event)
case EVENT_ENTITY_FELL:
if (entity.isHero() && heroIsOnBridge_)
unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
// Handle other events, and update heroIsOnBridge_...
void unlock(Achievement achievement)
// Unlock if not already unlocked...
bool heroIsOnBridge_;
The subject
这些通知都是有那些被观察的对象发出的。按照GOF的说法,这些对象被叫做“subject”。它有两个工作,第一,他维护了一个观察者列表,他们耐心地等待着被通知。
class Subject
Observer* observers_[MAX_OBSERVERS];
int numObservers_;
重要的一点是这个subject暴露了几个公有API,去修改这个列表:
class Subject
void addObserver(Observer* observer)
// Add to array...
void removeObserver(Observer* observer)
// Remove from array...
// Other stuff...
这让外部的代码可以控制谁接收通知,Subject跟Observer沟通但是它不与他们耦合在一起。在我们的例子里,没有一行代码提到成就系统。但是依然通知了成就系统。这就是这个模式的聪明之处。
subject维护一个observer列表,而不是一个observer也很重要。它保证了observer之间相互独立。例如,声音引擎也监听下落事件,这样它可以播放相应的音乐。如果subject只支持一个observer,当这个声音引擎注册的时候,就需要把成就系统反注册掉。
这就意味着两个系统相互排斥,后来者会禁用掉前者,多么无耻!对Observer列表的支持明确了Observer之间不再相互依赖。他们只知道自己是唯一监听这个Subject的对象。
剩下的工作就是subject发送通知了:
class Subject
protected:
void notify(const Entity& entity, Event event)
for (int i = 0; i & numObservers_; i++)
observers_[i]-&onNotify(entity, event);
// Other stuff...
可观察的物理引擎
现在我们只需要把这些跟物理引擎挂接起来,这样它就可以发送通知,而且成就系统可以绑定自己去接受通知了。我们已经很接近原始的设计模式秘诀了,继承Subject:
class Physics : public Subject
void updateEntity(Entity& entity);
让我们把Subject中的notify()定义为protected类型,这样它的子类Physics类就可以调用它去发送通知了,但是外部的代码不能。与此同时,阿爹到Observer()和removeObserver()定义为public,这样所有能得到物理引擎对象的类,都可以观察它。
现在,每当物理引擎发生了一些重要的事件,都可以像前面的例子那样调用notify()。遍历观察者列表,通知他们。
很简单,对吗?只是一个类,包含了一个指向一些对象接口的指针。很难相信,一些如此简单的东西正是那些不计其数的程序和应用框架的基石。
但是Observer模式并非没有被指责。当我问起他游戏程序员对Observer模式怎么看时,他们会提出一些不满。如果可以的话,让我们试着说服他们。
“太慢了”
我听到太多了,通常出自那些不真正理解这个模式的程序员之口。他们有一个偏见,那就是所有看起来像设计模式的东西,都会包含一大堆类,一堆间接调用等等,这些都是在浪费CPU时钟。
Observer模式被大大得误解了,因为它身边围绕了许多名声不好的家伙,叫做“时间”,“消息”,甚至“数据绑定”。这些都会拖慢系统(经常是故意的,理由正当的)。他们为维护了像队列这种东西,并且每次发送通知都会动态申请内存。
但是现在你看到这个模式是如何实现的了,你知道这不是事实。发送一个通知只是简单的遍历一个列表,调用一些虚函数。这会比直接静态调用要慢一点点,但是消耗微不足道。
我认为,这个模式最适合在不被频繁调用的代码中使用。所以你可以放心得动态分发。除此之外,再也没有多余的消耗了。我们没有为消息创建对象,没有队列。只是一个同步的间接调用而已。
事实上,你需要小心,因为Observer模式是同步的。Subject直接调用observer,这意味着知道所有observer的处理函数返回,Subject才能继续工作。一个很慢的observer可能会阻塞整个subject。
这有点耸人听闻,但这不致命。这只是一个你需要注意的点。经常使用基于事件编程的UI程序员有一个经验只谈:“远离UI线程”。
如果你在处理一个同步的事件,你需要尽快结束并返回,这样才不至于阻塞UI线程。当你有比较慢的工作要做时,把它抛到另外一个线程或者一个工作队列中。
你必须小心地处理observer的线程和显式的锁。如果observer被一个subject持有的锁卡住。那可能造成整个游戏的死锁。在多线程引擎中,你最好使用异步结构Event Queue。
过多的动态内存申请
整个程序员界(包括很多游戏程序员),已经投奔支持垃圾回收的语言了,动态申请内存不再像原来那样可怕了。但是对于性能敏感的软件像游戏,内存申请依然受关注,即时实在托管语言中。动态申请内存会消耗一定的时间,回收内存也一样,即使他们是自动的。
在前面的示例代码中,我使用了一个定长的数组是为了尽量简化。在实际的应用中,observer列表往往是动态创建的,随着observer的添加和删除,列表会相应的增长和缩短。这个内存搅拌器吓住了很多人。
当然,首先应该注意的是,只有当observer产生的时候,会申请内存。发送通知并不会带来内存申请,它只是一次方法调用。如果我们在游戏开始的时候就准备好这些observer,而且不再瞎折腾。内存申请的量是次数是很少的。
如果这仍然是个问题,不怕,我将介绍一个不需要任何动态内存申请就可以添加删除observer的方法。
链接的observer
到目前为止的代码中,Subject持有一个指向Observer指针的列表。Observer类自己没有列表的引用。它只是一个纯虚接口。接口被具体的有状态的类继承,这通常极好的。
但是如果我们在Observer中加入一些小的状态,我们可以用拆分subject中列表到observer中的这种方式来解决内存申请问题。subject不再持有这些指针,observer对象变成了一个链表的节点:
实现上,首先我们扔掉Subject中的数组,用一个指向Observer列表头的指针代替:
class Subject
: head_(NULL)
// Methods...
Observer* head_;
然后我们在Observer中加入一个指向下一个Observer的指针:
class Observer
friend class S
Observer()
: next_(NULL)
// Other stuff...
Observer* next_;
我们可以声明Subject为友元类。Subject有添加删除object的API,但是列表是在Observer类中自己管理的。给Subject添加管理列表能力的最简单的方法,就是声明他为友元。
注册一个新的Observer就是把它写入到列表中。我们就简单的把它插在前面:
void Subject::addObserver(Observer* observer)
observer-&next_ = head_;
另一个选择是加到链表的后面。这么做要麻烦一些。Subject必须要么遍历链表找到后面,要么保存另一个tail_指针,永远指向最后一个节点。
添加到链表前面会简单一些,但是也有其他影响。当我们遍历链表去发送通知到Observer的时候,最后添加的Observer最先收到通知。所以如果我们按照这样的顺序注册Observer A、B、C,他们会按照C、B、A这样的顺序接收通知。
理论上,选择那种方法都无所谓。有一个好的Observer规则是:一个Subject中的两个Observer不应该有对顺序关系的依赖。如果需要关注顺序,说明两个Observer耦合的太紧了,迟早会给你带来麻烦。
我们添加清除的方法:
void Subject::removeObserver(Observer* observer)
if (head_ == observer)
head_ = observer-&next_;
observer-&next_ = NULL;
Observer* current = head_;
while (current != NULL)
if (current-&next_ == observer)
current-&next_ = observer-&next_;
observer-&next_ = NULL;
current = current-&next_;
由于我们使用了单链表,我们必须先遍历找到这个observer,然后删除。如果我们用普通数组,也需要做同样的事情。如果我们使用双向链表,每一个observer都有一个指向前一个和后一个的指针。我们就可以在一个确定的时间内删除元素。在实际项目中我会这么做。
剩下的就是发送通知了,像遍历一样简单:
void Subject::notify(const Entity& entity, Event event)
Observer* observer = head_;
while (observer != NULL)
observer-&onNotify(entity, event);
observer = observer-&next_;
不错把?一个Subject想有多少observer就可以有多少,不需要一点多余的动态内存。注册和反注册就像用简单数组一样快。不过,我们还是牺牲了一个小特性。
一旦我们把Observer当作一个列表的节点,他就只能作为Subject中Observer列表的一部分了。换个说法,一个Observer同时只能观察一个Subject。在更传统的实现里,每一个Subject都有一个相互不依赖的列表,一个observer可以同时在多个列表中。
你也许可以忍受这种限制,我发现一个Subject有多个observer会更常见一些。如果无法忍受,还有一个更复杂的解决方案,仍然不需要动态内存申请。在本章写就有点太长了,但是我可以说个大概,抛砖引玉。
链表节点池
跟前面一样,每一个Subject有一个Observer的链表。但是链表的节点不再是Observer对象本身。他们被划分为一些小“链表节点”对象,里面含有一个指向Observer的指针和和一个指向下一个节点的指针。
多个节点可以指向同一个observer,也就意味着一个Observer可以同时存在与多个Subject的列表。我们就找回可以观察多个Subject的能力了。
避免内存申请的思路比较简单:既然所有的节点都拥有相同的大小和类型,你可以预申请一个对象池。这就给了你一个固定大小的节点列表,你可以使用甚至重复使用它,不需要再去进行实际的内存申请。
我想我们已经干掉了三个让人们畏惧这个模式的三个魔鬼。我们可以看到,他简单、快速、在内存管理方面表现良好。但是这是否说明我们在任何时候都应该用Observer模式呢?
现在,问题来了。像所有设计模式一样,Observer模式不是万能的。即使实现地既正确又高效,它也可能不是一个正确的选择。设计模式被误解往往是因为人们使用一个好的设计模式去解决一个错误的问题,结果似的问题更加糟糕。
有两个挑战依然存在,一个是技术层面的,另一个是可维护性层面的。我们先解决技术层面的,因为这个往往比较简单。
销毁Subject和Observer
我们前面的代码介绍是很平滑的,但是忽略了一个问题:当我们销毁Subject或者Observer时会发生什么?如果我们一时不小心销毁了一些Observer, 一些Subject可能依然持有指向它的指针。现在它就是一个指向一块被回收内存的野指针。当这些Subject尝试发送通知的时候,额…我们会迎来一个欢乐时光。
销毁Subject要容易一些,因为在大多数实现中,Observer不会有它的引用。即时如此,把Subject推进内存管理的垃圾箱也会带来一些问题。那些Observer也许仍然希望收到通知,但他们不知道他们永远也收不到了。他们不再是Observer了,但他们依然相信他们是。
你可以用多种方法解决。最简单也是我最看好的一个。当一个Observer销毁的时候,它需要自己从Subject中反注册掉自己。通常Observer知道那些Subject正在被观察,所以一般会在析构函数中加入一个removeObserver的调用。
如果你希望Subject销毁的时候释放对Observer的持有,也很简单。在Subject销毁之前,发送一个最后的“垂死”消息。这样,所有的observer就可以收到,然后进行合适的处理。
人类,即使是在机器公司呆过足够时间的人,也会染上一些自然小毛病——不靠谱。这正是我们为什么发明计算机:他们不会犯一些我们经常犯的错误。
一个更靠谱的答案是让Observer在销毁的时候自己去各个Subject中反注册。如果你在Observer基类中实现这套逻辑,所有用到这个基类的人都不需要关注这一点了。这样会增加一些复杂度,也就是,每个Observer需要维护一个他所观察的Subject列表。最后你得到了一些双向指针。
别担心,我们有垃圾回收
现在的孩子们因为有了支持垃圾回收的语言而自我感觉良好。认为你不再为此担心,因为你不再需要显式地删除任何东西。仔细想想!
想象一下:你有一个UI显示主角信息,如血量等等。当主角显示的时候,为他你创建一个对象。当它关闭的时候,你就忘记了他的存在让垃圾回收去清除它。
每当主角被打了脸(或者其他的,我猜),就发送一个通知。UI 会接收到,并且更新血条。很好,现在主角消失,你又没有反注册Observer,会发生什么呢?
UI再也不会显示了,但是垃圾回收不会清除它,因为主角的Observer还拥有他的引用。每一次UI被加载,我们就会往那个长长的列表上添加一个对象。
在玩家玩游戏的整个过程中,瞎转悠,或者进行战斗,主角不断的发送通知,所有的这些UI都会收到。他们已经不再显示了,但是依然接收通知,更新那些不可见的UI元素,浪费CPU时钟。如果我们在声音播放上也这样,我们会被震碎狗耳朵。
这个是通知系统一个比较常见的问题,它有一个名字:监听失效问题。Subject持有他的监听者的引用。你可以结束掉那些在内存中赖着不走的僵尸UI。这里的教程是关于反注册规则的。
接下来呢?
Observer模式深层次的问题来自它本身的目的。我们使用它,是因为它能帮我们实现两部分代码的解耦。让Subject可以不与Observer直接打交道。
当你专心处理Subject的行为时,这是极好的,因为这时你对其他无关的事情会很反感。如果你在处理物理引擎,你肯定不会希望你的编辑器,或者你的注意力被一大堆成就相关的事情占据。
话说回来,如果你的程序不能工作了,在Observer链的某个地方出了bug,查找原因就会变得很很困难。在显示调用下,很容易找到调用的方法。由于是静态的绑定关系,这对你的IDE来说是小菜一碟。
但是如果这发生在一个observer列表中,找到谁将会被通知的唯一途径,就是在运行时的observer列表中查找。你不是小弄清楚程序结构中的静态原因,而是必须在动态的行为中寻找答案。
我解决这个问题的指导方针很简单。如果你经常需要统筹两边的交互,才能明白一方的程序,那就不要用Observer模式来表达这种关系。直接一些更好。
当你在开发一个大规模程序的时候,你倾向于把你处理的所有模块搞到一起。我们有很多这样的术语:“分离关注点”,“连贯与衔接”还有“模块化”,但是归结起来一句话“把这些搞一起,别跟那些搀和”。
Observer模式非常擅长让一些互不相干的模块之间交互,而不需要耦合到一起。但是在一个浑然一体的模块内部就用处很小。
这就是为什么它非常适合我们的例子:成就系统和物理引擎根本互不相干,通常是不同的人实现的。我们把他们之间的交互最小化,这样在一边工作的程序员就不需要另一边的知识。
现在的Observer
设计模式是在1994年出现的。那个时候,面向对象编程是热点。每一个地球上的程序员都想“30天学会面向对象”,中层管理按照他们创建的类的数量付钱。工程师用继承深度来彰显勇气。
Observer正是在那个时代流行起来,所以就不要奇怪为什么“类泛滥”。但是当今的主流程序员更喜欢函数式编程。实现整个接口去接收通知已经不符合今天的审美了。
它感觉起来很笨重,它的确很笨重。例如,你不能得到一个类,去用不同subject发送的不同通知。
一个更现代化的Observer只会是一个指向方法或者函数的引用。在有一类函数的语言中,特别是有闭包的,这是一个更通用的方法。
例如,C#有一个语言支持的“events”。这样Observer就作为“代理”被注册,在C#中其实就是一个指向方法的引用。在Javascript的Event系统中,Observer可以是那些支持EventListener协议的对象,但是也可以是函数。后者更常用。
如果让我现在设计一个Observer系统,我会用基于函数的实现,而不是基于类。我更倾向于让你注册一个函数指针,而不是一个Observer接口。
未来的Observer
事件系统和其他一些类似于Observer模式的系统在现在很流行。而且已经是老生常谈了。但是如果你用他们做一个稍微大点的应用,你会注意到一些问题。在你Observer中有很多代码看起来很像。像这样:
1、获取一些状态改变的通知。
2、机械地修改一些UI去响应新状态。
就这些,“哎呀,英雄的血已经变成7了?让我们把血条宽度变成70像素吧。”过一会,就会感觉很乏味。长久以来,计算机科学研究者和软件工程师都在想尽办法避免这种工作。他们的努力伴随着一串名字出现:“数据流编程”,“函数反射编程”等等。
虽然有一些成功,但是只限定在一些想音频处理和芯片设计这些场合里,“圣杯”还没有被找到。在此期间,一个不太显眼的进步在悄悄发生。许多现代的应用框架都在用“数据绑定”。
不像其他的一些极端的模型,数据绑定并不试图完全去掉无效代码,也不试图用一个巨大的数据图架构整个程序。它要做的是把一些繁重的工作自动化,像摆放UI元素或者计算UI属性去响应数据变化。
像其他描述性系统,数据绑定可能太慢太复杂不适合在游戏引擎内部使用。但是如果我看不到它在游戏的一些其他对性能要求不高的部分(像UI)中使用,我会感到很奇怪。
现在,经典的Observer依然静静得立在那里。的确,它没有那些充满“函数式”“反射”这类热点技术新鲜,但是它足够的简单并且好用。对我来说,这两者是一个解决方案最重要的两点。
原文连接:&/flyweight.html
Flyweight 模式
雾气散去,壮观的原始森林展现在我们面前。古老的铁杉,不尽其数。像一座塔尖林立的绿色教堂。在巨大的树干之间,你只有拉开一定距离才能辨认出这是一个巨大的森林。
这是作为游戏开发者梦想的世外桃源般的设计,而这种场景往往因为一个设计模式的运用而变为现实。这个模式的名字就是再低调不过的:Flyweight(被很多书根据意思翻译为享元)。
我可以用轻描淡写的几句话描述一个无边森林,但是在一个实时游戏中实现它就是另一回事了。当你看到一个森林满屏都是树木的时候,在图形程序员眼里,他们是数以百万计的面片,这些面片必须每隔1/60秒就被塞进GPU一次。
我们说有数千棵树,每棵树又是由数千个面片的细节组成。就算你有足够的内存存储这些描述数据,在渲染时,这些数据也要通过总线从CPU送达到GPU中。
每棵树都有如下的一些相关的数据:
* 用于定义树干、枝杈、绿色植物的网格面片。
* &树皮和树叶的贴图。
* 在森林中的位置和朝向。
* 用于微调的参数如大小、色彩等等,让每一棵树看起来有些不同。
如果用代码写出来,我们得到如下结构:
class Tree
Mesh mesh_;
Texture bark_;
Texture leaves_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
这将是很大的一堆数据,并且模型和贴图也特别大。整个森林的数据太大了,我们无法在一帧内将这些数据传送给GPU。幸运的是有一个传统的解决办法。
关键点是森林里虽然有很多树,但是这些树看起来长得都差不多。他们完全可以使用相同的模型和贴图。这就意味着所有树实例中有很大一部分是相同的。
我们可以试着把这些对象分成两份。首先,我们把所有树共有的数据搬到一个单独的类中:
class TreeModel
Mesh mesh_;
Texture bark_;
Texture leaves_;
游戏中只需要一个这样的对象,因为没有必要在内存中把相同的模型和贴图保存上千份。然后,游戏中每一棵树的实例保存一份对TreeModel的引用。这样,Tree这个类中就只剩下了这些个性化的数据了。
class Tree
TreeModel* model_;
Vector position_;
double height_;
double thickness_;
Color barkTint_;
Color leafTint_;
你可以把它想象成这样:
这样就很好的解决了在内存中的存储问题,但是对渲染于事无补。在森林被画到屏幕上之前,它必须要先进入GPU,我们需要把共享的资源展开成显卡能够识别的格式。
一千个实例
为了使我们传入GPU的数据量最小化,我们希望能够传入共享的数据——TreeModel一次。然后,分别把每一棵树的独有数据如位置、颜色、大小穿进去。最后我们告诉GPU,用这一个模型渲染所有的实例。
幸运的是,现在的图形API和显卡都已经支持这种方式了。具体的细节已经超出了本书的讨论范围,但是Direct3D和OpenGL都可以使用叫做instance rendering的技术实现。
这两个平台提供的API中,你需要提供两个数据流。第一个是将被渲染多次的公用数据块——我们例子中的模型和贴图。第二个数据流包含了一个实例列表以及它们的参数,它们可以区分每一次对第一个数据流的绘制结果。经过一次绘制,整个森林就长出来了。
&Flyweight模式
现在我们掌握了一个实实在在的例子,我可以来演练一下这个设计模式。Flyweight,就像它的名字一样,主要适用用于我们有大量的对象需要减肥的时候。
通过instance rendering,不再占用过多的内存,就像不再占用过多的总线传送时间一样。其基本思路是一致的。
这个模式通过将一个对象的数据分成两类来解决问题。第一类不是单个实例个性化的数据,他们可以在所有实例间共享。GOF把这类数据叫做固有属性,但我更喜欢称之为上下文无关。在这个例子里,就是那些树的几何数据和贴图。
剩下的数据就是外部属性,这部分每一个实例都是独有的。这个例子中,就是树的位置、大小、颜色。就像上面那段代码展示的那样,这个模式通过在每一个出现的对象中共享固定属性,来达到节省内存的目的。
到现在为止,这个方法就像是最基本的资源共享,很难称之为一个设计模式。这可能使因为在这个例子中,我们很容易地分辨出哪一部分应该共享:TreeModel
我开始发现这个模式不同寻常(其实很聪明),是在一些共享对象不那么好定义的例子中。在这些情况下,你会感觉像是在玩分身魔术。让我展示另一个例子。
游戏中我们还需要为这些树木制作生长的地方。它们可能是草丛、泥地、山峦、湖面、大河,或者你能想到的其他地形。我们制作的地形是基于分片的:大地的表面由大量小的片段组成。每一个片段都覆盖了一种类型的地面。
每一种地表类型都有一些影响游戏的属性:
* 决定玩家移动速度的移动消耗。
* 一个标明是否是水域的标识,它决定了这块地形可不可以过船。
* 一张渲染需要的贴图
由于游戏程序员对性能比较偏执,所以我们不可能把所有的这些属性保存在世界的每一个面片中。一个常用的方法是定义一个地形类型的枚举:
enum Terrain
TERRAIN_GRASS,
TERRAIN_HILL,
TERRAIN_RIVER
// Other terrains...
然后World类包含了大量这样的面片类型:
class World
Terrain tiles_[WIDTH][HEIGHT];
为了得到一个面片的相关信息,我们做这样的处理:
int World::getMovementCost(int x, int y)
switch (tiles_[x][y])
case TERRAIN_GRASS: return 1;
case TERRAIN_HILL:
case TERRAIN_RIVER: return 2;
// Other terrains...
bool World::isWater(int x, int y)
switch (tiles_[x][y])
case TERRAIN_GRASS:
case TERRAIN_HILL:
case TERRAIN_RIVER:
// Other terrains...
你明白,这是可以工作的,但是我觉得太丑了。我觉的移动消耗和是不是水域应该是与地形相关的数据,但这里并没有在代码中体现出来。更糟糕的是,一个地形相关的数据被分散到一堆其他函数里面。最理想的状况是把他们整合到一起,这个才是我们设计这些对象的目的。
如果我们有这样一个地形的定义就太棒了:
class Terrain
Terrain(int movementCost,
bool isWater,
Texture texture)
: movementCost_(movementCost),
isWater_(isWater),
texture_(texture)
int getMovementCost() const { return movementCost_; }
bool isWater() const { return isWater_; }
const Texture& getTexture() const { return texture_; }
int movementCost_;
bool isWater_;
Texture texture_;
但是如果所有地形片段都有一个实例,那会带来我们不想承受的负担。如果你仔细观察这个类,你会发现其实里面并没有每一个地形片段独有的属性。在Flyweight中,所有的这些属性都应该被划为“固有属性”或者“上下文无关”。
考虑到这些,就没有理由创建多个Terrain对象了。每一块青草地的片段都跟其余的没有什么区别。我们不再使用地形枚举或者地形对象构成的网格了,我们用指向Terrain对象的指针构成网格来代替:
class World
Terrain* tiles_[WIDTH][HEIGHT];
// Other stuff…
所有用相同地形的片段都指向同一个Terrain实例。
由于这些Terrain实例在多个场合中被用到,如果它使用的内存是动态申请的,那他们的生命周期管理就会比较复杂。在这里,我们就直接在World中存储了。
class World
: grassTerrain_(1, false, GRASS_TEXTURE),
hillTerrain_(3, false, HILL_TEXTURE),
riverTerrain_(2, true, RIVER_TEXTURE)
Terrain grassTerrain_;
Terrain hillTerrain_;
Terrain riverTerrain_;
// Other stuff...
然后我们可以这样把地面绘制出来:
void World::generateTerrain()
// Fill the ground with grass.
for (int x = 0; x & WIDTH; x++)
for (int y = 0; y & HEIGHT; y++)
// Sprinkle some hills.
if (random(10) == 0)
tiles_[x][y] = &hillTerrain_;
tiles_[x][y] = &grassTerrain_;
// Lay a river.
int x = random(WIDTH);
for (int y = 0; y & HEIGHT; y++) {
tiles_[x][y] = &riverTerrain_;
现在我们再也不用通过World的方法来访问Terrain的数据了,我们可以直接得到Terrain对象:
const Terrain& World::getTile(int x, int y) const
return *tiles_[x][y];
这样,World就不再跟Terrain的实现细节耦合在一起了。如果你想得到一个面片的属性,你可以直接从Terrain对象中得到:
int cost = world.getTile(2, 3).getMovementCost();
我们找回了使用真正对象的美好API,并且没有带来额外的消耗——一个指针一般不会比一个枚举更大。
我说“一般”是因为对性能精打细算的人,会耿直地比较这种方法和使用枚举哪个性能更优。通过指针引用Terrain毕竟经过了一次间接寻址。为了能获得一块地形的属性如移动消耗,你必须先经过数组中的指针找到Terrain对象,然后才能在那里得到移动消耗。这种指针寻址可能会有高速缓存不命中的情况,造成运行变慢。
我们经常说,做优化的黄金法则是先验证。当今的计算机硬件在性能方面已经足够复杂,以至于它不再受单一因素影响。在我对本章的测试结果中,Flyweight并没有比枚举方式存在更多消耗。事实上,Flyweight反而明显地快一些。不过,这取决于其他部分在内存中是如何分布的。
我能够确信Flyweight的使用,不会造成失控。他在不带来额外开销的前提下,给了你一个使用面向对象形式的可能。如果你发现,代码里有大量的枚举或者switch语句,可以考虑用这种模式来代替。如果你担心效率问题,至少要在你将代码改的无法维护之前做一下测试。
Command模式
原文连接:/command.html
Command模式是我最喜欢的模式之一。在我写过的大多数大型的程序,游戏和其他代码中,随处可见。正确地使用它,可以让一些丑陋的代码变得整洁。这样一个漂亮的模式,“Gof”(《设计模式-设计模式:可复用面向对象软件的基础》的四个作者)给出了一个极其抽象晦涩的描述:
将一个请求封装成一个对象,从而可以让用户使用不同的请求,队列或者日志请求去参数化客户端,并且支持撤销操作。
我想,大家一定都觉得这是一个糟糕的句子。首先,它混淆了一些他要建立的概念。脱离软件这个语境,一些单词可能有别的意思,例如“client”可以是客户的意思。通过本人考察发现,人是不能被“参数化”的。
其次,这个句子的其他部分,罗列了一些可能用到这个模式的场景。但是不好理解,除非你恰好遇到了这种情况。我给出Command模式一个更加精炼的定义:
一个Command就是一个具像化的方法调用。
当然,“精炼”往往也意味着“强行简化”,所以这个解释也未必有多大进步。让我稍微解释一下。“具体”,在这里有一个别开生面的意思“实例化”。另一个解释是“变成一级函数(&函数如同整数和字符串等基本类型一样,作为参数传递、返回值 和 绑定变量名)”。
总而言之,两种解释的意思都是把一些操作封装到一块数据(也就是一个对象)中。你可以将这个对象存到一个变量中,传递到一个函数中,等等。所以,我把Command模式描述为“具象化的方法调用”,意思就是将一个方法封装成一个具体的对象。
这些听起来像是“回调”,“一级函数”,“函数指针”,“闭包”或者“偏应用函数”这些概念,这取决于你使用什么样的编程语言。这些概念确实大致相同。GOF最后说道:
命令模式是用面向对象的方式取代回调。
用这句话来诠释Command模式可能比他们选的那句定义更恰当。
当然,这些都是抽象的朦胧的。我想加入一些具体的例子,去补充这些解释。从现在开始,这些例子都巧妙的使用了Command模式。
每一个游戏都有一部分代码是读取用户输入——按下按钮,键盘事件,点击鼠标等等。记录下这些输入,并将其转化为游戏中有意义的行为。
有一种木逼的实现像这样:
void InputHandler::handleInput()
if (isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
这个函数在游戏循环中,每帧被调用一次。我确信你能够理解这段代码是干啥的。如果用户输入跟游戏中的行为是定死的,这段代码可以很好地工作。但是很多游戏允许用户自己配置每一个按钮的功能。
为了能支持这种配置,我们需要把这种对jump 和 fireGun这些函数的直接调用,变成一种能够置换的方式。“置换”这个词让我们想到了分配一个变量。因此我们需要一个对象表示一个游戏中的操作。这样,Command模式就来了。
我们定义一个基类,他代表一个可触发的游戏命令:
class Command
virtual ~Command() {}
virtual void execute() = 0;
然后我们为每种不同的游戏操作定义一种子类。
class JumpCommand : public Command
virtual void execute() { jump(); }
class FireCommand : public Command
virtual void execute() { fireGun(); }
// You get the idea...
在InputHandler类中,我们为每一个按键保存了一个指向Command对象的指针。
class InputHandler
void handleInput();
// Methods to bind commands...
Command* buttonX_;
Command* buttonY_;
Command* buttonA_;
Command* buttonB_;
现在输入处理只是一个指向那些的代理:
void InputHandler::handleInput()
if (isPressed(BUTTON_X)) buttonX_-&execute();
else if (isPressed(BUTTON_Y)) buttonY_-&execute();
else if (isPressed(BUTTON_A)) buttonA_-&execute();
else if (isPressed(BUTTON_B)) buttonB_-&execute();
原来输入模块直接调用函数,现在改为间接:
这是Command模式的一个简单例子,如果你觉得已经掌握了Command的真谛,就把这一章剩余的部分看作是利息吧。
前面例子中,我们定义的Command可以运行,但是作用相当有限。问题是他们假定有一些全局函数像jump(), fireGun()等等,这些函数能够立刻得到主角的动作控制器,然后像控制木偶一样控制主角。
这些假定大大地限制了这些Command的使用范围。也就是只有主角能够使用JumpCommand跳跃。让我们打破这层限制。我们不让调用的函数自己去找控制对象,而是将控制对象作为参数传给它。
class Command
virtual ~Command() {}
virtual void execute(GameActor& actor) = 0;
这里,GameActor 是我们的游戏对象类,代表了游戏世界中的一个演员。我们将它传递给execute函数,这样那些Command子类就可以调用相应的方法了。就像这样:
class JumpCommand : public Command
virtual void execute(GameActor& actor)
actor.jump();
如果这个actor是玩家主角的引用,这能根据玩家的输入准确的控制主角,这样就达到了跟第一个例子同样的效果。但在Command和演员之间插入了层,这让我们有了一个更加灵活的能力:我们可以通过改变actor参数,让玩家控制游戏世界中所有的演员。
事实上,这不是通常意义上的特性,但是经常会被时不时的用到。到目前为止,我们只留意了玩家控制主角,但是游戏世界的其他演员呢?他们是被游戏AI驱动的。我们可以使用同样的Command模式,作为AI引擎和演员之间的接口。AI代码只需要简单地抛出命令对象就可以了。
将组织Command的AI代码与控制演员的代码解耦,给了我们极大的灵活性。我们可以在不同的演员身上使用不同的AI,或者可以通过混合搭配不同的AI组合成不同种类的行为方式。如果想加入更牛逼的对手,只需要加入更牛逼的AI去生成一堆Command就可以实现。甚至我们可以为玩家控制的主角加上AI,这样可以像演示demo一样让游戏自动运行。
通过编写控制演员的Command,我们去掉了直接调用函数带来的紧耦合。这样我们就可以理解为通过一连串命令来控制演员了。
一些代码(输入控制或者AI)产生Command,并将他们做成一个命令流。另外一些代码(分发器或者演员本身)使用这些Commands。把这个命令流放在中间,就完成了对命令产生者和执行者的解耦。
撤销和重做
最后一个例子是这个模式最著名的一个用法。如果一个Command对象能做一件事,那离他能撤销这件事就不远了。撤销可以用在一些策略游戏中,你可以撤销掉你不喜欢的操作。在制作游戏的工具中,撤销也是一个非常必要的功能。一个最容易让你的策划恨你的方法,就是给他们提供一个不能撤销的关卡编辑器。
没有Command模式,实现撤销是非常困难的。有了他,就成了小菜一碟了。比如说,我们要做一个单人回合制游戏,我们会希望加入撤销操作,这样玩家就可以把注意力放在策略上,而不用做过多的猜测。
我们已经使用Command,非常方便地将用户输入的处理抽象化,因此玩家每一步操作都已经封装在Command之中了。例如,移动一个单位应该这么写:
class MoveUnitCommand : public Command
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
virtual void execute()
unit_-&moveTo(x_, y_);
Unit* unit_;
int x_, y_;
注意这里跟前面的写法有一个小小的不同。在上一个例子中,我们像把演员从Command中抽离出来。而这个例子,我们希望将Command和要移动的单位绑定到一起。每一个Command实例,不再是“移动某个物体”这样的,可以被用在不同物体上的通用操作;它变成了一个游戏回合中一个确定的步骤。
这里强调另一种Command模式的实现方法,在一些情况下,就像我们前两个例子,一个Command就是一个可以被复用的的对象,它代表了一个操作。前面我们的输入处理是在一个单独的Command对象中进行的,任何时候,一旦玩家按了正确的按键,它的execute()函数就会被调用。
在这里,这些Command就更加具体了。他们代表了在某一个特定的时间做了某件事。也就是说,每一次用户选择一次移动,输入处理代码就会产生一个Command实例。就像这样:
Command* handleInput()
Unit* unit = getSelectedUnit();
if (isPressed(BUTTON_UP)) {
// Move the unit up one.
int destY = unit-&y() - 1;
return new MoveUnitCommand(unit, unit-&x(), destY);
if (isPressed(BUTTON_DOWN)) {
// Move the unit down one.
int destY = unit-&y() + 1;
return new MoveUnitCommand(unit, unit-&x(), destY);
// Other moves...
return NULL;
这些Command 只使用一次的优点马上就会体现出来。为了使Command能够撤销,我们需要定义另外一个需要各个Command实现的方法:
class Command
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
undo()方法会恢复被execute()方法改变过的游戏状态。这里是我们前面定义的移动Command加入了对撤销的支持:
class MoveUnitCommand : public Command
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit),
xBefore_(0),
yBefore_(0),
virtual void execute()
// Remember the unit's position before the move
// so we can restore it.
xBefore_ = unit_-&x();
yBefore_ = unit_-&y();
unit_-&moveTo(x_, y_);
virtual void undo()
unit_-&moveTo(xBefore_, yBefore_);
Unit* unit_;
int xBefore_, yBefore_;
int x_, y_;
注意,我们在类里面添加了一些属性。一个单位移动后,它会忘了他原来的位置。如果我们想要撤销这次移动,我们必须自己记住这个单位原来的位置。这就是xBefore_和yBefore_所做的。
为了使玩家能够撤销移动操作,我们保存了最后一个执行的Command。当玩家按下Control + Z,我们就调用这个Command的undo()函数。(当他们已经撤销了,又要重做,我们只需要重新执行一下Command的execute函数就可以了。)
要支持多步撤销也不难。我们只需要用一个Command列表来替换原来的最后一个Command就可以,外加一个指向“当前”命令的引用。每当一个Command被执行,我们把它添加到列表里面,并将“当前”的引用指向它。
当玩家撤销,我们就撤销当前的Command并将当前的引用向后移动一位。当他们重做,我们就向前移动指针,并且执行Command。当他们在撤销后选择了一步新的操作,那当前Command之后的所有Command就会被销毁。
当我第一次在一个关卡编辑器里面实现后,我感觉自己是个天才。我对它如此的简单明了,如此的运行顺畅感到吃惊。Command规定了所有的数据修改都通过一个个Command进行。但是一旦你确定了这个规则,剩下的就很容易了。
&优雅与功能弱化
之前,我说Command与一级函数或者闭包非常像,但是前面每一个例子,我都使用了类来定义。如果你对函数式编程比较熟悉,你可能会疑惑,说好的函数呢?
我用这种方式写例子,是因为C++对一级函数支持很弱。函数指针没有状态,仿函数很奇葩,并且同样需要定义类。C++11的兰姆达表达式用起来很不顺手,原因是内存管理需要手动进行。
这些不是说你在其他语言中不能在Command模式中使用函数。如果你使用的语言有真正的闭包,一定用起来!某种意义上说,Command模式就是一个没有闭包的语言模仿闭包的方式。
例如,如果我们用Javascript开发游戏,我们可以像这样创建一个单位的Command:
function makeMoveUnitCommand(unit, x, y) {
// This function here is the command object:
return function() {
unit.moveTo(x, y);
我们可以使用一对闭包来添加对撤销的支持。
function makeMoveUnitCommand(unit, x, y) {
var xBefore, yB
execute: function() {
xBefore = unit.x();
yBefore = unit.y();
unit.moveTo(x, y);
undo: function() {
unit.moveTo(xBefore, yBefore);
如果你习惯这种函数式编程,这样做就显得很自然。如果不习惯,我希望这篇文章能对你有所帮助。对我来说,Command模式的用处恰好体现了函数式编程在解决某些问题上的优势。
TA的最新馆藏

我要回帖

更多关于 游戏常用的设计模式 的文章

 

随机推荐