旧行为是错的,改了也算 Breaking Change:dotnet/runtime 的判定规则与四桶分诊
项目地址:dotnet/runtime
dotnet/runtime 把「什么算破坏性变更」写进了三份文档:breaking-changes.md 讲怎么给 API 分桶、怎么做风险收益权衡;breaking-change-rules.md 逐条列出 Allowed / Disallowed;breaking-change-process.md 规定贡献者要走的 5 步流程。术语定义见 breaking-change-definitions.md。
有一个前提容易被忽略:这套规则只适用于已经在之前 RTM 版本发布过的 API。仍在开发中的新 API 可以修改,但预发布 API 变化时也要谨慎,避免无必要地打断生态。
四桶分诊
dotnet/runtime 把破坏性变更分成四个桶,用来分诊。
Bucket 1: Public Contract
明确违反公共契约的变更。例子:
- 重命名或移除 public 类型、成员或参数
- 修改 public 常量或 enum 成员的值
- 把原本不是 sealed 的类型 sealed
- 把 virtual 成员改成 abstract
- 给接口的基类型集合添加接口
- 从基类型集合中移除类型或接口
- 修改成员的返回类型
- 其他任何对 API 形状的不兼容变更
Bucket 2: Reasonable Grey Area
客户会合理依赖的行为变化。例子:
- 在已有的常见场景中抛出新的或不同的异常类型
- 原本会抛的异常不再抛了
- 对某个输入,变更后观察到不同的行为
- 缩小某个参数可接受值的范围
- 给类型新增实例字段,影响序列化
- 事件时序或顺序变化,即使文档没有规定
- 输入解析行为变化并抛出新错误,即使文档没有规定解析行为
这类变更需要判断:旧行为有多可预测、多明显、多一致?
Bucket 3: Unlikely Grey Area
客户有可能依赖、但大概率不会依赖的行为变化。例子:
- 修正一个细微角落案例中的行为
和 Bucket 2 一样,需要判断什么是合理的、什么不是。
Bucket 4: Clearly Non-Public
明显属于内部实现,或者理论上不算破坏、却会破坏某个应用的表面或行为变更。例子:
- 修改 internal API,破坏了私有反射
代码库演进不可能完全避免这类变更,所以不要求事先批准;但如果某个流行应用或库因此承受了太多痛苦,runtime 会回头重新审视这类变更。
Risk-Benefit:三种结果
对 Bucket 2 和 Bucket 3,要应用风险收益分析。旧行为是否「错」不影响判断,仍然要把影响想清楚。结果通常是三种之一:
- Accepted with compat switch:根据估算的客户影响,可能加一个 compat switch,让消费者在必要时恢复旧行为。
- Accepted:收益很大且风险极低,或者风险中等且 compat switch 不可行时,可能直接接受。
- Rejected:风险太高或改进太小,可能完全不接受这个提案。此时可以协助找替代方案,例如引入新 API 并废弃旧 API。
行为变更规则
Property、Field、Parameter 和 Return Values
Allowed
- 如果成员不是
virtual,可以扩大属性或参数的接受值范围。范围只能在不动静态类型的前提下扩大。例如可以移除if (x > 10) throw new ArgumentOutOfRangeException("x"),但不能把x的类型从int改成long或int?。 - 属性、字段、返回值或
out值可以返回更派生的类型。静态类型同样不能变。例如原先返回object,现在可以返回string实例;但不能把返回类型从object改成string。
Disallowed
- 如果成员是
virtual,扩大属性或参数的接受值范围。已有 override 对扩展后的值不会正确工作。 - 缩小属性或参数的接受值范围,例如输入解析变化并抛出新错误,即使文档没有规定解析行为。
- 扩大属性、字段、返回值或
out值的返回范围。 - 修改属性、字段、返回值或
out值的返回内容,例如ToString的返回值。假设某个 API 返回 0–10,但本意是除以二、忘了做,只返回 0–5;现在改成正确的值仍然是 breaking。 - 修改属性、字段或参数的默认值,无论通过重载还是默认值。
- 修改 enum 成员的值。
- 修改数值返回值的精度。
Exceptions
Allowed
- 抛出比现有异常更派生的异常。例如
CultureInfo.GetCultureInfo(String)在 .NET Framework 3.5 抛ArgumentException,.NET Framework 4.0 改为抛CultureNotFoundException,后者派生自ArgumentException,因此可接受。 - 抛出比
NotSupportedException、NotImplementedException、NullReferenceException或不可恢复异常更具体的异常。不可恢复异常包括StackOverflowException、SEHException、ExecutionEngineException、AccessViolationException。 - 抛出的新异常只适用于只能通过新参数值或新状态观察到的代码路径,旧版本代码无法命中。
- 移除异常,前提是 API 允许更稳健的行为或启用新场景。例如一个只支持正值、否则抛异常的
Divide方法,可以改成支持所有值并不再抛异常。
Disallowed
- 在上述情况之外抛出新异常。
- 在上述情况之外移除异常。
Platform Support
Allowed
- 某个操作原先在特定平台不支持,现在支持了。
Disallowed
- 某个操作原先在特定平台支持,现在不再支持,或现在需要特定 service-pack。
Code
Allowed
- 直接以提升某个操作性能为目的的变更。性能变化不能影响该 API 的其他行为,否则就是 breaking。
- 间接地、通常不利地影响性能的变更。只要该变更没有因为别的原因被归为 breaking,就是可接受的。
- 修改错误消息的文本。用户不应依赖这些文本,而且它们本来就会随 culture 变化。
- 调用一个之前未定义的全新事件。
Disallowed
- 给代码块添加
checked关键字。这可能让块内代码开始抛异常,不可接受。 - 修改事件触发顺序。开发者有理由期望事件按同样的顺序触发。
- 移除某个动作上事件的触发。
- 把同步 API 改成异步,或反过来。
- 触发一个之前从未触发过的现有事件。
- 修改给定事件的调用次数。
- 移除对
virtual/abstract成员的调用。这包括移除 finalizer,而该 finalizer 的 body 原本会调用 virtual 的Dispose(false)。
源码与二进制兼容变更
Assemblies
Allowed
- 在仍支持相同平台的前提下,让程序集变为 portable。
Disallowed
- 修改程序集的名称。
- 修改程序集的 public key。
Types
Allowed
- 当类型没有可访问的(public 或 protected)构造函数时,添加
sealed或abstract关键字。 - 提高类型的可见性。
- 引入新的基类。只要不引入新的抽象成员、不改变现有成员的语义或行为,就可以在两个已有类型之间插入类型。例如 .NET Framework 1.1 到 2.0,
DbConnection被引入为SqlConnection的新基类,而SqlConnection之前派生自Component。 - 给类型添加接口实现。添加会直接影响 designer 或 serializer 生成代码或数据、且 down-level 无法消费的接口(例如
ISerializable)时,要极度谨慎。如果接口对其他接口方法有默认接口实现,也要小心:默认实现可能和派生类中的其他默认实现冲突。 - 从类型移除接口实现,前提是该接口已经在层级更低处实现。
- 把类型从一个程序集移到另一个程序集。旧程序集必须用
TypeForwardedToAttribute指向新位置。 - 把
struct类型改成readonly struct类型。
Disallowed
- 当类型有可访问的(public 或 protected)构造函数时,添加
sealed或abstract关键字。 - 降低类型的可见性。
- 移除类型上的接口实现。如果添加的是派生自被移除接口的接口实现,则不 breaking。例如移除了
IDisposable,但实现了IComponent,而IComponent派生自IDisposable。 - 移除类型的一个或多个基类,包括把
struct改成class或反过来。 - 修改类型的命名空间或名称。
- 把
readonly struct类型改成struct类型。 - 把
struct类型改成ref struct类型,或反过来。 - 修改 enum 的底层类型。这是编译时和行为上的 breaking,也是二进制 breaking,还可能让 attribute 参数无法解析。
Members
Allowed
- 当 public 类型没有可访问的(public 或 protected)构造函数,或类型是
sealed时,添加 abstract 成员。 - 把方法移到类型层级中更高的类上。
- 提高非
virtual成员的可见性。 - 当类型没有可访问的(public 或 protected)构造函数,或类型是
sealed时,降低protected成员的可见性。 - 把成员从
abstract改成virtual。 - 引入或移除 override。注意,引入 override 可能让之前的消费者在调用
base时跳过这个 override。 - 把
ref readonly返回改成ref返回,virtual 方法或接口除外。 - 给接口添加带默认实现的接口方法。要注意带「更新」语义的默认实现,例如给
ICollection加void AddAll(IEnumerable items)。如果接口由 struct 实现,默认实现总是在装箱后的this上执行:struct 没有装箱时,运行时会替用户装箱,此时默认接口方法做的改动会丢失。
Disallowed
- 给接口添加 abstract 方法。
- 在已有接口类型(
IB)上,为已有接口方法(IA.Foo)添加默认实现。用户代码可能已经在另一个接口(IU)里为IA.Foo提供了默认实现;如果用户类型同时实现IU和IB,就会出现「菱形问题」,运行时/编译器无法确定接口调用该指向谁。这条规则同样适用于:在为非 public 接口上的 public 接口方法提供默认实现,而该非 public 接口由未 sealed 的 public 类型实现的情况。 - 当类型有可访问的(public 或 protected)构造函数且未
sealed时,给类型添加 abstract 成员。 - 给原本没有构造函数的类添加构造函数,却没有同时添加默认构造函数。
- 添加会排除已有重载、且定义了不同行为的重载。这会让原本绑定到旧重载的客户端失效。例如某个类只有一个接受
uint的方法,已有消费者直接传int值也能成功绑定;如果新增一个接受int的重载,重新编译或晚绑定的应用就会绑定到新重载。如果行为不同,那就是破坏性变更。 - 把暴露的字段移到类型层级中更高的类上。
- 移除或重命名成员,包括属性的 getter 或 setter、enum 成员。
- 当类型有可访问的(public 或 protected)构造函数且未
sealed时,降低protected成员的可见性。 - 在成员上添加或移除
abstract。 - 移除成员上的
virtual关键字。 - 给成员添加
virtual。虽然因为 C# 编译器倾向对非虚方法发出callvirtIL 指令,这个改动常常不会破坏太多场景,但不能依赖这一点。C# 不是唯一的目标语言,而且 C# 编译器越来越多地在目标方法非虚、this可证明非 null 时把callvirt优化成普通call(例如通过?.空传播运算符访问的方法)。把方法变成 virtual,意味着消费者代码往往会以非虚方式调用它。 - 把
ref返回改成ref readonly返回。 - 在 virtual 方法或接口上,把
ref readonly返回改成ref返回。 - 在成员上添加或移除
static关键字。 - 给原本无状态的 struct 添加字段。确定赋值规则允许使用未初始化变量,前提是变量类型是无状态 struct。如果 struct 变成有状态,代码就可能拿到未初始化的数据。这既是潜在的源码 breaking,也是二进制 breaking。
- 给原先不含引用类型字段、
ref字段或未加unmanaged约束的泛型参数字段的值类型,添加这些种类的字段。如果该值类型已经含有至少一个这类字段,再加一个就不 breaking。这条规则会递归适用于新字段中包含的值类型,它们也可能引入新的字段种类。 - 给接口方法添加
partial修饰符。Roslyn 有一个规范偏差,会让 partial 接口方法隐式变为非虚。给原本隐式 virtual 的接口方法添加partial时,要同时加上virtual修饰符,避免变成破坏性变更。
Signatures
Allowed
- 给参数添加
params。 - 移除字段上的
readonly,除非该字段的静态类型是可变值类型。
Disallowed
- 给字段添加
readonly。 - 给 enum 添加
FlagsAttribute。 - 修改属性、字段、参数或返回值的类型。
- 添加、移除参数,或改变参数顺序。
- 移除参数上的
params。 - 在参数上添加或移除
in、out、ref关键字。 - 重命名参数,包括只改大小写。这被视为 breaking 有两个原因:一是破坏晚绑定场景,例如 Visual Basic 的晚绑定特性和 C# 的
dynamic;二是开发者使用命名参数时会破坏源码兼容。 - 把参数修饰符从
ref改成out,或反过来。 - 在用更新后的内存安全规则编译时添加
unsafe修饰符。
Attributes
Allowed
- 修改不可观测的 attribute 的值。
Disallowed
- 移除 attribute。虽然可以逐案处理,但移除 attribute 往往会 breaking。例如
NonSerializedAttribute。 - 修改可观测 attribute 的值。
贡献者流程
- 创建或关联一个描述该破坏性变更的 issue,包含这些信息:打上 breaking-change 标签;变更的目标与动机;变更前行为;变更后行为;该变更影响的产品版本;旧代码被破坏时会看到的错误或其他行为;变通方法和缓解措施,包括 AppContext 开关;关联的功能或 bug 修复 issue 链接;在关联 PR 中引用该 issue。
- 把 issue 分享给你认为相关的 stakeholder,@ dotnet/compat team,并和评论者讨论,目标是拿到尽可能多的反馈。这可能涉及大量解释,一份写好的设计文档能减轻负担。
- 给关联 PR 打上 breaking-change 标签,并链接到你的破坏性变更 issue。
- PR 合并后,创建一个 docs issue,说明该破坏首次在哪个 .NET preview 中发布。
- 破坏性变更 issue 可以在 PR 合并后随时关闭。最佳实践是等变更在 public preview 中发布之后再关。
几条说明:
- quirk switch 是反应式添加到 .NET Core 的,只有在收到反馈说确实需要时才会加。不像 .NET Framework 那样,因为理论上可能需要就预先加上。
- 就受影响的产品版本而言,要同时考虑 .NET Core 和 .NET Framework,也要同时考虑源码兼容和二进制兼容。
示例 issue 可以参考 dotnet/runtime#28788 和 dotnet/runtime#37672。
对非 .NET 项目怎么用
这套「按公共契约分桶 + 风险收益评估」的判断法不依赖 .NET,可以搬到其他版本演进场景:
- Go module:导出标识符、接口形状、error 行为、JSON tag、默认值、排序与时序,都可能是公共契约。修正明显错误时,如果用户可能依赖旧值,也要按 breaking 评估。
- PHP 包:public class/method、参数名、返回类型、异常类型、序列化输出、默认配置,改动前先分桶。参数改名同样会破坏命名参数调用。
- 对外 HTTP/JSON 接口:新增字段通常兼容;删除或重命名字段、改类型、改默认值、改错误码、改排序或分页语义,按 breaking 处理。错误文案变化一般不视为契约。
- 兼容开关只在有真实反馈时加,不为假设场景预先埋。
- 版本号只是标签,是否 breaking 由公共契约和用户实际依赖决定。