物体的秘密生活

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

Barbara Liskov,使用抽象数据类型编程
Illustration of a rabbit next to its prototype, a schematic representation of a rabbit

第 4 章介绍了 JavaScript 的对象作为持有其他数据的容器。在编程文化中,面向对象编程是一组使用对象作为程序组织的核心原则的技术。尽管没有人真正同意其确切定义,但面向对象编程已经塑造了许多编程语言的设计,包括 JavaScript。本章描述了这些思想如何在 JavaScript 中应用。

抽象数据类型

面向对象编程的主要思想是使用对象,或者更确切地说,使用对象的类型作为程序组织的单位。将程序设置为若干严格分离的对象类型,提供了一种思考其结构的方式,从而强制执行某种纪律,防止所有内容变得纠缠不清。

这样做的方法是,将对象想象成你想象的电动搅拌机或其他家用电器。设计和组装搅拌机的人必须做专门的工作,需要材料科学和电学知识。他们在光滑的塑料外壳中隐藏了所有这些,这样那些只想搅拌煎饼糊的人就不必担心所有这些——他们只需要了解搅拌机可以操作的几个旋钮。

类似地,抽象数据类型对象类是一个子程序,它可能包含任意复杂的代码,但公开了一组有限的方法和属性,使用这些方法和属性的人员应该使用它们。这使得大型程序能够从许多电器类型构建起来,限制了这些不同部分相互纠缠的程度,因为要求它们只能以特定方式相互交互。

如果在一个这样的对象类中发现问题,通常可以修复或甚至完全重写它,而不会影响程序的其余部分。更重要的是,可能能够在多个不同的程序中使用对象类,避免从头开始创建其功能的需要。您可以将 JavaScript 的内置数据结构(例如数组和字符串)视为这种可重用的抽象数据类型。

每个抽象数据类型都有一个接口,它是外部代码可以在其上执行的操作集合。接口以外的任何细节都被封装起来,被视为类型的内部,与程序的其余部分无关。

即使是像数字这样的基本事物也可以被认为是一种抽象数据类型,它的接口允许我们添加它们、乘以它们、比较它们等等。事实上,在经典面向对象编程中,对单个对象作为主要组织单位的迷恋是有些不利的,因为有用的功能片段通常涉及一组不同的对象类紧密协同工作。

方法

在 JavaScript 中,方法不过是指向函数值的属性。这是一个简单的方法

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 fur and whiskers");
// → The white rabbit says 'Oh my fur and whiskers'
hungryRabbit.speak("Got any carrots?");
// → The hungry rabbit says 'Got any carrots?'

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

您可以将 this 视为一个额外的参数,它以不同于普通参数的方式传递给函数。如果你想显式地提供它,你可以使用函数的 call 方法,它将 this 值作为第一个参数,并将进一步的参数视为普通参数。

speak.call(whiteRabbit, "Hurry");
// → The white rabbit says 'Hurry'

由于每个函数都有自己的 this 绑定,其值取决于调用它的方式,因此您不能在使用 function 关键字定义的普通函数中引用包装范围的 this

箭头函数则不同——它们不会绑定自己的 this,但可以查看其周围范围的 this 绑定。因此,您可以执行以下类似代码,它引用了本地函数内的 this

let finder = {
  find(array) {
    return array.some(v => v == this.value);
  },
  value: 5
};
console.log(finder.find([4, 5]));
// → true

对象表达式中的 find(array) 这样的属性是定义方法的一种简写方式。它创建一个名为 find 的属性,并将其值设置为一个函数。

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

原型

创建具有 speak 方法的兔子对象类型的一种方法是创建一个辅助函数,该函数以兔子类型作为参数,并返回一个对象,该对象将该类型作为其 type 属性,并将我们的 speak 函数作为其 speak 属性。

所有兔子都共享该相同的方法。特别是对于具有许多方法的类型,如果有一种方法可以在一个地方保存类型的类型,而不是将它们单独添加到每个对象中,那就太好了。

在 JavaScript 中,原型是实现此目的的方法。对象可以链接到其他对象,以神奇地获取其他对象所具有的所有属性。使用 {} 符号创建的普通对象链接到一个名为 Object.prototype 的对象。

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

看起来我们只是从一个空对象中提取了一个属性。但实际上,toString 是存储在 Object.prototype 中的方法,这意味着它在大多数对象中都可用。

当一个对象收到对它没有的属性的请求时,将搜索它的原型以查找该属性。如果原型没有该属性,将搜索原型的原型,依此类推,直到找到没有原型的对象(Object.prototype 就是这样的对象)。

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

