Pourquoi étendre est le mal

Le extendsmot clé est le mal; peut-être pas au niveau de Charles Manson, mais suffisamment pour qu'il soit évité autant que possible. Le livre Gang of Four Design Patterns discute longuement du remplacement de l'héritage d'implémentation ( extends) par l'héritage d'interface ( implements).

Les bons concepteurs écrivent la plupart de leur code en termes d'interfaces, pas de classes de base concrètes. Cet article décrit les raisons pour lesquelles les concepteurs ont des habitudes aussi bizarres et présente également quelques notions de base sur la programmation basée sur l'interface.

Interfaces contre classes

J'ai assisté une fois à une réunion d'un groupe d'utilisateurs Java où James Gosling (l'inventeur de Java) était le conférencier vedette. Au cours de la mémorable session de questions-réponses, quelqu'un lui a demandé: "Si vous pouviez refaire Java, que changeriez-vous?" «J'oublierais les cours», répondit-il. Après que les rires se soient calmés, il a expliqué que le vrai problème n'était pas les classes en soi, mais plutôt l'héritage d'implémentation (la extendsrelation). L'héritage d'interface (la implementsrelation) est préférable. Vous devez éviter l'héritage d'implémentation dans la mesure du possible.

Perte de flexibilité

Pourquoi éviter l'héritage d'implémentation? Le premier problème est que l'utilisation explicite de noms de classes concrets vous enferme dans des implémentations spécifiques, ce qui rend les modifications en aval inutilement difficiles.

Au cœur des méthodologies de développement Agile contemporaines se trouve le concept de conception et de développement parallèles. Vous commencez la programmation avant de spécifier complètement le programme. Cette technique va à l'encontre de la sagesse traditionnelle, selon laquelle une conception doit être terminée avant le début de la programmation, mais de nombreux projets réussis ont prouvé que vous pouvez développer du code de haute qualité plus rapidement (et à moindre coût) de cette manière qu'avec l'approche traditionnelle en pipeline. La notion de flexibilité est cependant au cœur du développement parallèle. Vous devez écrire votre code de manière à pouvoir incorporer les exigences nouvellement découvertes dans le code existant aussi facilement que possible.

Plutôt que de mettre en œuvre les fonctionnalités dont vous pourriez avoir besoin, vous implémentez uniquement les fonctionnalités dont vous avez vraiment besoin, mais d'une manière qui s'adapte au changement. Si vous n'avez pas cette flexibilité, le développement parallèle n'est tout simplement pas possible.

La programmation aux interfaces est au cœur d'une structure flexible. Pour voir pourquoi, regardons ce qui se passe lorsque vous ne les utilisez pas. Considérez le code suivant:

