6. 对象秘密的一生

        第 4 章介绍了 JavaScript 的对象,它们是容纳其他数据的容器。在编程文化中,面向对象编程是一套将对象作为组织程序的核心原则的技术。虽然没有人真正认同它的准确定义,但面向对象编程已经影响了包括 JavaScript 在内的许多编程语言的设计。本章将介绍如何在 JavaScript 中应用这些思想。

抽象数据类型

        面向对象编程的主要思想是使用对象或对象类型作为程序的组织单位。将程序设置为若干严格分离的对象类型,为我们提供了一种思考程序结构的方法,从而可以执行某种纪律,防止一切事情变得纠缠不清。要做到这一点,就要像看待电动搅拌机或其他消费电器一样看待对象。设计和组装搅拌机的人必须从事专业工作,这需要材料科学和对电力的理解。他们用光滑的塑料外壳把这一切都掩盖起来,这样,只想搅拌煎饼面糊的人就不用担心这些了--他们只需要了解搅拌机的几个旋钮就可以操作了。

        同样,抽象数据类型或对象类也是一种子程序,它可能包含任意复杂的代码,但只公开了一组有限的方法和属性,供使用它的人使用。这样,大型程序就可以由多种类型的设备构建而成,并通过要求这些不同的部分只能以特定的方式进行交互,来限制它们之间的纠缠程度。

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

        每个抽象数据类型都有一个接口,即外部代码可以对其执行的操作集合。接口之外的任何细节都被封装起来,被视为该类型的内部操作,与程序的其他部分无关。即使像数字这样基本的东西,也可以看作是一种抽象的数据类型,其接口允许我们对其进行加法、乘法、比较等操作。事实上,在经典的面向对象编程中,将单个对象固定为主要组织单位的做法有些令人遗憾,因为有用的功能往往需要一组不同的对象类紧密配合。

方法

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

        通常情况下,方法需要对被调用的对象做一些事情。当函数作为方法被调用时--作为属性被查找并立即被调用,如 object.method()--在其正文中调用 this 的绑定会自动指向被调用的对象。

        你可以把它看作一个额外的参数,以不同于常规参数的方式传递给函数。如果要显式地提供它,可以使用函数的调用方法,该方法将 this 值作为第一个参数,并将其他参数视为常规参数。

        由于每个函数都有自己的 this 绑定,其值取决于函数的调用方式,因此在使用函数关键字定义的常规函数中,您无法引用包装作用域的 this。

        箭头函数则不同--它们不绑定自己的 this,但可以看到它们周围作用域的 this 绑定。因此,您可以执行类似下面的代码,在局部函数内部引用 this:

        对象表达式中的 find(array) 属性是定义方法的一种简便方法。它创建了一个名为 find 的属性,并赋予它一个函数作为其值。如果我使用函数关键字将参数写成 some,这段代码就无法运行。

