.NET中API破坏性变更的权威指南

244
我想尽可能收集关于.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:
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方法。

添加新实例方法

类型:源代码层面的安静语义变化。

受影响的语言: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扩展方法作为静态方法调用。


2
@Robert:你的链接是关于完全不同的东西——它描述了_.NET Framework_本身的_具体_破坏性变化。这是一个更广泛的问题,描述了可能会在_你自己的_ API(作为库/框架作者)中引入_破坏性变化_的_通用_模式。我不知道微软是否有任何完整的文档,但是任何这样的链接,即使是不完整的链接,都是非常受欢迎的。 - Pavel Minaev
1
是的,“二进制破坏”类别。在这种情况下,您已经有一个针对您程序集所有版本编译的第三方程序集。如果您直接替换新版本程序集,第三方程序集将停止工作 - 它可能无法在运行时加载,或者工作不正确。 - Pavel Minaev
@Pavel:罗伯特的链接是具体的,但如果我们将他们提到的例子概括一下,它可能仍然有用。 - Joren
3
我会在帖子和评论中添加这些内容 http://blogs.msdn.com/b/ericlippert/archive/2012/01/09/every-public-change-is-a-breaking-change.aspx - Lukasz Madon
1
有人知道有没有一个免费的工具可以报告从程序集A到程序集B的这些变化吗?(我知道NDepend) - JJS
显示剩余7条评论
17个回答

48

更改方法签名

类型: 二进制级别的变化

受影响的编程语言: 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);

16
如果有人试图为 bar 创建一个委托,那么实际上它也可以是源级别的破坏。 - Pavel Minaev
1
这归结于返回类型不计算方法签名的事实。您也不能仅基于返回类型重载两个函数。同样的问题。 - Jason Short
1
对这个答案的一个子问题:有人知道添加一个dotnet4默认值'public static void bar(int i = 0);'的含义吗?或者将该默认值从一个值更改为另一个值会有什么影响? - k3b
@k3b: 旧的编译二进制文件不会捕捉到新的默认值,而是将继续使用旧的默认值(类似于“const”)。如果涉及的方法是虚拟的或在接口上,这也会对VB产生源级别的破坏,因为当重写/实现时,VB要求默认值完全匹配。我可能会将其转化为单独的答案。 - Pavel Minaev
2
对于那些即将登陆此页面的人,我认为对于C#(以及“我认为”大多数其他面向对象编程语言),返回类型不会对方法签名产生影响。是的,答案正确,签名更改会对二进制级别的更改产生影响。__但是__,在我看来,示例似乎不正确。我能想到的正确示例是:之前 public decimal Sum(int a,int b)之后 public decimal Sum(decimal a, decimal b)请参考此MSDN链接3.6 签名和重载 - Bhanu Chhabra
显示剩余3条评论

45

添加一个带有默认值的参数。

破坏类型:二进制级别的破坏

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

这是因为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#中的默认值必须是编译时常量)。

3
这是一个连源代码层面都会产生重大变化的更新: Func<int> f = Foo; // 这个语句在新的签名下将会失败 - Vagaus

26

当我发现这个技巧时,它非常不明显,尤其是与接口的相同情况相比。这根本不是中断(break),但它足够令人惊讶,以至于我决定将其包含在内:

将类成员重构为基类成员

类型:不算中断!

受影响的语言:没有(即没有被破坏)

变更前 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仍然指定BarOverride覆盖了Foo::Bar而不是FooBase::Bar,程序集加载器也足够智能,可以正确地将其替换为另一个而且没有任何抱怨 - 显然,Foo是一个类是有区别的。明白了...


3
只要基类在同一程序集中,否则这将会是一项二进制破坏性改变。 - Jeremy
@Jeremy 在这种情况下会有哪种代码出现问题?任何外部调用者使用Baz()都会出现问题吗?还是只有那些试图扩展Foo并覆盖Baz()的人才会出现问题? - ChaseMedallion
@ChaseMedallion,如果你是二手用户,可能会出现问题。例如,已编译的 DLL 引用了旧版本的 Foo,而你引用了该编译的 DLL,但同时又使用了更新版本的 Foo DLL。这会导致一个奇怪的错误,至少在我之前开发的库中出现过这种情况。 - Jeremy

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)将在源代码和二进制级别上正常工作。 方法调用也不会出错。


这并不适用于所有编程语言。对于VB来说,这不是一个破坏性的源代码更改。但对于C#来说,它是破坏性的。 - Jeremy
那么 Implements IFoo.Bar 会透明地引用到 IFooBase.Bar 吗? - Pavel Minaev
是的,实际上可以通过实现继承的接口直接或间接地引用成员。但是,这总是一种破坏二进制兼容性的改变。 - Jeremy

16

重新排序枚举值

变更类型:源代码级/二进制级的静默语义更改

受影响的语言:所有语言

