我想尽可能多地收集有关.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_Explicit
和op_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扩展方法作为静态方法调用。
答案 0 :(得分:40)
种类:二进制级别中断
受影响的语言: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);
答案 1 :(得分:39)
休息时间:二元级休息
即使调用源代码不需要更改,仍然需要重新编译(就像添加常规参数一样)。
这是因为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#中的编译时常量)。
答案 2 :(得分:26)
当我发现它时,这个非常不明显,特别是考虑到界面相同情况的不同。这根本不是休息,但令人惊讶的是我决定将它包括在内:
善良:不是休息!
受影响的语言:无(即没有破坏)
更改前的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 :(得分:18)
这是一个可能不那么明显的“添加/删除接口成员”的特殊情况,我认为它应该根据我接下来要发布的另一个案例进行自己的输入。所以:
种类:在源和二进制级别中断
受影响的语言: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在接口成员实现的声明中都需要 exact 接口名称;因此,如果成员被移动到基接口,代码将不再编译。
二进制中断是由于接口方法在生成的IL中完全限定以进行显式实现,并且接口名称也必须精确。
可用的隐式实现(即C#和C ++ / CLI,但不是VB)在源和二进制级别上都能正常工作。方法调用也不会中断。
答案 4 :(得分:15)
有点突破:源级/二级安静语义变化
受影响的语言:全部
重新排序枚举值将保持源级兼容性,因为文字具有相同的名称,但它们的序数索引将被更新,这可能会导致某些类型的静默源级别中断。
更糟糕的是,如果不针对新的API版本重新编译客户端代码,则可以引入静默二进制级别中断。枚举值是编译时常量,因此它们的任何使用都被烘焙到客户端程序集的IL中。这种情况有时候很难发现。
public enum Foo
{
Bar,
Baz
}
public enum Foo
{
Baz,
Bar
}
Foo.Bar < Foo.Baz
答案 5 :(得分:11)
这实际上是一件非常罕见的事情,但是当它发生时却是一件令人惊讶的事情。
种类:源级别中断或安静语义更改。
受影响的语言: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类型推断引起的,存在重载决策。这里使用有限形式的鸭子打字来打破多个类型匹配的关系,通过检查lambda的主体是否对给定类型有意义 - 如果只有一种类型导致可编辑的身体,那么选择一个。
这里的危险是客户端代码可能有一个重载的方法组,其中一些方法接受自己类型的参数,而另一些方法则接受由库公开的类型的参数。如果他的任何代码依赖于类型推断算法来确定仅基于成员的存在或不存在的正确方法,那么将新成员添加到与其中一个客户端类型相同名称的类型之一可能会推断推断off,导致重载决策过程中出现歧义。
请注意,此示例中的类型Foo
和Bar
不以任何方式相关,不是通过继承或其他方式。仅在单个方法组中使用它们就足以触发它,如果在客户端代码中发生这种情况,则无法控制它。
上面的示例代码演示了一个更简单的情况,即这是源级别中断(即编译器错误结果)。但是,这也可以是静默语义更改,如果通过推理选择的重载具有其他参数,否则会导致它被排在下面(例如,具有默认值的可选参数,或者声明和实际参数之间的类型不匹配需要隐式转换)。在这种情况下,重载决策将不再失败,但编译器将悄悄地选择不同的重载。然而,在实践中,如果不仔细构建方法签名以故意造成它,就很难遇到这种情况。
答案 6 :(得分:9)
休息时间:来源和二进制
受影响的语言:全部
这实际上只是改变方法可访问性的一种变体 - 它只是更微妙一点,因为很容易忽略这样一个事实:并非所有对接口方法的访问都必须通过对接口类型的引用。
更改前的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 :(得分: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"
答案 8 :(得分:6)
中断: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"
答案 9 :(得分: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
如果新版本的API添加了命名空间Api.SomeNamespace.Data
,则上述代码将无法编译。
项目级命名空间导入变得更加复杂。如果上述代码中省略了Imports System
,但在项目级别导入System
命名空间,则代码仍可能导致错误。
但是,如果Api在其DataRow
命名空间中包含一个类Api.SomeNamespace.Data
,则代码将编译,但dr
将成为System.Data.DataRow
的实例。使用新版API编译时,旧版本的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
来源级别突破
使用相同的签名添加方法覆盖,除了通过引用而不是按值传递一个参数将导致引用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);
答案 10 :(得分:4)
API更改:
二进制级别中断:
添加一个使用另一个程序集(Class2)中的类型作为模板参数约束的新成员(受事件保护)。
protected void Something<T>() where T : Class2 { }
当子类用作此类的模板参数时,将子类(Class3)更改为从另一个程序集中的类型派生。
protected class Class3 : Class2 { }
protected void Something<T>() where T : Class3 { }
源级静默语义更改:
(不确定这些适合的地方)
部署更改:
引导程序/配置更改:
更新
抱歉,我没有意识到这对我来说唯一的原因是我在模板限制中使用它们。
答案 11 :(得分:3)
有点突破:源级安静语义更改
因为编译器将缺少默认参数值的方法调用转换为使用调用端的默认值的显式调用,所以给出了现有编译代码的兼容性;将为所有先前编译的代码找到具有正确签名的方法。
另一方面,不使用可选参数的调用现在编译为对缺少可选参数的新方法的调用。这一切仍然正常,但如果被调用的代码驻留在另一个程序集中,则调用它的新编译代码现在依赖于此程序集的新版本。部署调用重构代码的程序集而不部署重构代码所驻留的程序集导致找不到&#34;方法&#34;异常。
更改前的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);
}
答案 12 :(得分:1)
有点休息:来源和二进制
受影响的语言:最有可能全部用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
答案 13 :(得分:1)
种类:源代码级中断
受影响的语言: 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);
例外:以下方法或属性之间的调用不明确。
答案 14 :(得分:0)
种类:源代码级中断
受影响的语言: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");
}
答案 15 :(得分:0)
Visual Studio Extension NDepend在类别API Breaking Changes中提供了一些规则来检测二进制级别中断。仅在定义NDepend baseline时执行这些规则。
还提出了3条代码查询,以使用户浏览新的公共API元素:
答案 16 :(得分:0)
种类:二进制级中断
受影响的语言: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
。