mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
@@ -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讲的小结内容的展开,希望你能对照着,重新来理解和梳理这门课程。
|
||||
|
||||
希望这份加餐会让你后续的课程变得轻松一些。今天就到这里,下一讲我们开始讲面向对象。
|
||||
Reference in New Issue
Block a user