Faut-il une interface par implémentation ?
Nous avons eu récemment en interne un retour aux sources autour de la POO, et des principes SOLID. Celui-ci a donné lieu à compte rendu, dont la dernière phrase fut le point de départ d’une longue file de message que je vais tenter de résumer ici. Et pour les amoureux de la lecture, vous trouverez la copie intégrale à la fin de cet article.
La phrase en question : Il faut éviter de réaliser du SurDesign : Pourquoi mettre une interface lorsqu'il n'y a qu'une seule implémentation, pourquoi factoriser du code s'il n'est utilisé qu'à un seul endroit ?
Exposé d’un credo : si on fait une interface par implémentation, c’est pour faciliter les TU et l’IoC. Sauf que cela fait bien longtemps que les frameworks de mock permettent de mocker les classes elles-mêmes, et que l’IoC peut se faire via les implémentations directes, toute classe étant un type référence et donc initialisé à null par défaut.
Bref, il semblait que le pattern Interface/Implémentation soit obsolète, pour la plus grande joie de nombre d’entre nous. De plus, utiliser directement les implémentations permet aux outils de refactoring et d’analyse de code de ne pas se perdre (par exemple, si C1 appelle C2 qui appelle C3, on a soit une chaine de dépendance C1->C2->C3 si on utilise directement les implémentations, mais C1->I2 et C2->I3 avec des interfaces, on perd donc le lien entre C1 et C3).
Toutefois, utiliser les implémentations, cela force à tirer des dépendances entre les dll/war contenant lesdites implémentations. Ce qui implqiue que, par transitivité, à charger l’intégralité de ses assemblies à la moindre exécution de test unitaire(TU). En particulier, en cas d’utilisation de framework externes dont on ne maîtrise pas le code (ou qui peuvent faire des initialisations statiques), on peut se retrouver avec un TU qui fait des connexions à des bases de données, voire même affiche des pop-ups de connexion ! Dans ce cas, l’utilisation d’un assembly d’interfaces sans aucune dépendance permet d’éviter tous ces désagréments.
Un autre argument en faveur des interfaces est la facilité de contrôle de cette règle simple : « une implémentation = une interface et on n’utilise jamais directement les implémentations » via des outils automatiques, qui permet, sur un gros projet, de faire respecter à moindres frais un niveau de couplage très faible entre composants. Ça et interdire les new, bien sûr.
Oui mais, interdire les new, en pratique, ce n’est pas possible, puisqu’il y aura toujours des objets du framework à instancier, voire des classes privées, bref, on trouvera toujours de bonnes raisons de faire un new, non ?
Non, fait remarquer la vieille garde, c’est l’objectif de COM, qui remplace tous les new par ces appels à CoCreateInstance. Et la vieille garde (dont j’avoue faire partie), comme tous les vieux, quand on la lance sur son sujet favori, elle a du mal à s’arrêter.
Début de la parenthèse historique.
Donc, pourquoi COM ? Pourquoi un modèle aussi tordu où toute implémentation a une interface, où on fait des new sur les interfaces (merci VB) ? Pour cela, nous allons quelque peu revenir en arrière, à un temps que les moins de 20 ans ne peuvent pas connaître, comme le dit la chanson.
Or donc, il était une fois des exe et ces DLLs qui ne sont pas chargés dans le même espace mémoire. Mais pourquoi ? Et bien, parce qu’ainsi, on peut swapper sur disque chaque DLL, voire l’EXE, indépendamment, et quand on est sur un 286 avec 2 Mo de RAM, c’est nécessaire.
Du coup, conséquence, quand on fait un new dans une DLL, si le delete a lieu dans une autre DLL ou dans l’exe, cela ne marche pas, parce que l’objet a été alloué dans l’espace mémoire de la DLL d’origine et qu’on tente de le désallouer dans l’espace mémoire de celui qui fait l’appel au delete, donc ça ne fonctionne pas.
Du coup, COM, avec ses compteurs de références, laisse l’objet responsable de sa propre destruction, et donc s’assure que ladite destruction se fera dans le même espace mémoire que la création, puisque celle-ci a été faite à travers CoCreateInstance qui est assez intelligent pour aller faire la création au bon endroit. Exit les problèmes de destructeur qui plante !
Ajoutez à cela que COM, en héritier de CORBA, se veut indépendant du langage de programmation, et on obtient ce principe gravé dans le marbre de COM (et donc de Windows) qui est une implémentation = au moins 2 interfaces (celle sur laquelle faire le new et IUnknown, qui contient les mécanismes d’incrément et de décrément du compteur de référence).
Fin de la parenthèse historique.
Le rapport avec le sujet initial ? C# et Java ont été conçus pour être compatibles avec COM, donc ce pattern se trouve dans les gènes de ces deux langages, pour ainsi dire. De plus utiliser des interfaces assure, ou en tous cas facilite grandement, la compatibilité descendante, puisqu’une interface, ce n’est rien d’autre qu’un pointeur vers une table de fonctions virtuelles, et ça, nombreux sont les langages savent le traiter.
Au final : faut-il ou non avoir une interface par implémentation ? A votre avis ?
La communauté OCTO
Le message original
Proposition de principe :
GDU nous propose de réfléchir au principe suivant :
- Pour réaliser 1 changement (évolution/correction) je n'aimerais avoir à changer qu'une seule classe.
- Pour réaliser N changements (évolution/correction) je n'aimerais avoir à changer que N classes.
L'idée étant de DIVISER pour mieux régner. Moins tu as de fichiers à ouvrir pour corriger un problème plus la compréhension est aisée et donc par extension meilleure est la qualité (lisibilité, maintenabilité).
"On peut résoudre 1 fois 1000 problèmes ou 1000 fois un problème mais si c'est 1000 fois 1000 problèmes c'est foutu ^^" GDU
La démarche :
Pour affirmer/infirmer cette proposition nous avons progressé dans notre démarche en commentant chacun des principes de la démarche S.O.L.I.D de la conception orientée objet.
S : Single responsibility principle
- Définition : 1 classe = 1 responsabilité, Une classe doit se concentrer sur une seule fonctionnalité et ne pas faire à la fois le café et la vaisselle.
- TIPS :
- Si tu n'arrives pas à nommer ta classe c'est que tu as surement un problème de conception. (BLA)
O : Open/closed principle
- Définition : Le coeur (noyau) d'un framework ne doit pas être modifiable (sauf évolution fonctionnelle majeure) cependant le comportement doit être extensible.
- Exemples :
- Une application avec un SWITCH en plein milieu est un contre exemple : si l'on doit modifier le comportement il faut rajouter un bloc CASE et donc retoucher à la classe au coeur du FMK. (HTR)
- Un framework qui offre un mode de fonctionnement à base de plugins (on pense très fort à Maven) est un bon exemple d'application de ce concept. (CLU)
- Spring framework réalise l'injection de dépendances de manière masquée mais on peut étendre le comportement par défaut si on le souhaite sans avoir à toucher aux classes de Spring. (GDU).
L : Liskov substitution principle
- Définition : Si un composant travaille avec une classe E, elle doit pouvoir travailler également avec les sous-classes de E de manière transparente.
- Remarques :
- On se rend compte avec l'expérience que les possibilités offertes par l'héritage sont peu utilisées en pratique. On peut factoriser un morceau de code en le plaçant dans une classe abstraite et activer l'héritage. On se rends compte que la superclasse est toujours abstraite.
- En JAVA on n'utilise presque jamais de classe concrète avec héritage pour factoriser une partie du code. Si on le fait c'est que l'on a sans doute une erreur de conception (HTR)
I : Interface segregation principle
- Définition : On le relie au principe de cohésion : "Il faut spécialiser les interfaces"
- Remarques :
- Qu'est ce qu'une interface ? La définition la plus appropriée semble être un contrat.
- Une interface peut également être interprété comme la description d'un comportement.
- Si une interface est trop généraliste, si elle propose des méthodes pour faire le café ET la vaisselle alors il doit y avoir un problème de conception. Elles doivent restées indépendantes les unes des autres afin de faciliter les évolutions futures.
- Autrefois, lorsque les annotations n'existaient pas encore, le java a proposé des interfaces comme TAGS pour marquer la nature d'une classe mais sans méthode associée (clonable, Serializable...) de nos jours on aurait davantage utilisé les annotations.
- Certains langage comme Ruby ou Scala semblent proposer du code réutilisable directement dans les composants entre "interfaces" et "classes abstraites" on parle de MIXIN.
D : Dependency inversion principle
- Définition : L'injection de dépendances de Spring ou "Inversion of control"n correspond au principe de pouvoir choisir au runtime l'implémentation du composant que l'on souhaite.
- Remarques :
- Spring a donné des mauvaises habitudes aux développeurs comme mettre des interfaces lorsqu'il n''y en a pas besoin.
- Externaliser en XML l'injection des dépendances c'est bien mais cela peut vite devenir un sac de noeuds très complexe : fichiers XML multiples, plusieurs déclarations des même beans, plusieurs datasources ouvertes..... L'introduction d'annotations pour réaliser cette injection permet de simplifier la configuration.
- Si l'on se retrouve à injecter plus de 5 éléments avec du @AutoWired il y a des chances qu'il y a une violation du principe de cohésion.
- Il faut éviter de réaliser du Surdesign : Pourquoi mettre une interface lorsqu'il n'y a qu'une seule implémentation, pourquoi factoriser du code s'il n'est utilisé qu'à un seul endroit ?....