JavaScript 原理,其一

谈到 JavaScript 你会想到什么呢?浏览器?很好。Node?很好。还有其他的吗?肯定还会有。不过今天,我一不说浏览器,二不说 Node,我要说语言。

「语言这东西由说的必要吗?我每天写那么多程序,经手的代码都超过五位数行了,难道我还不会语言?笑话!还不如给我看 MVC、设计模式、代码案例,那些东西可更有用。」

——且慢——

没错,每个 JS 程序员都必定熟悉 JavaScript 语言,了解它的特性,比如对象、原型、函数以及 this 等等。熟悉到甚么程度?没错,就是到天天用,用成条件反射,好像直接刻进脑子里。但是,你有没有想过,这些「特性」的背后是什么?它们的机理是什么?哦, 你肯定不会想——废话,自己用了这么久的东西自己还不懂?另外,机理有用吗?对于「码农」来说,可能没用,但是不了解某个东西的机理,它的一些问题你就没 法处理,至少没法处理清楚。

比如,许多人都知道细胞呼吸的过程是「燃烧」葡萄糖,产生二氧化碳和水,可以用方程 C 6H 12O 6+O 2CO 2+H 2O 概括。然而简单的方程无法解释,为什么细胞可以进行无氧呼吸,同样消耗葡萄糖产生能量(尽管数量少于完整的呼吸),却不消耗氧气呢?原来,细胞呼吸实际上 有三个步骤:糖酵解、三羧酸循环和氧化磷酸化。在第一步「糖酵解」中,葡萄糖被分解成更简单的化合物丙酮酸,随后丙酮酸被送入三羧酸循环,得到二氧化碳和 NADH。NADH 在最后一步送入氧化磷酸化,在线粒体内膜上和氧气结合,得到水,这个过程放出的能量形成质子浓差,推动 ATP 合成酶运转合成大量的「能量通货」ATP。整个过程只有第三个步骤消耗氧气;三个步骤里,糖酵解和氧化磷酸化放出能量,三羧酸循环反而消耗能量,因而在无 氧条件下,只有糖酵解可以完成。

从这个例子可以看出,你若是不了解细胞呼吸的机理,你就无法解释无氧呼吸是怎么回事。不了解机理,就无法得心应手。于 JavaScript 也是一样的。不过呢,程序语言的境况要比生物学要简单的多。如果把我们研究的东西比作机器的话,生物学、化学、物理学摆在我们面前的是一个个黑箱,我们必 须费尽心思,用各种实验如光谱、传感器、比较跟踪分析等等才能轻微地撬开这个箱子的一角,从而窥视里面无比精妙的机构;与之相反,编译器、解释器那个箱子 则是完全透明,里面那台精密的机器就在你眼前,你可以直接靠过去看个够。编译器要是开源的话那就更好了,这回连箱子都没了,现在不仅可以看了,连拆都可 以,它已经完全是你手里的玩物了。

不仅如此,语言的设计师们会把这门语言的所有机理(没错——所有)写在一本书里,这本书就是这门语言的规范,那台机器的蓝图。在实践中重要的语言 ——例如 C、C++、Java,它们的规范甚至是国际标准。JavaScript 也不例外:ECMA 组织的 ECMA 262 规范就是 JavaScript 这门语言的标准,也包含了它的全部机理。我可以说,要是读懂了 ECMA 262,完全可以做出一个 JavaScript 解释器出来。

在大多数程序员的眼里,做编译器和解释器的都是一群神奇生物,他们能制作出一个神奇的东西,能让你写的代码「动起来」,就像是魔法师一样。对啊,程 序就是现代魔法,而写编译器的估计就是「现代魔法师」里最神秘的一群了。你可能知道语言有「语法」甚至能写出一个而简单语言的语法定义来,但是叫你写个语 法解析器你就熄火了——从未接触过这个领域,怎么写的出来!我估计这里的各位写一个「计算器」都会比较困难——不是按按钮的计算器,是敲一行表达式出结果 的计算器,而且表达式要支持加减乘除和括弧。(其实按按钮的计算器也会牵扯到语法解析的知识,各位都懂设计模式,知道建立联系吗?)

但语法解析真没你想象中的那么神秘,它实际上就是从句子里提取出含义的过程,只不过各位,你们平常「亲手处理」的字符串,要么表示数据(「配置文 件」),要么表示流程(「自动化脚本」)。如果项目是那种老板甲方天天催,一坏损失几百万的那种的话,我估计,你会,数据上 XML 流程上 Lua,完事。表示数据?XML 各种复杂数据都不在话下,而且类库众多,全球通用;表示流程?Lua 是正经的嵌入式脚本语言,体积小,速度快,功能强大。

