☰
Unison 类型更新实战:将数据构造函数重构为 Smart Constructor(以 `unique type Foo` 为例)
2026/10/10 5:51:31 网站建设 项目流程
  • 编程语言
  • 编译器
  • 语言运行时
  • 开发工具

【免费下载链接】unison

A friendly programming language from the future

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

本指南围绕unison-src/transcripts/idempotent/update-type-turn-constructor-into-smart-constructor.md这条 UCM 转录脚本展开,讲解如何在不破坏代码库的前提下,把 Unison 中一个公开的数据构造函数Bar降级为模块内部构造internal.Bar,并提供一个同名的 Smart Constructor(智能构造函数)作为对外入口。读完本文,你将掌握add/update命令的完整工作流、internal名称的作用域语义、Smart Constructor 的封装动机,以及如何通过view与find.verbose验证重构结果。

一、转录文档概述:这是一个什么样的"文档"

unison-src/transcripts/idempotent/目录是 Unison 项目用来做端到端回归测试的"转录"(transcript)文件集合。每条 transcript 都是一份可执行的 Markdown:其中的代码块要么是unison语言片段(会被加载进临时 scratch 文件并解析、类型检查),要么是ucm命令片段(会被实际送入 UCM 交互式终端执行),而:added-by-ucm标注的代码块则是转录运行后 UCM 回填的真实输出。idempotent(幂等)前缀意味着这些转录在相同状态下重复运行应产生相同结果。

本次关联文档update-type-turn-constructor-into-smart-constructor.md就是这类 transcript 中的一条:它的文件名本身就是一句话需求——"更新类型,把构造函数变成 Smart Constructor"。整篇文档仅由一个连续的操作序列构成,本文会逐段拆解其中的每个命令、每段代码和每行输出,并结合仓库源码解释其背后的实现机制。

想对比阅读其他以update为主题的同目录转录,可以参考 addupdatemessages.md、add-run.md、branch-squash.md 等,它们共同刻画了 UCMupdate命令的各种行为边界。

二、前置条件与初始状态

2.1 合并内置库

转录的第一步是builtins.merge:

> builtins.merge lib.builtin

该命令把 Unison 的内置库(builtins,例如Nat、+等语言内置类型和函数)合并到名为lib.builtin的命名空间中。Unison 是一种"无保留字"语言,Nat、Text等基础类型与+、==这类运算符都是通过这种方式注入普通名称空间的普通定义,而不是编译器硬编码的语法关键字。执行builtins.merge之后,Nat类型及其上的(+)运算才在当前分支中可用,后续示例代码中的n+10才能被解析和类型检查。

2.2 初始代码:一个普通的unique type

紧接着转录定义了一个一元数据类型和一个普通函数:

unique type Foo = Bar Nat makeFoo : Nat -> Foo makeFoo n = Bar (n+10)

这里有两个要点值得解释:

  • unique type:Unison 支持两种数据类型声明,unique type与普通type。unique修饰符表示这个类型在其内容之外还包含一个全局唯一标识符(在v2哈希方案下,唯一性是通过内容哈希加上类型级别标记共同决定的)。它使得即使是结构上完全相同、只是名字不同的两个类型也被视为不同;同时它保证了"这个类型就是它自己",从而允许我们对该类型做模式匹配而不会与同结构的其他类型混淆。
  • makeFoo : Nat -> Foo:这是一个典型的"辅助构造函数"——它包装了Bar,在构造时对输入做了n+10的加工。注意在 Unison 中,类型与其构造函数的全限定名是绑定在一起的:type Foo = Bar Nat隐式定义了Foo.Bar : Nat -> Foo这个构造函数。所以此时Bar既是构造Foo的唯一途径,也是Foo这个数据类型对外暴露的公开接口。

2.3 将初始定义加入代码库

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

UCM 的add命令把 scratch 文件中检测到的变更写入当前分支。转录输出中出现的 "I'm searching the branch for code that needs to be updated..." 是 UCM 在add/update时的固定流程提示语:它表示 UCM 正在扫描分支中所有依赖关系,判断哪些定义需要同步更新(例如makeFoo依赖类型Foo,当Foo的定义变化时,makeFoo是否受影响)。这里的输出为 "Done.",说明首次add一切顺利,没有需要额外处理的级联更新。

