Pourquoi l'implémentation par défaut des interfaces est-elle utile?

Dans mon dernier article, j'ai promis de parler de certains cas dans lesquels, je pense, il est logique d'envisager l'utilisation d'implémentations par défaut dans les interfaces. Cette fonctionnalité, bien sûr, n'annule pas de nombreuses conventions existantes pour l'écriture de code, mais j'ai trouvé que dans certaines situations, l'utilisation de l'implémentation par défaut conduit à un code plus propre et plus lisible (du moins à mon avis).

Extension d'interfaces avec compatibilité descendante


La documentation dit:
Le scénario le plus courant consiste à ajouter en toute sécurité des méthodes à une interface qui a déjà été publiée et utilisée par d'innombrables clients.
Le problème à résoudre est que chaque classe héritée de l'interface doit fournir une implémentation pour la nouvelle méthode. Ce n'est pas très difficile lorsque l'interface est utilisée uniquement par votre propre code, mais si elle est dans la bibliothèque publique ou utilisée par d'autres commandes, l'ajout d'un nouvel élément d'interface peut entraîner un gros mal de tête.

Prenons un exemple:

interface ICar { string Make { get; } } public class Avalon : ICar { public string Make => "Toyota"; } 

Si je veux ajouter une nouvelle méthode GetTopSpeed ​​() à cette interface, je dois ajouter son implémentation dans Avalon :

 interface ICar { string Make { get; } int GetTopSpeed(); } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; } 

Cependant, si je crée une implémentation par défaut de la méthode GetTopSpeed ​​() dans ICar , je n'aurai pas à l'ajouter à chaque classe héritée.

 interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; } 

Si nécessaire, je peux toujours surcharger l'implémentation dans des classes pour lesquelles la valeur par défaut ne convient pas:

 interface ICar { string Make { get; } public int GetTopSpeed() => 150; } public class Avalon : ICar { public string Make => "Toyota"; public int GetTopSpeed() => 130; } 

Il est important de considérer que la méthode par défaut GetTopSpeed ​​() sera disponible uniquement pour les variables transtypées en ICar et ne sera pas disponible pour Avalon s'il n'y a pas de surcharge. Cela signifie que cette technique est très utile si vous travaillez avec des interfaces (sinon, votre code sera inondé de nombreuses transtypages en interfaces pour accéder à l'implémentation par défaut de la méthode).

Mixins et traits (ou quelque chose comme ça)


Des concepts de langage similaires de mixins et de traits décrivent des façons d'élargir le comportement d'un objet à travers la composition sans avoir besoin d'héritage multiple.

Wikipedia rapporte ce qui suit sur les mixins:
Mixin peut également être considéré comme une interface avec des méthodes par défaut
Est-ce que ça ressemble à ça?

Mais, néanmoins, même avec l'implémentation par défaut, les interfaces en C # ne sont pas des mixins. La différence est qu'ils peuvent également contenir des méthodes sans implémentation, prendre en charge l'héritage d'autres interfaces, peuvent être spécialisés (apparemment, cela se réfère aux restrictions de modèle. - environ Transl.) Et ainsi de suite. Cependant, si nous faisons une interface qui ne contient que des méthodes avec une implémentation par défaut, ce sera, en fait, un mixin traditionnel.

Considérez le code suivant, qui ajoute des fonctionnalités à l'objet «mouvement» et suit son emplacement (par exemple, dans le développement de jeu):

 public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; } // ,         public void Move() => Location = ...; } public class Car : IMovable { public string Make => "Toyota"; } 

Aïe! Il y a un problème dans ce code que je n'ai pas remarqué jusqu'à ce que j'ai commencé à écrire ce message et essayé de compiler un exemple. Les interfaces (même celles qui ont une implémentation par défaut) ne peuvent pas stocker l'état. Par conséquent, les interfaces ne prennent pas en charge les propriétés automatiques. De la documentation :
Les interfaces ne peuvent pas stocker l'état d'instance. Bien que les champs statiques soient désormais autorisés dans les interfaces, les champs d'instance ne peuvent toujours pas être utilisés. Par conséquent, vous ne pouvez pas utiliser de propriétés automatiques, car elles utilisent implicitement des champs masqués.
Dans ce cas, les interfaces C # sont en contradiction avec le concept de mixins (pour autant que je les comprenne, les mixins peuvent stocker conceptuellement l'état), mais nous pouvons toujours atteindre l'objectif initial:

 public interface IMovable { public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; } // A method that changes location // using angle and speed public void Move() => Location = ...; } public class Car : IMovable { public string Make => "Toyota"; // ,         public (int, int) Location { get; set; } public int Angle { get; set; } public int Speed { get; set; } } 

Ainsi, nous avons atteint ce que nous voulions en mettant la méthode Move () et son implémentation à la disposition de toutes les classes qui implémentent l'interface IMovable . Bien sûr, la classe doit toujours fournir une implémentation pour les propriétés, mais au moins elles sont déclarées dans l'interface IMovable , ce qui permet à l'implémentation par défaut de Move () de fonctionner avec elles et garantit que toute classe qui implémente l'interface a le bon état.

