读:把 JSON 当编程语言执行——一个迷你解释器的构造过程
目录
原文 做了一个有趣的技术实验:把 JSON 当编程语言用。你写一个 JSON 数组,里面每个对象带一个 type 属性标明"这句是干什么的",然后喂给一个 JavaScript 写的解释器,它就会按顺序执行并返回结果。
本质上,这是在 JSON 语法里塞了一个微型 Lisp 解释器。原文的副标题 "With a Lithp" 也暗示了这一点(Lithp = Lisp 的大舌头读法)。
"怎么用 JS 写解释器"的教程已经很多了。这篇有意思的地方是:它用 JSON 做了一个具体的、可运行的"代码即数据"实例。数据格式本身什么都没变,只是有人写了个解释器按特定方式去解读它,JSON 就突然能执行了。
解释器的架构是经典三段式:reader → evaluator → printer。代码用 JavaScript,逻辑直白,不写 JS 也能看懂。
Reader 和 Printer:最简单的两个组件
Reader 的任务是把用户输入转成解释器能处理的数据结构。因为已经定好了"输入就是 JSON",reader 只需要做一件事:调 =JSON.parse=,解析失败就当普通字符串原样返回。
function jread(v) { let ret = null; try { ret = JSON.parse(v); } catch (e) { ret = v; // 解析失败就当字符串处理 } return ret; }
Printer 更简单:直接返回计算结果。因为假设解释器在命令行里跑,命令行自己会负责显示。
function jprint(v) { return v; }
解释器的主体骨架就是把这两个组件串起来,遍历输入数组,逐条读取、求值、打印:
function interpreter(input, env) { let ret = null; for (let i = 0; i < input.length; ++i) { ret = jread(input[i]); ret = jeval(ret, env); // 核心:求值 ret = jprint(ret); } return ret; }
Evaluator:类型标签分发
Evaluator 是整个解释器的核心。它的逻辑是:遇到"简单值"(数字、字符串、数组)直接返回;遇到"对象"就检查 type 属性,按类型标签分发到不同的处理逻辑。
function jeval(v, env) { // 原子值:直接返回(null 需单独判断,因为 typeof null === 'object') if (v === null || typeof v !== 'object' || v instanceof Array) { return v; } let ev = null; switch (v.type) { case 'num': case 'str': case 'vec': // 变量定义:先求值,再存入环境 ev = jeval(v.value, env); if (v.name) { jupdate(v.name, ev, env); } break; case 'var': // 变量引用:从环境里取 ev = jlook(v.name, env); if (typeof ev === 'object' && ev !== null && !(ev instanceof Array)) { return '<function ' + v.name + '>'; } break; case 'fn': // 函数定义:捕获当前环境,存入变量 ev = jfn(v.value, env); if (v.name) { jupdate(v.name, ev, env); } break; case 'call': // 函数调用:查找函数,传入参数求值 ev = jcall(jlook(v.name, env), v.args, env); break; case 'if': // 条件:null 为假,非 null 为真 ev = jif(v.cond, v.then, v.else, env); break; case 'while': // 循环:条件为真时重复执行 ev = jloop(v.cond, v.body, env); break; default: ev = null; break; } return ev; }
变量定义用的标签包括三种 *值的类型*(=num=、=str=、=vec=),和一个统一的 =var=。这样做的好处是 evaluator 可以在定义变量时顺手做类型检查,代价是标签列表会随类型增加而膨胀。
环境与作用域:用 __parent 链实现静态作用域
变量存在哪里?存在"环境"(environment)里。环境就是一个普通的 JS 对象,key 是变量名,value 是值。
function jlook(key, env) { let e = env; let r = undefined; while (r === undefined && e !== undefined) { r = e[key]; if (r === undefined) e = e.__parent; // 找不到才向父环境找 } return r; } function jupdate(key, val, env) { let e = env; let r = undefined; while (r === undefined && e !== undefined) { r = e[key]; if (r === undefined) e = e.__parent; // 找到了就不再上移 } if (e === undefined) e = env; // 找不到就在当前环境创建 e[key] = val; return val; }
关键机制是 __parent 指针。每个环境对象可以有一个 __parent 指向父环境,查找变量时沿着这条链一路向上。这正是静态作用域(lexical scoping)的标准实现方式:函数定义时捕获的环境链决定了它运行时能看到哪些变量。
函数:闭包的本质就是捕获环境
函数的定义分两步:先用 fn 标签声明,值是一个包含 args=(参数列表)和 =body=(函数体)的对象。解释器收到 =fn 表达式后,调用 jfn 创建一个"函数对象",把当前环境挂到 __parent 上:
function jfn(v, env) { let f = {}; f.env = { __parent: env }; // 捕获定义时的环境 f.args = v.args; f.body = v.body; return f; }
调用函数时(=call= 标签),=jcall= 创建一个新环境来存参数,然后把函数体里的表达式逐条求值:
function jcall(f, args, env) { let e = { __parent: f.env }; // 新环境链到函数定义时捕获的环境 for (let i = 0; i < f.args.length; ++i) { e[f.args[i]] = jeval(args[i], env); // 参数在调用环境中求值 } let ret = null; for (let i = 0; i < f.body.length; ++i) { ret = jeval(f.body[i], e); // 函数体在新环境中执行 } return ret; }
这就是闭包的本质:函数对象带着一个 __parent 指针,指向它定义时所在的环境。以后无论在哪里调用,变量查找都从自己环境开始,沿 __parent 链向上。JavaScript 的闭包、Lisp 的词法作用域,跟这是同一套机制。
条件与循环
条件表达式用 if 标签,=cond= 是条件,=then= 和 else 各是一个表达式数组。关于"真"和"假"的约定很简单:=null= 是假,任何非 null 值都是真。
function jif(cond, thenExpr, elseExpr, env) { let c = jeval(cond, env); let branch = (c === null) ? elseExpr : thenExpr; let ret = null; for (let i = 0; i < branch.length; ++i) { ret = jeval(branch[i], env); } return ret; }
循环用 while 标签:只要条件不等于 =null=,就重复执行 body。
function jloop(cond, body, env) { let r = null; while (jeval(cond, env) !== null) { for (let i = 0; i < body.length; ++i) { r = jeval(body[i], env); } } return r; }
算术与逻辑:设计原始操作时的取舍
JSON 语法不能直接写 "a + b" 这样的表达式,所以算术和逻辑运算也必须用类型标签(或内置函数)来实现。两种方案各有代价:
- *做成类型标签*:运算行为不可被用户覆盖,但没法作为参数传给高阶函数
- *做成内置函数*:可以传给高阶函数,但用户可能意外覆盖它们
原文选了类型标签方案(只展示了实现,没展开讨论)。
**算术运算**中,加减乘除各有语义设计。加法和乘法最直接,累加/累乘所有参数。减法和除法有趣一些:一个参数时取负/取倒数,多个参数时从左到右依次运算。
function jadd(args, env) { let r = 0; for (let i = 0; i < args.length; ++i) { r = r + jeval(args[i], env); } return r; } function jsub(args, env) { if (args.length === 1) { return -(jeval(args[0], env)); } let r = jeval(args[0], env); for (let i = 1; i < args.length; ++i) { r = r - jeval(args[i], env); } return r; } function jmul(args, env) { let r = 1; for (let i = 0; i < args.length; ++i) { r = r * jeval(args[i], env); } return r; } function jdiv(args, env) { if (args.length === 1) { return 1 / jeval(args[0], env); } let r = jeval(args[0], env); for (let i = 1; i < args.length; ++i) { r = r / jeval(args[i], env); } return r; }
**比较运算**(小于、大于等)按序检查参数:只要发现一对违反顺序的就立即返回 =null=,全部通过则返回 ="true"=。
function jlt(args, env) { let m = jeval(args[0], env); for (let i = 1; i < args.length; ++i) { let n = jeval(args[i], env); if (!(m < n)) return null; m = n; } return 'true'; } function jgt(args, env) { let m = jeval(args[0], env); for (let i = 1; i < args.length; ++i) { let n = jeval(args[i], env); if (!(m > n)) return null; m = n; } return 'true'; }
**逻辑运算**也是短路求值,跟 JS 的语义一致:=and= 遇到 null 就停,=or= 遇到非 null 就停,=not= 把 null 和非 null 互换。
function jand(args, env) { let c = jeval(args[0], env); for (let i = 1; i < args.length && c !== null; ++i) { c = jeval(args[i], env); } return c; } function jor(args, env) { let c = jeval(args[0], env); for (let i = 1; i < args.length && c === null; ++i) { c = jeval(args[i], env); } return c; } function jnot(args, env) { return (jeval(args[0], env) === null) ? 'true' : null; }
这些运算函数需要在 jeval 里注册为对应的类型标签。把以下 case 加入 jeval 的 switch:
// 以下代码会覆盖前面的 jeval 定义,补充算术和逻辑运算的 case function jeval(v, env) { if (v === null || typeof v !== 'object' || v instanceof Array) { return v; } let ev = null; switch (v.type) { case 'num': case 'str': case 'vec': ev = jeval(v.value, env); if (v.name) { jupdate(v.name, ev, env); } break; case 'var': ev = jlook(v.name, env); if (typeof ev === 'object' && ev !== null && !(ev instanceof Array)) { return '<function ' + v.name + '>'; } break; case 'fn': ev = jfn(v.value, env); if (v.name) { jupdate(v.name, ev, env); } break; case 'call': ev = jcall(jlook(v.name, env), v.args, env); break; case 'if': ev = jif(v.cond, v.then, v.else, env); break; case 'while': ev = jloop(v.cond, v.body, env); break; case 'add': ev = jadd(v.args, env); break; case 'sub': ev = jsub(v.args, env); break; case 'mul': ev = jmul(v.args, env); break; case 'div': ev = jdiv(v.args, env); break; case 'lt': ev = jlt(v.args, env); break; case 'gt': ev = jgt(v.args, env); break; case 'and': ev = jand(v.args, env); break; case 'or': ev = jor(v.args, env); break; case 'not': ev = jnot(v.args, env); break; default: ev = null; break; } return ev; }
完整示例
把以上组件拼起来,用测试案例覆盖解释器的全部运算单元。
四则混合运算: 一个嵌套表达式覆盖加、减、乘、除四种运算,相当于 =(1+2)*3 - 4/2 = 7=。
console.log("(1+2)*3 - 4/2 =", interpreter([{ type: 'sub', args: [ { type: 'mul', args: [{ type: 'add', args: [1, 2] }, 3] }, { type: 'div', args: [4, 2] } ]}], {}));
(1+2)*3 - 4/2 = 7
比较和逻辑运算: 小于/大于按序检查,=and= 短路到 null=,=or 短路到非 null=,=not 互换真假。
// 比较运算 console.log("1<2<3 =", interpreter([{ type: 'lt', args: [1, 2, 3] }], {})); console.log("1<3<2 =", interpreter([{ type: 'lt', args: [1, 3, 2] }], {})); console.log("3>2>1 =", interpreter([{ type: 'gt', args: [3, 2, 1] }], {})); // 逻辑运算 console.log("and(1,2,3) =", interpreter([{ type: 'and', args: [1, 2, 3] }], {})); console.log("and(1,null,3) =", interpreter([{ type: 'and', args: [1, null, 3] }], {})); console.log("or(null,null,42) =", interpreter([{ type: 'or', args: [null, null, 42] }], {})); console.log("not(null) =", interpreter([{ type: 'not', args: [null] }], {})); console.log("not(1) =", interpreter([{ type: 'not', args: [1] }], {}));
1<2<3 = true 1<3<2 = null 3>2>1 = true and(1,2,3) = 3 and(1,null,3) = null or(null,null,42) = 42 not(null) = true not(1) = null
条件表达式: null 为假走 else=,非 =null 为真走 =then=。
// 条件 console.log("if(null) =", interpreter([ { type: 'if', cond: null, then: [{ type: 'str', value: 'yes' }], else: [{ type: 'str', value: 'no' }] } ], {})); console.log("if(gt(42,0)) =", interpreter([ { type: 'if', cond: { type: 'gt', args: [42, 0] }, then: [{ type: 'str', value: 'positive' }], else: [{ type: 'str', value: 'non-positive' }] } ], {}));
if(null) = no if(gt(42,0)) = positive
变量 + 循环: 用 while 累加求和 =1+2+...+5=。
// 累加 1+2+...+5 let sumExample = [ { type: 'num', name: 'i', value: 0 }, { type: 'num', name: 'n', value: 5 }, { type: 'num', name: 'sum', value: 0 }, { type: 'while', cond: { type: 'lt', args: [{ type: 'var', name: 'i' }, { type: 'var', name: 'n' }] }, body: [ { type: 'num', name: 'i', value: { type: 'add', args: [{ type: 'var', name: 'i' }, 1] } }, { type: 'num', name: 'sum', value: { type: 'add', args: [{ type: 'var', name: 'sum' }, { type: 'var', name: 'i' }] } }, ] }, { type: 'var', name: 'sum' } ]; console.log("1+2+...+5 =", interpreter(sumExample, {}));
1+2+...+5 = 15
函数 + 闭包: 定义一个阶乘函数 fact(5)=,内部用 =while 循环。
// 阶乘 fact(5) = 120 let factExample = [ { type: 'fn', name: 'fact', value: { args: ['n'], body: [ { type: 'num', name: 'result', value: 1 }, { type: 'while', cond: { type: 'gt', args: [{ type: 'var', name: 'n' }, 0] }, body: [ { type: 'num', name: 'result', value: { type: 'mul', args: [{ type: 'var', name: 'result' }, { type: 'var', name: 'n' }] } }, { type: 'num', name: 'n', value: { type: 'sub', args: [{ type: 'var', name: 'n' }, 1] } }, ] }, { type: 'var', name: 'result' } ] } }, { type: 'call', name: 'fact', args: [5] }, ]; console.log("fact(5) =", interpreter(factExample, {}));
fact(5) = 120
字符串 + 数组: 字符串变量和数组变量都能正确定义和引用。
// 字符串 + 数组 console.log(interpreter([ { type: 'str', name: 'msg', value: 'Hello World!' }, { type: 'fn', name: 'greet', value: { args: [], body: [{ type: 'var', name: 'msg' }] } }, { type: 'call', name: 'greet', args: [] }, ], {})); console.log(interpreter([ { type: 'vec', name: 'xs', value: [1, 2, 3] }, { type: 'var', name: 'xs' } ], {}));
Hello World! [ 1, 2, 3 ]
为什么说它是"微型 Lisp"
回头看这个解释器的结构,它跟 Lisp 解释器的相似之处不是巧合:
- Lisp 的 S-expression 是 =(fn arg1 arg2...)=,这里的 JSON 表达是 ={type: "fn", args: [...], body: [...]}=。本质相同,只是穿了一层 JSON 的外衣
- Lisp 用
car/cdr取列表头和尾,这里用v.type/v.name/v.value取对象属性 - Lisp 的词法作用域通过环境链实现,这里用
__parent指针,机制一模一样 - Lisp 的
eval和apply循环,对应这里的jeval和jcall
原文把 Lisp 写成了"Lithp",大舌头发不准 s 音的梗。但玩笑归玩笑,这确实是一个"代码即数据"的微型演示:JSON 既是数据格式,又因为解释器的存在变成了可执行的语言。格式本身没有变,变的是解读它的方式。
如何自己跑一遍
文中所有代码块都设置了 :tangle json-interpreter.js=。如果你在用 Emacs 读这篇博文,在任意一个代码块里按 =C-c C-v t=,Org-mode 会把所有代码块拼接成一个完整的 =json-interpreter.js 文件。
如果不用 Emacs,可以直接下载我拼好的完整文件 json-interpreter.js(跟这篇博文在同一个目录下),然后:
node json-interpreter.js
Hello World!
你也可以改 helloworld 数组里的内容,试试文中的其他表达式(=if=、=while= 等)。