Apuntes · Capítulo 7Herencia e interfaces

Interfaces y composición

Interfaces y composición

La semana pasada vimos que una variable de tipo Notification puede apuntar a un EmailNotification o a un SmsNotification, y que la JVM elige qué send() ejecutar según el objeto. También vimos que una clase abstracta como Employee fija el qué (todo empleado tiene un monthlyPay()) y deja el cómo a cada subclase. Todo eso funcionaba dentro de una jerarquía: las clases que se sustituían eran parientes.

Este capítulo responde dos preguntas que salen de ahí. La primera: ¿qué hacemos cuando clases sin ningún parentesco tienen que ofrecer la misma operación? Una factura y un empleado no son parientes, pero los dos se pagan. La respuesta son las interfaces (interfaces). La segunda: ¿cuándo no conviene heredar? Vamos a ver que la herencia ata a la subclase con los detalles de la superclase, que una subclase puede no ser un buen sustituto aunque “sea un” en el diccionario, y que muchas veces es mejor que un objeto tenga a otro en lugar de ser otro: la composición (composition). Cerramos con lo que hace posible una interfaz en las pruebas: reemplazar una dependencia difícil por un test double escrito a mano.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), sección 5.7 Interfaces: 5.7.1, 5.7.2 (métodos default) y 5.7.3 (interfaces como tipos). Eck no trata la composición frente a la herencia ni el principio de Liskov; para eso, la introducción y la sección 2 de B. Liskov y J. Wing, A Behavioral Notion of Subtyping (1994), y la ficha Replace Superclass with Delegate del catálogo de refactoring de Martin Fowler. Para los test doubles, Test Double, también de Fowler. El código está en code/week-07 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): Un contrato sin estado y Tener en lugar de ser.
  • En clase: en la sesión A, el resto de Interfaces, de Una superclase, varias interfaces a ¿Interfaz o clase abstracta?; en la sesión B, La superclase frágil y El principio de sustitución de Liskov. En el laboratorio, Test doubles.
  • Después de clase: java.util.Stack: un error en la biblioteca estándar, ColorPoint otra vez, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.

Interfaces

Un contrato sin estado

En la Bodega hay cosas que pagar a fin de mes: las facturas de los proveedores y los sueldos de los empleados. Queremos un método que sume todo lo que se debe. Con lo que sabemos hasta ahora, la única forma de meter una factura y un empleado en la misma lista sería darles una superclase común, algo como abstract class ThingToPay. Pero eso es forzado: Employee ya tiene su jerarquía (la de la semana 6), y una factura no “es un” empleado ni al revés. Lo único que comparten es una capacidad: se pueden pagar.

Una interfaz es exactamente eso: un conjunto de métodos que una clase promete ofrecer, sin decir cómo y sin guardar estado. Payable.java:

public interface Payable {

    /** Monto por pagar, en céntimos. */
    long amountDue();
}

amountDue() no tiene cuerpo: es un método abstracto, igual que monthlyPay() en Employee. En una interfaz no hace falta escribir abstract ni public; todo método sin cuerpo de una interfaz es público y abstracto. javap lo confirma:

$ javap -cp build Payable
Compiled from "Payable.java"
public interface Payable {
  public abstract long amountDue();
  public default java.lang.String formattedAmount();
  public static long total(java.util.List<Payable>);
}

(Los otros dos métodos los vemos más abajo.) Una clase se compromete a cumplir la interfaz con implements. Invoice.java:

public class Invoice implements Payable {
    private final String number;
    private final long amount;      // en céntimos

    public Invoice(String number, long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("monto no positivo: " + amount);
        }
        this.number = number;
        this.amount = amount;
    }

    @Override
    public long amountDue() {
        return amount;
    }
    // ... number() y toString() ...
}

Y Employee, la clase abstracta de la semana 6 (en code/week-07 hay una copia), también la implementa: lo que se le debe a un empleado es su pago del mes.

public abstract class Employee implements Payable {
    // ... name, constructor, monthlyPay() abstracto ...

    @Override
    public long amountDue() {
        return monthlyPay();
    }
}

SalariedEmployee y HourlyEmployee no cambian: heredan amountDue() de Employee, y ese método llama a su monthlyPay().

Diagrama de clases. Arriba, la interfaz Payable con amountDue(), formattedAmount() default y total(List) static. Un cliente que llama a Payable.total(bills) solo la conoce a ella. Abajo a la izquierda, Invoice hereda de Object; abajo a la derecha, la clase abstracta Employee con SalariedEmployee y HourlyEmployee. Invoice y Employee implementan Payable con flechas punteadasDiagrama de clases. Arriba, la interfaz Payable con amountDue(), formattedAmount() default y total(List) static. Un cliente que llama a Payable.total(bills) solo la conoce a ella. Abajo a la izquierda, Invoice hereda de Object; abajo a la derecha, la clase abstracta Employee con SalariedEmployee y HourlyEmployee. Invoice y Employee implementan Payable con flechas punteadas
Invoice y Employee no tienen parentesco, pero los dos implementan Payable. La flecha punteada con punta hueca es implements; la sólida, extends.

En UML, implements se dibuja como la flecha de herencia pero punteada, y la interfaz lleva el estereotipo «interface».

Error común. Olvidar public al implementar el método:

public class Receipt implements Payable {
    long amountDue() {
        return 100;
    }
}
Receipt.java:2: error: amountDue() in Receipt cannot implement amountDue() in Payable
    long amountDue() {
         ^
  attempting to assign weaker access privileges; was public
1 error

El método de la interfaz es público, y quien lo implementa no puede ofrecerlo con menos acceso: alguien que tiene un Payable en la mano tiene derecho a llamarlo. Y si la clase no implementa el método, el error es el mismo que con un método abstracto que falta: Gift is not abstract and does not override abstract method amountDue() in Payable.

Una superclase, varias interfaces

Una clase puede extender una sola clase, pero implementar varias interfaces: class Employee extends Person implements Payable, Comparable<Employee> es válido. ¿Por qué esa asimetría?

Si Java permitiera class C extends A, B y tanto A como B tuvieran un campo id y un método describe(), ¿cuántos id tendría un objeto C? ¿Qué describe() ejecutaría c.describe()? Es el llamado problema del diamante, y C++, que sí permite herencia múltiple, necesita reglas especiales para resolverlo. Java lo evita de raíz: el estado (los campos de instancia) se hereda de una sola superclase. Una interfaz no tiene campos de instancia, así que implementar varias no crea esas dudas: lo único que se acumula son promesas de métodos.

Idea clave. La herencia de clases dice qué es un objeto y le da estado y código; hay una sola. Una interfaz dice qué sabe hacer un objeto; puede tener varias. Por eso los nombres de interfaz suelen ser capacidades: Payable, Comparable, Runnable, Iterable.

La interfaz como tipo

Una interfaz no se puede instanciar (new Payable() no compila), pero sí se usa como tipo: de variables, de parámetros, de listas. PayableDemo.java:

List<Payable> bills = new ArrayList<>();
bills.add(new Invoice("F001-120", 45_000));
bills.add(new SalariedEmployee("alice", 320_000));
bills.add(new HourlyEmployee("bob", 1_250, 80));

for (Payable p : bills) {
    IO.println(p + " -> " + p.formattedAmount());
}
IO.println("Total: " + Payable.total(bills) + " céntimos");
$ java -cp build PayableDemo
Invoice[F001-120] -> S/ 450.00
SalariedEmployee[alice] -> S/ 3200.00
HourlyEmployee[bob] -> S/ 1000.00
Total: 465000 céntimos

Todo lo de la semana 6 vale igual. El tipo estático de p es Payable, así que el compilador solo deja llamar a métodos de Payable (p.monthlyPay() no compila, aunque p apunte a un empleado). El tipo dinámico es el del objeto, y la JVM ejecuta el amountDue() de ese objeto: el de Invoice para la factura, el de Employee (que llama al monthlyPay() de cada subclase) para los empleados. El bytecode solo cambia el nombre de la instrucción:

$ javap -c -cp build Payable
...
        1: invokeinterface #1,  1            // InterfaceMethod amountDue:()J
...

Donde la semana pasada veíamos invokevirtual para un método de una clase, aquí aparece invokeinterface para un método de una interfaz. La regla es la misma: se busca el método empezando por la clase real del objeto.

Métodos default y static

Hasta Java 7, una interfaz solo podía tener métodos abstractos. Eso tenía un costo enorme: agregar un método a una interfaz publicada rompía todas las clases que la implementaban, porque de pronto les faltaba un método. Java 8 agregó los métodos default: métodos con cuerpo dentro de la interfaz, que las clases heredan si no les hacen override.

public interface Payable {
    long amountDue();

    /** Monto con formato de soles. Las clases lo heredan sin escribir nada. */
    default String formattedAmount() {
        long cents = amountDue();
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }

    /** Suma lo que se debe en una lista de cosas por pagar. */
    static long total(List<Payable> items) {
        long sum = 0;
        for (Payable p : items) {
            sum += p.amountDue();
        }
        return sum;
    }
}

formattedAmount() está escrito en términos del método abstracto: no sabe cómo se calcula el monto, pero sabe darle formato. Es la misma idea del Template Method de la semana 6, ahora en una interfaz. Invoice y Employee lo tienen sin haber cambiado una línea.

Un método static de una interfaz pertenece a la interfaz, como un método static de una clase: se llama Payable.total(bills), nunca a través de un objeto. Sirve para poner las utilidades junto al tipo que las usa (la API lo hace mucho: List.of(...), Comparator.comparing(...)).

Una interfaz también puede declarar constantes: cualquier campo de una interfaz es, implícitamente, public static final. Úsenlo poco: una interfaz que solo existe para juntar constantes (y que las clases “implementan” para escribirlas sin prefijo) mezcla un detalle de implementación con el tipo público de la clase. Para constantes, una clase con campos static final o un enum es mejor.

En Java. Si una clase implementa dos interfaces que traen el mismo método default, el compilador no elige por ella: la clase tiene que hacerle override, y puede llamar a la versión que quiera con Payable.super.formattedAmount(). El diamante vuelve a aparecer, pero solo con métodos, y Java obliga a resolverlo a mano.

Error común. Usar default para meter en la interfaz comportamiento que no se puede escribir solo con los métodos abstractos, o que depende de estado. Una interfaz no tiene campos de instancia: si el método default necesita guardar algo, lo que hace falta es una clase. Y un default que no sirve para algunas implementaciones (por ejemplo, un formato que no tiene sentido para ellas) obliga a hacerle override y es fácil olvidarlo.

Comparable<T>: una interfaz de la API

La biblioteca estándar está llena de interfaces. Una de las primeras que van a usar es Comparable<T>, con un solo método:

public interface Comparable<T> {
    int compareTo(T other);
}

a.compareTo(b) devuelve un número negativo si a va antes que b, cero si son equivalentes en el orden, y positivo si va después. Una clase que la implementa tiene un orden natural, y cualquier método que ordene sabe usarlo. Song.java es un record (semana 3) con los mismos componentes que el de la semana 4 (título, artista y duración en segundos), y se ordena por duración:

public record Song(String title, String artist, int seconds) implements Comparable<Song> {
    // ... constructor compacto que rechaza duraciones no positivas ...

    @Override
    public int compareTo(Song other) {
        return Integer.compare(seconds, other.seconds);
    }
}
List<Song> sorted = new ArrayList<>(List.of(a, b, c));
Collections.sort(sorted);
a.compareTo(b) = 1
ordenadas: [Song[title=Valicha, artist=Grupo Garúa, seconds=180], Song[title=Contigo Perú, artist=Trío Malecón, seconds=215], Song[title=El cóndor pasa, artist=Los Rieleros, seconds=260]]

Collections.sort no sabe nada de canciones: solo sabe que recibe una lista de cosas Comparable y llama a compareTo. El <Song> dice con qué se compara una canción (con otra canción); los genéricos se estudian en la semana 10, y el orden y los comparadores, en la 11.

Error común. Escribir return seconds - other.seconds;. Funciona con números pequeños, pero con valores grandes la resta hace overflow (semana 1) y el signo sale al revés. Integer.compare no tiene ese problema.

¿Interfaz o clase abstracta?

Las dos definen un tipo con métodos abstractos que otras clases completan. La diferencia está en lo que cada una puede llevar y en lo que dice del diseño:

Interfaz Clase abstracta
Campos de instancia (estado) no sí
Constructores no sí
Métodos con cuerpo default, static y private cualquiera
Cuántas puede tener una clase varias (implements) una (extends)
Qué expresa una capacidad (“se puede pagar”) un parentesco (“es un empleado”)

Regla práctica: si lo que quieren compartir es una capacidad que clases distintas pueden tener, interfaz. Si quieren compartir estado y código entre parientes cercanos (todos los empleados tienen nombre y boleta), clase abstracta. Y no es una elección exclusiva: Employee es una clase abstracta que implementa una interfaz, una combinación muy común.

Error común. Crear una interfaz por cada clase “por si acaso”: ProductService con una sola implementación ProductServiceImpl que nadie más va a escribir. Duplica cada firma en dos archivos y no gana nada. Una interfaz se justifica cuando hay, o habrá con certeza, más de una implementación; o cuando una prueba necesita reemplazar la real por un test double, como veremos al final del capítulo.

Composición frente a herencia

Tener en lugar de ser

La semana 4 escribimos una Playlist que guardaba sus canciones en un ArrayList<Song> privado. Eso es composición: un objeto tiene a otro como campo y le delega (delegation) parte del trabajo. Aquí va una versión con un invariante: la duración total nunca pasa de un máximo. Playlist.java:

public class Playlist {
    private final int maxSeconds;
    private final ArrayList<Song> songs = new ArrayList<>();
    private int totalSeconds = 0;

    public boolean add(Song song) {
        if (totalSeconds + song.seconds() > maxSeconds) {
            return false;
        }
        songs.add(song);                // delegación
        totalSeconds += song.seconds();
        return true;
    }

    public int size() {
        return songs.size();            // delegación
    }

    public List<Song> songs() {
        return List.copyOf(songs);      // copia de solo lectura
    }
    // ... constructor y totalSeconds() ...
}

Una idea tentadora es ahorrarse el campo y las delegaciones: “una lista de reproducción es una lista de canciones”, así que class InheritedPlaylist extends ArrayList<Song>. Hacemos override de add(Song) para validar la duración y listo. InheritedPlaylist.java:

@SuppressWarnings("serial")     // ArrayList es Serializable; aquí no serializamos
public class InheritedPlaylist extends ArrayList<Song> {
    private final int maxSeconds;

    @Override
    public boolean add(Song song) {
        if (totalSeconds() + song.seconds() > maxSeconds) {
            return false;
        }
        return super.add(song);
    }
    // ... constructor y totalSeconds(), que recorre la lista ...
}

(Sin la anotación, -Xlint:all avisa [serial] serializable class InheritedPlaylist has no definition of serialVersionUID: al heredar de ArrayList, la clase hereda también la promesa de poder guardarse como bytes, algo que no pensábamos hacer. Es la primera pista de que heredamos más de lo que queríamos.)

El problema es lo que viene con extends. ArrayList tiene 38 nombres de métodos públicos, sin contar los de Object, y la subclase los hereda todos: add(int, Song), addAll, set, clear, remove… Ninguno pasa por nuestro add(Song). PlaylistDemo.java crea las dos versiones con un máximo de 600 segundos:

IO.println("composición: " + mine.add(a) + " " + mine.add(b) + " " + mine.add(c)
        + " -> " + mine.totalSeconds() + " s");
IO.println("herencia:    " + bad.add(a) + " " + bad.add(b) + " " + bad.add(c)
        + " -> " + bad.totalSeconds() + " s");
bad.add(0, c);                       // heredado: no pasa por el add que valida
bad.addAll(List.of(c, c));           // heredado
IO.println("después de add(0, c) y addAll: " + bad.totalSeconds() + " s de 600");
composición: true true false -> 395 s
herencia:    true true false -> 395 s
después de add(0, c) y addAll: 1175 s de 600

Las dos rechazan la tercera canción cuando se usa add(Song). Pero la versión heredada tiene otras puertas, y por ellas el invariante se rompe sin que la clase se entere.

A la izquierda, ArrayList<Song> con una flecha de herencia hacia InheritedPlaylist, cuya interfaz pública tiene add(Song), que valida, y además add(int, Song), addAll, set, clear, remove y unos 30 métodos más heredados, en rojo: cualquiera de ellos rompe el máximo. A la derecha, Playlist con los campos privados songs y totalSeconds y solo cuatro métodos públicos (add, totalSeconds, size, songs), unida con un rombo de composición a un ArrayList<Song> privado al que delegaA la izquierda, ArrayList<Song> con una flecha de herencia hacia InheritedPlaylist, cuya interfaz pública tiene add(Song), que valida, y además add(int, Song), addAll, set, clear, remove y unos 30 métodos más heredados, en rojo: cualquiera de ellos rompe el máximo. A la derecha, Playlist con los campos privados songs y totalSeconds y solo cuatro métodos públicos (add, totalSeconds, size, songs), unida con un rombo de composición a un ArrayList<Song> privado al que delega
Con herencia, la interfaz pública de la subclase la decide la superclase. Con composición, la decide la clase: el ArrayList queda adentro, y solo se ofrece lo que respeta el invariante.

Idea clave. Con herencia, la subclase es también todo lo que es la superclase, incluidos los métodos que no le convienen. Con composición, la clase elige qué ofrecer, y el objeto que tiene adentro queda encapsulado. El precio de la composición son unas cuantas líneas de delegación (size() llama a songs.size()); casi siempre vale la pena.

La superclase frágil

Hay un problema más sutil. Una subclase depende no solo de qué hace la superclase, sino de cómo lo hace por dentro. Supongamos que queremos un conjunto que cuente cuántos elementos se intentaron agregar, para una estadística. Por herencia: CountingSet.java.

@SuppressWarnings("serial")
public class CountingSet extends HashSet<String> {
    private int added = 0;

    @Override
    public boolean add(String s) {
        added++;
        return super.add(s);
    }

    @Override
    public boolean addAll(Collection<? extends String> c) {
        added += c.size();
        return super.addAll(c);
    }

    public int added() {
        return added;
    }
}

Se ve correcto: cada método cuenta lo suyo y delega en la superclase. (El <? extends String> es la firma que exige HashSet; los wildcards son tema de la semana 10.) Pero FragileDemo.java dice otra cosa:

CountingSet inherited = new CountingSet();
inherited.addAll(List.of("arroz", "azúcar", "aceite"));
IO.println("herencia:    added() = " + inherited.added());
$ java -cp build FragileDemo
herencia:    added() = 6
composición: added() = 3

¿Seis? HashSet hereda addAll de AbstractCollection, y esa implementación recorre la colección y llama a add(e) por cada elemento. Ese add es this.add, y this es un CountingSet: por el dynamic dispatch de la semana 6, se ejecuta nuestro add, que vuelve a contar.

Animación: diagrama de secuencia con tres columnas: cliente, CountingSet (subclase, nuestro código) y HashSet (superclase, código de la API). El cliente llama a addAll con 3 elementos y el contador added pasa a 3. CountingSet llama a super.addAll(c); HashSet recorre la colección con this.add(e). Cada this.add vuelve a CountingSet, que hace added++, y luego super.add guarda el elemento. Al final added vale 6, pero se agregaron 3 elementosAnimación: diagrama de secuencia con tres columnas: cliente, CountingSet (subclase, nuestro código) y HashSet (superclase, código de la API). El cliente llama a addAll con 3 elementos y el contador added pasa a 3. CountingSet llama a super.addAll(c); HashSet recorre la colección con this.add(e). Cada this.add vuelve a CountingSet, que hace added++, y luego super.add guarda el elemento. Al final added vale 6, pero se agregaron 3 elementos
El dynamic dispatch lleva cada this.add de la superclase de vuelta a la subclase. Nada de esto se ve en el código de CountingSet: depende de cómo está escrito HashSet por dentro.

Lo grave no es el error en sí, sino dónde está escondido. La documentación de addAll no promete que llame a add; es un detalle de implementación. Si una versión futura de la biblioteca lo cambiara, CountingSet contaría bien o mal sin que nadie hubiera tocado su código. A esto se le llama el problema de la superclase frágil (fragile base class): la subclase se rompe por cambios, o por detalles, que no le pertenecen.

La versión por composición, DelegatingCountingSet.java, tiene un Set<String> como campo y le delega:

public class DelegatingCountingSet {
    private final Set<String> items = new HashSet<>();
    private int added = 0;

    public boolean add(String s) {
        added++;
        return items.add(s);
    }

    public boolean addAll(Collection<String> c) {
        added += c.size();
        return items.addAll(c);      // items.add no es nuestro add: no cuenta doble
    }
    // ... contains, size, added ...
}

Cuando items.addAll llama a add por dentro, llama al add del HashSet, no al nuestro, porque items es un HashSet y no un DelegatingCountingSet. La cuenta da 3, y seguirá dando 3 aunque HashSet cambie su implementación.

En Java. Este ejemplo es una versión del que usa J. Bloch en Effective Java (ítem 18, Favor composition over inheritance). La conclusión de Bloch: heredar de una clase pensada para eso, y documentada para eso, está bien; heredar de una clase concreta cualquiera solo para reutilizar su código es frágil. Por eso conviene marcar final las clases que no se diseñaron para ser extendidas (semana 5).

El principio de sustitución de Liskov

“Es un”, también en el comportamiento

La regla que venimos usando para la herencia es “B es un A”. El ejemplo de la lista de reproducción muestra que no basta: una lista de reproducción “es una” lista en el lenguaje cotidiano, pero no se comporta como un ArrayList (no acepta cualquier canción). La pregunta correcta es otra: ¿puedo usar un B en cualquier lugar donde el programa espera un A, sin que nada se rompa?

Retrato de Barbara Liskov, de cabello corto y rizado y blusa con flores, mirando a la cámara
Barbara Liskov, profesora del MIT y premio Turing 2008, en 2010. Foto: Kenneth C. Zirkel, CC BY-SA 3.0, Wikimedia Commons.

Barbara Liskov planteó esa pregunta en una conferencia de 1987, y en 1994 la formalizó con Jeannette Wing. Hoy se conoce como el principio de sustitución de Liskov (Liskov substitution principle, LSP): todo lo que se puede demostrar de los objetos del supertipo tiene que valer también para los objetos del subtipo. Dicho en términos de programa: un cliente escrito para el supertipo tiene que funcionar, sin cambios y sin sorpresas, con un objeto de cualquier subtipo. Es lo que la semana 6 dio por hecho al recorrer una ArrayList<Notification>.

En la práctica, el principio se revisa con tres preguntas sobre cada override:

  • Precondiciones: no se refuerzan. El subtipo no puede exigir más que el supertipo antes de aceptar una llamada. Si BankAccount.withdraw acepta cualquier monto que haya en el saldo, una subclase que además exija “no más de tres retiros al mes” (la SavingsAccount de la semana 5) rechaza llamadas que el cliente tenía derecho a hacer. Si los clientes de BankAccount no saben de ese límite, sustituirla por una SavingsAccount los sorprende.
  • Postcondiciones: no se debilitan. El subtipo tiene que garantizar al menos lo que garantiza el supertipo al terminar.
  • Invariantes: se mantienen. Lo que siempre vale para un objeto del supertipo vale para uno del subtipo.

El cuadrado que no era un rectángulo

El ejemplo clásico. En geometría, un cuadrado es un rectángulo. En código: Rectangle.java tiene ancho y alto que se cambian por separado, y cada setter promete dejar el otro lado como estaba:

public class Rectangle {
    private int width;
    private int height;

    /** Postcondición: cambia el ancho y deja el alto como estaba. */
    public void setWidth(int width) {
        this.width = width;
    }

    /** Postcondición: cambia el alto y deja el ancho como estaba. */
    public void setHeight(int height) {
        this.height = height;
    }

    public int area() {
        return width * height;
    }
    // ... constructor, width(), height() ...
}

Un Square que la extienda tiene que mantener su propio invariante (los dos lados iguales), así que cada setter cambia los dos (Square.java):

public class Square extends Rectangle {

    public Square(int side) {
        super(side, side);
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

Todo compila y cada clase, por separado, es correcta. Ahora un cliente que solo conoce Rectangle (LiskovDemo.java):

static void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    IO.println(r.getClass().getSimpleName() + ": área " + r.area() + " (se esperaba 20)");
}
$ java -cp build LiskovDemo
Rectangle: área 20 (se esperaba 20)
Square: área 16 (se esperaba 20)
Animación: arriba, el método resize(Rectangle r) con r.setWidth(5), r.setHeight(4) y el comentario espera r.area() == 20. Abajo, dos figuras dibujadas a escala. A la izquierda, new Rectangle(2, 3) pasa a 5 por 3 y luego a 5 por 4: área 20, lo esperado. A la derecha, new Square(2) pasa a 5 por 5 con setWidth y luego a 4 por 4 con setHeight: área 16, se esperaba 20Animación: arriba, el método resize(Rectangle r) con r.setWidth(5), r.setHeight(4) y el comentario espera r.area() == 20. Abajo, dos figuras dibujadas a escala. A la izquierda, new Rectangle(2, 3) pasa a 5 por 3 y luego a 5 por 4: área 20, lo esperado. A la derecha, new Square(2) pasa a 5 por 5 con setWidth y luego a 4 por 4 con setHeight: área 16, se esperaba 20
El mismo cliente con los dos objetos. setHeight(4) en el cuadrado también cambia el ancho: rompe la postcondición de Rectangle.setHeight.

Square.setHeight debilitó la postcondición de Rectangle.setHeight (“el ancho no cambia”). El cliente no hizo nada raro: confió en lo que Rectangle promete. La prueba de RectangleTest.java fija ese comportamiento con la misma función aplicada a los dos objetos:

private static int areaAfterResize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    return r.area();
}

@Test
void rectangleKeepsSidesIndependent() {
    Rectangle r = new Rectangle(2, 3);
    assertEquals(20, areaAfterResize(r));
}

@Test
void squareBreaksTheExpectation() {
    // Documenta la violación de Liskov: la misma prueba, con un Square, da 16.
    Rectangle s = new Square(2);
    assertNotEquals(20, areaAfterResize(s));
}

Esa es una buena forma de buscar violaciones de Liskov: escribir las pruebas del supertipo como métodos que reciben un objeto del supertipo, y pasarles objetos de cada subtipo. Si alguna falla, el subtipo no es un sustituto.

¿Cómo se arregla? No hay un Square extends Rectangle correcto mientras Rectangle tenga setters independientes. Opciones: que las figuras sean inmutables (sin setters, la postcondición problemática desaparece y un cuadrado de lado 2 sí es un rectángulo de 2 por 2), o que Square y Rectangle no hereden uno del otro y compartan, si hace falta, una interfaz Shape con area().

Error común. Decidir la herencia por la frase “B es un A” del diccionario. La pregunta es si B se comporta como A en todo lo que A promete. Un pingüino es un ave, pero si Bird tiene fly(), Penguin no es un buen subtipo.

java.util.Stack: un error en la biblioteca estándar

La propia API de Java tiene un caso famoso. java.util.Stack (de Java 1.0) extiende Vector, una lista. Un stack debería permitir solo push, pop y peek, pero Stack hereda todos los métodos de Vector (StackDemo.java):

Stack<String> pages = new Stack<>();
pages.push("inicio");
pages.push("catálogo");
pages.push("carrito");
IO.println("stack: " + pages + ", peek = " + pages.peek());

pages.add(0, "intrusa");        // heredado de Vector: inserta en el fondo
String middle = pages.get(2);   // heredado: lee el medio sin sacar nada
pages.remove(1);                // heredado: borra del medio
IO.println("después de add(0, ...), get(2) y remove(1): " + pages + ", get(2) = " + middle);
$ java -cp build StackDemo
stack: [inicio, catálogo, carrito], peek = carrito
después de add(0, ...), get(2) y remove(1): [intrusa, catálogo, carrito], get(2) = catálogo

push, pop y peek sí respetan el orden LIFO (el último que entra es el primero que sale). El problema es todo lo demás: la API heredada de Vector le permite a cualquier cliente insertar, leer o borrar en cualquier posición, y con eso romper la abstracción del stack sin que la clase pueda impedirlo. Es la InheritedPlaylist de arriba, en la biblioteca estándar. La documentación de Stack recomienda hoy usar la interfaz Deque y su implementación ArrayDeque en su lugar. Y como Stack está publicada desde 1996 y hay millones de líneas que la usan, ya no se puede corregir: con la herencia, un error de diseño en una clase pública queda para siempre.

ColorPoint otra vez

La semana 5 dejó un problema abierto: un ColorPoint extends Point que compara con instanceof rompe la simetría de equals (p.equals(cp) es verdadero y cp.equals(p) es falso), y comparar con getClass() arregla la simetría pero hace que un ColorPoint ya no sea sustituible por un Point en ningún conjunto ni lista. Con la herencia no había solución buena.

Con composición sí la hay: un punto con color tiene un punto (ColorPoint.java):

public final class ColorPoint {
    private final Point point;
    private final String color;

    public ColorPoint(int x, int y, String color) {
        this.point = new Point(x, y);
        this.color = color;
    }

    /** Vista del objeto como Point, para usarlo donde se espera un punto. */
    public Point asPoint() {
        return point;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof ColorPoint cp && point.equals(cp.point) && color.equals(cp.color);
    }

    @Override
    public int hashCode() {
        return Objects.hash(point, color);
    }
    // ... color(), toString() ...
}
$ java -cp build ColorPointDemo
p.equals(red)   = false
red.equals(p)   = false
p.equals(red.asPoint()) = true
red.equals(blue) = false

Un Point y un ColorPoint ya no son del mismo tipo, así que nunca son iguales, en ninguna de las dos direcciones: la simetría vuelve. Y quien necesite tratar un punto con color como un punto lo pide explícitamente con asPoint(). Las dos clases son final: nadie va a heredar de ellas y reabrir el problema.

Idea clave. Usen herencia solo si la relación “es un” se cumple también en el comportamiento: cada método del supertipo, con sus precondiciones, postcondiciones e invariantes, tiene que seguir valiendo en el subtipo. Si no, composición, y si los clientes necesitan tratar a los dos por igual, una interfaz común. Fowler llama a este refactoring Replace Superclass with Delegate: la subclase pasa a tener un campo del tipo de la que era su superclase y a delegarle lo que necesite.

Test doubles

Una dependencia difícil de controlar

Una interfaz tiene un uso más que aparece en cuanto se escriben pruebas. En la Bodega queremos un monitor que avise cuando un producto queda con poco stock. El aviso podría ser un correo, y el stock sale del inventario. Si StockMonitor creara por su cuenta un objeto que envía correos de verdad, cada prueba mandaría correos, y para probar el caso “stock bajo” habría que preparar un inventario completo con ese producto casi agotado.

La salida es que StockMonitor no dependa de clases concretas sino de dos interfaces, y que las reciba por el constructor (StockMonitor.java):

public interface StockLevels {
    int unitsOf(String product);
}

public interface Notifier {
    void send(String message);
}

public class StockMonitor {
    private final StockLevels levels;
    private final Notifier notifier;
    private final int threshold;

    public StockMonitor(StockLevels levels, Notifier notifier, int threshold) {
        this.levels = levels;
        this.notifier = notifier;
        this.threshold = threshold;
    }

    /** Revisa un producto; avisa si le quedan menos de threshold unidades. */
    public void check(String product) {
        int units = levels.unitsOf(product);
        if (units < threshold) {
            notifier.send("Stock bajo: " + product + " (" + units + ")");
        }
    }
}

(En el código, cada interfaz está en su propio archivo.) En producción, el programa le pasa el inventario real y un notificador que envía el aviso, como ConsoleNotifier, que lo imprime. En una prueba, le pasamos test doubles: objetos que implementan la misma interfaz y se comportan como la prueba necesita. El nombre viene del cine: el doble que reemplaza al actor en las escenas peligrosas.

Stub y spy

Fowler distingue varios tipos de test doubles. Esta semana usamos dos, escritos a mano como clases normales:

  • Un stub devuelve respuestas fijas. FixedStockLevels responde siempre el mismo número de unidades, sin inventario de por medio:

    public class FixedStockLevels implements StockLevels {
        private final int units;
    
        public FixedStockLevels(int units) {
            this.units = units;
        }
    
        @Override
        public int unitsOf(String product) {
            return units;
        }
    }
  • Un spy registra las llamadas que recibe, para que la prueba pregunte después qué pasó. SpyNotifier no envía nada: anota cada mensaje.

    public class SpyNotifier implements Notifier {
        private final List<String> sent = new ArrayList<>();
    
        @Override
        public void send(String message) {
            sent.add(message);
        }
    
        public List<String> sent() {
            return List.copyOf(sent);
        }
    }

Con los dos, la prueba (StockMonitorTest.java) prepara exactamente la situación que quiere y verifica qué aviso salió:

@Test
void notifiesWhenStockIsBelowThreshold() {
    SpyNotifier spy = new SpyNotifier();
    StockMonitor monitor = new StockMonitor(new FixedStockLevels(2), spy, 5);

    monitor.check("arroz");

    assertEquals(List.of("Stock bajo: arroz (2)"), spy.sent());
}

@Test
void staysQuietWhenStockIsEnough() {
    SpyNotifier spy = new SpyNotifier();
    StockMonitor monitor = new StockMonitor(new FixedStockLevels(5), spy, 5);

    monitor.check("arroz");

    assertTrue(spy.sent().isEmpty());
}
Diagrama de clases. StockMonitor, con check(product), usa dos interfaces: StockLevels con unitsOf(product) y Notifier con send(message). Debajo, cuatro clases que las implementan: inventario real y ConsoleNotifier, en gris, para producción; FixedStockLevels (stub: siempre 2) y SpyNotifier (spy: anota), en verde, para la prueba. Del spy cuelga la lista que anotó: Stock bajo: arroz (2)Diagrama de clases. StockMonitor, con check(product), usa dos interfaces: StockLevels con unitsOf(product) y Notifier con send(message). Debajo, cuatro clases que las implementan: inventario real y ConsoleNotifier, en gris, para producción; FixedStockLevels (stub: siempre 2) y SpyNotifier (spy: anota), en verde, para la prueba. Del spy cuelga la lista que anotó: Stock bajo: arroz (2)
StockMonitor solo conoce las interfaces. En producción recibe las implementaciones reales; en la prueba, un stub y un spy. El código de StockMonitor es el mismo en los dos casos.

StockMonitor no sabe ni le importa si habla con el inventario real o con un stub: solo conoce StockLevels. Es el polimorfismo de las semanas 6 y 7 usado para probar. Fíjense también en los valores de borde: con 2 unidades y umbral 5 hay aviso; con exactamente 5, no (la condición es units < threshold). Una buena prueba revisa el borde.

Idea clave. Una clase que recibe sus dependencias como interfaces (por el constructor) se puede probar sola, reemplazando cada dependencia por un test double. Una clase que crea dentro sus dependencias concretas (new EmailSender() en el constructor, o una llamada directa a la hora del sistema) arrastra esas dependencias a cada prueba.

En Java. Hay bibliotecas, como Mockito, que generan test doubles en tiempo de ejecución sin escribir clases. En este curso los escribimos a mano: así se ve que un test double es solo otra implementación de una interfaz, y que si la dependencia fuera una clase final sin interfaz, no habría dónde enchufarlo.

El código de la semana y make test

code/week-07 tiene los ejemplos del capítulo y el código inicial del laboratorio (Greeter, que depende de la hora real, y UndoHistory, que hereda de Stack). Dentro del contenedor:

$ cd code/week-07
$ make test
...
== FragileDemo
herencia:    added() = 6
composición: added() = 3
== LiskovDemo
Rectangle: área 20 (se esperaba 20)
Square: área 16 (se esperaba 20)
...
│  ├─ StockMonitorTest ✔
│  │  ├─ staysQuietWhenStockIsEnough() ✔
│  │  └─ notifiesWhenStockIsBelowThreshold() ✔
│  ├─ RectangleTest ✔
│  │  ├─ squareBreaksTheExpectation() ✔
│  │  └─ rectangleKeepsSidesIndependent() ✔
...
[        20 tests successful      ]
[         0 tests failed          ]

Algunas pruebas, como squareBreaksTheExpectation o inheritedVersionCountsTwiceWithAddAll, pasan porque documentan un comportamiento roto: dicen con una assertion qué hace hoy el mal diseño, y su nombre y su comentario lo dicen. Así el problema queda a la vista en la salida de las pruebas.

Resumen

  • Una interfaz es un contrato: métodos públicos y abstractos, sin estado. Una clase la cumple con implements, y puede implementar varias aunque extienda una sola clase: el estado se hereda de una sola superclase.
  • Una interfaz se usa como tipo de variables, parámetros y listas. El dynamic dispatch funciona igual (invokeinterface).
  • Los métodos default dan comportamiento escrito en términos de los métodos abstractos y permiten agregar métodos sin romper a quienes ya implementan la interfaz; los static son utilidades del tipo.
  • Comparable<T> y compareTo dan un orden natural que la API sabe usar.
  • Interfaz para una capacidad que tienen clases distintas; clase abstracta para compartir estado y código entre parientes.
  • Composición: tener un objeto y delegarle. La clase decide su interfaz pública y protege sus invariantes. Con herencia, la subclase hereda todo lo público de la superclase y depende de sus detalles internos: la superclase frágil.
  • Principio de Liskov: un subtipo tiene que poder reemplazar al supertipo sin sorpresas: precondiciones que no se refuerzan, postcondiciones que no se debilitan, invariantes que se mantienen. Square extends Rectangle lo viola. java.util.Stack muestra otro riesgo: hereda de Vector una API que permite romper su abstracción.
  • Herencia solo si “es un” se cumple en el comportamiento; si no, composición más una interfaz común.
  • Una clase que recibe sus dependencias como interfaces se prueba con test doubles: un stub devuelve respuestas fijas; un spy registra lo que recibió.

Ejercicios

  1. Agreguen a la Bodega una clase Tax (un impuesto con un nombre y un monto fijo) que implemente Payable, y agréguenla a la lista de PayableDemo. ¿Qué archivos tuvieron que cambiar? ¿Cambió Payable.total?
  2. Escriban una interfaz Discountable con un método long discount() y hagan que Invoice implemente Payable y Discountable a la vez. Luego agreguen a las dos interfaces un método default String describe() con cuerpos distintos y compilen. Copien el mensaje de error, explíquenlo y resuélvanlo con Payable.super.describe().
  3. Sin ejecutar, digan cuáles de estas líneas compilan, con Payable p = new HourlyEmployee("bob", 1_250, 80);: p.amountDue(), p.formattedAmount(), p.monthlyPay(), p.payslip(), Payable.total(List.of(p)), p.total(List.of(p)). Para las que no compilan, ¿qué haría falta?
  4. Escriban compareTo para que Invoice implemente Comparable<Invoice> ordenando de mayor a menor monto. Prueben con Collections.sort sobre una lista de tres facturas.
  5. Al Playlist por composición, agréguenle remove(Song) y una prueba que verifique que, después de quitar una canción, cabe una que antes no cabía. ¿De qué métodos tendrían que hacer override en InheritedPlaylist para tener la misma garantía? Hagan una lista de los métodos.
  6. En CountingSet, borren el override de addAll y corran FragileDemo. ¿Ahora cuenta bien? ¿Por qué esa “solución” sigue siendo frágil?
  7. Escriban ImmutableRectangle y ImmutableSquare sin setters (un método withWidth(int) que devuelve una figura nueva). ¿Puede ahora el cuadrado heredar del rectángulo sin violar el principio de Liskov? ¿Qué debería devolver withWidth en el cuadrado?
  8. Para cada caso, digan si la herencia cumple el principio de Liskov y justifiquen con una precondición, postcondición o invariante concreta: (a) ReadOnlyList extends ArrayList con add que lanza UnsupportedOperationException; (b) SavingsAccount extends BankAccount de la semana 5, con su límite de retiros; (c) HourlyEmployee extends Employee.
  9. Escriban una prueba que use un stub de StockLevels que devuelva 0 y un spy, y verifique el mensaje del aviso. Después escriban un tercer test double, CountingStockLevels, que además de responder cuente cuántas veces se le preguntó, y verifiquen que check consulta el stock una sola vez.
  10. En StockMonitor, cambien units < threshold por units <= threshold. ¿Qué prueba falla? ¿Qué decía esa prueba sobre el comportamiento?
  11. Busquen en la API de Java 25 la lista de interfaces que implementa ArrayList (“All Implemented Interfaces”). Elijan dos que no conozcan y expliquen, con una frase cada una, qué capacidad promete.
  12. Lean la sección 2 de Liskov y Wing (1994). Den con sus palabras el ejemplo de motivación que usan y relaciónenlo con java.util.Stack.