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

Pav*_*aev 219 .net versioning api clr cls-compliant

我想尽可能多地收集有关.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);
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

public class Foo
{
    public void Bar(IEnumerable x);
    public void Bar(ICloneable x);
}
Run Code Online (Sandbox Code Playgroud)

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

new Foo().Bar(new int[0]);
Run Code Online (Sandbox Code Playgroud)

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

种类:源级休息.

受影响的语言:C#,VB

语言不受影响:F#

更改前的API:

public class Foo
{
    public static implicit operator int ();
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

public class Foo
{
    public static implicit operator int ();
    public static implicit operator float ();
}
Run Code Online (Sandbox Code Playgroud)

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

void Bar(int x);
void Bar(float x);
Bar(new Foo());
Run Code Online (Sandbox Code Playgroud)

注意:F#没有被破坏,因为它没有任何语言级别的支持重载运算符,既不显式也不隐式 - 都必须直接调用op_Explicitop_Implicit方法.

添加新的实例方法

种类:源级静默语义变化.

受影响的语言:C#,VB

语言不受影响:F#

更改前的API:

public class Foo
{
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

public class Foo
{
    public void Bar();
}
Run Code Online (Sandbox Code Playgroud)

样本客户端代码遭受安静的语义更改:

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

new Foo().Bar();
Run Code Online (Sandbox Code Playgroud)

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

Jus*_*ury 40

更改方法签名

种类:二级休息

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

更改前的API

public static class Foo
{
    public static void bar(int i);
}
Run Code Online (Sandbox Code Playgroud)

更改后的API

public static class Foo
{
    public static bool bar(int i);
}
Run Code Online (Sandbox Code Playgroud)

变更之前的客户端代码示例

Foo.bar(13);
Run Code Online (Sandbox Code Playgroud)

  • 事实上,如果有人试图为`bar`创建委托,它也可以是源级别的中断. (13认同)
  • __对于那些将要登陆此页面的人__我认为对于 C#(以及“我认为”大多数其他 OOP 语言),返回类型对方法签名没有贡献。**是的** 答案是正确的,签名更改会导致二进制级别更改。__但是__这个例子似乎不正确恕我直言,我能想到的正确例子是 **BEFORE** publicdecimalSum(int a,int b) **After**publicdecimalSum(decimala,decimalb) 请参考此 MSDN 链接 [3.6 签名和重载](https://msdn.microsoft.com/en-us/library/aa691131(v=vs.71).aspx) (2认同)

Eld*_*rum 39

添加具有默认值的参数.

休息时间:二元级休息

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

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

更改前的API

public void Foo(int a) { }
Run Code Online (Sandbox Code Playgroud)

变更后的API

public void Foo(int a, string b = null) { }
Run Code Online (Sandbox Code Playgroud)

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

Foo(5);
Run Code Online (Sandbox Code Playgroud)

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

  • 即使对于源代码级别,这也是一个重大更改:`Func<int> f = Foo;` // 这将因更改的签名而失败 (3认同)

Pav*_*aev 26

当我发现它时,这个非常不明显,特别是考虑到界面相同情况的不同.这根本不是休息,但令人惊讶的是我决定将它包括在内:

将类成员重构为基类

善良:不休息!

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

更改前的API:

class Foo
{
    public virtual void Bar() {}
    public virtual void Baz() {}
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

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

class Foo : FooBase
{
    public virtual void Baz() {}
}
Run Code Online (Sandbox Code Playgroud)

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

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

   // Explicit override    
   public virtual void BarOverride() = Foo::Bar {}
};
Run Code Online (Sandbox Code Playgroud)

笔记:

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

