Archivo de la etiqueta: #clean_code

Herencia vs. Composición: elige bien tu herramienta

Herencia y composición: de qué estamos hablando

Uno de los debates más fecundos del diseño orientado a objetos es el de cuándo usar herencia y cuándo usar composición (o agregación). La herencia es la primera herramienta que aprende cualquier estudiante de POO y esa familiaridad temprana suele convertirla en un martillo que hace que todo parezcan clavos. Este artículo revisa qué dicen los autores más influyentes del campo y propone ejemplos concretos para ayudarte a elegir bien.

Antes de entrar en el debate, conviene fijar con precisión qué significa cada mecanismo. Aunque ya los conoces de clase, vale la pena verlos uno al lado del otro para apreciar sus diferencias estructurales.

Herencia

La herencia es el mecanismo por el cual una clase (la subclase o clase hija) adquiere automáticamente los atributos y métodos de otra clase (la superclase o clase madre). En Java se expresa con la palabra clave extends:

public class Animal {
   protected String nombre;

   public Animal(String nombre) {
       this.nombre = nombre;
  }

   public void comer() {
       System.out.println(nombre + " está comiendo.");
  }
}

public class Perro extends Animal {

   public Perro(String nombre) {
       super(nombre);
  }

   public void ladrar() {
       System.out.println(nombre + " está ladrando.");
  }
}

El diagrama UML de la jerarquía anterior es:

Un Perro hereda el método comer(), sin necesidad de redefinirlo y además añade su propio método ladrar(). La relación que modela la herencia es la relación “es-un” (is-a): un perro es un animal.

Desde el punto de vista de la implementación, la herencia establece una relación estática: se fija en el momento de escribir el código y no puede cambiar en tiempo de ejecución. Además, la subclase tiene acceso, mediante protected, a los detalles internos de la superclase, lo que crea un acoplamiento fuerte entre ambas.

Composición (y agregación)

La composición es el mecanismo por el cual una clase contiene una referencia a un objeto de otra clase como uno de sus campos. En lugar de heredar el comportamiento, lo delega al objeto contenido. Como ejemplo, vamos a desarrollar las siguientes clases: Persona y Direccion :

class Direccion {
   private String calle;
   private String ciudad;

   public Direccion(String calle, String ciudad) {
       this.calle  = calle;
       this.ciudad = ciudad;
  }

   public String toString() {
       return calle + ", " + ciudad;
  }
}

class Persona {
   private String nombre;
   private Direccion direccion;    // ← Persona TIENE UNA Direccion

   public Persona(String nombre, Direccion direccion) {
       this.nombre    = nombre;
       this.direccion = direccion;
  }

   public void presentarse() {
       System.out.println("Me llamo " + nombre + " y vivo en " + direccion);
  }
}

En este caso, el diagrama UML sería:

El uso podría ser:

Direccion dir    = new Direccion("Calle Mayor, 5", "Madrid");
Persona   alumno = new Persona("Ana García", dir);
alumno.presentarse();

Que daría lugar a la siguiente salida:

Me llamo Ana García y vivo en Calle Mayor, 5, Madrid

La relación que modela la composición es la relación “tiene-un” (has-a): una Persona tiene una Direccion. La Persona no es una Direccion, ni hereda nada de ella, simplemente la utiliza.

📎 Composición vs. agregación: técnicamente, hablamos de composición cuando el objeto contenido no puede existir sin el contenedor y de agregación, cuando el objeto contenido tiene vida propia y puede ser compartido. El ejemplo de Persona y Direccion ilustra precisamente la agregación: la dirección se crea fuera del constructor de Persona y podría ser compartida por dos personas que viven en el mismo domicilio. Un ejemplo de composición estricta sería un Pedido que contiene sus propias Lineas de pedido: si el pedido desaparece, sus líneas no tienen sentido fuera de él. En la práctica del diseño de software, el término “composición” se usa con frecuencia para referirse a ambos casos y así lo hacen también los autores que citaremos.

A diferencia de la herencia, la composición mantiene la encapsulación intacta: la Persona solo conoce la interfaz pública de Direccion, no sus detalles internos. Y si Direccion tiene varios tipos posibles, basta con que todos implementen una interfaz común para que una Persona pueda trabajar con cualquiera de ellos, incluso cambiando la dirección en tiempo de ejecución.

La tabla siguiente resume las características de los dos tipos de relación entre clases:

HerenciaComposición
Relación modelada“es-un” (is-a)“tiene-un” (has-a)
AcoplamientoAlto (la subclase conoce los internos de la superclase)Bajo (solo se conoce la interfaz pública)
EncapsulaciónSe debilitaSe preserva
FlexibilidadEstática (fijada en compilación)Dinámica (puede cambiar en ejecución)
ReutilizaciónAutomática, pero rígidaExplícita, pero flexible

Ejemplo de utilización incorrecta de la herencia

Vamos a ver un ejemplo en el que la utilización de la herencia llevaría a un diseño incorrecto. Tenemos una clase Motor que dispone de los métodos arrancar() y apagar(). Podríamos crear una clase Coche, que derive de la clase Motor, con el fin de reutilizar estos métodos:

// ─── MAL DISEÑO: Coche extiende Motor ───────────────────────────────────────
class Motor {
   private int cilindros;

   public Motor(int cilindros) {
       this.cilindros = cilindros;
  }

   public void arrancar() {
       System.out.println("Motor de " + cilindros + " cilindros arrancando.");
  }

   public void apagar() {
       System.out.println("Motor apagado.");
  }
}

// Un Coche "es un" Motor??? No tiene ningún sentido.
class Coche extends Motor {
   private String modelo;

   public Coche(String modelo, int cilindros) {
       super(cilindros);
       this.modelo = modelo;
  }

   public void describir() {
       System.out.println("Coche: " + modelo);
  }
}

Con este diseño, alguien puede escribir código absurdo que Java acepta sin quejarse:

Motor vehiculo = new Coche("Seat Ibiza", 4);  // Un Coche ES UN Motor???
vehiculo.arrancar();

Y lo peor: si mañana queremos que el Coche sea también Vehiculo (para poder meterlo en una lista junto a motos y camiones), Java no nos lo permite porque ya hemos “gastado” la herencia en Motor.


// ─── BUEN DISEÑO: Coche tiene un Motor ──────────────────────────────────────

class Motor {
   private int cilindros;

   public Motor(int cilindros) {
       this.cilindros = cilindros;
  }

   public void arrancar() {
       System.out.println("Motor de " + cilindros + " cilindros arrancando.");
  }

   public void apagar() {
       System.out.println("Motor apagado.");
  }
}

class Coche {
   private String modelo;
   private Motor motor;        // ← Coche TIENE UN Motor

   public Coche(String modelo, Motor motor) {
       this.modelo = modelo;
       this.motor  = motor;
  }

   public void arrancar() {
       System.out.println("Arrancando el " + modelo + "...");
       motor.arrancar();       // ← delega en el Motor
  }

   public void apagar() {
       motor.apagar();
       System.out.println(modelo + " apagado.");
  }
}

Ahora el uso es natural y semánticamente correcto:

Motor motor = new Motor(4);
Coche coche = new Coche("Seat Ibiza", motor);
coche.arrancar();
coche.apagar();

Salida:

Arrancando el Seat Ibiza...
Motor de 4 cilindros arrancando.
Motor apagado.
Seat Ibiza apagado.

La ventaja adicional, que vale la pena señalar, es que ahora Motor y Coche son independientes: si mañana el Motor cambia internamente, por ejemplo, añade un sistema de inyección, el Coche no se entera ni se rompe, siempre que arrancar() y apagar() sigan funcionando igual. Con la herencia, cualquier cambio en Motor podría afectar a Coche de maneras imprevistas.


Los peligros de heredar de una clase ajena

Hay situaciones en las que la herencia introduce problemas sutiles y difíciles de detectar. El caso más problemático es cuando derivamos una clase de otra en la que no tenemos acceso al código fuente: por ejemplo, una clase perteneciente a una librería de terceros.

Considera la siguiente clase Array, que envuelve un ArrayList y ofrece dos métodos para añadir elementos:

class Array {
   private ArrayList<Object> a = new ArrayList<Object>();

   public void add(Object element) {
       a.add(element);
  }

   public void addAll(Object elements[]) {
       for (int i = 0; i < elements.length; ++i)
           a.add(elements[i]);   // esta línea va a cambiar
  }
}

Se supone que esta clase Array pertenece a una librería de la que no tenemos acceso al código fuente. En nuestra aplicación necesitamos una variante que lleve la cuenta del número de elementos añadidos. Parece razonable derivar una clase ArrayCount que sobreescriba ambos métodos:

public class ArrayCount extends Array {
   private int count = 0;

   @Override
   public void add(Object element) {
       super.add(element);
       ++count;
  }

   @Override
   public void addAll(Object elements[]) {
       super.addAll(elements);
       count += elements.length;
  }

   public int getCount() {
       return count;
  }
}

Probamos la clase y todo funciona correctamente:

public class Main {
   public static void main(String[] args) {
       Integer[] list = {1, 2, 3};
       ArrayCount ac = new ArrayCount();
       ac.addAll(list);
       System.out.println(ac.getCount());  // Imprime 3 ✓
  }
}

El contador vale 3, que es el valor correcto. Todo parece en orden.

Ahora bien: supongamos que los autores de la librería publican una nueva versión y modifican addAll() internamente. El cambio consiste en que, en lugar de actuar directamente sobre el ArrayList, ahora llaman a su propio método add():

public void addAll(Object elements[]) {
   for (int i = 0; i < elements.length; ++i)
       add(elements[i]);   // esta línea ha cambiado
}

Los autores comprueban que la nueva versión pasa todos sus tests y que la funcionalidad de la librería no ha variado. El cambio parece inocuo. Pero si actualizamos la librería y volvemos a ejecutar nuestro programa:

6

El contador muestra 6 en lugar de 3. Nuestro código no ha cambiado ni una línea y sin embargo ahora falla.

¿Qué ha ocurrido? El mecanismo de enlace dinámico de Java hace que cuando Array.addAll() llama a add(), el método que se ejecuta no es el add() de Array sino el add() sobreescrito en ArrayCount. El resultado es que el contador se incrementa tres veces dentro de add() y luego otras tres veces más al final de addAll(). Seis en total.

No podemos detectar el problema leyendo nuestro código, porque el problema está en el código de la librería, al que no tenemos acceso. Ni siquiera podemos saber que addAll() llama internamente a add().

Este fenómeno tiene nombre: se conoce como el problema de la clase base frágil (fragile base class problem). La raíz del problema es que la herencia crea un acoplamiento muy fuerte entre la subclase y la superclase: la subclase depende, no solo de la interfaz pública de la superclase, sino también de sus detalles de implementación internos, que pueden cambiar en cualquier momento sin previo aviso.

La solución es usar composición en lugar de herencia:

public class ArrayCount {
   private Array array = new Array();  // ← contiene un Array, no extiende uno
   private int count = 0;

   public void add(Object element) {
       array.add(element);
       ++count;
  }

   public void addAll(Object elements[]) {
       array.addAll(elements);
       count += elements.length;
  }

   public int getCount() {
       return count;
  }
}

Ahora ArrayCount no depende de cómo Array implemente addAll() internamente. Da igual si llama a add() o no: nosotros solo usamos su interfaz pública y el contador se gestiona íntegramente en nuestro código. Si la librería cambia su implementación interna, nuestro contador sigue siendo correcto.

Qué dice la bibliografía de referencia

En esta segunda parte del artículo veremos cómo se han pronunciado sobre esta cuestión algunos de los autores más influyentes del diseño orientado a objetos. Es posible que algunos conceptos exijan un nivel algo más avanzado en POO para comprenderlos completamente, pero pueden leerse como una guía para cuando se quiera profundizar.

1. La Banda de los Cuatro (GoF)

