Skip to main content

第一章:面向对象的 JavaScript

1.1 动态类型语言和鸭子类型

静态类型语言的优点首先是在编译时就能发现类型不匹配的错误,编辑器可以帮助我们提前避免程序在运行期间有可能发生的一些错误。 其次,如果在程序中明确地规定了数据类型,编译器还可以针对这些信息对程序进行一些优化工作,提高程序执行速度。

静态类型语言的缺点

  1. 首先是迫使程序员依照强契约来编写程序,为每个变量规定数据类型,归根结底只是辅助我们编写可靠性高程序的一种手段,而不是编写程序的目的,毕竟大部分人编写程序的目的是为了完成需求交付生产。

  2. 其次,类型的声明也会增加更多的代码,在程序编写过程中,这些细节会让程序员的精力从思考业务逻辑上分散开来。

动态类型语言的优点

  1. 编写的代码数量更少,看起来也更加简洁,程序员可以把精力更多地放在业务逻辑上面。虽然不区分类型在某些情况下会让程序变得难以理解,但整体而言,代码量越少,越专注于逻辑表达,对阅读程序是越有帮助的。

动态类型语言的缺点

  1. 无法保证变量的类型,从而在程序的运行期有可能发生跟类型相关的错误。

鸭子类型的通俗说法是:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”

鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注 HAS-A,而不是 IS-A。

在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。

利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”

例如,一个对象若有 pushpop 方法,并且这些方法提供了正确的实现,它就可以被当作栈来使用。

一个对象如果有 length 属性,也可以依照下标来存取属性(最好还要拥有 slicesplice 等方法),这个对象就可以被当作数组来使用。

“面向接口编程”是设计模式中最重要的思想。

在 JavaScript 语言中,“面向接口编程”的过程跟主流的静态类型语言不一样。 因此,在 JavaScript 中实现设计模式的过程与在一些我们熟悉的语言中实现的过程会大相径庭。

1.2 多态

“多态”一词源于希腊文 polymorphism,拆开来看是 poly(复数)+ morph(形态)+ ism,从字面上我们可以理解为复数形态。

多态的实际含义是:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果

多态背后的思想是将 “做什么”“谁去做,以及具体怎么做” 分离开来。

动物都会叫,这是不变的,但是不同类型的动物具体怎么叫是可变的。

JavaScript 是一门不必进行类型检查的动态类型语言,为了真正了解多态的目的,我们需要转一个弯,从一门静态类型语言说起。

某些时候,在享受静态语言类型检查带来的安全性的同时,我们亦会感觉被束缚住了手脚。

为了解决这一问题,静态类型的面向对象语言通常被设计为可以向上转型:当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。

多态性的表现正是实现众多设计模式的目标。

使用继承来得到多态效果,是让对象表现出多态性的最常用手段。

继承通常包括实现继承和接口继承。

多态的思想实际上是把 “做什么” 和 “谁去做” 分离开来,要实现这一点,归根结底先要消除类型之间的耦合关系。

一个 JavaScript 对象,既可以表示 Duck 类型的对象,又可以表示 Chicken 类型的对象,这意味着 JavaScript 对象的多态性是与生俱来的

某一种动物能否发出叫声,只取决于它有没有 makeSound 方法,而不取决于它是否是某种类型的对象,这里不存在任何程度上的“类型耦合”。

在 JavaScript 中,并不需要诸如向上转型之类的技术来取得多态的效果。

多态的最根本好处在于,你不必再向对象询问 “你是什么类型” 而后根据得到的答案调用对象的某个行为——你只管调用该行为就是了,其他的一切多态机制都会为你安排妥当。

多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句

每个对象应该做什么,已经成为了该对象的一个方法,被安装在对象的内部,每个对象负责它们自己的行为。 所以这些对象可以根据同一个消息,有条不紊地分别进行各自的工作。

将行为分布在各个对象中,并让这些对象各自负责自己的行为,这正是面向对象设计的优点。

