Comment définir qu'une méthode peut être annulée est-il un engagement plus fort que de définir une méthode?

36

De: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: Je pense toujours que c'est vrai même après dix ans. L'héritage est un moyen intéressant de changer de comportement. Mais nous savons qu’elle est fragile, car la sous-classe peut facilement émettre des hypothèses sur le contexte dans lequel une méthode qu’elle substitue est appelée. Il existe un couplage étroit entre la classe de base et la sous-classe, en raison du contexte implicite dans lequel le code de sous-classe que je connecte sera appelé. La composition a une propriété plus agréable. Le couplage est réduit simplement en ayant quelques petites choses que vous branchez dans quelque chose de plus grand, et le plus gros objet rappelle simplement le plus petit objet. Du point de vue de l'API, définir qu'une méthode peut être remplacée est un engagement plus important que de définir qu'une méthode peut être appelée.

Je ne comprends pas ce qu'il veut dire. Quelqu'un pourrait-il s'il vous plaît l'expliquer?

q126y
la source

Réponses:

63

Un engagement est quelque chose qui réduit vos options futures. La publication d'une méthode implique que les utilisateurs l'appellent. Par conséquent, vous ne pouvez pas supprimer cette méthode sans rompre la compatibilité. Si vous l'aviez gardé private, ils ne pourraient pas (directement) l'appeler, et vous pourriez un jour le reformer sans problèmes. Par conséquent, publier une méthode est un engagement plus fort que de ne pas la publier. La publication d'une méthode pouvant être remplacée est un engagement encore plus fort. Vos utilisateurs peuvent l'appeler et ils peuvent créer de nouvelles classes où la méthode ne fait pas ce que vous pensez!

Par exemple, si vous publiez une méthode de nettoyage, vous pouvez vous assurer que les ressources sont correctement désallouées, à condition que les utilisateurs sachent appeler cette méthode en dernier recours. Mais si la méthode est remplaçable, quelqu'un pourrait la remplacer dans une sous-classe et ne pas appeler super. En conséquence, un troisième utilisateur peut utiliser cette classe et provoquer une fuite de ressources même s’ils ont consciencieusement appelécleanup() à la fin ! Cela signifie que vous ne pouvez plus garantir la sémantique de votre code, ce qui est une très mauvaise chose.

Pour l'essentiel, vous ne pouvez plus vous fier à du code exécuté dans des méthodes pouvant être substituées par l'utilisateur, car un intermédiaire peut le remplacer. Cela signifie que vous devez implémenter votre routine de nettoyage entièrement à l' privateaide de méthodes, sans aide de l'utilisateur. Par conséquent, il est généralement préférable de ne publier que des finaléléments, à moins qu'ils ne soient explicitement destinés à être remplacés par les utilisateurs d'API.

Kilian Foth
la source
11
C'est peut-être le meilleur argument contre l'héritage que j'ai jamais lu. Parmi toutes les raisons que j'ai rencontrées, je n'avais encore jamais rencontré ces deux arguments (couplage et rupture de fonctionnalité par substitution), mais ce sont deux arguments très puissants contre l'héritage.
David Arno
5
@DavidArno Je ne pense pas que ce soit un argument contre l'héritage. Je pense que c'est un argument contre "rendre tout annulable par défaut". L'héritage n'est pas dangereux en soi, son utilisation sans pensée l'est.
svick
15
Bien que cela semble être un bon point, je ne vois pas vraiment comment "un utilisateur peut ajouter son propre code de bug" est un argument. L'activation de l'héritage permet aux utilisateurs d'ajouter des fonctionnalités manquantes sans perdre la possibilité de mise à jour, une mesure permettant d'empêcher et de corriger les bogues. Si un code d'utilisateur au sommet de votre API est cassé, ce n'est pas un défaut d'API.
Sebb
4
Vous pouvez facilement transformer cet argument en: le premier codeur lance un argument de nettoyage, mais fait des erreurs et ne nettoie pas tout. Le deuxième codeur annule la méthode de nettoyage et fait du bon travail. Le codeur n ° 3 utilise la classe et ne présente aucune fuite de ressources, même si le codeur n ° 1 l'a gâché.
Pieter B
6
@Doval En effet. C'est pourquoi le fait que l'héritage soit la leçon numéro un dans presque tous les livres et classes d'introduction à la POO est une parodie.
Kevin Krumwiede
30

Si vous publiez une fonction normale, vous donnez un contrat unilatéral:
que fait la fonction si elle est appelée?

Si vous publiez un rappel, vous accordez également un contrat unilatéral:
quand et comment sera-t-il appelé?

Et si vous publiez une fonction qui peut être annulée, ce sont les deux à la fois, vous donnez donc un contrat à double face:
quand sera-t-elle appelée et que doit-elle faire si elle est appelée?

