¿Cómo definir que un método puede ser anulado es un compromiso más fuerte que definir que un método puede ser llamado?

36

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

Erich Gamma: Sigo pensando que es verdad incluso después de diez años. La herencia es una forma genial de cambiar el comportamiento. Pero sabemos que es frágil, porque la subclase puede hacer fácilmente suposiciones sobre el contexto en el que se llama a un método que anula. Hay un acoplamiento estrecho entre la clase base y la subclase, debido al contexto implícito en el que se llamará al código de subclase que conecto. La composición tiene una mejor propiedad. El acoplamiento se reduce con solo tener algunas cosas más pequeñas que se conectan a algo más grande, y el objeto más grande simplemente llama al objeto más pequeño. Desde el punto de vista de la API, definir que un método se puede anular es un compromiso más fuerte que definir que se puede llamar a un método.

No entiendo lo que quiere decir. ¿Alguien podría explicarlo?

q126y
fuente

Respuestas:

63

Un compromiso es algo que reduce sus opciones futuras. Publicar un método implica que los usuarios lo llamarán, por lo tanto, no puede eliminar este método sin romper la compatibilidad. Si lo hubiera guardado private, no podrían llamarlo (directamente), y algún día podría refactorizarlo sin problemas. Por lo tanto, publicar un método es un compromiso más fuerte que no publicarlo. Publicar un método reemplazable es un compromiso aún más fuerte. ¡Sus usuarios pueden llamarlo, y pueden crear nuevas clases donde el método no hace lo que usted cree que hace!

Por ejemplo, si publica un método de limpieza, puede asegurarse de que los recursos se desasignen correctamente siempre que los usuarios recuerden llamar a este método como lo último que hacen. Pero si el método es reemplazable, alguien podría anularlo en una subclase y no llamar super. Como resultado, un tercer usuario podría usar esa clase y causar una pérdida de recursos a pesar de que llamaron cleanup()al final . Esto significa que ya no puede garantizar la semántica de su código, lo cual es algo muy malo.

Esencialmente, ya no puede confiar en ningún código que se ejecute en métodos reemplazables por el usuario, porque algunos intermediarios podrían anularlo. Esto significa que debe implementar su rutina de limpieza completamente en privatemétodos, sin la ayuda del usuario. Por lo tanto, generalmente es una buena idea publicar solo finalelementos a menos que estén destinados explícitamente a ser anulados por los usuarios de API.

Kilian Foth
fuente
11
Este es posiblemente el mejor argumento contra la herencia que he leído. De todas las razones en contra que he encontrado, nunca me he encontrado con estos dos argumentos antes (acoplar y romper la funcionalidad a través de la anulación), sin embargo, ambos son argumentos muy poderosos contra la herencia.
David Arno
55
@DavidArno No creo que sea un argumento contra la herencia. Creo que es un argumento en contra de "hacer que todo se pueda anular por defecto". La herencia no es peligrosa en sí misma, usarla sin pensar es.
svick
15
Si bien esto parece un buen punto, realmente no puedo ver cómo "un usuario puede agregar su propio código de error" es un argumento. La habilitación de la herencia permite a los usuarios agregar funcionalidades carentes sin perder la capacidad de actualización, una medida que puede prevenir y corregir errores. Si un código de usuario encima de su API está roto, no es un defecto de la API.
Sebb
44
Podrías convertir fácilmente ese argumento en: el primer codificador hace un argumento de limpieza, pero comete errores y no limpia todo. El segundo codificador anula el método de limpieza y hace un buen trabajo, y el codificador # 3 usa la clase y no tiene ninguna pérdida de recursos a pesar de que el codificador # 1 hizo un desastre.
Pieter B
66
@Doval De hecho. Es por eso que es una parodia que la herencia es la lección número uno en casi todos los libros y clases introductorias de OOP.
Kevin Krumwiede
30

Si publica una función normal, otorga un contrato unilateral:
¿Qué hace la función si se llama?

Si publica una devolución de llamada, también otorga un contrato unilateral: ¿
Cuándo y cómo se llamará?

Y si publica una función reemplazable, ambas son a la vez, por lo que otorga un contrato a dos caras: ¿
cuándo se llamará y qué debe hacer si se llama?

Incluso si sus usuarios no abusan de su API (al romper su parte del contrato, que puede ser prohibitivamente costoso de detectar), puede ver fácilmente que este último necesita mucha más documentación, y todo lo que documenta es un compromiso, lo que limita Sus otras opciones.