El punto de partida obligatorio es el libro Design Patterns: Elements of Reusable Object-Oriented Software (Gamma, Helm, Johnson y Vlissides, 1994), conocido popularmente como el libro de la Gang of Four o GoF. En su introducción, los autores formulan dos principios de diseño orientado a objetos reutilizable. El segundo de ellos es:

“Favor object composition over class inheritance.” — Gamma et al., Design Patterns, 1994, p. 20

No es una frase suelta. Viene precedida por una página y media de argumentación y seguida de otro tanto sobre delegación. El núcleo del razonamiento es el siguiente:

  • La herencia es lo que los autores llaman reutilización de caja blanca (white-box reuse): la subclase tiene acceso, o al menos dependencia, de los detalles internos de la superclase. Esto crea un acoplamiento fuerte que hace el código frágil.
  • La composición, en cambio, es reutilización de caja negra (black-box reuse): el objeto compuesto solo conoce la interfaz del objeto que contiene, no sus tripas.
  • Los autores advierten de que “inheritance breaks encapsulation” (la herencia rompe la encapsulación) porque, si la superclase cambia su implementación interna, las subclases pueden romperse aunque su propio código no haya variado.
  • Su observación empírica es contundente: en su experiencia, los diseñadores abusan de la herencia (designers overuse inheritance).En una entrevista en 2004, Erich Gamma (uno de los cuatro autores) amplió su posición:

“Inheritance is a cool way to change behavior. But we know that it’s brittle […] There’s a tight coupling between the base class and the subclass. […] Composition has a nicer property. The coupling is reduced by just having some smaller things you plug into something bigger.” — Erich Gamma, entrevista en Artima (2004)

También puntualizó algo importante que suele olvidarse: “A common misunderstanding is that composition doesn’t use inheritance at all.” La composición suele apoyarse en herencia de interfaz (implementar interfaces), que es semánticamente sana. Lo que los autores desaconsejan es el abuso de la herencia de implementación entre clases concretas.


2. Joshua Bloch, Effective Java

Joshua Bloch, arquitecto principal de la API de Java en Sun Microsystems, dedica dos ítems completos a este tema en su libro Effective Java (cuya tercera edición data de 2018):

  • Ítem 18: “Favor composition over inheritance”
  • Ítem 19: “Design and document for inheritance or else prohibit it”El argumento central de Bloch en el Ítem 18 es que la herencia viola la encapsulación: una subclase depende de los detalles de implementación de su superclase, detalles que pueden cambiar de una versión a otra. Su recomendación práctica es:

“If you are tempted to have a class B extend a class A, ask yourself the question: is every B really an A?” — Bloch, Effective Java 3ª ed., Ítem 18

Si la respuesta no es un sí rotundo, B no debería extender A. En su lugar, recomienda dar a la nueva clase un campo privado que contenga una instancia de la clase existente (forwarding o delegación), patrón que llama wrapper class o clase envoltorio.

El Ítem 19 va aún más lejos: si una clase no ha sido diseñada explícitamente para ser heredada (con documentación detallada de sus invariantes y sus métodos sobreescribibles), debería prohibirse su extensión declarándola final o haciendo privados sus constructores.


3. Barbara Liskov y el LSP

Barbara Liskov es conocida principalmente por el Principio de Sustitución de Liskov (LSP), formulado en su conferencia de 1987 “Data Abstraction and Hierarchy” (OOPSLA’87) y formalizado junto a Jeannette Wing en 1994:

“If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.” — Liskov & Wing, 1994

¿Qué tiene que ver el LSP con la elección entre herencia y composición? Todo. El LSP establece la condición semántica mínima que debe cumplirse para que la herencia sea legítima: una subclase debe poder sustituir a su superclase en cualquier contexto sin alterar el comportamiento correcto del programa. Cuando ese criterio no se cumple, es una señal inequívoca de que la relación entre las dos clases no es una verdadera relación “es-un” y de que debería reemplazarse por composición.

Liskov también dejó constancia en “Data Abstraction and Hierarchy” de que la herencia de jerarquías es adecuada cuando la relación se identifica claramente en la fase de diseño; en caso contrario, otras alternativas (agrupación por composición, paso de procedimientos como argumentos) pueden ser superiores.

En su libro conjunto con John Guttag, Program Development in Java: Abstraction, Specification, and Object-Oriented Design (2001), Liskov y Guttag desarrollaron un conjunto de reglas prácticas (de firma, de propiedades y de métodos) para identificar cuándo un subtipo es semánticamente correcto.


4. Robert C. Martin — SOLID y Agile Software Development

Robert C. Martin (Uncle Bob) popularizó el acrónimo SOLID para referirse a cinco principios de diseño orientado a objetos. Tres de ellos conectan directamente con este debate:

  • LSP (Liskov Substitution Principle): ya discutido arriba.
  • OCP (Open/Closed Principle): las entidades software deben estar abiertas para extensión pero cerradas para modificación. Este principio se satisface frecuentemente mejor con composición (patrones Strategy, Decorator) que con herencia directa.
  • DIP (Dependency Inversion Principle): los módulos de alto nivel no deben depender de módulos de bajo nivel; ambos deben depender de abstracciones. Esto favorece composición con interfaces frente a herencia de clases concretas.En su libro Agile Software Development: Principles, Patterns, and Practices (2002), Martin desarrolla el concepto de “fragile base class problem”: cuando una superclase cambia, las subclases pueden romperse de maneras imprevistas, aunque el cambio parezca inocuo. Es exactamente el mismo problema que señalan GoF y Bloch, analizado desde la perspectiva de la gestión del cambio en sistemas ágiles.

Los dos criterios fundamentales

Para decidir entre herencia y composición, la literatura coincide en señalar dos criterios de decisión. El resto de las propiedades que se recogen a continuación son consecuencias que se derivan de la elección, no criterios en sí mismos.

Criterios de decisión:

CriterioHerenciaComposición
Relación semántica“es-un” (is-a)“tiene-un” (has-a)
Prueba de sustituciónLa subclase puede usarse donde se espera la superclase (LSP)No es necesaria la sustitución

El primer criterio (“es-un“) es necesario pero no suficiente. El segundo (LSP) es el que lo valida semánticamente: una relación “es-un” que no supera la prueba de sustitución no justifica la herencia.

Propiedades resultantes de la elección:

PropiedadHerenciaComposición
EncapsulaciónSe debilitaSe preserva
Flexibilidad en tiempo de ejecuciónFija en compilaciónPuede cambiar dinámicamente
AcoplamientoAlto (caja blanca)Bajo (caja negra)

Estas propiedades no son criterios de elección, sino consecuencias: si la herencia es semánticamente correcta, su mayor acoplamiento y menor flexibilidad son el precio a pagar. Si no lo es, esas mismas propiedades son razones adicionales para preferir la composición.


Ejemplos avanzados

Ejemplo 1: El clásico error — Stack sobre Vector

Este es el ejemplo canónico de Bloch. En la API estándar de Java, la clase Stack extiende Vector:

// Así está en java.util — ¡un ejemplo de lo que NO se debe hacer!
public class Stack<E> extends Vector<E> {
   public E push(E item) { ... }
   public E pop()       { ... }
   public E peek()       { ... }
   public boolean empty() { ... }
}

El problema es que Stack hereda toda la interfaz pública de Vector, incluyendo métodos como add(int index, E element) o remove(int index) que violan la semántica de una pila: permiten insertar y eliminar elementos en posiciones arbitrarias. Una pila no “es un” vector, tiene un vector. El diseño correcto es:

public class Pila<E> {
   private final List<E> elementos = new ArrayList<>();

   public void push(E elemento) {
       elementos.add(elemento);
  }

   public E pop() {
       if (empty()) throw new EmptyStackException();
       return elementos.remove(elementos.size() - 1);
  }

   public E peek() {
       if (empty()) throw new EmptyStackException();
       return elementos.get(elementos.size() - 1);
  }

   public boolean empty() {
       return elementos.isEmpty();
  }
}

Ahora la semántica es correcta: solo se exponen las operaciones de pila y la implementación interna (un ArrayList) puede cambiarse sin afectar a los clientes.


Ejemplo 2: La trampa del LSP — Rectángulo y Cuadrado

Este es el ejemplo más citado para ilustrar una violación del LSP. Matemáticamente, todo cuadrado es un rectángulo, así que parece razonable hacer:

public class Rectangulo {
   protected int ancho;
   protected int alto;

   public void setAncho(int ancho) { this.ancho = ancho; }
   public void setAlto(int alto)   { this.alto = alto;   }
   public int area() { return ancho * alto; }
}

public class Cuadrado extends Rectangulo {
   @Override
   public void setAncho(int lado) {
       this.ancho = lado;
       this.alto  = lado;   // ← mantiene el invariante del cuadrado
  }

   @Override
   public void setAlto(int lado) {
       this.ancho = lado;   // ← ídem
       this.alto  = lado;
  }
}

El problema surge cuando un método recibe un Rectangulo por parámetro:

public static void duplicarAlto(Rectangulo r) {
   int anchoOriginal = r.ancho;
   r.setAlto(r.alto * 2);
   // Esperamos que el ancho no haya cambiado
   assert r.ancho == anchoOriginal : "¡Si el parámetro es un Cuadrado, el ancho ha cambiado!";
}

Si pasamos un Cuadrado, la aserción falla: setAlto también modificó el ancho. El Cuadrado no puede sustituir al Rectangulo sin romper el programa, luego viola el LSP. La herencia es incorrecta aquí.

La solución es no usar herencia de implementación. Se puede usar una interfaz común, si tiene sentido semántico:

public interface Figura {
   int area();
}

public class Rectangulo implements Figura {
   private final int ancho, alto;
   public Rectangulo(int ancho, int alto) {
       this.ancho = ancho; this.alto = alto;
  }
   @Override public int area() { return ancho * alto; }
}

public class Cuadrado implements Figura {
   private final int lado;
   public Cuadrado(int lado) { this.lado = lado; }
   @Override public int area() { return lado * lado; }
}

Ambas clases son independientes (cada una con sus propios invariantes) y comparten solo la abstracción Figura. Es más seguro y más fiel a la semántica.


Ejemplo 3: Comportamiento variable: el patrón Strategy

Supongamos que tenemos vehículos que se desplazan de diferentes maneras. Un diseño basado en herencia podría ser:

// Diseño con herencia — rígido
public abstract class Vehiculo {
   public abstract void desplazarse();
}

public class Coche extends Vehiculo {
   @Override
   public void desplazarse() { System.out.println("Ruedo por la carretera"); }
}

public class Barco extends Vehiculo {
   @Override
   public void desplazarse() { System.out.println("Navego por el agua"); }
}

Funciona bien mientras el comportamiento sea fijo. Pero ¿qué ocurre con un vehículo anfibio que puede rodar o navegar? Con herencia simple no hay solución limpia. Con composición, el comportamiento se convierte en una dependencia que puede cambiar:

// Interfaz de estrategia
public interface ModoDeDesplazamiento {
   void desplazarse();
}

// Implementaciones concretas
public class Rodadura implements ModoDeDesplazamiento {
   @Override public void desplazarse() { System.out.println("Ruedo por la carretera"); }
}

public class Navegacion implements ModoDeDesplazamiento {
   @Override public void desplazarse() { System.out.println("Navego por el agua"); }
}

// La clase Vehiculo delega el comportamiento
public class Vehiculo {
   private ModoDeDesplazamiento modo;

   public Vehiculo(ModoDeDesplazamiento modo) {
       this.modo = modo;
  }

   public void setModo(ModoDeDesplazamiento modo) {
       this.modo = modo;   // ← cambiable en tiempo de ejecución
  }

   public void desplazarse() {
       modo.desplazarse();
  }
}

Ahora un vehículo anfibio puede cambiar de modo sin necesidad de crear nuevas subclases:

Vehiculo anfibio = new Vehiculo(new Rodadura());
anfibio.desplazarse();  // "Ruedo por la carretera"
anfibio.setModo(new Navegacion());
anfibio.desplazarse();  // "Navego por el agua"