属性

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

        所有兔子都会共享相同的方法。特别是对于有很多方法的类型,如果有一种方式可以将类型的方法集中在一个地方,而不是单独添加到每个对象中,那就更好了。

        在 JavaScript 中,原型就是这样一种方法。对象可以链接到其他对象,从而神奇地获得其他对象拥有的所有属性。使用 {} 符号创建的普通对象会链接到一个名为 Object.prototype 的对象。

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

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

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

        许多对象的原型并不直接是 Object.prototype,而是另一个提供不同默认属性的对象。函数源于 Function.prototype,数组源于 Array.prototype。

        这样的原型对象本身就有一个原型,通常是 Object.prototype,因此它仍能间接提供 toString 等方法。

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

        "protoRabbit"兔子是所有兔子共享属性的容器。单个兔子对象(如黑兔)包含仅适用于自身的属性(在本例中为其类型),并从其原型中获得共享属性。

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

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

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

  • 抽象数据类型:JavaScript 的原型系统提供了一种灵活的方式来定义类和对象。类定义了对象的结构(属性和方法)。
  • 类的实例:实例是类的具体对象,具有类所定义的属性和方法。类可以看作是对象的蓝图。
  • 共享属性:原型可以用于定义所有实例共享的属性。例如,兔子的某些属性(如颜色)可以在原型中定义。
  • 独特属性:每个实例可能有自己的特定属性(如兔子的类型),这些属性需要直接在对象中存储,而不是在原型中。
  • 构造函数的作用:构造函数用于创建新对象实例,确保每个实例不仅能从原型获取共享方法,还能初始化自己特有的属性。

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

        class 关键字是类声明的开始,它允许我们定义一个构造函数和一组方法。在声明的大括号内可以写入任意数量的方法。这段代码的作用是定义了一个名为 Rabbit 的绑定,该绑定持有一个函数,该函数运行构造函数中的代码,并拥有一个持有 speak 方法的原型属性。该函数不能像普通函数那样被调用。在 JavaScript 中,构造函数是通过在前面加上关键字 new 来调用的。这样做会创建一个新的实例对象,其原型就是函数原型属性中的对象,然后运行与新对象绑定的函数,最后返回对象。

事实上,类是在 2015 版 JavaScript 中才引入的。任何函数都可以用作构造函数,而在 2015 年之前,定义类的方法是编写一个常规函数,然后操作其 proptotype 属性。

因此,所有非箭号函数都以包含一个空对象的原型属性开始。

按照惯例,构造函数的名称都是大写的,以便与其他函数区分开来。

        理解原型与构造函数的关联方式(通过其原型属性)与对象的原型关联方式(可通过 Object.getPrototypeOf 查找)之间的区别非常重要。构造函数的实际原型是 Function.prototype,因为构造函数是函数。构造函数的 prototype 属性包含通过它创建的实例所使用的原型。

  • 构造函数与原型的关系
  • 构造函数是一个函数,用于创建对象的实例。所有构造函数都继承自 Function.prototype,这意味着它们是函数,因此可以调用函数的特性和方法。每个构造函数都有一个 prototype 属性,这个属性是一个对象,定义了所有通过这个构造函数创建的实例共享的方法和属性。
  • 对象的原型关联
  • 当你创建一个实例时,该实例的原型指向构造函数的 prototype 属性。这可以通过 Object.getPrototypeOf(instance) 来查找。
  • 这条链式结构使得实例可以访问构造函数原型中定义的共享属性和方法。
  • 区别
  • 构造函数的原型属性用于定义所有实例共享的内容,而实例本身的原型通过 Object.getPrototypeOf 可以访问。
  • 理解这两者的关系有助于更好地掌握 JavaScript 的原型继承和对象创建机制。

        构造函数通常会在其中添加一些针对实例的属性。也可以在类声明中直接声明属性。与方法不同的是,这些属性会添加到实例对象而不是原型中(意味着: 实例属性是特定于对象的,而原型方法是共享的)。

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

私有属性

        类通常会定义一些内部使用的属性和方法,而这些属性和方法并不是接口的一部分。这些属性称为私有属性,与之相对的是公共属性,后者是对象外部接口的一部分。

要声明私有方法,请在其名称前加上 # 号。这些方法只能在定义它们的类声明中调用。

当一个类没有声明构造函数时,它会自动获得一个空构造函数。

        如果试图从类外部调用 #getSecret,就会出错。它的存在完全隐藏在类的声明中。要使用私有实例属性,必须对其进行声明。常规属性可以通过赋值来创建,但私有属性必须在类声明中声明才能使用。

该类实现了一个用于获取低于给定最大值的随机整数的程序。它只有一个公共属性:getNumber。

