Go 版 Clojure 来了!let-go 让你在 Golang 里写 Lisp
标签: let-go / Clojure / Go / Lisp / 函数式编程 / 编译器 / 虚拟机
原文: 微信公众号「编程悟道」https://mp.weixin.qq.com/s/fbQMxAMu1xQzr1euyJvqmg
核心亮点
这个项目的作者在 2021 年干了一件特别"骚"的事:他用 Go 写了一个 Clojure 方言的编译器,还带了个栈虚拟机。
- 10MB 二进制文件,冷启动只要 7 毫秒,连 JVM 都不需要
- 通过了 jank-lang 测试套件 95.4% 的测试(4696/4921 个断言)
- Go + Clojure 双向互操作:在 10MB 的二进制里同时享受 Clojure 的函数式编程快感和 Go 的生态
- 支持 WASM:可以编译成自包含的网页,带终端模拟
- 甚至能在 Plan 9 上运行(你没看错,就是那个古老的 Plan 9 操作系统)
- 项目名"let-go":既是"let go"(放手)的谐音,也是"let"+"Go"的组合
一、问题:想写 Clojure,但讨厌 JVM?
Clojure 是一门非常优雅的 Lisp 方言,以其不可变数据结构、持久化数据结构、惰性序列、变换器(transducers)等特性著称。
但 Clojure 跑在 JVM 上,这就带来了问题:
- JVM 启动太慢:冷启动 363ms,内存占用 92MB
- 体积太大:JDK 本身就要 304MB
- 不是所有场景都适合:写个脚本还要装 JDK?
于是,有人开始做轻量级的 Clojure 实现:
| 实现 | 体积 | 启动时间 | 内存占用 |
|---|---|---|---|
| Babashka | 68MB | 18ms | 27MB |
| Joker | 26MB | 12ms | 22MB |
| Clojure JVM | 304MB(JDK) | 363ms | 92MB |
| let-go | ~10MB | 7ms | 13.5MB |
let-go 在"小而快"这个维度上,简直是降维打击。
二、let-go 是什么?
let-go 是一个 Clojure 方言,它有一个字节码编译器和栈虚拟机,全部用 Go 实现。
GitHub:https://github.com/nooga/let-go
作者:nooga
协议:MIT
主要语言:Go
作者在 README 里说得很坦诚:
"我 2021 年开始做这个,本来就是个精心设计的玩笑——一个借口,让我在写 Go 的时候也能写 Clojure。"
结果这个"玩笑"越做越大,现在变成了一个真正有用的工具。作者用它来写 CLI 工具、脚本、Web 服务器,甚至基于它构建了一个无守护进程的容器运行时(lgcr)。
你没看错,有人用这个"玩笑项目"搞了个容器运行时。
三、它能做什么?
let-go 支持的功能列表,看得我头皮发麻:
核心特性
- 持久化数据结构:Clojure 那套不可变数据结构的精髓
- 惰性序列:lazy seq,你懂的
- 变换器:transducers,函数式编程的瑞士军刀
- 协议和记录:protocols 和 records,Clojure 的面向对象
- 多重方法:multimethods,比 Java 的重载优雅一万倍
- core.async:Go 的 goroutine 风格并发,但用 Clojure 的语法写
- 大整数:BigInts,数学计算不怕溢出
Go 双向互操作
更重要的是,它能和 Go 双向互操作。你可以:
- 在 let-go 里调用 Go 的函数
- 操作 Go 的结构体
- 使用 Go 的 channel
这意味着什么?意味着你可以在一个 10MB 的二进制文件里,同时享受 Clojure 的函数式编程快感和 Go 的生态。
四、性能对比
来看一组真实的基准测试数据(Apple M1 Pro):
| 测试项 | let-go | Babashka | Joker | Clojure JVM |
|---|---|---|---|---|
| map/filter | 7.9ms | 21.5ms | 82ms | 15ms |
| 持久化 map 操作 | 20.8ms | 23.7ms | 95ms | 12ms |
| fib(35) | 2.08s | 2.15s | 8.9s | 0.8s |
| tak(18,12,6) | 1.2s | 1.3s | 5.1s | 0.5s |
结论:
- 在短生命周期的数据操作上,let-go 表现最好
- 在长时间运行的计算密集型任务上,JVM 的 HotSpot JIT 编译后能反超
- let-go 比 Babashka 快约 3 倍,比 Joker 快 10 倍以上(字节码 VM vs 树遍历解释器)
五、代码示例
看看它到底有多像 Clojure:
;; 定义一个函数
(defn greet [name]
(str "Hello, " name "!"))
;; 使用持久化数据结构
(def my-map {:name "Alice" :age 30 :city "Tokyo"})
;; 惰性序列
(def fibs
(lazy-cat [0 1] (map + fibs (rest fibs))))
;; 变换器
(def xf (comp (filter even?) (map inc)))
(transduce xf + (range 10))
;; core.async
(require '[clojure.core.async :as async])
(def ch (async/chan 10))
(async/go
(async/>! ch "hello"))
(async/<!! ch)
看到没?这就是 Clojure,纯正的 Clojure。不需要改任何语法,直接跑。
六、支持哪些标准库?
let-go 实现了 Clojure 的大部分标准库:
Clojure 标准库
- clojure.core:宏、解构、惰性序列、变换器、协议、记录、多重方法、原子、正则、元数据、大整数
- clojure.string:完整实现
- clojure.set:完整实现
- clojure.walk:prewalk、postwalk 等
- clojure.edn:read、read-string
- clojure.pprint:pprint、cl-format
- clojure.test:deftest、is、testing 等
- clojure.core.async:channel、go/go-loop、alts!等(用真正的 goroutine 实现)
自定义命名空间
- io:多态读写器、slurp/spit、惰性行序列、编码、URL、with-open
- http:Ring 风格的服务器+客户端、流式响应
- json:read-json、write-json(浮点数保留、记录感知)
- transit:transit+json 编解码器
- os:sh、stat、ls、cwd、getenv/setenv、exit 等
- System:JVM 风格的 getProperty、getenv、exit 等
- syscall:直接 Linux 系统调用(mount、unshare、mknod、prctl 等)
七、编译目标
let-go 的编译目标包括:
- 独立二进制文件:编译成可执行文件,直接运行
- WASM 网页:编译成自包含的 WASM 网页,带终端模拟
- Plan 9:甚至能在 Plan 9 上运行(你没看错,就是那个古老的 Plan 9 操作系统)
作者还计划实现:
- 浏览器中的 nREPL(let-go VM 跑在 WASM 里,编辑器通过 WebSocket 连接)
- let-go 字节码到 Go 的翻译
八、快速上手
# 安装 let-go
go install github.com/nooga/let-go@latest
# 运行一个 REPL
let-go
# 编译一个脚本到独立二进制
let-go compile my-script.lg -o my-script
# 编译到 WASM 网页
let-go compile my-script.lg --wasm -o my-script.html
九、为什么你应该关注这个项目?
- 如果你喜欢 Clojure 但讨厌 JVM:let-go 给了你一个轻量级的替代方案
- 如果你写 Go 但想试试函数式编程:let-go 让你在 Go 的生态里写 Clojure
- 如果你需要快速启动的脚本语言:7ms 冷启动,比 Python 都快
- 如果你对语言实现感兴趣:这个项目的代码结构清晰,是学习编译器实现的好材料
十、一些有趣的细节
- 项目名"let-go":既是"let go"(放手)的谐音,也是"let"+"Go"的组合
- 作者自称"loafers":λ-gophers 的谐音,λ 代表 lambda 演算,gophers 是 Go 社区的昵称
- 项目 Logo:一只穿着拖鞋的可爱生物,作者叫它"Squishy loafer"
十一、已知的限制
let-go 不是 JVM Clojure 的即插即用替代品。它不能加载 JAR 文件,也不打算支持。大部分惯用的 Clojure 代码可以不加修改地运行,但一个依赖真实库的项目可能需要调整。
具体来说:
- 不支持 Java 互操作(废话,没有 JVM)
- 不支持 AOT 编译到 JVM 字节码
- 一些数值边界情况(溢出检测、BigInt 提升等)可能不同
写在最后
let-go 这个项目,让我想起了那句话:"最好的玩笑,往往是最认真的。"
作者 nooga 用一个"玩笑"开始的项目,现在变成了一个功能完整、性能出色、实用性强的 Clojure 方言实现。它证明了:有时候,最好的创新来自于"不务正业"的探索。
如果你对函数式编程感兴趣,或者想在你的 Go 项目里引入一些 Clojure 的优雅,let-go 绝对值得一试。
毕竟,10MB 的二进制、7ms 的冷启动、95.4% 的测试通过率——这已经不是一个玩具了。
本文整理自微信公众号「编程悟道」,原文链接:https://mp.weixin.qq.com/s/fbQMxAMu1xQzr1euyJvqmg