UP | HOME

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 :

Les développements doivent être menés d'abord en Java, puis en Typescript. La convention de nommage doit être respectée. L'énoncé concerne Java et demande peu d'adaptations pour la traduction en Typescript.

Lors du TD1, il a été possible de réutiliser du code dans deux contextes différents :

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 :

Sorry, your browser does not support SVG.

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.

Le but de ce TD est d'étudier les quatre méthodes de factorisation et de réaliser les deux solutions optimales. Au total, nous disposons grâce au TD1 de trois modes de constructions (pour la couche basse) et de trois manières de calculer (pour la couche haute), soit au total neuf combinaisons possibles.

Les neuf combinaisons possibles
Calcul
Via des int Récursif En représentation décimale
Etat Int positif int / int int / récursion int / décimal
Inductif
inductif / int inductif / récursion inductif / décimal
Décimal
décimal / int décimal / récursion décimal / décimal

Héritage - Approche ascendante ("bottom-up")

Analyser dans le TD1 l'utilisation de l'héritage.

td1_architecture.png

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. Une couche haute peut être répétée, dans le cas où plusieurs couches basses sont proposées.

Voir le paquet session2.td.heritageAscendant pour une implémentation illustrant cette approche. Voici le diagramme des types correspondant.

td2_bottomUp.png

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ées 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.

td2_topDown.png

Figure 4: Approche descendante - Factorisation de la couche haute

Remarque : plutôt que d'utiliser une classe abstraite, il aurait été possible d'utiliser une interface concrète (avec des méthodes par défaut). Cette solution est plus souple dans la mesure où il est possible d'implémenter plusieurs interfaces et donc d'obtenir un héritage multiple. Désormais, avec Java 8, l'usage de classes abstraites peut se limiter à la factorisation d'états, ce qui n'est pas le cas ici.

Héritage multiple (Java 8 au moins ou Typescript avec une astuce)

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 factorisation, 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). En typescript, il est également possible de réaliser une forme d'héritage multiple, en utilisant les capacités réflexives de Javascript (voir la documentation et le répertoire heritageMultiple).

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 et fabriques calculées),
  • implémente une interface concrète implémentant la couche haute (calculs algébriques et services utiles) et
  • agrège les fabriques natives et les services utiles de la classe Object, et définit les constructeurs.

Définir dans le paquet session2.td.heritageMultiple une implémentation illustrant cette approche. Voici le diagramme des types correspondant.

td2_multipleInheritance.png

Figure 5: Héritage multiple - Factorisation des deux couches

Procéder ainsi pour produire cette solution optimale.

  • Définir la couche haute par des interfaces concrètes (cf. les interfaces AlgebreX). Pour le cas inductif, on traite d'abord les cas particuliers (Zero et Succ) puis le cas général.
  • Définir la couche basse par des classes abstraites (cf. NombreDecimal, IntPositif, EtatZero et EtatSucc).
  • Définir les classes concrètes par héritage d'une interface de la couche haute et d'une classe de la couche basse, soit au total neuf implémentations).
  • Tester comme pour le TD1 les neuf combinaisons possibles.

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))

Définir dans le paquet session2.td.agregation une implémentation illustrant cette approche. Voici le diagramme des types correspondant.

td2_aggregationDelegation.png

Figure 6: Agrégation avec délégation - Factorisation des deux couches

Procéder ainsi pour produire cette solution optimale.

  • Définir l'interface générique EtatNaturel exprimant une propriété.
  • Définir l'interface EtatNaturelPur ayant pour propriétés l'interface précédente et l'interface FabriqueNaturels.
  • Définir les trois implémentations de EtatNaturelPur.
  • Définir une nouvelle interface Nat, qui hérite de trois propriétés et agrège les deux méthodes garantissant une correspondance entre Nat et EtatNatuelPur.
  • Définir la classe abstraite NatDeleguantEtat qui délègue à l'état la réalisation des méthodes.
  • Définir par héritage les trois classes implémentant la couche haute.
  • Tester comme pour le TD1 les neuf combinaisons possibles.

Author: Hervé Grall
Version history: v1: 2016-10-10; v2: 2016-10-14[+image, *text]; v3: 2017-10-11[+dev]; v4: 2018-10-10[+ts].
Comments or questions: Send a mail.
The webpage content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.