C #: compatibilité descendante et surcharge

Bonjour collègues!

Nous rappelons à tous que nous avons un excellent livre de Mark Price, " C # 7 et .NET Core. Développement multiplateforme pour les professionnels ." Remarque: il s'agit de la troisième édition, la première édition a été écrite dans la version 6.0 et n'apparaît pas en russe, et la troisième édition a été publiée dans l'original en novembre 2017 et couvre la version 7.1.


Après la publication d'un tel recueil, qui a fait l'objet d'une édition scientifique distincte pour vérifier la compatibilité descendante et d'autres correctes du matériel présenté, nous avons décidé de traduire un article intéressant de John Skeet sur les difficultés connues et peu connues de la compatibilité descendante qui peuvent survenir en C #. Bonne lecture.

En juillet 2017, j'ai commencé à écrire un article sur le contrôle de version. Bientôt abandonné, car le sujet était trop étendu pour le couvrir en un seul post. Sur un tel sujet, il est plus logique de mettre en évidence un site / wiki / référentiel entier. J'espère revenir un jour sur ce sujet, car je le considère extrêmement important et je pense qu'il reçoit beaucoup moins d'attention qu'il ne le mérite.

Ainsi, dans l'écosystème .NET, le versioning sémantique est généralement le bienvenu - cela semble génial, mais exige que tout le monde comprenne également ce qui est considéré comme un «changement fondamental». Voilà ce que je pense depuis longtemps. L'un des aspects qui m'a le plus récemment frappé est la difficulté d'éviter des changements fondamentaux lors de la surcharge des méthodes. C'est à ce sujet (principalement) que nous discuterons du post que vous lisez; après tout, ce sujet est très intéressant.
Pour commencer - une brève définition ...

Sources et compatibilité binaire

Si je peux recompiler mon code client avec la nouvelle version de la bibliothèque, et que tout fonctionne bien, alors c'est la compatibilité au niveau du code source. Si je peux redéployer mon binaire client avec la nouvelle version de la bibliothèque sans recompilation, alors il est compatible binaire. Rien de tout cela n'est un surensemble de l'autre:

  • Certaines modifications peuvent être incompatibles avec le code source et le code binaire en même temps - par exemple, vous ne pouvez pas supprimer un type public entier dont vous dépendez complètement.
  • Certaines modifications sont compatibles avec le code source, mais incompatibles avec le code binaire - par exemple, si vous convertissez un champ statique public en lecture seule en propriété.
  • Certaines modifications sont compatibles avec le binaire, mais pas compatibles avec la source - par exemple, l'ajout d'une surcharge qui peut provoquer une ambiguïté lors de la compilation.
  • Certaines modifications sont compatibles avec le code source et binaire - par exemple, une nouvelle implémentation du corps de la méthode.

Alors de quoi parle-t-on?

Supposons que nous ayons une bibliothèque publique de la version 1.0, et que nous voulons y ajouter plusieurs surcharges afin de finaliser la version 1.1. Nous nous en tenons au versioning sémantique, nous avons donc besoin d'une compatibilité descendante. Qu'est-ce que cela signifie que nous pouvons et ne pouvons pas faire, et peut-on répondre à toutes les questions ici par «oui» ou «non»?

Dans différents exemples, je montrerai le code dans les versions 1.0 et 1.1, puis le code «client» (c'est-à-dire le code qui utilise la bibliothèque), qui peut se casser à la suite de modifications. Il n'y aura ni corps de méthode, ni déclarations de classe, car ils ne sont, par essence, pas importants - nous accordons la plus grande attention aux signatures. Cependant, si vous êtes intéressé, toutes ces classes et méthodes peuvent être facilement reproduites. Supposons que toutes les méthodes décrites ici se trouvent dans la classe Library .

Le changement le plus simple imaginable, orné de la transformation d'un groupe de méthodes en délégué
L'exemple le plus simple qui me vient à l'esprit est d'ajouter une méthode paramétrée là où il y en a déjà une non paramétrée:

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


Même ici, la compatibilité est incomplète. Considérez le code client suivant:

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

Dans la première version de la bibliothèque, tout va bien. L'appel de la méthode HandleAction convertit le groupe de méthodes en délégué library.Foo et, par conséquent, une Action est créée. Dans la version 1.1, la situation devient ambiguë: un groupe de méthodes peut être converti en Action ou Action. Autrement dit, une telle modification est incompatible avec le code source.

À ce stade, il est tentant de simplement abandonner et de vous promettre de ne plus jamais ajouter de surcharge. Ou nous pouvons dire qu'un tel cas est assez peu probable pour ne pas avoir peur d'un tel échec. Appelons pour l'instant les transformations d'un groupe de méthodes hors de portée.

Types de référence indépendants

Prenons un autre contexte dans lequel vous devez utiliser des surcharges avec le même nombre de paramètres. On peut supposer qu'une telle modification de la bibliothèque sera non destructive:

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

À première vue, tout est logique. Nous conservons la méthode d'origine, donc nous ne briserons pas la compatibilité binaire. Le moyen le plus simple de le casser est d'écrire un appel qui fonctionne dans la version 1.0, mais ne fonctionne pas dans la version 1.1, ou fonctionne dans les deux versions, mais de différentes manières.
Quelle incompatibilité entre v1.0 et v1.1 un tel appel peut-il donner? Nous devons avoir un argument compatible avec la string et FileStream . Mais ce sont des types de référence qui ne sont pas liés les uns aux autres ...

Le premier échec est possible si nous effectuons une conversion implicite définie par l'utilisateur en string et 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); } 

J'espère que le problème est évident: le code qui était auparavant sans ambiguïté et fonctionnait avec la string est maintenant ambigu, car le type OddlyConvertible peut être implicitement converti à la fois en string et en FileStream (les deux surcharges sont applicables, aucune d'elles n'est meilleure que l'autre).

