This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
<audio id="audio" title="12 | 1 in 1..constructor这行代码的结果既可能是true也可能是false" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/58/98/58a43316f5f91fc063221413c2db8698.mp3"></audio>
你好,我是周爱民。欢迎你回到我的专栏。
如果你听过上一讲那么你应该知道接下来我要与你聊的是JavaScript的**面向对象系统**。
最早期的JavaScript只有一个非常弱的对象系统。我用过JavaScript 1.0甚至可能还是最早尝试用它在浏览器中写代码的一批程序员我也寻找和收集过早期的CEniv和ScriptEase只为了探究它最早的语言特性与JavaScript之间的相似之处。
然而不得不说的是曾经的JavaScript在**面向对象**特性方面在语法上更像Java而在实现上却是谁也不像。
## JavaScript 1.0~1.3中的对象
在JavaScript 1.0的时候对象是不支持继承的。那时的JavaScript使用的是称为“**类抄写**”的技术来创建对象,就是“在一个函数中将`this引用`添加属性,并且使用`new运算`来创建对象实例”,例如:
```
function Car() {
this.name = &quot;Car&quot;;
this.color = &quot;Red&quot;;
}
var x = new Car();
```
关于类抄写以及与此相关的性质我会在后续的内容中详细讲述。现在你在这里需要留意的是在“Car()”这个函数中事实上该函数是以“类”的身份来声明了一系列的属性Property。正是因此使用`new Car()`来创建的“类的实例”(也就是对象`this`)也就具有了这些属性。
这样的“类→对象”的模型其实是很简单和粗糙的。但JavaScript 1.0时代的**对象**就是如此并且重要的是事实上直到现在JavaScript的对象仍然如此。ECMAScript规范明确定义了这样的一个概念
对象是零到多个的属性的集合。
>
In ECMAScript, an **object** is a collection of zero or more **properties**.
你可能还注意到了JavaScript 1.0的对象系统是有类的并且在语义上也是“对象创建自类”。这使得它在表面上“看起来”还是有一些继承性的。例如一个对象必然继承了它的类所声明的那些性质也就是“属性”。但是因为这个1.0版存在的时间很短所以后来大多数人都不记得JavaScript“**有类,而又不支持类的继承**”这件事情从而将从JavaScript 1.1才开始具有的**原型继承**作为它最主要的面向对象特征。
在这个阶段JavaScript中有关全局环境和全局变量的设计也已经成熟了简单地来说就是
1. 向没有声明的变量名赋值,会隐式地创建一个全局变量;
1. 全局变量会被绑定为全局对象global的属性 。
这样一来JavaScript的变量环境或者全局环境与对象系统就关联了起来。而接下来由于JavaScript也实现了带有闭包性质的函数因此“闭包”也成了环境的管理组件。也就是说闭包与对象都具有实现变量环境的能力。
因此在这个阶段JavaScript提出了“**对象闭包**”与“**函数闭包**”两个概念,并把它们用来实现的环境称为“**域**Scope”。这些概念和语言特性一直支持JavaScript走到1.3版本并随着ECMAScript ed3确定了下来。
在这个时代JavaScript语言的设计与发展还基本是以它的发明者布兰登·艾奇Brendan Eich为主导的JavaScript的语言特性也处于一个较小的集合中并且它的应用也主要是以浏览器客户端为主。这时代的JavaScript深得早期设计与语言定义的精髓。这些东西你可以从后来布兰登·艾奇的一个开源项目中读到。这个项目称为Narcissus是用JavaScript来实现的一个完整的JavaScript 1.3。在这个项目中对象和函数所创建的闭包都统一由一个简单的对象表示称为scope它包括“object”和“parent”两个成员分别表示本闭包的对象以及父一级的作用域。例如
```
scope = {
object: &lt;创建本闭包的对象或函数&gt;,
parent: &lt;父级的scope&gt;
}
```
因此,所谓“**使用with语句创建一个对象闭包**”就简单地被实现为“向既有的作用域链尾加入一个新的scope”。
```
// code from $(narcissus)/src/jsexec.js
...
// 向x所代表的scope-chain表尾加入一个新的scope
x.scope = {object: t, parent: x.scope};
try {
// n.body是with语句中执行的语句块
execute(n.body, x); // 指在该闭包(链)`x`中执行上述语句
}
finally {
x.scope = x.scope.parent; // 移除链尾的一个scope
}
```
可见JavaScript 1.3时代的执行环境,其实就是一个闭包链的管理。而且这种闭包既可以是对象的,也可以是函数的。尽管在静态语法说明或描述时,它们被称为**作用域**或**域**Scope或者在动态环境中它们被称为**上下文**Context但在本质上它们是同样的一堆东西。
综合来看JavaScript中的对象本质上是**属性集**,这可以视为一个**键值列表**,而对象继承是由这样的列表构成的、称为原型的链。另一方面,执行的上下文就是函数或全局的变量表,这同样可以表达为一个键值列表,而执行环境也可以视为一个由该键值列表构成的链。
于是在JavaScript 1.3以及ECMAScript ed3的整个时代这门语言仅仅依赖**键值列表**和**基于它们的链**实现并完善了它最初的设计。
## 属性访问与可见性
但是从一开始JavaScript就有一个东西没有说清楚那就是属性名的可见性。
这种可见性在OOP面向对象编程中有专门的、明确的说法但在早期的JavaScript中它可以简单地理解为“**一个属性是否能用for..in语句列举出来**”。如果它可以被列举,那么就是可见的,否则就称为隐藏的。
你知道任何对象都有“constructor”这个属性缺省指向创建它的构造器函数并且它应当是隐藏的属性。但是在早期的JavaScript中这个属性如何隐藏却是没有规范来约定的。例如在JScript中它就是一个特殊名字只要是这个名字就隐藏而在SpiderMonkey中当用户重写这个属性后它就变成了可见的。
后来ECMAScript就约定了所谓的“**属性的性质**attributes”这样的东西也就是我们现在知道的**可写性**、**可列举性**(可见性)和**可配置性**。ECMAScript约定
- “constructor”缺省是一个不可列举的属性
- 使用赋值表达式添加属性时,属性的可列举性缺省为`true`
这样一来“constructor”在可见性这里是指可列举性上的行为就变得可预期了。
类似于此的ECMAScript约定了读写属性的方法以及在属性中访问、操作性质的全部规则并统一使用所谓“属性描述符”来管理这些规则。于是这使得ECMAScript规范进入了5.x时代。相较于早期的3.x这个版本的ECMAScript规范并没有太多的改变只是从语言概念层面上实现了“大一统”所有浏览器厂商以及引擎的开发者都遵循了这些规则为后续的JavaScript大爆发——ECMAScript 6的发布铺平了道路。
到目前为止JavaScript中的对象仍然是简单的、原始的、使用JavaScript 1.x时代的基础设计的原型继承。而每一个对象仍然都只是简简单单的一个所谓的“**属性包**”。
## 从原型中继承来的属性
对于绝大多数对象来说“constructor”是从它的原型继承来的一个属性这有别于它“自有的Own”属性。在原型继承中在子类实例重写属性时实际发生的行为是“**在子类实例的自有属性表中添加一个新项**”。这并不改变原型中相同属性名的值,但子类实例中的**属性性质**以及**值**覆盖了原型中的。这是原型继承——几乎是公开的——所有的秘密所在。
在使用原型继承来的属性时,有两种可能的行为,这取决于属性的具体性质——属性描述符的类型。
1. 如果是**数据描述符**d那么`d.value`总是指向这个数据的值本身;
1. 如果是**存取描述符**,那么`d.get()``d.set()`将分别指向属性的存取方法。
并且如果是存取描述符那么存取方法get/setter并不一定关联到数据也并不一定是数据的置值或取值。某些情况下存取方法可能会用作特殊的用途例如模拟在VBScript中常常出现的“无括号的方法调用”。
```
excel = Object.defineProperty(new Object, 'Exit', {
get() {
process.exit();
}
});
// 类似JScript/VBScript中的ActiveObject组件的调用方法
excel.Exit;
```
当用户不使用属性赋值或`defineProperty()`等方法来添加自有的属性时,属性访问会(默认地)上溯原型链直到找到指定属性。这一定程度上成就了“包装类”这一特殊的语言特性。
所谓“**包装类**”是JavaScript从Java借鉴来的特性之一它使得用户代码可以用标准的面向对象方法来访问普通的值类型数据。于是所谓“一切都是对象”就在眨眼间变成了现实。例如下面这个示例中使用的字符串常量x它的值是"abc"
```
x = &quot;abc&quot;;
console.log(x.toString());
```
当在使用x.toString()时JavaScript会自动将“值类型的字符串"abc"”通过包装类变成一个字符串对象。这类似于执行下面的代码使用函数Object()来“将这个值显式地转换为对象”。
```
console.log(Object(x).toString());
```
这个包装的过程发生于**函数调用运算“( )”**的处理过程中或者将“x.toString”作为整体来处理的过程中例如作为一个ECMAScript规范引用类型来处理的过程。也就是说仅仅是“对象属性存取”这个行为本身并不会触发一个普通“值类型数据”向它的包装类型转换。
除了`Undefined`,基本类型中的所有值类型数据都有自己的包装类,包括符号,又或者布尔值。这使得这些值类型的数据也可以具有与之对应的包装类的原型属性或方法。这些属性与方法自己引用自原型,而不是自有数据。很显然的,值类型数据本身并不是对象,因此也不可能拥有自有的属性表。
## 字面量与标识符
通常情况下,开发人员会将标识符直接称为**名字**在ECMAScript规范中它的全称是“标识符名字**IdentifierName**)”),而**字面量**是一个数据的文本表示。显然通常标识符就用作后者的名字标识。对于这两种东西在ECMAScript中的处理机制并不太一样并且在文本解析阶段就会把二者区分开来。
```
// var x = 1;
1;
x;
```
比如在这个例子中如果其中“1”是字面量值JavaScript会直接处理它而x是一个标识符哪怕它只是一个值类型数据的变量名就需要建立一个“引用”来处理了。但是接下来如果是代码假设下面的代码是成立的
```
1.toString
```
那么它作为“整体”就需要被创建为一个引用以作为后续计算的操作数取成员值或仅是引用该成员。是的就它们同是“引用”这一事实而言“1.toString”与“x”在引擎级别有些类似。
然而在数字字面量中“1.xxxxx”这样的语法是有含义的。它是浮点数的表示法。所以“1.toString”这样的语法在JavaScript中会报错这个错误来自于浮点数的字面量解析过程而不是“`.`作为存取运算符”的处理过程。在JavaScript中浮点数的小位数是可以为空的因此“1.”和“1.0”将作为相同的浮点数被解析出来。
既然“1.”表示的是浮点数那么“1..constructor”表示的就是该浮点数字面量的“.constructor”属性。
现在我想你已经看出来了,标题中的:
```
1 in 1..constructor
```
其实是一个表达式。在语义上“1..constructor”与“Object(1.0).constructor”这样的表达式是等义的且它们的使用效果也是一样的。
```
# 检查对象“constructor”是否有属性名“1”
&gt; 1 in Object(1.0).constructor
false
# (同上)
&gt; 1 in 1..constructor
fales
```
## 属性存取的不确定性
除了**存取器**get/setter带来的不确定性之外JavaScript的属性存取结果还受到**原型继承(链)**的影响。上例中的表达式值并不恒为`false`例如我们给Number加一个下标值为1的属性我们不用管这个属性的值是什么那么标题中的表达式“1 in 1..constructor”的值就会是`true`了。
```
# 修改原型链中的对象
&gt; Number[1] = true; // or anything
# 影响到上例中表达式的结果
&gt; 1 in 1..constructor
true
```
因为`Object(1.)`意味着将数字“1.0”封装成它对应的包装类的一个对象实例x我们假设这个对象是x那么“1..constructor”也就指向x.constructor。
```
x = new Number(1.0);
```
而“x.constructor”不是自有属性并且由于x是“Number()”这个类/构造器的子类实例因此该属性实际继承自原型链上的“Number.prototype.construtcotr”这个属性。然后在缺省情况下“**aFunction**.prototype.construtcotr”指向这个函数自身。
也就是说“Number.prototype.construtctor”与“1..constructor”相同且都指向Number()自身。
所以上面的示例中当我们添加了“Number[1]”这个下标属性之后,标题中表达式的值就变了。
## 知识回顾
这一讲的标题看起来像是其他语言中的循环或迭代又或者在代码文本上看起来像是一个范围检查语义上看起来像是“1在某个1..n”的范围中。但事实上它不仅包含了JavaScript中从对象成员存取这样的基础话题还一直延伸到了**包装类**这样的复杂概念的全部知识。
当然重要的是源于JavaScript中面向对象系统的独特设计它的对象属性存取结果总是不确定的。
- 如果属性不是自有的,那么它的值就是原型决定的;
- 当属性是存取方法的,那么它的值就是求值决定的。
## 思考题
虽然这一讲没有太深入的内容,但是有两道练习题留给大家,非常烧脑:
1. 试述表达式`[]`的求值过程。
1. 在上述表达式中加上符号“`+-*/`”并确保结果可作为表达式求值。
>
<p>NOTE题目1是一个空数组的“单值表达式”当它作为表达式来处理时请问它是如何求值的你得先想想它的“值”是多少<br>
NOTE题目2的意思就是如何把这些字符组合在一起仍然是一个可求值的表达式。</p>
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。

