☰
Unison 代码库 update 命令实战:修改带依赖类型的类型时,让 UCM 自动替你更新依赖者
2026/10/10 2:30:12 网站建设 项目流程
  • 编程语言
  • 编译器
  • 语言运行时
  • 开发工具

【免费下载链接】unison

A friendly programming language from the future

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

本文以 Unison 仓库中的交互式 transcript update-type-with-dependent-type.md 为骨架,完整还原一次"修改被其他类型依赖的类型"的真实操作,并对照 UCM(Unison Codebase Manager)源码逐阶段解释update命令的内部机制。读完本文,你将掌握add/update的命令语义、UCM 输出的+/~符号含义、view与find.verbose的验证手法,以及"依赖者自动重写"背后的实现原理。

场景还原:一次被依赖类型的修改

这是日常开发中最常见的一类重构:你定义了一个类型Foo,另一个类型Baz引用了它;现在你想给Foo的构造器增加一个参数,并希望代码库里所有依赖Foo的定义(包括Baz及其构造器)一起跟着变。UCM 的update命令正是为这种场景设计的。

第一步:初始化内建库并定义两个类型

transcript 首先通过builtins.merge将内建库合并到命名空间lib.builtin(该步骤在 transcript 中以:hide隐藏,属于标准初始化动作),然后在 scratch 文件scratch.u中定义两个unique type:

unique type Foo = Bar Nat unique type Baz = Qux Foo

其中Baz的构造器Qux的字段类型就是Foo——Baz是Foo的直接依赖者。注意unique type表明该类型声明带有一个唯一的类型 GUID,更新类型时 UCM 会为其保留/重新生成 GUID(见后文makeUniqueTypeGuids的处理)。

第二步:add 把定义加入代码库

定义写好并完成类型检查后,UCM 自动给出变更摘要(transcript 中以:added-by-ucm标记的片段是 transcript runner 运行时由 UCM 自动生成的输出,并非手工编写):

Loading changes detected in scratch.u. + type Baz + type Foo Run `update` to apply these changes to your codebase.

这里的+前缀表示"新增":Baz与Foo在代码库中尚不存在,因此被标记为待添加的新定义。随后执行add:

> add Okay, I'm searching the branch for code that needs to be updated... Done.

第三步:修改 Foo 并触发变更检测

现在把Foo的构造器从接收一个Nat改为接收两个Nat:

unique type Foo = Bar Nat Nat

scratch 文件重新加载后,UCM 的变更摘要发生变化:

Loading changes detected in scratch.u. ~ type Foo ~ (modified) Run `update` to apply these changes to your codebase.

注意这里不再是+,而是~:表示名为Foo的类型在代码库中已存在同名定义,且新文件中的定义与其引用不同,因此这是一次修改(updated)而非新增(new)。~ (modified)是对该变更的补充说明。相比之下,Baz并没有出现在摘要里——因为文件本身没有改动它。

第四步:update 自动更新依赖者

执行update,这是本场景的核心命令。其输出分为清晰的三个阶段:

> update Okay, I'm searching the branch for code that needs to be updated... That's done. Now I'm making sure everything typechecks... Everything typechecks, so I'm saving the results... Done.

这三句话对应update处理器的三个内部阶段:搜索依赖者 → 重新类型检查 → 保存结果。Baz依赖Foo,属于"需要更新的代码",因此被自动纳入更新范围;由于一切类型检查顺利通过,UCM 直接保存结果并打印Done.。

第五步:用 view 与 find.verbose 验证结果

更新完成后,用view检查两个类型:

> view Foo type Foo = Bar Nat Nat > view Baz type Baz = Qux Foo

Foo已经是新签名Bar Nat Nat;而Baz的定义Qux Foo字面上看起来没变,但关键在于它内部引用的Foo已经指向了新的Foo版本——UCM 在保存时替Baz重写了引用。

用find.verbose查看完整 hash 可以证实这一点:

> find.verbose 1. -- #1uosg6rv85ql7rbohtfvqqacgjl5pp2faj0t3k3dkrtn0t3jqdh2m2om8earv0jh8m8j86vv6bv1h17jl8a2lfa857pm6n27hnisi1g type Baz 2. -- #1uosg6rv85ql7rbohtfvqqacgjl5pp2faj0t3k3dkrtn0t3jqdh2m2om8earv0jh8m8j86vv6bv1h17jl8a2lfa857pm6n27hnisi1g#0 Baz.Qux : Foo -> Baz 3. -- #hlhjq1lf1cvfevkvb9d441kkubn0f6s43gvrd4gcff0r739vomehjnov4b3qe8506fb5bm8m5ba0sol9mbljgkk3gb2qt2u02v6i2vo type Foo 4. -- #hlhjq1lf1cvfevkvb9d441kkubn0f6s43gvrd4gcff0r739vomehjnov4b3qe8506fb5bm8m5ba0sol9mbljgkk3gb2qt2u02v6i2vo#0 Foo.Bar : Nat -> Nat -> Foo