所以啊,你不会有机会亲自写个语法解析器的,至少在工作里,不会有的!语法解析器是庞大的编译原理冰山上最简单的一角,光这一角,Grune 和 Jacobs 写了整整一本书,比一本小字典还大;这一角之外的东西,比如编译器后端部分的各种优化,更是卷帙浩繁——而它们还只是编译器技术的部分。程序语言的机理更 多的是建构在编译器技术之外的另一块大陆:语言理论之上。在语言理论的世界里,已经几乎不会出现代码了,全是公式。没错!公式!总之,「语言的机理」对一 般程序员来说,实在是太遥远了,太不可企及了,我们只能像仰望星空一样仰望那群大魔导士。

各位可能穷尽一生也不会亲自写一个编译器,因而有些人就抛出论调说「编译器那一大堆东西,于我无用,不用学!」不过正如我前面说的,原理不掌握,你 应用语言永远不能得心应手,你对程序语言的理解,绝对无法和掌握这个神秘领域的人相提并论,达到相同的高度。例如 JavaScript 的函数调用,你写 object.m(xs) 可以进行正确的「方法调用」,但 f = object.m; f(xs) 就有问题(f 得不到正确的 this)。 相比这个问题困扰过很多人吧!一些书籍会为了解释这个现象会把函数调用分成两种,一个是「普通调用」,一个是「方法调用」,然后强调他们在语法和语义上的 区别云云。当然我不是想推翻这句话,它是对的,而且是个相当好的解释;但实际上,按照规范(11.2.3 节),JavaScript 根本就不区分所谓「普通调用」和「方法调用」。下面把 11.2.3 节原样抄下来:

产生式 「CallExpression : MemberExpression Arguments」 依如下方法解释

  1. ref 为解释 MemberExpression 的结果
  2. funcGetValue(ref)
  3. argList 为解释 Arguments 的结果,它应该是由各个参数组成的列表(见 11.2.4)
  4. 如果 Type(func) 不是 Object,抛出 TypeError 异常
  5. 如果 IsCallable(func) 为假,抛出 TypeError 异常
  6. 如果 Type(ref)是 Reference 则:
    1. 如果 IsPropertyReferernce(ref)为真,则
      1. thisValueGetBase(ref)
    2. 否则(此时 ref的基底是一个环境记录)
      1. thisValue 为对 GetBase(ref) 调用 ImplicitThisValue 的结果
  7. 否则 ref不是 Reference:
    1. thisValueundefined
  8. 返回对 func 调用 [[Call]] 内方法的结果,其中 this 指定为 thisValue,参数表指定为 argList

产生式 「CallExpression : CallExpression Arguments」 也依相同方法解释,惟第一步里 MemberExpression 换成 CallExpression

Note

如果 func 是原生 ECMAScript 对象,CallExpression 解释结果一定不会是 Reference 类型;其为宿主对象时是否是 Reference 由实现决定,但如果是,只能是非严格的属性 Referrnce。

上面这些东西直接抄自 ECMA 262,原封未动。这段文字为 CallExpression 语法(JavaScript 里的函数调用)赋予了含义。ECMA 262 里 CallExpression 的语法是两条产生式

  • CallExpression
     → MemberExpression Arguments
  • CallExpression
     → CallExpression Arguments

这两条产生式随后被赋予「函数调用」的语义。所谓「方法调用」所必须的 this 信息实际上是被 MemberExpression 的值携带的。携带 this 信息的东西叫做 Reference,它是解释器使用的一种内部类型,可以记录某个表达式所「关联」的 this 信息。规范里并没有「方法调用」这个说法,「方法调用」这个词汇是为了解释 Reference 造出来的。「方法调用」这个词算是个不错的词,相比规范里精确却叫人难懂的 Reference,「方法调用」和「普通调用」的分野显然要简单得多。毕竟规范要精确、无歧义地定义一门语言,而程序员使用的时候,第一,不需要了解到 如此程度;第二,说的太精确会伤人脑细胞,程序员的脑子可都金贵得很,不能磕着碰着。程序员的脑子是人脑,人脑可以接受不太精确的信息,然后结合经验,形 成你写程序时候的行为准则。编译器则不同,编译器是运行在电脑上的,电脑里的东西必须精确,所以编译器作者看规范看得最勤快;也因为如此,做编译器的人往 往是理解语言更为深入、最理解语言机理的人。

