Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

原型模式 (Prototype)

用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

— GoF 定义

动机

我第一次听到“原型“这个词是在《设计模式》里。今天似乎每个人都在说这个词,但结果发现他们说的不是那个设计模式。我们这里会覆盖它,但我还会向你展示“原型“这个词及其背后的概念出现在其他更有趣的地方。但首先,让我们回顾一下原始的模式。

译注:我说“原始“可不是随随便便的。《设计模式》引用了 Ivan Sutherland 在 1963 年的传奇项目 Sketchpad 作为这个模式最早的实例之一。当其他人都在听 Dylan 和 Beatles 的时候,Sutherland 正忙于——你知道的——发明 CAD、交互式图形和面向对象编程的基本概念。看看演示视频,准备好被震撼吧。


原型设计模式

假设我们正在做一个类似 Gauntlet 风格的游戏。各种生物和恶魔蜂拥而至英雄周围,都想分一块他的肉。这些不受欢迎的晚餐同伴通过“生成器“进入竞技场,每种敌人有一种不同的生成器。

为了这个例子的方便,假设我们为游戏中的每种怪物都有不同的类——GhostDemonSorcerer 等:

class Monster {
  // 诸如此类……
};

class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

生成器构造特定类型怪物的实例。为了支持游戏中的每种怪物,我们可以用暴力方式——为每种怪物类做一个生成器类,导致一个并行的类层级:

译注:我挖了一本落满灰尘的 UML 书来画这张图。空心三角箭头 表示“继承自“。

实现起来是这样:

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_;
};

一旦所有怪物都支持了 clone(),我们就不再需要为每种怪物类单独写生成器类了。取而代之的是一个统一的生成器:

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(); }
};

译注:我不确定是 C++ 程序员学会了喜欢模板,还是模板直接把一些人吓得彻底离开了 C++。不管怎样,今天我看到的所有用 C++ 的人都在用模板。

使用起来像这样:

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

译注:这里的基类 Spawner 是为了让不关心生成器具体创建哪种怪物的代码可以统一使用它,通过指向 Monster 的指针来工作。如果我们只有 SpawnerFor<T> 类,那这个模板的所有实例化之间就没有一个共同的超类型,任何处理任意怪物类型的生成器的代码本身也需要带模板参数。

一等公民类型

前两种方案解决了“需要有一个以类型为参数的 Spawner 类“的问题。在 C++ 中,类型通常不是一等公民,所以需要一些花招。如果你使用的是动态类型语言——如 JavaScript、Python 或 Ruby——类本身就是可以传来传去的普通对象,解决起来就直接多了。

创建生成器时,只需传入它应该构造的怪物类——那个代表怪物类的运行时实际对象。简单得像派一样。

有了所有这些方案,老实说我没发现哪个场景下我认为原型设计模式是最佳答案。也许你的体验会不同,但现在我们把它放一边,来谈点别的:原型作为一种语言范式

译注:某种程度上,类型对象模式是缺乏一等公民类型的另一种变通方案。不过,即使在一等公民类型的语言中,这个模式依然有用——因为它让你自己定义什么是“类型“。你可能需要与语言内置类不同的语义。


原型语言范式

很多人认为“面向对象编程“就是“类“的同义词。关于 OOP 的定义,感觉像是不同宗教派别的信条,但一个相对没有争议的理解是:OOP 让你定义将数据和代码捆绑在一起的“对象“。相比 C 这样的结构化语言和 Scheme 这样的函数式语言,OOP 的决定性特征是它把状态和行为紧密绑定在一起。

你可能认为类是做到这一点的唯一方法,但包括 Dave Ungar 和 Randall Smith 在内的少数几个人不同意。他们在 80 年代创建了一种叫做 Self 的语言。它跟任何语言一样 OOP,但它没有类。

Self

从纯粹意义上说,Self 比基于类的语言更加面向对象。我们认为 OOP 是状态和行为的结合,但基于类的语言实际上在两者之间有一条分界线。

想想你最喜欢的基于类的语言的语义。要访问对象上的某个状态,你查实例的内存。状态包含在实例中。

但要调用方法,你得先找实例的类,然后在那里找方法。行为包含在中。获取方法总是有那一层间接引用,这意味着字段和方法是不同的。

译注:例如,在 C++ 中调用虚方法,你要先找实例中指向虚表的指针,然后在虚表中查找方法。

Self 消除了这种区别。要查找任何东西,你只在对象上找。一个实例可以同时包含状态和行为。你可以有一个对象,它拥有一个完全独一无二的方法。

译注:没有人是一座孤岛,但对象是。

如果 Self 只是做到这样,那就很难用了。基于类的语言中的继承,虽然有缺点,但提供了一个有用的机制来复用多态代码和避免重复。要在没有类的情况下实现类似的功能,Self 有委托

要查找某个对象上的字段或调用方法,我们首先在对象本身中查找。如果有,就完事了。如果没有,我们看对象的父对象。这只是指向另一个对象的引用。当我们在第一个对象上找不到属性时,我们尝试它的父对象,然后父对象的父对象,依此类推。换句话说,失败的查找被委托给了对象的父对象。

