Я хотел бы собрать как можно больше информации об управлении версиями API в .NET/CLR, а также о том, как изменения API делают или не разрушают клиентские приложения. Сначала определим некоторые термины:
Изменение API - изменение общедоступного определения типа, включая любого из его публичных пользователей. Это включает в себя изменение типов и имен участников, изменение базового типа типа, добавление/удаление интерфейсов из списка реализованных интерфейсов типа, добавление/удаление элементов (включая перегрузки), изменение видимости элемента, метод переименования и параметры типа, добавление значений по умолчанию для параметров метода, добавления/удаления атрибутов для типов и элементов и добавления/удаления параметров типового типа для типов и элементов (я пропустил что-нибудь?). Это не включает никаких изменений в телах членов или каких-либо изменений в частных членах (т.е. Мы не учитываем Reflection).
Разрыв на двоичном уровне - изменение API, которое приводит к сбоям клиента, скомпилированным по сравнению со старой версией API, потенциально не загружаемой новой версией. Пример: изменение сигнатуры метода, даже если он позволяет вызываться так же, как и раньше (т.е.: Void для возврата значений значений по умолчанию для параметра/параметра по умолчанию).
Разрыв на уровне источника - изменение API, в результате чего существующий код написан для компиляции с более старой версией API, потенциально не скомпилированной с новой версией. Однако уже скомпилированные клиентские сборки работают по-прежнему. Пример: добавление новой перегрузки, которая может привести к двусмысленности в вызовах методов, которые были однозначными ранее.
Изменение тихой семантики на уровне исходного кода - изменение API, в результате чего существующий код, написанный для компиляции с более старой версией API, спокойно меняет свою семантику, например. путем вызова другого метода. Однако код должен продолжать компиляцию без предупреждений/ошибок, и ранее скомпилированные сборки должны работать по-прежнему. Пример: реализация нового интерфейса в существующем классе, в результате которого при перегрузке выбирается другая перегрузка.
Конечная цель состоит в том, чтобы каталогизировать как можно больше изменчивых и спокойных семантических API-интерфейсов, а также описывать точный эффект разлома, и какие языки не затрагиваются и не затрагиваются им. Чтобы расширить последнее: хотя некоторые изменения затрагивают все языки повсеместно (например, добавление нового элемента в интерфейс приведет к нарушению реализации этого интерфейса на любом языке), некоторые требуют очень специфической семантики языка для входа в игру, чтобы получить перерыв. Обычно это включает перегрузку методов и, в общем, что-то, что связано с неявными преобразованиями типов. Кажется, что нет никакого способа определить "наименьший общий знаменатель" здесь даже для CLS-совместимых языков (т.е. тех, которые соответствуют, по крайней мере, правилам "потребителя CLS", как определено в спецификации CLI), хотя я буду признателен, если кто-то исправляет меня как неправильный здесь - так что это должно будет идти языком по языку. Наиболее интересными являются, естественно, те, которые поставляются с .NET из коробки: С#, VB и F #; но другие, такие как IronPython, IronRuby, Delphi Prism и т.д. также актуальны. Чем больше углового случая, тем интереснее это будет - такие вещи, как удаление членов, довольно очевидны, но тонкие взаимодействия между, например, перегрузка метода, необязательные/параметры по умолчанию, вывод лямбда-типа и операторы преобразования могут быть очень неожиданными.
Несколько примеров, чтобы запустить это:
Вид: разрыв исходного уровня
Языки затронуты: С#, 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]);
Вид: разрыв исходного уровня.
Языки затронуты: С#, 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
.
Вид: тихая семантика на уровне исходного кода.
Языки затронуты: С#, 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 вызывались как статические методы.
Вид: разрыв двоичного уровня
Языки затронуты: С# (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);
bar
.
Тип разрыва: разрыв двоичного уровня
Даже если исходный код вызова не нуждается в изменении, его все равно необходимо перекомпилировать (как при добавлении регулярного параметра).
Это потому, что С# компилирует значения параметров по умолчанию непосредственно в вызывающую сборку. Это означает, что если вы не перекомпилируете, вы получите исключение 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 ничего о них не знает. (Это также объясняет, почему значения по умолчанию должны быть константами времени компиляции в С#).
Func<int> f = Foo;
// это не удастся с измененной подписью
Это было очень неочевидно, когда я его обнаружил, особенно в свете разницы с той же ситуацией для интерфейсов. Это не перерыв, но это удивительно, что я решил включить его:
Вид: не перерыв!
Языки затронуты: ни один (т.е. ни один не сломан)
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 {}
};
Примечания:
С++/CLI - это единственный язык .NET, который имеет конструкцию, аналогичную явной реализации интерфейса для членов виртуального базового класса - "явное переопределение". Я полностью ожидал, что это приведет к тому же поломке, что и при перемещении элементов интерфейса к базовому интерфейсу (поскольку IL, сгенерированный для явного переопределения, такой же, как для явной реализации). К моему удивлению, это не так, даже если сгенерированный IL по-прежнему указывает, что BarOverride
переопределяет Foo::Bar
, а не FooBase::Bar
, загрузчик сборок достаточно умен, чтобы правильно заменить один на другой без каких-либо жалоб - по-видимому, тот факт, что Foo
- это класс, в чем разница. Перейти фигурой...
Это, возможно, не столь очевидный частный случай "добавления/удаления элементов интерфейса", и я решил, что он заслуживает собственной записи в свете другого случая, который я собираюсь опубликовать дальше. Итак:
Вид: разбивается как на исходный, так и на двоичный уровни
Языки затронуты: С#, VB, С++/CLI, F # (для исходного break; двоичного, естественно, влияет на любой язык)
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();
Примечания:
Для разрыва исходного уровня проблема в том, что для С#, VB и С++/CLI требуется точное имя интерфейса в объявлении реализации члена интерфейса; таким образом, если элемент перемещается в базовый интерфейс, код больше не будет компилироваться.
Двоичный разрыв связан с тем, что методы интерфейса полностью определены в сгенерированном IL для явных реализаций, а также имя интерфейса также должно быть точным.
Неявная реализация там, где она доступна (т.е. С# и С++/CLI, но не VB) будет нормально работать как на уровне источника, так и на двоичном уровне. Вызов метода также не прерывается.
Implements IFoo.Bar
будет прозрачно ссылаться на IFooBase.Bar
?
Вид разрыва: Изменение тихой семантики исходного уровня/двоичного уровня
Языки затронуты: все
Переупорядоченные перечислимые значения будут поддерживать совместимость на уровне источника, так как литералы имеют одно и то же имя, но их порядковые индексы будут обновляться, что может вызвать некоторые виды молчащих разрывов на уровне источника.
Еще хуже, что тихие разрывы двоичного уровня могут быть введены, если код клиента не перекомпилирован в отношении новой версии API. Значения Enum являются константами времени компиляции, и поэтому их использование испекает в клиентской сборке IL. Этот случай может быть особенно трудным определить время от времени.
public enum Foo
{
Bar,
Baz
}
public enum Foo
{
Baz,
Bar
}
Foo.Bar < Foo.Baz
Это действительно очень редко на практике, но тем не менее удивительно, когда это происходит.
Вид: изменение уровня источника или тихая семантика.
Языки затронуты: С#, VB
Языки не затронуты: F #, С++/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());
}
}
Примечания:
Проблема здесь вызвана выводами лямбда-типа в С# и 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
Затрагиваемые языки: Visual Basic и С# *
Info: Когда вы меняете нормальное поле или переменную в свойство в визуальном базовом, любой внешний код, ссылающийся на этого участника каким-либо образом, должен быть перекомпилирован.
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"
out
и ref
методов, в отличие от полей, и не могут быть целью унарного оператора &
.
Вид разрыва: Источник
Затрагиваемые языки: все
Рефакторинг явной реализации интерфейса в неявный является более тонким в том, как он может нарушить 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"
Foo
не было открытого метода с именем GetEnumerator
, и вы вызываете метод через ссылку типа Foo
...
Изменение уровня исходного уровня/изменение тишины на уровне исходного уровня
Из-за того, как разрешение пространства имен работает в 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 version 1?) и С#.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:
Разрыв двоичного уровня:
Добавление нового члена (защищенного события), который использует тип из другой сборки (Class2) в качестве ограничения аргумента шаблона.
protected void Something<T>() where T : Class2 { }
Изменение дочернего класса (Class3) для вывода из типа в другой сборке, когда класс используется как аргумент шаблона для этого класса.
protected class Class3 : Class2 { }
protected void Something<T>() where T : Class3 { }
Изменение тихой семантики исходного уровня:
(не уверен, где они подходят)
Изменения развертывания:
Изменения в Bootstrap/Configuration:
Update:
Извините, я не понимал, что единственная причина, по которой это ломалось для меня, это то, что я использовал их в ограничениях шаблонов.
TypeForwardedToAttribute
.
Вид разрыва: Изменение тихой семантики исходного уровня
Поскольку компилятор преобразует вызовы методов с отсутствующими значениями параметров по умолчанию для явного вызова со значением по умолчанию на вызывающей стороне, предоставляется совместимость для существующего скомпилированного кода; метод с правильной сигнатурой будет найден для всего ранее скомпилированного кода.
С другой стороны, вызовы без использования необязательных параметров теперь скомпилированы как вызов нового метода, в котором отсутствует необязательный параметр. Все это работает нормально, но если вызываемый код находится в другой сборке, новый скомпилированный код, вызывающий его, теперь зависит от новой версии этой сборки. Развертывание сборок, вызывающих рефакторизованный код, без развертывания сборки, в которой находится реорганизованный код, приводит к исключениям "метод не найден".
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);
}
Вид разрыва: источник и Двоичный
Затрагиваемые языки: скорее всего все, протестированные на С#.
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