覆盖派生属性

        为对象添加属性时,无论该属性是否存在于原型中,该属性都会被添加到对象本身。如果原型中已有同名的属性,该属性将不再影响对象,因为它已隐藏在对象自身属性的后面。

        下图描绘了代码运行后的情况。Rabbit 和对象原型位于 killerRabbit 后面,作为一种背景,可以在这里查找对象本身没有的属性。

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

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

        在数组中调用 toString 的结果类似于在数组中调用 .join(“,”) - 在数组值之间加上逗号。在数组中直接调用 Object.prototype.toString 会产生不同的字符串。该函数并不了解数组,因此它只是在方括号中加上 object 和类型名称。

映射

        我们在上一章中看到过 map 这个词,它指的是通过对数据结构中的元素应用函数来转换数据结构的操作。虽然令人困惑,但在程序设计中,同一个词被用来表示相关但相当不同的事物。

        映射(名词)是一种将值(键)与其他值关联起来的数据结构。例如,您可能想将姓名映射到年龄。为此可以使用对象。

        在这里,对象的属性名是人名,属性值是他们的年龄。但我们肯定没有在地图中列出任何名叫 toString 的人。然而,由于普通对象派生自 Object.prototype,因此看起来该属性是存在的。

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

        对象属性名必须是字符串。如果你需要的映射的键不能轻易转换成字符串,比如对象,那么你就不能使用对象作为映射。

幸运的是,JavaScript 中的 Map 类正是为此目的而编写的。它可以存储映射,并允许任何类型的键。

        set、get 和 has 方法是 Map 对象接口的一部分。编写一个能快速更新和搜索大量值集的数据结构并不容易,但我们不必担心。我们可以通过这个简单的界面来使用他们的成果。

        如果你确实有一个普通对象,但出于某种原因需要将其作为映射处理,那么 Object.keys 只返回对象自身的键,而不是原型中的键,这一点很有用。作为 in 操作符的替代,您可以使用 Object.hasOwn 函数,它会忽略对象的原型。(译者注: 奇怪,我怎么只找到了:hasOwnProperty 方法)

多态

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

        这是一个强大思想的简单实例。当编写一段代码来处理具有特定接口(本例中为 toString 方法)的对象时,任何一种恰好支持该接口的对象都可以插入代码并与之配合使用。这种技术被称为多态性。多态代码可以处理不同类型的值,只要它们支持它所期望的接口。

        一个广泛使用的接口示例是类似数组的对象,它有一个长度属性,其中包含一个数字,每个元素都有编号属性。数组和字符串都支持这种接口,其他各种对象也是如此,我们将在后面有关浏览器的章节中看到其中一些。我们在第 5 章中对 forEach 的实现适用于任何提供此接口的对象。事实上,Array.prototype.forEach 也是如此。

获取器(getter)、设置器(setter)和静态(statics)

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

        这样的对象没有必要直接在实例中计算和存储这样的属性。即使是直接访问的属性也可能隐藏一个方法调用。此类方法称为 getter 方法,在对象表达式或类声明中的方法名称前写入 get 即可定义此类方法。

        每当有人读取该对象的 size 属性时,就会调用相关的方法。当属性被写入时,也可以使用 setter 做类似的事情。

        Temperature 类允许您以摄氏度或华氏度为单位读写温度,但内部只存储摄氏度,并在华氏度获取器和设置器中自动转换为摄氏度。

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

  • 直接附加属性:有时你可能希望将某些方法或属性直接附加到构造函数本身,而不是它的原型上。这意味着这些属性或方法是构造函数的静态成员。
  • 静态方法:静态方法无法通过实例访问,只能通过构造函数直接调用。例如,你可以定义一个用于创建实例的工厂方法。
  • 用途:这样的设计可以提供一些辅助功能,比如创建不同类型的实例,或用于验证和初始化,而不需要每个实例都重复定义。

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