Comme exemple plus complet et pratique, considérez un mixin pour la journalisation:

 public interface ILogger { public void LogInfo(string message) => LoggerFactory .GetLogger(this.GetType().Name) .LogInfo(message); } public static class LoggerFactory { public static ILogger GetLogger(string name) => new ConsoleLogger(name); } public class ConsoleLogger : ILogger { private readonly string _name; public ConsoleLogger(string name) { _name = name ?? throw new ArgumentNullException(nameof(name)); } public void LogInfo(string message) => Console.WriteLine($"[INFO] {_name}: {message}"); } 

Maintenant, dans n'importe quelle classe, je peux hériter de l'interface ILogger :

 public class Foo : ILogger { public void DoSomething() { ((ILogger)this).LogInfo("Woot!"); } } 

Et un tel code:

 Foo foo = new Foo(); foo.DoSomething(); 

Sorties:

 [INFO] Foo: Woot! 

Remplacement des méthodes d'extension


L'application la plus utile que j'ai trouvée remplace un grand nombre de méthodes d'extension. Revenons à un exemple de journalisation simple:

 public interface ILogger { void Log(string level, string message); } 

Avant que les implémentations par défaut n'apparaissent dans les interfaces, en règle générale, j'écrivais beaucoup de méthodes d'extension pour cette interface, de sorte que dans une classe héritée, une seule méthode devait être implémentée, ce qui permettait aux utilisateurs d'avoir accès à de nombreuses extensions:

 public static class ILoggerExtensions { public static void LogInfo(this ILogger logger, string message) => logger.Log("INFO", message); public static void LogInfo(this ILogger logger, int id, string message) => logger.Log("INFO", $"[{id}] message"); public static void LogError(this ILogger logger, string message) => logger.Log("ERROR", message); public static void LogError(this ILogger logger, int id, string message) => logger.Log("ERROR", $"[{id}] {message}"); public static void LogError(this ILogger logger, Exception ex) => logger.Log("ERROR", ex.Message); public static void LogError(this ILogger logger, int id, Exception ex) => logger.Log("ERROR", $"[{id}] {ex.Message}"); } 

Cette approche fonctionne très bien, mais pas sans défauts. Par exemple, les espaces de noms de classe avec des extensions et des interfaces ne correspondent pas nécessairement. Plus un bruit visuel gênant sous la forme d'un paramètre et d'un lien vers une instance de l'enregistreur:

 this ILogger logger logger.Log 

Maintenant, je peux remplacer les extensions par des implémentations par défaut:

 public interface ILogger { void Log(string level, string message); public void LogInfo(string message) => Log("INFO", message); public void LogInfo(int id, string message) => Log("INFO", $"[{id}] message"); public void LogError(string message) => Log("ERROR", message); public void LogError(int id, string message) => Log("ERROR", $"[{id}] {message}"); public void LogError(Exception ex) => Log("ERROR", ex.Message); public void LogError(int id, Exception ex) => Log("ERROR", $"[{id}] {ex.Message}"); } 

Je trouve cette implémentation plus propre et plus facile à lire (et à prendre en charge).

L'utilisation de l'implémentation par défaut présente également plusieurs avantages par rapport aux méthodes d'extension:

  • Peut utiliser ceci
  • Vous pouvez fournir non seulement des méthodes, mais aussi d'autres éléments: par exemple, des indexeurs
  • L'implémentation par défaut peut être surchargée pour clarifier le comportement.

Ce qui m'embrouille dans le code ci-dessus, c'est qu'il n'est pas tout à fait évident quels membres d'interface ont une implémentation par défaut et qui font partie du contrat que la classe héritée doit implémenter. Un commentaire séparant les deux blocs pourrait aider, mais j'aime la clarté stricte des méthodes d'extension à cet égard.

Pour résoudre ce problème, j'ai commencé à déclarer des interfaces qui ont des membres avec l'implémentation par défaut comme partielles (sauf peut-être très simples). Ensuite, je mets les implémentations par défaut dans un fichier séparé avec une convention de dénomination de la forme «ILogger.LogInfoDefaults.cs» , «ILogger.LogErrorDefaults.cs» et ainsi de suite. S'il y a peu d'implémentations par défaut et qu'il n'y a pas besoin de regroupement supplémentaire, alors je nomme le fichier "ILogger.Defaults.cs" .

Cela sépare les membres avec implémentation par défaut du contrat non implémentable, que les classes héritées doivent implémenter. De plus, il vous permet de couper des fichiers très longs. Il existe également une astuce avec le rendu des fichiers attachés de style ASP.NET dans des projets de n'importe quel format. Pour ce faire, ajoutez au fichier de projet ou dans Directory.Build.props :

 <ItemGroup> <ProjectCapability Include="DynamicDependentFile"/> <ProjectCapability Include="DynamicFileNesting"/> </ItemGroup> 

Vous pouvez maintenant sélectionner « Imbrication de fichiers » dans l' Explorateur de solutions et tous vos fichiers .Defaults.cs apparaîtront comme les descendants du fichier d'interface «principal».

En conclusion, il existe encore plusieurs situations dans lesquelles les méthodes d'extension sont préférées:

  • Si vous travaillez généralement avec des classes, pas avec des interfaces (car vous devez convertir des objets en interfaces pour accéder aux implémentations par défaut)
  • Si vous utilisez souvent des extensions avec des modèles: public static T SomeExt < T > (this T foo) (par exemple dans l' API Fluent )

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


All Articles