JSVM2:表达式求值的现实主义,左值、运算符与 ES2015 裁剪

📅
2 分钟阅读
·

前几篇说明了骨架、作用域、控制流和函数。本文说明表达式求值。其主要问题有两个:赋值左值是引用而非值;ES2015 包含大量语法,需要按输入范围裁剪。前半部分讨论左值和运算符,后半部分核对 ES2015 的实际支持范围。

左值是引用

a = 1 这行代码,右边求值得 1,很容易。左边麻烦。在 jsvm2 里,Identifier 节点的求值结果是值:identifier.ts:84-89 沿作用域链做 hasBinding,拿到就返回 $var.value,拿不到抛 ReferenceError。但赋值要写的不是值,是这个绑定本身。ECMA-262 把左边求值的结果叫 Reference,一个「指向绑定的指针」,赋值走的是 PutValue。

jsvm2 没有实现规范里的 Reference 类型。规范里的 Reference 是一个带 base(对象或环境记录)和 name 的持久结构,赋值、取值、delete、复合赋值都围绕它定义,完整实现意味着所有左值相关的求值都要重写一遍。实现使用“伪左值”:无论左侧形态如何,最终都表示为带 getter/setter 的 Var 对象,赋值统一写为 $var.value = v。该方案保留 Reference 的读写能力;JS 语法不支持传递引用本身,因此不需要实现该能力。

左边是标识符时最简单,assignmentExpression.ts:79-99 直接用 scope.hasBinding(name) 把作用域里的 Var 拿出来。Var 本身就是带 getter/setter 的类(见第 2 篇),天然就是引用。这里顺带做两件事:标识符未声明时抛 ErrNotDefined(wxml 模式下退化到非严格语义,在全局作用域补声明);$var.kind === Kind.const 时抛 Assignment to constant variable.,const 的不可写语义就落在这里(assignmentExpression.ts:104-110)。

左边是成员表达式时没有现成的 Var 可拿,a.x 只是对象上的一个属性。做法是现场造一个(assignmentExpression.ts:144-173):

$var = {
  kind: Kind.var,
  set value(value: any) {
    // (此处略去一段注释掉的原型链特判代码)
    if (object == null) {
      throw overrideStack(ErrCanNotSetProperty(property, typeof object), stack, node);
    } else {
      object[property] = value;
    }
    object[property] = value;
  },
  get value() {
    return object[property];
  },
};

objectproperty 是提前求值好的局部变量,setter 闭包捕获它们。这个匿名对象活不过这一次赋值,但够用了:赋值语义需要的全部就是「能读当前值、能写新值」。

使用伪左值后,13 种赋值运算符由一张表处理(assignmentExpression.ts:9-62)。表项形式为 ($var, v) => { $var.value += v; return $var.value; }AssignmentExpression 处理器准备左值和右值后,通过 AssignmentExpressionMap[node.operator]($var, rightValue) 调用对应实现。表中包含 =+=-=、位运算复合赋值及用 Math.pow 实现的 ES2016 **=。后文会核对该实现与 readme 标记是否一致。

赋值顺序示例

assignmentExpression.ts:112-123 有一段我当时写下的中文注释,推演的是那道经典面试题:

var a = { n: 1 };
var b = a;
a.x = a = { n: 2 };
// 问:执行完 a 和 b 分别是什么

答案是 a = {n:2}b = {n:1, x:{n:2}}。关键是赋值的求值顺序:先把左边的引用解析出来并挂起,再算右边,最后通过挂起的引用写入。左引用解析时 a.x 还不存在,JS 会在堆里的旧对象上创建成员 x,引用指向这个新成员。接着算右边,a = {n:2} 是个简单赋值,a 这个名字重新绑到新对象上,但旧对象因为有 b 占用不会被回收。最后一步把 {n:2} 写入刚才挂起的引用,写入的是旧对象的 x。所以 b.x 等于新的 a

这段推演能自然落地,是因为代码顺序恰好对了:assignmentExpression.ts:128 先求 object137 行再求 rightValue,setter 闭包捕获的是旧对象。实测跑了一遍,输出 {"n":2}{"n":1,"x":{"n":2}},和规范一致。