在 JavaScript 这种将函数作为一等对象的语言中,函数本身也是对象,函数用来封装行为并且能够被四处传递。

当我们对一些函数发出“调用”的消息时,这些函数会返回不同的执行结果,这是“多态性”的一种体现,也是很多设计模式在 JavaScript 中可以用高阶函数来代替实现的原因。

1.3 封装

封装的目的是将信息隐藏。

这一节将讨论更广义的封装,不仅包括封装数据和封装实现,还包括封装类型和封装变化。

在许多语言的对象系统中,封装数据是由语法解析来实现的,这些语言也许提供了 privatepublicprotected 等关键字来提供不同的访问权限。

除了 ECMAScript 6 中提供的 let 之外,一般我们通过函数来创建作用域。

在 ECMAScript 6 中,还可以通过 Symbol 创建私有属性。

有时候我们喜欢把封装等同于封装数据,但这是一种比较狭义的定义。

封装的目的是将信息隐藏,封装应该被视为“任何形式的封装”,也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。

封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。 当我们修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。

迭代器的作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式来顺序访问这个聚合对象。

一般而言,封装类型是通过抽象类和接口来进行的。

把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。

在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一。 比如工厂方法模式、组合模式等。

对于 JavaScript 的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。

从设计模式的角度出发,封装在更重要的层面体现为封装变化。

“找到变化并封装之”

这 23 种设计模式分别被划分为 创建型模式结构型模式行为型模式

  1. 创建型模式的目的就是 封装创建对象的变化
  2. 结构型模式封装的是 对象之间的组合关系
  3. 行为型模式封装的是 对象的行为变化

1.4 原型模式和基于原型继承的 JavaScript 对象系统

在 Brendan Eich 为 JavaScript 设计面向对象系统时,借鉴了 Self 和 Smalltalk 这两门基于原型的语言。

在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。

在原型编程的思想中,类并不是必需的,对象未必需要从类中创建而来,一个对象是通过克隆另外一个对象所得到的。

原型模式不单是一种设计模式,也被称为一种编程泛型。

从设计模式的角度讲,原型模式是用于创建对象的一种模式。

如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。

原型模式选择了另外一种方式,我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。

如果需要一个跟某个对象一模一样的对象,就可以使用原型模式。

原型模式的实现关键,是语言本身是否提供了 clone 方法。

ECMAScript 5 提供了 Object.create 方法,可以用来克隆对象。

原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。

在用 Java 等静态类型语言编写程序的时候,类型之间的解耦非常重要。

new XXX 创建对象的方式显得很僵硬。工厂方法模式和抽象工厂模式可以帮助我们解决这个问题,但这两个模式会带来许多跟产品类平行的工厂类层次,也会增加很多额外的代码。

原型模式提供了另外一种创建对象的方式,通过克隆对象,我们就不用再关心对象的具体类型名字。这就像一个仙女要送给三岁小女孩生日礼物,虽然小女孩可能还不知道飞机或者船怎么说,但她可以指着商店橱柜里的飞机模型说“我要这个”。

在 JavaScript 这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题。

在 JavaScript 语言中不存在类的概念,对象也并非从类中创建出来的,所有的 JavaScript 对象都是从某个对象上克隆而来的。

作为一门基于原型的语言,Io 中同样没有类的概念,每一个对象都是基于另外一个对象的克隆。 就像吸血鬼的故事里必然有一个吸血鬼祖先一样。

跟使用“类”的语言不一样的地方是,Io 语言中最初只有一个根对象 Object,其他所有的对象都克隆自另外一个对象。

如果 A 对象是从 B 对象克隆而来的,那么 B 对象就是 A 对象的原型。

这个原型链是很有用处的,当我们尝试调用 Dog 对象的某个方法时,而它本身却没有这个方法,那么 Dog 对象会把这个请求委托给它的原型 Animal 对象,如果 Animal 对象也没有这个属性,那么请求会顺着原型链继续被委托给 Animal 对象的原型 Object 对象,这样一来便能得到继承的效果,看起来就像 Animal 是 Dog 的“父类”, Object 是 Animal 的“父类”。

