Una interfaz dice qué puede hacer un tipo, la herencia le da a una clase todo lo que hace su padre, y la composición envuelve un objeto. Aprende métodos default, super, clases abstractas y la trampa de la clase base frágil ejecutando programas pequeños.
Java te da tres formas de conectar tipos. Una interfaz es un contrato que cualquier clase puede firmar. La herencia, con extends, le da a una clase todo lo que tiene su padre. La composición guarda otro objeto en un campo y lo llama. Elegir la equivocada es como una subclase termina rota por código que nunca vio.
Este post cubre las interfaces, los métodos default, estáticos y privados de una interfaz, extends y super, @Override, protected y final, las clases abstractas, el problema de la clase base frágil, la composición con reenvío y decoradores, y los casts. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.
Las interfaces son contratos
Una interfaz lista los métodos que un tipo promete tener, sin decir cómo funcionan. Cualquier clase o record puede implementarla, y el código que solo conoce la interfaz puede usarlos a todos. Aquí hay tres figuras que no tienen nada más en común:
interface Shape {
double area();
String name();
}
record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public String name() {
return "circle";
}
}
record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
@Override
public String name() {
return "rectangle";
}
}
class Triangle implements Shape {
private final double base;
private final double height;
Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return base * height / 2;
}
@Override
public String name() {
return "triangle";
}
}
double totalArea(List<Shape> shapes) {
double total = 0;
for (Shape s : shapes) {
total += s.area();
}
return total;
}
void main() {
List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3), new Triangle(4, 5));
for (Shape s : shapes) {
IO.println(String.format("%-9s %6.2f", s.name(), s.area()));
}
IO.println(String.format("total %6.2f", totalArea(shapes)));
}
Imprime:
circle 3.14
rectangle 6.00
triangle 10.00
total 19.14
totalArea nunca menciona círculos ni triángulos. Le pide su área a cada Shape, y cada objeto responde con su propio código. Eso es polimorfismo: una sola llamada, s.area(), y el objeto decide qué método se ejecuta. Agrega un Hexagon mañana y totalArea funciona sin ningún cambio.
Los métodos de una interfaz son public aunque no lo escribas. Por eso cada implementación dice public double area(). Si lo quitas, javac lo rechaza con area() in Circle cannot implement area() in Shape.
Los métodos default y estáticos dejan crecer una interfaz
Un método default es un método de interfaz con cuerpo. Cada clase que implementa la interfaz lo recibe gratis, y cualquiera de ellas puede sobrescribirlo. Cuando muchas clases ya implementan una interfaz, agregar un método abstracto las rompe a todas. Un método default no.
interface Shape {
double area();
default String describe() {
return String.format("a shape with area %.2f", area());
}
static Shape square(double side) {
return new Rectangle(side, side);
}
}
record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
@Override
public String describe() {
return "a " + width + " by " + height + " rectangle";
}
}
void main() {
List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3), Shape.square(4));
for (Shape s : shapes) {
IO.println(s.describe());
}
}
Imprime:
a shape with area 3.14
a 2.0 by 3.0 rectangle
a 4.0 by 4.0 rectangle
Circle nunca escribió describe, así que recibió el default. El default llama a area(), y esa llamada llega al area propio del círculo. Rectangle sobrescribió describe con algo mejor. El JDK usó esto para agregar stream() a Collection sin romper todas las clases de colecciones.
square es un método estático de interfaz. Lo llamas sobre la interfaz, Shape.square(4), y no se hereda: Rectangle.square(4) no compila. Es un buen lugar para los métodos factory que pertenecen al contrato, como List.of.
Dos defaults con el mismo nombre no compilan
Una clase puede implementar dos interfaces, y a veces las dos tienen un método default con la misma firma. Java no va a elegir uno por ti:
interface Camera {
default String describe() {
return "takes photos";
}
}
interface Phone {
default String describe() {
return "makes calls";
}
}
class SmartPhone implements Camera, Phone {
}
void main() {
IO.println(new SmartPhone().describe());
}
La compilación falla con:
Main.java:13: error: types Camera and Phone are incompatible;
class SmartPhone implements Camera, Phone {
^
La siguiente línea de la salida de javac lo explica: class Main.SmartPhone inherits unrelated defaults for describe() from types Camera and Phone. El Main. aparece porque un archivo fuente compacto anida tus clases dentro de una clase oculta Main, como explica la parte sobre clases.
Esto se llama el problema del diamante, y Java te obliga a resolverlo a mano. Sobrescribe el método y llama a los defaults que quieras con Interface.super.method():
interface Camera {
default String describe() {
return "takes photos";
}
}
interface Phone {
default String describe() {
return "makes calls";
}
}
class SmartPhone implements Camera, Phone {
@Override
public String describe() {
return Phone.super.describe() + " and " + Camera.super.describe();
}
}
void main() {
IO.println(new SmartPhone().describe());
}
Imprime:
makes calls and takes photos
Phone.super.describe() significa “ejecuta el default que da Phone, sobre este objeto”. Puedes llamar a uno, a los dos o a ninguno, pero tienes que elegir.
Los métodos privados comparten código entre defaults
Cuando dos métodos default necesitan el mismo auxiliar, ese auxiliar puede ser un método private de la interfaz. No es parte del contrato, así que quienes implementan la interfaz no pueden verlo ni llamarlo:
interface Greeter {
String name();
default String hello() {
return wrap("Hello, " + name());
}
default String goodbye() {
return wrap("Goodbye, " + name());
}
private String wrap(String text) {
return "[" + text + "]";
}
}
record English(String name) implements Greeter {}
void main() {
var g = new English("Ana");
IO.println(g.hello());
IO.println(g.goodbye());
}
Imprime:
[Hello, Ana]
[Goodbye, Ana]
Los métodos privados de interfaz llegaron en Java 9. Lo verificamos: la misma interfaz compilada con javac --release 8 falla con private interface methods are not supported in -source 8. English no necesita cuerpo: el accessor name() del record cumple con el método de la interfaz.
Las interfaces de un solo método pueden ser lambdas
Una interfaz con exactamente un método abstracto se llama interfaz funcional, y Java te deja escribir una implementación de ella como lambda en vez de como clase. Runnable, Comparator y Function son interfaces de este tipo, y los métodos default no cuentan para ese “uno”. La anotación @FunctionalInterface le pide al compilador que verifique que una interfaz se mantenga así. La parte sobre lambdas y streams explica cómo escribirlas y usarlas.
Herencia con extends
Una clase que hace extends de otra hereda sus campos y métodos, y puede sobrescribir los métodos para cambiar lo que hacen. La clase que extiende es su superclase, o padre. Una clase solo puede extender una clase. Aquí hay una pequeña familia de animales, de tres niveles:
class Animal {
protected final String name;
Animal(String name) {
this.name = name;
}
String sound() {
return "...";
}
String describe() {
return name + " says " + sound();
}
}
class Dog extends Animal {
Dog(String name) {
super(name);
}
@Override
String sound() {
return "Woof";
}
}
class Puppy extends Dog {
Puppy(String name) {
super(name);
}
@Override
String sound() {
return super.sound().toLowerCase() + "?";
}
@Override
String describe() {
return super.describe() + " (a puppy called " + name + ")";
}
}
void main() {
List<Animal> animals = List.of(new Animal("Rock"), new Dog("Rex"), new Puppy("Bit"));
for (Animal a : animals) {
IO.println(a.describe());
}
}
Imprime:
Rock says ...
Rex says Woof
Bit says woof? (a puppy called Bit)
Pasaron muchas cosas en un programa corto:
super(name)llama al constructor del padre.Animalno tiene constructor sin argumentos, así queDogtiene que llamar a este. Cada constructor ejecuta el constructor de su padre antes de que sus propios campos estén listos.super.sound()llama a la versión del padre de un método, aunque esta clase lo sobrescriba.Puppytomó el"Woof"del perro y lo cambió.protectedennamedeja que las subclases lo lean, y así fue comoPuppy.describeusóname. También abre el campo a todas las clases del mismo paquete, y todas las clases de un archivo comparten paquete, así que un programa de un solo archivo no puede mostrar aprotectedrechazando a nadie.- Dynamic dispatch (despacho dinámico) es la línea que más importa.
describe()está escrito una sola vez, enAnimal, y llama asound(). Cuandodescribe()se ejecuta sobre unDog, esa llamada asound()va aDog.sound(). Java elige el método según la clase real del objeto en tiempo de ejecución, no según el tipo de la variable ni la clase donde se escribió la llamada.
Esa regla hace que la herencia sea poderosa, y también es lo que la hace peligrosa.
@Override atrapa el error de tipeo
@Override le dice al compilador que un método debe reemplazar a uno del padre. Es opcional, y si no lo pones se cuela un error de tipeo. Aquí Dog escribe mal sound:
class Animal {
String sound() {
return "...";
}
}
class Dog extends Animal {
String sonud() {
return "Woof";
}
}
void main() {
Animal rex = new Dog();
IO.println(rex.sound());
}
Imprime:
...
Eso compiló, incluso con javac -Xlint:all -Werror. sonud es un método totalmente nuevo que nadie llama, y el perro hace en silencio el sonido del padre. Ponle @Override:
class Animal {
String sound() {
return "...";
}
}
class Dog extends Animal {
@Override
String sonud() {
return "Woof";
}
}
void main() {
Animal rex = new Dog();
IO.println(rex.sound());
}
La compilación falla con:
Main.java:8: error: method does not override or implement a method from a supertype
@Override
^
También atrapa un tipo de parámetro equivocado, que produce una sobrecarga en vez de una sobrescritura.
final impide sobrescribir
Un método final no se puede sobrescribir, y una clase final no se puede extender. Usa final en un método cuando las reglas del padre tienen que cumplirse en todas las subclases:
class Account {
private int balance;
final void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("deposit must be positive");
}
balance += amount;
}
}
class SneakyAccount extends Account {
@Override
void deposit(int amount) {
IO.println("no checks here");
}
}
void main() {
new SneakyAccount().deposit(-50);
}
La compilación falla con:
Main.java:14: error: deposit(int) in Main.SneakyAccount cannot override deposit(int) in Main.Account
void deposit(int amount) {
^
javac agrega overridden method is final en la línea siguiente. En una clase entera, final cierra la puerta por completo. String es final, así que class LoudString extends String falla con cannot inherit from final String. Los records también son final. La parte sobre sealed types cubre el punto intermedio, donde una clase lista exactamente qué subclases permite.
Un constructor que llama a un método sobrescribible
El constructor del padre se ejecuta antes que el cuerpo del constructor del hijo, y el dynamic dispatch sigue funcionando dentro de él. Junta esos dos hechos y un padre puede llamar a un método del hijo antes de que el hijo haya asignado sus campos:
class Widget {
Widget() {
IO.println("Widget constructor calls render()");
render();
}
void render() {
IO.println("plain widget");
}
}
class Label extends Widget {
private final String text;
Label(String text) {
this.text = text;
}
@Override
void render() {
IO.println("label: " + text.toUpperCase());
}
}
void main() {
new Label("hello");
}
Imprime y se detiene:
Widget constructor calls render()
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "this.text" is null
text es final, y aquí todavía vale null. El primer paso del constructor de Label fue un super() invisible, que ejecutó Widget(), que llamó a render(), que llegó a Label.render(). this.text = text todavía no se había ejecutado. Un campo final se asigna una sola vez, pero puedes leerlo antes de que eso pase.
Java 25 te da una solución. Sus cuerpos de constructor flexibles permiten que un constructor asigne sus propios campos antes de llamar a super(), como muestra la parte sobre clases. Escribe this.text = text; y luego super(); en el constructor de Label, y el mismo programa imprime label: HELLO. La regla de siempre es más simple: no llames a métodos sobrescribibles desde un constructor.
Clases abstractas vs. interfaces
Una clase abstracta es una clase que no puedes crear directamente. Puede tener campos, constructores y métodos terminados, además de métodos abstract que cada subclase tiene que escribir. new Account() sobre la clase de abajo falla con Main.Account is abstract; cannot be instantiated.
abstract class Account {
private int balance;
private final List<String> history = new ArrayList<>();
abstract int fee(int amount);
final void withdraw(int amount) {
int total = amount + fee(amount);
balance -= total;
history.add("-" + total);
}
final void deposit(int amount) {
balance += amount;
history.add("+" + amount);
}
String statement() {
return getClass().getSimpleName() + " " + history + " balance " + balance;
}
}
class Checking extends Account {
@Override
int fee(int amount) {
return 1;
}
}
class Savings extends Account {
@Override
int fee(int amount) {
return amount / 10;
}
}
void main() {
List<Account> accounts = List.of(new Checking(), new Savings());
for (Account a : accounts) {
a.deposit(100);
a.withdraw(50);
IO.println(a.statement());
}
}
Imprime:
Checking [+100, -51] balance 49
Savings [+100, -55] balance 45
Account es dueña del saldo, del historial y de las reglas para cambiarlos. Sus métodos final fijan los pasos, y el único método abstract, fee, es el único hueco que llena cada subclase. Esa forma se llama el patrón template method (método plantilla).
Una interfaz no podría hacer esto, y la diferencia se reduce a tres cosas:
- Estado. Una clase abstracta puede tener campos de instancia y un constructor. Una interfaz no. Solo puede tener constantes, y sus métodos default tienen que funcionar a través de otros métodos.
- Cuántas. Una clase extiende una sola clase, pero implementa cualquier cantidad de interfaces. Un record o un enum no puede extender ninguna clase, pero sí implementar interfaces.
- Acoplamiento. Extender una clase te ata a sus campos y a su implementación. Implementar una interfaz te ata solo a las firmas de los métodos.
Empieza con una interfaz, porque cualquier cosa puede firmarla. Agrega una clase abstracta cuando las implementaciones comparten estado y pasos reales, y deja la interfaz como el tipo que usa la gente. El JDK hace eso con List y AbstractList.
El problema de la clase base frágil
Una subclase depende de cómo está escrito su padre por dentro, no solo de lo que el padre promete. Cuando esos detalles cambian, o nunca fueron lo que suponías, la subclase se rompe. Este es el ejemplo clásico, de Effective Java de Joshua Bloch: un HashSet que cuenta cuántos elementos se agregaron alguna vez.
class InstrumentedHashSet<E> extends HashSet<E> {
private static final long serialVersionUID = 1L;
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
int addCount() {
return addCount;
}
}
void main() throws Exception {
var set = new InstrumentedHashSet<String>();
set.addAll(List.of("Snap", "Crackle", "Pop"));
IO.println("size: " + set.size());
IO.println("addCount: " + set.addCount());
var addAll = HashSet.class.getMethod("addAll", Collection.class);
IO.println("addAll is written in " + addAll.getDeclaringClass().getName());
}
Imprime:
size: 3
addCount: 6
addAll is written in java.util.AbstractCollection
Entraron tres elementos, y el contador dice seis. Lo ejecutamos en Java 25 para ver si el JDK todavía se comporta así, y sí. La última línea muestra por qué: HashSet no escribe su propio addAll. Hereda uno de AbstractCollection, y ese recorre la colección y llama a add para cada elemento. getMethod puede lanzar una excepción comprobada, y por eso main dice throws Exception.
Esta es la cadena de llamadas:
Una llamada a addAll cuenta los tres elementos y luego pasa el trabajo al AbstractCollection.addAll heredado. Ese método llama a add para cada elemento, y el dynamic dispatch envía cada llamada de vuelta al add de la subclase, que los cuenta otra vez.
Las dos sobrescrituras son correctas por separado. El bug viene de un hecho que no está en el contrato de HashSet: cuáles de sus propios métodos llama por dentro. Eso se llama self-use (autouso), y no se puede ver desde afuera.
La solución obvia es borrar la sobrescritura de addAll, y hoy funciona. Pero ahora tu contador es correcto solo porque AbstractCollection.addAll resulta llamar a add. Si un JDK futuro le diera a HashSet un addAll más rápido que guarde los elementos directamente, tu contador bajaría en silencio a cero para las inserciones en bloque. De cualquier forma, tu clase depende de código que no es tuyo y que no puedes ver.
Explicado como si tuvieras diez años
La herencia es recibir la casa entera de tus papás. Te quedas con los cuartos, los muebles y el jardín, y te mudas el primer día sin construir nada. También te quedas con el techo que gotea y que no conocías, y con el interruptor del pasillo que en secreto también abre la manguera del jardín. Puedes pintar cualquier cuarto, pero los cables detrás de las paredes siguen siendo de ellos.
La composición es llamar a un plomero cuando lo necesitas. Vives en tu propia casa. Cuando hay que arreglar un tubo, llamas al plomero y le dices cuál. Él puede cambiar su forma de trabajar, comprar herramientas nuevas o jubilarse, y tú simplemente llamas a otro plomero. Nada de lo que haga puede inundar un cuarto al que no lo dejaste entrar.
La versión precisa
Una subclase hereda la implementación del padre, no solo su interfaz. Cualquier método que sobrescriba puede ser llamado por el propio código del padre, porque Java despacha según la clase del objeto en tiempo de ejecución. Así que la corrección de la subclase depende del self-use del padre: qué métodos llaman a qué otros métodos, y en qué orden. Ese detalle casi nunca está documentado, y el autor del padre puede cambiarlo en la próxima versión.
La composición no tiene este problema. Un envoltorio guarda el otro objeto en un campo privado y llama a sus métodos públicos. Las llamadas internas del objeto envuelto van a sus propios métodos, nunca de vuelta al envoltorio, porque el envoltorio no es una subclase. El envoltorio depende solo del contrato.
Dónde falla la analogía: una casa no se puede construir pensando en el próximo dueño, pero una clase sí. Un padre diseñado para la herencia documenta su self-use, como hace AbstractList, y marca el resto como final. Extender una clase así es seguro.
Composición: reenviar en vez de extender
Con composición, tu clase tiene un set en vez de ser uno. Guarda un Set en un campo y le reenvía cada llamada. Aquí está el contador otra vez, escrito de esa forma:
class InstrumentedSet<E> {
private final Set<E> inner;
private int addCount = 0;
InstrumentedSet(Set<E> inner) {
this.inner = inner;
}
boolean add(E e) {
addCount++;
return inner.add(e);
}
boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return inner.addAll(c);
}
int size() {
return inner.size();
}
int addCount() {
return addCount;
}
@Override
public String toString() {
return inner.toString();
}
}
void main() {
var fromHash = new InstrumentedSet<String>(new HashSet<>());
fromHash.addAll(List.of("Snap", "Crackle", "Pop"));
IO.println("HashSet: size " + fromHash.size() + ", addCount " + fromHash.addCount());
var fromTree = new InstrumentedSet<String>(new TreeSet<>());
fromTree.addAll(List.of("Snap", "Crackle", "Pop"));
fromTree.add("Snap");
IO.println("TreeSet: size " + fromTree.size() + ", addCount " + fromTree.addCount());
IO.println(fromTree);
}
Imprime:
HashSet: size 3, addCount 3
TreeSet: size 3, addCount 4
[Crackle, Pop, Snap]
El contador es correcto. inner.addAll(c) todavía llama a add por dentro, pero ese es el add propio del HashSet, y nuestro envoltorio nunca se entera. Agregar "Snap" por segunda vez cuenta como un intento de agregar, que es lo que promete la clase, mientras el tamaño se queda en 3.
Hay un beneficio extra. El envoltorio acepta cualquier Set, así que la misma clase cuenta las inserciones en un TreeSet, que mantiene sus elementos ordenados. La versión con subclase solo podía ser un HashSet.
El costo es el código de reenvío. Para pasar este envoltorio donde se espera un Set, tendría que implementar Set<E> y reenviar cada método. Effective Java los escribe una sola vez, en un ForwardingSet reutilizable.
“Es un” o “tiene un”
La herencia dice que un Dog es un Animal: donde necesites un animal, un perro tiene que servir. La composición dice que una clase tiene una cosa que usa. Antes de escribir extends, pregúntate si cada método del padre tiene sentido en el hijo. El propio JDK se equivocó con esto una vez. Stack extiende Vector, así que una pila es una lista, y hereda métodos de lista que rompen la idea de pila:
void main() {
var stack = new Stack<String>();
stack.push("first");
stack.push("second");
stack.push("third");
stack.add(0, "sneaked in at the bottom");
stack.remove(2);
IO.println(stack);
IO.println("pop: " + stack.pop());
}
Imprime:
[sneaked in at the bottom, first, third]
pop: third
Una pila solo debería dejarte hacer push y pop en el tope. Esta nos dejó insertar en el fondo y borrar del medio, porque Stack no puede quitarle los métodos a Vector. Una pila tiene una lista adentro. No es una. Por eso la propia documentación de Stack te dice que uses un Deque, como ArrayDeque, en su lugar.
Un decorador apila envoltorios
Un decorador es un envoltorio que implementa la misma interfaz que el objeto que envuelve. Como el envoltorio también es de ese tipo, puedes envolver un envoltorio y construir el comportamiento por capas. Los records hacen que cada capa sea un tipo corto:
interface Formatter {
String format(String text);
}
record Plain() implements Formatter {
@Override
public String format(String text) {
return text;
}
}
record Shout(Formatter inner) implements Formatter {
@Override
public String format(String text) {
return inner.format(text).toUpperCase();
}
}
record Exclaim(Formatter inner) implements Formatter {
@Override
public String format(String text) {
return inner.format(text) + "!";
}
}
record Timestamp(Formatter inner, String time) implements Formatter {
@Override
public String format(String text) {
return "[" + time + "] " + inner.format(text);
}
}
void main() {
Formatter f = new Timestamp(new Exclaim(new Shout(new Plain())), "09:30");
IO.println(f.format("server started"));
Formatter g = new Shout(new Timestamp(new Plain(), "9am"));
IO.println(g.format("server started"));
}
Imprime:
[09:30] SERVER STARTED!
[9AM] SERVER STARTED
Cada capa llama a la que tiene adentro y luego agrega su propio cambio. En g, Shout está afuera, así que también pasó a mayúsculas la marca de tiempo, y 9am se volvió 9AM. El orden importa, y lo eliges cuando construyes el objeto.
Con herencia necesitarías una subclase para cada combinación. Aquí cuatro tipos pequeños las cubren todas. Las clases de I/O del JDK funcionan así: un BufferedReader envuelve un InputStreamReader, que envuelve un InputStream.
instanceof y casts
instanceof revisa si un objeto es de un tipo dado, y un cast le dice al compilador que lo trate como ese tipo. El compilador confía en el cast, así que la revisión ocurre en tiempo de ejecución. Cuando el objeto no es de ese tipo, el cast lanza una excepción:
void main() {
List<Object> values = List.of("8080", 8080);
for (Object v : values) {
if (v instanceof String) {
String s = (String) v;
IO.println("a string of length " + s.length());
} else {
IO.println("not a string: " + v);
}
}
String port = (String) values.get(1);
IO.println("port is " + port);
}
Imprime y se detiene:
a string of length 4
not a string: 8080
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
El bucle era seguro porque su cast estaba detrás de una revisión con instanceof. El último cast no tenía revisión, y el valor es un Integer. El código real se topa con esto cuando los valores salen de un Map<String, Object>.
Cuando haces cast de tus propias clases en un archivo fuente compacto, el mensaje nombra el class loader y termina con un @ y un número hexadecimal que puede cambiar entre ejecuciones. Por eso este ejemplo usa tipos del JDK.
Una cadena larga de revisiones con instanceof suele indicar que el método debería estar en la interfaz, como pasó con area(). Cuando revisar el tipo es la herramienta correcta, la parte sobre sealed types muestra v instanceof String s, que revisa y hace el cast en un solo paso.
Qué recordar
- Una interfaz es un contrato. Cualquier clase o record puede implementar varias, y el código escrito contra la interfaz funciona con todas.
- Los métodos default permiten que una interfaz agregue comportamiento sin romper a quienes la implementan. Dos defaults en conflicto no compilan; sobrescribe el método y elige con
Camera.super.describe(). extendshereda los campos y métodos de un solo padre. Java despacha según la clase real del objeto, así que el código del padre puede llamar a la sobrescritura del hijo, incluso desde un constructor.- Pon
@Overrideen cada sobrescritura, para que un error de tipeo haga fallar la compilación. Usafinalpara impedir sobrescribir ysuperpara llamar al padre. - Usa una clase abstracta cuando las implementaciones comparten estado y pasos reales. Si no, prefiere una interfaz.
- Extender una clase que no controlas te ata a su self-use oculto.
InstrumentedHashSetcuenta 6 para 3 elementos en Java 25, porque eladdAllheredado llama aadd. - La composición envuelve un objeto y le reenvía las llamadas, así que el funcionamiento interno del objeto envuelto no puede llamar de vuelta a tu código. Los decoradores apilan esos envoltorios.
Extiende una clase solo cuando el hijo de verdad es el padre y el padre se construyó para ser extendido. Si no, envuélvela.