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

Comparación entre la anatomía de una clase abstracta y la de una interfaz Clase abstracta — un molde incompleto public abstract class Figura { protected String nombre; ← puede guardar ESTADO public abstract double calcularArea(); ← sin cuerpo: obliga a la subclase public void describir() { ... } ← con cuerpo: se hereda tal cual No se puede instanciar: new Figura() es error. Y una clase extiende SOLO UNA clase abstracta. Interfaz — un contrato puro public interface Dibujable { int MAX_CAPAS = 10; ← constante: public static final void dibujar(); ← abstracto y público por defecto default void resaltar() { ... } ← implementación por defecto (Java 8+) static Dibujable vacio() { ... } ← utilidad de la propia interfaz No guarda estado de instancia. Y una clase puede implementar TODAS las interfaces que quiera.
La clase abstracta aporta estado y código heredable; la interfaz aporta un contrato que cualquier clase puede firmar, sin importar de quién herede.

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 abstract ella 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”:

Una clase extiende una sola clase abstracta pero implementa varias interfaces «abstract class» Ave comer(), plumaje «interface» Nadable nadar() «interface» Volable volar(), altitudMaxima() extends (línea llena, solo una) implements (punteada, las que quieras) Pato extends Ave implements Nadable, Volable hereda comer(); implementa nadar() y volar() Java no tiene herencia múltiple de clases, pero un objeto sí puede cumplir todos los contratos que necesite.
Una sola línea de herencia, muchos contratos. Por eso las interfaces son la herramienta para combinar capacidades que no comparten un ancestro.
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

CriterioClase abstractaInterfaz
Relación que expresa”es un” — comparten identidad”es capaz de” — comparten una capacidad
Cuántas se pueden usarSolo una (extends)Todas las que quieras (implements)
Estado de instanciaSí, campos normalesNo, solo constantes static final
ConstructoresSíNo
Visibilidad de los métodosCualquiera, incluida protectedSiempre public
Agregar un método despuésRompe las subclases si es abstractoNo 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:

  1. La declaración package: define el namespace al que pertenece cada archivo fuente.
  2. El Fully Qualified Class Name (FQCN): la identidad canónica y universal de la clase para la JVM.
  3. 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.

Estructura de paquetes de un proyecto y su correspondencia con la declaración package La ruta de carpetas y la declaración package tienen que coincidir. Siempre. com.facundouferer.tienda dominio Producto.java · Cliente.java · Pedido.java servicio CarritoServicio.java · PagoServicio.java Main.java el punto de entrada de la aplicación package com.facundouferer.tienda.dominio; Primera línea de cada archivo. Si no coincide con la ruta real, no compila. import ...tienda.dominio.Producto; Necesario para usar una clase de otro paquete. Dentro del mismo paquete no hace falta. Convención: tu dominio al revés. facundouferer.ar → com.facundouferer Así dos librerías distintas nunca chocan. Agrupá por responsabilidad (dominio, servicio, repositorio), no por tipo de artefacto (todas las interfaces juntas).
Los paquetes son la primera línea de arquitectura de un proyecto: el nombre de la carpeta ya cuenta qué hace lo que hay adentro.
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:

  1. Importás con import la clase que uses con mayor frecuencia (usará su nombre simple).
  2. 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, no tienda_dominio).
  • Un archivo .java por clase pública, y el archivo se llama exactamente igual que la clase.
  • Agrupá por responsabilidad, no por tipo de artefacto: tienda.dominio y tienda.servicio expresan arquitectura limpia; tienda.interfaces y tienda.clases solo 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?

Asociación, agregación y composición ordenadas de la relación más débil a la más fuerte Las tres formas de relacionar objetos, de la más débil a la más fuerte Profesor Curso Asociación — se conocen y colaboran, pero cada uno vive por su cuenta. Equipo Jugador Agregación — el todo agrupa partes que ya existían. Si se disuelve, ellas siguen. Casa Habitación Composición — la parte no existe sin el todo. Se demuele la casa, se va con ella. Rombo hueco: la parte sobrevive. Rombo lleno: la parte muere con el todo, y el todo la crea en su constructor.
La pregunta que decide cuál es cuál: si destruyo el objeto contenedor, ¿la parte sigue teniendo sentido por sí sola?
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

ErrorQué pasaCómo se arregla
Usar clase abstracta donde va una interfazSe 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étodoDeja 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 interfazTodo 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 carpetaError 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ónLa 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 clasesEl 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

  1. Definí la interfaz Pagable con boolean pagar(double monto) (true si se cobra, false si se rechaza), boolean estaDisponible() y un método default pagarSiPuede(double monto) que solo cobre si el medio está disponible.
  2. Implementala en TarjetaCredito (con límite disponible) y en MercadoPago (con saldo en cuenta).
  3. Creá una clase abstracta MedioDePagoDigital implements Pagable que guarde el email del titular y resuelva estaDisponible() con un campo activo, dejando pagar() abstracto.
  4. Hacé que MercadoPago extienda esa clase abstracta.
  5. En el main, armá un Pagable[] y cobrá el mismo monto a todos con un solo bucle, sin instanceof y 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 default existen 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 package es 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?