Chciałbym zebrać jak najwięcej informacji dotyczących wersjonowania API w .NET / CLR, a konkretnie, w jaki sposób zmiany API niszczą lub nie psują aplikacji klienckich. Najpierw zdefiniujmy niektóre terminy:
Zmiana interfejsu API - zmiana publicznie widocznej definicji typu, w tym dowolnego z jego publicznych elementów. Obejmuje to zmianę typu i nazwy członka, zmianę typu podstawowego typu, dodawanie / usuwanie interfejsów z listy zaimplementowanych interfejsów typu, dodawanie / usuwanie członków (w tym przeciążeń), zmianę widoczności członka, zmianę nazwy metody i parametrów typu, dodawanie wartości domyślnych dla parametrów metody, dodawania / usuwania atrybutów typów i członków oraz dodawania / usuwania ogólnych parametrów typów dla typów i członków (czy coś przegapiłem?). Nie obejmuje to żadnych zmian w organach członkowskich ani żadnych zmian w członkach prywatnych (tj. Nie bierzemy pod uwagę Odbicia).
Przerwa na poziomie binarnym - zmiana interfejsu API, która powoduje, że zestawy klientów kompilowane na podstawie starszej wersji interfejsu API potencjalnie nie ładują się z nową wersją. Przykład: zmiana sygnatury metody, nawet jeśli pozwala na wywołanie w taki sam sposób, jak poprzednio (tj. Void, aby zwrócić typ / parametr wartości domyślne przeciążenia).
Przerwa na poziomie źródła - zmiana interfejsu API, która powoduje, że istniejący kod został napisany w celu skompilowania ze starszą wersją interfejsu API potencjalnie nie kompilując się z nową wersją. Jednak już skompilowane zestawy klienckie działają tak jak wcześniej. Przykład: dodanie nowego przeciążenia, które może powodować niejednoznaczność wywołań metod, które były jednoznaczne poprzednio.
Cicha semantyka na poziomie źródła - zmiana API, która powoduje, że istniejący kod napisany w celu kompilacji ze starszą wersją API cicho zmienia semantykę, np. Przez wywołanie innej metody. Kod powinien jednak nadal się kompilować bez ostrzeżeń / błędów, a wcześniej skompilowane zestawy powinny działać jak poprzednio. Przykład: implementacja nowego interfejsu w istniejącej klasie, która powoduje wybranie innego przeciążenia podczas rozwiązywania problemu przeciążenia.
Ostatecznym celem jest skatalogowanie jak największej liczby łamliwych i cichych semantyki zmian w interfejsie API oraz opisanie dokładnego efektu złamania oraz tego, na jakie języki nie ma on wpływu. Aby rozwinąć ten drugi: podczas gdy niektóre zmiany dotyczą wszystkich języków uniwersalnie (np. Dodanie nowego elementu do interfejsu spowoduje przerwanie implementacji tego interfejsu w dowolnym języku), niektóre wymagają bardzo specyficznej semantyki języka, aby wejść do gry, aby uzyskać przerwę. Zwykle wiąże się to z przeciążeniem metod i ogólnie wszystkim, co ma związek z konwersjami typu niejawnego. Wydaje się, że nie ma tutaj sposobu na zdefiniowanie „najmniej wspólnego mianownika” nawet dla języków zgodnych z CLS (tj. Tych, które są zgodne co najmniej z regułami „konsumenta CLS” zdefiniowanymi w specyfikacji CLI) - chociaż ja ” Będę wdzięczny, jeśli ktoś poprawi mnie, że się tutaj mylę - więc będzie musiał przejść język po języku. Najbardziej interesujące są oczywiście te, które są dostarczane z .NET po wyjęciu z pudełka: C #, VB i F #; ale inne, takie jak IronPython, IronRuby, Delphi Prism itp. są również istotne. Im bardziej jest to przypadek narożny, tym bardziej interesujące będzie - usuwanie elementów jest dość oczywiste, ale subtelne interakcje między np. Przeciążeniem metody, parametrami opcjonalnymi / domyślnymi, wnioskowaniem typu lambda i operatorami konwersji mogą być bardzo zaskakujące czasami.
Kilka przykładów na rozpoczęcie:
Dodawanie przeciążeń nowej metody
Rodzaj: przerwa na poziomie źródła
Języki, których to dotyczy: C #, VB, F #
API przed zmianą:
public class Foo
{
public void Bar(IEnumerable x);
}
API po zmianie:
public class Foo
{
public void Bar(IEnumerable x);
public void Bar(ICloneable x);
}
Przykładowy kod klienta działający przed zmianą i zepsuty po nim:
new Foo().Bar(new int[0]);
Dodanie nowych przeciążeń operatora niejawnej konwersji
Rodzaj: przerwa na poziomie źródła.
Języki, których to dotyczy: C #, VB
Nie dotyczy języków: F #
API przed zmianą:
public class Foo
{
public static implicit operator int ();
}
API po zmianie:
public class Foo
{
public static implicit operator int ();
public static implicit operator float ();
}
Przykładowy kod klienta działający przed zmianą i zepsuty po nim:
void Bar(int x);
void Bar(float x);
Bar(new Foo());
Uwagi: F # nie jest zepsuty, ponieważ nie ma żadnego wsparcia na poziomie języka dla przeciążonych operatorów, ani jawnych, ani niejawnych - oba muszą być wywoływane bezpośrednio jako op_Explicit
i op_Implicit
metody.
Dodanie nowych metod instancji
Rodzaj: cicha semantyka na poziomie źródła.
Języki, których to dotyczy: C #, VB
Nie dotyczy języków: F #
API przed zmianą:
public class Foo
{
}
API po zmianie:
public class Foo
{
public void Bar();
}
Przykładowy kod klienta, który podlega cichej zmianie semantyki:
public static class FooExtensions
{
public void Bar(this Foo foo);
}
new Foo().Bar();
Uwagi: F # nie jest zepsuty, ponieważ nie obsługuje języka ExtensionMethodAttribute
i wymaga wywoływania metod rozszerzenia CLS jako metod statycznych.
źródło
Odpowiedzi:
Zmiana podpisu metody
Rodzaj: Break na poziomie binarnym
Języki, których to dotyczy: C # (VB i F # najprawdopodobniej, ale niesprawdzone)
API przed zmianą
API po zmianie
Przykładowy kod klienta działający przed zmianą
źródło
bar
.Dodanie parametru o wartości domyślnej.
Kind of Break: przerwa na poziomie binarnym
Nawet jeśli wywołujący kod źródłowy nie musi się zmieniać, nadal wymaga ponownej kompilacji (podobnie jak w przypadku dodawania zwykłego parametru).
Jest tak, ponieważ C # kompiluje wartości domyślne parametrów bezpośrednio do zestawu wywołującego. Oznacza to, że jeśli nie dokonasz ponownej kompilacji, otrzymasz MissingMethodException, ponieważ stary zestaw próbuje wywołać metodę z mniejszą liczbą argumentów.
Interfejs API przed zmianą
API po zmianie
Przykładowy kod klienta, który jest następnie łamany
Kod klienta musi zostać ponownie skompilowany na
Foo(5, null)
poziomie kodu bajtowego. Wywoływany zestaw będzie zawierał tylkoFoo(int, string)
, a nieFoo(int)
. Jest tak, ponieważ domyślne wartości parametrów są wyłącznie funkcją językową, środowisko wykonawcze .Net nic o nich nie wie. (To wyjaśnia również, dlaczego wartości domyślne muszą być stałymi w czasie kompilacji w C #).źródło
Func<int> f = Foo;
// to się nie powiedzie ze zmienionym podpisemTen był bardzo nieoczywisty, kiedy go odkryłem, zwłaszcza w świetle różnicy w tej samej sytuacji dla interfejsów. To wcale nie jest przerwa, ale zaskakujące jest to, że zdecydowałem się dołączyć:
Przekształcenie członków klasy w klasę podstawową
Rodzaj: bez przerwy!
Języki, których dotyczy problem: brak (tzn. Żaden nie jest uszkodzony)
API przed zmianą:
API po zmianie:
Przykładowy kod, który działa przez całą zmianę (mimo że spodziewałem się, że się zepsuje):
Uwagi:
C ++ / CLI jest jedynym językiem .NET, który ma konstrukcję analogiczną do implementacji interfejsu jawnego dla członków wirtualnej klasy podstawowej - „jawne zastąpienie”. W pełni spodziewałem się, że spowoduje to taki sam rodzaj zepsucia, jak przy przenoszeniu elementów interfejsu do interfejsu podstawowego (ponieważ IL wygenerowana dla jawnego zastąpienia jest taka sama jak dla jawnej implementacji). Ku mojemu zdziwieniu tak nie jest - mimo że wygenerowana IL nadal określa, że
BarOverride
zastępuje,Foo::Bar
aFooBase::Bar
moduł ładujący jest wystarczająco inteligentny, aby zastąpić się nawzajem poprawnie bez żadnych skarg - najwyraźniejFoo
to, co robi różnicę , jest klasą. Domyśl...źródło
Ten jest być może nie tak oczywistym szczególnym przypadkiem „dodawania / usuwania elementów interfejsu”, i pomyślałem, że zasługuje na własny wpis w świetle innego przypadku, który zamierzam opublikować w następnej kolejności. Więc:
Refaktoryzacja elementów interfejsu do interfejsu podstawowego
Rodzaj: zrywa na poziomie źródłowym i binarnym
Języki, których dotyczy problem: C #, VB, C ++ / CLI, F # (dla przerwania źródła; binarny naturalnie wpływa na dowolny język)
API przed zmianą:
API po zmianie:
Przykładowy kod klienta, który jest uszkodzony przez zmianę na poziomie źródła:
Przykładowy kod klienta, który jest uszkodzony przez zmianę na poziomie binarnym;
Uwagi:
W przypadku podziału poziomu źródła problem polega na tym, że C #, VB i C ++ / CLI wymagają dokładnej nazwy interfejsu w deklaracji implementacji elementu interfejsu; dlatego jeśli element zostanie przeniesiony do interfejsu podstawowego, kod nie będzie się kompilował.
Przerwanie binarne wynika z faktu, że metody interfejsu są w pełni kwalifikowane w wygenerowanej IL do jawnych implementacji, a nazwa interfejsu musi być również dokładna.
Implikowana implementacja, jeśli jest dostępna (tj. C # i C ++ / CLI, ale nie VB) będzie działać dobrze zarówno na poziomie źródłowym, jak i binarnym. Wywołania metod też nie psują.
źródło
Implements IFoo.Bar
będzie się odwoływać w przejrzysty sposóbIFooBase.Bar
?Zmiana kolejności wyliczonych wartości
Rodzaj przerwy: cicha semantyka na poziomie źródła / na poziomie binarnym
Dotyczy języków: wszystkie
Zmiana kolejności wyliczonych wartości zachowa zgodność na poziomie źródła, ponieważ literały mają tę samą nazwę, ale ich indeksy porządkowe zostaną zaktualizowane, co może powodować niektóre rodzaje cichych przerw na poziomie źródła.
Jeszcze gorzej jest cichy podział na poziomie binarnym, który można wprowadzić, jeśli kod klienta nie zostanie ponownie skompilowany z nową wersją API. Wartości wyliczane są stałymi czasami kompilacji i jako takie wszelkie ich zastosowania są zapisywane w IL zestawu klienta. Ten przypadek może być czasami szczególnie trudny do wykrycia.
Interfejs API przed zmianą
API po zmianie
Przykładowy kod klienta, który działa, ale później jest uszkodzony:
źródło
To jest naprawdę bardzo rzadka rzecz w praktyce, ale mimo to zaskakująca, kiedy to się dzieje.
Dodawanie nowych nieobciążonych członków
Rodzaj: przerwa na poziomie źródła lub cicha zmiana semantyki.
Języki, których to dotyczy: C #, VB
Nie dotyczy języków: F #, C ++ / CLI
API przed zmianą:
API po zmianie:
Przykładowy kod klienta, który jest uszkodzony przez zmianę:
Uwagi:
Problem jest spowodowany wnioskowaniem typu lambda w C # i VB w obecności rozdzielczości przeciążenia. Stosuje się tutaj ograniczoną formę pisania kaczki, aby zerwać więzi, w których pasuje więcej niż jeden typ, sprawdzając, czy ciało lambda ma sens dla danego typu - jeśli tylko jeden typ daje kompilowalną treść, to ten jest wybierany.
Niebezpieczeństwo polega na tym, że kod klienta może mieć przeciążoną grupę metod, w której niektóre metody przyjmują argumenty własnych typów, a inne przyjmują argumenty typów ujawnione przez bibliotekę. Jeśli któryś z jego kodów opiera się następnie na algorytmie wnioskowania typu, aby ustalić poprawną metodę opartą wyłącznie na obecności lub nieobecności członków, wówczas dodanie nowego członka do jednego z typów o tej samej nazwie, co w jednym z typów klienta, może potencjalnie wyłączone, co powoduje niejednoznaczność podczas usuwania przeciążenia.
Zauważ, że typy
Foo
iBar
w tym przykładzie nie są w żaden sposób powiązane, ani przez dziedziczenie, ani w żaden inny sposób. Wystarczy ich użycie w jednej grupie metod, aby to wyzwolić, a jeśli zdarzy się to w kodzie klienta, nie masz nad tym kontroli.Przykładowy kod powyżej pokazuje prostszą sytuację, w której jest to przerwa na poziomie źródła (tj. Wyniki błędów kompilatora). Może to jednak być również cicha zmiana semantyki, jeśli przeciążenie wybrane za pomocą wnioskowania zawiera inne argumenty, które w przeciwnym razie spowodowałyby, że zostanie on uszeregowany poniżej (np. Argumenty opcjonalne z wartościami domyślnymi lub niedopasowanie typu między argumentem zadeklarowanym a rzeczywistym wymagającym niejawnego argumentu konwersja). W takim scenariuszu rozdzielczość przeciążenia już nie zawiedzie, ale kompilator po cichu wybierze inne przeciążenie. W praktyce jednak bardzo trudno jest napotkać ten przypadek bez starannego tworzenia sygnatur metod, aby celowo go spowodować.
źródło
Przekształć implementację interfejsu niejawnego na jawny.
Kind of Break: Source and Binary
Języki, których dotyczy problem: wszystkie
Jest to tak naprawdę tylko wariant zmiany dostępności metody - jest tylko trochę bardziej subtelny, ponieważ łatwo przeoczyć fakt, że nie każdy dostęp do metod interfejsu jest koniecznie przez odniesienie do typu interfejsu.
Interfejs API przed zmianą:
Interfejs API po zmianie:
Przykładowy kod klienta, który działa przed zmianą, a następnie ulega uszkodzeniu:
źródło
Konwertuj jawną implementację interfejsu na domyślną.
Kind of Break: Źródło
Języki, których dotyczy problem: wszystkie
Refaktoryzacja implementacji interfejsu jawnego na domyślną jest bardziej subtelna w tym, w jaki sposób może ona złamać interfejs API. Na pozór wydaje się, że powinno to być względnie bezpieczne, jednak w połączeniu z dziedziczeniem może powodować problemy.
Interfejs API przed zmianą:
Interfejs API po zmianie:
Przykładowy kod klienta, który działa przed zmianą, a następnie ulega uszkodzeniu:
źródło
Foo
nie miała nazwy publicznejGetEnumerator
, a metoda wywoływana jest za pomocą odwołania typuFoo
.. ,yield return "Bar"
:) ale tak, widzę, dokąd to zmierza -foreach
zawsze wywołuje publiczną metodę o nazwieGetEnumerator
, nawet jeśli nie jest to prawdziwa implementacjaIEnumerable.GetEnumerator
. Wydaje się, że ma to jeszcze jeden kąt: nawet jeśli masz tylko jedną klasę i implementuje się jąIEnumerable
jawnie, oznacza to, że dodanie zmiany nazwy metody publicznej,GetEnumerator
która jąforeach
wywoła , jest przełomową zmianą źródła , ponieważ teraz będzie ona używać tej metody zamiast implementacji interfejsu. Ten sam problem dotyczy równieżIEnumerator
wdrażania ...Zmiana pola na właściwość
Kind of Break: API
Języki, których dotyczy problem: Visual Basic i C # *
Informacja: Kiedy zmienisz normalne pole lub zmienną na właściwość w języku Visual Basic, każdy kod zewnętrzny odnoszący się do tego elementu w jakikolwiek sposób będzie musiał zostać ponownie skompilowany.
Interfejs API przed zmianą:
Interfejs API po zmianie:
Przykładowy kod klienta, który działa, ale później jest uszkodzony:
źródło
out
iref
argumentów metod, w przeciwieństwie do pól, i nie może być celem&
operatora jednoargumentowego .Dodawanie przestrzeni nazw
Przerwa na poziomie źródła / cicha semantyka na poziomie źródła
Ze względu na sposób, w jaki działa rozpoznawanie przestrzeni nazw w vb.Net, dodanie przestrzeni nazw do biblioteki może spowodować, że kod Visual Basic skompilowany z poprzednią wersją interfejsu API nie skompiluje się z nową wersją.
Przykładowy kod klienta:
Jeśli nowa wersja interfejsu API doda przestrzeń nazw
Api.SomeNamespace.Data
, powyższy kod nie zostanie skompilowany.Staje się to bardziej skomplikowane przy imporcie przestrzeni nazw na poziomie projektu. Jeśli
Imports System
zostanie pominięty w powyższym kodzie, aleSystem
przestrzeń nazw zostanie zaimportowana na poziomie projektu, kod może nadal powodować błąd.Jeśli jednak interfejs API zawiera klasę
DataRow
wApi.SomeNamespace.Data
przestrzeni nazw, kod zostanie skompilowany, aledr
będzie to przypadek,System.Data.DataRow
gdy zostanie skompilowany ze starą wersją interfejsu API iApi.SomeNamespace.Data.DataRow
po skompilowaniu z nową wersją interfejsu API.Zmiana nazwy argumentu
Przerwa na poziomie źródła
Zmiana nazw argumentów jest przełomową zmianą w vb.net od wersji 7 (?) (.Net wersja 1?) I c # .net od wersji 4 (.Net wersja 4).
API przed zmianą:
API po zmianie:
Przykładowy kod klienta:
Parametry ref
Przerwa na poziomie źródła
Dodanie zastąpienia metody z tą samą sygnaturą, z tym wyjątkiem, że jeden parametr jest przekazywany przez referencję zamiast przez wartość spowoduje, że źródło vb, które odwołuje się do interfejsu API, nie będzie w stanie rozwiązać funkcji. Visual Basic nie ma możliwości (?) Odróżnienia tych metod w punkcie wywołania, chyba że mają one różne nazwy argumentów, więc taka zmiana może spowodować, że oba elementy nie będą nadawać się do użycia z kodu VB.
API przed zmianą:
API po zmianie:
Przykładowy kod klienta:
Zmiana pola na właściwość
Przerwa na poziomie binarnym / Przerwa na poziomie źródła
Oprócz oczywistej przerwy na poziomie binarnym może to powodować przerwanie na poziomie źródła, jeśli element członkowski zostanie przekazany do metody przez odwołanie.
API przed zmianą:
API po zmianie:
Przykładowy kod klienta:
źródło
Zmiana interfejsu API:
Przerwa na poziomie binarnym:
Dodanie nowego elementu (chronionego przed zdarzeniem), który wykorzystuje typ z innego zestawu (Klasa 2) jako ograniczenie argumentu szablonu.
Zmiana klasy potomnej (Class3), aby wywodziła się z typu w innym zestawie, gdy klasa jest używana jako argument szablonu dla tej klasy.
Ciche zmiany semantyki na poziomie źródła:
(nie jestem pewien, gdzie one pasują)
Zmiany we wdrożeniu:
Zmiany w bootstrapie / konfiguracji:
Aktualizacja:
Przepraszam, nie zdawałem sobie sprawy, że jedynym powodem, dla którego mi to przeszkadzało, było użycie ich w ograniczeniach szablonów.
źródło
TypeForwardedToAttribute
zostanie użyte.-Werror
naciskaj w swoim systemie kompilacji, który dostarczasz z wydanymi archiwami. Ta flaga jest najbardziej pomocna dla twórcy kodu i najczęściej nieprzydatna dla konsumenta.Dodanie metod przeciążania w celu zmniejszenia użycia domyślnych parametrów
Rodzaj przerwy: cicha semantyka na poziomie źródła zmienia się
Ponieważ kompilator przekształca wywołania metod z brakującymi wartościami parametrów domyślnych w jawne wywołanie z wartością domyślną po stronie wywołującej, zapewniona jest kompatybilność z istniejącym skompilowanym kodem; dla całego wcześniej skompilowanego kodu zostanie znaleziona metoda z poprawnym podpisem.
Z drugiej strony wywołania bez użycia parametrów opcjonalnych są teraz kompilowane jako wywołanie nowej metody, w której brakuje parametru opcjonalnego. Wszystko nadal działa poprawnie, ale jeśli wywoływany kod znajduje się w innym zestawie, nowo skompilowany kod wywołujący go jest teraz zależny od nowej wersji tego zestawu. Wdrażanie zestawów wywołujących refaktoryzowany kod bez wdrażania pakietu, w którym znajduje się refaktoryzowany kod, powoduje wyjątki „nie znaleziono metody”.
API przed zmianą
API po zmianie
Przykładowy kod, który nadal będzie działał
Przykładowy kod, który jest teraz zależny od nowej wersji podczas kompilacji
źródło
Zmiana nazwy interfejsu
Kinda of Break: Source and Binary
Języki, których dotyczy problem: najprawdopodobniej wszystkie przetestowane w C #.
Interfejs API przed zmianą:
Interfejs API po zmianie:
Przykładowy kod klienta, który działa, ale później jest uszkodzony:
źródło
Metoda przeciążenia z parametrem typu zerowalnego
Rodzaj: Przerwa na poziomie źródła
Języki, których to dotyczy: C #, VB
API przed zmianą:
API po zmianie:
Przykładowy kod klienta działający przed zmianą i zepsuty po nim:
Wyjątek: wywołanie jest niejednoznaczne między następującymi metodami lub właściwościami.
źródło
Awans na metodę przedłużenia
Rodzaj: przerwa na poziomie źródła
Języki, których dotyczy problem: C # v6 i wyższe (może inne?)
API przed zmianą:
API po zmianie:
Przykładowy kod klienta działający przed zmianą i zepsuty po nim:
Więcej informacji: https://github.com/dotnet/csharplang/issues/665
źródło