Полное руководство по изменениям API в .NET - PullRequest
214 голосов
/ 22 сентября 2009

Я хотел бы собрать как можно больше информации о версиях API в .NET / CLR и, в частности, о том, как изменения API нарушают или не нарушают клиентские приложения. Сначала давайте определим некоторые термины:

Изменение API - изменение общедоступного определения типа, включая любых его открытых членов. Это включает в себя изменение типа и имен элементов, изменение базового типа типа, добавление / удаление интерфейсов из списка реализованных интерфейсов типа, добавление / удаление элементов (включая перегрузки), изменение видимости элемента, переименование метода и параметров типа, добавление значений по умолчанию для параметров метода, добавление / удаление атрибутов для типов и членов, а также добавление / удаление параметров общих типов для типов и членов (я что-то пропустил?). Сюда не входят какие-либо изменения в членских организациях или какие-либо изменения в частных членах (т.е. мы не принимаем во внимание Отражение).

Разрыв двоичного уровня - изменение API, которое приводит к тому, что клиентские сборки, скомпилированные для более старой версии API, потенциально не загружаются с новой версией. Пример: изменение сигнатуры метода, даже если он позволяет вызываться так же, как и раньше (т. Е. Void для возврата значений по умолчанию для значений типа / параметра).

Разрыв на уровне исходного кода - изменение API, в результате которого существующий код, написанный для компиляции со старой версией API, потенциально не компилируется с новой версией. Однако уже скомпилированные клиентские сборки работают как и прежде. Пример: добавление новой перегрузки, которая может привести к неоднозначности в вызовах методов, которые были однозначными в предыдущем.

Изменение тихой семантики на уровне исходного кода - изменение API, в результате которого существующий код, написанный для компиляции со старой версией API, незаметно меняет свою семантику, например, вызвав другой метод. Однако код должен продолжать компилироваться без предупреждений / ошибок, а ранее скомпилированные сборки должны работать как прежде. Пример: реализация нового интерфейса в существующем классе, что приводит к другой перегрузке, выбранной во время разрешения перегрузки.

Конечная цель состоит в том, чтобы каталогизировать как можно больше ломаных и тихих изменений API семантики и описать точный эффект поломки, а также то, на какие языки он влияет, а какие нет. Чтобы расширить последний: хотя некоторые изменения повсеместно влияют на все языки (например, добавление нового члена в интерфейс нарушит реализации этого интерфейса на любом языке), некоторые требуют очень специфической семантики языка, чтобы вступить в игру, чтобы получить разрыв. Это обычно включает перегрузку методов и, вообще, все, что связано с неявными преобразованиями типов. Кажется, что нет никакого способа определить «наименее общий знаменатель» здесь даже для CLS-совместимых языков (то есть тех, которые соответствуют по крайней мере правилам «потребителя CLS», как определено в спецификации CLI) - хотя я буду признателен, если кто-то исправляет меня здесь как ошибку - так что это должно идти от языка к языку. Естественно, наиболее интересны те, которые поставляются с .NET из коробки: C #, VB и F #; но другие, такие как IronPython, IronRuby, Delphi Prism и т. д. также актуальны. Чем больше это углового случая, тем интереснее это будет - такие вещи, как удаление членов, довольно очевидны, но тонкие взаимодействия между, например, перегрузка метода, необязательные параметры / параметры по умолчанию, вывод типа лямбда и операторы преобразования могут иногда вызывать удивление.

Несколько примеров, чтобы это запустить:

Добавление перегрузок нового метода

Вид: разрыв уровня источника

Затронутые языки: 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 вызывались как статические методы.

Ответы [ 14 ]

40 голосов
/ 24 сентября 2009

Изменение подписи метода

Вид: бинарный разрыв

Затронутые языки: 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);
39 голосов
/ 07 мая 2014

Добавление параметра со значением по умолчанию.

Вид разрыва: разрыв двоичного уровня

Даже если исходный код вызова не нужно менять, его все равно нужно перекомпилировать (как при добавлении обычного параметра).

Это потому, что 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 #).

26 голосов
/ 06 октября 2009

Это было очень неочевидно, когда я его обнаружил, особенно в свете разницы с той же ситуацией для интерфейсов. Это вовсе не перерыв, но достаточно удивительно, что я решил включить его:

Преобразование членов класса в базовый класс

Вид: не перерыв!

Затронутые языки: нет (т. Е. Никто не сломан)

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 это класс, который имеет значение. Пойди разберись ...

18 голосов
/ 06 октября 2009

Это, пожалуй, не столь очевидный особый случай «добавления / удаления членов интерфейса», и я подумал, что он заслуживает отдельной записи в свете другого случая, который я собираюсь опубликовать в следующем. Итак:

Рефакторинг элементов интерфейса в базовый интерфейс

Вид: разрывы на исходном и двоичном уровнях

Затрагиваемые языки: 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), будет хорошо работать как на исходном, так и на двоичном уровне. Вызовы методов также не прерываются.

15 голосов
/ 05 января 2014

Изменение нумерации перечисленных значений

Вид разрыва: Изменение тихой семантики на уровне источника / двоичного уровня

Затронутые языки: все

Изменение порядка перечисляемых значений сохранит совместимость на уровне источника, поскольку литералы имеют одинаковые имена, но их порядковые индексы будут обновлены, что может привести к некоторым типам тихих разрывов на уровне источника.

Еще хуже то, что тихие разрывы двоичного уровня могут быть введены, если клиентский код не перекомпилируется с новой версией API. Enum-значения являются константами времени компиляции, и любое их использование запекается в IL клиентской сборки. Этот случай может быть особенно трудно заметить время от времени.

API до изменения

public enum Foo
{
   Bar,
   Baz
}

API после изменения

public enum Foo
{
   Baz,
   Bar
}

Пример клиентского кода, который работает, но впоследствии не работает:

Foo.Bar < Foo.Baz
11 голосов
/ 22 февраля 2011

Это действительно очень редкая вещь на практике, но, тем не менее, удивительно, когда это происходит.

Добавление новых незагруженных членов

Вид: разрыв исходного уровня или тихая смена семантики.

Затронутые языки: 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 при наличии разрешения перегрузки. Ограниченная форма утиной типизации используется здесь, чтобы разорвать связи, где более чем один тип соответствует, проверяя, имеет ли смысл тело лямбды для данного типа - если только один тип приводит к компилируемому телу, выбирается тот.

Опасность заключается в том, что в клиентском коде может быть перегруженная группа методов, где некоторые методы принимают аргументы своих собственных типов, а другие принимают аргументы типов, предоставляемых вашей библиотекой. Если какой-либо из его кодов затем использует алгоритм вывода типов для определения правильного метода, основанного исключительно на наличии или отсутствии членов, то добавление нового члена к одному из ваших типов с тем же именем, что и в одном из типов клиента, может потенциально вызвать вывод выключено, что приводит к неоднозначности при разрешении перегрузки.

Обратите внимание, что типы Foo и Bar в этом примере никоим образом не связаны ни наследованием, ни иным образом. Для их запуска достаточно их использования в одной группе методов, и если это происходит в клиентском коде, вы не можете его контролировать.

Пример кода выше демонстрирует более простую ситуацию, когда это разрыв на уровне исходного кода (то есть результаты ошибки компилятора). Тем не менее, это также может быть автоматическим изменением семантики, если перегрузка, которая была выбрана с помощью логического вывода, имела другие аргументы, которые иначе привели бы к ее ранжированию ниже (например, необязательные аргументы со значениями по умолчанию или несоответствие типов между объявленным и фактическим аргументом, требующим неявного преобразование). В таком случае разрешение перегрузки больше не будет терпеть неудачу, но компилятор будет спокойно выбирать другую перегрузку. На практике, однако, очень трудно столкнуться с этим случаем без тщательного построения сигнатур методов, чтобы преднамеренно вызывать его.

9 голосов
/ 30 октября 2009

Преобразование неявной реализации интерфейса в явную.

Вид разрыва: источник и двоичный код

Затронутые языки: все

Это на самом деле всего лишь вариант изменения доступности метода - он немного более тонкий, так как легко упустить из виду тот факт, что не весь доступ к методам интерфейса обязательно осуществляется через ссылку на тип интерфейса. *

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 голосов
/ 30 октября 2009

Преобразование явной реализации интерфейса в неявную.

Вид разрыва: Источник

Затронутые языки: все

Реорганизация явной реализации интерфейса в неявную является более тонкой в ​​том, как она может сломать 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"
6 голосов
/ 21 ноября 2013

Изменение поля для свойства

Вид разрыва: 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"
5 голосов
/ 23 октября 2013

Добавление пространства имен

Разрыв уровня источника / изменение семантики тихого уровня источника

Из-за того, как в vb.Net работает разрешение пространства имен, добавление пространства имен в библиотеку может привести к тому, что код Visual Basic, скомпилированный с предыдущей версией API, не скомпилируется с новой версией.

Пример кода клиента:

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.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

Параметры Ref

Разрыв уровня источника

Добавление переопределения метода с той же сигнатурой, за исключением того, что один параметр передается по ссылке, а не по значению, приведет к тому, что vb source, который ссылается на API, не сможет разрешить функцию. 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);
...