如果说「方法调用」算是个要解释的话,坊间书籍里有些解释就不那么好了,甚至是错误的,比如月影那本大部头《JavaScript 王者归来》里面那个「最长行匹配」就误导了不少人,还有 Douglas 宣称的「JavaScript 里所有数值都是浮点」。这些错处或多或少都是因为对规范不熟悉导致的(虽然 ECMA 262 v3 的佶屈聱牙也要负一般责任)。而它们也是我开始写「JavaScript 原理」的原因。我看规范看了不少次,本身参与了对它的翻译,外加现在在维护 moescript 这个 altjs 语言(编译到 JavaScript 的语言),对 JS 的各种特性,都有所接触。

所以,我打算在这里,第一从严谨的角度出发分析 ECMA 规范和 JavaScript,第二写写这两年来的一些真人经历,主要是 moescript 开发的经历。我希望 jser 们可以追随一个编译器作者和分析者的脚步,来审视你们日复一日使用的东西,看能不能得到一些启发。除开 JS,我也会谈及一些其他的知识和技术,还有少少的个人评价。个人水平有限,还望看官包涵;如果有错误,就大胆的来批好了。

 

有关「节后三问」

为了让我的书不变成「耳边风」,我会在每一节后面加三个问题。这些题目希望各位认真把它做出来。笔者比照 TAOCP,为每个题目标出了难度。它们如下表列:

  • [D] 非常简单,您应该在几分钟内就可以解答出来。
  • [C] 比较简单的问题。可能需要好好阅读问题,并且大概要 20 分钟。
  • [B] 中等难度的问题。为了完整解答它,你可能需要几个小时的努力。(当然,你的电脑联网的话时间可能更长)。本书中的多数题目都是这个难度。
  • [A] 相当困难或者繁杂的题目,或许需要几个星期才能把它做完。大学教授会把它作为学期大作业。
  • [论外] 对于这个难度的问题,可能还没有人知道答案。如果你在大学或者研究所的话,你可以把它作为研究方向。如果你把它做出来了,首先应该发篇论文,而不是找我(不过如果我看到阁下的研究成果的话,我会去亲自找您)。

下面是难度的示例:

[D] 难度 [B] 表示什么?
[C] 证明 173 =4913;并推广你的结论。(这类问题笔者可不喜欢。)
[B] 证明用筛法筛选小于 n 的所有素数的时间复杂度(基于余数判断次数)是 O(nloglogn)
[A] 写一个完整的 JavaScript 解释器,并通过 test262
[论外] PNP 相等吗?

节后三问

[C] 查阅文献,世界上第一个编译器是谁写的?它叫什么名字?有哪些后续版本?
[B] 除了生物代谢过程,化学反应的机理也很重要(这也就是为什么许多诺贝尔奖得主都是研究化学动力学的)。有些看似简单的化学反应可能拥有相当复杂的反应历程,比如 H 2+O 2H 2O。查阅文献,这个反应的机理是什么?牵扯到了多少种中间产物?氢气和氧气按一定比例混合后会爆炸,而这个混合比有上下界,只有在上下界确定的范围内才会爆炸。从反应机理出发解释上下界存在的原因。
[B] 文章说「表示数据用 XML 很好」——XML 确实很适合来表示数据,然而近年来的 Web 服务提供的数据接口却以 JSON 为主。分析下 JSON 相对 XML 的优劣。

 

(本回完)

还有下面一系列的教程,关注

http://typeof.net/s/jsmech/02.html

http://typeof.net/s/jsmech/03.html

http://typeof.net/s/jsmech/03-a.html

http://typeof.net/s/jsmech/04.html


关注我

我的微信公众号:前端开发博客,在后台回复以下关键字可以获取资源。

  • 回复「小抄」,领取Vue、JavaScript 和 WebComponent 小抄 PDF
  • 回复「Vue脑图」获取 Vue 相关脑图
  • 回复「思维图」获取 JavaScript 相关思维图
  • 回复「简历」获取简历制作建议
  • 回复「简历模板」获取精选的简历模板
  • 回复「加群」进入500人前端精英群
  • 回复「电子书」下载我整理的大量前端资源,含面试、Vue实战项目、CSS和JavaScript电子书等。
  • 回复「知识点」下载高清JavaScript知识点图谱

每日分享有用的前端开发知识,加我微信:caibaojian89 交流