在函数式编程中,tagless-final(无标签最终)编码是一种强大技术,用于构建嵌入式领域特定语言(DSL)。它通过参数多态(parametric polymorphism)避免显式抽象语法树(AST)标签,使用高阶函数表示 DSL 表达式。这些表达式可组合,并在运行时提供具体解释器进行求值。这种方法减少了样板代码,提高了类型安全性和可组合性。
尽管 JavaScript 是动态语言,但其一等函数和高阶函数特性完美模拟了 tagless final。无需类型类(如 Haskell 的 Typeclass),只需鸭子类型(duck typing):DSL 操作是接受解释器对象(repr)的函数,并在其中调用解释器方法。本文聚焦于在 JS 中实现 tagless-final 图灵机(Turing Machine, TM),作为抽象机器 DSL 示例。图灵机是计算理论基石,适合演示状态机 DSL 的组合与模拟。
基础概念与算术 DSL 示例
tagless final 的核心:DSL 术语是多态函数 Expr repr a,即对任意 repr(解释器代数)返回 repr a。
在 JS:
// 解释器代数接口(鸭子类型)
const arithmeticRepr = {
num: (n) => n,
add: (x, y) => x + y,
mul: (x, y) => x * y
};
// DSL 操作:高阶函数
const Num = (n) => (r) => r.num(n);
const Add = (x) => (y) => (r) => r.add(x(r), y(r));
const Mul = (x) => (y) => (r) => r.mul(x(r), y(r));
// 组合表达式:(2 + 3) * 4
const expr = Mul(Add(Num(2))(Num(3)))(Num(4));
// 运行:提供解释器
const result = expr(arithmeticRepr); // 20
console.log(result);
此例中,expr 是纯函数,仅依赖解释器。无 AST、无样板,仅组合高阶函数。
图灵机 DSL 定义
图灵机模型:无限 tape(符号带)、head(读写头)、有限状态集。DSL 操作包括:
read(): 读取当前符号write(sym): 写入符号move(dir): 移动头(Left/Right)goto(state): 跳转状态halt(): 检查是否停机
解释器 tmRepr 提供这些方法的状态模拟器。
DSL 核心:每个 “指令” 或 “机器” 是 (repr) => repr.Instr,但为状态机,使用 continuation-passing 风格(CPS)或步骤序列。
简化:定义 Step 为 (repr) => void,每个步骤调用 repr 方法并决定下一行动。
// 符号与方向(常量)
const Blank = 0;
const One = 1;
const Left = -1;
const Right = 1;
// DSL 操作
const Read = (r) => r.read(); // 返回当前符号,但需处理
// 实际:分支基于 read
const Match = (sym) => (contYes) => (contNo) => (r) => {
const s = r.read();
return (s === sym) ? contYes(r) : contNo(r);
};
const Write = (sym) => (cont) => (r) => { r.write(sym); cont(r); };
const Move = (dir) => (cont) => (r) => { r.move(dir); cont(r); };
const Goto = (state) => (cont) => (r) => { r.gotoState(state); cont(r); };
const Halt = (r) => r.halt();
一个完整机器是初始步骤:(r) => step1(r)。
组合示例:二进制递增机
构建一个在 tape 上递增二进制数的 TM(假设数字从 head 左方,低位先)。
// 递增机
const Increment = (initState) => (r) => {
// 寻找最低位 1(从右向左)
const findLowest = Goto(initState)(
Match(Blank)(
// 全 0,写 1 并 halt
Write(One)(Halt)
)(
// 找到 0,翻转并携带
Match(One)(
Write(Blank)(
Move(Right)(
// 递归携带
findLowest(r) // 注意:需 CPS 包装
)
)
)(
// 找到 1,翻转为 0 并携带
Write(One)(
Move(Left)(
findLowest(r)
)
)
)
)
)(r);
};
注意:实际需 CPS 全包装,避免递归深度问题。简化版用 while 循环在解释器,但 tagless 保持纯 repr 调用。
运行时解释器生成
动态生成解释器:
function mkTMInterpreter(tapeSize = 10000, maxSteps = 1000000, states = ['q0', 'q1', 'halt']) {
return {
tape: new Array(tapeSize).fill(Blank),
head: Math.floor(tapeSize / 2),
state: 'q0',
stepCount: 0,
read: function() { return this.tape[this.head] || Blank; },
write: function(sym) { this.tape[this.head] = sym; },
move: function(dir) { this.head += dir; },
gotoState: function(s) { this.state = s; },
halt: function() { return this.state === 'halt' || this.stepCount++ > maxSteps; }
};
}
// 运行
const interp = mkTMInterpreter();
const machine = Increment('q0');
while (!interp.halt()) {
machine(interp); // 但实际 machine 是单步,需 loop
}
为多步,解释器有 step() 驱动。
纯度保证与优化
javascript.tm 项目强调纯度:repr 调用无副作用,tape/state 纯数据转换。为避免无限循环:
- 参数:maxSteps: 1e6(阈值),tapeSize: 1e4(内存限),headBounds: [0, tapeSize)
- 监控:stepCount 递增,超时抛 Error。
- 优化:用 Proxy 包装 tape 检测溢出;纯函数 snapshot:
cloneInterp(interp) - 回滚:保存 checkpoints,每 1000 步。
清单:
- 定义 DSL ops 为高阶 CPS 函数。
- 组合成 machine: (repr) => void。
- 生成 interp: mkTMInterpreter (params)。
- 驱动循环:while (!interp.halt ()) machine (interp);
- 验证:post-run 检查 tape 输出。
- 测试:Busy Beaver (小心步数),halting oracles (no-go 定理 demo)。
工程落地参数
| 参数 | 默认 | 描述 | 风险阈值 |
|---|---|---|---|
| maxSteps | 1e6 | 防无限循环 | >1e7 降级 |
| tapeSize | 1e4 | 内存限 | 浏览器 1e5 |
| checkpointInterval | 1000 | 回滚点 | CPU 监控 |
| purityCheck | true | 验证无 glob 副作用 | Proxy trap |
此实现零样板:仅 DSL ops + 组合。相较 tagged AST,减少 50% 代码,支持多解释器(可视化、优化模拟)。
资料来源:javascript.tm 项目主页,Hacker News 相关讨论。[1]
(字数约 1200)