重新排序枚举值将保持源代码级别的兼容性,因为文字名称相同,但它们的序数索引将被更新,这可能会导致某些静默的源代码级别的问题。

更糟糕的是,如果客户端代码没有重新编译以针对新的API版本,则可能会导致静默的二进制级别的问题。枚举值是编译时常量,因此对它们的任何使用都嵌入到了客户端程序集的IL中。有时,这种情况尤其难以发现。

变更前的API

public enum Foo
{
   Bar,
   Baz
}

API更改后

public enum Foo
{
   Baz,
   Bar
}

工作但之后会出现问题的示例客户端代码:

Foo.Bar < Foo.Baz

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类型推断与重载解析结合使用引起的。 这里采用了有限形式的duck typing来打破选择多个匹配类型的平局,通过检查lambda体是否针对给定类型有意义来确定 - 如果只有一个类型的体得到编译,则选择该类型。
危险在于客户端代码可能具有重载的方法组,其中某些方法接受他自己的类型的参数,而其他方法接受您的库公开的类型的参数。 如果他的任何代码依赖于类型推断算法仅根据成员的存在或不存在来确定正确的方法,那么向其中一个客户端类型添加与其名称相同的新成员可能会使推断出现偏差,从而导致重载解析期间的歧义。
请注意,此示例中的Foo和Bar类型没有任何关系,既不通过继承也不通过其他方式。 仅在单个方法组中使用它们就足以触发此操作,如果发生在客户端代码中,则无法控制它。
上面的示例代码演示了一个更简单的情况,其中这是源级别的错误(即编译器错误结果)。 但是,如果通过推断选择的重载具有其他参数,否则将导致其排名低于其他重载(例如具有默认值的可选参数或者声明和实际参数之间的类型不匹配需要隐式转换),则这也可能是静默语义更改。 在这种情况下,重载解析将不再失败,但编译器将悄悄地选择不同的重载。 但是,在实践中,很难在不仔细构造方法签名以故意引起此问题的情况下遇到此问题。

11

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

受影响的类型:所有类型

这只是一种改变方法可访问性的变体——它只是有点微妙,因为很容易忽视并非所有访问接口方法的方式都是通过接口类型的引用。

更改前的API:

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

API变更后:

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

变更前有效的示例客户端代码,但变更后失效:

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

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"

抱歉,我不太明白 - 在API更改之前的示例代码肯定根本无法编译,因为在更改之前Foo没有名为GetEnumerator的公共方法,并且您正在通过类型为Foo的引用调用该方法... - Pavel Minaev
事实上,我试图从记忆中简化一个例子,结果变成了“foobar”(请原谅双关语)。我更新了这个例子以正确地演示情况(并且可以编译)。 - LBushkin
在我的例子中,问题不仅仅是由于接口方法从隐式转换为公共所引起的。它取决于C#编译器确定在foreach循环中调用哪个方法的方式。根据编译器使用的解析规则,它会从派生类的版本切换到基类的版本。 - LBushkin
你忘记了 yield return "Bar" :) 但是,我现在明白这个问题的方向了——foreach总是调用名为GetEnumerator的公共方法,即使它不是IEnumerable.GetEnumerator的真正实现。这似乎还有一个角度:即使你只有一个类,并且它显式地实现了IEnumerable,这意味着向其中添加一个名为GetEnumerator的公共方法是一种破坏性的更改,因为现在foreach将使用该方法而不是接口实现。同样的问题也适用于IEnumerator实现... - Pavel Minaev

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"

2
这实际上会在 C# 中破坏东西,因为属性不能用于方法的 outref 参数,不像字段,并且不能作为一元 & 运算符的目标。 - Pavel Minaev

6

命名空间添加

源代码级别中断/源代码级别安静语义改变

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

示例客户端代码:

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,那么该代码将编译,但在使用旧版本的API编译时dr将是System.Data.DataRow的实例,在使用新版本的API编译时将是Api.SomeNamespace.Data.DataRow的实例。

参数重命名

源代码级别的破坏 在vb.net 7版(.Net版本1)和c#.net 4版(.Net版本4)中,更改参数名称是一个破坏性的变化。
更改前的API:
namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}

更改后的API:

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

示例客户端代码:

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

Ref参数

源代码级别中断

如果添加一个方法覆盖,其签名相同,除了一个参数由引用而不是值传递,将导致引用API的vb源代码无法解析该函数。Visual Basic在调用点无法区分这些方法(除非它们具有不同的参数名称),因此这种更改可能会导致vb代码无法使用两个成员。

更改前的API:

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

更改后的API:

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

示例客户端代码:

Api.SomeNamespace.Foo.Bar(str)

字段到属性的更改

二进制级别的断点/源代码级别的断点

除了明显的二进制级别的断点外,如果成员被按引用传递给方法,这可能会导致源代码级别的断点。

更改前的API:

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

更改后的API:

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

样例客户端代码:

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接