第 4 版现已上市。在此阅读

第 6 章物体的秘密生活

抽象数据类型通过编写一种特殊的程序来实现[…],该程序根据可以对它执行的操作来定义类型。

Barbara Liskov,使用抽象数据类型进行编程
Picture of a rabbit with its proto-rabbit

第 4 章介绍了 JavaScript 的对象。在编程文化中,我们有一种叫做面向对象编程的东西,它是一组使用对象(以及相关概念)作为程序组织核心原则的技术。

虽然没有人真正同意其精确定义,但面向对象编程已经塑造了许多编程语言的设计,包括 JavaScript。本章将描述这些思想如何在 JavaScript 中应用。

封装

面向对象编程的核心思想是将程序分成更小的部分,并让每个部分负责管理自己的状态。

这样,关于程序一部分工作方式的一些知识可以被本地化到该部分。其他部分的工作人员不必记住甚至不知道这些知识。每当这些本地细节发生变化时,只需要更新直接围绕它的代码。

这样程序的不同部分通过接口相互交互,接口是有限的一组函数或绑定,它们以更抽象的级别提供有用的功能,隐藏其精确的实现。

这些程序部分使用对象进行建模。它们的接口由一组特定的方法和属性组成。作为接口一部分的属性称为公共属性。其他外部代码不应该触碰的属性称为私有属性。

许多语言提供了一种方法来区分公共和私有属性,并防止外部代码完全访问私有属性。JavaScript 再次采取了极简主义的方法,还没有——至少还没有。正在进行的工作是为了将此添加到语言中。

尽管语言本身没有内置这种区分,但 JavaScript 程序员正在成功地使用这种想法。通常,可用接口在文档或注释中描述。在属性名称的开头放置下划线 (_) 字符也很常见,以表明这些属性是私有的。

将接口与实现分离是一个好主意。它通常被称为封装

方法

方法只不过是保存函数值的属性。这是一个简单的方法

let rabbit = {};
rabbit.speak = function(line) {
  console.log(`The rabbit says '${line}'`);
};

rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'

通常,方法需要对它被调用的对象做些什么。当一个函数作为方法被调用时——被查找为一个属性并立即调用,如 object.method() 中那样——它主体中称为 this 的绑定会自动指向它被调用的对象。

