Compositions et héritage

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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 plate-forme 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 :

 
Sélectionnez

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 toutes les ajouts réalisés sur une liste :

 
Sélectionnez

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 :

 
Sélectionnez

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 oeil soupçonneux :)

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Articles et tutoriels Java
L'essentiel de Java en une heure
L'API java.nio du JDK 1.4
Inversion de contrôle en Java
L'introspection
Le Java Community Process
Conception de tests unitaires avec JUnit
Les Strings se déchaînent
Présentation de SWT
La programmation réseau en Java avec les sockets
Du bon usage de l'héritage et de la composition
Les références et la gestion de la mémoire en Java
Constructeurs et méthodes exportées en Java
Les membres statiques, finaux et non immuables en Java
Les classes et objets immuables en Java
Comprendre et optimiser le Garbage Collector
Les principes de la programmation d'une interface graphique
Les opérateurs binaires en Java
Prenez le contrôle du bureau avec JDIC
Les Java Data Objects (JDO version 1.0.1)
La persistance des données avec Hibernate 2.1.8
Journalisation avec l'API Log4j
Java 5.0 et les types paramétrés
Les annotations de Java 5
Java 1.5 et les types paramétrés
Créer un moteur de recherche avec Lucene
Articles et tutoriels Swing
Threads et performance avec Swing
Rechercher avec style en utilisant Swing
Splash Screen avec Swing et Java3D
Drag & Drop avec style en utilisant Swing
Attendre avec style en utilisant Swing
Mixer Java3D et Swing
Articles et tutoriels Java Web
Redécouvrez le web avec Wicket
Cette création est mise à disposition sous un contrat Creative Commons (Paternité - Partage des Conditions Initiales à l'Identique).