  • 只要基类在同一个程序集中.否则它是一个二进制的破坏性变化. (3认同)

Pav*_*aev 19

这是一个可能不那么明显的"添加/删除接口成员"的特殊情况,我认为它应该根据我接下来要发布的另一个案例进行自己的输入.所以:

将接口成员重构为基接口

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

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

更改前的API:

interface IFoo
{
    void Bar();
    void Baz();
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

interface IFooBase 
{
    void Bar();
}

interface IFoo : IFooBase
{
    void Baz();
}
Run Code Online (Sandbox Code Playgroud)

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

class Foo : IFoo
{
   void IFoo.Bar() { ... }
   void IFoo.Baz() { ... }
}
Run Code Online (Sandbox Code Playgroud)

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

(new Foo()).Bar();
Run Code Online (Sandbox Code Playgroud)

笔记:

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

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

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


glo*_*pes 15

重新排序枚举值

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

受影响的语言:全部

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

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

更改前的API

public enum Foo
{
   Bar,
   Baz
}
Run Code Online (Sandbox Code Playgroud)

变更后的API

public enum Foo
{
   Baz,
   Bar
}
Run Code Online (Sandbox Code Playgroud)

示例客户端代码可以工作但之后会被破坏:

Foo.Bar < Foo.Baz
Run Code Online (Sandbox Code Playgroud)


Pav*_*aev 11

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

添加新的非重载成员

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

受影响的语言:C#,VB

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

更改前的API:

public class Foo
{
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

public class Foo
{
    public void Frob() {}
}
Run Code Online (Sandbox Code Playgroud)

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

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());        
    }
}
Run Code Online (Sandbox Code Playgroud)

笔记:

这里的问题是由C#和VB中的lambda类型推断引起的,存在重载决策.这里使用有限形式的鸭子打字来打破多个类型匹配的关系,通过检查lambda的主体是否对给定类型有意义 - 如果只有一种类型导致可编辑的身体,那么选择一个.

这里的危险是客户端代码可能有一个重载的方法组,其中一些方法接受他自己的类型的参数,而其他方法接受由库公开的类型的参数.如果他的任何代码依赖于类型推断算法来确定仅基于成员的存在或不存在的正确方法,那么将新成员添加到与其中一个客户端类型相同名称的类型之一可能会推断推断off,导致重载决策过程中出现歧义.

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

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


LBu*_*kin 9

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

休息:源和二进制

受影响的语言:全部

这实际上只是改变方法可访问性的一种变体 - 它只是更微妙一点,因为很容易忽略这样一个事实:并非所有对接口方法的访问都必须通过对接口类型的引用.

更改前的API:

public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator();
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator();
}
Run Code Online (Sandbox Code Playgroud)

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

new Foo().GetEnumerator(); // fails because GetEnumerator() is no longer public
Run Code Online (Sandbox Code Playgroud)


LBu*_*kin 7

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

休息时间:来源

受影响的语言:全部

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

更改前的API:

public class Foo : IEnumerable
{
    IEnumerator IEnumerable.GetEnumerator() { yield return "Foo"; }
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

public class Foo : IEnumerable
{
    public IEnumerator GetEnumerator() { yield return "Foo"; }
}
Run Code Online (Sandbox Code Playgroud)

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

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"
Run Code Online (Sandbox Code Playgroud)


Hag*_*t18 6

将字段更改为属性

休息时间:API

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

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

更改前的API:

Public Class Foo    
    Public Shared Bar As String = ""    
End Class
Run Code Online (Sandbox Code Playgroud)

更改后的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    
Run Code Online (Sandbox Code Playgroud)

示例客户端代码可以工作但之后会被破坏:

Foo.Bar = "foobar"
Run Code Online (Sandbox Code Playgroud)

  • 它实际上也会破坏C#中的东西,因为属性不能用于方法的`out`和`ref`参数,与字段不同,并且不能成为一元`&`运算符的目标. (2认同)

jsw*_*f19 5

命名空间添加

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

由于名称空间解析在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
Run Code Online (Sandbox Code Playgroud)

如果新版本的API添加了命名空间Api.SomeNamespace.Data,则以上代码将无法编译。

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

但是,如果Api DataRow在其Api.SomeNamespace.Data命名空间中包含一个类,则代码将进行编译,但dr将成为System.Data.DataRow使用旧版本的API Api.SomeNamespace.Data.DataRow进行编译以及使用新版本的API进行编译时的实例。

参数重命名

源代码级中断

更改参数名称是vb.net从版本7(?)(.Net版本1?)和c#.net从版本4(.Net版本4)起的重大变化。

更改前的API:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string y) {
           ...
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

客户端代码示例:

Api.SomeNamespace.Foo.Bar(x:"hi"); //C#
Api.SomeNamespace.Foo.Bar(x:="hi") 'VB
Run Code Online (Sandbox Code Playgroud)

参考参数

源代码级中断

添加具有相同签名的方法替代,只是一个参数是通过引用而不是通过值传递的,将导致引用该API的vb源无法解析该函数​​。除非它们具有不同的参数名称,否则Visual Basic无法在调用点上区分这些方法,因此更改可能导致两个成员都无法从vb代码中使用。

更改前的API:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

namespace SomeNamespace {
    public class Foo {
        public static void Bar(string x) {
           ...
        }
        public static void Bar(ref string x) {
           ...
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

客户端代码示例:

Api.SomeNamespace.Foo.Bar(str)
Run Code Online (Sandbox Code Playgroud)

改变财产的领域

二进制级中断/源级中断

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

更改前的API:

namespace SomeNamespace {
    public class Foo {
        public int Bar;
    }
}
Run Code Online (Sandbox Code Playgroud)

更改后的API:

namespace SomeNamespace {
    public class Foo {
        public int Bar { get; set; }
    }
}
Run Code Online (Sandbox Code Playgroud)

客户端代码示例:

FooBar(ref Api.SomeNamespace.Foo.Bar);
Run Code Online (Sandbox Code Playgroud)