function speak(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak};
let hungryRabbit = {type: "hungry", 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!'
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 绑定,其值取决于它被调用的方式,因此你不能在使用 function 关键字定义的常规函数中引用包装范围的 this

箭头函数不同——它们不绑定自己的 this,但可以查看周围范围的 this 绑定。因此,你可以做类似于以下代码的事情,它引用了局部函数内的 this

function normalize() {
  console.log(this.coords.map(n => n / this.length));
}
normalize.call({coords: [0, 2, 3], length: 5});
// → [0, 0.4, 0.6]

如果我使用 function 关键字编写了 map 的参数,代码将无法工作。

原型

仔细观察。

let empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

我从一个空对象中提取了一个属性。魔法!

好吧,不是真的。我只是隐瞒了 JavaScript 对象工作方式的信息。除了它们的一组属性之外,大多数对象还具有原型。原型是另一个用作属性备用来源的对象。当一个对象收到一个它没有的属性请求时,它将搜索其原型的属性,然后搜索其原型的原型,依此类推。

那么那个空对象的原型是谁呢?它是伟大的祖先原型,几乎所有对象的实体,Object.prototype

console.log(Object.getPrototypeOf({}) ==
            Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

正如你所猜到的,Object.getPrototypeOf 返回对象的原型。

JavaScript 对象的原型关系形成一个树状结构,在这个结构的根部是 Object.prototype。它提供了一些出现在所有对象中的方法,例如 toString,它将对象转换为字符串表示。

许多对象没有直接将 Object.prototype 作为其原型,而是拥有另一个提供不同默认属性集的对象。函数来自 Function.prototype,数组来自 Array.prototype

console.log(Object.getPrototypeOf(Math.max) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
            Array.prototype);
// → true

这样的原型对象本身也会有原型,通常是 Object.prototype,因此它仍然间接地提供像 toString 这样的方法。

你可以使用 Object.create 创建一个具有特定原型的对象。

let protoRabbit = {
  speak(line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
  }
};
let killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'

对象表达式中的属性,例如 speak(line),是定义方法的一种简写方式。它创建一个名为 speak 的属性,并赋予它一个函数作为其值。

“proto”兔子充当所有兔子共享的属性的容器。单个兔子对象,如杀手兔子,包含仅适用于自身的属性——在本例中是它的类型——并从其原型派生共享属性。

JavaScript 的原型系统可以解释为对称为的面向对象概念的非正式理解。类定义了一种对象类型的形状——它有哪些方法和属性。这样的对象称为类的实例

原型对于定义所有类的实例共享相同值的属性(如方法)很有用。每个实例不同的属性,例如我们兔子的 type 属性,需要直接存储在对象本身中。

因此,要创建给定类的实例,你必须创建一个从正确原型派生的对象,但你也必须确保它本身具有该类实例应该具有的属性。这就是构造函数的功能。

function makeRabbit(type) {
  let rabbit = Object.create(protoRabbit);
  rabbit.type = type;
  return rabbit;
}

JavaScript 提供了一种方法来简化这种类型的函数定义。如果你在函数调用之前放置关键字 new,该函数将被视为构造函数。这意味着将自动创建一个具有正确原型的对象,绑定到函数中的 this,并在函数结束时返回。

构造对象时使用的原型对象是通过获取构造函数的 prototype 属性找到的。

function Rabbit(type) {
  this.type = type;
}
Rabbit.prototype.speak = function(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
};

let weirdRabbit = new Rabbit("weird");

构造函数(实际上是所有函数)都会自动获得一个名为 prototype 的属性,该属性默认情况下保存一个简单的空对象,该对象从 Object.prototype 派生而来。如果你愿意,可以用一个新的对象覆盖它。或者,你可以向现有对象添加属性,如示例所示。

按照惯例,构造函数的名称大写,以便它们可以轻松地与其他函数区分开来。

重要的是要了解原型与构造函数关联的方式(通过其 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";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log(blackRabbit.teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

下面的图示展示了这段代码运行后的情况。RabbitObject 原型作为一种背景,位于 killerRabbit 的后面,如果对象本身没有找到属性,就可以在原型中查找。

Rabbit object prototype schema

覆盖原型中存在的属性可能是一件很有用的事。正如兔子牙齿的例子所示,覆盖可以用来表达更泛型对象类实例中的特殊属性,同时让非特殊对象从其原型中获取标准值。

覆盖还用于为标准函数和数组原型提供与基本对象原型不同的 toString 方法。

console.log(Array.prototype.toString ==
            Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

对数组调用 toString 会得到与调用 .join(",") 相似的结果——它在数组中的值之间添加逗号。直接用数组调用 Object.prototype.toString 会生成不同的字符串。该函数不知道数组,因此它只是在方括号之间添加单词 object 和类型名称。

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

映射

我们在上一章中看到过 map 这个词,它指的是一种操作,通过对数据结构中的元素应用一个函数来变换数据结构。令人困惑的是,在编程中,同一个词也被用来指代另一个相关但相当不同的东西。

map(名词)是一种数据结构,它将值(键)与其他值关联起来。例如,你可能希望将名字映射到年龄。可以使用对象来实现这一点。

let ages = {
  Boris: 39,
  Liang: 22,
  Júlia: 62
};

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

这里,对象的属性名是人们的名字,属性值是他们的年龄。但我们肯定没有在我们的映射中列出任何名字叫 toString 的人。然而,由于普通对象是从 Object.prototype 派生的,所以看起来该属性在那里。

因此,使用普通对象作为映射很危险。有几种方法可以避免这个问题。首先,可以创建没有原型的对象。如果你将 null 传递给 Object.create,生成的对象将不会从 Object.prototype 派生,可以安全地用作映射。

console.log("toString" in Object.create(null));
// → false

对象属性名必须是字符串。如果你需要一个键不能轻松转换为字符串的映射——比如对象——你不能使用对象作为你的映射。

幸运的是,JavaScript 附带了一个名为 Map 的类,它是专门为此目的而编写的。它存储映射并允许任何类型的键。

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

setgethas 方法是 Map 对象接口的一部分。编写一个能够快速更新和搜索大量值的 data structure 并不容易,但我们不必担心。有人已经帮我们做好了,我们可以通过这个简单的接口来使用他们的工作。

如果你确实有一个需要以某种方式作为映射来处理的普通对象,那么知道 Object.keys 只返回对象自身的键,而不是原型中的键,这一点很有用。作为 in 运算符的替代方法,你可以使用 hasOwnProperty 方法,它会忽略对象的原型。

console.log({x: 1}.hasOwnProperty("x"));
// → true
console.log({x: 1}.hasOwnProperty("toString"));
// → false

多态

当你在对象上调用 String 函数(它将值转换为字符串)时,它会调用该对象的 toString 方法,以尝试从该对象中创建一个有意义的字符串。我提到过一些标准原型定义了它们自己的 toString 版本,这样它们就可以创建包含比 "[object Object]" 更有用信息的字符串。你也可以自己做。

Rabbit.prototype.toString = function() {
  return `a ${this.type} rabbit`;
};

console.log(String(blackRabbit));
// → a black rabbit

这是一个强大思想的简单例子。当一段代码被写成可以与具有特定接口的对象一起工作时——在本例中,是一个 toString 方法——任何碰巧支持该接口的对象都可以插入到代码中,它就会正常工作。

这种技术被称为 多态。多态代码可以与不同形状的值一起工作,只要它们支持它期望的接口。

我在第 4 章中提到过 for/of 循环可以遍历多种数据结构。这是多态的另一个例子——这类循环期望数据结构暴露一个特定的接口,数组和字符串都具备这个接口。我们也可以将这个接口添加到我们自己的对象中!但在我们能做到这一点之前,我们需要知道什么是符号。

符号

多个接口可以使用相同的属性名来表示不同的东西。例如,我可以定义一个接口,其中 toString 方法应该将对象转换为一段纱线。一个对象不可能同时符合该接口和 toString 的标准用法。

这是一个不好的主意,这个问题并不常见。大多数 JavaScript 程序员根本不会考虑它。但是语言设计者,他们的工作就是考虑这些东西,已经为我们提供了一个解决方案。

当我声称属性名是字符串时,这并不完全准确。它们通常是字符串,但它们也可以是符号。符号是使用 Symbol 函数创建的值。与字符串不同,新创建的符号是唯一的——你不能创建同一个符号两次。

let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(blackRabbit[sym]);
// → 55

传递给 Symbol 的字符串在将其转换为字符串时会被包含进来,这可以让你在显示它时(例如在控制台中)更容易识别符号。但它除了这个之外没有任何意义——多个符号可能具有相同的名称。

既是唯一的,又能用作属性名,这使得符号适合定义可以与其他属性和平共处,无论它们的名字是什么。

const toStringSymbol = Symbol("toString");
Array.prototype[toStringSymbol] = function() {
  return `${this.length} cm of blue yarn`;
};

console.log([1, 2].toString());
// → 1,2
console.log([1, 2][toStringSymbol]());
// → 2 cm of blue yarn

可以通过在属性名周围使用方括号,将符号属性包含在对象表达式和类中。这会导致属性名被求值,就像方括号属性访问符号一样,这使我们能够引用包含符号的绑定。

let stringObject = {
  [toStringSymbol]() { return "a jute rope"; }
};
console.log(stringObject[toStringSymbol]());
// → a jute rope

迭代器接口

传递给 for/of 循环的对象被期望是可迭代的。这意味着它有一个名为 Symbol.iterator 符号(由语言定义的符号值,存储为 Symbol 函数的属性)的方法。

当被调用时,该方法应该返回一个提供第二个接口的对象,迭代器。这是实际进行迭代的东西。它有一个 next 方法,返回下一个结果。该结果应该是一个对象,它有一个 value 属性,如果存在,则提供下一个值,以及一个 done 属性,如果不存在更多结果,则为 true,否则为 false。

请注意,nextvaluedone 属性名是普通的字符串,而不是符号。只有 Symbol.iterator(它很可能会被添加到很多不同的对象中)是一个真正的符号。

我们可以直接使用这个接口。

let okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next());
// → {value: "O", done: false}
console.log(okIterator.next());
// → {value: "K", done: false}
console.log(okIterator.next());
// → {value: undefined, done: true}

让我们实现一个可迭代的数据结构。我们将构建一个矩阵类,充当二维数组。

class Matrix {
  constructor(width, height, element = (x, y) => undefined) {
    this.width = width;
    this.height = height;
    this.content = [];

    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        this.content[y * width + x] = element(x, y);
      }
    }
  }

  get(x, y) {
    return this.content[y * this.width + x];
  }
  set(x, y, value) {
    this.content[y * this.width + x] = value;
  }
}

该类将其内容存储在一个 width × height 个元素的单一数组中。元素按行存储,因此,例如,第五行的第三个元素(使用从零开始的索引)存储在位置 4 × width + 2。

构造函数接受一个宽度、一个高度和一个可选的 element 函数,该函数将用于填充初始值。有 getset 方法来检索和更新矩阵中的元素。

当循环遍历矩阵时,你通常会对元素的位置和元素本身都感兴趣,因此我们将让我们的迭代器生成具有 xyvalue 属性的对象。

class MatrixIterator {
  constructor(matrix) {
    this.x = 0;
    this.y = 0;
    this.matrix = matrix;
  }

  next() {
    if (this.y == this.matrix.height) return {done: true};

    let value = {x: this.x,
                 y: this.y,
                 value: this.matrix.get(this.x, this.y)};
    this.x++;
    if (this.x == this.matrix.width) {
      this.x = 0;
      this.y++;
    }
    return {value, done: false};
  }
}

该类在它的 xy 属性中跟踪迭代矩阵的进度。next 方法首先检查是否已到达矩阵底部。如果没有,它首先创建包含当前值的 object,然后更新其位置,如果需要,移动到下一行。

让我们设置 Matrix 类使其可迭代。在本书中,我偶尔会使用事后原型操作来为类添加方法,这样各个代码片段保持简洁和自包含。在一个不需要将代码分割成小片段的正常程序中,你应该直接在类中声明这些方法。

Matrix.prototype[Symbol.iterator] = function() {
  return new MatrixIterator(this);
};

我们现在可以使用 for/of 循环遍历矩阵。

let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`);
for (let {x, y, value} of matrix) {
  console.log(x, y, value);
}
// → 0 0 value 0,0
// → 1 0 value 1,0
// → 0 1 value 0,1
// → 1 1 value 1,1

获取器、设置器和静态方法

接口通常主要由方法组成,但也可以包含保存非函数值的属性。例如,Map 对象有一个 size 属性,它可以告诉您存储了多少个键。

此类对象甚至不需要直接在实例中计算和存储此类属性。即使直接访问的属性也可能隐藏方法调用。此类方法称为getter,它们通过在对象表达式或类声明中的方法名前面写get来定义。

let varyingSize = {
  get size() {
    return Math.floor(Math.random() * 100);
  }
};

console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49

每当有人从该对象的size 属性读取时,都会调用关联的方法。当写入属性时,可以使用setter执行类似的操作。

class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }
  get fahrenheit() {
    return this.celsius * 1.8 + 32;
  }
  set fahrenheit(value) {
    this.celsius = (value - 32) / 1.8;
  }

  static fromFahrenheit(value) {
    return new Temperature((value - 32) / 1.8);
  }
}

let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30

Temperature 类允许您以摄氏度或华氏度读取和写入温度,但它在内部只存储摄氏度,并在fahrenheit getter 和 setter 中自动进行摄氏度的转换。

有时您希望将一些属性直接附加到构造函数,而不是附加到原型。此类方法将无法访问类实例,但可以用于提供创建实例的其他方法。

在类声明中,在其名称之前写有static 的方法存储在构造函数上。因此,Temperature 类允许您写Temperature.fromFahrenheit(100) 来使用华氏度创建温度。

继承

一些矩阵被称为对称。如果将对称矩阵绕其左上角到右下角的对角线镜像,它将保持不变。换句话说,存储在x,y 处的值始终与存储在y,x 处的值相同。

假设我们需要一个类似于 Matrix 的数据结构,但它强制执行矩阵是对称的且保持对称。我们可以从头开始编写它,但这将涉及重复一些与我们已经编写的代码非常相似的代码。

JavaScript 的原型系统使创建类成为可能,就像旧类一样,但对它的一些属性具有新的定义。新类的原型源自旧原型,但为其添加了新的定义,例如,为 set 方法添加了新的定义。

在面向对象编程术语中,这称为继承。新类继承了旧类的属性和行为。

class SymmetricMatrix extends Matrix {
  constructor(size, element = (x, y) => undefined) {
    super(size, size, (x, y) => {
      if (x < y) return element(y, x);
      else return element(x, y);
    });
  }

  set(x, y, value) {
    super.set(x, y, value);
    if (x != y) {
      super.set(y, x, value);
    }
  }
}

let matrix = new SymmetricMatrix(5, (x, y) => `${x},${y}`);
console.log(matrix.get(2, 3));
// → 3,2

使用 extends 表明此类不应直接基于默认的 Object 原型,而应基于其他类。这被称为超类。派生类是子类

为了初始化 SymmetricMatrix 实例,构造函数通过 super 关键字调用其超类的构造函数。这是必要的,因为如果这个新对象要像一个 Matrix 一样(大致)工作,它将需要矩阵具有的实例属性。为了确保矩阵是对称的,构造函数包装 element 函数以交换对角线以下值的坐标。

set 方法再次使用 super,但这次不是调用构造函数,而是调用超类方法集中特定方法。我们正在重新定义 set,但想要使用原始行为。因为 this.set 指的是set 方法,所以调用它将不起作用。在类方法中,super 提供了一种以超类中定义的方式调用方法的方式。

继承允许我们从现有的数据类型构建略微不同的数据类型,而工作量相对较少。它是面向对象传统的一个基本组成部分,与封装和多态性并列。但尽管后两者现在普遍被认为是绝妙的想法,但继承却存在争议。

封装和多态性可以用来分离代码片段,从而减少整个程序的混乱,而继承从根本上将类绑定在一起,创建更多混乱。从类继承时,您通常需要比简单使用它时了解有关其工作原理的更多信息。继承可能是一个有用的工具,我在自己的程序中偶尔会使用它,但它不应该是您使用的第一个工具,而且您可能不应该主动寻找构建类层次结构(类家族树)的机会。

instanceof 运算符

有时了解对象是否来自特定类非常有用。为此,JavaScript 提供了一个名为 instanceof 的二元运算符。

console.log(
  new SymmetricMatrix(2) instanceof SymmetricMatrix);
// → true
console.log(new SymmetricMatrix(2) instanceof Matrix);
// → true
console.log(new Matrix(2, 2) instanceof SymmetricMatrix);
// → false
console.log([1] instanceof Array);
// → true

该运算符将能识别继承的类型,因此 SymmetricMatrixMatrix 的一个实例。该运算符也可以应用于标准构造函数,如 Array。几乎所有对象都是 Object 的一个实例。

摘要

因此,对象不仅仅持有自己的属性。它们有原型,它们是其他对象。只要它们的原型具有该属性,它们的行为就会像具有它们没有的属性一样。简单对象以 Object.prototype 作为它们的原型。

构造函数,它们是名称通常以大写字母开头的函数,可以使用 new 运算符创建新对象。新对象的原型将是在构造函数的 prototype 属性中找到的对象。您可以通过将给定类型的所有值的共同属性放入它们的原型中来充分利用这一点。有一个 class 表示法,它提供了一种清晰的方法来定义构造函数及其原型。

您可以定义 getter 和 setter,以便在每次访问对象的属性时秘密调用方法。静态方法是存储在类的构造函数中而不是其原型中的方法。

instanceof 运算符可以根据给定的对象和构造函数,告诉您该对象是否是该构造函数的实例。

对象的一个有用之处是为它们指定接口,并告诉每个人他们应该只通过该接口与您的对象进行交互。构成您的对象的其余细节现在被封装,隐藏在接口后面。

多种类型可以实现同一个接口。使用接口编写的代码会自动知道如何使用提供接口的任意多个不同对象。这称为多态性

在实现多个仅在某些细节上有所不同的类时,将新类编写为现有类的子类继承部分行为可能会有所帮助。

练习

向量类型

编写一个名为 Vec 的类,它表示二维空间中的向量。它接受 xy 参数(数字),这些参数应该保存到相同名称的属性中。

Vec 原型提供两个方法,plusminus,它们接受另一个向量作为参数并返回一个新向量,该向量具有两个向量(this 和参数)的xy 值的总和或差。

在原型中添加一个 getter 属性 length,用于计算向量的长度,即点 (x, y) 到原点 (0, 0) 的距离。

// Your code here.

console.log(new Vec(1, 2).plus(new Vec(2, 3)));
// → Vec{x: 3, y: 5}
console.log(new Vec(1, 2).minus(new Vec(2, 3)));
// → Vec{x: -1, y: -1}
console.log(new Vec(3, 4).length);
// → 5

如果您不确定 class 声明的格式,请回顾 Rabbit 类示例。

向构造函数添加 getter 属性可以通过在方法名前面加上 get 来完成。要计算从 (0, 0) 到 (x, y) 的距离,您可以使用勾股定理,该定理指出我们要查找的距离的平方等于 x 坐标的平方加上 y 坐标的平方。因此,√(x2 + y2) 是您想要的数字,而 Math.sqrt 是您在 JavaScript 中计算平方根的方式。

群组

标准 JavaScript 环境提供另一个名为 Set 的数据结构。与 Map 的实例类似,集合保存了一组值。与 Map 不同,它不会将其他值与这些值相关联 - 它只是跟踪哪些值是集合的一部分。一个值只能成为集合的一部分一次 - 再次添加它没有任何影响。

编写一个名为 Group 的类(因为 Set 已经被占用)。与 Set 类似,它具有 adddeletehas 方法。它的构造函数创建一个空组,add 将一个值添加到组中(但前提是它还不是成员),delete 从组中删除其参数(如果它是成员),而 has 返回一个布尔值,指示其参数是否是组的成员。

使用 === 运算符或类似的运算符(如 indexOf)来确定两个值是否相同。

为该类提供一个静态 from 方法,该方法接受一个可迭代对象作为参数,并创建一个包含迭代该对象产生的所有值的组。

class Group {
  // Your code here.
}

let group = Group.from([10, 20]);
console.log(group.has(10));
// → true
console.log(group.has(30));
// → false
group.add(10);
group.delete(10);
console.log(group.has(10));
// → false

最简单的方法是在实例属性中存储一个组成员数组。可以使用 includesindexOf 方法来检查给定值是否在数组中。

您的类的构造函数可以将成员集合设置为一个空数组。当调用 add 时,它必须检查给定值是否在数组中,或者在其他情况下使用 push 将其添加到数组中。

从数组中删除元素,在 `delete` 中,并不直观,但可以使用 `filter` 创建一个不包含该值的新数组。不要忘记用新过滤后的数组版本覆盖保存成员的属性。

from 方法可以使用 `for`/`of` 循环从可迭代对象中获取值,并调用 `add` 将它们放入新创建的组中。

可迭代组

使之前练习中的 `Group` 类可迭代。如果你不再清楚接口的具体形式,请参考本章前面关于迭代器接口的部分。

如果你使用数组来表示组的成员,不要只返回通过调用数组上的 `Symbol.iterator` 方法创建的迭代器。这样做虽然可以,但违背了本练习的目的。

如果你的迭代器在迭代过程中组被修改时表现异常,这是可以接受的。

// Your code here (and the code from the previous exercise)

for (let value of Group.from(["a", "b", "c"])) {
  console.log(value);
}
// → a
// → b
// → c

定义一个新的类 `GroupIterator` 可能是有价值的。迭代器实例应该有一个属性来跟踪组中的当前位置。每次调用 `next` 时,它都会检查是否已完成,如果未完成,则会跳过当前值并返回它。

`Group` 类本身获得一个由 `Symbol.iterator` 命名的函数,当调用时,它会为该组返回一个新的迭代器类实例。

借用方法

在本章前面,我提到过,当你想忽略原型的属性时,对象的 `hasOwnProperty` 可以作为 `in` 运算符更强大的替代方案。但是,如果你的映射需要包含 `“hasOwnProperty”` 这个词怎么办?你将无法再调用该方法,因为对象的自身属性隐藏了该方法的值。

你能想到一种在具有同名自身属性的对象上调用 `hasOwnProperty` 的方法吗?

let map = {one: true, two: true, hasOwnProperty: true};

// Fix this call
console.log(map.hasOwnProperty("one"));
// → true

记住,存在于普通对象上的方法来自 `Object.prototype`。

还要记住,你可以使用函数的 `call` 方法以特定的 `this` 绑定来调用函数。