Полное руководство по кардинальным изменениям API в .NET.

StackOverflow https://stackoverflow.com/questions/1456785

Вопрос

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

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

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

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

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

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

Несколько примеров для начала:

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

Добрый:разрыв на уровне исходного кода

Затронутые языки:С#, ВБ, Ф#

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

Добавление новых перегрузок операторов неявного преобразования

Добрый:разрыв на уровне источника.

Затронутые языки:С#, ВБ

Не затронутые языки:Ф#

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 методы.

Добавление новых методов экземпляра

Добрый:тихое изменение семантики на уровне источника.

Затронутые языки:С#, ВБ

Не затронутые языки:Ф#

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

Это было полезно?

Решение

Изменение сигнатуры метода

Добрый:Перерыв на двоичном уровне

Затронутые языки: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);

Другие советы

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

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

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

Это связано с тем, что 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#).

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

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

Добрый:не перерыв!

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

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

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

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

Добрый:разрывы как на исходном, так и на двоичном уровне

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

Изменение порядка перечисляемых значений

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

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

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

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

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

public enum Foo
{
   Bar,
   Baz
}

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

public enum Foo
{
   Baz,
   Bar
}

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

Foo.Bar < Foo.Baz

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

Добавление новых не перегруженных участников

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

Затронутые языки:С#, ВБ

Не затронутые языки: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 в этом примере не связаны никаким образом, ни по наследству, ни как-то иначе.Простого использования их в одной группе методов достаточно, чтобы вызвать это, и если это происходит в клиентском коде, вы не можете это контролировать.

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

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

Вид перерыва:Исходный и двоичный файлы

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

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

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"

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

Вид перерыва: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"

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

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

Из-за того, как разрешение пространства имен работает в 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

Ссылочные параметры

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

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

Изменение 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 или любым процессором
  4. Сборка/тестирование на другой установке фреймворка (т.е.установка версии 3.5 на компьютер .Net 2.0 позволяет выполнять вызовы API, для которых затем требуется .Net 2.0 SP2)

Изменения начальной загрузки/конфигурации:

  1. Добавление/удаление/изменение пользовательских опций конфигурации (т.е.настройки App.config)
  2. В связи с интенсивным использованием IoC/DI в современных приложениях необходимо переконфигурировать и/или изменить код начальной загрузки для кода, зависящего от DI.

Обновлять:

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

Добавление методов перегрузки для прекращения использования параметров по умолчанию.

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

Поскольку компилятор преобразует вызовы методов с отсутствующими значениями параметров по умолчанию в явный вызов со значением по умолчанию на вызывающей стороне, обеспечивается совместимость существующего скомпилированного кода;метод с правильной сигнатурой будет найден для всего ранее скомпилированного кода.

С другой стороны, вызовы без использования необязательных параметров теперь компилируются как вызов нового метода, в котором отсутствует необязательный параметр.Все по-прежнему работает нормально, но если вызываемый код находится в другой сборке, вызывающий его вновь скомпилированный код теперь зависит от новой версии этой сборки.Развертывание сборок, вызывающих реорганизованный код, без развертывания сборки, в которой находится рефакторинг, приводит к возникновению исключений «метод не найден».

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

Переименование интерфейса

Типа перерыва:Источник и Двоичный

Затронутые языки:Скорее всего все, проверено на 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

Метод перегрузки с параметром типа, допускающего значение NULL.

Добрый: Разрыв на уровне источника

Затронутые языки: С#, ВБ

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

Исключение:Вызов неоднозначен между следующими методами или свойствами.

Переход к методу расширения

Добрый:разрыв на уровне исходного кода

Затронутые языки: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/charpang/issues/665

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top