2.4 中间状态小结

此时代码库中:

  • 类型Foo是公开可构造的:Foo.Bar(以Foo前缀限定)可以直接构造Foo;
  • makeFoo是用户自定义的、对输入做加工的工厂函数;
  • 尚未有任何"封装"或"隐藏"概念——任何代码都能直接Bar或Foo.Bar构造Foo。

这正是本转录要改变的初始局面。

三、核心操作:把构造函数改为内部构造 + Smart Constructor

3.1 新代码的语义

转录的关键一步是提交如下新代码,然后执行update:

unique type Foo = internal.Bar Nat Foo.Bar : Nat -> Foo Foo.Bar n = internal.Bar n

这段代码做了三件事:

  1. 构造函数改名:类型定义从type Foo = Bar Nat变为type Foo = internal.Bar Nat。internal是 Unison 中一个具有特殊作用域语义的名字——凡是以internal.为前缀的构造函数,都属于"内部构造器"(internal constructor)。它们在模式匹配中依然可用,但不能直接以Foo.internal.Bar之外的形式被普通用户代码构造。
  2. 同名的公开入口:新定义了一个Foo.Bar : Nat -> Foo,其实现Foo.Bar n = internal.Bar n直接转调内部构造器。
  3. 类型声明保持unique type不变:因此转录输出中才会有 "(and 1 unchanged type)" 的提示。

于是,重构后的Foo对外呈现为:构造Foo的唯一公共途径是 Smart ConstructorFoo.Bar,而类型真正的数据构造器internal.Bar被藏在了internal名字空间后面。任何想要直接构造Foo的代码都必须显式写出Foo.internal.Bar——这通常意味着它绕过了 Smart Constructor 可能施加的不变量检查。

关于模块化语义的更深层讨论可参见 docs/type-declarations.markdown:其中阐述了"如何不暴露内部实现、保持数据不变量、自由变更内部实现而不破坏客户端代码"这一 Unison 设计目标,并探讨了internal、friend[Foo]、private[Foo]等候选机制的取舍。

3.2 UCM 检测到的变更

Loading changes detected in scratch.u. ~ Foo.Bar : Nat -> Foo (and 1 unchanged type) ~ (modified) Run `update` to apply these changes to your codebase.

:added-by-ucm是转录运行后由 UCM 自动回填的输出,它展示了update执行前 UCM 的"变更预扫描"结果:

  • ~ Foo.Bar : Nat -> Foo:波浪号~表示Foo.Bar这个**术语(term)**将被更新(它是新加入的同名 Smart Constructor);
  • (and 1 unchanged type):类型Foo的声明体虽然包含构造函数名的修改,但 UCM 判定其类型层面"没有变化";
  • ~ (modified):表示存在"被修改"的定义,需要update来落盘。

这里值得注意一个细节:尽管Foo.Bar的签名Nat -> Foo与原来Foo.Bar的签名完全相同,UCM 依然将其标记为修改,因为它的内容(term 体)变了——不再是隐式生成的裸构造函数,而是一个调用internal.Bar的包装函数。这正是 Smart Constructor 重构在 UCM 变更检测层面的直接体现:公开名字没变,背后实现换了。

3.3 执行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.

与add不同的是,update会先扫描分支中所有引用旧定义的代码(例如makeFoo引用了Bar),尝试自动传播更新,然后对整个代码库重新做类型检查,全部通过后才写入结果。这条输出序列——"searching the branch" → "making sure everything typechecks" → "saving the results"——对应 UCMupdate命令的三阶段实现:依赖扫描、全量类型检查、持久化。

转录输出显示重构后makeFoo没有被标记为需要更新。原因在于:虽然Bar变成了internal.Bar,但makeFoo的代码是makeFoo n = Bar (n+10)。在 Unison 中,Bar(未限定的短名)在Foo类型仍在作用域内时会被解析为Foo.Bar——也就是新的 Smart Constructor,而非Foo.internal.Bar。因此makeFoo的语义在重构前后保持一致,无需修改,这也验证了"Smart Constructor 替换数据构造器"对既有调用方是透明的。