Este es el patrón Strategy descrito por GoF y es un ejemplo paradigmático de composición que resuelve lo que la herencia no puede.


Ejemplo 4: Cuando la herencia SÍ es la elección correcta

La herencia no es mala en sí misma; es una herramienta potente cuando se usa adecuadamente. Es la elección correcta cuando:

  1. La relación “es-un” es genuina y estable.
  2. Se satisface el LSP: la subclase puede sustituir a la superclase sin sorpresas.
  3. La subclase extiende el comportamiento de la superclase sin alterar sus contratos.El ejemplo clásico en los cursos de POO es la jerarquía de figuras geométricas con polimorfismo:
public abstract class Figura {
   public abstract double area();
   public abstract double perimetro();

   public void describir() {
       System.out.printf("Área: %.2f, Perímetro: %.2f%n", area(), perimetro());
  }
}

public class Circulo extends Figura {
   private final double radio;

   public Circulo(double radio) { this.radio = radio; }

   @Override public double area()     { return Math.PI * radio * radio; }
   @Override public double perimetro() { return 2 * Math.PI * radio;   }
}

public class RectanguloFigura extends Figura {
   private final double base, altura;

   public RectanguloFigura(double base, double altura) {
       this.base = base; this.altura = altura;
  }

   @Override public double area()     { return base * altura;         }
   @Override public double perimetro() { return 2 * (base + altura);   }
}

Aquí la herencia es correcta porque:

  • Un Circulo es una Figura genuinamente.
  • El Circulo puede sustituir a cualquier Figura en cualquier contexto (LSP se cumple).
  • El polimorfismo permite escribir código genérico:
List<Figura> figuras = List.of(new Circulo(5), new RectanguloFigura(4, 3));
for (Figura f : figuras) {
   f.describir();
}

Resumen: guía de decisión

¿La relación entre B y A es realmente "B es un A"?

├── NO → Usa COMPOSICIÓN

└── SÍ → ¿Se cumple el LSP? (¿puede B sustituir a A sin romper nada?)
        │
        ├── NO → Usa COMPOSICIÓN (o rediseña la jerarquía)
        │
        └── SÍ → ¿A fue A diseñada para ser heredada (documentada, testada)?
                  │
                  ├── NO → Considera COMPOSICIÓN o habla con el autor de A
                  │
                  └── SÍ → HERENCIA es apropiada

Conclusión

La preferencia por la composición sobre la herencia no es una moda ni una regla arbitraria. Es una recomendación avalada por décadas de experiencia colectiva, formulada con rigor por algunos de los autores más influyentes del diseño de software:

  • GoF la elevan a principio de diseño fundamental (1994).
  • Barbara Liskov proporciona el criterio formal para saber cuándo la herencia es semánticamente válida (1987, 1994).
  • Joshua Bloch la convierte en consejo práctico inmediato para programadores Java (2001, 2018).
  • Robert C. Martin la integra en el marco SOLID y en su análisis de fragilidad del software (2002).La herencia tiene su lugar: es la herramienta adecuada cuando la relación “es-un” es semánticamente sólida y se satisface el LSP. Fuera de ese contexto, la composición produce diseños más flexibles, más encapsulados y más fáciles de mantener a largo plazo.

Referencias

  • Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
  • Liskov, B. (1987). Data Abstraction and Hierarchy. OOPSLA’87 Keynote.
  • Liskov, B., & Wing, J. (1994). A Behavioral Notion of Subtyping. ACM Transactions on Programming Languages and Systems, 16(6), 1811–1841.
  • Liskov, B., & Guttag, J. (2001). Program Development in Java: Abstraction, Specification, and Object-Oriented Design. Addison-Wesley.
  • Bloch, J. (2018). Effective Java (3ª ed.). Addison-Wesley. [Ítems 18 y 19]
  • Martin, R. C. (2002). Agile Software Development: Principles, Patterns, and Practices. Pearson Education.
  • Gamma, E. (2004). How to Use Design Patterns [entrevista, Part I]. Artima Developer. https://www.artima.com/articles/how-to-use-design-patterns
  • Gamma, E. (2004). Erich Gamma on Flexibility and Reuse [entrevista, Part II]. Artima Developer. https://www.artima.com/articles/erich-gamma-on-flexibility-and-reuse
  • Gamma, E. (2004). Design Principles from Design Patterns [entrevista, Part III]. Artima Developer. https://www.artima.com/articles/design-principles-from-design-patterns

Clases abstractas e interfaces en Java: lo que un objeto es frente a lo que puede hacer

Clases abstractas e interfaces: de qué estamos hablando

Cuando diseñamos una jerarquía de clases en Java, en ocasiones necesitamos definir un tipo padre que no tenga sentido instanciar directamente, sino que sirva como modelo para las clases que deriven de él. Java ofrece dos herramientas distintas para esto: las clases abstractas y las interfaces. Aunque a primera vista pueden parecer intercambiables, tienen diferencias importantes que conviene entender bien antes de elegir entre ellas.

Clases abstractas

Una clase abstracta es una clase que no puede instanciarse directamente. Se declara con la palabra clave abstract y puede contener:

  • Atributos de instancia (estado).
  • Constructores.
  • Métodos concretos (con implementación).
  • Métodos abstractos (sin implementación), que las subclases están obligadas a implementar.

El ejemplo que ya conoces de clase es la jerarquía de figuras geométricas:

public abstract class Figura {
   private String color;           // ← atributo de instancia

   public Figura(String color) {   // ← constructor
       this.color = color;
  }

   public String getColor() {      // ← método concreto
       return color;
  }

   public abstract double area();        // ← método abstracto
   public abstract double perimetro();   // ← método abstracto
}

Las subclases deben implementar obligatoriamente todos los métodos abstractos:

public class Circulo extends Figura {
   private double radio;

   public Circulo(String color, double radio) {
       super(color);
       this.radio = radio;
  }

   @Override
   public double area() {
       return Math.PI * radio * radio;
  }

   @Override
   public double perimetro() {
       return 2 * Math.PI * radio;
  }
}

Si una subclase no implementa todos los métodos abstractos, Java obliga a declararla también como abstract. Hay que tener en cuenta además que, en Java, una clase solo puede extender una única clase padre, sea esta abstracta o concreta. Esta restricción, conocida como herencia simple, tendrá importancia cuando hagamos la comparación con los interfaces.

Interfaces

Un interfaz es una declaración de comportamiento sin implementación. Se declara con la palabra clave interface y en su forma clásica solo puede contener:

  • Métodos abstractos (implícitamente son public abstract).
  • Constantes (implícitamente son public static final).

Una clase implementa un interfaz con la palabra clave implements:

public interface Dibujable {
   void dibujar();         // ← implícitamente public abstract
}

public interface Redimensionable {
   void redimensionar(double factor); // ← implícit. public abstract
}

Una misma clase puede implementar varias interfaces a la vez, lo que no es posible con la herencia de clases:

class Circulo extends Figura implements Dibujable, Redimensionable {
   private double radio;

   public Circulo(String color, double radio) {
       super(color);
       this.radio = radio;
  }

   @Override
public double area() { return Math.PI * radio * radio; }

   @Override
public double perimetro() {return 2 * Math.PI * radio; }

   @Override
public void dibujar()     {
System.out.println("Dibujando círculo");
}

   @Override
public void redimensionar(double factor) {
       this.radio *= factor;
  }
}

📎 Java 8 y los métodos default: a partir de Java 8, las interfaces pueden incluir métodos con implementación, declarados con la palabra clave default. Esto redujo parte de la distancia histórica entre interfaces y clases abstractas. Sin embargo, las interfaces siguen sin poder tener atributos de instancia ni constructores, lo que mantiene vigente la distinción fundamental entre ambas.

La tabla comparativa de partida

Clase abstractaInterfaz
InstanciaciónNo se puede instanciar directamenteNo se puede instanciar directamente
Atributos de instanciaNo
ConstructoresNo
Métodos concretosSolo con default (Java 8+)
Métodos abstractosSí (todos, por defecto)
Herencia/implementaciónUna sola clase padreMúltiples interfaces
Relación que modela“es-un” con estado compartidoContrato de comportamiento

Con estas dos herramientas claras, la pregunta es cuándo elegir cada una. Eso es lo que veremos en los siguientes apartados.


Herencia de implementación y herencia de tipos

Aunque son mecanismos distintos, tanto extender una clase abstracta como implementar un interfaz son formas de herencia en Java y es útil distinguirlas por su nombre:

  • Herencia de implementación (class inheritance): cuando una clase extiende una clase abstracta (o concreta). La subclase hereda tanto el contrato (los métodos) como la implementación (el código) de la clase padre.
  • Herencia de tipos (interface inheritance): cuando una clase implementa un interfaz. La clase hereda únicamente el contrato, sin ninguna implementación.

Ambas formas de herencia comparten una propiedad fundamental: el dynamic dispatch o asociación dinámica. Esto significa que, cuando llamamos a un método a través de una referencia del tipo padre, Java decide en tiempo de ejecución qué implementación ejecutar según el tipo real del objeto. El resultado es el mismo independientemente de si el tipo padre es una clase abstracta o un interfaz:

// Con clase abstracta
Figura f1 = new Circulo("rojo", 5.0);
Figura f2 = new Rectangulo("azul", 4.0, 3.0);
f1.area();   // ejecuta Circulo.area()
f2.area();   // ejecuta Rectangulo.area()

// Con interfaz
Dibujable d1 = new Circulo("rojo", 5.0);
Dibujable d2 = new Rectangulo("azul", 4.0, 3.0);
d1.dibujar();   // ejecuta Circulo.dibujar()
d2.dibujar();   // ejecuta Rectangulo.dibujar()

En ambos casos, el código que llama al método no sabe ni necesita saber qué tipo concreto tiene el objeto. Esa es la esencia del polimorfismo y funciona igual con los dos mecanismos.

Esta equivalencia es importante: la elección entre clase abstracta e interfaz no afecta al polimorfismo. Lo que cambia son las capacidades del tipo padre (si puede tener estado, constructores, lógica compartida) y las restricciones que impone sobre las clases que lo utilizan. Esos son precisamente los criterios que veremos a continuación.


La restricción más importante: herencia simple

Java permite que una clase extienda una sola clase padre. Esto es la herencia simple y se aplica tanto a clases concretas como a clases abstractas. En cambio, una clase puede implementar múltiples interfaces sin ninguna restricción.

Esta asimetría tiene consecuencias prácticas inmediatas. Veamos un ejemplo concreto.

Supongamos que estamos desarrollando un videojuego y tenemos la siguiente jerarquía:

public abstract class Personaje {
   protected String nombre;
   protected int vida;

   public Personaje(String nombre, int vida) {
       this.nombre = nombre;
       this.vida   = vida;
  }

   public abstract void atacar();
}

Ahora queremos crear un Mago que extienda Personaje. Sin problema:

public class Mago extends Personaje {
   public Mago(String nombre) {
       super(nombre, 100);
  }

   @Override
   public void atacar() {
       System.out.println(nombre + " lanza un hechizo.");
  }
}

Pero resulta que en nuestro juego algunos personajes pueden volar y otros pueden nadar. Queremos que el Mago pueda hacer ambas cosas. Si intentamos resolverlo con clases abstractas, el diseño se bloquea:

public abstract class Volador {
   public abstract void volar();
}

public abstract class Nadador {
   public abstract void nadar();
}

// ¡Esto no compila! Java no permite extender dos clases a la vez.
public class Mago extends Personaje, Volador, Nadador {
  ...
}

Java no lo permite. Hemos “gastado” la herencia en Personaje, y no podemos extender nada más. Con interfaces, el problema desaparece:

public interface Volador {
   void volar();
}

public interface Nadador {
   void nadar();
}

// Esto sí compila: una clase padre y los interfaces que necesitemos.
public class Mago extends Personaje implements Volador, Nadador {

