技术札记

从 Flow 哲学到 C Runtime:我如何从零实现 HHY Language

复盘 HHY Language v1.0 的完整开发过程:为什么以数据流为核心,Lexer、Parser、AST、语义检查与 Runtime 如何协作,以及一门语言怎样通过资源边界、测试和规范真正成为可用的工程系统。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

从 Flow 哲学到 C Runtime:我如何从零实现 HHY Language

houhuiyang.com/zh/notes/building-hhy-language-from-flow-philosophy-to-runtime

过去一段时间,我用 C 从零实现了一门系统脚本语言:HHY Language

它不是把几条 Shell 命令换一种写法,也不是给自然语言套一层执行器。HHY 有自己的 Token、Grammar、AST、作用域、语义检查、运行时值模型、惰性 Stream、结构化错误和资源限制。源码从进入 Lexer 开始,到最终打开文件、启动进程或发送 HTTP 请求,每一步都有确定行为。

HHY 的定位很简单:

Pipe Everything. 让文件、进程、网络和结构化数据进入同一种 Flow。

path("./logs")
    |> files("**/*.log")
    |> flat_map { file -> read_lines(file.path) }
    |> where { line -> contains(line, "ERROR") }
    |> take(100)
    |> save_lines(path("errors.txt"))

这篇文章不只介绍语法。我更想复盘一门语言从想法走向 v1.0 时,哪些技术真正重要,以及我做过哪些有意识的取舍。

我为什么还要做一门语言

系统自动化并不缺工具。Shell 适合连接进程,Python 适合写完整程序,jqawkcurl 又各自解决一类问题。问题是,当任务同时涉及目录遍历、文本流、JSON、HTTP、并发和错误处理时,思维会在多套模型之间切换。

我想验证的是:能否把这些对象统一成数据源、变换和动作,让程序主要描述“数据如何流动”,而不是“每一步如何调度”。

source |> transform |> filter |> action

因此,|> 在 HHY 里不是为了好看。它是语言、标准库和 Runtime 共同遵守的组合边界:

x |> f           => f(x)
x |> f(a, b)     => f(x, a, b)
x |> obj.f(a)    => obj.f(x, a)

一旦这条规则稳定,文件、进程、HTTP 请求和普通集合就可以使用同一种表达方式。语言哲学只有进入可执行语义,才不只是品牌文案。

先冻结语义,再扩展功能

造语言最容易陷入的误区,是不断增加关键字和标准库函数。真正困难的是回答那些不显眼的问题:换行什么时候结束语句?/ 是除法还是正则?Map 和代码块都使用 {} 时如何消歧?管道是否自动展开 Stream?并发是否保序?错误发生后,上游资源由谁关闭?

我最终把 docs/HHY_V1.md 设为唯一规范来源。v1.0 先冻结一组基础决策:动态类型、变量默认不可变、显式闭包、普通调用统一使用括号、Stream 默认惰性且单次消费、普通错误 fail-fast、HTTP 构造请求后由 send 产生副作用、parallel 有界并发并默认保序。

这个顺序很重要:规范不是代码完成后的说明,而是代码修改的前置约束。 如果实验实现与规范冲突,先决定要不要改变语言,再修改规范和测试,最后改代码。

一段源码如何变成可执行行为

HHY 当前采用直接解释执行,但它仍然拥有一条完整的语言前端:

UTF-8 Source
  → Lexer / Token
  → Parser / AST
  → Checker / Scope & Contract
  → Runtime / Value & Environment
  → Stream / System Effect

每一层只解决自己的问题。Lexer 不判断变量是否存在;Parser 不打开文件;Checker 不执行 HTTP;Runtime 不重新猜运算符优先级。边界越清楚,错误越容易定位,语言也越容易演进。

Lexer:先把字符变成带位置的事实

Lexer 的工作是把 UTF-8 源码切分成 Token。HHY 的每个 Token 都保留类型、原始切片、行号和列号。这些位置信息不是调试附属品,而是后续 Parser、Checker 和 Runtime 能给出可用错误的基础。