Même si vos utilisateurs n'abusent pas de votre API (en rompant leur part du contrat, ce qui pourrait coûter très cher à détecter), vous pouvez facilement voir que cette dernière a besoin de beaucoup plus de documentation, et tout ce que vous documentez est un engagement qui limite vos autres choix.

Un exemple de revenir sur un tel contrat bilatéral est le passage de showet hideà setVisible(boolean)en java.awt.Component .

Déduplicateur
la source
+1 Je ne sais pas pourquoi l'autre réponse a été acceptée. cela soulève des points intéressants, mais ce n'est certainement pas la bonne réponse à cette question, en ce sens que ce n'est pas ce que l'on entend par le passage cité.
Ruakh
C'est la bonne réponse, mais je ne comprends pas l'exemple. Le remplacement de show and hide par setVisible (boolean) semble rompre un code qui n'utilise pas non plus l'héritage. Est-ce que je manque quelque chose?
eigensheep
3
@eigensheep: showet hideexistent toujours, ils sont juste @Deprecated. Ainsi, le changement ne casse aucun code qui les invoque simplement. Mais si vous les avez remplacées, vos remplacements ne seront pas appelés par les clients qui migrent vers le nouveau "setVisible". (Je n'ai jamais utilisé Swing, donc je ne sais pas à quel point il est courant de passer outre cela; mais depuis que cela s'est passé il y a longtemps, j'imagine que la raison pour laquelle Deduplicator se souvient c'est que c'est lui qui l'a mordu douloureusement.)
ruakh
12

La réponse de Kilian Foth est excellente. J'aimerais juste ajouter l'exemple canonique * de la raison pour laquelle c'est un problème. Imaginez une classe de points entière:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

Maintenant, passons à une classe 3D.

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

Super simple! Utilisons nos points:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

Vous vous demandez probablement pourquoi je publie un exemple aussi simple. Voici le piège:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

Lorsque nous comparons le point 2D au point 3D équivalent, nous obtenons la valeur true, mais lorsque nous inversons la comparaison, nous obtenons la valeur false (car p2a échoue instanceof Point3D ).

Conclusion

  1. Il est généralement possible d'implémenter une méthode dans une sous-classe de telle sorte qu'elle ne soit plus compatible avec la façon dont la super-classe s'attend à ce qu'elle fonctionne.

  2. Il est généralement impossible d'implémenter equals () sur une sous-classe très différente d'une manière compatible avec sa classe parente.

Lorsque vous écrivez un cours que vous avez l'intention de permettre aux gens de sous-classer, c'est une très bonne idée d'écrire un contrat. précisant le comportement de chaque méthode. Mieux encore, un ensemble de tests unitaires que les personnes pourraient exécuter contre leur implémentation de méthodes annulées pour prouver qu’elles ne violaient pas le contrat. Presque personne ne le fait parce que c'est trop de travail. Mais si vous vous en souciez, c'est la chose à faire.

Comparateur est un excellent exemple de contrat bien défini . Ignorez simplement ce qui est dit .equals()pour les raisons décrites ci-dessus. Voici un exemple de ce que le comparateur ne .equals()peut pas faire .

Remarques

  1. L'élément 8 de "Effective Java" de Josh Bloch était la source de cet exemple, mais Bloch utilise un ColorPoint qui ajoute une couleur au lieu d'un troisième axe et utilise des doubles au lieu d'ints. L'exemple Java de Bloch est essentiellement dupliqué par Odersky / Spoon / Venners qui a rendu leur exemple disponible en ligne.

  2. Plusieurs personnes se sont opposées à cet exemple car si vous informez la classe parent de la sous-classe, vous pouvez résoudre ce problème. Cela est vrai s’il ya un nombre de sous-classes assez petit et si le parent les connaît toutes. Mais la question initiale concernait la création d'une API pour laquelle quelqu'un d'autre écrirait des sous-classes. Dans ce cas, vous ne pouvez généralement pas mettre à jour l'implémentation parent pour qu'elle soit compatible avec les sous-classes.

Prime

Comparator est également intéressant car il résout correctement le problème de la mise en œuvre de equals () Mieux encore, un modèle permet de résoudre ce type de problème d'héritage: le modèle de conception Stratégie. Les classes qui intéressent les gens de Haskell et de Scala constituent également le modèle de la stratégie. L'héritage n'est ni mauvais ni faux, c'est simplement délicat. Pour en savoir plus, consultez l'article de Philip Wadler Comment rendre le polymorphisme ad hoc moins ad hoc

