第八章: 面向对象编程
¶ 在九十年代早期,一种叫做 面向对象编程的技术席卷了软件行业。它背后的许多理念在当时并不新鲜,但它们最终获得了足够的势头,开始流行起来,变得时尚。书籍被撰写,课程被开设,编程语言被开发出来。突然之间,每个人都在歌颂面向对象编程的优点,热情地将其应用于每一个问题,说服自己他们终于找到了 *编写程序的正确方式*。
¶ 这种现象并不少见。当一个过程很困难、很混乱时,人们总是渴望寻找一种神奇的解决方案。当看似有效的解决方案出现时,他们便准备好成为忠实的追随者。即使在今天,对于许多程序员来说,面向对象编程(或他们对它的理解)就像圣经一样。当一个程序不是“真正面向对象的”,无论这意味着什么,它都被认为是明显落后的。
¶ 尽管如此,很少有潮流能像这个一样保持长久的流行。面向对象编程的持久力主要可以解释为其核心理念非常稳固、实用。在本章中,我们将讨论这些理念,以及 JavaScript 对它们的(相当古怪的)理解。以上段落绝不是为了贬低这些理念。我想做的是提醒读者不要对它们产生不健康的依恋。
¶ 顾名思义,面向对象编程与对象相关。到目前为止,我们一直将对象用作值的松散集合,并在需要时添加和修改它们的属性。在面向对象的方法中,对象被视为独立的小世界,外部世界只能通过有限的、定义良好的 接口与它们接触,即一些特定的方法和属性。我们在 第七章 末尾使用的“到达列表”就是这样一个例子:我们只使用三个函数 `makeReachedList`、`storeReached` 和 `findReached` 来与它交互。这三个函数构成了这种对象的接口。
¶ 我们见过的 `Date`、`Error` 和 `BinaryHeap` 对象也是这样工作的。它们没有提供用于操作对象的常规函数,而是提供了一种使用 `new` 关键字创建这些对象的方式,以及一些提供其余接口的方法和属性。
¶ 为对象提供方法的一种方法是简单地将函数值附加到它。
var rabbit = {}; rabbit.speak = function(line) { print("The rabbit says '", line, "'"); }; rabbit.speak("Well, now you're asking me.");
¶ 在大多数情况下,方法需要知道它应该作用于 *谁*。例如,如果有不同的兔子,`speak` 方法必须指明哪只兔子在说话。为此,存在一个名为 this
的特殊变量,它在调用函数时始终存在,并且在将函数作为方法调用时指向相关对象。当函数作为属性查找并立即调用时,它被调用为方法,例如 `object.method()`。
function speak(line) { print("The ", this.adjective, " rabbit says '", line, "'"); } var whiteRabbit = {adjective: "white", speak: speak}; var fatRabbit = {adjective: "fat", speak: speak}; whiteRabbit.speak("Oh my ears and whiskers, how late it's getting!"); fatRabbit.speak("I could sure use a carrot right now.");
¶ 现在我可以阐明 apply
方法的第一个神秘参数,我们在 第六章 中始终为它使用 `null`。这个参数可以用来指定函数必须应用到的对象。对于非方法函数,这无关紧要,因此使用 `null`。
speak.apply(fatRabbit, ["Yum."]);
¶ 函数还有一个 call
方法,它类似于 `apply`,但你可以分别提供函数的参数,而不是作为数组提供。
speak.call(fatRabbit, "Burp.");
¶ new
关键字提供了一种方便的方式来创建新对象。当一个函数前面带有 `new` 关键字调用时,它的 this
变量将指向一个 *新的* 对象,它将自动返回这个对象(除非它显式地返回其他东西)。用于像这样创建新对象的函数被称为 构造函数。下面是兔子的构造函数
function Rabbit(adjective) { this.adjective = adjective; this.speak = function(line) { print("The ", this.adjective, " rabbit says '", line, "'"); }; } var killerRabbit = new Rabbit("killer"); killerRabbit.speak("GRAAAAAAAAAH!");
¶ 在 JavaScript 程序员中,以大写字母开头构造函数名是一种约定俗成的做法。这使得它们很容易与其他函数区分开来。
¶ 为什么 `new` 关键字是必要的?毕竟,我们可以简单地这样写
function makeRabbit(adjective) { return { adjective: adjective, speak: function(line) {/*etc*/} }; } var blackRabbit = makeRabbit("black");
¶ 但这并不完全相同。`new` 在幕后做了一些事情。首先,我们的 `killerRabbit` 有一个名为 constructor
的属性,它指向创建它的 `Rabbit` 函数。`blackRabbit` 也有这样一个属性,但它指向的是 Object
函数。
show(killerRabbit.constructor); show(blackRabbit.constructor);
¶ constructor
属性来自哪里?它是兔子的 原型的一部分。原型是 JavaScript 对象工作方式中一个强大但有点令人困惑的部分。每个对象都基于一个原型,它为对象提供了一组固有的属性。到目前为止,我们使用的简单对象基于最基本的原型,该原型与 `Object` 构造函数相关联。事实上,键入 `{}` 等同于键入 `new Object()`。
var simpleObject = {}; show(simpleObject.constructor); show(simpleObject.toString);
¶ toString
是 `Object` 原型的一部分。这意味着所有简单对象都有一个 `toString` 方法,它将它们转换为字符串。我们的兔子对象基于与 `Rabbit` 构造函数相关联的原型。你可以使用构造函数的 `prototype` 属性来访问它的原型
show(Rabbit.prototype); show(Rabbit.prototype.constructor);
¶ 每个函数都会自动获得一个 `prototype` 属性,它的 `constructor` 属性指向函数本身。因为兔子原型本身是一个对象,它基于 `Object` 原型,并共享它的 `toString` 方法。
show(killerRabbit.toString == simpleObject.toString);
¶ 尽管对象似乎共享了其原型的属性,但这种共享是单向的。原型的属性会影响基于它的对象,但该对象的属性永远不会改变原型。
¶ 精确的规则是这样的:在查找属性的值时,JavaScript 首先查看对象 *本身* 拥有的属性。如果存在一个具有我们正在查找的名称的属性,那么我们获得的就是那个值。如果没有这样的属性,它将继续搜索对象的原型,然后是原型的原型,依此类推。如果找不到任何属性,将返回 `undefined` 值。另一方面,在 *设置* 属性值时,JavaScript 永远不会访问原型,而是始终在对象本身中设置属性。
Rabbit.prototype.teeth = "small"; show(killerRabbit.teeth); killerRabbit.teeth = "long, sharp, and bloody"; show(killerRabbit.teeth); show(Rabbit.prototype.teeth);
¶ 这确实意味着原型可以在任何时候被用来向所有基于它的对象添加新的属性和方法。例如,我们的兔子可能需要跳舞。
Rabbit.prototype.dance = function() { print("The ", this.adjective, " rabbit dances a jig."); }; killerRabbit.dance();
¶ 而且,正如你可能猜到的,原型兔子是所有兔子共同拥有的值的完美位置,例如 `speak` 方法。下面是 `Rabbit` 构造函数的一种新方法
function Rabbit(adjective) { this.adjective = adjective; } Rabbit.prototype.speak = function(line) { print("The ", this.adjective, " rabbit says '", line, "'"); }; var hazelRabbit = new Rabbit("hazel"); hazelRabbit.speak("Good Frith!");
¶ 所有对象都有一个原型,并从该原型接收一些属性,这一事实可能很棘手。这意味着使用对象来存储一组事物,例如 第四章 中的猫,可能会出错。例如,如果我们想知道是否存在一只名为 `“constructor”` 的猫,我们会这样检查
var noCatsAtAll = {}; if ("constructor" in noCatsAtAll) print("Yes, there definitely is a cat called 'constructor'.");
¶ 这有问题。一个相关的问题是,扩展标准构造函数(如 `Object` 和 `Array`)的原型以添加新的有用函数通常是可行的。例如,我们可以为所有对象提供一个名为 `properties` 的方法,该方法返回一个数组,其中包含对象拥有的(非隐藏)属性的名称
Object.prototype.properties = function() { var result = []; for (var property in this) result.push(property); return result; }; var test = {x: 10, y: 3}; show(test.properties());
¶ 这立即显示了问题。现在,`Object` 原型拥有一个名为 `properties` 的属性,使用 `for` 和 in
遍历任何对象的属性,也会返回该共享属性,这通常不是我们想要的。我们只对对象本身拥有的属性感兴趣。
¶ 幸运的是,有一种方法可以确定一个属性是属于对象本身还是属于它的原型之一。不幸的是,它确实使遍历对象的属性变得有些笨拙。每个对象都有一个名为 hasOwnProperty
的方法,它告诉我们对象是否拥有一个具有给定名称的属性。使用它,我们可以这样重写我们的 `properties` 方法
Object.prototype.properties = function() { var result = []; for (var property in this) { if (this.hasOwnProperty(property)) result.push(property); } return result; }; var test = {"Fat Igor": true, "Fireball": true}; show(test.properties());
¶ 当然,我们可以将其抽象成一个高阶函数。注意,`action` 函数同时使用属性的名称及其在对象中的值调用。
function forEachIn(object, action) { for (var property in object) { if (object.hasOwnProperty(property)) action(property, object[property]); } } var chimera = {head: "lion", body: "goat", tail: "snake"}; forEachIn(chimera, function(name, value) { print("The ", name, " of a ", value, "."); });
¶ 但是,如果我们找到一只名为 `hasOwnProperty` 的猫呢?(你永远不知道。)它将存储在对象中,下次我们想遍历猫的集合时,调用 `object.hasOwnProperty` 将失败,因为该属性不再指向函数值。可以通过执行更丑陋的操作来解决这个问题
function forEachIn(object, action) { for (var property in object) { if (Object.prototype.hasOwnProperty.call(object, property)) action(property, object[property]); } } var test = {name: "Mordecai", hasOwnProperty: "Uh-oh"}; forEachIn(test, function(name, value) { print("Property ", name, " = ", value); });
¶ (注意:此示例目前在 Internet Explorer 8 中无法正常工作,因为它似乎在覆盖内置原型属性方面存在一些问题。)
¶ 在这里,我们没有使用对象本身中找到的方法,而是从 `Object` 原型中获取方法,然后使用 `call` 将其应用于正确的对象。除非有人真正弄乱了 `Object.prototype` 中的方法(不要这样做),否则这应该可以正常工作。
¶ hasOwnProperty
也可以用于那些我们一直使用 in
运算符来查看对象是否具有特定属性的情况。但是,还有一个问题。我们在 第 4 章 中看到,一些属性,例如 toString
,是“隐藏”的,在使用 for
/in
遍历属性时不会显示。事实证明,Gecko 家族(最重要的是 Firefox)的浏览器为每个对象提供了一个名为 __proto__
的隐藏属性,它指向该对象的原型。hasOwnProperty
将为此返回 true
,即使程序没有显式添加它。访问对象的原型可能非常方便,但将其设为属性并不是一个好主意。尽管如此,Firefox 是一款广泛使用的浏览器,因此在为网络编写程序时必须小心这一点。存在一个方法 propertyIsEnumerable
,它对于隐藏属性返回 false
,并且可以用来过滤掉诸如 __proto__
之类的奇怪事物。可以使用像这样的表达式来可靠地解决这个问题
var object = {foo: "bar"}; show(Object.prototype.hasOwnProperty.call(object, "foo") && Object.prototype.propertyIsEnumerable.call(object, "foo"));
¶ 简洁明了,对吧?这是 JavaScript 设计不太好的地方之一。对象既充当“具有方法的值”,原型对这些值很有用,又充当“属性集”,原型对此只有阻碍作用。
¶ 每次需要检查属性是否存在于对象中时都要编写上述表达式是不可行的。我们可以将其放入一个函数中,但更好的方法是为这种情况编写一个构造函数和一个原型,在这种情况下,我们希望将对象视为仅一个属性集。因为你可以用名称来查找东西,所以我们将其称为 Dictionary
。
function Dictionary(startValues) { this.values = startValues || {}; } Dictionary.prototype.store = function(name, value) { this.values[name] = value; }; Dictionary.prototype.lookup = function(name) { return this.values[name]; }; Dictionary.prototype.contains = function(name) { return Object.prototype.hasOwnProperty.call(this.values, name) && Object.prototype.propertyIsEnumerable.call(this.values, name); }; Dictionary.prototype.each = function(action) { forEachIn(this.values, action); }; var colours = new Dictionary({Grover: "blue", Elmo: "orange", Bert: "yellow"}); show(colours.contains("Grover")); show(colours.contains("constructor")); colours.each(function(name, colour) { print(name, " is ", colour); });
¶ 现在,与将对象视为简单属性集相关的混乱都被“封装”在一个便捷的接口中:一个构造函数和四个方法。请注意,Dictionary
对象的 values
属性不是此接口的一部分,它是一个内部细节,在使用 Dictionary
对象时,不需要直接使用它。
¶ 每当你编写一个接口时,最好添加一个注释,简要说明它的作用以及如何使用它。这样,当有人(可能是你在三个月后写完它之后)想要使用这个接口时,他们可以快速了解如何使用它,而不需要学习整个程序。
¶ 大多数情况下,在设计接口时,很快就会发现你所想出的东西存在一些局限性和问题,并进行更改。为了避免浪费时间,建议在接口在几个实际情况中被使用并证明其实用性之后再记录它们。——当然,这可能会让人想要完全忘记文档。就我个人而言,我将编写文档视为添加到系统中的“收尾工作”。当感觉准备就绪时,就可以开始写一些东西了,看看它用英语(或任何语言)写出来是否和用 JavaScript(或任何编程语言)写出来一样好。
¶ 对象的外部接口与其内部细节之间的区别很重要,原因有两个。首先,拥有一个小型、描述清晰的接口使对象更容易使用。你只需要记住接口,而不必担心其他内容,除非你要更改对象本身。
¶ 其次,通常发现有必要或实用地更改对象类型的内部实现1,例如,为了提高效率或修复某些问题。当外部代码访问对象中的每个属性和细节时,你就无法更改任何内容,除非同时更新大量其他代码。如果外部代码只使用一个小接口,你就可以随心所欲地进行操作,只要你不更改接口。
¶ 有些人在这方面走得很远。例如,他们永远不会在对象的接口中包含属性,只包含方法——如果他们的对象类型有长度,它将可以通过 getLength
方法访问,而不是 length
属性。这样,如果他们想要以不再具有 length
属性的方式更改对象,例如,因为现在它有某个内部数组,它必须返回其长度,他们就可以更新函数而不会更改接口。
¶ 我个人的看法是,在大多数情况下,这并不值得。添加一个只包含 return this.length;
的 getLength
方法只会添加毫无意义的代码,而且,在大多数情况下,我认为毫无意义的代码比偶尔需要更改对象接口的风险更大。
¶ 向现有原型添加新方法可能非常方便。特别是 JavaScript 中的 Array
和 String
原型可以使用更多基本方法。例如,我们可以用数组上的方法替换 forEach
和 map
,并将我们在 第 4 章 中编写的 startsWith
函数设为字符串上的方法。
¶ 但是,如果你的程序必须在与另一个程序(无论是由你编写还是由其他人编写)相同的网页上运行,而另一个程序天真地使用 for
/in
——就像我们到目前为止一直在使用它那样——那么向原型(特别是 Object
和 Array
原型)添加内容肯定会破坏某些东西,因为这些循环将突然开始看到这些新属性。出于这个原因,有些人宁愿完全不碰这些原型。当然,如果你小心谨慎,并且不希望你的代码与编写糟糕的代码共存,那么向标准原型添加方法是一种非常好的技术。
¶ 在本章中,我们将构建一个虚拟的生态箱,一个装有昆虫在其中移动的容器。其中将涉及一些对象(毕竟,这是关于面向对象编程的章节)。我们将采用一种相当简单的方法,使生态箱成为一个二维网格,就像 第 7 章 中的第二张地图一样。在这个网格上,有一些虫子。当生态箱处于活动状态时,所有虫子每半秒都有机会采取一个动作,例如移动。
¶ 因此,我们将时间和空间都分成具有固定大小的单位——空间是正方形,时间是半秒。这通常使在程序中建模变得更容易,但当然也具有极度不准确的缺点。幸运的是,此生态箱模拟器不需要在任何方面保持准确,因此我们可以使用这种方法。
¶ 生态箱可以用“计划”来定义,计划是一个字符串数组。我们本可以使用一个字符串,但由于 JavaScript 字符串必须保持在一行,因此输入会困难得多。
var thePlan = ["############################", "# # # o ##", "# #", "# ##### #", "## # # ## #", "### ## # #", "# ### # #", "# #### #", "# ## o #", "# o # o ### #", "# # #", "############################"];
¶ "#"
字符用于表示生态箱的墙壁(以及里面的装饰性岩石),"o"
表示虫子,空格表示空地,正如你可能猜到的那样。
¶ 这样的计划数组可以用来创建一个生态箱对象。该对象跟踪生态箱的形状和内容,并让里面的虫子移动。它有四个方法:首先是 toString
,它将生态箱转换回类似于其基础计划的字符串,这样你就可以看到里面发生了什么。然后是 step
,它允许生态箱中的所有虫子移动一步,如果它们愿意的话。最后,还有 start
和 stop
,它们控制生态箱是否“运行”。当它运行时,step
每半秒自动调用一次,这样虫子就会继续移动。
¶ 网格上的点将再次用对象表示。在 第 7 章 中,我们使用了三个函数,point
、addPoints
和 samePoint
来处理点。这一次,我们将使用一个构造函数和两个方法。编写构造函数 Point
,它接受两个参数,点的 x 和 y 坐标,并生成一个包含 x
和 y
属性的对象。为这个构造函数的原型添加一个方法 add
,它接受另一个点作为参数,并返回一个新点,其 x
和 y
是两个给定点的 x
和 y
的总和。此外,添加一个方法 isEqualTo
,它接受一个点,并返回一个布尔值,指示 this
点是否引用与给定点相同的坐标。
¶ 除了这两个方法之外,x
和 y
属性也是此类型对象接口的一部分:使用点对象的代码可以自由地检索和修改 x
和 y
。
function Point(x, y) { this.x = x; this.y = y; } Point.prototype.add = function(other) { return new Point(this.x + other.x, this.y + other.y); }; Point.prototype.isEqualTo = function(other) { return this.x == other.x && this.y == other.y; }; show((new Point(3, 1)).add(new Point(2, 4)));
¶ 确保你的 add
版本保持 this
点不变并生成一个新的点对象。更改当前点而不是生成新点的方法类似于 +=
运算符,而此方法类似于 +
运算符。
¶ 在编写对象来实现某个程序时,并不总是很清楚哪个功能应该放在哪里。有些东西最好编写为对象的函数,有些东西最好表示为单独的函数,还有一些东西最好通过添加一种新类型的对象来实现。为了保持清晰和组织,重要的是要尽可能减少对象类型所拥有的方法和职责数量。当一个对象做得太多时,它就会变成一个功能混乱的大杂烩,成为一个令人困惑的来源。
¶ 我在上面说过,生态箱对象将负责存储其内容并让里面的虫子移动。首先,请注意,它是让它们移动,而不是使它们移动。虫子本身也是对象,这些对象负责决定它们想做什么。生态箱只是提供了一个基础设施,每半秒询问它们要做什么,如果它们决定移动,它就会确保这会发生。
¶ 存储用于保存生态缸内容的网格可能会非常复杂。它必须定义某种表示方式、访问该表示方式的方法、从“计划”数组初始化网格的方法、将网格内容写入字符串以便用于toString
方法的方法,以及虫子在网格上的移动方式。如果可以将其中一部分移到另一个对象中,这样生态缸对象本身就不会变得太大太复杂,那就太好了。
¶ 每当你发现自己要在一个对象中混合数据表示和特定问题代码时,最好尝试将数据表示代码放入一个单独类型的对象中。在本例中,我们需要表示一个值的网格,所以我写了一个Grid
类型,它支持生态缸需要的操作。
¶ 为了存储网格上的值,有两个选项。可以使用一个数组数组,如下所示
var grid = [["0,0", "1,0", "2,0"], ["0,1", "1,1", "2,1"]]; show(grid[1][2]);
¶ 或者可以将所有值放入一个数组中。在这种情况下,可以通过获取数组中位置x + y * width
处的元素来找到x
、y
处的元素,其中width
是网格的宽度。
var grid = ["0,0", "1,0", "2,0", "0,1", "1,1", "2,1"]; show(grid[2 + 1 * 3]);
¶ 我选择了第二种表示方式,因为它使初始化数组变得容易得多。new Array(x)
会生成一个长度为x
的新数组,该数组填充了undefined
值。
function Grid(width, height) { this.width = width; this.height = height; this.cells = new Array(width * height); } Grid.prototype.valueAt = function(point) { return this.cells[point.y * this.width + point.x]; }; Grid.prototype.setValueAt = function(point, value) { this.cells[point.y * this.width + point.x] = value; }; Grid.prototype.isInside = function(point) { return point.x >= 0 && point.y >= 0 && point.x < this.width && point.y < this.height; }; Grid.prototype.moveValue = function(from, to) { this.setValueAt(to, this.valueAt(from)); this.setValueAt(from, undefined); };
¶ 我们还需要遍历网格的所有元素,以找到我们需要移动的虫子,或者将整个网格转换为字符串。为了方便起见,我们可以使用一个高阶函数,该函数以一个动作作为参数。将each
方法添加到Grid
的原型中,该方法以一个有两个参数的函数作为参数。它会对网格上的每个点调用此函数,并将该点的点对象作为第一个参数,并将该点在网格上的值作为第二个参数。
¶ 从0
、0
开始,逐行遍历各个点,以便1
、0
在0
、1
之前处理。这将使以后编写生态缸的toString
函数变得更容易。(提示:将用于x
坐标的for
循环放在用于y
坐标的循环中。)
¶ 建议不要直接在网格对象的cells
属性中进行操作,而是使用valueAt
来获取值。这样,如果我们决定(出于某种原因)使用不同的方法来存储值,我们只需要重写valueAt
和setValueAt
,而其他方法可以保持不变。
Grid.prototype.each = function(action) { for (var y = 0; y < this.height; y++) { for (var x = 0; x < this.width; x++) { var point = new Point(x, y); action(point, this.valueAt(point)); } } };
¶ 最后,测试网格
var testGrid = new Grid(3, 2); testGrid.setValueAt(new Point(1, 0), "#"); testGrid.setValueAt(new Point(1, 1), "o"); testGrid.each(function(point, value) { print(point.x, ",", point.y, ": ", value); });
¶ 在我们开始编写Terrarium
构造函数之前,我们必须对将生活在其中的这些“虫子对象”进行更具体的说明。前面我提到过,生态缸会询问虫子它们想要采取什么行动。它的工作原理如下:每个虫子对象都有一个act
方法,当调用该方法时,它会返回一个“动作”。动作是一个对象,它具有一个type
属性,该属性指定了虫子想要采取的动作类型,例如"move"
。对于大多数动作,动作还包含额外的信息,例如虫子想要移动的方向。
¶ 虫子非常近视,它们只能看到网格上周围正方形的方块。但它们可以利用这些方块作为它们动作的基础。当调用act
方法时,它会得到一个对象,该对象包含有关所讨论虫子周围环境的信息。对于八个方向中的每一个,它都包含一个属性。表示虫子上方是什么的属性称为"n"
,表示虫子上面和右边的属性称为"ne"
,依此类推。为了查找这些名称所指的方向,以下字典对象很有用
var directions = new Dictionary( {"n": new Point( 0, -1), "ne": new Point( 1, -1), "e": new Point( 1, 0), "se": new Point( 1, 1), "s": new Point( 0, 1), "sw": new Point(-1, 1), "w": new Point(-1, 0), "nw": new Point(-1, -1)}); show(new Point(4, 4).add(directions.lookup("se")));
¶ 当一只虫子决定移动时,它会通过给生成的动作对象一个direction
属性来指示它想要移动的方向,该属性指定了这些方向中的一个名称。我们可以制作一个简单而愚蠢的虫子,它总是朝南走,即“向光移动”,如下所示
function StupidBug() {}; StupidBug.prototype.act = function(surroundings) { return {type: "move", direction: "s"}; };
¶ 现在我们可以开始编写Terrarium
对象类型本身了。首先,它的构造函数,它以一个计划(一个字符串数组)作为参数,并初始化其网格。
var wall = {}; function Terrarium(plan) { var grid = new Grid(plan[0].length, plan.length); for (var y = 0; y < plan.length; y++) { var line = plan[y]; for (var x = 0; x < line.length; x++) { grid.setValueAt(new Point(x, y), elementFromCharacter(line.charAt(x))); } } this.grid = grid; } function elementFromCharacter(character) { if (character == " ") return undefined; else if (character == "#") return wall; else if (character == "o") return new StupidBug(); }
¶ wall
是一个对象,用于标记网格上墙壁的位置。就像真正的墙壁一样,它没有做太多的事情,它只是在那里占据空间。
¶ 生态缸对象最直接的方法是toString
,它将一个生态缸转换为字符串。为了便于操作,我们在wall
和StupidBug
的原型上都标记了一个character
属性,该属性保存表示它们的字符。
wall.character = "#"; StupidBug.prototype.character = "o"; function characterFromElement(element) { if (element == undefined) return " "; else return element.character; } show(characterFromElement(wall));
¶ 现在我们可以使用Grid
对象的each
方法来构建一个字符串。但是为了使结果可读,最好在每行末尾添加一个换行符。网格上位置的x
坐标可用于确定何时到达行尾。在Terrarium
原型中添加一个toString
方法,该方法不接受任何参数并返回一个字符串,当将其传递给print
时,它会显示一个不错的生态缸二维视图。
Terrarium.prototype.toString = function() { var characters = []; var endOfLine = this.grid.width - 1; this.grid.each(function(point, value) { characters.push(characterFromElement(value)); if (point.x == endOfLine) characters.push("\n"); }); return characters.join(""); };
¶ 并进行尝试...
var terrarium = new Terrarium(thePlan); print(terrarium.toString());
¶ 在尝试解决上述练习时,你可能尝试过在作为参数传递给网格的each
方法的函数内部访问this.grid
。这将不起作用。调用函数总是会导致在该函数内部定义一个新的this
,即使它没有用作方法。因此,函数外部的任何this
变量都将不可见。
¶ 有时可以通过将所需信息存储在变量中来轻松解决此问题,例如endOfLine
,该变量在内部函数中是可见的。如果你需要访问整个this
对象,也可以将其存储在一个变量中。self
(或that
)这个名字通常用于此类变量。
¶ 但是所有这些额外的变量可能会变得很混乱。另一个不错的解决方案是使用类似于第 6 章中的partial
的函数。此函数不是添加函数参数,而是添加一个this
对象,使用函数的apply
方法的第一个参数
function bind(func, object) { return function(){ return func.apply(object, arguments); }; } var testArray = []; var pushTest = bind(testArray.push, testArray); pushTest("A"); pushTest("B"); show(testArray);
¶ 这样,你就可以将一个内部函数bind
到this
,它将与外部函数具有相同的this
。
¶ 在表达式bind(testArray.push, testArray)
中,testArray
这个名称仍然出现了两次。你能设计一个名为method
的函数,它允许你将一个对象绑定到其方法之一,而不需要两次命名该对象吗?
¶ 可以将方法的名称作为字符串给出。这样,method
函数就可以为自己查找正确的值函数。
function method(object, name) { return function() { return object[name].apply(object, arguments); }; } var pushTest = method(testArray, "push");
¶ 在实现生态缸的step
方法时,我们将需要bind
(或method
)。此方法必须遍历网格上的所有虫子,询问它们要采取的动作,并执行给定的动作。你可能很想在网格上使用each
,只处理遇到的虫子。但如果一只虫子向南或向东移动,我们将在同一回合中再次遇到它,并允许它再次移动。
¶ 相反,我们首先将所有虫子收集到一个数组中,然后对其进行处理。此方法收集虫子或其他具有act
方法的东西,并将它们存储在也包含其当前位置的对象中
Terrarium.prototype.listActingCreatures = function() { var found = []; this.grid.each(function(point, value) { if (value != undefined && value.act) found.push({object: value, point: point}); }); return found; };
¶ 在要求一只虫子采取行动时,我们必须将一个包含其当前周围环境信息的对象传递给它。此对象将使用我们之前看到的方向名称("n"
、"ne"
等)作为属性名称。每个属性都包含一个字符的字符串,该字符串由characterFromElement
返回,指示虫子在该方向上可以看到什么。
¶ 在Terrarium
原型中添加一个listSurroundings
方法。它接受一个参数,即虫子当前所处的点,并返回一个包含该点周围环境信息的对象。当点位于网格边缘时,对于超出网格范围的方向使用"#"
,以便虫子不会尝试移动到那里。
¶ 提示:不要写出所有方向,在directions
字典上使用each
方法。
Terrarium.prototype.listSurroundings = function(center) { var result = {}; var grid = this.grid; directions.each(function(name, direction) { var place = center.add(direction); if (grid.isInside(place)) result[name] = characterFromElement(grid.valueAt(place)); else result[name] = "#"; }); return result; };
¶ 请注意,使用grid
变量来解决this
问题。
¶ 以上两种方法都不是Terrarium
对象的外部接口的一部分,它们是内部细节。某些语言提供了明确声明某些方法和属性为“私有”的方法,并使其从对象外部使用成为错误。JavaScript 并没有,因此你必须依靠注释来描述对象的接口。有时,使用某种命名方案来区分外部属性和内部属性可能会有用,例如,在所有内部属性之前加上一个下划线(“_
”)。这将使意外使用不属于对象接口一部分的属性变得更容易发现。
¶ 接下来是一个内部方法,它将询问一只虫子要采取什么行动,并执行该行动。它接受由listActingCreatures
返回的包含object
和point
属性的对象作为参数。目前,它只知道"move"
动作
Terrarium.prototype.processCreature = function(creature) { var surroundings = this.listSurroundings(creature.point); var action = creature.object.act(surroundings); if (action.type == "move" && directions.contains(action.direction)) { var to = creature.point.add(directions.lookup(action.direction)); if (this.grid.isInside(to) && this.grid.valueAt(to) == undefined) this.grid.moveValue(creature.point, to); } else { throw new Error("Unsupported action: " + action.type); } };
¶ 请注意,它会检查所选方向是否在网格内部且为空,如果不在,则会忽略它。这样,虫子就可以随意要求采取任何行动——只有在实际上可行的情况下才会执行该行动。这充当了虫子与生态缸之间的一层隔离层,并使我们在编写虫子的act
方法时可以不那么精确——例如,StupidBug
总是朝南走,而不管它路径上可能存在的任何墙壁。
¶ 这三个内部方法最终让我们可以编写step
方法,该方法为所有错误提供了执行操作的机会(所有具有act
方法的元素 - 我们也可以为wall
对象提供一个,并让墙壁行走)。
Terrarium.prototype.step = function() { forEach(this.listActingCreatures(), bind(this.processCreature, this)); };
¶ 现在,让我们制作一个生态缸,看看虫子是否会移动...
var terrarium = new Terrarium(thePlan); print(terrarium); terrarium.step(); print(terrarium);
¶ 等等,为什么上面的代码调用print(terrarium)
并最终显示了我们toString
方法的输出?print
使用String
函数将其参数转换为字符串。对象通过调用其toString
方法转换为字符串,因此为自己的对象类型提供有意义的toString
是一个很好的方法,可以使它们在打印时易于阅读。
Point.prototype.toString = function() { return "(" + this.x + "," + this.y + ")"; }; print(new Point(5, 5));
¶ 如前所述,Terrarium
对象还获得start
和stop
方法来启动或停止其模拟。为此,我们将使用浏览器提供的两个函数,称为setInterval
和clearInterval
。第一个用于定期执行其第一个参数(一个函数,或包含 JavaScript 代码的字符串)。它的第二个参数给出调用之间的毫秒数(1/1000 秒)。它返回一个值,可以将其传递给clearInterval
以停止其效果。
var annoy = setInterval(function() {print("What?");}, 400);
¶ 还有...
clearInterval(annoy);
¶ 还有类似的函数用于一次性基于时间的操作。 setTimeout
在给定的毫秒数后导致函数或字符串执行,而clearTimeout
会取消此类操作。
Terrarium.prototype.start = function() { if (!this.running) this.running = setInterval(bind(this.step, this), 500); }; Terrarium.prototype.stop = function() { if (this.running) { clearInterval(this.running); this.running = null; } };
¶ 现在我们有一个带有简单虫子的生态缸,我们可以运行它。但要查看正在发生的事情,我们必须反复执行print(terrarium)
,否则我们不会看到正在发生的事情。这不是很实用。如果它可以自动打印会更好。如果我们能更新生态缸的单个打印输出,而不是在彼此下方打印一千个生态缸,它看起来也会更好。为了解决第二个问题,此页面方便地提供了一个名为inPlacePrinter
的函数。它返回一个类似于print
的函数,它不是将输出添加到输出,而是替换其先前的输出。
var printHere = inPlacePrinter(); printHere("Now you see it."); setTimeout(partial(printHere, "Now you don't."), 1000);
¶ 为了在生态缸每次发生变化时重新打印它,我们可以修改step
方法,如下所示
Terrarium.prototype.step = function() { forEach(this.listActingCreatures(), bind(this.processCreature, this)); if (this.onStep) this.onStep(); };
¶ 现在,当onStep
属性已添加到生态缸时,它将在每一步被调用。
var terrarium = new Terrarium(thePlan); terrarium.onStep = partial(inPlacePrinter(), terrarium); terrarium.start();
¶ 注意使用partial
- 它生成一个应用于生态缸的原位打印机。这样的打印机只接受一个参数,所以部分应用它后没有参数剩下,它变成了一个零参数函数。这正是我们需要的onStep
属性。
¶ 不要忘记在生态缸不再有趣时停止它(这应该很快),这样它就不会继续浪费你的计算机资源
terrarium.stop();
¶ 但是谁想要一个只有一个类型,而且很笨的虫子的生态缸?我可不要。如果我们能添加不同类型的虫子会很不错。幸运的是,我们所要做的只是使elementFromCharacter
函数更加通用。现在它包含三个直接输入或“硬编码”的情况
function elementFromCharacter(character) { if (character == " ") return undefined; else if (character == "#") return wall; else if (character == "o") return new StupidBug(); }
¶ 我们可以保持前两种情况不变,但最后一个情况过于具体。一个更好的方法是将字符和相应的虫子构造函数存储在字典中,并在其中查找它们
var creatureTypes = new Dictionary(); creatureTypes.register = function(constructor) { this.store(constructor.prototype.character, constructor); }; function elementFromCharacter(character) { if (character == " ") return undefined; else if (character == "#") return wall; else if (creatureTypes.contains(character)) return new (creatureTypes.lookup(character))(); else throw new Error("Unknown character: " + character); }
¶ 注意register
方法是如何添加到creatureTypes
中的 - 这是一个字典对象,但没有理由它不应该支持一个额外的函数。这个方法查找与构造函数关联的字符,并将其存储在字典中。它只应在原型实际上具有character
属性的构造函数上调用。
¶ elementFromCharacter
现在在creatureTypes
中查找给定的字符,并在遇到未知字符时引发异常。
¶ 这是一个新的虫子类型,以及在creatureTypes
中注册其字符的调用
function BouncingBug() { this.direction = "ne"; } BouncingBug.prototype.act = function(surroundings) { if (surroundings[this.direction] != " ") this.direction = (this.direction == "ne" ? "sw" : "ne"); return {type: "move", direction: this.direction}; }; BouncingBug.prototype.character = "%"; creatureTypes.register(BouncingBug);
¶ 你能弄清楚它做了什么吗?
¶ 为了选择一个随机方向,我们需要一个方向名称数组。我们当然可以只键入["n", "ne", ...]
,但这会重复信息,重复的信息让我感到不安。我们也可以使用directions
中的each
方法来构建数组,这已经很好了。
¶ 但这里显然有一个普遍性需要发现。获取字典中属性名称的列表听起来像一个有用的工具,所以我们将其添加到Dictionary
原型中。
Dictionary.prototype.names = function() { var names = []; this.each(function(name, value) {names.push(name);}); return names; }; show(directions.names());
¶ 一个真正的焦虑程序员会立即恢复对称性,也添加一个values
方法,该方法返回一个存储在字典中的值的列表。但我想这可以等到我们需要它的时候。
¶ 这里有一个从数组中获取随机元素的方法
function randomElement(array) { if (array.length == 0) throw new Error("The array is empty."); return array[Math.floor(Math.random() * array.length)]; } show(randomElement(["heads", "tails"]));
¶ 还有虫子本身
function DrunkBug() {}; DrunkBug.prototype.act = function(surroundings) { return {type: "move", direction: randomElement(directions.names())}; }; DrunkBug.prototype.character = "~"; creatureTypes.register(DrunkBug);
¶ 所以,让我们测试一下我们的新虫子
var newPlan = ["############################", "# #####", "# ## ####", "# #### ~ ~ ##", "# ## ~ #", "# #", "# ### #", "# ##### #", "# ### #", "# % ### % #", "# ####### #", "############################"]; var terrarium = new Terrarium(newPlan); terrarium.onStep = partial(inPlacePrinter(), terrarium); terrarium.start();
¶ 注意弹跳的虫子正在弹开醉酒的虫子?纯粹的戏剧。无论如何,当你看完这场引人入胜的表演后,关掉它
terrarium.stop();
¶ 现在我们有两种类型的对象,它们都具有act
方法和character
属性。因为它们共享这些特征,所以生态缸可以以相同的方式处理它们。这使我们能够拥有各种类型的虫子,而无需更改生态缸代码中的任何内容。这种技术称为多态性,它可以说是面向对象编程最强大的方面。
¶ 多态性的基本思想是,当编写一段代码来处理具有特定接口的对象时,任何碰巧支持此接口的对象都可以插入代码中,它将正常工作。我们已经看到了简单的示例,比如对象的toString
方法。所有具有有意义的toString
方法的对象都可以传递给print
和其他需要将值转换为字符串的函数,并且将生成正确的字符串,无论它们的toString
方法如何选择构建此字符串。
¶ 类似地,forEach
适用于真实的数组和arguments
变量中的伪数组,因为它只需要一个length
属性以及名为0
、1
等等的属性,用于数组的元素。
¶ 为了使生态缸中的生活更加逼真,我们将添加食物和繁殖的概念。生态缸中的每个生物都获得一个新的属性energy
,该属性通过执行操作而减少,通过吃东西而增加。当有足够的能量时,生物可以繁殖2,产生一种同类的生物。
¶ 如果只有虫子在浪费能量四处移动和互相吞噬,生态缸很快就会屈服于熵的力,能量耗尽,成为一个毫无生气的荒地。为了防止这种情况发生(至少是太快地发生),我们在生态缸中添加了地衣。地衣不会移动,它们只是利用光合作用来获取能量并繁殖。
¶ 为了使这一切正常工作,我们需要一个具有不同processCreature
方法的生态缸。我们只需将方法替换到Terrarium
原型的 processCreature
中,但我们已经非常喜欢弹跳和醉酒虫子的模拟,我们不想破坏我们旧的生态缸。
¶ 我们可以做的是创建一个新的构造函数LifeLikeTerrarium
,它的原型基于Terrarium
原型,但具有不同的processCreature
方法。
¶ 有几种方法可以做到这一点。我们可以遍历Terrarium.prototype
的属性,并将它们逐个添加到LifeLikeTerrarium.prototype
中。这很容易做到,在某些情况下,它是最好的解决方案,但在这种情况下,有一个更简洁的方法。如果我们使旧的原型对象成为新的原型对象的原型(你可能需要重新阅读几次),它将自动具有所有属性。
¶ 不幸的是,JavaScript 没有直接的方法来创建原型是特定其他对象的新的对象。但是,可以使用以下技巧编写一个执行此操作的函数
function clone(object) { function OneShotConstructor(){} OneShotConstructor.prototype = object; return new OneShotConstructor(); }
¶ 此函数使用一个空的一次性构造函数,其原型是给定的对象。当对这个构造函数使用new
时,它将基于给定的对象创建一个新对象。
function LifeLikeTerrarium(plan) { Terrarium.call(this, plan); } LifeLikeTerrarium.prototype = clone(Terrarium.prototype); LifeLikeTerrarium.prototype.constructor = LifeLikeTerrarium;
¶ 新的构造函数不需要与旧的构造函数做任何不同的事情,所以它只是在this
对象上调用旧的构造函数。我们还必须在新的原型中恢复constructor
属性,否则它将声称其构造函数是Terrarium
(当然,这只有在我们使用这个属性时才会成为一个真正的问题,而我们没有使用它)。
¶ 现在可以替换LifeLikeTerrarium
对象的某些函数,或添加新的函数。我们基于一个旧的对象类型创建了一个新的对象类型,这节省了我们重新编写在Terrarium
和LifeLikeTerrarium
中相同的所有函数的工作。这种技术被称为“继承”。新类型继承了旧类型的属性。在大多数情况下,这意味着新类型仍将支持旧类型的接口,尽管它也可能支持旧类型没有的几个方法。这样,新类型的对象可以在所有可以放置旧类型的对象的位置(多态地)使用。
¶ 在大多数显式支持面向对象编程的编程语言中,继承是一件非常直截了当的事情。在 JavaScript 中,该语言并没有真正指定一种简单的继承方法。因此,JavaScript 程序员发明了许多不同的继承方法。不幸的是,它们都不完美。幸运的是,如此广泛的方法允许程序员为正在解决的问题选择最适合的方法,并且允许在其他语言中完全不可能实现的某些技巧。
¶ 在本章结束时,我将展示几种其他实现继承的方法以及它们存在的问题。
¶ 这是新的 processCreature
方法。它很大。
LifeLikeTerrarium.prototype.processCreature = function(creature) { if (creature.object.energy <= 0) return; var surroundings = this.listSurroundings(creature.point); var action = creature.object.act(surroundings); var target = undefined; var valueAtTarget = undefined; if (action.direction && directions.contains(action.direction)) { var direction = directions.lookup(action.direction); var maybe = creature.point.add(direction); if (this.grid.isInside(maybe)) { target = maybe; valueAtTarget = this.grid.valueAt(target); } } if (action.type == "move") { if (target && !valueAtTarget) { this.grid.moveValue(creature.point, target); creature.point = target; creature.object.energy -= 1; } } else if (action.type == "eat") { if (valueAtTarget && valueAtTarget.energy) { this.grid.setValueAt(target, undefined); creature.object.energy += valueAtTarget.energy; valueAtTarget.energy = 0; } } else if (action.type == "photosynthese") { creature.object.energy += 1; } else if (action.type == "reproduce") { if (target && !valueAtTarget) { var species = characterFromElement(creature.object); var baby = elementFromCharacter(species); creature.object.energy -= baby.energy * 2; if (creature.object.energy > 0) this.grid.setValueAt(target, baby); } } else if (action.type == "wait") { creature.object.energy -= 0.2; } else { throw new Error("Unsupported action: " + action.type); } if (creature.object.energy <= 0) this.grid.setValueAt(creature.point, undefined); };
¶ 该函数仍然首先要求生物执行一个动作,前提是它没有耗尽能量(死亡)。然后,如果该动作具有 direction
属性,则立即计算该方向指向网格上的哪个点以及当前位于该点的值。五个支持的动作中有三个需要知道这一点,如果它们都单独计算它,代码会更加难看。如果没有 direction
属性,或者属性无效,则变量 target
和 valueAtTarget
保持为未定义。
¶ 在此之后,它遍历所有动作。一些动作需要在执行之前进行额外检查,这通过一个单独的 if
完成,因此,例如,如果生物试图穿过墙壁,我们不会生成 "Unsupported action"
异常。
¶ 请注意,在 "reproduce"
动作中,母体生物损失的能量是新生生物获得能量的两倍(生育并不容易),并且只有当母体拥有足够的能量来繁殖时,新生物才会被放置在网格上。
¶ 动作执行完毕后,我们检查生物是否耗尽能量。如果是,则它会死亡,我们将其移除。
¶ 地衣不是一种非常复杂的生物。我们将使用字符 "*"
来表示它。确保你已从 练习 8.6 中定义了 randomElement
函数,因为它在这里再次使用。
function Lichen() { this.energy = 5; } Lichen.prototype.act = function(surroundings) { var emptySpace = findDirections(surroundings, " "); if (this.energy >= 13 && emptySpace.length > 0) return {type: "reproduce", direction: randomElement(emptySpace)}; else if (this.energy < 20) return {type: "photosynthese"}; else return {type: "wait"}; }; Lichen.prototype.character = "*"; creatureTypes.register(Lichen); function findDirections(surroundings, wanted) { var found = []; directions.each(function(name) { if (surroundings[name] == wanted) found.push(name); }); return found; }
¶ 地衣不会长到超过 20 个能量,否则当它们被其他地衣包围并且没有空间繁殖时,它们会变得巨大。
¶ 创建一个 LichenEater
生物。它以 10
的能量开始,并以以下方式行为
- 当它的能量达到 30 或更高,并且附近有空间时,它会繁殖。
- 否则,如果附近有地衣,它会吃掉一个随机的地衣。
- 否则,如果有空间移动,它会移动到附近一个随机的空方格中。
- 否则,它会等待。
¶ 使用 findDirections
和 randomElement
来检查周围环境并选择方向。为地衣吞噬者指定 "c"
作为其字符(吃豆人)。
function LichenEater() { this.energy = 10; } LichenEater.prototype.act = function(surroundings) { var emptySpace = findDirections(surroundings, " "); var lichen = findDirections(surroundings, "*"); if (this.energy >= 30 && emptySpace.length > 0) return {type: "reproduce", direction: randomElement(emptySpace)}; else if (lichen.length > 0) return {type: "eat", direction: randomElement(lichen)}; else if (emptySpace.length > 0) return {type: "move", direction: randomElement(emptySpace)}; else return {type: "wait"}; }; LichenEater.prototype.character = "c"; creatureTypes.register(LichenEater);
¶ 并尝试一下。
var lichenPlan = ["############################", "# ######", "# *** **##", "# *##** ** c *##", "# *** c ##** *#", "# c ##*** *#", "# ##** *#", "# c #* *#", "#* #** c *#", "#*** ##** c **#", "#***** ###*** *###", "############################"]; var terrarium = new LifeLikeTerrarium(lichenPlan); terrarium.onStep = partial(inPlacePrinter(), terrarium); terrarium.start();
¶ 最有可能的是,你会看到地衣很快会过度生长到温室的很大一部分,之后食物的丰富使得吞噬者数量众多,以至于它们消灭了所有地衣,以及它们自己。啊,大自然的悲剧。
terrarium.stop();
¶ 让你的温室的居民在几分钟后灭绝有点令人沮丧。为了解决这个问题,我们必须教我们的地衣吞噬者有关长期可持续农业的知识。通过让它们只在看到至少两个地衣时才吃东西,无论它们多么饥饿,它们永远不会消灭地衣。这需要一些纪律,但结果是一个不会自我毁灭的生物群落。这是一个新的 act
方法——唯一的变化是,它现在只在 lichen.length
至少为 2 时才吃东西。
LichenEater.prototype.act = function(surroundings) { var emptySpace = findDirections(surroundings, " "); var lichen = findDirections(surroundings, "*"); if (this.energy >= 30 && emptySpace.length > 0) return {type: "reproduce", direction: randomElement(emptySpace)}; else if (lichen.length > 1) return {type: "eat", direction: randomElement(lichen)}; else if (emptySpace.length > 0) return {type: "move", direction: randomElement(emptySpace)}; else return {type: "wait"}; };
¶ 再次运行上面的 lichenPlan
温室,看看结果如何。除非你非常幸运,否则地衣吞噬者可能仍然会在一段时间后灭绝,因为在饥饿的大规模时期,它们会在空旷的空间里漫无目的地爬来爬去,而不是找到就在拐角处的那些地衣。
¶ 找到一种方法来修改 LichenEater
,使其更有可能存活下来。不要作弊——this.energy += 100
是作弊。如果你重写了构造函数,不要忘记在 creatureTypes
字典中重新注册它,否则温室将继续使用旧的构造函数。
¶ 一种方法是减少其运动的随机性。通过始终选择一个随机方向,它会经常来回移动,却无法到达任何地方。通过记住它上次去的方向,并优先选择那个方向,吞噬者会浪费更少的时间,并且更快地找到食物。
function CleverLichenEater() { this.energy = 10; this.direction = "ne"; } CleverLichenEater.prototype.act = function(surroundings) { var emptySpace = findDirections(surroundings, " "); var lichen = findDirections(surroundings, "*"); if (this.energy >= 30 && emptySpace.length > 0) { return {type: "reproduce", direction: randomElement(emptySpace)}; } else if (lichen.length > 1) { return {type: "eat", direction: randomElement(lichen)}; } else if (emptySpace.length > 0) { if (surroundings[this.direction] != " ") this.direction = randomElement(emptySpace); return {type: "move", direction: this.direction}; } else { return {type: "wait"}; } }; CleverLichenEater.prototype.character = "c"; creatureTypes.register(CleverLichenEater);
¶ 使用之前的温室计划尝试一下。
¶ 一个单链食物链仍然有点原始。你能编写一个新的生物 LichenEaterEater
(字符 "@"
),它通过吃地衣吞噬者来生存吗?试着找到一种方法让它融入生态系统,而不会很快灭绝。修改 lichenPlan
数组以包含其中的一些,并尝试一下。
¶ 你自己尝试吧。我无法找到真正好的方法来阻止这些生物要么马上灭绝,要么吞噬掉所有地衣吞噬者然后灭绝。只有在发现两块食物时才吃的技巧对它们来说不太有效,因为它们的食物到处移动,很难在一个地方找到两个。似乎有帮助的是让吞噬者吞噬者变得非常胖(高能量),这样它就可以在没有地衣吞噬者的情况下生存,而且繁殖速度很慢,这可以阻止它太快地消灭其食物来源。
¶ 地衣和吞噬者经历着周期性的运动——有时地衣很多,导致许多吞噬者出生,这导致地衣变得稀少,这导致吞噬者饿死,这导致地衣变得丰富,等等。你可以尝试让地衣吞噬者吞噬者“冬眠”(在找不到食物的情况下使用 "wait"
动作)。如果你为这个冬眠选择正确的时间,或者让他们在闻到大量食物时自动醒来,这可能是一个好策略。
¶ 到这里就结束了我们对温室的讨论。本章的其余部分将深入探讨继承以及与 JavaScript 中的继承相关的问题。
¶ 首先,一些理论。面向对象编程的学生经常会听到关于继承的正确和错误用法的冗长、微妙的讨论。重要的是要记住,继承最终只是一个技巧,它允许懒惰的3程序员编写更少的代码。因此,关于是否正确使用继承的问题归结为最终代码是否正确工作以及是否避免了无用的重复。尽管如此,这些学生所使用的原理提供了一种很好的方式来开始思考继承。
¶ 继承是基于现有类型(超类型)创建一个新类型的对象(子类型)。子类型从超类型的属性和方法开始,继承它们,然后修改其中的一些,并可选地添加新的属性和方法。当子类型建模的事物可以被认为是超类型对象时,继承最有效。
¶ 因此,Piano
类型可以是 Instrument
类型的子类型,因为钢琴是一种乐器。因为钢琴有一整排琴键,所以人们可能会想要让 Piano
成为 Array
的子类型,但钢琴不是数组,这样实现它一定会导致各种各样的愚蠢行为。例如,钢琴也有踏板。为什么 piano[0]
会给我第一个琴键,而不是第一个踏板?当然,情况是,钢琴拥有琴键,因此最好给它一个 keys
属性,可能还有一个 pedals
属性,两者都包含数组。
¶ 子类型可以是另一个子类型的超类型。一些问题最好通过构建复杂的类型家族树来解决。不过,你必须注意不要过度依赖继承。过度使用继承是将程序变成一大堆混乱的绝佳方式。
¶ new
关键字和构造函数的 prototype
属性的工作原理表明了使用对象的某种方式。对于简单的对象,例如温室生物,这种方式效果很好。不幸的是,当一个程序开始认真使用继承时,这种对象方法很快就会变得笨拙。添加一些函数来处理常见操作可以使事情变得更顺畅一些。例如,许多人会在对象上定义 inherit
和 method
方法。
Object.prototype.inherit = function(baseConstructor) { this.prototype = clone(baseConstructor.prototype); this.prototype.constructor = this; }; Object.prototype.method = function(name, func) { this.prototype[name] = func; }; function StrangeArray(){} StrangeArray.inherit(Array); StrangeArray.method("push", function(value) { Array.prototype.push.call(this, value); Array.prototype.push.call(this, value); }); var strange = new StrangeArray(); strange.push(4); show(strange);
¶ 如果你在网上搜索“JavaScript”和“继承”,你会发现很多关于它的不同变体,其中一些比上面介绍的复杂得多,也聪明得多。
¶ 请注意,这里编写的 push
方法如何使用其父类型原型中的 push
方法。这是使用继承时经常做的事情——子类型中的方法在内部使用超类型的方法,但对其进行扩展。
¶ 这种基本方法最大的问题是构造函数和原型之间的二元性。构造函数扮演着非常重要的角色,它们是为对象类型命名的事物,当你需要访问原型时,你必须访问构造函数并获取它的 prototype
属性。
¶ 这不仅会导致大量的输入("prototype"
有 9 个字母),而且还会造成混淆。在上面的例子中,我们不得不为 StrangeArray
写一个空且无用的构造函数。很多次,我发现自己不小心将方法添加到构造函数而不是其原型,或者试图调用 Array.slice
,而实际上我应该调用 Array.prototype.slice
。在我看来,原型本身是对象类型最重要的方面,而构造函数只是原型的扩展,一种特殊的方法。
¶ 通过在 Object.prototype
中添加一些简单的辅助方法,可以创建一种对象和继承的替代方法。在这种方法中,类型由其原型表示,我们将使用大写字母的变量来存储这些原型。当需要进行任何“构造”工作时,可以通过名为 construct
的方法完成。我们在 Object
原型中添加了一个名为 create
的方法,它用来代替 new
关键字。它克隆对象,并调用其 construct
方法(如果存在此方法),并将传递给 create
的参数传递给它。
Object.prototype.create = function() { var object = clone(this); if (typeof object.construct == "function") object.construct.apply(object, arguments); return object; };
¶ 继承可以通过克隆原型对象并添加或替换其某些属性来实现。我们还为其提供了一个便捷的简写方法,即 extend
方法,它克隆应用于其的对象,并向此克隆添加传递给它的对象中的属性。
Object.prototype.extend = function(properties) { var result = clone(this); forEachIn(properties, function(name, value) { result[name] = value; }); return result; };
¶ 在不能修改 Object
原型的场景下,这些方法当然可以作为普通函数(非方法)实现。
¶ 举个例子。如果你足够大,你可能曾经玩过“文字冒险”游戏,你在游戏中通过输入命令来移动虚拟世界,并获得周围事物以及你执行的动作的文字描述。那可是游戏!
¶ 我们可以这样写出这种游戏中一个物品的原型。
var Item = { construct: function(name) { this.name = name; }, inspect: function() { print("it is ", this.name, "."); }, kick: function() { print("klunk!"); }, take: function() { print("you can not lift ", this.name, "."); } }; var lantern = Item.create("the brass lantern"); lantern.kick();
¶ 像这样从它继承……
var DetailedItem = Item.extend({ construct: function(name, details) { Item.construct.call(this, name); this.details = details; }, inspect: function() { print("you see ", this.name, ", ", this.details, "."); } }); var giantSloth = DetailedItem.create( "the giant sloth", "it is quietly hanging from a tree, munching leaves"); giantSloth.inspect();
¶ 省略强制的 prototype
部分,使得从 DetailedItem
的构造函数中调用 Item.construct
变得稍微简单一些。注意,在 DetailedItem.construct
中直接使用 this.name = name
是个坏主意。这会重复一行代码。当然,重复一行代码比调用 Item.construct
函数更短,但是如果我们最终在该构造函数中添加了其他内容,就必须在两个地方都添加。
¶ 大多数情况下,子类型的构造函数应该首先调用超类型的构造函数。这样,它就从一个有效的超类型对象开始,然后可以扩展它。在这种新的原型方法中,不需要构造函数的类型可以省略它。它们将自动继承其超类型的构造函数。
var SmallItem = Item.extend({ kick: function() { print(this.name, " flies across the room."); }, take: function() { // (imagine some code that moves the item to your pocket here) print("you take ", this.name, "."); } }); var pencil = SmallItem.create("the red pencil"); pencil.take();
¶ 即使 SmallItem
没有定义自己的构造函数,用 name
参数创建它也起作用,因为它从 Item
原型继承了构造函数。
¶ JavaScript 有一个名为 instanceof
的运算符,可用于确定对象是否基于某个原型。你将对象放在左侧,将构造函数放在右侧,它返回一个布尔值,如果构造函数的 prototype
属性是对象的直接或间接原型,则返回 true
,否则返回 false
。
¶ 当你不使用常规构造函数时,使用此运算符就会变得相当笨拙——它期望一个构造函数作为它的第二个参数,但我们只有原型。类似于 clone
函数的技巧可以用来解决这个问题:我们使用一个“假构造函数”,并将 instanceof
应用于它。
Object.prototype.hasPrototype = function(prototype) { function DummyConstructor() {} DummyConstructor.prototype = prototype; return this instanceof DummyConstructor; }; show(pencil.hasPrototype(Item)); show(pencil.hasPrototype(DetailedItem));
¶ 接下来,我们要制作一个具有详细描述的小物品。看起来这个物品必须同时从 DetailedItem
和 SmallItem
继承。JavaScript 不允许一个对象有多个原型,即使有,问题也不那么容易解决。例如,如果 SmallItem
由于某种原因也定义了一个 inspect
方法,那么新原型应该使用哪一个 inspect
方法呢?
¶ 从多个父类型派生一个对象类型称为 多重继承。一些语言退缩了,完全禁止它,另一些语言定义了复杂的方案,以便以一种定义明确且实用的方式使其发挥作用。在 JavaScript 中实现一个像样的多重继承框架是可能的。事实上,与往常一样,有多种很好的方法可以实现这一点。但是它们都过于复杂,在这里无法讨论。相反,我将展示一种在大多数情况下足够简单的方法。
¶ 一个 mixin 是一种特殊的原型,可以“混合”到其他原型中。SmallItem
可以被视为这样的原型。通过将它的 kick
和 take
方法复制到另一个原型中,我们将“小”特性混合到了这个原型中。
function mixInto(object, mixIn) { forEachIn(mixIn, function(name, value) { object[name] = value; }); }; var SmallDetailedItem = clone(DetailedItem); mixInto(SmallDetailedItem, SmallItem); var deadMouse = SmallDetailedItem.create( "Fred the mouse", "he is dead"); deadMouse.inspect(); deadMouse.kick();
¶ 请记住,forEachIn
只遍历对象自己的属性,因此它会复制 kick
和 take
,但不会复制 SmallItem
从 Item
继承的构造函数。
¶ 当 mixin 有构造函数,或当它的某些方法与它所混合的原型中的方法“冲突”时,混合原型就会变得更加复杂。有时,手动 mixin 是可行的。假设我们有一个原型 Monster
,它有自己的构造函数,我们想要将它与 DetailedItem
混合起来。
var Monster = Item.extend({ construct: function(name, dangerous) { Item.construct.call(this, name); this.dangerous = dangerous; }, kick: function() { if (this.dangerous) print(this.name, " bites your head off."); else print(this.name, " runs away, weeping."); } }); var DetailedMonster = DetailedItem.extend({ construct: function(name, description, dangerous) { DetailedItem.construct.call(this, name, description); Monster.construct.call(this, name, dangerous); }, kick: Monster.kick }); var giantSloth = DetailedMonster.create( "the giant sloth", "it is quietly hanging from a tree, munching leaves", true); giantSloth.kick();
¶ 但请注意,这会导致创建 DetailedMonster
时 Item
构造函数被调用两次——一次通过 DetailedItem
构造函数,一次通过 Monster
构造函数。在这种情况下,并不会造成太大危害,但在某些情况下,这会导致问题。