- 编程语言
- 编译器
- 语言运行时
- 开发工具
【免费下载链接】unison
A friendly programming language from the future
在 Unison 这种以内容寻址(content-addressed)为基础的函数式编程语言中,代码库(codebase)里的每个定义都通过哈希唯一标识,一个 term 被修改后会产生全新的引用,其所有引用者并不会自动指向新版本。而 UCM(Unison Codebase Manager)的update命令正是解决这一问题的关键工具:它会自动重新类型检查并重建所有受影响的依赖者,让foo改变后,引用foo的bar等定义一并得到更新。本文以仓库内官方 transcript 文档 update-term-with-dependent.md 为主体,结合 UCM 命令处理器的源码实现,完整还原update的工作流程,并说明其底层依赖图查询、类型检查回退与分支写入机制,帮助你理解并正确使用这一核心命令。
一、场景速览:一次带依赖项的更新
update-term-with-dependent.md是一份 UCM transcript(可自动执行并校验输出的交互式测试文档)。它用最简短的例子演示了update的核心行为:更新一个 term 时,自动把它的直接依赖者一起更新。整个实验过程如下。
第一步:初始化内置库。
> builtins.merge Done.第二步:在 scratch 文件中定义两个 term,其中bar依赖foo:
foo : Nat foo = 5 bar : Nat bar = foo + 10UCM 检测到 scratch 文件中的变更,提示两个定义均为新增(+),等待add提交:
Loading changes detected in scratch.u. + bar : Nat + foo : Nat Run `update` to apply these changes to your codebase.第三步:用add将定义写入代码库。
> add Okay, I'm searching the branch for code that needs to be updated... Done.第四步:只修改foo,把它的实现从5改为6,不动bar。
foo : Nat foo = 6scratch 文件检测到变更,显示foo为"已修改"(~):
Loading changes detected in scratch.u. ~ foo : Nat ~ (modified) Run `update` to apply these changes to your codebase.第五步:执行update并查看bar的当前定义。
> 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. > view bar bar : Nat bar = use Nat + foo + 10输出显示update后bar的源代码形式没有变化——仍然是foo + 10——但它内部引用的是更新后的新foo。这正是update与add的本质区别:add只添加新定义;update会顺带重写所有引用旧版本的依赖者。
二、update命令的定位:官方帮助与别名
在 InputPatterns.hs 中,update命令的官方定义如下:
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 }帮助文本点明了三个关键语义:
- 添加最新类型检查文件中的所有内容到命名空间,与
add相同; - 替换同名已有定义(即"更新"而非"新增");
- 尝试相应地更新所有已存在的依赖者(dependents);如果该过程无法自动完成,依赖者会被放回 scratch 文件供你人工修复。
几个值得注意的细节:
update的别名是add,二者解析到相同的输入类型Input.Update2I,说明它们在 UCM 输入分发层面共用同一套处理管线;update不接收命令行参数,作用于"最近一次类型检查的文件"(即当前 scratch 文件),因此 transcript 中的操作顺序(先编辑 scratch,再执行命令)是使用前提;- 同文件还定义了 diff.update(别名
update.diff),它"预览update会产生的改动,且是只读操作、不修改代码库",适合在真正执行update前预演依赖影响。
三、update的处理流程:源码级逐步拆解
update的实际处理函数是handleUpdate2,位于 Update2.hs。其执行步骤与 transcript 中的三行状态输出一一对应,我们可以据此逐段还原。
3.1 前置校验:冲突与不一致声明
进入核心流程前,UCM 会做两类断言:
- 通过
Branch.asUnconflicted检查当前命名空间不存在冲突名(conflicted names),否则报Output.ConflictedDefn并提前返回; - 通过
Codebase.getBranchDeclNameLookup结合声明一致性检查(DeclCoherencyCheck),确保命名空间中没有不一致的声明(incoherent decls),否则以Output.IncoherentDeclDuringUpdate回滚中止。
对应源码见 Update2.hs。
3.2 第一步输出:"搜索需要更新的代码"
随后 UCM 输出第一条提示Okay, I'm searching the branch for code that needs to be updated...(见 Update2.hs),并立即在一个数据库事务中完成三件事:
- 确定被更新的定义集合:将最新类型检查文件中的命名空间绑定与代码库现有绑定逐一比对,仅保留真正发生变化的项(
addedOrUpdatedNamespaceBindings)。比对逻辑位于 Update2.hs:文件里的引用若与代码库中同名定义引用一致,则视为"无变化"被丢弃;不同则标记为待更新。 - 查询所有依赖者:调用
getNamespaceDependentsOf,以被更新定义为查询点,在当前命名空间内计算传递依赖者闭包(详见第四节)。 - 排除被文件本身遮蔽的依赖者:如果某个依赖者恰好也在当前文件里被重新定义,则不再单独处理它(
Map.withoutKeys namespaceBindings,见 Update2.hs)。
3.3 第二步输出:"确保一切都能通过类型检查"
如果存在需要更新的依赖者,UCM 输出That's done. Now I'm making sure everything typechecks...(见 Update2.hs),然后进入核心的**"渲染–重解析–重类型检查"**环节:
- 把原始文件与所有依赖者的已水合(hydrated)定义合并,重新渲染成一个完整的 Unison 源文件(
makePrettyUnisonFile,见 Update2.hs); - 用当前命名空间的名称环境(
Cli.makeParsingEnv)重新解析并类型检查(parseAndTypecheck)。
这里有一个值得展开的实现细节(见 Update2.hs 的注释):"更新旧引用到新引用"是通过把旧引用渲染成名字、再在重解析时让名字解析到新引用实现的——源码注释原文称其为"the world's weirdest implementation of AST substitution"(世界上最古怪的 AST 替换实现)。渲染时使用makePPE构造的 PrettyPrintEnv:对文件内定义按名称后缀化(suffixify by name),对代码库中的定义则按哈希后缀化(suffixify by hash),同时用Names.shadowing处理命名空间与文件之间可能的重名歧义。
3.4 第三步输出:"类型检查通过,保存结果"
类型检查成功则输出Everything typechecks, so I'm saving the results...(见 [Update2.hs](https://gitcode.com/gh_mirrors/un/unison/blob/7997ec36e9bc67175b061920d085ca2f433fbf6d/unison-cli/src/Unison/Codebase/Editor/HandleInput/Update2.hs?utm_source=gitcode_repo_files#L288)),随后:
- 把新的(含重写后依赖者的)类型检查文件写入代码库:
Codebase.addDefsToCodebase; - 通过
typecheckedUnisonFileToBranchUpdates把文件内容转成一批分支更新操作(branch updates),再以Cli.stepAt "update"提交到当前项目分支(见 Update2.hs)。
typecheckedUnisonFileToBranchUpdates(见 Update2.hs)生成的更新模式值得注意:对于每个 term,它先生成makeAnnihilateTermName(移除旧名字绑定),再生成makeAddTermName(把名字绑定到新引用);对于每个声明(type/ability),则额外处理其构造器的删除与重加(makeAnnihilateTypeName/makeAddTypeName/insertTypeConstructorActions)。这正是"替换"语义在分支层面(Branch0)的具体落地:旧引用被移除,新引用按同名就位。
3.5 类型检查失败的兜底路径
如果依赖者无法自动适配新定义,handleUpdate2有两条兜底路径(见 Update2.hs):
- 若当前正处于
update/upgrade/merge分支中(pp.branch.isUpdate || ...),则直接把命名空间推进到"移除依赖者"后的状态,并把渲染出的待修复文件写回 scratch 文件; - 否则,新建一个临时分支(
createBranch,分支名形如update-<分支名>,类型为CreateFrom'Update),同样把待修复文件写回 scratch 文件。
无论哪条路径,最终都会把"需要人工修复的定义"以如下注释块形式写进 scratch 文件(见 Update2.hs):
-- The definitions below no longer typecheck with the changes above. -- Please fix the errors and try `update` again.而subtractDependents(见 UpdateUtils.hs)的作用是在此过程中从命名空间里滤掉待修复的依赖者(builtin 引用与构造器除外),从而保证分支状态的一致性。
四、依赖者如何被找到:传递依赖查询的底层原理
transcript 中"只改foo,bar却一并更新"的行为,依赖的是getNamespaceDependentsOf(见 [UpdateUtils.hs](https://gitcode.com/gh_mirrors/un/unison/blob/7997ec36e9bc67175b061920d085ca2f433fbf6d/unison-cli/src/Unison/Cli/UpdateUtils.hs?utm_source=gitcode_repo_files#L73-L89))。它的注释写得很清楚:给定一个无冲突命名空间和一组依赖,返回该命名空间中这些依赖的**(传递)依赖者子集**。
4.1 从命名空间到数据库查询
getNamespaceDependentsOf的核心一行是:
Operations.transitiveDependentsWithinScope (Names.unconflictedReferenceIds defns) dependenciestransitiveDependentsWithinScope定义于 Operations.hs,其语义为:返回query的所有在scope内的传递依赖者(排除自引用)。实现上它先做类型转换,然后直接调用 SQLite 查询Q.getTransitiveDependentsWithinScope完成传递闭包计算。
值得强调的是"作用域(scope)"参数:查询结果被限制在当前命名空间(unconflicted defns)内。这意味着update只影响当前分支/命名空间里可见的定义,不会越界修改其他命名空间或lib.*库依赖中的内容。实际上handleUpdate2在前面已经专门检查过:任何被添加/更新的绑定如果落在lib.*段,整个更新会被拒绝(Output.CantUpdateLib,见 Update2.hs)。
4.2 SQLite 层的支撑:依赖索引
依赖查询的高效性由 SQLite 代码库层的数据结构支撑。在 codebase2/codebase-sqlite 中,迁移脚本 018-add-derived-dependents-by-dependency-index.sql 明确为"由依赖索引派生出的依赖者"建立索引,使得"谁依赖了谁"可以沿依赖边快速反查。transitiveDependentsWithinScope的同文件中还定义了transitiveDependentsGraphWithinScope(见 Operations.hs),它以DependencyEdge(term 依赖 term / term 依赖 type / type 依赖 type)的形式返回完整依赖图,供delete、edit等命令复用——Merge2.hs、EditDependents.hs、Delete.hs中都能看到对transitiveDependentsWithinScope的调用,说明这是整个 UCM 共享的依赖分析基础设施。
4.3 水合:把引用还原成定义
依赖者查询得到的是引用 ID 集合,但重写它们需要拿到真实定义内容,这一步由hydrateRefs完成(见 UpdateUtils.hs):term 引用通过Codebase.unsafeGetTermComponent、type 引用通过Codebase.expectTypeDeclarationComponent批量取回对应组件,再经nameHydratedRefIds把名字、引用、定义三者关联起来,供渲染阶段使用。
五、版本开关与命令族:update的配套机制
源码中还提供了一个实验性开关:useUpdateV2(见 [Update2.hs](https://gitcode.com/gh_mirrors/un/unison/blob/7997ec36e9bc67175b061920d085ca2f433fbf6d/unison-cli/src/Unison/Codebase/Editor/HandleInput/Update2.hs?utm_source=gitcode_repo_files#L84-L87)):
useUpdateV2 :: Bool useUpdateV2 = not . isJust . unsafePerformIO $ lookupEnv "UNISON_USE_UPDATE_V1"即:默认使用 v2 更新逻辑;若设置了环境变量UNISON_USE_UPDATE_V1,则回退到旧版实现(v2 与 v1 的差异主要体现在类型检查失败时是推进分支并新建临时更新分支,还是仅写回 scratch 文件)。这是仓库当前版本的实际行为,普通使用无需关心,但了解它有助于排查行为差异。
围绕update的完整命令族还包括:
| 命令 | 作用 | 源码位置 |
|---|---|---|
add | update的别名,解析为同一个Update2I输入 | InputPatterns.hs |
diff.update(别名update.diff) | 只读预览update将产生的改动 | InputPatterns.hs |
builtins.update | 更新内置定义(builtins) | InputPatterns.hs |
add.run | 把最近一次run的结果定义加入代码库 | InputPatterns.hs |
六、从 transcript 到可重复验证
update-term-with-dependent.md位于 transcripts/idempotent/ 目录,是 Unison 项目 transcript 测试体系的一部分:transcript 文件以```ucm与```unison代码块描述交互,由 Transcripts.hs 中的运行器自动执行并把实际输出与预期输出比对(同目录下大量.output.md文件即保存的预期输出)。由于该用例的每一步都幂等可重放,你可以把它当作验证本文所述行为的"活文档":builtins.merge初始化、add提交、修改foo、update、view bar,每一步的输出与 update-term-with-dependent.md 中的记录一致,即说明本机 UCM 的依赖者自动更新行为正常。
七、结论与使用要点
update-term-with-dependent.md用 30 行代码块讲清了 Unison 更新模型中最关键的一条规则:在 Unison 中,改名引用是安全的,因为update会基于代码库的依赖图,自动重建所有受影响的依赖者。综合文档与源码,实践中有几点值得牢记:
update与add不同:add只做加法;update做"同名替换 + 依赖者级联更新"。新增定义时二者等价,修改已有定义时必须使用update(或diff.update先预览)。- 更新的范围是当前命名空间:依赖查询被
transitiveDependentsWithinScope限制在无冲突命名空间内,lib.*中的定义即使被改动也不会被update波及,反而会触发CantUpdateLib报错。 - 自动更新不是魔法:当依赖者无法在新版本下通过类型检查时,
update不会强行破坏你的代码,而是把它们连同修复提示写回 scratch 文件(或置于临时更新分支),等你修复后再次update。 - 分支层面是"移除旧绑定 + 添加新绑定":
typecheckedUnisonFileToBranchUpdates为每个更新项生成 annihilate/add 配对操作,这正是内容寻址代码库中"替换"的标准落地方式。
- 编程语言
- 编译器
- 语言运行时
- 开发工具
【免费下载链接】unison
A friendly programming language from the future
相关推荐
A2UI完整指南:如何让AI代理直接生成交互式界面
A2UI完整指南:如何让AI代理直接生成交互式界面 在聊天框输入"预订一张两人位",几秒后界面里出现一张预订卡片:日期选择器、时间输入框和一个"确认"按钮。A2
编程语言编译器语言运行时开发工具PayloadsAllTheThings 之 CRLF 注入(HTTP 响应拆分)实战指南:从响应头注入到会话固定、XSS 与开放重定向
PayloadsAllTheThings 之 CRLF 注入(HTTP 响应拆分)实战指南:从响应头注入到会话固定、XSS 与开放重定向 CRLF 注入(Car
编程语言编译器语言运行时开发工具基于GDExtension架构的Steamworks集成解决方案:实现Godot游戏引擎与Steam平台的无缝对接
基于GDExtension架构的Steamworks集成解决方案:实现Godot游戏引擎与Steam平台的无缝对接 随着独立游戏开发市场的蓬勃发展,Godot引
游戏开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考