词法层需要处理不少上下文。例如 /ERROR/itotal / count 都包含 /。HHY 根据当前位置是否允许结束一个表达式判断它是正则起点还是除法运算符。10mib500ms80% 也在 Lexer 中成为 Bytes、Duration 和 Percent,而不是先读成字符串再交给库函数猜测。

我还在 Lexer 入口验证 UTF-8,严格限制字符串转义,并让换行成为明确 Token。因为 HHY 允许跨行管道,换行是否结束语句不能靠简单地丢弃空白来决定。

最佳实践是:Token 必须保存足够多的源码事实,但不要提前承担语义。 如果 Lexer 开始理解作用域或函数签名,后面的层次就会失去清晰边界。

Parser:把优先级和上下文写进结构

Parser 使用递归下降解析表达式,并按照优先级逐层构造节点:后缀调用与成员访问、单目运算、乘除、加减、比较、逻辑、空值合并、管道与赋值。

递归下降的价值不是“实现简单”,而是每条语法规则都能对应一段可读代码。Map 与 Block 都使用 {},但它们出现的语法位置不同;闭包也由调用阶段的上下文识别。与其在 Lexer 中发明三种左花括号,不如让 Parser 在已经知道当前规则的地方做决定。

Parser 还必须面对错误恢复。遇到缺失表达式后如果立即退出,用户一次只能修一个错误;如果盲目继续,又会产生大量级联报错。HHY 在声明、控制流、换行和分号等安全边界进行同步,让一次 hhy check 能报告多个真实问题,同时避免把后续合法代码误解成当前表达式。

AST 为什么重要

AST(抽象语法树)是源码表面形式与执行语义之间最关键的中间表示。它去掉无关标点,却保留程序真正的结构。

例如:

processes
    |> where { process -> process.memory > 1gb }
    |> take(10)
    |> print

会形成近似这样的结构:

Pipe
├── Pipe
│   ├── Pipe
│   │   ├── Identifier "processes"
│   │   └── Call "where"
│   │       └── Closure
│   │           └── Binary ">"
│   └── Call "take"
│       └── Literal "10"
└── Identifier "print"

HHY 的 AST 节点包含 kind、来源 Token 和子节点。节点类型覆盖声明、控制流、调用、成员访问、闭包、Pipe、List、Map 和 Literal。来源 Token 会一直保留到 Runtime,因此运行时错误也能指回用户写下的具体位置。

AST 的价值至少有四个:

  1. Parser 只负责“这段代码是什么结构”;
  2. Checker 可以在不执行副作用的情况下遍历程序;
  3. Runtime 面向稳定节点执行,不必重复解析文本;
  4. hhy ast、Formatter、测试快照和未来的优化器可以共享同一表示。

好的 AST 不应该机械复制所有标点,也不应过早降低成只适合某一种 Runtime 的指令。它要足够抽象,同时保留诊断与工具链需要的源码关联。

Checker:动态语言也需要执行前的确定性

HHY 是动态类型语言,但“动态”不等于所有错误都等到执行时发生。Checker 在运行前处理可以静态确认的规则:未定义名称、同一作用域重复绑定、不可变变量赋值、函数参数数量、函数外 return、循环外 break、模块循环依赖和不存在的导出。

Checker 还承担并发边界。parallel 的闭包不能捕获可变绑定或单次消费 Stream,因为这些值不能安全地跨 Worker 共享。把这类问题放到执行前,比在线程已经启动后产生数据竞争更可靠。

标准库函数并不是只注册一个 C 函数指针。HHY 为每个 callable 保存 contract:参数数量、副作用类型、是否惰性、是否支持取消、值能否跨线程发送、输入输出约束和执行线程。这份 contract 同时服务 Checker、Runtime、--dry-run 执行计划和未来扩展协议。

这里的原则是:能在执行副作用之前证明的问题,就不要留给 Runtime。

Runtime:语言真正开始承担后果的地方

Runtime 负责值、环境、函数调用、错误传播和系统资源。HHY 的值不仅有 Null、Bool、Int、Float、String、List 和 Map,还包括 Regex、Bytes、Duration、Percent、Path、File、Process、HttpRequest、DateTime、Function 与 Stream。