这个机制并不复杂,却非常强大,Io 和 JavaScript 一样,基于原型链的委托机制就是原型继承的本质。

原型编程中的一个重要特性,即当对象无法响应某个请求时,会把该请求委托给它自己的原型。

我们可以发现原型编程范型至少包括以下基本规则:

  • 所有的数据都是对象。
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
  • 对象会记住它的原型。
  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。

在原型继承方面,JavaScript 的实现原理和 Io 语言非常相似,JavaScript 也同样遵守这些原型编程的基本规则:

  • 所有的数据都是对象。
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
  • 对象会记住它的原型。
  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。

下面我们来分别讨论 JavaScript 是如何在这些规则的基础上来构建它的对象系统的。

  1. 所有的数据都是对象

    JavaScript 在设计的时候,模仿 Java 引入了两套类型机制:基本类型和对象类型。 基本类型包括 undefined、number、boolean、string、function、object。

    从现在看来,这并不是一个好的想法。

    按照 JavaScript 设计者的本意,除了 undefined 之外,一切都应是对象。

    为了实现这一目标,numberbooleanstring 这几种基本类型数据也可以通过“包装类”的方式变成对象类型数据来处理。

    能说在 JavaScript 中所有的数据都是对象,但可以说绝大部分数据都是对象。

    在 JavaScript 中也一定会有一个根对象存在,这些对象追根溯源都来源于这个根对象。

    JavaScript 中的根对象是 Object.prototype 对象。 Object.prototype 对象是一个空的对象。

    我们在 JavaScript 遇到的每个对象,实际上都是从 Object.prototype 对象克隆而来的,Object.prototype 对象就是它们的原型。

    var obj1 = new Object();
    ​​var obj2 = {};​​

    可以利用 ECMAScript 5 提供的 Object.getPrototypeOf 来查看这两个对象的原型。

    console.log( Object.getPrototypeOf( obj1 ) === Object.prototype ); //输出:true
    ​​​​​​​​console.log( Object.getPrototypeOf( obj2 ) === Object.prototype ); //输出:true​​
  2. 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它

    在 JavaScript 语言里,我们并不需要关心克隆的细节,因为这是引擎内部负责实现的。 我们所需要做的只是显式地调用 var obj1 = new Object() 或者 var obj2 = {}。 此时,引擎内部会从 Object.prototype 上面克隆一个对象出来,我们最终得到的就是这个对象

    function Person(name) {
    this.name = name;
    }
    Person.prototype.getName = function () {
    return this.name;
    };
    var a = new Person("sven");
    console.log(a.name); // 输出:sven
    console.log(a.getName()); // 输出:sven
    console.log(Object.getPrototypeOf(a) === Person.prototype); // 输出:true

    在这里 Person 并不是类,而是函数构造器

    JavaScript 的函数既可以作为普通函数被调用,也可以作为构造器被调用。

    当使用 new 运算符来调用函数时,此时的函数就是一个构造器。

    new 运算符来创建对象的过程,实际上也只是先克隆 Object.prototype 对象,再进行一些其他额外操作的过程。

    在 Chrome 和 Firefox 等向外暴露了对象 __proto__ 属性的浏览器下,我们可以通过下面这段代码来理解 new 运算的过程。

    function Person(name) {
    this.name = name;
    }
    Person.prototype.getName = function () {
    return this.name;
    };
    var objectFactory = function () {
    var obj = new Object(), // 从 Object.prototype 上克隆一个空的对象
    Constructor = [].shift.call(arguments); // 取得外部传入的构造器,此例是 Person
    obj.__proto__ = Constructor.prototype; // 指向正确的原型
    var ret = Constructor.apply(obj, arguments); // 借用外部传入的构造器给 obj 设置属性
    return typeof ret === "object" ? ret : obj; // 确保构造器总是会返回一个对象
    };
    var a = objectFactory(Person, "sven");
    console.log(a.name); // 输出:sven
    console.log(a.getName()); // 输出:sven
    console.log(Object.getPrototypeOf(a) === Person.prototype); // 输出:true

    我们看到,分别调用下面两句代码产生了一样的结果。

    ​​​​​​​​​​var a = objectFactory( A, 'sven' );
    ​​var a = new A( 'sven' );​​
  3. 对象会记住它的原型

    如果请求可以在一个链条中依次往后传递,那么每个节点都必须知道它的下一个节点。

    同理,要完成 Io 语言或者 JavaScript 语言中的原型链查找机制,每个对象至少应该先记住它自己的原型。

    就 JavaScript 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。

    对于“对象把请求委托给它自己的原型”这句话,更好的说法是对象把请求委托给它的构造器的原型。

    JavaScript 给对象提供了一个名为 __proto__ 的隐藏属性,某个对象的 __proto__ 属性默认会指向它的构造器的原型对象,即 {Constructor}.prototype

    实际上,__proto__ 就是对象跟“对象构造器的原型”联系起来的纽带。 正因为对象要通过 __proto__ 属性来记住它的构造器的原型。

  4. 如果对象无法响应某个请求,它会把这个请求委托给它的构造器的原型

    这条规则即是原型继承的精髓所在。

    对 Io 语言的学习中,我们已经了解到,当一个对象无法响应某个请求的时候,它会顺着原型链把请求传递下去,直到遇到一个可以处理该请求的对象为止。

    JavaScript 的克隆跟 Io 语言还有点不一样。

    Io 中每个对象都可以作为原型被克隆。

    而在 JavaScript 中,每个对象都是从 Object.prototype 对象克隆而来的,

    这样的话,我们只能得到单一的继承关系,即每个对象都继承自 Object.prototype 对象,这样的对象系统显然是非常受限的。

    实际上,虽然 JavaScript 的对象最初都是由 Object.prototype 对象克隆而来的,但对象构造器的原型并不仅限于 Object.prototype 上,而是可以动态指向其他对象。

    这样一来,当对象 a 需要借用对象 b 的能力时,可以有选择性地把对象 a 的构造器的原型指向对象 b,从而达到继承的效果。

    var obj = { name: "sven" };
    var A = function () {};
    A.prototype = obj;
    var a = new A();
    console.log(a.name); // 输出:sven

    我们来看看执行这段代码的时候,引擎做了哪些事情。

    • 尝试遍历对象 a 中的所有属性,但没有找到 name 这个属性。
    • 查找 name 属性的这个请求被委托给对象 a 的构造器的原型,它被 a.__proto__ 记录着并且指向 A.prototype,而 A.prototype 被设置为对象 obj
    • 在对象 obj 中找到了 name 属性,并返回它的值。
    var A = function () {};
    A.prototype = { name: "sven" };
    var B = function () {};
    B.prototype = new A();
    var b = new B();
    console.log(b.name); // 输出:sven

    再看这段代码执行的时候,引擎做了什么事情。

    • 首先,尝试遍历对象 b 中的所有属性,但没有找到 name 这个属性。
    • 查找 name 属性的请求被委托给对象 b 的构造器的原型,它被 b.__proto__ 记录着并且指向 B.prototype,而 B.prototype 被设置为一个通过 new A() 创建出来的对象。
    • 在该对象中依然没有找到 name 属性,于是请求被继续委托给这个对象构造器的原型 A.prototype
    • A.prototype 中找到了 name 属性,并返回它的值。

    原型链并不是无限长的。

    Object.prototype 的原型是 null

    设计模式在很多时候其实都体现了语言的不足之处。

    Peter Norvig 曾说,设计模式是对语言不足的补充,如果要使用设计模式,不如去找一门更好的语言。

    当前的 JavaScript 引擎下,通过 Object.create 来创建对象的效率并不高,通常比通过构造函数创建对象要慢。

    通过设置构造器的 prototype 来实现原型继承的时候,除了根对象 Object.prototype 本身之外,任何对象都会有一个原型。

    通过 Object.create(null) 可以创建出没有原型的对象。