238

我想尽可能多地收集有关 .NET/CLR 中 API 版本控制的信息,特别是 API 更改如何破坏或不破坏客户端应用程序。首先,让我们定义一些术语:

API 更改- 公开可见的类型定义的更改,包括其任何公共成员。这包括更改类型和成员名称、更改类型的基类型、从类型的已实现接口列表中添加/删除接口、添加/删除成员(包括重载)、更改成员可见性、重命名方法和类型参数、添加默认值对于方法参数,添加/删除类型和成员的属性,以及添加/删除类型和成员的泛型类型参数(我错过了什么吗?)。这不包括成员机构的任何变化,或私人成员的任何变化(即我们不考虑反射)。

二进制级中断- API 更改导致针对旧版本 API 编译的客户端程序集可能不会与新版本一起加载。示例:更改方法签名,即使它允许以与以前相同的方式调用(即:void 返回类型/参数默认值重载)。

源代码级中断- 一种 A​​PI 更改,导致编写的现有代码针对旧版本的 API 进行编译可能无法与新版本一起编译。然而,已经编译的客户端程序集像以前一样工作。示例:添加一个新的重载可能会导致之前明确的方法调用出现歧义。

源代码级别的安静语义更改- 一种 A​​PI 更改,导致编写用于针对旧版本 API 进行编译的现有代码悄悄地更改其语义,例如通过调用不同的方法。然而,代码应该继续编译而没有警告/错误,并且以前编译的程序集应该像以前一样工作。示例:在现有类上实现一个新接口,导致在重载决议期间选择不同的重载。

最终目标是对尽可能多的中断和安静语义 API 更改进行分类,并描述中断的确切影响,以及哪些语言受其影响和不受其影响。扩展后者:虽然某些更改普遍影响所有语言(例如,向接口添加新成员将破坏该接口在任何语言中的实现),但有些更改需要非常特定的语言语义才能发挥作用才能获得突破。这通常涉及方法重载,并且通常涉及与隐式类型转换有关的任何事情。即使对于符合 CLS 的语言(即至少符合 CLI 规范中定义的“CLS 消费者”规则的语言),似乎也没有任何方法可以在这里定义“最小公分母”——尽管我 如果有人在这里纠正我的错误,我将不胜感激 - 所以这将不得不按语言进行。最感兴趣的自然是 .NET 开箱即用的那些:C#、VB 和 F#;但其他的,如 IronPython、IronRuby、Delphi Prism 等也是相关的。越是极端情况,它就越有趣——移除成员之类的事情是不言而喻的,但是方法重载、可选/默认参数、lambda 类型推断和转换运算符之间的微妙交互可能会非常令人惊讶有时。

几个例子来启动这个:

添加新的方法重载

种类:源级中断

受影响的语言:C#、VB、F#

变更前的 API:

public class Foo
{
    public void Bar(IEnumerable x);
}

变更后的API:

public class Foo
{
    public void Bar(IEnumerable x);
    public void Bar(ICloneable x);
}

示例客户端代码在更改之前工作并在更改之后中断:

new Foo().Bar(new int[0]);

添加新的隐式转换运算符重载

种类:源级中断。

受影响的语言:C#、VB

不受影响的语言:F#

变更前的 API:

public class Foo
{
    public static implicit operator int ();
}

变更后的API:

public class Foo
{
    public static implicit operator int ();
    public static implicit operator float ();
}

示例客户端代码在更改之前工作并在更改之后中断:

void Bar(int x);
void Bar(float x);
Bar(new Foo());

注意:F# 没有损坏,因为它没有任何语言级别的对重载运算符的支持,无论是显式的还是隐式的 - 都必须直接调用op_Explicitop_Implicit方法。

添加新的实例方法

Kind:源级安静的语义变化。

受影响的语言:C#、VB

不受影响的语言:F#

变更前的 API:

public class Foo
{
}

变更后的API:

public class Foo
{
    public void Bar();
}

遭受安静语义更改的示例客户端代码:

public static class FooExtensions
{
    public void Bar(this Foo foo);
}

new Foo().Bar();

注意:F# 没有损坏,因为它没有语言级别的支持ExtensionMethodAttribute,并且需要将 CLS 扩展方法作为静态方法调用。

4

17 回答 17

46

更改方法签名

种类:二进制中断

受影响的语言:C#(最有可能是 VB 和 F#,但未经测试)

变更前的 API

public static class Foo
{
    public static void bar(int i);
}

变更后的API