符号

        我在第 4 章中提到,for/of 循环可以在多种数据结构上循环。这是多态性的另一种情况--这种循环希望数据结构暴露一个特定的接口,数组和字符串就是这样。我们还可以将这种接口添加到自己的对象中!但在此之前,我们需要简单了解一下符号类型。

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

        一个对象既想成为路线,又想成为类似数组的对象(也许是为了枚举其航点),这有点牵强,而且这种问题在实践中并不常见。不过,对于像迭代协议这样的事情,语言设计者需要一种真正不会与其他属性冲突的属性类型。因此,2015 年,符号被添加到语言中。

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

译者注:Rabbit.prototype["sym"] = 55; 我这边是加了双引号后才设值成功的。

        在将 Symbol 转换为字符串时,会包含传递给 Symbol 的字符串,例如,在控制台中显示符号时,可以更容易地识别符号。但除此之外就没有其他意义了--多个符号可能具有相同的名称。

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

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

迭代器接口

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

        调用该方法时,该方法应返回一个提供第二个接口 iterator 的对象。这就是实际的迭代对象。它有一个 next 方法,用于返回下一个结果。这个结果应该是一个对象,它有一个 value 属性(如果有下一个值的话)和一个 done 属性(当没有更多结果时,这个属性应该为 true,否则为 false)。

        请注意,next、value 和 done 属性名称是普通字符串,而不是符号。只有 Symbol.iterator 才是一个真正的符号,它可能会被添加到许多不同的对象中。

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

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

        请注意,在静态方法中,这指向的是类的构造函数,而不是实例--调用静态方法时周围没有实例(静态方法的执行早于实例创建)。

对列表进行迭代应返回列表从开始到结束的所有元素。我们将为迭代器编写一个单独的类。

        该类通过更新其 list 属性来跟踪列表的迭代进度,每当返回一个值时就移动到下一个列表对象,并在该列表为空(null)时报告迭代完成。

        让我们将 List 类设置为可遍历。在本书中,我偶尔会使用事后原型操作来为类添加方法,从而使单个代码片段保持小而独立。在普通程序中,不需要将代码分割成小块,而是直接在类中声明这些方法。

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

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

继承

        想象一下,我们需要一个列表类型,就像我们之前看到的 List 类一样,但因为我们会一直询问它的长度,所以我们不希望它每次都要扫描它的其余部分。相反,我们希望在每个实例中存储长度,以便高效访问。

        通过 JavaScript 的原型系统,我们可以创建一个新类,它与原来的类非常相似,但它的某些属性有了新的定义。新类的原型来源于旧类的原型,但增加了新的定义,比如长度获取器。

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

        extends 这个词的使用表明,这个类不应该直接基于默认的 Object 原型,而应该基于其他一些类。这就是所谓的超类。派生类就是子类。

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

        然后,构造函数将 list 的长度存储在一个私有属性中。如果我们在这里写 this.length,类自己的 getter 就会被调用,但这还不起作用,因为 #length 还没有被填入。我们可以使用 super.something 来调用超类原型上的方法和获取器,这通常很有用。

        继承允许我们用相对较少的工作从现有的数据类型中构建出略有不同的数据类型。它与封装和多态性一样,是面向对象传统的基本组成部分。虽然后两者现在被普遍认为是很好的想法,但继承却更有争议。

        封装和多态可以用来将代码片段相互分离,减少整个程序的纠结性,而继承则从根本上将类绑在一起,造成更多纠结。在继承一个类时,你通常要比简单地使用它更了解它是如何工作的。继承是一种有用的工具,可以使某些类型的程序更加简洁,但它不应该是你使用的第一种工具,你可能也不应该主动去寻找构建类层次结构(类的家族树)的机会。

instanceof操作符

        有时,了解一个对象是否派生于一个特定的类是非常有用的。为此,JavaScript 提供了一个名为 instanceof 的二进制操作符。

        该运算符可以透视继承类型,因此 LengthList 就是 List 的实例。该运算符也可应用于标准构造函数,如 Array。几乎所有对象都是 Object 的实例。