正如您所料,Object.getPrototypeOf 返回对象的原型。

许多对象并没有直接将 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 blackRabbit = Object.create(protoRabbit);
blackRabbit.type = "black";
blackRabbit.speak("I am fear and darkness");
// → The black rabbit says 'I am fear and darkness'

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

JavaScript 的原型系统可以解释为对抽象数据类型或类的自由形式的解读。一个定义了一种对象类型的形状——它具有哪些方法和属性。这样的对象被称为类的实例

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

要创建一个给定类的实例,您必须创建一个从适当原型派生的对象,但您必须确保它本身具有此类实例应该具有的属性。这就是构造函数的作用。

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

JavaScript 的类符号使定义此类型的函数以及原型对象变得更加容易。

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

class 关键字开始一个类声明,它允许我们一起定义一个构造函数和一组方法。可以在声明的花括号内编写任意数量的方法。这段代码具有定义一个名为 Rabbit 的绑定的效果,该绑定包含一个函数,该函数运行 constructor 中的代码,并具有一个 prototype 属性,该属性包含 speak 方法。

此函数不能像普通函数一样调用。在 JavaScript 中,构造函数通过在它们前面加上 new 关键字来调用。这样做会创建一个新的实例对象,其原型是函数的 prototype 属性中的对象,然后使用绑定到新对象的 this 来运行函数,最后返回该对象。

let killerRabbit = new Rabbit("killer");

事实上,class 直到 2015 年版的 JavaScript 才被引入。任何函数都可以用作构造函数,在 2015 年之前,定义类的做法是编写一个普通函数,然后操作其 prototype 属性。

function ArchaicRabbit(type) {
  this.type = type;
}
ArchaicRabbit.prototype.speak = function(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
};
let oldSchoolRabbit = new ArchaicRabbit("old school");

因此,所有非箭头函数都以一个 prototype 属性开头,该属性包含一个空对象。

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

理解原型与构造函数之间的区别非常重要:原型通过prototype属性与构造函数关联,而对象则拥有原型(可以通过Object.getPrototypeOf找到)。构造函数的实际原型是Function.prototype,因为构造函数是函数。构造函数的prototype属性保存了通过它创建的实例所使用的原型。

console.log(Object.getPrototypeOf(Rabbit) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf(killerRabbit) ==
            Rabbit.prototype);
// → true

构造函数通常会向this添加一些针对实例的属性。也可以在类声明中直接声明属性。与方法不同,这些属性被添加到实例对象而不是原型中。

class Particle {
  speed = 0;
  constructor(position) {
    this.position = position;
  }
}

function一样,class既可以在语句中使用,也可以在表达式中使用。当用作表达式时,它不会定义绑定,而只是生成构造函数作为值。在类表达式中,可以省略类名。

let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello

私有属性

类通常会定义一些供内部使用,不属于其接口的属性和方法。这些被称为私有属性,与公共属性相反,公共属性是对象外部接口的一部分。

要声明一个私有方法,请在其名称前添加#符号。这些方法只能从定义它们的class声明内部调用。

class SecretiveObject {
  #getSecret() {
    return "I ate all the plums";
  }
  interrogate() {
    let shallISayIt = this.#getSecret();
    return "never";
  }
}

如果类没有声明构造函数,它将自动获得一个空构造函数。

如果尝试从类外部调用#getSecret,则会出错。它的存在完全隐藏在类声明内部。

要使用私有实例属性,必须声明它们。常规属性可以通过简单地赋值来创建,但私有属性必须在类声明中声明才能使用。

这个类实现了一个获取给定最大值以下随机整数的工具。它只有一个公共属性:getNumber