但同一文件里藏着一个顺序错误。计算属性的左值,property139-141 行才求值,位置在 rightValue 之后。规范要求对象和属性都先求值再算右边。我写了个用例实测:

var order = [];
function g() { order.push('obj'); return {}; }
function h() { order.push('prop'); return 'k'; }
function r() { order.push('rhs'); return 1; }
g()[h()] = r();
// 规范: obj,prop,rhs
// jsvm2 实测: obj,rhs,prop

属性求值和右边求值颠倒,平时写不出有副作用的属性表达式就暴露不了,属于语义偏差里藏得比较深的一种。

运算符语义委托给宿主

与左值的手工实现不同,运算符部分主要复用宿主行为。binaryExpression.ts:5-30 是一张运算符表,每个运算符映射到一个宿主 lambda:'<<': (a, b) => a << b'==': (a, b) => a == binstanceof: (a, b) => a instanceof b,一共 21 个。处理器正文只有三行(binaryExpression.ts:33-39):左右子节点求值,查表,调用。

该方案直接复用 JS 的类型转换语义。== 的抽象相等比较、+ 的字符串拼接规则、关系运算符的 ToPrimitive,规范里每一个都是好几节的算法,手写几乎一定写错。委给宿主之后这些规则一行都不用写,而且和宿主行为逐字节一致。instanceof 也顺带正确了,因为原型链本来就是宿主的(见第 5 篇)。in 同理,对象就是宿主对象,属性查找走宿主语义。

这张表能成立有一个前提:解释器里流动的值就是宿主值。jsvm2 不给值包包装类,数字就是宿主数字,对象就是宿主对象,所以宿主的 +<== 才能直接吃。这个前提在第 5 篇讲函数时已经反复用到,表达式这里是另一个受益点。一旦决定自己包装值,这张表就全部作废,每个运算符都得先拆包再算再包回去。

&&|| 也复用宿主的短路行为(logicalExpression.ts:7-12):

return {
  '||': () =>
    path.visitor(path.createChild(node.left)) || path.visitor(path.createChild(node.right)),
  '&&': () =>
    path.visitor(path.createChild(node.left)) && path.visitor(path.createChild(node.right)),
}[node.operator]();

右边子节点的求值包在 path.visitor(...) 里,而 path.visitor(...) 出现在宿主 || 的右操作数位置。宿主的短路惰性直接变成解释器的短路惰性:左边为真,右边那个子树根本不会被遍历。另一个容易做错的点是返回值,规范里 a || b 返回的是操作数本身而不是布尔值,这里宿主 || 返回什么解释器就返回什么,自然正确。如果当初自作主张包一层 Boolean(),就埋了一个 bug。

还有一个语义特例在 typeoftypeof 未声明变量 不抛错,返回 'undefined',这是规范里唯一容忍不可解析引用的运算符。实现在 unaryExpression.ts:41-48:参数是标识符时先查 scope.hasBinding,查不到直接返回 'undefined',根本不触发求值;参数是其他表达式才正常求值。对比 identifier.ts:84-89,同一个 Identifier 节点,独立求值时未声明抛 ReferenceError,在 typeof 上下文里返回 'undefined'。节点类型相同,语义由上下文决定,这也是为什么求值函数需要拿到 Path 而不只是裸节点。

左值相关的已知问题

除了上面计算属性的顺序问题,还有几个实测确认过的坑,一并记下来。

成员复合赋值的 setter 写了两遍。回看上面贴的代码,else 分支里写了一次 object[property] = valueif 结束后第 168 行又无条件写了一次。普通数据属性写两遍无害,实测 o.x += 5 结果是 6,功能正常;但如果属性带 setter 或者目标是 Proxy,副作用就执行两次。从结构看第 168 行像是重构残留。

UpdateExpression 双重求值。updateExpression.ts:22-36 求一遍 objectproperty 来构造伪 Var,47 行又对整个 node.argument 调了一次 path.visitor 拿当前值。成员是计算属性时副作用函数跑两遍,实测:

var calls = 0;
function f() { calls++; return 'x'; }
var o = { x: 1 };
o[f()]++;
// 规范: calls === 1
// jsvm2 实测: calls === 2

delete 标识符只读没删。unaryExpression.ts:21-28 的标识符分支,拿到 this 绑定后做的是 return $this.value[node.argument.name],读了一次值当返回值,什么都没删。严格模式下 @babel/parser 直接拒绝 delete x 这种写法(解析期就报错),所以这个分支几乎不可达,但可达时行为也是错的。

这些问题均与左值建模相关:错误发生在引用的解析、保存或读写过程。

ES2015 的裁剪范围

至此已说明 ES5 的表达式。ES2015 新增数十种语法节点,每种都对应规范中的具体语义。jsvm2 只执行 Babel 产物,因此不需要覆盖所有语法形态,实现范围限定为业务编译产物实际出现的语法。

最后 es2015 目录里只有 6 个文件 79 行,其中 index.ts 还是注册表,真正有语义的 5 个文件 66 行。能这么少,是因为大部分 ES2015 特性寄生在 ES5 的实现里:

  • let/const 没有新文件。variable.tsscope.declare(kind, ...) 按 kind 分发(scope.ts:60-62),const 的写检查在赋值处理器里,就是前面说的 assignmentExpression.ts:104。TDZ 没有做,let 声明前访问会静默拿到外层同名绑定,这个偏差第 3 篇已经交代过,不再重复。
  • 解构没有新文件,塞在 variable.ts:29-75
  • 默认参数没有新文件,function.ts:48-50 把 AssignmentPattern 节点连同实参值交给 visitor,由 es2015 目录里 18 行的 assignmentPattern.ts 声明成 const。这里有个细节:默认参数声明成了 const,函数体里对参数再赋值会抛 Assignment to constant variable.,而规范里参数是可写的。普通参数走的是 declareVar,只有带默认值的参数被升级成了 const,同一个函数里两种参数的可写性不一致。rest 参数同理(function.ts:51-53),同样落在 const 上。
  • 箭头函数是独立文件,第 5 篇讲过,两行实现词法 this。
  • new.targetmetaProperty.ts 的 7 行实现,近似方案第 5 篇也交代过。

寄生策略让 ES2015 的增量成本变得很小,但代价是覆盖不完整,而且不完整得没什么规律。哪些路径断了,下面实测。

readme 与实际支持范围的核对

readme 的语法支持清单以打钩表示支持。逐条执行 ES2015 之后的特性后,发现两项标记与实际实现不一致。

第一处是 ForOfStatement,readme 打了钩,但 es5 目录只有 ForStatementForInStatement,es2015 目录根本没有循环语句的文件。实测:

for (const x of [1, 2]) {}
// jsvm2 实测: [jsvm] Not implement for 'ForOfStatement' syntax

直接抛 ErrImplement。这符合「未实现就明确报错」的原则,但 readme 不该打钩。

第二处更微妙。readme 的 ES2016 一节给 BinaryExpression 打了钩。ES2016 唯一的语法新增就是幂运算符 **,这个钩的意思只能是「支持 **」。但 binaryExpression.ts:5-30 的运算符表里没有 **。实测:

2 ** 3
// jsvm2 实测: BinaryExpressionOperatorMap[node.operator] is not a function

查表落空,抛一个连错误信息都不友好的 TypeError。而前面提到的 **= 倒是用 Math.pow 实现了,实测 var a = 2; a **= 3 得 8。同一族语义,复合赋值做了,二元运算符没做,readme 按「做了」宣传。

这两处偏差的原因我猜是同一个:清单按「打算支持」维护,不是按「实测支持」维护。第 1 篇里说这张清单是诚实的,现在要做个修正:打钩的 ES5 部分经得起核对,ES2015 之后的部分有两个名不副实的钩。特性清单也需要测试守护,靠自觉维护就会漂。

顺带还发现一个反向的偏差。readme 里 ObjectPattern 打了钩、ArrayPattern 没打钩,但 variable.ts 里两个分支都存在(variable.ts:2950),实现程度差不多,都是「简单形态可用、复杂形态断」。数组解构没打钩属于低估了自己,但比起前面两个高估,这种偏差无害,只是清单和现实又对不上了一次。

