API打破.NET中更改的最终指南

A definitive guide to API-breaking changes in .NET

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

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

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

源代码级中断-导致现有代码针对旧版本的API进行编译的API更改,可能不会使用新版本进行编译。但是,已编译的客户端程序集与以前一样工作。示例:添加一个新的重载,该重载可能会导致前面明确的方法调用中出现歧义。

源代码级静默语义更改-一种API更改,导致现有代码根据旧版本的API编写,从而静默地更改其语义,例如通过调用不同的方法。但是,代码应该继续编译,没有警告/错误,以前编译的程序集应该像以前一样工作。示例:在现有类上实现一个新接口,这将导致在重载解析期间选择不同的重载。

最终的目标是尽可能多地对中断和安静的语义API更改进行编目,并描述中断的确切效果,以及哪些语言受此影响,哪些语言不受此影响。扩展后一种语言:虽然有些更改会普遍影响所有语言(例如,向接口添加新成员将中断该接口在任何语言中的实现),但有些更改需要非常具体的语言语义来发挥作用,以获得中断。这通常涉及方法重载,并且通常与隐式类型转换有关。这里似乎没有任何方法来定义"最小公分母",即使是对于符合CLS的语言(即那些至少符合CLI规范中定义的"CLS使用者"规则的语言),不过如果有人纠正我在这里的错误,我会感激的-所以这将不得不一种语言一种语言。那些最感兴趣的自然是.NET开箱即用的:C、VB和F,但是其他的,如Ironpython、IronRuby、Delphi Prism等也有关系。一个角的例子越多,它就越有趣——像移除成员这样的事情是非常不明显的,但是方法重载、可选/默认参数、lambda类型推理和转换运算符之间的细微交互有时会非常令人惊讶。

以下是一些例子:

添加新方法重载

种类:震源级中断

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

变更前API:

1
2
3
4
public class Foo
{
    public void Bar(IEnumerable x);
}

变更后的API:

1
2
3
4
5
public class Foo
{
    public void Bar(IEnumerable x);
    public void Bar(ICloneable x);
}

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

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

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

种类:震源级中断。

受影响的语言:C,VB

不受影响的语言:f#

变更前API:

1
2
3
4
public class Foo
{
    public static implicit operator int ();
}

变更后的API:

1
2
3
4
5
public class Foo
{
    public static implicit operator int ();
    public static implicit operator float ();
}

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

1
2
3
void Bar(int x);
void Bar(float x);
Bar(new Foo());

注意:f没有中断,因为它不支持任何语言级别的重载运算符,既不显式也不隐式-两者都必须直接调用为op_Explicitop_Implicit方法。

添加新实例方法

种类:源代码级安静语义变化。

受影响的语言:C,VB

不受影响的语言:f#

变更前API:

1
2
3
public class Foo
{
}

变更后的API:

1
2
3
4
public class Foo
{
    public void Bar();
}

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

1
2
3
4
5
6
public static class FooExtensions
{
    public void Bar(this Foo foo);
}

new Foo().Bar();

注意:f没有中断,因为它没有对ExtensionMethodAttribute的语言级支持,并且需要将cls扩展方法作为静态方法调用。


更改方法签名

种类:二进制级中断

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

变更前API

1
2
3
4
public static class Foo
{
    public static void bar(int i);
}

变更后API

1
2
3
4
public static class Foo
{
    public static bool bar(int i);
}

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

1
Foo.bar(13);


添加具有默认值的参数。

中断类型:二进制级中断

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

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

变更前API

1
public void Foo(int a) { }

变更后API

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

随后中断的示例客户端代码

1
Foo(5);

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


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

将类成员重构为基类

好样的:别急!

受影响的语言:无(即无中断)

变更前API:

1
2
3
4
5
class Foo
{
    public virtual void Bar() {}
    public virtual void Baz() {}
}

变更后的API:

1
2
3
4
5
6
7
8
9
class FooBase
{
    public virtual void Bar() {}
}

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

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