public static class Foo
{
    public static bool bar(int i);
}

更改前工作的示例客户端代码

Foo.bar(13);
于 2009-09-24T17:00:45.447 回答
43

添加具有默认值的参数。

中断类型:二进制级中断

即使调用源代码不需要更改,但仍然需要重新编译(就像添加常规参数时一样)。

这是因为 C# 将参数的默认值直接编译到调用程序集中。这意味着如果你不重新编译,你会得到一个 MissingMethodException,因为旧的程序集试图调用一个参数较少的方法。

变更前的 API

public void Foo(int a) { }

变更后的 API

public void Foo(int a, string b = null) { }

之后被破坏的示例客户端代码

Foo(5);

客户端代码需要Foo(5, null)在字节码级别重新编译。被调用的程序集将仅包含Foo(int, string),而不包含Foo(int)。这是因为默认参数值纯粹是一种语言特性,.Net 运行时对它们一无所知。(这也解释了为什么默认值必须是 C# 中的编译时常量)。

于 2014-05-07T12:13:02.730 回答
26

当我发现它时,这一点非常不明显,特别是考虑到接口在相同情况下的差异。这根本不是休息,但令人惊讶的是,我决定将其包括在内:

将类成员重构为基类

善良:不休息!

受影响的语言:无(即没有损坏)

变更前的 API:

class Foo
{
    public virtual void Bar() {}
    public virtual void Baz() {}
}

变更后的API:

class FooBase
{
    public virtual void Bar() {}
}

class Foo : FooBase
{
    public virtual void Baz() {}
}

在整个更改过程中保持工作的示例代码(即使我预计它会中断):

// C++/CLI
ref class Derived : Foo
{
   public virtual void Baz() {{

   // Explicit override    
   public virtual void BarOverride() = Foo::Bar {}
};

笔记:

C++/CLI 是唯一具有类似于虚拟基类成员的显式接口实现的构造的 .NET 语言 - “显式覆盖”。我完全期望这会导致与将接口成员移动到基本接口时相同的破坏(因为为显式覆盖生成的 IL 与显式实现相同)。令我惊讶的是,事实并非如此——即使生成的 IL 仍然指定BarOverrideoverridesFoo::Bar而不是FooBase::Bar,程序集加载器足够聪明,可以正确地替换另一个而没有任何抱怨——显然,Foo是一个类这一事实造成了不同。去搞清楚...

于 2009-10-05T22:24:58.403 回答
21

这可能是“添加/删除接口成员”的一个不那么明显的特殊情况,我认为根据我接下来要发布的另一种情况,它应该有自己的条目。所以:

将接口成员重构为基本接口

种类:在源代码和二进制级别都中断

受影响的语言:C#、VB、C++/CLI、F#(用于源代码中断;二进制自然会影响任何语言)

变更前的 API:

interface IFoo
{
    void Bar();
    void Baz();
}

变更后的API:

interface IFooBase 
{
    void Bar();
}

interface IFoo : IFooBase
{
    void Baz();
}

在源代码级别被更改破坏的示例客户端代码:

class Foo : IFoo
{
   void IFoo.Bar() { ... }
   void IFoo.Baz() { ... }
}

在二进制级别被更改破坏的示例客户端代码;

(new Foo()).Bar();

笔记:

对于源码级别中断,问题是C#、VB和C++/CLI都需要在接口成员实现的声明中准确的接口名称;因此,如果成员被移动到基接口,代码将不再编译。

二进制中断是因为接口方法在生成的 IL 中完全限定了显式实现,并且接口名称也必须准确。

可用的隐式实现(即 C# 和 C++/CLI,但不是 VB)在源代码和二进制级别都可以正常工作。方法调用也不会中断。

于 2009-10-05T22:18:20.773 回答
16

重新排序枚举值

中断类型:源级/二进制级安静语义变化

受影响的语言:所有

重新排序枚举值将保持源代码级别的兼容性,因为文字具有相同的名称,但它们的序号索引将被更新,这可能会导致某些类型的无声源代码级别中断。

更糟糕的是,如果客户端代码没有针对新的 API 版本重新编译,可能会引入无声的二进制级中断。枚举值是编译时常量,因此它们的任何使用都被烘焙到客户端程序集的 IL 中。这种情况有时特别难以发现。

变更前的 API

public enum Foo
{
   Bar,
   Baz
}

变更后的 API

public enum Foo
{
   Baz,
   Bar
}

示例客户端代码有效但之后被破坏:

Foo.Bar < Foo.Baz
于 2014-01-04T20:47:52.540 回答
13

这在实践中确实是一件非常罕见的事情,但当它发生时仍然令人惊讶。

添加新的非重载成员

种类:源级别中断或安静的语义更改。

受影响的语言:C#、VB

不受影响的语言:F#、C++/CLI

变更前的 API:

public class Foo
{
}

变更后的API:

public class Foo
{
    public void Frob() {}
}

被更改破坏的示例客户端代码:

class Bar
{
    public void Frob() {}
}

class Program
{
    static void Qux(Action<Foo> a)
    {
    }

    static void Qux(Action<Bar> a)
    {
    }

    static void Main()
    {
        Qux(x => x.Frob());        
    }
}

笔记:

这里的问题是由 C# 和 VB 中存在重载决议的 lambda 类型推断引起的。这里采用了一种有限形式的鸭子类型,通过检查 lambda 的主体是否对给定类型有意义来打破多于一种类型匹配的关系——如果只有一种类型导致可编译的主体,则选择该类型。

这里的危险是客户端代码可能有一个重载的方法组,其中一些方法接受他自己类型的参数,而其他方法接受你的库公开的类型的参数。如果他的任何代码然后依赖类型推断算法来仅根据成员的存在或不存在来确定正确的方法,那么将新成员添加到您的类型之一与客户端类型之一中的名称相同可能会引发推断关闭,导致在过载解决过程中产生歧义。

请注意,在此示例中,类型FooBar不以任何方式相关,不通过继承或其他方式。仅仅在单个方法组中使用它们就足以触发这种情况,如果这发生在客户端代码中,您将无法控制它。

上面的示例代码演示了一个更简单的情况,即这是一个源级中断(即编译器错误结果)。但是,如果通过推理选择的重载具有其他参数,否则这将导致其排在下面(例如,具有默认值的可选参数,或声明的参数和实际参数之间的类型不匹配,需要隐式转换)。在这种情况下,重载决议将不再失败,但编译器会悄悄地选择不同的重载。然而,在实践中,如果不仔细构造方法签名来故意导致这种情况,就很难遇到这种情况。

于 2011-02-21T23:14:19.520 回答
11

Convert an implicit interface implementation into an explicit one.

Kind of Break: Source and Binary

Languages Affected: All

This is really just a variation of changing a method's accessibility - its just a little more subtle since it's easy to overlook the fact that not all access to an interface's methods are necessarily through a reference to the type of the interface.

API Before Change:

public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator();
}

API After Change:

public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator();
}

Sample Client code that works before change and is broken afterwards:

new Foo().GetEnumerator(); // fails because GetEnumerator() is no longer public
于 2009-10-30T17:31:57.617 回答
7

将字段更改为属性

中断类型:API

受影响的语言:Visual Basic 和 C#*

信息:当您将普通字段或变量更改为 Visual Basic 中的属性时,任何以任何方式引用该成员的外部代码都需要重新编译。

变更前的 API:

Public Class Foo    
    Public Shared Bar As String = ""    
End Class

更改后的 API:

Public Class Foo
    Private Shared _Bar As String = ""
    Public Shared Property Bar As String
        Get
            Return _Bar
        End Get
        Set(value As String)
            _Bar = value
        End Set
    End Property
End Class    

示例客户端代码有效但之后被破坏:

Foo.Bar = "foobar"
于 2013-11-21T18:08:46.020 回答
7

将显式接口实现转换为隐式接口实现。

休息类型:来源

受影响的语言:所有

将显式接口实现重构为隐式实现在如何破坏 API 方面更为微妙。从表面上看,这似乎应该是相对安全的,但是当与继承结合时,它可能会导致问题。

变更前的 API:

public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator() { yield return "Foo"; }
}

更改后的 API:

public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator() { yield return "Foo"; }
}