   public Mago(String nombre) {
       super(nombre, 100);
  }

   @Override
   public void atacar() {
       System.out.println(nombre + " lanza un hechizo.");
  }

   @Override
   public void volar() {
       System.out.println(nombre + " vuela sobre las nubes.");
  }

   @Override
   public void nadar() {
       System.out.println(nombre + " nada bajo el agua.");
  }
}

El patrón es claro: la clase abstracta define lo que un objeto es (un Personaje, con su estado y su lógica común), mientras que los interfaces definen lo que un objeto puede hacer (volar, nadar, dibujarse, serializarse, compararse). Esta distinción es la clave para entender cuándo usar cada herramienta.


Cuándo sí tiene sentido una clase abstracta

La clase abstracta es la herramienta adecuada cuando el tipo padre necesita algo más que declarar un contrato:

  • Cuando tiene un estado propio que compartir con las subclases.
  • Cuando hay un constructor con una lógica común que no tiene sentido duplicar.
  • Cuando se quiere que las clases derivadas sigan los pasos de un algoritmo que deben ejecutarse siempre en el mismo orden.

Cuando hay estado compartido entre las subclases

Si varias subclases necesitan los mismos atributos, lo natural es declararlos en la clase abstracta y dejar que las subclases los hereden. Un interfaz no puede hacer esto porque no admite atributos de instancia.

En el ejemplo de figuras geométricas que ya conoces, todas las figuras tienen un color. Declararlo en la clase abstracta evita repetirlo en cada subclase:

public abstract class Figura {
   private String color;

   public Figura(String color) {
       this.color = color;
  }

   public String getColor() {
       return color;
  }

   public abstract double area();
   public abstract double perimetro();
}

Si Figura fuera un interfaz, cada subclase tendría que gestionar el color por su cuenta, duplicando código innecesariamente.

Cuando hay un constructor con lógica común

Relacionado con lo anterior: si la inicialización de los atributos compartidos requiere alguna lógica, como validaciones, transformaciones o inicializaciones complejas, esa lógica puede centralizarse en el constructor de la clase abstracta y reutilizarse mediante super() en todas las subclases.

public abstract class Figura {
   private String color;

   public Figura(String color) {
       if (color == null || color.isEmpty()) {
           throw new IllegalArgumentException(
"El color no puede estar vacío.");
      }
       this.color = color;
  }
}

Todas las subclases que llamen a super(color) se benefician automáticamente de esa validación sin tener que repetirla.

Cuando se quiere definir un algoritmo con pasos fijos

Este es el caso de uso más característico de las clases abstractas: el patrón Template Method. La idea es que la clase abstracta define la estructura de un algoritmo en un método concreto, pero delega algunos de sus pasos a las subclases mediante métodos abstractos.

Imaginemos que queremos modelar el proceso de preparar una bebida caliente. Los pasos son siempre los mismos: hervir agua, preparar la bebida, servir. Sin embargo, el paso de preparar la bebida varía según si es té o café:

public abstract class BebidaCaliente {
   private String nombre;

   public BebidaCaliente(String nombre) {
       this.nombre = nombre;
  }

   // Método plantilla: define el algoritmo completo
   public final void preparar() {
       hervirAgua();
       prepararBebida(); // ← variable, lo implementa cada subclase
       servir();
  }

   private void hervirAgua() {
       System.out.println("Hirviendo agua...");
  }

   protected abstract void prepararBebida(); // ← cada subclase

   private void servir() {
       System.out.println("Sirviendo " + nombre + ".");
  }
}

public class Cafe extends BebidaCaliente {
   public Cafe() {
       super("café");
  }

   @Override
   protected void prepararBebida() {
       System.out.println("Añadiendo café molido y filtrando.");
  }
}

public class Te extends BebidaCaliente {
   public Te() {
       super("té");
  }

   @Override
   protected void prepararBebida() {
       System.out.println("Añadiendo la bolsita de té y dejando reposar.");
  }
}

El uso sería:

BebidaCaliente bebida = new Cafe();
bebida.preparar();

Salida:

Hirviendo agua...
Añadiendo café molido y filtrando.
Sirviendo café.

Un interfaz no puede implementar este patrón en su forma clásica, porque no puede tener un método concreto que llame a métodos abstractos propios con garantía de orden y control. La clase abstracta es aquí la única herramienta adecuada.


Cuándo el interfaz es claramente superior

El interfaz es la herramienta adecuada cuando lo que queremos definir es un contrato de comportamiento sin imponer ninguna jerarquía ni compartir estado. Hay tres situaciones en las que el interfaz gana claramente a la clase abstracta:

  • Cuando una clase necesita cumplir varios contratos a la vez.
  • Cuando queremos añadir comportamiento a clases ya existentes.
  • Cuando queremos definir tipos para composición.

Cuando una clase necesita cumplir varios contratos a la vez

Ya lo vimos en la sección anterior: Java solo permite extender una clase, pero permite implementar múltiples interfaces. Siempre que una clase necesite comprometerse con más de un comportamiento independiente, el interfaz es la única opción viable.

Este es el caso más frecuente en la práctica y el argumento más inmediato para preferir interfaces a clases abstractas cuando no hay estado que compartir.

Cuando queremos añadir comportamiento a clases ya existentes

Supongamos que tenemos una clase Factura que ya extiende otra clase y no podemos cambiar su jerarquía. Si queremos que Factura sea comparable, para poder ordenar facturas por importe, por ejemplo, basta con implementar el interfaz Comparable:

public class Factura extends Documento implements Comparable<Factura> {
   private double importe;

   public Factura(double importe) {
       this.importe = importe;
  }

   @Override
   public int compareTo(Factura otra) {
       return Double.compare(this.importe, otra.importe);
  }
}

Con una clase abstracta esto sería imposible: Factura ya tiene su clase padre y no puede tener otra. El interfaz permite añadir capacidades a una clase existente sin tocar su jerarquía: encajar un interfaz en una clase que ya existe es sencillo; encajar una clase abstracta en una jerarquía ya establecida es, en general, imposible. A este mecanismo, Bloch lo llamó retroadaptación (retrofitting).

Cuando queremos definir tipos para composición

En ocasiones una clase no necesita ser algo, sino poder hacer algo. Y ese “poder hacer” puede combinarse con otras capacidades de forma flexible. En estos casos, el interfaz es la herramienta natural.

Imaginemos que estamos modelando vehículos que pueden desplazarse de diferentes maneras. Podríamos definir el comportamiento de desplazamiento como un interfaz:

public interface ModoDeDesplazamiento {
   void desplazarse();
}

public class Rodadura implements ModoDeDesplazamiento {
   @Override
   public void desplazarse() {
       System.out.println("Rodando por la carretera.");
  }
}

public class Navegacion implements ModoDeDesplazamiento {
   @Override
   public void desplazarse() {
       System.out.println("Navegando por el agua.");
  }
}

public class Vehiculo {
   private ModoDeDesplazamiento modo;

   public Vehiculo(ModoDeDesplazamiento modo) {
       this.modo = modo;
  }

   public void setModo(ModoDeDesplazamiento modo) {
       this.modo = modo;
  }

   public void desplazarse() {
       modo.desplazarse();
  }
}

El Vehiculo no sabe ni le importa si rueda o navega: solo conoce el interfaz ModoDeDesplazamiento y delega en él. Esto permite cambiar el comportamiento en tiempo de ejecución sin modificar la clase Vehiculo:

Vehiculo anfibio = new Vehiculo(new Rodadura());
anfibio.desplazarse();           // Rodando por la carretera.
anfibio.setModo(new Navegacion());
anfibio.desplazarse();           // Navegando por el agua.

ModoDeDesplazamiento podría haber sido una clase abstracta, pero entonces Rodadura y Navegacion habrían gastado su única herencia en ella, bloqueando cualquier otra jerarquía. Como interfaz, no impone ninguna restricción y el diseño queda abierto a cualquier combinación futura.

📎 La regla práctica de Bloch: en Effective Java, Joshua Bloch resume la preferencia por los interfaces con una pregunta sencilla: ¿necesita el tipo padre tener estado o lógica compartida? Si la respuesta es no, usa un interfaz. Si la respuesta es sí, considera una clase abstracta, o mejor aún, combina ambas herramientas con el patrón de implementación esquelética, que se comenta más adelante y seguramente desarrollaremos en un artículo posterior.

📎 Patrón Strategy: el diseño utilizado en el ejemplo tiene nombre propio en la literatura del software. Se llama patrón Strategy y es uno de los 23 patrones del libro de la Banda de los Cuatro (GoF). Lo reconocerás siempre por sus tres elementos: un interfaz que define el comportamiento intercambiable (la estrategia), varias clases que lo implementan (las estrategias concretas) y una clase que las usa delegando en ellas (el contexto). En nuestro ejemplo, ModoDeDesplazamiento es la estrategia, Rodadura y Navegacion son las estrategias concretas, y Vehiculo es el contexto.


Guía de decisión

¿El tipo padre necesita atributos de instancia o constructores con lógica común?

├── SÍ → Usa una CLASE ABSTRACTA
│      │
│      └── ¿Además necesitas cumplir varios contratos?
│            │
│            ├── SÍ → CLASE ABSTRACTA + INTERFACES adicionales
│            │
│            └── NO → Usa solo CLASE ABSTRACTA

└── NO → ¿Necesitas un algoritmo con pasos fijos y variables?
      │
      ├── SÍ → Usa una CLASE ABSTRACTA (patrón Template Method)
      │
      └── NO → Usa un INTERFAZ
            │
            └── ¿Implementación con partes comunes reutilizables?
                  │
                  ├── SÍ → INTERFAZ + CLASE ABSTRACTA esquelética
                  │
                  └── NO → Usa solo el INTERFAZ

📎 Nota: la combinación INTERFAZ + CLASE ABSTRACTA esquelética es un patrón especialmente potente que merece explicación detallada. Lo desarrollaremos en un artículo posterior.

📎 El patrón en la API de Java: si estás estudiando las colecciones de Java, ya has visto este patrón en acción. List es el interfaz que define el contrato, AbstractList es la clase abstracta esquelética que implementa la parte común y ArrayList es la implementación concreta. Lo mismo ocurre con Map y AbstractMap, o con Set y AbstractSet. Es uno de los patrones de diseño más utilizados en la propia API de Java.


Conclusión

Las clases abstractas y los interfaces son dos herramientas distintas para un problema similar: definir un tipo padre que sirva de modelo para otras clases. Elegir bien entre ellas no es una cuestión de estilo sino de diseño.

La regla general es sencilla: usa una clase abstracta cuando el tipo padre necesite compartir estado o lógica común con sus subclases, y usa un interfaz cuando solo necesites definir un contrato de comportamiento. En la práctica, ambas herramientas se complementan con frecuencia: el interfaz define el tipo y la clase abstracta proporciona una implementación parcial reutilizable.

Hay una pregunta que conviene hacerse siempre antes de decidir: ¿estoy definiendo lo que un objeto es, o lo que un objeto puede hacer? Si la respuesta es lo primero, la clase abstracta es probablemente la herramienta adecuada. Si es lo segundo, el interfaz es la elección natural.

Una vez clara la diferencia entre estas dos herramientas, el siguiente paso es preguntarse cuándo conviene usar herencia, sea de clase abstracta o de interfaz y cuándo es mejor usar composición. Esa es precisamente la pregunta que abordaremos en el próximo artículo.


Bárbara Liskov y el Principio de Sustitución de Liskov

Bárbara Liskov es una informática y matemática nacida en California en 1939, famosa por sus contribuciones en el mundo de los lenguajes de programación.

La profesora Liskov pertenece a la National Academy of Engineering de los Estados Unidos. En 2004 ganó la Medalla John Von Neumann, premio de ciencias de computación establecido por la dirección del IEEE, por «su contribución fundamental a los lenguajes de programación, metodologías de programación y sistemas distribuidos».

