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

API change - a change in the publicly visible definition of a type, including any of its public members. This includes changing type and member names, changing base type of a type, adding/removing interfaces from list of implemented interfaces of a type, adding/removing members (including overloads), changing member visibility, renaming method and type parameters, adding default values for method parameters, adding/removing attributes on types and members, and adding/removing generic type parameters on types and members (did I miss anything?). This does not include any changes in member bodies, or any changes to private members (i.e. we do not take into account Reflection).

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

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

源代码级安静的语义变化——API的变化会导致编写的现有代码针对旧版本的API悄悄地改变其语义,例如通过调用不同的方法。然而,代码应该继续编译,没有警告/错误,以前编译的程序集应该像以前一样工作。示例:在现有类上实现一个新接口,这会导致在重载解析过程中选择不同的重载。

The ultimate goal is to catalogize as many breaking and quiet semantics API changes as possible, and describe exact effect of breakage, and which languages are and are not affected by it. To expand on the latter: while some changes affect all languages universally (e.g. adding a new member to an interface will break implementations of that interface in any language), some require very specific language semantics to enter into play to get a break. This most typically involves method overloading, and, in general, anything having to do with implicit type conversions. There doesn't seem to be any way to define the "least common denominator" here even for CLS-conformant languages (i.e. those conforming at least to rules of "CLS consumer" as defined in CLI spec) - though I'll appreciate if someone corrects me as being wrong here - so this will have to go language by language. Those of most interest are naturally the ones that come with .NET out of the box: C#, VB and F#; but others, such as IronPython, IronRuby, Delphi Prism etc are also relevant. The more of a corner case it is, the more interesting it will be - things like removing members are pretty self-evident, but subtle interactions between e.g. method overloading, optional/default parameters, lambda type inference, and conversion operators can be very surprising at times.

这里有几个例子:

添加新的方法重载

Kind:源级中断

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

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

Kind:源级中断。

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


更改方法签名

类型:二进制级别的中断

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

这可能是一个不太明显的“添加/删除接口成员”的特殊情况,我认为它应该根据我接下来要发布的另一个情况单独进入。所以:

将接口成员重构为基接口

Kind:源级和二进制级都中断

受影响的语言: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)在源代码和二进制级别上都可以很好地工作。方法调用也不会中断。


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

将类成员重构为基类

Kind:不休息!

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

更改前的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 is the only .NET language that has a construct analogous to explicit interface implementation for virtual base class members - "explicit override". I fully expected that to result in the same kind of breakage as when moving interface members to a base interface (since IL generated for explicit override is the same as for explicit implementation). To my surprise, this is not the case - even though generated IL still specifies that BarOverride overrides Foo::Bar rather than FooBase::Bar, assembly loader is smart enough to substitute one for another correctly without any complaints - apparently, the fact that Foo is a class is what makes the difference. Go figure...


API的改变:

添加[Obsolete]属性(你已经提到了属性;然而,当使用warning-as-error时,这可能是一个破坏性的更改。)

二进制级别突破:

Moving a type from one assembly to another Changing the namespace of a type Adding a base class type from another assembly. Adding a new member (event protected) that uses a type from another assembly (Class2) as a template argument constraint. protected void Something<T>() where T : Class2 { } Changing a child class (Class3) to derive from a type in another assembly when the class is used as a template argument for this class. protected class Class3 : Class2 { } protected void Something<T>() where T : Class3 { }

源级安静语义更改:

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


(不知道这些适合哪里)

部署变更:

添加/移除依赖/引用 将依赖项更新到新版本 在x86、Itanium、x64或anycpu之间更改“目标平台” 在不同的框架安装上构建/测试(例如,在.Net 2.0盒子上安装3.5允许调用需要.Net 2.0 SP2的API)

引导程序/配置更改:

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

更新:

对不起,我没有意识到这是打破我的唯一原因是我在模板约束中使用它们。


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

打破:源和二进制

受影响语言:所有

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

更改前的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

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

打破:来源

受影响语言:所有

将显式接口实现重构为隐式接口实现在如何破坏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"

这种情况在实践中是非常罕见的,但当它发生时还是令人惊讶的。

添加新的非重载成员

Kind:源级中断或安静的语义更改。

受影响的语言: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类型,通过检查lambda的主体对给定类型是否有意义来打破多个类型匹配的关系——如果只有一种类型产生可编译的主体,则选择该类型。