译注:我这里简化了。Self 实际上支持多个父对象。父对象只是被特殊标记的字段,这意味着你可以做像继承父对象或在运行时改变父对象之类的事情,导致所谓的动态继承

父对象让我们可以在多个对象之间复用行为(以及状态!),所以我们覆盖了类的一部分功能。类的另一个关键功能是给我们一种创建实例的方法。当你需要一个新的“玩意儿“时,你可以直接 new Thingamabob(),或者你喜欢的语言的其他语法。类就是它自身实例的工厂。

没有类,我们怎么制造新东西呢?特别是,如何制造一堆有共同点的新东西?就像设计模式一样,在 Self 中做这件事的方法是克隆

在 Self 中,就好像每个对象都自动支持原型设计模式。任何对象都可以被克隆。要制造一堆相似的对象,你:

  1. 把一个对象敲打成你想要的形状。你可以直接克隆系统内建的基础 Object,然后往里塞字段和方法。
  2. 克隆它来制作你想要的任意多个……呃……克隆。

这给了我们原型设计模式的优雅,而不用忍受自己实现 clone() 的繁琐——它是系统自带的。

译注:我意识到从头构建一门语言并不是最高效的学习方式,但能说什么呢?我有点另类。如果你好奇,这门语言叫 Finch。

这是一个如此美丽、巧妙、精简的系统,以至于我了解了它之后,就开始创建一门基于原型的语言来获得更多实践经验。

结果如何?

我对使用纯基于原型的语言感到超级兴奋,但一旦我的语言跑起来,我发现一个不愉快的事实:用它编程并没有那么有趣。

译注:我后来从可靠渠道听说很多 Self 程序员也得出了同样的结论。不过这个项目远非失败。Self 太动态了,需要各种虚拟机创新才能跑得足够快。他们为即时编译、垃圾回收和优化方法分派而发明的技术——经常由同一批人实现——正是让世界上许多动态类型语言快得足以用于大规模流行应用的那些技术。

当然,语言本身实现起来很简单,但那是因为它把复杂度推给了用户。当我开始尝试使用它时,我发现自己怀念类提供的结构。我最终试图在库的层面上重新实现它,因为语言本身没有。

也许这是因为我之前的经验都是基于类的语言,所以我的思想被那种范式污染了。但我直觉上认为,大多数人就是喜欢定义良好的“事物种类“。

除了基于类的语言取得了巨大成功之外,看看有多少游戏有明确的角色类别,以及精确的敌人、物品和技能清单,每种都井井有条地贴了标签。你很少看到游戏中每个怪物都是一个独特的“雪花“——比如“介于巨魔和哥布林之间,还带点蛇的味道“。

译注:而且,实际用原型风格写的代码到底有多少也很能说明问题。我找过。

虽然原型是一个非常酷的范式,我也希望更多人了解它,但我很高兴我们大多数人实际上并不每天用它编程。我所看到的完全拥抱原型的代码有一种奇特的模糊性,让人觉得难以把握。

那么 JavaScript 呢?

好吧,如果基于原型的语言这么不友好,那怎么解释 JavaScript?这是一个每天有数百万人使用的基于原型的语言。地球上运行着比任何其他语言都多的 JavaScript 代码。

JavaScript 的创造者 Brendan Eich 直接从 Self 中汲取了灵感,JavaScript 的许多语义都是基于原型的。每个对象可以有一组任意的属性,包括字段和“方法“(其实只是作为字段存储的函数)。一个对象还可以有另一个对象,称为它的“原型“,在字段访问失败时委托给它。

译注:作为语言设计者,原型的一个吸引人之处在于它比类更容易实现。Eich 充分利用了这一点:JavaScript 的第一个版本是在十天内创建的。

但是,尽管如此,我相信 JavaScript 在实践中与基于类的语言的共同点比与原型语言的共同点更多。一个提示 JavaScript 已经远离 Self 的证据是:基于原型语言的核心操作——克隆——已经无处可寻。

JavaScript 中没有克隆对象的方法。最接近的是 Object.create(),它允许你创建一个委托给现有对象的新对象。即便如此,这也直到 ECMAScript 5 才加入——在 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 把核心语义包装在更“类“似的东西里。


数据建模中的原型

好了,我一直在谈论原型我不喜欢的东西,这让这一章变得很沉闷。我觉得这本书更多的是喜剧而不是悲剧,所以我们用一个我确实认为原型——更具体地说委托——有用的领域来收尾吧。

如果你点算一下游戏中代码所占的字节数 vs 数据所占的字节数,你会发现数据占比自编程诞生以来一直在稳步增长。早期的游戏几乎程序化生成一切,以塞进软盘和旧游戏卡带。在今天的许多游戏中,代码只是一个驱动游戏的“引擎“,而游戏本身完全定义在数据中。