示例客户端代码在更改之前工作并在之后被破坏:

class Bar : Foo, IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator() // silently hides base instance
    { yield return "Bar"; }
}

foreach( var x in new Bar() )
    Console.WriteLine(x);    // originally output "Bar", now outputs "Foo"
于 2009-10-30T18:10:18.617 回答
6

Namespace Addition

Source-level break / Source-level quiet semantics change

Due to the way namespace resolution works in vb.Net, adding a namespace to a library can cause Visual Basic code that compiled with a previous version of the API to not compile with a new version.

Sample client code:

Imports System
Imports Api.SomeNamespace

Public Class Foo
    Public Sub Bar()
        Dim dr As Data.DataRow
    End Sub
End Class

If a new version of the API adds the namespace Api.SomeNamespace.Data, then the above code will not compile.

It becomes more complicated with project-level namespace imports. If Imports System is omitted from the above code, but the System namespace is imported at the project level, then the code may still result in an error.

However, if the Api includes a class DataRow in its Api.SomeNamespace.Data namespace, then the code will compile but dr will be an instance of System.Data.DataRow when compiled with the old version of the API and Api.SomeNamespace.Data.DataRow when compiled with the new version of the API.

Argument Renaming

Source-level break

Changing the names of arguments is a breaking change in vb.net from version 7(?) (.Net version 1?) and c#.net from version 4 (.Net version 4).

