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 ?….

 

 

4 commentaires pour “Faut-il une interface par implémentation ?”

  1. l’intérêt du sur-design est de pouvoir utiliser des outils automatisables pour vérifier la qualité du code, comme par exemple jdepend dont les résultats sont sensibles au nombre d’interfaces par paquage en fonction du nombre d’implémentations.

  2. Comme souligné dans l’article, une interface est un contrat. Mais il ne faut pas non plus penser qu’une interface est nécessaire une ‘interface’ dans le sens java du terme.

    Chaque classe expose un contrat qui sera respecté par ses instances. Le niveau de formalisation du contrat doit dépendre du contexte.

    Par exemple, pour un projet interne, il n’est pas forcement nécessaire de faire un appel d’offre puis de faire signer un engagement de type forfait. Avec des partenaires externes, cela peut par contre être plus justifié.

    Il en va de même pour du code. Dans la mesure où une classe est utilisée au sein d’un artefact de façon limitée et ne doit pas être visible par d’autres artefacts, une ‘interface’ peut clairement être une perte de temps, de ressources. Par contre, s’il doit s’agir d’un point d’extension, la non-utilisation d’une interface doit absolument être justifiée.

    Le problème des ‘interfaces’ restent leur évolution (d’où la mention des MIXIN). Utiliser une classe abstraite peut simplifier les choses puisqu’un nouveau comportement peut être ajouté sans pour autant casser les implémentations existantes. Mais cela rajoute évidemment une contrainte pour l’implémenteur puisqu’une classe ne peut étendre qu’une seule autre classe.

  3. Dans le temps, on faisait des service registry. Justement un peu comme en COM. Tu demandes au service registry de te donner une instance d’une interface.

    Ça marchait très bien et TU il suffisait de remplacer la véritable implémentation par un mock au début de test. Le problème c’est que les dépendances n’était pas très explicite (il faut aller à la recherche des getService dans la classe)

    Donc en fait, c’est la faute des tests unitaires si on s’est mis à mettre des interfaces partout parce que c’était la seule façon de mocker. Et pendant un temps ça a été l’enfer jusqu’à ce que Eclipse invente le Ctrl+T.

    Les frameworks d’IOC ont permis de rendre les dépendances explicites et en parallèle les frameworks de mocks se sont mis à mocker des classes. Mais les interfaces sont restées en donnant une vague impression de « plus propre ».

    Et on s’est finalement rendu compte que dans 95% du temps, on utilise une seule implémentation réelle et une de test. Donc on s’est mis à faire de l’autowired et à retirer les interfaces… pour le grand bonheur de tous pour toutes les raisons données dans l’article.

    Des commentaires:
    - En Java, les interfaces du JDK sont nommées comme n’importe quelle classe. Il n’y a pas de I devant, ni de impl sur les implémentations d’ailleurs. Ce type de notation provient de Microsoft (COM, IDL, etc) très fan de notation Hongroise pratique en C pour donner le type d’une variable.
    - Il y a une raison pour cela. C’est que si un matin, vous avez une classe et vous réalisez qu’en fait vous voulez maintenant avoir une interface et deux implémentations, vous le faite sans renommage et donc sans modification au code appelant. En gros, en Java, l’appelant n’est pas sensé savoir s’il travaille sur une classe ou une interface.
    - Il faut bien évidemment respecter la règle des new cachés dans l’IOC ou une factory pour permettre le refactoring et les tests

  4. Je vais digresser, pour parler un peu de cette fameuse notation hongroise et du pourquoi du I devant les interfaces COM :
    Déjà, la notation hongroise n’a pas pour objectif d’indiquer le type d’une variable (voir l’article suivant pour un peu d’histoire : http://www.joelonsoftware.com/articles/Wrong.html

    Ensuite, en C++, il n’y a pas de notion d’interface. Donc on a des classes, qui sont des « interfaces » (i.e. n’ont que des méthodes virtuelles pures, sauf le destructeurs) et des classes, qui sont des classes abstraites (i.e. contenant une partie de méthodes virtuelles putes et une partie d’implémentations) et des classes qui sont des classes.

    Donc, comment les distinguer ? C’est là qu’intervient la (vraie) notation hongroise. On préfixe les interfaces par I, les classes abstraites sont soit suffixées par Base, soit préfixées par A, soit … (contrairement aux interfaces, il n’y a pas de standard de fait) et les autres nommées normalement (voire suffixées par Impl)

    Bref, en C++, cela se justifie. En C#, beaucoup moins, mais l’habitude est prise et le framework utilise cette norme, parce que les développeurs du framework venaient du C++, donc ça se répand au langage.

    Mick

Laissez un commentaire