Un ejemplo de incumplir un contrato de este tipo a doble cara es el movimiento desde showy hidehacia setVisible(boolean)en java.awt.Component .

Deduplicador
fuente
+1. No estoy seguro de por qué se aceptó la otra respuesta; hace algunos puntos interesantes, pero definitivamente no es la respuesta correcta a esta pregunta, ya que definitivamente no es lo que significa el pasaje citado.
ruakh
Esta es la respuesta correcta, pero no entiendo el ejemplo. Reemplazar show and hide con setVisible (boolean) parece romper el código que no usa herencia también. ¿Me estoy perdiendo de algo?
eigensheep
3
@eigensheep: showy hideaún existen, son solo @Deprecated. Entonces, el cambio no rompe ningún código que simplemente los invoca. Pero si los ha anulado, los clientes que migren al nuevo 'setVisible' no llamarán a sus anulaciones. (Nunca he usado Swing, así que no sé qué tan común es anularlos; pero como sucedió hace mucho tiempo, imagino que la razón por la que Deduplicator lo recuerda es porque lo mordió dolorosamente)
ruakh
12

La respuesta de Kilian Foth es excelente. Solo me gustaría agregar el ejemplo canónico * de por qué esto es un problema. Imagine una clase de punto entero:

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);
    }
}

Ahora subclasifiquemos para que sea un punto 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);
    }
}

Súper simple! Usemos nuestros puntos:

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

Probablemente se esté preguntando por qué estoy publicando un ejemplo tan fácil. Aquí está el truco:

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

Cuando comparamos el punto 2D con el punto 3D equivalente, obtenemos verdadero, pero cuando invertimos la comparación, obtenemos falso (porque p2a falla instanceof Point3D).

Conclusión

  1. Por lo general, es posible implementar un método en una subclase de tal manera que ya no sea compatible con la forma en que la superclase espera que funcione.

  2. En general, es imposible implementar equals () en una subclase significativamente diferente de una manera que sea compatible con su clase padre.

Cuando escribe una clase que pretende permitir que las personas subclasifiquen, es una muy buena idea redactar un contrato sobre cómo debe comportarse cada método. Aún mejor sería un conjunto de pruebas unitarias que las personas podrían ejecutar contra sus implementaciones de métodos anulados para demostrar que no violan el contrato. Casi nadie hace eso porque es demasiado trabajo. Pero si te importa, eso es lo que debes hacer.

Un gran ejemplo de un contrato bien enunciado es Comparator . Simplemente ignore lo que dice .equals()por las razones descritas anteriormente. Aquí hay un ejemplo de cómo Comparator puede hacer cosas .equals()que no puede .

Notas

  1. El elemento 8 "Java efectivo" de Josh Bloch fue la fuente de este ejemplo, pero Bloch usa un ColorPoint que agrega un color en lugar de un tercer eje y usa dobles en lugar de ints. El ejemplo de Bloch en Java está básicamente duplicado por Odersky / Spoon / Venners que hicieron que su ejemplo estuviera disponible en línea.

  2. Varias personas se han opuesto a este ejemplo porque si le informa a la clase principal sobre la subclase, puede solucionar este problema. Eso es cierto si hay un número suficientemente pequeño de subclases y si el padre las conoce todas. Pero la pregunta original era sobre hacer una API para la cual alguien más escribirá subclases. En ese caso, generalmente no puede actualizar la implementación principal para que sea compatible con las subclases.

Prima

Comparator también es interesante porque soluciona el problema de implementar equals () correctamente. Mejor aún, sigue un patrón para solucionar este tipo de problema de herencia: el patrón de diseño de la Estrategia. Las clases de tipo que entusiasman a la gente de Haskell y Scala también son el patrón de estrategia. La herencia no es mala o incorrecta, solo es complicada. Para leer más, consulte el artículo de Philip Wadler Cómo hacer que el polimorfismo ad-hoc sea menos ad-hoc.

