有关.NET中API突破性更改的权威指南


227

我想收集有关.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_Explicitop_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扩展方法称为静态方法。



1
@Robert:您的链接的内容非常不同-它描述了.NET Framework本身的特定重大更改。这是一个更广泛的问题,描述了可以在您自己的 API(作为库/框架作者)中引入重大更改的通用模式。我不知道来自MS的任何此类文档都是完整的,尽管绝对欢迎与此类文档的任何链接,即使它们不完整。
帕维尔·米纳夫09年

在任何这些“中断”类别中,是否有任何问题只会在运行时才变得明显?
罗希特

1
是的,“二进制中断”类别。在这种情况下,您已经具有针对所有版本的程序集编译的第三方程序集。如果就地放下了新版本的程序集,则第三方程序集将停止工作-它要么在运行时根本无法加载,要么工作不正常。
Pavel Minaev 09年

Answers:


42

更改方法签名

种类:二进制级中断

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

15
实际上,如果有人尝试为创建委托,也可能是源代码级中断bar
帕维尔·米纳夫09年

也是如此。在我的公司应用程序中对打印实用程序进行了一些更改时,我发现了这个特定问题。发布此更新时,并非所有引用此实用程序的DLL都会重新编译和发布,因此会引发methodnotfound异常。
贾斯汀·德鲁里

1
这可以回溯到返回类型不计入方法签名的事实。您不能仅基于返回类型重载两个函数。同样的问题。
杰森·肖特

1
回答这个问题:是否有人知道添加dotnet4默认值'public static void bar(int i = 0);'的含义?或将该默认值从一个值更改为另一个值?
k3b

1
对于那些想进入此页面的人, 我认为对于C#(以及大多数其他OOP语言为“我认为”),返回类型对方法签名没有贡献。 的,答案是正确的,即签名更改有助于二进制级别更改。 该示例不似乎是正确的恕我直言,正确的例子,我能想到的是 公共十进制总和(INT A,INT B) 公共十进制总和(十进制,小数二)请参阅此MSDN链接3.6签名和重载
Bhanu Chhabra

40

添加具有默认值的参数。

中断类型:二进制级中断

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

这是因为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
即使对于源代码级别,这也是一个重大变化:Func<int> f = Foo;//这将因更改的签名而失败
Vagaus

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
只要基类在同一程序集中。否则,它是二进制的重大更改。
杰里米

@Jeremy在这种情况下,哪种代码会中断?外部调用者对Baz()的使用是否会中断,或者这仅仅是尝试扩展Foo并覆盖Baz()的人员的问题?
ChaseMedallion

@ChaseMedallion如果您是二手用户,它会崩溃。例如,编译的DLL引用了Foo的较旧版本,而您引用了该编译的DLL,但也使用了Foo DLL的较新版本。它因一个奇怪的错误而中断,或者至少在我之前开发的库中对我有用。
杰里米(Jeremy)

19

这是“添加/删除接口成员”的一种不太明显的特殊情况,我认为鉴于我接下来要发表的另一种情况,它应该自己输入。所以:

将接口成员重构为基本接口

种类:在源代码级别和二进制级别上都中断

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


并非所有语言都正确。对于VB而言,这不是重大的源代码更改。对于C#,是这样。
杰里米(Jeremy)

那么Implements IFoo.Bar会透明地引用IFooBase.Bar吗?
帕维尔·米纳夫

是的,实际上确实如此,在实现成员时,可以直接或通过继承接口间接引用成员。但是,这始终是一个重大的二进制更改。
杰里米(Jeremy),

15

重新排序枚举值

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

受影响的语言:全部

对枚举值进行重新排序将保持源级别的兼容性,因为文字具有相同的名称,但是它们的序数索引将被更新,这可能会导致某些静默的源级别中断。

更糟糕的是,如果不针对新的API版本重新编译客户端代码,则会引入无提示的二进制级别的中断。枚举值是编译时常量,因此,它们的任何使用都会被烘焙到客户端程序集的IL中。有时很难发现这种情况。

更改前的API

public enum Foo
{
   Bar,
   Baz
}

更改后的API

public enum Foo
{
   Baz,
   Bar
}

样例客户端代码有效,但随后被破坏:

Foo.Bar < Foo.Baz

12

实际上,这是一件非常罕见的事情,但是当它发生时却是令人惊讶的一件事情。

添加新的未超载成员

种类:源代码级别中断或安静的语义更改。

受影响的语言: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的主体对于给定类型是否有意义,此处采用有限形式的鸭子类型来打断领带,其中不止一种类型匹配-如果只有一种类型导致可编译主体,则选择该类型。

