关于。net中api破坏变化的权威指南

我想收集尽可能多的关于。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类型推断和转换操作符之间的微妙交互有时会非常令人惊讶。

这里有几个例子:

添加新的方法重载

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_Explicitop_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扩展方法作为静态方法被调用。

36353 次浏览

更改方法签名

类型:二进制级别的中断

受影响的语言: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是唯一具有类似于虚拟基类成员显式接口实现的构造的. net语言——“显式重写”。我完全期望这将导致与将接口成员移动到基接口时相同的破坏(因为为显式覆盖生成的IL与为显式实现生成的IL相同)。令我惊讶的是,事实并非如此——尽管生成的IL仍然指定BarOverride覆盖Foo::Bar而不是FooBase::Bar,汇编加载器足够聪明,可以正确地替换另一个而没有任何抱怨——显然,Foo是一个类的事实是造成差异的原因。去图…

API的改变:

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

二进制级别突破:

  1. 将类型从一个程序集移动到另一个程序集
  2. 更改类型的名称空间
  3. 从另一个程序集添加基类类型。
  4. 添加一个新成员(事件保护),该成员使用来自另一个程序集(Class2)的类型作为模板参数约束。

    protected void Something<T>() where T : Class2 { }
    
  5. 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 { }
    

Source-level quiet semantics change:

  1. Adding/removing/changing overrides of Equals(), GetHashCode(), or ToString()

(not sure where these fit)

Deployment changes:

  1. Adding/removing dependencies/references
  2. Updating dependencies to newer versions
  3. Changing the 'target platform' between x86, Itanium, x64, or anycpu
  4. Building/testing on a different framework install (i.e. installing 3.5 on a .Net 2.0 box allows API calls that then require .Net 2.0 SP2)

Bootstrap/Configuration changes:

  1. Adding/Removing/Changing custom configuration options (i.e. App.config settings)
  2. With the heavy use of IoC/DI in todays applications, it's somethings necessary to reconfigure and/or change bootstrapping code for DI dependent code.

Update:

Sorry, I didn't realize that the only reason this was breaking for me was that I used them in template constraints.

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

打破:源和二进制

受影响语言:所有

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

更改前的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的主体对给定类型是否有意义来打破多个类型匹配的关系——如果只有一种类型产生可编译的主体,则选择该类型。

这里的危险在于客户端代码可能有一个重载的方法组,其中一些方法采用自己的类型参数,而另一些方法采用您的库公开的类型参数。如果他的任何代码都依赖于类型推断算法来仅根据成员的存在或不存在来确定正确的方法,那么向您的类型中添加与客户端类型中同名的新成员可能会导致推断中断,从而在重载解析过程中产生歧义。

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

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

重命名接口

稍微中断一下:Source和二进制

受影响的语言:很可能全部,用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.Data,则上述代码将无法编译。

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

然而,如果Api在其Api.SomeNamespace.Data命名空间中包含类DataRow,则代码将被编译,但dr在使用旧版Api编译时将是System.Data.DataRow的实例,而在使用新版本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);

将字段更改为属性

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

重新排序枚举值

break: 源级/二进制级安静语义更改

受影响语言:全部

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

更糟糕的是,如果客户端代码没有根据新的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#中默认值必须是编译时常量)。

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

break: 源级安静语义更改

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

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

更改前的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中断更改类别中提供了几个规则来检测二进制电平中断。这些规则只在定义了NDepend对于基线时执行。

  • API突破性变化:类型:该规则警告在基线中公开可见的类型,不再公开可见或它已被删除。使用这种类型的客户端代码将被破坏。
  • API突破性变化:方法:该规则警告在基线中公开可见的方法,不再公开可见或它已被删除。使用这种方法的客户端代码将被破坏。注意,如果一个方法签名被更改,那么旧的方法版本将被删除,而新方法版本将被添加,因此将在旧的方法版本上检测到破坏性的更改。
  • API突破性更改:字段:该规则警告在基线中公开可见的字段,不再公开可见或已被删除。使用该字段的客户端代码将被破坏。
  • API的突破性变化:接口和抽象类:如果一个公开可见的接口或抽象类被更改并包含新的抽象方法,或者一些抽象方法被删除,该规则会发出警告。实现这样的接口或从这样的抽象类派生的客户端代码将被破坏。
  • 破碎的可序列化类型:该规则警告带有SerializableAttribute标记的类型的破坏更改。为此,该规则搜索自基线以来添加或删除的可序列化实例字段的可序列化类型。注意,它不考虑带NonSerializedAttribute标记的字段。
  • 避免更改枚举标记状态:此规则匹配以前在基线中使用FlagsAttribute标记的枚举类型,而不再使用。它也匹配相反的枚举类型,现在用FlagsAttribute标记,并且在基线中没有标记。使用FlagsAttribute标记是枚举的强属性。不是在行为方面(当枚举被标记为FlagsAttribute时,只有enum.ToString ()方法行为会改变),而是在意义方面:枚举是值的范围还是标志的范围?

还有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