En 2008 se convirtió en la segunda mujer en la historia en ganar el premio Turing, considerado el Nóbel en ciencias de computación, por «su contribución a los fundamentos teóricos y prácticos en el diseño de lenguajes de programación y sistemas, especialmente relacionados con la abstracción de datos, tolerancia a fallos y computación distribuida».

En 2018, la Universidad Politécnica de Madrid le otorgó un doctorado honoris causa.

Además de sus artículos científicos, Bárbara Liskov ha publicado varios libros. De ellos, merece la pena mencionar en este contexto su libro “Program Development in Java: Abstraction, Specification, and Object-Oriented Design“. Se trata de un libro fácil de leer y con el que se puede aprender mucho acerca del diseño de aplicaciones en Java.

Quizás, la contribución a la programación por la que es más conocida es por su Principio de Sustitución de Liskov (Liskov Substitution Principle, LSP). El principio fue introducido por primera vez por Barbara Liskov en una conferencia en 1987, titulada “Data Abstraction and Hierarchy”. El principio fue reformulado y publicado formalmente en un artículo conjunto de Barbara Liskov y Jeannette Wing en 1994. Ese artículo se titula “A Behavioral Notion of Subtyping“, publicado en ACM Transactions on Programming Languages and Systems (TOPLAS), y es donde aparece la definición matemática que se cita habitualmente:

“Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.”

En términos de clases Java, el Principio de Sustitución de Liskov indica que, en cualquier contexto en el que se utilice un objeto de tipo T, tiene que ser posible utilizar en su lugar un objeto de tipo S, siendo S un subtipo de T (ya sea por herencia o por implementación de interfaz), sin que el programa funcione de manera inadecuada.”

El LSP es uno de los principios más importantes en relación con la arquitectura de software. Tiene fama de ser difícil de entender y es muy frecuente ver aplicaciones que lo violan flagrantemente.

En este artículo voy a hacer una introducción básica, explicada de manera sencilla mediante algún ejemplo, sobre cómo aplicar este principio a las jerarquías de clases de nuestros programas escritos en Java. El LSP es mucho más complejo de lo que aquí vamos a mostrar. Quizás en artículos posteriores profundice un poco más en el tema.

Primer ejemplo: jerarquía de archivos

Supongamos que estamos implementando una aplicación que maneja distintos tipos de archivos. Podríamos plantear una clase abstracta Archivo, que sirviera de base a los diferentes tipos de archivos, con los métodos correspondientes a las distintas operaciones que se pueden hacer con los archivos. Entre estos métodos podrían estar los métodos abstractos abrir() e imprimir():

public abstract class Archivo {
public abstract void abrir();
public abstract void imprimir();
   /** Otros métodos **/
}

Ahora, podríamos implementar un par de clases concretas que deriven de Archivo, por ejemplo, ArchivoTexto y ArchivoImagen:

public class ArchivoTexto extends Archivo {
@Override
public void abrir() {
System.out.println("Abriendo ArchivoTexto");
}
@Override
public void imprimir() {
System.out.println("Imprimiendo ArchivoTexto");
}
   /** Otros métodos **/
}

public class ArchivoImagen extends Archivo {
@Override
public void abrir() {
System.out.println("Abriendo ArchivoImagen");
}

@Override
public void imprimir() {
System.out.println("Imprimiendo ArchivoImagen");
}
   /** Otros métodos **/
}

Nuestra aplicación podría tener una clase, a la que hemos llamado GestorArchivos, que dispusiera de distintos métodos para realizar operaciones con los archivos. Uno de esos métodos podría ser el método imprimir(), que recibe un array de archivos y los va abriendo e imprimiendo:

public class GestorArchivos {
public void imprimir(Archivo[] archivos) {
for(Archivo archivo: archivos) {
archivo.abrir();
archivo.imprimir();
}
}
   /** Otros métodos **/
}

El programa cliente, podría ser parecido al siguiente:

public class Main_1 {
public static void main(String[] args) {
GestorArchivos gestor = new GestorArchivos();

Archivo a1 = new ArchivoTexto();
Archivo a2 = new ArchivoImagen();
Archivo[] archivos = {a1, a2};

gestor.imprimir(archivos);
}
}

El diagrama UML de la aplicación es el siguiente:

La salida de este programa sería:

Hasta este momento, todo está correcto. El programa cliente GestorArchivos utiliza los objetos del tipo Archivo a través del array que recibe como argumento en su método imprimir(). Los elementos de ese array pueden ser de cualquier tipo derivado de Archivo y todo funciona correctamente.

La aplicación crece…

Supongamos que la aplicación necesita ahora gestionar un nuevo tipo de archivos: ArchivoAudio. Podríamos derivar el tipo ArchivoAudio de nuestra clase abstracta Archivo. El problema es que este tipo de archivos no se puede imprimir y, por la jerarquía de herencia impuesta, está obligado a implementar el método imprimir().

Podríamos pensar en una solución que consistiera en lanzar una excepción si se intenta imprimir un objeto del tipo ArchivoAudio:

public class ArchivoAudio extends Archivo {
@Override
public void abrir() {
System.out.println("Abriendo ArchivoAudio");
}
@Override
public void imprimir() {
throw new UnsupportedOperationException();
}
}

Esta solución supone un trastorno: si se envía uno de estos archivos al método imprimir() de la clase GestorArchivos, el programa se interrumpiría.

Podríamos razonar de la siguiente manera: podemos modificar el método imprimir() de la clase GestorArchivos de forma que, si recibe un objeto del tipo ArchivoAudio, no intente imprimirlo, para que no se lance la excepción ni se interrumpa el programa:

public class GestorArchivos {
public void imprimir(Archivo[] archivos) {
for (Archivo archivo : archivos) {
if ((archivo instanceof ArchivoAudio) == false) {
archivo.abrir();
archivo.imprimir();
}
}
}
}

Esto es una violación del Principio de Sustitución de Liskov: nuestra jerarquía debería permitir la utilización de cualquier objeto de un subtipo de Archivo en el programa cliente, sin que el cliente se tenga que preocupar de discernir la clase concreta de la que se trata. Imagina que en el futuro se necesita gestionar otros tipos de archivos no imprimibles, por ejemplo archivos de vídeo. Nos veríamos obligados a modificar de nuevo la clase cliente GestorArchivos para incorporar una nueva excepción en todos los métodos en los que fuera necesario.

Esta violación del LSP es una señal de que la jerarquía que hemos planteado no es adecuada para el problema que queremos resolver. La clase base Archivo establece el contrato que deben cumplir las clases derivadas: tener métodos para abrir y para imprimir el archivo. Pero la clase ArchivoAudio incumple dicho contrato: los archivos de audio no se pueden imprimir.

La solución correcta es crear una jerarquía cuyos contratos se cumplan. Podemos partir de una clase abstracta Archivo con un método abrir() y derivar de ella una segunda clase abstracta ArchivoImprimible que incorpora un método imprimir() y sirve de clase base de todos los tipos de archivos que se pueden imprimir.

La solución correcta podría ser la que se refleja en el siguiente esquema:

Con esta nueva jerarquía, podríamos modificar el método imprimir() de GestorArchivos de la siguiente forma:

public class GestorArchivos {
public void imprimir(Archivo[] archivos) {
for (Archivo archivo : archivos) {
if (archivo instanceof ArchivoImprimible) {
ArchivoImprimible imprimible =
(ArchivoImprimible)archivo;
imprimible.abrir();
imprimible.imprimir();
}
}
}
}

Esta solución no viola el LSP. La comprobación que se hace dentro del método imprimir() no es la de si el objeto archivo es de una clase concreta, sino si dispone de cierta capacidad. No obstante, la observación del código permite intuir la solución más limpia: cambiar el tipo de parámetro del método imprimir(), para que solo pueda recibir objetos del tipo ArchivoImprimible:

public class GestorArchivos {
public void imprimir(ArchivoImprimible[] archivos) {
for (ArchivoImprimible archivo : archivos) {
archivo.abrir();
archivo.imprimir();
}
}
}

Ahora, el método imprimir() del programa cliente siempre recibe el tipo de archivos adecuado. No obstante, según las circunstancias de la aplicación, podría ser más conveniente una u otra solución. Las dos son correctas y ninguna de ellas viola el LSP.

La solución de jerarquía planteada también se podría hacer en base a un Interface Imprimible, en lugar de la clase abstracta ArchivoImprimible. Dejo a ejercicio del lector la solución a que daría lugar este otro planteamiento de la jerarquía.

Una violación más sutil del LSP

Siguiendo con el ejemplo anterior, supongamos que hubiera un tipo de archivos que, sí se pueden imprimir, pero necesitan una configuración previa. Pensemos en una hoja de cálculo: antes de imprimir, hay que establecer qué filas y columnas queremos incluir en la impresión. Podríamos implementar la clase HojaDeCalculo de la siguiente forma:

public class HojaDeCalculo extends ArchivoImprimible {

private boolean configurado;

public void configurar() {
configurado=true;
}
@Override
public void imprimir() {
if(configurado==false) {
throw new IllegalStateException();
}
System.out.println("Imprimiendo HojaDeCalculo...");
}
@Override
public void abrir() {
System.out.println("Abriendo HojaDeCalculo");
}
}

La clase HojaDeCalculo, en su método imprimir(), lanza una excepción si no se ha llamado previamente al método configurar(). Podríamos hacer lo siguiente en la clase GestorArchivos:

public class GestorArchivos {
public void imprimir(ArchivoImprimible[] archivos) {
for (ArchivoImprimible archivo : archivos) {
archivo.abrir();

if(archivo instanceof HojaDeCalculo) {
((HojaDeCalculo)archivo).configurar();
}

archivo.imprimir();
}
}
}

Como seguramente has intuido, este código viola el LSP: el programa cliente no tiene que identificar la clase concreta sobre la que está operando. En este caso, ademas de identificar una clase concreta, tiene que saber qué operaciones tiene que hacer en ella para poder usar el método imprimir().

La diferencia con el caso de los archivos imprimibles es que ahora el problema no está en la jerarquía, que podría ser adecuada. El problema es que estamos imponiendo que la clase GestorArchivo tiene que saber cómo operar un tipo de archivos concreto.

Piensa que este tipo de situación se podría dar en otros tipos de archivos: archivos que es necesario comprimir antes de enviar por correo electrónico, archivos que es necesario codificar en JSON o XML antes de enviarlos por Internet o cualquier otra situación. Y el programa cliente no es el que tiene que gestionar todas esas peculiaridades. Si lo hiciera, con cada nuevo tipo de archivo, habría que volver a codificar la clase GestorArchivos.

La solución es que sean las propias clases las que gestionen su estado con las operaciones que tengan que hacer. Podríamos modificar el método imprimir() de la clase HojaDeCalculo para que, si el archivo no está configurado, ella misma llame al método configurar():

@Override
public void imprimir() {
   if(configurado==false) {
       configurar();
  }
   System.out.println("Imprimiendo HojaDeCalculo...");
}

Ahora, el código de GestorArchivos puede operar perfectamente con cualquier ArchivvoImprimible que reciba:

public class GestorArchivos {
public void imprimir(ArchivoImprimible[] archivos) {
for (ArchivoImprimible archivo : archivos) {
archivo.abrir();
archivo.imprimir();
}
}
}

Hasta aquí, esta pequeña introducción al Principio de Sustitución de Liskov. Hay otros aspectos de este principio y otros tipos de violaciones del mismo que seguramente trataremos en otros artículos. Por el momento, basta con que entiendas el planteamiento más simple de la aplicación del LSP.

💡 12 reglas de buenas prácticas de programación (con ejemplos en Java)

Aprender a programar no consiste solo en hacer que un programa funcione.

Un programa puede funcionar… y ser difícil de entender, mantener o ampliar. Por eso, además de aprender sintaxis, es fundamental adoptar una serie de buenas prácticas que nos ayuden a escribir mejor código.