API before change:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}

API after change:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string y) {
           ...
        }
    }
}

Sample client code:

Api.SomeNamespace.Foo.Bar(x:"hi"); //C#
Api.SomeNamespace.Foo.Bar(x:="hi") 'VB

Ref Parameters

Source-level break

Adding a method override with the same signature except that one parameter is passed by reference instead of by value will cause vb source that references the API to be unable to resolve the function. Visual Basic has no way(?) to differentiate these methods at the call point unless they have different argument names, so such a change could cause both members to be unusable from vb code.

API before change:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}

API after change:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
        public static void Bar(ref string x) {
           ...
        }
    }
}

Sample client code:

Api.SomeNamespace.Foo.Bar(str)

Field to Property Change

Binary-level break/Source-level break

Besides the obvious binary-level break, this can cause a source-level break if the member is passed to a method by reference.

API before change:

namespace SomeNamespace {
    public class Foo {
        public int Bar;
    }
}

API after change:

namespace SomeNamespace {
    public class Foo {
        public int Bar { get; set; }
    }
}

Sample client code:

FooBar(ref Api.SomeNamespace.Foo.Bar);
于 2013-10-23T03:00:27.570 回答
4

API变更:

  1. 添加 [Obsolete] 属性(您在提及属性时有点涵盖了这一点;但是,当使用 warning-as-error 时,这可能是一个重大更改。)

二进制中断:

  1. 将类型从一个程序集移动到另一个程序集
  2. 更改类型的命名空间
  3. 从另一个程序集中添加基类类型。
  4. 添加一个新成员(受事件保护),该成员使用来自另一个程序集 (Class2) 的类型作为模板参数约束。

    protected void Something<T>() where T : Class2 { }
    
  5. 当该类用作此类的模板参数时,将子类 (Class3) 更改为从另一个程序集中的类型派生。

    protected class Class3 : Class2 { }
    protected void Something<T>() where T : Class3 { }
    

源级安静语义变化:

  1. 添加/删除/更改 Equals()、GetHashCode() 或 ToString() 的覆盖

(不确定这些适合哪里)

部署更改:

  1. 添加/删除依赖项/引用
  2. 将依赖项更新到较新的版本
  3. 在 x86、Itanium、x64 或 anycpu 之间更改“目标平台”
  4. 在不同的框架安装上构建/测试(即在 .Net 2.0 机器上安装 3.5 允许随后需要 .Net 2.0 SP2 的 API 调用)

引导程序/配置更改:

  1. 添加/删除/更改自定义配置选项(即 App.config 设置)
  2. 随着当今应用程序中大量使用 IoC/DI,有必要为依赖于 DI 的代码重新配置和/或更改引导代码。

更新:

抱歉,我没有意识到这对我来说破坏的唯一原因是我在模板约束中使用了它们。

于 2009-10-08T17:24:14.687 回答
3

添加重载方法以终止默认参数的使用

中断类型:源级安静语义更改

因为编译器将缺少默认参数值的方法调用转换为在调用方使用默认值的显式调用,所以给出了对现有编译代码的兼容性;将为所有先前编译的代码找到具有正确签名的方法。

另一方面,不使用可选参数的调用现在被编译为对缺少可选参数的新方法的调用。一切仍然正常,但是如果被调用的代码驻留在另一个程序集中,则调用它的新编译代码现在依赖于该程序集的新版本。部署调用重构代码的程序集而不部署重构代码所在的程序集会导致“未找到方法”异常。

变更前的 API

  public int MyMethod(int mandatoryParameter, int optionalParameter = 0)
  {
     return mandatoryParameter + optionalParameter;
  }    

