首页 文章

.NET中针对API破坏性更改的权威指南

提问于
浏览
201

我想尽可能多地收集有关.NET / CLR中API版本控制的信息,特别是API更改如何破坏客户端应用程序 . 首先,让我们定义一些术语:

API change - 类型的公开可见定义的更改,包括其任何公共成员 . 这包括更改类型和成员名称,更改类型的基本类型,从类型的已实现接口列表添加/删除接口,添加/删除成员(包括重载),更改成员可见性,重命名方法和类型参数,添加默认值对于方法参数,在类型和成员上添加/删除属性,以及在类型和成员上添加/删除泛型类型参数(我错过了什么吗?) . 这不包括成员团体的任何变更,或私人成员的任何变更(即我们不考虑反思) .

Binary-level break - API更改导致针对旧版本API编译的客户端程序集可能无法加载新版本 . 示例:更改方法签名,即使它允许以与之前相同的方式调用(即:void返回类型/参数默认值重载) .

Source-level break - API更改导致编写现有代码以针对旧版API进行编译,可能无法使用新版本进行编译 . 然而,已编译的客户端程序集与以前一样工作 . 示例:添加一个新的重载,该重载可能导致前一个明确的方法调用不明确 .

Source-level quiet semantics change - 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扩展方法作为静态方法调用 .