这里的危险是客户端代码可能具有重载的方法组,其中某些方法采用自己类型的参数,而另一些方法采用库公开的类型的参数。如果他的任何代码然后仅依赖于类型的存在或不存在,就依靠类型推断算法来确定正确的方法,那么将新成员添加到您的其中一个类型中且名称与客户端类型之一相同的新成员可能会引发推断关闭,导致在重载解析过程中产生歧义。

请注意,在此示例中,类型FooBar绝不以任何方式关联,既不通过继承也不以其他方式关联。仅在一个方法组中使用它们就足以触发此操作,如果在客户端代码中发生此操作,则您无法对其进行控制。

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


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

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

断裂种类:来源

受影响的语言:全部

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

抱歉,我不太明白-更改API之前的示例代码根本不会编译,因为更改Foo之前没有名为的公共方法GetEnumerator,而您是通过Foo.. 类型的引用来调用该方法的。 。
帕维尔Minaev

确实,我试图从内存中简化一个示例,结果以“ foobar”(对双关语原谅)告终。我更新了示例,以正确演示这种情况(并且可以编译)。
LBushkin

在我的示例中,问题不仅是由接口方法从隐式到公开的过渡引起的。这取决于C#编译器确定在foreach循环中调用哪个方法的方式。给定编译器选择的解析规则,它将从派生类中的版本切换到基类中的版本。
LBushkin

您忘记了yield return "Bar":)但是,是的,我知道现在的发展方向- foreach始终调用名为public的方法GetEnumerator,即使它不是的真正实现IEnumerable.GetEnumerator。这似乎还有一个角度:即使您只有一个类,并且它是IEnumerable显式实现的,这意味着添加名为GetEnumerator它的公共方法是一项源代码巨大的更改,因为现在foreach将在接口实现上使用该方法。同样,同样的问题也适用于IEnumerator实现...
Pavel Minaev 09年

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"

2
它实际上将打破在C#中的东西为好,因为属性不能用于outref方法的参数,不同领域,而不能是一元的目标&运营商。
Pavel Minaev 2013年

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上面的代码中省略了if ,但是System在项目级导入名称空间,则该代码可能仍会导致错误。

但是,如果Api DataRow在其Api.SomeNamespace.Data命名空间中包含一个类,则该代码将进行编译,但dr将成为System.Data.DataRow使用旧版API和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源无法解析该函数​​。除非它们具有不同的参数名称,否则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);

4

API变更:

  1. 添加[过时]属性(您已经用提及的属性覆盖了该属性;但是,在使用警告错误时,这可能是一个重大更改。)

二进制级中断:

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

    protected void Something<T>() where T : Class2 { }
  5. 当将该类用作该类的模板参数时,将子类(Class3)更改为从另一个程序集中的类型派生。

    protected class Class3 : Class2 { }
    protected void Something<T>() where T : Class3 { }

源级别的安静语义发生了变化:

  1. 添加/删除/更改Equals(),GetHashCode()或ToString()的替代

(不确定这些位置是否合适)

部署变更:

  1. 添加/删除依赖项/引用
  2. 将依赖关系更新到较新版本
  3. 在x86,Itanium,x64或任何CPU之间更改“目标平台”
  4. 在不同的框架安装上进行构建/测试(即,在.Net 2.0盒上安装3.5,从而允许API调用,这些调用随后需要.Net 2.0 SP2)

引导程序/配置更改:

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

更新:

抱歉,我没有意识到这对我造成困扰的唯一原因是我将它们用于模板约束。


“添加一个使用另一个程序集中的类型的新成员(受事件保护)。” -IIRC,客户端仅需要引用包含已引用的程序集基本类型的从属程序集;它不必引用仅使用的程序集(即使类型在方法签名中);我对此不确定100%。您是否有针对此的精确规则参考?此外,如果TypeForwardedToAttribute使用的话,移动类型可以是不间断的。
帕维尔·米纳夫

对我来说,“ TypeForwardedTo”是个新闻,我会检查一下。至于其他的,我也不是100%。。。让我看看是否可以复制,我将更新帖子。
csharptest.net

因此,请不要-Werror在使用发行版tarball发行的构建系统中强行使用。此标志对代码开发人员最有帮助,而对消费者通常无济于事。
binki 2014年

@binki的优点是,仅在DEBUG构建中将警告视为错误才足够。
csharptest.net 2014年

3

添加重载方法以消除默认参数的使用

休息的种类: 源代码级安静语义的更改

由于编译器将缺少默认参数值的方法调用转换为在调用方具有默认值的显式调用,因此可以兼容现有的已编译代码。将为所有先前编译的代码找到具有正确签名的方法。

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

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

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

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);

例外:以下方法或属性之间的调用不明确。


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");
}

更多信息:https : //github.com/dotnet/csharplang/issues/665

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.