执行摘要:我们习以为常的软件工程基建——文件系统路径、模块名、构建产物、依赖版本、Git 三方合并——本质上都在弥补同一个缺陷:源码的文本身份与语义身份是两套东西。重命名一个函数,语义未变,文本却全变了;改一行注释,文本变了,语义未变。于是我们需要构建系统去重建语义、需要锁文件去钉住版本、需要 CI 去反复验证。Unison 的做法是把这个前提直接删掉:代码的身份就是它被规范化之后的 AST 哈希。本文拆解这条设计带来的连锁反应——内容寻址代码库怎么组织、为什么可以做到"无构建"、为什么任意闭包都能安全地跨节点序列化、代数效应(Abilities)如何把副作用写进类型签名、以及这套模型在工程上真实要付的代价。


一、身份问题:从"文本 + 路径"到"语义哈希"

传统语言里,一个函数的身份由三件事共同决定:所在文件路径、函数名、以及调用它时链接器解析到的那份二进制。三者任何一处不一致,就会得到"在我机器上能跑"这类问题。

Unison 把身份收敛成一个值:

-- 类型签名
structural type Pet = Dog | Cat | Bird

greet : Pet -> Text
greet = cases
  Dog -> "woof"
  Cat -> "meow"
  Bird -> "tweet"

这段代码存进代码库时,编译器先做规范化(canonicalization):变量名被替换为 De Bruijn 式的绑定编号,类型被解析为对哈希的引用而非对名字的引用,字面量与注解统一编码,然后整体做一次哈希。greet 得到的名字类似 #a8f3k9d2q1,与它叫 greet 还是 sayHello、缩进是两空格还是四空格、有没有注释完全无关。

由此得到几个近乎"免费"的性质:

  • 重命名是零操作。改名字只动别名表(metadata),不动定义本身,因此不需要改调用点、不需要重构工具、不会产生巨型 diff。
  • 重复定义自动合并。两个人写了逻辑完全相同的函数,哈希相同,天然是同一个定义。
  • 依赖是精确的。一个定义依赖的是"哈希集合",不是"某个包的某个版本范围"。
# ucm(Unison Code Manager)里看依赖
.> dependents #a8f3k9d2q1     # 谁用了它
.> dependencies #a8f3k9d2q1   # 它用了谁
.> view #a8f3k9d2q1

这已经不是"更好的 grep",而是精确语义索引:你可以在百万行代码里问"所有写入数据库的调用点",而且答案由类型系统保证不漏。


二、结构类型与唯一类型:哈希语义的两个档位

Unison 提供了两档哈希策略,这是理解它演化能力的关键:

structural type Pet = Dog | Cat | Bird          -- 按结构哈希
unique type UserId = UserId Nat                 -- 带 GUID,按身份哈希

structural 类型的哈希只由它的结构决定。好处是跨代码库天然兼容——你在 A 项目里定义的 Pet 和我在 B 项目里定义的 Pet,只要是同样结构,就是同一个类型,可以直接互相传值,不需要共享的"公共依赖包"来对齐。代价是:一旦你给 Pet 加一个构造子,哈希就变了,它就是另一个类型,旧数据不再属于它。

unique 类型则带一个生成时写死的 GUID,哈希不随结构变化。于是你改名、加字段、调整顺序,类型身份保持稳定——这正是需要长期持久化数据、需要 schema 演化的场景所要的。

工程上的判断很直接:跨进程边界传输的 DTO 用 structural;落库、落日志、需要长期演进的领域实体用 unique。 选错档位的代价是后期一次痛苦的迁移,和选错数据库主键性质是同一类错误。


三、无构建:类型检查即缓存

因为身份是语义哈希,"这个依赖变没变"这个问题变成了 O(1) 的哈希比较。Unison 因此不需要传统构建系统:

  1. 你写入一个定义 → 类型检查 → 结果以哈希为 key 落盘(对象以哈希命名存放,名称与分支元数据走 SQLite 索引)。
  2. 下次任何引用它的定义被检查时,直接从代码库取回已检查过的 AST 与类型,不需要重新解析、重新编译依赖链。
  3. 增量检查的粒度是单个定义,不是文件、不是模块、不是 crate。

对比一下 Bazel / Buck2 那套:它们要做的事是"在文件系统的文本世界里,用 action key 和缓存,尽量逼近语义级别的不重复构建"。Unison 是让这个问题不存在——语言层面就已经是内容寻址了,构建系统没有用武之地。

这也顺带解决了 CI 里最贵的一类开销:依赖升级后"到底要重编译多少"。在 Unison 里答案是精确的,只重算哈希真正变了的定义闭包。


四、代码即数据:为什么任意闭包都能安全跨节点

这是内容寻址最被低估的红利。传统分布式计算里,"把一段代码发到远端执行"是高危操作:你要么发源码(远端要有一模一样的编译器和依赖树),要么发字节码(要对齐 classpath 与版本),要么发容器镜像(重且慢)。

在 Unison 里,一个值或一个函数就是一棵以哈希为根的树。序列化 = 把根哈希发出去,接收方发现本地缺少某些子树,就按哈希按需拉取(pull),拉到即完整——因为哈希就是内容本身,不存在"版本对不上"。