这里能看到三条关键信息:

  • Foo与Foo.Bar共享新 hash#hlhjq…(类型声明与其构造器同属一个声明命名空间),构造器行用#0后缀标识构造器编号,其新签名为Nat -> Nat -> Foo。
  • Baz与Baz.Qux的 hash#1uosg…是全新的。虽然Baz的源码文本没有改动,但因为它引用的Foo版本变了,Baz的语义内容随之改变,所以整个声明被重新哈希,Baz.Qux : Foo -> Baz中的Foo已是新引用。
  • 依赖关系保持完好:Baz.Qux的类型签名仍然引用Foo,没有出现"悬空引用"或编译错误。

update 命令的语义:与 add 的关系

在 UCM 中,update与add实际上是同一命令的两个名字。看命令模式的定义 InputPatterns.hs:

update :: InputPattern update = InputPattern { patternName = "update", aliases = ["add"], visibility = I.Visible, params = noParams, help = P.wrap $ "Adds everything in the most recently typechecked file to the namespace," <> "replacing existing definitions having the same name, and attempts to update all the existing dependents accordingly. If the process" <> "can't be completed automatically, the dependents will be added back to the scratch file" <> "for your review.", parse = const $ pure Input.Update2I }

官方帮助文本精确概括了它的行为:

  1. 将最近一次类型检查通过的文件内容加入命名空间;
  2. 替换同名既有定义(这正是 transcript 中~ type Foo的含义);
  3. 尝试自动更新所有既有依赖者;
  4. 如果无法自动完成,会把依赖者写回 scratch 文件供你人工修复后再试一次。

这也是为什么本例中即使Baz不在被修改的文件里,update也会自动找到它并完成更新。另外,diff.update(别名update.diff)是update的只读预览版本,不修改代码库,适合在大型重构前先查看影响范围。

三阶段输出背后的源码流程

transcript 中update的三行进度输出,对应处理器 Update2.hs 中handleUpdate2的执行主线:

阶段一:搜索需要更新的代码

处理器首先做若干前置校验,然后输出"Okay, I'm searching the branch for code that needs to be updated..."(对应 Update2.hs):

  • 检查命名空间中是否有冲突名称(conflicted names),有则提前返回Output.ConflictedDefn;
  • 用声明一致性检查(decl coherency check)验证现有声明自洽,失败则回滚并报Output.IncoherentDeclDuringUpdate;
  • 拒绝任何触及lib.*命名空间的更新(Output.CantUpdateLib);
  • 随后调用 getNamespaceDependentsOf,基于transitiveDependentsWithinScope找出所有被更新定义(这里是Foo)的传递依赖者,并把它们的名字收集起来。由于Baz直接引用Foo,它必然出现在这份名单里。

阶段二:把依赖者与你的改动一起重新类型检查

如果存在依赖者,处理器输出"That's done. Now I'm making sure everything typechecks..."(Update2.hs),然后执行一次非常巧妙的"重写-解析"过程:

  1. 把你修改后的文件与所有依赖者(hydrated 后的定义)渲染成一份漂亮的 Unison 源码文本;
  2. 用包含新旧引用的命名环境(makePPE,见 Update2.hs)重新解析、重新类型检查,得到第二份类型检查结果secondTuf。

源码注释对这一步的形容非常贴切(Update2.hs):