解构的组合矩阵

解构是裁剪不完整的典型样本。把上下文(声明、赋值、函数参数)乘以模式(对象、数组)再乘以特性(默认值、重命名、嵌套、rest),组合出十几条路径,每条路径实现了大概 80%,断的位置各不相同。逐条实测的结果:

声明加对象解构是覆盖最好的一条路径。var {a, b: alias} = obj 正常。断掉的地方:...rest 被静默忽略,实测 var {p, ...rest} = {p:1, q:2} 之后 typeof rest'undefined',rest 根本没声明,因为 variable.ts:36 只认 ObjectProperty,RestElement 直接跳过。重命名加默认值也断,var {m: alias = 9} = {m:1} 之后 alias 没声明,因为 variable.ts:39n.value.name,而默认值形态下 n.value 是 AssignmentPattern 节点,没有 name。取不到键的属性同样不声明,规范里应该声明为 undefined,这里是压根没有绑定,后续访问会抛 ReferenceError 而不是拿到 undefined

声明加数组解构只处理元素是普通标识符的情况(variable.ts:66isIdentifier 判断)。var [q = 5] = [1] 里元素是 AssignmentPattern,被跳过,q 未声明,后面用到 q 时抛 q is not defined。嵌套模式和 rest 同理跳过。

赋值加解构整条路径都没有。AssignmentExpression 的左值只处理 Identifier 和 MemberExpression 两种形态,({z} = {z:1}) 走到的是初始桩 Var,它的 setter 直接 throw new Error('no implement')assignmentExpression.ts:66-76)。报错信息简陋,但至少是明确的报错,不是静默写错地方。

参数加解构也是断的,而且断法最隐蔽:function.ts:54-56 对不认识的参数形态只 console.error('Function: 无效参数'),不抛错,参数静默不绑定。默认值加解构的组合(function f({a} = {}))则走 AssignmentPattern 处理器,左边不是标识符时抛 ErrImplementassignmentPattern.ts:15-17),这一种又是报错。同一个「不支持」,三种表现:静默忽略、console 告警、明确抛错。

调用处的 spread 没有 flatten。spreadElement.ts:4-6 原样返回数组,callExpression.ts:69 对每个参数求值后直接传给 func.apply,没有展开。实测 f(...[1,2,3])arguments.length 是 1,整个数组作为第一个参数传进去了。

这份矩阵摆出来不好看,但它在生产上没出过一次问题。原因是输入形态被收窄过:业务代码全部是 babel 产物,解构大多被编译期降级,残留的也是 var {a} = obj 这种最简单的形态。矩阵里断掉的路径,恰恰是工具链永远不会生成的路径。「编译期收窄解释期」这条主线,在解构上体现得最彻底:解释器的语义缺口和转译层的输出形态是配套的。

裁剪的实现原则

ES2015 的实现遵循三项原则。

能委给宿主的委给宿主,这是全系列反复讲的,运算符表是收益最大的样本,免费的类型转换和免费的短路。

实现不起的明确报错,ErrImplement 和桩 setter 的 no implement 都是这个思路。引擎表现为严格模式,本来就把一类语义(with、八进制字面量、非严格 delete)挡在了 parser 层,报错是默认姿态。

裁剪要诚实,这一条做得不够好。明确的报错是诚实,readme 上的两个错钩不是。语义缺口本身可以接受,业务输入是收窄的,缺口不暴露;但文档把缺口说成已支持,就把「知道自己不支持」变成了「不知道自己不支持」,这两者的差别在排障时是小时和天的差别。

解构矩阵和 UpdateExpression 的双重求值等半实现路径仍需修复。由于维护资源有限,且编译层会规避这些路径,本文先记录其实际行为。使用 jsvm2 时,需要根据这些结果判断哪些输入会失败及其失败方式。

下一篇说明 jsvm2 如何通过五层测试验证其在目标输入范围内的语义。


659 字 · 59 段落
ximing

Follow onGitHub

相关文章