这很棒,但把大堆内容推入数据文件并不能神奇地解决大型项目的组织挑战。如果有什么的话,它只会更难。我们之所以使用编程语言,是因为它们有管理复杂度的工具。

我们不会把一段代码复制粘贴到十个地方,而是把它移到一个可以通过名称调用的函数里。我们不会把方法复制到一堆类中,而是把它放到一个单独的类中,由那些类继承或混入。

当你的游戏数据达到一定规模时,你确实会开始想要类似的功能。数据建模是一个很深的话题,我无法在这本当之无愧地论述,但我确实想抛出一个你可以在自己的游戏中考虑的特性:使用原型和委托来复用数据。

译注:我指的是完全原创的标题,绝不受任何先前存在的自上而下多玩家地下城闯关街机游戏的启发。请别告我。

假设我们在我之前提到的无耻 Gauntlet 仿制品中定义数据模型。游戏设计师需要以某种文件格式指定怪物和物品的属性。

一种常见的方法是使用 JSON。数据实体基本上是映射(map)、或属性包、或者其他几十种术语之一——程序员最喜欢干的事就是给已经有名字的东西再发明一个新名字。

译注:我们重新发明它们的次数太多了,以至于 Steve Yegge 称之为“万能设计模式“。

于是一个游戏中哥布林可能被定义成这样:

{
  "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" 的字段,那么它定义了另一个对象的名称,当前对象会委托给这个对象。任何在第一个对象上不存在的属性,都会回退到在原型上查找。

译注:这让“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"]
}

由于弓箭手和法师以步兵为原型,我们不需要重复血量、抗性和弱点。我们给数据模型添加的逻辑超级简单——基本的单委托——但我们已经消除了大量重复。

这里一个有趣的点是:我们没有为三个具体哥布林类型设置第四个“基础哥布林“抽象原型来委托。相反,我们选了一个最简单的哥布林来委托给它。

这在基于原型的系统中感觉很自然——任何对象都可以被用作克隆来创建新的改良对象——我认为在这里同样自然。它特别适合游戏中的数据建模——游戏中经常有世界中一次性的特殊实体。想想 Boss 和独特物品。它们往往是对游戏中更常见物体的改良,原型委托是一种很好的定义方式。神奇的断头之剑,其实就是一把长剑加上一些加成,可以直接这样表达:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

在你的游戏引擎的数据建模系统中增加一点点能力,就可以让设计师更容易为游戏世界中的武器和怪物添加各种小变化——而这种丰富性正是让玩家着迷的东西。


实践 Demo

原型模式


笔记与思考

1. 语言范式:类语言 vs 原型语言

语言底层模型
Java纯类语言,一切必须在类中
C++类语言,支持多范式
Python类语言,type / __mro__ 体系,但类本身是一等公民
JavaScript原型语言(Self 后代),ES6 class 是语法糖
GDScript类语言(Godot 4 class_name

JS 虽底层是原型链,但实践中已经“类化“——社区惯用法(new + prototype.method)、ES6 class 语法、以及最关键的:JS 没有 clone 操作,而克隆是原型范式的核心。Brendan Eich 从 Self 借了机制,但生态把它推向了“看起来像类语言“的方向。

2. 原型模式真正的用武之地

GoF 原型模式(clone() + 生成器基类)在现代游戏开发中已经边缘化。原因:

  • 每种怪物独立类的做法已被组件模式和类型对象模式取代
  • 自己写 clone() 涉及深/浅拷贝的语义坑
  • 模板、生成函数、一等公民类型(Python/JS 可直接传类)都能更简洁地解决同一问题

真正有价值的是数据建模中的委托——游戏中大量实体定义存在重复(10 种哥布林共享血量、抗性),JSON 不支持继承,用 "prototype" 字符串做引用是最轻量的复用方案。

核心原则:不需要造干净抽象的“基类“——直接选一个最普通的实体当原型即可。 这比类层级更自然,因为游戏中传奇武器本来就是“普通长剑 + 点东西“。

3. Demo 关键领悟

Demo 做了这样一个过程:

  1. MONSTERS 对象模拟从 JSON 加载的原始数据——纯数据,没有原型链
  2. parsePrototype(name) 按需解析,解构出 prototype 字段 + 自身属性,递归合并原型链
  3. parsePrototypeIter(循环版)做等价比对,验证正确性

过程中经历了一个重要的弯路:想用 Object.create 预构建 JS 原生原型链来“更优雅地“解决,但这是错误方向——JSON 本身就是纯数据,"prototype": "goblin grunt" 只是字符串,不是对象引用。手写那 6 行递归就是最简洁的解法。宁愿保持数据干净、解析函数简单,不要为“用上语言特性“而增加预处理层。

4. 递归 vs 循环

递归版(parsePrototype)代码最直观,对于原型链深度 2-3 层的场景完全安全。循环版(parsePrototypeIter)用 while 沿链收集各层,再从远到近 Object.assign 合并,避免递归栈溢出风险,逻辑等价。生产环境中数据嵌套不可控时,循环版更稳妥。