Muchas de estas reglas no son nuevas: aparecen una y otra vez en libros clásicos como:

  • Clean Code — Robert C. Martin
  • Effective Java — Joshua Bloch
  • Refactoring — Martin Fowler
  • The Pragmatic Programmer — Andrew Hunt y David Thomas
  • Code Complete — Steve McConnell

A continuación se presentan 12 reglas fundamentales, con ejemplos en Java.


1. Usa nombres que expresen intención

👉 Un buen nombre debe indicar claramente qué representa o qué hace la variable, el método o la clase, sin necesidad de comentarios.

📚 Referencia: Clean Code

🔸 Variables

❌ Mal:

int x;

✔️ Bien:

int edad;

🔸 Métodos

❌ Mal:

void f() {
   // ...
}

✔️ Bien:

void calcularMedia() {
   // ...
}

🔸 Clases

❌ Mal:

class Datos {
}

✔️ Bien:

class Alumno {
}

📎 Si necesitas un comentario para explicar qué hace una variable o un método, probablemente su nombre no es adecuado.

2. Las funciones deben ser pequeñas y hacer una sola cosa

👉 Una función debe tener una única responsabilidad y ser fácil de entender de un vistazo.

📚 Referencias: “Clean Code“, “Refactoring“.

📌 Relacionado con: SRP (Single Responsibility Principle)

🔸 Ejemplo

❌ Mal:

void procesarAlumno(Alumno alumno) {
   // validar edad
   if (alumno.getEdad() < 0) {
       System.out.println("Edad incorrecta");
  }

   // calcular media de notas
   double media = (alumno.getNota1() + alumno.getNota2()) / 2;

   // guardar en fichero
   System.out.println("Guardando alumno...");
}

✔️ Bien:

void procesarAlumno(Alumno alumno) {
   validarAlumno(alumno);
   double media = calcularMedia(alumno);
   guardarAlumno(alumno, media);
}

void validarAlumno(Alumno alumno) {
   if (alumno.getEdad() < 0) {
       System.out.println("Edad incorrecta");
  }
}

double calcularMedia(Alumno alumno) {
   return (alumno.getNota1() + alumno.getNota2()) / 2;
}

void guardarAlumno(Alumno alumno, double media) {
   System.out.println("Guardando alumno...");
}

📎 Refactorización
El paso de la versión “mal” a la versión “bien” no consiste en añadir funcionalidad, sino en mejorar la estructura del código sin cambiar su comportamiento.
Este proceso se conoce como refactorización (refactoring). En este caso concreto, hemos aplicado la técnica de extracción de métodos (extract method), dividiendo una función grande en varias funciones más pequeñas y especializadas.


⚠️ Señales de alerta

  • Si el nombre de la función contiene “y” (por ejemplo, calcularYGuardar) → probablemente hace más de una cosa
  • Si necesitas poner comentarios dentro de una función → probablemente deberías dividirla en varias funciones más pequeñas
  • Si una función es difícil de explicar en una sola frase → probablemente hace demasiadas cosas

3. Evita comentarios innecesarios

👉 El código debe ser lo suficientemente claro como para no necesitar comentarios que expliquen qué hace.

📚 Referencia: Clean Code

🔸 Ejemplo con variables

❌ Mal:

// Edad
int x;

✔️ Bien:

int edad;

En el primer caso, el comentario es necesario porque el nombre de la variable no aporta información. En el segundo, el propio nombre hace innecesaria cualquier explicación.

🔸 Ejemplo con funciones

❌ Mal:

void procesarAlumno(Alumno alumno) {
   // validar edad
   if (alumno.getEdad() < 0) {
       System.out.println("Edad incorrecta");
  }

   // calcular media
   double media = (alumno.getNota1() + alumno.getNota2()) / 2;

   // guardar alumno
   System.out.println("Guardando alumno...");
}

✔️ Bien:

void procesarAlumno(Alumno alumno) {
   validarAlumno(alumno);
   double media = calcularMedia(alumno);
   guardarAlumno(alumno, media);
}

En el primer caso, los comentarios son necesarios porque el código no es claro. En el segundo, los nombres de los métodos explican exactamente qué ocurre, por lo que los comentarios dejan de ser necesarios.


⚠️ Nota importante

Esto no significa que los comentarios sean malos.

Son útiles para:

  • explicar decisiones de diseño
  • documentar APIs
  • aclarar aspectos no evidentes

Pero no deberían usarse para explicar código que podría ser más claro.


Regla práctica

  • Si un comentario explica qué hace el código → probablemente el código puede mejorar.
  • Si un comentario explica por qué se hace algo → suele ser útil.

4. No repitas código (DRY)

👉 El principio DRY (Don’t Repeat Yourself, «no te repitas») establece que cada pieza de lógica debe tener una única representación en el código. Cuando el mismo cálculo o bloque de instrucciones aparece copiado en varios sitios, cualquier corrección futura obliga a localizar y modificar todas las copias; si se pasa por alto alguna, el programa queda en un estado inconsistente.

📚 Referencia: The Pragmatic Programmer

📌 Acrónimo: DRY (Don’t Repeat Yourself)

🔸 Ejemplo

❌ Mal:

public class Main {
public static void main(String[] args) {
double radio1 = 5.0;
double area1 = 3.14 * radio1 * radio1;
System.out.printf("Area del circulo 1: %.2f%n", area1);

double radio2 = 3.0;
double area2 = 3.14 * radio2 * radio2;
System.out.printf("Area del circulo 2: %.2f%n", area2);

double radio3 = 7.5;
double area3 = 3.14 * radio3 * radio3;
System.out.printf("Area del circulo 3: %.2f%n", area3);
}
}

En este código, el cálculo del área está duplicado en varios sitios. Si en el futuro se decide aumentar la precisión de π o añadir una comprobación sobre el radio, habría que modificar todas las apariciones.

✔️ Mejor:

public class Main {

public static void main(String[] args) {
double radio1 = 5.0;
double area1 = calcularAreaCirculo(radio1);
System.out.printf("Area del circulo 1: %.2f%n", area1);

double radio2 = 3.0;
double area2 = calcularAreaCirculo(radio2);
System.out.printf("Area del circulo 2: %.2f%n", area2);

double radio3 = 7.5;
double area3 = calcularAreaCirculo(radio3);
System.out.printf("Area del circulo 3: %.2f%n", area3);
}

static double calcularAreaCirculo(double radio) {
return Math.PI * radio * radio;
}
}

Ahora, el cálculo está centralizado en un único método. Cualquier cambio se realiza en un solo lugar, lo que reduce errores y facilita el mantenimiento.

Aún así, en main() se repiten tres bloques casi idénticos. Podríamos usar un bucle y simplificar el código:

✔️ Bien:

public class Main {
public static void main(String[] args) {
double[] radios = {5.0, 3.0, 7.5};
for(int i=0; i<radios.length; i++) {
double area = calcularAreaCirculo(radios[i]);
System.out.printf("Area del circulo %d: %.2f%n",
i+1, area);
}
}

static double calcularAreaCirculo(double radio) {
return Math.PI * radio * radio;
}
}

👉 Regla práctica

  • Si repites un mismo cálculo o una misma fórmula → probablemente estás violando DRY
  • Si un cambio obliga a modificar varios sitios → hay duplicación
  • DRY no solo evita repetir código, sino evitar repetir conocimiento

⚠️ Nota importante

En Java, muchas constantes de uso habitual ya están definidas en la biblioteca estándar. Por ejemplo, el número π está disponible como Math.PI, con toda la precisión necesaria. Siempre que sea posible, es preferible usar constantes ya definidas en lugar de redefinirlas.

5. Evita los números mágicos

👉 Los valores constantes deben tener un nombre significativo que explique qué representan y aparecer en el código a través de la constante que los representa, no codificados directamente como números mágicos.

📚 Referencia: Clean Code

🔸 Ejemplo

❌ Mal:

static double calcularPrecioConIva(double precio) {
return precio * 1.21;
}

static double calcularIva(double precio) {
return precio * 0.21;
}

En este código, el valor 0.21 aparece repetido en varias funciones. Si el tipo de IVA cambia, hay que buscar y modificar todas las apariciones, con el riesgo de pasar alguna por alto. Además, alguien que lea el código por primera vez no sabe inmediatamente qué significa ese valor.

✔️ Bien:

static final double IVA = 0.21;

static double calcularPrecioConIva(double precio) {
return precio * (1 + IVA);
}

static double calcularIva(double precio) {
return precio * IVA;
}

Ahora el valor tiene un nombre que explica su significado, y aparece en un único lugar. Si el tipo impositivo cambia, la modificación se hace una sola vez y queda actualizada en todas las partes del programa que lo utilizan.

Regla práctica

  • Si aparece un número «suelto» en el código → probablemente debería ser una constante
  • Si no sabes qué significa un valor sin leer más código → necesita un nombre
  • Las constantes hacen el código más claro y más fácil de mantener

6. Evita listas largas de parámetros

👉 Cuando una función tiene muchos parámetros, suele ser difícil de entender y utilizar correctamente.

📚 Referencia: Effective Java

No hay un número mágico, pero como regla general:

  • 1–2 parámetros → normal
  • 3 parámetros → empieza a ser sospechoso
  • 4 o más → probablemente hay un problema de diseño

🔸 Ejemplo

❌ Mal:

void imprimirUsuario(String nombre, int edad, String email, String telefono) {
System.out.println(nombre + " (" + edad + ")");
...
}

✔️ Bien:

class Usuario {
String nombre;
int edad;
String email;
String telefono;
}

void imprimirUsuario(Usuario usuario) {
System.out.println(usuario.nombre + " (" + usuario.edad + ")");
...
}

En el primer caso, la función recibe muchos datos independientes, lo que la hace más difícil de usar y más propensa a errores (por ejemplo, cambiar el orden de los parámetros). En el segundo, los datos están agrupados en un objeto que representa un concepto del dominio (Usuario), lo que hace el código más claro y más fácil de manejar.

Por ahora los campos de Usuario son accesibles directamente desde fuera de la clase. En la siguiente regla veremos por qué eso puede ser un problema y cómo solucionarlo.

Regla práctica

  • Si una función tiene muchos parámetros → revisa si puedes agruparlos en un objeto
  • Si varios parámetros están relacionados → probablemente deberían formar una clase
  • Si dudas sobre el orden de los parámetros → el diseño puede mejorarse

🔥 Nota avanzada

👉 En algunos casos, cuando es necesario crear objetos con muchos parámetros, se utilizan técnicas como el patrón Builder, que se estudiarán en asignaturas más avanzadas.

7. Prefiere objetos inmutables

👉 Un objeto inmutable es aquel cuyo estado no puede cambiar después de crearse.

📚 Referencia: Effective Java

🔸 Ejemplo

❌ Mal:

class Usuario {
   String nombre;
   int edad;
}
Usuario u = new Usuario();
u.nombre = "Ana";
u.edad = 20;
u.edad = -5; // nadie lo impide

En este código, los campos son accesibles directamente desde fuera de la clase. Cualquier parte del programa puede modificar el estado del objeto en cualquier momento, incluso asignando valores inválidos.

✔️ Bien:

class Usuario {
   private final String nombre;
   private final int edad;

   public Usuario(String nombre, int edad) {
       this.nombre = nombre;
       this.edad = edad;
  }

   public String getNombre() { return nombre; }
   public int getEdad() { return edad; }
}

Ahora los campos son privados y no pueden modificarse tras la construcción. El objeto se crea de una vez con todos sus datos y su estado no cambia.

En la regla anterior agrupábamos datos en un objeto (Usuario). Aquí damos un paso más: hacemos que ese objeto sea seguro y consistente.

Regla práctica

  • Si un objeto no necesita cambiar → hazlo inmutable
  • Si un atributo no debe modificarse → decláralo final
  • Evita exponer atributos directamente → usa encapsulación

⚠️ Nota importante

La inmutabilidad aporta varias ventajas:

  • Evita errores difíciles de detectar
  • Facilita el razonamiento sobre el código
  • Hace el código más seguro en entornos concurrentes