GlenPeterson
la source
1
SortedMap et SortedSet ne modifient toutefois pas les définitions de la equalsfaçon dont Map et Set le définissent. Equality ignore complètement l'ordre, avec pour conséquence que, par exemple, deux SortedSets avec les mêmes éléments mais des ordres de tri différents se comparent toujours de manière égale.
user2357112 prend en charge Monica
1
@ user2357112 Vous avez raison et j'ai supprimé cet exemple. SortedMap.equals () étant compatible avec Map est une question distincte sur laquelle je vais me plaindre. SortedMap est généralement O (log2 n) et HashMap (l'implémentation canonique de Map) est O (1). Par conséquent, vous utiliseriez une SortedMap uniquement si vous tenez vraiment à la commande. Pour cette raison, j'estime que l'ordre est suffisamment important pour constituer un élément essentiel du test equals () dans les implémentations SortedMap. Ils ne doivent pas partager une implémentation d'equals () avec Map (ils le font via AbstractMap en Java).
GlenPeterson
3
"L'héritage n'est ni mauvais ni faux, c'est juste délicat." Je comprends ce que vous dites, mais les choses difficiles entraînent généralement des erreurs, des bugs et des problèmes. Lorsque vous pouvez accomplir les mêmes choses (ou presque toutes les mêmes choses) de manière plus fiable, alors la manière la plus délicate est mauvaise.
Jpmc26
7
Ceci est un exemple terrible , Glen. Vous venez d'utiliser l'héritage d'une manière qui ne devrait pas l'être, il n'est pas étonnant que les classes ne fonctionnent pas comme prévu. Vous avez enfreint le principe de substitution de Liskov en fournissant une abstraction erronée (le point 2D), mais ce n'est pas parce que l'héritage est mauvais dans votre mauvais exemple que cela est mauvais en général. Bien que cette réponse puisse sembler raisonnable, elle ne fera que dérouter les personnes qui ne réalisent pas qu'elle enfreint la règle de base en matière d'héritage.
Andy
3
ELI5 du Principe du Substituton de Liskov dit: Si une classe Best un enfant d'une classe Aet que vous instanciez un objet d'une classe B, vous devriez pouvoir transtyper l' Bobjet de classe sur son parent et utiliser l'API de la variable convertie sans perdre les détails d'implémentation de l'enfant. Vous avez enfreint la règle en fournissant la troisième propriété. Comment comptez-vous accéder aux zcoordonnées après avoir converti la Point3Dvariable en Point2D, alors que la classe de base n'a aucune idée de l'existence d'une telle propriété? Si en cassant une classe enfant vers sa base vous cassez l'API publique, votre abstraction est fausse.
Andy
4

L'héritage affaiblit l'encapsulation

Lorsque vous publiez une interface avec l'héritage autorisé, vous augmentez considérablement la taille de votre interface. Chaque méthode pouvant être remplacée peut être remplacée et doit donc être considérée comme un rappel fourni au constructeur. L'implémentation fournie par votre classe est simplement la valeur par défaut du rappel. Il convient donc de fournir un type de contrat indiquant les attentes vis-à-vis de la méthode. Cela se produit rarement et est l'une des principales raisons pour lesquelles le code orienté objet est appelé fragile.

Vous trouverez ci-dessous un exemple réel (simplifié) du framework de collections Java, gracieuseté de Peter Norvig ( http://norvig.com/java-iaq.html ).

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

Alors que se passe-t-il si nous sous-classe cela?

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

Nous avons un bug: Nous ajoutons parfois "dog" et la hashtable reçoit une entrée pour "dogss". La cause était une personne fournissant une implémentation de put à laquelle la personne qui conçoit la classe Hashtable ne s’attendait pas.

L'héritage rompt l'extensibilité

Si vous autorisez la sous-classe de votre classe, vous vous engagez à ne pas ajouter de méthodes à votre classe. Cela pourrait autrement être fait sans rien casser.

Lorsque vous ajoutez de nouvelles méthodes à une interface, toute personne ayant hérité de votre classe devra implémenter ces méthodes.

eigensheep
la source
3

Si une méthode doit être appelée, vous devez simplement vous assurer qu'elle fonctionne correctement. C'est ça. Terminé.

Si une méthode est conçue pour être remplacée, vous devez également bien réfléchir à la portée de la méthode: si la portée est trop grande, la classe enfant devra souvent inclure le code copié-collé de la méthode parente; si elle est trop petite, de nombreuses méthodes devront être remplacées pour que les nouvelles fonctionnalités souhaitées soient ajoutées, ce qui ajoute à la complexité et au nombre de lignes inutile.

Par conséquent, le créateur de la méthode parent doit émettre des hypothèses sur la manière dont la classe et ses méthodes pourraient être remplacées à l'avenir.

Cependant, l'auteur parle d'un problème différent dans le texte cité:

Mais nous savons qu’elle est fragile, car la sous-classe peut facilement émettre des hypothèses sur le contexte dans lequel une méthode qu’elle substitue est appelée.

Considérez la méthode aqui est normalement appelée méthode b, mais dans certains cas rares et non évidents de méthode c. Si l'auteur de la méthode dominante néglige la cméthode et ses attentes a, il est évident que les choses peuvent mal se passer.

Par conséquent, il est plus important de adéfinir clairement et sans ambiguïté, bien documenté, "fait une chose et le fait bien" - davantage que s'il s'agissait d'une méthode conçue uniquement pour être appelée.

Pluvieux
la source