Dans ce cas, il est peut-être raisonnable d'interdire les conversions définies par l'utilisateur ... mais ce code peut être réduit et beaucoup plus facile:

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

Nous pouvons implicitement convertir un littéral null en n'importe quel type de référence ou en n'importe quel type significatif nullable ... par conséquent, encore une fois, la situation dans la version 1.1 est ambiguë. Essayons encore ...

Paramètres des types de référence et des types significatifs non nullables

Supposons que nous ne nous soucions pas des transformations définies par l'utilisateur, mais que nous n'aimons pas les littéraux null problématiques. Comment dans ce cas ajouter une surcharge avec un type significatif non nul?

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

À première vue, c'est bien - library.Foo(null) fonctionnera bien dans la v1.1. Il est donc en sécurité? Non, tout simplement pas en C # 7.1 ...

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

Le littéral par défaut est exactement nul, mais s'applique à tout type. C'est très pratique - et un vrai casse-tête en matière de surcharge et de compatibilité :(

Paramètres facultatifs

Les paramètres facultatifs sont un autre problème. Supposons que nous ayons un paramètre facultatif et que nous voulons en ajouter un deuxième. Nous avons trois options, identifiées ci-dessous comme 1.1a, 1.1b et 1.1c.

  //  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 = "") 


Mais que faire si le client fait deux appels:

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

La bibliothèque 1.1a maintient la compatibilité au niveau binaire, mais viole au niveau du code source: maintenant library.Foo() ambigu. Selon les règles de surcharge en C #, les méthodes sont préférées qui ne nécessitent pas que le compilateur "remplisse" tous les paramètres facultatifs disponibles, cependant, il ne régule pas le nombre de paramètres facultatifs qui peuvent être remplis.

La bibliothèque 1.1b maintient la compatibilité au niveau source, mais viole la compatibilité binaire. Le code compilé existant est conçu pour appeler une méthode avec un seul paramètre - et une telle méthode n'existe plus.

La bibliothèque 1.1c conserve la compatibilité binaire, mais est pleine de surprises possibles au niveau du code source. Maintenant, l'appel library.Foo() est résolu en une méthode avec deux paramètres, tandis que library.Foo("xyz") résolu en une méthode avec un paramètre (du point de vue du compilateur, il est préférable à une méthode avec deux paramètres, principalement parce qu'il n'y a pas de paramètres optionnels aucun remplissage requis). Cela peut être acceptable si une version avec un paramètre délègue simplement les versions avec deux paramètres, et dans les deux cas, la même valeur par défaut est utilisée. Cependant, il semble étrange que la valeur du premier appel change si la méthode à laquelle il était précédemment résolu existe toujours.

La situation avec les paramètres facultatifs devient encore plus déroutante si vous souhaitez ajouter un nouveau paramètre non pas à la fin, mais au milieu - par exemple, essayez d'adhérer à l'accord et de conserver le paramètre CancellationToken facultatif à la toute fin. Je n'entrerai pas dans ça ...

Méthodes généralisées

La conclusion des types dans le meilleur des cas n'était pas une tâche facile. Lorsqu'il s'agit de résoudre les surcharges, ce travail se transforme en cauchemar uniforme.

Supposons que nous ayons une seule méthode non généralisée dans la v1.0, et dans la v1.1 nous ajoutons une autre méthode généralisée.

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

À première vue, ce n'est pas si effrayant ... mais voyons ce qui se passe dans le code client:

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

Dans la bibliothèque v1.0, les deux appels sont résolus dans Foo(object) - la seule méthode disponible.

La bibliothèque v1.1 est rétrocompatible: si vous prenez le fichier client exécutable compilé pour v1.1, les deux appels utiliseront toujours Foo(object) . Mais, en cas de recompilation, le deuxième appel (et seulement le second) basculera pour fonctionner avec la méthode généralisée. Les deux méthodes s'appliquent aux deux appels.

Au premier appel, l'inférence de type montrera que T est un object , donc la conversion de l'argument en type de paramètre dans les deux cas sera réduite en object dans object . Super. Le compilateur appliquera la règle selon laquelle les méthodes non génériques sont toujours préférables aux méthodes génériques.

Lors du deuxième appel, l'inférence de type montrera que T sera toujours une string , donc lors de la conversion d'un argument en un paramètre de type, nous obtenons une string en object pour la méthode d'origine ou une string en string pour la méthode généralisée. La deuxième transformation est «meilleure», c'est pourquoi la deuxième méthode est choisie.

Si les deux méthodes fonctionnent de la même manière, très bien. Sinon, vous casserez la compatibilité d'une manière très non évidente.

Héritage et typage dynamique

Désolé, je suis déjà essoufflé. L'héritage et le typage dynamique lors de la résolution des surcharges peuvent se manifester de la manière la plus «cool» et mystérieuse.
Si nous ajoutons une telle méthode à un niveau de la hiérarchie d'héritage qui surchargera la méthode de la classe de base, alors la nouvelle méthode sera traitée en premier et sera préférée à la méthode de la classe de base, même si la méthode de la classe de base est plus précise lors de la conversion d'un argument en paramètre de type. Il y a assez d'espace pour tout mélanger.

Il en va de même pour la frappe dynamique (dans le code client); dans une certaine mesure, la situation devient imprévisible. Vous avez déjà sérieusement sacrifié la sécurité lors de la compilation ... alors ne soyez pas surpris si quelque chose se casse.

Résumé

J'ai essayé de rendre les exemples de cet article assez simples. Tout devient très compliqué, et très rapidement, lorsque vous avez beaucoup de paramètres optionnels. Le versioning est une affaire compliquée, ma tête s'en gonfle.

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


All Articles