C #: Abwärtskompatibilität und Überlastung

Hallo Kollegen!

Wir erinnern alle daran, dass wir ein großartiges Mark Price-Buch haben: " C # 7 und .NET Core. Plattformübergreifende Entwicklung für Profis ". Bitte beachten Sie: Dies ist die dritte Ausgabe, die erste Ausgabe wurde in Version 6.0 geschrieben und erschien nicht in russischer Sprache. Die dritte Ausgabe wurde im November 2017 im Original veröffentlicht und umfasst Version 7.1.


Nach der Veröffentlichung eines solchen Kompendiums, das einer separaten wissenschaftlichen Bearbeitung unterzogen wurde, um die Abwärtskompatibilität und andere Korrektheit des vorgestellten Materials zu überprüfen, beschlossen wir, einen interessanten Artikel von John Skeet darüber zu übersetzen, welche bekannten und wenig bekannten Schwierigkeiten mit der Abwärtskompatibilität in C # auftreten können. Viel Spaß beim Lesen.

Bereits im Juli 2017 habe ich begonnen, einen Artikel über die Versionierung zu schreiben. Bald gab es auf, weil das Thema zu umfangreich war, um es in nur einem Beitrag zu behandeln. Bei einem solchen Thema ist es sinnvoller, eine ganze Site / ein Wiki / ein Repository hervorzuheben. Ich hoffe, dass ich eines Tages auf dieses Thema zurückkommen kann, da ich es für äußerst wichtig halte und denke, dass es viel weniger Aufmerksamkeit erhält, als es verdient.

Im .NET-Ökosystem wird die semantische Versionierung normalerweise begrüßt - es klingt großartig, erfordert jedoch, dass jeder gleichermaßen versteht, was als „grundlegende Änderung“ angesehen wird. Das habe ich mir schon lange gedacht. Einer der Aspekte, die mich zuletzt beeindruckt haben, ist, wie schwierig es ist, grundlegende Änderungen bei Überladungsmethoden zu vermeiden. Es geht (hauptsächlich) darum, dass wir den Beitrag diskutieren, den Sie lesen; Immerhin ist dieses Thema sehr interessant.
Um loszulegen - eine kurze Definition ...

Quellen und Binärkompatibilität

Wenn ich meinen Clientcode mit der neuen Version der Bibliothek neu kompilieren kann und alles einwandfrei funktioniert, ist dies Kompatibilität auf Quellcodeebene. Wenn ich meine Client-Binärdatei mit der neuen Version der Bibliothek erneut bereitstellen kann, ohne sie neu zu kompilieren, ist sie binärkompatibel. Nichts davon ist eine Obermenge des anderen:

  • Einige Änderungen sind möglicherweise nicht gleichzeitig mit dem Quellcode und dem Binärcode kompatibel. Sie können beispielsweise nicht einen gesamten öffentlichen Typ löschen, von dem Sie vollständig abhängig sind.
  • Einige Änderungen sind mit dem Quellcode kompatibel, jedoch nicht mit Binärcode kompatibel. Wenn Sie beispielsweise ein öffentliches schreibgeschütztes statisches Feld in eine Eigenschaft konvertieren.
  • Einige Änderungen sind mit Binärdateien kompatibel, jedoch nicht mit der Quelle. Sie können beispielsweise eine Überladung hinzufügen, die beim Kompilieren zu Mehrdeutigkeiten führen kann.
  • Einige Änderungen sind sowohl mit dem Quell- als auch mit dem Binärcode kompatibel - beispielsweise eine neue Implementierung des Methodenkörpers.

Worüber reden wir also?

Angenommen, wir haben eine öffentliche Bibliothek der Version 1.0 und möchten dieser mehrere Überladungen hinzufügen, um die Version 1.1 fertigzustellen. Wir bleiben bei der semantischen Versionierung, daher benötigen wir Abwärtskompatibilität. Was bedeutet das, was wir können und was nicht, und können alle Fragen hier mit „Ja“ oder „Nein“ beantwortet werden?

In verschiedenen Beispielen werde ich den Code in den Versionen 1.0 und 1.1 und dann den "Client" -Code (dh den Code, der die Bibliothek verwendet) zeigen, der aufgrund von Änderungen beschädigt werden kann. Es wird weder Methodenkörper noch Klassendeklarationen geben, da diese im Wesentlichen nicht wichtig sind - wir achten hauptsächlich auf Signaturen. Wenn Sie jedoch interessiert sind, können alle diese Klassen und Methoden leicht reproduziert werden. Angenommen, alle hier beschriebenen Methoden befinden sich in der Library Klasse.

Die einfachste denkbare Änderung, geschmückt mit der Umwandlung einer Gruppe von Methoden in einen Delegierten
Das einfachste Beispiel, das mir in den Sinn kommt, ist das Hinzufügen einer parametrisierten Methode, bei der es bereits eine nicht parametrisierte gibt:

  //   1.0 public void Foo() //   1.1 public void Foo() public void Foo(int x) 