1
2
3
4
5
6
7
8
// C++/CLI
ref class Derived : Foo
{
   public virtual void Baz() {{

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

笔记:

C++/CLI是唯一的.NET语言,其构造类似于虚拟基类成员的显式接口实现——"显式重写"。我完全希望这会导致与将接口成员移动到基接口时相同的破坏(因为为显式重写生成的IL与显式实现相同)。令我惊讶的是,事实并非如此——尽管生成的IL仍然规定BarOverride会覆盖Foo::Bar,而不是FooBase::Bar,但是装配加载程序足够聪明,可以正确地用一个替换另一个,而无需任何抱怨——显然,Foo是一个类这一事实才是关键所在。去想象…


这一个可能不是很明显的"添加/删除接口成员"的特殊情况,我认为它应该有自己的条目,考虑到下一个我要发布的另一个情况。所以:

将接口成员重构为基接口

种类:源代码和二进制代码都有中断

受影响的语言:C语言、VB、C++、CLI、F(源断言;二进制语言自然影响任何语言)

变更前API:

1
2
3
4
5
interface IFoo
{
    void Bar();
    void Baz();
}

变更后的API:

1
2
3
4
5
6
7
8
9
interface IFooBase
{
    void Bar();
}

interface IFoo : IFooBase
{
    void Baz();
}

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

1
2
3
4
5
class Foo : IFoo
{
   void IFoo.Bar() { ... }
   void IFoo.Baz() { ... }
}

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

1
(new Foo()).Bar();

笔记:

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

二进制中断是由于接口方法在生成的IL中对于显式实现是完全限定的,并且那里的接口名称也必须是精确的。

可用的隐式实现(即C和C++ +CLI,而不是VB)在源代码和二进制级别都能很好地工作。方法调用也不会中断。


重新排序枚举值

中断类型:源级/二进制级安静语义更改

受影响的语言:全部

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

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

变更前API

1
2
3
4
5
public enum Foo
{
   Bar,
   Baz
}

变更后API

1
2
3
4
5
public enum Foo
{
   Baz,
   Bar
}

工作但随后中断的示例客户端代码:

1
Foo.Bar < Foo.Baz

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

添加新的非重载成员

种类:源代码级中断或安静语义更改。

受影响的语言:C,VB

语言不受影响:F~(+),C++/CLI

变更前API:

1
2
3
public class Foo
{
}

变更后的API:

1
2
3
4
public class Foo
{
    public void Frob() {}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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类型推断引起的。这里使用了一种有限的duck类型,通过检查lambda的主体对于给定的类型是否有意义来打破多个类型匹配的关系-如果只有一个类型导致可编译主体,则选择该主体。

这里的危险在于,客户机代码可能有一个重载的方法组,其中一些方法使用自己类型的参数,而另一些方法使用库公开的类型的参数。如果他的任何代码依赖于类型推理算法,仅根据成员的存在或不存在来确定正确的方法,那么向您的某个类型添加一个新成员(与客户机的某个类型中的成员同名),可能会引发推理,从而导致重载解决过程中的模糊性。

注意,本例中的FooBar类型不以任何方式相关,不通过继承或其他方式。仅仅在一个方法组中使用它们就足以触发这一点,如果这发生在客户机代码中,那么您就无法控制它。

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


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

中断类型:源和二进制

受影响的语言:全部

这实际上只是改变一个方法的可访问性的一个变化——只是稍微微妙一点,因为很容易忽略这样一个事实,即并非所有对接口方法的访问都必须通过引用接口的类型来完成。

变更前API:

1
2
3
4
public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator();
}

变更后的API:

1
2
3
4
public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator();
}

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

1
new Foo().GetEnumerator(); // fails because GetEnumerator() is no longer public

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

中断类型:来源

受影响的语言:全部

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

变更前API:

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

变更后的API:

1
2
3
4
public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator() { yield return"Foo"; }
}

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

1
2
3
4
5
6
7
8
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"


将字段更改为属性

断裂类型:API

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

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

变更前API:

1
2
3
Public Class Foo    
    Public Shared Bar As String =""    
End Class

变更后的API:

1
2
3
4
5
6
7
8
9
10
11
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

工作但随后中断的示例客户端代码:

1
Foo.Bar ="foobar"


命名空间添加

源级中断/源级安静语义更改

由于在vb.net中命名空间解析的工作方式,向库中添加命名空间可能会导致用以前版本的API编译的Visual Basic代码无法用新版本编译。

示例客户端代码:

1
2
3
4
5
6
7
8
Imports System
Imports Api.SomeNamespace

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

如果新版本的API添加了名称空间Api.SomeNamespace.Data,则上述代码将不会编译。

随着项目级命名空间导入的进行,它变得更加复杂。如果上述代码中省略了Imports System,但在项目级别导入了System名称空间,则代码仍可能导致错误。

但是,如果API在其Api.SomeNamespace.Data名称空间中包含一个类DataRow,那么代码将编译,但dr在用旧版本的API编译时将是System.Data.DataRow的实例,在用新版本的API编译时将是Api.SomeNamespace.Data.DataRow的实例。

参数重命名

震源级中断

在vb.net中,更改参数名称是对版本7(?)的重大更改。(.NET版本1?)以及版本4(.NET版本4)中的C.NET。

变更前API:

1
2
3
4
5
6
7
namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}

