Méthodes pour factoriser du code - Héritage et agrégation avec délégation - Exemple des entiers naturels
Table of Contents
Les exercices qui suivent concernent une architecture en couches et passent en revue différentes méthodes de factorisation du code pour de telles architectures :
- héritage avec approche ascendante,
- héritage avec approche descendante,
- héritage multiple (en Java 8 uniquement),
- agrégation avec délégation.
Lors du TD1, il a été possible de réutiliser du code dans deux contextes différents :
- d'une part, le code des opérations algébriques a pu être recopié textuellement d'une classe d'implémentation à deux autres classes d'implémentation,
- d'autre part, deux classe d'implémentation ont pu être obtenues par héritage, afin de réutiliser l'état et les accesseurs hérités.
La possibilité d'une réutilisation doit se comprendre dans le cadre de la discipline suivie, qui requiert de stratifier une classe d'implémentation en plusieurs couches avec les dépendances locales suivantes en simplifiant légèrement :
- les accesseurs et les constructeurs dépendant des attributs,
- les fabriques dépendant des constructeurs,
- les services dépendant des accesseurs et des fabriques.
Figure 1: Couches d'une classe d'implémentation
Ainsi dans le premier cas, où seul l'état variait, seules les couches en contact devaient être modifiées. Dans le second cas, où seules les implémentations des services variaient, il était possible de conserver les couches inférieures, les attributs et les accesseurs (ainsi que des fabriques).
Cependant, la recopie textuelle (le "copier-coller") n'est pas une bonne pratique en programmation. Imaginons qu'on veuille faire évoluer le code copié, pour l'améliorer ou le corriger : dans ce cas, toutes les copies doivent être mises à jour, ce qui suppose qu'on en conserve un référencement exhaustif. Plutôt que de recopier, on préfère factoriser : le facteur commun est alors partagé. Nous allons étudier les méthodes de factorisation, fondées sur l'héritage et l'agrégation avec délégation.
Calcul | |||
---|---|---|---|
Via des int | Récursif | ||
Etat | Int positif | int / int | int / récursion |
Inductif |
inductif / int | inductif / récursion |
Héritage - Approche ascendante ("bottom-up")
Analyser dans le TD1 l'utilisation de l'héritage.
Figure 2: Approche ascendante - Factorisation de l'état, des accesseurs et de certaines fabriques
La définition des classes ZeroRec et SuccRec par héritage des classes Zero et Succ a permis de factoriser la couche basse : l'état, les accesseurs et partiellement les fabriques. Il est possible de mieux mettre en évidence cette factorisation en procédant ainsi :
- définition de deux classes abstraites EtatZero et EtatSucc implémentant la couche basse,
- définition par héritage de deux classes, ZeroCalculantSurInt et SuccCalculantSurInt, complétant les deux classes abstraites par des calculs réalisés avec des int,
- définition par héritage de deux classes, ZeroRec et SuccRec, complétant les deux classes abstraites par des calculs récursifs.
Cette définition illustre l'approche ascendante pour l'héritage : partant de la couche basse, factorisée, on étend par héritage en ajoutant une couche haute.
Voir le paquet session2.td.heritageAscendant pour une implémentation illustrant cette approche. Voici le diagramme des types correspondant.
Figure 3: Approche ascendante - Factorisation de la couche basse
Héritage - Approche descendante
L'approche descendante permet de factoriser la couche haute, ici les calculs algébriques.
Factoriser les services algébriques dans une classe abstraite AlgebreNatParInt. La classe est abstraite parce que les méthodes de la couche basse (accesseurs, fabriques) ne sont pas implémentés contrairement à celles de la couche haute. Des classes concrètes d'implémentation sont produites en étendant par héritage la classe abstraite : la couche basse est alors complétée, la couche haute étant factorisée.
Voir le paquet session2.td.heritageDescendant pour une implémentation illustrant cette approche. Voici le diagramme des types correspondant.
Figure 4: Approche descendante - Factorisation de la couche haute
Héritage multiple (Java 8 seulement)
Avec l'approche ascendante ou descendante, on factorise soit la couche basse, soit la couche haute, mais pas les deux simultanément. Pour parvenir à cette double factorisaton, il est nécessaire de recourir à l'héritage multiple. En Java, depuis la version 8, l'héritage multiple est possible. Il présente cependant quelques particularités qui ne sont pas pénalisantes en pratique : une classe ne peut étendre par héritage qu'une seule classe, mais peut implémenter plusieurs interfaces, certaines pouvant être concrètes, au sens où leurs méthodes peuvent être implémentées (précédées alors du qualificatif default).
Dans le cas qui nous intéresse, une classe réalisant les deux couches
- hérite d'une classe implémentant la couche basse (accesseurs),
- implémente une interface concrète implémentant la couche haute (caluls et fabriques calculées) et
- agrège les fabriques natives, ainsi que les constructeurs.
Voir le paquet session2.td.heritageMultiple pour une implémentation illustrant cette approche. Voici le diagramme des types correspondant.
Figure 5: Héritage multiple - Factorisation des deux couches
Ajouter les deux combinaisons manquantes.
Agrégation avec délégation
Une solution alternative à l'héritage multiple est l'agrégation avec délégation. La classe implémentant la couche haute agrège un objet auquel elle délègue l'implémentation de la couche basse.
L'implémentation présente quelques difficultés dans le cas qui nous intéresse.
La couche basse correspondant aux accesseurs doit être implémentée séparément. Il est donc nécessaire de définir une interface, EtatNaturelPur, contenant les seuls accesseurs. Cependant, le type de retour d'un des accesseurs (predecesseur) dépend de l'interface Nat : autrement dit, pour implémenter la couche basse, on aurait besoin de construire un objet implémentant les deux couches, ce qui est impossible. Une solution est de modifier le type de retour en EtatNaturelPur. Il est alors possible de factoriser la définition des accesseurs par une interface générique exprimant une propriété : EtatNaturel<T>.
La délégation peut être factorisée dans une classe abstraite NatDeleguantEtat. L'implémentation de la délégation est facilitée s'il existe une correspondance bi-univoque entre des Nat et des EtatNaturelPur. C'est pourquoi deux méthodes sont ajoutées à l'interface Nat :
Nat creerNatAvecEtat(EtatNaturelPur etat); EtatNaturelPur etat();
L'implémentation de la délégation suppose aussi qu'il est possible de construire des EtatNaturelPur comme des Nat. Ainsi, comme l'interface Nat, l'interface EtatNaturelPur possède la propriété FabriqueNaturels.
Une fois ces difficultés résolues, la solution se développe ainsi :
- définir la classe abstraite NatDeleguantEtat réalisant la délégation,
- pour obtenir une classe d'implémentation de la couche haute, l'étendre par héritage, suivant une approche ascendante,
- définir les classes implémentant l'interface EtatNaturelPur pour réaliser la couche basse.
Pour obtenir un objet implémentant les deux couches, il suffit d'appeler le constructeur d'une classe implémentant la couche haute, en injectant un objet implémentant la couche basse.
new NatCalculantAvecDesInts(new IntPositif(0))
Voir le paquet session2.td.agregation pour une implémentation illustrant cette approche. Voici le diagramme des types correspondant.
Figure 6: Agrégation avec délégation - Factorisation des deux couches