Auch hier ist die Kompatibilität unvollständig. Betrachten Sie den folgenden Client-Code:

  //  static void Method() { var library = new Library(); HandleAction(library.Foo); } static void HandleAction(Action action) {} static void HandleAction(Action<int> action) {} 

In der ersten Version der Bibliothek ist alles in Ordnung. Durch Aufrufen der HandleAction Methode wird die Gruppe von Methoden in den library.Foo Delegaten konvertiert und als Ergebnis eine Action . In Version 1.1 wird die Situation mehrdeutig: Eine Gruppe von Methoden kann in Aktion oder Aktion konvertiert werden. Das heißt, genau genommen ist eine solche Änderung nicht mit dem Quellcode kompatibel.

In diesem Stadium ist es verlockend, einfach aufzugeben und sich zu versprechen, nie wieder Überlastungen hinzuzufügen. Oder wir können sagen, dass ein solcher Fall wahrscheinlich nicht genug Angst vor einem solchen Versagen hat. Nennen wir die Transformationen einer Gruppe von Methoden vorerst außerhalb des Geltungsbereichs.

Nicht verwandte Referenztypen

Stellen Sie sich einen anderen Kontext vor, in dem Sie Überladungen mit der gleichen Anzahl von Parametern verwenden müssen. Es ist davon auszugehen, dass eine solche Änderung der Bibliothek zerstörungsfrei ist:

 //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(FileStream x) 

Auf den ersten Blick ist alles logisch. Wir behalten die ursprüngliche Methode bei, damit die Binärkompatibilität nicht beeinträchtigt wird. Der einfachste Weg, dies zu unterbrechen, besteht darin, einen Aufruf zu schreiben, der in Version 1.0 funktioniert, jedoch nicht in Version 1.1 oder in beiden Versionen, jedoch auf unterschiedliche Weise.
Welche Inkompatibilität zwischen v1.0 und v1.1 kann ein solcher Aufruf verursachen? Wir müssen ein Argument haben, das sowohl mit string als auch mit FileStream kompatibel ist. Dies sind jedoch Referenztypen, die nicht miteinander verwandt sind ...

Der erste Fehler ist möglich, wenn wir eine benutzerdefinierte implizite Konvertierung in string und FileStream :

 //  class OddlyConvertible { public static implicit operator string(OddlyConvertible c) => null; public static implicit operator FileStream(OddlyConvertible c) => null; } static void Method() { var library = new Library(); var convertible = new OddlyConvertible(); library.Foo(convertible); } 

Ich hoffe, das Problem liegt auf der Hand: Der Code, der zuvor eindeutig war und mit string ist jetzt mehrdeutig, da der Typ OddlyConvertible implizit in string und FileStream konvertiert werden FileStream (beide Überladungen sind anwendbar, keine von ihnen ist besser als die andere).

Vielleicht ist es in diesem Fall vernünftig, benutzerdefinierte Konvertierungen zu verbieten ... aber dieser Code kann heruntergefahren werden und ist viel einfacher:

 //  static void Method() { var library = new Library(); library.Foo(null); } 

Wir können ein Nullliteral implizit in einen beliebigen Referenztyp oder in einen nullwertfähigen signifikanten Typ konvertieren. Daher ist die Situation in Version 1.1 wiederum nicht eindeutig. Versuchen wir es noch einmal ...

Parameter von Referenztypen und nicht nullbaren signifikanten Typen

Angenommen, wir interessieren uns nicht für benutzerdefinierte Transformationen, aber wir mögen keine problematischen Nullliterale. Wie kann in diesem Fall eine Überladung mit einem nicht nullbaren signifikanten Typ hinzugefügt werden?

  //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(int x) 

Auf den ersten Blick ist es gut - library.Foo(null) funktioniert in Version library.Foo(null) einwandfrei. Also ist er sicher? Nein, nur nicht in C # 7.1 ...

  //  static void Method() { var library = new Library(); library.Foo(default); } 