四、重构结果的验证:view与find.verbose

4.1view Foo:查看类型当前定义

> view Foo type Foo = internal.Bar Nat

view命令展示的是定义当前在代码库中的形态:类型Foo的构造器确实已经是internal.Bar。注意这里显示的是定义本身,不会自动展开Foo.Bar这个 Smart Constructor——想看它的实现,需要用view Foo.Bar。

4.2find.verbose:查看完整命名空间与哈希

> find.verbose 1. -- #oebc8v8v9lob5bnq7go1pjhfjbtnh8dmfhontua90t3mji0cl91t1dqaece9quofrk1vsbq6g0ukfigoi0vmvc01v8roceppejlgbs8 type Foo 2. -- #gl18p1lnbeari67ohdt9n46usnvsl59a6up1lhd9r808pqb7tt5edsf65o98bqcvb529mfm7q631ciuv2t5nqnde1i7b9t5mlu1drto Foo.Bar : Nat -> Foo 3. -- #oebc8v8v9lob5bnq7go1pjhfjbtnh8dmfhontua90t3mji0cl91t1dqaece9quofrk1vsbq6g0ukfigoi0vmvc01v8roceppejlgbs8#0 Foo.internal.Bar : Nat -> Foo 4. -- #td96hudai64mf0qgtusc70ehv98krs10jghdipjluc6cp4j8ac65msrt3tji18enpm2tm8d8h2qcf3parke19g7s17ipkd925m3061g makeFoo : Nat -> Foo

