原文链接:http://gameprogrammingpatterns.com/prototype.html
Prototype(原型)
我第一次听说“Prototype”这个词是在设计模式中。今天我发现大家都在说这个词,但是已经不是在谈设计模式了。我们会在这里展开讨论,但是我也会给你介绍一些别的有趣的地方,以及一些其他的衍生概念。但让我们先领略一遍经典的内容。
原型设计模式、
假设我们在做一款类似《圣铠传奇》的游戏。在英雄的周围会产生很多生物和魔鬼,企图分享他的肉。这些恶心的晚餐伙伴是通过“卵”进入场景的,并且不同的敌人用的是不同的卵。 在这个例子中,我们为每一种怪物创建一个类-Ghost,Demon,Sorcerer等等,像这样:
1 2 3 4 5 6 7 8 |
class Monster { // Stuff... }; class Ghost : public Monster {}; class Demon : public Monster {}; class Sorcerer : public Monster {}; |
一个卵的构造函数实例化一个特定的怪物类型。为了支持游戏中所有的怪物,我们简单粗暴得为每一种怪物写一个卵类,构成了这个并行的类结构。
像这样实现他们:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class Spawner { public: virtual ~Spawner() {} virtual Monster* spawnMonster() = 0; }; class GhostSpawner : public Spawner { public: virtual Monster* spawnMonster() { return new Ghost(); } }; class DemonSpawner : public Spawner { public: virtual Monster* spawnMonster() { return new Demon(); } }; |
除非你是按代码行数付费的,否则堆砌这么多垃圾可不时间令人预约的事。大量的类,大量的冗余代码,大量的副本,大量的复制,大量的重复…
原型模式提供了一个解决方案。主要思想是一个对象可以产生其他跟他相似的对象。如果你有一个Ghost,你可以用它产生更多的Ghost。如果你有一个Demon,你可以产生其他Demon。任何一个怪物都可以被当作一个原型怪物,用来产生其他的分身。
为了实现这个,我们提供一个基类,Monster,和一个虚函数clone():
1 2 3 4 5 6 7 8 |
class Monster { public: virtual ~Monster() {} virtual Monster* clone() = 0; // Other stuff... }; |
每一个怪物的子类都可以实现一个方法,返回一个跟自己类型状态一模一样的新对象。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Ghost : public Monster { public: Ghost(int health, int speed) : health_(health), speed_(speed) {} virtual Monster* clone() { return new Ghost(health_, speed_); } private: int health_; int speed_; }; |
一旦所有的怪物都支持了这个方法,我们不再需要为每一个怪物类提供一个卵类,我们只需要提供一个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Spawner { public: Spawner(Monster* prototype) : prototype_(prototype) {} Monster* spawnMonster() { return prototype_->clone(); } private: Monster* prototype_; }; |
它内部会保存一个怪物,被隐藏起来。怪物的主要目的是用它作为一个模版产生出更多一样的怪物来。就像蜂王从来不离开蜂巢。
为了创建一个ghost卵,我们创建一个ghost对象最为原型,然后创建一个卵,保存这个原型:
1 2 |
Monster* ghostPrototype = new Ghost(15, 3); Spawner* ghostSpawner = new Spawner(ghostPrototype); |
这个模式的优雅之处在于它不只是克隆原型类,它还可以克隆怪物的状态。这就意味着我们可以通过创建相应的原型精灵,来产生快精灵、弱精灵、慢精灵…
我发现了这个模式的优雅和给人惊醒之处,我没有企图自己提出这个模式,但我也没法想象如果不知道它怎么办。
效果如何
现在,我不需要为每一种怪物产生一个“卵”了,这很好。但是我必须在每一个怪物类中实现clone方法。这其实跟那些“卵”的代码数量差不多。 当你坐下来试着写这些clone()方法时,依然会有一写讨厌的语义坑。我们是做深度克隆还是浅克隆?换一种说法,如果怪物拿了一个叉子,那在克隆怪物的时候需要克隆叉子吗? 另外,看起来这个模式并没有在这个人为制造的问题中省去多少代码,而且这本身就是一个人为制造的问题。我们必须意识到,我们要为每一种怪物写一个类,现在这种方法已经不被大多数游戏工程师采用了。 我们大多数人都会感受到,像这样大量的继承关系管理起来很痛苦,这就是我们为什么要用Component 和 TypeObject模式去组合成不同的类型,而不是只封装在在私有的类中。
卵函数
即时我们用不同的为每一种怪物定义不同的类,我们也有其他方法来处理这些猫腻。我们不为每一种怪物定义不同的卵类,我们定义卵函数:
1 2 3 4 |
Monster* spawnGhost() { return new Ghost(); } |
这样比用一个整类来创建怪物要轻量的多。然后卵类就可以简单地存储一个函数指针:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
typedef Monster* (*SpawnCallback)(); class Spawner { public: Spawner(SpawnCallback spawn) : spawn_(spawn) {} Monster* spawnMonster() { return spawn_(); } private: SpawnCallback spawn_; }; |
这样,创建一个Ghost,你可以这样做:
1 |
Spawner* ghostSpawner = new Spawner(spawnGhost); |
模版
现在,大部分C++开发者都很熟悉模版了,我们的卵类需要根据不同类型生成相应的实例,但是我们不希望为每一种类写代码。一个很自然的解决方案就是用模版支持的类型参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Spawner { public: virtual ~Spawner() {} virtual Monster* spawnMonster() = 0; }; template <class T> class SpawnerFor : public Spawner { public: virtual Monster* spawnMonster() { return new T(); } }; |
这样使用:
1 |
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
在一些场景下,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中如何定义类型和创建对象。我们从一个构造函数开始:
1 2 3 4 |
function Weapon(range, damage) { this.range = range; this.damage = damage; } |
1 |
这几行代码创建了一个对象,并且初始化了数据。你可以这样调用: |
1 |
var sword = new Weapon(10, 16); |
这个new 操作符用一个指向一个空对象的this调用了Weapon()函数体。函数体为其添加了一些属性,然后自动返回一个被填充的对象。 new还做了一些其他的事情。当他创建一个空白对象后,绑定了一个托管的原型对象。你可以直接用Wapon.prototype访问这个对象。 既然在构造函数中添加了状态,为了添加方法,你往往需要为原型对象添加方法。就像这样:
1 2 3 4 5 6 7 |
Weapon.prototype.attack = function(target) { if (distanceTo(target) > this.range) { console.log("Out of range!"); } else { target.health -= this.damage; } } |
这为Weapon的原型添加了一个attack属性,这个属性的值是一个函数。从现在开始,每一个通过new Weapon()返回的对象,都托管了Weapon.prototype,你现在可以这样调用sword.attack()这个函数。它看起来是这样的:
让我们回顾一下: 1、创建对象的方法是通过“new”操作符调用一个代表一种类型的构造函数。 2、状态储存在实例本身中。 3、行为通过一层间接的托管到原型中,原型存储了很多方法,这些方法是通过这个共造函数构造的对象共用的。 你一定觉得我疯了,但这跟我前面描述的类很像。你可以在Javascript中写基于原型的代码(除了克隆),但是语言本身的语义鼓励你使用基于类的特性。 我个人认为,这是很好的。就像我前面说的那样,我发现在基于原型的语言上加倍下注,会让代码很难理解,所以我希望JavaScript的核心机制更偏向于基于类。
数据模型的原型
我在喋喋不休的说为啥不喜欢原型,这让这一章读起来很蛋疼。我想这本书应该是喜剧而不是悲剧,所以,我们用一个我认为原型,更确切的说是托管更有用的使用场景来结束这一章吧。 如果你比较一下一个游戏中代码的数量和数据的数量,你会发现数据的比例从开始就一直稳步的增长。早期的游戏几乎全用程序实现,这样才能适应磁盘和老游戏机。今天的游戏,代码只是一个驱动游戏的引擎,而游戏的主体是用数据定义的。 这很好,但是把大量的游戏内容推进数据文件,并没有很好地解决大项目的组织问题。从某种意义上讲,更增大的复杂度。我们用编程语言的原因正式因为它提供了管理复杂度的工具。 我们不去复制粘贴代码到十个地方,我们把它写进一个函数然后通过名字调用。我们不去把一个方法拷贝到一堆类中,我们可以把它写进一个单独的类中,让那一堆类去继承它。 当你的游戏数据到达一定的规模,你就开始希望有相似的特性了。数据模型是一个很深的话题,我不想在这里讨论,但是我想抛砖引玉,让你考虑一下自己游戏中使用原型和托管去重用数据。 让我们为前面提到的游戏定义一个数据模型。游戏设计师需要在文件中为一些怪物和道具定义属性。 一个常用的方式是使用JSON。数据节点是基本的地图、属性包,或者其他的一些内容。因为没有哪个程序员愿意去开发一个已经有的游戏。 所以,哥布林在游戏中会被定义成这个样子:
1 2 3 4 5 6 7 |
{ "name": "goblin grunt", "minHealth": 20, "maxHealth": 30, "resists": ["cold", "poison"], "weaknesses": ["fire", "light"] } |
这非常简单明了,即时最不擅长文本的设计者也可以掌握。所以你就扔进了一堆大哥布林家族的兄弟姐妹进去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "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数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
{ "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和其他特有的物品。他们经常要重用一些游戏中常见的属性,原型委托就是一个很好的定义方式。那柄魔法断头剑,其实就是一柄有攻击加成的长剑,我们就可以直接扩展成:
1 2 3 4 5 |
{ "name": "Sword of Head-Detaching", "prototype": "longsword", "damageBonus": "20" } |
你游戏引擎的数据模型有一个额外的能力,就是能够让设计师在你游戏世界中的武器和动物中加入一些变数。这些变数会丰富游戏,给玩家惊喜。
1 |
1 |
1 |
1 |
1 |