Compositions et héritage

Romain Guy (Gfx)

- Titre : Compositions et héritage

- Auteur : Romain Guy (Gfx)

- Parution : 9 août 2006

- Mise à jour : 17 juin 2020

- Licence : Cette création est mise à disposition sous un contrat Creative Commons (Paternité - Partage des Conditions Initiales à l'Identique).

Sommaire

- I. Compositions et héritage

I - Compositions et héritage

L'héritage est une des grandes qualités de la programmation orientée objet, ainsi qu'une de ses caractéristiques majeures. Puisque le titre de ce billet ne devrait normalement pas attirer des énergumènes quelconques, je vais partir du principe que vous connaissez les bases de la programmation orientée objet et je ne vous ferai donc pas l'insulte de faire un rappel sur l'héritage. À propos, bien que ce billet propose des exemples en Java, ses analyses et conclusions sont valables avec n'importe quel langage orienté objet.

L'héritage de classes est bien souvent mal employé par les développeurs qui lui donnent une signification plus large que nécessaire. L'héritage définit normalement une relation sémantique entre deux classes. Par exemple, un bouton est un composant graphique. Malheureusement, à cette relation sémantique est souvent substituée une relation fonctionnelle. On n'hérite plus alors d'une autre classe pour marquer des qualités intrinsèques, mais pour hériter de fonctionnalités qui évitent au programmeur de devoir les implémenter à nouveau. En résumé, sous prétexte de favoriser la réutilisation du code, le développeur se rend coupable d'une grave erreur de conception qui peut avoir des conséquences assez importantes dans des API publiques.

L'API de la plateforme Java SE contient plusieurs exemples flagrants d'héritage fonctionnel qui n'auraient jamais dû exister. Comme tous les mauvais exemples de l'API Java que j'utilise dans ces articles, celui-ci date de Java 1.x, lorsque de nombreux concepts n'étaient pas encore maîtrisés. Intéressons-nous donc à la classe java.util.Stack censée représenter une pile LIFO. Avant l'apparition de J2SE 1.2, il n'existait pas d'API uniformisée pour les collections, les interfaces List, Map et Collection ne sont apparues que plus tard. Ainsi, pour réutiliser le code existant, la classe Stack a été implémentée en dérivant java.util.Vector. Cette erreur de conception pourrait être pardonnée, bien qu'un tas ne soit pas un vecteur, on peut facilement se convaincre du contraire, si elle n'avait pas introduit d'importants problèmes fonctionnels. L'API de la classe Stack ne présente en soi aucun problème. Elle expose en revanche toute l'API de Vector. Il est donc possible de manipuler le tas comme un vecteur et de réaliser des opérations inattendues qui peuvent altérer le fonctionnement de votre programme :

Stack pile = new Stack();
pile.push("Bas de la pile");
pile.push("Haut de la pile");
pile.insertElementAt("Perdu", 0);
while (!pile.empty()) {
System.out.println(pile.pop());
}

Cet exemple viole allègrement le contrat établi par la documentation de Stack qui stipule que the Stack class represents a last-in-first-out (LIFO) stack of objects. Prenons maintenant un autre exemple, une classe intitulée LoggedList qui enregistre dans un journal tous les ajouts réalisés sur une liste :

public class LoggedList extends ArrayList {
private static final Logger LOG = Logger.getLogger("logged lists");

public void add(E element) {
LOG.info("Added element: " + e.toString());
super.add(e);
}

public void addAll(Collection c) {
for (E e : c) {
LOG.info("Added element: " + e.toString());
}
super.addAll(c);
}
}

Cette implémentation naïve semble parfaitement légitime à première vue. Trois graves problèmes existent néanmoins, une erreur de sémantique, une limitation fonctionnelle et un bug. Cela fait beaucoup pour quelques lignes de code. Le premier problème est relativement simple : une liste journalisée n'est pas une ArrayList. Le lien sémantique introduit par le programmeur est complètement artificiel, mais surtout faux. De cette première erreur découle naturellement la deuxième, notre liste est limitée à l'implémentation fournie par la classe ArrayList. Que faire si une liste chaînée est mieux appropriée, mais que je désire la journaliser ? Enfin, cette classe enregistre dans le journal tous les ajouts en double lorsque la méthode addAll() est invoquée. Ce comportement est dû à un détail de l'implémentation d'ArrayList qui appelle add() dans addAll(). Vous ne pouvez pas sérieusement prendre en compte ce fait pour votre propre implémentation. Si vous corrigez le bug en retirant la journalisation d'addAll(), que se passera-t-il si la prochaine version de l'API change l'implémentation ? Vous perdrez alors la journalisation d'addAll().

Il est donc très important de prendre le temps de réfléchir à la signification de la relation que vous rédigez chaque fois que vous héritez une classe. La composition est une solution parfaite dans la plupart des situations. En utilisant la composition, vous définissez une relation que possède un objet. Cette technique, qui est aussi une des notions de base de la programmation orientée objet, est souvent associée aux interfaces. La composition se retrouve en outre dans plusieurs design patterns comme le composite, le delegate ou le decorator. L'exemple de composition que je vais utiliser est tiré du projet Fuse. Il s'agit de la classe FallbackMap qui délègue ses appels à une Map par défaut s'ils échouent dans la Map courante. La solution évidente pour de nombreux programmeurs pour réaliser une telle classe serait d'hériter d'HashMap pour bénéficier de toute son implémentation. En se contenant d'implémenter java.util.Map et en favorisant la composition le résultat est beaucoup plus robuste :

class FallbackMap<K, V> implements Map<K, V> {
private final Map<K, V> fallback;
private final Map<K, V> peer;

FallbackMap(Map<K, V> fallback) {
this.fallback = fallback;

peer = new HashMap<K, V>();
}

public int size() {
return peer.size() + fallback.size();
}

public boolean isEmpty() {
return !(peer.isEmpty() && fallback.isEmpty());
}

public boolean containsKey(Object key) {
return (peer.containsKey(key) ||
fallback.containsKey(key));
}

public boolean containsValue(Object value) {
return (peer.containsValue(value) ||
fallback.containsValue(value));
}

public V get(Object key) {
if (peer.containsKey(key)) return peer.get(key);

return fallback.get(key);
}

public V put(K key, V value) {
return peer.put(key, value);
}

public V remove(Object key) {
return null;
}

public void putAll(Map<? extends K, ? extends V> t) {
peer.putAll(t);
}

public void clear() {
peer.clear();
}

public Set<K> keySet() {
Set<K> keySet = new HashSet<K>();

keySet.addAll(peer.keySet());
keySet.addAll(fallback.keySet());

return keySet;
}

public Collection<V> values() {
Collection<V> values = new ArrayList<V>();

values.addAll(peer.values());
values.addAll(fallback.values());

return values;
}

public Set<Entry<K, V>> entrySet() {
Set<Entry<K, V>> entrySet = new HashSet<Entry<K, V>>();

entrySet.addAll(peer.entrySet());
entrySet.addAll(fallback.entrySet());

return entrySet;
}
}

Malgré sa longueur cette classe ne demande que très peu de travail. La grande majorité de son code a en outre été généré par un IDE (cherchez dans le menu Code, Source ou Refactoring une fonction appelée Generate Delegate ou équivalent). La même classe implémentée par héritage pourrait laisser la possibilité aux clients de ne pas accéder la map par défaut en invoquant des méthodes de la classe parente, non redéfinie dans FallbackMap.

Il existe de nombreuses situations dans lesquelles vous pourrez écrire une classe par héritage qui ne présente aucun problème fonctionnel (même si vous ne pourrez corriger la sémantique). J'insiste beaucoup sur le fait que vous devez néanmoins vous concentrer sur la composition, car une API peut évoluer. Il suffit que l'auteur de la classe que vous dérivez rajoute une méthode et que vous ne mettiez pas à jour votre version pour introduire un bug potentiel dans votre code source. L'utilisation de l'héritage favorise en outre beaucoup trop une relation à une implémentation spécifique, qui présente les mêmes dangers en cas d'évolution de la classe parente.

J'espère que vous regarderez dorénavant vos définitions de classes d'un œil soupçonneux :)