class RandomSource {
  #max;
  constructor(max) {
    this.#max = max;
  }
  getNumber() {
    return Math.floor(Math.random() * this.#max);
  }
}

覆盖派生属性

当您向对象添加属性时,无论该属性是否存在于原型中,该属性都会被添加到对象本身。如果原型中已经存在相同名称的属性,该属性将不再影响对象,因为它现在被对象的自身属性隐藏了。

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((new Rabbit("basic")).teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

以下图表概述了运行这段代码后的情况。RabbitObject原型作为一种背景位于killerRabbit之后,可以在其中查找对象本身中未找到的属性。

A diagram showing the object structure of rabbits and their prototypes. There is a box for the 'killerRabbit' instance (holding instance properties like 'type'), with its two prototypes, 'Rabbit.prototype' (holding the 'speak' method) and 'Object.prototype' (holding methods like 'toString') stacked behind it.

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

覆盖也用于为标准函数和数组原型提供与基本对象原型不同的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

方法setgethasMap对象接口的一部分。编写一个可以快速更新和搜索大量值的數據結構并不容易,但我们不用担心。有人为我们做了这件事,我们可以通过这个简单的接口来使用他们的成果。

如果您确实有一个需要以某种方式将其视为映射的普通对象,那么了解Object.keys只返回对象的自身键,而不是原型中的键非常有用。作为in运算符的替代方案,您可以使用Object.hasOwn函数,它会忽略对象的原型。

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

多态性

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

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

console.log(String(killerRabbit));
// → a killer rabbit

这是一个强大的思想的简单示例。当一段代码被编写用来处理具有特定接口的对象时——在这种情况下,是一个toString方法——任何碰巧支持此接口的对象都可以插入到代码中,并且能够与之交互。

这种技术称为多态性。多态代码可以处理不同形状的值,只要它们支持代码期望的接口。

一个广泛使用的接口的例子是具有length属性的类数组对象,该属性保存一个数字,以及用于每个元素的编号属性。数组和字符串都支持此接口,以及其他各种对象,其中一些将在后面的章节中介绍浏览器。

Array.prototype.forEach.call({
  length: 2,
  0: "A",
  1: "B"
}, elt => console.log(elt));
// → A
// → B

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

接口通常包含普通属性,而不仅仅是方法。例如,Map对象有一个size属性,它告诉您在其中存储了多少个键。

对于这样的对象,没有必要直接在实例中计算和存储此类属性。即使是直接访问的属性也可能隐藏方法调用。这些方法称为获取器,它们通过在对象表达式或类声明中将get放在方法名称之前来定义。

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

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

每当有人读取这个对象的size属性时,都会调用关联的方法。您可以使用设置器在写入属性时执行类似的操作。

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获取器和设置器中自动进行摄氏度之间的转换。

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

在类声明中,在方法或属性的名称前添加static的,将存储在构造函数上。例如,Temperature类允许您编写Temperature.fromFahrenheit(100)来使用华氏度创建一个温度。

let boil = Temperature.fromFahrenheit(212);
console.log(boil.celsius);
// → 100

符号

我在第4章中提到过for/of循环可以遍历几种数据结构。这是多态性的另一个例子——这样的循环期望数据结构公开一个特定的接口,数组和字符串都具有该接口。我们也可以将这个接口添加到我们自己的对象中!但在我们这样做之前,我们需要简要地看一下符号类型。

多个接口可以使用相同的属性名称来表示不同的东西。例如,在类数组对象上,length是指集合中的元素数量。但是,描述徒步旅行路线的对象接口可以使用length来提供路线的长度(以米为单位)。一个对象不可能同时符合这两个接口。

一个试图同时成为路由和数组状的对象(也许是为了枚举其路点)有点牵强,而且这种问题在实际应用中并不常见。然而,对于像迭代协议这样的东西,语言设计者需要一种真正不会与任何其他类型冲突的属性类型。因此,在 2015 年,语言中添加了 *符号*。

大多数属性,包括我们到目前为止看到的所有属性,都是用字符串命名的。但是,也可以使用符号作为属性名称。符号是使用 Symbol 函数创建的值。与字符串不同,新创建的符号是唯一的——您无法创建两次相同的符号。

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

您传递给 Symbol 的字符串包含在您将其转换为字符串时,并且可以使您更容易识别符号,例如,在控制台中显示它时。但它除了这一点之外没有其他含义——多个符号可能具有相同的名称。

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

const length = Symbol("length");
Array.prototype[length] = 0;

console.log([1, 2].length);
// → 2
console.log([1, 2][length]);
// → 0

可以通过在属性名称周围使用方括号,将符号属性包含在对象表达式和类中。这将导致方括号之间的表达式被求值为属性名称,类似于方括号属性访问符号。

let myTrip = {
  length: 2,
  0: "Lankwitz",
  1: "Babelsberg",
  [length]: 21500
};
console.log(myTrip[length], myTrip.length);
// → 21500 2

迭代器接口

传递给 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}

让我们实现一个类似于 第 4 章 中练习中链表的可迭代数据结构。这次我们将把列表写成一个类。

class List {
  constructor(value, rest) {
    this.value = value;
    this.rest = rest;
  }

  get length() {
    return 1 + (this.rest ? this.rest.length : 0);
  }

