原型模式 Prototype
遊戲設計模式 Game Programming PatternsDesign Patterns Revisited
我第一次聽到“原型”這個詞是在設計模式中。 如今,似乎每個人都在用這個詞,但他們討論的實際上不是設計模式。 我們會討論他們所說的原型,也會討論術語“原型”的有趣之處,和其背後的理念。 但首先,讓我們重訪傳統的設計模式。
原型設計模式
假設我們要用《聖鎧傳說》的風格做款遊戲。 野獸和惡魔圍繞着英雄,爭着要吃他的血肉。 這些可怖的同行者通過“生產者”進入這片區域,每種敵人有不同的生產者。
在這個例子中,假設我們遊戲中每種怪物都有不同的類——Ghost
,Demon
,Sorcerer
等等,像這樣:
class Monster
{
// 代碼……
};
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};
生產者構造特定種類怪物的實例。 爲了在遊戲中支持每種怪物,我們可以用一種暴力的實現方法, 讓每個怪物類都有生產者類,得到平行的類結構:
實現後看起來像是這樣:
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();
}
};
// 你知道思路了……
除非你會根據代碼量來獲得工資, 否則將這些焊在一起很明顯不是好方法。 衆多類,衆多引用,衆多冗餘,衆多副本,衆多重複自我……
原型模式提供了一個解決方案。 關鍵思路是一個對象可以產出與它自己相近的對象。 如果你有一個惡靈,你可以製造更多惡靈。 如果你有一個惡魔,你可以製造其他惡魔。 任何怪物都可以被視爲原型怪物,產出其他版本的自己。
爲了實現這個功能,我們給基類Monster
添加一個抽象方法clone()
:
class Monster
{
public:
virtual ~Monster() {}
virtual Monster* clone() = 0;
// 其他代碼……
};
每個怪獸子類提供一個特定實現,返回與它自己的類和狀態都完全一樣的新對象。舉個例子:
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_;
};
一旦我們所有的怪物都支持這個, 我們不再需要爲每個怪物類創建生產者類。我們只需定義一個類:
class Spawner
{
public:
Spawner(Monster* prototype)
: prototype_(prototype)
{}
Monster* spawnMonster()
{
return prototype_->clone();
}
private:
Monster* prototype_;
};
它內部存有一個怪物,一個隱藏的怪物, 它唯一的任務就是被生產者當做模板,去產生更多一樣的怪物, 有點像一個從來不離開巢穴的蜂后。
爲了得到惡靈生產者,我們創建一個惡靈的原型實例,然後創建擁有這個實例的生產者:
Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);
這個模式的靈巧之處在於它不但拷貝原型的類,也拷貝它的狀態。 這就意味着我們可以創建一個生產者,生產快速鬼魂,虛弱鬼魂,慢速鬼魂,而只需創建一個合適的原型鬼魂。
我在這個模式中找到了一些既優雅又令人驚歎的東西。 我無法想象自己是如何創造出它們的,但我更無法想象不知道這些東西的自己該如何是好。
效果如何?
好吧,我們不需要爲每個怪物創建單獨的生產者類,那很好。
但我們確實需要在每個怪物類中實現clone()
。
這和使用生產者方法比起來也沒節約多少代碼量。
當你坐下來試着寫一個正確的clone()
,會遇見令人不快的語義漏洞。
做深層拷貝還是淺層拷貝呢?換言之,如果惡魔拿着叉子,克隆惡魔也要克隆叉子嗎?
同時,這看上去沒減少已存問題上的代碼, 事實上還增添了些人爲的問題。 我們需要將每個怪物有獨立的類作爲前提條件。 這絕對不是當今大多數遊戲引擎運作的方法。
我們中大部分痛苦地學到,這樣龐雜的類層次管理起來很痛苦, 那就是我們爲什麼用組件模式和類型對象爲不同的實體建模,這樣無需一一建構自己的類。
生產函數
哪怕我們確實需要爲每個怪物構建不同的類,這裏還有其他的實現方法。 不是使用爲每個怪物建立分離的生產者類,我們可以創建生產函數,就像這樣:
Monster* spawnGhost()
{
return new Ghost();
}
這比構建怪獸生產者類更簡潔。生產者類只需簡單地存儲一個函數指針:
typedef Monster* (*SpawnCallback)();
class Spawner
{
public:
Spawner(SpawnCallback spawn)
: spawn_(spawn)
{}
Monster* spawnMonster()
{
return spawn_();
}
private:
SpawnCallback spawn_;
};
爲了給惡靈構建生產者,你需要做:
Spawner* ghostSpawner = new Spawner(spawnGhost);
模板
如今,大多數C++開發者已然熟悉模板了。 生產者類需要爲某類怪物構建實例,但是我們不想硬編碼是哪類怪物。 自然的解決方案是將它作爲模板中的類型參數:
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
template <class T>
class SpawnerFor : public Spawner
{
public:
virtual Monster* spawnMonster() { return new T(); }
};
像這樣使用它:
Spawner* ghostSpawner = new SpawnerFor<Ghost>();
第一公民類型
前面的兩個解決方案使用類完成了需求,Spawner
使用類型進行參數化。
在C++中,類型不是第一公民,所以需要一些改動。
如果你使用JavaScript,Python,或者Ruby這樣的動態類型語言,
它們的類是可以傳遞的對象,你可以用更直接的辦法解決這個問題。
當你完成一個生產者,直接向它傳遞要構建的怪物類——那個代表了怪物類的運行時對象。超容易的,對吧。
綜上所述,老實說,我不能說找到了一種情景,而在這個情景下,原型設計模式是最好的方案。 也許你的體驗有所不同,但現在把它擱到一邊,我們討論點別的:將原型作爲一種語言範式。
原型語言範式
很多人認爲“面向對象編程”和“類”是同義詞。 OOP的定義卻讓人感覺正好相反, 毫無疑問,OOP讓你定義“對象”,將數據和代碼綁定在一起。 與C這樣的結構化語言相比,與Scheme這樣的函數語言相比, OOP的特性是它將狀態和行爲緊緊地綁在一起。
你也許認爲類是完成這個的唯一方式方法, 但是包括Dave Ungar和Randall Smith的一大堆傢伙一直在拼命區分OOP和類。 他們在80年代創建了一種叫做Self的語言。它不用類實現了OOP。
Self語言
就單純意義而言,Self比基於類的語言更加面向對象。 我們認爲OOP將狀態和行爲綁在一起,但是基於類的語言實際將狀態和行爲割裂開來。
拿你最喜歡的基於類的語言的語法來說。 爲了接觸對象中的一些狀態,你需要在實例的內存中查詢。狀態包含在實例中。
但是,爲了調用方法,你需要找到實例的類, 然後在那裏調用方法。行爲包含在類中。 獲得方法總需要通過中間層,這意味着字段和方法是不同的。
Self結束了這種分歧。無論你要找啥,都只需在對象中找。 實例同時包含狀態和行爲。你可以構建擁有完全獨特方法的對象。
如果這就是Self語言的全部,那它將很難使用。 基於類的語言中的繼承,不管有多少缺陷,總歸提供了有用的機制來重用代碼,避免重複。 爲了不使用類而實現一些類似的功能,Self語言加入了委託。
如果要在對象中尋找字段或者調用方法,首先在對象內部查找。 如果能找到,那就成了。如果找不到,在對象的父對象中尋找。 這裏的父類僅僅是一個對其他對象的引用。 當我們沒能在第一個對象中找到屬性,我們嘗試它的父對象,然後父類的父對象,繼續下去直到找到或者沒有父對象爲止。 換言之,失敗的查找被委託給對象的父對象。
父對象讓我們在不同對象間重用行爲(還有狀態!),這樣就完成了類的公用功能。
類做的另一個關鍵事情就是給出了創建實例的方法。
當你需要新的某物,你可以直接new Thingamabob()
,或者隨便什麼你喜歡的表達法。
類是實例的生產工廠。
不用類,我們怎樣創建新的實例? 特別地,我們如何創建一堆有共同點的新東西? 就像這個設計模式,在Self中,達到這點的方式是使用克隆。
在Self語言中,就好像每個對象都自動支持原型設計模式。 任何對象都能被克隆。爲了獲得一堆相似的對象,你:
- 將對象塑造成你想要的狀態。你可以直接克隆系統內建的基本
Object
,然後向其中添加字段和方法。 - 克隆它來產出……額……隨你想要多少就克隆多少個對象。
無需煩擾自己實現clone()
;我們就實現了優雅的原型模式,原型被內建在系統中。
這個系統美妙,靈巧,而且小巧, 一聽說它,我就開始創建一個基於原型的語言來進一步學習。
它的實際效果如何?
能使用純粹基於原型的語言讓我很興奮,但是當我真正上手時, 我發現了一個令人不快的事實:用它編程沒那麼有趣。
是的,語言本身很容易實現,那是因爲它把複雜度甩給了用戶。 一旦開始試着使用這語言,我發現我想念基於類語言中的層次結構。 最終,在構建語言缺失的庫概念時,我放棄了。
鑑於我之前的經驗都來自基於類的語言,因此我的頭腦可能已經固定在它的範式上了。 但是直覺上,我認爲大部分人還是喜歡有清晰定義的“事物”。
除去基於類的語言自身的成功以外,看看有多少遊戲用類建模描述玩家角色,以及不同的敵人、物品、技能。 不是遊戲中的每個怪物都與衆不同,你不會看到“洞穴人和哥布林還有雪混合在一起”這樣的怪物。
原型是非常酷的範式,我希望有更多人瞭解它, 但我很慶幸不必天天用它編程。 完全皈依原型的代碼是一團漿糊,難以閱讀和使用。
JavaScript又怎麼樣呢?
好吧,如果基於原型的語言不那麼友好,怎麼解釋JavaScript呢? 這是一個有原型的語言,每天被數百萬人使用。運行JavaScript的機器數量超過了地球上其他所有的語言。
Brendan Eich,JavaScript的締造者, 從Self語言中直接汲取靈感,很多JavaScript的語義都是基於原型的。 每個對象都有屬性的集合,包含字段和“方法”(事實上只是存儲爲字段的函數)。 A對象可以擁有B對象,B對象被稱爲A對象的“原型”, 如果A對象的字段獲取失敗就會委託給B對象。
但除那以外,我相信在實踐中,JavaScript更像是基於類的而不是基於原型的語言。 JavaScript與Self有所偏離,其中一個要點是除去了基於原型語言的核心操作“克隆”。
在JavaScript中沒有方法來克隆一個對象。
最接近的方法是Object.create()
,允許你創建新對象作爲現有對象的委託。
這個方法在ECMAScript5中才添加,而那已是JavaScript出現後的第十四年了。
相對於克隆,讓我帶你參觀一下JavaScript中定義類和創建對象的經典方法。
我們從構造器函數開始:
function Weapon(range, damage) {
this.range = range;
this.damage = damage;
}
這創建了一個新對象,初始化了它的字段。你像這樣引入它:
var sword = new Weapon(10, 16);
這裏的new
調用Weapon()
函數,而this
綁定在新的空對象上。
函數爲新對象添加了一系列字段,然後返回填滿的對象。
new
也爲你做了另外一件事。
當它創建那個新的空對象時,它將空對象的委託和一個原型對象連接起來。
你可以用Weapon.prototype
來獲得原型對象。
屬性是添加到構造器中的,而定義行爲通常是通過向原型對象添加方法。就像這樣:
Weapon.prototype.attack = function(target) {
if (distanceTo(target) > this.range) {
console.log("Out of range!");
} else {
target.health -= this.damage;
}
}
這給武器原型添加了attack
屬性,其值是一個函數。
由於new Weapon()
返回的每一個對象都有給Weapon.prototype
的委託,
你現在可以通過調用sword.attack()
來調用那個函數。
看上去像是這樣:
讓我們複習一下:
- 通過“new”操作創建對象,該操作引入代表類型的對象——構造器函數。
- 狀態存儲在實例中。
- 行爲通過間接層——原型的委託——被存儲在獨立的對象中,代表了一系列特定類型對象的共享方法。
說我瘋了吧,但這聽起來很像是我之前描述的類。 你可以在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"]
}
現在,如果這是代碼,我們會聞到了臭味。 在實體間有很多的重複,訓練優良的程序員討厭重複。 它浪費了空間,消耗了作者更多時間。 你需要仔細閱讀代碼才知道這些數據是不是相同的。 這難以維護。 如果我們決定讓所有哥布林變強,需要記得將三個哥布林都更新一遍。糟糕糟糕糟糕。
如果這是代碼,我們會爲“哥布林”構建抽象,並在三個哥布林類型中重用。 但是無能的JSON沒法這麼做。所以讓我們把它做得更加巧妙些。
我們可以爲對象添加"prototype"
字段,記錄委託對象的名字。
如果在此對象內沒找到一個字段,那就去委託對象中查找。
這樣,我們可以簡化我們的哥布林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"]
}
由於弓箭手和術士都將grunt作爲原型,我們就不需要在它們中重複血量,防禦和弱點。 我們爲數據模型增加的邏輯超級簡單——基本的單一委託——但已經成功擺脫了一堆冗餘。
有趣的事情是,我們沒有更進一步,把哥布林委託的抽象原型設置成“基本哥布林”。 相反,我們選擇了最簡單的哥布林,然後委託給它。
在基於原型的系統中,對象可以克隆產生新對象是很自然的, 我認爲在這裏也一樣自然。這特別適合記錄那些只有一處不同的實體的數據。
想想Boss和其他獨特的事物,它們通常是更加常見事物的重新定義, 原型委託是定義它們的好方法。 斷頭魔劍,就是一把擁有加成的長劍,可以像下面這樣表示:
{
"name": "Sword of Head-Detaching",
"prototype": "longsword",
"damageBonus": "20"
}
只需在遊戲引擎上多花點時間,你就能讓設計者更加方便地添加不同的武器和怪物,而增加的這些豐富度能夠取悅玩家。