总结

        对象不仅仅拥有自己的属性。它们有原型,也就是其他对象。只要它们的原型拥有它们不拥有的属性,它们就会表现得好像拥有该属性一样。简单对象的原型是 Object.prototype。

        构造函数是名称通常以大写字母开头的函数,可以与 new 运算符一起使用来创建新对象。新对象的原型就是在构造函数的 prototype 属性中找到的对象。将给定类型的所有值共享的属性放入其原型中,可以很好地利用这一点。有一种类符号提供了定义构造函数及其原型的清晰方法。

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

        instanceof 操作符可以在给定一个对象和一个构造函数的情况下,告诉你该对象是否是该构造函数的实例。

        对于对象来说,一件有用的事情就是为它们指定一个接口,并告诉所有人,他们只能通过该接口与你的对象对话。构成对象的其他细节现在都被封装起来,隐藏在接口后面。您可以使用私有属性来隐藏对象的一部分,使其不为外界所知。不止一种类型可以实现同一个接口。为使用接口而编写的代码会自动知道如何与提供接口的任意数量的不同对象协同工作。这就是所谓的多态性。在实现仅在某些细节上不同的多个类时,将新类写成现有类的子类,继承其部分行为,可能会有所帮助。

练习
矢量类型(vector)

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

给 Vec 原型提供两个方法:

  1. plus 和 minus,这两个方法将另一个向量作为参数,并返回一个新向量,这个新向量具有两个向量(本向量和参数)的 x 值和 y 值的和或差。
  2. 在原型中添加一个 getter 属性 length,用于计算向量的长度,即点(x,y)到原点(0,0)的距离。

代码:

class Vec {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }

    plus(vector) {
        this.x += vector.x;
        this.y += vector.y;
        return this;
    }

    minus(vector) {
        this.x -= vector.x;
        this.y -= vector.y;
        return this;
    }

    get length() {
        return Math.sqrt(this.x * this.x + this.y * this.y);
    }
}

        标准 JavaScript 环境提供了另一种名为集合(Set)的数据结构。与 Map 的实例一样,集合也是值的集合。与 Map 不同的是,它不会将其他值与这些值关联起来,而只是跟踪哪些值是集合的一部分。一个值只能成为集合的一部分--再次添加不会有任何影响。

  1. 编写一个名为 Group 的类(因为 Set 已被占用)。与 Set 类一样,它也有 add、delete 和 has 方法。它的构造函数会创建一个空组,add 会向组中添加一个值(但前提是它还不是组的成员),delete 会从组中删除它的参数(如果它是组的成员),has 会返回一个布尔值,表示它的参数是否是组的成员。
  2. 使用 === 操作符或类似的 indexOf 来确定两个值是否相同。
  3. 为该类提供一个静态 from 方法,该方法将一个可迭代对象作为参数,并创建一个组,其中包含通过迭代产生的所有值。

代码:

class Group {
    arr;
    constructor() {
        this.arr = [];
    }

    add(val) {
        if (!this.has(val)) {
            this.arr.push(val);
        }
    }

    delete(val) {
        this.arr = this.arr.filter(n => n !== val);
    }

    has(val) {
        return this.arr.indexOf(val) !== -1;
    }

    get val() {
        return this.arr;
    }
    static from(vals) {
        let ne = new Group();
        for (let i of vals) {
            ne.arr.push(i);
        }
        return ne;
    }
}
可迭代组

        将前面练习中的 Group 类设置为可迭代。如果还不清楚迭代器接口的具体形式,请参阅本章前面关于迭代器接口的章节。

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

如果在迭代过程中修改了组,你的迭代器表现奇怪也没关系。

代码:

//新建迭代器类
class Groupiterator {
    position = 0;
    constructor(group) {
        console.log(group);
        this.group = group;
    }
    next() {
        if (this.position < this.group.arr.length) {
            return { value: this.group.arr[this.position++], done: false };
        } else {
            return { done: true }
        }
    }
}

//绑定迭代器
Group.prototype[Symbol.iterator] = function () {
    return new Groupiterator(this);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值