- 示例工程
【免费下载链接】mal
mal - Make a Lisp
mal(Make a Lisp)是一个用数十种语言逐步实现 Lisp 解释器的教学项目。在编写step0到stepA的过程中,实现者常会在"获取毫秒时间戳""语言没有函数指针""没有标准输入输出"等具体问题上卡壳。docs/Hints.md 正是为这些高频难点准备的答疑文档:它不讲解语法规范,而是围绕六个真实实现问题给出解决思路、兜底方案与源码示例。本文以该文档为主体,结合仓库中 bash、make、vimscript 等实现的实际源码与测试用例,逐条展开这些难点的背景、方案与可验证的落地细节,帮助读者在自研 mal 时少走弯路。
一、Hints.md 在 mal 项目中的定位
在整个仓库中,docs/Hints.md承担的是"实现 FAQ"角色,与 docs/FAQ.md、docs/notes.md 等文档互补。它的内容组织方式非常直接:以问题开头的标题(如 "How do I get milliseconds since epoch for the time-ms function?"),紧跟一段包含方案与代码的解答,部分问题还附带了指向具体实现的锚点(<a name="...">)。
文档覆盖的六个主题分别是:
| 主题 | 对应问题 | 文档状态 |
|---|---|---|
time-ms时间戳 | 如何获取自纪元以来的毫秒数 | 已解答 |
| 核心函数实现 | 语言没有函数引用时如何实现 | 已解答 |
| 终端 I/O | 没有标准 I/O 能力时如何输入输出 | 已解答 |
| 命令行参数 | 运行时不支持读取参数时怎么办 | 已解答 |
| Reader 位置跟踪 | 如何不使用可变对象实现 reader | 已解答 |
slurp/ 异常对象 | 无法读原始文件、无法抛出任意对象 | TBD(未定稿) |
其中前五个问题已给出完整方案,最后一个问题的答案仍标记为 "TBD"。本文会按此骨架展开,并在各节补充仓库中的真实实现作为佐证。
二、time-ms:如何获取自纪元以来的毫秒时间戳
2.1 功能背景与测试要求
time-ms是 mal 核心库(stepA)中的一个原生函数,其作用是为计时与基准测试(benchmark)提供当前时间。在 tests/stepA_mal.mal 中可以看到它的实际用法:
;; Testing time-ms function (def! start-time (time-ms)) (= start-time 0) ;=>false (sumdown 10) ; Waste some time ;=>55 (> (time-ms) start-time) ;=>true从测试可以看出三条硬性要求:调用time-ms得到的值必须非零、两次调用之间时间必须单调递增、返回值需足以支撑计时差值计算。这也是 Hints 文档要求"准确毫秒"的原因——若精度不足,(> (time-ms) start-time)这类比较在快速执行时可能无法区分。
2.2 首选:语言原生能力
Hints 文档明确指出,绝大多数语言都提供某种获取毫秒时间戳的原生手段,只是可能藏得比较深(例如需要从某个底层库或系统调用中挖掘)。建议优先在本语言的文档、Stack Overflow 或社区讨论渠道中查找;只有当确认目标语言确实没有原生途径时,才考虑兜底方案。
2.3 兜底方案:shell 出调用date
Hints 给出的最后手段是调用系统date命令:
date +%s%3N%s输出自 Unix 纪元(1970-01-01 00:00:00 UTC)以来的秒数,%3N输出当前秒的毫秒部分(不足三位补零),两者拼接即得到完整的毫秒时间戳。该方案局限于 Linux/UNIX 环境,且要求运行时能够发起子进程(shell out)。
2.4 仓库实战:bash 与 make 的实现
Hints 文档提到 bash 和 make 两个实现"很可能"必须使用上述方法。对照源码可以确认这一点:
- bash 实现impls/bash/core.sh:
# return number of milliseconds since epoch time_ms () { local ms=$(date +%s%3N) _number "${ms}" }并在核心函数注册表中以[time-ms]=time_ms(impls/bash/core.sh)映射到该函数。
- make 实现impls/make/core.mk:
time-ms = $(call _number,$(shell date +%s%3N))make 实现本身没有运行时概念,完全依赖$(shell ...)在展开阶段调用外部命令,这与 Hints 文档"shell 出调用"的描述完全一致。
- 额外发现:chuck 实现(impls/chuck/types/subr/MalTimeMs.ck)也走了类似路线——通过
Std.system("date +%s%3N > " + temp_file)将时间戳写入临时文件再读回。这说明"用date +%s%3N兜底"在仓库中确实是一个被多次验证的通用方案。
2.5time-ms的语义弹性:不必严格是纪元时间
Hints 文档特别强调了一个容易被忽略的放宽条件:time-ms技术上只需要返回"自某个任意基准点(甚至程序启动时)以来的毫秒数"即可正确用于计时与基准测试。返回自纪元以来的毫秒数只是为了各实现之间结果一致、便于调试,并非硬性要求;当语言存在整数大小限制(例如无法表示当前毫秒时间戳的量级)时,从程序启动时起算同样可以满足测试。
从测试的角度看,这一放宽是合理的:(= start-time 0)要求非零、(> (time-ms) start-time)要求单调递增,这两点对"任意基准点"方案同样成立。
三、语言没有函数引用时,如何实现核心/原生函数
3.1 问题本质与"大 switch"方案
部分极简语言不提供函数指针、闭包或 lambda 等一等函数(first-class function)抽象。Hints 文档给出的核心思路是:如果语言没有函数引用,就自己造一个——实现一个"总调度"函数,内部用大 switch(或等价的分发机制)根据标记调用对应的原生实现(+、list、throw等),从而在语言特性之外"模拟"出函数引用。
以 mal 的典型实现为例,核心函数会注册进 REPL 基础环境(base environment),例如 bash 实现中所有核心函数都登记在一个关联数组里:
[+]=num_plus [-]=num_minus [list]=_list [hash-map]=_hash_map [time-ms]=time_ms(见 impls/bash/core.sh 附近的核心表。)在无函数引用的语言里,这些表项无法直接指向函数,需要改为某种标记值,并让EVAL在遇到"函数调用"时识别该标记,转而去调用那个"大 switch"函数完成分发。
3.2 标记机制与 EVAL 协作
Hints 明确指出,除了存储符号名之外,还必须有一种标签或标记,用来告诉EVAL:这个对象是"应通过大 switch 分发的核心函数",而不是普通数据。这一设计与 mal 一贯的"类型标签 + 统一求值"架构吻合——从仓库中任意一种实现(如 impls/bash/step9_try.sh 的EVAL中对defmacro!、try*、if、do等特殊形式的分发)都可以看到,mal 的EVAL本身就是围绕"根据形式标签走不同分支"组织的。
3.3 step4 提前引入 step5 的函数表示
文档还给出了一个进阶建议:如果语言连闭包/匿名函数都没有(注意:足够丰富的面向对象特性通常可以模拟出闭包),那么在 step4(if/fn*/do)阶段就应当提前借用 step5 的函数实现方式——把函数做成普通数据类型,存储三样东西:
- 函数体(AST);
- 参数列表;
- 定义函数时的环境(环境引用)。
当函数被调用时,EVAL直接求值这些存储的字段,而不是调用语言层面的闭包。这样做的代价是 step4 稍微麻烦一点,但收益是 step5 只剩一个 TCO(尾调用优化)循环要实现——因为函数存储方式已经在 step4 重构完成,无需推倒重来。
四、没有标准 I/O 时,如何实现终端输入输出
4.1 三种合法变通路径
如果目标语言在运行期间能以某种方式取得输入输出(即便不是标准的终端/文件 I/O),Hints 文档给出了三条可接受的路径:
- 编写 wrapper 脚本:在语言程序外面包一层宿主脚本,负责搬运数据;
- 调用外部 shell 脚本:由语言运行时发起 shell 调用完成 I/O;
- 其他"合适"的 hack:只要能配合测试运行器(test runner)工作、且 hack 仅用于绕过语言 I/O 限制,就视为可被上游接受。
仓库根部存在测试驱动脚本 runtest.py 与各实现目录下的run启动脚本,任何绕行方案最终都必须保证能通过这套测试流程。
4.2 仓库实战:vimscript 的 wrapper 脚本
Hints 文档点名了 impls/vimscript/run_vimscript.sh。该脚本以 Vim 的 ex 模式(-e)运行,让脚本在启动时执行、以qall!结束,从而避免真正启动 Vim 界面:
exec vim -i NONE -V1 -nNesS $vimscriptfile -- "$@" | cat它通过标准输出(| cat)把 Vim 脚本产生的内容导出到终端,同时-- "$@"把命令行参数透传给 Vim。这就是典型的"wrapper 脚本 + 非标准 I/O 通道"组合:vimscript 本身无法直接做常规终端读写,但借助 Vim 的缓冲区与脚本执行机制,加上 shell 层的重定向,完成了 mal REPL 的输入输出。
4.3 仓库实战:make 实现调用 shell readline
Hints 文档提到的make/readline.mk与make/util.mk在仓库中位于 impls/make/readline.mk 与 impls/make/util.mk。make 没有任何运行时 I/O 能力,其 REPL 完全建立在$(shell ...)之上。
以 readline 为例,impls/make/readline.mk 内嵌了一段 bash 命令:先用history -r载入历史文件,再用read -u 0 -r -e -p交互读取一行输入,history -s记录后输出$line ok,最后history -a写回历史(历史文件默认为$HOME/.mal-history)。由于每次$(shell ...)都是独立 shell 实例,它通过"每次调用前后保存/恢复历史文件"来模拟出跨调用的 readline 历史行为。
配套的 impls/make/util.mk 则负责解决 make 与 shell 之间的数据交换:它定义了str_encode_nospace/str_decode_nospace(用«、»、§、¶等 Unicode 字符对空格、括号、换行等特殊字符做编码,避免 make 的空白分隔与$(...)展开破坏数据),以及_read_file(用sed -z 's/\n/$(_NL)/g'读取整个文件并把换行替换为编码字符)和print(基于$(info ...)输出)。这套编码体系是 make 实现能在"无 I/O"前提下完成 slurp、readline、println 等全部核心功能的关键。
五、语言运行时不支持访问命令行参数时怎么办
5.1 通用方案:环境变量或临时文件
绝大多数语言都能通过main的参数(如 C 的argc/argv)或全局变量(如 Python 的sys.argv)访问命令行参数。若目标语言两者皆无,Hints 建议编写 wrapper 脚本:由脚本读取用户传入的命令行参数,再以目标语言可读的方式传给程序——通常是通过环境变量,或写入临时文件。
5.2 仓库实战:vimscript 通过argv()访问
vimscript 本身有argv()函数可返回命令行参数,仓库中 step6 起的实现正是这样用的,例如 impls/vimscript/step6_file.vim:
return ListNew(map(copy(argv()[1:]), {_, arg -> StringNew(arg)}))以及启动时加载首个参数作为脚本文件:
if !empty(argv()) call RE('(load-file "' . argv(0) . '")', repl_env)(见 impls/vimscript/step6_file.vim。)这里argv()与 wrapper 脚本run_vimscript.sh中的-- "$@"配合:shell 把参数透传给 Vim,Vim 的argv()再取回。该模式在 impls/vimscript/step7_quote.vim、impls/vimscript/step8_macros.vim、impls/vimscript/step9_try.vim、impls/vimscript/stepA_mal.vim 中保持一致,形成稳定的"wrapper 脚本桥接参数"套路。
六、Reader 不使用可变对象的位置跟踪
6.1 思路:传递位置而非修改对象
mal 的读取器(reader)通常需要跟踪 token 列表中的当前位置。Hints 明确回答:并不需要一个可变对象,只需要某种方式记录当前位置。推荐的实现方式是函数式地传递状态——把 token 列表和当前位置一起传给 reader 函数(read_form、read_list、read_atom等),解析完成后同时返回解析出的 AST 和新的位置。
6.2 伪代码与多返回值处理
文档给出的伪代码是:
ast, position = read_list(tokens, position)如果语言不支持函数返回多个值,则需要定义一个数据结构同时携带"新位置"与"解析出的 AST"两个字段。这一方案让 reader 全程无副作用,也天然契合 step2 之后EVAL对不可变数据的处理方式;对于支持元组/解构的语言(如 Python、Ruby、Go),实现起来最为直白。
七、尚未定稿的两个问题与仓库中的参考实现
Hints 文档末尾将以下两个问题标记为 "TBD"(待解答),但仓库中已有实现可作参考:
7.1 无原始文件读取能力时如何实现 slurp
该问题答案仍为 TBD,但可以观察现有实现的做法:
- bash:impls/bash/core.sh 用
mapfile lines < file把文件所有行读入数组,再拼接为带换行的字符串,封装成 mal 字符串对象返回。 - make:impls/make/util.mk 的
_read_file用sed -z一次性读完整文件,并把换行替换为编码字符¶(_NL),从而绕开 make 无法直接处理多行文本的限制。 - 两种做法共同点:都不依赖语言原生"读取原始文件"的 API,而是借助 shell 工具链完成,与本文第四节的 I/O 变通思路一脉相承。
7.2 只支持字符串异常的语言如何抛出任意对象
该问题同样标为 TBD,但 bash 实现的try*/catch*提供了一个参考:它并不把异常对象作为语言级异常抛出,而是用全局错误标记传播。在 impls/bash/step9_try.sh 中可以看到,try*分支先求值受保护表达式,若全局__ERROR被置位,则检查子句首项是否为catch*;命中后把错误值绑定到 catch 的参数并求值处理分支,若没有catch*子句则原样传播__ERROR。这种方式让"只能抛字符串"的语言也能以"全局标记 + 对象引用"的变通形式传递任意 mal 对象。
结语
docs/Hints.md看似只有寥寥几个问答,却浓缩了 mal 实现中最容易卡壳的五类工程问题:时间精度、一等函数缺失、I/O 缺失、参数访问缺失与 Reader 的可变状态。对照仓库源码可以看到,每一类问题在 bash、make、vimscript 等"能力受限"的实现中都演化出了具体且可验证的解法——shell 出调用、wrapper 脚本、编码协议、全局标记等。无论读者是准备提交一个新的语言实现,还是想理解现有实现的底层机制,本文梳理的这些问题、方案与源码路径(impls/bash/core.sh、impls/make/core.mk、impls/make/util.mk、impls/vimscript/run_vimscript.sh 以及 tests/stepA_mal.mal 中的对应测试)都可以作为直接的参考起点。
- 示例工程
【免费下载链接】mal
mal - Make a Lisp
相关推荐
攻克时间差计算难题:TiKV timestampdiff函数实现全解析
攻克时间差计算难题:TiKV timestampdiff函数实现全解析 你是否还在为分布式数据库中的时间差计算头疼?当需要精确计算两个时间戳之间的年、月、日甚至
数据库KV存储分布式数据库云原生终极指南:5步掌握yuzu模拟器,在PC上畅玩Switch游戏
终极指南:5步掌握yuzu模拟器,在PC上畅玩Switch游戏 yuzu模拟器是一款功能强大的开源Nintendo Switch模拟器,让你能够在Windows
图形学游戏开发跨平台音频开发新范式:用libsoundio攻克实时I/O痛点
跨平台音频开发新范式:用libsoundio攻克实时I/O痛点 你是否还在为音频应用的跨平台兼容性头疼?还在为不同系统的音频API差异焦头烂额?本文将带你全面掌
音视频
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考