13 回答

  • 0

    更改方法签名

    种类:二级休息

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

    使用默认值添加参数 .

    Kind of Break: Binary-level break

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

    那是因为C#编译参数的默认值直接进入调用程序集 . 这意味着如果你不重新编译,你将得到一个MissingMethodException,因为旧程序集试图调用一个参数较少的方法 .

    API Before Change

    public void Foo(int a) { }
    

    API After Change

    public void Foo(int a, string b = null) { }
    

    Sample client code that is broken afterwards

    Foo(5);
    

    客户端代码需要在字节码级别重新编译为 Foo(5, null) . 被调用的程序集只包含 Foo(int, string) ,而不是 Foo(int) . 这是因为默认参数值纯粹是一种语言特性,.Net运行时对它们一无所知 . (这也解释了为什么默认值必须是C#中的编译时常量) .

  • 17

    当我发现它时,这个非常不明显,特别是考虑到界面相同情况的不同 . 这根本不是休息,但令人惊讶的是我决定将它包括在内:

    将类成员重构为基类

    亲切:不是休息!

    受影响的语言:无(即没有破坏)

    更改前的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语言 - "explicit override" . 我完全期望在将接口成员移动到基接口时导致相同类型的破坏(因为为显式覆盖生成的IL与显式实现相同) . 令我惊讶的是,情况并非如此 - 即使生成的IL仍然指定 BarOverride 覆盖 Foo::Bar 而不是 FooBase::Bar ,程序集加载器足够聪明,可以正确地替换另一个而没有任何抱怨 - 显然, Foo 是一个类的事实是什么有所作为 . 去搞清楚...

  • 23

    这是一个可能不那么明显的“添加/删除接口成员”的特殊情况,我认为它应该根据我接下来要发布的另一个案例进行自己的输入 . 所以:

    将接口成员重构为基接口

    种类:在源和二进制级别中断

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

  • 3

    重新排序枚举值

    休息时间: Source-level/Binary-level quiet semantics change

    受影响的语言:全部

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

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

    API变更之前

    public enum Foo
    {
       Bar,
       Baz
    }
    

    更改后的

    API

    public enum Foo
    {
       Baz,
       Bar
    }
    

    示例客户端代码可以工作但之后被破坏:

    Foo.Bar < Foo.Baz
    
  • 7

    这个在实践中真的是一件非常罕见的事情,但是当它发生时却是一个令人惊讶的事情 .

    添加新的非重载成员

    种类:源级别中断或安静语义更改 .

    受影响的语言: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,导致重载决策过程中出现歧义 .

    请注意类型本例中的 FooBar 不以任何方式相关,不依赖于继承或其他方式 . 仅在单个方法组中使用它们就足以触发它,如果在客户端代码中发生这种情况,则无法控制它 .

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

  • 5

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

    休息:源和二进制

    受影响的语言:全部

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

    更改前的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
    
  • 4

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

    休息时间:来源

    受影响的语言:全部

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

    将字段更改为属性

    休息时间:API

    受影响的语言:Visual Basic和C#*

    信息:当您将普通字段或变量更改为visual basic中的属性时,需要重新编译以任何方式引用该成员的任何外部代码 .

    API Before Change:

    Public Class Foo    
        Public Shared Bar As String = ""    
    End Class
    

    API After Change:

    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
    

    Sample client code that works but is broken afterwards :

    Foo.Bar = "foobar"
    
  • 12

    命名空间添加

    Source-level break / Source-level quiet semantics change

    由于名称空间解析在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 .

    参数重命名

    Source-level break

    更改参数名称是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
    

    参考参数

    Source-level break

    添加具有相同签名的方法覆盖,除了通过引用而不是按值传递一个参数将导致引用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)
    

    字段到属性更改

    Binary-level break/Source-level break

    除了明显的二进制级别中断之外,如果成员通过引用传递给方法,这可能会导致源级别中断 .

    更改前的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);
    
  • 35

    API change:

    • 添加[Obsolete]属性(你提到了属性,但是,当使用warning-as-error时,这可能是一个重大变化 . )

    Binary-level break:

    • 将类型从一个装配移动到另一个装配

    • 更改类型的命名空间

    • 从另一个程序集添加基类类型 .

    • 添加一个新成员(受事件保护),该成员使用另一个程序集(Class2)中的类型作为模板参数约束 .

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

    Source-level quiet semantics change:

    • 添加/删除/更改Equals(),GetHashCode()或ToString()的覆盖

    (不知道这些在哪里适合)

    Deployment changes:

    • 添加/删除依赖项/引用

    • 将依赖项更新为较新版本

    • 更改x86,Itanium,x64或anycpu之间的'target platform'

    • 在不同的框架安装上构建/测试(即在.Net 2.0框上安装3.5允许API调用,然后需要.Net 2.0 SP2)

    Bootstrap/Configuration changes:

    • 添加/删除/更改自定义配置选项(即App.config设置)

    • 在当今的应用程序中大量使用IoC / DI,重新配置和/或更改DI相关代码的引导代码是必要的 .

    更新:

    对不起,我没有意识到这对我来说唯一的原因是我在模板限制中使用它们 .

  • 2

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

    休息时间: Source-level quiet semantics change

    因为编译器将缺少默认参数值的方法调用转换为使用调用端的默认值的显式调用,所以给出了现有编译代码的兼容性;将为所有先前编译的代码找到具有正确签名的方法 .

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

    API before change

    public int MyMethod(int mandatoryParameter, int optionalParameter = 0)
      {
         return mandatoryParameter + optionalParameter;
      }
    

    API after change

    public int MyMethod(int mandatoryParameter, int optionalParameter)
      {
         return mandatoryParameter + optionalParameter;
      }
    
      public int MyMethod(int mandatoryParameter)
      {
         return MyMethod(mandatoryParameter, 0);
      }
    

    Sample code that will still be working

    public int CodeNotDependentToNewVersion()
      {
         return MyMethod(5, 6); 
      }
    

    Sample code that is now dependent to the new version when compiling

    public int CodeDependentToNewVersion()
      {
         return MyMethod(5); 
      }
    
  • 10

    重命名界面

    休息时间:来源和 Binary

    受影响的语言:最有可能全部用C#进行测试 .

    API Before Change:

    public interface IFoo
    {
        void Test();
    }
    
    public class Bar
    {
        IFoo GetFoo() { return new Foo(); }
    }
    

    API After Change:

    public interface IFooNew // Of the exact same definition as the (old) IFoo
    {
        void Test();
    }
    
    public class Bar
    {
        IFooNew GetFoo() { return new Foo(); }
    }
    

    Sample client code that works but is broken afterwards:

    new Bar().GetFoo().Test(); // Binary only break
    IFoo foo = new Bar().GetFoo(); // Source and binary break
    

相关问题