攻克 mal 实现难点:Hints 指南中的时间戳、函数引用、I/O 与 Reader 设计
2026/9/24 0:40:12 网站建设 项目流程
  • 示例工程

【免费下载链接】mal

mal - Make a Lisp

项目地址:https://gitcode.com/gh_mirrors/ma/mal
点击查看免费下载

mal(Make a Lisp)是一个用数十种语言逐步实现 Lisp 解释器的教学项目。在编写step0stepA的过程中,实现者常会在"获取毫秒时间戳""语言没有函数指针""没有标准输入输出"等具体问题上卡壳。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(或等价的分发机制)根据标记调用对应的原生实现(+listthrow等),从而在语言特性之外"模拟"出函数引用。

以 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*ifdo等特殊形式的分发)都可以看到,mal 的EVAL本身就是围绕"根据形式标签走不同分支"组织的。

3.3 step4 提前引入 step5 的函数表示

文档还给出了一个进阶建议:如果语言连闭包/匿名函数都没有(注意:足够丰富的面向对象特性通常可以模拟出闭包),那么在 step4(if/fn*/do)阶段就应当提前借用 step5 的函数实现方式——把函数做成普通数据类型,存储三样东西:

  1. 函数体(AST);
  2. 参数列表;
  3. 定义函数时的环境(环境引用)。

当函数被调用时,EVAL直接求值这些存储的字段,而不是调用语言层面的闭包。这样做的代价是 step4 稍微麻烦一点,但收益是 step5 只剩一个 TCO(尾调用优化)循环要实现——因为函数存储方式已经在 step4 重构完成,无需推倒重来。

四、没有标准 I/O 时,如何实现终端输入输出

4.1 三种合法变通路径

如果目标语言在运行期间能以某种方式取得输入输出(即便不是标准的终端/文件 I/O),Hints 文档给出了三条可接受的路径:

  1. 编写 wrapper 脚本:在语言程序外面包一层宿主脚本,负责搬运数据;
  2. 调用外部 shell 脚本:由语言运行时发起 shell 调用完成 I/O;
  3. 其他"合适"的 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.mkmake/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_formread_listread_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_filesed -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

项目地址:https://gitcode.com/gh_mirrors/ma/mal
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询