8. Maneja correctamente los errores

👉 Los errores no deben ignorarse: deben detectarse y tratarse de forma adecuada.

📚 Referencia: Effective Java

🔸 Ejemplo

En la regla anterior hicimos que Usuario fuera inmutable. Pero aún es posible construir un objeto con valores inválidos:

❌ Mal:

class Usuario {
private final String nombre;
private final int edad;

public Usuario(String nombre, int edad) {
this.nombre = nombre;
this.edad = edad; // no se valida
}
}
Usuario u = new Usuario("Ana", -5); // se crea sin error

El objeto se construye sin problemas, aunque la edad sea negativa. El error pasa desapercibido.

✔️ Bien:

class Usuario {
private final String nombre;
private final int edad;

public Usuario(String nombre, int edad) {
if (edad < 0) {
throw new IllegalArgumentException("La edad no puede ser negativa");
}
this.nombre = nombre;
this.edad = edad;
}

public String getNombre() { return nombre; }
public int getEdad() { return edad; }
}

Ahora el error se detecta en el momento adecuado y se comunica mediante una excepción. Es imposible construir un Usuario con un estado inválido.

🔸 Ejemplo de uso

❌ Mal:

try {
   Usuario u = new Usuario("Ana", -5);
} catch (Exception e) {
   // ignorar el error
}

✔️ Bien:

try {
   Usuario u = new Usuario("Ana", -5);
} catch (IllegalArgumentException e) {
   System.out.println("Error al crear el usuario: " + e.getMessage());
}

En el primer caso, el error se captura pero se ignora, lo que puede ocultar problemas graves. En el segundo, se gestiona de forma explícita.

Regla práctica

  • No ignores excepciones → siempre haz algo con ellas
  • Usa tipos de excepción específicos (IllegalArgumentException, etc.)
  • Valida los datos lo antes posible, preferiblemente en el constructor

⚠️ Nota importante

Lanzar una excepción no es «fallar», es:

  • detectar un problema antes de que se propague
  • evitar que el objeto quede en un estado incorrecto
  • hacer el error visible en lugar de ocultarlo

Conexión con la regla anterior

En la regla anterior hicimos el objeto Usuario inmutable: sus campos no pueden cambiar tras la construcción. Aquí añadimos validación para garantizar que el objeto nunca se construya en un estado inválido. Las dos reglas juntas aseguran que Usuario sea siempre correcto.

9. Escribe código fácil de leer (KISS)

👉 La solución más simple y clara suele ser la mejor.

📚 Referencia: The Pragmatic Programmer

📌 Acrónimo: KISS (Keep It Simple, Stupid)

🔸 Ejemplo

❌ Mal:

if ((edad >= 18 && edad <= 65 && !tieneDeudas) || esEmpleadoVIP) {
permitirAcceso();
}

✔️ Bien:

boolean edadValida = edad >= 18 && edad <= 65;
boolean clienteSinDeudas = !tieneDeudas;
boolean accesoEspecial = esEmpleadoVIP;

if ((edadValida && clienteSinDeudas) || accesoEspecial) {
permitirAcceso();
}

En el primer caso, la condición es difícil de leer y entender de un vistazo. En el segundo, el uso de variables intermedias permite expresar claramente la intención del código.

Regla práctica

  • Si una expresión es difícil de leer → divídela
  • Usa variables con nombres significativos
  • Prefiere claridad frente a “código compacto”

⚠️ Nota importante

Escribir menos código no siempre significa escribir mejor código. A veces, añadir unas pocas líneas mejora mucho la legibilidad.

Conexión con reglas anteriores

Igual que en reglas anteriores, los nombres (edadValida, clienteSinDeudas) hacen que el código se explique por sí mismo, evitando la necesidad de comentarios.

10. Escribe tests siempre que puedas

👉 Un test permite comprobar automáticamente que una parte del programa funciona como esperamos.

📚 Referencia: Test Driven Development: By Example

📌 Relacionado con: TDD (Test-Driven Development)

🔸 Ejemplo

Supongamos que queremos implementar un método que determine si un alumno está aprobado.

❌ Sin test:

class Evaluacion {
   static boolean estaAprobado(double nota) {
       return nota > 5; // error sutil
  }
}

✔️ Con test:

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test;

class EvaluacionTest {

   @Test
   void aprobadoConCinco() {
       assertTrue(Evaluacion.estaAprobado(5));
  }

   @Test
   void suspensoConMenosDeCinco() {
       assertFalse(Evaluacion.estaAprobado(4.9));
  }
}

En este caso, el código tiene un error (> 5 en lugar de >= 5) que es fácil de cometer y difícil de detectar a simple vista. Al ejecutar el test y ver que falla, podemos localizar enseguida el error. El test define claramente el comportamiento esperado y permite detectar el error de forma inmediata.

class Evaluacion {
   static boolean estaAprobado(double nota) {
       return nota >= 5;
  }
}

Regla práctica

  • Si una regla tiene límites (≥, ≤, etc.) → es importante testearla
  • Los tests ayudan a detectar errores pequeños pero importantes
  • Un buen test describe claramente el comportamiento esperado

⚠️ Nota importante

Los tests son especialmente útiles cuando:

  • Hay condiciones límite.
  • Hay reglas del dominio (como “aprobado”).
  • El código puede cambiar en el futuro.

Conexión con reglas anteriores

Igual que en la regla DRY, aquí también aparece una regla del sistema (“qué es aprobar”). Los tests permiten asegurar que esa regla se cumple siempre.

11. Refactoriza constantemente

👉 Mejorar el código es parte del desarrollo, no una fase posterior.

📚 Referencia: Refactoring

🔸 Ejemplo

Supongamos que ya tenemos un código que funciona correctamente:

class Evaluacion {
   static boolean estaAprobado(double nota) {
       return nota >= 5;
  }

   static String mensajeResultado(double nota) {
       if (nota >= 5) {
           return "Aprobado";
      } else {
           return "Suspenso";
      }
  }
}

El código funciona, pero hay duplicación.

✔️ Refactorizado:

class Evaluacion {
   static boolean estaAprobado(double nota) {
       return nota >= 5;
  }

   static String mensajeResultado(double nota) {
       if (estaAprobado(nota)) {
           return "Aprobado";
      } else {
           return "Suspenso";
      }
  }
}

El código inicial es correcto, pero repite la misma lógica (nota >= 5) en varios sitios. Al refactorizar, reutilizamos el método estaAprobado(), haciendo el código más claro y evitando duplicación.

Regla práctica

  • Refactorizar no es “arreglar errores”, sino mejorar el código
  • Si ves duplicación → refactoriza
  • Si algo se puede expresar mejor → refactoriza

⚠️ Nota importante

Refactorizar significa:

  • No cambiar el comportamiento.
  • Mejorar la estructura interna.
  • Facilitar futuras modificaciones.

Conexión con reglas anteriores

En esta regla se aplican varias ideas vistas antes:

  • DRY → eliminamos duplicación
  • nombres claros → estaAprobado
  • funciones pequeñas → mejor organización

📎 Un buen programador no solo escribe código que funciona, sino que mejora continuamente el código que ya funciona.

12. Piensa antes de programar

👉 Antes de escribir código, es fundamental entender el problema y diseñar una solución.

📚 Referencia: The Pragmatic Programmer

🔸 Ejemplo

Supongamos que queremos gestionar los datos de varios alumnos y determinar si han aprobado. Sin reflexionar sobre el diseño, es habitual empezar así:

❌ Mal:

String[] nombres  = {"Ana", "Luis", "María"};
double[] notas    = {8.5, 4.0, 9.0};

for (int i = 0; i < nombres.length; i++) {
   if (notas[i] >= 5) {
       System.out.println(nombres[i] + ": Aprobado");
  } else {
       System.out.println(nombres[i] + ": Suspenso");
  }
}

El código funciona para este caso concreto, pero los datos de un alumno están repartidos en arrays distintos. Si añadimos un atributo nuevo (por ejemplo, el email), necesitamos un tercer array y recordar mantener siempre los tres sincronizados. La relación entre los datos no está expresada en el código.

✔️ Bien:

class Alumno {
   String nombre;
   double nota;

   public Alumno(String nombre, double nota) {
       this.nombre = nombre;
       this.nota = nota;
  }

   boolean estaAprobado() {
       return nota >= 5;
  }
}
...

Alumno[] alumnos = {
   new Alumno("Ana",  8.5),
   new Alumno("Luis", 4.0),
   new Alumno("María", 9.0)
};

for (Alumno a : alumnos) {
   String resultado = a.estaAprobado() ? "Aprobado" : "Suspenso";
   System.out.println(a.nombre + ": " + resultado);
}

Al modelar el problema antes de codificarlo, los datos de cada alumno están encapsulados en un objeto y la lógica de negocio («¿está aprobado?») vive junto a los datos a los que pertenece. Si mañana necesitamos guardar también el email de cada alumno, en la versión con arrays tendríamos que añadir un tercer array y mantenerlo sincronizado con los otros dos. En la versión con clase, basta con añadir un campo a Alumno y el resto del código no se ve afectado.

En un programa real aplicaríamos también las reglas 7 y 8: haríamos los campos privados y validaríamos los datos en el constructor.

Regla práctica

  • Antes de programar → identifica los conceptos del problema
  • Agrupa en una clase los datos que representan una misma entidad
  • La lógica que pertenece a un dato debe estar cerca de ese dato

⚠️ Nota importante

Muchos problemas de programación no son de sintaxis, sino de diseño. Dedicar unos minutos a pensar qué entidades intervienen y cómo se relacionan suele simplificar mucho el código resultante.

Conexión con todo el artículo

Esta regla resume todas las anteriores:

  • Nombres claros → pensar qué representan los datos
  • Funciones pequeñas → diseñar responsabilidades
  • DRY → identificar conocimiento común
  • Inmutabilidad → decidir qué debe cambiar

Todas las buenas prácticas empiezan antes de escribir código.

📎 Programar bien no consiste solo en escribir código que funcione, sino en construir soluciones que otros puedan entender, mantener y mejorar. Y todo ello empieza mucho antes de escribir la primera línea de código.

📊 Resumen de reglas

#ReglaAcrónimoReferencia
1Usa nombres que expresen intenciónClean Code
2Funciones pequeñas y con una sola responsabilidadSRPClean Code, Refactoring
3Evita comentarios innecesariosClean Code
4No repitas códigoDRYThe Pragmatic Programmer
5Evita números mágicosClean Code
6Evita listas largas de parámetrosEffective Java
7Prefiere objetos inmutablesEffective Java
8Maneja correctamente los erroresEffective Java
9Escribe código simple y legibleKISSThe Pragmatic Programmer
10Escribe testsTDDTest Driven Development: By example
11Refactoriza constantementeRefactoring
12Piensa antes de programarThe Pragmatic Programmer

Estas 12 reglas resumen algunos de los principios más importantes que aparecen una y otra vez en la práctica profesional.

📎 Estas reglas no son leyes estrictas, pero sí principios que aparecen constantemente en la práctica profesional. No deben memorizarse de forma aislada. En la práctica, suelen aparecer juntas y se refuerzan unas a otras. Aprenderlas desde el principio marca una gran diferencia:

  • el código funciona,
  • se entiende
  • y puede evolucionar

Programar bien no es aplicar reglas de forma mecánica, sino desarrollar el criterio para saber cuándo y cómo aplicarlas. Porque, al final, programar bien no es solo resolver problemas, sino resolverlos de forma que otros puedan entender, mantener y continuar tu trabajo.

📚 12 libros imprescindibles para aprender a programar (bien)

Aprender a programar es relativamente fácil. Aprender a programar bien es otra historia.

Aprender a programar no consiste solo en dominar un lenguaje. De hecho, eso es casi lo de menos. Lo verdaderamente importante es aprender a pensar como programador, a escribir código que otros puedan entender, mantener y evolucionar. Es decir, desarrollar criterio.

