Héritage et composition (relation Is-a vs Has-a) en Java

1. Vue d'ensemble

L'héritage et la composition - ainsi que l'abstraction, l'encapsulation et le polymorphisme - sont les pierres angulaires de la programmation orientée objet (POO).

Dans ce didacticiel, nous aborderons les bases de l'héritage et de la composition, et nous nous concentrerons fortement sur la détection des différences entre les deux types de relations.

2. Principes de base de l'héritage

L'héritage est un mécanisme puissant mais surutilisé et mal utilisé.

En termes simples, avec l'héritage, une classe de base (alias type de base) définit l'état et le comportement communs pour un type donné et laisse les sous-classes (alias sous-types) fournir des versions spécialisées de cet état et de ce comportement.

Pour avoir une idée claire sur la façon de travailler avec l'héritage, créons un exemple naïf: une classe de base Person qui définit les champs et méthodes communs pour une personne, tandis que les sous-classes Waitress et Actress fournissent des implémentations de méthodes supplémentaires et détaillées.

Voici la classe Person :

public class Person { private final String name; // other fields, standard constructors, getters }

Et ce sont les sous-classes:

public class Waitress extends Person { public String serveStarter(String starter) { return "Serving a " + starter; } // additional methods/constructors } 
public class Actress extends Person { public String readScript(String movie) { return "Reading the script of " + movie; } // additional methods/constructors }

De plus, créons un test unitaire pour vérifier que les instances des classes Waitress et Actress sont également des instances de Person , montrant ainsi que la condition «is-a» est remplie au niveau du type:

@Test public void givenWaitressInstance_whenCheckedType_thenIsInstanceOfPerson() { assertThat(new Waitress("Mary", "[email protected]", 22)) .isInstanceOf(Person.class); } @Test public void givenActressInstance_whenCheckedType_thenIsInstanceOfPerson() { assertThat(new Actress("Susan", "[email protected]", 30)) .isInstanceOf(Person.class); }

Il est important de souligner ici la facette sémantique de l'héritage . En plus de réutiliser l'implémentation de la classe Person , nous avons créé une relation «is-a» bien définie entre le type de base Person et les sous-types Waitress et Actress . Les serveuses et les actrices sont, en effet, des personnes.

Cela peut nous amener à nous demander: dans quels cas d'utilisation l'héritage est-il la bonne approche à adopter?

Si les sous-types remplissent la condition «est-un» et fournissent principalement des fonctionnalités supplémentaires plus bas dans la hiérarchie des classes, alors l'héritage est la voie à suivre.

Bien entendu, le remplacement de méthode est autorisé tant que les méthodes surchargées conservent la substituabilité type / sous-type de base promue par le principe de substitution de Liskov.

De plus, nous devons garder à l'esprit que les sous-types héritent de l'API du type de base , ce qui dans certains cas peut être excessif ou simplement indésirable.

Sinon, nous devrions utiliser la composition à la place.

3. Héritage dans les modèles de conception

Bien que le consensus soit que nous devrions favoriser la composition par rapport à l'héritage chaque fois que possible, il existe quelques cas d'utilisation typiques où l'héritage a sa place.

3.1. Le modèle de supertype de calque

Dans ce cas, nous utilisons l'héritage pour déplacer le code commun vers une classe de base (le supertype), sur une base par couche .

Voici une implémentation de base de ce modèle dans la couche de domaine:

public class Entity { protected long id; // setters } 
public class User extends Entity { // additional fields and methods } 

Nous pouvons appliquer la même approche aux autres couches du système, telles que les couches de service et de persistance.

3.2. Le modèle de méthode de modèle

Dans le modèle de méthode de modèle, nous pouvons utiliser une classe de base pour définir les parties invariantes d'un algorithme, puis implémenter les parties variantes dans les sous-classes :

public abstract class ComputerBuilder { public final Computer buildComputer() { addProcessor(); addMemory(); } public abstract void addProcessor(); public abstract void addMemory(); } 
public class StandardComputerBuilder extends ComputerBuilder { @Override public void addProcessor() { // method implementation } @Override public void addMemory() { // method implementation } }

4. Les bases de la composition

La composition est un autre mécanisme fourni par la POO pour réutiliser la mise en œuvre.

En un mot, la composition nous permet de modéliser des objets composés d'autres objets , définissant ainsi une relation «has-a» entre eux.

En outre, la composition est la forme d'association la plus forte , ce qui signifie que le ou les objets qui composent ou sont contenus par un objet sont également détruits lorsque cet objet est détruit .

Pour mieux comprendre le fonctionnement de la composition, supposons que nous ayons besoin de travailler avec des objets qui représentent des ordinateurs .

Un ordinateur est composé de différentes parties, y compris le microprocesseur, la mémoire, une carte son, etc., de sorte que nous pouvons modéliser à la fois l'ordinateur et chacune de ses parties en tant que classes individuelles.

Voici à quoi pourrait ressembler une implémentation simple de la classe Computer :

public class Computer { private Processor processor; private Memory memory; private SoundCard soundCard; // standard getters/setters/constructors public Optional getSoundCard() { return Optional.ofNullable(soundCard); } }

Les classes suivantes modélisent un microprocesseur, la mémoire et une carte son (les interfaces sont omises par souci de concision):

public class StandardProcessor implements Processor { private String model; // standard getters/setters }
public class StandardMemory implements Memory { private String brand; private String size; // standard constructors, getters, toString } 
public class StandardSoundCard implements SoundCard { private String brand; // standard constructors, getters, toString } 

Il est facile de comprendre les motivations derrière le fait de pousser la composition sur l'héritage. Dans chaque scénario où il est possible d'établir une relation sémantiquement correcte «has-a» entre une classe donnée et d'autres, la composition est le bon choix à faire.

In the above example, Computer meets the “has-a” condition with the classes that model its parts.

It's also worth noting that in this case, the containing Computer object has ownership of the contained objects if and only if the objects can't be reused within another Computer object. If they can, we'd be using aggregation, rather than composition, where ownership isn't implied.

5. Composition Without Abstraction

Alternatively, we could've defined the composition relationship by hard-coding the dependencies of the Computer class, instead of declaring them in the constructor:

public class Computer { private StandardProcessor processor = new StandardProcessor("Intel I3"); private StandardMemory memory = new StandardMemory("Kingston", "1TB"); // additional fields / methods }

Bien sûr, il s'agirait d'une conception rigide et étroitement couplée, car nous rendrions l' ordinateur fortement dépendant d'implémentations spécifiques de processeur et de mémoire .

Nous ne profiterions pas du niveau d'abstraction fourni par les interfaces et l'injection de dépendances.

Avec la conception initiale basée sur les interfaces, nous obtenons une conception faiblement couplée, qui est également plus facile à tester.

6. Conclusion

Dans cet article, nous avons appris les principes de base de l'héritage et de la composition en Java, et nous avons exploré en profondeur les différences entre les deux types de relations («is-a» vs «has-a»).

Comme toujours, tous les exemples de code présentés dans ce didacticiel sont disponibles à l'adresse over sur GitHub.