mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-15 05:33:48 +08:00
mod
This commit is contained in:
@@ -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 = "Car";
|
||||
this.color = "Red";
|
||||
}
|
||||
|
||||
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: <创建本闭包的对象或函数>,
|
||||
parent: <父级的scope>
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
因此,所谓“**使用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 = "abc";
|
||||
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”
|
||||
> 1 in Object(1.0).constructor
|
||||
false
|
||||
|
||||
# (同上)
|
||||
> 1 in 1..constructor
|
||||
fales
|
||||
|
||||
```
|
||||
|
||||
## 属性存取的不确定性
|
||||
|
||||
除了**存取器**(get/setter)带来的不确定性之外,JavaScript的属性存取结果还受到**原型继承(链)**的影响。上例中的表达式值并不恒为`false`,例如我们给Number加一个下标值为1的属性(我们不用管这个属性的值是什么),那么标题中的表达式“1 in 1..constructor”的值就会是`true`了。
|
||||
|
||||
```
|
||||
# 修改原型链中的对象
|
||||
> Number[1] = true; // or anything
|
||||
|
||||
|
||||
# 影响到上例中表达式的结果
|
||||
> 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>
|
||||
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -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 = "Car";
|
||||
this.color = "Red";
|
||||
}
|
||||
|
||||
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 = "Car";
|
||||
this.color = "Red";
|
||||
}
|
||||
|
||||
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()`的子类实例吗?
|
||||
> 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运算来创建,而不能使用“()”来做函数调用。**例如:
|
||||
|
||||
```
|
||||
> new AClass()
|
||||
AClass {}
|
||||
|
||||
> AClass()
|
||||
TypeError: Class constructor AClass cannot be invoked without 'new'
|
||||
|
||||
```
|
||||
|
||||
如果你尝试将“ES6的类”作为函数调用,那么JavaScript就会抛出一个异常。
|
||||
|
||||
在ECMAScript 6之后,JavaScript内部是明确区分方法与函数的:不能对方法做new运算。如果你尝试这样做,JavaScript也会抛一个异常出来,提示你“这个函数不是一个构造器(is not a constructor)”。例如:
|
||||
|
||||
```
|
||||
# 声明一个带有方法的对象字面量
|
||||
> obj = { foo() {} }
|
||||
{ foo: [Function: foo] }
|
||||
|
||||
# 对方法使用new运算会导致异常
|
||||
> 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中这是很正常的用法,例如:
|
||||
|
||||
```
|
||||
> f = new Function;
|
||||
|
||||
> f instanceof Function
|
||||
true
|
||||
|
||||
> f()
|
||||
undefine
|
||||
|
||||
```
|
||||
|
||||
接下来,你也确实可以用传统方法写一个`Function()`的子类,但这样的子类创建的实例就不能调用。例如:
|
||||
|
||||
```
|
||||
> MyFunction = function() {};
|
||||
|
||||
> MyFunction.prototype = new Function;
|
||||
|
||||
> f = new MyFunction;
|
||||
|
||||
> [f instanceof MyFunction, f instanceof Functcion]
|
||||
[ true, true ]
|
||||
|
||||
> 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。
|
||||
|
||||
```
|
||||
> class MyFunction extends Function { }
|
||||
|
||||
> f = new MyFunction;
|
||||
|
||||
> 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之前将抛出异常)
|
||||
> new (function() {return 1});
|
||||
{}
|
||||
|
||||
## 非派生类的构造方法返回无效值
|
||||
> new (class { constructor() { return 1 } })
|
||||
{}
|
||||
|
||||
## 派生类的构造方法返回无效值
|
||||
> 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之外,还有哪些方法可以操作原型/原型链?
|
||||
|
||||
这些问题既是对本小节内容的回顾,也是下一阶段的课程中会用到的一些基础知识。建议你好好地寻求一下答案。
|
||||
|
||||
最后,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -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("Eagle", "Talon TSi", 1993);
|
||||
|
||||
// 创建对象
|
||||
myCar = new CarEx("red")
|
||||
|
||||
```
|
||||
|
||||
这个方案基本上来说,就是两个解决思路的集合:使用构造器函数来处理一些“不同的东西”;使用原型继承,来从父类继承“相同的东西”。最后,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() { ... } // <- [[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有什么用呢?
|
||||
|
||||
希望你能将自己的答案分享出来,让我也有机会听听你的收获。
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试如下:
|
||||
|
||||
```
|
||||
> new MyClass;
|
||||
{}
|
||||
|
||||
> 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)有什么不同?
|
||||
|
||||
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -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赋值将不成立,因为右侧的对象将不能被创建出来。例如:
|
||||
|
||||
```
|
||||
> let [a, b] = {a, b}
|
||||
ReferenceError: a is not defined
|
||||
|
||||
```
|
||||
|
||||
但前两个示例在代码逻辑上是可以成立的,只是“一般来说”运行会抛出异常。例如:
|
||||
|
||||
```
|
||||
# “赋值未声明变量”
|
||||
> a = 100, b = 200;
|
||||
|
||||
# 示例代码(与使用var声明相同)
|
||||
> [a, b] = {a, b};
|
||||
TypeError: {(intermediate value)(intermediate value)} is not iterable
|
||||
|
||||
```
|
||||
|
||||
现在你可以思考一个小小的问题:
|
||||
|
||||
- **有什么办法可以让这个代码可以执行呢?**
|
||||
|
||||
这就回到今天这一讲的标题的核心话题了。
|
||||
|
||||
## 两种数据结构的统一
|
||||
|
||||
既然我已经说过,对象和数组在本质上都是存放“一堆数据”的结构,而差异只是查找的过程不同。那么,模拟它们不同的查找过程,也就可以在这些结构之间完成统一的“赋值行为”。
|
||||
|
||||
“数组赋值模板”其实是引用了数组的下标索引过程,ECMAScript将索引次序用专门的增序来管理,并将右操作数视作为“迭代器”来取值。注意,你确实需要留意这两者之间的区别,重点在于:“迭代器”的取值是序列的,但并没有确定使用数组的下标(例如序号)。
|
||||
|
||||
所以,只要让右侧的对象成为一个“可迭代对象”,那么赋值表达式就可以知道如何将它赋给左侧的模板了。这并不难:
|
||||
|
||||
```
|
||||
## 模拟成数组的迭代器
|
||||
> Object.prototype[Symbol.iterator] = function() {
|
||||
return Array.prototype[Symbol.iterator].call(Object.values(this));
|
||||
};
|
||||
|
||||
## 测试
|
||||
> a = 100, b = 200;
|
||||
|
||||
> [a, b] = {a, b}
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
当然,你也可以不借用数组的迭代器。这是一个更简单的版本:
|
||||
|
||||
```
|
||||
Object.prototype[Symbol.iterator] = function*() {
|
||||
yield* Object.values(this);
|
||||
};
|
||||
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
也就是说,只需要将“对象成员”的列举,变成“对象成员的值”的列举,那么关联数组就可以用作索引数组了。当然,在代码中你也通常不需要这样写。只要写成下面这样就足够了:
|
||||
|
||||
```
|
||||
> [a, b] = Object.values({a, b})
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
既然将对象赋给数组(赋值模板)是可行的,那么将数组赋给“对象(赋值模板)”又是否可行呢?答案当然是“可以”。不过仍然和上面的问题一样,你得有办法在模板中“描述”索引与名字之间的关系才行。例如:
|
||||
|
||||
```
|
||||
# 在对象赋值模板中声明变量名与索引的关系
|
||||
> ({0: x, 1: y} = [a, b])
|
||||
|
||||
> 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)”这两种东西,都将只处理那些“可列举的、自有的”属性。因此,展开过程并不受对象原型的影响。例如:
|
||||
|
||||
```
|
||||
# 测试变量
|
||||
> var a = 100, b = 200;
|
||||
|
||||
# 将数组展开到一个对象(的成员)
|
||||
> obj = {...[a,b]}
|
||||
{0: 100, 1: 200}
|
||||
|
||||
# 或,将对象展开到一个数组
|
||||
> iterator = function*() { yield* Object.values(this) };
|
||||
> obj[Symbol.iterator] = iterator;
|
||||
> arr = [...obj]
|
||||
[ 100, 200
|
||||
|
||||
```
|
||||
|
||||
## 知识回顾
|
||||
|
||||
这一讲的话题,重点在于从抽象层面认识对象与数组这两种东西,以及它们更为学术的名词概念:关联数组和索引数组。
|
||||
|
||||
由于索引数组本质上是关联数组的特例,所以在JavaScript中,用关联数组(也就是对象)来实现索引数组(也就是一般概念上的数组对象)是合理的,并且也是有着很深层面的理论根基的一个设计。
|
||||
|
||||
由于两种数据结构既相关、又相同,因此在它们之间相互转换的行为,其实就是一个名字和索引变换的游戏,这也是本讲中会再次讨论“展开语法”的原因:展开语法是在两种数据类型之间的一个桥梁。
|
||||
|
||||
当然,这一讲的标题尽管并不能直接运行,但“如何让它能运行”这个问题所涉及的知识,与我们计算机领域中较深层面的运行原理,以及较高层次的抽象结构之间,都存在着密不可分的关系。无论是出于理解JavaScript代码的目的,还是出于理解语言中最本质的那些假设或前设,我都非常建议你尝试一下这篇文章中的示例代码。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,作为一个小小的思考与练习,我希望你能够在学习完这一讲之后回答一个问题:
|
||||
|
||||
- “有迭代器的对象”在哪些场合中可以替代“索引数组”?
|
||||
|
||||
谢谢你的收听,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -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是对象
|
||||
> typeof(null)
|
||||
'object'
|
||||
|
||||
// 类可以派生自null
|
||||
> MyClass = class extends null {}
|
||||
[Function: MyClass]
|
||||
|
||||
// 对象可以创建自null
|
||||
> x = Object.create(null);
|
||||
{}
|
||||
|
||||
```
|
||||
|
||||
所以,Null类型是一个“对象类型(也就是类)”,是所有对象类型的“元类型”。
|
||||
|
||||
而`null`值,是一个连属性表没有的对象,它是“元类型”系统的第一个实例,你可以称之为一个原子。
|
||||
|
||||
## 属性表
|
||||
|
||||
没有属性表的对象称为null。而一个原子级别的对象,意味着它只有一个属性表,它不继承自任何其他既有的对象,因此这个属性表的原型也就指向null。
|
||||
|
||||
原子对象是“对象”的最原始的形态。它的唯一特点就是“原型为null”,其中有一些典型示例,譬如:
|
||||
|
||||
1. 你可以使用Object.getPrototypeOf()来发现,Object()这个构造器的原型其实也是一个原子对象。——也就是所有一般对象的祖先类最终指向的,仍然是一个null值。
|
||||
1. 你也可以使用Object.setPrototypeOf()来将任何对象的原型指向null值,从而让这个对象“变成”一个原子对象。
|
||||
|
||||
```
|
||||
# JavaScript中“Object(对象类型)”的原型是一个原子对象
|
||||
> Object.getPrototypeOf(Object.prototype)
|
||||
null
|
||||
|
||||
# 任何对象都可以通过将原型置为null来“变成”原子对象
|
||||
> Object.setPrototypeOf(new Object, null)
|
||||
{}
|
||||
|
||||
```
|
||||
|
||||
但为什么要“变成”原子对象呢?或者说,你为什么需要一个“原子对象”呢?
|
||||
|
||||
因为它就是“对象”最真实的、最原始的、最基础抽象的那个数据结构:**关联数组**。
|
||||
|
||||
所谓属性表,就是关联数组。一个空索引数组与空的关联数组在JavaScript中是类似的(都是对象):
|
||||
|
||||
```
|
||||
# 空索引数组
|
||||
> a = Object.setPrototypeOf(new Array, null)
|
||||
{}
|
||||
|
||||
# 空关联数组
|
||||
> x = Object.setPrototypeOf(new Object, null)
|
||||
{}
|
||||
|
||||
```
|
||||
|
||||
而且本质上来说,空的索引数组只是在它的属性表中默认有一个不可列举的属性,也就是`length`。例如:
|
||||
|
||||
```
|
||||
# (续上例)
|
||||
|
||||
# 数组的长度
|
||||
> a.length
|
||||
0
|
||||
|
||||
# 索引数组的属性
|
||||
> Object.getOwnPropertyDescriptors(a)
|
||||
{ length:
|
||||
{ value: 0,
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: false } }
|
||||
|
||||
```
|
||||
|
||||
正因为数组有一个默认的、隐含的“length”属性,所以它才能被迭代器列举(以及适用于数组展开语法),因为迭代器需要“额外地维护一个值的索引”,这种情况下“length”属性成了有效的参考,以便于在迭代器中将“0…length-1”作为迭代的中止条件。
|
||||
|
||||
而一个原子的、支持迭代的索引数组也可通过添加“Symbol.iterator”属性来得到。例如:
|
||||
|
||||
```
|
||||
# (续上例)
|
||||
|
||||
# 使索引数组支持迭代
|
||||
> a[Symbol.iterator] = Array.prototype[Symbol.iterator]
|
||||
[Function: values]
|
||||
|
||||
# 展开语法(以及其他运算)
|
||||
> [...a]
|
||||
[]
|
||||
|
||||
```
|
||||
|
||||
现在,整个JavaScript的对象系统被还原到了两张简单的属性表,它们是两个原子对象,一个用于表达索引数组,另一个用于表达关联数组。
|
||||
|
||||
当然,还有一个对象,也是所有原子对象的父类实例:`null`。
|
||||
|
||||
## 派生自原子的类
|
||||
|
||||
JavaScript中的类,本质上是原型继承的一个封装。而原型继承,则可以理解为多层次的关联数组的链(原型链就是属性表的链)。之所以在这里说它是“多层次的”,是因为在面向对象技术出现的早期,在《结构程序设计》这本由三位图灵奖得主合写的经典著作中,“面向对象编程”就被称为“层次结构程序设计”。所以,“层次设计”其实是从数据结构的视角对面向对象中继承特性的一个精准概括。
|
||||
|
||||
类声明将“extends”指向null值,并表明该类派生自null。为了使这样的类(例如MyClass)能创建出具有原子特性的实例,JavaScript给它赋予了一个特性:MyClass.prototype的原型指向null。这个性质也与JavaScript中的Object()构造器类似。例如:
|
||||
|
||||
```
|
||||
> class MyClass extends null {}
|
||||
> Object.getPrototypeOf(MyClass.prototype)
|
||||
null
|
||||
|
||||
> Object.getPrototypeOf(Object.prototype)
|
||||
null
|
||||
|
||||
```
|
||||
|
||||
也就是说,这里的MyClass()类可以作为与Object()类处于类似层次的“根类”。通常而言,称为“(所有对象的)祖先类”。这种类,是在JavaScript中构建元类继承体系的基础。不过元类以及相关的话题,这里就不再展开讲述了。
|
||||
|
||||
这里希望你能关注的点,仅仅是在“层次结构”中,这样声明出来的类,与Object()处在相同的层级。
|
||||
|
||||
通过“extends null”来声明的类,是不能直接创建实例的,因为它的父类是null,所以在默认构造器中的“SuperCall(也就是super())”将无法找到可用的父类来创建实例。因此,通常情况下使用“extends null”来声明的类,都由用户来声明一个自己的构造方法。
|
||||
|
||||
但是也有例外,你思考一下这个问题:如果MyClass.prototype指向null,而super指向一个有效的父类,其结果如何呢?
|
||||
|
||||
是的,这样就得到了一个能创建“具有父类特性(例如父类的私有槽)”的原子对象。例如:
|
||||
|
||||
```
|
||||
> class MyClass extends null {}
|
||||
|
||||
# 这是一个原子的函数类
|
||||
> Object.setPrototypeOf(MyClass, Function);
|
||||
|
||||
# f()是一个函数,并且是原子的
|
||||
> f = new MyClass;
|
||||
> f(); // 可以调用
|
||||
> typeof f; // 是"function"类型
|
||||
|
||||
# 这是一个原子的日期类
|
||||
> Object.setPrototypeOf(MyClass, Date);
|
||||
|
||||
# d是一个日期对象,并且也是原子的
|
||||
> d = new MyClass;
|
||||
> Date.prototype.toString.call(d); // 它有内部槽用于存放日期值
|
||||
'Mon Nov 04 2019 18:27:27 GMT+0800 (CST)'
|
||||
|
||||
# a是一个原子的数组类
|
||||
> Object.setPrototypeOf(MyClass, Array);
|
||||
> a = new MyClass;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
## 一般函数/构造器
|
||||
|
||||
由于一般函数可以直接作为构造器,你可能也已经习惯了这种从ECMAScript 6之前的JavaScript沿袭下来的风格。一般情况下,这样的构造器也可以被称为“(传统的)类”,并且在ECMAScript 6中,所谓“非派生类(没有extends声明的类)”实际上也是用这样的函数/构造器来实现的。
|
||||
|
||||
这样的函数/构造器/非派生类其实是相同性质的东西,并且都是基于ECMAScript 6之前的构造器概念来实现类的实例化——也就是构造过程的。出于这样的原因,它们都不能调用SuperCall(也就是`super()`)来创建`this`实例。不过,旧式风格的构造过程将总是使用构造器的`.prototype`属性来创建实例。因而,让它们创建原子对象的方法也就变得非常简单:把它们的原型变成原子,就可以了。例如:
|
||||
|
||||
```
|
||||
# 非派生类(没有extends声明的类)
|
||||
> class MyClass {}
|
||||
> Object.setPrototypeOf(MyClass.prototype, null)
|
||||
> new MyClass
|
||||
{}
|
||||
|
||||
# 一般函数/构造器
|
||||
> function AClass() {}
|
||||
> Object.setPrototypeOf(AClass.prototype, null)
|
||||
> 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语言最独特的那些设计,我们其实才初窥门径。现在,尽管你已经在原子层面掌握了“数据”,但从计算机语言的角度上来看,你只是拥有了一个静态的系统,最重要的、也是现在最缺乏的,是让它们“动起来”。
|
||||
|
||||
从下一讲开始,我会与你聊聊“动态语言”,希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -0,0 +1,248 @@
|
||||
<audio id="audio" title="18 | a + b:动态类型是灾难之源还是最好的特性?(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/73/fa7c1d4453329b872e9b2a784e49bc73.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,欢迎回到我的专栏。今天我们讲的主题是JavaScript的动态类型系统。
|
||||
|
||||
**动态类型**是JavaScript的动态语言特性中最有代表性的一种。
|
||||
|
||||
动态执行与动态类型是天生根植于JavaScript语言核心设计中的基础组件,它们相辅相成,导致了JavaScript在学习上是易学难精,在使用中是易用易错。成兹败兹,难以得失论。
|
||||
|
||||
## 类型系统的简化
|
||||
|
||||
从根底上来说,JavaScript有着两套类型系统,如果仅以此论,那么还算不上复杂。
|
||||
|
||||
但是ECMAScript对语言类型的约定,又与JavaScript原生的、最初的语言设计不同,这导致了各种解释纷至沓来,很难统一成一个说法。而且,ECMAScript又为规范书写而订立了一套类型系统,并不停地演进它。这就如同雪上加霜,导致JavaScript的类型系统越发地说不清楚了。
|
||||
|
||||
在讨论动态类型的时候,可以将JavaScript类型系统做一些简化,从根底里来说,JavaScript也就是typeof()所支持的7种类型,其中的“**对象**(object)”与“**函数**(function)”算一大类,合称为**引用类型**,而其他类型作为**值类型**。
|
||||
|
||||
无论如何,我们就先以这种简单的类型划分为基础,来讨论JavaScript中的动态类型。因为这样一来,JavaScript中的类型转换变得很简单、很干净,也很易懂,可以用两条规则概括如下:
|
||||
|
||||
1. 从值x到引用,调用Object(x)函数。
|
||||
1. 从引用x到值,调用x.valueOf()方法;或调用4种值类型的包装类函数,例如Number(x),或者String(x)等等。
|
||||
|
||||
简单吧?当然不会这么简单。
|
||||
|
||||
## 先搞定一半
|
||||
|
||||
在**类型转换**这件事中,有“半件”是比较容易搞定的。
|
||||
|
||||
这个一半,就是“**从值x到引用**”。因为主要的值类型都有对应的引用类型,因此JavaScript可以用简单方法一一对应地将它们转换过去。
|
||||
|
||||
使用`Object(x)`来转换是很安全的方法,在用户代码中不需要特别关心其中的`x`是什么样的数据——它们可以是特殊值(例如null、undefined等),或是一般的值类型数据,又或者也可以是一个对象。所有使用`Object(x)`的转换结果,都将是一个尽可能接近你的预期的**对象**。例如,将数字值转换成数字对象:
|
||||
|
||||
```
|
||||
> x = 1234;
|
||||
|
||||
> Object(x);
|
||||
[Number: 1234]
|
||||
|
||||
```
|
||||
|
||||
类似的还包括字符串、布尔值、符号等。而null、undefined将被转换为一个一般的、空白的对象,与`new Object`或一个空白字面量对象(也就是`{ }`)的效果一样。这个运算非常好用的地方在于,如果x已经是一个对象,那么它只会返回原对象,而不会做任何操作。也就是说,它没有任何的副作用,对任何数据的预期效果也都是“返回一个对象”。而且在语法上,`Object(x)`也类似于一个类型转换运算,表达的是将`任意x`转换成`对象x`。
|
||||
|
||||
简单的这“半件事”说完后,我们反过来,接着讨论将**对象转换成值**的情况。
|
||||
|
||||
## 值VS原始值(Primitive values)
|
||||
|
||||
任何对象都会有继承自原型的两个方法,称为`toString()`和`valueOf()`,这是JavaScript中“对象转换为值”的关键。
|
||||
|
||||
一般而言,你可以认为“任何东西都是可以转换为`字符串`的”,这个很容易理解,比如`JSON.stringify()`就利用了这一个简单的假设,它“几乎”可以将JavaScript中的任何对象或数据,转换成JSON格式的文本。
|
||||
|
||||
所以,我的意思是说,在JavaScript中将任何东西都转换成字符串这一点,在核心的原理上,以及具体的处理技术上都并不存在什么障碍。
|
||||
|
||||
但是如何理解“**将函数转换成字符串**”呢?
|
||||
|
||||
从最基础的来说,函数有两个层面的含义,一个是它的可执行代码,也就是文本形式的源代码;另一个则是函数作为对象,也有自己的属性。
|
||||
|
||||
所以,“理论上来说”,函数也可以被作为一个对象来转换成字符串,或者说,序列化成文本形式。
|
||||
|
||||
又或者再举一个例子,我们需要如何来理解将一个“符号对象”转换成“符号”呢?是的,我想你一定会说,没有“符号对象”这个东西,因为符号是值,不是对象。其实这样讲只是对了一半,因为现实中确实可以将一个“符号值”转换为一个“符号对象”,只需要调用一下我们上面说过的`Object()`这个函数就好了。
|
||||
|
||||
```
|
||||
> x = Object(Symbol())
|
||||
[Symbol: Symbol()]
|
||||
|
||||
```
|
||||
|
||||
那么在这种情况下,这个“符号对象x”又怎么能转换为字符串呢?
|
||||
|
||||
所以,“一切都能转换成字符串”只是理论上行得通,而实际上很多情况下是做不到的。
|
||||
|
||||
在这些“无法完成转换”的情况下,JavaScript仍然会尝试给出一个有效的字符串值。基本上,这种转换只能保证“不抛出异常”,而无法完成任何有效的计算。例如,你在通常情况下将对象转换为字符串,就只会得到一个“简单的描述”,仅能表示“这是一个对象”而没有任何其它实际意义。
|
||||
|
||||
```
|
||||
> (new Object).toString()
|
||||
'[object Object]'
|
||||
|
||||
```
|
||||
|
||||
为了将这个问题“一致化”——也就是将问题收纳成更小的问题,JavaScript约定,所有“对象 -> 值”的转换结果要尽量地趋近于string、number和boolean三者之一。不过这从来都不是“书面的约定”,而是因为JavaScript在早期的作用,就是用于浏览器上的开发,而:
|
||||
|
||||
- 浏览器可以显示的东西,是string;
|
||||
- 可以计算的东西,是number;
|
||||
- 可以表达逻辑的东西,是boolean。
|
||||
|
||||
因此,在一个“最小的、可以被普通人理解的、可计算的程序系统中”,支持的“值类型数据”的最小集合,就应该是这三种。
|
||||
|
||||
这个问题不仅仅是浏览器,就算是一台放在云端的主机,你想要去操作它,那么通过控制台登录之后的shell脚本,也必须支持它。更远一点地说,你远程操作一台计算机,与浏览器用户要使用gmail,这二者在计算的抽象上是一样的,只是程序实现的复杂性不一样而已。
|
||||
|
||||
所以,对于(ECMAScript 5以及之前的)JavaScript来说,当它支持值转换向“对应的”对象时,或者反过来从这些对象转换回值的时候,所需要处理的也无非是这三种类型而已。而处理的具体方法也很简单,就是在使用`Object(x)`来转换得到的对象实例中添加一个内部槽,存放这个`x`的值。更确切地说,下面两行代码在语义上的效果是一致的(它是在一个称为`PrimitiveValue`的内部槽中置入这个值的):
|
||||
|
||||
```
|
||||
obj = Object(x);
|
||||
|
||||
// 等效于(如果能操作内部槽的话)
|
||||
obj.[[PrimitiveValue]] = x;
|
||||
|
||||
```
|
||||
|
||||
于是,当需要从对象中转换回来到值类型时,也就是把这个`PrimitiveValue`值取出来就可以了。而“**取出这个值,并返回给用户代码**”的方法,就称为`valueOf()`。
|
||||
|
||||
到了ECMAScript 6中,这个过程就稍稍有些不同,这个内部槽是区别值类型的,因此为每种值类型设计了一个独立的私有槽名字。加上ES8中出现的大整数类型(BigInt),一共就有了5个对应的私有槽:`[[BooleanData] [[NumberData]]`、`[[StringData] [[SymbolData]]`和`[[BigIntData]]`。其中除了`Symbol`类型之外,都是满足在上面所说的:
|
||||
|
||||
- 一个“最小的、可以被普通人理解的、可计算的程序系统中”,支持的“值类型数据”的最小集合
|
||||
|
||||
这样一个设定的。
|
||||
|
||||
那么“符号”这个东西出现的必要性何在呢?
|
||||
|
||||
这个问题我就不解释了,算作本讲的课后习题之一,希望你可以踊跃参与讨论。不过就问题的方向来说,仍然是出于**计算系统的完备性**。如果你非要说这个是因为张三李四喜欢,某个tc39提案者的心头好,这样的答案就算是当事人承认,我也是不认可的。:)
|
||||
|
||||
好。回到正题。那么在ECMAScript 6之后,除`[[PrimitiveValue]]`这个私有槽变成了5种值类型对应的、独立的私有槽之外,还有什么不同呢?
|
||||
|
||||
是的,这个你可能也已经注意到了。ECMAScript 6之后还出现了`Symbol.toPrimitive`这个符号。而它,正是将原本的`[[PrimitiveValue]]`这个私有槽以及其访问过程标准化,然后暴露给JavaScript用户编程的一个界面。
|
||||
|
||||
说到这里,就必须明确**一般的值**(Values)与**原始值**(Primitive values)之间的关系了。
|
||||
|
||||
不过,在下一步的讨论之前,我要先帮你总结一下前面的内容:
|
||||
|
||||
也就是说,从`typeof(x)`的7种结果类型来看,其中string、boolean、number、bigint和symbol的值类型与对象类型转换,就是将该值存入私有槽,或者从私有槽中把相应的值取出来就好了。
|
||||
|
||||
在语言中,这些对应的对象类型被称为“包装类”,与此相关的还有“装箱”与“拆箱”等等行为,这也是后续会涉及到的内容。
|
||||
|
||||
>
|
||||
NOTE: 在ECMAScript 6之前,由于`[PrimitiveValue]`来存放对应的封装类。也就是说,只有当`obj.[Class]`存放着`false`值时,它才是`false`值所对应的对象实例。
|
||||
|
||||
|
||||
>
|
||||
而ECMAScript 6将上述的依赖项变成了一个,也就是说只要有一个对象有内部槽`[[BooleanData]]`,那么它就是某个boolean值对应的对象。这样处理起来就简便了,不必每次做两项判断。
|
||||
|
||||
|
||||
所以,一种关于“原始值”的简单解释是:所有5种能放入私有槽(亦即是说它们有相应的包装类)的值(Values),都是原始值;并且,再加上两个特殊值undefined和null,那么就是所谓原始值(Primitive values)的完整集合了。
|
||||
|
||||
接下来,如果转换过程发生在“值与值”之间呢?
|
||||
|
||||
## 干掉那两个碍事儿的
|
||||
|
||||
bigint这个类型最好说,它跟number在语言特性上是一回事儿,所以它的转换没什么特殊性,下面我会在讲到number的时候,一并讲解。
|
||||
|
||||
除此之外,还有两个类型在与其他类型的转换中是简单而特殊的。
|
||||
|
||||
例如,**symbol**这个值类型,它其实既没有办法转换成别的类型,也没有办法从别的类型转换过来。无论是哪种方式转换,它在语义上都是丢失了的、是没有意义的。当然,现实中你也可以这么用,比如用`console.log()`来将一个符号显示出来,这在控制台里面,是有显示信息输出的。
|
||||
|
||||
```
|
||||
> console.log(Symbol())
|
||||
Symbol()
|
||||
|
||||
```
|
||||
|
||||
这里的确发生了一个“symbol -> string”的转换。但它的结果只能表示这是一个符号,至于是哪个符号,符号a还是符号b,全都分不出来。类似于此,所有“符号 -> 其他值类型”的转换不需要太特别的讨论,由于所有能发生的转换都是定值,所以你可以做一张表格出来对照参考即可。当然,如果是“其他值类型 -> symbol”的这种转换,实际结果就是创建一个新符号,而没有“转换”的语义了。
|
||||
|
||||
另外一个碍事儿的也特别简单,就是**boolean**。
|
||||
|
||||
ECMAScript为了兼容旧版本的JavaScript,直接将这个转换定义成了一张表格,这个表格在ECMAScript规范或者我们常用的[MDN](https://developer.mozilla.org/)(Mozilla Developer Network)上可以直接查到。简单地说,就是除了undefined、null、0、NaN、""(empty string)以及BigInt中的0n返回false之外,其他的值转换为boolean时,都将是true值。
|
||||
|
||||
当然,不管怎么说,要想记住这些类型转换并不容易(当然也不难),简单的做法,就是直接把它们的包装类当作函数来调用,转换一下就好了。在你的代码中也可以这么写,例如,使用“String(x)”就是将x转换成string类型,又或者“Boolean(x)”就是将x转换为true/false值。
|
||||
|
||||
```
|
||||
> x = 100n; // `bigint` value
|
||||
> String(x) // to `string` value
|
||||
'100n'
|
||||
|
||||
> Boolean(x); // to `boolean` value
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
这些操作简单易行,也不容易出错,用在代码中还不影响效率,一切都很好。
|
||||
|
||||
>
|
||||
NOTE: 这些可以直接作为函数调用的包装类,一共有四个,包括String()、Number()、Boolean()和BigInt()。此外,Symbol()在形式上与此相同,但执行语义是略有区别的。
|
||||
|
||||
|
||||
但并不那么简单。因为我还没有跟你讨论过字符串和数字值的转换。
|
||||
|
||||
以及,还有特别要命的“隐式转换”。
|
||||
|
||||
## 隐式转换
|
||||
|
||||
由于函数的参数没有类型声明,所以用户代码可以传入任何类型的值。对于JavaScript核心库中的一些方法或操作来说,这表明它们需要一种统一、一致的方法来处理这种类型差异。例如说,要么拒绝“类型不太正确的参数”,抛出异常;要么用一种方式来使这些参数“变得正确”。
|
||||
|
||||
后一种方法就是“隐式转换”。但是就这两种方法的选择来说,JavaScript并没有编码风格层面上的约定。基本上,早期JavaScript以既有实现为核心的时候,倾向于让引擎吞掉类型异常(TypeError),尽量采用隐式转换来让程序在无异常的情况下运行;而后期,以ECMAScript规范为主导的时候,则倾向于抛出这些异常,让用户代码有机会处理类型问题。
|
||||
|
||||
隐式转换最主要的问题就是会带来大量的“潜规则”。
|
||||
|
||||
例如经典的`String.prototype.search(r)`方法,其中的参数从最初设计时就支持在`r`参数中传入一个字符串,并且将隐式地调用`r = new RegExp(r)`来产生最终被用来搜索的正则表达式。而`new RegExp(r)`这个运算中,由于`RegExp()`构造器又会隐式地将`r`从任何类型转换为字符串类型,因而在这整个过程中,向原始的`r`参数传入任何值都不会产生任何的异常。
|
||||
|
||||
例如,其实你写出下面这样的代码也是可以运行的:
|
||||
|
||||
```
|
||||
> "aa1aa".search(1)
|
||||
2
|
||||
|
||||
> "000false111".search(0 > 5)
|
||||
3
|
||||
|
||||
```
|
||||
|
||||
隐式转换导致的“潜规则”很大程度上增加了理解用户代码的难度,也不利于引擎实现。因此,ECMAScript在后期就倾向于抛弃这种做法,多数的“新方法”在发现类型不匹配的时候,都设计为显式地抛出类型错误。一个典型的结果就是,在ECMAScript 3的时代,TypeError这个词在规范中出现的次数是24次;到了ECMAScript 5,是114次;而ECMAScript 6开始就暴增到419次。
|
||||
|
||||
因此,越是早期的特性,越是更多地采用了带有“潜规则”的隐式转换规则。然而很不幸的是,几乎所有的“运算符”,以及大多数常用的原型方法,都是“早期的特性”。
|
||||
|
||||
所以在类型转换方面,JavaScript成了“潜规则”最多的语言之一。
|
||||
|
||||
## 好玩的
|
||||
|
||||
@graybernhardt 曾在2012年发布过一个[讲演](https://www.destroyallsoftware.com/talks/wat)(A lightning talk by Gary Bernhardt from CodeMash 2012),提到一个非常非常著名的案例,来说明这个隐式转换,以及它所带来的“潜规则”有多么的不可预测。这个经典的示例是:
|
||||
|
||||
- 将`[]`和`{}`相加,会发生什么?
|
||||
|
||||
尝试一下这个case,你会看到:
|
||||
|
||||
```
|
||||
> [] + {}
|
||||
'[object Object]'
|
||||
|
||||
> {} + []
|
||||
0
|
||||
|
||||
> {} + {}
|
||||
NaN
|
||||
|
||||
> [] + []
|
||||
''
|
||||
|
||||
```
|
||||
|
||||
嗯!四种情况居然没有一个是相同的!
|
||||
|
||||
不过有一点需要注意到的,就是输出的结果,总是会“收敛”到两种类型:字符串,或者数值。嗯,“隐式转换”其实只是表面现象,核心的问题是,这种转换的结果总是倾向于“string/number”两种值类型。
|
||||
|
||||
这个,才是我们这一讲要讲“大问题”。
|
||||
|
||||
## 且听下回分解
|
||||
|
||||
到现在为止,这一节课其实才开了个头,也就是对“a + b”这个标题做了一个题解而已。这主要是因为在JavaScript中有关类型处理的背景信息太多、太复杂,而且还处在不停的变化之中。许多稍早的信息,与现在的应用环境中的现状,或者你手边可备查的资料之间都存在着不可调和的矛盾冲突,因此对这些东西加以梳理还原,实在是大有必要的。这也就是为什么这一讲会说到现在,仍然没有切入正题的原因。
|
||||
|
||||
当然,一部分原因也在于:这些絮絮叨叨的东西,也原本就是“正题”的一部分。比如说,你至少应该知道的内容包括:
|
||||
|
||||
- 语言中的引用类型和值类型,以及ECMAScript中的原始值类型(Primitive values)之间存在区别;
|
||||
- 语言中的所谓“引用类型”,与ECMAScript中的“引用(规范类型)”是完全不同的概念;
|
||||
- 所有值通过包装类转换成对象时,这个对象会具有一个内部槽,早期它统一称为`[[PrimitiveValue]]`,而后来JavaScript为每种包装类创建了一个专属的;
|
||||
- 使用typeof(x)来检查x的数据类型,在JavaScript代码中是常用而有效方法;
|
||||
- 原则上来说,系统只处理boolean/string/number三种值类型(bigint可以理解为number的特殊实现),其中boolean与其他值类型的转换是按对照表来处理的。
|
||||
|
||||
总的来说,类型在JavaScript中的显式转换是比较容易处理的,而标题“a + b”其实包含了太多隐式转换的可能性,因此尤其复杂。关于这些细节,且听下回分解。
|
||||
|
||||
这一讲没有复习题。不过如果你愿意,可以把上面讲到的@graybernhardt 的四个示例尝试一下,解释一下它们为什么是这个结果。
|
||||
|
||||
而下一讲,我再来为你公布答案,并且做详细解说。
|
||||
@@ -0,0 +1,340 @@
|
||||
<audio id="audio" title="19 | a + b:动态类型是灾难之源还是最好的特性?(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2c/f3/2cca3bf74ef3b0bec336e50c2b938ff3.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
上一讲,我们说到如何将复杂的类型转换缩减到两条简单的规则,以及两种主要类型。这两条简单规则是:
|
||||
|
||||
1. 从值x到引用:调用Object(x)函数。
|
||||
1. 从引用x到值:调用x.valueOf()方法;或,调用四种值类型的包装类函数,例如Number(x),或者String(x)等等。
|
||||
|
||||
两种主要类型则是**字符串**和**数字值**。
|
||||
|
||||
当类型转换系统被缩减成这样之后,有些问题就变得好解释了,但也确实有些问题变得更加难解。例如@graybernhardt 在讲演中提出的灵魂发问,就是:
|
||||
|
||||
- 如果将数组跟对象相加,会发生什么?
|
||||
|
||||
如果你忘了,那么我们就一起来回顾一下这四个直击你灵魂深处的示例,简单地说,这些示例就是“数组与对象”相加的四种情况,结果都完全不同。
|
||||
|
||||
```
|
||||
> [] + {}
|
||||
'[object Object]'
|
||||
|
||||
> {} + []
|
||||
0
|
||||
|
||||
> {} + {}
|
||||
NaN
|
||||
|
||||
> [] + []
|
||||
''
|
||||
|
||||
```
|
||||
|
||||
而这个问题,也就是这两讲的标题中“a + b”这个表达式的由来。也就是说,如何准确地解释“两个操作数相加”,与如何全面理解JavaScript的类型系统的转换规则,关系匪浅!
|
||||
|
||||
## 集中精力办大事
|
||||
|
||||
一般来说,运算符很容易知道操作数的类型,例如“a - b”中的减号,我们一看就知道意图,是两个数值求差,所以a和b都应该是数值;又例如“obj.x”中的点号,我们一看也知道,是取**对象obj**的属性名**字符串x**。
|
||||
|
||||
当需要引擎“推断目的”时,JavaScript设定推断结果必然是三种基础值(boolean、number和string)。由于其中的boolean是通过查表来进行的,所以就只剩下了number和string类型需要“自动地、隐式地转换”。
|
||||
|
||||
但是在JavaScript中,“加号(+)”是一个非常特别的运算符。像上面那样简单的判断,在加号(+)上面就不行,因为它在JavaScript中既可能是字符串连结,也可能是数值求和。另外还有一个与此相关的情况,就是`object[x]`中的`x`,其实也很难明确地说它是字符串还是数值。因为计算属性(computed property)的名字并不能确定是字符串还是数值;尤其是现在,它还可能是符号类型(symbol)。
|
||||
|
||||
>
|
||||
NOTE:在讨论计算属性名(computed property name)时,JavaScript将它作为预期为字符串的一个值来处理,即`r = ToPrimitive(x, String)`。但是这个转换的结果仍然可能是5种值类型之一,因此在得到最终属性名的时候,JavaScript还会再调用一次`ToString(r)`。
|
||||
|
||||
|
||||
由于“加号(+)”不能通过代码字面来判断意图,因此只能在运算过程中实时地检查操作数的类型。并且,这些类型检查都必须是基于“加号(+)运算必然操作两个值数据”这个假设来进行。于是,JavaScript会先调用`ToPrimitive()`内部操作来分别得到“a和b两个操作数”可能的原始值类型。
|
||||
|
||||
所以,问题就又回到了在上面讲的`Value vs. Primitive values`这个东西上面。对象到底会转换成什么?这个转换过程是如何决定的呢?
|
||||
|
||||
这个过程包括如下的四个步骤。
|
||||
|
||||
### 步骤一
|
||||
|
||||
首先,JavaScript约定:如果`x`原本就是原始值,那么`ToPrimitive(x)`这个操作直接就返回`x`本身。这个很好理解,因为它不需要转换。也就是说(如下代码是不能直接执行的):
|
||||
|
||||
```
|
||||
# 1. 如果x是非对象,则返回x
|
||||
> _ToPrimitive(5)
|
||||
5
|
||||
|
||||
```
|
||||
|
||||
### 步骤二
|
||||
|
||||
接下来的约定是:如果`x`是一个对象,且它有对应的五种`PrimitiveValue`内部槽之一,那么就直接返回这个内部槽中的原始值。由于这些对象的`valueOf()`就可以达成这个目的,因此这种情况下也就是直接调用该方法(步骤三)。相当于如下代码:
|
||||
|
||||
```
|
||||
# 2. 如果x是对象,则尝试得到由x.valueOf()返回的原始值
|
||||
> Object(5).valueOf()
|
||||
5
|
||||
|
||||
```
|
||||
|
||||
但是在处理这个约定的时候,JavaScript有一项特别的设定,就是对“引擎推断目的”这一行为做一个预设。如果某个运算没有预设目的,而JavaScript也不能推断目的,那么JavaScript就会强制将这个预设为“number”,并进入“传统的”类型转换逻辑(步骤四)。
|
||||
|
||||
所以,简单地说(**这是一个非常重要的结论**):
|
||||
|
||||
如果一个运算无法确定类型,那么在类型转换前,它的运算数将被预设为number。
|
||||
|
||||
>
|
||||
<p>NOTE1:预设类型在ECMAScript称为PreferredType,它可以为undefined或"default"。但是“default”值是“传统的”类型转换逻辑所不能处理的,这种情况下,JavaScript会先将它重置为“number”。也就是说,在传统的转换模式中,“number”是优先的。<br>
|
||||
NOTE2:事实上,只有对象的符号属性Symbol.toPrimitive所设置的函数才会被要求处理“default”这个预设。这也是在Proxy/Reflect中并没有与类型转换相关的陷阱或方法的原因。</p>
|
||||
|
||||
|
||||
于是,这里会发生两种情况,也就是接下来的步骤三和步骤四。
|
||||
|
||||
### 步骤三:作为原始值处理
|
||||
|
||||
如果是上述的五种包装类的对象实例(它们有五种`PrimitiveValue`内部槽之一),那么它们的`valueOf()`方法总是会忽略掉“number”这样的预设,并返回它们内部确定(即内部槽中所保留的)的原始值。
|
||||
|
||||
所以,如果我们为符号创建一个它的包装类对象实例,那么也可以在这种情况下解出它的值。例如:
|
||||
|
||||
```
|
||||
> x = Symbol()
|
||||
|
||||
> obj = Object(x)
|
||||
|
||||
> obj.valueOf() === x
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
正是因为对象(如果它是原始值的包装类)中的原始值总是被解出来,所以,你要将数字值`5`转换成两个对象类型,并且再将这两个对象相加,那么其结果也会是数值10。
|
||||
|
||||
```
|
||||
> Object(5) + Object(5)
|
||||
10
|
||||
|
||||
```
|
||||
|
||||
这个代码看起来是两个对象“相加”,但是却等效于它们的原始值直接相加。
|
||||
|
||||
但是如果考虑“对象属性存取”这样的例子情况就发生了变化,由于“对象属性存取”是一个“有预期”的运算——它的预期是“字符串”,因此会有第二种情况——步骤四。
|
||||
|
||||
### 步骤四:进入“传统的类型转换逻辑”
|
||||
|
||||
这需要利用到对象的`valueOf()`和`toString()`方法:当预期是“number”时,`valueOf()`方法优先调用;否则就以`toString()`为优先。并且,重要的是,上面的预期只决定了上述的优先级,而当调用优先方法仍然得不到非对象值时,还会顺序调用另一方法。
|
||||
|
||||
这带来了一个结果,即:如果用户代码试图得到“number”类型,但`x.valueOf()`返回的是一个对象,那么就还会调用`x.toString()`,并最终得到一个字符串。
|
||||
|
||||
到这里,就可以解释前面四种对象与数组相加所带来的特殊效果了。
|
||||
|
||||
## 解题1:从对象到原始值
|
||||
|
||||
在`a + b`的表达式中,`a`和`b`是对象类型时,由于“加号(+)”运算符并不能判别两个操作数的预期类型,因此它们被“优先地”假设为数字值(number)进行类型转换。这样一来,无论是对象,还是数组,它们的.valueOf()方法调用的结果都将得到它们本身。如果用typeof()看一下,结果还仍然是`object`类型。接下来,由于这个调用.valueOf()方法的结果不是值类型,所以就会再尝试一下调用.toString()这个方法。
|
||||
|
||||
```
|
||||
# 在预期是'number'时,先调用`valueOf()`方法,但得到的结果仍然是对象类型;
|
||||
> [typeof ([].valueOf()), typeof ({}.valueOf())]
|
||||
[ 'object', 'object' ]
|
||||
|
||||
# 由于上述的结果是对象类型(而非值),于是再尝试`toString()`方法来得到字符串
|
||||
> [[].toString(), {}.toString()]
|
||||
[ '', '[object Object]' ]
|
||||
|
||||
```
|
||||
|
||||
在这里,我们就会看到有一点点差异了。空数组转换出来,是一个空字符串,而对象的转换成字符串时是’[object Object]’。
|
||||
|
||||
所以接下来的四种运算变成了下面这个样子,它们其实是对字符串相加,也就是字符串连接的结果。
|
||||
|
||||
```
|
||||
# [] + {}
|
||||
> '' + '[object Object]'
|
||||
'[object Object]'
|
||||
|
||||
# {} + []
|
||||
> ???
|
||||
0
|
||||
|
||||
# {} + {}
|
||||
> ???
|
||||
NaN
|
||||
|
||||
# [] + []
|
||||
> '' + ''
|
||||
''
|
||||
|
||||
```
|
||||
|
||||
好的,你应该已经注意到了,在第二和第三种转换的时候我打了三个问号“???”。因为如果按照上面的转换过程,它们无非是字符串拼接,但结果它们却是两个数字值,分别是0,还有NaN。
|
||||
|
||||
怎么会这样?!!
|
||||
|
||||
## 解题2:“加号(+)”运算的戏分很多
|
||||
|
||||
现在看看这两个表达式。
|
||||
|
||||
```
|
||||
{} + []
|
||||
{} + {}
|
||||
|
||||
```
|
||||
|
||||
你有没有一点熟悉感?嗯,很不幸,它们的左侧是一对大括号,而当它们作为语句执行的时候,会被优先解析成——块语句!并且大括号作为结尾的时候,是可以省略掉语句结束符“分号(;)”的。
|
||||
|
||||
所以,你碰到了JavaScript语言设计历史中最大的一块铁板!就是所谓“自动分号插入(ASI)”。这个东西的细节我这里就不讲了,但它的结果是什么呢?上面的代码变成下面这个样子:
|
||||
|
||||
- `{}; +[]`
|
||||
- `{}; +{}`
|
||||
|
||||
实在是不幸啊!这样的代码仍然是可以通过语法解析,并且仍然是可以进行表达式计算求值的!
|
||||
|
||||
于是后续的结论就比较显而易见了。
|
||||
|
||||
由于“+”号同时也是“正值运算符”,并且它很明显可以准确地预期后续操作数是一个数值,所以它并不需要调用`ToPrimitive()`内部操作来得到原始值,而是直接使用“ToNumber(x)”来尝试将`x`转换为数字值。而上面也讲到,“将对象转换为数字值,等效于使用它的包装类来转换,也就是Number(x)”。所以,上述两种运算的结果就变成了下面的样子:
|
||||
|
||||
```
|
||||
# +[] 将等义于
|
||||
> + Number([])
|
||||
0
|
||||
|
||||
# +{} 将等义于
|
||||
> + Number({})
|
||||
NaN
|
||||
|
||||
```
|
||||
|
||||
## 解题3:预期 vs. 非预期
|
||||
|
||||
但是你可能会注意到:当使用“… + {}”时,`ToPrimitive()`转换出来的,是字符串“[object Object]”;而在使用“+ {}”时,`ToNumber(x)`转换出来的却是值NaN。所以,在不同的预期下面,“对象->值”转换的结果却并不相同。
|
||||
|
||||
这之间有什么规律吗?
|
||||
|
||||
我们得先理解哪些情况下,JavaScript是不能确定用户代码的预期的。总结起来,这其实很有限,包括:
|
||||
|
||||
1. “加号(+)”运算中,不能确定左、右操作数的类型;
|
||||
1. “等值(==)”运算中,不能确定左、右操作数的类型;(JavaScript认为,如果左、右操作数之一为string、number、bigint和symbol四种基础类型之一,而另一个操作数是对象类型(x),那么就需要将对象类型“转换成基础类型(ToPrimitive(x))”来进行比较。操作数将尽量转换为数字来进行比较,即最终结果将等效于:Number(x) == Number(y)。)
|
||||
1. “new Date(x)”中,如果x是一个非Date()实例的对象,那么将尝试把x转换为基础类型x1;如果x1是字符串,尝试从字符串中parser出日期值;否则尝试x2 = Number(x1),如果能得到有效的数字值,则用x2来创建日期对象。
|
||||
1. 同样是在Date()的处理中,(相对于缺省时优先number类型来说,)JavaScript内部调整了Date在转换为值类型时的预期。一个Date类型的对象(x)转换为值时,将优先将它视为字符串,也就是先调用x.toString(),之后再调用x.valueOf()。
|
||||
|
||||
其他情况下,JavaScript不会为用户代码调整或假设预期值。这也就是说,按照ECMAScript内部的逻辑与处理过程,其他的运算(运算符或其他内置操作)对于“对象x”,都是有目标类型明确的、流程确定的方法来转换为“(值类型的)值”的。
|
||||
|
||||
## 其他
|
||||
|
||||
### 显式的 vs. 隐式的转换
|
||||
|
||||
很大程度上来说,显式的转换其实只决定了“转换的预期”,而它内部的转换过程,仍然是需要“隐式转换过程”来参与的。例如,你调用Number()函数来转换对象`x`:
|
||||
|
||||
```
|
||||
> x = new Object
|
||||
> Number(x)
|
||||
NaN
|
||||
|
||||
```
|
||||
|
||||
对于这样的一个显式转换,Number()只决定它预期的目标是’number’类型,并最终将调用`ToPrimitive(x, 'Number')`来得到结果。然而,一如之前所说的,ToPrimitive()会接受任何一个“原始值”作为结果`x1`返回(并且要留意的是,在这里null值也是原始值),因此它并不保证结果符合预期`'number'`。
|
||||
|
||||
所以,最终Number()还会再调用一次转换过程,尝试将`x1`转换为数字。
|
||||
|
||||
### 字符串在“+”号中的优先权
|
||||
|
||||
另一方面,在“+”号运算中,由于可能的运算包括数据和字符串,所以按照隐式转换规则,在不确定的情况下,优先将运算数作为数字处理。那么就是默认“+”号是做求和运算的。
|
||||
|
||||
但是,在实际使用中,结果往往会是字符串值。
|
||||
|
||||
这是因为字符串在“+”号运算中还有另一层面的优先级,这是由“+”号运算符自已决定的,因而并不是类型转换中的普遍规则。
|
||||
|
||||
“+”号运算符约定,对于它的两个操作数,在通过`ToPrimitive()`得到两个相应的原始值之后,二者之任一是字符串的话,就优先进行字符串连接操作。也就是说,这种情况下另一个操作数会发生一次“值->值”的转换,并最终连接两个字符串以作为结果返回。
|
||||
|
||||
那么,我们怎么理解这个行为呢?比如说,如果对象x转换成数字和字符串的效果如下:
|
||||
|
||||
```
|
||||
x = {
|
||||
valueOf() { console.log('Call valueOf'); return Symbol() },
|
||||
toString() { console.log('Call toString'); return 'abc' }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我声明了一个对象x,它带有两个定制的toString()和valueOf()方法,用来观察类型转换的过程;并且,其中valueOf()会返回一个symbol符号,也就是说,它是“值类型”,但既不是字符串,也不是数字值。
|
||||
|
||||
接下来我们尝试用它跟一个任意值做“+”号运算,例如:
|
||||
|
||||
```
|
||||
# 例1:与非字符串做“+”运算时
|
||||
> true + x
|
||||
Call valueOf
|
||||
TypeError: Cannot convert a Symbol value to a number
|
||||
|
||||
```
|
||||
|
||||
“+”号运算在处理这种情况(用对象与非字符串值做加号运算)时,会先调用`x`的valueOf()方法,然后由于“+”号的两个操作数都不是字符串,所以将再次尝试将它们转换成数字并求和。又例如:
|
||||
|
||||
```
|
||||
# 例2:与字符串做“+”运算时
|
||||
> 'OK, ' + x
|
||||
Call valueOf
|
||||
TypeError: Cannot convert a Symbol value to a string
|
||||
|
||||
```
|
||||
|
||||
这种情况下,由于存在一个字符串操作数,因此“字符串连接”运算被优先,于是会尝试将`x`转换为字符串。
|
||||
|
||||
然而需要注意的是,上述两个操作中都并没有调用x.toString(),而“都仅仅是”在ToPrimitive()内部操作中调用了x.valueOf()。也就是说,在检测操作数的值类型“是否是字符串”之后,再次进行的“值->值”的转换操作是基于ToPrimitive()的结果,而非原对象`x`的。
|
||||
|
||||
这也是之前在“解题3”中特别讲述Date()对象这一特例的原因。因为Date()在“调用ToPrimitive()”这个阶段的处理顺序是反的,所以它会先调用x.toString,从而产生不一样的效果。例如:
|
||||
|
||||
```
|
||||
// 创建MyDate类,覆盖valueOf()和toString()方法
|
||||
class MyDate extends Date {
|
||||
valueOf() { console.log('Call valueOf'); return Symbol() }
|
||||
toString() { console.log('Call toString'); return 'abc' }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试如下:
|
||||
|
||||
```
|
||||
# 示例
|
||||
> x = new MyDate;
|
||||
|
||||
# 与非字符串做“+”运算时
|
||||
> true + x
|
||||
Call toString
|
||||
trueabc
|
||||
|
||||
# 与非字符串做“+”运算时
|
||||
> 'OK, ' + x
|
||||
Call toString
|
||||
OK, abc
|
||||
|
||||
```
|
||||
|
||||
那么对于Date()这个类来说,这又是如何做到的呢?
|
||||
|
||||
### Symbol.toPrimitive的处理
|
||||
|
||||
简单地说,Date类重写了原型对象Date.prototype上的符号属性`Symbol.toPrimitive`。任何情况下,如果用户代码重写了对象的`Symbol.toPrimitive`符号属性,那么`ToPrimitive()`这个转换过程就将由用户代码负责,而原有的顺序与规则就失效了。
|
||||
|
||||
我们知道,由于调用`ToPrimitive(hint)`时的入口参数hint可能为`default/string/number`这三种值之一,而它要求返回的只是“值类型”结果,也就是说,结果可以是所有5种值类型之任一。因此,用户代码对`ToPrimitive(hint)`的重写可以“参考”这个hint值,也可以无视之,也可以在许可范围内返回任何一种值。
|
||||
|
||||
简单地说,它就是一个超强版的`valueOf()`。
|
||||
|
||||
事实上,一旦用户代码声明了符号属性`Symbol.toPrimitive`,那么valueOf()就失效了,ECMAScript采用这个方式“一举”摧毁了原有的隐式转换的全部逻辑。这样一来,包括预期的顺序与重置,以及toString和valueOf调用等等都“不复存焉”。
|
||||
|
||||
一切重归于零:定制`Symbol.toPrimitive`,返回`值类型`;否则抛出异常。
|
||||
|
||||
>
|
||||
NOTE:Date()类中仍然是会调用toString或valueOf的,这是因为在它的`Symbol.toPrimitive`实现中仅是调整了两个方法的调用顺序,而之后仍然是调用原始的、内置的`ToPrimitive()`方法的。对于用户代码来说,可以自行决定该符号属性(方法)的调用结果,无需依赖`ToPrimitive()`方法。
|
||||
|
||||
|
||||
## 结语与思考
|
||||
|
||||
今天我们更深入地讲述了类型转换的诸多细节,除了这一讲的简单题解之外,对于“+”号运算也做了一些补充。
|
||||
|
||||
总地来讲,我们是在讨论JavaScript语言所谓“动态类型”的部分,但是动态类型并不仅限于此。也就是说JavaScript中并不仅仅是“类型转换”表现出来动态类型的特性。例如一个更简单的问题:
|
||||
|
||||
“x === x”在哪些情况下不为true?
|
||||
|
||||
这原本是这两讲的另一个备选的标题,它也是讨论动态类型问题的。只不过这个问题所涉及的范围太窄,并不适合展开到这两讲所涵盖的内容,因此被弃用了。这里把它作为一个小小的思考题留给你,你可以试着找找答案。
|
||||
|
||||
>
|
||||
<p>NOTE1:我可以告诉你答案不只一个,例如“x是NaN”。^^.<br>
|
||||
NOTE2:“x是NaN”这样的答案与动态类型或动态语言这个体系没什么关系,所以它不是我在这里想与你讨论的主要话题。</p>
|
||||
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -0,0 +1,312 @@
|
||||
<audio id="audio" title="20 | (0, eval)("x = 100") :一行让严格模式形同虚设的破坏性设计(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/e2/4bd2728e9a497f56e8e9cee8e3e2fae2.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
今天我们讨论动态执行。与最初的预告不同 ,我在这一讲里把原来的第20讲合并掉了,变成了20~21的两讲合讲,但也分成了上、下两节。所以,其实只是课程的标题少了一个,内容却没有变。
|
||||
|
||||
**动态执行**是JavaScript最早实现的特性之一,eval()这个函数是从JavaScript 1.0就开始内置了的。并且,最早的setTimeout()和setInterval()也内置了动态执行的特性:它们的第1个参数只允许传入一个字符串,这个字符串将作为代码体动态地定时执行。
|
||||
|
||||
>
|
||||
NOTE:setTimeout/setInterval执行字符串的特性如今仍然保留在大多数浏览器环境中,例如Safari或Mozilla,但这在Node.js/Chrome环境中并不被允许。需要留意的是,setTimeout/setInterval并不是ECMAScript规范的一部分。
|
||||
|
||||
|
||||
关于这一点并不难理解,因为JavaScript本来就是脚本语言,它最早也是被作为脚本语言设计出来的。因此,把“装载脚本+执行”这样的核心过程,通过一个函数暴露出来成为基础特性既是举手之劳,也是必然之举。
|
||||
|
||||
然而,这个特性从最开始就过度灵活,以至于后来许多新特性在设计中颇为掣肘,所以在ECMAScript 5的严格模式出现之后,它的特性受到了很多的限制。
|
||||
|
||||
接下来,我将帮助你揭开重重迷雾,让你得见最真实的“eval()”。
|
||||
|
||||
## eval执行什么
|
||||
|
||||
最基本的、也是最重要的问题是:eval究竟是在执行什么?
|
||||
|
||||
在代码`eval(x)`中,`x`必须是一个字符串,不能是其他任何类型的值,也不能是一个字符串对象。如果尝试在x中传入其他的值,那么eval()将直接以该值为返回值,例如:
|
||||
|
||||
```
|
||||
# 值1
|
||||
> eval(null)
|
||||
null
|
||||
|
||||
# 值2
|
||||
> eval(false)
|
||||
false
|
||||
|
||||
# 字符串对象
|
||||
> eval(Object('1234'))
|
||||
[String: '1234']
|
||||
|
||||
# 字符串值
|
||||
> eval(Object('1234').toString())
|
||||
1234
|
||||
|
||||
```
|
||||
|
||||
这里,eval()会按照JavaScript语法规则来尝试解析字符串x,包括对一些特殊字面量(例如8进制)的语法解析。这样的解析会与parseInt()或Number()函数实现的类型转换有所不同,例如:对8进制的解析,在eval()的代码中就可以使用’012’来表示十进制的10。而使用parseInt()或Number()函数,就不支持8进制,会忽略前缀字符0,得到十进制的12。
|
||||
|
||||
```
|
||||
# JavaScript在源代码层面支持8进制
|
||||
> eval('012')
|
||||
10
|
||||
|
||||
# 但parseInt()不支持8进制(除非显式指定radix参数)
|
||||
> parseInt('012')
|
||||
12
|
||||
|
||||
# Number()也不支持8进制
|
||||
> Number('012')
|
||||
12
|
||||
|
||||
```
|
||||
|
||||
另外,eval()会将参数`x`强制理解为语句行,这样一来,当按照“语句->表达式”的顺序解析时,“{ }”将被优先理解为语句中的大括号。于是,下面的代码就成了JavaScript初学者的经典噩梦,也就是“尝试将一个对象字面量的字符串作为代码文本执行”所导致的问题。
|
||||
|
||||
```
|
||||
# 试图返回一个对象
|
||||
> eval('{abc: 1}')
|
||||
1
|
||||
|
||||
```
|
||||
|
||||
在这种情况下,由于第一个字符被理解为块语句,那么“abc:”就将被解析成标签语句;接下来,"1"会成为一个“单值表达式语句”。所以,结果是返回了这个表达式的值,也就是1,而不是一个字面量声明的对象。
|
||||
|
||||
>
|
||||
NOTE:这一个示例就是原来用作第20讲的标题的一行代码。只不过,在实际写的时候发现能展开讲的内容太少,所以做了一下合并。:)
|
||||
|
||||
|
||||
## eval在哪儿执行
|
||||
|
||||
eval总是将代码执行在当前上下文的“当前位置”。这里的所谓的“当前上下文”并不是它字面意思中的“代码文本上下文”,而是指“(与执行环境相关的)执行上下文”。
|
||||
|
||||
我在之前的文章中给你提到过与JavaScript的执行系统相关的两个组件:环境和上下文。但我一直在尽力避免详细地讨论它们,甚至在一些场合中将它们混为一谈。
|
||||
|
||||
然而,在讨论eval()“执行的位置”的时候,这两个东西却必须厘清,因为严格地来讲,**环境**是JavaScript在语言系统中的静态组件,而**上下文**是它在执行系统中的动态组件。
|
||||
|
||||
### 环境
|
||||
|
||||
怎么说呢?
|
||||
|
||||
JavaScript中,环境可以细分为四种,并由两个类别的基础环境组件构成。这四种环境是:全局(Global)、函数(Function)、模块(Module)和Eval环境;两个基础组件的类别分别是:声明环境(Declarative Environment)和对象环境(Object Environment)。
|
||||
|
||||
你也许会问:不对啊?我们常说的词法环境到哪里去了呢?不要着急,我们马上就会讲到它的。这里先继续说清楚上面的六个东西。
|
||||
|
||||
首先是两个类别,它们是所有其他环境的基础,是两种抽象级别最低的、基础的环境组件。**声明环境**就是名字表,可以是引擎内核用任何方式来实现的一个“名字->数据”的对照表;**对象环境**是JavaScript的一个对象,用来“模拟/映射”成上述的对照表的一个结果,你也可以把它看成一个具体的实现。所以,
|
||||
|
||||
- 概念:所有的“环境”本质上只有一个功能,就是用来管理“名字->数据”的对照表;
|
||||
- 应用:“对象环境”只为全局环境的global对象,或`with (obj)...`语句中的`对象obj`创建,其他情况下创建的环境,都必然是“声明环境”。
|
||||
|
||||
所以,所谓四种环境,其实是上述的两种基础组件进一步应用的结果。其中,全局(Global)环境是一个复合环境,它由一对“对象环境 + 声明环境”组成;其他3种环境,都是一个单独的声明环境。
|
||||
|
||||
你需要关注到的一个事实是:所有的四种环境都与执行相关——看起来它们“像是”为每种可执行的东西都创建了一个环境,但是它们事实上都不是可以执行的东西,也不是执行系统(执行引擎)所理解的东西。更加准确地说:
|
||||
|
||||
上述四种环境,本质上只是为JavaScript中的每一个“可以执行的语法块”创建了一个名字表的影射而已。
|
||||
|
||||
### 执行上下文
|
||||
|
||||
JavaScript的执行系统由一个执行栈和一个执行队列构成,这在之前也讲过。关于它们的应用原理,你可以回顾一下[第6讲](https://time.geekbang.org/column/article/168980)(`x: break x`),以及[第10讲](https://time.geekbang.org/column/article/174314)(`x = yield x`)中的内容。
|
||||
|
||||
在执行队列中保存的是待执行的任务,称为Job。这是一个抽象概念,它指明在“创建”这个执行任务时的一些关联信息,以便正式“执行”时可以参考它;而“正式的执行”发生在将一个新的上下文被“推入(push)”执行栈的时候。
|
||||
|
||||
所以,上下文是一个任务“执行/不执行”的关键。如果一个任务只是任务,并没有执行,那么也就没有它的上下文;如果一个上下文从栈中撤出,那么就必须有地方能够保存这个上下文,否则可执行的信息就丢失了(这种情况并不常见);如果一个新上下文被“推入(push)”栈,那么旧的上下文就被挂起并压向栈底;如果当前活动上下文被“弹出(pop)”栈,那么处在栈底的旧上下文就被恢复了。
|
||||
|
||||
>
|
||||
NOTE:很少需要在用户代码(在它的执行过程中)撤出和保存上下文的过程,但这的确存在。比如生成器(GeneratorContext),或者异步调用(AsyncContext)。
|
||||
|
||||
|
||||
而每一个上下文只关心两个高度抽象的信息:其一是执行点(包括状态和位置),其二是执行时的参考,也就是前面一再说到的“名字的对照表”。
|
||||
|
||||
所以,重要的是:每一个执行上下文都需要关联到一个对照表。这个对照表,就称为“词法环境(Lexical Environment)”。显然,它可以是上述四种环境之任一;并且,更加重要的,也可是两种基础组件之任一!
|
||||
|
||||
如上是一般性质的执行引擎逻辑,对于大多数“通用的”执行环境来说,这是足够的。
|
||||
|
||||
但对于JavaScript来说这还不够,因为JavaScript的早期有一个“能够超越词法环境”的东西存在,就是“var变量”。所谓词法环境,就是一个能够表示标识符在源代码(词法)中的位置的环境,由于源代码分块,所以词法环境就可以用“链式访问”来映射“块之间的层级关系”。但是“var变量”突破了这个设计限制,例如,我们常常说到的变量提升,也就是在一个变量赋值前就能访问它;又例如所有在同一个全局或函数内部的`var x`其实都是同一个,而无论它隔了多少层的块级作用域。于是你可以写出这样一个示例来:
|
||||
|
||||
```
|
||||
var x = 1;
|
||||
if (true) {
|
||||
var x = 2;
|
||||
|
||||
with (new Object) {
|
||||
var x = 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个示例中,无论你把`var x`声明在if语句后面的块中,还是with语句后面的块中,“1、2、3”所在的“var变量”`x`,都突破了它们所在的词法作用域(或对应的词法环境),而指向全局的`x`。
|
||||
|
||||
于是,自ECMAScript 5开始约定,ECMAScript的执行上下文将有两个环境,一个称为词法环境,另一个就称为变量环境(Variable Environment);所有传统风格的“var声明和函数声明”将通过“变量环境”来管理。
|
||||
|
||||
这个管理只是“概念层面”的,实际用起来,并不是这么回事。
|
||||
|
||||
### 管理
|
||||
|
||||
为什么呢?
|
||||
|
||||
如果你仔细读了ECMAScript,你会发现,所谓的全局上下文(例如Global Context)中的两个环境其实都指向同一个!也就是:
|
||||
|
||||
```
|
||||
#(如下示例不可执行)
|
||||
> globalCtx.LexicalEnvironment === global
|
||||
true
|
||||
|
||||
> globalCtx.VariableEnvironment === global
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
这就是在实现中的取巧之处了。
|
||||
|
||||
对于JavaScript来说,由于全局的特性就是“var变量”和“词法变量”共用一个名字表,因此你声明了“var变量”,那么就不能声明“同名的let/const变量”。例如:
|
||||
|
||||
```
|
||||
> var x = 100
|
||||
> let x = 200
|
||||
SyntaxError: Identifier 'x' has already been declared
|
||||
|
||||
```
|
||||
|
||||
所以,事实上它们“的确就是”同一个环境。
|
||||
|
||||
而具体到“var变量”本身,在传统中,JavaScript中只有函数和全局能够“保存var声明的变量”;而在ECMAScript 6之后,模块全局也是可以保存“var声明的变量”的。因此,事实上也就只有它们的“变量环境(VariableEnvironment)”是有意义的,然而即使如此(也就是说即使从原理上来说它们都是“有用的”),它们仍然是指向同一个环境组件的。也就是说,之前的逻辑仍然是成立的:
|
||||
|
||||
```
|
||||
#(如下示例不可执行)
|
||||
> functionCtx.LexicalEnvironment === functionCtx.VariableEnvironment
|
||||
true
|
||||
|
||||
> moduleCtx.LexicalEnvironment === moduleCtx.VariableEnvironment
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
那么,非得要“分别地”声明这两个组件又有什么用呢?答案是:对于eval()来说,它的“词法环境”与“变量环境”存在着其他的可能性!
|
||||
|
||||
### 不用于执行的环境
|
||||
|
||||
环境在本质上是“作用域的映射”。作用域如果不需要被上下文管理,那么它(所对应的环境)也就不需要关联到上下文。
|
||||
|
||||
在早期的JavaScript中,作用域与执行环境是一对一的,所以也就常常混用,而到了ECMAScript 5之后,有一些作用域并没有对应用执行环境,所有就分开了。在ECMAScript 5之后,ECMAScript规范中就很少使用“作用域(Scope)”这个名词,转而使用“环境”这个概念来替代它。
|
||||
|
||||
哪些东西的作用域不需要关联到上下文呢?其实,一般的块级作用域都是这样的。例如一般的块级作用域:
|
||||
|
||||
```
|
||||
// 对象闭包
|
||||
with (x) ...
|
||||
|
||||
```
|
||||
|
||||
很显然的,这里的`with语句`为对象`x`创建了一个对象闭包,就是对象作用域,也是我们在上面讨论过的“对象环境”。然而,由于这个语句其实只需要执行在当前的上下文环境(函数/模块/全局)中,因此它不需要“被关联到”一个执行上下文,也不需要作为一个独立的可执行组件“推入(push)”到执行栈。所以,这时创建出来的环境,就是一个不用于执行的环境。
|
||||
|
||||
只有前面所说过的四种环境是用于执行的环境,而其他的所有环境(以及反过来对应的作用域)都是不用于执行的,它们与上下文无关。并且,既然与上下文没有关联,那么也就不存在“词法环境”和“变量环境”了。
|
||||
|
||||
从语法上,(在代码文本中)你可以找到除了上述四种环境之外的其他任何一种块级作用域,事实上它们每个作用域都有一个对应的环境:with语句的环境用“对象环境”创建出来,而其他的(例如for语句的迭代环境,又例如swith/try语句的块)是用“声明环境”创建出来的。
|
||||
|
||||
对于这些用于执行的环境中的其中三个,ECMAScript直接约定了它们(也就是Global/Module/Function)的创建过程。例如全局环境,就称为NewGlobalEnvironment()。因为它们都可以在代码解析(Parser)的阶段得到,并且在代码运行之前由引擎创建出来。
|
||||
|
||||
而唯有一个环境,是没有独立创建过程,并且在程序运行过程中动态创建的,这就是“Eval环境”。
|
||||
|
||||
所以Eval环境是主要用于应对“动态执行”的环境。
|
||||
|
||||
### eval()的环境
|
||||
|
||||
上面我们说到,所谓“Eval环境”是主要用于应对“动态执行”的,并且它的词法环境与变量环境“可能会**不一样**”。这二者其实是相关的,并且,这还与“严格模式”这一特殊机制存在紧密的关系。
|
||||
|
||||
当在`eval(x)`用一般的方式执行代码时,如果`x`字符串中存在着`var变量`声明,那么会发生什么事情呢?按照传统JavaScript的设计,这意味着在它所在的函数作用域,或者全局作用域会有一个新的变量被创建出来。这也就是JavaScript的“动态声明(函数和var变量)”和“动态作用域”的效果,例如:
|
||||
|
||||
```
|
||||
var x = 'outer';
|
||||
function foo() {
|
||||
console.log(x); // 'outer'
|
||||
eval('var x = 100;');
|
||||
console.log(x); // '100'
|
||||
}
|
||||
foo();
|
||||
|
||||
```
|
||||
|
||||
如果按照传统的设计与实现,这就会要求eval()在执行时能够“引用”它所在的函数或全局的“变量作用域”。并且进一步地,这也就要求eval有能力“总是动态地”查找这个作用域,并且JavaScript执行引擎还需要理解“用户代码中的eval”这一特殊概念。正是为了避免这些行为,所以ECMAScript约定,在执行上下文中加上“变量环境(Variable Environment)”这个东西,以便在执行过程中,仅仅只需要查找“当前上下文”就可以找到这个能用来登记变量的名字表。
|
||||
|
||||
也就是说,“变量环境(VariableEnvironment)”存在的意义,就是动态地登记“var变量”。
|
||||
|
||||
因此,它也仅仅只用在“Eval环境”的创建过程中。“Eval环境”是唯一一个将“变量环境”指向了与它自有的“词法环境”不同位置的环境。
|
||||
|
||||
>
|
||||
NOTE: 其实函数中也存在一个类似的例外。但这个处理过程是在函数的环境创建之后,在函数声明实例化阶段来完成的,因此与这里的处理略有区别。由于是函数声明的实例化(FunctionDeclaration Instantiation)阶段来处理,因此这也意味着每次实例化(亦即是每次调用函数并导致闭包创建)时都会重复一次这个过程:在执行上下文的内部重新初始化一次变量环境与词法环境,并根据严格模式的状态来确定词法环境与变量环境是否是同一个。
|
||||
|
||||
|
||||
这里既然提到了“Eval自有的词法环境”,那么也稍微解释一下它的作用。
|
||||
|
||||
对于Eval环境来说,它也需要一个自己的、独立的作用域,用来确保在“eval(x)”的代码x中存在的那些const/let声明有自己的名字表,而不影响当前环境。这与使用一对大括号来表示的一个块级作用域是完全一致的,并且也使用相同的基础组件(即声明环境、Declarative Environment)来创建得到。这就是在eval()中使用const/let不影响它所在函数或其他块级作用域的原因,例如:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var x = 100;
|
||||
eval('let x = 200; console.log(x);'); // 200
|
||||
console.log(x); // 100
|
||||
}
|
||||
foo();
|
||||
|
||||
```
|
||||
|
||||
而同样的示例,由于“变量环境”指向它在“当前上下文(也就是foo函数的函数执行上下文)”的变量环境,也就是:
|
||||
|
||||
```
|
||||
#(如下示例不可执行)
|
||||
> evalCtx.VariableEnvironment === fooCtx.VariableEnvironment
|
||||
true
|
||||
|
||||
> fooCtx.VariableEnvironment === fooCtx.LexicalEnvironment
|
||||
true
|
||||
|
||||
> evalCtx.VariableEnvironment = evalCtx.LexicalEnvironment
|
||||
false
|
||||
|
||||
```
|
||||
|
||||
所以,当eval中执行代码“var x = …”时,就可以通过`evalCtx.VariableEnvironment`来访问到`fooCtx.VariableEnvironment`了。例如:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
var x = 100;
|
||||
eval('var x = 200; console.log(x);'); // 200, x指向foo()中的变量x
|
||||
console.log(x); // 200
|
||||
}
|
||||
foo();
|
||||
|
||||
```
|
||||
|
||||
也许你正在思考,为什么eval()在严格模式中就不能覆盖/重复声明函数、全局等环境中的同名“var变量”呢?
|
||||
|
||||
答案很简单,只是一个小小的技术技巧:在“严格模式的Eval环境”对应的上下文中,变量环境与词法环境,都指向它们自有的那个词法环境。于是这样一来,在严格模式中使用`eval("var x...")`和`eval("let x...")`的名字都创建在同一个环境中,它们也就自然不能重名了;并且由于没有引用它所在的(全局或函数的)环境,所以也就不能改写这些环境中的名字了。
|
||||
|
||||
那么一个eval()函数**所需要的**“Eval环境”究竟是严格模式,还是非严格模式呢?
|
||||
|
||||
你还记得“严格模式”的使用原则么?eval(x)的严格模式要么继承自当前的环境,要么就是代码`x`的第一个指令是字符串“use strict”。对于后一种情况,由于eval()是动态parser代码`x`的,所以它只需要检查一下parser之后的AST(抽象语法树)的第一个节点,是不是字符串“use strict”就可以了。
|
||||
|
||||
这也是为什么“切换严格模式”的指示指令被设计成这个奇怪模样的原因了。
|
||||
|
||||
>
|
||||
NOTE:按照ECMAScript 6之后的约定,模块默认工作在严格模式下(并且不能切换回非严格模式),所以它其中的eval()也就必然处于严格模式。这种情况下(即严格模式下),eval()的“变量环境”与它的词法环境是同一个,并且是自有的。因此模块环境中的变量环境(moduleCtx.VariableEnvironment)将永远不会被引用到,并且用户代码也无法在其中创建新的“var变量”。
|
||||
|
||||
|
||||
## 最后一种情况
|
||||
|
||||
标题中的eval()的代码文本,说的却是最后一种情况。在这种情况下,代码文本将指向一个“未创建即赋值”的变量`x`,我们知道,按照ECMAScript的约定,在非严格模式中,向这样的变量赋值就意味着在全局环境中创建新的变量`x`;而在严格模式中,这将不被允许,并因此而抛出异常。
|
||||
|
||||
由于Eval环境通过“词法环境与变量环境分离”来隔离了“严格模式”对它的影响,因此上述约定在两种模式下实现起来其实都比较简单。
|
||||
|
||||
对于非严格模式来说,代码可以通过词法环境的链表逆向查找,直到global,并且因为无法找到`x`而产生一个“未发现的引用”。我们之前讲过,在非严格模式中,对“未发现的引用”的置值将实现为向全局对象“global”添加一个属性,于是间接地、动态地就实现了添加变量`x`。对于严格模式呢,向“未发现的引用”的置值触发一个异常就可以了。
|
||||
|
||||
这些逻辑都非常简单,而且易于理解。并且,最关键和最重要的是,这些机制与我今天所讲的内容——也就是变量环境和词法环境——完全无关。
|
||||
|
||||
然而,接下来你需要动态尝试一下:
|
||||
|
||||
- 如果你按标题中的代码去尝试写eval(),那么无论如何——无论你处于严格模式还是非严格模式,你都将创建出一个变量x来。
|
||||
|
||||
标题中的代码突破了“严格模式”的全部限制!这就是我下一讲要为你讲述的内容了。
|
||||
|
||||
今天没有设置知识回顾,也没有作业。但我建议你尝试一下标题中的代码,也可以回顾一下本节课中提到的诸多概念与名词。
|
||||
|
||||
我相信,它与你平常使用的和理解的,有许多不一致的地方,甚至有矛盾之处。但是,相信我,这就是这个专栏最独特的地方:它讲述JavaScript的核心原理,而不是重复那些你可能已经知道的知识。
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -0,0 +1,398 @@
|
||||
<audio id="audio" title="21 | (0, eval)("x = 100") :一行让严格模式形同虚设的破坏性设计(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/af/543b545017e1234aeecc0291d7d910af.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。欢迎回到我的专栏。书接上回,这一讲我们仍然讲动态执行。
|
||||
|
||||
之前我说到过,setTimeout和setInterval的第一个参数可以使用字符串,那么如果这个参数使用字符串的话,代码将会在哪里执行呢?毕竟当定时器被触发的时候,程序的执行流程“很可能”已经离开了当前的上下文环境,而切换到未知的地方去了。
|
||||
|
||||
所以,的确如你所猜测的那样,如果采用这种方式来执行代码,那么代码片断将在全局环境中执行。并且,这也是后来这一功能被部分限制了的原因,例如你在某些版本的Firefox中这样做,那么你可能会得到如下的错误提示:
|
||||
|
||||
```
|
||||
> setTimeout('alert("HI")', 1000)
|
||||
Content Security Policy: The page’s settings blocked the loading of a resource at eval (“script-src”).
|
||||
|
||||
```
|
||||
|
||||
在全局环境中执行代码所带来的问题远远不止于此,接下来,我们就从这个问题开始谈起。
|
||||
|
||||
## 在全局环境中的eval
|
||||
|
||||
早期的JavaScript是应用于浏览器环境中的,因此,当网页中使用`<SCRIPT>`标签加载.js文件时候,代码就会在浏览器的全局环境中执行。但这个过程是同步的,将BLOCK掉整个网页的装载进度,因此有了`defer`这个属性来指示代码异步加载,将这个加载过程延迟到网页初始化结束之后。不过即使如此,JavaScript代码仍然是执行在全局环境中的。
|
||||
|
||||
在那个时代,`<SCRIPT>`标签还支持`for`和`event`属性,用于指定将JavaScript代码绑定给指定的HTML元素或事件响应。当采用这种方式的时候,代码还是在全局环境中执行,只不过可能初始化为一个函数(的回调),并且`this`指向元素或事件。很不幸,有许多浏览器并不实现这些特性,尤其是`for`属性,它也许在IE中还存在,这一特性与ActiveXObject的集成有关。
|
||||
|
||||
关于脚本的动态执行,你能想象的绝大多数能在浏览器中玩的花样大概都在这里了。当然,你还可以在DOM中动态地插入一个`SCRIPT`标签来装载脚本,这在Ajax还没有那么流行的时候是唯二之选。另一种选择,是在Document初始化结束之前使用`document.write()`。
|
||||
|
||||
总而言之,为了动态执行一点什么,古典时代的WEB程序员是绞尽脑汁。
|
||||
|
||||
那么为什么不用`eval()`呢?
|
||||
|
||||
按照JavaScript脚本的执行机制,所有的.js文件加载之后,它的全局代码只会执行一次。无论是在浏览器还是在Node.js环境中,以及它们的模块加载环境中,都是如此。这意味着放在这些全局代码中的`eval()`事实上也就只在初始化阶段执行一次而已。而`eval()`又有一个特别的性质,那就是它“总是在”当前上下文中执行代码。因此,所有其他的、放在函数中的`eval()`代码都只会影响函数内的、局部的上下文,而无法影响全局。
|
||||
|
||||
也就是说,除了初始化,`eval()`无法在全局执行。
|
||||
|
||||
不同的浏览器都有各自的内置机制来解决这个问题。IE会允许用户代码调用`window.execScript()`,实现那些希望`eval()`执行在全局的需求。而Firefox采用了另外的一条道路,称为`window.eval()`。这个从字面上就很好理解,就是“让`eval()`代码执行在window环境中”。而`window`就是浏览器中的全局对象global,也就是说,window.eval与global.eval是等义的。
|
||||
|
||||
这带来了另外一个著名的、在Firefox早期实现的JavaScript特性,称为“对象的eval”。
|
||||
|
||||
如果你试图执行`obj.eval(x)`,那么就是将代码文本`x`执行在`obj`的对象闭包中(类似于`with (obj) eval(x)`)。因为全局环境就是使用global来创建的“对象环境(对象闭包)”,所以这是在实现“全局eval()”的时候“顺手”就实现了的特性。
|
||||
|
||||
但这意味着用户代码可以将`eval`函数作为一个方法赋给任何一个JavaScript对象,以及任何一个属性名字。例如:
|
||||
|
||||
```
|
||||
var obj = { do: eval };
|
||||
obj.do('alert("HI")');
|
||||
|
||||
```
|
||||
|
||||
## 名字之争
|
||||
|
||||
现在,“名字”成了一个问题,在任何地方、任何位置,任何对象以及任何函数的上下文中都能“以不同的名字”来eval()一段代码文本。
|
||||
|
||||
这太不友好了!这意味着我们永远无法有效地判断、检测和优化用户代码。一方面,这对于程序员来说是灾难,另一方面,对引擎的实现者来说也非常绝望。
|
||||
|
||||
于是,从ECMAScript 6开始,ECMAScript规定了“标准而规范地使用eval()”的方法:你仅仅只能直接使用一个字面文本为“eval”字符串的函数名字,并且作为普通函数调用的形式来调用`eval()`,这样才算是“**直接调用的eval()**”。
|
||||
|
||||
这个约定是非常非常罕见的。JavaScript历史上几乎从未有过在规范中如此强调一个名字“在字面文本上的规范性”。在ECMAScript 5之后,一共也只出现了两个,这里的"eval"是一个,而另一个是严格模式(这个稍晚一点我们也会详细讲到)。
|
||||
|
||||
根据ECMAScript的约定,下面的这些都不是“直接调用的eval()”:
|
||||
|
||||
```
|
||||
// 对象属性
|
||||
obj = { eval }
|
||||
obj.eval(x)
|
||||
|
||||
// 更名的属性名或变量名(包括全局的或函数内局部的)
|
||||
e = eval
|
||||
var e = eval
|
||||
e(x)
|
||||
|
||||
// super引用中的父类属性(包括原型方法和静态方法)
|
||||
class MyClass { eval() { } }
|
||||
MyClass.eval = eval;
|
||||
class MyClassEx extends MyClass {
|
||||
foo() { super.eval(x) }
|
||||
static foo() { super.eval(x) }
|
||||
}
|
||||
|
||||
// 作为函数(或其他大多数表达式)的返回
|
||||
function foo() { return eval }
|
||||
foo()(x)
|
||||
// (或)
|
||||
(_=>eval)()(x)
|
||||
|
||||
```
|
||||
|
||||
总之,你所有能想到的一切——换个名字,或者作为对象属性的方式来调用eval,都不再作为“直接调用的eval()”来处理了。
|
||||
|
||||
那么,你可能会想要知道,怎样才算是“直接调用的eval()”,以及它有什么效果呢?
|
||||
|
||||
很简单的,在全局、模块、函数的任意位置,以及一个运行中的`eval(...)`的代码文本的任意位置上,你使用的
|
||||
|
||||
>
|
||||
`eval(x)`
|
||||
|
||||
|
||||
这样的代码,都被称为“直接调用”。直接调用eval()意味着:
|
||||
|
||||
- 在代码所在位置上,临时地创建一个“Eval环境”,并在该环境中执行代码`x`。
|
||||
|
||||
而反过来,其他任何将`eval()`调用起来,或者执行到`eval()`函数的方式,都称为“间接调用”。
|
||||
|
||||
而这两讲的标题中的写法,就是一个经典的“间接调用eval”的写法:
|
||||
|
||||
```
|
||||
(0, eval)(x)
|
||||
|
||||
```
|
||||
|
||||
晚一点,我们会再来详细讲述这个“间接调用”,接下来我们先说说与它相关的一点基础知识,也就是“严格模式”。
|
||||
|
||||
>
|
||||
NOTE:之所以称为“经典的”写法,是因为在ECMAScript规范的测试项目test262中,所有间接调用相关的示例都是采用这种写法的。
|
||||
|
||||
|
||||
## 严格模式是执行限制而不是环境属性
|
||||
|
||||
ECMAScript 5中推出的严格模式是一项重大的革新之举,它静默无声地拉开了ECMAScript 6~ECMAScript 10这轰轰烈烈的时代序幕。
|
||||
|
||||
之所以说它是“静默无声的”,是因为这项特性刚出来的时候,大多数人并不知道它有什么用,有什么益处,以及为什么要设计成这个样子。所以,它几乎算是一个被“强迫使用”的特性,对你的团队来说是这样,对整个的JavaScript生态来说也是如此。
|
||||
|
||||
但是“严格模式”确实是一个好东西,没有它,后来的众多新特征就无法形成定论,它奠定了一个稳定的、有效的、多方一致的语言特性基础,几乎被所有的引擎开发厂商欢迎、接受和实现。
|
||||
|
||||
所以,我们如今大多数新写的JavaScript代码其实都是在严格模式环境中运行的。
|
||||
|
||||
对吗?
|
||||
|
||||
不太对。上面这个结论对于大多数开发者来说是适用的,并能理解和接受。但是,要是你在ECMAScript规范层面,或者在JavaScript引擎层面来看这句话,你会发现:咦?!“严格模式环境”是什么鬼?我们从来没见过这个东西!
|
||||
|
||||
是的,所谓“严格模式”,其实从来都不是一种环境模式,或者说,没有一个环境是具有“严格模式”这样的属性的。所有的执行环境——所有在执行引擎层面使用的“执行上下文(ExecuteContext)”,以及它们所引用的“环境(Environment)”,都没有“严格模式”这样的模式,也没有这样的性质。
|
||||
|
||||
我们所有的代码都工作在非严格模式中,而“严格模式”不过是代码执行过程中的一个限制。更确切地说,即使你用如下命令行:
|
||||
|
||||
```
|
||||
> node --use-strict
|
||||
|
||||
```
|
||||
|
||||
来启动Node.js,也仍然是运行在一个JavaScript的“非严格模式”环境中的!是的,是的,我知道,你可以立即写出来一行代码来反驳上述观点:
|
||||
|
||||
```
|
||||
# (在上例启动的Node.js环境中测试)
|
||||
> arguments = 1
|
||||
SyntaxError: Unexpected eval or arguments in strict mode
|
||||
|
||||
```
|
||||
|
||||
但是请相信我:上面的示例只是一个执行限制,你绝对是运行在一个“非严格模式”环境中的!
|
||||
|
||||
因为所有的四种执行环境(包括Eval环境),在它们创建和初始化时都并没有“严格模式”这样的性质。并且,在全局环境初始化之前,在宿主环境中初始化引擎时,引擎也根本不知道所谓“严格模式”的存在。严格模式这个特性,是在环境创建完之后,在执行代码之前,从源代码文本中获取的性质,例如:
|
||||
|
||||
```
|
||||
// (JavaScript引擎的初始化过程)
|
||||
|
||||
// 初始化全局,in InitializeHostDefinedRealm()
|
||||
CALL SetRealmGlobalObject(realm, global, thisValue)
|
||||
-> CALL NewGlobalEnvironment(globalObj, thisValue)
|
||||
|
||||
// 执行全局任务(含解析源代码文本等),in ScriptEvaluationJob()
|
||||
s = ParseScript(sourceText, realm, hostDefined)
|
||||
CALL ScriptEvaluation(s)
|
||||
|
||||
// 执行全局代码,in ScriptEvaluation(s)
|
||||
result = GlobalDeclarationInstantiation(scriptBody, globalEnv)
|
||||
if (result.[[Type]] === normal) {
|
||||
result = ENGING_EVALUATING(scriptBody)
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
在这整个过程中,ParseScript()解析源代码文本时,如果发现“严格模式的指示字符串”,那么就会将解析结果(例如抽象语法树ast)的属性ast.IsStrict置为true。但这个标记仅仅只作用于抽象语法树层面,而环境中并没有相关的标识——在模块中,这个过程是类似的,只是缺省就置为true而已。
|
||||
|
||||
而另一方面,例如函数,它的“严格模式的指示字符串”也是在**语法解析阶段**得到的,并作为函数对象的一个内部标记。但是函数环境创建时却并不使用它,因此也不能在环境中检测到它。
|
||||
|
||||
我列举所有这些事实,是试图说明:“严格模式”是它们相关的可执行对象的一个属性,但并不是与之对应的执行环境的属性。因此,当“执行引擎”通过“词法环境或变量环境”来查找时,是看不到这些属性的,也就是说,执行引擎所知道的环境并没有“严格/不严格”的区别。
|
||||
|
||||
那么严格模式是怎么被实现的呢?
|
||||
|
||||
答案是,绝大多数严格模式特性都是在“相关的可执行对象”创建或初始化阶段就被处理掉的。例如,严格模式约定“没有arguments.caller和arguments.callee”,那么,就在初始化这个对象的时候不创建这两个属性就好了。
|
||||
|
||||
另外一部分特性是在**语法分析阶段**识别和处理的。例如“禁止掉8进制字面量”,由于“严格模式的指示字符串(‘use strict’)”总是在第一行代码,所以在其他代码parser之前,解析器就已经根据指示字符串配置好了解析逻辑,对“8进制字面量”可以直接抛出异常了。
|
||||
|
||||
从等等类似于此的情况,你能看到“严格模式”的所有限制特性,其实都并不需要执行引擎参与。进一步地来说,引擎设计者也并不愿意掺合这件事,因为这种模式识别将大幅度地降低引擎的执行效能,以及使引擎优化的逻辑复杂化。
|
||||
|
||||
但是,现在来到了“eval()”调用,怎么处理它的严格模式问题呢?
|
||||
|
||||
## 直接调用VS间接调用
|
||||
|
||||
绝大多数严格模式的特性都与语法分析结束后在指定对象上置的“IsStrict”这样的标记有关,它们可以指导引擎如何创建、装配和调用代码。但是到了执行器内部,由于不可能从执行上下文开始反向查找环境,并进一步检测严格模式标识,所以`eval()`在原则上也不能知道“当前的”严格模式状态。
|
||||
|
||||
这有例外,因为“直接调用eval()”是比较容易处理的,因为在使用`eval()`的时候,调用者——注意不是执行引擎——可以在当前自己的状态中得到严格模式的值,并将该值传入`eval()`的处理过程。这在ECMAScript中是如下的一段规范:
|
||||
|
||||
```
|
||||
...
|
||||
- If strictCaller is true, let strictEval be true.
|
||||
- Else, let strictEval be IsStrict of script.
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
也就是说,如果caller的严格模式是true,那么`eval(x)`就继承这个模式,否则就从`x`(也就是script)的语法解析结果中检查IsStrict标记。
|
||||
|
||||
那么间接调用呢?
|
||||
|
||||
所谓间接调用,是JavaScript为了避免代码侵入,而对所有非词法方式的(即直接书写在代码文本中的)`eval()`调用所做的定义。并且ECMAScript约定:
|
||||
|
||||
- 约定1:所有的“间接调用”的代码总是执行在“全局环境”中。
|
||||
|
||||
这样一来,你就没有办法向函数内传入一个对象,并用该对象来“在函数内部”执行一堆侵入代码了。
|
||||
|
||||
但是回到前面的问题:如果是间接调用,那么这里的`strictCaller`是谁呢?又处于哪种“严格模式”状态中呢?
|
||||
|
||||
答案是:不知道。因为当这样来引用全局的时候,上下文/环境中并没有全局的严格模式性质;反向查找源代码文本或解析过的ast树呢,既不经济也不可靠。所以,就有另外一个约定:
|
||||
|
||||
- 约定2:所有的“间接调用”的代码将默认执行在“非严格模式”中。
|
||||
|
||||
也就是说,间接调用将突破引擎对严格模式的任何设置,你总是拥有一个“全局的非严格模式”并在其中执行代码。例如:
|
||||
|
||||
```
|
||||
# (控制台)
|
||||
> node --use-strict
|
||||
|
||||
# (Node.js环境, 严格模式的全局环境)
|
||||
> arguments = 1
|
||||
SyntaxError: Unexpected eval or arguments in strict mode
|
||||
> 012
|
||||
SyntaxError: Octal literals are not allowed in strict mode.
|
||||
|
||||
# 间接调用(例1)
|
||||
> (0, eval)('arguments = 1') // accept!
|
||||
> arguments
|
||||
1
|
||||
|
||||
# 间接调用(例2)
|
||||
> (0, eval)('012') // accept!
|
||||
10
|
||||
|
||||
# 间接调用(例3,本讲的标题代码,将创建变量x)
|
||||
> (0, eval)('x = 100') // accept!
|
||||
> x
|
||||
100
|
||||
|
||||
```
|
||||
|
||||
## 为什么标题中的代码是严格模式
|
||||
|
||||
最后一个疑问,就是为什么“标题中的这种写法”会是一种间接调用。并且,更有对比性地来看,如果是下面这种写法,为什么就“不再是”间接调用了呢?例如
|
||||
|
||||
```
|
||||
# 直接调用
|
||||
> (eval)('x = 100')
|
||||
ReferenceError: x is not defined
|
||||
at eval (eval at ...)
|
||||
|
||||
# 间接调用
|
||||
> (0, eval)('x = 100')
|
||||
100
|
||||
|
||||
```
|
||||
|
||||
在JavaScript中,表达式的返回结果(Result)可能是值,也可能是“引用(规范类型)”。在“引用”的情况中,有两个例子是比较常见、却又常常被忽略的,包括:
|
||||
|
||||
```
|
||||
# 属性存取返回的是引用
|
||||
> obj.x
|
||||
|
||||
# 变量的标识符(作为单值表达式)是引用
|
||||
> x
|
||||
|
||||
```
|
||||
|
||||
我们之前的课程中说过,所有这种“引用(规范类型)”类型的结果(Result),在作为左手端的时候,它是引用;而作为右手端的时候,它是值。所以,才会有“x = x”这一个表达式的完整语义:
|
||||
|
||||
- 将右手端x的值,赋给左手端的x的引用。
|
||||
|
||||
好了,然而还存在一个运算符,它可以“原样返回”之前运算的结果(Result),这就是“分组运算符()”。因为这个运算符有这样的特性,所以当它作用于属性存取和一般标识符时,分组运算返回的也仍然是后者的“运算结果(Result)”。例如:
|
||||
|
||||
```
|
||||
# “结果(Result)”是`100`的值
|
||||
> (100)
|
||||
|
||||
# “结果(Result)”是`{}`对象字面量(值)
|
||||
> ({})
|
||||
|
||||
# “结果(Result)”是`x`的引用
|
||||
> (x)
|
||||
|
||||
# “结果(Result)”是`obj.x`的引用
|
||||
> (obj.x)
|
||||
|
||||
```
|
||||
|
||||
所以,从“引用”的角度上来看,`(eval)`和`eval`的效果也就完全一致,它们都是`global.eval`在“当前上下文环境”中的一个引用。但是我们接下来看,我们在这一讲的标题中写的这个分组表达式是这样的:
|
||||
|
||||
```
|
||||
(0, eval)
|
||||
|
||||
```
|
||||
|
||||
这意味着在分组表达式内部还有一个运算,称为“连续运算(逗号运算符)”。连续运算的效果是“计算每一个表达式,并返回最后一个表达式的值(Value)”。注意,这里不是“结果(Result)”。所以它相当于执行了:
|
||||
|
||||
```
|
||||
(GetValue(0), GetValue(eval))
|
||||
|
||||
```
|
||||
|
||||
因此最后一个运算将使结果从“Result->Value”,于是“引用(的信息)”丢失了。在它外层(也就是其后的)分组运算得到的、并继续返回的结果,就是“GetValue(eval)”了。这样一来,在用户代码中的`(eval)(x)`还是直接调用“eval的引用”,而`(0, eval)(x)`就已经变成间接调用“eval的值”了。
|
||||
|
||||
讲到这里,你可能已经意识到:关键在于`eval`是一个引用,还是一个值?是的,的确如此!不过在ECMAScript规范中,一个“eval的直接调用”除了必须是一个“引用”之外,还有一个附加条件:它还必须是一个环境引用!
|
||||
|
||||
也就是说,属性引用的`eval`仍然是算着间接调用的。例如:
|
||||
|
||||
```
|
||||
# (控制台,直接进入全局的严格模式)
|
||||
> node --use-strict
|
||||
|
||||
# 测试用的代码(in Node.js)
|
||||
> var x = 'arguments = 1'; // try source-text
|
||||
|
||||
# 作为对象属性
|
||||
> var obj = {eval};
|
||||
|
||||
# 间接调用:这里的确是一个引用,并且名字是字符串文本"eval",但它是属性引用
|
||||
> (obj.eval)(x)
|
||||
1
|
||||
|
||||
# 直接调用:eval是当前环境中的一个名字引用(标识符)
|
||||
> eval(x)
|
||||
SyntaxError: Unexpected eval or arguments in strict mode
|
||||
|
||||
# 直接调用:同上(分组运算符保留了引用的性质)
|
||||
> (eval)(x)
|
||||
SyntaxError: Unexpected eval or arguments in strict mode
|
||||
|
||||
```
|
||||
|
||||
所以,无论如何,只要这个函数的名字是“eval”,并且是“global.eval这个函数在当前环境中的引用”,那么它就可以得到豁免,成为传统意义上的“直接调用”。例如:
|
||||
|
||||
```
|
||||
// (一些豁免的案例,如下是直接调用)
|
||||
|
||||
// with中的对象属性(对象环境)
|
||||
with ({ eval }) eval(x)
|
||||
|
||||
// 直接名字访问(作为缺省参数引用)
|
||||
function foo(x, eval=eval) {
|
||||
return eval(x)
|
||||
}
|
||||
|
||||
// 不更改名字的变量名(位于函数环境内部的词法/变量环境中)
|
||||
function foo(x) {
|
||||
var eval = global.eval; // 引用自全局对象
|
||||
return eval(x)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## eval怎么返回结果
|
||||
|
||||
那么最后一个问题,是“eval怎么返回结果呢”?
|
||||
|
||||
这个问题的答案反倒非常简单。由于`eval(x)`是将代码文本`x`作为语句执行,所以它将返回语句执行的结果。所有语句执行都只返回值,而不返回引用。所以, 即使代码`x`的运算结果(Result)是一个“引用(规范类型)”,那么`eval()`也只返回它的值,即“GetValue(Result)”。例如:
|
||||
|
||||
```
|
||||
# 在代码文本中直接创建了一个`eval`函数的“引用(规范类型)”
|
||||
> obj = { foo() { return this === obj } }
|
||||
|
||||
# this.foo调用中未丢失`this`这个引用
|
||||
> obj.foo()
|
||||
true
|
||||
|
||||
# 同上,分组表达式传回引用,所以`this`未丢失
|
||||
> (obj.foo)()
|
||||
true
|
||||
|
||||
# eval将返回值,所以`this`引用丢失了
|
||||
> eval('obj.foo')()
|
||||
false
|
||||
|
||||
```
|
||||
|
||||
## 结语
|
||||
|
||||
今天这一讲结束了对标题中代码的全部分析。由于标题中的代码是一个“间接调用的eval”,因此它总是运行在一个非严格模式的全局中,于是变量`x`也就总是可以被创建或重写。
|
||||
|
||||
“间接调用(IndriectCall)”是JavaScript非常非常少见的一种函数调用性质,它与“SuperCall”可以合并起来,视为JavaScript中执行系统中的“两大顶级疑难”。对间接调用的详细分析,涉及执行引擎的工作原理、环境和环境组件的使用、严格模式、引用(规范类型)的特殊性,以及最为特殊的“eval是作为特殊名字来识别的”等等多个方面的基础特性。
|
||||
|
||||
间接调用对“严格模式”并非是一种传统意义上的“破坏”,只是它的工作机制正正好地绕过了严格模式。因为严格模式并不是环境的性质,而是代码文本层面的执行限制,所以当eval的间接调用需要使用全局时,无法“得到并进入”这种模式而已。
|
||||
|
||||
最后,间接调用其实是对传统的window.execScript或window.eval的一个保留。它有着在兼容性方面的实用意义,但对系统的性能、安全性和可靠性都存在威胁。无论如何,你应该限制它在代码中的使用。不过,它的的确确是ECMAScript规范中严格声明和定义过的特性,并且可称得上是“黑科技(Hack skill)”了。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天有一个作业留给你思考,问题很简单:
|
||||
|
||||
- 请你尝试再找出一例豁免案例,也就是直接调用eval()的写法。
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
|
||||
今天的课程就到这里。下一讲,我们将讨论“动态函数”,这既是“动态语言”部分的最后一小节,也将是专栏的最后一讲。
|
||||
@@ -0,0 +1,270 @@
|
||||
<audio id="audio" title="22 | new Function('x = 100')();:函数的类化是对动态与静态系统的再次统一" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6b/d6/6b00f74dae542038f2142da3815e20d6.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,欢迎回到我的专栏。
|
||||
|
||||
今天是专栏最后一讲,我接下来要跟你聊的,仍然是JavaScript的动态语言特性,主要是动态函数的实现原理。
|
||||
|
||||
标题中的代码比较简单,是常用、常见的。这里稍微需要强调一下的是“最后一对括号的使用”,由于运算符优先级的设计,它是在new运算之后才被调用的。也就是说,标题中的代码等义于:
|
||||
|
||||
```
|
||||
// (等义于)
|
||||
(new Function('x = 100'))()
|
||||
|
||||
// (或)
|
||||
f = new Function('x = 100')
|
||||
f()
|
||||
|
||||
```
|
||||
|
||||
此外,这里的`new`运算符也可以去掉。也就是说:
|
||||
|
||||
```
|
||||
new Function(x)
|
||||
|
||||
// vs.
|
||||
Function(x)
|
||||
|
||||
```
|
||||
|
||||
这两种写法没有区别,都是动态地创建一个函数。
|
||||
|
||||
## 函数的动态创建
|
||||
|
||||
如果在代码中声明一个函数,那么这个函数必然是具名的。具名的、静态的函数声明有两个特性:
|
||||
|
||||
1. 是它在所有代码运行之前被创建;
|
||||
1. 它作为语句的执行结果将是“空(Empty)”。
|
||||
|
||||
这是早期JavaScript中的一个硬性的约定,但是到了ECMAScript 6开始支持模块的时候,这个设计就成了问题。因为模块是静态装配的,这意味着它导出的内容“应该是”一个声明的结果或者一个声明的名字,因为只有**声明**才是静态装配阶段的特性。但是,所有声明语句的完成结果都是Empty,是无效的,不能用于导出。
|
||||
|
||||
>
|
||||
NOTE:关于6种声明,请参见《[第02讲](https://time.geekbang.org/column/article/165198)》。
|
||||
|
||||
|
||||
而声明的名字呢?不错,这对具名函数来说没问题。但是匿名函数呢?就成了问题了。
|
||||
|
||||
因此,在支持匿名函数的“缺省导出(export default …)”时,ECMAScript就引入了一个称为“函数定义(Function Definitions)”的概念。这种情况下,函数表达式是匿名的,但它的结果会绑定给一个名字,并且最终会导出那个名字。这样一来,函数表达式也就有了“类似声明的性质”,但它又不是静态声明(Declarations),所以概念上叫做定义(Definitions)。
|
||||
|
||||
>
|
||||
NOTE:关于匿名函数对缺省导出的影响,参见《[第04讲](https://time.geekbang.org/column/article/166491)》。
|
||||
|
||||
|
||||
在静态声明的函数、类,以及这里说到的函数定义之外,用户代码还可以创建自己的函数。这同样有好几种方式,其中之一,是使用`eval()`,例如:
|
||||
|
||||
```
|
||||
# 在非严格模式下,这将在当前上下文中“声明”一个名为foo的函数
|
||||
> eval('function foo() {}')
|
||||
|
||||
```
|
||||
|
||||
还有一种常见的方式,就是使用动态创建。
|
||||
|
||||
### 几种动态函数的构造器
|
||||
|
||||
在JavaScript中,“动态创建”一个东西,意味着这个东西是一个对象,它创建自类/构造器。其中`Function()`是一切函数缺省的构造器(或类)。尽管内建函数并不创建自它,但所有的内建函数也通过简单的映射将它们的原型指向Function。除非经过特殊的处理,所有JavaScript中的函数原型均最终指向`Function()`,它是所有函数的祖先类。
|
||||
|
||||
这种处理/设计使得JavaScript中的函数有了“完整的”面向对象特性,函数的“类化”实现了JavaScript在函数式语言和面向对象语言在概念上的大一统。于是,一个内核级别的概念完整性出现了,也就是所谓:对象创建自函数;函数是对象。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/38/7a1dfb4942dd0484dd03aba2eb204c38.png" alt="">
|
||||
|
||||
>
|
||||
NOTE:关于概念完整性以及它在“体系性”中的价值,参见《[加餐3:让JavaScript运行起来](https://time.geekbang.org/column/article/175261)》。
|
||||
|
||||
|
||||
在ECMAScript 6之后,有赖于类继承体系的提出,JavaScript中的函数也获得了“子类化”的能力,于是用户代码也可以派生函数的子类了。例如:
|
||||
|
||||
```
|
||||
class MyFunction extends Function {
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是用户代码无法重载“函数的执行”能力。很明显,这是执行引擎自身的能力,除非你可以重写引擎,否则重载执行能力也就无从谈起。
|
||||
|
||||
>
|
||||
NOTE:关于类、派生,以及它们在对原生构造器进行派生时的贡献,请参见《[第15讲](https://time.geekbang.org/column/article/179238)》。
|
||||
|
||||
|
||||
除了这种用户自定义的子类化的函数之外,JavaScript中一共只有四种可以动态创建的函数,包括:**一般函数**(Function)、**生成器函数**(GeneratorFunction)、**异步生成器函数**(AsyncGeneratorFunction)和**异步函数**(AsyncFunction)。又或者说,用户代码可以从这四种函数之任一开始来派生它们的子类,在保留它们的执行能力的同时,扩展接口或功能。
|
||||
|
||||
但是,这四种函数在JavaScript中有且只有`Function()`是显式声明的,其他三种都没有直接声明它们的构造器,这需要你用如下代码来得到:
|
||||
|
||||
```
|
||||
const GeneratorFunction = (function* (){}).constructor;
|
||||
const AsyncGeneratorFunction = (async function* (){}).constructor
|
||||
const AsyncFunction = (async x=>x).constructor;
|
||||
|
||||
// 示例
|
||||
(new AsyncFunction)().then(console.log); // promise print 'undefined'
|
||||
|
||||
```
|
||||
|
||||
### 函数的三个组件
|
||||
|
||||
我们提及过函数的三个组件,包括:**参数**、**执行体**和**结果**。其中“结果(Result)”是由代码中的return子句负责的,而其他两个组件,则是“动态创建一个函数”所必须的。这也是上述四个函数(以及它们的子类)拥有如下相同界面的原因:
|
||||
|
||||
Function (p1, p2, … , pn, body)
|
||||
|
||||
>
|
||||
NOTE:关于函数的三个组件,以及基于它们的变化,请参见《[第8讲](https://time.geekbang.org/column/article/171617)、[第9讲](https://time.geekbang.org/column/article/172636)、[第10讲](https://time.geekbang.org/column/article/174314),它们分别讨论“三个组件”、改造“执行体”,以及改造“参数和结果”》。
|
||||
|
||||
|
||||
其中,用户代码可以使用字符串来指定p1…pn的形式参数(Formals),并且使用字符串来指定函数的执行体(Body)。类似如下:
|
||||
|
||||
```
|
||||
f = new Function('x', 'y', 'z', 'console.log(x, y, z)');
|
||||
|
||||
// 测试
|
||||
f(1,2,3); // 1 2 3
|
||||
|
||||
```
|
||||
|
||||
JavaScript也允许用户代码将多个参数合写为一个,也就是变成类似如下形式:
|
||||
|
||||
```
|
||||
f = new Function('x, y, z', ...);
|
||||
|
||||
```
|
||||
|
||||
或者在字符串声明中使用缺省参数等扩展风格,例如:
|
||||
|
||||
```
|
||||
f = new Function('x = 0, ...args', 'console.log(x, ...args)');
|
||||
f(undefined, 200, 300, 400); // 0 200 300 400
|
||||
|
||||
```
|
||||
|
||||
### 动态函数的创建过程
|
||||
|
||||
所有的四种动态函数的创建过程都是一致的,它们都将调用内部过程[CreateDynamicFunction()](https://tc39.es/ecma262/#sec-createdynamicfunction)来创建函数对象。但相对于静态声明的函数,动态创建(CreateDynamicFunction)却有自己不同的特点与实现过程。
|
||||
|
||||
>
|
||||
NOTE:关于对象的构造过程,请参见《[第13讲(13 | new X)](https://time.geekbang.org/column/article/177397)》。
|
||||
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/b7/51e63c42eb159ddbc78326da0fb914b7.jpg" alt="">
|
||||
|
||||
JavaScript在创建函数对象时,会为它分配一个称为“allocKind”的标识。相对于静态创建,这个标识在动态创建过程中反而更加简单,正好与上述四种构造器一一对应,也就不再需要进行语法级别的分析与识别。其中除了`normal`类型(它所对应的构造器是`Function()`)之外,其他的三种都不能作为构造器来创建和初始化。所以,只需要简单地填写它们的内部槽,并置相应的原型(原型属性F.prototype以及内部槽F.[[Prototype]])就可以了。
|
||||
|
||||
最后,当函数作为对象实例完成创建之后,引擎会调用一个称为“函数初始化(FunctionInitialize)”的内置过程,来初始那些与具体实例相关的内部槽和外部属性。
|
||||
|
||||
>
|
||||
NOTE:在ECMAScript 6中,动态函数的创建过程主要由FunctionAllocate和FunctionInitialize两个阶段完成。而到了ECMAScript 9中,ECMAScript规范将FunctionAllocate的主要功能归入到OrdinaryFunctionCreate中(并由此规范了函数“作为对象”的创建过程),而原本由FunctionInitialize负责的初始化,则直接在动态创建过程中处理了。
|
||||
|
||||
|
||||
然后呢?然后,函数就创建完了。
|
||||
|
||||
是的!“好像”什么也没有发生?!事实上,在引擎层面,所谓的“动态函数创建”就是什么也没有发生,因为执行引擎并不理解“声明一个函数”与“动态创建一个函数”之间的差异。
|
||||
|
||||
我们试想一下,如果一个执行引擎要分别理解这两种函数并尝试不同的执行模式或逻辑,那么这个引擎的效率得有多差。
|
||||
|
||||
## 作为一个函数
|
||||
|
||||
通常情况下,接下来还需要一个变量来引用这个函数对象,或者将它作为表达式操作数,它才会有意义。如果它作为引用,那么它跟普通变量或其他类型的数据类似;如果它作为一般操作数,那么它应该按照上一讲所说的规则,转换成“值类型”才能进行运算。
|
||||
|
||||
>
|
||||
NOTE:关于引用、操作数,以及值类型等等,请参见《[第01讲(01 | delete 0)](https://time.geekbang.org/column/article/164312)》。
|
||||
|
||||
|
||||
所以,如果不讨论“动态函数创建”内在的特殊性,那么它的创建与其他数据并没有本质的不同:创建结果一样,对执行引擎或运行环境的影响也一样。而这种“没有差异”反而体现了“函数式语言”的一项基本特性:函数是数据。也就是说,函数可以作为一般数据来处理,例如对象,又例如值。
|
||||
|
||||
函数与其他数据不同之处,仅在于它是可以调用的。那么“动态创建的函数”与一般函数相比较,在调用/执行方面有什么特殊性吗?
|
||||
|
||||
答案是,仍然没有!在ECMAScript的内部方法`Call()`或者函数对象的内部槽`[[Call]] [[Construct]]`中,根本没有任何代码来区别这两种方式创建出来的函数。它们之间毫无差异。
|
||||
|
||||
>
|
||||
NOTE:事实上,不惟如此,我尝试过很多的方式来识别不同类型的函数(例如构造器、类、方法等)。除了极少的特例之外,在用户代码层面是没有办法识别函数的类型的。就现在的进展而言,`isBindable()`、`isCallable()`、`isConstructor()`和`isProxy()`这四个函数是可以实现的,其他的类似`isClassConstructor()`、`isMethod()`和`isArrowFunction()`都没有有效的识别方式。
|
||||
|
||||
|
||||
>
|
||||
NOTE:如上的这些识别函数,需要在不利用toString()方法,以及不调用函数的情况下来完成。因为执行函数会带来未知的结果,而toString方法的实现在许多引擎中并不标准,不可依赖。
|
||||
|
||||
|
||||
不过,如果我们将时钟往回拔一点,考察一下这个函数被创建出来之前所发生的事情,那么,我们还是能找到“唯一一点不同”。而这,也将是我在“动态语言”这个系列中为你揭示的最后一个秘密。
|
||||
|
||||
## 唯一一点不同
|
||||
|
||||
在“函数初始化(FunctionInitialize)”这个阶段中,ECMAScript破天荒地约定了几行代码,这段规范文字如下:
|
||||
|
||||
>
|
||||
<p>Let realmF be the value of F’s [[Realm]] internal slot.<br>
|
||||
Let **scope** be realmF.[[GlobalEnv]].<br>
|
||||
Perform **FunctionInitialize**(F, Normal, parameters, body, **scope**).</p>
|
||||
|
||||
|
||||
它们是什么意思呢?
|
||||
|
||||
规范约定需要从函数对象所在的“域(即引擎的一个实例)”中取出全局环境,然后将它作为“父级的作用域(scope)”,传入`FunctionInitialize()`来初始化函数`F`。也就是说,所有的“动态函数”的父级作用域将指向全局!
|
||||
|
||||
你绝不可能在“当前上下文(环境/作用域)”中动态创建动态函数。和间接调用模式下的`eval()`一样,所有动态函数都将创建在全局!
|
||||
|
||||
一说到跟“间接调用eval()”存有的相似之处,可能你立即会反应过来:这种情况下,`eval()`不仅仅是在全局执行,而且将突破“全局的严格模式”,代码将执行在非严格模式中!那么,是不是说,“动态函数”既然与它有相似之处,是不是也有类似性质呢?
|
||||
|
||||
>
|
||||
NOTE:关于间接调用`eval()`,请参见《[第21讲](https://time.geekbang.org/column/article/184589)》。
|
||||
|
||||
|
||||
答案是:的确!
|
||||
|
||||
出于与“间接调用eval()”相同的原因——即,在动态执行过程中无法有效地(通过上下文和对应的环境)检测全局的严格模式状态,所以动态函数在创建时只检测代码文本中的第一行代码是否为`use strict`指示字,而忽略它“外部scope”是否处于严格模式中。
|
||||
|
||||
因此,即使你在严格模式的全局环境中创建动态函数,它也是执行在非严格模式中的。它与“间接调用eval()”的唯一差异,仅在于“多封装了一层函数”。
|
||||
|
||||
例如:
|
||||
|
||||
```
|
||||
# 让NodeJS在启动严格模式的全局
|
||||
> node --use-strict
|
||||
|
||||
# (在上例启动的NodeJS环境中测试)
|
||||
> x = "Hi"
|
||||
ReferenceError: x is not defined
|
||||
|
||||
# 执行在全局,没有异常
|
||||
> new Function('x = "Hi"')()
|
||||
undefined
|
||||
|
||||
# `x`被创建
|
||||
> x
|
||||
'Hi'
|
||||
|
||||
# 使用间接调用的`eval`来创建`y`
|
||||
> (0, eval)('y = "Hello"')
|
||||
> y
|
||||
'Hello'
|
||||
|
||||
```
|
||||
|
||||
## 结尾
|
||||
|
||||
所以,回到今天这一讲的标题上来。标题中的代码,事实与上一讲中提到的“间接调用eval()”的效果一致,同样也会因为在全局中“向未声明变量赋值”而导致创建一个新的变量名`x`。并且,这一效果同样不受所谓的“严格模式”的影响。
|
||||
|
||||
在JavaScript的执行系统中出现这两个语法效果的根本原因,在于执行系统试图从语法环境中独立出来。如果考虑具体环境的差异性,那么执行引擎的性能将会较差,且不易优化;如果不考虑这种差异性,那么“严格模式”这样的性质就不能作为(执行引擎理解的)环境属性。
|
||||
|
||||
在这个两难中,ECMAScript帮助我们做出了选择:牺牲一致性,换取性能。
|
||||
|
||||
>
|
||||
NOTE:关于间接调用`eval()`对环境的使用,以及环境相关的执行引擎组件的设计与限制,请参见《[第20讲](https://time.geekbang.org/column/article/183440)》。
|
||||
|
||||
|
||||
当然这也带来了另外一些好处。例如终于有了`window.execScript()`的替代实现,以及通过`new Function`这样来得到的、动态创建的函数,就可以“安全地”应用于并发环境。
|
||||
|
||||
至于现在,《JavaScript核心原理解析》一共22讲内容就全部结束了。
|
||||
|
||||
在这个专栏中,我为你讲述了JavaScript的静态语言设计、面向对象语言的基本特性,以及动态语言中的类型与执行系统。这看起来是一些零碎的、基本的,以及应用性不强的JavaScript特性,但是事实上,它们是你理解“更加深入的核心原理”的基础。
|
||||
|
||||
如果不先掌握这些内容,那么更深入的,例如多线程、并行语言特性等等都是空中楼阁,就算你勉强学来,也不过是花架子,是理解不到真正的“核心”的。
|
||||
|
||||
而这也是我像现在这样设计《JavaScript核心原理解析》22讲框架的原因。我希望你能在这些方面打个基础,先理解一下ECMAScript作为“语言设计者”这个角色的职责和关注点,先尝试一下深入探索JavaScript核心原理的乐趣(与艰难)。然后,希望我们还有机会在新的课程中再见!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/a9/c4/a9c16d26dfcdd8ff91002344df2297c4.jpg" alt="">](https://jinshuju.net/f/TmdBMP)
|
||||
|
||||
多谢你的收听,最后邀请你填写这个专栏的[调查问卷](https://jinshuju.net/f/TmdBMP),我也想听听你的意见和建议,我将继续答疑解惑、查漏补缺,与你回顾这一路行来的苦乐。
|
||||
|
||||
再见。
|
||||
|
||||
>
|
||||
<p>NOTE:编辑同学说还有一个“结束语”,我真不知道怎么写。不过,如果你觉得意犹未尽的话,到时候请打开听听吧(或许还有好货叱)。<br>
|
||||
by aimingoo.</p>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<audio id="audio" title="06 | x: break x; 搞懂如何在循环外使用break,方知语句执行真解" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/7d/70de56e9a3ba4b11497125ed750bd57d.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
上一讲的`for`语句为你揭开了JavaScript执行环境的一角。在执行系统的厚重面纱之下,到底还隐藏了哪些秘密呢?那些所谓的执行环境、上下文、闭包或块与块级作用域,到底有什么用,或者它们之间又是如何相互作用的呢?
|
||||
|
||||
接下来的几讲,我就将重点为你讲述这些方面的内容。
|
||||
|
||||
## 用中断(Break)代替跳转
|
||||
|
||||
在Basic语言还很流行的时代,许多语言的设计中都会让程序代码支持带地址的“语句”。例如,Basic就为每行代码提供一个标号,你可以把它叫做“**行号**”,但它又不是绝对物理的行号,通常为了增减程序的方便,会使用“1,10,20…...”等等这样的间隔。如果想在第10行后追加1行,就可以将它的行号命名为“11”。
|
||||
|
||||
行号是一种很有历史的程序逻辑控制技术,更早一些可以追溯到汇编语言,或可以手写机器代码的时代(确实存在这样的时代)。那时由于程序装入位置被标定成内存的指定位置,所以这个位置也通常就是个地址偏移量,可以用数字化或符号化的形式来表达。
|
||||
|
||||
所有这些“为代码语句标示一个位置”的做法,其根本目的都是为了实现“GOTO跳转”,任何时候都可以通过“GOTO 标号”的语法来转移执行流程。
|
||||
|
||||
然而,这种黑科技在20世纪的60~70年代就已经普遍地被先辈们批判过了。这样的编程方式只会大大地降低程序的可维护性,其正确性或正确性验证都难以保障。所以,后面的故事想必你都知道了,半个多世纪之前开始的**“结构化”运动**一直影响至今,包括现在我与你讨论的这个JavaScript,都是“结构化程序设计”思想的产物。
|
||||
|
||||
所以,简单地说:JavaScript中没有GOTO语句了。取而代之的,是**分块代码**,以及**基于代码分块的流程控制技术**。这些控制逻辑基于一个简单而明了的原则:如果代码分块中需要GOTO的逻辑,那么就为它设计一个“自己的GOTO”。
|
||||
|
||||
这样一来,所有的GOTO都是“块(或块所在语句)自己知道的”。这使得程序可以在“自己知情的前提下自由地GOTO”。整体看起来还不错,很酷。然而,问题是那些“标号”啊,或者“程序地址”之类的东西已经被先辈们干掉了,因此就算设计了GOTO也找不到去处,那该怎么办呢?
|
||||
|
||||
### 第一种中断
|
||||
|
||||
第一种处理方法最为简洁,就是**约定“可以通过GOTO到达的位置”**。
|
||||
|
||||
在这种情况下,JavaScript将GOTO的“离开某个语句”这一行为理解为“中断(Break)该语句的执行”。由于这个中断行为是明确针对于该语句的,所以“GOTO到达的位置”也就可以毫无分歧地约定为该语句(作为代码块)的结束位置。这是“break”作为子句的由来。它用在某些“可中断语句(**BreakableStatement**)”的内部,用于中断并将程序流程“跳转(GOTO)到语句的结束位置”。
|
||||
|
||||
在语法上,这表示为(该语法只作用于对“可中断语句”的中断):
|
||||
|
||||
>
|
||||
****break****;
|
||||
|
||||
|
||||
所谓“可中断语句”其实只有两种,包括全部的**循环语句**,以及**switch语句**。在这两种语句内部使用的“break;”,采用的就是这种处理机制——中断当前语句,将执行逻辑交给下一语句。
|
||||
|
||||
### 第二种中断
|
||||
|
||||
与第一种处理方法的限制不同,第二种中断语句可以中断“任意的标签化语句”。所谓标签化语句,就是在一般语句之前加上“xxx:”这样的标签,用以指示该语句。就如我在文章中写的这两段示例:
|
||||
|
||||
```
|
||||
// 标签aaa
|
||||
aaa: {
|
||||
...
|
||||
}
|
||||
|
||||
// 标符bbb
|
||||
bbb: if (true) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对比这两段示例代码,你难道不会有这么一个疑惑吗?在标签aaa中,显然aaa指示的是后续的“块语句”的块级作用域;而在标签bbb中,`if`语句是没有块级作用域的,那么bbb到底指示的是“if语句”呢,还是其后的`then`分支中的“块语句”呢?
|
||||
|
||||
这个问题本质上是在“块级作用域”与“标签作用的(语句)范围”之间撕裂了一条鸿沟。由于标签bbb在语义上只是要“标识其后的一行语句”,因此这种指示是与“块级作用域(或词法环境)”没有关系的。简单地说,标签化语句理解的是“位置”,而不是“(语句在执行环境中的)范围”。
|
||||
|
||||
因此,中断这种标签化语句的“break”的语法,也是显式地用“标签”来标示位置的。例如:
|
||||
|
||||
>
|
||||
****break**** **labelName;**
|
||||
|
||||
|
||||
所以你才会看到,我在文章中写的这两种语句都是可行的:
|
||||
|
||||
```
|
||||
// 在if语句的两个分支中都可以使用break;
|
||||
// (在分支中深层嵌套的语句中也是可以使用break的)
|
||||
aaa: if (true) {
|
||||
...
|
||||
}
|
||||
else {
|
||||
...
|
||||
break aaa;
|
||||
}
|
||||
|
||||
// 在try...catch...finally中也可以使用break;
|
||||
bbb: try {
|
||||
...
|
||||
}
|
||||
finally {
|
||||
break bbb;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于标签bbb的finally块中使用的这个特例,我需要再特别说明:如果在try或try..finally块中使用了return,那么这个break将发生于最后一行语句之后,但是却是在return语句之前。例如我在文章中写的这段代码:
|
||||
|
||||
```
|
||||
var i = 100;
|
||||
function foo() {
|
||||
bbb: try {
|
||||
console.log("Hi");
|
||||
return i++; // <-位置1:i++表达式将被执行
|
||||
}
|
||||
finally {
|
||||
break bbb;
|
||||
}
|
||||
console.log("Here");
|
||||
return i; // <-位置2
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试如下:
|
||||
|
||||
```
|
||||
> foo()
|
||||
Hi
|
||||
Here
|
||||
101
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,你的预期可能会是“位置1”返回的100,而事实上将执行到输出“Here”并通过位置2返回101。这也很好地说明了**`break`语句本质上就是作用于其后的“一个语句”,而与它“有多少个块级作用域”无关**。
|
||||
|
||||
## 执行现场的回收
|
||||
|
||||
break将“语句的‘代码块’”理解为**位置**,而不是理解为作用域/环境,这是非常重要的前设!
|
||||
|
||||
然而,我在上面已经讲过了,程序代码中的“位置”已经被先辈们干掉了。他们用了半个世纪来证明了一件事情:**想要更好、更稳定和更可读的代码,那么就忘掉“(程序的)位置”这个东西吧!**
|
||||
|
||||
通过“作用域”来管理代码的确很好,但是作用域与“语句的位置”以及“GOTO到新的程序执行”这样的理念是矛盾的。它们并不在同一个语义系统内,这也是**标签**与**变量**可以重名而不相互影响的根本原因。由于这个原因,在使用标签的代码上下文中,**执行现场的回收**就与传统的“块”以及“块级作用域”根本上不同。
|
||||
|
||||
JavaScript的执行机制包括“执行权”和“数据资源”两个部分,分别映射可计算系统中的“逻辑”与“数据”。而块级作用域(也称为词法作用域)以及其他的作用域本质上就是一帧数据,以保存执行现场的一个瞬时状态(也就是每一个执行步骤后的现场快照)。而JavaScript的运行环境被描述为一个后入先出的栈,这个栈顶永远就是当前“执行权”的所有者持用的那一帧数据,也就是代码活动的现场。
|
||||
|
||||
JavaScript的运行环境通过函数的CALL/RETURN来模拟上述“数据帧”在栈上的入栈与出栈过程。任何一次函数的调用,即是向栈顶压入该函数的上下文环境(也就是作用域、数据帧等等,它们在不同场合下的相同概念)。所以,包括那些在全局或模块全局中执行的代码,以及Promise中执行调度的那些内部处理,所有的这些JavaScript内部过程或外部程序都统一地被封装成函数,通过CALL/RETURN来激活、挂起。
|
||||
|
||||
所以,“作用域”就是在上述过程中被操作的一个对象。
|
||||
|
||||
- 作用域退出,就是函数RETURN。
|
||||
- 作用域挂起,就是执行权的转移。
|
||||
- 作用域的创建,就是一个闭包的初始化。
|
||||
- ……
|
||||
|
||||
然而如之前所说的,“**break** labelName;”这一语法独立于“执行过程”的体系,它表达一个位置的跳转,而不是一个数据帧在栈上的进出栈。这是labelName独立于标识符体系(也就是词法环境)所带来的附加收益!
|
||||
|
||||
基于对“语句”的不同理解,JavaScript设计了一种全新方法,用来清除这个跳转所带来的影响(也就是回收跳转之前的资源分配)。而这多余出来的设计,其实也是上述收益所需要付出的代价。
|
||||
|
||||
## 语句执行的意义
|
||||
|
||||
对于语句的跳转来说,“离开语句”意味着清除语句所持有的一切资源,如同函数退出时回收闭包。但是,这也同样意味着“语句”中发生的一切都消失了,对于函数来说,return和yield是唯二从这个现场发出信息的方式。那么语句呢?语句的执行现场从这个“程序逻辑的世界”中湮灭之后,又留下了什么呢?
|
||||
|
||||
>
|
||||
NOTE: 确实存在从函数中传出信息的其他结构,但这些也将援引别的解释方式,这些就留待今后再讲了。
|
||||
|
||||
|
||||
语句执行与函数执行并不一样。函数是求值,所以返回的是对该函数求值的结果(Result),该结果或是值(Value),或是结果的引用(Reference)。而语句是命令,语句执行的返回结果是该命令得以完成的状态(Completion, Completion Record Specification Type)。
|
||||
|
||||
注意,JavaScript是一门混合了函数式与命令式范型的语言,而这里对函数和语句的不同处理,正是两种语言范型根本上的不同抽象模型带来的差异。
|
||||
|
||||
在ECMAScript规范层面,本质上所有JavaScript的执行都是语句执行(这很大程度上解释了为什么eval是执行语句)。因此,ECMAScript规范中对执行的描述都称为“运行期语义(Runtime Semantics)”,它描述一个JavaScript内部的行为或者用户逻辑的行为的过程与结果。也就是说这些运行期语义都最终会以一个完成状态(Completion)来返回。例如:
|
||||
|
||||
- 一个函数的调用:调用函数——执行函数体(EvaluateBody)并得到它的“完成”结果(result)。
|
||||
- 一个块语句的执行:执行块中的每行语句,得到它们的“完成”结果(result)。
|
||||
|
||||
这些结果(result)包括的状态有五种,称为完成的类型:normal、break、continue、return、throw。也就是说,任何语句的行为,要么是包含了有效的、可用于计算的数据值(Value):
|
||||
|
||||
- 正常完成(normal)
|
||||
- 一个函数调用的返回(return)
|
||||
|
||||
要么是一个不可(像数据那样)用于计算或传递的纯粹状态:
|
||||
|
||||
- 循环过程中的继续下次迭代(continue)
|
||||
- 中断(break)
|
||||
- 异常(throw)
|
||||
|
||||
>
|
||||
NOTE: throw是一个很特殊的流程控制语句,它与这里的讨论的流程控制有相似性,不同的地方在于:它并不需要标签。关于throw更多的特性,我还会在稍后的课程中给你具体地分析。
|
||||
|
||||
|
||||
所以当运行期出现了一这个称为“中断(break)”的状态时,JavaScript引擎需要找到这个“break”标示的目标位置(**result**.Target),然后与当前语句的标签(如果有的话)对比:
|
||||
|
||||
- 如果一样,则取break源位置的语句执行结果为值(Value)并以正常完成状态返回;
|
||||
- 如果不一样,则继续返回break状态。
|
||||
|
||||
这与函数调用的过程有一点类似之处:由于对“break状态”的拦截交给语句退出(完成)之后的下一个语句,因此如果语句是嵌套的,那么其后续(也就是外层的)语句就可以得到处理这个“break状态”的机会。举例来说:
|
||||
|
||||
```
|
||||
console.log(eval(`
|
||||
aaa: {
|
||||
1+2;
|
||||
bbb: {
|
||||
3+4;
|
||||
break aaa;
|
||||
}
|
||||
}
|
||||
`)); // 输出值:7
|
||||
|
||||
```
|
||||
|
||||
在这个示例中,“break aaa”语句是发生于bbb标签所示块中的。但当这个中断发生时,
|
||||
|
||||
- 标签化语句bbb将首先捕获到这个语句完成状态,并携带有标签aaa;
|
||||
- 由于bbb语句完成时检查到的状态中的中断目标(Target)与自己的标签不同,所以它将这个状态继续作为自己的完成状态,返回给外层的aaa标签化语句aaa;
|
||||
- 语句aaa得到上述状态,并对比标签成功,返回结果为语句`3+4`的值(作为完成状态传出)。
|
||||
|
||||
所以,语句执行总是返回它的完成状态,且如果这个完成状态是包含值(Value)的话,那么它是可以作为JavaScript代码可访问的数据来使用的。例如,如果该语句被作为`eval()`来执行,那么它就是eval()函数返回的值。
|
||||
|
||||
## 中断语句的特殊性
|
||||
|
||||
最后的一个问题是:标题中的这行代码有什么特殊性呢?
|
||||
|
||||
相信你知道我总是会设计一些难解的,以及表面上矛盾和歧义的代码,并围绕这样的代码来组织我的专题的每一讲的内容。而今天这行代码在“貌似难解”的背后,其实并不包含任何特殊的执行效果,它的执行过程并不会对其他任何代码构成任何影响。
|
||||
|
||||
我列出这行代码的原因有两点。
|
||||
|
||||
1. 它是最小化的break语句的用法,你不可能写出更短的代码来做break的示例了;
|
||||
1. 这种所谓“不会对其他任何代码构成任何影响”的语句,也是JavaScript中的特有设计。
|
||||
|
||||
首先,由于“标签化语句”必须作用于“一个”语句,而**语句**理论上的最小化形式是“空语句”。但是将空语句作为break的目标标签语句是不可能的,因为你还必须在标签语句所示的语句范围内使用break来中断。空语句以及其他一些单语句是没有这样的语句范围的,因此最小化的示例就只能是对break语句自身的中断。
|
||||
|
||||
其次,语句的返回与函数的返回有相似性。例如,函数可以不返回任何东西给外部,这种情况下外部代码得到的函数出口信息会是undefined值。
|
||||
|
||||
由于典型的函数式语言的“函数”应该是没有副作用的,所以这意味着该函数的执行过程不影响任何其他逻辑——也不在这个“程序逻辑的世界”中留下任何的状态。事实上,你还可以用“void”运算符来阻止一个函数返回的值影响它的外部世界。函数是“表达式运算”这个体系中的,因此用一个运算符来限制它的逻辑,这很合理。
|
||||
|
||||
虽然“**break** labelName”的中止过程是可以传出“最后执行语句”的状态的,但是你只要回忆一下这个过程就会发现一个悖论:任何被break的代码上下文中,最后执行语句必然会是“break语句”本身!所以,如果要在这个逻辑中实现“语句执行状态”的传递,那么就必须确保:
|
||||
|
||||
1. “break语句”不返回任何值(ECMAScript内部约定用“Empty”值来表示);
|
||||
1. 上述“不返回任何值”的语句,也不会影响任何语句的既有返回值。
|
||||
|
||||
所以,事实上我们已经探究了“break语句”返回值的两个关键特性的由来:
|
||||
|
||||
- 它的类型必然是“break”;
|
||||
- 它的返回值必然是“空(Empty)”。
|
||||
|
||||
对于Empty值,在ECMAScript中约定:在多行语句执行时它可以被其他非Empty值更新(UpdateEmpty),而Empty不可以覆盖其他任何值。
|
||||
|
||||
这就是空语句等也同样“不会对其他任何代码构成任何影响”的原因了。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
今天的内容有一些非常重要的、关键的点,主要包括:
|
||||
|
||||
1. “GOTO语句是有害的。”——1972年图灵奖得主艾兹格·迪科斯彻(Edsger Wybe Dijkstra, 1968)。
|
||||
1. 很多新的语句或语法被设计出来用来替代GOTO的效果的,但考虑到GOTO的失败以及无与伦比的破坏性,这些新语法都被设计为功能受限的了。
|
||||
1. 任何的一种GOTO带来的都是对“顺序执行”过程的中断以及现场的破坏,所以也都存在相应的执行现场回收的机制。
|
||||
1. 有两种中断语句,它们的语义和应用场景都不相同。
|
||||
1. 语句有返回值。
|
||||
1. 在顺序执行时,当语句返回Empty的时候,不会改写既有的其他语句的返回值。
|
||||
1. 标题中的代码,是一个“最小化的break语句示例”。
|
||||
|
||||
## 思考题
|
||||
|
||||
- 找到其他返回Empty的语句。
|
||||
- 尝试完整地对比函数执行与语句执行的过程。
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -0,0 +1,252 @@
|
||||
<audio id="audio" title="07 | `${1}`:详解JavaScript中特殊的可执行结构" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/1d/73d939df14126bb7e7279893e3e4bd1d.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
今天这一讲的标题是一个**模板**。模板这个语法元素在JavaScript中出现得很晚,以至于总是有人感到奇怪:为什么JavaScript这么晚才弄出个模板这样的东西?
|
||||
|
||||
模板看起来很简单,就是把一个字符串里的东西替换一下就行了,C语言里的printf()就有类似的功能,Bash脚本里也可以直接在字符串里替换变量。这个功能非常好用,但在实现上其实很简单,无非就是字符串替换而已。
|
||||
|
||||
## 模板是什么?
|
||||
|
||||
但是,模板就是一个字符串吗?或者我们需要更准确地问一个概念上的问题:
|
||||
|
||||
模板是什么?
|
||||
|
||||
回顾之前的内容,我们说JavaScript中,有**语句**和**表达式**两种基本的可执行元素。这在语言设计的层面来讲,是很普通的,大多数语言都这么设计。少数的语言会省略掉**语句**这个语法元素,或者添加其它一些奇怪的东西,不过通常情况下它的结果就是让语言变得不那么人性。那么,是不是说,JavaScript中只有语句和表达式是可以执行的呢?
|
||||
|
||||
答案是“No”,譬如这里讲到的模板,其实就是**一种特殊的可执行结构**。
|
||||
|
||||
所有特殊可执行结构其实都是来自于某种固定的、确定的逻辑。这些逻辑语义是非常明确的,输入输出都很确定,这样才能被设计成一个标准的、易于理解的可执行结构。并且,如果在一门语言中添加太多的、有特殊含义的执行结构,那么这门语言就像上面说的,会显得“渐渐地有些奇怪了”。
|
||||
|
||||
语言的坏味道就是这样产生的。越来越多的抽象概念放进来,固化成一种特殊的逻辑或结构,试图通过非正常的逻辑来影响程序员的思维过程,于是就会渐渐地变得令人不愉快了。
|
||||
|
||||
如果我们抛开JavaScript核心库或者标准语言运行时里面的那些东西,例如Map、Set等等,专门考察一下在语言及语法层面定义的特殊可执行结构的话,都会有哪些可执行结构浮出水面呢?
|
||||
|
||||
## 参数表
|
||||
|
||||
第一个不太容易注意到的东西就是参数表。
|
||||
|
||||
在JavaScript语言的内核中,参数表其实是一个独立的语法组件:
|
||||
|
||||
- 对于函数来说,参数表就是在函数调用时传入的参数0到n;
|
||||
- 对于构造器以及构造器的new运算来说,参数表就是new运算的一个运算数。
|
||||
|
||||
这二者略微有一点区别,在远古时期的JavaScript中,它们是很难区分的。然而在ECMAScript的规范中,这个参数表被统一成了标准的List。这个List也是一种ECMAScript中的规范类型,与引用、属性描述符等等规范类型类似,它在相关的操作中是作为一个独立的部分参与运算的。
|
||||
|
||||
要证实这一点是很容易的。例如在JavaScript的反射机制中,使用代理对象就能拿到一个函数调用的入参,或者new运算过程中传入的参数,它们都表示成一个标准的数组:
|
||||
|
||||
```
|
||||
handler.apply = function(target, thisArgument, argArray) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里`argArray`表示为一个数组,但这只是参数表在传入后通过“特殊可执行结构”执行的结果。如果追究这个行为背后的逻辑,那么这个列表实际上是根据形式参数的样式(Formal of Parameters),按照传入参数逐一匹配出来的。这个所谓“**逐一匹配**”,就是我们说的“**特殊的可执行的逻辑**”。
|
||||
|
||||
任何实际参数在传入一个函数的形式参数时,都会经历这样的一个执行过程,它是“将函数实例化”这个内部行为的一个处理阶段。
|
||||
|
||||
我们之前也说过了,所谓“**将函数实例化**”就是将函数从源代码文本变成一个可以执行的、运行期的闭包的过程。
|
||||
|
||||
在这个过程中,参数表作为可执行结构,它的执行结果就是将传入的参数值变成与形式参数规格一致的实际参数,最终将这些参数中所有的值与它们“在形式参数表中的名字”绑定起来,作为函数闭包中可以访问的名字。
|
||||
|
||||
说完这段,我估计你听得都累了。听起来很啰嗦很复杂,但是简单化地讲呢,就是把参数放在arguments列表中,然后让arguments中的值与参数表中的名字对应起来。而这就是对“参数表(argArray)”这个可执行结构的全部操作。
|
||||
|
||||
了解这个有什么用呢?很有用。
|
||||
|
||||
其一,我们要记得,JavaScript中有个东西没有参数表,那就是箭头函数,那么上面的逻辑是如何实现的呢?
|
||||
|
||||
其二,我们还要知道JavaScript中有种形式参数的风格,称为“简单参数(**Simple Parameter List**)”,这与argArray的使用存在莫大的关系。
|
||||
|
||||
关于这两点,我们往简化里说,就是箭头函数也是采用与上述过程完全一致的处理逻辑,只是在最后没有向闭包绑定arguments这个名字而已。而所谓简单参数,就是可以在形式参数表中可以明确数出参数个数的、没有使用扩展风格声明参数的参数表。
|
||||
|
||||
## 扩展风格的参数表
|
||||
|
||||
什么是扩展风格的参数表呢?它也称为“非简单的参数列表(**Non**-**Simple Parameter List**)”,这就与其它几种可执行结构有关了,例如说**缺省参数**。
|
||||
|
||||
事实上,缺省参数是非常有意思的可执行结构,它长得就是下面这个样子:
|
||||
|
||||
```
|
||||
function foo(x = 100) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这意味着在语法分析期,JavaScript就得帮助该参数登记下“100”这个值。然后在实际处理这个参数时,至少需要一个赋值表达式的操作,用来将这个值与它的名字绑定起来。所以,foo()函数调用时,总有一段执行逻辑来访问形式参数表以及执行这个赋值表达式。
|
||||
|
||||
让问题变得更复杂的地方在于:这个值“100”可以是一个表达式的运算结果,由于表达式可以引用上下文中的其它变量,因此上面的所谓“登记”,就不能只是记下一个字面量值那么简单,必须登记一个表达式,并且在运行期执行它。例如:
|
||||
|
||||
```
|
||||
var x = 0;
|
||||
function foo(i = x++) {
|
||||
console.log(i);
|
||||
}
|
||||
foo(); // 1st
|
||||
foo(); // 2nd
|
||||
|
||||
```
|
||||
|
||||
这样每次调用foo()的时候,“x++”就都会得到执行了。所以,缺省参数就是一种可执行结构,是参数表作为可执行结构的逻辑中的一部分。同样的,**剩余参数**和**参数展开**都具有类似的性质,也都是参数表作为可执行结构的逻辑中的一部分。
|
||||
|
||||
既然提到参数展开,这里是可以略微多讨论一下的,因为它与后面还要讲到的另外一种可执行结构有关。参数展开是唯一一个可以影响“传入参数个数”的语法。例如:
|
||||
|
||||
```
|
||||
foo(...args)
|
||||
|
||||
```
|
||||
|
||||
这个语法的关键处不在于形式参数的声明,而在于实际参数的传入。
|
||||
|
||||
这里传入时实际只用到了一个参数,即“args”,但是“…”语法对这个数组进行了展开,并且根据args.length来扩展了参数表的长度/大小。由于其它参数都是按实际个数计数的,所以这里的参数展开就成了唯一能动态创建和指定参数个数的语法。
|
||||
|
||||
这里之所以强调这一语法,是因为在传统的JavaScript中,这一语法是使用foo.apply()来替代的。历史中,“new Function()”这个语法没有类似于`apply()`的创建和处理参数表的方式,所以早期的JavaScript需要较复杂的逻辑,或者是调用eval()来处理动态的new运算。
|
||||
|
||||
这个过程相当麻烦,真的是“谁用谁知道”。而如今,它可以只使用一行代码替代:
|
||||
|
||||
```
|
||||
new Func(...args)
|
||||
|
||||
```
|
||||
|
||||
这正是我们之前说“函数和(使用new运算的)构造器的参数表不一样”所带来的差异。那么这个参数展开是怎么实现的呢?答案是**迭代器**。
|
||||
|
||||
参数展开其实是数组展开的一种应用,而数组展开在本质上就是依赖迭代器的。
|
||||
|
||||
你可以在任何内置迭代器的对象(亦即是Symbol.iterator这个符号属性有值的对象)上使用展开语法,使它们按迭代顺序生成相应多个“元素(elements)”,并将这些元素用在需要的地方,而不仅仅是将它展开。例如`yield*`,又例如**模板赋值**。我们知道迭代器是有一组界面约定的,那么这个迭代器界面本质上也是一种可执行结构。
|
||||
|
||||
## 赋值模板
|
||||
|
||||
赋值模板是我们今天要讲到的第三种可执行结构。
|
||||
|
||||
模板赋值是ECMAScript 6之后提供一种声明标识符的语法,该语法依赖一个简单的赋值过程,可以抽象地理解为下面这样:
|
||||
|
||||
```
|
||||
a = b
|
||||
|
||||
```
|
||||
|
||||
等号的左侧称为赋值模板(AssignmentPattern),而右侧称为值(Value)。
|
||||
|
||||
在JavaScript中,任何出现类似语法或语义过程的位置,本质上都可以使用模板赋值的。也就是说,即使没有这个“赋值符号(等号)”,只要语义是“向**左操作数**(lhs)上的标识符,赋以**右操作数**(rhs)的值”,那么它就适用于模板赋值。
|
||||
|
||||
很显然,我们前面说的“向参数表中的形式参数(的名字),赋以实际参数的值”,也是这样的一个过程。所以,JavaScript在语法上很自然地就支持了在参数表中使用模板赋值,以及在任何能够声明一个变量或标识符的地方,来使用模板赋值。例如:
|
||||
|
||||
```
|
||||
function foo({x, y}) {
|
||||
...
|
||||
}
|
||||
|
||||
for (var {x, y} in obj) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而所有这些地方的赋值模板,都是在语法解析期就被分析出来,并在JavaScript内部作为一个可执行结构存放着。然后在运行期,会用它们来完成一个“从右操作数按模板取值,并赋值给左操作数”的过程。这与将函数的参数表作为**样式**(Formal)存放起来,然后在运行期逐一匹配传入值是异曲同工的。
|
||||
|
||||
所有上述的执行结构,我们都可以归为一个大类,称为“**名字和值的绑定**”。
|
||||
|
||||
也就是说,所有这些执行的结果都是一个名字,执行的语义就是给这个名字一个值。显然这是不够的,因为除了给这个名字一个值之外,最终还得使用这个名字以便进行更多的运算。那么,这个“找到名字并使用名字”的过程,就称为“**发现**(Resolve binding)”,而其结果,就称为“**引用**(reference)”。
|
||||
|
||||
任何的名字,以及任何的字面量的值,本质上都可以作为一个被发现的对象,并且在实际应用中也是如此。在代码的语法分析阶段,发现一个名字与发现一个值本质上没有什么不同,所以如下的两行代码:
|
||||
|
||||
```
|
||||
a = 1
|
||||
1 = 1
|
||||
|
||||
```
|
||||
|
||||
其实在JavaScript中都可以通过语法解析,并且进入实际的代码执行阶段。所以“1=1”是一个运行期错误(ReferenceError),而不是语法错误(SyntaxError)。那么所谓的“发现的结果——引用(Reference)”,也就不是简单的一个语法标识符,而是一个可执行结构了。更进一步地说,如下面这些代码,每一个都会导致一个引用(的可执行结构):
|
||||
|
||||
```
|
||||
a
|
||||
1
|
||||
"use strict"
|
||||
obj.foo
|
||||
|
||||
```
|
||||
|
||||
正是因此,所以上面的第三行代码才会成为一个“可以导致当前作用域切换为严格模式”的**指令**。因为它是引用,也是可执行结构。对待它,JavaScript只需要像调用函数一样,将它处理成一段确定逻辑就可以了。
|
||||
|
||||
这几个引用中有一个非常特殊的引用,就是obj.foo,它被称为属性引用(Property Reference)。属性引用不是简单的标识符引用,而是一个属性存取运算的结果。所以,表达式运算的结果可以是一个引用。那么它的特殊性在哪里呢?它是为数不多的、可以存储原表达式信息,并将该信息“传递”到后续表达式的特殊结构。严格地说,所有的引用都可以设计成这个样子,只不过属性引用是我们最常见到的罢了。
|
||||
|
||||
然而,为什么要用“引用(Reference)”这种结构来承担这一责任呢?
|
||||
|
||||
这与JavaScript中的“方法调用”这一语义的特殊实现有关。JavaScript并不是静态分析的,因此它无法在语法阶段确定“obj.foo”是不是一个函数,也不知道用户代码在得到“obj.foo”这个属性之后要拿来做什么用。
|
||||
|
||||
```
|
||||
obj.foo()
|
||||
|
||||
```
|
||||
|
||||
直到运行期处理到下一个运算(例如上面这样的运算时),JavaScript引擎才会意识到:哦,这里要调用一个方法。
|
||||
|
||||
然而,方法调用的时候是需要将obj作为foo()函数的this值传入,这个信息只能在上一步的属性存取“obj.foo”中才能得到。所以obj.foo作为一个属性引用,就有责任将这个信息保留下来,传递给它的下一个运算。只有这样,才能完成一次“将函数作为对象方法调用”的过程。
|
||||
|
||||
引用作为函数调用(以及其它某些运算)的“左操作数(lhs)”时,是需要传递上述信息的。这也就是“引用”这种可执行结构的确定逻辑。
|
||||
|
||||
本质上来说,它就是要帮助JavaScript的执行系统来完成“发现/解析(Resolve)”过程,并在必要时保留这个过程中的信息。在引擎层面,如果一个过程只是将“查找的结果展示出来”,那么它最终就表现为值;如果包括这个过程信息,通常它就表现为引用。
|
||||
|
||||
那么作为一个执行系统来讲,JavaScript执行的最终的结果到底表达为一个引用呢,还是一个值呢?答案是“值”。
|
||||
|
||||
因为你没有办法将一个引用(包括它的过程信息)在屏幕上打印出来,而且即便打印出来,用户也没有兴趣。用户真正关心的是打印出来的那个结果,例如在屏幕上显示“Hello world”。所以无论如何,JavaScript创建引用也好,处理这些引用或特殊结构的执行过程也好,最终目的,还是计算求值。
|
||||
|
||||
## 模板字面量
|
||||
|
||||
回到我们今天的话题上来。我们为什么要讲这些可执行结构呢?事实上,我们在标题中的列出的这行代码是一个**模板字面量**(TemplateLiteral):
|
||||
|
||||
```
|
||||
`${1}`
|
||||
|
||||
```
|
||||
|
||||
而模板字面量是上述所有这些可执行结构的集大成者。它本身是一个特殊的可执行结构,但是它调动了包括引用、求值、标识符绑定、内部可执行结构存储,以及执行函数调用在内的全部能力。这是JavaScript厘清了所有基础的可执行结构之后,才在语法层面将它们融会如一的结果。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
接下来我们对今天的这一行代码做个总结,并对相关的内容再做些补充。
|
||||
|
||||
标题中的代码称为**模板字面量**,是一种可执行结构。JavaScript中有许多类似的可执行结构,它们通常要用固定的逻辑,并在确定的场景下,交付JavaScript的一些核心语法的能力。
|
||||
|
||||
与参数表和赋值模板有相似的地方,模板字面量也是将它的形式规格(Formal)作为可执行结构来保存的。
|
||||
|
||||
只是参数表与赋值模板关注的是名字,因此存储的是“名字(lhs)”与“名字的值(rhs)的取值方法”之间的关系,执行的结果是argArray或在当前作用域中绑定的名字等。
|
||||
|
||||
而模板字面量关注的是值,它存储的是“结果”与“结果的计算过程”之间的关系。由于模板字面量的执行结果是一个字符串,所以当它作为值来读取时,就会激活它的运算求值过程,并返回一个字符串值。
|
||||
|
||||
模板字面量与所有其它字面量(能作为引用)相似,它也可以作为引用。
|
||||
|
||||
```
|
||||
1=1
|
||||
|
||||
```
|
||||
|
||||
“1=1”包括了“1”作为引用和值(lhs和rhs)的两种形式,在语法上是成立的。
|
||||
|
||||
```
|
||||
foo`${1}`
|
||||
|
||||
```
|
||||
|
||||
所以上面这行代码在语法上也是成立的。因为在这个表达式中,`${1}`使用的不是模板字面量的值,而是它的一个“(类似于引用的)结构”。
|
||||
|
||||
“模板字面量调用(TemplateLiteral Call)”是唯一一个会使用模板字面量的引用形态(并且也没有直接引用它的内部结构)的操作。这种引用形态的模板字面量也被称为“标签模板(Tagged Templates)”,主要包括模板的位置和那些可计算的标签的信息。例如:
|
||||
|
||||
```
|
||||
> var x = 1;
|
||||
> foo = (...args) => console.log(...args);
|
||||
> foo`${x}`
|
||||
[ '', '' ] 1
|
||||
|
||||
```
|
||||
|
||||
模板字面量的内部结构中,主要包括将模板多段截开的一个数组,原始的模板文本(raw)等等。在引擎处理模板时,只会将该模板解析一次,并将这些信息作为一个可执行结构缓存起来(以避免多次解析降低性能),此后将只使用该缓存的一个引用。当它作为字面量被取值时,JavaScript会在当前上下文中计算各个分段中的表达式,并将表达式的结果填回到模板从而拼接成一个结果,最后返回给用户。
|
||||
|
||||
## 思考题
|
||||
|
||||
关于模板的话题其实还有很多可探索的空间,所以建议你仔细阅读一下ECMAScript规范,以便对今天的内容有更深入的理解,例如ECMAScript中如何利用模板的缓存。今天的思考题是:
|
||||
|
||||
- 为什么ECMAScript要创建一个“模板调用”这样古怪的语法呢?
|
||||
|
||||
当然,JavaScript内部其实还有很多其它的可执行结构,我今后还会讲到一些。或者你现在就可以开始去发掘,希望你能与大家一起分享,让我也有机会听听你的收获。
|
||||
@@ -0,0 +1,281 @@
|
||||
<audio id="audio" title="08 | x => x:函数式语言的核心抽象:函数与表达式的同一性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/11/5f31e0bd1bb4f91512f9368621169811.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
在运行期,语句执行和特殊的可执行结构都不是JavaScript的主角,多数情况下,它们都只充当过渡角色而不为开发人员所知。我相信,你在JavaScript中最熟悉的执行体一定是**全局代码**,以及**函数**。
|
||||
|
||||
而今天,我要为你解析的就是函数的执行过程。
|
||||
|
||||
如同在之前分析语句执行的时候与你谈到过的,语句执行是命令式范型的体现,而函数执行代表了JavaScript中对函数式范型的理解。厘清这样的基础概念,对于今天要讨论的内容来说,是非常重要和值得的。
|
||||
|
||||
很多人会从**对象**的角度来理解JavaScript中的函数,认为“函数就是具有[[Call]]私有槽的对象”。这并没有错,但是这却是从静态视角来观察函数的结果。
|
||||
|
||||
要知道函数是执行结构,那么执行过程发生了什么呢?这个问题从对象的视角是既观察不到,也得不到答案的。并且,事实上如果上面这个问题问得稍稍深入一点,例如“对象的方法是怎么执行的呢”,那么就必须要回到“函数的视角”,或者运行期的、动态的角度来解释这一切了。
|
||||
|
||||
## 函数的一体两面
|
||||
|
||||
用静态的视角来看函数,它就是一个函数对象(函数的实例)。如果不考虑它作为对象的那些特性,那么函数也无非就是“用三个语义组件构成的实体”。这三个语义组件是指:
|
||||
|
||||
1. 参数:函数总是有参数的,即使它的形式参数表为空;
|
||||
1. 执行体:函数总是有它的执行过程,即使是空的函数体或空语句;
|
||||
1. 结果:函数总是有它的执行的结果,即使是undefined。
|
||||
|
||||
并且,重要的是“这三个语义组件缺一不可”。晚一点我会再来帮你分析这个观点。现在你应该关注的问题是——我为什么要在此之前强调“用静态的视角来看”。
|
||||
|
||||
在静态的语义分析阶段,函数的三个组件中的两个是显式的,例如在下面的声明中:
|
||||
|
||||
```
|
||||
function f() {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
语法`( )`指示了参数,而`{ }`指示了执行体,并且,我们隐式地知道该函数有一个结果。这也是JavaScript设计经常被批判的一处:由于没有静态类型声明,所以我们也无法知道函数返回何种结果。当我们把这三个部分构成的一个整体看作执行体的时候:
|
||||
|
||||
```
|
||||
(function f() {
|
||||
...
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
那么它的结果是一个函数类型的“数据”。这在函数式语言中称为“函数是第一类型的”,也就是说函数既是可以执行的**逻辑**,也同时是可以被逻辑处理的**数据**。
|
||||
|
||||
函数作为数据时,它是“原始的函数声明”的一个实例(注意这里并不强调它是对象实例)。这个实例必须包括上述三个语义组件中的两个,即**参数**与**执行体**。否则,它作为实例将是不完整的、不能准确复现原有的函数声明的。为了达到这个目的,JavaScript为每个实例创建了一个闭包,并且作为上述“函数类型的‘数据’”的实际结果。例如:
|
||||
|
||||
```
|
||||
var arr = new Array;
|
||||
for (var i=0; i<5; i++) arr.push(function f() {
|
||||
// ...
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,静态的函数`f()`有且仅有一个;而在执行后,`arr[]`中将存在该函数`f()`的5个实例,每一个称为该函数的一个运行期的闭包。它们各各不同,例如:
|
||||
|
||||
```
|
||||
> arr[0] === arr[1]
|
||||
false
|
||||
|
||||
```
|
||||
|
||||
所以简而言之,任何时候只要用户代码引用一次这样的函数(的声明或字面量),那么它就会拿到该函数的一个闭包。注意,得到这个闭包的过程与是否调用它是无关的。
|
||||
|
||||
## 两个语义组件
|
||||
|
||||
上面说过,这样的闭包有两个语义组件:参数和执行体。在创建这个闭包时,它们也将同时被实例化。
|
||||
|
||||
这样做的目的,是为了保证每个实例/闭包都有一个自己独立的运行环境,也就是**运行期上下文**。JavaScript中的闭包与运行环境并没有明显的语义差别,唯一不同之处,仅在于这个“运行环境”中每次都会有一套新的“参数”,且执行体的运行位置(如果有的话)被指向函数代码体的第一个指令。
|
||||
|
||||
然而,你或许会问:我为什么要如此细致地强调这一点,巨细无遗地还原创建这样的环境的每一步呢?
|
||||
|
||||
这样的小心和质疑是必要的!如果你真的这样问了,那么非常感谢,你提出了“函数”的一个关键假设:它可以是多次调用的。
|
||||
|
||||
这显然是废话。
|
||||
|
||||
但是,如果你将它与之前讨论过的for循环对照起来观察的话,就会发现一个事实:函数体和for的循环体(这些用来实现逻辑复用的执行结构)的创建技术,是完全一样的!
|
||||
|
||||
也就是说,命令式语句和函数式语言,是采用相同的方式来执行逻辑的。只不过前者把它叫做_iteratorEnv_,是_loopEnv_的实例;后者把它叫做闭包,是函数的实例。
|
||||
|
||||
再往源头探究一点:导致for循环需要多个_iteratorEnv_实例的原因,在于循环语句试图在多个迭代中复用参数(迭代变量),而函数这样做的目的,也同时是为了处理这些参数(形式参数表)的复用而已。
|
||||
|
||||
所以,闭包的作用与实现方法都与“for循环”中的迭代环境没有什么不同。同样地,对于这一讲的标题中的这行代码来说,它也(首先)代表了这样两个语义组件:
|
||||
|
||||
- 参数`x`
|
||||
- 执行体`x`
|
||||
|
||||
在闭包创建时,参数x将作为闭包(作用域/环境)中的名字被初始化——这个过程中“参数x”只作为名字或标识符,并且“将会在”闭包中登记一个名为“x”的变量;按照约定,它的值是undefined。并且,还需要强调的是,这个过程是引擎为闭包初始化的,发生于用户代码得到这个闭包之前。
|
||||
|
||||
然而所谓“参数的登记过程”很重要吗?当然重要。
|
||||
|
||||
## 简单参数类型
|
||||
|
||||
完整而确切地说,这一讲标题中的函数是一个“简单参数类型的箭头函数”。而下面这个就不“简单”了:
|
||||
|
||||
```
|
||||
(x = x) => x;
|
||||
|
||||
```
|
||||
|
||||
在ECMAScript 6之前的函数声明中,它们的参数都是“简单参数类型”的。在ECMAScript 6之后,凡是在参数声明中使用了缺省参数、剩余参数和模板参数之一的,都不再是“简单的”(**non-simple parameters**)。在具体实现中,这些新的参数声明意味着它们会让函数进入一种特殊模式,由此带来三种限制:
|
||||
|
||||
1. 函数无法通过显式的"use strict"语句来切换到严格模式,但能接受它被包含在一个严格模式的语法块中(从而隐式地切换到严格模式);
|
||||
1. 无论是否在严格模式中,函数参数声明都将不接受“重名参数”;
|
||||
1. 无论是否在严格模式中,形式参数与arguments之间都将解除绑定关系。
|
||||
|
||||
这样处理的原因在于:在使用传统的简单参数时,只需要将调用该参数时传入的实际参数与参数对象(arguments)绑定就可以了;而使用“非简单参数”时,需要通过“初始器赋值”来完成名字与值的绑定。同样,这也是导致“形式参数与arguments之间解除绑定关系”的原因。
|
||||
|
||||
>
|
||||
NOTE 1: 两种绑定模式的区别在于:通常将实际参数与参数对象绑定时,只需要映射两个数组的下标即可,而“初始器赋值”需要通过名字来索引值(以实现绑定),因此一旦出现“重名参数”就无法处理了。
|
||||
|
||||
|
||||
所以,所谓参数的登记过程,事实上还影响了它们今后如何绑定实际传入的参数。
|
||||
|
||||
## 传入参数的处理
|
||||
|
||||
要解释“参数的传入”的完整过程,得先解释**为什么“形式参数需要两种不同的登记过程”**。而在这所有一切之前,还得再解释什么是“**传入的参数**”。
|
||||
|
||||
首先,JavaScript的函数是“非惰性求值”的,也就是说在函数界面上不会传入一个延迟计算的求值过程,而是“积极地”传入已经求值的结果。例如:
|
||||
|
||||
```
|
||||
// 一般函数声明
|
||||
function f(x) {
|
||||
console.log(x);
|
||||
}
|
||||
|
||||
// 表达式`a=100`是“非惰性求值”的
|
||||
f(a = 100);
|
||||
|
||||
```
|
||||
|
||||
在这个示例中,传入函数`f()`的将是赋值表达式`a = 100`完成计算求值之后的结果。考虑到这个“结果”总是存在“值和引用”两种表达形式,所以JavaScript在这里约定“传值”。于是,上述示例代码最终执行到的将是`f(100)`。
|
||||
|
||||
回顾这个过程,请你注意一个问题:`a = 100`这行表达式执行在哪个上下文环境中呢?
|
||||
|
||||
答案是:在函数外(上例中是全局环境)。
|
||||
|
||||
接下来才来到具体调用这个函数`f()`的步骤中。而直到这个时候,JavaScript才需要向环境中的那些名字(例如`function f(x)`中的形式参数名x)“绑定实际传入的值”。对于这个`x`来说,由于参数与函数体使用同一个块作用域,因此如果函数参数与函数内变量同名,那么它们事实上将是同一个变量。例如:
|
||||
|
||||
```
|
||||
function f(x) {
|
||||
console.log(x);
|
||||
var x = 200;
|
||||
}
|
||||
// 由于“非惰性求值”,所以下面的代码在函数调用上完全等义于上例中`f(a = 100)`
|
||||
f(100);
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,函数内的三个x实际将是同一个变量,因此这里的`console.log(x)`将显示变量`x`的传入参数值100,而`var x = 200;`并不会导致“重新声明”一个变量,仅仅是覆盖了既有的x。
|
||||
|
||||
现在我们回顾之前讨论的两个关键点:
|
||||
|
||||
1. 参数的登记过程发生在闭包创建的过程中;
|
||||
1. 在该闭包中执行“绑定实际传入的参数”的过程。
|
||||
|
||||
## 意外
|
||||
|
||||
对于后面这个过程来说,如果参数是简单的,那么JavaScript引擎只需要简单地绑定它们的一个对照表就可以了。并且,由于所有被绑定的、传入的东西都是“值”,所以没有任何需要引用其它数据的显式执行过程。“值”是数据,而非逻辑。
|
||||
|
||||
所以,对于简单参数来说,是没有“求值过程”发生于函数的调用界面上的。然而,对于下面例子中这样的“非简单参数”函数声明来说:
|
||||
|
||||
```
|
||||
function foo(x = 100) {
|
||||
console.log(x);
|
||||
}
|
||||
foo();
|
||||
|
||||
```
|
||||
|
||||
在“绑定实际传入的参数”时,就需要执行一个“x = 100”的计算过程。不同于之前的`f(a = 100)`,在这里的表达式`x = 100`将执行于这个新创建的闭包中。这很好理解,左侧的“参数x”是闭包中的一个语法组件,是初始化创建在闭包中的一个变量声明,因此只有将表达式放在这个闭包中,它才可以正确地完成计算过程。
|
||||
|
||||
然而这样一来,在下面这个示例中,表达式右侧的`x`也将是该闭包中的x。
|
||||
|
||||
```
|
||||
f = (x = x) => x;
|
||||
|
||||
```
|
||||
|
||||
这貌似并没有什么了不起的,但真正使用它的时候,会触发一个异常:
|
||||
|
||||
```
|
||||
> f()
|
||||
ReferenceError: x is not defined
|
||||
at f (repl:1:10)
|
||||
|
||||
```
|
||||
|
||||
这是一个意外。
|
||||
|
||||
## 无初值的绑定
|
||||
|
||||
这个异常提示其实并不准确,因为在这个上下文环境(闭包)中,`x`显然是声明过的。事实上,这也是两种不同的登记过程(“直接arguments绑定”与“初始器赋值”)的主要区别之一。尽管在本质上,这两种登记过程所初始化的变量都是相同的,称为“**可变绑定**(**Mutable Binding**)”。
|
||||
|
||||
“可变”是指它们可以多次赋值,简单地说就是let/var变量。但显然地,上述的示例正好展示了var/let的两种不同性质:
|
||||
|
||||
```
|
||||
function foo() {
|
||||
console.log(x); // ReferenceError: x is not defined
|
||||
let x = 100;
|
||||
}
|
||||
foo();
|
||||
|
||||
```
|
||||
|
||||
由于let变量不能在它的声明语句之前(亦即是未初始化之前)访问,因此上例触发了与之前的箭头函数`f()`完全相同的异常。也就是说,在`(x = x) => x`中的三个x都是指向相同的变量,并且当函数在尝试执行“初始器赋值”时会访问第2个`x`,然而此时由于变量x是未赋值的,因此它就如同let变量一样不可访问,从而触发异常。
|
||||
|
||||
为什么在处理函数的参数表时要将`x`创建为一个let变量,而不是var变量呢?
|
||||
|
||||
事实上,这二者并没有区别,如之前我所讲过的,它们都是“可变绑定(**Mutable Binding**)”。并且,对于var/let来说,一开始的时候它们其实都是“无初值的绑定”。只不过JavaScript在处理var语句声明的变量时,将这个“绑定(Binding)”赋了一个初值`undefined`,因此你才可以在代码中自由、提前地访问那些“var变量”。而对应的,let语句声明的变量没有“缺省地”赋这个初值,所以才不能在第一行赋值语句之前访问它,例如:
|
||||
|
||||
```
|
||||
console.log(x); // ReferenceError: x is not defined
|
||||
let x = 100;
|
||||
|
||||
```
|
||||
|
||||
处理函数参数的过程与此完全相同:参数被创建成“可变绑定”,如果它们是简单参数则被置以初值`undefined`,否则它们就需要一个所谓的“初始器”来赋初值。也就是说,并非JavaScript要刻意在这里将它作为var/let变量之一来创建,而只是用户逻辑执行到这个位置的时候,所谓的“可变绑定”还没有来得及赋初值罢了。
|
||||
|
||||
然而,唯一在这个地方还存疑的是:**为什么不干脆就在“初始器”创建的时候,就赋一个初值undefined呢?**
|
||||
|
||||
说到这里,可能你也猜到了,因为在“缺省参数”的语法设计里面,undefined正好是一个有意义的值,它用于表明参数表指定位置上的形式参数是否有传入,所以参数undefined也就不能作为初值来绑定,这就导致了使用“初始器”的参数表中,所对应那些变量是一个“无初值的绑定”。
|
||||
|
||||
因此如果这个“初始器”(我是指在它初始化的阶段里面)正好也要访问变量自身,那么就会导致出错了。而这个出错过程也就与如下示例的代码是一样的,并且也导致一样的错误:
|
||||
|
||||
```
|
||||
> let x = x;
|
||||
ReferenceError: x is not defined
|
||||
|
||||
```
|
||||
|
||||
所以,最终的事实是`(x = x) => x`这样的语法并不违例,而是第二个`x`导致了非法访问“无初值的绑定”。
|
||||
|
||||
## 最小化的函数式语言示例
|
||||
|
||||
那么现在我再来为你解析一下标题中的`x => x`。
|
||||
|
||||
这行代码意味着一个最小化的函数。它包括了一个函数完整的三个语法组件:**参数**、**执行体**和**结果**,并且也包括了JavaScript实现这三个语法组件的全部处理过程——这些是我在这一讲中所讨论的全部内容。重要的是,它还直观地反映了“函数”的本质,就是“数据的转换”。也就是说,所有的函数与表达式求值的本质,都是将数据`x`映射成`x'`。
|
||||
|
||||
我们编写程序的这一行为,在本质上就是针对一个“输入(x, input/arguments)”,通过无数次的数据转换来得到一个最终的”输出(x’, output/return)”。所有计算的本质皆是如此,所有的可计算对象也可以通过这一过程来求解。
|
||||
|
||||
因此,函数在能力上也就等同于全部的计算逻辑(等同于结构化程序思想中的“单入口->单出口”的顺序逻辑)。
|
||||
|
||||
箭头函数是匿名的,并且事实上所谓名字并不是函数在语言学中的重要特性。名字/标识符,是语法中的词法组件,它指代某个东西的抽象,但它本身既不是计算的过程(逻辑),也不是计算的对象(数据)。
|
||||
|
||||
那么,我接下来要说的是,**没有名字的函数在语言中的意义是什么呢?**
|
||||
|
||||
它既是**逻辑**,也是**数据**。例如:
|
||||
|
||||
```
|
||||
let f = x => x;
|
||||
let zero = f.bind(null, 0);
|
||||
|
||||
```
|
||||
|
||||
现在,zero既是一个逻辑,是可以执行的过程,它返回数值0;也是一个数据,它包含数值0。
|
||||
|
||||
>
|
||||
NOTE 1:箭头函数与别的函数的不同之处在于它并不绑定“this”和“arguments”。此外,由于箭头函数总是匿名的,因此它也不会在环境中绑定函数名。
|
||||
|
||||
|
||||
>
|
||||
NOTE 2: ECMAScript 6之后的规范中,当匿名函数或箭头函数赋给一个变量时,它将会以该变量名作为函数名。但这种情况下,该函数名并不会绑定给环境,而只是出现在它的属性中,例如“**f.name**”。
|
||||
|
||||
|
||||
## 知识回顾
|
||||
|
||||
现在我来为这一讲的内容做个回顾。
|
||||
|
||||
1. 传入参数的过程执行于函数之外,例如`f(a=100)`;绑定参数的过程执行于函数(的闭包)之内,例如`function foo(x=100) ..`。
|
||||
1. `x=>x`在函数界面的两端都是值操作,也就是说input/output的都是数据的值,而不是引用。
|
||||
1. 参数有两种初始化方法,它们根本的区别在于绑定初值的方式不同。
|
||||
1. 闭包是函数在运行期的一个实例。
|
||||
|
||||
## 思考题
|
||||
|
||||
1. 表达式如何等价于上述计算过程?
|
||||
1. 表达式与函数在抽象概念上的异同?
|
||||
1. 试以表达式来实现标题中的箭头函数的能力。
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -0,0 +1,386 @@
|
||||
<audio id="audio" title="09 | (...x):不是表达式、语句、函数,但它却能执行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/65/e4d105b8e77cf7ff7010baf1ced35465.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,欢迎回到我的专栏。
|
||||
|
||||
从之前的课程中,你应该已经对语句执行和函数执行有了基本的了解。事实上,这两种执行其实都是对**顺序**、**分支**与**循环**三种逻辑在语义上的表达。
|
||||
|
||||
也就是说,不论一门语言的语法有什么特异之处,它对“执行逻辑”都可以归纳到这三种语义的表达方式上来。这种说法事实上也并不特别严谨,因为这三种基本逻辑还存在进一步抽象的空间(这些也会是我将来会讨论到的内容,今天暂且不论)。
|
||||
|
||||
今天这一讲中,主要讨论的是第二种执行的一些细节,也就是对“函数执行”的进一步补充。
|
||||
|
||||
在上一讲中,我有意将函数分成三个语义组件来讲述。我相信在绝大多数的情况下,或者在绝大多数的语言教学中,都是不必要这样做的。这三个语义组件分别是指参数、执行体和结果,将它们分开来讨论,最主要的价值就在于:通过改造这三个语义组件的不同部分,我们可以得到不同的“函数式的”执行特征与效果。换而言之,可以通过更显式的、特指的或与应用概念更贴合的语法来表达新的语义。与所谓“特殊可执行结构”一样,这些语义也用于映射某种固定的、确定的逻辑。
|
||||
|
||||
语言的设计,本质就是为确定的语义赋以恰当的语法表达。
|
||||
|
||||
## 递归与迭代
|
||||
|
||||
如果循环是一种确定的语义,那么如何在函数执行中为它设计合适的语法表达呢?
|
||||
|
||||
递归绝对是一个好的、经典的求解思路。递归将循环的次数直接映射成函数“执行体”的重复次数,将循环条件放在函数的参数界面中,并通过函数调用过程中的值运算来传递循环次数之间的数值变化。递归作为语义概念简单而自然,唯一与函数执行存在(潜在的)冲突的只是所谓栈的回收问题,亦即是尾递归的处理技巧等,但这些都是实现层面的要求,而与语言设计无关。
|
||||
|
||||
由于递归并不改变函数的三个语义组件中的任何一个,因此它与函数执行过程完全没有冲突,也没有任何新的需求与设计。这句话的潜在意思是说,函数的三个语义组件都不需要为此作出任何的设计修改,例如:
|
||||
|
||||
```
|
||||
const f = x => x && f(--x);
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,是没有出现任何特殊的语法和运算/操作符的,它只是对函数、(变量或常量的)声明、表达式以及函数调用等等的简单组合。
|
||||
|
||||
然而迭代不是。迭代也是循环语义的一种实现,它说明循环是“函数体”的重复执行,而不是“递归”所理解的“函数调用自己”的语义。这是一种可受用户代码控制的循环体。你可以尝试创建这样一个简单的迭代函数:
|
||||
|
||||
```
|
||||
// 迭代函数
|
||||
function foo(x = 5) {
|
||||
return {
|
||||
next: () => {
|
||||
return {done: !x, value: x && x--};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然而请仔细观察这样的两个实现,你需要注意在这个迭代函数中有“值(value)和状态(done)”两个控制变量,并且它的实际执行代码与上面的函数f()是一样的:
|
||||
|
||||
```
|
||||
// in 函数f()
|
||||
x && f(--x)
|
||||
|
||||
// in 迭代foo()
|
||||
x && x--
|
||||
|
||||
```
|
||||
|
||||
也就是说,递归函数“**f()**”和迭代函数“foo()”其实是在实现相同的过程。只是由于“递归完成与循环过程的结束”在这里是相同的语义,因此函数“**f()**”中不需要像迭代函数那样来处理“状态(done)”的传出。递归函数“**f()**”,要么结束,要么无穷递归。
|
||||
|
||||
## 迭代对执行过程的重造和使用
|
||||
|
||||
在JavaScript中,是通过一个中间对象来使用迭代过程_foo()_的。该中间对象称为迭代器,foo()称为迭代器函数,用于返回该迭代器。例如:
|
||||
|
||||
```
|
||||
var tor = foo(); // default `x` is 5
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
迭代器具有`.next()`方法用于一次(或每次)迭代调用。由于没有约定迭代调用的方式,因此可以用任何过程来调用它。例如:
|
||||
|
||||
```
|
||||
// 在循环语句中处理迭代调用
|
||||
var tor = foo(5), result = tor.next();
|
||||
while (!result.done) {
|
||||
console.log(result.value);
|
||||
result = tor.next();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除了一些简单的、概念名词上的置换外,这些与你所见过的绝大多数有关“迭代器与生成器”的介绍并没有什么不同。并且你也应当理解,正是这个“.next()”调用的界面维护了迭代过程的上下文,以及值之间的相关性(例如一个值序列的连续性)。
|
||||
|
||||
根据约定,如果有一个对象“包含”这样一个迭代器函数(以返回一个迭代器),那么这个对象就是可迭代的。基于JavaScript中“对象是属性集(所以所有包含的东西都必然是属性)”的概念,这个迭代函数被设计为称为“Symbol.iterator”的符号属性。例如:
|
||||
|
||||
```
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo; // default `x` is 5
|
||||
|
||||
```
|
||||
|
||||
现在,你可以使用这个可迭代对象了:
|
||||
|
||||
```
|
||||
> console.log(...x);
|
||||
5 4 3 2 1
|
||||
|
||||
```
|
||||
|
||||
现在,你看到了这一讲标题中的代码:
|
||||
|
||||
```
|
||||
(...x)
|
||||
|
||||
```
|
||||
|
||||
不过,不同的是,标题中的代码是不能执行的。
|
||||
|
||||
## 展开语法
|
||||
|
||||
问题的关键点在于:`...x`是什么?
|
||||
|
||||
在形式上,“…”看起来像是一个运算符,而`x`是它的操作数。但是,如果稍微深入地问一下这个问题,就会令人生疑了。例如:如果它是运算符,那么运算的返回值是什么?
|
||||
|
||||
答案是,它既不返回值,也不返回引用。
|
||||
|
||||
那么如果它不是运算符,或者说`...x`也并不是表达式,或许它们可以被理解为“语句”吗?即使如此,与上面相同的问题也会存在。例如:如果它是语句,那么该语句的返回值是什么?
|
||||
|
||||
答案是,既不是空(Empty),也不是其它结果(Result)。因此它也不是语句(并且,因为console.log()是表达式,而表达式显然也“不可能包含语句”)。
|
||||
|
||||
所以,`...x`既不是表达式,也不是语句。它不是我们之前讲过的任何一种概念,而仅仅只是“语法”。作为语法,ECMAScript在这里规定它只是对一个确定的语义的封装。
|
||||
|
||||
在语义上,它用于“展开一个可迭代对象”。
|
||||
|
||||
## 如何做到呢?
|
||||
|
||||
为什么我要绕这么大个圈子来介绍这个“简单的”展开语法呢?又或者说,ECMAScript为什么要弄出这么一个“新”概念呢?
|
||||
|
||||
这与函数的第三个语义组件——“值”是有关的。在JavaScript中(也包括在绝大多数支持函数的语言中),函数只能返回一个值。然而,如果迭代器表达的是一个重复执行的执行体,并且每次执行都返回一个值,那么又怎么可能用“返回一个值”的函数来返回呢?
|
||||
|
||||
与此类似,语句也只有一个这样的单值返回,所以批语句执行也仍然只是返回最后一行的结果。并且,一旦`...x`被理解为语句,那么它就不能用作操作数,成为一个表达式的部分。这在概念上是不容许的。所以,当在“函数”这个级别表达多次调用时,尽管它可以通过“对象(迭代对象)”来做形式上的封装,却无法有效地表达“多次调用的多个结果”。这才是展开语法被设计出来的原因。
|
||||
|
||||
如果可迭代对象表达的是“多个值”,那么可以作用于它的操作或运算通常应该是那些面向“值的集合(Collections)”的。更确切地说,它是可以面向“索引集合(Indexed Collections)”和“键值集合(Keyed Collections)”设计的语法概念。因此在现在的,以及将来的ECMAScript规范中,你将会看到它的操作,例如通常包括的合并、映射、筛选等等,将在包括对象、数组、集(Set)、图(Map)等等数据的处理中大放异彩。
|
||||
|
||||
而现在,其实我想问的问题是,在函数中是如何做到迭代处理的呢?
|
||||
|
||||
## 内部迭代过程
|
||||
|
||||
迭代的本质是多次函数调用,在JavaScript内部实现这一机制,本质上就是管理这些多次调用之间的关系。这显然包括一个循环过程,和至少一个循环控制变量。
|
||||
|
||||
这个迭代有一个开启过程,简单的如展开语法(“…”),复杂的如for…of语句。这些语法/语法结构通过类似如下两个步骤来完成迭代的开启:
|
||||
|
||||
```
|
||||
var tor = foo(5), result = tor.next();
|
||||
while (!result.done) ...
|
||||
|
||||
```
|
||||
|
||||
但是如同我在之前的课程,以及上面的讨论中一再强调的这是“一个执行过程”,既然是过程,那么就存在过程被中断的可能。简单的示例如下:
|
||||
|
||||
```
|
||||
while (!result.done) {
|
||||
break;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
是的,这个过程什么也不会发生。如果是在经典的while循环里面,那么它的result和tor,以及foo()调用所开启的那个函数闭包都被当前上下文管理或回收。然而,如果在一个展开过程,或者for…of循环中,相应的“语法”管理上述这些组件的时候又需要怎样的处理呢?例如:
|
||||
|
||||
```
|
||||
function touch(x) {
|
||||
if (x==2) throw new Error("hard break");
|
||||
}
|
||||
|
||||
// 迭代函数
|
||||
function foo2(x = 5) {
|
||||
return {
|
||||
next: () => {
|
||||
touch(x); // some process methods
|
||||
return {done: !x, value: x && x--};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 示例
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo2; // default `x` is
|
||||
|
||||
```
|
||||
|
||||
测试如下:
|
||||
|
||||
```
|
||||
> console.log(...x);
|
||||
Error: hard break
|
||||
|
||||
```
|
||||
|
||||
这个示例是一个简单异常,但如果这个异常发生于for…of中:
|
||||
|
||||
```
|
||||
> for (let i of x) console.log(i);
|
||||
5
|
||||
4
|
||||
3
|
||||
Error: hard break
|
||||
|
||||
```
|
||||
|
||||
在这两种示例中,异常都是发生于foo2()这个函数调用的一个外部处理过程中,而等到用户代码有机会操作时,已经处于console.log()调用或for…of循环中了,如果用户在这里设计异常处理过程,那么foo2()中的touch(x)管理和涉及的资源都无法处理。因此,ECMAScript设计了另外两个方法来确保foo2()中的代码在“多次调用”中仍然是受控的。这包括两个回调方法:
|
||||
|
||||
>
|
||||
<p>tor.return(),当迭代正常过程退出时回调<br>
|
||||
tor.throw(),当迭代过程异常退出时回调</p>
|
||||
|
||||
|
||||
这并不难于证实:
|
||||
|
||||
```
|
||||
> Object.getOwnPropertyNames(tor.constructor.prototype)
|
||||
[ 'constructor', 'next', 'return', 'throw' ]
|
||||
|
||||
```
|
||||
|
||||
现在如果给tor的return属性加一个回调函数,会发生什么呢?
|
||||
|
||||
```
|
||||
// 迭代函数
|
||||
function foo2(x = 5) {
|
||||
return {
|
||||
// 每次.next()都不会返回done状态,因此可列举无穷次
|
||||
"next": () => new Object, // result instance, etc.
|
||||
"return": () => console.log("RETURN!")
|
||||
}
|
||||
}
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo2; // default `x` is 5
|
||||
|
||||
```
|
||||
|
||||
测试一下:
|
||||
|
||||
```
|
||||
# 列举x,第一次迭代后即执行break;
|
||||
> for (let i of x) break;
|
||||
RETURN!
|
||||
|
||||
```
|
||||
|
||||
结果是`RETURN!`?
|
||||
|
||||
什么鬼?!
|
||||
|
||||
## 异常处理
|
||||
|
||||
并且如果你试图在tor.throw中去响应foo()迭代中的异常,却什么也得不到。例如:
|
||||
|
||||
```
|
||||
// 迭代函数
|
||||
function foo3(x = 5) {
|
||||
return {
|
||||
// 第一个.next()执行时即发生异常
|
||||
"next": () => { throw new Error },
|
||||
"throw": () => console.log("THROW!")
|
||||
}
|
||||
}
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo3;
|
||||
|
||||
```
|
||||
|
||||
在测试中,异常直接被抛给了全局:
|
||||
|
||||
```
|
||||
> console.log(...x);
|
||||
Error
|
||||
at Object.next (repl:4:27)
|
||||
|
||||
```
|
||||
|
||||
继续!显然可以把这个例子跟最开始使用的foo()组合起来,foo()迭代可以正确地得到`5 4 3 2 1`,而上面的return/throw可以捕获过程的退出或异常。例如:
|
||||
|
||||
```
|
||||
// 迭代函数
|
||||
function foo4(x = 5) {
|
||||
return {
|
||||
// foo()中的next
|
||||
next: () => {
|
||||
return {done: !x, value: x && x--};
|
||||
},
|
||||
|
||||
|
||||
// foo2()和foo3()中的return和throw
|
||||
"return": () => console.log("RETURN!"),
|
||||
"throw": () => console.log("THROW!")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo4
|
||||
|
||||
```
|
||||
|
||||
测试:
|
||||
|
||||
```
|
||||
>>console.log(...x);
|
||||
5 4 3 2 1
|
||||
|
||||
```
|
||||
|
||||
Ok,成功是成功了!但是,“RETURN/THROW“呢?
|
||||
|
||||
这里简直就是迭代的地狱!
|
||||
|
||||
## 是谁的退出与异常?
|
||||
|
||||
回顾之前的内容,迭代过程并不是一个语法执行的过程,而是应该理解为一组函数执行的过程;对于这一批函数执行过程中的结束行为,也应该理解为函数内的异常或退出。因此,尽管在for…of的表面上看,是break发生了语句中的中止,而在迭代处理的内部发生的,却是“一个迭代过程的退出”。与此同样复杂的是,在这一批函数的多个执行上下文中,不论是在哪儿发生了异常,其实只有外层的第一个能捕获异常的环境能响应这个异常。
|
||||
|
||||
简单地说:“退出(RETURN)”是执行过程的,“异常(THROW)”是外部的。
|
||||
|
||||
JavaScript中,迭代被处理为两个实现用的组件,一个是(循环的)迭代过程,另一个是(循环的)迭代控制变量。表现在tor这个迭代对象上来看,就是(对于循环来说,)“如果谁使用迭代变量tor,那么就是谁管理迭代过程”。
|
||||
|
||||
这个“管理循环过程”意味着:
|
||||
|
||||
- 如果迭代结束(不论它因为什么结束),那么触发tor.return事件;
|
||||
- 如果发现异常(只要是当前环境能捕获到的异常),那么触发tor.throw事件。
|
||||
|
||||
这两个过程总是发生在“管理循环过程”的行为框架中。例如在下面这个过程中:
|
||||
|
||||
```
|
||||
for (let i of x) {
|
||||
if (i == 2) break;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于 `for .. of`语句将获得x对象的迭代变量tor,那么它也将管理x对象的迭代过程。因此,在for语句break之后(在for语句将会退出自己的作用之前),它也就必须去“通知”x对象迭代过程也结束了,于是这个语句触发了tor.return事件。
|
||||
|
||||
同样,如果是一个数组展开过程:
|
||||
|
||||
```
|
||||
console.log(...x);
|
||||
|
||||
```
|
||||
|
||||
那么将是`...x`这个“展开语法”来负责上述的迭代过程的管理和“通知”,这个语法在它所在的位置上是无法响应异常的。该语法所在位置是一个表达式,不可能在它内部使用`try..catch`语句。
|
||||
|
||||
```
|
||||
function touch(x) {
|
||||
if (x==2) throw new Error("hard break");
|
||||
}
|
||||
|
||||
// 迭代函数
|
||||
function foo5(x = 5) {
|
||||
return {
|
||||
// foo2()中的next
|
||||
next: () => {
|
||||
touch(x); // some process methods
|
||||
return {done: !x, value: x && x--};
|
||||
},
|
||||
|
||||
// foo3()中的return和throw
|
||||
"return": () => console.log("RETURN!"),
|
||||
"throw": () => console.log("THROW!")
|
||||
}
|
||||
}
|
||||
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo5;
|
||||
|
||||
try {
|
||||
console.log(...x);
|
||||
}
|
||||
catch(e) {} // m
|
||||
|
||||
```
|
||||
|
||||
这段示例代码将mute掉一切:既没有console.log()输出,也没有异常信息,tor的return/throw一个也没有发生。
|
||||
|
||||
对于x这个可迭代对象,以及foo5()这个迭代器函数来说,世界是安静的:它既不知道自己发生了什么,也不知道它的外部世界发生了什么。因为`...x`这个语法既没有管理迭代过程(因此不理解tor的退出/return行为),也没有在异常发生时向内“通知”tor.throw事件的能力。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
标题中的示例是不能执行的,因为其中的括号并不是表达式中分组运算符,也不是语句中的函数调用,也不是声明中的形式参数表。声明中的`...x`被定义为“展开语法”,是逻辑的映射(它返回的是处理逻辑),而不是“值”或“引用”。它在不同的位置被JavaScript解释成不同的语义,包括对象展开和数组展开,并通过一组特定的代码来实现上述的语义。
|
||||
|
||||
在`...x`被理解为数组展开时,本质上是将`x`视为一个可迭代对象,并通过一个迭代变量(例如tor)来管理它的迭代过程。在JavaScript中的迭代对象x的生存周期是交由使用它的表达式、语句或语法来管理的,包括在必要的时候通过tor来向内通知return/throw事件。
|
||||
|
||||
在本讲的示例中,展开语法“…x”是没有向内通知的能力的,而“for … of”可以隐式地向内通知。对于后者,for…of中的break和continue,以及循环的正常退出都能够通知return事件,但它并没有内向通知throw的能力,因为for…of语句本身并不捕获和处理throw。
|
||||
|
||||
## 思考题
|
||||
|
||||
- 既然上面的过程完全不使用tor.throw,那么它被设计出来做什么?
|
||||
- `...x`为什么称为“展开语法”,为什么ECMAScript不提供一个表达式/语句之外的概念来指代它?
|
||||
- continue在那种情况下触发tor.return?
|
||||
- yield* x将导致什么?
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -0,0 +1,254 @@
|
||||
<audio id="audio" title="10 | x = yield x:迭代过程的“函数式化”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/c9/7a8d0796b78dfa1fbfe30f0c021945c9.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。欢迎回到我的专栏。
|
||||
|
||||
相信上一讲的迭代过程已经在许多人心中留下了巨大的阴影,所以很多人一看今天的标题,第一个反应是:“又来!”
|
||||
|
||||
其实我经常习惯用**同一个例子**,或者**同类型示例的细微不同**去分辨与反映语言特性上的核心与本质的不同。如同在[第2讲](https://time.geekbang.org/column/article/165198)和[第3讲](https://time.geekbang.org/column/article/165985)中都在讲的连续赋值,看起来形似,却根本上不同。
|
||||
|
||||
同样,我想你可能也已经注意到了,在[第5讲](https://time.geekbang.org/column/article/167907)(for (let x of [1,2,3]) ...)和[第9讲](https://time.geekbang.org/column/article/172636)((...x))中所讲述的内容是有一些相关性的。它们都是在讲循环。但第5讲主要讨论的是语句对循环的抽象和如何在循环中处理块。而第9讲则侧重于如何通过函数执行把(类似第5讲的)语句执行重新来实现一遍。事实上,仅仅是一个“循环过程”,在JavaScript中就实现了好几次。这些我将来都会具体地来为你分析。
|
||||
|
||||
至于今天,我还是回到函数的三个语义组件,也就是“参数、执行体和结果”来讨论。上一讲本质上讨论的是对“执行体”这个组件的重造,今天,则讨论对“参数和结果”的重构。
|
||||
|
||||
## 将迭代过程展开
|
||||
|
||||
通过上一讲,你应该知道迭代器是可以表达为一组函数的连续执行的。那么,如果我们要把这一组函数展开来看的话,其实它们之间的相似性是极强的。例如上一讲中提到的迭代函数`foo()`,当你把它作为对象x的迭代器符号名属性,并通过对象x来调用它的迭代展开,事实上也就相当于只调用了多次的return语句。
|
||||
|
||||
```
|
||||
// 迭代函数
|
||||
function foo(x = 5) {
|
||||
return {
|
||||
next: () => {
|
||||
return {done: !x, value: x && x--};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo; // default `x` is 5
|
||||
console.log(...x);
|
||||
|
||||
```
|
||||
|
||||
事实上相当于只调用了5次return语句,可以展开如下:
|
||||
|
||||
```
|
||||
// 上例在形式上可以表达为如下的逻辑
|
||||
console.log(
|
||||
/*return */{done: false, value: 5}.value,
|
||||
/*return */{done: false, value: 4}.value,
|
||||
/*return */{done: false, value: 3}.value,
|
||||
/*return */{done: false, value: 2}.value,
|
||||
/*return */{done: false, value: 1}.value
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
在形式上,类似上面这样的例子也可以展开来,表现它作为“多个值”的输出过程。
|
||||
|
||||
事实上连续的tor.next()调用最终仅是为了获取它们的值(result.value),那么如果封装这些值的生成过程,就可以用一个新的函数来替代一批函数。
|
||||
|
||||
这样的一个函数就称为**生成器函数**。
|
||||
|
||||
但是,由于函数只有一个出口(RETURN),所以用“函数的退出”是无法映射“**函数包含一个多次生成值的过程**”这样的概念的。如果要实现这一点,就必须让函数可以多次进入和退出。而这,就是今天这一讲的标题上的这个`yield` 运算符的作用。这些作用有两个方面:
|
||||
|
||||
1. 逻辑上:它产生一次函数的退出,并接受下一次tor.next()调用所需要的进入;
|
||||
1. 数据上:它在退出时传出指定的值(结果),并在进入时携带传入的数据(参数)。
|
||||
|
||||
所以,`yield`实际上就是在生成器函数中用较少的代价来实现一个完整“函数执行”过程所需的“参数和结果”。而至于“执行体”这个组件,如果你听过上一讲的话,相信你已经知道了:执行体就是tor.next()所推动的那个迭代逻辑。
|
||||
|
||||
例如,上面的例子用生成器来实现就是:
|
||||
|
||||
```
|
||||
function *foo() {
|
||||
yield 5;
|
||||
yield 4;
|
||||
yield 3;
|
||||
yield 2;
|
||||
yield 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或者更通用的过程:
|
||||
|
||||
```
|
||||
function *foo2(x=5) {
|
||||
while (x--) yield x;
|
||||
}
|
||||
|
||||
// 测试
|
||||
let x = new Object;
|
||||
x[Symbol.iterator] = foo2; // default `x` is 5
|
||||
console.log(...x); // 4 3 2 1 0
|
||||
|
||||
```
|
||||
|
||||
我们又看到了循环,尽管它被所谓的生成器函数封装了一次。
|
||||
|
||||
## 逻辑的重现
|
||||
|
||||
我想你已经注意到了,生成器的关键在于如何产生`yield`运算所需要的两个逻辑:(函数的)退出和进入。
|
||||
|
||||
事实上生成器内部是顺序的5行代码,还是一个循环逻辑,所以对于外部的使用者来说它是不可知的。生成器通过一个迭代器接口的界面与外部交互,只要`for..of`或`...x`以及其他任何语法、语句或表达式识别该迭代器接口,那么它们就可以用tor.next()以及result.done状态来组织外部的业务逻辑,而不必界面后面的(例如数据传入传出的)细节了。
|
||||
|
||||
然而,对于生成器来说,“(函数的)退出和进入”是如何实现的呢?
|
||||
|
||||
在[第6讲](https://time.geekbang.org/column/article/168980)(x: break x;)中提到过“**执行现场**”这个东西,事实上它包括三个层面的概念:
|
||||
|
||||
1. 块级作用域以及其他的作用域本质上就是一帧数据,交由所谓“环境”来管理;
|
||||
1. 函数是通过CALL/RETURN来模拟上述“数据帧”在栈上的入栈与出栈过程,也称为调用栈;
|
||||
1. 执行现场是上述环境和调用栈的一个瞬时快照(包括栈上数据的状态和执行的“位置”)。
|
||||
|
||||
其中的“位置”是一个典型的与“(逻辑的)执行过程”相关的东西,第六讲中的“break”就主要在讲这个“位置”的控制——包括静态的标签,以及标签在执行过程中所映射到的位置。
|
||||
|
||||
函数的进入(CALL)意味着数据帧的建立以及该数据帧压入调用栈,而退出(RETURN)意味着它弹出栈和数据帧的销毁。从这个角度上来说,`yield`运算必然不能使该函数退出(或者说必须不能让数据帧从栈上移除和销毁)。因为`yield`之后还有其他代码,而一旦数据帧销毁了,那么其他代码就无法执行了。
|
||||
|
||||
所以,`yield`是为数不多的能“挂起”当前函数的运算。但这并不是`yield`主要的、标志性的行为。`yield`操作最大的特点是**它在挂起当前函数时,还将函数所在栈上的执行现场移出了调用栈**。由于`yield`可以存在于生成器函数内的第n层作用域中。
|
||||
|
||||
```
|
||||
function foo3() { // 块作用域1
|
||||
if (true) { // 块作用域2
|
||||
while (true) { // 块作用域3
|
||||
yield 100
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
所以,一个在多级的块作用域深处的`yield`运算发生时,需要向这个数据帧(作用域链)外层检索到第一个函数帧(即函数环境,FunctionEnvironment),挂起它以及它内部的全部环境。而执行位置,将会通过函数的调用关系,一次性地返回到上一次tor.next()的下一行代码。也就是说相当于在tor.next()内部执行了一次`return`。
|
||||
|
||||
为了简化所谓“向外层检索”这一行为,JavaScript通常是使用所谓“执行上下文”来管理这些数据帧(环境)与执行位置的。执行上下文与函数或代码块的词法上下文不同,因为执行上下文只与“可执行体”相关,是JavaScript引擎内部的数据结构,它总是被关联(且仅只关联)到一个函数入口。
|
||||
|
||||
由于JavaScript引擎将JavaScript代码理解为函数,因此事实上这个“执行上下文”能关联所有的用户代码文本。
|
||||
|
||||
“所有的代码文本”意味着“.js文件”的全局入口也会被封装成一个函数,且全部的模块顶层代码也会做相同的封装。这样一来,所有通过文件装载的代码文本都会只存在于同一个函数中。由于在Node.js或其他一些具体实现的引擎中,无法同时使用标准的ECMAScript模块装载和.js文件装载,因此事实上来说,这些引擎在运行JavaScript代码时(通常地)也就只有一个入口的函数。
|
||||
|
||||
而所有的代码其实也就只运行在该函数的、唯一的一个“执行上下文”中。
|
||||
|
||||
如果用户代码——通过任意的手段——试图挂起这唯一的执行上下文,那么也就意味着整个的JavaScript都停止了执行。因此,“挂起”这个上下文的操作是受限制的,被一系列特定的操作规范管理。这些规范我在这一讲的稍晚部分内容中会详细讲述,但这里,我们先关注一个关键问题:到底有多少个执行上下文?
|
||||
|
||||
如果模块与文件装载机制分开,那么模块入口和文件入口就是二选一的。当然在不同的引擎中这也不尽相同,只是在这里分开讨论会略为清晰一些。
|
||||
|
||||
**模块入口**是所有模块的顶层代码的顺序组合,它们被封装为一个称为“顶层模块执行(TopLevelModule Evaluation Job)”的函数,作为模块加载的第一个执行上下文创建。类似的是,一般的.js文件装载也会创建一个称为“脚本执行(Script EvaluationJob)”的函数。后者,也是文件加载中所有全局代码块称为“Script块”的原因。
|
||||
|
||||
除了这两种执行上下文之外,eval()总是会开启一个执行上下文的。
|
||||
|
||||
JavaScript为eval()所分配的这个执行上下文,与调用eval()时的函数上下文享有同一个环境(包括词法环境和变量环境等等),并在退出eval()时释放它的引用,以确保同一个环境中“同时”只有一个逻辑在执行。
|
||||
|
||||
接下来,如果一个一般函数被调用,那么它也将形成一个对应的执行上下文,但是由于这个上下文是“被”调用而产生的,所以它会创建一个“调用者(caller)”函数的上下文的关联,并创建在caller之后。由于栈是后入先出的结构,因此总是立即执行这个“被调用者(callee)”函数的上下文。
|
||||
|
||||
这也是调用栈入栈“等义于”调用函数的原因。
|
||||
|
||||
但这个过程也就意味着这个“当前的(活动的)”调用栈是由一系列执行上下文以及它们所包含的数据帧所构成的。而且,就目前来说,这个调用栈的底部,要么是模块全局(_TopLevelModuleEvaluationJob_任务),要么就是脚本全局(_ScriptEvaluationJob_任务)。
|
||||
|
||||
一旦你了解了这些,那么你就很容易理解生成器的特殊之处了:
|
||||
|
||||
所有其他上下文都在执行栈上,而生成器的上下文(多数时间是)在栈的外面。
|
||||
|
||||
## 有趣的.next()方法
|
||||
|
||||
如果有一行`yield`代码出现在生成器函数中,那么当这个生成器函数执行到`yield`表达式时会发生什么呢?
|
||||
|
||||
这个问题貌似不好回答,但是如果问:是什么让这个生成器函数执行到“`yeild`表达式”所在位置的呢?这个问题就好回答了:是tor.next()方法。如下例:
|
||||
|
||||
```
|
||||
function* foo3() {
|
||||
yield 10;
|
||||
}
|
||||
let tor = foo3();
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
我们可以简单地写一个生成器函数`foo3()`,它的内部只有一行`yield`代码。在这样的一个示例中,调用foo3()函数之后,你就已经获得了来自foo3()的一个迭代器对象,在习惯上的,我称它为tor。并且,在语法形式上,貌似foo3()函数已经执行了一次。
|
||||
|
||||
但是,事实上foo3()所声明的函数体并没有执行(因为它是生成器函数),而是直到用户代码调用`tor.next()`的时候,foo3()所声明的函数体才正式执行并直到那唯一的一行代码:表达式`yeild`。
|
||||
|
||||
```
|
||||
# 调用迭代器方法
|
||||
> tor.next()
|
||||
{ value: 10, done: false }
|
||||
|
||||
```
|
||||
|
||||
这时,foo3()所声明的函数体正式执行,并直到表达式`yeild 10`,生成器函数才返回了第一个值`10`。
|
||||
|
||||
如同上一讲中所说到的,这表明在代码`tor = foo3()`中,函数调用“foo3()”的实际执行效果是:生成一个迭代过程,并将该过程交给了tor对象。
|
||||
|
||||
换而言之:tor是foo3()生成器(内部的)迭代过程的一个句柄。从引擎内的实现过程来说,tor其实包括状态(state)和执行上下文(context)两个信息,它是`GeneratorFunction.prototype`的一个实例。这个tor所代表的生成器在创建出来的时候将立即被挂起,因此状态值(state)初始化置为"启动时挂起(suspendedStart)",而当在调用tor.next()因`yield`运算而导致的挂起称为"Yield时挂起(suspendedYield)"。
|
||||
|
||||
另一个信息,即context,就指向tor被创建时的上下文。上面已经说过了,所谓上下文一定指的是一个外部的、内部的或由全局/模块入口映射成的函数。
|
||||
|
||||
接下来,当tor.next()执行时,tor所包括的context信息被压到栈顶执行;当tor.next()退出时,这个context就被从栈上移除。这个过程与调用eval()是类似的,总是能保证指定栈是全局唯一活动的一个栈。
|
||||
|
||||
如果活动栈唯一,那么系统就是同步的。
|
||||
|
||||
因为只需要一个执行线程。
|
||||
|
||||
## 对传入参数的改造
|
||||
|
||||
生成器对“函数执行”的执行体加以改造,使之变成由tor.next()管理的多个片断。用来映射多次函数调用的“每个body”。除此之外,它还对传入参数加以改造,使执行“每个body”时可以接受不同的参数。这些参数是通过tor.next()来传入,并作为yield运算的结果而使用的。
|
||||
|
||||
这里JavaScript偷偷地更换了概念。也就是说, 在:
|
||||
|
||||
```
|
||||
x = yield x
|
||||
|
||||
```
|
||||
|
||||
这行表达式中,从语法上看是表达式`yield x`求值,实际的执行效果是:
|
||||
|
||||
- `yield`向函数外发送计算表达式`x`的值;
|
||||
|
||||
而 `x = ...`的赋值语义变成了:
|
||||
|
||||
- `yield`接受外部传入的参数并作为结果赋给x。
|
||||
|
||||
将tor.next()联合起来看,由于tor所对应的上下文在创建后总是挂起的,因此第一个tor.next()调用总是将执行过程“推进”到第一行`yield`并挂起。例如:
|
||||
|
||||
```
|
||||
function* foo4(x=5) {
|
||||
console.log(x--); // `tor = foo4()`时传入的值5
|
||||
// ...
|
||||
|
||||
x = yield x; // 传出`x`的值
|
||||
console.log(x); // 传入的arg
|
||||
// ...
|
||||
}
|
||||
|
||||
let tor = foo4(); // default `x` is 5
|
||||
result = tor.next(); // 第一次调用.next()的参数将被忽略
|
||||
console.log(result.value)
|
||||
|
||||
```
|
||||
|
||||
执行结果将显示:
|
||||
|
||||
```
|
||||
5 // <- defaut `5`
|
||||
4 // <- result.value `4`
|
||||
|
||||
```
|
||||
|
||||
而foo4()函数在`yield`表达式执行后将挂起。而当在下一次调用tor.next(arg)时,一个已经被`yield`挂起的生成器将恢复(resume),这时传入的参数arg就将作为`yield`表达式(在它的上下文中)的结果。也就是上例中第二个console.log(x)中的`x`值。例如:
|
||||
|
||||
```
|
||||
# 传入100,将作为foo4()内的yield表达式求值结果赋给`x = ...`
|
||||
> tor.next(100)
|
||||
100
|
||||
|
||||
```
|
||||
|
||||
## 知识回顾
|
||||
|
||||
今天这一讲,谈的是将迭代过程展开并重新组织它的语义,然后变成生成器与`yield`运算的全过程。
|
||||
|
||||
在这个过程中,你需要关注的是JavaScript对“迭代过程”展开之后的代码体和参数处理。
|
||||
|
||||
事实上,这包含了对函数的全部三个组件的重新定义:代码体、参数传入、值传出。只不过,在`yield`中尤其展现了对传入传出的处理而已。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的这一讲不安排什么特别的课后思考,我希望你能补充一下一个小知识点的内容:由于今天的内容中没有讲“委托的yield”这个话题,因此你可以安排一些时间查阅资料,对这个运算符——也就是“yeild*”的实现过程和特点做一些深入探索。
|
||||
|
||||
欢迎你在进行深入思考后,与其他同学分享自己的想法,也让我有机会能听听你的收获。
|
||||
@@ -0,0 +1,271 @@
|
||||
<audio id="audio" title="11 | throw 1;:它在“最简单语法榜”上排名第三" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/36/d7/36b4435dd92f1b4ea3ffd7b0312ccbd7.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,欢迎回到我的专栏。
|
||||
|
||||
今天我将为你介绍的是在ECMAScript规范中,实现起来“最简单”的JavaScript语法榜前三名的JavaScript语句。
|
||||
|
||||
标题中的`throw 1`就排在这个“最简单榜”第三名。
|
||||
|
||||
>
|
||||
NOTE: 预定的加餐将是下一讲的内容,敬请期待。^^.
|
||||
|
||||
|
||||
## 为什么讲最简单语法榜
|
||||
|
||||
为什么要介绍这个所谓的“最简单的JavaScript语法榜”呢?
|
||||
|
||||
在我看来,在ECMAScript规范中,对JavaScript语法的实现,尤其是语句、表达式,以及基础特性最核心的部分等等,都可以在对这前三名的实现过程和渐次演进关系中展示出来。甚至基本上可以说,你只要理解了最简单榜的前三名,也就理解了设计一门计算机语言的基础模型与逻辑。
|
||||
|
||||
`throw`语句在ECMAScript规范描述中,它的执行实现逻辑只有三行:
|
||||
|
||||
>
|
||||
<p>**ThrowStatement** : **throw** **Expression**;<br>
|
||||
1.**Let** exprRef be the result of evaluating Expression.<br>
|
||||
2.**Let** exprValue be ? GetValue(exprRef).<br>
|
||||
3.**Return** ThrowCompletion(****exprValue****).</p>
|
||||
|
||||
|
||||
这三行代码描述包括两个`Let`语句,以及最后一个`Return`返回值。当然,这里的Let/Return是自然语言的语法描述,是ECMAScript规范中的写法,而不是某种语言的代码。
|
||||
|
||||
将这三行代码倒过来看,最后一行的ThrowCompletion()调用其实是一个简写,完整的表示法是一行用于返回完成记录的代码。这里的“记录”,也是一种在ECMAScript规范中的“规范类型”,与之前一直在讲的“引用规范类型”类似,都是ECMAScript特有的。
|
||||
|
||||
>
|
||||
**Return** Completion { [Type]: ****exprValue****, [[Target]]: empty }
|
||||
|
||||
|
||||
在ECMAScript规范的书写格式中,一对大括号“{ }”是记录的字面量(Record Literals)表示。也就是说,执行`throw`语句,在引擎层面的效果就是:**返回一个类型为"throw"的一般记录。**
|
||||
|
||||
>
|
||||
NOTE:在之前的课程中讲到标签化语句的时候,提及过上述记录中的`[[target]]`字段的作用,也就是仅仅用作“**break** **labelName**”和“**continue** **labelName**”中的标签名。
|
||||
|
||||
|
||||
这行代码也反映了“**JavaScript语句执行是有值(Result)的**”这一事实。也就是说,任何JavaScript语句执行时总是会“返回”一个值,包括空语句。
|
||||
|
||||
空语句其实也是上述“最简单榜”的Top 1,因为它在ECMAScript的实现代码有且仅有一行:
|
||||
|
||||
>
|
||||
1.**Return** NormalCompletion(****empty****).
|
||||
|
||||
|
||||
其中的`NormalCompletion()`也是一个简写,完整的表示法与上面的`ThrowCompletion()`也类似,不过其中的传入参数argument在这里是empty。
|
||||
|
||||
>
|
||||
**Return** Completion { [Type]: ****argument****, [[Target]]: empty }
|
||||
|
||||
|
||||
而传入参数argument在这里是empty,这是ECMAScript规范类型中的一个特殊值,理解为规范层面可以识别的Null值就可以了(例如它也用来指没有`[[Target]]`)。也就是说,所谓“空语句(**Empty statement**)”,就是返回结果为“空值(**Empty**)”的一般语句。类似于此的,这一讲标题中的语句`throw 1`,就是一个返回"throw"类型结果的语句。
|
||||
|
||||
>
|
||||
NOTE:这样的返回结果(Result)在ECMAScript中称为完成记录,这在之前的课程中已经讲述过了。
|
||||
|
||||
|
||||
然而,向谁“返回”呢?以`throw 1`为例,谁才是`throw`语句的执行者呢?
|
||||
|
||||
## 在语句之外看语句
|
||||
|
||||
在JavaScript中,除了`eval()`之外,从无“如何执行语句”一说。
|
||||
|
||||
这是因为任何情况下,“装载脚本+执行脚本”都是引擎自身的行为,用户代码在引擎内运行时,如“鱼不知水”一般,是难以知道语句本身的执行情况的。并且,即使是`eval()`,由于它的语义是“语句执行并求值”,所以事实上从`eval()`的结果来看是无法了解语句执行的状态的。
|
||||
|
||||
因为“求值”就意味着去除了“执行结果(Result)”中的状态信息。
|
||||
|
||||
ECMAScript为JavaScript提供语言规范,出于ECMAScript规范书写的特殊性,它也同时是引擎实现的一个规范。在ECMAScript中,所有语句都被解析成待处理的结点,最顶层的位置总是被称为_Script_或_Module_的一个块(块语句),其他的语句将作为它的一级或更深层级的、嵌套的子级结点,这些结点称为“**Parse Node**”,它们构成的整个结构称为“**Parse Tree**”。
|
||||
|
||||
无论如何,语句总是一个树或子树,而表达式可以是一个子树或一个叶子结点。
|
||||
|
||||
>
|
||||
NOTE:空语句可以是叶子结点,因为没有表达式做它的子结点。
|
||||
|
||||
|
||||
执行语句与执行表达式在这样的结构中是没有明显区别的,而所谓“执行代码”,在实现上就被映射成执行这个树上的子树(或叶子结点)。
|
||||
|
||||
所谓“顺序执行的语句”表现在_“Parse Tree_”这个树上,就是同一级的子树。它们之间平行(相同层级),并且相互之间没有“相互依赖的运算”,所以它们的值(也就是尝试执行它们共同的父结点所对应的语句)就将是最后一个语句的结果。所有顺序执行语句的结果向前覆盖,并返回最终语句的结果(Result)。
|
||||
|
||||
事实上在表达式中,也存在相同语句的执行过程。也就是如下两段代码在执行效果上其实没有什么差异:
|
||||
|
||||
```
|
||||
// 表达式的顺序执行
|
||||
1, 2, 3, 4;
|
||||
|
||||
// 语句的顺序执行
|
||||
1; 2; 3; 4;
|
||||
|
||||
```
|
||||
|
||||
更进一步地说,如下两种语法,其抽象的语义上也是一样的:
|
||||
|
||||
```
|
||||
// 通过分组来组合表达式
|
||||
(1, 2, 3, 4)
|
||||
|
||||
// 通过块语句来组合语句
|
||||
{1; 2; 3; 4;}
|
||||
|
||||
```
|
||||
|
||||
所以,从语法树的效果上来看,所谓“语句的执行者”,其实就是它外层的语句;而最外层的语句,总是被称为_Script_或_Module_的一个块,并且它会将结果返回给shell、主进程或`eval()`。
|
||||
|
||||
除了`eval()`之外,所有外层语句都并不依赖内层语句的返回值;除了shell程序或主进程程序之外,也没有应用逻辑来读取这些语句缺省状态下的值。
|
||||
|
||||
## 值的覆盖与读取
|
||||
|
||||
语句的五种完成状态(normal, break, continue, return, 以及 throw)中,“Nomal(缺省状态)”大多数情况下是不被读取的,break和continue用于循环和标签化语句,而return则是用于函数的返回值。于是,所有的状态中,就只剩下了本讲标题中的`throw 1`所指向的,也就是“异常抛出(throw)”这个状态。
|
||||
|
||||
>
|
||||
NOTE: 有且仅有return和throw两个状态是确保返回时携带有效值(包括undefined)的。其他的完成类型则不同,可能在返回时携带“空(empty)”值,从而需要在语句外的代码(shell、主进程或eval)进行特殊的处理。
|
||||
|
||||
|
||||
return语句总是显式地返回值或隐式地置返回值为undefined,也就是说它总是返回值,而break和continue则是不携带返回值的。那么是不是说,当一个“**语句块**”的最终语句是break或continue以及其他一些不携带返回值的语句时,该“**语句块**”总是没有返回值的呢?
|
||||
|
||||
答案是否。
|
||||
|
||||
ECMAScript语言约定,在块中的多个语句顺序执行时,遵从两条规则:
|
||||
|
||||
1. 在向前覆盖既有的语句完成值时,`empty`值不覆盖任何值;
|
||||
1. 部分语句在没有有效返回值,且既有语句的返回值是`empty`时,默认用`undefined`覆盖之。
|
||||
|
||||
规则1比较容易理解,表明一个语句块会尽量将块中最后有效的值返回出来。
|
||||
|
||||
例如在之前的课程中提到的空语句、break语句等,都是返回`empty`的,不覆盖既有的值,所以它们也就不影响整个语句块的执行。又例如当你将一个有效返回的语句放到空语句后面的时候,那么这个语句的返回,也就越过空语句,覆盖了其它现有的有效结果值。
|
||||
|
||||
```
|
||||
# Run in NodeJS
|
||||
> eval(`{
|
||||
1;
|
||||
2;
|
||||
; // empty
|
||||
x:break x; // empty
|
||||
}`)
|
||||
2
|
||||
|
||||
```
|
||||
|
||||
在这个例子中的后面两行语句都返回`empty`,因此不覆盖既有的值,所以整个语句块的执行结果是`2`。又例如:
|
||||
|
||||
```
|
||||
# Run in NodeJS
|
||||
> eval(`{
|
||||
; // empty
|
||||
1;
|
||||
; // empty
|
||||
}`)
|
||||
1
|
||||
|
||||
```
|
||||
|
||||
在这个例子中第1行代码执行结果返回`empty`,于是第2行的结果`1`覆盖了它;而第3行的结果仍然是`empty`所以不导致覆盖,因此整个语句的返回值将是1。
|
||||
|
||||
>
|
||||
**NOTE: 参见13.2.13 Block -> RS: Evaluation**, 以及15.2.1.23 Module -> RS: Evaluation中,对**UpdateEmpty**(s, sl)的使用。
|
||||
|
||||
|
||||
而上述的规则2,就比较复杂一些了。这出现在if、do…while、while、for/for…in/for…of、with、switch和try语句块中。在ECMAScript 6之后,这些语句约定不会返回empty,因此它的执行结果“至少会返回一个undefined值”,而在此之前,它们的执行结果是不确定的,既可能返回undefined值,也可能返回empty,并导致上一行语句值不覆盖。举例来说:
|
||||
|
||||
```
|
||||
# Run in NodeJS 5.10+ (or NodeJS 4)
|
||||
> eval(`{
|
||||
2;
|
||||
if (true);
|
||||
}`)
|
||||
undefined
|
||||
|
||||
```
|
||||
|
||||
由于ES6约定`if`语句不返回`empty`,所以第1行返回的值`2`将被覆盖,最终显示为`undefined`。而在此之前(例如NodeJS 4),它将返回值`2`;
|
||||
|
||||
>
|
||||
NOTE: 参考阅读[《前端要给力之:语句在JavaScript中的值》](https://blog.csdn.net/aimingoo/article/details/51136511)。
|
||||
|
||||
|
||||
由此一来,ECMAScript规范约定了JavaScript中所有语句的执行结果的可能范围:`empty`,或一个既有的执行结果(包括undefined)。
|
||||
|
||||
## 引用的值
|
||||
|
||||
现在还存在最后一个问题:所谓“引用”,算是什么值?
|
||||
|
||||
回顾第一讲的内容:表达式的本质是求值运算,而引用是不能直接作为最终求值的操作数的。因此引用实际上不能作为语句结果来返回,并且它在表达式计算中也仅是作为中间操作数(而非表达最终值的操作数)。所以在语句返回值的处理中,总是存在一个“执行表达式并‘取值’”的操作,以便确保不会有“引用”类型的数据作为语句的最终结果。而这,也就是在ECMAScript规中的`throw 1`语句的第二行代码的由来:
|
||||
|
||||
>
|
||||
2.**Let** exprValue be ? GetValue(exprRef).
|
||||
|
||||
|
||||
事实上在这里的符号“? **opName**()”语法也是一个简写,在ECMAScript中它表示一个ReturnIfAbrupt(x)的语义:如果设一个“处理(**opName()**)”的结果是x,那么,
|
||||
|
||||
>
|
||||
如果x是特殊的(非normal类型的)完成记录,则返回x;否则返回一个以x.[[value]]为值的、normal类型的完成记录。
|
||||
|
||||
|
||||
简而言之,就是在GetValue()这个操作外面再封装一次异常处理。这往往是很有效的,例如一个`throw`语句,它自己的throw语义还没有执行到呢,结果在处理它的操作数时就遇到一个异常,这该怎么办呢?
|
||||
|
||||
```
|
||||
throw 1/0;
|
||||
|
||||
```
|
||||
|
||||
那么exprRef作为表达式的计算结果,其本身就将是一个异常,于是`? GetValue(exprRef)`就可以返回这个异常对象(而不是异常的值)本身了。类似地,所谓“表达式语句”(这是排在“最简单语句榜”的第二名的语句)就直接返回这个值:
|
||||
|
||||
>
|
||||
<p>**ExpressionStatement**: **Expression**;<br>
|
||||
1.**Let** exprRef be the result of evaluating Expression.<br>
|
||||
2.**Return** ? GetValue(exprRef).</p>
|
||||
|
||||
|
||||
## 还有一行代码
|
||||
|
||||
现在还有一行代码,也就是第一行的“****let**** **… result of evaluating …**”。其中的“result of evaluating…”基本上算是ECMAScript中一个约定俗成的写法。不管是执行语句还是表达式,都是如此。这意味着引擎需要按之前我讲述过的那些执行逻辑来处理对应的代码块、表达式或值(操作数),然后将结果作为Result返回。
|
||||
|
||||
ECMAScript所描述的引擎,能够理解“执行一行语句”与“执行一个表达式”的不同,并且由此决定它们返回的是一个“引用记录”还是“完成记录”(规范类型)。当外层的处理逻辑发现是一个引用时,会再根据当前逻辑的需要将“引用”理解为左操作数(取引用)或右操作数(取值);否则当它是一个完成记录时,就尝试检测它的类型,也就是语句的完成状态(throw、return、normal或其他)。
|
||||
|
||||
所以,throw语句也好,return语句也罢,所有的语句与它“外部的代码块(或`Parse Tree`中的父级结点)”间其实都是通过这个**完成状态**来通讯的。而外部代码块是否处理这个状态,则是由外部代码自己来决定的。
|
||||
|
||||
而几乎所有的外部代码块在执行一个语句(或表达式)时,都会采用上述的ReturnIfAbrupt(x)逻辑来封装,也就是说,如果是normal,则继续处理;否则将该完成状态原样返回,交由外部的、其他的代码来处理。所以,就有了下面这样一些语法设计:
|
||||
|
||||
1. 循环语句用于处理非标签化的continue与break,并处理为normal;否则,
|
||||
1. 标签语句用于拦截那些“向外层返回”的continue和break;且,如果能处理(例如是目标标签),则替换成normal。
|
||||
1. 函数的内部过程`[[Call]]`,将检查“函数体执行”(将作为一个块语句执行)所返回状态是否是return类型,如果是,则替换成normal。
|
||||
|
||||
显而易见,所有语句行执行结果状态要么是normal,要么就是还未被拦截的throw类型的语句完成状态。
|
||||
|
||||
try语句用于处理那些漏网之鱼(throw状态):在catch块中替换成normal,以表示try语句正常完成;或在finally中不做任何处理,以继续维持既有的完成状态,也就是throw。
|
||||
|
||||
## 值1
|
||||
|
||||
最后,本小节标题中的代码中只剩下了一个值`1`,在实际使用中,它既可以是一个其他表达式的执行结果,也可以是一个用户定义或创建的对象。无论如何,只要它是一个JavaScript可以处理的结果Result(引用或值),那么它就可以通过内部运算`GetValue()`来得到一个真实数据,并放在一个throw类型的完成记录中,通过一层一层的`Parse Tree/Nodes`中的`ReturnIfAbrupt(x)`向上传递,直到有一个try块捕获它。例如:
|
||||
|
||||
```
|
||||
try {
|
||||
throw 1;
|
||||
catch(e) {
|
||||
console.log(e); // 1
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或者,它也可能溢出到代码的最顶层,成为根级`Parse Node`,也就是`Script`或`Module`类型的全局块的返回值。
|
||||
|
||||
这时,引擎或Shell程序就会得到它,于是……你的程序挂了。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
在最近几讲,我讲的内容从语句执行到函数执行,从引用类型到完成类型,从循环到迭代,基本上已经完成了关于JavaScript执行过程的全部介绍。
|
||||
|
||||
当然,这些都是在串行环境中发生的事情,至于并行环境下的执行过程,在专栏的后续文章中我还会再讲给你。
|
||||
|
||||
作为一个概述,建议你回顾一下本专栏之前所讲的内容。包括(但不限于):
|
||||
|
||||
1. 引用类型与值类型在ECMAScript和JavaScript中的不同含义;
|
||||
1. 基本逻辑(顺序、分支与循环)在语句执行和函数执行中的不同实现;
|
||||
1. 流程控制逻辑(中断、跳转和异步等)的实现方法,以及它们的要素,例如循环控制变量;
|
||||
1. JavaScript执行语句和函数的过程,引擎层面从装载到执行完整流程;
|
||||
1. 理解语法解析让物理代码到标记(Token)、标识符、语句、表达式等抽象元素的过程;
|
||||
1. 明确上述抽象元素的静态含义与动态含义之间的不同,明确语法元素与语义组件的实例化。
|
||||
|
||||
综合来看,JavaScript语言是面向程序员开发来使用的,是面子上的活儿,而ECMAScript既是规范也是实现,是藏在引擎底下的事情。ECMAScript约定了一整套的框架、类型与体系化的术语,根本上就是为了严谨地叙述JavaScript的实现。并且,它提供了大量的语法或语义组件,用以规范和实现将来的JavaScript。
|
||||
|
||||
直到现在,我向你的讲述的内容,在ECMAScript中大概也是十不过一。这些内容主要还是在刻画ECMAScript规范的梗概,以及它的核心逻辑。
|
||||
|
||||
从下一讲开始,我将向你正式地介绍JavaScript最重要的语言特性,也就是它的面向对象系统。
|
||||
|
||||
当然,一如本专栏之前的风格,我不会向你介绍类型x.toString()这样的、可以在手册上查阅的内容,我的本意,在于与你一起学习和分析:
|
||||
|
||||
JavaScript是怎样的一门语言,以及它为什么是这样的一种语言。
|
||||
@@ -0,0 +1,238 @@
|
||||
<audio id="audio" title="加餐 | 让JavaScript运行起来" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/db/4b/dbc09a86c81e6dfc4daddbe191e1954b.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。欢迎回到我的专栏。今天,是传说中的加餐时间,我将与你解说前11讲内容的整体体系和结论。
|
||||
|
||||
我们从一个问题讲起,那就是:JavaScript到底是怎么运行起来的呢?
|
||||
|
||||
看起来这个问题最简单的答案是“解析→运行”。然而对于一门语言来说,“引擎解释与运行”都是最终结果的表象,真正处于原点的问题其实是:“JavaScript运行的是什么?”
|
||||
|
||||
在前11讲中,我是试图将JavaScript整个的运行机制摊开在你的面前,因此我们有两条线索可以抓:
|
||||
|
||||
1. 表面上,它是讲引用和执行过程;
|
||||
1. 在底下,讲的是引擎对“JavaScript是什么”的理解。
|
||||
|
||||
## 从文本到脚本
|
||||
|
||||
我们先从第二条线索,也就是更基础层面的线索讲起。
|
||||
|
||||
JavaScript的所谓“脚本代码”,在引擎层面看来,首先就是一段文本。在性质上,装载`a.js`执行与`eval('...')`执行并没有区别,它们的执行对象都被理解为一个“字符串”,也就是字符串这一概念本身所表示的、所谓的“字符序列”。
|
||||
|
||||
在字符序列这个层面上,最简单和最经济的处理逻辑是**正向遍历**,这也是为什么“语句解析器”的开发者总是希望“语言的设计者”能让他们“一次性地、不需要回归地”解析代码的原因。
|
||||
|
||||
回归(也就是查看之前“被parser过的代码”)就意味着解析器需要暂存旧数据,无法将解析器做得足够简洁,进而无法将解析器放在小存储的环境中。根本上来说,JavaScript解析引擎是“逐字符”地处理代码文本的。
|
||||
|
||||
JavaScript从“逐字符处理”得到的引擎可以理解的对象,称为记号(Tokens)。这个概念,是从第一讲就开始提的,你回顾第一讲的内容,在提出`Tokens`这个概念的时候,有这样一句话:
|
||||
|
||||
>
|
||||
一个记号是没有语义的,记号既可以是语言能识别的,也可以是语言不能识别的。唯有把这二者同时纳入语言范畴,那么这个语言才能识别所谓的“语法错误”。
|
||||
|
||||
|
||||
我之所以用**“delete运算”**作为《JavaScript核心原理解析》的开篇,是因为在我看来,这讲的是一种“不知死,即不知生”的道理。如果你不知道一个东西是如何被毁灭的,那么你也不知道它创生的意义。
|
||||
|
||||
然而,这个理解也可以倒过来,是所谓的“不知生,亦不知死”。也就是说,如果你都不知道它被创造出来的时候是什么,那么你也不知道你毁灭了什么。
|
||||
|
||||
而这个记号(Tokens),就是引擎从文本到脚本,JavaScript引擎也好、语言也好,它们创造出来的第一个东西——也是在创世原点唯一的东西。
|
||||
|
||||
记号,要么是可识别的,要么是不能识别的。并且,它们必须同时纳入语言范畴。这个“必须同时纳入”,决定了二者不是相互孤立的元素,而是同一体系下的东西,也就是所谓的“体系的完整性”。
|
||||
|
||||
## 引用与静态语言的处理
|
||||
|
||||
看完底层的线索,我们再来看看JavaScript运行机制的表面线索。
|
||||
|
||||
引用(References)是静态语言与引擎之间的桥梁,它是ECMAScript规范中最大的一个挑战,你理解了“规范层面的引用(References)”,也就基本上理解了ECMAScript规范整个的叙述框架。这个框架的核心在于——ECMAScript的目的是描述“引擎如何实现”,而不是“描述语言是什么”。
|
||||
|
||||
规范层面中的引用与引擎的核心设计有关。
|
||||
|
||||
在JavaScript语言层面,它希望引擎是一个执行器,更具体的描述是:引擎的核心是一个表达式计算的、连续的执行过程。表达式计算是整个JavaScript语言中最核心的预设,一旦超出这个预设,JavaScript语言的结构体系就崩溃了。
|
||||
|
||||
所以,本质上来说,JavaScript的所谓“语句能执行”也是一个或一组表达式计算过程,而且所有的计算都必须能描述成一个基本的模式:`opCode -> opData`,也就是用操作符去处理操作数。
|
||||
|
||||
这个相信你也明白了,这回到了我们计算理论最初的原点,是我们学习计算机这门课程最初的那个设定:**计算实现的就是”计算求解“的过程**。它的另一个公式化的表达就是著名的”算法+数据结构=程序“。
|
||||
|
||||
当然,这个说得有点远了,在这个概念集合中,最关键的点在于“**执行过程最终是表达式计算**”。因此,语句执行也是表达式计算,函数调用也是表达式计算,各种特殊执行结果还是表达式计算。
|
||||
|
||||
这些“计算”总会有一个返回值,是什么呢?
|
||||
|
||||
你可以参考文章里的这张图,它说明了JavaScript中最核心的两种执行过程(它们都被称为evaluating)是如何最终被统一的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/11/b281ed38f4894823bdb0daf771d27311.jpg" alt="">
|
||||
|
||||
在语句执行的层面,它返回一个语句的完成状态,这个状态中包括了一个“value”域,它必须且必然会是JavaScript语言理解的类型,也就是typeof()所识别的所有的值。这样一来,任何“语句”“代码”或“代码文本”就都可以被执行了,并且都可以使用console.log()输出结果给你了。
|
||||
|
||||
这其中最重要的一件事是,在任何语句执行并得到结果时,如果它“当时”是一个所谓的“引用”,那么这个引用就必须先调用“GetValue(x)”来得到值,然后放到这个“value”域中去。因为“引用”是一个规范层面的东西,它不是JavaScript语言能理解的,也无法展示给开发者。
|
||||
|
||||
最后,ECMAScript约定:可以在“value”域中放上`Empty`,这表明语句执行“没有值”。它能表明有值,也能表明无值,仍然是“概念完整性”。
|
||||
|
||||
而到了表达式执行时(注意函数调用也是表达式执行的一种),这个过程又被重来了一回。不过表达式执行会返回两个东西:它要么直接返回一个“上面的完成结果所理解的值”,要么返回一个包含这样的值的“引用”。
|
||||
|
||||
你可能会说了,不对呀——你刚才还说所谓“概念的完整性”,是“要么返回东西,要么返回没有东西”啊。
|
||||
|
||||
对的,在表达式执行这个体系里面,“没有东西”是所谓的“不可发现的引用(UnresolvableReference)”。
|
||||
|
||||
所以,完整的概念集是:值(value)、引用(Reference)和不可发现的引用(UnresolvableReference)。
|
||||
|
||||
一个不可发现的引用是能被处理的,例如`delete x`,或者`typeof x`。所有“能处理引用的”运算符都能处理它。当然,在严格模式中,会在语法分析阶段就报异常,那是另一个层面的东西,有机会的时候我们再聊。这里,在JavaScript语言层面,它仍然在维护一种简单的完整性。
|
||||
|
||||
那么,为什么要有“引用”这么个东西呢?
|
||||
|
||||
你想想,如果没有引用,你就得将所有的东西都直接当成一个被处理的对象,例如用1G的内存来处理一个1G文本的记号。这显然不可行。我们可以用一个简单的法子来解决,就是加一个指针指向它,在不需要访问它的“内容”时,我们就访问这个指针好了。而引用,也就是所有在“不访问内容”的情况下,用于指向这个内容的一个结构。它叫什么名字其实都好、都行,重点的是:
|
||||
|
||||
1. 它代表这个东西,r(x)。
|
||||
1. 它包含这个东西,所以可以x = GetValue®。
|
||||
|
||||
所以本质上,引用还是指向值、代表值的一个概念,它只是“获得值的访问能力”的一个途径。最终的结果仍然指向原点:计算值、求值。
|
||||
|
||||
## 结构与体系的回顾
|
||||
|
||||
讲完JavaScript整个运行机制的两条线索后,就是加餐的最后一部分内容了,我会直接为你解说前11讲的主题。
|
||||
|
||||
### 模块一:体系1
|
||||
|
||||
- 1 | delete 0
|
||||
|
||||
讲述的是“规范引用”,将“规范引用”与传统概念中的引用区别开来。用Result来指代执行结果的“引用状态和值状态未区分”。同时指明,“状态未区分”的原因是:同一个标识符,在作为_lhs_和_rhs_的时候意义是不同的;并且,在计算没有“推进到”下一步之前,上一步的Result是无法确知“将作为”**lhs**/_rhs_的哪一种操作数的。
|
||||
|
||||
JavaScript确实有一部分表达式(或操作)是能处理“规范引用”的,例如`delete x`就是其中之一。有关哪些运算能处理“规范引用”,建议你自己翻阅ECMAScript,并从中归纳。
|
||||
|
||||
- 2 | var x = y = 100
|
||||
|
||||
这一讲的核心是讲六种声明。所有声明(语句)都是没有返回值的(返回Empty),因为它没有返回值,所以它对其他执行过程没有影响。也就是说,声明语句必须能被理解为“静态分析的结果”,而不是“动态执行的结果”。
|
||||
|
||||
前者称为“声明语义”,后者称为“执行语义”。声明语义就是静态语言的处理,执行语义就是动态语言的处理。这是两种语言范型的分水岭。
|
||||
|
||||
- 3 | a.x = a = {n:2}
|
||||
|
||||
这一讲的核心是讲表达式执行与(看起来跟它相似的)语句声明之间的区别。虽然两种看起来都相似,但其实只有这一讲的才是“表达式连等”。
|
||||
|
||||
在这一讲结尾的部分,我做了一个总结:有关“引用”的介绍,以及“语句”和“表达式”之间的差异与分别,自此暂告段落。
|
||||
|
||||
- 4 | export default function() {}
|
||||
|
||||
这一讲的核心是讲“名字”的使用。“有名字/没有名字”是一对概念,而“没有名字”就称为“default”,那就是将概念收敛到了唯一一个:名字。所有有关export/import的处理,就是名字与它所代表的东西之间的关系映射。
|
||||
|
||||
而“模块装载的过程”必须发生在用户代码之前,一共包括了两个意思:
|
||||
|
||||
1. 引擎必须有一个依赖顺序来“初始化”那些名字,这个与export语句是“声明”有关,声明意味着它是静态完成的(名字总是被静态声明的);
|
||||
1. 用户代码需要依赖那些名字,这与import语句不是“声明”有关,它不是声明,那么它需要通过“执行”来得到结果的,而这些“执行”必须在用户代码之前。其顺序,就是所谓模块装载树的遍历。
|
||||
|
||||
- 5 | for (let x of [1,2,3]) …
|
||||
|
||||
这一讲的要点不是讲语句执行,而是讲**块级作用域**,更进一步的,它是在讲作用域的“识别”与处理。它颠覆读者认知的地方在于提出:绝大多数语句并没有块级作用域,因为**它们不需要**。
|
||||
|
||||
而需要块级作用域的for语句,根本的需求是需要处理多次迭代中的变量暂存。这个是有很大开销的,这与“计算机语言”的一个核心原理有关:迭代需要循环控制变量,这是命令式语言有变量的根源(之一),也是函数式语言需要处理递归的根源。
|
||||
|
||||
“需不需要存储计算过程中的变量”,也是命令式语言与函数式语言的分水岭。
|
||||
|
||||
以上是前5讲的内容。 到现在为止,在第一模块中,我们主要提出的是语言的三个层面的概念:
|
||||
|
||||
- 第一层概念:记号
|
||||
- 第二层概念:引用、值
|
||||
- 第三层概念:表达式、语句、名字、环境/作用域 、(顺序执行的三种基础)逻辑
|
||||
|
||||
>
|
||||
NOTE: 这主要是在[《程序原本》](https://github.com/aimingoo/my-ebooks)前三章中的概念,包括“数、逻辑和抽象”。部分涉及到第四章,也就是“语言”中的概念。
|
||||
|
||||
|
||||
这些概念其实基本上都是在“代码的静态组织”过程中就完成/实现了的。你使用一门语言,其实本质上就是在跟第三层概念打交道,而ECMAScript或者引擎是工作在第二个层面的。第一个层面,则是物理层面与逻辑层面的、最初的映射。
|
||||
|
||||
### 模块二:体系2
|
||||
|
||||
接下来,我们讨论第6~11讲。
|
||||
|
||||
- 6 | x: break x;
|
||||
|
||||
这一讲是讲了真正的语句执行。仍然是“不知死,即不知生”的讲法,`break x`与语句的关系,同`delete x`与引用的关系其实差不多。
|
||||
|
||||
而且这一讲也提出了“语句以执行的完成状态”为结果,这个伏笔要留到第8讲来解开。
|
||||
|
||||
- 7 | ${1}
|
||||
|
||||
讲述了特殊的可执行结构。如果按照第一讲中所表达的“JavaScript引擎的核心是一个表达式计算的、连续的执行过程”,那么将所有显式的、隐式的“执行行为”合起来看,才是“执行逻辑”的全体。正如你不了解每一种特殊的可执行结构,也就不了解“${1}”为什么是最“晚”出现的语言特性之一。因为它是对其他执行结构的“集大成者”。
|
||||
|
||||
当然还有一点特殊之处也是你需要了解的,eval(str)是执行语句,`而`${str}`是`执行表达式。本质上来说,JavaScript为这两种执行都找到了“执行一个字符串”的模式,这仍然是“概念完整性”。
|
||||
|
||||
>
|
||||
NOTE:试试如下代码:
|
||||
|
||||
|
||||
```
|
||||
> `${{}}`
|
||||
'[object Object]'
|
||||
|
||||
> eval('{}')
|
||||
undefined
|
||||
|
||||
```
|
||||
|
||||
- 8 | x => x
|
||||
|
||||
表面上看是讲一个箭头函数,实际上是在讲函数式语言。关键处是解开第6讲伏笔的这一句:
|
||||
|
||||
>
|
||||
语句执行是命令式范型的体现,而函数执行代表了JavaScript中的对函数式范型的理解。
|
||||
|
||||
|
||||
另外,这一讲把函数分成了三个语法组件:参数、执行体、结果。这是非常重要的一个点,它引导了后面两讲的讨论方式。
|
||||
|
||||
- 9 | (…x)
|
||||
|
||||
这一讲说的是如何改造函数的三个语法组件中的“执行体”。这一讲提出了“改造三个语法组件”的意义,也就是说,函数式语言无论如何变、语法如何处理,其实本质上,就是在这三个点上做手脚、玩花样。
|
||||
|
||||
- 10 | x = yield x
|
||||
|
||||
这一讲说的是如何改造函数的三个语法组件中的“参数”和“结果”。
|
||||
|
||||
>
|
||||
NOTE: 这一讲也为将来“再讲循环”留了一个伏笔,不过这并不是前20讲的内容,这是“更远的将来”。^^.
|
||||
|
||||
|
||||
- 11 | throw 1;
|
||||
|
||||
这一讲其实讲的是怎么读ECMAScript规范。
|
||||
|
||||
不过它是以“最小化的”三个规范说明,来讲述了ECMAScript层面是如何一步一步地将JavaScript搭建出来的。这一讲里面有很多概念和观念,一旦你弄明白了,对ECMAScript也好,JavaScript也好,都能起到“点化”的作用。
|
||||
|
||||
其实这里有很重要的一点引导,是这样一句话:
|
||||
|
||||
>
|
||||
其中的“result of evaluating…”基本上算是ECMAScript中一个约定俗成的写法,不管是执行语句还是表达式,都是如此。
|
||||
|
||||
|
||||
这句话很重要,它从ECMAScript规范层面、从语句叙述的层面“一致化了”语句执行和表达式执行。注意:这就是上面那张图的出处!
|
||||
|
||||
这是第二模块的内容。 根本上来说,承接我们这一模块的总标题“JavaScript是如何运行的”,我主要为你讲述了三层概念:
|
||||
|
||||
- 第三层概念:表达式执行、函数执行、函数执行的扩展。
|
||||
- 第二层概念:在规范层面如何统一“表达式执行和函数执行”。
|
||||
- 第一层概念:语言体系的建立。
|
||||
|
||||
参考前面的图,既然执行结果被统一为“result”,且执行被统一为“evaluating”,那么运算就被统一成“result of evaluating…”,并且结果(如果返回给计算系统的外部的话)就是一个能被理解的result.value。
|
||||
|
||||
>
|
||||
NOTE: 这个概念层次的构建,以及最终对它要达到的效果的预期,你可以参考阅读《程序原本》第4.6节,它的标题是:将“计算机程序设计”教成语言课,是本末倒置的。
|
||||
|
||||
|
||||
### 模块三:体系3
|
||||
|
||||
回顾上面的内容,
|
||||
|
||||
- “体系1”说的是“物理到逻辑”的映射
|
||||
- “体系2”说的是“语言体系的建立”
|
||||
|
||||
总体上来看,它们是在陈述一件事情:“抽象的语言”如何处理“物理的代码”。
|
||||
|
||||
这仍然是一个体系。
|
||||
|
||||
>
|
||||
<p>NOTE: 回顾前两大模块的标题,其实这个“体系3”我是一开始就告诉了你的:<br>
|
||||
从零开始:JavaScript语言是如何构建起来的<br>
|
||||
从表达式到执行引擎:JavaScript是如何运行的</p>
|
||||
|
||||
|
||||
## 最后
|
||||
|
||||
本来课程设计中并没有今天这一讲的加餐。按原定的计划,就是用第11讲最后的“小结”算作引导你的、对之前内容最终回顾了。
|
||||
|
||||
但是考虑到课程进度和实际上的难度,才有了上一次的和今天的加餐。尤其是今天的内容,其实就是对上一讲——第11讲的小结内容的展开,希望你能对照着,重新来理解和梳理这门课程。
|
||||
|
||||
希望这份加餐会让你后续的课程变得轻松一些。今天就到这里,下一讲我们开始讲面向对象。
|
||||
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="01 | delete 0:JavaScript中到底有什么是可以销毁的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/f5/866ed6d1256f70b5727bea4313c145f5.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,感谢你来听我的专栏。
|
||||
|
||||
今天是这个系列的第一讲,我将从JavaScript中最不起眼的、使用率最低的一个运算——delete讲起。
|
||||
|
||||
你知道,JavaScript是一门面向对象的语言。它很早就支持了delete运算,这是一个元老级的语言特性。但细追究起来,delete其实是从JavaScript 1.2中才开始有的,与它一同出现的,是对象和数组的字面量语法。
|
||||
|
||||
有趣的是,JavaScript中最具恶名的typeof运算其实是在1.1版本中提供的,比delete运算其实还要早。这里提及typeof这个声名狼藉的运算符,主要是因为delete的操作与类型的识别其实是相关的。
|
||||
|
||||
## 习惯中的“引用”
|
||||
|
||||
早期的JavaScript在推广时,仍然采用传统的数据类型的分类方法,也就是说,它宣称自己同时支持值类型和引用类型的数据,并且,所谓值类型中的字符串是按照引用来赋值和传递引用(而不是传递值)的。这些都是当时“开发人员的概念集”中已经有的、容易理解的知识,不需要特别解释。
|
||||
|
||||
但是什么是引用类型呢?
|
||||
|
||||
在这件事上,JavaScript偷了个懒,它强行定义了“Object和Function就是引用类型”。这样一来,引用类型和值类型就给开发人员讲清楚了,对象和函数呢,也就可以理解了:它们按引用来传递和使用。
|
||||
|
||||
绝大多数情况下,这样解释起来是行得通的。但是到了delete运算这里,就不行。
|
||||
|
||||
因为这样一来,`delete 0`就是删除一个值,而`delete x`就既可能是删除一个值,也可能是删除一个引用。然而,当时JavaScript又同时约定:那些在global对象上声明的属性,就“等同于”全局变量。于是,这就带来了第三个问题:`delete x`还可能是删除一个global对象上的属性。而它在执行这个操作的时候,看起来却像是一个全局变量(的名字)。
|
||||
|
||||
这中间有哪些细节的区别呢?
|
||||
|
||||
delete这个运算的表面意思,是该运算试图销毁某种东西。然而,`delete 0`中的0是一个具体的、字面量表示的“值”。一个字面量值“0”如何在现实世界中销毁呢?假定它销毁了,那是不是说,在这个语言当前的运行环境中,就不能使用0这个值了呢?显然,这不合理。
|
||||
|
||||
所以,JavaScript认为“**所有删除值的delete就直接返回true**”,表明该行为过程中没有异常。很不幸,JavaScript 1.2的时代并没有结构化异常处理(即try…catch语句)。所以,通过函数调用中返回true来表明“没有异常”,其实是很常规的做法。
|
||||
|
||||
然而,返回值只表明执行过程中没有异常,但实际的执行行为是“什么也没发生”。你显然不可能真的将“0”从执行系统中清理出去。
|
||||
|
||||
那么接下来,就还剩下删除变量和删除属性。由于全局变量实际上是通过全局对象的属性来实现的,因此删除变量也就存在识别这两种行为的必要性。例如:
|
||||
|
||||
```
|
||||
delete x
|
||||
|
||||
```
|
||||
|
||||
这行代码究竟是在删除什么呢?出于JavaScript是动态语言这项特性,从根本上来说,我们是没有办法在语法分析期来判断`x`的性质的。所以现在,需要有一种方法在运行期来标识`x`的性质,以便进一步地处理它。
|
||||
|
||||
这就导致了一种新的“引用”类型呼之欲出。
|
||||
|
||||
## 到底在删除什么?
|
||||
|
||||
探索工作往往如此,是所谓“进五退一”,甚至是“进五退四”。在今后的专栏文章中,你往往会看到,我在碰触到一种新东西的时候会竭力向前,但随后又后退好几步,再来讨论一些更基础层面的东西。这是因为如果不把这些基础概念说得清楚明白,那么往前冲的那几步常常就被带偏了方向。
|
||||
|
||||
一如现在这个问题:`delete 0`到底是在删除什么?
|
||||
|
||||
对于一门编译型语言来说,所谓“0”,就是上面所述的一个值,它可以是基础值(Primitive values),也可以是数值类型。但如果将这个问题上升到编译之前的、所谓语法分析的阶段,那么“0”就会被称为一个记号(Tokens)。一个记号是没有语义的,记号既可以是语言能识别的,也可以是语言不能识别的。唯有把这二者同时纳入语言范畴,那么这个语言才能识别所谓的“语法错误”。
|
||||
|
||||
delete不仅仅是要操作0或x这样的单个记号或标识符(例如变量)。因为这个语法实际起作用的是一个对象的属性,也就是“删除对象的成员”。那么它真正需要的语法其实是:
|
||||
|
||||
```
|
||||
delete obj.x
|
||||
|
||||
```
|
||||
|
||||
只不过因为全局对象的成员可以用全局变量的形式来存取,所以它才有了
|
||||
|
||||
```
|
||||
delete x
|
||||
|
||||
```
|
||||
|
||||
这样的语法语义而已。所以,这正好将你之前所认识的倒转过来,是删除x这个成员,而不是删除x这个值。不过终归有一点是没错的:既然没办法表达异常,而delete 0又不产生异常,那么它自然就该返回true。
|
||||
|
||||
然而,如果你理解了`delete obj.x`,那么就一定会想到:`obj.x`既不是之前说过的引用类型,也不是之前说过的值类型,它与`typeof(x)`识别的所有类型都无关。因为,它是一个表达式。
|
||||
|
||||
所以,delete这个操作的正式语法设计并不是“删除某个东西”,而是“删除一个表达式的结果”:
|
||||
|
||||
```
|
||||
delete UnaryExpression
|
||||
|
||||
```
|
||||
|
||||
## 表达式的结果是什么?
|
||||
|
||||
在JavaScript中表达式是一个很独特的东西,所有一切表达式运算的终极目的都是为了得到一个值,例如字符串。然后再用另外一些操作将这个值输出出来,例如变成网页中的一个元素(element)。这是JavaScript语言创生的原力,也是它的基础设计。也只是因为有了这种设计,它才变得既像面向对象的,又像函数式语言的样子。
|
||||
|
||||
表达式的执行特性,以及表达式与语句的关系等等细节,回头我放在第二阶段的内容中讲给你听。现在我们只需要关注一个要点,表达式计算的结果到底是什么?因为就像上面所说的,这个结果,才是`delete`这个操作要删除的东西。
|
||||
|
||||
在JavaScript中,有两个东西可以被执行并存在执行结果(Result),包括语句和表达式。比如你用`eval()`来执行一个字符串,那么实际上,你执行的是一个语句,并返回了语句的值;而如果你使用一对括号来强制一个表达式执行,那么这个括号运算得到的,就是这个表达式的值。
|
||||
|
||||
表达式的值,在ECMAScript的规范中,称为“引用”。
|
||||
|
||||
这是一种称为“规范类型”的东西。
|
||||
|
||||
## 规范中的“引用”
|
||||
|
||||
实际上这个概念出现得也很早。从JavaScript 1.3开始,ECMAScript规范就在语言定义的层面,正式地将上述的天坑补起来,推出了上面说到的这个“(真正的)引用类型”。
|
||||
|
||||
但是,由于这个时候规范的影响力在开发人员中并不那么大,所以开发人员还是习惯性地将对象和函数称为引用,而其它类型就称为值,并且继续按照传统的理解来解释JavaScript中对数据的处理。
|
||||
|
||||
这种情况下,一个引用只是在语法层面上表达“它是对某种语法元素的引用”,而与在执行层面的值处理或引用处理没关系。所以,下面这行简短的语句:
|
||||
|
||||
```
|
||||
delete 0
|
||||
|
||||
```
|
||||
|
||||
实际上是在说:JavaScript将0视为一个表达式,并尝试删除它的求值结果。
|
||||
|
||||
所以,现在这里的0,其实不是值(Value)类型的数据,而是一个表达式运算的结果(Result)。而在进一步的删除操作之前,JavaScript需要检测这个Result的类型:
|
||||
|
||||
- 如果它是值,则按照传统的JavaScript的约定返回true;
|
||||
- 如果它是一个引用,那么对该引用进行分析,以决定如何操作。
|
||||
|
||||
这个检测过程说明,ECMAScript约定:任何表达式计算的结果(Result)要么是一个值,要么是一个引用。并且需要留意的是,在这个描述中,所谓对象,其实也是值。准确地说,是“非引用类型”。例如:
|
||||
|
||||
```
|
||||
delete {}
|
||||
|
||||
```
|
||||
|
||||
那么显然,这里要删除的一对大括号是表示一个字面量的对象,当它被作为表达式执行的时候,结果也是一个值。这也是我常常将所有这类表达式称为“单值表达式”的原因,这里并没有所谓的“引用”。
|
||||
|
||||
你可以像下面这样,非常细致而准确地解释这一行代码:单值表达式的运算结果返回那个“对象字面量”的单值。然后,`delete`运算发现它的操作数是“值/非引用类型”,就直接返回了true。
|
||||
|
||||
所以,什么也没有发生。
|
||||
|
||||
## 还会发生什么
|
||||
|
||||
那么到底还会发生什么呢?
|
||||
|
||||
在JavaScript的内部,所谓“引用”是可以转换为“值”,以便参与值运算的。因为表达式的本质是求值运算,所以引用是不能直接作为最终求值的操作数的。这依赖于一个非常核心的、称为“GetValue()”的内部操作。所谓内部操作,也称为内部抽象操作(internal abstract operations),是ECMAScript描述一个符合规范的引擎在具体实现时应当处理的那些行为。
|
||||
|
||||
`GetValue()`是从一个引用中取出值来的行为。这有什么用呢?比如说下面这行代码:
|
||||
|
||||
```
|
||||
x = x
|
||||
|
||||
```
|
||||
|
||||
我们上面说过,所谓x其实是一个引用。上面的表达式其实是一个赋值表达式,那么“引用x赋值给引用x”有什么意义呢?其实这在语法层面来解释是非常直接的:
|
||||
|
||||
>
|
||||
所有赋值操作的含义,是将右边的“值”,赋给左边用于包含该值的“引用”。
|
||||
|
||||
|
||||
那么上面的`x=x`,其实就是被翻译成:
|
||||
|
||||
```
|
||||
x = GetValue(x)
|
||||
|
||||
```
|
||||
|
||||
来执行的。而JavaScript识别两个x的不同的方法,就称为“手性”,即是所谓“左手端(**lhs, left hand side**)”和“右手端(**rhs**)”。它本来是用来描述自然语言的语法中,一个修饰词应该是放在它的主体的前面或是后面的。而在程序设计语言中,它用来说明一个记号(Token)是放在了赋值符号(例如“=”号)的左边或是右边。作为一个简单的结论,区别上例中的两个x的方法就是:
|
||||
|
||||
>
|
||||
如果x放在左边作为lhs,那么它是引用;如果放在右边作为rhs,那么就是值。
|
||||
|
||||
|
||||
所以`x=x`的语义并不是“x赋给x”,而是“**把值x赋给引用x**”。
|
||||
|
||||
所以,“delete x”归根到底,是在**删除一个表达式的、引用类型的结果(Result)**,而不是在**删除x表达式**,或者这个**删除表达式的值(Value)**。
|
||||
|
||||
是的,在JavaScript中的`delete`是一个很罕见的、能直接操作“引用”的语法元素。由于这里的“引用”是在ECMAScript规范层面的概念,因此在JavaScript语言中能操作它的语法元素其实非常少。
|
||||
|
||||
然而很不幸,delete就是其中之一。
|
||||
|
||||
## 告诉我这些有什么用
|
||||
|
||||
等等,我想你一定会问了:神啊,让我知道这些究竟有什么用呢?我永远也不会去执行`delete 0`这样的操作啊!
|
||||
|
||||
是的。但是我接下来要告诉你的事实是:`obj.x`也是一个引用。对象属性存取是JavaScript的面向对象的基本操作之一,所以本质上我们早就在使用“引用”这个东西了,只不过它太习以为常,所以大家都视而不见。
|
||||
|
||||
“属性存取("."运算符)”返回一个关于“x”的引用,然后它可以作为下一个操作符(例如函数调用运算“()”)的左手端来使用,这才有了著名的“对象方法调用”运算:
|
||||
|
||||
```
|
||||
obj.x()
|
||||
|
||||
```
|
||||
|
||||
因为在对象方法调用的时候,函数_x()_是来自于`obj.x`这个引用的,所以这个引用将`obj`这个对象传递给x(),这才会让函数_x()_内部通过this来访问到obj。
|
||||
|
||||
根本上来说,如果`obj.x`只是值,或者它作为右手端,那么它就不能“携带”obj这个对象,也就完成不了后续的方法调用操作。
|
||||
|
||||
>
|
||||
对象存取 + 函数调用 = 方法调用
|
||||
|
||||
|
||||
这是JavaScript通过连续表达式运算来实现新的语义/语法的经典示例。
|
||||
|
||||
而所谓“连续运算”其实是函数式运算范式的基本原则。也就是说,`obj.x()`是在JavaScript中集合了“引用规范类型操作”“函数式”“面向对象”和“动态语言”等多种特性于一体的一个简单语法。
|
||||
|
||||
而它对语言的基础特性的依赖,就在于:
|
||||
|
||||
- `delete 0`中的这个`0`是一个表达式求值;
|
||||
- `delete x`中的`x`是一个引用;
|
||||
- `delete obj.x`中`obj.x`是一组表达式连续运算的结果(Result/引用);
|
||||
|
||||
于是,我们现在可以解释,当x是全局对象global的属性时,所谓`delete x`其实只需要返回`global.x`这个引用就可以了。而当它不是全局对象global的属性时,那么就需要从当前环境中找到一个名为`x`的引用。找到这两种不同的引用的过程,称为ResolveBinding;而这两种不同的`x`,称为不同环境下绑定的标识符/名字。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
下一讲我将给你讲述的,就是这个名字从声明到发现的全过程。至于现在,这一讲就要告一段落了。今天的内容中,有一些知识点我来带你回顾一下。
|
||||
|
||||
- delete 运算符尝试删除值数据时,会返回true,用于表示没有错误(Error)。
|
||||
- delete 0的本质是删除一个表达式的值(Result)。
|
||||
- delete x与上述的区别只在于Result是一个引用(Reference)。
|
||||
- delete其实只能删除一种引用,即对象的成员(Property)。
|
||||
|
||||
所以,只有在`delete x`等值于`delete obj.x`时delete才会有执行意义。例如`with (obj) ...`语句中的delete x,以及全局属性global.x。
|
||||
|
||||
## 思考题
|
||||
|
||||
- delete x中,如果x根本不存在,会发生什么?
|
||||
- delete object.x中,如果x是只读的,会发生什么?
|
||||
|
||||
希望你喜欢我的分享。
|
||||
@@ -0,0 +1,252 @@
|
||||
<audio id="audio" title="02 | var x = y = 100:声明语句与语法改变了JavaScript语言核心性质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/87/04/877fe81349f521061eac66e153dddd04.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
如果你听过上一讲的内容,心里应该会有一个问题,那就是——在规范中存在的“引用”到底有什么用?它对我们的编程有什么实际的影响呢?
|
||||
|
||||
当然,除了已经提及过的`delete 0`和`obj.x`之外,在今后的课程中,我还会与你讨论这个“引用”的其它应用场景。而今天的内容,就从标题来看,若是我要说与这个“引用”有关,你说不定得跳起来说我无知;但若说是跟“引用”无关的话呢,我觉得又不能把标题中的这一行代码解释清楚。
|
||||
|
||||
为什么这行代码看起来与规范类型中的“引用”无关呢?因为这行代码出现的时候,连ECMAScript这个规范都不存在。
|
||||
|
||||
我大概是在JavaScript 1.2左右的时代就接触到这门语言,在我写的最早的一些代码中就使用过它,并且——没错,你一定知道的:它能执行!
|
||||
|
||||
有很多的原因会促使你在JavaScript写出表达式连等这样的代码。从C/C++走过来的程序员对这样的一行代码是不会陌生的。它能用,而且结果也与你的预期会一致,例如:
|
||||
|
||||
```
|
||||
var x = y = 100;
|
||||
console.log(x); // 100
|
||||
console.log(y); // 100
|
||||
|
||||
```
|
||||
|
||||
它既没错、又好用,还很酷,你说我们为什么不用它呢?然而很不幸,这行代码可能是JavaScript中最复杂和最容易错用的表达式了。
|
||||
|
||||
所以今天我要和你一起好好地盘盘它。
|
||||
|
||||
## 声明
|
||||
|
||||
至今为止,除标签声明之外,JavaScript中一共只有六条声明用的语句。注意,所有真正被定义“声明”的语法结构都一定是“语句”,并且都用于声明一个或多个标识符。这里的标识符包括变量、常量等。
|
||||
|
||||
严格意义上讲,JavaScript只有变量和常量两种标识符,六条声明语句中:
|
||||
|
||||
- **let** **x** …
|
||||
|
||||
声明变量x。不可在赋值之前读。
|
||||
|
||||
- **const** **x** …
|
||||
|
||||
声明常量x。不可写。
|
||||
|
||||
- **var** **x** …
|
||||
|
||||
声明变量x。在赋值之前可读取到undefined值。
|
||||
|
||||
- **function** **x** …
|
||||
|
||||
声明变量x。该变量指向一个函数。
|
||||
|
||||
- **class** **x** …
|
||||
|
||||
声明变量x。该变量指向一个类(该类的作用域内部是处理严格模式的)。
|
||||
|
||||
- **import** …
|
||||
|
||||
导入标识符并作为常量(可以有多种声明标识符的模式和方法)。
|
||||
|
||||
除了这六个语句之外,还有两个语句有潜在的声明标识符的能力,不过它们并不是严格意义上的声明语句(声明只是它们的语法效果)。这两个语句是指:
|
||||
|
||||
- **for** (****var****|****let****|****const**** **x** …) …
|
||||
|
||||
for语句有多种语法来声明一个或多个标识符,用作循环变量。
|
||||
|
||||
- **try** … **catch** (**x**) …
|
||||
|
||||
catch子句可以声明一个或多个标识符,用作异常对象变量。
|
||||
|
||||
总的来说,除上述的语法,用户是没有其它方式来在当前的代码上下文中“声明”出一个标识符来的。而我之所以在这里严格强调这一“汇总性的”结果,是因为下面的一个简单论断,所有的“声明”:
|
||||
|
||||
>
|
||||
<ul>
|
||||
- 都意味着JavaScript将可以通过“静态”语法分析发现那些声明的标识符;
|
||||
- 标识符对应的变量/常量“一定”会在用户代码执行前就已经被创建在作用域中。
|
||||
</ul>
|
||||
|
||||
|
||||
这个标题中的`var x`就是一个声明。在这个声明的后半部分,使用“=”这个符号引导了一个初始化语法——通常情况下可以将它理解为一个赋值运算。
|
||||
|
||||
## 从读取值到赋值
|
||||
|
||||
声明是在语法分析阶段就处理的,并且因此它会使得当前代码上下文在正式执行之前就拥有了被声明的标识符,例如`x`。
|
||||
|
||||
这其实非常有趣,因为这表明JavaScript虽然被称为是“动态语言”,但确实是拥有静态语义的。而在JavaScript的早期,这个静态语义其实并没有处理得太好,一个典型的问题就是所谓的“变量提升”。也就是可以在变量声明之前访问该变量。例如:
|
||||
|
||||
```
|
||||
console.log(x); // undefined
|
||||
var x = 100;
|
||||
console.log(x); // 100
|
||||
|
||||
```
|
||||
|
||||
这个“变量提升”还包括“变量被创建于声明它的语法块”之外的意思,但这并不是这里要讨论的内容,我会在今后再讲它。在今天的课程里,你只需要留意这个变量的读写过程就好了。那么,关于读取值,之前声明的变量与常量又有什么不同呢?
|
||||
|
||||
如上面已经说过的,由于标识符是在用户代码执行之前就已经由静态分析得到,并且创建在环境中,因此let声明的变量和var声明的变量在这一点上没有不同:它们都是在读取一个“已经存在的”标识符名。例如:
|
||||
|
||||
```
|
||||
var y = "outer";
|
||||
function f() {
|
||||
console.log(y); // undefined
|
||||
console.log(x); // throw a Exception
|
||||
let x = 100;
|
||||
var y = 100;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
正是由于`var y`所声明的那个标识符在函数f()创建(它自己的闭包)时就已经存在,所以才阻止了`console.log(y)`访问全局环境中的`y`。类似的,`let x`所声明的那个`x`其实也已经存在f()函数的上下文环境中。访问它之所以会抛出异常(Exception),不是因为它不存在,而是因为这个标识符被拒绝访问了。
|
||||
|
||||
在ECMAScript 6之后出现的`let/const`变量在“声明(和创建)一个标识符”这件事上,与`var`并没有什么不同,只是JavaScript拒绝访问还没有绑定值的`let/const`标识符而已。
|
||||
|
||||
回到ECMAScript 6之前:JavaScript是允许访问还没有绑定值的`var`所声明的标识符的。这种标识符后来统一约定称为“变量声明(varDelcs)”,而“let/const”则称为“词法声明(lexicalDecls)”。JavaScript环境在创建一个“变量名(varName in varDecls)”后,会为它初始化绑定一个undefined值,而”词法名字(lexicalNames)”在创建之后就没有这项待遇,所以它们在缺省情况下就是“还没有绑定值”的标识符。
|
||||
|
||||
>
|
||||
NOTE:6种声明语句中的函数是按varDecls的规则声明的;类的内部是处于严格模式中,它的名字是按let来处理的,而import导入的名字则是按const的规则来处理的。所以,所有的声明本质上只有三种处理模式:var变量声明、let变量声明和const常量声明。
|
||||
|
||||
|
||||
所以,标题中的`var x = ...`在语义上就是为变量x绑定一个初值。在具体的语言环境中,它将被实现为一个赋值操作。
|
||||
|
||||
## 赋值
|
||||
|
||||
如果是在一门其它的(例如编译型的)语言中,“为变量x绑定一个初值”就可能实现为“在创建环境时将变量x指向一个特定的初始值”。这通常是静态语言的处理方法,然而,如前面说过的,JavaScript是门动态的语言,所以它的“绑定初值”的行为是通过动态的执行过程来实现的,也就是赋值操作。
|
||||
|
||||
那么请你仔细想想,一个赋值操作在语法上怎么表达呢?例如:
|
||||
|
||||
```
|
||||
变量名 = 值
|
||||
|
||||
```
|
||||
|
||||
这样对吗?不对!在JavaScript中,这样讲是非常不正确的。正确的说法是:
|
||||
|
||||
```
|
||||
lRef = rValue
|
||||
|
||||
```
|
||||
|
||||
也就是将右操作数(的值)赋给左操作数(的引用)。它的严格语法表达是:
|
||||
|
||||
>
|
||||
**LeftHandSideExpression** < **=** | ****AssignmentOperator**** > **AssignmentExpression**
|
||||
|
||||
|
||||
也就是说,在JavaScript中,一个赋值表达式的左边和右边其实“都是”表达式!
|
||||
|
||||
## 向一个不存在的变量赋值
|
||||
|
||||
接下来我要给你介绍的是从JavaScript 1.0开始就遗留下来的一个巨坑,也就是所谓的“变量泄漏”问题。这在早期的JavaScript中的确是一个好用的特性:如果你向一个不存在的变量名赋值,那么JavaScript会在全局范围内创建它。
|
||||
|
||||
也就是说,代码中不需要显式地声明一个变量了,变量可以随用随声明,也不用像后来的`let`语句一样,还要考虑在声明语句之前能不能访问的问题了。这非常简单,在少量的代码中也相当易用。
|
||||
|
||||
但是,如果代码规模扩大,变成百千万行代码,那么“**一个全局变量是在哪里声明和创建的**”就变成一个非常要紧的问题。
|
||||
|
||||
如果随时都可能泄露一个代码给全局,或者随时都可能因为忘记本地的声明而读写了全局变量,那对调试除错将是一场灾难。另外,晚一些出现的运行期优化技术也不能很好地处理这种情况。所以从ECMAScript5开始的严格模式就禁止了这种特性,试图避免用户将变量泄露到全局环境。
|
||||
|
||||
然而现实中,即使在严格模式下这种漏露也未能避免。这称为“**间接执行**”,这将是另一个巨大的议题,并且是ECMAScript6之后开始的一种新的机制。但是现在这里发生的事情,也就是这个“向不存在的变量赋值”的问题,是从JavaScript 1.0时代就遗留下来的问题,也是ECMAScript为JavaScript填补的最大设计漏洞之一。
|
||||
|
||||
那么,在具体技术细节上,这个变量声明是如何发生的呢?
|
||||
|
||||
事实上,这是因为在早期设计中,JavaScript的全局环境是引擎使用一个称为“**全局对象**”东西管理起来的。
|
||||
|
||||
这个全局对象几乎类似或完全等同于一个普通对象。只不过,JavaScript引擎将全局的一些缺省对象、运行期环境的原生对象等东西都初始化在这个全局对象的属性中,并使用这个对象创建了一个称为“**全局对象闭包**”的东西,从而得到了JavaScript的全局环境。
|
||||
|
||||
早期的JavaScript的引擎实现非常简洁,许多基础的技术组件都是直接复用的,例如这里的所谓全局环境、全局闭包,或者全局对象的实现方法,就与“**with语句**”的效果完全相同——他们是相互复用的。
|
||||
|
||||
当向一个不存在的变量赋值的时候,由于全局对象的属性表是可以动态添加的,因此JavaScript将变量名作为属性名添加给全局对象。而访问所谓全局变量时,就是访问这个全局对象的属性。因此,实际效果就变成了“可以动态地向全局环境中添加一个变量”。并且,显然地,我们在第一讲已经讲过这个结果——你可以删除掉这个动态添加的“变量”,因为本质上就是在删除全局对象的属性。
|
||||
|
||||
那么现在(我是指在ECMAScript6之后)的JavaScript的全局环境有什么不同吗?
|
||||
|
||||
为了兼容旧的JavaScript语言设计,现在的JavaScript环境仍然是通过将全局对象初始化为这样的一个全局闭包来实现的。但是为了得到一个“尽可能”与其它变量环境相似的声明效果(varDecls),ECMAScript规定在这个全局对象之外再维护一个变量名列表(varNames),所有在静态语法分析期或在eval()中使用`var`声明的变量名就被放在这个列表中。然后约定,这个变量名列表中的变量是“直接声明的变量”,不能使用`delete`删除。
|
||||
|
||||
于是,我们得到了这样的一种结果:
|
||||
|
||||
```
|
||||
> var a = 100;
|
||||
> x = 200;
|
||||
|
||||
# `a`和`x`都是global的属性
|
||||
> Object.getOwnPropertyDescriptor(global, 'a');
|
||||
{ value: 100, writable: true, enumerable: true, configurable: false }
|
||||
> Object.getOwnPropertyDescriptor(global, 'x');
|
||||
{ value: 200, writable: true, enumerable: true, configurable: true }
|
||||
|
||||
# `a`不能删除, `x`可以被删除
|
||||
> delete a
|
||||
false
|
||||
> delete x
|
||||
true
|
||||
|
||||
# 检查
|
||||
> a
|
||||
100
|
||||
> x
|
||||
ReferenceError: x is not defin
|
||||
|
||||
```
|
||||
|
||||
所以,表面看起来“泄漏到全局的变量”与使用`var`声明的都是全局变量,并且都实现为global的属性,但事实上它们是不同的。并且当`var`声明发生在eval()中的时候,这一特性又还有所不同,例如:
|
||||
|
||||
```
|
||||
# 使用eval声明
|
||||
> eval('var b = 300');
|
||||
|
||||
# 它的性质是可删除的
|
||||
> Object.getOwnPropertyDescriptor(global, 'b').configurable;
|
||||
true
|
||||
|
||||
# 检测与删除
|
||||
> b
|
||||
300
|
||||
> delete b
|
||||
true
|
||||
> b
|
||||
ReferenceError: b is not define
|
||||
|
||||
```
|
||||
|
||||
这种情况下使用`var`声明的变量名尽管也会添加到varNames列表,但它也可以从varNames中移除(这是唯一一种能从varNames中移除项的特例,而lexicalNames中的项是不可移除的)。
|
||||
|
||||
## 发生了什么?
|
||||
|
||||
所以,现在回到今天讨论的这行代码`var x = y = 100`,在这行代码中,等号的右边是一个表达式`y = 100`,它发生了一次“向不存在的变量赋值”,所以它隐式地声明了一个全局变量`y`,并赋值为100。
|
||||
|
||||
而一个赋值表达式操作本身也是有“结果(Result)”的,它是右操作数的值。注意,这里是“值”而非“引用”,例如下面的测试中的`a`将是一个函数,而不是带着“this对象”信息的方法:
|
||||
|
||||
```
|
||||
// 调用obj.f()时将检测this是不是原始的obj
|
||||
> obj = { f: function() { return this === obj } };
|
||||
|
||||
// false,表明赋值表达式的“结果(result)”只是右侧操作数的值,即函数f
|
||||
> (a = obj.f)();
|
||||
false
|
||||
|
||||
```
|
||||
|
||||
到现在为止,我们讲述了整个语句的过程,也就是说,由于“y = 100”的结果是100,所以该值将作为初始值赋值“变量x”。并且,从语义上来说,这是变量“x”的初始绑定。
|
||||
|
||||
之所以强调这一点,是因为相同的分析过程也可以用在const声明上,而const声明是只有一次绑定的,常量的初始绑定也是通过“执行赋值过程”来实现的。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
- var等声明语句总是在变量作用域(变量表)或词法作用域中静态地声明一个或多个标识符。
|
||||
- 全局变量的管理方式决定了“向一个不存在的变量赋值”所导致的变量泄漏是不可避免的。
|
||||
- 动态添加的“var声明”是可以删除的,这是唯一能操作varNames列表的方式(不过它并不存在多少实用意义)。
|
||||
- 变量声明在引擎的处理上被分成两个部分:一部分是静态的、基于标识符的词法分析和管理,它总是在相应上下文的环境构建时作为名字创建的;另一部分是表达式执行过程,是对上述名字的赋值,这个过程也称为绑定。
|
||||
- 这一讲标题里的这行代码中,x和y是两个不同的东西,前者是声明的名字,后者是一个赋值过程可能创建的变量名。
|
||||
|
||||
## 思考题
|
||||
|
||||
根据今天讲解的内容,我希望你可以尝试回答以下问题:
|
||||
|
||||
- 严格来说,声明不是语句。但是,是哪些特性决定了声明不是“严格意义上的”语句呢?
|
||||
|
||||
在下一讲中我会来讲一讲JavaScript社区中的一个历史悬案,这桩悬案与今天讨论的这行代码的唯一区别在于:它不是声明语句,而是赋值表达式。
|
||||
@@ -0,0 +1,349 @@
|
||||
<audio id="audio" title="03 | a.x = a = {n:2}:一道被无数人无数次地解释过的经典面试题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/83/53fc95378939b6b1bef341be705b2983.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
在前端的历史中,有很多人都曾经因为同一道面试题而彻夜不眠。这道题出现在9年之前,它的提出者“蔡mc(蔡美纯)”曾是JQuery的提交者之一,如今已经隐去多年,不复现身于前端。然而这道经典面试题仍然多年挂于各大论坛,被众多后来者一遍又一遍地分析。
|
||||
|
||||
在2010年10月,[Snandy](https://github.com/snandy)于iteye/cnblogs上发起对这个话题的讨论之后,淘宝的玉伯(lifesinger)也随即成为这个问题早期的讨论者之一,并写了一篇“**a.x = a = { }, 深入理解赋值表达式**”来专门讨论它。再后来,随着它在各种面试题集中频繁出现,这个问题也就顺利登上了知乎,成为一桩很有历史的悬案。
|
||||
|
||||
蔡mc最初提出这个问题时用的标题是“**赋值运算符:"=", 写了10年javascript未必全了解的"="**”,原本的示例代码如下:
|
||||
|
||||
```
|
||||
var c = {};
|
||||
c.a = c = [];
|
||||
alert(c.a); //c.a是什么?
|
||||
|
||||
```
|
||||
|
||||
蔡mc是在阅读JQuery代码的过程中发现了这一使用模式:
|
||||
|
||||
```
|
||||
elemData = {}
|
||||
...
|
||||
elemData.events = elemData = function(){};
|
||||
elemData.events = {};
|
||||
|
||||
```
|
||||
|
||||
并质疑,为什么`elemData.events`需要连续两次赋值。而Snandy在转述的时候,换了一个更经典、更有迷惑性的示例:
|
||||
|
||||
```
|
||||
var a = {n:1};
|
||||
a.x = a = {n:2};
|
||||
alert(a.x); // --> undefined
|
||||
|
||||
```
|
||||
|
||||
Okay,这就是今天的主题。
|
||||
|
||||
接下来,我就为你解释一下,为什么在第二行代码之后`a.x`成了undefined值。
|
||||
|
||||
## 与声明语句的不同之处
|
||||
|
||||
你可能会想,三行代码中出问题的,为什么不是第1行代码?
|
||||
|
||||
在上一讲的讨论中,声明语句也可以是一个连等式,例如:
|
||||
|
||||
```
|
||||
var x = y = 100;
|
||||
|
||||
```
|
||||
|
||||
在这个示例中,“var”关键字所声明的,事实上有且仅有“x”一个变量名。
|
||||
|
||||
在可能的情况下,变量“y”会因为赋值操作而导致JavaScript引擎“**意外**”创建一个全局变量。所以,声明语句“var/let/const”的一个关键点在于:语句的关键字var/let/const只是用来“声明”变量名x的,去除掉“var x”之后剩下的部分,并不是一个严格意义上的“赋值运算”,而是被称为“初始器(Initializer)”的语法组件,它的词法描述为:
|
||||
|
||||
>
|
||||
**Initializer**: **=** **AssignmentExpression**
|
||||
|
||||
|
||||
在这个描述中,“=”号并不是运算符,而是一个语法分隔符号。所以,之前我在讲述这个部分的时候,总是强调它“被实现为一个赋值操作”,而不是直接说“它是一个赋值操作”,原因就在这里。
|
||||
|
||||
如果说在语法“var x = 100”中,“= 100”是向x绑定值,那么“var x”就是单纯的标识符声明。这意味着非常重要的一点——**“x”只是一个表达名字的、静态语法分析期作为标识符来理解的字面文本,而不是一个表达式**。
|
||||
|
||||
而当我们从相同的代码中去除掉“var”关键字之后:
|
||||
|
||||
```
|
||||
x = y = 100;
|
||||
|
||||
```
|
||||
|
||||
其中的“x”却是一个表达式了,它被严格地称为“赋值表达式的左手端(lhs)操作数”。
|
||||
|
||||
所以,关键的区别在于:(赋值表达式左侧的)操作数可以是另一个表达式——这在专栏的第一讲里就讲过了,而“var声明”语句中的等号左边,绝不可能是一个表达式!
|
||||
|
||||
也许你会质疑:难道ECMAScript 6之后的模板赋值的左侧,也不是表达式?确实,答案是:如果它用在声明语句中,那么就“不是”。
|
||||
|
||||
对于声明语句来说,紧随于“var/let/const”之后的,一定是变量名(标识符),且无论是一个或多个,都是在JavaScript语法分析阶段必须能够识别的。
|
||||
|
||||
如果这里是赋值模板,那么“var/let/const”语句也事实上只会解析那些用来声明的变量名,并在运行期使用“初始器(Initializer)”来为这些名字绑定值。这样,“变量声明语句”的语义才是确定的,不至于与赋值行为混淆在一起。
|
||||
|
||||
因此,根本上来说,在“var声明”语法中,变量名位置上就是写不成`a.x`的。例如:
|
||||
|
||||
```
|
||||
var a.x = ... // <- 这里将导致语法出错
|
||||
|
||||
```
|
||||
|
||||
所以,在最初蔡mc提出这个问题时,以及其后Sanady和玉伯的转述中,都不约而同地在代码中绕过了第一行的声明,而将问题指向了第二行的连续赋值运算。
|
||||
|
||||
```
|
||||
var a = {n:1}; // 第一行
|
||||
a.x = a = {n:2}; // 第二行
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
## 来自《JavaScript权威指南》的解释
|
||||
|
||||
有人曾经引述《JavaScript权威指南》中的一段文字(4.7.7 运算顺序),来解释第二行的执行过程:
|
||||
|
||||
>
|
||||
JavaScript总是严格按照从左至右的顺序来计算表达式。
|
||||
|
||||
|
||||
并且还举了一个例子:
|
||||
|
||||
>
|
||||
例如,在表达式`w = x + y * z`中,将首先计算子表达式w,然后计算x、y和z;然后,y的值和z的值相乘,再加上x的值;最后将其赋值给表达式w所指代的变量或属性。
|
||||
|
||||
|
||||
《JavaScript权威指南》的解释是没有问题的。首先,在这个赋值表达式的右侧`x + y*z`中,`x`与`y*z`是求和运算的两个操作数,任何运算的操作数都是严格从左至右计算的,因此x先被处理,然后才会尝试对`y`和`z`求乘积。这里所谓的“x先被处理”是JavaScript中的一个特异现象,即:
|
||||
|
||||
>
|
||||
一切都是表达式,一切都是运算。
|
||||
|
||||
|
||||
这一现象在语言中是函数式的特性,类似“一切被操作的对象都是函数求值的结果,一切操作都是函数”。
|
||||
|
||||
这对于以过程式的,或编译型语言为基础的学习者来说是很难理解的,因为在这些传统的模式或语言范型中,所谓“标识符/变量”就是一个计算对象,它可能直接表达为某个内存地址、指针,或者是一个编译器处理的东西。对于程序员来说,将这个变量直接理解为“操作对象”就可以了,没有别的、附加的知识概念。例如:
|
||||
|
||||
```
|
||||
a = 100
|
||||
b * c
|
||||
|
||||
```
|
||||
|
||||
这两个例子中,a、b、c都是确定的操作数,我们只需要
|
||||
|
||||
- 将第一行理解为“a有了值100”;
|
||||
- 将第二行理解为“b与c的乘积”
|
||||
|
||||
就可以了,至于引擎怎么处理这三个变量,我们是不管的。
|
||||
|
||||
然而在JavaScript中,上面一共是有六个操作的。以第二行为例,包括:
|
||||
|
||||
- 将`b`理解为单值表达式,求值并得到`GetValue(evalute('b'))`;
|
||||
- 将`c`理解为单值表达式,求值并得到`GetValue(evalute('c'))`;
|
||||
- 将上述两个值理解为求积表达式’*'的两个操作数,计算
|
||||
|
||||
>
|
||||
`evalute('*', GetValue(evalute('b')), GetValue(evalute('c')))`
|
||||
|
||||
|
||||
所以,关键在于`b`和`c`在表达式计算过程中都并不简单的是“一个变量”,而是“一个单值表达式的计算结果”。这意味着,在面对JavaScript这样的语言时,你需要关注“变量作为表达式是什么,以及这样的表达式如何求值(以得到变量)”。
|
||||
|
||||
那么,现在再比较一下今天这一讲和上一讲的示例:
|
||||
|
||||
```
|
||||
var x = y = 100;
|
||||
a.x = a = {n:2}
|
||||
|
||||
```
|
||||
|
||||
在这两个例子中,
|
||||
|
||||
- x是一个标识符(不是表达式),而y和100都是表达式,且`y = 100`是一个赋值表达式。
|
||||
- a.x是一个表达式,而`a = {n:2}`也是表达式,并且后者的每一个操作数(本质上)也都是表达式。
|
||||
|
||||
这就是“语句与表达式”的不同。正如上一讲的所强调的:“**var x”从来都不进行计算求值,所以也就不能写成“var a.x …**”。
|
||||
|
||||
>
|
||||
所以严格地说,在上一讲的例子中,并不存在连续赋值运算,因为“var x = …”是**值绑定操作**,而不是“将…赋值给x”。在代码`var x = y = 100;`中实际只存在一个赋值运算,那就是“y = 100”。
|
||||
|
||||
|
||||
## 两个连续赋值的表达式
|
||||
|
||||
所以,今天标题中的这行代码,是真正的、两个连续赋值的表达式:
|
||||
|
||||
```
|
||||
a.x = a = {n:2}
|
||||
|
||||
```
|
||||
|
||||
并且,按照之前的理解,`a.x`总是最先被计算求值的(从左至右)。
|
||||
|
||||
回顾第一讲的内容,你也应该记得,所谓“a.x”也是一个表达式,其结果是一个“引用”。这个表达式“a.x”本身也要再计算它的左操作数,也就是“a”。完整地讲,“a.x”这个表达式的语义是:
|
||||
|
||||
- 计算单值表达式`a`,得到`a`的引用;
|
||||
- 将右侧的名字`x`理解为一个标识符,并作为“.”运算的右操作数;
|
||||
- 计算“a.x”表达式的结果(Result)。
|
||||
|
||||
表达式“a.x”的计算结果是一个引用,因此通过这个引用保存了一些计算过程中的信息——例如它保存了“a”这个对象,以备后续操作中“可能会”作为`this`来使用。所以现在,在整行代码的前三个表达式计算过程中,“a”是作为一个**引用**被暂存下来了的。
|
||||
|
||||
那么这个“a”现在是什么呢?
|
||||
|
||||
```
|
||||
var a = {n:1};
|
||||
a.x = ...
|
||||
|
||||
```
|
||||
|
||||
从代码中可见,保存在“a.x”这个引用中的“a”是当前的“{n:1}”这个对象。好的,接下来再继续往下执行:
|
||||
|
||||
```
|
||||
var a = {n:1};
|
||||
a.x = // <- `a` is {n:1}
|
||||
a = // <- `a` is {n:1}
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
这里的“a = …”中的`a`仍然是当前环境中的变量,与上一次暂存的值是相同的。这里仍然没有问题。
|
||||
|
||||
但接下来,发生了赋值:
|
||||
|
||||
```
|
||||
...
|
||||
a.x = // <- `a` is {n:1}
|
||||
a = // <- `a` is {n:1}
|
||||
{n:2}; // 赋值,覆盖当前的左操作数(变量`a`)
|
||||
|
||||
```
|
||||
|
||||
于是,左操作数`a`作为一个引用被覆盖了,这个引用仍然是当前上下文中的那个变量`a`。因此,这里真实地发生了一次`a = {n:2}`。
|
||||
|
||||
那么现在,表达式最开始被保留在“一个结果(Result)”中的引用`a`会更新吗?
|
||||
|
||||
不会的。这是因为那是一个“**运算结果**(Result)”,这个结果有且仅有引擎知道,它现在是一个引擎才理解的“**引用**(规范对象)”,对于它的可能操作只有:
|
||||
|
||||
- 取值或置值(GetValue/PutValue),以及
|
||||
- 作为一个引用向别的地方传递等。
|
||||
|
||||
当然,如同第一讲里强调的,它也可以被typeof和delete等操作引用的运算来操作。但无论如何,在JavaScript用户代码层面,能做的主要还是**取值**和**置值**。
|
||||
|
||||
现在,在整个语句行的最左侧“**空悬**”了一个已经求值过的“a.x”。当它作为赋值表达式的左操作数时,它是一个被赋值的引用(这里是指将`a.x`的整体作为一个引用规范对象)。而它作为结果(Result)所保留的“a”,是在被第一次赋值操作覆盖之前的、那个“原始的变量`a`”。也就是说,如果你试图访问它的“a.n”,那应该是值“1”。
|
||||
|
||||
这个被赋值的引用“a.x”其实是一个未创建的属性,赋值操作将使得那个“原始的变量`a`”具有一个新属性,于是它变成了下面这样:
|
||||
|
||||
```
|
||||
// a.x中的“原始的变量`a`”
|
||||
{
|
||||
x: {n: 2}, // <- 第一次赋值“a = {n:2}”的结果
|
||||
n: 1
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就是第二次赋值操作的结果。
|
||||
|
||||
## 复现现场
|
||||
|
||||
上面发生了两次赋值,第一次赋值发生于“a = {n: 2}”,它覆盖了“原始的变量`a`”;第二次赋值发生于被“a.x”引用暂存的“原始的变量`a`”。
|
||||
|
||||
我可以给出一段简单的代码,来复现这个现场,以便你看清这个结果。例如:
|
||||
|
||||
```
|
||||
// 声明“原始的变量a”
|
||||
var a = {n:1};
|
||||
|
||||
// 使它的属性表冻结(不能再添加属性)
|
||||
Object.freeze(a);
|
||||
|
||||
try {
|
||||
// 本节的示例代码
|
||||
a.x = a = {n:2};
|
||||
}
|
||||
catch (x) {
|
||||
// 异常发生,说明第二次赋值“a.x = ...”中操作的`a`正是原始的变量a
|
||||
console.log('第二次赋值导致异常.');
|
||||
}
|
||||
|
||||
// 第一次赋值是成功的
|
||||
console.log(a.n); //
|
||||
|
||||
```
|
||||
|
||||
第二次赋值操作中,将尝试向“原始的变量`a`”添加一个属性“a.x“,且如果它没有冻结的话,属性“a.x”会指向第一次赋值的结果。
|
||||
|
||||
## 回到标题中的示例
|
||||
|
||||
那标题中的这行代码的最终结果是什么呢?答案是:
|
||||
|
||||
- 有一个新的`a`产生,它覆盖了原始的变量`a`,它的值是`{n:2}`;
|
||||
- 最左侧的“a.x”的计算结果中的“原始的变量`a`”在引用传递的过程中丢失了,且“a.x”被同时丢弃。
|
||||
|
||||
所以,第二次赋值操作“a.x = …”实际是无意义的。因为它所操作的对象,也就是“原始的变量`a`”被废弃了。但是,如果有其它的东西,如变量、属性或者闭包等,持有了这个“原始的变量`a`”,那么上面的代码的影响仍然是可见的。
|
||||
|
||||
>
|
||||
事实上,由于JavaScript中支持属性读写器,因此向“a.x”置值的行为总是可能存在“某种执行效果”,而与“a”对象是否被覆盖或丢弃无关。
|
||||
|
||||
|
||||
例如:
|
||||
|
||||
```
|
||||
var a = {n:1}, ref = a;
|
||||
a.x = a = {n:2};
|
||||
console.log(a.x); // --> undefined
|
||||
console.log(ref.x); // {n:2}
|
||||
|
||||
```
|
||||
|
||||
这也解释了最初“蔡mc”的疑问:连续两次赋值`elemData.events`有什么用?
|
||||
|
||||
如果`a`(或`elemData`)总是被重写的旧的变量,那么如下代码:
|
||||
|
||||
```
|
||||
a.x = a = {n:2}
|
||||
|
||||
```
|
||||
|
||||
意味着给**旧的变量**添加一个**指向新变量的属性**。因此,一个链表是可以像下面这样来创建的:
|
||||
|
||||
```
|
||||
var i = 10, root = {index: "NONE"}, node = root;
|
||||
|
||||
// 创建链表
|
||||
while (i > 0) {
|
||||
node.next = node = new Object;
|
||||
node.index = i--; // 这里可以开始给新node添加成员
|
||||
}
|
||||
|
||||
// 测试
|
||||
node = root;
|
||||
while (node = node.next) {
|
||||
console.log(node.index);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我做这道面试题做一点点细节上的补充:
|
||||
|
||||
- 这道面试题与运算符优先级无关;
|
||||
- 这里的运算过程与“栈”操作无关;
|
||||
- 这里的“引用”与传统语言中的“指针”没有可比性;
|
||||
- 这里没有变量泄漏;
|
||||
- 这行代码与上一讲的例子有本质的不同;
|
||||
- 上一讲的例子“var x = y = 100”严格说来并不是连续赋值。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
前三讲中,我通过对几行特殊代码的分析,希望能帮助你理解“引用(规范类型)”在JavaScript引擎内部的基本运作原理,包括:
|
||||
|
||||
- 引用在语言中出现的历史;
|
||||
- 引用与值的创建与使用,以及它的销毁(delete);
|
||||
- 表达式(求值)和引用之间的关系;
|
||||
- 引用如何在表达式连续运算中传递计算过程的信息;
|
||||
- 仔细观察每一个表达式(及其操作数)计算的顺序;
|
||||
- 所有声明,以及声明语句的共性。
|
||||
|
||||
## 复习题
|
||||
|
||||
下面有几道复习题,希望你尝试解答一下:
|
||||
|
||||
1. 试解析`with ({x:100}) delete x;` 将发生什么。
|
||||
1. 试说明`(eval)()`与`(0, eval)()`的不同。
|
||||
1. 设“a.x === 0”,试说明“(a.x) = 1”为什么可行。
|
||||
1. 为什么`with (obj={}) x = 100;` 不会给obj添加一个属性’x’?
|
||||
|
||||
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -0,0 +1,294 @@
|
||||
<audio id="audio" title="04 | export default function() {}:你无法导出一个匿名函数表达式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/67/d4/67e0433087a241e999716b1a5196c0d4.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,欢迎回到我的专栏。
|
||||
|
||||
今天我要讲述的内容是从ECMAScript 6开始在JavaScript中出现的**模块技术**,这对许多JavaScript开发者来说都是比较陌生的。
|
||||
|
||||
一方面在于它出现得较晚,另一方面,则是因为在普遍使用的Node.js环境带有自己内置的模块加载技术。因此,ECMAScript 6模块需要通过特定的命令行参数才能开启,它的应用一直以来也就不够广泛。
|
||||
|
||||
导致这种现象的根本原因在于**ECMAScript 6模块是静态装配的**,而传统的Node.js模块却是动态加载的。因而两种模块的实现效果与处理逻辑都大相径庭,Node.js无法在短期内提供有效的手段帮助开发者将既有代码迁移到新的模块规范下。
|
||||
|
||||
总结起来,确实是这些更为现实的原因阻碍了ECMAScript 6模块技术的推广,而非是ECMAScript 6模块是否成熟,或者设计得好与不好。
|
||||
|
||||
不过即使如此,ECMAScript 6模块仍然在JavaScript的一些大型应用库、包,或者对新规范更友好的项目中得到了不错的运用和不俗的反响,尤其是在使用转译器(例如Babel)的项目中,开发者通常是首选ECMAScript 6模块语法的。
|
||||
|
||||
因此ECMAScript 6模块也有着非常好的应用环境与前景。
|
||||
|
||||
## 导出的内容
|
||||
|
||||
上一讲我提到过有且仅有六种声明语法,而本质上export也就只能导出这六种声明语法所声明的标识符,并且在导出时将它们统一称为“名字”。
|
||||
|
||||
在语言设计中,所谓“标识符”与“名字”是有语义差别的,export将之称为名字,就意味着这是一个标识符的子集。类似的其它子集也是存在的,例如“保留字是标识符名,但不能用作标识符(A reserved word is an IdentifierName that cannot be used as an Identifier)”。
|
||||
|
||||
在JavaScript语言的设计上,除了那些预设的标点符号(例如大括号、运算符之类),以及部分的保留字和关键字之外,事实上用户代码可以书写的只有三种东西。这包括:
|
||||
|
||||
- 标识符:(通常是)一个**名字**;
|
||||
- 字面量:表明由它的字面含义所决定的一个**值**;
|
||||
- 模板:一个可计算结果的字符串**值**。
|
||||
|
||||
所以,如果在这个层面上解构一份你所书写的JavaScript代码,那么你所能书写/声明的,就一定只有“名字和值”。
|
||||
|
||||
这个结论是非常非常关键的。为什么呢?因为export事实上就只能导出“名字和值”。然而一旦它能导出“名字和值”,也就意味着它能导出一个模块中的“全部内容”,因为如上所面所讲的:
|
||||
|
||||
“名字和值”正是你所书写的代码的全部。
|
||||
|
||||
## 我的代码去哪儿了呢?
|
||||
|
||||
你是不是一刹那之间觉得自己的代码都白写了。:)
|
||||
|
||||
确实是的,真的是白写了。不过,我在前面讲的都是纯粹的“语言设计”,在语言设计层面上来讲,代码就是文本,是没有应用逻辑的。而你所写的代码绝大多数都是应用逻辑,当去除掉这些应用逻辑之后,那些剩下的死气沉沉的、纯粹的符号,才是语言层面的所谓“代码文本”。
|
||||
|
||||
去掉了执行逻辑所表达的那些行为、动作、结果和用户操作的代码,就是静态代码了。而事实上,ECMAScript 6中的模块就是用来理解你的程序中的那些静态代码的,也就是那些没有任何生气的字符和符号。因此它也就只能理解上面所谓的6种声明,以及它们声明出来的那些“名字和值”。
|
||||
|
||||
再无其它。
|
||||
|
||||
## 解析export
|
||||
|
||||
所以,将所有export语法分类,其实也就只有两个大类。如下:
|
||||
|
||||
```
|
||||
// 导出“(声明的)名字”
|
||||
export <let/const/var> x ...;
|
||||
export function x() ...
|
||||
export class x ...
|
||||
export {x, y, z, ...};
|
||||
|
||||
|
||||
// 导出“(重命名的)名字”
|
||||
export { x as y, ...};
|
||||
export { x as default, ... };
|
||||
|
||||
|
||||
// 导出“(其它模块的)名字”
|
||||
export ... from ...;
|
||||
|
||||
|
||||
// 导出“值”
|
||||
export default <expression
|
||||
|
||||
```
|
||||
|
||||
关于导出声明的、重命名的和其它模块的名字这三种情况,其实都比较容易理解,就是形成一个名字表,让外部模块能够查看就可以了。
|
||||
|
||||
但是对于最后这种形式,也就是“(导出)值”的形式,事实上是非常特殊的。因为如同我在上面所讲过的,要导出一个模块的全部内容就必须导出“(全部的)名字和值”,然而纯粹的值没有名字,于是也就没法访问了,所以这就与“导出点什么东西”的概念矛盾了。
|
||||
|
||||
因为这个东西要是没名字,也就连“自己是什么”都说不清楚,也就什么也不是了。
|
||||
|
||||
所以ECMAScript 6模块约定了一个称为"default"的名字,用于来导出当前模块中的一个“值”。显然的,由于所谓“值”是表达式的运算结果,所以这里的语法形式就是:
|
||||
|
||||
```
|
||||
export default <expression>;
|
||||
|
||||
```
|
||||
|
||||
其中的“_expression”_就是用于求值的,以便得到一个结果(Result)并导出成为缺省的名字“default”。这里有两个便利的情况,一个是在JavaScript中,一般的字面量也是值、也是单值表达式,因此导出这样一个字面量也是合法的:
|
||||
|
||||
```
|
||||
export default 2; // as state of the module, etc.
|
||||
export default "some messages"; // data or information
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
第二个便利的情况,是因为JavaScript中对象也是字面量、也是值、也是单值表达式。而对象成员可以组合其它任何数据,所以通过上述的语法几乎可以导出当前模块中全部的“值”(亦即是任何可以导出的数据)。例如:
|
||||
|
||||
```
|
||||
var varName = 100;
|
||||
export default {
|
||||
varName, // 直接导出名字
|
||||
propName: 123, // 导出值
|
||||
funcName: function() { }, // 导出函数
|
||||
foo() { // 或导出与主对象相关联的方法
|
||||
// method
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以,事实上`export default ...`虽然简单,却是对“导出名字”的非常必要的补充。这样一来,用户既可以导出那些有名字的数据,也可以导出那些没有名字的数据,即一个模块中所有的数据都可以被导出了。
|
||||
|
||||
那么接下来,就要讲到标题中的这个语法了:
|
||||
|
||||
```
|
||||
export default function() {}
|
||||
|
||||
```
|
||||
|
||||
你知道在这个语法中export到底导出了什么吗?是名字?还是值?
|
||||
|
||||
## 导出语句的处理逻辑
|
||||
|
||||
在讨论这个问题之前,你得先思考一个更关键的问题:“export如何导出名字”。这个问题的关键之处在于,如果只是导出一个名字,那么它其实在“某个名字表”中做一个登记项就可以了。并且JavaScript中也的确是这样处理的。但是实际使用的时候,这个名字还是要绑定一个具体的值才是可以使用的。因此,一个export也必须理解为这样两个步骤:
|
||||
|
||||
1. 导出一个名字
|
||||
1. 为上述名字绑定一个值
|
||||
|
||||
这两个步骤其实与使用“var x = 100”来声明一个变量的过程是一致的。因此以如下代码为例(注意六种声明在名字处理上是类似的),
|
||||
|
||||
```
|
||||
export var x = 100;
|
||||
|
||||
```
|
||||
|
||||
在导出的时候,其实是先在“某个名字表”中登记一个“名字x”就可以了。这个过程也就是JavaScript在模块装载之前对export所做的全部工作。不过如果是从另一端(亦即是import语句)的角度看过来,那么就会多出来一个步骤。import语句会(例如`import {x} from ...`):
|
||||
|
||||
1. (与export类似)按照语法在当前模块中声明名字,例如上面的`x`;
|
||||
1. 添加一个当前模块对目标模块的依赖项。
|
||||
|
||||
有了上述的第二步操作,JavaScript就可以依据所有它能在静态文本中发现的`import`语句来形成模块依赖树,最后就可以找到这个模块依赖树最顶端的根模块,并尝试加载之。
|
||||
|
||||
所以关键的是,在“模块export/import”语法中 ,JavaScript是依赖import来形成依赖树的,与export无关。但是直到目前为止(我的意思是直到找到所有导入和导出的名字,并完成所有模块的装配的现在为止),没有任何一行用户的JavaScript代码是被执行过的。至于原因,从本讲的最开始我就讲过了:这个export/import过程中,源代码只被理解为静态的、没有逻辑的“代码文本”。那么既然“没有逻辑”,又怎么可能执行类似于:
|
||||
|
||||
```
|
||||
export default <expression>;
|
||||
|
||||
```
|
||||
|
||||
中的“**expression**”呢?要知道所谓表达式,就是程序的计算逻辑啊。
|
||||
|
||||
所以,这里先得出了第一个关键结论:
|
||||
|
||||
在处理export/import语句的全程,没有表达式被执行!
|
||||
|
||||
## 导出名字与导出值的差异
|
||||
|
||||
现在,假如:
|
||||
|
||||
```
|
||||
export default <expression>;
|
||||
|
||||
```
|
||||
|
||||
中的“expression”在导入导出中完全不起作用(不执行),那么这行语句又能做什么呢?事实上,这行语句与直接“导出一个名字”并没有任何区别。它与这样的语法相同:
|
||||
|
||||
```
|
||||
export var x = 100;
|
||||
|
||||
```
|
||||
|
||||
它们都只是导出一个名字,只是前者导出的是“default”这个特殊名字,而后者导出的是一个变量名“x”。它们都是确定的、符合语法规则的标识符,也可以表示为一个字符串的字面文本。它们的作用也完全一致:就是在前面所说的“某个名字表”中添加“一个登记项”而已。
|
||||
|
||||
所以,导出名字与导出值本质上并没有差异,在静态装配的阶段,它们都只是表达为一个名字而已。
|
||||
|
||||
然后,也正是如同`var x = 100;`在执行阶段需要有一个将“值100”绑定给“变量x(的引用)”的过程一样,这个`export default ...;`语句也需要有完全相同的一个过程来将它后面的表达式(**expression**)的结果绑定给“default”这个名字。如果不这么做,那么“**export default**”在语义上的就无法实现导出名字“**default**”了——在静态装配阶段,名字“default”只是被初始化为一个“单次绑定的、未初始化的标识符”。
|
||||
|
||||
所以现在你就可以在语义上模拟这样一个过程,即:
|
||||
|
||||
```
|
||||
export default function() {}
|
||||
|
||||
// 类似于如下代码
|
||||
//(但并不在当前模块中声明名字"default")
|
||||
export var default = function() {}
|
||||
|
||||
|
||||
```
|
||||
|
||||
你可以进一步地模拟JavaScript后续的装配过程。这个过程其实非常简单:
|
||||
|
||||
- 找到并遍历模块依赖树的所有模块(这个树是排序的),然后
|
||||
- 执行这些模块最顶层的代码(**Top Level Module Evaluation**)。
|
||||
|
||||
在执行到上述`var default ....`(或类似对应的`export default ...`)语句时,执行后面的表达式,并将执行结果(Result)绑定给左侧的那个变量就可以了。如此,直到所有模块的顶层代码都执行完毕,那么所有的导出名字和它们的值也都必然是绑定完成了的。
|
||||
|
||||
同样,由于import的名字与export的名字只是一个映射关系,所以import的名字(所对应的值)也就初始化完成了。
|
||||
|
||||
再确切地说(这是第二个关键结论):
|
||||
|
||||
所谓模块的装配过程,就是执行一次顶层代码而已。
|
||||
|
||||
## 匿名函数表达式的执行结果
|
||||
|
||||
接下来讨论语句中的`... function() {}`这个匿名函数表达式。
|
||||
|
||||
按照JavaScript的约定,匿名函数表达式可以理解为一个函数的“字面量(值)”。理解“字面量值”这个说法是很有意义的,因为它意味着它没有名字。你可不要在心中暗骂哦,这绝不是废话。
|
||||
|
||||
“字面量(值)没有名字”就意味着执行这个“单值表达式”不会在当前作用域中产生一个名字,即使这个函数是具名的,也必然是如此。所以,这才带来了JavaScript中的经典示例,即:具名函数作为表达式时,名字在块级作用域中无意义。例如:
|
||||
|
||||
```
|
||||
// 具名函数作为表达式
|
||||
var x1 = function x2() {
|
||||
...
|
||||
}
|
||||
|
||||
// 具名函数(声明)
|
||||
function x3() {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上面的例子中,x1~3都是具有不同的语义的。其中,x2是不会在当前作用域(示例中是全局)中登记为名字的。而现在,就这一讲的主题来说,在使用下面的语法:
|
||||
|
||||
```
|
||||
export default function() { }
|
||||
export default function x() { }
|
||||
|
||||
```
|
||||
|
||||
导出一个匿名函数,或者一个具名的函数的时候,这两种情况下是不同的。但无论它是否具名,它们都是不可能在当前作用域中绑定给`default`这个名字,作为这个名字对应的值的。
|
||||
|
||||
这段处理逻辑被添加在语法:
|
||||
|
||||
>
|
||||
**ExportDeclaration**: **export** **default** **AnonymousFunctionDefinition**;
|
||||
|
||||
|
||||
>
|
||||
NOTE: ECMAScript是将这里导出的对象称为_Expression_/**AssignmentExpression**,这里所谓_AnonymousFunctionDefinition_则是其中_AssignmentExpression_的一个具体实例。
|
||||
|
||||
|
||||
的执行(**Evaluation**)处理过程中。也就是说当执行这行声明时,如果后面的表达式是匿名函数声明,那么它将强制在当前作用域中登记为“****<em>default**</em>**”这样一个特殊的名字,并且在执行时绑定该匿名函数。所以,尽管语义上我们需要将它登记为类似`var default ...`所声明的名字“****default****”,但事实上它被处理成了一个不可访问的中间名字,然后影射给该模块的“某个名字表”。
|
||||
|
||||
不过需要注意的是,这是一个**匿名函数定义**(**AnonymousFunctionDefinition**),而不是一个匿名函数表达式(**Anonymous FunctionExpression**)。一般函数的语句则被称为声明(或更严谨地称为宣告,**Function Declarations**)。而所谓**匿名函数定义**,其本身是表述为:
|
||||
|
||||
>
|
||||
**aName** = ****FunctionExpression****
|
||||
|
||||
|
||||
或类似于此的语法风格的。它可以用在一般的赋值表达式、变量声明的右操作数,以及对象声明的成员初始值等等位置。在这些位置上,该函数表达式总是被关联给一个名字。一方面,这种关联不是严格意义上的“名字->值”的绑定语义;另一方面,当该函数关联给名字(`aName`)时,JavaScript又会反向地处理该函数(作为对象`f`)的属性`f.name`,使该名字指向`aName`。
|
||||
|
||||
所以,在本讲中的“export default function() {}”,在严格意义上来说(这是第三个关键结论):
|
||||
|
||||
它并不是导出了一个匿名函数表达式,而是导出了一个匿名函数定义(Anonymous Function Definition)。
|
||||
|
||||
因此,该匿名函数初始化时才会绑定给它左侧的名字“****default****”,这会导致`import f from ...`之后访问`f.name`值会得到“****default****”这个名字。
|
||||
|
||||
类似的,你使用下面的代码也会得到这个“****default****”:
|
||||
|
||||
```
|
||||
var obj = {
|
||||
"default": function() {}
|
||||
};
|
||||
console.log(obj.default.name); // "default"
|
||||
|
||||
```
|
||||
|
||||
## 知识补充
|
||||
|
||||
关于export,还可以有一些补充的知识点。
|
||||
|
||||
- `export ...`语句通常是按它的词法声明来创建的标识符的,例如`export var x = ...`就意味着在当前模块环境中创建的是一个变量,并可以修改等等。但是当它被导入时,在`import`语句所在的模块中却是一个常量,因此总是不可写的。
|
||||
- 由于`export default ...`没有显式地约定名字“default(或**default**)”应该按`let/const/var`的哪一种来创建,因此JavaScript缺省将它创建成一个普通的变量(var),但即使是在当前模块环境中,它事实上也是不可写的,因为你无法访问一个命名为“**default**”的变量——它不是一个合法的标识符。
|
||||
- 所谓匿名函数,仅仅是当它直接作为操作数(而不是具有上述“匿名函数定义”的语法结构)时,才是真正匿名的,例如:
|
||||
|
||||
```
|
||||
console.log((function(){}).name); // ""
|
||||
|
||||
```
|
||||
|
||||
- 由于类表达式(包括匿名类表达式)在本质上就是函数,因此它作为default导出时的性质与上面所讨论的是一致的。
|
||||
- 导出项(的名字)总是作为词法声明被声明在当前模块作用域中的,这意味着它不可删除,且不可重复导出。亦即是说即使是用`var x...`来声明,这个`x`也是在_lexicalNames_中,而不是在_varNames_中。
|
||||
- 所谓“某个名字表”,对于export来说是模块的导出表,对于import来说就是名字空间(名字空间是用户代码可以操作的组件,它映射自内部的模块导入名字表)。不过,如果用户代码不使用“import * as …”的语法来创建这个名字空间,那么该名字表就只存在于JavaScript的词法分析过程中,而不会(或并不必要)创建它在运行期的实例。这也是我一直用“某个名字表”来称呼它的原因,它并不总是以实体形式存在的。
|
||||
- 上述名字表简化了ECMAScript中对导入导出记录(**ImportEntry/ExportEntry Record Fields**)的理解。因此如果你试图了解更多,建议你阅读ECMAScript的具体章节。
|
||||
- 没有模块会导出(传统意义上的)main(),因为ECMAScript为了维护模块的静态语义,而把执行过程及其入口的定义丢回给了引擎或宿主本身。
|
||||
|
||||
## 思考题
|
||||
|
||||
本讲的内容中,你需要重点复习三个关键结论的得出过程。这对于之前几讲中所讨论的内容会是很好的回顾。
|
||||
|
||||
除此之外,建议你思考如下问题:
|
||||
|
||||
- 为什么在import语句中会出现“变量提升”的效果?
|
||||
|
||||
如果你并不了解什么是“变量提升”,不用担心,下一讲中我会再次提到它。
|
||||
@@ -0,0 +1,263 @@
|
||||
<audio id="audio" title="05 | for (let x of [1,2,3]) ...:for循环并不比使用函数递归节省开销" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/fe/454307d34b873ef16a9f3a78bafaccfe.mp3"></audio>
|
||||
|
||||
你好,我是周爱民。欢迎回到我的专栏,我将为你揭示JavaScript最为核心的那些实现细节。
|
||||
|
||||
语句,是JavaScript中组织代码的基础语法组件,包括函数声明等等在内的六种声明,其实都被归为“语句”的范畴。因此,如果将一份JavaScript代码中的所有语句抽离掉,那么大概就只会剩下为数不多的、在全局范围内执行的表达式了。
|
||||
|
||||
所以,理解“语句”在JavaScript中的语义是重中之重。
|
||||
|
||||
尽管如此,你实际上要了解的也无非是**顺序**、**分支**、**循环**这三种执行逻辑而已,相比于它们,其它语句在语义上的复杂性通常不值一提。而这三种逻辑中尤其复杂的就是循环,今天的这一讲,我就来给你讲讲它。
|
||||
|
||||
## 块
|
||||
|
||||
在ECMAScript 6之后,JavaScript实现了**块级作用域**。因此,现在绝大多数语句都基于这一作用域的概念来实现。近乎想当然的,几乎所有开发者都认为一个JavaScript语句就有一个自己的块级作用域。这看起来很好理解,因为这样处理是典型的、显而易见的**代码分块的结果**。
|
||||
|
||||
然而,事实上正好相反。
|
||||
|
||||
真正的状况是,**绝大多数JavaScript语句都并没有自己的块级作用域**。从语言设计的原则上来看,越少作用域的执行环境调度效率也就越高,执行时的性能也就越好。
|
||||
|
||||
基于这个原则,`switch`语句被设计为有且仅有一个作用域,无论它有多少个case语句,其实都是运行在一个块级作用域环境中的。例如:
|
||||
|
||||
```
|
||||
var x = 100, c = 'a';
|
||||
switch (c) {
|
||||
case 'a':
|
||||
console.log(x); // ReferenceError
|
||||
break;
|
||||
case 'b':
|
||||
let x = 200;
|
||||
break;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,switch语句内是无法访问到外部变量`x`的,即便声明变量`x`的分支`case 'b'`永远都执行不到。这是因为所有分支都处在同一个块级作用域中,所以任意分支的声明都会给该作用域添加这个标识符,从而覆盖了全局的变量`x`。
|
||||
|
||||
一些简单的、显而易见的块级作用域包括:
|
||||
|
||||
```
|
||||
// 例1
|
||||
try {
|
||||
// 作用域1
|
||||
}
|
||||
catch (e) { // 表达式e位于作用域2
|
||||
// 作用域2
|
||||
}
|
||||
finally {
|
||||
// 作用域3
|
||||
}
|
||||
|
||||
// 例2
|
||||
//(注:没有使用大括号)
|
||||
with (x) /* 作用域1 */; // <- 这里存在一个块级作用域
|
||||
|
||||
// 例3, 块语句
|
||||
{
|
||||
// 作用域1
|
||||
|
||||
```
|
||||
|
||||
除了这三个语句和“**一个特例**”之外,所有其它的语句都是没有块级作用域的。例如`if`条件语句的几种常见书写形式:
|
||||
|
||||
```
|
||||
if (x) {
|
||||
...
|
||||
}
|
||||
|
||||
// or
|
||||
if (x) {
|
||||
...
|
||||
}
|
||||
else {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这些语法中的“块级作用域”都是一对大括号表示的“块语句”自带的,与上面的“例3”是一样的,而与`if`语句本身无关。
|
||||
|
||||
那么,这所谓的“一个特例”是什么呢?这个特例,就是今天这一讲标题中的`for`循环。
|
||||
|
||||
## 循环语句中的块
|
||||
|
||||
并不是所有的循环语句都有自己的块级作用域,例如while和do..while语句就没有。而且,也不是所有for语句都有块级作用域。在JavaScript中,有且仅有:
|
||||
|
||||
>
|
||||
for (**<let/const>** ...) ...
|
||||
|
||||
|
||||
这个语法有自己的块级作用域。当然,这也包括相同设计的`for await`和`for .. of/in ..`。例如:
|
||||
|
||||
>
|
||||
<p>for await (**<let/const>** x of ...) ...<br>
|
||||
for (**<let/const>** x ... in ...) ...<br>
|
||||
for (**<let/const>** x ... of ...) ...</p>
|
||||
|
||||
|
||||
等等。你应该已经注意到了,这里并没有按照惯例那样列出“var”关键字。关于这一点,后面写到的时候我也会再次提及到。就现在来说,你可能需要关心的问题是:**为什么这是个特例?**以及,**如果它是拥有自己的块级作用域的特例,那么它有多少个块级作用域呢?**
|
||||
|
||||
后面这个问题的答案,是:“说不准”。
|
||||
|
||||
看起来,我是被JavaScript的古怪设计击败了。我居然给出了这么一个显而易见是在糊弄大众的答案!但是要知道,所谓的“块级作用域”有两种形式,一种是静态的词法作用域,这对于上面的for语句来说,它们都只有两个块级作用域。但是对于另一种动态的、“块级作用域”的实例来说,这答案就真的是“说不准”了。
|
||||
|
||||
不过,先放下这个,我接下来先给你解释一下“**为什么这里需要一个特例**”。
|
||||
|
||||
## 特例
|
||||
|
||||
除了语句的关键字和语法结构本身之外,语句中可以包括什么呢?
|
||||
|
||||
如果你归纳一下语句中可以包含的全部内容,你应该可以看到一个简单的结果:所有在语句内可以存在的东西只有四种:表达式、其它语句、标识符声明(取决于声明语句或其它的隐式声明的方式),以及一种特殊的语法元素,称为“标签(例如标签化语句,或break语句指向的目标位置)”。
|
||||
|
||||
所谓“块级作用域”,本质上只包括一组标识符。因此,只有当存在潜在标识符冲突的时候,才有必要新添加一个作用域来管理它们。例如函数,由于函数存在“重新进入”的问题,所以它必须有一个作用域来管理“重新进入之前”的那些标识符。这个东西想必你是听说过的,它被称为“**闭包**”。
|
||||
|
||||
>
|
||||
NOTE: 在语言设计时,有三种需求会促使语句构建自己的作用域,标识符管理只是其中之一。其它两种情况,要么是因为在语法上支持多语句(例如try...catch..finally语句),要么是语句所表达的语义要求有一个块,例如“块语句{ }”在语义上就要求它自己是一个块级作用域。
|
||||
|
||||
|
||||
所以**标签**、**表达式**和**其它语句**这三种东西都不需要使用一个“独立作用域”去管理起来。所谓“其它语句”当然存在这种冲突,不过显然这种情况下它们也应该自己管理这个作用域。所以,对于当前语句来说,就只需要考虑剩下的唯一一种情况,就是在“**语句中包含了标识符声明**”的情况下,需要创建块级作用域来管理这些声明出来的标识符。
|
||||
|
||||
在所有六种声明语句之外,只剩下`for (<let/const>...)...`这一个语句能在它的语法中去做这样的标识符声明。所以,它就成了块级作用域的这个唯一特例。
|
||||
|
||||
那么这个语法中为什么单单没有了“var声明”呢?
|
||||
|
||||
## 特例中的特例
|
||||
|
||||
“var声明”是特例中的特例。
|
||||
|
||||
这一特性来自于**JavaScript远古时代的作用域设计**。在早期的JavaScript中,并没有所谓的块级作用域,那个时候的作用域设计只有“函数内”和“函数外”两种,如果一个标识符不在任何(可以多层嵌套的)函数内的话,那么它就一定是在“全局作用域”里。
|
||||
|
||||
“函数内→全局”之间的作用域,就只有概念上无限层级的“函数内”。
|
||||
|
||||
而在这个时代,变量也就只有“var声明”的变量。由于作用域只有上面两个,所以任何一个“var声明”的标识符,要么是在函数内的,要么就是在全局的,没有例外。按照这个早期设计,如下语句中的变量`x`:
|
||||
|
||||
```
|
||||
for (var x = ...)
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
是不应该出现在“**for语句所在的**”块级作用域中的。它应该出现其外层的某个函数作用域,或者全局作用域中。这种越过当前语法范围,而在更外围的作用域中登记名字行为就称为“**提升(Hoisting/Hoistable)**”。
|
||||
|
||||
ECMAScript 6开始的JavaScript在添加块级作用域特性时充分考虑了对旧有语法的兼容,因此当上述语法中出现“var声明”时,它所声明的标识符是与该语句的块级作用域无关的。在ECMAScript中,这是两套标识符体系,也是使用两套作用域来管理的。确切地说:
|
||||
|
||||
- 所有“var声明”和函数声明的标识符都登记为varNames,使用“**变量作用域**”管理;
|
||||
- 其它情况下的标识符/变量声明,都作为lexicalNames登记,使用“**词法作用域**”管理。
|
||||
|
||||
>
|
||||
NOTE: 考虑到对传统JavaScript的兼容,函数内部的顶层函数名是提升到变量作用域中来管理的。 > > NOTE: 我通常会将“在变量声明语句前使用该变量”也称为一种提升效果(**Hoisting effect**),但这种说法不见于ECMAScript规范。ES规范将这种“提前使用”称为“访问一个未初始化的绑定(**uninitialized mutable/immutable binding**)”。而所谓“var声明能被提前使用”的效果,事实上是“var变量总是被引擎预先初始化为undefined”的一种后果。
|
||||
|
||||
|
||||
所以,语句`for (<const/let> x ...) ...`语法中的标识符`x`是一个**词法名字**,应该由`for`语句为它创建一个(块级的)词法作用域来管理之。
|
||||
|
||||
然而进一步后,新的问题产生了:一个词法作用域是足够的吗?
|
||||
|
||||
## 第二个作用域
|
||||
|
||||
首先,必须要拥有至少一个块级作用域。如之前讲到的,这是出于管理标识符的必要性。下面的示例简单说明这个块级作用域的影响:
|
||||
|
||||
```
|
||||
var x = 100;
|
||||
for (let x = 102; x < 105; x++)
|
||||
console.log('value:', x); // 显示“value: 102~104”
|
||||
console.log('outer:', x); // 显示“outer: 100”
|
||||
|
||||
```
|
||||
|
||||
因为`for`语句的这个块级作用域的存在,导致循环体内访问了一个局部的`x`值(循环变量),而外部的(outer)变量`x`是不受影响的。
|
||||
|
||||
那么在循环体内是否需要一个新的块级作用域呢?这取决于在语言设计上是否支持如下代码:
|
||||
|
||||
```
|
||||
for (let x = 102; x < 105; x++)
|
||||
let x = 200;
|
||||
|
||||
```
|
||||
|
||||
也就是说,如果循环体(单个语句)允许支持新的变量声明,那么为了避免它影响到循环变量,就必须为它再提供另一个块级作用域。很有趣的是,**在这里,JavaScript是不允许声明新的变量的**。上述的示例会抛出一个异常,提示你“单语句不支持词法声明”:
|
||||
|
||||
>
|
||||
**SyntaxError: Lexical declaration cannot appear in a single-statement context**
|
||||
|
||||
|
||||
这个语法错误并不常见,因为很少有人会尝试构建这样的特殊代码。然而事实上,它是一个普遍存在的语法禁例,例如以下语句语法:
|
||||
|
||||
```
|
||||
// if语句中的禁例
|
||||
if (false) let x = 100;
|
||||
|
||||
// while语句中的禁例
|
||||
while (false) let x = 200;
|
||||
|
||||
// with语句中的禁例
|
||||
with (0) let x = 300
|
||||
|
||||
```
|
||||
|
||||
所以,现在可以确定:循环语句(对于支持“**let/const**”的for语句来说)“通常情况下”只支持一个块级作用域。更进一步地说,在上面的代码中,我们并没有机会覆盖for语句中的“let/const”声明。
|
||||
|
||||
但是如果在for语句支持了let/const的情况下,仅仅只有一个块级作用域是不方便的。例如:
|
||||
|
||||
```
|
||||
for (let i=0; i<2; i++) /* 用户代码 */;
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,“只有一个块级作用域”的设计,将会导致“用户代码”直接运行在与“let声明”相同的词法作用域中。对于这个例子来说,这一切还好,因为“let i = 0”这个代码只执行了一次,因为它是for语句的初始化表达式。
|
||||
|
||||
但是对于下面这个例子来说,“只有一个块级作用域”就不够了:
|
||||
|
||||
```
|
||||
for (let i in x) ...;
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,“let i ...”在语义上就需要被执行多次——因为在静态结构中它的多次迭代都作用于同一个语法元素。而你是知道的,let语句的变量不能重复声明的。所以,这里就存在了一个冲突:“let/const”语句的单次声明(不可覆盖)的设计,与迭代多次执行的现实逻辑矛盾了。
|
||||
|
||||
这个矛盾的起点,就是“只有一个块级作用域”。所以,在JavaScript引擎实现“支持_let/const_的for语句”时,就在这个地方做了特殊处理:为循环体增加一个作用域。
|
||||
|
||||
这样一来,“let i”就可以只执行一次,然后将“i in x”放在每个迭代中来执行,这样避免了与“let/const”的设计冲突。
|
||||
|
||||
上面讲的,其实是JavaScript在语法设计上的处理,也就是在语法设计上,需要为使用`let/const`声明循环变量的for语句多添加一个作用域。然而,这个问题到了具体的运行环境中,变量又有些不同了。
|
||||
|
||||
## for循环的代价
|
||||
|
||||
在JavaScript的具体执行过程中,作用域是被作为环境的上下文来创建的。如果将for语句的块级作用域称为**forEnv**,并将上述为循环体增加的作用域称为**loopEnv**,那么**loopEnv**它的外部环境就指向**forEnv**。
|
||||
|
||||
于是在loopEnv看来,变量`i`其实是登记在父级作用域forEnv中,并且loopEnv只能使用它作为名字“i”的一个引用。更准确地说,在loopEnv中访问变量`i`,在本质上就是通过环境链回溯来查找标识符(Resolve identifier, or Get Identifier Reference)。
|
||||
|
||||
上面的矛盾“貌似”被解决了,但是想想程序员可以在每次迭代中做的事情,这个解决方案的结果就显得并不那么乐观了。例如:
|
||||
|
||||
```
|
||||
for (let i in x)
|
||||
setTimeout(()=>console.log(i), 1000);
|
||||
|
||||
```
|
||||
|
||||
这个例子创建了一些定时器。当定时器被触发时,函数会通过它的闭包(这些闭包处于loopEnv的子级环境中)来回溯,并试图再次找到那个标识符`i`。然而,当定时器触发时,整个for迭代有可能都已经结束了。这种情况下,要么上面的forEnv已经没有了、被销毁了,要么它即使存在,那个`i`的值也已经变成了最后一次迭代的终值。
|
||||
|
||||
所以,要想使上面的代码符合预期,这个loopEnv就必须是“随每次迭代变化的”。也就是说,需要为每次迭代都创建一个新的作用域副本,这称为**迭代环境**(iterationEnv)。因此,每次迭代在实际上都并不是运行在loopEnv中,而是运行在该次迭代自有的iterationEnv中。
|
||||
|
||||
也就是说,在语法上这里只需要两个“块级作用域”,而实际运行时却需要为其中的第二个块级作用域创建无数个副本。
|
||||
|
||||
这就是for语句中使用“let/const”这种块级作用域声明所需要付出的代价。
|
||||
|
||||
## 知识回顾
|
||||
|
||||
今天我讲述了for循环为了支持局部的标识符声明而付出的代价。
|
||||
|
||||
在传统的JavaScript中是不存在这个问题的,因为“var声明”是直接提升到函数的作用域中登记的,不存在上面的矛盾。这里讲的for语句的特例,是在ECMAScript 6支持了块级作用域之后,才出现的特殊语法现象。当然,它也带来了便利,也就是可以在每个for迭代中使用独立的循环变量了。
|
||||
|
||||
当在这样的for循环中添加块语句时(这是很常见的),块语句是作为iterationEnv的子级作用域的,因此块语句在每个迭代中都会都会创建一次它自己的块级作用域副本。这个循环体越大,支持的层次越多,那么这个环境的创建也就越频繁,代价越高昂。再加上可以使用函数闭包将环境传递出去,或交给别的上下文引用,这里的负担就更是雪上加霜了。
|
||||
|
||||
注意,无论用户代码是否直接引用loopEnv中的循环变量,这个过程都是会发生的。这是因为JavaScript允许动态的eval(),所以引擎并不能依据代码文本静态地分析出循环体(ForBody)中是否引用哪些循环变量。
|
||||
|
||||
你应该知道一种理论上的观点,也就是所谓“**循环与函数递归在语义上等价**”。所以在事实上,上述这种**for循环并不比使用函数递归节省开销**。在函数调用中,这里的循环变量通常都是通过函数参数传递来处理的。因而,那些支持“let/const”的for语句,本质上也就与“在函数参数界面中传递循环控制变量的递归过程”完全等价,并且在开销上也是完全一样的。
|
||||
|
||||
因为每一次函数调用其实都会创建一个**新的闭包**——也就是函数的作用域的一个副本。
|
||||
|
||||
## 思考题
|
||||
|
||||
- 为什么单语句(single-statement)中不能出现词法声明( lexical declaration )?
|
||||
|
||||
希望你喜欢我的分享,也欢迎你把文章分享给你的朋友。
|
||||
@@ -0,0 +1,144 @@
|
||||
<audio id="audio" title="加餐 | 捡豆吃豆的学问(上):这门课讲的是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/3a/97d543162f55c7b180cd3189d743943a.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,今天又到了我的《JavaScript核心原理解析》课。
|
||||
|
||||
不过与以往不同,今天不上正经课,讲点课外的话。前两天极客时间的编辑老师找我,开了一个会,认认真真地讨论了一下这门课程,认为当务之急,是给同学们开个小灶,要好好讲一下“怎么学这门课程”。因为这才短短地讲了1/3,许多同学就已经跟不上了。
|
||||
|
||||
说到开小灶,我第一个就是想到了“吃”。
|
||||
|
||||
为什么呢?因为我父亲就是厨师,正正经经地考过厨师证,说不得多有名气,但一个县城里面,能跟他水平看齐的,也就三两个人而已。我的哥哥后来也随了父业,做了厨师,精通京、粤、淮扬多个菜系,更是拿了一级厨师的证书。
|
||||
|
||||
而我呢,没学厨,从父亲手上学到的唯一功夫,就是一个“吃”字。
|
||||
|
||||
## 什么是“会吃”?
|
||||
|
||||
会吃这件事,其实不是下嘴的功夫。这跟绝大多数人想的不同。下嘴就吃,就算有再多的法子,也无非是生啃细嚼;花样再多,也不过是甩动大腮帮子,劳力活而已。
|
||||
|
||||
真正会吃,说的是三件事,第一是食材,第二是味道,第三件,就单单一个“懂”字。
|
||||
|
||||
吃货世界里的“食材”,搁在我们今天的话题里,就是这二十来讲的课程。开课之前,有同学便过来打探,问我这个课程都讲些什么,看看值不值得一听。我就索性问了问他,你想听些什么呢?
|
||||
|
||||
- VUE?没有。
|
||||
- HTML、CSS?没有。
|
||||
- 前端?客户端?浏览器?手机App?……没有,统统没有。
|
||||
|
||||
那位同学不死心,又悻悻然地问了一句:总该讲点Node.js、React Native,又或者是NPM之类的吧?
|
||||
|
||||
我索性给他摊了底牌:二十节课程里面,就只算是提到这些名词,大概也不超过五次。
|
||||
|
||||
那位同学回了一句:那你让我学什么?
|
||||
|
||||
## “食材”的问题
|
||||
|
||||
好问题啊!学什么呢?
|
||||
|
||||
我记得在豌豆荚的时候,有参与一些招聘的工作。老实说,作为架构师,招聘工作通常不是在第一轮,大概会是到第二、三轮的时候,才会轮到我上场。也就是说,我需要面试的,大抵都是其他“面试官”认为“这家伙还有点料”的。
|
||||
|
||||
有一次,我的一个面试结束得比较快,在内部的HR系统中填写评论的时候,正经才写了四个字,便被其他面试官打断了,他嚷嚷着,几乎快要被整个公司都听到了,说:“爱民老师,人家十几年的一线开发,大型系统都做过那么多,经验很丰富的!你怎么这么点评呢?“
|
||||
|
||||
嗯,我在HR系统中写评论,起头里的四个字是:**水平一般**。
|
||||
|
||||
我想了想,停下写评论,给这个面试官聊聊开头这四个字。我说,我其实不太常用这种措辞来评论候选人,但这个也算是例外了。为什么呢?因为这个人真的是能力很强,下手很快,做的产品和项目也多,经验非常丰富,但是他确实就是水平一般。
|
||||
|
||||
什么叫“水平一般”呢?
|
||||
|
||||
因为他学的东西,别人也都学;会的东西,别人也都会;他强的东西,别人一样也强。即使别人今天不如他学得多、会得多,又或者不比他强多少,但是只要花点时间、下点功夫,也就一样儿也不会比他差。他十几年的一线开发,把自己做成了熟手、熟练工,东西会得再多,技巧再熟练,也不过是卖油翁的“但手熟尔”。
|
||||
|
||||
我在面试里面,确实问了他几个偏向核心的问题,他也确实知道,很清晰、很准确。但再进一步问原理时,他却是一无所知。所以,我又引导、设问,说:“如果现在让你来考虑这个问题,你会从哪儿入手呢?”那位候选人想了好几个招数,中规中矩,然而无一可用。
|
||||
|
||||
这就是关键所在。
|
||||
|
||||
“核心原理”不是一些招数技法,不是拿来多练多用就行了的。所谓“核心”呢,不见得是大家都知道的——一眼望去,万千条路径之中,找到最正确的那一条,才是核心。但是这个东西可以教,也可以记,下次看见这个路,照着走就是了。所以,大公司里有所谓的“核心团队”,新人进去,不消半年工夫,功力就大增了,出来能带一个团队了。咦,带团队做什么呢?冲锋陷阵啊,杀敌交人头啊!反正,和上面说的“吃”一样,还是劳力活。
|
||||
|
||||
所以,就算是在“核心团队”里,他们也只是帮你指出核心之所在,最多教你会一些套路,让人净增功力。但是这样的核心只是“死东西”,不懂得核心的原理,就如同上面说过的那位面试候选人一样,出的都是些中规中矩的招数。
|
||||
|
||||
“中规中矩”有什么不好么?
|
||||
|
||||
确实,有些时候就是不好。“中规中矩”就是所谓的“一般”。所谓的“一般”不是指你能力一般,而是指你眼界一般、思想一般,对你面前这个事物的理解程度,也是一般。
|
||||
|
||||
所以,所谓“水平一般”,是因为学的是“一般”的东西,再怎么学,还是一般。
|
||||
|
||||
>
|
||||
汉语中,“一般”这个词,是指跟别的一样,例如“一般无二”,而不仅仅是指“普通”,也并非贬义。
|
||||
|
||||
|
||||
## 这门课到底讲什么呢?
|
||||
|
||||
《JavaScript核心原理解析》这门课,核心不是在讲JavaScript如何用,或者如何学习JavaScript。我在开这门课,列出这二十多行代码作为标题时,就说过,我要讲的是语言。
|
||||
|
||||
语言最核心的部分也有两个,第一个是**语言的构成**,第二个是**如何构成**。我在这门课里,主要还是讲“如何构成”的,因为ECMAScript就是以“如何构成(也就是如何按照ECMAScript规范来实现一个JavaScript)”为制订规范的主要目标和方向的。
|
||||
|
||||
在内容上面,这门课跳过了对“构成一门语言的那些基础元素(也就是语言的构成)”的讨论,而是直接进入到“如何将基础元素组织起来,成为JavaScript”。比如,我就没讲什么是**动态语言**,或者也没有讲什么是**标识符**。这里有两点是很不幸的:
|
||||
|
||||
1. ECMAScript中没有明确地写“我是动态语言”;
|
||||
1. ECMAScript里面明确地使用了“标识符”这个名词。
|
||||
|
||||
为什么“这两点”都是很不幸的呢?
|
||||
|
||||
因为无论有没有明确地使用这些名词,在ECMAScript中,这些概念都没有规范性质的定义。这是因为ECMAScript是直接面向“语言/引擎的编写者”,因此这个规范就默认它的读者是了解这些基础的或者学术的概念的。就好象它不会解释什么是二进制,什么是位运算一样,因为它默认是在计算机这个领域里的。
|
||||
|
||||
然而,我想你应该不是“计算机语言/编程语言学”的专家,因此那些所谓“构成一门语言的基础元素”,其实**你不懂,也是正常的**。在听这门课的大多数同学,其实也都一样的不懂。
|
||||
|
||||
然而,注意这里的这个词——“一样的”。这也就意味着,只有了解了那些“不一样的”“不懂的”东西,你才会成为“不一般的”。
|
||||
|
||||
**而这门课程的目的,也正是要让你成为那个“不一般的”。**
|
||||
|
||||
所以,你需要放下以前你认为你懂的那些东西。如同开篇词中所说的,它们正是阻碍了你前进的东西。比如说,有同学就从[第1讲](https://time.geekbang.org/column/article/164312)开始,就一直被“引用”这个概念困扰,因为他所理解的JavaScript的概念与我所讲的完全不同,而且混淆不清。这种状况在评论区中表现得很明显,大多数同学都是在旧有的概念中构建新的概念集合,如同浮沙高塔,原本基础就搭得不对,你再怎么努力,也是上不了这个台阶的。
|
||||
|
||||
然而,又如同评论区里的“海绵薇薇”同学一样,只要突破了“引用”这个概念,把旧的东西扔掉,基于这个新的东西来理解,那么再看前几讲的东西,就豁然开朗了。
|
||||
|
||||
我所讲的东西,原本并不是什么特别高明的、高深的技巧。问题只是,你要把原本依赖的那些概念、想法、设定,或者你认为正确的那些逻辑一一扔掉,你才能“看到”这些新的东西。
|
||||
|
||||
这个“扔掉”的过程太难。很多年前,我在给一个Borland的纪念网页上留下的题字就是:
|
||||
|
||||
所见即所得,所见即所碍。
|
||||
|
||||
>
|
||||
Borland是“所见即所得”这一开发理念的大成者;而最终,他也是死于对这一概念的固执坚守。
|
||||
|
||||
|
||||
关于“引用”这个概念的突破,我想如果有机会,我会请极客时间的编辑们约请“海绵薇薇”同学来讲一下他如何理解这个东西,以及在理解这个概念前后,对JavaScript有哪些不一样的认识。
|
||||
|
||||
回头来说这门课程。语言这个东西,其实是你最终要在这门课中看到的“真相的全部”。
|
||||
|
||||
我希望你通过这门课程,能真正地了解语言:语言是什么,它长得怎么样,它为什么长成这个样子,它如何成长、长大,又或者变化的。对于语言来说,它的生命的原点在哪里?源动力又在哪里?
|
||||
|
||||
举一例来说,在第一模块(也就是前五讲)中,如果你理解了“x = x”表达的意思是“将值赋给引用”(当然前提是,你需要知道并接受“这里的引用和值,不是JavaScript中的,而是ECMAScript中的概念”)。那么,你就几乎能贯通整个JavaScript语言的构造过程,了解所有语句、词法,以及引擎内核结构的设计原理与应用原则。
|
||||
|
||||
所以,你现在再看看第一模块的总标题:“从零开始:JavaScript语言是如何构建起来的 (5讲)”。
|
||||
|
||||
## 马钰曾经说过
|
||||
|
||||
说到这里,可能就会有一些同学听出不同的意思来了:咦,爱民老师好像是在说,他的课程是屠龙秘籍,所以不是一般人学的,或者是一般人学不会的。
|
||||
|
||||
当然,绝不是这样的。相反,我对屠龙术与杀鸡刀向来没有偏见,我只是在这里要强调一个东西:这个课程讲的东西,跟平常你听到的并不一样。
|
||||
|
||||
尺长寸短,但只要是用在合适的地方,就都是好东西。
|
||||
|
||||
相反的,你非得像公孙大娘舞剑一般,去百万军中杀敌,那么你大概就是最先中了黑箭的那一个。关于这一点的不同,我在之前写过的一篇名为[《前端要给力之:代码可以有多烂?》](https://kb.cnblogs.com/page/83497/)的博客文章里面也讲过,那篇文章从一个简单题目一直谈到了模板范型,最后呢,我还是建议大家用最保守的版本来编程序。
|
||||
|
||||
为什么?
|
||||
|
||||
研究是研究,实战是实战,不要把二者混为一谈。就好比在[第3讲](https://time.geekbang.org/column/article/165985)里说到过的那个面试题,最终我给出了一个应用场景:用来形成链表。然而,如果你真的要在工程项目中这样去写链表,那么大概第二天就被开除了。将这个代码写入jQuery的那个提交者有没有被开除,我可不知道,反正我见到这样的提交者,是要打板子的。
|
||||
|
||||
说回正题。我仅仅告诉你这个东西“与众不同”是不够的。毕竟,你听这堂课的目的,还是要学懂它,对吧。
|
||||
|
||||
然而你可知道,历史上有一个人,正好是怎么学都学不会东西的。他有七个师父,个个是江湖知名的大侠,个个也都呕心沥血,然而却教出来个一个弱鸡,还没行走江湖,初遇敌手就被人抓住了脚给扔出去了。这个历史人物来自金庸大师的《射雕英雄传》,就是郭靖。你看,我在这里给你说这个故事,可不是闲扯,因为书中有人说了一句话,道出了这个郭靖“学不会”的实质。书中第32回写道,那道人问道:
|
||||
|
||||
>
|
||||
“你这六位师父都是武林中顶尖儿的人物,……你又不是不用功,为什么十年来进益不多,你可知道什么原因?”
|
||||
|
||||
|
||||
郭靖回答说:
|
||||
|
||||
>
|
||||
“那是因为弟子太笨,师父们再用心教也教不会。”
|
||||
|
||||
|
||||
这时候那位道人就笑了,说了一句古今以来,求学问道最核心的要义,这句话,原话就是:
|
||||
|
||||
>
|
||||
“那未必尽然,这是教而不明其法,学而不得其道。”
|
||||
|
||||
|
||||
所以,关于我们今天说的这一门课程,“学不好、或者学不会”,其关键就在这位道人——马钰(不是马云)——说过的这句话:教者要有其法,而学者要得其道。
|
||||
@@ -0,0 +1,188 @@
|
||||
<audio id="audio" title="加餐 | 捡豆吃豆的学问(下):这门课该怎么学?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/f5/d7b9ea5009dc03ee82a63296f0710af5.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,在上一讲中讨论了这门课程所学的内容“到底是什么”。接下来,我们再来看看“怎么学这门课程”。
|
||||
|
||||
## 教的方法
|
||||
|
||||
我先来说说这门课的教法。有没有简单、明晰的授课方式呢?有的,你在极客时间上也好,学校的课程里也好,常见的一个教法套路便是:
|
||||
|
||||
- 开篇立个题,把问题抛出来,说我们今天要讲什么,关键点有一二三四;
|
||||
- 接下来就讲这一二三四,分析也好,解说也罢,趣谈也可,总之让你听得开心有味;
|
||||
- 最后收纳主题,我们讲了一二三四,你看看你听懂没有。
|
||||
|
||||
听不听得懂?能!你认真听下来,只要老师不差,绝对能懂,如果正好是你没听过的内容,还感觉耳目一开,受用无穷。
|
||||
|
||||
但是你仔细想想,你至多知道了老师所讲的,还能知道别的什么吗?几乎不能。清楚明白、无有疏漏,但也毫无差别,你听到的跟别人听到的,你学到的跟别人学到的,完全一样。
|
||||
|
||||
所以,这也就是“一般的”知识。
|
||||
|
||||
如同我刚才说过的:你学的跟人家一样,听到的跟人家一样,知道的跟人家一样,充其量学了个跟老师讲的一模一样的,可不就是“水平一般”么?
|
||||
|
||||
核心原理可不可以这样讲?你可不可以这样学?答案是,其实也是可以的。我如果是把这个课程当成一门功法,一一二二地讲给你听,你全然听去了,一字不落下,那这个核心原理课也可以讲得如同流水清风,让你很是舒坦。
|
||||
|
||||
但是你可记得,我在这门课的开篇词里说到的,学这门课的目的是什么呢?我说过,我希望**你能够构建自己的“语言学习体系”**。体系性,才是所有学习中最难得的。如果你有自己学习的体系,甚至是构建体系的体系,那么学这门课又算什么难事呢?这门课真的在讲什么高深的佛法,玄妙的奥义么?都不是的,它是在讲“另一个体系”下的东西!
|
||||
|
||||
准确地说,这门课程的讲法,与你过去二三十年来的学习方法是两个不同的体系;这门课程的内容,与你从业经历中所熟悉的语言,也分属于不同的知识体系!既然如此,你怎么可能指望用你现在的法子,在你的体系中去理解这些知识,又或者为这些知识构建“自己的语言学习体系”呢?
|
||||
|
||||
所以这门课程,一开始的讲法就大不相同。
|
||||
|
||||
这门课程的“标题”是一行代码,它通常很奇怪,有可能很有用,也有可能根本就不能用,它本身或许就难解,就是一个“问题”。然而,你需要知道,这个“标题”,或者这个标题中的“问题”,其实一点儿也不重要,我讲课不去奔着这个问题去,你学习中也不必奔着这个问题来。“求解这个问题”根本就是一件没有什么意义的事情。
|
||||
|
||||
所以在从一开始听课,一直听到现在的同学中,还有一些是困于[第1讲](https://time.geekbang.org/column/article/164312)的“delete 0”这行代码的,希望明白这行代码在讲什么、有什么用的同学,可以暂时地收收你的思想,因为——解决这个问题,其实没有什么大用。
|
||||
|
||||
既然“主题没什么用”,那么我怎么讲呢?其实我每一讲的开始,就无非是拿这个标题做个引子,然后无所谓背景、历史、相关的知识点,以及各种各样的问题纷纷地抛出来,貌似东讲讲、西讲讲,一直都绕开了这个“标题中的代码”在走。
|
||||
|
||||
事实上,我把这整个过程称为“撒豆子”。
|
||||
|
||||
怎么“撒”呢?整个课程大概2/3甚至4/5的内容,就是一大把豆子!一股脑儿地撒出来,没什么章法,也没什么技巧,也没有什么道理。只有一个原则,这些豆子,都是围绕“标题”的这个话题来的,它们或是互有相关性,或者是彼此有相似性,等等。
|
||||
|
||||
总之,简单地说:它们是“同一个系统”下的东西。
|
||||
|
||||
这是我组织每一节课程的基本原则,这个原则就是:在标题之下,东拉西扯,直到一地豆子,四处乱滚。
|
||||
|
||||
最后我告诉你,学习这门课程的终极秘密:
|
||||
|
||||
>
|
||||
把这些碎纸片捡起来,捡起来的,就是你的体系。
|
||||
|
||||
|
||||
## 学的方法
|
||||
|
||||
所以,这门课的听法也就不同。
|
||||
|
||||
你非要去盯着每节课的标题,把它弄得一清二楚,知道它怎么来的,怎么解释,以及怎么去应用到项目中,老实说,也无不可,也会有所得。但终究是“捡了芝麻丢了西瓜”。我既然说了,这“大西瓜”就是这一地的豆子,关键是在于你怎么捡,而不是在于我怎么讲。
|
||||
|
||||
所以,我再来讲讲这个“捡豆子”的方法。
|
||||
|
||||
**1.设问,列问题**
|
||||
|
||||
我可能在讲课中会“问”一些问题,但多数情况下,那是为了讲课的上下文连贯,那些问题本身并没有太明确的指向性。而且,即使是“有指向性”,又能如何呢?你求解了,也不过是多解了一个问题,于你无益。
|
||||
|
||||
**真正有用的,是你自己学会“提问题”**。
|
||||
|
||||
- 找一张纸,列一下这个标题给你带来的问题;
|
||||
- 列一下在这个主题下面你不知道的,或者你想知道的问题;
|
||||
- 列一下听课过程中发现的不解的、难解的问题;
|
||||
- 列一下你的理解跟我所讲的内容之间,那些貌似“不可调和”的概念问题。
|
||||
|
||||
这些仍然不够重要。更重要的设问是:
|
||||
|
||||
- 为什么会有这些问题?
|
||||
- 这些问题指向哪个“黑暗未知的方向”?(这个方向是你的知识盲点)
|
||||
- 老师为什么要撒这些豆子?(这些豆子有内在的相关性,而这就是我撒他们的目的)
|
||||
- 为什么会存在跟既有知识的矛盾?
|
||||
- 为什么在JavaScript语言的层面“看不到这些问题”?
|
||||
- 为什么……要问上面这些问题?
|
||||
- ……
|
||||
|
||||
总之,**带着问题来学习,学会从你的问题中求解**。这个过程,就已经与你之前的学习方法不同了。
|
||||
|
||||
是你接受“我所讲的知识”好呢?还是你“找到自己的答案”好呢?
|
||||
|
||||
**2.求解,在知识域中找答案**
|
||||
|
||||
既然我在每一个大段落中划了一个知识域,那么上面这些问题也就应该在这个知识域里去求解。
|
||||
|
||||
比如说你有人生、事业、理想的困惑了,那么你该去找知心小姐姐,非得在这么二十讲的课程中去寻找答案,你肯定是找不到的。所以,上面你可以尽量宽泛地设问,到了这个求解的时候,却应该把它限定到我们讲的这个问题域里面来。这二十讲一共有四个大主题,每个大主题是一个领域。所以你得想想,你的问题可以放在哪个领域里,为什么这么放,为什么是这个领域,为什么不在其他的领域范围内。
|
||||
|
||||
- 这件事跟主题有什么关系?
|
||||
- 这个东西的哪方面跟其他东西“有关系”?
|
||||
- 怎么表达这种关系?
|
||||
- 如何把它们放在同一个体系下(逻辑下或者抽象概念下)来解释?
|
||||
- ……
|
||||
|
||||
总之,多问几个为什么。
|
||||
|
||||
求解、答案都可以是错的,没关系,先做着,直到你能得到一个“貌似可能的解”。
|
||||
|
||||
**3.推翻,找到反例,精化抽象**
|
||||
|
||||
有了“貌似可能的解”只是个开头,如果你止步于此,那之前的努力就全部白费了。这跟“一般的学法”并没有什么不同,甚至还远远不如别的老师的教法,直接给你来个“三段式”的立题求解。真正对整个学习起到提升效果的,正是这第三步的“推翻”。
|
||||
|
||||
问题是你提出的,答案是你找到的,而推翻也由你来行使。
|
||||
|
||||
正是因为问题是你提出的,所以你知道“源起”;正是因为答案是你找到的,所以你知道“经由”。你知道一件事情的源起与经由,那么要找到这件事情的关键处,其实只需要看看那些“自相矛盾”的地方,就好了。你找到你的逻辑的、过程的、结果的任何一处反例,进而重新按上述过程来思考,重新找到“貌似可能的(第二个)解”。
|
||||
|
||||
如此往复,最终你就看到了一些事物最初,以及最终的面貌。
|
||||
|
||||
有了这个面貌,你为它命个名字,抽出个概念,于是就得到了一个“抽象”。有了抽象概念,你就可以在概念的层面上描述事物,以及进行事物的推演。而这,就是架构的基本功。**有了体系性,有了概念抽象,有了推演过程,你做的,就是体系架构的工作,而不是“写代码”。**代码是你架构的表现方式,仅此而已。
|
||||
|
||||
我想这个过程,以及这个过程的可能的结果已经超出了多数同学的“需要”。是的,暂时的,你并不需要变成“架构师”,我这门课也并不是要教你“做一个用JavaScript的架构师”。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
但是,正因为这个最后“收官”的过程比较抽象、比较虚。所以,我给你在[第1讲](https://time.geekbang.org/column/article/164312)的时候就留了个伏笔,你回顾一下,我在第1讲的结束的时候,提过一个问题:
|
||||
|
||||
>
|
||||
delete x 中,如果 x 根本不存在,会发生什么?
|
||||
|
||||
|
||||
这个问题在“潇潇雨歇”同学的答案后面(他的答案是正确的)。在他的答案里面,我又提了两个潜在的问题。其一是:
|
||||
|
||||
>
|
||||
在(如果x根本不存在,delete x什么也不做,返回true)的这种情况下,x是什么呢?它显然是语法可以识别的东西,但如果这样,在语法上它是什么,且在执行环境中它又是什么?
|
||||
|
||||
|
||||
这个问题其实问得很深,也正是我们这里说的:如果你找到了“貌似可能的解”,那么就进一步地找一下反例,进一步地“精化抽象”。
|
||||
|
||||
为什么呢?
|
||||
|
||||
其实啊,我们得问一个很深层的、有些哲学性的问题:不知,是不是“知”的一种?
|
||||
|
||||
对于JavaScript来说,如果一个标识符x“根本不存在”,那么就是真正的“不知道它存在”吗?不是的,JavaScript必须知道——“这里有一个未知的标识符”。对于JavaScript引擎来说,我不能确定它是什么,我的整个引擎中都找不到它,但是我必须把它“标识”出来,只有把它标识出来,我才能处理它!
|
||||
|
||||
所以,在语法概念上,**词法记号**(Tokens)是比**标记**(Identifer)更底层的抽象概念——也就是更“精化”的抽象。
|
||||
|
||||
但在JavaScript中,不需要理解所谓“词法记号”,因为它不需要在这种引擎层面的“对代码文本的理解”。而在引擎层面,是将代码文本解析成词法记号序列的:它认为,所有这样的序列——也就是一串字符,要么能解释成标识符,要么就是一个不能识别的序列。
|
||||
|
||||
当“不能识别的序列”出现的时候,就是语法解析期错误,简单地说,就是代码错了。接下来,当词法记号是有效的标记时,它可能是能识别的、环境中有的,也就是说它是能被引擎从环境中发现(Resolve)到的引用,因此它就称为“可发现引用(ResolvableReference)”,反之——例如上面提到的“未声明的x”,就称为“不可发现的引用(IsUnresolvableReference)”。
|
||||
|
||||
>
|
||||
注意,这些概念不是我生造的,你在读ECMAScript规范时就会看到这些概念名词。只是ECMAScript并不解释这些概念的由来,以及它们之间的抽象关系。
|
||||
|
||||
|
||||
所以,引擎必须能识别“不能识别的标识符”。能识别才能处理,即使这个处理“仅仅是”抛出一个异常。
|
||||
|
||||
你想想,要是不能识别、不能抛出异常,那么这个引擎就该出现完全未知的逻辑了,这种情况下,引擎的更外层,例如宿主程序,又例如操作系统就会无法处理了,就会中止进程。引擎要么抛出一个异常,然后退出程序;要么操作系统直接将引擎杀死,连异常也没有。
|
||||
|
||||
我们都是有经验的程序员,上面哪种处理更好,是一目了然的事情。而上溯整个处理过程,就在于在“精化抽象”的过程中,有没有处理“不可发现的引用”,又或者说,“未发现”是不是被当成了一个需要处理的抽象概念。
|
||||
|
||||
少了一个抽象概念,少了一个处理逻辑,你的程序就“莫名其妙”地退出了。如果这是一个框架,或者这是一个库,一个平台系统,那么这个抽象概念一旦少了,就没有人会去使用它了。因为,你知道的,系统中怕的不是出错,而是,出了错却不知道。
|
||||
|
||||
“知未知”,就是这个概念系统中最顶层的抽象了。
|
||||
|
||||
>
|
||||
这是一个在“概念完整性”方面的实践。
|
||||
|
||||
|
||||
>
|
||||
对于一个体系来说,概念完整性是很重要的,如果缺乏关键概念,那么这个体系构建就会出现漏洞。习惯性上,人们用“概念对称性”来解决这个完整性的问题,例如“能发现的 vs. 不能发现的”,这两个概念在抽象层面上,就是指“所有的”;又例如,索引数组对应连续存储,而关联数组对应非连续存储,所以“连续的 vs. 非连续的”,就意味着“数组能处理所有存储(的数据)”。
|
||||
|
||||
|
||||
## 别担心,还有
|
||||
|
||||
到这里,可能就有同学说了,这个讲课的方法是很新颖,学习的方法看起来也可行,但是我就是这么做的呀,问题我想不到“有效的解”啊。
|
||||
|
||||
对啊,如果你一次两次就能想到有效的解了,一遍两遍就学成收工了,那也只能说明这个东西还是“一般的东西”,这个方法也就是“一般的方法”,而照着这个路子做下去,你也就还是个“一般的你”。
|
||||
|
||||
所以,不要担心,你没学明白也正常,上面的做法找不到“有效的解”也正常,这门课听到现在,以及后面要听的内容,都无非是给你一个“使用这种学习方法”的训练营,你在这个过程中,多练多试,多出错多反思,就成了。
|
||||
|
||||
学习要“知味”,你一旦从这个过程中得到了收获,你就如同食髓,乐此不疲了。所以,不要气馁,放松心态,坚持就好了。
|
||||
|
||||
并且呢,这门课程后续还为大家准备了更“丰盛”的加餐。按照编辑们为你制定好的学习计划,我还会在第10讲之后,给大家再补一个加餐。这份加餐跟今天的大有不同。我会将前10讲的课程串联起来,精讲每一讲的主题,对内容详加梳理,列提纲、划重点(敲黑板),也就是帮你把豆子们都找出来、串起来。
|
||||
|
||||
当然,我需要在这里强调的事情是,这件事情一做过,就意味着“你自己找豆子”就结束了。豆子是你找来的,还是我拿给你看的,大不相同。
|
||||
|
||||
所以我觉得啊,你还是自己多努力找找。如果你需要补补课,加强一些基础概念方面的知识,那么我希望你有时间读一下[《程序原本》](https://github.com/aimingoo/my-ebooks),限于这堂课要讲的内容,你只需要读一下《程序原本》前10章的内容就可以了,并且,有许多内容可以跳过去。是的,即使不懂、“不求甚解”也是可以的。有些东西就可以先“存而不论”,而这些等到你将来回头来看时,便可以立时了然。
|
||||
|
||||
另外,如果你的英语还不错,那么仍然推荐你看看[《ECMAScript规范》](https://tc39.es/ecma262),一些概念上它定义得严谨得多。不过这些概念背后的东西,就得你自己去体会了,ECMAScript里面是不讲的。这里还有一份[W3C组织的中文译本](https://www.w3.org/html/ig/zh/wiki/ES5),虽然只是ECMAScript5的,而且还不完整,但是要达到“补概念”这个目的,还是够用了。
|
||||
|
||||
## 其他
|
||||
|
||||
今天的思考题跟以前不同,这道题,你答不答得出来,都是不要紧的,就算“想着玩儿”就好了。问题是这样的:
|
||||
|
||||
- 按照前面说的,所谓“会吃”,有三件事情,是食材、味道和“懂”这一个字儿。食材,我们讲了,是课程的内容;味道,我们也讲了,是课程中的教与学的法子,以及“形成体系性”这样的潜在目的。那么,什么是“懂”呢?
|
||||
|
||||
这个问题,就留给你了。我想啊,要知道什么是“懂”,大概才真的算是“会吃”了。
|
||||
|
||||
我今天的课程就聊到这里。希望你吃得好,胃口好,好好消化这一份专属的加餐,然后我们下一讲,再继续学习。
|
||||
87
极客时间专栏/JavaScript核心原理解析/开篇词/开篇词 | 如何解决语言问题?.md
Normal file
87
极客时间专栏/JavaScript核心原理解析/开篇词/开篇词 | 如何解决语言问题?.md
Normal file
@@ -0,0 +1,87 @@
|
||||
<audio id="audio" title="开篇词 | 如何解决语言问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/0a/27b8f65c328de80f0c83a4e6af2fdd0a.mp3"></audio>
|
||||
|
||||
你好,我是周爱民,和你一样,我喜欢JavaScript。
|
||||
|
||||
我是《JavaScript语言精髓与编程实践》这本书的作者,这个书名正好也刻画了我追随JavaScript的轨迹:在过去的二十年中,我一面研究它的语言精髓,一面做编程实践。
|
||||
|
||||
曾经在很长的时间里面,我的脑海中时常会有一个闪念:如何解决语言问题?这也伴随着强烈的自我否定与质疑,因为它的潜台词是:我还没有搞定语言问题。
|
||||
|
||||
## 问对问题
|
||||
|
||||
在那之前,我是从DBASE这个数据库入手,从类似SQL的语言开始学习的。第一门正式学习的语言是汇编,然后是Basic和Pascal。后来在商用产品开发的环境中,我选择了Delphi这门语言。这一段语言学习的经历,直到2003年前后戛然而止,那时我写完了我的第一本书,名字就叫《Delphi源代码分析》。
|
||||
|
||||
这些是我既有的语言知识,一定程度上来说,它限制了我对JavaScript的进一步学习,既成习惯的思维方式和知识体系盘根错节,渗透极深。
|
||||
|
||||
我是从1998年左右就开始接触JavaScript这门语言的,然而直到五六年之后,我仍然在使用Delphi中的面向对象概念与理论来理解与分析JavaScript的对象模型。这也是我早期做WEUI这个项目时的困扰:我一面努力改造着JavaScript这门语言,一面又被它所左右。
|
||||
|
||||
那个时代,有太多人做着与我相似的事情:要上线一个大的前端产品,就先写一个框架或库,将传统语言中的经验复制过来。那些是我们的既得财富,闪烁着记忆的光芒、知识的火花,是自我价值和薪资的体现,所以它们是不可抛弃的、不可否认的、不可亵渎的。
|
||||
|
||||
在类似于此的、固化的思维里面,我们勤劳地、不知疲倦地写着新代码,而究其原因,只是我们不愿意丢弃那些旧代码而已。
|
||||
|
||||
如此负重前行,以至于让我不得不怀疑,自己是否还有能力掌握这门“世界上最被误解的语言”。这是Douglas Crockford(就是创建了JSON格式那位大牛)在2001年说过的话。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/ca/a7314875afe34589031d8d183ddb8fca.jpg" alt="">
|
||||
|
||||
我真正下定决心抛弃所有,来重新理解这门语言的时候,是在我写出《Delphi源代码分析》之后不久。这个时候,Borland破产了,Delphi被卖掉了,而我也做了架构师。此时,那些既有的经验,以及对语言孰优孰劣的固执己见都变得可有可无了。这时,对于我一直以来的困惑,我才真正地问对了第一个问题:
|
||||
|
||||
JavaScript到底是一门什么样的语言?
|
||||
|
||||
## 语言
|
||||
|
||||
我常常说,问对了问题,也就有了“解”。
|
||||
|
||||
在JavaScript诞生的时候,主流的应用开发语言大多是静态的,以及单一语言类型的。当时面向对象编程大行其道,众多语言都纷纷以“**我最OOP**”为宣传噱头,以及将它想像成语言发展的必然方向。随着Java/JVM的成熟,使用中间指令集+虚拟机来运行的语言环境也变得流行起来,因此一个虚拟机上跑很多种语言也就成了常态。但即使如此,具体到每一种语言,其主要特性还是单一的,并且通常以保持“**语言特性的纯净**”为己任。
|
||||
|
||||
JavaScript却是一个异类,它最开始是一门“简单”的小语言,没有丰富的语言特性,也没有大一统的野心,更没有“包打天下”的虚拟机引擎。为了维护这种“小而简洁”,当然,另一部分原因也在于它的创始者太过匆忙和随意——它只实现了一些基础的语言特性,而没有从根本上“陈述”自己的设计原则与理念。
|
||||
|
||||
这门语言非常摇摆,在面向对象火的时候,它说“我是OOP的语言”;在函数式语言呼声渐高的时候,它又说“我是函数式的”。另外,你知道的,它天生还是一门动态的语言。不过,它也还包括一些静态的、结构化的语言成分。
|
||||
|
||||
支持这门语言挣扎求生、一路行来的,正是这门语言最初的、最精彩的设计:**它是一门多范型语言,或者,也称为混合范型语言**。JavaScript的简单来自于此,复杂也来自于此;生存能力来自于此,抨击诟病也来自于此。
|
||||
|
||||
的确,如果我不抛开Delphi语言给我留下的历史包袱,我还是能从JavaScript中看到我熟悉的、引以为傲的经验闪光,并让这些东西迅速激活我对语言的兴趣和掌控感。然而只需要稍稍多一点点时间,混合语言中的其它组分就会变成我的困扰,变成这门语言给我带来的种种陷阱,变成我近乎绝望的自我怀疑。
|
||||
|
||||
而其实问题的求解也很简单:不要试图去纯化这门语言。
|
||||
|
||||
## 语言的特性
|
||||
|
||||
所以在这个专栏里面,我就想与你讲一讲我对JavaScript各种语言特性的理解,还有展示将这些语言特性和语言范型融合如一的具体挑战与折衷。
|
||||
|
||||
JavaScript主要包括5个方面的语言特性:结构化编程、面向对象编程、动态语言、函数式语言和并行语言。因此在这个专栏中,我将以“语言”为核心,主要讨论语言设计,结构化和面向对象特性,以及部分的动态语言特性。还有一些其它部分的特性,我将会在以后的专栏中再给你讲。
|
||||
|
||||
在讲述的内容方面,尽管每一讲都是一个独立的标题,但总体来说,这些内容也是循序渐进的,因此你最好不要落下课程。并且如果有时间、有机会的话,还是对前面的内容做一些分析和巩固为好。
|
||||
|
||||
另外,你可能已经注意到了:每一讲的标题都是一行代码。尽管这些代码绝大多数都是有意义的、可以使用的,但是我并不是从实用性出发来写出这些代码的,因此它们不见得能很好地使用在你的项目中。我尽量使每一讲的标题在表达多种语言特性的同时,指向一个主要的、核心的内容讲述方向。
|
||||
|
||||
事实上,如果你看到那样的一行标题后,能猜出它涉及到哪些混合语言特性,或者是由哪些特性共同作用以导致这个代码的形式风格或可能的结果,那么你也就相当于复盘了你的知识储备,这有助于你建立自己的知识体系。
|
||||
|
||||
所以,你大可以将这样的标题当作一把念珠,没事的时候,盘一盘。:)
|
||||
|
||||
## 体系性
|
||||
|
||||
说到体系性,其实这才是我这个课程最关注的地方。
|
||||
|
||||
我希望综合这些代码的特殊性,代码所涉问题的领域,代码的逐步分解解析,以及辨析与该代码相似的或同类的问题,一方面发掘它们潜在的应用,另一方面,则在于帮助你构建一个语言知识结构。这样的语言知识结构,能让你看到曾经摸过的那些项目,写过的那些代码,填过的那些巨坑的影子,并且发掘暗影背后涌动的语言原力,找到属于自己的、可规划的语言学习体系。
|
||||
|
||||
你不需要精通所有的语言,但如果你了解那些语言类型的核心的、本质的差异,建立起自己的对语言的认识观和辨析力,那么当你接触到一门新的语言时,便可以在极快的时间内将它纳入自己的语言知识结构,快速地映射到那些历史项目和研发经验中。你可以通过想象,将新的语言在自己的经验中“回放一遍”,这相当于用新语言重写了一遍那些代码,也相当于将你自己的历史经验全部消化在这个新语言之中。
|
||||
|
||||
这样的语言学习和感悟方法,收效是极快的。
|
||||
|
||||
我曾经在豌豆荚的工作中,完成过一个用Lua来实现的、支持大规模并发的服务端项目,那是一个金融级的风控产品。在我对Lua了解几乎为零的情况下,出于对“编程语言核心原理”的了解,通过上述知识映射的方法,我在零学习的情况就开始了编码工作。除了必要的查手册之外,切换语言的时间成本几乎可以忽略不计。
|
||||
|
||||
当然,听到这里的时候,你可能会说“Lua和JavaScript的相似性太高”,又或者说“Lua太简单了”。但是事实上,我之前在学习Erlang的时候也是如此,以及后来在学习Scala的时候仍然是如此。当你真正地“解决了语言问题”时,“新语言”真的对你来说完全不能称其为问题。
|
||||
|
||||
所谓的语言特性,其实是对语言的核心抽象概念的语法表现。所以,所谓的“学习新语言”,只不过是在玩“变换代码风格”的游戏,而已。
|
||||
|
||||
一旦你建立了你的体系性,那么相当于你创建了游戏规则,你就成了“编程游戏”中的上帝。
|
||||
|
||||
你将会有一种切实的、万物如一的操控感。
|
||||
|
||||
## 学这门课
|
||||
|
||||
所以,这门课的内容大概率不会用在你的一个线上项目中,也不会提高你写代码的速度。但**它绝对能让你提升对代码的洞察力**,让你可以在纷繁的代码中快速找到它在性能、组织、逻辑等问题的关键,也可以在语言层面给出合理的解释。
|
||||
|
||||
当然,就一个具体问题的具体解,这一切还是不够的,因为语言是实现业务的工具,而不是业务本身。所以,在面试中不仅仅会考察你的语言能力,也会考察你对曾经项目的经验与感受。
|
||||
|
||||
无论如何,我希望你能找到自己对语言的认识,无论是不是通过这门课程,“构造认识”对你自己而言都是极致重要的事情。如果在这其中,我的课程能对你有所帮助,哪怕仅仅是一点点启发,我想,这都是我非常乐意看到的结果了。
|
||||
|
||||
最后,多谢你来看我的专栏。关于JavaScript,你的理解是怎样的呢?你又有什么样的期待?欢迎留言。
|
||||
43
极客时间专栏/JavaScript核心原理解析/结束语/结束语 | 愿你能做一个真正“懂”的程序员.md
Normal file
43
极客时间专栏/JavaScript核心原理解析/结束语/结束语 | 愿你能做一个真正“懂”的程序员.md
Normal file
@@ -0,0 +1,43 @@
|
||||
<audio id="audio" title="结束语 | 愿你能做一个真正“懂”的程序员" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/d1/cd0fb2ce71e1d938ed8812bd8f3aaad1.mp3"></audio>
|
||||
|
||||
我常常讲一个比喻,这个比喻是说有一座塔,塔门口有两尊石狮子。
|
||||
|
||||
如果有人登塔,那么进塔之前他固然是会看到这个狮子的,往上走,正好到塔后,石狮子就没有了,于是这个人说“就我一楼之所见,没有狮子”;绕到前面,一看,石狮子好好地在那儿,于是这个人又说“于我所见,有狮子”。如此行至二楼,他又会说“没有狮子”,而后又否定说“真真切切是有狮子在的”。
|
||||
|
||||
对于旁人来说:只听这个人讲“有,或没有”狮子,会知道他在几楼吗?又或者说,就算知道这个人在几楼,又能知道他说“有,或没有”狮子,是综览事实之所见,还是未见事实全貌时的一时所言?你其实并不知道。
|
||||
|
||||
我们只是要么相信了对方所在的高度,要么认可了对方所言的真假。而大多数时候,我们其实无从判断:那个人说的是不是对的,又或者他说的,究竟是在几层楼上看见的石狮子。
|
||||
|
||||
每一层可见的狮子,都是相同的狮子;但每一次的所见,却不相同。同样是真理,在初学者和大师的口中说出来,尽管字面上都是一样的,但是却包含着不同高度的理解。所谓大师,也不过是先行者,只是他处在的楼层,决定了他看得见下面所有的层次上的真相,也辨得清每一层所见的石狮子的样子。
|
||||
|
||||
所以,所谓“懂”,其实说的不是一个结果,而是一个状态:知道自己所在之位置的,才是真的懂;知道自己所见之局限的,才是真的懂;知道自己所向之湮远的,才是真的懂。
|
||||
|
||||
## 要知道自己所在的位置
|
||||
|
||||
我写过《程序原本》,250多页的内容,然而到了《我的架构思想》中,就只留下了一句“程序是可被组织的元素”。
|
||||
|
||||
只有有了这一句话的铺垫,架构思想才成立,否则所谓思想就成了空立在文字之上的东西,是空洞贫乏的呓语了。正是这些年来的经历,让我找到了自己在“程序”这个事物中的位置,从而找到了我在“架构”这个事物中的基础;前者的位置,决定了后者的高度。
|
||||
|
||||
而《JavaScript核心原理解析》这门课程一直在做的,就是帮你找到这样的一个铺垫,它无非是一个起点,这个起点是你构建语言体系时,所铺下的第一块基石。倘若它能帮助你以此为基点先行一步,那么这就是这门课的价值所在,也是你与众不同的价值所在。
|
||||
|
||||
## 要知道自己所见的局限
|
||||
|
||||
在[第22讲](https://time.geekbang.org/column/article/185031),也就是最后的一讲中,我写了9个NOTE,来指向此前在其它章节中所讲述的内容。我不知道你是否注意到了这一点,又是否理解这其中的用意。
|
||||
|
||||
其实每一个课程,乃至于每一篇文章,都是由更多的知识来组成的。你能读明白第22讲,是源自于前21讲的积累,并不是一蹴而就的。
|
||||
|
||||
然而读得明白第22讲,也并不是真的懂了。你要看得到前面的那些知识、那些细节,你还要把这些知识与细节归纳起来,形成结构,形成体系,形成一个简单的模型或概念。这些是你所见到的东西,而不再仅仅是我的讲述。你回忆得起来我的所述,只说明你的记性好;要构建得了自己的见识,才说明你开始懂了。
|
||||
|
||||
要有所见,但也不要固执于已见,那样就没有进步了。这个进步的过程,才是真的懂。所谓进步,不是否定别人,而是否定自己。
|
||||
|
||||
## 思考才是进步的本质
|
||||
|
||||
停下来,思考才是进步的本质!
|
||||
|
||||
这是李维先生十几年前同我说的。我把这句话写在了《大道至简》这本书的序里,也记在了我的思想中。因而我常常会停下来,不去急于得到结果,而是去看看我想要的是什么,期望的方向又是什么。我总觉得,这些往往是更重要的。
|
||||
|
||||
如果以“构建语言体系”作为目标,那么我很难向你描述那绝顶处的风光,我所能展示的,也仅仅是这个塔上某一层的所见而已。我仍前行不辍,仍然时有新思,我仍愿意将对于“石狮子”的每个点滴观察与你分享。风起了,起点越来越远,我回头望向石狮子的时候,它都渺远到几乎再也看不见了……但我知道,这意味着终点也就越来越近了。
|
||||
|
||||
“俯仰叹山低天小”,这句诗讲的便是登临山顶的快意。彼时彼处,汝可在否?!
|
||||
|
||||
谢谢你的收听,愿你能做一个真正“懂”的程序员。
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
你好,我是周爱民。
|
||||
|
||||
《JavaScript核心原理解析》已经完结一段时间了。在这段时间里,我依然收到了很多用户的留言,很感谢你一直以来的认真学习和支持!
|
||||
|
||||
为了帮助你检验自己的学习效果,我特别给你准备了20道结课测试题,例题如下:
|
||||
|
||||
```
|
||||
例题1. 如下代码中是“直接调用的eval”的有___。
|
||||
A:with ({eval}) eval(x)
|
||||
B:({eval}).eval(x)
|
||||
C:(0,eval)(x)
|
||||
D:global.eval(x)
|
||||
|
||||
参考《JavaScript核心原理解析》第21讲。
|
||||
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
例题2. 如下用于动态执行的代码将执行在当前上下文中的有___。
|
||||
A:Function('let x = 100')()
|
||||
B:Function('var x = 100')()
|
||||
C:(0, eval)('var x = 100')
|
||||
D:with ({eval}) eval('var x = 100')
|
||||
|
||||
参考《JavaScript核心原理解析》第22讲。
|
||||
|
||||
```
|
||||
|
||||
接下来的这套测试题中共有 20 道题目,包括19道单选题,1道多选题,满分 100 分,系统会自动评分。
|
||||
|
||||
点击下面按钮,马上开始测试吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=186&exam_id=434)
|
||||
Reference in New Issue
Block a user