Das Standardliteral ist genau null, gilt jedoch für jeden Typ. Dies ist sehr praktisch - und ein echtes Problem, wenn es um Überlastung und Kompatibilität geht :(

Optionale Parameter

Optionale Parameter sind ein weiteres Problem. Angenommen, wir haben einen optionalen Parameter und möchten einen zweiten hinzufügen. Wir haben drei Optionen, die im Folgenden als 1.1a, 1.1b und 1.1c bezeichnet werden.

  //  1.0 public void Foo(string x = "") //  1.1a //   ,         public void Foo(string x = "") public void Foo(string x = "", string y = "") //  1.1b //          public void Foo(string x = "", string y = "") //  1.1c //   ,    ,   //  ,     . public void Foo(string x) public void Foo(string x = "", string y = "") 


Was aber, wenn der Client zwei Anrufe tätigt:

 //  static void Method() { var library = new Library(); library.Foo(); library.Foo("xyz"); } 

Bibliothek 1.1a behält die Kompatibilität auf Binärebene bei, verstößt jedoch auf Quellcodeebene: Jetzt ist library.Foo() eindeutig. Gemäß den Überladungsregeln in C # werden Methoden bevorzugt, bei denen der Compiler nicht alle verfügbaren optionalen Parameter „ausfüllen“ muss, jedoch nicht regelt, wie viele optionale Parameter gefüllt werden können.

Bibliothek 1.1b behält die Kompatibilität auf Quellenebene bei, verletzt jedoch die Binärkompatibilität. Vorhandener kompilierter Code dient zum Aufrufen einer Methode mit einem einzelnen Parameter - und eine solche Methode existiert nicht mehr.

Die 1.1c-Bibliothek behält die Binärkompatibilität bei, ist jedoch auf Quellcodeebene mit möglichen Überraschungen behaftet. Jetzt wird der Aufruf library.Foo() in eine Methode mit zwei Parametern aufgelöst, während library.Foo("xyz") in eine Methode mit einem Parameter aufgelöst wird (aus Sicht des Compilers ist dies einer Methode mit zwei Parametern vorzuziehen, hauptsächlich weil keine optionalen Parameter vorhanden sind keine Füllung erforderlich). Dies kann akzeptabel sein, wenn eine Version mit einem Parameter einfach Versionen mit zwei Parametern delegiert und in beiden Fällen der gleiche Standardwert verwendet wird. Es scheint jedoch seltsam, dass sich der Wert des ersten Aufrufs ändert, wenn die Methode, in die er zuvor aufgelöst wurde, noch vorhanden ist.

Die Situation mit optionalen Parametern wird noch verwirrender, wenn Sie einen neuen Parameter nicht am Ende, sondern in der Mitte hinzufügen möchten. Versuchen Sie beispielsweise, die Vereinbarung einzuhalten und den optionalen Parameter CancellationToken ganz am Ende beizubehalten. Ich werde nicht darauf eingehen ...

Verallgemeinerte Methoden

Das Abschließen von Typen in den besten Zeiten war keine leichte Aufgabe. Wenn es darum geht, Überlastungen zu beheben, wird diese Arbeit zu einem einheitlichen Albtraum.

Angenommen, wir haben in Version 1.0 nur eine nicht verallgemeinerte Methode, und in Version 1.1 fügen wir eine weitere verallgemeinerte Methode hinzu.

 //  1.0 public void Foo(object x) //  1.1 public void Foo(object x) public void Foo<T>(T x) 

Auf den ersten Blick ist es nicht so beängstigend ... aber mal sehen, was im Client-Code passiert:

 //  static void Method() { var library = new Library(); library.Foo(new object()); library.Foo("xyz"); } 

In der Bibliothek v1.0 werden beide Aufrufe in Foo(object) - der einzigen verfügbaren Methode.

Die v1.1-Bibliothek ist abwärtskompatibel: Wenn Sie die für v1.1 kompilierte ausführbare Client-Datei verwenden, verwenden beide Aufrufe weiterhin Foo(object) . Im Falle einer Neukompilierung wechselt der zweite Aufruf (und nur der zweite) zur Arbeit mit der verallgemeinerten Methode. Beide Methoden gelten für beide Aufrufe.

Beim ersten Aufruf zeigt die Typinferenz, dass T ein object , sodass die Konvertierung des Arguments in den Parametertyp in beiden Fällen auf object im object reduziert object . Großartig. Der Compiler wendet die Regel an, dass nicht generische Methoden generischen Methoden immer vorzuziehen sind.

Beim zweiten Aufruf zeigt die Typinferenz, dass T immer eine string ist. Wenn Sie also ein Argument in einen Typparameter konvertieren, erhalten Sie für die ursprüngliche Methode eine string für ein object oder für die verallgemeinerte Methode eine string für eine string . Die zweite Transformation ist „besser“, daher wird die zweite Methode gewählt.

Wenn die beiden Methoden auf die gleiche Weise funktionieren, ist das in Ordnung. Wenn nicht, wird die Kompatibilität auf eine nicht offensichtliche Weise unterbrochen.

Vererbung und dynamische Eingabe

Entschuldigung, ich bin schon außer Atem. Sowohl die Vererbung als auch die dynamische Typisierung beim Auflösen von Überlastungen können sich auf die „coolste“ und mysteriöseste Weise manifestieren.
Wenn wir eine solche Methode auf einer Ebene der Vererbungshierarchie hinzufügen, die die Methode der Basisklasse überlastet, wird die neue Methode zuerst verarbeitet und der Methode der Basisklasse vorgezogen, auch wenn die Methode der Basisklasse beim Konvertieren eines Arguments in einen Typparameter genauer ist. Es gibt genug Platz, um alles durcheinander zu bringen.

Gleiches gilt für die dynamische Eingabe (im Client-Code). Bis zu einem gewissen Grad wird die Situation unvorhersehbar. Sie haben die Sicherheit beim Kompilieren bereits ernsthaft geopfert. Seien Sie also nicht überrascht, wenn etwas kaputt geht.

Zusammenfassung

Ich habe versucht, die Beispiele in diesem Artikel einfach genug zu gestalten. Alles wird sehr kompliziert und sehr schnell, wenn Sie viele optionale Parameter haben. Die Versionierung ist eine komplizierte Angelegenheit, mein Kopf schwillt an.

Source: https://habr.com/ru/post/de414223/


All Articles