🗒️面向对象

这篇不是原创,是整理的笔记

1. 对象

1.1 描述方式

Object 在英文中是一切事物的总称。

A thing that can be seen and touched, but is not alive.

Grady Booch 在《面向对象分析与设计》中提到,从人类的认知角度上说,对象应该是下列事物之一:

  1. 一个可以触摸或者可以看见的东西

  2. 人的智力可以理解的东西

  3. 可以指导思考或行动(进行想象或施加动作)的东西

不同编程语言的设计者,利用不同的语言特性来抽象描述对象。

  1. 使用“类”的方式来描述对象(最成功的流派),比如 C++、Java

  2. 使用“原型”的方式来描述对象(较冷门),比如 self、kevo、JavaScript

“基于类”的编程提倡的开发模型是关注分类和类之间的关系。在这类语言中,总是先有类,再从类去实例化一个对象,然后类与类之间又可能会形成继承、组合等关系。同时,类又往往与语言的类型系统相整合,形成一定编译时的能力。

“基于原型”的编程看起来更为提倡程序员去关注对象和对象之间的关系,然后才去关心如何将这些对象划分到与之相似的原型对象,而不是将它们分成类。比如,“照猫画虎”里的猫看起来就是虎的原型。基于原型的面向对象系统通过“复制”的方式来创建新对象,这通常有两种思路——浅拷贝和深拷贝,历史上也因此产生了两个流派,显然 JavaScript 选择了浅拷贝。

基于类和基于原型都能满足基本的复用和抽象需求,只是适用的场景不太相同。

1.2 本质特征

幸运的是,从运行时角度看,可以不用受到这些“基于类的设施”的困扰,因为任何语言运行时类的概念都是被弱化的。

《面向对象分析与设计》中提到,对象的本质特征:

  1. 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象

  2. 对象具有状态:同一对象可能处于不同状态下

  3. 对象具有行为:对象的状态可能因为它的行为而产生变迁

关于对象的唯一标识,通常都是用内存地址来体现的。

// 对象具有唯一的内存地址
let o1 = {};
let o2 = {};
o1 == o2; // false
o1 === o2; // false

关于对象的状态和行为,不同语言会用不同的术语来抽象描述。比如:

  • C++ 是成员变量、成员函数

  • Java 是属性、方法

  • JavaScript 是将状态和行为统一抽象为属性

var o = {
  d: 1,
  f() {
    console.log(this.d);
  }
};
// 虽然写法不同,但对于 JavaScript 来说,d 和 f 就是两个普通的属性

2. JavaScript 的对象

2.1 相关背景

由于公司的政治原因,JavaScript 在推出之时被要求模仿 Java,所以 Brendan Eich 在“原型运行时”的基础上引入了 new、this 等语言特性,使之看起来更像 Java,但并没有继承等关键特性。

在 ES6 之前,大量的 JavaScript 程序员试图在原型体系的基础上,把 JavaScript 变得更像是基于类的编程,比如 PrototypeJS、Dojo。事实上,它们成了某种 JavaScript 的古怪方言,甚至产生了一系列互不相容的社群。

从 ES6 开始,JavaScript 提供了 class 关键字来定义类,尽管这样的方案仍然是基于原型运行时系统的模拟,但它修正了之前一些常见的“坑”,统一了社区的方案。

Brendan 更是透露过,他最初的构想是一个拥有基于原型的面向对象能力的 scheme 语言(基于原型的面向对象+函数式)。在 JavaScript 之前,原型系统就更多地与高动态性语言配合,并且多数基于原型的语言都提倡运行时的原型修改,这应该是 Brendan 选择原型系统很重要的理由。

2.2 动态性

在实现了对象本质特征的基础上,JavaScript 中对象独有的特色是:对象具有高度的动态性。JavaScript 赋予了使用者在运行时为对象添加和修改状态和行为的能力,这跟绝大多数基于类的或是静态的对象设计完全不同。

let o = { a: 1 };
o.b = 2;

为了提高抽象能力,JavaScript 的属性被设计成了比别的语言更复杂的形式,它并非只是简单的名称和值,而是用一组特征(attributes)来描述属性(property)。JavaScript 的属性提供了数据属性和访问器属性两类,访问器属性可以视为一种函数的语法糖,详见 Object 的属性描述符

实际上,JavaScript 对象的运行时是一个“属性的集合”,属性的 key 是字符串或者 Symbol(以 Symbol 为属性名算是 JavaScript 对象的一个特色),value 是数据属性特征值或者访问器属性特征值。

3. JavaScript 的原型

3.1 原型系统

抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说是相当简单,概括为两条:

  1. 如果所有对象都有私有字段 [[prototype]],就是对象的原型

  2. 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止

这个模型在 ES 的各个历史版本中并没有很大的改变,但从 ES6 以来,JavaScript 提供了一系列内置函数,以便更直接地访问和操纵原型。有三个方法,分别是:

  1. Object.create() 根据指定的原型创建新的对象,原型可以是 null

  2. Object.getPrototypeOf() 获取一个对象的原型

  3. Object.setPrototypeOf() 设置一个对象的原型

利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用。