变更后的API:

1
2
3
4
5
6
7
namespace SomeNamespace {
    public class Foo {
        public static void Bar(string y) {
           ...
        }
    }
}

示例客户端代码:

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

参考参数

震源级中断

添加一个具有相同签名的方法重写,除了一个参数是通过引用而不是通过值传递的,否则将导致引用API的VB源无法解析函数。Visual Basic无法(?)在调用点区分这些方法,除非它们有不同的参数名,因此这样的更改可能导致这两个成员在VB代码中不可用。

变更前API:

1
2
3
4
5
6
7
namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}

变更后的API:

1
2
3
4
5
6
7
8
9
10
namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
        public static void Bar(ref string x) {
           ...
        }
    }
}

示例客户端代码:

1
Api.SomeNamespace.Foo.Bar(str)

字段到属性的更改

二进制级中断/源级中断

除了明显的二进制级别中断之外,如果通过引用将成员传递给方法,这可能会导致源级别中断。

变更前API:

1
2
3
4
5
namespace SomeNamespace {
    public class Foo {
        public int Bar;
    }
}

变更后的API:

1
2
3
4
5
namespace SomeNamespace {
    public class Foo {
        public int Bar { get; set; }
    }
}

示例客户端代码:

1
FooBar(ref Api.SomeNamespace.Foo.Bar);

API更改:

  • 添加[obsolete]属性(您可以用提到的属性来覆盖它;但是,当使用warning as error时,这可能是一个破坏性的更改。)
  • 二进制级别分隔符:

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

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

    1
    2
    protected class Class3 : Class2 { }
    protected void Something<T>() where T : Class3 { }
  • 源级静默语义更改:

  • 添加/删除/更改equals()、getHashCode()或toString()的重写
  • (不确定这些适合哪里)

    部署更改:

  • 添加/删除依赖项/引用
  • 正在将依赖项更新到新版本
  • 在x86、Itanium、x64或anycpu之间更改"目标平台"
  • 在不同的框架安装上构建/测试(即在.NET 2.0框上安装3.5允许API调用然后需要.NET 2.0 SP2)
  • 引导程序/配置更改:

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

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


    添加重载方法以消亡默认参数用法

    一种突破:源代码级的安静语义变化

    由于编译器将缺少默认参数值的方法调用转换为调用端具有默认值的显式调用,因此提供了与现有编译代码的兼容性;将为所有以前编译的代码找到具有正确签名的方法。

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

    变更前API

    1
    2
    3
    4
      public int MyMethod(int mandatoryParameter, int optionalParameter = 0)
      {
         return mandatoryParameter + optionalParameter;
      }

    变更后API

    1
    2
    3
    4
    5
    6
    7
    8
    9
      public int MyMethod(int mandatoryParameter, int optionalParameter)
      {
         return mandatoryParameter + optionalParameter;
      }

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

    仍在工作的示例代码

    1
    2
    3
    4
      public int CodeNotDependentToNewVersion()
      {
         return MyMethod(5, 6);
      }

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

    1
    2
    3
    4
      public int CodeDependentToNewVersion()
      {
         return MyMethod(5);
      }

    重命名接口

    有点突破:源代码和二进制代码

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

    变更前API:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface IFoo
    {
        void Test();
    }

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

    变更后的API:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface IFooNew // Of the exact same definition as the (old) IFoo
    {
        void Test();
    }

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

    工作但随后中断的示例客户端代码:

    1
    2
    new Bar().GetFoo().Test(); // Binary only break
    IFoo foo = new Bar().GetFoo(); // Source and binary break