Pipe 节点执行时,Runtime 先求左值,再把它注入右侧 callable。这个实现保持了语法规则与运行规则的一致,而不是为每个标准库算子写特殊语法。

系统脚本语言最重要的不是算出 1 + 1,而是如何处理后果:文件是否关闭、进程超时后是否回收、HTTP 是否验证 TLS、取消能否沿 Stream 传播、部分写入失败后会不会留下损坏文件。

HHY 的语言堆使用受限的保守追踪 GC,但文件、进程和网络句柄显式关闭。GC 解决“语言对象何时不可达”,不能替代“操作系统资源何时必须释放”。这两类生命周期如果混在一起,短脚本可能看不出问题,长 Flow 和失败路径一定会暴露。

Stream:惰性不是性能开关,而是执行协议

HHY 的 Stream 是拉取式、惰性、单次消费的。wheremaptake 不会立即把全部输入装进内存,而是等下游请求下一项时逐级拉取。

sink.next()
  → take.next()
  → where.next()
  → files.next()
  → one value returns downstream

这带来三个直接结果:大文件和目录不必一次加载;take(10) 可以让上游提前停止;取消和错误能够沿链路关闭资源。

但惰性也需要明确边界。sort_bygroup_by 和某些 reduce 必须形成屏障并缓存输入,因此 Runtime 会受到内存和记录数量限制。parallel 也不是无限创建线程:它有最大并发、有限缓冲、保序输出、fail-fast 和取消清理。

我越来越认同一个判断:并发 API 的关键不是如何开始,而是如何停止。 早停、错误、Ctrl-C 和超时路径如果没有统一 unwind,正常路径跑得再快也不算可靠。

显式副作用与安全默认值

HHY 把“描述动作”和“执行动作”尽量分开:http.get 构造 HttpRequest,send 才访问网络;run 接收参数数组且默认不经过 Shell,只有显式 shell 才启用 Shell 语义;文件保存优先使用原子写入;Runtime 对内存、打开文件数、进程数、并行度、HTTP Body、正则步骤、递归深度和总运行时间设置上限。

hhy run --dry-run 会输出脱敏执行计划而不执行外部副作用。它依赖的正是 AST 和 callable contract,而不是字符串搜索。只有语言从一开始就对副作用建模,这种能力才可能可信。

测试一门语言,不能只测正确输入

语言实现最危险的部分往往在失败路径。因此 HHY 的测试不只包含“能够运行”的示例,还包含:

“文档可执行”是我认为非常重要的一条实践。示例如果不进入 CI,随着语言演进,它迟早会变成看起来合理、实际上已经不能运行的旧语法。

我刻意没有做什么

v1.0 没有 JIT,没有静态类型系统,也没有开放 Native ABI。第三方扩展边界已经预留,但 Runtime 不在首个稳定版里加载未知扩展。

这些不是遗漏,而是范围控制。一门语言的可信度来自少量承诺被完整实现,而不是功能列表足够长。直接解释执行让我先把语义、错误、资源和跨平台行为做实;动态类型符合系统脚本的反馈速度;不冻结 Native ABI 则避免内部结构还在演进时背上长期兼容债务。

这次造语言带给我的判断

实现一门语言让我重新理解“抽象”的含义。抽象不是给代码换一个更漂亮的名字,而是建立一组跨层一致的约束。

|> 只有在 Parser 能正确结合、AST 能准确表达、Checker 能验证 callable、Runtime 能传播取消、标准库能遵守惰性协议时,才真正成为 Flow-first。否则它只是一枚运算符。

AST 很重要,但 AST 不是终点;Parser 很重要,但能解析不代表能可靠执行;Runtime 很重要,但没有规范和测试的 Runtime 只是当前实现的偶然行为。

我最终把一门可用语言理解为:

语言 = 语法 + 语义 + 执行模型 + 副作用边界 + 诊断 + 工具链 + 兼容承诺

HHY v1.0 还很年轻,但它已经能用同一种 Flow 思维处理文件、进程、网络与结构化数据,并且能够解释自己将做什么、在哪里失败、如何停止和怎样释放资源。这比“又发明了一种语法”更接近我真正想完成的事情。

项目

返回技术札记