En la enseñanza de la programación, es habitual centrar la atención en lenguajes, herramientas o entornos de desarrollo. Sin embargo, estos aspectos son, en gran medida, circunstanciales.

Lo que realmente distingue a un buen programador es su capacidad para escribir código comprensible, diseñar soluciones adecuadas y tomar decisiones fundamentadas.

En este artículo se presenta una selección de doce libros que ayudan precisamente a eso: a ir más allá de la sintaxis y comprender qué significa realmente construir buen software. Son libros que enseñan buenas prácticas, diseño y fundamentos sólidos.

La mayoría son clásicos, pero se complementan con algunas referencias más modernas que reflejan la práctica actual.

📎 A comienzos de los años 2000, varios de los autores mencionados en este artículo participaron en la redacción del Manifiesto Ágil, un breve pero influyente texto que redefinió la forma de entender el desarrollo de software. Frente a enfoques más rígidos y planificados, el manifiesto propone priorizar a las personas y la comunicación, el software funcionando, la colaboración con el cliente y la capacidad de adaptación al cambio.

A partir de estos principios surgieron diversas metodologías y prácticas que han marcado profundamente la ingeniería del software moderna, como Scrum o Extreme Programming. Muchas de las ideas presentes en libros como Clean Code, Refactoring o Test Driven Development están estrechamente relacionadas con esta forma de entender el desarrollo: iterativo, incremental y centrado en la calidad del código.


🧼 Escribir código limpio y mantenible

1. Clean Code

Autor: Robert C. Martin

Si tuviera que recomendar un solo libro, sería este. Probablemente se trata del libro más influyente sobre calidad del código.

Su idea central es sencilla pero profunda: el código debe ser legible y expresar intención. Clean Code enseña a escribir código que se entienda, que sea legible y que transmita intención. Habla de nombres, funciones, comentarios, tests… pero, sobre todo, de disciplina.

Sobre el autor: Robert C. Martin, conocido como “Uncle Bob”, es uno de los firmantes del Manifiesto Ágil y ha sido una figura clave en la difusión de las buenas prácticas en ingeniería del software durante décadas.


2. Code Complete

Autor: Steve McConnell

Una visión sistemática y global del desarrollo de software. Desde detalles de implementación hasta calidad y mantenimiento.

Es más denso que otros libros, pero muy recomendable.

Sobre el autor: McConnell es también autor de Rapid Development y fundador de Construx Software, una consultora centrada en mejorar procesos de desarrollo en empresas. Antes, trabajó en Microsoft y Boeing. Se le atribuye la afirmación de que, de media, cada 1000 líneas de código en producción tienen entre 15 y 50 errores.


3. Refactoring

Autor: Martin Fowler

El término refactoring se refiere a modificar un código sin cambiar su funcionalidad. El principio que guía el libro es que el código no se escribe una sola vez, sino que hay que mejorarlo constantemente.

Este libro enseña cómo transformar código existente sin cambiar su comportamiento, apoyándose en pequeñas mejoras seguras.

Sobre el autor: Martin Fowler es uno de los referentes en arquitectura de software y divulgación técnica, especialmente conocido por su trabajo en patrones de arquitectura empresarial. Fue uno de los firmantes del Manifiesto Ágil.


🏗️ Diseño y arquitectura del software

4. Design Patterns

Autores: Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides.

Este libro es un clásico imprescindible. A partir de su publicación en 1994 se han escrito cientos de libros y artículos tratando o desarrollando los patrones de diseño que se describen en el libro.

Introduce soluciones reutilizables a problemas de diseño en programación orientada a objetos. Aporta un vocabulario común para hablar de arquitectura. Después de leerlo, verás patrones de diseño por todos los lados, cada vez que trabajes en código.

Sobre los autores: conocidos como la “banda de los cuatro” (GoF), varios de ellos participaron en el desarrollo de herramientas clave como Eclipse, lo que demuestra la aplicación práctica de sus ideas.


5. Clean Architecture

Autor: Robert C. Martin

Explica cómo organizar sistemas complejos para que sean mantenibles y resistentes al cambio.

Ideal cuando se da el salto de programas pequeños a aplicaciones reales.

Sobre el autor: además de su faceta técnica, Martin ha sido un activo formador y conferenciante, contribuyendo a establecer estándares de calidad en el desarrollo profesional.


☕ Buenas prácticas aplicadas (Java)

6. Effective Java

Autor: Joshua Bloch

Aquí hago una excepción: un libro centrado en un lenguaje concreto.

Effective Java es una colección de recomendaciones prácticas sobre cómo usar Java correctamente. Pero su verdadero valor es que traduce principios generales (inmutabilidad, encapsulación, diseño de APIs…) a decisiones concretas de código.

Para quien aprende Java, es probablemente el libro más útil de esta lista en el corto plazo.

Sobre el autor: Joshua Bloch fue uno de los principales arquitectos de la biblioteca estándar de Java. Durante su etapa en Sun Microsystems, fue el diseñador del Java Collections Framework, lo que da a este libro un valor especialmente sólido.


🧪 Desarrollo guiado por pruebas y programación extrema

Muchas de las prácticas modernas de desarrollo tienen su origen en la Extreme Programming (XP), propuesta por Kent Beck a finales de los años 90. XP plantea llevar al extremo una idea sencilla: si algo es bueno en el desarrollo de software, hacerlo de forma continua.

En este contexto surge el desarrollo guiado por pruebas (Test Driven Development, TDD), una práctica en la que primero se escriben las pruebas y después el código que las satisface. Este enfoque no solo mejora la calidad del software, sino que también influye directamente en su diseño, favoreciendo soluciones más simples, modulares y fáciles de mantener.

Muchas de las ideas presentes en libros como Refactoring, Clean Code o Working Effectively with Legacy Code encajan de forma natural con esta filosofía: el código se construye y se mejora de manera iterativa, con feedback constante y con una fuerte atención a su calidad interna.

7. Test Driven Development: By Example

Autor: Kent Beck

Introduce una de las prácticas más influyentes del desarrollo moderno: escribir primero las pruebas y después el código.

Más allá de la técnica, muestra cómo TDD conduce a diseños más simples, código más modular y mayor confianza en los cambios.

Sobre el autor: Kent Beck es uno de los creadores de la programación extrema (XP, Extreme Programming) y uno de los firmantes del Manifiesto Ágil, con una enorme influencia en las prácticas modernas de desarrollo.

📎 Kent Beck desarrolló XP mientras trabajaba en el proyecto Chrysler Comprehensive Compensation System (C3), donde buscaba mejorar la calidad del software y la capacidad de adaptación al cambio.

El nombre “extreme” no es casual. La idea era llevar al extremo prácticas que ya se consideraban buenas:

  • Si las revisiones de código son buenas → revisar constantemente (pair programming).
  • Si probar es bueno → probar todo el tiempo (TDD).
  • Si integrar es bueno → integrar continuamente.
  • Si el diseño es importante → refactorizar continuamente.

XP se consolidó con el libro: Extreme Programming Explained (1999), también de Kent Beck. Este libro definió prácticas como:

  • Test-Driven Development.
  • Pair Programming.
  • Continuous Integration.
  • Small Releases.

🧠 Mentalidad y oficio del programador

8. The Pragmatic Programmer

Autores: Andrew Hunt y David Thomas

Este libro, más que técnicas, enseña actitud.

Responsabilidad, aprendizaje continuo, automatización… Un libro muy accesible y lleno de ideas prácticas.

Sobre los autores: Andrew Hunt y David Thomas también fueron firmantes del famoso Agile Manifesto en 2001. Fundaron la editorial Pragmatic Bookshelf, muy influyente en la literatura moderna de programación. También se les atribuye el acrónimo del principio de desarrollo DRY: Don’t Repeat Yourself.


9. Working Effectively with Legacy Code

Autor: Michael Feathers

Si bien los principios SOLID fueron formulados por Robert C. Martin a comienzos de los años 2000, el acrónimo SOLID fue popularizado posteriormente por Michael Feathers (2004).

Los principios SOLID son:

  • Single responsability principle.
  • Open-closed principle.
  • Liskov substitution principle.
  • Interface segregation principle.
  • Dependency inversion principle.

Este libro explica cómo enfrentarse a código existente (y a menudo problemático).

Especialmente útil en entornos reales, donde rara vez se empieza desde cero.

Sobre el autor: Feathers es conocido por su trabajo en metodologías ágiles y por acuñar definiciones muy utilizadas, como considerar “legacy code” aquel que no tiene tests.


10. The Mythical Man-Month

Autor: Frederick P. Brooks Jr.

Este libro es un clásico sobre la gestión de proyectos software.

Explica por qué los proyectos se retrasan y desmonta mitos muy extendidos (como que añadir más desarrolladores acelera el trabajo).

Sobre el autor: Frederick Brooks es conocido como el padre del IBM System/360, pues dirigió el desarrollo del sistema operativo OS/360 en IBM, una experiencia que dio origen a muchas de las reflexiones del libro.


🧩 Fundamentos: pensar como programador

11. Structure and Interpretation of Computer Programs

Autores: Harold Abelson y Gerald Jay Sussman

“The Wizard Book”, un libro exigente, pero muy formativo.

Enseña a razonar sobre programas y a entender profundamente qué significa programar. Hay una cita de este libro que suelo decir en clase y que me encanta:

Los programas deben escribirse para que las personas los lean y, solo de forma incidental, para que las máquinas los ejecuten.

Sobre los autores: ambos son profesores del MIT, y este libro ha sido durante décadas el núcleo de uno de los cursos más influyentes de programación del mundo.


12. Introduction to Algorithms

Autores: Thomas H. Cormen et al.

El estándar en algoritmia.

No trata directamente el estilo de código, pero sí proporciona rigor: eficiencia, complejidad y modelado de problemas.

Sobre los autores: el libro reúne a varios investigadores de primer nivel y es utilizado como manual en universidades de todo el mundo.


🚀 Más allá de los clásicos: referencias modernas

Los libros anteriores comparten una característica: muchos tienen décadas… y siguen siendo fundamentales. Esto se debe a que los principios básicos del buen software cambian muy poco.

Sin embargo, el contexto sí ha evolucionado. Hoy desarrollamos sistemas distribuidos, trabajamos en equipos grandes y desplegamos continuamente.

Para comprender ese entorno, pueden complementarse con algunas obras más recientes:

Designing Data-Intensive Applications, Martin Kleppmann

Sobre el autor: Kleppmann es investigador en sistemas distribuidos en la Universidad de Cambridge, lo que aporta una base académica muy sólida al libro.


A Philosophy of Software Design, John Ousterhout

Sobre el autor: Ousterhout es el creador del lenguaje Tcl y ha trabajado durante décadas en sistemas y diseño de software.


Software Engineering at Google, Google

Sobre los autores: el libro recoge prácticas reales de ingeniería dentro de Google, una de las organizaciones con mayor escala en desarrollo software.


Modern Software Engineering, David Farley

Sobre el autor: Farley es uno de los impulsores del concepto de Continuous Delivery, ampliamente adoptado en la industria actual.

🤔 ¿Por qué mezclar tantos enfoques?

Porque programar bien no es una sola cosa. Implica:

  • escribir código claro (Clean Code),
  • diseñar bien (Design Patterns, Clean Architecture),
  • tomar buenas decisiones (Effective Java),
  • y entender los fundamentos (algoritmos).

Cada uno de estos libros ataca una parte del problema. Si estás empezando o consolidando tu formación como programador, una posible ruta sería:

  1. Clean Code y The Pragmatic Programmer.
  2. Refactoring y Effective Java.
  3. Design Patterns y Clean Architecture.
  4. Fundamentos: algoritmos o SICP.
  5. Y, posteriormente, alguna referencia moderna.

Porque, en última instancia, la programación no consiste solo en hacer que un programa funcione, sino en construir software que pueda perdurar. Y eso, más que cualquier lenguaje o tecnología, es lo que define a un buen programador.