🗒️面向对象
这篇不是原创,是整理的笔记
1. 对象
1.1 描述方式
Object 在英文中是一切事物的总称。
A thing that can be seen and touched, but is not alive.
Grady Booch 在《面向对象分析与设计》中提到,从人类的认知角度上说,对象应该是下列事物之一:
一个可以触摸或者可以看见的东西
人的智力可以理解的东西
可以指导思考或行动(进行想象或施加动作)的东西
不同编程语言的设计者,利用不同的语言特性来抽象描述对象。
使用“类”的方式来描述对象(最成功的流派),比如 C++、Java
使用“原型”的方式来描述对象(较冷门),比如 self、kevo、JavaScript
“基于类”的编程提倡的开发模型是关注分类和类之间的关系。在这类语言中,总是先有类,再从类去实例化一个对象,然后类与类之间又可能会形成继承、组合等关系。同时,类又往往与语言的类型系统相整合,形成一定编译时的能力。
“基于原型”的编程看起来更为提倡程序员去关注对象和对象之间的关系,然后才去关心如何将这些对象划分到与之相似的原型对象,而不是将它们分成类。比如,“照猫画虎”里的猫看起来就是虎的原型。基于原型的面向对象系统通过“复制”的方式来创建新对象,这通常有两种思路——浅拷贝和深拷贝,历史上也因此产生了两个流派,显然 JavaScript 选择了浅拷贝。
基于类和基于原型都能满足基本的复用和抽象需求,只是适用的场景不太相同。
1.2 本质特征
幸运的是,从运行时角度看,可以不用受到这些“基于类的设施”的困扰,因为任何语言运行时类的概念都是被弱化的。
《面向对象分析与设计》中提到,对象的本质特征:
对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象
对象具有状态:同一对象可能处于不同状态下
对象具有行为:对象的状态可能因为它的行为而产生变迁
关于对象的唯一标识,通常都是用内存地址来体现的。
关于对象的状态和行为,不同语言会用不同的术语来抽象描述。比如:
C++ 是成员变量、成员函数
Java 是属性、方法
JavaScript 是将状态和行为统一抽象为属性
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 赋予了使用者在运行时为对象添加和修改状态和行为的能力,这跟绝大多数基于类的或是静态的对象设计完全不同。
为了提高抽象能力,JavaScript 的属性被设计成了比别的语言更复杂的形式,它并非只是简单的名称和值,而是用一组特征(attributes)来描述属性(property)。JavaScript 的属性提供了数据属性和访问器属性两类,访问器属性可以视为一种函数的语法糖,详见 Object 的属性描述符。
实际上,JavaScript 对象的运行时是一个“属性的集合”,属性的 key 是字符串或者 Symbol(以 Symbol 为属性名算是 JavaScript 对象的一个特色),value 是数据属性特征值或者访问器属性特征值。
3. JavaScript 的原型
3.1 原型系统
抛开 JavaScript 用于模拟 Java 类的复杂语法设施(如 new、Function Object、函数的 prototype 属性等),原型系统可以说是相当简单,概括为两条:
如果所有对象都有私有字段
[[prototype]]
,就是对象的原型读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止
这个模型在 ES 的各个历史版本中并没有很大的改变,但从 ES6 以来,JavaScript 提供了一系列内置函数,以便更直接地访问和操纵原型。有三个方法,分别是:
Object.create()
根据指定的原型创建新的对象,原型可以是 nullObject.getPrototypeOf()
获取一个对象的原型Object.setPrototypeOf()
设置一个对象的原型
利用这三个方法,我们可以完全抛开类的思维,利用原型来实现抽象和复用。
3.2 早期版本
在早期的 JavaScript 版本中,“类”的定义是一个私有属性 [[class]]
,内置类型诸如 Number、String、Date 等都指定了 [[class]]
属性以表示它们的类。语言使用者访问 [[class]]
属性的唯一方式是 Object.prototype.toString
。示例代码如下:
所以,在 ES3 和之前的版本中,JavaScript 中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。
从 ES5 开始,[[class]]
私有属性被 Symbol.toStringTag
代替(详见 Well-known Symbols),Object.prototype.toString
的意义从命名上不再和 class 相关。
但是,考虑到 JavaScript 语法中跟 Java 相似的部分,对类的讨论不能试图用“new
运算针对的是构造器对象而不是类”来回避,所以我们仍然要把 new
理解成 JavaScript 面向对象的一部分。
new
运算接受一个构造器和一组调用参数,实际上做了以下几件事:
以构造器的 prototype 属性(注意与私有字段
[[prototype]]
的区分)为原型,创建新对象将 this 和调用参数传给构造器,执行
如果构造器返回的是对象,则返回,否则返回第一步创建的对象
new
这样的行为,试图让函数对象在语法上变得和类相似,但它客观上提供了两种方式,一是在构造器中添加属性,二是在构造器的 prototype 属性上添加属性。
在没有 Object.create()
、Object.setPrototypeOf()
的早期版本中,new
运算是唯一可以指定 [[prototype]]
的方法。当时的 mozilla 提供了私有属性 __proto__
,但多数环境并不支持。
3.3 ES6 中的类
ES6 引入了 class 关键字,并且在标准中删除了所有与 [[class]]
相关的私有属性描述,类的概念正式从属性升级成了语言的基础设施。从此,基于类的编程方式成了 JavaScript 的官方编程范式。
此外,最重要的是,类提供了继承能力。与早期的原型模拟的方式相比,使用 extends 关键字自动设置了 constructor,并且会自动调用父类的构造函数,这是一种坑更少的设计。
至此,用 new
和 function 搭配的怪异行为终于可以退休了(虽然运行时没有改变)。类的写法实际上也是由原型运行时来承载的,逻辑上 JavaScript 认为每个类都是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象上。
当我们使用类的思想来设计代码时,应该尽量使用 class 来声明类,而让 function 回归到原本的函数语义。一些激进的观点认为,class 关键字和箭头运算符可以完全替代旧的 function 关键字,因为它们明确地区分了定义类和定义函数的这两种意图,这也是有一定道理的。
4. 写在最后
JavaScript 是一门正统的面向对象语言,它提供了完全运行时的对象系统,这使得它可以模仿不同的面向对象的编程范式——基于类和基于原型。JavaScript 中的对象是一个具有高度动态性的属性集合。
在新的 ES 版本中,不再需要模拟类了,因为我们有了光明正大的新语法 class。而原型体系则同时作为面向对象的编程范式和运行时机制而存在,我们可以自由地选择将类或者原型作为代码的抽象风格,但是无论选择哪种,理解运行时的原型系统都是很有必要的。
5. 参考来源
Last updated