find.verbose会在名字之外额外显示每个定义的完整内容哈希(#开头的十六进制串)。这个输出是理解本转录结果的关键:

  • 第 1 项type Foo:其哈希#oebc8v8v...同时被第 3 项引用为#oebc8v8v...#0的前缀。#0是 Unison 内容寻址中对"类型内部第 0 个构造器"的引用方式——即Foo.internal.Bar这个构造器的身份由Foo类型哈希加构造器序号0共同决定。这也解释了为什么internal.Bar与Foo.Bar虽然名字相同前缀但哈希不同:它们一个是类型声明的成员(随类型哈希定位),一个是独立的顶层术语。
  • 第 2 项Foo.Bar : Nat -> Foo:这就是我们新建的 Smart Constructor,它的哈希#gl18p1ln...是独立的(因为它是一个普通函数定义),与类型哈希不同。同一名字Foo.Bar在两代Foo定义(Barvsinternal.Bar)之间对应着不同的哈希,这正是 Unison 内容寻址历史的体现:改名(术语重命名)会改变哈希,而旧哈希依旧可以通过update前后的因果链追踪到。
  • 第 3 项Foo.internal.Bar : Nat -> Foo:内部构造器。它的类型签名与Foo.Bar完全相同(都是Nat -> Foo),区别仅在于位置与权限:internal.前缀表明它属于类型内部实现,普通代码不应当直接调用它。
  • 第 4 项makeFoo:如前所述,它没有被修改——尽管它内部调用Bar,但现在Bar解析到的是 Smart ConstructorFoo.Bar。

从实现层面看,"internal前缀 + 构造器序号定位"并非转录特有的魔法,而是贯穿 Unison 编译器各层的既定设计:在解析与类型检查阶段(parser-typechecker),internal.作为合法的构造器限定名被识别;在核心表示层(unison-core),构造器通过Reference.DerivedId(内容哈希 + 序号)被唯一标识;在哈希计算层(unison-hashing-v2),构造器的哈希始终绑定其所属类型的内容哈希。因此,#oebc8v8v...#0这种"类型哈希 + 构造器序号"的引用形式是 Unison 全局名称解析的基础机制。

五、为什么需要 Smart Constructor?——设计动机

结合 docs/type-declarations.markdown 的讨论,本转录演示的重构回答了一个经典模块化问题:如何让一个库暴露公共 API,却不暴露其内部表示。

直接暴露Foo.Bar : Nat -> Foo时,任何使用方都能绕过makeFoo的n+10加工逻辑,直接构造出"不合规"的Foo值。把构造函数降级为internal.Bar,再以同名 Smart ConstructorFoo.Bar作为唯一公共入口,就实现了三件事:

  1. 保持数据不变量(invariant):所有对外创建的Foo都必须经过Foo.Bar(或在库内部经过internal.Bar),库可以确保任何Foo值都满足既定约束;
  2. 自由的内部实现变更:未来把internal.Bar换成别的表示(比如internal.Baz Text),只要保持Foo.Bar : Nat -> Foo的签名不变,客户端代码零改动;
  3. 命名空间层面的封装:internal前缀在名字上就把"内部实现"与"公共接口"区分开,阅读find.verbose输出即可一眼看出哪些名字是库作者希望使用者触碰的。

需要注意的是,Unison 中的internal构造器在模式匹配上仍然可用——也就是说,case x of internal.Bar n -> ...是合法的。它限制的是"从库外部直接构造值"这条路径,而非"观察已有值"这条路径。这与某些语言中完全私有化构造函数的语义不同,属于 Unison "封装但不隐藏模式匹配"的设计取向。

六、动手复现:把本转录变成你自己的练习

由于 transcript 文件本身是可执行的,你完全可以把它当作一份交互式实验脚本,在自己的 UCM 会话中逐步重放:

  1. 启动 UCM(例如ucm或通过项目的 unison-cli-main 入口构建的二进制),进入一个空的本地代码库;
  2. 依次执行builtins.merge lib.builtin、add,把第一段unique type Foo = Bar Nat与makeFoo加入代码库;
  3. 用第二段代码(internal.Bar+Foo.BarSmart Constructor)替换 scratch 内容,执行update;
  4. 用view Foo与find.verbose核对结果,确认与本文展示的输出一致。

如果你想进一步验证 Smart Constructor 的效果,可以在重构后尝试直接构造:在 scratch 中输入Bar 42或Foo.Bar 42,前者应解析到 Smart ConstructorFoo.Bar(透明包装),而Foo.internal.Bar 42虽然可以构造出值,但会在名字上明确暴露"你正在绕过公共接口"的事实。你还可以把makeFoo的实现临时改为makeFoo n = internal.Bar n,再运行update,观察 UCM 如何把这种"直接使用内部构造器"的调用识别为需要更新的依赖。

需要说明的是:internal.Bar在同一文件内仍然可直接调用(本转录的Foo.Bar定义本身就是这么写的)。"internal" 约束在实践中更多是约定俗成的封装纪律 + 名称空间的自我文档化,它的严格强制语义(如编译期拒绝外部直接构造)属于 Unison 后续版本仍在演进的方向,本转录展示的是当前可稳定依赖的行为。

七、小结

这条转录文档虽然只有短短 80 行,却完整覆盖了一次"把数据构造函数升级为 Smart Constructor"的端到端重构流程:

  • 用internal.前缀把原构造器从公共接口降级为类型内部实现;
  • 用一个同名的顶层函数Foo.Bar提供带封装语义的构造入口;
  • 用add/update完成代码库更新,且因为 Unison 对短名Bar的解析自动指向新的Foo.Bar,既有调用方makeFoo无需任何修改;
  • 用view Foo与find.verbose从定义形态与内容哈希两个维度验证重构结果,其中"类型哈希 + 构造器序号#0"的引用形式揭示了构造器身份与所属类型的内容寻址绑定关系。

对于希望用 Unison 构建库或长期维护代码库的开发者,这组操作是"面向演化设计"的最小可复现实战模板:公开接口保持不变,内部表示自由演化——而这正是 Unison 内容寻址 + 名称空间设计想要兑现的承诺。

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

【免费下载链接】unison

A friendly programming language from the future

项目地址:https://gitcode.com/gh_mirrors/un/unison
点击查看免费下载
上一篇:终极Blender 3MF插件指南:如何完美解决3D打印格式兼容性问题
下一篇:Blender 3MF插件:3D打印工作流的终极免费解决方案

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

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

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

立即咨询