Clases Abstractas, Interfaces y Organización del Código
En la lección anterior, Vehiculo tenía un problema silencioso: nada impide escribir new Vehiculo("Ford"). Y un “vehículo genérico” no existe en el mundo real. Es un concepto, no una cosa.
Peor todavía: Vehiculo.arrancar() tuvo que inventar una implementación por defecto ("El vehículo arranca.") que ninguna subclase usa de verdad. Escribimos código solo para que compile.
Java tiene dos herramientas para resolver esto, y elegir mal entre ellas es una de las decisiones de diseño que más caro se paga:
- Clase abstracta: un molde incompleto. Trae estado y comportamiento ya resueltos, y deja huecos que la subclase está obligada a llenar.
- Interfaz: un contrato puro. No dice cómo se hace nada, solo qué tiene que saber hacer quien la firme.
1. Clases abstractas: moldes que no se pueden instanciar
Una clase marcada como abstract no se puede instanciar. Solo existe para ser extendida:
public abstract class Figura {
protected final String nombre;
protected Figura(String nombre) { // sí, las abstractas tienen constructor
this.nombre = nombre;
}
// Método abstracto: sin cuerpo. Cada subclase DEBE implementarlo.
public abstract double calcularArea();
public abstract double calcularPerimetro();
// Método concreto: ya resuelto, se hereda tal cual.
public void describir() {
System.out.printf("%s → área %.2f, perímetro %.2f%n",
nombre, calcularArea(), calcularPerimetro());
}
}
Mirá bien describir(), porque ahí está la gracia del asunto: llama a dos métodos que todavía no existen. La clase abstracta escribe el algoritmo general una sola vez y delega los pasos concretos en quien la extienda.
public class Circulo extends Figura {
private final double radio;
public Circulo(double radio) {
super("Círculo");
if (radio <= 0) {
System.out.println("Radio inválido, se usó 1 por defecto.");
radio = 1;
}
this.radio = radio;
}
@Override
public double calcularArea() { return Math.PI * radio * radio; }
@Override
public double calcularPerimetro() { return 2 * Math.PI * radio; }
}
public class Rectangulo extends Figura {
private final double base, altura;
public Rectangulo(double base, double altura) {
super("Rectángulo");
this.base = base;
this.altura = altura;
}
@Override
public double calcularArea() { return base * altura; }
@Override
public double calcularPerimetro() { return 2 * (base + altura); }
}
Y ahora new Figura(...) ni siquiera compila. El compilador dejó de permitir el objeto que no tenía sentido. Eso es exactamente lo que buscábamos.
// Figura f = new Figura("algo"); // ERROR: Figura is abstract; cannot be instantiated
Figura[] figuras = { new Circulo(3), new Rectangulo(4, 5) };
for (Figura f : figuras) {
f.describir(); // polimorfismo, igual que en la lección anterior
}
Si una subclase no implementa todos los métodos abstractos que hereda, tiene que declararse
abstractella también. Java no te deja tener una clase concreta con huecos.
2. Interfaces: contratos que cualquiera puede firmar
Una interfaz describe qué se puede hacer, nunca cómo:
public interface Pagable {
// Todos los métodos son public abstract por defecto: no hace falta escribirlo
boolean pagar(double monto);
boolean estaDisponible();
}
Cualquier clase puede firmarlo con implements, y el compilador la obliga a cumplirlo entero:
public class TarjetaCredito implements Pagable {
private final String numero;
private double limiteDisponible;
public TarjetaCredito(String numero, double limiteDisponible) {
this.numero = numero;
this.limiteDisponible = limiteDisponible;
}
// true si el cobro se realizó, false si se rechazó por límite insuficiente
@Override
public boolean pagar(double monto) {
if (monto > limiteDisponible) {
System.out.println("Límite insuficiente.");
return false;
}
limiteDisponible -= monto;
System.out.println("Pagado con tarjeta " + numero);
return true;
}
@Override
public boolean estaDisponible() { return limiteDisponible > 0; }
}
Lo importante: TarjetaCredito no hereda de nadie. La interfaz no consume el único extends que tenés. Esa es su ventaja decisiva.
Métodos default y static
Desde Java 8 una interfaz puede traer implementaciones:
public interface Pagable {
boolean pagar(double monto);
boolean estaDisponible();
// default: implementación heredable que las clases pueden sobrescribir o no
default void pagarSiPuede(double monto) {
if (estaDisponible()) {
pagar(monto);
} else {
System.out.println("Medio de pago no disponible.");
}
}
// static: utilidad que pertenece a la interfaz, no a las clases
static boolean esMontoValido(double monto) {
return monto > 0 && monto < 1_000_000;
}
}
Los default existen por una razón muy concreta: permiten agregar un método nuevo a una interfaz sin romper las mil clases que ya la implementaban. Antes de Java 8, agregar un método a una interfaz pública rompía todo el ecosistema que dependía de ella.
Úsalos con moderación. Una interfaz llena de default deja de ser un contrato y empieza a ser una clase abstracta mal disfrazada.
3. Implementar varias interfaces a la vez
Acá se ve por qué las interfaces no son simplemente “clases abstractas sin código”:
public class Pato extends Ave implements Nadable, Volable {
@Override public void nadar() { System.out.println("El pato nada."); }
@Override public void volar() { System.out.println("El pato vuela."); }
}
Y ahora un mismo objeto puede verse desde ángulos distintos según lo que necesite cada método:
Pato pato = new Pato();
Ave a = pato; // como ave
Nadable n = pato; // como algo que nada
Volable v = pato; // como algo que vuela
// Un método que solo necesita que algo nade, no necesita saber que es un pato:
public void competenciaDeNatacion(Nadable[] participantes) {
for (Nadable participante : participantes) {
participante.nadar();
}
}
En ese array pueden convivir un Pato, un Pez y un Submarino, tres clases que no comparten absolutamente ningún ancestro. La interfaz es lo único que tienen en común, y alcanza.
4. Cuál elegir
| Criterio | Clase abstracta | Interfaz |
|---|---|---|
| Relación que expresa | ”es un” — comparten identidad | ”es capaz de” — comparten una capacidad |
| Cuántas se pueden usar | Solo una (extends) | Todas las que quieras (implements) |
| Estado de instancia | Sí, campos normales | No, solo constantes static final |
| Constructores | Sí | No |
| Visibilidad de los métodos | Cualquiera, incluida protected | Siempre public |
| Agregar un método después | Rompe las subclases si es abstracto | No rompe nada si es default |
La regla práctica que funciona en el 90 % de los casos:
Interfaz por defecto. Clase abstracta solo cuando hay estado o código realmente compartido que no querés repetir.
Y las dos se combinan sin problema, que es el patrón más habitual en las librerías serias:
public interface RepositorioCursos {
void guardar(Curso curso);
Curso buscarPorId(long id);
}
// Base abstracta que resuelve lo repetitivo: buscar en el almacén interno
public abstract class RepositorioCursosEnMemoria implements RepositorioCursos {
protected final Curso[] almacen = new Curso[100];
protected int cantidad = 0;
@Override
public Curso buscarPorId(long id) {
for (int i = 0; i < cantidad; i++) {
if (almacen[i].getId() == id) {
return almacen[i];
}
}
return null; // no encontrado
}
// guardar() queda abstracto: cada subclase concreta decide qué validar antes de guardar
}
5. Organización del código: paquetes y el concepto de namespace en Java
¿Qué es un namespace (espacio de nombres)?
En ingeniería de software y ciencias de la computación, un namespace (espacio de nombres) es un contenedor lógico que agrupa un conjunto de identificadores (clases, interfaces, tipos) para proporcionarles un contexto único y evitar colisiones de nombres (name collisions).
A medida que un proyecto crece e integra librerías de terceros (por ejemplo utilidades de base de datos, clientes HTTP o modelos de dominio), es inevitable que distintas partes del sistema definan tipos con el mismo nombre simple: dos personas pueden crear una clase Usuario, o tu código puede definir Date mientras el driver SQL también incluye su propio Date. Sin un sistema de namespaces, todos los nombres compartirían un único espacio global plano y el compilador sería incapaz de distinguir cuál usar.
Java no tiene la palabra clave namespace: utiliza package
A diferencia de lenguajes como C++, C# o PHP, Java no tiene una palabra reservada namespace. En su lugar, el lenguaje implementa el concepto de namespace de forma nativa a través de tres componentes complementarios:
- La declaración
package: define el namespace al que pertenece cada archivo fuente. - El Fully Qualified Class Name (FQCN): la identidad canónica y universal de la clase para la JVM.
- La sentencia
import: un alias léxico para usar nombres simples en el código sin tipear el FQCN cada vez.
A diferencia de otros lenguajes donde los namespaces son construcciones puramente lógicas desconectadas del almacenamiento físico, Java impone una correspondencia estricta: el namespace declarado en package debe coincidir exactamente con la ruta de carpetas en el classpath.
package com.facundouferer.tienda.dominio; // primera línea, obligatoria
public class Producto { ... }
Fully Qualified Class Name (FQCN) e identidad de tipo
Para la JVM, el nombre simple Producto es solo una etiqueta local. La identidad formal y única de la clase ante el cargador de clases (ClassLoader) es su Fully Qualified Class Name (FQCN):
com.facundouferer.tienda.dominio.Producto
Gracias a este esquema jerárquico de namespaces, dos clases homónimas pueden coexistir sin ningún conflicto dentro del mismo sistema:
com.facundouferer.tienda.dominio.Producto(entidad del modelo de dominio local)com.proveedor.catalogo.Producto(DTO importado de un catálogo externo)
Resolución de colisiones de nombres con import
La sentencia import no carga código en memoria; simplemente registra un alias para que no tengas que escribir el FQCN cada vez que usás una clase en ese archivo.
¿Qué ocurre si necesitás utilizar dos clases con el mismo nombre simple que pertenecen a distintos namespaces/paquetes en un mismo archivo?
// ERROR: no podés importar dos clases con el mismo nombre simple en el mismo archivo
import java.util.Date;
import java.sql.Date; // Error de compilación: 'Date is already defined in a single-type import'
Java prohíbe esta ambigüedad. Para resolver la colisión de namespaces:
- Importás con
importla clase que uses con mayor frecuencia (usará su nombre simple). - Desambiguás la segunda clase escribiendo su FQCN completo directamente en el código donde la necesites:
package com.facundouferer.tienda.servicio;
import java.util.Date; // Date sin calificar se refiere a java.util.Date
public class AuditoriaServicio {
// Usa el nombre simple del namespace importado:
private Date fechaOperacion = new Date();
// Desambiguación explícita mediante el FQCN completo para evitar la colisión de namespace:
private java.sql.Date fechaPersistenciaBD = new java.sql.Date(System.currentTimeMillis());
public void registrar() {
System.out.println("Fecha en memoria (java.util): " + fechaOperacion);
System.out.println("Fecha para base de datos (java.sql): " + fechaPersistenciaBD);
}
}
Encapsulamiento a nivel de Namespace: package-private
Los namespaces en Java no son simples carpetas cosméticas; también establecen límites de visibilidad y confianza arquitectónica.
Como vimos en la lección Constructores, Modificadores de Acceso y Getters/Setters, el modificador por defecto de Java (sin palabra clave, conocido como package-private) restringe el acceso exclusivamente a las clases dentro del mismo paquete/namespace. Esto permite crear subsistemas modulares con una interfaz pública (public) y un conjunto de clases colaboradoras internas protegidas del exterior.
Reglas para diseñar paquetes y namespaces
- Convención de dominio inverso: El nombre comienza con tu dominio al revés (
com.facundouferer), garantizando que tus namespaces sean únicos en el mundo entero y no colisionen en repositorios como Maven Central. - Todo en minúsculas: Sin mayúsculas, tildes ni guiones (
tienda.dominio, notienda_dominio). - Un archivo
.javapor clase pública, y el archivo se llama exactamente igual que la clase. - Agrupá por responsabilidad, no por tipo de artefacto:
tienda.dominioytienda.servicioexpresan arquitectura limpia;tienda.interfacesytienda.clasessolo duplican la jerarquía sin aportar semántica. - Imports estáticos con moderación:
import static java.lang.Math.PI;permite importar miembros estáticos al namespace léxico local, pero debe usarse con criterio para no oscurecer el origen de los métodos.
6. Modelar relaciones: asociación, agregación y composición
Heredar no es la única forma de conectar clases, ni la más frecuente. En la práctica, la mayoría de las relaciones son de contención, y se distinguen por una sola pregunta: ¿qué pasa con la parte cuando desaparece el todo?
import java.util.Arrays;
// ASOCIACIÓN: se conocen, ninguno es dueño del otro
public class Profesor {
private Curso[] cursos = new Curso[10];
private int cantidadCursos = 0;
public void asignar(Curso c) {
if (cantidadCursos == cursos.length) {
cursos = Arrays.copyOf(cursos, cursos.length * 2);
}
cursos[cantidadCursos] = c;
cantidadCursos++;
}
}
// AGREGACIÓN: el equipo recibe jugadores que ya existían y les sobrevive
public class Equipo {
private final Jugador[] jugadores;
public Equipo(Jugador[] jugadores) {
this.jugadores = Arrays.copyOf(jugadores, jugadores.length); // copia defensiva, ver Arrays de Objetos
}
}
// COMPOSICIÓN: la casa CREA sus habitaciones y no las suelta nunca
public class Casa {
private final Habitacion[] habitaciones;
public Casa(int cantidadHabitaciones) {
habitaciones = new Habitacion[cantidadHabitaciones];
for (int i = 0; i < cantidadHabitaciones; i++) {
habitaciones[i] = new Habitacion(i + 1); // las crea acá adentro
}
}
public int cantidadHabitaciones() { return habitaciones.length; }
// No hay getHabitaciones(): nadie de afuera toca las partes
}
Fijate el patrón en el código: en la composición, el contenedor crea las partes en su propio constructor y no las expone. En la agregación, las recibe de afuera. Esa diferencia en el código es exactamente la diferencia conceptual.
7. Errores frecuentes
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
| Usar clase abstracta donde va una interfaz | Se gasta el único extends disponible y la clase ya no puede heredar de lo que realmente necesita. | Empezar siempre por la interfaz; agregar clase abstracta solo si hay estado compartido. |
Interfaz con un default para cada método | Deja de ser un contrato y pasa a ser una clase abstracta sin constructor ni estado. | Los default son para evolucionar la interfaz sin romper implementaciones, no para escribir lógica. |
| Declarar campos mutables en una interfaz | Todo campo en una interfaz es public static final: es una constante global compartida, no estado del objeto. | Si necesitás estado, necesitás una clase (abstracta o no). |
| Paquete que no coincide con la carpeta | Error de compilación confuso sobre clases que “no existen”. | La declaración package tiene que reflejar la ruta exacta. |
Colisión de namespaces al importar clases homónimas (ej. dos Date o dos Order) | Error de compilación: is already defined in a single-type import. | Desambiguar el namespace importando una sola y utilizando el Fully Qualified Class Name (FQCN) para la otra. |
| Modelar como agregación algo que es composición | La parte queda expuesta y alguien de afuera la modifica o la comparte entre dos contenedores. | Si la parte no vive sin el todo: crearla adentro y no exponerla. |
| Un solo paquete gigante con todas las clases | El package-private deja de proteger nada y no se entiende la arquitectura. | Separar por responsabilidad desde el primer día. |
8. Ejercicio práctico guiado
Desafío: medios de pago
- Definí la interfaz
Pagableconboolean pagar(double monto)(truesi se cobra,falsesi se rechaza),boolean estaDisponible()y un métododefaultpagarSiPuede(double monto)que solo cobre si el medio está disponible. - Implementala en
TarjetaCredito(con límite disponible) y enMercadoPago(con saldo en cuenta). - Creá una clase abstracta
MedioDePagoDigital implements Pagableque guarde elemaildel titular y resuelvaestaDisponible()con un campoactivo, dejandopagar()abstracto. - Hacé que
MercadoPagoextienda esa clase abstracta. - En el
main, armá unPagable[]y cobrá el mismo monto a todos con un solo bucle, sininstanceofy sin castear.
Ver solución sugerida
public interface Pagable {
boolean pagar(double monto);
boolean estaDisponible();
default boolean pagarSiPuede(double monto) {
if (monto <= 0) {
System.out.println(" ✗ Monto inválido, se omite el cobro.");
return false;
}
if (estaDisponible()) {
return pagar(monto);
}
System.out.println(" ✗ Medio no disponible, se omite el cobro.");
return false;
}
}
// Clase abstracta: aporta el ESTADO y el comportamiento compartido.
public abstract class MedioDePagoDigital implements Pagable {
protected final String email;
protected boolean activo;
protected MedioDePagoDigital(String email) {
if (email == null || !email.contains("@")) {
System.out.println("Email inválido, se usó \"sin-email@ejemplo.com\" por defecto.");
email = "sin-email@ejemplo.com";
}
this.email = email;
this.activo = true;
}
@Override
public boolean estaDisponible() {
return activo;
}
public void desactivar() { this.activo = false; }
// pagar() sigue abstracto: cada medio digital cobra a su manera.
}
public class MercadoPago extends MedioDePagoDigital {
private double saldo;
public MercadoPago(String email, double saldo) {
super(email);
this.saldo = saldo;
}
@Override
public boolean estaDisponible() {
return super.estaDisponible() && saldo > 0; // reutiliza y refina
}
@Override
public boolean pagar(double monto) {
if (monto > saldo) {
System.out.printf(" ✗ MercadoPago (%s) — saldo insuficiente.%n", email);
return false;
}
saldo -= monto;
System.out.printf(" ✓ MercadoPago (%s) — saldo restante $%.2f%n", email, saldo);
return true;
}
}
// No hereda de nadie: solo firma el contrato.
public class TarjetaCredito implements Pagable {
private final String ultimosCuatro;
private double limiteDisponible;
public TarjetaCredito(String ultimosCuatro, double limiteDisponible) {
this.ultimosCuatro = ultimosCuatro;
this.limiteDisponible = limiteDisponible;
}
@Override
public boolean estaDisponible() { return limiteDisponible > 0; }
@Override
public boolean pagar(double monto) {
if (monto > limiteDisponible) {
System.out.printf(" ✗ Tarjeta ****%s — límite insuficiente.%n", ultimosCuatro);
return false;
}
limiteDisponible -= monto;
System.out.printf(" ✓ Tarjeta ****%s — límite restante $%.2f%n",
ultimosCuatro, limiteDisponible);
return true;
}
}
public class MainCobros {
public static void main(String[] args) {
MercadoPago mpVacio = new MercadoPago("vacio@mail.com", 0);
Pagable[] medios = {
new TarjetaCredito("4417", 50000),
new MercadoPago("facu@mail.com", 30000),
mpVacio // sin saldo: se va a omitir
};
System.out.println("Cobrando $12.500 a cada medio:");
for (Pagable medio : medios) {
medio.pagarSiPuede(12500); // el default decide; nadie pregunta el tipo
}
}
}
Dos cosas para mirar acá.
La primera: TarjetaCredito y MercadoPago no comparten ningún ancestro, y aun así conviven en el mismo Pagable[]. La interfaz fue suficiente.
La segunda: MercadoPago.estaDisponible() llama a super.estaDisponible() y le suma su propia condición. Reutiliza la regla de la clase abstracta en lugar de repetirla, exactamente el mismo patrón que usaste con super.calcularSalario() en la lección anterior.
Para llevarte
- Una clase abstracta es un molde incompleto: aporta estado y código, obliga a llenar los huecos y no se puede instanciar.
- Una interfaz es un contrato: dice qué se sabe hacer, no cómo, y no consume tu único
extends. - Regla práctica: empezá por la interfaz; agregá una clase abstracta solo cuando haya estado o lógica realmente compartida.
- Los métodos
defaultexisten para hacer evolucionar una interfaz sin romper a quienes ya la implementaban. - Una clase extiende una sola clase, pero implementa todas las interfaces que necesite. Esa es la salida de Java a la herencia múltiple.
- El
packagees la implementación del concepto de namespace en Java: previene colisiones mediante el FQCN, debe coincidir con la jerarquía de carpetas y conviene organizarlo por responsabilidad, no por tipo de artefacto. - Asociación, agregación y composición se distinguen con una sola pregunta: si destruyo el todo, ¿la parte sigue existiendo?