变更后的API

  public int MyMethod(int mandatoryParameter, int optionalParameter)
  {
     return mandatoryParameter + optionalParameter;
  }

  public int MyMethod(int mandatoryParameter)
  {
     return MyMethod(mandatoryParameter, 0);
  }

仍然可以工作的示例代码

  public int CodeNotDependentToNewVersion()
  {
     return MyMethod(5, 6); 
  }

编译时现在依赖于新版本的示例代码

  public int CodeDependentToNewVersion()
  {
     return MyMethod(5); 
  }
于 2016-11-16T09:37:13.540 回答
1

重命名接口

有点中断:源代码和二进制文件

受影响的语言:很可能是所有语言,在 C# 中测试。

变更前的 API:

public interface IFoo
{
    void Test();
}

public class Bar
{
    IFoo GetFoo() { return new Foo(); }
}

更改后的 API:

public interface IFooNew // Of the exact same definition as the (old) IFoo
{
    void Test();
}

public class Bar
{
    IFooNew GetFoo() { return new Foo(); }
}

示例客户端代码有效但之后被破坏:

new Bar().GetFoo().Test(); // Binary only break
IFoo foo = new Bar().GetFoo(); // Source and binary break
于 2012-11-19T14:46:59.870 回答
1

使用可为空类型的参数重载方法

种类:源级中断

受影响的语言:C#、VB

变更前的 API:

public class Foo
{
    public void Bar(string param);
}

变更后的API:

public class Foo
{
    public void Bar(string param);
    public void Bar(int? param);
}

示例客户端代码在更改之前工作并在更改之后中断:

new Foo().Bar(null);

例外:以下方法或属性之间的调用不明确。

于 2019-07-18T13:02:57.097 回答
1

推广到扩展方法

种类:源级中断

受影响的语言:C# v6 及更高版本(可能是其他语言?)

变更前的 API:

public static class Foo
{
    public static void Bar(string x);
}

变更后的API:

public static class Foo
{
    public void Bar(this string x);
}

示例客户端代码在更改之前工作并在更改之后中断:

using static Foo;

class Program
{
    static void Main() => Bar("hello");
}

更多信息:https ://github.com/dotnet/csharplang/issues/665

于 2019-05-23T17:29:18.730 回答
0

Visual Studio 扩展 NDepend 在 API Breaking Changes 类别中提供了几个规则来检测二进制级别中断仅当定义了NDepend 基线时,才会执行这些规则。

  • API 重大更改:类型:如果某个类型在基线中公开可见、不再公开可见或已被删除,则此规则会发出警告。使用这种类型的客户端代码将被破坏。
  • API 重大更改:方法:如果方法在基线中公开可见、不再公开可见或已被删除,则此规则会发出警告。使用这种方法的客户端代码将被破坏。请注意,如果方法签名被更改,则旧方法版本被视为已删除,而新方法版本被视为已添加,因此将在旧方法版本上检测到重大更改。
  • API 重大更改:字段:如果某个字段在基线中公开可见、不再公开可见或已被删除,则此规则会发出警告。使用此类字段的客户代码将被破坏。
  • API 重大更改:接口和抽象类:如果公开可见的接口或抽象类已更改并包含新的抽象方法或某些抽象方法已被删除,则此规则会发出警告。实现此类接口或从此类抽象类派生的客户端代码将被破坏。
  • 损坏的可序列化类型:此规则警告使用SerializableAttribute标记的类型的中断更改。为此,此规则搜索自基线以来添加或删除的可序列化实例字段的可序列化类型。请注意,它没有考虑使用NonSerializedAttribute标记的字段。
  • 避免更改枚举标志状态:此规则匹配过去在基线中使用FlagsAttribute标记的枚举类型,而不再匹配。它还匹配相反的枚举类型,这些类型现在用FlagsAttribute标记,并且没有在基线中标记。使用FlagsAttribute标记是枚举的一个强大属性。与其说是在行为方面(只有enum.ToString()方法的行为在使用FlagsAttribute标记枚举时发生变化),但在含义方面:枚举是一个值范围还是一个标志范围?

还提出了 3 个代码查询让用户浏览新的公共 API 元素:

于 2020-08-26T08:29:44.647 回答
0

静态只读转换为 const

种类:二进制中断

受影响的语言:C#、VB 和 F#

变更前的 API:

public static class Foo
{
    public static readonly string Bar = "Value";
}

变更后的API:

public static class Foo
{
    public const string Bar = "Value";
}

所有客户端都需要重新编译以针对新更改,否则MissingFieldException会抛出 a。

于 2020-11-03T14:54:48.747 回答