用途
该技能用于指导修改已发布的 F# 库,使其保持二进制兼容:针对旧版本(v1)编译的应用,在把库 DLL 替换为新版本(v2)后,无需重新编译即可运行并得到相同结果。
技能强调:解决方案构建成功只能证明源码兼容,因为构建会重新编译每个调用方;它不能证明二进制兼容。
主要规则
- 不要给公开 let 函数新增参数:编译后的静态方法签名会改变,旧应用会得到 MissingMethodException。应新增函数并把旧函数保留为包装(如
let parsePrice text = parsePriceIn "USD" text)。 - 类型成员应新增重载,不要替换旧成员:新增可选参数(
?currency)虽保持源码兼容,但会替换旧方法,旧应用会得到 MissingMethodException。 - 为每个公开函数写显式类型或使用 .fsi 文件:函数体改动可能改变推断签名(如 int 变 'a、seq 变 list),导致 MissingMethodException。
- union case 只能追加在末尾:在中间插入会改变后续 case 的标签编号,旧应用会静默走错匹配分支,且包验证和 apicompat 不会报告。
- 不要向旧应用返回新的 union case:旧应用遇到未知 case 会静默走错分支;重新编译的应用会得到 FS0025 警告和 MatchFailureException。如旧应用可能通过现有函数拿到新 case,应让该函数只返回旧 case,另加新函数;否则需发布主版本。
- 不要给调用方构造的公开 record 加字段:record 构造和 copy-and-update 会调用构造函数,旧应用会得到 MissingMethodException;record 中不能使用
new(FS0871)。应把新数据放入新类型。 - inline 函数体和 [<Literal>] 值会被复制进每个应用:旧应用保留旧值直到重新编译。可变内容应使用普通函数和普通 let 值,并以
--nooptimizationdata构建;但该标志不改变针对更早带优化数据 DLL 编译的应用,且会阻止跨程序集内联,可能使调用方代码变慢,inline 函数仍会被复制。 - 重命名时保留包装:次要版本中新增新名称并将旧名称保留为普通包装;仅在主版本中标记
[<Obsolete("Use X.")>]并在发布说明中写明。[<CompiledName("oldName")>]可让旧应用继续工作,但重新编译的 F# 调用方会得到 FS0039。 - 不要在柯里化与元组参数之间切换:两者编译为同一方法,旧应用可继续运行,但重新编译的 F# 调用方会失败,apicompat 不会报告。
- 不要在次要版本中提高 FSharp.Core 依赖:默认依赖于打包所用 SDK 的 FSharp.Core 版本;引用较低版本的调用方只得到 NU1605 警告,构建后启动时抛 FileNotFoundException。应显式设置库所需的最低版本。
- 不要给公开接口新增抽象成员:旧应用实现该接口会得到 TypeLoadException,重新编译会得到 FS0366。应新增继承旧接口的新接口,并用
:?检测。
使用步骤
- 保留基线:在改动前找到已发布版本(git tag、release 目录或已发布包),构建并保留其 DLL。
- 按上述规则修改,检查没有公开 let 函数加参数、union case 未移动、构造 record 未加字段、公开接口未加抽象成员。
- 运行包验证(在
dotnet pack中启用EnablePackageValidation并设置PackageValidationBaselineVersion)或使用 apicompat 工具比较 DLL;两者对移除或变更成员报告 CP0002。 - 运行技能提供的
scripts/sig-diff.ps1检查 IL 工具不报告的 F# 层面变化(union case 顺序、柯里化/元组、字面量、推断类型);退出码 0 表示无签名变化,1 表示有变化,2 表示构建或检出失败。 - 用基线 DLL 在 Release 下构建一个小型调用方,把新库 DLL 复制到其输出目录不重新编译并运行,验证旧 case 输出一致且无 MissingMethodException、TypeLoadException 或 MissingFieldException。
- 用新版本重新编译调用方并测试新功能。
- 对接受的破坏性变更发布主版本;在
CompatibilitySuppressions.xml中记录每个二进制破坏并注释原因。
工具说明
- 包验证:在构建中启用,报告移除/变更成员(CP0002),不报告 union case 顺序和复制函数体。
- apicompat:适用于不在 feed 上的 DLL,报告内容与包验证相同,
--strict-mode还会报告每个新增成员。 sig-diff.ps1:在包验证之后使用,报告 F# 视角的 case 顺序、柯里化/元组、字面量、推断类型,不报告函数体变化。- PublicApiGenerator 快照:可选,报告每个公开变化但不报告函数体变化。
- Microsoft.CodeAnalysis.PublicApiAnalyzers:对 F# 不适用。
验证
从技能根目录运行 pwsh ./scripts/run.ps1。运行器为每个用例构建库版本和针对基线的调用方,用新 DLL 运行旧调用方、重新编译调用方,并运行 apicompat、apicompat --strict-mode、签名 diff 和 PublicApiGenerator,结果与用例 Consumer.fs 中的 // expect 行比较。使用 -Case <name> 只运行部分用例,此时不运行工具证明。
限制
没有任何工具能找到被复制的函数体或行为变化,只有实际运行旧应用才能发现。部分规则(如 union case 排序、柯里化/元组切换)不被 IL 工具检测,需要依赖签名 diff 脚本。技能本身不重复 fsharp-signature-files 的内容。