.NET中API断开更改的权威指南
我想尽可能多地收集有关.NET / CLR中API版本化的信息,特别是API更改如何做或不做中断客户端应用程序。 首先,我们来定义一些术语:
API更改 – 对types的公开可见定义(包括其任何公共成员)的更改。 这包括更改types和成员名称,更改types的基types,从types的已实现接口列表中添加/删除接口,添加/删除成员(包括重载),更改成员可见性,重命名方法和types参数,添加默认值对于方法参数,在types和成员上添加/删除属性,以及在types和成员上添加/删除genericstypes参数(我错过了什么?)。 这不包括成员机构的任何变化,或私人成员的任何变化(即我们没有考虑到反思)。
二进制级别中断 – API更改导致客户端程序集针对旧版API进行编译,可能无法使用新版本加载。 示例:更改方法签名,即使它允许以与以前相同的方式调用(即:void返回types/参数默认值重载)。
源代码级别中断 – API更改导致现有代码被编写为针对较早版本的API进行编译,可能无法使用新版本进行编译。 但是,已编译的客户端程序集像以前一样工作。 例如:添加一个新的重载,可能导致以前明确的方法调用不明确。
源代码级安静语义更改 – 导致现有代码写入API的老版本API进行编译的API更改会悄悄地改变其语义,例如通过调用不同的方法。 该代码应该继续编译没有警告/错误,以前编译的程序集应该像以前一样工作。 例如:在现有的类上实现一个新的接口,导致在重载parsing期间select不同的过载。
最终的目标是尽可能多地编目尽可能多的破坏性和安静的语义API变化,描述破坏的确切效果,以及哪些语言不受其影响。 为了扩展后者:尽pipe一些变化普遍影响所有语言(例如,向接口添加新成员将会以任何语言破坏该接口的实现),但是一些需要非常特定的语言语义来进入rest。 这通常涉及方法重载,并且通常涉及任何与隐式types转换有关的操作。 在这里,即使对于符合CLS的语言(即至less符合CLI规范中定义的“CLS消费者”规则的语言),在这里似乎也没有任何方法来定义“最小公分母” – 尽pipe我会欣赏有人纠正我在这里是错误的 – 所以这将不得不通过语言去语言。 那些最感兴趣的人自然就是.NET开箱即用的:C#,VB和F#; 但是其他的如IronPython,IronRuby,Delphi Prism等也是相关的。 angular落案例越多,它就越有意思 – 像删除成员这样的事情是不言而喻的,但是,例如方法重载,可选/默认参数,lambdatypes推断和转换操作符之间的微妙交互可能是非常令人惊讶的有时。
几个例子来启动这个:
增加新的方法重载
种类:源级别中断
受影响的语言: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_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扩展方法作为静态方法来调用。 
更改方法签名
种类:二进制级别的rest
受影响的语言:C#(VB和F#最有可能,但未经testing)
更改之前的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); 
添加一个默认值的参数。
种类的rest:二进制级别的rest
即使调用源代码不需要改变,仍然需要重新编译(就像添加常规参数一样)。
这是因为C#将参数的默认值直接编译到调用程序集中。 这意味着如果你不重新编译,你会得到一个MissingMethodException,因为旧的程序集试图调用一个参数较less的方法。
更改前的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) 。 这是因为默认参数值纯粹是一种语言function,.Net运行时不知道任何关于它们的信息。  (这也解释了为什么默认值必须是C#编译时常量)。 
这一点在我发现的时候是非常不明显的,特别是鉴于接口的情况不同。 这根本不算什么,但是我决定把它包括在内,这真是令人惊讶:
将类成员重构成基类
善良:不是rest!
受影响的语言:无(即没有损坏)
更改前的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语言 – “显式覆盖”。 我完全预料到,导致与将接口成员移动到基本接口时相同的破坏types(因为为明确覆盖生成的IL与显式实现相同)。 令我惊讶的是,情况并非如此 – 即使生成的IL仍然指定BarOverride覆盖Foo::Bar而不是FooBase::Bar ,但是程序集加载器足够聪明,可以正确replace另一个,而不会有任何抱怨 – 显然, Foo是一个类是什么使差异。 去搞清楚… 
这是一个可能不太明显的“添加/删除接口成员”的特殊情况,我认为这是值得自己参考的另一个案件,我要下一个post。 所以:
将接口成员重构成基本接口
种类:在源和二进制级别中断
受影响的语言: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),隐式实现在源和二进制级别都可以正常工作。 方法调用也不会中断。
这实际上是一个非常罕见的事情,但是当它发生的时候却是一个令人惊讶的事情。
添加新的非重载成员
种类:源级别中断或安静语义更改。
受影响的语言: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中的lambdatypes推理引起的。 在这里采用有限forms的鸭子打字来打破多于一种types匹配的关系,通过检查拉姆达的身体是否对于给定types有意义 – 如果只有一种types产生可编译的主体,那么select一个。
这里的危险是客户端代码可能有一个重载的方法组,其中一些方法需要自己types的参数,而其他方法则需要由库提供的types的参数。 如果他的任何代码都依赖于types推断algorithm来根据成员的存在或不存在来确定正确的方法,那么将一个新成员添加到与其中一个客户机types具有相同名称的types中的一个可能会推断closures,导致重载parsing过程中的模糊。
 请注意,在这个例子中,typesFoo和Bar不以任何方式相关,而不是通过inheritance或其他方式。 仅仅在单个方法组中使用它们就足以触发这个事件,如果这发生在客户端代码中,则无法控制它。 