-- 示意:把计算提交到远端执行
distributedAggregate : [Job] ->{Remote, Log, Exception} Result
distributedAggregate jobs =
  jobs
    |> List.map (job -> Remote.fork here (runJob job))   -- 在这里 fork
    |> Remote.await                                       -- 在这里收
    |> List.foldLeft (acc r -> mergeResult acc r) emptyResult

Remote.fork 能把一个尚未求值的计算(thunk)发到另一个节点。它不需要你预先部署二进制、不需要保证两边跑同一份 release——节点之间只交换缺失的定义子树。这让"fan-out 聚合"这类模式的开发体验接近写本地 map,而运维复杂度接近调一个 RPC。

structural 类型在这里也省掉了共享 DTO 包的脏活:两端只要结构一致,类型就一致,数据就能传。没有 .proto,没有代码生成步骤,没有"谁是 schema 的 owner"。


五、Abilities:把副作用钉进类型

Unison 用代数效应(algebraic effects)表达副作用,语言里叫 Abilities。一个函数能干什么,写在签名里:

-- 纯函数,没有 ability 标注
parseConfig : Text -> Either Error Config

-- 需要读文件、需要抛异常
loadConfig : FilePath ->{IO, Exception} Config

-- 需要访问远端
fetchUser : UserId ->{Remote} Optional User

{IO, Exception} 不是注释,是类型的一部分:调用 loadConfig 的函数自身必须也声明这些 ability,或者提供一个 handler 把它们消掉。这带来两个直接收益:

  1. 可测试性:测试时用一个把 Store 消掉的 handler,替换成内存 Map,被测代码一行不改。不需要 mock 框架、不需要依赖注入容器,因为"handler 是什么"是语言级的。
  2. 可审计性:哪些代码能碰网络、能碰磁盘,静态可见。这在 AI Agent 沙箱、多租户代码执行这类场景里,比运行时 seccomp 过滤更早、也更便宜。

自定义 ability 与 handler 的骨架(语法为示意,细节以官方文档为准):

ability Store where
  get : k ->{Store} Optional v
  put : k -> v ->{Store} ()

-- handler:把 Store 解释成内存 Map
Store.inMemory : Map k v -> '{Store, g} a ->{g} (a, Map k v)

对比 Haskell 的 MTL / 类型类:MTL 的组合经常需要 lift 的层叠与 MonadIO 逃生舱;Unison 的 ability 集合是行的结构化类型,组合就是集合并集,不需要 lift。这是它在工程上更轻的主要原因。


六、代价:你必须接受的五个约束

说清楚优点,也得说清楚账单。

  1. 哈希即历史,改一点就是新东西。修改 structural 类型会分裂出新类型,旧定义依旧存在但不再被引用。好处是"永不破坏旧代码",代价是代码库里会积累孤儿定义,需要 ucm 的 delete/todo 流程定期清扫。
  2. 文本编辑器体验是摩擦点。你写的是人类可读的源码,但权威表示是 AST。格式化、diff、review 都要过 ucm 这一层,习惯了直接看 git diff 的团队会有不适期。
  3. 生态规模是硬约束。库的数量、驱动完备度、可招到的人——这些不会因为设计优雅而自动解决。把 Unison 放在核心交易链路上之前,先评估你能接受多少次"自己写驱动"。
  4. 性能与运行时成熟度。抽象层(ability、按哈希解析)会带来开销,JIT/运行时优化的深度也远不及 JVM/V8 这种量级的工程投入。计算密集的热路径请留在别处,把它用在编排与胶水层。
  5. schema 迁移依然要人做决定。unique 类型保证了身份稳定,但字段语义怎么演进、旧数据怎么回填,语言不会替你决定。

七、它和 Nix / Git / Bazel 是什么关系

容易混淆的一点是:Nix 也是内容寻址,Git 也是内容寻址。区别在于粒度与对象。

系统内容寻址的对象能推导出的性质
Git文件 blob / 提交树版本可复现、去重,但不懂语义,靠文本三方合并
Nix构建输入与产物路径构建可复现,但依赖仍是包粒度,需人工写 derivation
Bazelaction key(输入 + 命令)构建可复现、增量,但仍是文件/目标粒度
Unison单个函数/类型定义(AST)语义级去重、精确依赖、跨节点代码传输

Git 遇冲突时要靠文本行做三方合并,是因为它对"两个函数是不是同一个"一无所知。Unison 在定义粒度上有答案,所以合并冲突的种类会显著变少——但注意,它不会让冲突归零:两个人改同一个哈希指向的定义,仍然是语义冲突,只是工具能精确地把它指出来。


八、结论与选型建议

Unison 的核心赌注是:如果身份是语义的,那一整类基建(构建系统、包管理冲突、schema 对齐、分布式代码分发)会同时消失。 这个赌注在技术上站得住——它是已经被实现出来的东西,不是论文里的设想。

一句话选型:需要频繁做扇出式分布式计算、受够了 DTO 包与版本对齐、且副作用边界需要被静态约束的场景(比如 AI Agent 的工具执行运行时、多租户函数计算),Unison 值得认真评估;需要榨干单机算力、或强依赖成熟生态的场景,请回到 JVM/Rust/Go。

最务实的落地路径不是整体重写,而是把它放在编排层:核心计算仍由现有服务承担,Unison 负责把跨节点的编排、副作用约束与代码分发从"运维问题"降级为"类型问题"。这也是内容寻址这个思路最容易被低估的价值——它改变的不是性能曲线,而是哪些问题还需要被人工处理。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部