// 创建了一个 cat 对象
const cat = {
  say() {
    console.log("miao~");
  },
  jump() {
    console.log("jump");
  }
};
// 在 cat 的基础上做了些修改,然后创建了 tiger 对象
const tiger = Object.create(cat, {
  say: {
    writable: true,
    configurable: true,
    enumerable: true,
    value: function() {
      console.log("roar!");
    }
  }
});
// 根据 cat 对象和 tiger 对象来创建另外的对象
const anotherCat = Object.create(cat);
anotherCat.say();
const anotherTiger = Object.create(tiger);
anotherTiger.say();
// 因为是浅拷贝,所以可以通过原始的 cat 对象和 tiger 对象来控制所有猫和虎的行为

3.2 早期版本

在早期的 JavaScript 版本中,“类”的定义是一个私有属性 [[class]],内置类型诸如 Number、String、Date 等都指定了 [[class]] 属性以表示它们的类。语言使用者访问 [[class]] 属性的唯一方式是 Object.prototype.toString。示例代码如下:

// 具有内置 class 属性的对象
var o = new Object();
var n = new Number();
var s = new String();
var b = new Boolean();
var d = new Date();
var arg = (function() {
  return arguments;
})();
var r = new RegExp();
var f = new Function();
var arr = new Array();
var e = new Error();
console.log(
  [o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v))
);
// 0: "[object Object]"
// 1: "[object Number]"
// 2: "[object String]"
// 3: "[object Boolean]"
// 4: "[object Date]"
// 5: "[object Arguments]"
// 6: "[object RegExp]"
// 7: "[object Function]"
// 8: "[object Array]"
// 9: "[object Error]"

所以,在 ES3 和之前的版本中,JavaScript 中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。

从 ES5 开始,[[class]] 私有属性被 Symbol.toStringTag 代替(详见 Well-known Symbols),Object.prototype.toString 的意义从命名上不再和 class 相关。

但是,考虑到 JavaScript 语法中跟 Java 相似的部分,对类的讨论不能试图用“new 运算针对的是构造器对象而不是类”来回避,所以我们仍然要把 new 理解成 JavaScript 面向对象的一部分。

new 运算接受一个构造器和一组调用参数,实际上做了以下几件事:

  1. 以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象

  2. 将 this 和调用参数传给构造器,执行

  3. 如果构造器返回的是对象,则返回,否则返回第一步创建的对象

new 这样的行为,试图让函数对象在语法上变得和类相似,但它客观上提供了两种方式,一是在构造器中添加属性,二是在构造器的 prototype 属性上添加属性。

// 1. 直接在构造器中给 this 添加属性
function c1() {
  this.p1 = "p1.1";
  this.p2 = function() {
    console.log(this.p1);
  };
}
var o1 = new c1();
o1.p2();

// 2. 修改构造器的 prototype 属性指向的对象(从这个构造器构造出来的所有对象的原型)
function c2() {}
c2.prototype.p1 = "p2.1";
c2.prototype.p2 = function() {
  console.log(this.p1);
};
var o2 = new c2();
o2.p2();

在没有 Object.create()Object.setPrototypeOf() 的早期版本中,new 运算是唯一可以指定 [[prototype]] 的方法。当时的 mozilla 提供了私有属性 __proto__,但多数环境并不支持。

3.3 ES6 中的类

ES6 引入了 class 关键字,并且在标准中删除了所有与 [[class]] 相关的私有属性描述,类的概念正式从属性升级成了语言的基础设施。从此,基于类的编程方式成了 JavaScript 的官方编程范式。

class Rectangle {
  constructor(height, width) {
    // 数据型成员最好写在构造器里面
    this.height = height;
    this.width = width;
  }
  // Getter,通过 get/set 关键字来创建
  get area() {
    return this.calcArea();
  }
  // Method,通过 () 和 {} 来创建
  calcArea() {
    return this.height * this.width;
  }
}

此外,最重要的是,类提供了继承能力。与早期的原型模拟的方式相比,使用 extends 关键字自动设置了 constructor,并且会自动调用父类的构造函数,这是一种坑更少的设计。

至此,用 new 和 function 搭配的怪异行为终于可以退休了(虽然运行时没有改变)。类的写法实际上也是由原型运行时来承载的,逻辑上 JavaScript 认为每个类都是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象上。

当我们使用类的思想来设计代码时,应该尽量使用 class 来声明类,而让 function 回归到原本的函数语义。一些激进的观点认为,class 关键字和箭头运算符可以完全替代旧的 function 关键字,因为它们明确地区分了定义类和定义函数的这两种意图,这也是有一定道理的。

4. 写在最后

JavaScript 是一门正统的面向对象语言,它提供了完全运行时的对象系统,这使得它可以模仿不同的面向对象的编程范式——基于类和基于原型。JavaScript 中的对象是一个具有高度动态性的属性集合。

在新的 ES 版本中,不再需要模拟类了,因为我们有了光明正大的新语法 class。而原型体系则同时作为面向对象的编程范式和运行时机制而存在,我们可以自由地选择将类或者原型作为代码的抽象风格,但是无论选择哪种,理解运行时的原型系统都是很有必要的。

5. 参考来源

Last updated