.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