The danger here is that client code may have an overloaded method group where some methods take arguments of his own types, and others take arguments of types exposed by your library. If any of his code then relies on type inference algorithm to determine the correct method based solely on presence or absence of members, then adding a new member to one of your types with the same name as in one of the client's types can potentially throw inference off, resulting in ambiguity during overload resolution.

请注意,本例中的类型Foo和Bar在任何方面都没有关联,不是通过继承或其他方式。仅仅在一个方法组中使用它们就足以触发这种情况,如果这种情况发生在客户端代码中,则您无法控制它。

The sample code above demonstrates a simpler situation where this is a source-level break (i.e. compiler error results). However, this can also be a silent semantics change, if the overload that was chosen via inference had other arguments which would otherwise cause it to be ranked below (e.g. optional arguments with default values, or type mismatch between declared and actual argument requiring an implicit conversion). In such scenario, the overload resolution will no longer fail, but a different overload will be quietly selected by the compiler. In practice, however, it is very hard to run into this case without carefully constructing method signatures to deliberately cause it.


重命名接口

休息一下:源代码和二进制文件

受影响的语言:很可能全部,用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

名称空间之外

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

由于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。数据,则上述代码将无法编译。

使用项目级名称空间导入会变得更加复杂。如果上面的代码中省略了Imports System,但是在项目级别导入了System名称空间,那么代码仍然可能导致错误。

然而,如果Api在它的Api. somenamspace . data命名空间中包含一个类DataRow,那么代码将被编译,但dr将是System.Data.DataRow的一个实例,当使用旧版本的Api编译时,当使用新版本的Api编译时,则是Api. somenamspace . 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

参考参数

源代码级打破

添加具有相同签名的方法重写,只是其中一个参数是通过引用而不是通过值传递的,这将导致引用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);

将字段更改为属性

打破: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"

重新排序枚举值

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

受影响语言:全部

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

更糟糕的是,如果客户端代码没有根据新的API版本重新编译,就会引入无声的二进制级中断。Enum值是编译时的常量,因此任何对它们的使用都会被写入客户端程序集的IL中。这种情况有时尤其难以发现。

更改前的API

public enum Foo
{
   Bar,
   Baz
}

更改后的API

public enum Foo
{
   Baz,
   Bar
}

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

Foo.Bar < Foo.Baz

添加具有默认值的参数。

中断类型:二进制级别的中断

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

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


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

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

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

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

更改前的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); 
  }

推广到扩展方法

Kind:源级中断

受影响的语言: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


具有可空类型参数的重载方法

类型:源级中断

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

例外:以下方法或属性之间的调用是模糊的。


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

API Breaking Changes: Types: This rule warns if a type publicly visible in the baseline, is not publicly visible anymore or if it has been removed. Clients code using such type will be broken. API Breaking Changes: Methods: This rule warns if a method publicly visible in the baseline, is not publicly visible anymore or if it has been removed. Clients code using such method will be broken. Note that if a method signature gets changed the old method version is seen as removed and the new method version is seen as added, so a breaking change will be detected on the old method version. API Breaking Changes: Fields: This rule warns if a field publicly visible in the baseline, is not publicly visible anymore or if it has been removed. Clients code using such field will be broken. API Breaking Changes: Interfaces and Abstract Classes: This rule warns if a publicly visible interface or abstract class has been changed and contains new abstract methods or if some abstract methods have been removed. Clients code that implement such interface or derive from such abstract class will be broken. Broken serializable types: This rule warns about breaking changes in types tagged with SerializableAttribute. To do so, this rule searches for serializable type with serializable instance fields added or removed since the baseline. Notice that it doesn't take account of fields tagged with NonSerializedAttribute. Avoid changing enumerations Flags status: This rule matches enumeration types that used to be tagged with FlagsAttribute in the baseline, and not anymore. It also matches the opposite, enumeration types that are now tagged with FlagsAttribute, and were not tagged in the baseline. Being tagged with FlagsAttribute is a strong property for an enumeration. Not so much in terms of behavior (only the enum.ToString() method behavior changes when an enumeration is tagged with FlagsAttribute) but in terms of meaning: is the enumeration a range of values or a range of flags?

还有3个代码查询建议让用户浏览新的公共API元素:

新的公开可见类型。 新的公开可见方法。 新的公开可见字段。


到常量的静态只读转换

类型:二进制级别的中断

受影响的语言: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异常。