GlenPeterson
fuente
1
Sin embargo, SortedMap y SortedSet en realidad no cambian las definiciones de equalscómo Map y Set lo definen. Igualdad ignora por completo el orden, con el efecto de que, por ejemplo, dos SortedSets con los mismos elementos pero diferentes órdenes de clasificación todavía se comparan igual.
user2357112 es compatible con Monica
1
@ user2357112 Tienes razón y he eliminado ese ejemplo. SortedMap.equals () que es compatible con Map es un tema separado del que procederé a quejarme. SortedMap es generalmente O (log2 n) y HashMap (la implicación canónica de Map) es O (1). Por lo tanto, solo usarías un SortedMap si realmente te importa hacer un pedido. Por esa razón, creo que el orden es lo suficientemente importante como para ser un componente crítico de la prueba equals () en las implementaciones de SortedMap. No deberían compartir una implementación equals () con Map (lo hacen a través de AbstractMap en Java).
GlenPeterson
3
"La herencia no es mala ni está mal, es simplemente complicado". Entiendo lo que estás diciendo, pero las cosas difíciles suelen conducir a errores, errores y problemas. Cuando puede lograr las mismas cosas (o casi todas las mismas cosas) de una manera más confiable, entonces la forma más complicada es mala.
jpmc26
77
Este es un ejemplo horrible , Glen. Acabas de usar la herencia de una manera que no debería usarse, no es de extrañar que las clases no funcionen de la manera prevista. Rompiste el principio de sustitución de Liskov al proporcionar una abstracción incorrecta (el punto 2D), pero solo porque la herencia sea mala en tu ejemplo incorrecto no significa que sea mala en general. Aunque esta respuesta puede parecer razonable, solo confundirá a las personas que no se dan cuenta de que infringe la regla de herencia más básica.
Andy
3
ELI5 del Principio de sustitución de Liskov dice: Si una clase Bes hija de una clase Ay debe crear una instancia de un objeto de una clase B, debe poder lanzar el Bobjeto de clase a su padre y usar la API de la variable convertida sin perder ningún detalle de implementación de el niño.Rompiste la regla al proporcionar la tercera propiedad. ¿Cómo planea acceder a la zcoordenada después de convertir la Point3Dvariable Point2D, cuando la clase base no tiene idea de que existe tal propiedad? Si al lanzar una clase secundaria a su base, se rompe la API pública, su abstracción es incorrecta.
Andy
4

La herencia debilita la encapsulación

Cuando publica una interfaz con la herencia permitida, aumenta sustancialmente el tamaño de su interfaz. Cada método reemplazable podría ser reemplazado y, por lo tanto, debe considerarse como una devolución de llamada proporcionada al constructor. La implementación proporcionada por su clase es simplemente el valor predeterminado de la devolución de llamada. Por lo tanto, se debe proporcionar algún tipo de contrato que indique cuáles son las expectativas sobre el método. Esto rara vez ocurre y es una de las principales razones por las cuales el código orientado a objetos se llama quebradizo.

A continuación se muestra un ejemplo real (simplificado) del marco de colecciones de Java, cortesía 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);
        }
    }
}

Entonces, ¿qué sucede si subclasificamos esto?

/** 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);
    }
}

Tenemos un error: ocasionalmente agregamos "dog" y la tabla hash obtiene una entrada para "dogss". La causa fue alguien que proporcionó una implementación de put que la persona que diseñó la clase Hashtable no esperaba.

La herencia rompe la extensibilidad

Si permite que su clase se subclasifique, se compromete a no agregar ningún método a su clase. De lo contrario, esto podría hacerse sin romper nada.

Cuando agrega nuevos métodos a una interfaz, cualquier persona que haya heredado de su clase deberá implementar esos métodos.

Eigensheep
fuente
3

Si se pretende llamar a un método, solo debe asegurarse de que funcione correctamente. Eso es. Hecho.

Si un método está diseñado para ser anulado, también debe pensar cuidadosamente sobre el alcance del método: si el alcance es demasiado grande, la clase secundaria a menudo necesitará incluir código copiado del método principal; Si es demasiado pequeño, será necesario anular muchos métodos para tener la nueva funcionalidad deseada; esto agrega complejidad y un recuento de líneas innecesario.

Por lo tanto, el creador del método padre debe hacer suposiciones sobre cómo la clase y sus métodos podrían anularse en el futuro.

Sin embargo, el autor habla sobre un tema diferente en el texto citado:

Pero sabemos que es frágil, porque la subclase puede hacer fácilmente suposiciones sobre el contexto en el que se llama a un método que anula.

Considere el método aque normalmente se llama desde el método b, pero en algunos casos raros y no obvios desde el método c. Si el autor del método de anulación pasa por alto el cmétodo y sus expectativas sobrea , es obvio cómo las cosas pueden salir mal.

Por lo tanto, es más importante que ase defina clara e inequívocamente, bien documentado, "hace una cosa y lo hace bien", más que si fuera un método diseñado únicamente para ser llamado.

Lluvioso
fuente