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á encode/week-07y se prueba conmake testdentro 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,ColorPointotra 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().
En UML, implements se dibuja como la flecha de herencia pero punteada, y la
interfaz lleva el estereotipo «interface».
Error común. Olvidar
publical 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 errorEl método de la interfaz es público, y quien lo implementa no puede ofrecerlo con menos acceso: alguien que tiene un
Payableen 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 conPayable.super.formattedAmount(). El diamante vuelve a aparecer, pero solo con métodos, y Java obliga a resolverlo a mano.
Error común. Usar
defaultpara 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étododefaultnecesita guardar algo, lo que hace falta es una clase. Y undefaultque 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.compareno 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”:
ProductServicecon una sola implementaciónProductServiceImplque 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.
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 asongs.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.


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
finallas 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?

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.withdrawacepta cualquier monto que haya en el saldo, una subclase que además exija “no más de tres retiros al mes” (laSavingsAccountde la semana 5) rechaza llamadas que el cliente tenía derecho a hacer. Si los clientes deBankAccountno saben de ese límite, sustituirla por unaSavingsAccountlos 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)


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
Birdtienefly(),Penguinno 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.
FixedStockLevelsresponde 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ó.
SpyNotifierno 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());
}
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
finalsin 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
defaultdan comportamiento escrito en términos de los métodos abstractos y permiten agregar métodos sin romper a quienes ya implementan la interfaz; losstaticson utilidades del tipo. Comparable<T>ycompareTodan 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 Rectanglelo viola.java.util.Stackmuestra otro riesgo: hereda deVectoruna 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
- Agreguen a la Bodega una clase
Tax(un impuesto con un nombre y un monto fijo) que implementePayable, y agréguenla a la lista dePayableDemo. ¿Qué archivos tuvieron que cambiar? ¿CambióPayable.total? - Escriban una interfaz
Discountablecon un métodolong discount()y hagan queInvoiceimplementePayableyDiscountablea la vez. Luego agreguen a las dos interfaces un métododefault String describe()con cuerpos distintos y compilen. Copien el mensaje de error, explíquenlo y resuélvanlo conPayable.super.describe(). - 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? - Escriban
compareTopara queInvoiceimplementeComparable<Invoice>ordenando de mayor a menor monto. Prueben conCollections.sortsobre una lista de tres facturas. - Al
Playlistpor composición, agréguenleremove(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 enInheritedPlaylistpara tener la misma garantía? Hagan una lista de los métodos. - En
CountingSet, borren el override deaddAlly corranFragileDemo. ¿Ahora cuenta bien? ¿Por qué esa “solución” sigue siendo frágil? - Escriban
ImmutableRectangleyImmutableSquaresin setters (un métodowithWidth(int)que devuelve una figura nueva). ¿Puede ahora el cuadrado heredar del rectángulo sin violar el principio de Liskov? ¿Qué debería devolverwithWidthen el cuadrado? - 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 ArrayListconaddque lanzaUnsupportedOperationException; (b)SavingsAccount extends BankAccountde la semana 5, con su límite de retiros; (c)HourlyEmployee extends Employee. - Escriban una prueba que use un stub de
StockLevelsque 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 quecheckconsulta el stock una sola vez. - En
StockMonitor, cambienunits < thresholdporunits <= threshold. ¿Qué prueba falla? ¿Qué decía esa prueba sobre el comportamiento? - 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. - 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.