f () {LinkedList list = new LinkedList (); // ... g (liste); } g (liste LinkedList) {list.add (...); g2 (liste)}

Supposons maintenant qu'une nouvelle exigence de recherche rapide soit apparue, de sorte que cela LinkedListne fonctionne pas. Vous devez le remplacer par un fichier HashSet. Dans le code existant, ce changement n'est pas localisé puisque vous devez modifier non seulement f()mais aussi g()(qui prend un LinkedListargument), et tout ce qui g()passe la liste à.

Réécrire le code comme ceci:

f () {Collection list = new LinkedList (); // ... g (liste); } g (Liste des collections) {list.add (...); g2 (liste)}

permet de changer la liste chaînée en table de hachage simplement en remplaçant le new LinkedList()par un new HashSet(). C'est ça. Aucun autre changement n'est nécessaire.

Comme autre exemple, comparez ce code:

f () {Collection c = nouveau HashSet (); // ... g (c); } g (Collection c) {pour (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }

pour ça:

f2 () {Collection c = nouveau HashSet (); // ... g2 (c.iterator ()); } g2 (Iterator i) {while (i.hasNext ();) do_something_with (i.next ()); }

La g2()méthode peut maintenant parcourir les Collectiondérivés ainsi que les listes de clés et de valeurs que vous pouvez obtenir à partir d'un fichier Map. En fait, vous pouvez écrire des itérateurs qui génèrent des données au lieu de parcourir une collection. Vous pouvez écrire des itérateurs qui fournissent des informations à partir d'un échafaudage de test ou d'un fichier au programme. Il y a une énorme flexibilité ici.

Couplage

Un problème plus crucial avec l'héritage d'implémentation est le couplage - la dépendance indésirable d'une partie d'un programme sur une autre partie. Les variables globales fournissent l'exemple classique des raisons pour lesquelles un couplage fort cause des problèmes. Si vous changez le type de la variable globale, par exemple, toutes les fonctions qui utilisent la variable (c'est-à-dire, sont couplées à la variable) peuvent être affectées, donc tout ce code doit être examiné, modifié et retesté. De plus, toutes les fonctions qui utilisent la variable sont couplées les unes aux autres via la variable. Autrement dit, une fonction peut affecter de manière incorrecte le comportement d'une autre fonction si la valeur d'une variable est modifiée à un moment difficile. Ce problème est particulièrement horrible dans les programmes multithread.

En tant que concepteur, vous devez vous efforcer de minimiser les relations de couplage. Vous ne pouvez pas éliminer complètement le couplage, car un appel de méthode d'un objet d'une classe à un objet d'une autre est une forme de couplage lâche. Vous ne pouvez pas avoir de programme sans couplage. Néanmoins, vous pouvez réduire considérablement le couplage en suivant servilement les préceptes OO (orientés objet) (le plus important est que l'implémentation d'un objet doit être complètement cachée des objets qui l'utilisent). Par exemple, les variables d'instance d'un objet (champs membres qui ne sont pas des constantes) doivent toujours l'être private. Période. Aucune exception. Déjà. Je suis sérieux. (Vous pouvez parfois utiliser protectedefficacement des méthodes, maisprotected Les variables d'instance sont une abomination.) Vous ne devriez jamais utiliser les fonctions get / set pour la même raison - ce sont juste des moyens trop compliqués de rendre un champ public (bien que les fonctions d'accès qui renvoient des objets complets plutôt qu'une valeur de raisonnable dans les situations où la classe de l'objet retourné est une abstraction clé dans la conception).

Je ne suis pas pédant ici. J'ai trouvé une corrélation directe dans mon propre travail entre la rigueur de mon approche OO, le développement rapide de code et la maintenance facile du code. Chaque fois que je viole un principe central d'OO comme le masquage de l'implémentation, je finis par réécrire ce code (généralement parce que le code est impossible à déboguer). Je n'ai pas le temps de réécrire les programmes, je suis donc les règles. Ma préoccupation est tout à fait pratique - je ne m'intéresse pas à la pureté au nom de la pureté.

Le problème fragile de la classe de base

Maintenant, appliquons le concept de couplage à l'héritage. Dans un système d'héritage d'implémentation qui utilise extends, les classes dérivées sont très étroitement couplées aux classes de base, et cette connexion étroite n'est pas souhaitable. Les concepteurs ont appliqué le surnom de «problème de la classe de base fragile» pour décrire ce comportement. Les classes de base sont considérées comme fragiles car vous pouvez modifier une classe de base de manière apparemment sûre, mais ce nouveau comportement, lorsqu'il est hérité par les classes dérivées, peut entraîner un dysfonctionnement des classes dérivées. Vous ne pouvez pas dire si un changement de classe de base est sûr simplement en examinant les méthodes de la classe de base isolément; vous devez également examiner (et tester) toutes les classes dérivées. De plus, vous devez vérifier tout le code qui utilise à la fois la classe de base etobjets de classe dérivée également, car ce code peut également être interrompu par le nouveau comportement. Un simple changement dans une classe de base clé peut rendre un programme entier inopérant.

Examinons ensemble les problèmes fragiles de couplage de classe de base et de classe de base. La classe suivante étend la ArrayListclasse de Java pour qu'elle se comporte comme une pile:

class Stack étend ArrayList {private int stack_pointer = 0; public void push (objet article) {add (stack_pointer ++, article); } objet public pop () {return remove (--stack_pointer); } public void push_many (Object [] articles) {for (int i = 0; i <articles.length; ++ i) push (articles [i]); }}

Même une classe aussi simple que celle-ci a des problèmes. Considérez ce qui se passe lorsqu'un utilisateur tire parti de l'héritage et utilise la méthode ArrayLists clear()pour tout retirer de la pile:

Stack a_stack = new Stack (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

Le code se compile avec succès, mais comme la classe de base ne sait rien sur le pointeur de pile, l' Stackobjet est maintenant dans un état indéfini. Le prochain appel à push()place le nouvel élément à l'index 2 (la stack_pointervaleur actuelle de la pile), de sorte que la pile contient effectivement trois éléments - les deux derniers sont des déchets. (La Stackclasse Java a exactement ce problème; ne l'utilisez pas.)

Une solution au problème indésirable d'héritage de méthode consiste Stackà remplacer toutes les ArrayListméthodes qui peuvent modifier l'état du tableau, de sorte que les substitutions manipulent correctement le pointeur de pile ou lèvent une exception. (La removeRange()méthode est un bon candidat pour lancer une exception.)

Cette approche présente deux inconvénients. Premièrement, si vous écrasez tout, la classe de base devrait vraiment être une interface, pas une classe. L'héritage d'implémentation est inutile si vous n'utilisez aucune des méthodes héritées. Deuxièmement, et plus important encore, vous ne voulez pas qu'une pile prenne en charge toutes les ArrayListméthodes. Cette removeRange()méthode embêtante n'est pas utile, par exemple. La seule façon raisonnable d'implémenter une méthode inutile est de lui faire lever une exception, car elle ne devrait jamais être appelée. Cette approche déplace efficacement ce qui serait une erreur de compilation dans le runtime. Pas bon. Si la méthode n'est tout simplement pas déclarée, le compilateur supprime une erreur de méthode introuvable. Si la méthode est là mais lève une exception, vous ne découvrirez pas l'appel tant que le programme ne s'exécutera pas.

Une meilleure solution au problème de la classe de base consiste à encapsuler la structure de données au lieu d'utiliser l'héritage. Voici une version nouvelle et améliorée de Stack:

class Stack {private int stack_pointer = 0; Private ArrayList the_data = new ArrayList (); public void push (article objet) {the_data.add (stack_pointer ++, article); } Public Object pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] articles) {for (int i = 0; i <o.length; ++ i) push (articles [i]); }}

Jusqu'ici tout va bien, mais considérez la question fragile de la classe de base. Disons que vous souhaitez créer une variante Stackqui suit la taille maximale de la pile sur une certaine période. Une implémentation possible pourrait ressembler à ceci:

la classe Monitorable_stack étend la pile {private int high_water_mark = 0; private int current_size; public void push (Objet article) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (article); } objet public pop () {--current_size; retourne super.pop (); } public int maximum_size_so_far () {return high_water_mark; }}

Cette nouvelle classe fonctionne bien, au moins pendant un certain temps. Malheureusement, le code exploite le fait qui push_many()fait son travail en appelant push(). Au début, ce détail ne semble pas être un mauvais choix. Cela simplifie le code et vous obtenez la version de classe dérivée de push(), même lorsque le Monitorable_stackest accessible via une Stackréférence, de sorte que les high_water_markmises à jour correctement.

One fine day, someone might run a profiler and notice the Stack isn't as fast as it could be and is heavily used. You can rewrite the Stack so it doesn't use an ArrayList and consequently improve the Stack's performance. Here's the new lean-and-mean version:

class Stack { private int stack_pointer = -1; private Object[] stack = new Object[1000]; public void push( Object article ) { assert stack_pointer = 0; return stack[ stack_pointer-- ]; } public void push_many( Object[] articles ) { assert (stack_pointer + articles.length) < stack.length; System.arraycopy(articles, 0, stack, stack_pointer+1, articles.length); stack_pointer += articles.length; } } 

Notice that push_many() no longer calls push() multiple times—it does a block transfer. The new version of Stack works fine; in fact, it's better than the previous version. Unfortunately, the Monitorable_stack derived class doesn't work any more, since it won't correctly track stack usage if push_many() is called (the derived-class version of push() is no longer called by the inherited push_many() method, so push_many() no longer updates the high_water_mark). Stack is a fragile base class. As it turns out, it's virtually impossible to eliminate these types of problems simply by being careful.

Notez que vous ne rencontrez pas ce problème si vous utilisez l'héritage d'interface, car aucune fonctionnalité héritée ne vous gêne. S'il Stacks'agit d'une interface, implémentée à la fois par a Simple_stacket a Monitorable_stack, alors le code est beaucoup plus robuste.