View File

@@ -0,0 +1,292 @@
<audio id="audio" title="13 | new X从构造器到类为你揭密对象构造的全程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/86/afd99eaefdac81619b6f77a3ceb9cd86.mp3"></audio>
你好,我是周爱民。
今天我只跟你聊一件事就是JavaScript构造器。标题中的这行代码中规中矩是我这个专栏题目列表中难得的正经代码。
>
NOTE需要稍加说明的是这行代码在JavaScript 1.x的某些版本或具体实现中是不能使用的。即使ECMAScript ed1开始就将它作为标准语法之一当时也还是有许多语言并不支持它。
**构造器**这个东西是JavaScript中面向对象系统的核心概念之一。跟“属性”相比如果属性是静态的结构那么“构造器”就是动态的逻辑。
没有构造器的JavaScript就是一个充填了无数数据的、静态的对象空间。这些对象之间既没有关联也不能衍生更不可能发生交互。然而这却真的就是JavaScript 1.0那个时代的所谓“面向对象系统”的基本面貌。
## 基于对象的JavaScript
为什么呢因为JavaScript1.0的时代也就是最早最早的JavaScript其实是没有继承的。
你可能会说既然是没有继承的那么JavaScript为什么一开始就能声称自己是“面向对象”的、“类似Java”的一门语言呢其实这个讲法是前半句对后半句不对。JavaScript和Java名字相似但语言特性却大是不同这就跟北京的“海淀五路居”和“五路居”一样差了得有20公里。
那前半句为什么是对的呢JavaScript 1.0连继承都没有,为什么又能称为面向对象的语言呢?
其实从我在前两讲中讲过的内容来看JavaScript 1.0确实已经可以将函数作为构造器,并且在函数中向它的实例(也就是`this`对象)抄写类声明的那些属性。在早期的面向对象理论里面,就已经可以称这个函数为**类**,而这个被创建出来的实例为**对象**了。
所以有了类、对象以及一个约定的构造过程有了这三个东西JavaScript就声称了自己是一门“面向对象”的语言并且还是一门“有类语言”。
所以JavaScript从1.0开始就有类在这个类也就是构造器中采用的是所谓“类抄写”的方案将类所拥有的属性声明一项一项地抄写到对象上面而这个对象就是我们现在大家都知道的this引用。
这样一来一段声明类和构造对象的代码大概写出来就是下面这个样子在一个函数里面不停地向this对象写属性最后再用new运算符来创建一下它的实例就好了。
```
function Car() {
this.name = &quot;Car&quot;;
this.color = &quot;Red&quot;;
}
var x = new Car();
...
```
## 类与构造器
由于在这样的构造过程中,`this`是作为`new`运算所构造出来的那个实例来使用的因此JavaScript 1.0约定全局环境中不能使用`this`的。因为全局环境与`new`运算无关,全局环境中也并不存在一个被`new`创建出来的实例。
然而随着`JavaScript 1.1`的到来JavaScript支持“原型继承”了于是“类抄写”成为了一个过时的方案。对于继承性来说它显得无用对于一个具体的实例来说它又具有“类说明了实例的结构”这样的语义。
因此,从“**原型继承**”在JavaScript中出现的第一天开始“类继承VS原型继承”之间就存在不可调和的矛盾。在`JavaScript 1.1`中,类抄写是可以与原型继承混合使用的。
例如你可以用类抄写的方式写一个Device()类然后再写一个Car()类最后你可以将Car()类的原型指向Device。这一切都是合理的、正常的写法。
```
function Device() {
this.id = 0; // or increment
}
function Car() {
this.name = &quot;Car&quot;;
this.color = &quot;Red&quot;;
}
Car.prototype = new Device();
var x = new Car();
console.log(x.id); //
```
于是现在你可以用new运算来创建子类Car()的实例了例如按照以前的习惯我们称这个实例为x这也仍然没有问题。
但是在面向对象编程OOP`x`既是`Car()`的子类实例也是“Device()”的子类实例这是OOP的继承性所约定的基本概念。这正是这门语言很有趣的地方**一方面使用了类继承的基础结构和概念,另一方面又要实现原型继承和基于原型链检查的逻辑。**例如,你用`x instanceof Device`这样的代码来检查一下,看看“`x`是不是`Device()`的子类实例”。
```
# `x`是`Device()`的子类实例吗?
&gt; x instanceof Device
true
```
于是,这里的`instanceof`运算被实现为一个**动态地访问原型链**的过程:它将从`Car.prototype`属性逆向地在原型链中查到你指定的——“原型”。
首先JavaScript从对象`x`的内部结构中取得它的原型。这个原型的存在,与`new`运算是直接相关的——在早期的JavaScript中有且仅有`new`运算会向对象内部写“原型”这个属性(称为"[[Prototype]]"内部槽。由于new运算是依据它运算时所使用的构造器来填写这个属性的所以这意味着它在实际实现时将Car.prototype这个值直接给填到x对象的内部属性去了。
```
// x = new Car()
x.[[Prototype]] === Car.prototype
```
`instanceof`运算中,`x instanceof AClass`表达式的右侧是一个类名对于之前的例子来说它指向构造器Car但实际上JavaScript是使用`AClass.prototype`来做比对的对于“Car()构造器”来说就是“Car.prototype”。但是如果上一个例子需要检查的是`x instanceof Device`也就是“Device.prototype”那么这二者显然是不等值的。
所以,`instanceof`运算会再次取“x.[[Prototype] [[Prototype]]”这个内部原型也就是顺着原型链向上查找并且你将找到一个等值于“x的内部原型”的东西。
```
// 因为
x.[[Prototype]] === Car.prototype
// 且
Car.prototype = new Device()
// 所以
x.[[Prototype]].[[Prototype]] === Device.prototype
```
现在,由于在`x`的原型链上发现了“x instanceof Device”运算右侧的“Device.prototype”所以这个表达式将返回True值表明
对象`x``Device()`或其子类的一个实例。
现在对于大多数JavaScript程序员来说上述过程应该都不是秘密也并不是特别难解的核心技术。但是在它的实现过程中所带有的语言设计方面的这些历史痕迹却不是那么容易一望即知的了。
## ECMAScript 6之后的类
在ECMAScript 6之前JavaScript中的**函数**、**类**和**构造器**这三个概念是混用的。一般来说,它们都被统一为“**函数Car()**”这个基础概念而当它用作“x = new Car()”这样的运算,或从`x.constructor`这样的属性中读取时,它被理解为**构造器**当它用作“x instanceof Car”这样的运算或者讨论OOP的继承关系时它被理解为**类**。
习惯上如果程序要显式地、字面风格地说明一个函数是构造器、或者用作构造过程那么它的函数名应该首字母大写。同时如果一个函数要被明确声明为“静态类也就不需要创建实例的类例如Math那么它的函数名也应该首字母大写。
>
NOTE: 仅从函数名的大小写来判断,只是惯例。没有任何方法来确认一个函数是不是“被设计为”构造器,或者静态类,又或者“事实上”是不是二者之一。
从ECMAScript 6开始JavaScript有了使用`class`来声明“类”的语法。例如:
```
class AClass {
...
}
```
自此之后JavaScript的“类”与“函数”有了明确的区别**类只能用new运算来创建而不能使用“()”来做函数调用。**例如:
```
&gt; new AClass()
AClass {}
&gt; AClass()
TypeError: Class constructor AClass cannot be invoked without 'new'
```
如果你尝试将“ES6的类”作为函数调用那么JavaScript就会抛出一个异常。
在ECMAScript 6之后JavaScript内部是明确区分方法与函数的不能对方法做new运算。如果你尝试这样做JavaScript也会抛一个异常出来提示你“这个函数不是一个构造器is not a constructor”。例如
```
# 声明一个带有方法的对象字面量
&gt; obj = { foo() {} }
{ foo: [Function: foo] }
# 对方法使用new运算会导致异常
&gt; new obj.foo()
TypeError: obj.foo is not a constructor
```
注意这个异常中又出现了关键字“constructor”。这让我们的讨论又一次回到了开始的话题**什么是构造器?**
在ECMAScript 6之后函数可以简单地分为三个大类
1.只可以做new运算
1. 方法:只可以做调用“( )”运算;
1. 一般函数除部分函数有特殊限制外同时可以做new和调用运算。
其中,典型的“方法”在内部声明时,有三个主要特征:
1. 具有一个名为“主对象`[[HomeObject]]`”的内部槽;
1. 没有名为“构造器`[[Construct]]`”的内部槽;
1. 没有名为“`prototype`”的属性。
后两种特征(没有`[[Construct]]`内部槽和`prototype`属性)完全排除了一个普通方法用作构造器的可能。对照来看,所谓“类”其实也是作为方法来创建的,但它有独立的构造过程和原型属性。
函数的“.prototype”的属性描述符中的设置比较特殊它不能删除但可以修改writable is true。当这个值被修改成null值时它的子类对象是以null值为原型的当它被修改成非对象值时它的子类对象是以Object.prototype为原型的否则当它是一个对象类型的值时它的子类才会使用该对象作为原型来创建实例。
运算符“new”总是依照这一规则来创建对象实例`this`
不过,对于“类”和一般的“构造器(函数)”,这个创建过程会略有不同。
## 创建`this`的顺序问题
如前所述如果对ECMAScript 6之前的构造器函数例如`f`)使用`new`运算那么这个new运算会使用`f.prototype`作为原型来创建一个`this`对象,然后才是调用`f()`函数,并将这个函数的执行过程理解为“类抄写(向用户实例抄写类所声明的属性)”。从用户代码的视角上来看,这个新对象就是由当前`new`运算所操作的那个函数`f()`创建的。
这在语义上非常简洁明了:由于`f()`是this的类因此`f.prototype`决定了this的原型`f()`执行过程决定了初始化this实例的方式。但是它带来了一个问题一个从JavaScript 1.1开始至今都困扰JavaScript程序员的问题
无法创建一个有特殊性质的对象,也无法声明一个具有这类特殊性质的类。
这是什么意思呢?比如说,所有的函数有一个公共的父类/祖先类,称为`Function()`。所以你可以用`new Function()`来创建一个普通函数这个普通函数也是可以调用的在JavaScript中这是很正常的用法例如
```
&gt; f = new Function;
&gt; f instanceof Function
true
&gt; f()
undefine
```
接下来,你也确实可以用传统方法写一个`Function()`的子类,但这样的子类创建的实例就不能调用。例如:
```
&gt; MyFunction = function() {};
&gt; MyFunction.prototype = new Function;
&gt; f = new MyFunction;
&gt; [f instanceof MyFunction, f instanceof Functcion]
[ true, true ]
&gt; f()
TypeError: f is not a funct
```
至于原因你可能也已经知道了JavaScript所谓的函数其实是“一个有`[[Call]]`内部槽的对象”。而`Function()`作为JavaScript原生的函数构造器它能够在创建的对象例如`this`)中添加这个内部槽,而当使用上面的继承逻辑时,用户代码(例如`MyFunction()`就只是创建了一个普通的对象因为用户代码没有能力操作JavaScript引擎层面才支持的那些“内部槽”。
所以,有一些“类/构造器”在ECMAScript 6之前是不能派生子类的例如Function又例如Date。
而到了ECMAScript 6它的“类声明”采用了不同的构造逻辑。ECMAScript 6要求所有子类的构造过程都不得创建这个`this`实例并主动的把这个创建的权力“交还”给父类、乃至祖先类。这也就是ECMAScript 6中类的两个著名特性的由来如果类声明中通过extends指定了父类那么
1. 必须在构造器方法constructor中显式地使用`super()`来调用父类的构造过程;
1. 在上述调用结束之前,是不能使用`this`引用的。
显然,真实的`this`创建就通过层层的`super()`交给了父类或祖先类中支持创建这个实例的构造过程。这样一来,子类中也能得到一个“拥有父类所创建的带有内部槽的”实例,因此上述的`Function()``Date()`等等的子类也就可以实现了。例如你可以在class MyFunction的声明中直接用extends指示父类为Function。
```
&gt; class MyFunction extends Function { }
&gt; f = new MyFunction;
&gt; f()
undefine
```
这样一来,即使`MyFunction()`的类声明中缺省了“constructor()”构造方法这种情况下JavaScript会在这种情况下为它自动创建一个并且其内部也仅有一个“super()”代码。关于这些过程的细节,我将留待下一讲再具体地与你解析。在这里,你最应该关注的是这个过程带来的必然结果:
ECMAScript 6的类是由父类或祖先类创建`this`实例的。
不过仍然有一点是需要补充的:如果类声明`class`中不带有`extends`子句那么它所创建出来的类与传统JavaScript的函数/构造器是一样的,也就是由自己来创建`this`对象。很显然,这是因为它无法找到一个显式指示的父类。不过关于这种情况,仍然隐藏了许多实现细节,我将会在下一讲中与你一起来学习它。
## 用户返回new的结果
在JavaScript中关于new运算与构造函数的最后一个有趣的设计就是**用户代码可以干涉new运算的结果**。默认情况下,这个结果就是上述过程所创建出来的`this`对象实例,但是用户可以通过在构造器函数/方法中使用`return`语句来显式地重置它。
这也是从JavaScript 1.0就开始具有的特性。因为JavaScript 1.x中的函数、类与构造器是混用的所以用户代码在函数中“返回些什么东西”是正常的语法也是正常的逻辑需求。但是JavaScript要求在构造器中返回的数据必须是一个对象否则就将抛出一个运行期的异常。
这个处理的约定从ECMAScript ed3开始有了些变化。从ECMAScript ed3开始检测构造器返回值的逻辑从`new`运算符中移到了`[[Construct]]`的处理过程中并且重新约定当构造器返回无效值非对象值或null使用原有已经创建的`this`对象作为构造过程`[[Constuct]]`的返回值。
因此到了ECMAScript 6之后那些一般函数以及非派生类就延续了这一约定**使用已经创建的`this`对象来替代返回的无效值**。这意味着它们总是能返回一个对象要么是new运算按规则创建的this要么是用户代码返回的对象。
>
NOTE: 关于为什么非派生类也支持这一约定的问题,我后续的课程中会再次讲到。基本上来说,你可以认为这是为了让它与一般构造器保持足够的“相似性”。
然而严格来说,引擎是不能理解“为什么用户代码会在构造器中返回一个一般的值类型数据”的。因为对于类的预期是返回一个对象,返回这种“无效值”是与预期矛盾的。因此,对于那些派生的子类(即声明中使用了`extends`子句的类ECMAScript要求严格遵循“不得在构造器中返回非对象值以及null值”的设计约定并在这种情况下直接抛出异常。例如
```
## (注ES3之前将抛出异常
&gt; new (function() {return 1});
{}
## 非派生类的构造方法返回无效值
&gt; new (class { constructor() { return 1 } })
{}
## 派生类的构造方法返回无效值
&gt; new (class extends Object { constructor() { return 1 } })
TypeError: Derived constructors may only return object or undefine
```
## 知识回顾
今天这一讲的一些知识点,是与你学习后续的专栏内容有关的。包括:
1. 在使用类声明来创建对象时,对象是由父类或祖先类创建的实例,并使用`this`引用传递到当前(子级的)类的。
1. 在类的构造方法和一般构造器函数中返回值是可以影响new运算的结果的但JavaScript确保new运算不会得到一个非对象值。
1. 类或构造器(函数)的首字母大写是一种惯例,而不是语言规范层面的约束。
1. 类继承过程也依赖内部构造过程(`[[Contruct]]`和原型属性prototype并且类继承实际上是原型继承的应用与扩展不同于早期JavaScript1.0使用的类抄写。
无论如何从JavaScript 1.0开始的“类抄写”这一特性依然是可用的。无论是在普通函数、类还是构造器中,都可以向`this`引用上抄写属性,但这个过程变得与“如何实现继承性”完全无关。这里的`this`可以是函数调用时传入的而不再仅仅来自于new运算的内置的构造过程创建。
## 思考题
1. 除了使用new X运算还有什么方法可以创建新的对象
1. 在ECMAScript 6之后除了new X之外还有哪些方法可以操作原型/原型链?
这些问题既是对本小节内容的回顾,也是下一阶段的课程中会用到的一些基础知识。建议你好好地寻求一下答案。
最后,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,189 @@
<audio id="audio" title="14 | super.xxx()虽然直到ES10还是个半吊子实现却也值得一讲" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a5/8f/a565b7d860dadcf083d9bd3410b2278f.mp3"></audio>
你好我是周爱民接下来我们继续讲述JavaScript中的那些奇幻代码。
今天要说的内容打根儿里起还是得从JavaScript的1.0谈起。在此前我已经讲过了JavaScript 1.0连继承都没有但是它实现了以“类抄写”为基础的、基本的面向对象模型。而在此之后才在JavaScript 1.1开始提出,并在后来逐渐完善了原型继承。
这样一来在JavaScript中从概念上来讲所谓对象就是一个从原型对象衍生过来的实例因此这个子级的对象也就具有原型对象的全部特征。
然而,既然是子级的对象,必然与它原型的对象有所不同。这一点很好理解,如果没有不同,那就没有必要派生出一级关系,直接使用原型的那一个抽象层级就可以了。
所以有了原型继承带来的子级对象这样的抽象层级在这个子级对象上就还需要有让它们跟原型表现得有所不同的方法。这时JavaScript 1.0里面的那个“类抄写”的特性就跳出来了它正好可以通过“抄写”往对象也就是构造出来的那个this上面添加些东西来制造这种不同。
也就是说JavaScript 1.1的面向对象系统的设计原则就是:**用原型来实现继承,并在类(也就是构造器)中处理子一级的抽象差异**。所以从JavaScript 1.1开始JavaScript有了自己的面向对象系统的完整方案这个示例代码大概如下
```
// 这里用于处理“不同的东西”
function CarEx(color) {
this.color = color;
...
}
// 这里用于从父类继承“相同的东西”
CarEx.prototype = new Car(&quot;Eagle&quot;, &quot;Talon TSi&quot;, 1993);
// 创建对象
myCar = new CarEx(&quot;red&quot;)
```
这个方案基本上来说就是两个解决思路的集合使用构造器函数来处理一些“不同的东西”使用原型继承来从父类继承“相同的东西”。最后new运算符在创建对象的过程中分别处理“原型继承”和构造器函数中的“类抄写”补齐了最后的一块木板。
你看,一个对象系统既能处理继承关系中那些“相同的东西”,又能处理“不同的东西”,所以显而易见:**这个系统能处理基于对象的“全部的东西”**。正是因为这种概念上的完整性所以从JavaScript 1.1开始一直到ECMAScript 5都在对象系统的设计上没能再有什么突破。
## 为什么要有super
但是有一个东西很奇怪,这也是对象继承的典型需求,就是说:子级的对象除了要继承父级的“全部的东西”之外,它还要继承“全部的能力”。
为什么只继承“全部的东西”还不够呢?如果只有全部的东西,那子级相对于父级,不过是一个系统的静态变化而已。就好像一棵枯死了的树,往上面添加些人造的塑料的叶子、假的果子,看起来还是树,可能还很好看,但根底里就是没有生命力的。而这样的一棵树,只有继承了原有的树的生命力,才可能是一棵活着的树。
如果继承来的树是活着的,那么装不装那些人造的叶子、果子,其实就不要紧了。
然而传统的JavaScript却做不到“继承全部的能力”。那个时候的JavaScript其实是能够在一定程度上继承来自原型的“部分能力”的譬如说原型有一个方法那么子级的实例就可以使用这个方法这时候子级也就继承了原型的能力。
然而这还不够。譬如说,如果子级的对象重写了这个方法,那么会怎么样呢?
在ECMAScript 6之前如果发生这样的事那么对不起**原型中的这个方法相对于子级对象来说,就失效了。**
原则上来讲,在子级对象中就再也找不到这个原型的方法了。这个问题非常地致命:这意味着子级对象必须重新实现原型中的能力,才能安全地覆盖原型中的方法。如果是这样,子级对象就等于要重新实现一遍原型,那继承性就毫无意义了。
这个问题追根溯源还是要怪到JavaScript 1.0~1.1的时候,设计面向对象模型时偷了的那一次懒。也就是直接将“类抄写”用于实现子级差异的这个原始设计,太过于简陋。“类抄写”只能处理那些显而易见的属性、属性名、属性性质,等等,却无法处理那些“方法/行为”背后的逻辑的继承。
由于这个缘故JavaScript 1.1之后的各种大规模系统中,都有人不断地在跳坑和补坑,致力于解决这么一个简单的问题:**在“类抄写”导致的子类覆盖中,父类的能力丢失了**。
为了解决这种继承问题ECMAScript 6就提出了一个标准解决方案这就是今天我们讲述的这一行代码中“super”这个关键字的由来。ECMAScript 6约定如果父类中的名字被覆盖了那么你可以在子类中用super来找到它们。
## super指向什么
既然我们知道super出现的目的就是解决父类的能力丢失这一问题那么我们也就很容易理解一个特殊的语言设计了在JavaScript中super只能在方法中使用。所谓方法其实就是“类的或者对象的能力”super正是用来弥补覆盖父类同名方法所导致的缺陷因此只能出现在方法之中这也就是很显而易见的事情了。
当然,从语言内核的角度上来说,这里还存在着一个严重的设计限制,这个问题是:怎么找到父类?
在传统的JavaScript中所谓方法就是函数类型的属性也就是说它与一般属性并没有什么不同可以被不同的对象抄写来抄写去。其实方法与普通属性没有区别也是“类抄写”机制得以实现的核心依赖条件之一。然而这也就意味着所谓“传统的方法”没有特殊性也就没有“归属于哪个类或哪个对象”这样的性质。因此这样的方法根本上也就找不到它自己所谓的类进而也就找不到它的父类。
所以实现super这个关键字的核心在于为每一个方法添加一个“它所属的类”这样的性质这个性质被称为“主对象HomeObject”。
所有在ECMAScript 6之后通过方法声明语法得到的“方法”虽然仍然是函数类型但是与传统的“函数类型的属性即传统的对象方法”存在着一个根本上的不同这些新的方法增加了一个内部槽用来存放这个主对象也就是ECMAScript规范中名为[[HomeObject]]的那个内部槽。这个主对象就用来对在类声明,或者字面量风格的对象声明中,(使用方法声明语法)所声明的那些方法的主对象做个登记。这有三种情况:
1. 在类声明中如果是类静态声明也就是使用static声明的方法那么主对象就是这个类例如AClass。
1. 就是一般声明那么该方法的主对象就是该类所使用的原型也就是AClass.prototype。
1. 第三种情况,如果是对象声明,那么方法的主对象就是对象本身。
但这里就存在一个问题了super指向的是父类但是对象字面量并不是基于类继承的那么为什么字面量中声明的方法又能使用`super.xxx`既然对象本身不是类那么super“指向父类”或者“**用于解决覆盖父类能力**”的含义岂不是就没了?
这其实又回到了JavaScript 1.1的那项基础设计中,也就是“用原型来实现继承”。
原型就是一个对象,也就是说本质上子类或父类都是对象;而所谓的类声明只是这种继承关系的一个载体,真正继承的还是那个原型对象本身。既然子类和父类都可能是,或者说必须是对象,那么对象上的方法访问“父一级的原型上的方法”就是必然存在的逻辑了。
出于这个缘故在JavaScript中只要是方法——并且这个方法可以在声明时明确它的“主对象HomeObject那么它就可以使用super。这样一来对象方法也就可以引用到它父级原型中的方法了。这一点其实也是“利用原型继承和类抄写”来实现面向对象系统时在概念设计上的一个额外的负担。
但接下来所谓“怎么找到父类”的问题就变得简单了当每一个方法都在其内部登记了它的主对象之后ECMAScript约定只需要在方法中取出这个主对象HomeObject那么它的原型就一定是所谓的父类。这很明显因为方法登记的是它声明时所在的代码块的HomeObject也就是声明时它所在的类或对象所以这个HomeObject的原型就一定是父类。也就是把“通过原型继承得到子类”的概念反过来用一下就得到了父类的概念。
## super.xxx()
我们今天讲的内容到现在为止只说明了两件事。第一件是为什么要有super第二件就是super指向什么。
接下来我们要讲super.xxx。简单地说这就是个属性存取。这从语法上一看就明白了似乎是没有什么特殊的对吧未必如此
回顾一下我们在第7讲中讲述到的内容super.xxx在语法上只是属性存取但super.xxx()却是方法调用而且super.xxx()是表达式计算中罕见的、在双表达式连用中传递引用的一个语法。
所以关键不是在于super.xxx如何存取属性而在于super.xxx存取到的属性在JavaScript内核中是一个“引用”。按照语法设计这个引用包括了左侧的对象并且在它连用“函数调用”语法的时候将这个左侧的对象作为this引用传入给后者。
更确切地说,假如我们要问“在 `super.xxx()`调用时,函数`xxx()`中得到的this是什么”那么按照传统的属性存取语法可以推论出来的答案是这个this值应该是super
但是很不幸,这不是真的。
### super.xxx()中的this值
在super.xxx()这个语法中xxx()函数中得到的this值与super——没有“一点”关系不过还是有“半点”关系的。不过在具体讲这“半点”关系之前呢我需要先讲讲它会得到一个怎样的this以及如何能得到这个this。
super总是在一个方法如下例中的obj.foo函数中才能引用。这是我们今天这一讲前半段中所讨论的。这个方法自己被调用的时候理论上来说应该是在一个foo()方法内使用的、类似`super.xxx()`这样的代码。
```
obj = {
foo() {
super.xxx();
}
}
// 调用foo方法
obj.foo();
```
这样在调用这个foo()方法时它总是会将obj传入作为this所以foo()函数内的this就该是obj。而我们看看其中的super.xxx()我们期望它调用父类的xxx()方法时传入的当前实例也就是obj正好在是在foo()函数内的那个this其实也就是obj。继承来的行为应该是施加给现实中的当前对象的施加给原型也就是这里的super是没什么用的。所以在这几个操作符的连续运算中只需要把当前函数中的那个this传给父类xxx()方法就行了。
然而怎么传呢?
我们说过super.xxx在语言内核上是一个“规范类型中的引用”ECMAScript约定将这个语法标记成“Super引用SuperReference并且为这个引用专门添加了一个thisValue域。这个域其实在函数的上下文中也有一个相同名字的也是相同的含义。然后ECMAScript约定了优先取Super引用中的thisValue值然后再取函数上下文中的。
所谓函数上下文之前略讲过一点就是函数在调用的时候创建的那个用于调度执行的东西而这个thisValue值就放在它的环境记录里面也就可以理解成函数执行环境的一部分。
如此一来在函数也就是我们这里的方法中取super的this值时就得到了为super专门设置的这个this对象。而且事实上这个thisValue是在执行引擎发现super这个标识符GetIdentifierReference的时候就从当前环境中取出来并绑定给super引用的。
回顾上述过程super.xxx()这个调用中有两个细节需要你多加注意:
1. super关键字所代表的父类对象是通过当前方法的[[HomeObject]]的原型链来查找的;
1. this引用是从当前环境所绑定的this中抄写过来并绑定给super的。
为什么要关注上面这两个特别特别小的细节呢?
我们知道在构造方法中this引用也就是将要构造出来的对象实例事实上是由祖先类创建的。关于这一点如果你印象不深了请回顾一下上一讲也就是第13讲 “new X”的内容。那么既然this是祖先类创建的也就意味着在刚刚进入构造方法时this引用其实是没有值的必须采用我们这里讲到的“继承父类的行为”的技术让父类以及祖先类先把this构造出来才行。
所以这里就存在了一个矛盾这是一个“先有鸡还是先有蛋”的问题一方面构造方法中要调用父类构造方法来得到this另一方面调用父类方法的super.xxx()需要先从环境中找到并绑定一个this。
概念上这是无解的。
ECMAScript为此约定只能在调用了父类构造方法之后才能使用super.xxx的方式来引用父类的属性或者调用父类的方法也就是访问SuperReference之前必须先调用父类构造方法这称为SuperCall在代码上就是直接的`super()`调用这一语法)。这其中也隐含了一个限制:在调用父类构造方法时,也就是`super()`这样的代码中super是不绑定this值的也不在调用中传入this值的。因为这个阶段根本还没有this。
### super()中的父类构造方法
事实上不仅仅如此。因为如果你打算调用父类构造方法注意之前讲的是父类方法这里是父类构造方法也就是构造器那么很不幸事实上你也找不到super。
以new MyClass()为例类MyClass的constructor()方法声明时它的主对象其实是MyClass.prototype而不是MyClass。因为后者是静态类方法的主对象而显然constructor()方法只是一般方法而不是静态类方法例如没有static关键字。所以在MyClass的构造方法中访问super时通过HomeObject找到的将是原型的**父级对象**。而这并不是父类构造器,例如:
```
class MyClass extends Object {
constructor() { ... } // &lt;- [[HomeObject]]指向MyClass.prototype
}
```
我们知道super()的语义是“调用父类构造方法”,也就应当是`extends`所指定的Object()。而上面讲述的意思是说,在当前构造方法中,无法通过[[HomeObject]]来找到父类构造方法。
那么JavaScript又是怎么做的呢其实很简单在这种情况下JavaScript会从当前调用栈上找到当前函数——也就是new MyClass()中的当前构造器并且返回该构造器的原型作为super。
也就是说,类的原型就是它的父类。这又是我们在上面讨论过的:把“通过原型继承得到子类”的概念反过来用一下,就得到了父类的概念。
### 为什么构造方法不是静态的?
也许你会提一个问题为什么不直接将constructor()声明为类静态方法呢?事实上我在分析清楚这个`super()`逻辑的时候,第一反应也是如此。类静态方法中的[[HomeObject]]就是MyClass自己啊如果这样的话就不必换个法子来找到super了。
是的这个逻辑没错。但是我们记得在构造方法consturctor()中也是可以使用super.xxx()的与调用父类一般方法即MyClass.prototype上的原型方法的方式是类似的。
因此根本问题在于一方面super()需要将父类构造器作为super另一方面super.xxx需要引用父类的原型上的属性。
这两个需求是无法通过同一个[[HomeObject]]来实现的。这个问题只会出现在构造方法中并且也只与super()冲突。所以super()中的super采用了别的方法这里是指在调用栈上查找当前函数的方式来查找当前类以及父类而且它也是作为特殊的语法来处理的。
现在JavaScript通过当前方法的[[HomeObject]]找到了super也找到了它的属性super.xxx这个称为Super引用SuperReference并且在背地里为这个SuperReference绑定了一个thisValue。于是接下来它只需要做一件事就可以了调用super.xxx()。
## 知识回顾
下面我来为第13讲做个总结这一讲有4个要点
1. 只能在方法中使用super因为只有方法有[[HomeObject]]。
1. super.xxx()是对super.xxx这个引用SuperReference作函数调用操作调用中传入的this引用是在**当前环境的上下文**中查找的。
1. super实际上是在通过原型链查找父一级的对象而与它是不是**类继承**无关。
1. 如果在类的声明头部没有声明extends那么在构造方法中也就不能调用父类构造方法。
>
第4个要点涉及到两个问题其一是它显然显式的没有所谓`super`其二是没有声明extends的类其实是采用传统方式创建的构造器。但后者不是在本讲中讨论的内容。
## 思考题
1. 请问`x = super.xxx.bind(...)`会发生什么这个过程中的thisValue会如何处理
1. super引用是动态查找的但类声明是静态声明请问二者会有什么矛盾简单地说super引用的并不一定是你所预期的静态声明的请尝试写一个这种示例
1. super.xxx如果是属性而不是函数/方法那么绑定this有什么用呢
希望你能将自己的答案分享出来,让我也有机会听听你的收获。

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="15 | return Object.create(new.target.prototype):做框架设计的基本功:写一个根类" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/ef/7b6e533383d309f71c119fbfb4e7ecef.mp3"></audio>
你好,我是周爱民。
今天这一讲的标题呢,比较长。它是我这个专栏中最长的标题了。不过说起来,这个标题的意义还是很简单的,就是返回一个用`Object.create()`来创建的对象。
因为用到了`return`这个子句,所以它显然应该是一个函数中的退出代码,是不能在函数外单独使用的。
这个函数呢,必须是一个构造器。更准确地说,标题中的代码必须工作在构造过程之中。因为除了`return`,它还用到了一个称为元属性(**meta property**)的东西,也就是`new.target`
迄今为止,`new.target`是JavaScript中唯一的一个元属性。
## 为什么需要定义自己的构建过程
通过之前的课程你应该知道JavaScript使用原型继承来搭建自己的面向对象的继承体系在这个过程中诞生了两种方法
1. 使用一般函数的构造器;
1. 使用ECMAScript 6之后的类。
从根底上来说这两种方法的构建过程都是在JavaScript引擎中事先定义好了的例如在旧式风格的构造器中以代码`new X`为例),对象`this`实际上是由new运算依据`X.prototype`来创建的。循此前例ECMAScript 6中的类在创建`this`对象时也需要这个`X.prototype`来作为原型。
但是按照ECMAScript 6的设计创建这个`this`对象的行为与权力,将通过`super()`被层层转交,直到父类或祖先类中有能力创建该对象的那个构造器或类为止。而在这时,父类是不可能知道`new X`运算中的这个子类为何的,因为父类通常是更早先被声明出来的。既然它的代码一早就被决定了,那么对子类透明也就是正常的了。
于是真正的矛盾在这时候就出现了:**父类并不知道子类`X`,却又需要`X.prototype`来为实例`this`设置原型。**
ECMAScript为此提出了`new.target`这个东西,它就指向上面的`X`,并且随着`super()`调用一层层地向上传递,以便最终创建者类可以使用它。也就是说,以之前讨论过的`Date()`为例,它的构建过程必然包括“类似于”如下两行代码来处理`this`
```
// 在JavaScript内置类Date()中可能的处理逻辑
function _Date() {
this = Object.Create(Date.prototype, { _internal_slots });
Object.setPrototypeOf(this, new.target.prototype);
...
}
```
1. 依据父类的原型也就是Date.prototype来创建对象实例this因为它是父类创建出来的
1. 置this实例的原型为子类的prototype也就是new.target.prototype因为它最终是子类的实例。
这也就是为什么Proxy()类的construct句柄与Reflect.construct()方法中都需要传递一个称为`_newTarget`_的额外参数的原因。`new.target`这个元属性,事实上就是在构造过程中,在`super()`调用的参数界面上传递的。只不过你在构造方法中写`super()`的时候是JavaScript引擎隐式地帮你传递了这个参数而已。
你可能已经发现了问题的关键:**是`super()`在帮助你传递这个`new.target`参数!**
那么,如果函数中没有调用`super()`呢?
## 先补个课:关于隐式的构造方法
在之前的课程中我提及过当类声明中没有“constructor()”方法时JavaScript会主动为它创建一个。关于这一点当时并没有展开来细讲所以这里先补个课。
首先你通常写一个类的时候都不太会主动去声明构造方法“constructor()”。因为多数情况下,类主要是定义它的实例的那些性质,例如方法或属性存取器。极端的情况下,你也可能只写一个空的类,只是为了将父类做一次简单的派生。例如:
```
class MyClass extends Object {}
```
无论是哪种情况,总之**你就是没有写“constructor()”方法**。有趣的是事实上JavaScript初始化出来的这个MyClass类它作为一个函数就是指向那个“constructor()”方法的,两者是同一个东西。
不过这一点不太容易证实。因为在“constructor()”方法内部无法访问它自身,不能写出类似“**constructor===MyClass**”这样的检测条件来。所以你只能在ECMAScript的规范文档中去确认这一点。
那么既然MyClass就是constructor()方法,而用户代码又没有声明这个方法。那么该怎么办呢?
ECMAScript规范就约定在这种情况下引擎需要向用户代码中插入一段硬代码。也就是帮你写一个缺省的构造方法然后引擎为这个硬代码的代码文本动态地生成一个“构造方法”声明最后再将它初始化为类MyClass()。这里的“硬代码”包括两个代码片断,分别对应于“有/没有”`extends`声明的情况。如下:
```
// 如果在class声明中有extends XXX
class MyClass extends XXX {
// 自动插入的缺省构造方法
constructor(...args) {
super(...args);
}
...
}
// 如果在class声明中没有声明extends
class MyClass {
// 自动插入的缺省构造方法
constructor() {}
...
}
```
在声明中如果有extends语法的话缺省构造方法中就插入一个SuperCall()而如果声明中没有extends那么缺省构造方法就是一段空的代码什么也没有。
所以,现在你看到了你所提出的问题的第一个答案:
如果没有声明构造方法因此没有super()调用),那么就让引擎偷偷声明一个。
## 非派生类是不用调用super()的
另一种特殊情况就是上面的这种非派生类也就在类声明中语法中没有“extends XXX”的这种情况。上面的硬代码中JavaScript引擎为它生成的就是一个空的构造方法目的呢也就是为了创建类所对应的那个函数体。并且貌似别无它用。
这种非派生类的声明非常特别本质上来说它是兼容旧的JavaScript构造器声明的一种语法。也就是说如果“extends XXX”不声明那么空的构造方法和空的函数一样并且即使是声明了具体的构造方法那么它的行为也与传统的构造函数一样。
为了这种一致性,当这种非派生类的构造方法返回无效值时,它和传统的构造函数也会发生相同的行为——“返回已创建的`this`”。例如:
```
class MyClass extends Object {
constructor() {
return 1;
}
}
function MyConstructor() {
return 1;
}
```
测试如下:
```
&gt; new MyClass;
{}
&gt; new MyConstructor;
{}
```
这样的相似性还包括一个重要的、与今天讨论的主题相关的特性:**非派生类也不需要调用`super()`。**至于原因,则是非常明显的,因为“创建`this`实例”的行为是由引擎隐式完成的,对于传统的构造器是这样,对于非派生类的构造方法,也是这样。二者的行为一致。
那么这种情况下还有没有“new.target”呢事实是
在传统的构造函数和非派生类的构造方法中,一样是有`new.target`的。
然而为什么呢?`new.target`是需要用`super()`来传递的呀?!
是的,这两种函数与类的确不调用`super()`,但这只说明它不需要向父类传递`new.target`而已。要知道,当它自已作为父类时,还是需要接受由它的子类传递来的那些`new.target`的。
所以,你所提出的问题还有第二个答案:
如果是不使用`super()`调用的类或构造器函数,那么可以让它做根类(祖先类)。
## 定制的构造方法
你应该还记得,上面这两种情况的类或构造器函数都是可以通过`return`来返回值的。之前的课程中也一再强调过:
- 在这样的类中返回非对象值,那么就默认替换成已创建的`this`
- 返回通过`return`传出的对象(也就是一个用户定制的创建过程)。
所以如果是用户定制的创建过程,那么就回到了最开始的那个问题上:
父类并不知道子类`X`,却又需要`X.prototype`来为实例`this`设置原型。
因此事实上如果用户要在“根类/祖先类”的层级上实现一个定制过程,并且还需要返回一个子类所需要的实例,那么它除了自己创建`this`之外,还需要调用一个为实例`x`置它的类原型X.prototype的过程
```
// 参见本讲开始的_Date()过程
Object.setPrototypeOf(x, X.prototype)
```
由于`X.prototype`是子类通过`super()`传递来的,因此作为父类的`MyClass`中通常需要处理的代码,就变成了`为this引用置new.target.prototype这个原型`
```
// (也就是)
Object.setPrototypeOf(this, new.target.prototype);
```
然而还有一种更加特殊的情况:类的构造方法中也可能没有`this`这个引用。
```
class MyClass extends null {
constructor() {
...
}
}
```
例如当你为extends这个声明置null值时由于`extends`声明`MyClass`派生自`null`(也就是没有原型),那么在构造方法中也是不能调用`super()`的。并且由于没有原型JavaScript引擎也不会缺省为这个`MyClass`创建`this`实例。所以在这个“constructor()”构造方法中,既没有`this`也不能调用`super()`
怎么办呢?
你必须确信这样的类只能用作根类(显然,它不是任何东西派生出来的子类)。因此,在语义上,它可以自己创建一个实例。也就是说,这样的根类之所以存在的目的,就是用来替代本讲前面讨论的所有过程,以为“它的子类创建一个`this`实例”为己任。因此,完整实现这一目的的最简单方式,就是本讲标题中的这一行代码:
```
class MyClass extends null {
constructor() {
return Object.create(new.target.prototype);
}
}
// 测试
console.log(new MyClass); // MyClass {}
console.log(new (class MyClassEx extends MyClass{})); // MyClassEx {}
```
所以仅仅是这样的一行代码就几乎已经穷尽了JavaScript类构建过程的全部秘密。
## 其他
当然如果父类并不关心子类实例的原型那么它返回任何的对象都是可以的子类在super()的返回中并不检查原型继承链的维护情况。也就是说,确实存在“子类创建出非该类的实例”的情况。例如:
```
class MyClass {
constructor() { return new Date };
}
class MyClassEx extends MyClass {
constructor() { super() }; // or default
foo() {
console.log('check only');
}
}
var x = new MyClassEx;
console.log(x instanceof MyClassEx); // false
console.log('foo' in x); // fals
```
今天的内容就到这里。有关继承、原型与类的所有内容就暂时告一段落了。下一讲开始,我将侧重为你介绍对象的本质,以及它的应用。
## 思考题
当然,这一讲仍然会留有一个习题。仅仅一个而已:
- `new.target`为什么称为元属性,它与`a.b`例如super.xxx或者a.toString有什么不同
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,274 @@
<audio id="audio" title="16 | [a, b] = {a, b}:让你从一行代码看到对象的本质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/39/a8a83584b9f1eb8ddfa5ef6275571239.mp3"></audio>
你好,我是周爱民。欢迎回到我的专栏。
接下来的两讲我要讲的仍然是JavaScript中的面向对象。有所不同的是今天这一讲说的是JavaScript中的对象本质而下一讲要说的则是它最原始的形态也通常称为原子对象
说回今天的话题,所谓的“对象本质”,就是从根本上来问,对象到底是什么?
## 对象的前生后世
要知道,面向对象技术并不是与生俱来、顺理成章就成为了占有率最高的编程技术的。
在早期,面向对象技术其实并不太受待见,因为它的抽象层级比较高,也就意味着它离具体的机器编程比较远,没有哪种硬件编程技术(在当时)是需要所谓的面向对象的。最核心的那部分编程逻辑通常就是写寄存器、响应中断,或者是发送指令。这些行为都是面向机器逻辑的,与什么面向对象之类的都无关。
最早大概是1967年的时候艾伦Alan Kay提出了这么一个称为“对象”的抽象概念和基于它的面向对象编程object-oriented programming这也成为他所发明的Smalltalk这个语言中的核心概念之一。
然而,回顾这段历史,这个所谓的“对象”的抽象概念中,只包含了**数据**和**行为**两个部分,分别称为**状态保存**和**消息发送**,再进一步地说,也就是我们今天讲的“**属性**”和“**方法**”。并且,在这个基础上,有了这些状态(或称为数据)的局部保存、保护和隐藏等概念,也就是我们现在说的**对象成员的可见性问题**。
你看,这里没有**继承**,也没有**多态**。历史中,最早出现的所谓**对象**,其实只是对数据的封装!
所以你会看到最近十余年来,无数的业界大师、众多的语言流派对所谓的“继承”,以及与此相关的“多态”特性发起非难。追根溯源,就在于这两个概念并非是“面向对象”思想的必然产物,因而它们的存在将有可能增加系统抽象的复杂性。
具体到你所了解的JavaScript一些新的面向对象特性也总会在ECMAScript规范的草案阶段碰壁。
例如近两年来最受非议的“Class Fields”提案在添加了“**私有字段**”这个概念之后,却将“**保护属性**”这个皮球扔给了远未成熟的注解提案。究其原因呢,则是“**字段**”与“**继承性**”之间存在概念和实现模型的冲突。
这也不枉我常常说tc39中存在着大量的“OOP敌视者”尽管是玩笑但也确实反映了“面向对象编程思想”在这门语言中恶劣的生存状态。
然而并不仅仅如此。最近这些年的新语言,除了使用类似“**字段**”“**记录**”这样的抽象概念来驱逐面向对象之外,还对**函数式编程**洞开怀抱。在我看来,这既是流行的趋势,也确实是计算机编程语言进化的必然方向。但是,这也带来了更深层面的问题,使得**面向对象**的生存环境进一步恶化。
为什么呢?
你看,面向对象的**封装**、**继承**和**多态**三个核心概念中多态有一部分是与继承性相关的去掉继承性多态就死了一半。而另一半又被“接口Interface”这个概念给干掉了。于是整个OOP的体系中就只剩下“封装”还算在概念上能独善其身。这也与上面说到的艾伦有关毕竟他提出的“面向对象”的最初意图也就在于提高封装性。
然而,一旦引入“函数式编程”,情况就发生了变化。
函数式语言根本不考虑数据封装问题,逻辑之间的数据是由函数界面(也就是函数参数)来传递的,而函数自身又强调“无副作用”,也就意味着它不影响函数之外的数据——那函数外也就没有任何数据封装(例如隐蔽)的要求了。
所以,简单地说,函数式一出,面向对象的最后一根稻草——“封装”特性也就扑街了!
你看看,面向对象到底怎么了?混了半个世纪了,最终落下个谁谁都嫌弃、人人都喊打的局面,连个打根儿上起就存在的核心抽象概念,都被人家掘断了气儿。
讲到这,你是不是觉得我给你扯的太远了?其实不是的。
这一讲的标题是“x = y”这样一个赋值表达式而赋值表达式右边的“y”正是这样的一个“对象”。我与你说了半天的这些所谓“三个核心概念”在这一行代码中被瓦解掉了2/3剩下的正是最最原始的东西
- 所谓对象,是对数据的封装;
- 所谓解构,就是从封装的对象中,抽取数据。
你看,聊了半天,我又圆回来了吧:对象,其实是一个数据结构;解构赋值,就是将这个结构解构了,拿去赋值。
要紧的地方在于:对象,是怎样的一个数据结构呢?
## 两种数据结构
其实所谓的“某某编程思想”,本质上就是在说两个东西:一个,是在编程中怎么管理数据,另一个则是怎么组织逻辑。
而结构化又或者说具体到“数据结构”无非是在说将系统中的数据用统一的、确切的、有限的数据样式给管理起来。这些样式小到一个位bit、一个字节byte大到一个库Database、一个节点Node都是对数据加以规划的结果。编程的思想在机器指令的编码与数据集群的管理里面都是如出一辙的。在所有的这些思想的背后都有一个核心的问题
- 如何抽象“一堆”的数据,使得它们能被方便和有效地管理。
在我们的单机系统或者说像JavaScript这类应用环境的编程语言中这些数据是假设被放在“有限的存储空间里面”的。这个假设模拟了内存和指令带宽的基本性能。
那么,在这样有限的存储空间里面如何存储数据呢?又或者说,如何得到一个“最高的抽象层级的数据结构”,以便于通过编程语言来处理操作呢?
一个数据结构的抽象层次越是低级那么对它的编程就越是复杂。例如说如果你需要面向“位bit”来编程那么差不多就需要写机器指令或者手工去搬动逻辑电路的开关了。
所谓“最高的抽象层级”,在一个“有限的存储空间”里面,其实只能表达为一个“块”。简单地说,你只能称呼“一堆数据”为“一堆数据”,因为当你不了解它们的具体性质时,你只能这样称呼它。而“块”其实是对“有限空间”的边界分解,设定了“有限空间”,那么对应的,也就出来了“块”这个概念。
而由此带来的问题是:在一个有限空间中,如何找到一个“块”?
如果从这些“块”的相关位置出发,以位置关系来看,就只有两个解:
1. 为所有**连续的**块添加一个**连续的**“索引”;
1. 为所有**不连续**的块添加一个唯一的“名字”。
当然关键点在于所谓的“连续”和“不连续”。“连续”“不连续”在语义上就是二分的所以也就只需要两个解。其中“索引”比较简单它就对应于连续性本身表达为可计算的特性是“a[**i**]”也就是a的下标_i_。
而“名字”对应于“找到块”这一目的本身,表达为一个可计算的函数“**f()**”。你可以认为这里的`f``find`的简写。于是一旦系统认为一个函数“**f()**”可以用于找到它需要计算的数据那么数据就可以理解为“b[**f()**]”,而其中的函数***f()***如何实现,则可以交给“另外的一个系统”去完成了。
那么重要的是为什么不能将“i”也理解为“找到i”呢
如果是这样那么这个所谓的“索引”其实也可以作为名字啊对的如果这样来理解那么也可以为上面的“a[**i**]”引入一个用于计算索引的函数f只是该函数_f()_的唯一作用就是返回了“**i**”。也就是:
```
function f() {
return i
}
a[i] === a[f()];
```
现在,我们看到了这两个数据结构——一种是“连续的块”,另一种是“不连续的块”,它们都存在一种统一的“找到块的模式”,也就是:通过一个函数来找到块。
进一步阐释的话,对于索引数组来说,这个函数是取数组成员的“索引”;对于关联数组来说,这个函数是取数组成员的“名字”。其中“关联数组”是用一对“名/值”来创建的数组在实现中为了将无穷尽的“名字”收敛在一个有限范围内通常是用值的HASH作为名字。
所以,在“怎么管理数据”这个问题上,你可以将所有数据看成只具有两种数据结构的构成,一种称为**索引数组**(对应于可索引的块),另一种称为**关联数组**(对应于不可索引的块)。而究其根本来说,索引数组其实是关联数组的一个特例——被存取的数据所关联的名字就是它的索引。
JavaScript中的“对象”在本质上就是这样的一个关联数组。同时所谓的“数组Array”——也就是索引数组Index array正是作为关联数组的一个特例来实现的。这样一来JavaScript就实现了两种数据结构的大统一
1. 数组Array class是一种对象Object class
1. 对象本质上是关联数组Associative array
## 解构
所以,对象不过是“稍微复杂一点的数据结构”,相比起来,它并不比稍早一点出现的“记录/结构体”更复杂。从抽象的演进过程来说,对象只是“没有顺序存储限制,以及添加了成员名字的”结构体而已。<br>
<img src="https://static001.geekbang.org/resource/image/f7/af/f71eda18ca7a490bb794fe244a3c25af.jpg" alt="">
>
图引自《程序原本》“10.1 抽象本质上的一致性”
在前面的文章里我就讲过,计算的本质是求“值”,因此几乎所有的引用类型呢,最终都会将“**与它的相关的运算结果**”指向“**值**”。至于这一切背后的原因,其实也很简单,就是物理的计算系统最终也只能接收“字节、位”等等这样的值类型数据。但是在高级语言中,或者应用编程中呢,程序员又需要高层级的抽象来简化编程,所以才会有**结构体**,以及我们在这里讲到的**对象**。
还原这个过程,也就意味着“结构”是应用编程的必须,而“解构”是底层计算的必须。从一个“结构(这里是指数据结构,或者对象等复杂的结构)”中把那些值数据取出来,就称为解构。这一讲的代码标题,就是这样的一个“解构赋值”,它的目的呢,也正是“从一个结构中提取值”。你仔细看这行代码:
```
[a, b] = {a, b}
```
等号右侧是一个对象的字面量,它的语义是将`a``b`两个数据变成“对象”这个数据结构中的两个成员。其中由于a、b都是既已约定的名字所以在作为对象成员的时候“名字+值”就都已经具备了,完全符合“关联数组(或名/值数据对)”的语义要求。
而再看它的左侧,是一个数组?不是的,这称为一个“(数组)赋值模板”。
所谓赋值模板不过是“变量名字”和“它的值”之间的位置关系的一个“说明”这个说明是描述型的、声明风格的。因此它事实上在JavaScript语法解析阶段就完成了处理根本不会“产生”任何运行期的执行过程。
所以左侧的“赋值模板”只是说明了一堆被声明的变量,也就是说,它们跟代码`var x, y, z = 100`中的`x,y,z`这样的名字声明没有任何差异,在处理上也是一样的。但是,这些赋值模板中声明的变量,每一个都“绑定”了一段赋值过程。这样的“赋值过程”在之前讲**函数的非简单参数**时也讲过(参见[第8讲](https://time.geekbang.org/column/article/171617)就是“初始器赋值”。在ECMAScript中尽管它们调用的是相同的“赋值过程”但这两者之间是有语义上的区别的。具体来说就是
- 当赋值模板用作声明var/let/const上面的“赋值过程”将作为值绑定的初始器
- 当该模板用作赋值运算的右操作数时,右操作数将作为“赋值过程”的传入参数。
因此,对于标题中的代码来说,存在三种在语义上并不相同的逻辑:
```
// 1. lhsKind is assignment, call DestructuringAssignmentEvaluation
[a, b] = {a, b}
// 2. lhsKind is varBinding, call BindingInitialization,
// and env will be current function scope.
var [a, b] = {a, b}
// 3. lhsKind is lexicalBinding, call BindingInitialization and current env
let [a, b] = {a, b}
```
当然,其结果都是一样的,也就是左侧的`a``b`都将被赋以左侧对象`{a, b}`所解构出来的“值”。但是,如果你运行标题中的代码,你会发现它“可能”与你的预期并不一样。例如左侧的`a``b`与原来有的变量“a、b”并不一样假设这些变量是有的话
在上面的三个例子中示例三的let/const赋值将不成立因为右侧的对象将不能被创建出来。例如
```
&gt; let [a, b] = {a, b}
ReferenceError: a is not defined
```
但前两个示例在代码逻辑上是可以成立的,只是“一般来说”运行会抛出异常。例如:
```
# “赋值未声明变量”
&gt; a = 100, b = 200;
# 示例代码与使用var声明相同
&gt; [a, b] = {a, b};
TypeError: {(intermediate value)(intermediate value)} is not iterable
```
现在你可以思考一个小小的问题:
- **有什么办法可以让这个代码可以执行呢?**
这就回到今天这一讲的标题的核心话题了。
## 两种数据结构的统一
既然我已经说过,对象和数组在本质上都是存放“一堆数据”的结构,而差异只是查找的过程不同。那么,模拟它们不同的查找过程,也就可以在这些结构之间完成统一的“赋值行为”。
“数组赋值模板”其实是引用了数组的下标索引过程ECMAScript将索引次序用专门的增序来管理并将右操作数视作为“迭代器”来取值。注意你确实需要留意这两者之间的区别重点在于“迭代器”的取值是序列的但并没有确定使用数组的下标例如序号
所以,只要让右侧的对象成为一个“可迭代对象”,那么赋值表达式就可以知道如何将它赋给左侧的模板了。这并不难:
```
## 模拟成数组的迭代器
&gt; Object.prototype[Symbol.iterator] = function() {
return Array.prototype[Symbol.iterator].call(Object.values(this));
};
## 测试
&gt; a = 100, b = 200;
&gt; [a, b] = {a, b}
...
```
当然,你也可以不借用数组的迭代器。这是一个更简单的版本:
```
Object.prototype[Symbol.iterator] = function*() {
yield* Object.values(this);
};
...
```
也就是说,只需要将“对象成员”的列举,变成“对象成员的值”的列举,那么关联数组就可以用作索引数组了。当然,在代码中你也通常不需要这样写。只要写成下面这样就足够了:
```
&gt; [a, b] = Object.values({a, b})
...
```
既然将对象赋给数组(赋值模板)是可行的,那么将数组赋给“对象(赋值模板)”又是否可行呢?答案当然是“可以”。不过仍然和上面的问题一样,你得有办法在模板中“描述”索引与名字之间的关系才行。例如:
```
# 在对象赋值模板中声明变量名与索引的关系
&gt; ({0: x, 1: y} = [a, b])
&gt; console.log(x, y);
100 200
```
如果你直接使用像标题一样的代码(并且将它们反过来的话),例如:
```
{a, b} = [a, b]
```
那么由于没有这种关系描述,所以右侧的数组被“强制地”作为一个对象来使用,因此变成了取`a``b`这两个成员的值。当然,它的结果就是不可预知的了。这种不可预知,来自于“将右侧数组作为对象”的并尝试取得具体的成员这样的行为,并且还受到它的原型对象的影响。
当然也有使类似行为不受到原型影响的办法这就是“人人都爱”的所谓“展开语法Spread syntax”。
关于展开语法的特点,我之前在[第9讲](https://time.geekbang.org/column/article/172636)中也已经讲过了你可以复习一下那一讲的内容。展开语法与这一讲略有关联的事情是“对象展开Object spread以及与它相关的“剩余参数Rest paraments”这两种东西都将只处理那些“可列举的、自有的”属性。因此展开过程并不受对象原型的影响。例如
```
# 测试变量
&gt; var a = 100, b = 200;
# 将数组展开到一个对象(的成员)
&gt; obj = {...[a,b]}
{0: 100, 1: 200}
# 或,将对象展开到一个数组
&gt; iterator = function*() { yield* Object.values(this) };
&gt; obj[Symbol.iterator] = iterator;
&gt; arr = [...obj]
[ 100, 200
```
## 知识回顾
这一讲的话题,重点在于从抽象层面认识对象与数组这两种东西,以及它们更为学术的名词概念:关联数组和索引数组。
由于索引数组本质上是关联数组的特例所以在JavaScript中用关联数组也就是对象来实现索引数组也就是一般概念上的数组对象是合理的并且也是有着很深层面的理论根基的一个设计。
由于两种数据结构既相关、又相同,因此在它们之间相互转换的行为,其实就是一个名字和索引变换的游戏,这也是本讲中会再次讨论“展开语法”的原因:展开语法是在两种数据类型之间的一个桥梁。
当然这一讲的标题尽管并不能直接运行但“如何让它能运行”这个问题所涉及的知识与我们计算机领域中较深层面的运行原理以及较高层次的抽象结构之间都存在着密不可分的关系。无论是出于理解JavaScript代码的目的还是出于理解语言中最本质的那些假设或前设我都非常建议你尝试一下这篇文章中的示例代码。
## 思考题
最后,作为一个小小的思考与练习,我希望你能够在学习完这一讲之后回答一个问题:
- “有迭代器的对象”在哪些场合中可以替代“索引数组”?
谢谢你的收听,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。

View File

@@ -0,0 +1,246 @@
<audio id="audio" title="17 | Object.setPrototypeOf(x, null)连Brendan Eich都认错但null值还活着" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/ae/9f71d1e291f04e4c187ab6fb3944a7ae.mp3"></audio>
你好我是周爱民。欢迎回来继续学习JavaScript。
今天是关于面向对象的最后一讲,上次已经说过,今天这一讲要讨论的是原子对象。关于原子对象的讨论,我们应该从`null`值讲起。
`null`值是一个对象。
## null值
很多人说JavaScript中的`null`值是一个BUG设计连JavaScript之父Eich都跳出来对Undefined+Null的双设计痛心疾首`null`值的特殊设计是一个“抽象漏洞abstraction leak”。这个东西什么意思呢很难描述基本上你可以理解为在概念设计层面也就是抽象层脑袋突然抽抽了一不小心就写出这么个怪胎。
>
NOTE[“typeof null”的历史](https://2ality.com/2013/10/typeof-null.html) , [JavaScript 的设计失误](https://juejin.im/entry/5b5ad2fb6fb9a04fb900de7c) 。
然而我却总是觉得不尽如此因为如果你仔细思考过JavaScript的类型系统你就会发现`null`值的出现是有一定的道理的当然Eich当年脑子是不是这样犯的抽抽也未为可知。怎么讲呢
早期的JavaScript一共有6种类型其中number、string、boolean、object和function都是有一个确切的“值”的而第6种类型`Undefined`定义了它们的反面也就是“非值”。一般讲JavaScript的书大抵上都会这么说
>
`undefined`用于表达一个值/数据不存在也就是“非值non-value例如return没有返回值或变量声明了但没有绑定数据。
这样一来,”值+非值“就构成了一个完整的类型系统。
但是呢JavaScript又是一种“面向对象”的语言。那么“对象”作为一个类型系统在抽象上是不是也有“非对象”这样的概念呢有啊答案就是“null”它的语义是
>
`null`用于表达一个对象不存在,也就是“非对象”,例如在原型继承中上溯原型链直到根类——根类没有父类,因此它的原型就指向`null`
正如“undefined”是一个值类型一样“null”值也是一个对象类型。这很对称、很完美只要你愿意承认“JavaScript中存在两套类型系统”那么上面的一切解释就都行得通。
事实上,不管你承不承认,这样的两套类型系统都是存在的。也因此,才有了所谓的**值类型的包装类**,以及对象的`valueOf()`这个原型方法。
现在,的确是时候承认`typeof(null) === 'object'`这个设计的合理性了。
## Null类型
正如Undefined是一个类型`undefined`是它唯一的值一样Null也是一个类型`null`是它唯一的值。
你或许已经发现我在这里其实直接引用了ECMAScript对Null类型的描述的确ECMAScript就是这样约定了`null`值的出处,并且很不幸的是,它还约定了`null`值是一个原始值Primitive values这是ECMAScript的概念与我在前面的叙述中唯一冲突的地方。
如果你“能/愿意”违逆ECMAScript对“语言类型**Language types**)”的说明,稍稍“苟同”一下我上述的看法,那么下面的代码一定会让你觉得“豁然开朗”。这三行代码分别说明:
1. null是对象
1. 类可以派生自null
1. 对象也可以创建自null。
```
// null是对象
&gt; typeof(null)
'object'
// 类可以派生自null
&gt; MyClass = class extends null {}
[Function: MyClass]
// 对象可以创建自null
&gt; x = Object.create(null);
{}
```
所以Null类型是一个“对象类型也就是类是所有对象类型的“元类型”。
`null`值,是一个连属性表没有的对象,它是“元类型”系统的第一个实例,你可以称之为一个原子。
## 属性表
没有属性表的对象称为null。而一个原子级别的对象意味着它只有一个属性表它不继承自任何其他既有的对象因此这个属性表的原型也就指向null。
原子对象是“对象”的最原始的形态。它的唯一特点就是“原型为null”其中有一些典型示例譬如
1. 你可以使用Object.getPrototypeOf()来发现Object()这个构造器的原型其实也是一个原子对象。——也就是所有一般对象的祖先类最终指向的仍然是一个null值。
1. 你也可以使用Object.setPrototypeOf()来将任何对象的原型指向null值从而让这个对象“变成”一个原子对象。
```
# JavaScript中“Object对象类型”的原型是一个原子对象
&gt; Object.getPrototypeOf(Object.prototype)
null
# 任何对象都可以通过将原型置为null来“变成”原子对象
&gt; Object.setPrototypeOf(new Object, null)
{}
```
但为什么要“变成”原子对象呢?或者说,你为什么需要一个“原子对象”呢?
因为它就是“对象”最真实的、最原始的、最基础抽象的那个数据结构:**关联数组**。
所谓属性表就是关联数组。一个空索引数组与空的关联数组在JavaScript中是类似的都是对象
```
# 空索引数组
&gt; a = Object.setPrototypeOf(new Array, null)
{}
# 空关联数组
&gt; x = Object.setPrototypeOf(new Object, null)
{}
```
而且本质上来说,空的索引数组只是在它的属性表中默认有一个不可列举的属性,也就是`length`。例如:
```
# (续上例)
# 数组的长度
&gt; a.length
0
# 索引数组的属性
&gt; Object.getOwnPropertyDescriptors(a)
{ length:
{ value: 0,
writable: true,
enumerable: false,
configurable: false } }
```
正因为数组有一个默认的、隐含的“length”属性所以它才能被迭代器列举以及适用于数组展开语法因为迭代器需要“额外地维护一个值的索引”这种情况下“length”属性成了有效的参考以便于在迭代器中将“0…length-1”作为迭代的中止条件。
而一个原子的、支持迭代的索引数组也可通过添加“Symbol.iterator”属性来得到。例如
```
# (续上例)
# 使索引数组支持迭代
&gt; a[Symbol.iterator] = Array.prototype[Symbol.iterator]
[Function: values]
# 展开语法(以及其他运算)
&gt; [...a]
[]
```
现在整个JavaScript的对象系统被还原到了两张简单的属性表它们是两个原子对象一个用于表达索引数组另一个用于表达关联数组。
当然,还有一个对象,也是所有原子对象的父类实例:`null`
## 派生自原子的类
JavaScript中的类本质上是原型继承的一个封装。而原型继承则可以理解为多层次的关联数组的链原型链就是属性表的链。之所以在这里说它是“多层次的”是因为在面向对象技术出现的早期在《结构程序设计》这本由三位图灵奖得主合写的经典著作中“面向对象编程”就被称为“层次结构程序设计”。所以“层次设计”其实是从数据结构的视角对面向对象中继承特性的一个精准概括。
类声明将“extends”指向null值并表明该类派生自null。为了使这样的类例如MyClass能创建出具有原子特性的实例JavaScript给它赋予了一个特性MyClass.prototype的原型指向null。这个性质也与JavaScript中的Object()构造器类似。例如:
```
&gt; class MyClass extends null {}
&gt; Object.getPrototypeOf(MyClass.prototype)
null
&gt; Object.getPrototypeOf(Object.prototype)
null
```
也就是说这里的MyClass()类可以作为与Object()类处于类似层次的“根类”。通常而言称为“所有对象的祖先类”。这种类是在JavaScript中构建元类继承体系的基础。不过元类以及相关的话题这里就不再展开讲述了。
这里希望你能关注的点仅仅是在“层次结构”中这样声明出来的类与Object()处在相同的层级。
通过“extends null”来声明的类是不能直接创建实例的因为它的父类是null所以在默认构造器中的“SuperCall也就是super()”将无法找到可用的父类来创建实例。因此通常情况下使用“extends null”来声明的类都由用户来声明一个自己的构造方法。
但是也有例外你思考一下这个问题如果MyClass.prototype指向null而super指向一个有效的父类其结果如何呢
是的,这样就得到了一个能创建“具有父类特性(例如父类的私有槽)”的原子对象。例如:
```
&gt; class MyClass extends null {}
# 这是一个原子的函数类
&gt; Object.setPrototypeOf(MyClass, Function);
# f()是一个函数,并且是原子的
&gt; f = new MyClass;
&gt; f(); // 可以调用
&gt; typeof f; // 是&quot;function&quot;类型
# 这是一个原子的日期类
&gt; Object.setPrototypeOf(MyClass, Date);
# d是一个日期对象并且也是原子的
&gt; d = new MyClass;
&gt; Date.prototype.toString.call(d); // 它有内部槽用于存放日期值
'Mon Nov 04 2019 18:27:27 GMT+0800 (CST)'
# a是一个原子的数组类
&gt; Object.setPrototypeOf(MyClass, Array);
&gt; a = new MyClass;
...
```
## 一般函数/构造器
由于一般函数可以直接作为构造器你可能也已经习惯了这种从ECMAScript 6之前的JavaScript沿袭下来的风格。一般情况下这样的构造器也可以被称为“传统的类”并且在ECMAScript 6中所谓“非派生类没有extends声明的类”实际上也是用这样的函数/构造器来实现的。
这样的函数/构造器/非派生类其实是相同性质的东西并且都是基于ECMAScript 6之前的构造器概念来实现类的实例化——也就是构造过程的。出于这样的原因它们都不能调用SuperCall也就是`super()`)来创建`this`实例。不过,旧式风格的构造过程将总是使用构造器的`.prototype`属性来创建实例。因而,让它们创建原子对象的方法也就变得非常简单:把它们的原型变成原子,就可以了。例如:
```
# 非派生类没有extends声明的类
&gt; class MyClass {}
&gt; Object.setPrototypeOf(MyClass.prototype, null)
&gt; new MyClass
{}
# 一般函数/构造器
&gt; function AClass() {}
&gt; Object.setPrototypeOf(AClass.prototype, null)
&gt; new MyClass
{}
```
## 原子行为
直接施加于原子对象上的最终行为可以称为原子行为。如同LISP中的表只有7个基本操作符一样原子行为的数量也是很少的。准确地说对于JavaScript来说它只有13个可以分成三类其中包括
- 操作原型的3个分别用于读写内部原型槽以及基于原型链检索
- 操作属性表的8个包括冻结、检索、置值和查找等类似于数据库的增删查改
- 操作函数行为的2个分别用于函数调用和对象构造。
讲到这里你可能已经意识到了所谓“代理对象Proxy”的陷阱方法也正好就是这13个。这同样也可以理解为代理对象就是接管一个对象的原子行为将它转发给被代理行为处理。
正因为JavaScript的对象有且仅有这13个原子行为所以代理才能“无缝且全面地”代理任何对象。
这也是在ECMAScript中的代理变体对象proxy object is an exotic object 只有15个内部槽的原因包括上述13个原子行为的内部槽其他两个内部槽分别指向被代理对象ProxyTarget和用户代码设置的陷阱列表ProxyHandler。总共15个不多不少。
>
NOTE: 如果更详细地考察13个代理方法其实严格地说来只有8个原子行为其实其他5个行为是有相互依赖的而非原子级别的操作。这5个“非原子行为”的代理方法是DefineOwnProperty、 HasProperty、Get、Set和Delete它们会调用其他原子行为来检查原型或属性描述符。
## 知识回顾
任何一个对象都可以通过标题中的语法变成原子对象,它可以被理解为**关联数组**并且如果它有一个称为“length”的属性那么它就可以被理解为**索引数组**。我们在上一讲中说过,所有的数据,在本质上来说都可以看成“连续的一堆”,或“不连续的一堆”,所以“索引数组+关联数组”在数据结构上就可以表达“所有的数据”。
如果你对有关JavaScript的类型系统尤其是隐于其中的**原子类型**和**元类型**等相关知识感兴趣,可以阅读我的另外一篇博客文章[《元类型系统是对JavaScript内建概念的补充》](https://blog.csdn.net/aimingoo/article/details/82144108)。
好了今天的课程就到这里。很高兴你能一路坚持着将之前的十七讲听完不过对于JavaScript语言最独特的那些设计我们其实才初窥门径。现在尽管你已经在原子层面掌握了“数据”但从计算机语言的角度上来看你只是拥有了一个静态的系统最重要的、也是现在最缺乏的是让它们“动起来”。
从下一讲开始,我会与你聊聊“动态语言”,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。