上面的示例代码演示了一个更简单的情况,即源代码级别的中断(即编译器错误结果)。 但是,如果通过推理select的重载有其他参数,否则会导致它被排在下面(例如,具有默认值的可选参数,或者需要隐式的声明和实际参数之间的types不匹配,这也可以是无声的语义更改转换)。 在这种情况下,重载parsing不会失败,但编译器会安静地select不同的重载。 然而,在实践中,如果不仔细地构build方法签名来故意造成这种情况,就很难遇到这种情况。
重新排列枚举值
中断types: 源代码级别/二进制级别的安静语义更改
受影响的语言:全部
对枚举值进行重新sorting将保持源代码级别的兼容性,因为文字具有相同的名称,但是它们的序号会更新,这会导致某些无声的源级别中断。
更糟糕的是,如果客户端代码没有针对新的API版本重新编译,那么可以引入无声的二进制级别的中断。 枚举值是编译时常量,因此它们的任何用法都被烘焙到客户程序集的IL中。 这种情况有时可能特别难以发现。
更改前的API
 public enum Foo { Bar, Baz } 
更改后的API
 public enum Foo { Baz, Bar } 
示例客户端代码有效,但之后被破坏:
 Foo.Bar < Foo.Baz 
将隐式接口实现转换为显式接口实现。
一种rest:来源和二进制
受影响的语言:全部
这实际上只是改变一个方法的可访问性的一个变种 – 它只是更微妙一点,因为很容易忽略这样一个事实,即并不是所有对接口方法的访问都必须通过对接口types的引用。
更改前的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 
将显式接口实现转换为隐式接口实现。
种类的rest:来源
受影响的语言:全部
将显式接口实现重构为隐式接口实现在如何破坏API方面更加微妙。 表面看来,这应该是相对安全的,但是,如果与inheritance结合起来,可能会导致问题。
更改前的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" 
将字段更改为属性
一种rest:API
受影响的语言:Visual Basic和C#*
信息:在Visual Basic中将普通字段或variables更改为属性时,任何以任何方式引用该成员的外部代码都需要重新编译。
更改前的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" 
命名空间添加
源级别中断/源级别安静语义更改
由于命名空间parsing在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和Api.SomeNamespace.Data.DataRow编译时, dr将是System.Data.DataRow一个实例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) { ... } } } 
更改后的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源无法parsing该函数。 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更改:
- 添加[Obsolete]属性(你可能会提到属性,但是,当使用warning-as-error时,这可能是一个突破性的改变)。
 
二进制级别的突破:
- 将一个types从一个程序集移动到另一个
 - 改变一个types的命名空间
 - 从另一个程序集中添加一个基类。
 - 
添加一个使用另一个程序集(Class2)types的新成员(事件保护)作为模板参数约束。
protected void Something<T>() where T : Class2 { } - 
将类用作此类的模板参数时,将子类(Class3)更改为从另一个程序集中的types派生。
protected class Class3 : Class2 { } protected void Something<T>() where T : Class3 { } 
源代码级别的安静语义更改:
- 添加/删除/改变Equals(),GetHashCode()或ToString()的覆盖
 
(不知道这些合适的地方)
部署更改:
- 添加/删除依赖关系/参考
 - 更新依赖关系到更新的版本
 - 更改x86,Itanium,x64或anycpu之间的“目标平台”
 - 在不同的框架安装上进行构build/testing(例如,在.Net 2.0盒子上安装3.5允许需要.Net 2.0 SP2的API调用)
 
引导程序/configuration更改:
- 添加/删除/更改自定义configuration选项(即App.config设置)
 - 由于在当今的应用中大量使用IoC / DI,有必要重新configuration和/或改变DI相关代码的引导代码。
 
更新:
对不起,我没有意识到,这是突破我的唯一原因是我用它们在模板约束。
添加重载方法来消除默认参数的使用
中断types: 源级安静语义更改
由于编译器将缺less默认参数值的方法调用转换为调用方的默认值的显式调用,因此给出了现有编译代码的兼容性; 所有以前编译的代码都会find正确签名的方法。
另一方面,不使用可选参数的调用现在被编译为对缺less可选参数的新方法的调用。 这一切仍然工作正常,但如果被调用的代码驻留在另一个程序集中,则新调用的代码现在依赖于此程序集的新版本。 调用重构代码的部署程序集时,如果没有部署重构代码所在的程序集,则会导致“未find方法”exception。
更改之前的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); } 
重命名一个接口
有点突破:来源和二进制
受影响的语言:最有可能的所有,在C#中testing。
更改前的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