diff --git a/6.md b/6.md index ba1c103..24b35c2 100644 --- a/6.md +++ b/6.md @@ -1,90 +1,98 @@ ## 六、深入理解对象 -> The problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. +> 抽象数据类型是通过编写一种特殊的程序来实现的,该程序根据可在其上执行的操作来定义类型。 > -> Joe Armstrong,《Coders at Work》中的采访 +> Barbara Liskov,《Programming with Abstract Data Types》 -当程序员说“对象”(object)时,你要知道这可是一个内涵丰富的术语。在我的职业当中,对象就是一种生活方式、是神圣工作的一部分,同时也是现如今大家热衷讨论的词语。 +第 4 章介绍了 JavaScript 的对象。 在编程文化中,我们有一个名为面向对象编程的东西,这是一组技术,使用对象(和相关概念)作为程序组织的中心原则。 -在外人看来,这些话可能有些让人摸不着头脑。让我们一起来简要了解一下对象作为编程结构的历史。 +虽然没有人真正同意其精确定义,但面向对象编程已经成为了许多编程语言的设计,包括 JavaScript 在内。 本章将描述这些想法在 JavaScript 中的应用方式。 -### 6.1 历史 +### 封装 -与绝大多数程序设计的故事一样,我们先从需要解决的问题的复杂性切入。有一种理论说,我们可以将复杂的问题划分成小而独立的子问题来解决,这样复杂的问题就可以迎刃而解了。而这些子问题被称之为对象。 +面向对象编程的核心思想是将程序分成小型片段,并让每个片段负责管理自己的状态。 -对象就像一个坚硬的外壳,它隐藏了其内部的复杂性,并提供给我们一些旋钮和接头(比如方法)作为操纵对象的接口。其想法是这样的:我们可以忽略对象内部的复杂性,转而直接使用相对简单的接口。 +通过这种方式,一些程序片段的工作方式的知识可以局部保留。 从事其他方面的工作的人,不必记住甚至不知道这些知识。 无论什么时候这些局部细节发生变化,只需要直接更新其周围的代码。 -我们举个例子,假设一个对象提供了操作显示屏界面部分区域的接口,你可以使用这些接口在屏幕特定区域中绘制形状或文字,而不必考虑如何将这些形状转换成屏幕上的像素。你可以使用一套方法(比如drawCircle)来操作对象,这也是你使用该对象唯一需要了解的事情。 +这种程序的不同片段通过接口,函数或绑定的有限集合交互,它以更抽象的级别提供有用的功能,并隐藏它的精确实现。 -这些想法最早出现于20世纪70和80年代,而在20世纪90年代,随着面向对象的编程变革的兴起,这些思想才真正流行起来。突然之间,就有一群人开始宣扬面向对象才是编程的正道,而且任何没有使用面向对象思想的程序都是过时的设计。 +这些程序片段使用对象建模。 它们的接口由一组特定的方法和属性组成。 接口的一部分的属性称为公共的。 其他外部代码不应该接触属性的称为私有的。 -![](../Images/00191.jpeg) +许多语言提供了区分公共和私有属性的方法,并且完全防止外部代码访问私有属性。 JavaScript 再次采用极简主义的方式,没有。 至少目前还没有 - 有个正在开展的工作,将其添加到该语言中。 -这些狂热的言论总会造成一些不切实际的荒谬行径,也因此产生了一群反对面向对象变革的人。现如今在某些领域里,面向对象甚至被认为是一种不好的设计。 +即使这种语言没有内置这种区别,JavaScript 程序员也成功地使用了这种想法。 通常,可用的接口在文档或数字一中描述。 在属性名称的的开头经常会放置一个下划线(`_`)字符,来表明这些属性是私有的。 -我喜欢从实际应用的角度,而非从单纯的意识形态上来看待问题。面向对象的变革推动了一些概念的流行,其中不乏一些十分有用的概念,其中最重要的一个概念就是封装(将内部的复杂性与外部接口区分开来)。这些概念值得我们学习。 - -本章将会对JavaScript中处理对象的不同之处以及一般的面向对象技术进行详细阐述。 +将接口与实现分离是一个好主意。 它通常被称为封装。 ### 6.2 方法 -方法只是引用了函数值的属性。以下是一个简单的方法: +方法不过是持有函数值的属性。 这是一个简单的方法: ``` -var rabbit = {}; +let rabbit = {}; rabbit.speak = function(line) { - console.log("The rabbit says '" + line + "'"); + console.log(`The rabbit says '${line}'`); }; rabbit.speak("I'm alive."); // → The rabbit says 'I'm alive.' ``` -方法通常会在对象被调用时执行一些操作。将函数作为对象的方法调用时,会找到对象中对应的属性并直接调用。在调用object.method()时,对象中的一个特殊变量this会指向当前方法所属的对象。 +方法通常会在对象被调用时执行一些操作。将函数作为对象的方法调用时,会找到对象中对应的属性并直接调用。当函数作为方法调用时,函数体内叫做`this`的绑定自动指向在它上面调用的对象。 ``` function speak(line) { - console.log("The " + this.type + " rabbit says '" + - line + "'"); + console.log(`The ${this.type} rabbit says '${line}'`); } -var whiteRabbit = {type: "white", speak: speak}; -var fatRabbit = {type: "fat", speak: speak}; +let whiteRabbit = {type: "white", speak: speak}; +let fatRabbit = {type: "fat", speak: speak}; whiteRabbit.speak("Oh my ears and whiskers, " + "how late it's getting!"); // → The white rabbit says 'Oh my ears and whiskers, how // late it's getting!' -fatRabbit.speak("I could sure use a carrot right now."); -// → The fat rabbit says 'I could sure use a carrot -// right now.' +hungryRabbit.speak("I could use a carrot right now."); +// → The hungry rabbit says 'I could use a carrot right now.' +``` + +你可以把`this`看作是以不同方式传递的额外参数。 如果你想显式传递它,你可以使用函数的`call`方法,它接受`this`值作为第一个参数,并将其它处理为看做普通参数。 + +``` +speak.call(hungryRabbit, "Burp!"); +// → The hungry rabbit says 'Burp!' ``` 这段代码使用了关键字this来输出正在说话的兔子的种类。我们回想一下apply和bind方法,这两个方法接受的第一个参数可以用来模拟对象中方法的调用。这两个方法会把第一个参数复制给this。 -函数有一个call方法,类似于apply方法。该方法也可以用于函数调用,但该方法会像普通函数一样接受参数,我们不需要将参数放到数组中。和apply和bind方法一样,你也可以向call方法传递一个特定的this值。 +由于每个函数都有自己的`this`绑定,它的值依赖于它的调用方式,所以在用`function`关键字定义的常规函数中,不能引用外层作用域的`this`。 + +箭头函数是不同的 - 它们不绑定他们自己的`this`,但可以看到他们周围(定义位置)作用域的`this`绑定。 因此,你可以像下面的代码那样,在局部函数中引用`this`: ``` -speak.apply(fatRabbit, ["Burp!"]); -// → The fat rabbit says 'Burp!' -speak.call({type: "old"}, "Oh my."); -// → The old rabbit says 'Oh my.' +function normalize() { + console.log(this.coords.map(n => n / this.length)); +} +normalize.call({coords: [0, 2, 3], length: 5}); +// → [0, 0.4, 0.6] ``` -### 6.3 原型 +如果我使用`function`关键字将参数写入`map`,则代码将不起作用。 + +### 原型 我们来仔细看看以下这段代码。 ``` -var empty = {}; +let empty = {}; console.log(empty.toString); // → function toString(){…} console.log(empty.toString()); // → [object Object] ``` -我从空的对象中取出了一个属性。有意思! +我从一个空对象中取出了一个属性。 好神奇! -实际上并非如此。我只是掩盖了一些JavaScript对象的内部工作细节罢了。每个对象除了拥有自己的属性外,几乎都包含一个原型(prototype)。原型是另一个对象,是对象的一个属性来源。当开发人员访问一个对象不包含的属性时,就会从对象原型中搜索属性,接着是原型的原型,依此类推。 +实际上并非如此。我只是掩盖了一些JavaScript对象的内部工作细节罢了。每个对象除了拥有自己的属性外,都包含一个原型(prototype)。原型是另一个对象,是对象的一个属性来源。当开发人员访问一个对象不包含的属性时,就会从对象原型中搜索属性,接着是原型的原型,依此类推。 那么空对象的原型是什么呢?是Object.prototype,它是所有对象中原型的父原型。 @@ -96,14 +104,14 @@ console.log(Object.getPrototypeOf(Object.prototype)); // → null ``` -和我们所想的结果一样,Object.getPrototypeOf函数返回了一个空对象的原型,即Object.prototype。 +正如你的猜测,`Object.getPrototypeOf`返回一个对象的原型。 JavaScript对象原型的关系是一种树形结构,整个树形结构的根部就是Object.prototype。Object.prototype提供了一些可以在所有对象中使用的方法。比如说,toString方法可以将一个对象转换成其字符串表示形式。 -许多对象并不直接将Object.prototype作为其原型,而会使用另一个原型对象,用于提供对象自己的默认属性。函数继承自Function.prototype,而数组继承自Array.prototype。 +许多对象并不直接将Object.prototype作为其原型,而会使用另一个原型对象,用于提供一系列不同的默认属性。函数继承自Function.prototype,而数组继承自Array.prototype。 ``` -console.log(Object.getPrototypeOf(isNaN) == +console.log(Object.getPrototypeOf(Math.max) == Function.prototype); // → true console.log(Object.getPrototypeOf([]) == @@ -113,58 +121,103 @@ console.log(Object.getPrototypeOf([]) == 对于这样的原型对象来说,其自身也包含了一个原型对象,通常情况下是Object.prototype,所以说,这些原型对象可以间接提供toString这样的方法。 -Object.getPrototypeOf函数返回的结果是对象原型。你可以利用一个特定的原型来使用Object.create方法创建对象。 +你可以使用`Object.create`来创建一个具有特定原型的对象。 ``` -var protoRabbit = { - speak: function(line) { - console.log("The " + this.type + " rabbit says '" + - line + "'"); +let protoRabbit = { + speak(line) { + console.log(`The ${this.type} rabbit says '${line}'`); } }; -var killerRabbit = Object.create(protoRabbit); +let killerRabbit = Object.create(protoRabbit); killerRabbit.type = "killer"; killerRabbit.speak("SKREEEE!"); // → The killer rabbit says 'SKREEEE!' ``` +像对象表达式中的`speak(line)`这样的属性是定义方法的简写。 它创建了一个名为`speak`的属性,并向其提供函数作为它的值。 + 原型对象protoRabbit是一个容器,用于包含所有兔子对象的公有属性。每个独立的兔子对象(比如killerRabbit)可以包含其自身属性(比如本例中的type属性),也可以派生其原型对象中公有的属性。 -### 6.4 构造函数 +### 类 -这里有一种更加简便的方法创建对象,我们可以直接从一些公有原型中派生并构造对象,即使用构造函数来创建对象。在JavaScript中,调用函数之前添加一个关键字new则表示调用其构造函数。构造函数中包含了指向新对象的变量this,除非构造函数显式地返回了另一个对象的值,否则构造函数会返回这个新创建的对象。 +JavaScript 的原型系统可以解释为对一种面向对象的概念(称为类(class))的某种非正式实现。 类定义了对象的类型的形状 - 它具有什么方法和属性。 这样的对象被称为类的实例(instance)。 -通过关键字new创建的对象称之为构造函数的实例。 +原型对于属性来说很实用。一个类的所有实例共享相同的属性值,例如方法。 每个实例上的不同属性,比如我们的兔子的`type`属性,需要直接存储在对象本身中。 -这里给出一个简单的用于创建rabbit的构造函数。按照惯例,构造函数的名称一般以大写字母开头,这样便于区分构造函数与其他的函数。 +所以为了创建一个给定类的实例,你必须使对象从正确的原型派生,但是你也必须确保,它本身具有这个类的实例应该具有的属性。 这是构造器(constructor)函数的作用。 + +``` +function makeRabbit(type) { + let rabbit = Object.create(protoRabbit); + rabbit.type = type; + return rabbit; +} +``` + +JavaScript 提供了一种方法,来使得更容易定义这种类型的功能。 如果将关键字`new`放在函数调用之前,则该函数将被视为构造器。 这意味着具有正确原型的对象会自动创建,绑定到函数中的`this`,并在函数结束时返回。 + +构造对象时使用的原型对象,可以通过构造器的`prototype`属性来查找。 ``` function Rabbit(type) { this.type = type; } -var killerRabbit = new Rabbit("killer"); -var blackRabbit = new Rabbit("black"); -console.log(blackRabbit.type); -// → black -``` - -对于构造函数来说(实际上,对所有函数适用),都会自动获得一个名为prototype的属性。在默认情况下,该属性是一个普通的派生自Object.prototype的空对象。所有使用特定构造函数创建的对象都会将构造函数的prototype属性作为其原型。因此,我们可以很容易地为所有使用Rabbit构造函数创建的对象添加speak方法。 - -``` Rabbit.prototype.speak = function(line) { - console.log("The " + this.type + " rabbit says '" + - line + "'"); + console.log(`The ${this.type} rabbit says '${line}'`); }; -blackRabbit.speak("Doom..."); -// → The black rabbit says 'Doom...' +let weirdRabbit = new Rabbit("weird"); ``` -我们需要注意两种方法使用上的区别,第一种是使用构造函数建立对象与原型之间的关联(直接通过prototype属性来获取),第二种是将原型对象作为对象的属性(使用Object.getPrototyOf获取属性)。构造函数其实就是函数,因此其实际原型是Function.prototype。而构造函数的prototype属性则是其所创建的实例的原型,而非构造函数自身的原型。 +构造器(实际上是所有函数)都会自动获得一个名为`prototype`的属性,默认情况下它包含一个普通的,来自`Object.prototype`的空对象。 如果需要,可以用新对象覆盖它。 或者,您可以将属性添加到现有对象,如示例所示。 -### 6.5 覆盖继承的属性 +按照惯例,构造器的名字是大写的,这样它们可以很容易地与其他函数区分开来。 -当你向一个对象添加属性时,无论该属性是否存已经存在于对象原型中,该属性都会被添加到这个对象中去,并作为对象自己的属性使用。如果原型中存在同名属性,那么在调用该属性时,就不会再调用原型中的那个属性了,转而调用我们添加到对象中的属性。但原型本身不会被修改。 +重要的是,理解原型与构造器关联的方式(通过其`prototype`属性),与对象拥有原型(可以通过`Object.getPrototypeOf`查找)的方式之间的区别。 构造器的实际原型是`Function.prototype`,因为构造器是函数。 它的`prototype`属性拥有原型,用于通过它创建的实例。 + +``` +console.log(Object.getPrototypeOf(Rabbit) == + Function.prototype); +// → true +console.log(Object.getPrototypeOf(weirdRabbit) == + Rabbit.prototype); +// → true +``` + +## 类的表示法 + +所以 JavaScript 类是带有原型属性的构造器。 这就是他们的工作方式,直到 2015 年,这就是你编写他们的方式。 最近,我们有了一个不太笨拙的表示法。 + +``` +class Rabbit { + constructor(type) { + this.type = type; + } + speak(line) { + console.log(`The ${this.type} rabbit says '${line}'`); + } +} + +let killerRabbit = new Rabbit("killer"); +let blackRabbit = new Rabbit("black"); +``` + +`class`关键字是类声明的开始,它允许我们在一个地方定义一个构造器和一组方法。 可以在声明的大括号内写入任意数量的方法。 一个名为`constructor`的对象受到特别处理。 它提供了实际的构造器,它将绑定到名称`"Rabbit"`。 其他函数被打包到该构造器的原型中。 因此,上面的类声明等同于上一节中的构造器定义。 它看起来更好。 + +类声明目前只允许方法 - 持有函数的属性 - 添加到原型中。 当你想在那里保存一个非函数值时,这可能会有点不方便。 该语言的下一个版本可能会改善这一点。 现在,您可以在定义该类后直接操作原型来创建这些属性。 + +像`function`一样,`class`可以在语句和表达式中使用。 当用作表达式时,它没有定义绑定,而只是将构造函数作为一个值生成。 您可以在类表达式中省略类名称。 + +``` +let object = new class { getWord() { return "hello"; } }; +console.log(object.getWord()); +// → hello +``` + +### 覆盖派生的属性 + +将属性添加到对象时,无论它是否存在于原型中,该属性都会添加到对象本身中。 如果原型中已经有一个同名的属性,该属性将不再影响对象,因为它现在隐藏在对象自己的属性后面。 ``` Rabbit.prototype.teeth = "small"; @@ -183,9 +236,9 @@ console.log(Rabbit.prototype.teeth); ![](../Images/00204.jpeg) -覆盖原型中存在的属性是很有用的一个特性。就像示例展示的那样,我们覆盖了killerRabbit的teeth属性,这可以用来描述实例(对象中更为泛化的类的实例)的特殊属性,同时又可以让普通对象从原型中获取标准的值。 +覆盖原型中存在的属性是很有用的特性。就像示例展示的那样,我们覆盖了killerRabbit的teeth属性,这可以用来描述实例(对象中更为泛化的类的实例)的特殊属性,同时又可以让简单对象从原型中获取标准的值。 -我们也可以为标准函数和数组原型提供一个不同于Object原型的toString方法。 +覆盖也用于向标准函数和数组原型提供`toString`方法,与基本对象的原型不同。 ``` console.log(Array.prototype.toString == @@ -202,127 +255,98 @@ console.log(Object.prototype.toString.call([1, 2])); // → [object Array] ``` -### 6.6 原型污染 +### 映射 -我们随时都可以使用原型对象添加新的属性和方法。比如说,我们也许希望兔子能够跳舞。 +我们在上一章中看到了映射(map)这个词,用于一个操作,通过对元素应用函数来转换数据结构。 令人困惑的是,在编程时,同一个词也被用于相关而不同的事物。 + +映射(名词)是将值(键)与其他值相关联的数据结构。 例如,您可能想要将姓名映射到年龄。 为此可以使用对象。 ``` -Rabbit.prototype.dance = function() { - console.log("The " + this.type + " rabbit dances a jig."); +let ages = { + Boris: 39, + Liang: 22, + Júlia: 62 }; -killerRabbit.dance(); -// → The killer rabbit dances a jig. + +console.log(`Júlia is ${ages["Júlia"]}`); +// → Júlia is 62 +console.log("Is Jack's age known?", "Jack" in ages); +// → Is Jack's age known? false +console.log("Is toString's age known?", "toString" in ages); +// → Is toString's age known? true ``` -这种方法非常方便,但有时也会引发问题。在前面的章节中,我们使用对象将一些值和名称关联起来,实现方法是为这些名称创建对应的属性,并赋予这些属性相应的值。这里给出第4章中的示例: +在这里,对象的属性名称是人们的姓名,并且该属性的值为他们的年龄。 但是我们当然没有在我们的映射中列出任何名为`toString`的人。 似的,因为简单对象是从`Object.prototype`派生的,所以它看起来就像拥有这个属性。 + +因此,使用简单对象作为映射是危险的。 有几种可能的方法来避免这个问题。 首先,可以使用`null`原型创建对象。 如果将`null`传递给`Object.create`,那么所得到的对象将不会从`Object.prototype`派生,并且可以安全地用作映射。 ``` -var map = {}; -function storePhi(event, phi) { - map[event] = phi; -} - -storePhi("pizza", 0.069); -storePhi("touched tree", -0.081); -``` - -我们可以使用for/in循环遍历对象中所有的phi系数,并使用in操作符测试对象是否包含对应的属性。但不幸的是,这种方式会到对象的原型中寻找属性。 - -``` -Object.prototype.nonsense = "hi"; -for (var name in map) - console.log(name); -// → pizza -// → touched tree -// → nonsense -console.log("nonsense" in map); -// → true -console.log("toString" in map); -// → true - -// Delete the problematic property again -delete Object.prototype.nonsense; -``` - -上面的代码是错误的,我们的数据集中并没有名为“nonsense”的事件。 - -而且这里肯定也没有名为“toString”的事件。 - -奇怪的是,toString并没有出现在for/in循环中,而使用in运算符测试时则返回true。这是因为JavaScript会区分“可枚举(enumerable)”与“不可枚举(nonenumerable)”属性。 - -我们创建并赋予对象的所有属性都是可枚举的。而Object.prototype中的标准属性都不可枚举,因此这些标准属性不会出现在for/in循环中。 - -我们可以使用Object.defineProperty函数定义自己的不可枚举属性,该函数允许我们在创建属性时控制属性类型。 - -``` -Object.defineProperty(Object.prototype, "hiddenNonsense", - {enumerable: false, value: "hi"}); -for (var name in map) - console.log(name); -// → pizza -// → touched tree -console.log(map.hiddenNonsense); -// → hi -``` - -因此现在hiddenNonsense属性确实存在,但不会出现在循环中。这就是我们想要的效果。但目前依然有个问题,常规的in运算符会认为Object.prototype中的属性存在于我们的对象中。为此,我们可以使用对象的hasOwnProperty方法。 - -``` -console.log(map.hasOwnProperty("toString")); +console.log("toString" in Object.create(null)); // → false ``` -该方法告知我们对象自身是否包含某个属性,而不会搜索其原型。该方法提供的信息往往比in运算符给出的结果更有用。 +对象属性名称必须是字符串。 如果你需要一个映射,它的键不能轻易转换为字符串 - 比如对象 - 你不能使用对象作为你的映射。 -当你担心某些人(装载到你程序中的某些其他代码)会干扰基础对象的原型时,我建议这样使用for/in循环: +幸运的是,JavaScript 带有一个叫做`Map`的类,它正是为了这个目的而编写。 它存储映射并允许任何类型的键。 ``` -for (var name in map) { - if (map.hasOwnProperty(name)) { - // ... this is an own property - } -} +let ages = new Map(); +ages.set("Boris", 39); +ages.set("Liang", 22); +ages.set("Júlia", 62); +console.log(`Júlia is ${ages.get("Júlia")}`); +// → Júlia is 62 +console.log("Is Jack's age known?", ages.has("Jack")); +// → Is Jack's age known? false +console.log(ages.has("toString")); + // → false ``` -### 6.7 无原型对象 +`set`,`get`和`has`方法是`Map`对象的接口的一部分。 编写一个可以快速更新和搜索大量值的数据结构并不容易,但我们不必担心这一点。 其他人为我们实现,我们可以通过这个简单的接口来使用他们的工作。 -不过兔子洞的故事还没结束。如果有人在我们的map对象中添加了hasOwnProperty属性并将其值设置为42该怎么办?现在我们调用map.hasOwnProperty就会调用当前对象的属性,该属性指向一个数字,而不是函数。 - -在这种情况下,原型只会给我们造成不必要的麻烦,而我们实际上需要的是一个没有原型的对象。我们可以使用Object.create函数并根据特定原型来创建对象。你可以传递null作为原型,并创建一个无原型对象。对于像map这样的对象来说,属性可以是任何元素,我们恰好可以使用这种方式来创建对象。 +如果您确实有一个简单对象,出于某种原因需要将它视为一个映射,那么了解`Object.keys`只返回对象的自己的键,而不是原型中的那些键,会很有用。 作为`in`运算符的替代方法,您可以使用`hasOwnProperty`方法,该方法会忽略对象的原型。 ``` -var map = Object.create(null); -map["pizza"] = 0.069; -console.log("toString" in map); -// → false -console.log("pizza" in map); +console.log({x: 1}.hasOwnProperty("x")); // → true +console.log({x: 1}.hasOwnProperty("toString")); +// → false ``` -好多了!我们不再需要hasOwnProperty函数,因为对象的所有属性都是对象自己的属性。现在,无论是否有人修改Object.prototype,我们都可以安全地使用for/in循环了。 - ### 6.8 多态 -当你对某个对象调用String函数将对象的值转换成字符串时,该函数将会调用对象的toString方法,创建并返回一个包含某些含义的字符串。我曾提到过一些标准类型的原型都定义了各自版本的toString方法,因此这些对象创建的字符串会比“[object Object]”这样的结果更有意义。 - -我们用简单的例子演示了这个强大的功能。当编写一段代码时,我们可以使用包含特定接口的对象进行工作。在这个例子中是toString方法,只要对象支持这些接口,我们就可以将这些对象插入代码中,并保证代码正常工作。 - -我们将这种技术称为多态(polymorphism)。虽然在整个过程中没有修改任何东西的形状,但我们还是这么称呼这种技术。我们可以利用多态来操作不同类型的值,只要这些值支持所需的接口即可。 - -### 6.9 绘制表格 - -我们来通过一个稍微复杂的例子来深入了解一下多态和面向对象的编程思想。我们编写一个程序,将一个由表格单元格组成的二维数组转化成字符串,该字符串包含了与二维数组对应且布局规整的表格,要求每一列笔直整齐,每一行也要保证对齐。构建出来的字符串如下所示: +当你调用一个对象的`String`函数(将一个值转换为一个字符串)时,它会调用该对象的`toString`方法来尝试从它创建一个有意义的字符串。 我提到一些标准原型定义了自己的`toString`版本,因此它们可以创建一个包含比`"[object Object]"`有用信息更多的字符串。 你也可以自己实现。 ``` -name height country ------------- ------ ------------- -Kilimanjaro 5895 Tanzania -Everest 8848 Nepal -Mount Fuji 3776 Japan -Mont Blanc 4808 Italy/France -Vaalserberg 323 Netherlands -Denali 6168 United States -Popocatepetl 5465 Mexico +Rabbit.prototype.toString = function() { + return `a ${this.type} rabbit`; +}; + +console.log(String(blackRabbit)); +// → a black rabbit +``` + +这是一个强大的想法的简单实例。 当一段代码为了与某些对象协作而编写,这些对象具有特定接口时(在本例中为`toString`方法),任何类型的支持此接口的对象都可以插入到代码中,并且它将正常工作。 + +这种技术被称为多态(polymorphism)。 多态代码可以处理不同形状的值,只要它们支持它所期望的接口即可。 + +我在第四章中提到`for/of`循环可以遍历几种数据结构。 这是多态性的另一种情况 - 这样的循环期望数据结构公开的特定接口,数组和字符串是这样。 你也可以将这个接口添加到你自己的对象中! 但在我们实现它之前,我们需要知道什么是符号。 + +### 符号 + +多个接口可能为不同的事物使用相同的属性名称。 例如,我可以定义一个接口,其中`toString`方法应该将对象转换为一段纱线。 一个对象不可能同时满足这个接口和`toString`的标准用法。 + +这是一个坏主意,这个问题并不常见。 大多数 JavaScript 程序员根本就不会去想它。 但是,语言设计师们正在思考这个问题,无论如何都为我们提供了解决方案。 + +当我声称属性名称是字符串时,这并不完全准确。 他们通常是,但他们也可以是符号(symbol)。 符号是使用`Symbol`函数创建的值。 与字符串不同,新创建的符号是唯一的 - 您不能两次创建相同的符号。 + +``` +let sym = Symbol("name"); +console.log(sym == Symbol("name")); +// → false +Rabbit.prototype[sym] = 55; +console.log(blackRabbit[sym]); +// → 55 ``` 我们的表格绘制程序的构建函数会根据用户输入的宽度和高度来确定每列的宽度和每行的高度。接着,构建函数会根据之前输入的参数来绘制表格,并将最终结果用字符串输出。 @@ -395,7 +419,7 @@ drawRow函数先将行中的单元格对象转换成块(block),每个块 drawLine函数每次从所有块中提取出属于同一行的文本,并将彼此相邻的块用一个空格字符连接起来,这样可以在表格的每一列之间创建一个字符宽度的间隔。 -现在,我们来编写用于创建文本单元格的构造函数,实现表格的单元格接口。构造函数使用split方法将字符串分割成数组,数组中每个元素是一行文本。我们可以给split方法指定一个字符串,每当遇到该字符串时,split方法会使用该参数分割字符串,并返回分割后的字符串片段数组。minWidth方法用于找出数组中字符串的最大宽度。 +现在,我们来编写用于创建文本单元格的构造器,实现表格的单元格接口。构造器使用split方法将字符串分割成数组,数组中每个元素是一行文本。我们可以给split方法指定一个字符串,每当遇到该字符串时,split方法会使用该参数分割字符串,并返回分割后的字符串片段数组。minWidth方法用于找出数组中字符串的最大宽度。 ``` function repeat(string, times) { @@ -505,7 +529,7 @@ console.log(drawTable(dataTable(MOUNTAINS))); ### 6.10 Getter与Setter -在定义接口的时候,我们可以在对象中增加一些非方法属性。我们可以定义minHeight和minWidth属性来存储数字值。但这就需要我们在构造函数中加入计算高度与宽度的逻辑,这样会在构造函数中添加与构造对象无关的代码,并导致一些问题。比如带下划线单元格的内部单元格改变时,带下划线单元格的尺寸也需要改变。 +在定义接口的时候,我们可以在对象中增加一些非方法属性。我们可以定义minHeight和minWidth属性来存储数字值。但这就需要我们在构造器中加入计算高度与宽度的逻辑,这样会在构造器中添加与构造对象无关的代码,并导致一些问题。比如带下划线单元格的内部单元格改变时,带下划线单元格的尺寸也需要改变。 这就促使一些人采用了一种原则,即接口中的所有属性必须都是方法。我们并不直接访问简单的值属性,而是使用get和set方法读取修改属性。使用这种原则的缺点是我们需要额外编写大量用于修改和读取属性的方法。 @@ -549,7 +573,7 @@ console.log(cell.heightProp); 我们还差一些工作才能完成绘制表格的程序。我们希望将数字列右对齐以增强表格的可读性。我们应该创建另一个类似于TextCell的单元格类型,只不过不是右侧填补空格,而是在左侧填补空格,这样就可以将内容右对齐。 -我们可以简单地编写一个新的构造函数,并在原型中添加三个方法。但由于原型自身可以包含原型的,因此我们可以采取更为巧妙的方法来完成任务。 +我们可以简单地编写一个新的构造器,并在原型中添加三个方法。但由于原型自身可以包含原型的,因此我们可以采取更为巧妙的方法来完成任务。 ``` function RTextCell(text) { @@ -566,9 +590,9 @@ RTextCell.prototype.draw = function(width, height) { }; ``` -我们重用了TextCell的构造函数、minHeight和minWidth属性。RTextCell基本上就是TextCell,只不过draw方法中包含了不同的函数。 +我们重用了TextCell的构造器、minHeight和minWidth属性。RTextCell基本上就是TextCell,只不过draw方法中包含了不同的函数。 -我们将这种模式称之为继承。我们可以使用继承来花很少的力气构造出与当前类型相似的数据类型,其中两个数据结构只有细微差别。新的构造函数通常会调用旧的构造函数(使用call方法将新对象作为旧构造函数的this值)。当调用构造函数时,我们可以认为所有旧的对象类型中包含的字段都已经添加到了新对象中。我们让构造函数的原型继承旧的原型对象,因此所有新类型实例都可以访问旧原型中的属性。最后,我们将一些属性添加到新的原型中并覆盖这些属性。 +我们将这种模式称之为继承。我们可以使用继承来花很少的力气构造出与当前类型相似的数据类型,其中两个数据结构只有细微差别。新的构造器通常会调用旧的构造器(使用call方法将新对象作为旧构造器的this值)。当调用构造器时,我们可以认为所有旧的对象类型中包含的字段都已经添加到了新对象中。我们让构造器的原型继承旧的原型对象,因此所有新类型实例都可以访问旧原型中的属性。最后,我们将一些属性添加到新的原型中并覆盖这些属性。 现在,如果我们稍微调整一下dataTable函数,使用RTextCall来处理数字,最终得到的表格就可以满足我们的要求了。 @@ -603,7 +627,7 @@ console.log(drawTable(dataTable(MOUNTAINS))); ### 6.12 instanceof运算符 -在有些时候,了解某个对象是否继承自某个特定构造函数也是十分有用的。JavaScript为此提供了一个二元运算符,名为instanceof。 +在有些时候,了解某个对象是否继承自某个特定构造器也是十分有用的。JavaScript为此提供了一个二元运算符,名为instanceof。 ``` console.log(new RTextCell("A") instanceof RTextCell); @@ -616,25 +640,25 @@ console.log([1] instanceof Array); // → true ``` -该运算符会查遍所有继承类型。RTextCell对象是TextCell的实例,因为RTextCell.prototype派生自TextCell.prototype。该运算符也可以用于标准构造函数(比如Array)。几乎所有对象都是Object的实例。 +该运算符会查遍所有继承类型。RTextCell对象是TextCell的实例,因为RTextCell.prototype派生自TextCell.prototype。该运算符也可以用于标准构造器(比如Array)。几乎所有对象都是Object的实例。 ### 6.13 本章小结 对象比我们之前了解到的复杂许多。对象中有另一个对象:原型,只要原型中包含了属性,那么根据原型构造出来的对象也就可以看成包含了相应的属性。简单对象直接以Object.prototype作为原型。 -构造函数的名称通常以大写字母开头,可以和new运算符一起使用创建新的对象。新对象的原型是构造函数的prototype属性。你可以充分利用原型将特定类型的所有属性放在原型中共享。instanceof运算符可以判断特定对象是否是特定构造函数的实例。 +构造器的名称通常以大写字母开头,可以和new运算符一起使用创建新的对象。新对象的原型是构造器的prototype属性。你可以充分利用原型将特定类型的所有属性放在原型中共享。instanceof运算符可以判断特定对象是否是特定构造器的实例。 我们可以为对象添加接口,用户只需通过接口来使用对象即可。你的对象中的其他细节则被封装了起来,隐藏在接口之后为用户提供所需功能。 当我们谈论接口的时候,其实不同的对象也可以实现相同的接口,只不过不同的对象提供了不同的内部实现细节罢了,我们把这种特性称之为多态。我们会在编程过程中经常使用到多态这个特性。 -如果我们实现的对象之间的差别微乎其微,那么我们可以直接使用原型来创建新的类型,而新类型则通过继承原有类型的原型来实现,并使用新的构造函数来调用原有类型中的构造函数。这样可以得到一个类似于旧类型的新类型,而且我们还可以在原有类型当中添加属性或覆盖属性。 +如果我们实现的对象之间的差别微乎其微,那么我们可以直接使用原型来创建新的类型,而新类型则通过继承原有类型的原型来实现,并使用新的构造器来调用原有类型中的构造器。这样可以得到一个类似于旧类型的新类型,而且我们还可以在原有类型当中添加属性或覆盖属性。 ### 6.14 习题 #### 6.14.1 向量类型 -编写一个构造函数Vector,以二维空间表示数组。该函数接受两个数字参数x和y,并将其保存到对象的同名属性中。 +编写一个构造器Vector,以二维空间表示数组。该函数接受两个数字参数x和y,并将其保存到对象的同名属性中。 向Vector原型添加两个方法:plus和minus,它们接受另一个向量作为参数,分别返回两个向量(一个是this,另一个是参数)的和向量与差向量。