11|throw1;:它在“最简单语法榜”上排名第三
文章目录
你好,我是周爱民,欢迎回到我的专栏。
今天我将为你介绍的是在 ECMAScript 规范中,实现起来“最简单”的 JavaScript 语法榜前三名的 JavaScript 语句。
标题中的throw 1
就排在这个“最简单榜”第三名。
NOTE: 预定的加餐将是下一讲的内容,敬请期待。^^.
为什么讲最简单语法榜
为什么要介绍这个所谓的“最简单的 JavaScript 语法榜”呢?
在我看来,在 ECMAScript 规范中,对 JavaScript 语法的实现,尤其是语句、表达式,以及基础特性最核心的部分等等,都可以在对这前三名的实现过程和渐次演进关系中展示出来。甚至基本上可以说,你只要理解了最简单榜的前三名,也就理解了设计一门计算机语言的基础模型与逻辑。
throw
语句在 ECMAScript 规范描述中,它的执行实现逻辑只有三行:
ThrowStatement: throwExpression;
1.Let exprRef be the result of evaluating Expression.
2.Let exprValue be ? GetValue(exprRef).
3.Return ThrowCompletion(exprValue).
将这三行代码倒过来看,最后一行的 ThrowCompletion() 调用其实是一个简写,完整的表示法是:
Return Completion { [Type]: exprValue, [[Target]]: empty }
在 ECMAScript 规范的书写格式中,一对大括号“{ }”是记录的字面量(Record Literals)表示。也就是说,执行throw
语句,在引擎层面的效果就是:返回一个类型为"throw"的一般记录。
NOTE:在之前的课程中讲到标签化语句的时候,提及过上述记录中的
[[target]]
字段的作用,也就是仅仅用作“breaklabelName”和“continuelabelName”中的标签名。
这行代码也反映了“JavaScript 语句执行是有值(Result)的”这一事实。也就是说,任何 JavaScript 语句执行时总是会“返回”一个值,包括空语句。
空语句其实也是上述“最简单榜”的 Top 1,因为它在 ECMAScript 的实现代码有且仅有一行:
1.Return NormalCompletion(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 语言约定,在块中的多个语句顺序执行时,遵从两条规则:
- 在向前覆盖既有的语句完成值时,
empty
值不覆盖任何值; - 部分语句在没有有效返回值,且既有语句的返回值是
empty
时,默认用undefined
覆盖之。
规则 1 比较容易理解,表明一个语句块会尽量将块中最后有效的值返回出来。例如:
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 中的值》。
由此一来,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 1/0;
那么 exprRef 作为表达式的计算结果,其本身就将是一个异常,于是? GetValue(exprRef)
就可以返回这个异常对象(而不是异常的值)本身了。类似的,所谓“表达式语句”(这是排在“最简单语句榜”的第二名的语句)就直接返回这个值:
ExpressionStatement:Expression;
1.Let exprRef be the result of evaluating Expression.
2.Return ? GetValue(exprRef).
还有一行代码
现在还有一行代码,也就是第一行的“let… result of evaluating …”。其中的“result of evaluating…”基本上算是 ECMAScript 中一个约定俗成的写法。不管是执行语句还是表达式,都是如此。这意味着引擎需要按之前我讲述过的那些执行逻辑来处理对应的代码块、表达式或值(操作数),然后将结果作为 Result 返回。
ECMAScript 所描述的引擎,能够理解“执行一行语句”与“执行一个表达式”的不同,并且由此决定它们返回的是一个“引用记录”还是“完成记录”(规范类型)。当外层的处理逻辑发现是一个引用时,会再根据当前逻辑的需要将“引用”理解为左操作数(取引用)或右操作数(取值);否则当它是一个完成记录时,就尝试检测它的类型,也就是语句的完成状态(throw、return、normal 或其他)。
所以,throw 语句也好,return 语句也罢,所有的语句与它“外部的代码块(或Parse Tree
中的父级结点)”间其实都是通过这个完成状态来通讯的。而外部代码块是否处理这个状态,则是由外部代码自己来决定的。
而几乎所有的外部代码块在执行一个语句(或表达式)时,都会采用上述的 ReturnIfAbrupt(x) 逻辑来封装,也就是说,如果是 normal,则继续处理;否则将该完成状态原样返回,交由外部的、其他的代码来处理。所以,就有了下面这样一些语法设计:
- 循环语句用于处理非标签化的 continue 与 break,并处理为 normal;否则,
- 标签语句用于拦截那些“向外层返回”的 continue 和 break;且,如果能处理(例如是目标标签),则替换成 normal。
- 函数的内部过程
[[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 执行过程的全部介绍。
当然,这些都是在串行环境中发生的事情,至于并行环境下的执行过程,在专栏的后续文章中我还会再讲给你。
作为一个概述,建议你回顾一下本专栏之前所讲的内容。包括(但不限于):
- 引用类型与值类型在 ECMAScript 和 JavaScript 中的不同含义;
- 基本逻辑(顺序、分支与循环)在语句执行和函数执行中的不同实现;
- 流程控制逻辑(中断、跳转和异步等)的实现方法,以及它们的要素,例如循环控制变量;
- JavaScript 执行语句和函数的过程,引擎层面从装载到执行完整流程;
- 理解语法解析让物理代码到标记(Token)、标识符、语句、表达式等抽象元素的过程;
- 明确上述抽象元素的静态含义与动态含义之间的不同,明确语法元素与语义组件的实例化。
综合来看,JavaScript 语言是面向程序员开发来使用的,是面子上的活儿,而 ECMAScript 既是规范也是实现,是藏在引擎底下的事情。ECMAScript 约定了一整套的框架、类型与体系化的术语,根本上就是为了严谨地叙述 JavaScript 的实现。并且,它提供了大量的语法或语义组件,用以规范和实现将来的 JavaScript。
直到现在,我向你的讲述的内容,在 ECMAScript 中大概也是十不过一。这些内容主要还是在刻画 ECMAScript 规范的梗概,以及它的核心逻辑。
从下一讲开始,我将向你正式地介绍 JavaScript 最重要的语言特性,也就是它的面向对象系统。
当然,一如本专栏之前的风格,我不会向你介绍类型 x.toString() 这样的、可以在手册上查阅的内容,我的本意,在于与你一起学习和分析:
JavaScript 是怎样的一门语言,以及它为什么是这样的一种语言。
文章作者 anonymous
上次更新 2024-01-27