  static fromArray(array) {
    let result = null;
    for (let i = array.length - 1; i >= 0; i--) {
      result = new this(array[i], result);
    }
    return result;
  }
}

请注意,this 在静态函数中指向类的构造函数,而不是实例——在静态函数被调用时,没有实例存在。

遍历列表应该从头到尾返回列表的所有元素。我们将为迭代器编写一个单独的类。

class ListIterator {
  constructor(list) {
    this.list = list;
  }

  next() {
    if (this.list == null) {
      return {done: true};
    }
    let value = this.list.value;
    this.list = this.list.rest;
    return {value, done: false};
  }
}

该类通过更新其 list 属性来跟踪遍历列表的进度,以便在返回一个值时移动到下一个列表对象,并在该列表为空(null)时报告它已完成。

让我们设置 List 类使其可迭代。在这本书中,我偶尔会使用事后原型操作来向类添加函数,以便代码片段保持简洁和自包含。在常规程序中,没有必要将代码拆分为小片段,您会在类中直接声明这些函数。

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

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

let list = List.fromArray([1, 2, 3]);
for (let element of list) {
  console.log(element);
}
// → 1
// → 2
// → 3

数组符号和函数调用中的 ... 语法类似地适用于任何可迭代对象。例如,您可以使用 [...value] 创建一个包含任意可迭代对象中元素的数组。

console.log([..."PCI"]);
// → ["P", "C", "I"]

继承

想象一下,我们需要一个类似于之前看到的 List 类的列表类型,但是因为我们将一直询问它的长度,所以我们不希望它每次都必须扫描它的 rest。相反,我们希望将长度存储在每个实例中以实现高效访问。

JavaScript 的原型系统使创建 *新* 类成为可能,就像旧类一样,但对其中一些属性的定义进行了更改。新类的原型继承自旧原型,但为例如 length getter 添加了一个新的定义。

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

class LengthList extends List {
  #length;

  constructor(value, rest) {
    super(value, rest);
    this.#length = super.length;
  }

  get length() {
    return this.#length;
  }
}

console.log(LengthList.fromArray([1, 2, 3]).length);
// → 3

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

要初始化 LengthList 实例,构造函数通过 super 关键字调用其超类的构造函数。这是必要的,因为如果这个新对象要像 List 一样(大致)表现,它将需要列表具有的实例属性。

构造函数然后将列表的长度存储在一个私有属性中。如果我们在那里写了 this.length,类的 getter 本身会被调用,但这还不能工作,因为 #length 还没有被填入。我们可以使用 super.something 来调用超类原型上的函数和 getter,这在很多时候很有用。

继承允许我们用相对少的工作量从现有数据类型构建略微不同的数据类型。它是面向对象传统的核心部分,与封装和多态并列。但是,虽然后两者现在通常被认为是绝妙的想法,但继承却更有争议。

封装和多态可以用来 *分离* 代码片段,减少整个程序的混乱程度,而继承从根本上将类绑定在一起,造成 *更多* 的混乱。从一个类继承时,您通常需要了解有关它工作原理的更多信息,而不是仅仅使用它。继承可能是有用的工具,可以使某些类型的程序更简洁,但它不应该是你第一个使用的工具,而且你可能不应该主动寻找构建类层次结构(类的家谱)的机会。

instanceof 运算符

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

console.log(
  new LengthList(1, null) instanceof LengthList);
// → true
console.log(new LengthList(2, null) instanceof List);
// → true
console.log(new List(3, null) instanceof LengthList);
// → false
console.log([1] instanceof Array);
// → true

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

总结

对象不仅仅保存它们自己的属性。它们有原型,这些原型是其他对象。只要它们的原型具有该属性,它们就会表现得好像它们具有它们没有的属性。简单对象以 Object.prototype 作为它们的原型。

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

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

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

使用对象的一个有用方法是为它们指定一个接口,并告诉每个人他们应该只通过该接口与您的对象进行交互。构成您的对象的其余细节现在被 *封装*,隐藏在接口后面。您可以使用私有属性将对象的一部分隐藏在外部世界之外。

不止一种类型可以实现相同的接口。使用接口编写的代码会自动知道如何处理提供该接口的任意数量的不同对象。这被称为 *多态*。

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

练习

向量类型

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

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

在原型上添加一个名为 length 的 getter 属性,它计算向量的长度——也就是点 (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 中计算平方根的方法,x ** 2 可用于对数字进行平方。

分组

标准 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 命名的