We are updating old references to new references by rendering old references as names that are then parsed back to resolve to new references (the world's weirdest implementation of AST substitution).

(我们通过把旧引用渲染成名字、再解析回新引用的方式完成新旧引用的替换——这是世界上最奇特的 AST 替换实现。)

正是这一步让Baz.Qux中的Foo引用被静默地指向了新版本的Foo。

阶段三:保存结果

类型检查通过后输出"Everything typechecks, so I'm saving the results..."(Update2.hs),随后:

  • 调用Codebase.addDefsToCodebase把第二份类型检查结果写入代码库;
  • 通过typecheckedUnisonFileToBranchUpdates(Update2.hs)为每个声明生成分支更新动作:删除旧的类型名与旧构造器名,插入新的类型名与新构造器名;
  • 以Cli.stepAt "update"提交这批更新,最终返回Output.Success,对应终端上的Done.。

为什么 Baz 的 hash 也变了:哈希的传递性

find.verbose输出中最容易被忽视的细节是:Baz的 hash 变成了全新的#1uosg…。这是因为 Unison 的引用是内容寻址的:Baz = Qux Foo中的Foo引用指向的是Foo的完整 hash。当Foo从Bar Nat变为Bar Nat Nat后,其 hash 从旧值变为#hlhjq…,而Baz的表示中包含这个引用,于是Baz自身的内容也随之变化,必须重新哈希。

这正是 Unison 依赖管理"不动手也全自动"的体现:任何一处依赖关系的改变都会沿依赖图向上传播新的 hash,而update一次性把整条链上的声明全部重写、重哈希并保存,确保代码库始终处于自洽状态。这个场景之所以被放在 idempotent 目录下,也说明该操作的输出是确定且可重复验证的。

底层支撑:Slurp 如何区分"新增"与"更新"

transcript 中+ type Baz与~ type Foo的区别,来自 UCM 对 scratch 文件做"slurp"分析时的状态分类。在 Slurp.hs 中定义了DefnStatus:

data DefnStatus = CtorTermCollision -- 文件中的构造器与代码库中的 term 冲突 | Duplicated -- 与代码库中定义完全相同 | New -- 全新定义 | TermCtorCollision -- 文件中的 term 与代码库中的构造器冲突 | Updated -- 同名定义已存在于代码库,但引用不同

判定逻辑(computeSelfStatuses,Slurp.hs)很直接:按名字在代码库中查找已有定义——查不到就是New(+),引用完全相同就是Duplicated(不显示),同名但引用不同就是Updated(~)。本场景中:

  • Baz、Foo首次出现时是New,摘要显示+;
  • 修改后的Foo名字已存在且引用不同,被归类为Updated,摘要显示~。

slurpFile还会计算每个定义的依赖状态DepStatus:如果一个新定义传递依赖某个需要更新的定义,它会被标记为"阻塞"(blocked),需要等依赖者一起更新后才能加入。这保证了update不会把引用了旧Foo的孤立定义写入代码库。

失败路径:类型检查不过时会发生什么

本 transcript 是"顺利通过"的路径;但如果修改的波及范围很大、依赖者无法自动适配(例如Baz的某个 term 依赖了Foo.Bar的旧字段结构),update会走失败分支:把无法通过的依赖者追加渲染回 scratch 文件,并给出如下指引(见 Update2.hs 中makePrettyUnisonFile生成的文件头注释):

-- The definitions below no longer typecheck with the changes above. -- Please fix the errors and try `update` again.

其理念是:UCM 只负责能自动完成的部分,剩下的交还给你——依赖者被写回 scratch 文件供你手工修改,修复后再次执行update即可继续。这正是update帮助文本中"If the process can't be completed automatically, the dependents will be added back to the scratch file for your review"的落地实现。

此外,handleUpdate2还处理了在 merge/update/upgrade 分支上执行update的特殊情况(成功后将结果合并回父分支并删除临时分支,见 Update2.hs),以及通过UNISON_USE_UPDATE_V1环境变量切换旧版更新流程的后备开关(Update2.hs)。

小结

通过这份 transcript 我们可以总结出update命令处理"修改被依赖类型"场景的完整心智模型:

  1. 检测:slurp 分析按名字比对代码库,用+/~区分新增与更新(Slurp.hs);
  2. 波及面:用transitiveDependentsWithinScope找出所有传递依赖者(UpdateUtils.hs);
  3. 重写:把"你的修改 + 依赖者"渲染成文本再解析回新引用,相当于一次全自动的 AST 替换(Update2.hs);
  4. 保存:重新类型检查通过后,删除旧声明、插入新声明并提交,成功返回Done.;
  5. 兜底:无法自动完成时,把依赖者写回 scratch 文件,修复后重试即可。

对于 Unison 开发者而言,这意味着日常的"改类型、连带改依赖"类重构可以放心交给update:你只需修改源头定义,UCM 会沿着哈希依赖图把整条依赖链上的声明重写、重哈希并保持代码库自洽;find.verbose则是验证"哪些 hash 发生了变化"的最直观工具。

  • 编程语言
  • 编译器
  • 语言运行时
  • 开发工具

【免费下载链接】unison

A friendly programming language from the future

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

相关推荐

上一篇:告别255字符限制:GSE魔兽世界智能一键宏插件的完整实战指南
下一篇:pkNX 终极上手指南:如何用宝可梦 Switch ROM 编辑器打造专属冒险

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

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

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

立即咨询