Archivo por meses: mayo 2026

Dibuja antes de picar tecla

Hábitos que distinguen a un programador que empieza de uno que madura

Para quienes ya saben escribir código, pero aún no saben pensar en código.

Cuando empiezas a programar, la batalla es contra la sintaxis. ¿Va el punto y coma aquí o allá? ¿Por qué me falla la compilación? ¿Qué significa ese NullPointerException?

Pero los programadores más experimentados saben que la dificultad no está en la sintaxis, sino en el diseño correcto de las aplicaciones. La mayoría de los problemas al desarrollar software no son problemas de codificación, son problemas de pensar. Los errores más silenciosos y peligrosos son los errores de diseño.

Un programa puede compilar perfectamente, ejecutarse sin fallos y aun así estar fundamentalmente mal construido. Son los errores que no te avisa el compilador, pero que te perseguirán semanas después cuando quieras añadir una funcionalidad, corregir un fallo o simplemente releer tu propio código.

Este artículo muestra una serie de consejos para mejorar tu técnica como programador y recoge algunos de los errores más frecuentes que cometen los programadores nóveles, con ejemplos concretos en C y Java.


1. Dibuja antes de picar tecla

Antes de escribir una sola línea de código, coge un papel y un bolígrafo.

Suena anticuado. Puede que incluso innecesario. Pero es uno de los hábitos que más diferencian a un programador experimentado de uno que acaba de empezar.

El programador novel abre el editor nada más leer el enunciado. El resultado es casi siempre el mismo: a mitad del programa descubre que su planteamiento inicial era erróneo, que necesita una variable que no había previsto o que las funciones que escribió primero no encajan con las que necesita ahora. Borra, reescribe, da vueltas. El editor se convierte en el lugar donde se piensa, no donde se construye.

Utilizar esquemas visuales proporciona algunas ventajas durante la fase de diseño:

  • La estructura queda más clara antes de empezar a teclear.
  • Se identifican con mayor facilidad los casos límite.
  • Se facilita la comunicación entre miembros del equipo.
  • El papel es más rápido que el compilador para descartar ideas malas.

¿Qué se dibuja exactamente?

Depende del problema, pero hay tres herramientas básicas que cubren la mayoría de los casos:

Diagramas de flujo para los algoritmos. Antes de escribir un bucle o una cadena de condiciones, dibuja las cajas y las flechas. ¿Por dónde entra el flujo? ¿Qué condición lo bifurca? ¿Dónde termina? Un diagrama de flujo toscamente trazado en un minuto te muestra los casos que no habías considerado.

Bocetos de estructuras de datos. En C, antes de declarar un struct, dibuja los campos que necesitas y las relaciones entre ellos. En Java, antes de escribir una clase, dibuja un rectángulo con su nombre, sus atributos y sus métodos principales. No hace falta que sea UML formal: basta con que seas capaz de responder a la pregunta ¿qué sabe esta clase de sí misma?

Pseudocódigo. A medio camino entre el lenguaje natural y el código real. No tiene sintaxis estricta; su único objetivo es fijar la lógica antes de preocuparte por los detalles del lenguaje.

Escribir esto en papel lleva dos minutos. Detectar que el criterio de aprobado debe ser un parámetro (¿y si en otra asignatura es 4,5?) lleva otros treinta segundos. Hacer ese mismo cambio después de haber escrito cincuenta líneas de código puede llevarte media hora.

La regla práctica

No es necesario dibujar todo siempre. Para una función de tres líneas, el papel sobra. Pero cuando un problema te parezca medianamente complejo o cuando tengas dudas de por dónde empezar, esa es exactamente la señal para apartar el teclado y coger el bolígrafo.

Herramientas para dibujar esquemas

El dibujo inicial de los esquemas se hace mejor a mano. Es más rápido y, por la forma de funcionar nuestro cerebro, dibujarlos a mano permite fijar mejor las ideas en la memoria. No obstante, cuando los esquemas pasan a ser definitivos y decides guardarlos en el ordenador o incluirlos en un documento, es útil utilizar alguna herramienta de diseño. Yo utilizo InkScape y Draw.io. Se trata de herramientas libres y gratuitas que funcionan muy bien. InkScape tiene una curva de aprendizaje más pronunciada, pero también permite hacer muchas más cosas. Todos los esquemas que ves en los artículos de Matemata están hechos con InkScape. También me han hablado de Excalidraw, que es una herramienta web, pero nunca la he probado.

Más allá del dibujo: planificar antes de programar

Dibujar es la forma concreta de una habilidad más amplia: planificar antes de programar. Imagina a un pintor que, nada más recibir el encargo, agarra el pincel y empieza a pintar sin haber visto cómo es la habitación. Es probable que se quede sin pintura a mitad, que no le cuadren las esquinas o que tenga que repintar tres veces lo que hizo el primer día.

Antes de escribir la primera línea, dedica aunque sea cinco minutos a responder:

  • ¿Qué tiene que hacer exactamente este programa o función?
  • ¿Qué datos recibe y qué datos devuelve?
  • ¿Hay casos especiales que deba contemplar desde el principio?

La respuesta no tiene que ser perfecta. Solo tiene que existir.


2. Nombres que no dicen nada

Este es quizás el hábito más fácil de mejorar y con más impacto inmediato. Compara estos dos fragmentos en C:

/* Versión con nombres pobres */
int f(int a, int b) {
   int r = 0;
   for (int i = 0; i < b; i++) {
       r += a;
  }
   return r;
}
/* Versión con nombres descriptivos */
int multiplicar(int factor, int veces) {
   int resultado = 0;
   for (int i = 0; i < veces; i++) {
       resultado += factor;
  }
   return resultado;
}

El código es idéntico. Pero el segundo se entiende sin necesidad de ejecutarlo mentalmente.

La regla práctica es esta: el nombre debe responder a la pregunta “¿qué es esto?” sin necesitar mirar el resto del código. Si tienes que leer el cuerpo de una función para entender qué devuelve, el nombre ha fallado.

Esto aplica igualmente en Java:

// Mal: ¿qué hace este método? ¿qué es "d"?
public boolean chk(int d) {
   return d >= 18;
}

// Bien: queda claro sin leer el cuerpo
public boolean esMayorDeEdad(int edad) {
   return edad >= 18;
}

Algunos consejos concretos:

  • Variables: sustantivos o frases nominales (precio, nombreUsuario, listaAlumnos).
  • Funciones/métodos: verbos o frases verbales (calcularMedia, obtenerNombre, esPrimo).
  • Booleanos: que puedan leerse como una afirmación (estaVacio, tienePermiso, esValido).
  • Evita abreviaturas salvo las universalmente conocidas (i, j en bucles; msg para mensaje; len para longitud).

La misma exigencia de claridad que aplicamos a los nombres se extiende a todo el código: la tentación de escribir expresiones “inteligentes” es otra forma del mismo problema.


3. Intentar ser demasiado “ingenioso”

Uno de los errores más frecuentes en programadores principiantes consiste en intentar escribir código “impresionante” en lugar de código claro.

Cuando alguien empieza a sentirse cómodo con un lenguaje de programación, es habitual que aparezca la tentación de utilizar expresiones cada vez más compactas, operadores encadenados, trucos sintácticos o construcciones difíciles de entender. El programador tiene la sensación de estar escribiendo código más avanzado o más profesional, cuando en realidad muchas veces está haciendo justo lo contrario: crear software más difícil de comprender y mantener.

Por ejemplo, algunos principiantes intentan condensar demasiada lógica en una sola línea:

if ((x > 0 && y < 10) || (z != 0 && (a+b*c-d/e) > 50 && !error)) {

Aunque esta instrucción puede ser correcta, entender exactamente qué está ocurriendo requiere un esfuerzo innecesario. Un código más claro suele ser preferible:

boolean coordenadas_validas = (x > 0 && y < 10);
boolean calculo_correcto = (z != 0 && (a + b * c - d / e) > 50);

if (coordenadas_validas || (calculo_correcto && !error)) {

La segunda versión ocupa más líneas, pero resulta mucho más fácil de leer, revisar y modificar.

Otro caso típico aparece cuando se utilizan operadores de incremento o asignaciones complejas dentro de expresiones:

vector[i++] = datos[++j] + --k;

Aunque un programador experimentado podría analizar esta línea, su comportamiento no es evidente a simple vista. Además, expresiones de este tipo suelen ser fuente de errores difíciles de detectar.

En general, cuando una línea de código necesita varios segundos de análisis para comprenderse, probablemente debería reescribirse de forma más sencilla.

Muchos programadores noveles creen que el buen código es el más corto o el más inteligente”. Sin embargo, en desarrollo profesional ocurre exactamente lo contrario: se valora especialmente el código claro y mantenible.

Un programa se escribe una vez, pero se lee y se modifica muchas veces. Además, en numerosos casos, quien tendrá que mantener el código en el futuro será otra persona… o incluso el propio autor varios meses después, cuando ya haya olvidado cómo funcionaba aquella expresión tan ingeniosa.

Por ello, una de las recomendaciones más importantes para cualquier programador es priorizar siempre la claridad sobre la brillantez, la simplicidad sobre la complejidad y la legibilidad sobre el ahorro de líneas. Un código sencillo no es señal de poca experiencia. Muy al contrario: escribir soluciones claras y fáciles de entender suele requerir más madurez y más disciplina que escribir código innecesariamente complicado.

De hecho, muchos programadores experimentados siguen una regla informal muy útil:

“Si una solución parece demasiado inteligente, probablemente no sea una buena solución.”


4. Una función, una responsabilidad

Uno de los principios más importantes en programación es que cada parte del código, cada función, cada método, cada clase, debe tener una responsabilidad clara y acotada. Cuando ese principio se ignora, el código crece sin control y acaba siendo imposible de entender o modificar.

El caso más extremo es el de escribir todo el programa dentro del main(). Al principio puede parecer cómodo, especialmente en programas pequeños. Sin embargo, a medida que el programa crece, el main() termina convirtiéndose en un bloque enorme donde se mezclan todas las tareas: lectura de datos, validaciones, cálculos, menús, impresión de resultados, tratamiento de errores…

El resultado suele ser un código difícil de leer y todavía más difícil de mantener. Localizar un error es complicado. Reutilizar una parte del código, casi imposible. Modificar algo sin romper otra cosa, un riesgo constante.

Una buena señal de que un programa está bien estructurado es que el main() sea relativamente corto y actúe únicamente como coordinador general, delegando el trabajo real en funciones más pequeñas y especializadas.

Ahora bien, dividir el código en funciones no es suficiente si cada función sigue haciéndolo todo. Una función no es un cajón de sastre. Si su descripción incluye la palabra “y”, probablemente hace demasiado.

Imagina que alguien te pide en un restaurante: “Toma nota del pedido, ve a la cocina, cocina el plato, tráelo a la mesa y después cobra al cliente.” Eso no es un camarero, es una persona haciendo cinco trabajos a la vez. Si algo falla, no sabes en cuál de los cinco pasos ocurrió el problema.

El mismo principio aplica a las funciones. Una función debería hacer una sola cosa, y hacerla bien:

/* Mal: esta función hace tres cosas a la vez */
void procesarAlumnos(Alumno* lista, int n) {
   /* Lee datos por teclado */
   for (int i = 0; i < n; i++) {
       printf("Nombre: ");
       scanf("%49s", lista[i].nombre);
       printf("Nota: ");
       scanf("%f", &lista[i].nota);
  }
   /* Calcula la media */
   float suma = 0;
   for (int i = 0; i < n; i++) {
       suma += lista[i].nota;
  }
   float media = suma / n;
   /* Imprime el resultado */
   printf("Media de la clase: %.2f\n", media);
}
/* Bien: cada función tiene una responsabilidad */
void leerAlumnos(Alumno* lista, int n);
float calcularMedia(const Alumno* lista, int n);
void imprimirMedia(float media);

Un caso especialmente frecuente de esta mezcla de responsabilidades es combinar el cálculo con la presentación. Una función encargada de calcular un resultado no debería imprimir mensajes por pantalla: su responsabilidad debería limitarse únicamente al cálculo.

// Mal: calcula y muestra a la vez
public static void calcularPrecioFinal(double precio, double iva) {
   double resultado = precio + precio * iva / 100.0;
   System.out.println("El precio final es: " + resultado);
}
// Bien: calcula y devuelve; mostrar es tarea de quien llama
public static double calcularPrecioFinal(double precio, double iva) {
   return precio + precio * iva / 100.0;
}

De esta forma, la función de cálculo puede reutilizarse fácilmente en otros contextos: una interfaz gráfica, una aplicación web o incluso otro programa distinto. Separar responsabilidades hace que el software sea más claro, más reutilizable y más sencillo de mantener. Aunque en programas pequeños esta separación pueda parecer innecesaria, en proyectos medianos o grandes se vuelve fundamental.


5. Copiar y pegar: el camino fácil hacia el desastre

Tienes un fragmento de código que funciona. Lo necesitas en otro sitio. La tentación es copiar, pegar y listo.

El problema viene cuando ese código tiene un fallo, o cuando cambian los requisitos. De repente tienes que recordar en cuántos sitios lo pegaste y arreglarlo en todos. Invariablemente, te olvidarás de uno.

Este principio se conoce como DRY: Don’t Repeat Yourself (No te repitas).

Ejemplo en Java:

// Mal: la misma lógica de validación duplicada
public void registrarUsuario(String nombre, int edad) {
   if (nombre == null || nombre.isBlank()) {
       throw new IllegalArgumentException("Nombre no válido");
  }
   if (edad < 0 || edad > 150) {
       throw new IllegalArgumentException("Edad no válida");
  }
   // ...
}

public void actualizarUsuario(String nombre, int edad) {
   if (nombre == null || nombre.isBlank()) {
       throw new IllegalArgumentException("Nombre no válido");
  }
   if (edad < 0 || edad > 150) {
       throw new IllegalArgumentException("Edad no válida");
  }
   // ...
}
// Bien: la validación vive en un solo lugar
private void validarDatos(String nombre, int edad) {
   if (nombre == null || nombre.isBlank()) {
       throw new IllegalArgumentException("Nombre no válido");
  }
   if (edad < 0 || edad > 150) {
       throw new IllegalArgumentException("Edad no válida");
  }
}

public void registrarUsuario(String nombre, int edad) {
   validarDatos(nombre, edad);
   // ...
}

public void actualizarUsuario(String nombre, int edad) {
   validarDatos(nombre, edad);
   // ...
}

Cuando los criterios de validación cambien (y cambiarán), solo hay un lugar donde tocar.


6. Ignorar los casos límite

Un error habitual es pensar: “si compila, ya está“. Un programa que solo funciona con datos perfectos no funciona.

El programador novel suele probar su código con casos “normales”: una lista con varios elementos, una cadena con contenido, un número positivo. Pero los programas reales reciben datos imperfectos: listas vacías, cadenas nulas, números negativos, ficheros inexistentes.

Antes de dar por terminada una función, pregúntate:

  • ¿Qué pasa si la lista está vacía?
  • ¿Qué pasa si el puntero es NULL (en C) o el objeto es null (en Java)?
  • ¿Qué pasa si el número es negativo, cero, o muy grande?
  • ¿Qué pasa si el usuario no introduce nada?

Ejemplo en C:

/* Mal: se rompe si lista es NULL o n es 0 */
float calcularMedia(float* lista, int n) {
   float suma = 0;
   for (int i = 0; i < n; i++) {
       suma += lista[i];
  }
   return suma / n; /* División por cero si n == 0 */
}

/* Bien: maneja los casos problemáticos */
float calcularMedia(float* lista, int n) {
   if (lista == NULL || n <= 0) {
       return 0.0f; /* O señalizar el error de otra forma */
  }
   float suma = 0;
   for (int i = 0; i < n; i++) {
       suma += lista[i];
  }
   return suma / n;
}

El código robusto no es el que funciona cuando todo va bien. Es el que no se rompe cuando algo va mal. Los errores suelen aparecer en los casos que no pensamos.


7. Comentar el “qué” en lugar del “por qué”

Los comentarios son valiosos, pero hay una trampa frecuente: comentar lo que el código ya dice por sí solo.

// Mal: el comentario no añade ninguna información
i++; // Incrementa i en 1

// Mal: describe el "qué", que ya es evidente
if (edad >= 18) { // Si la edad es mayor o igual a 18
   // ...
}

Un comentario útil explica por qué el código hace lo que hace, especialmente cuando la razón no es obvia:

// Bien: explica una decisión no evidente
// La edad legal varía por país; usamos 18 como mínimo internacional
if (edad >= 18) {
   // ...
}

// Bien: advierte de una trampa conocida
// Nota: indexOf devuelve -1 si no encuentra el carácter,
// por eso comprobamos >= 0 y no > 0
if (linea.indexOf(':') >= 0) {
   // ...
}

Una buena guía práctica: si tienes que escribir un comentario para explicar qué hace una línea de código, considera si sería mejor reescribir esa línea para que se explique sola (con un mejor nombre, extrayendo una función, etc.).

Muchos principiantes programan como si el código fuese a ejecutarse una sola vez. Pero el verdadero coste del software suele estar en modificar, ampliar y corregir.

El código no se escribe para el ordenador; se escribe para futuros humanos.


8. Optimizar antes de tiempo

“La optimización prematura es la raíz de todos los males.” — Donald Knuth

El programador novel a veces se preocupa por la eficiencia antes de tener un programa que funcione correctamente. Decide usar estructuras de datos complejas, evitar llamadas a funciones “por el coste”, o reescribir bucles en formas rebuscadas para que sean “más rápidos”.

El resultado casi siempre es código difícil de leer, difícil de depurar y que además no es significativamente más rápido en la práctica.

El orden correcto es:

  1. Haz que funcione. Un programa correcto y lento es infinitamente mejor que uno rápido e incorrecto.
  2. Haz que sea claro. El código que se entiende es el código que se puede mantener.
  3. Haz que sea rápido, pero solo si hay una necesidad real y medida.

Los ordenadores modernos son extraordinariamente rápidos. La mayoría de los programas que escribirás en tu vida no tienen un problema de rendimiento. Y cuando lo tengan, las herramientas de profiling te dirán exactamente dónde está el cuello de botella, que casi nunca es donde imaginas.


9. No saber cuándo parar de añadir

Hay un fenómeno conocido como gold plating (chapado en oro): añadir funcionalidades que nadie ha pedido porque “quedan bien” o “pueden ser útiles algún día”.

El problema es que cada línea de código es código que hay que mantener, depurar y entender. El mejor código es, con frecuencia, el que no existe.

Cuando estés tentado de añadir algo extra, pregúntate: ¿Lo necesita el programa ahora mismo o simplemente me parece interesante?

Empieza por lo mínimo que resuelve el problema. Añade después si es necesario. Este principio tiene nombre: YAGNI (You Ain’t Gonna Need It, no lo vas a necesitar).


10. No releer tu propio código

El código recién escrito parece perfecto. Pero el código que relees al día siguiente, con la mente descansada, revela cosas que no veías antes.

Desarrolla el hábito de revisar tu propio código antes de darlo por terminado. Léelo como si fuera de otra persona. Pregúntate:

  • ¿Entiendo este fragmento sin contexto adicional?
  • ¿Hay algo que podría simplificarse?
  • ¿He manejado todos los casos posibles?

Y, cuando sea posible, pide a alguien más que lo lea. Una mirada externa detecta problemas que el autor no puede ver porque está demasiado familiarizado con el código.


Conclusión

Aprender sintaxis es relativamente rápido. Aprender a diseñar software claro, mantenible y robusto lleva mucho más tiempo. La diferencia entre un programador novel y uno con experiencia no está solo en conocer más funciones de la librería estándar o dominar más algoritmos. Está en los hábitos de trabajo: en dibujar antes de codificar, en elegir nombres cuidadosamente, en mantener las funciones pequeñas, en anticipar los fallos.

Estos hábitos no se aprenden leyendo un artículo, sino practicándolos conscientemente hasta que se vuelven naturales. Pero el primer paso es saber que existen.

La próxima vez que vayas a escribir una función, detente un segundo. Coge el bolígrafo. Dibuja lo que quieres construir. Esos segundos se convierten, con el tiempo, en horas de depuración ahorradas.


Este artículo forma parte de una serie de materiales didácticos para estudiantes de Programación I y Programación II en la ETSIST-UPM.

Elige bien tu colección

Si llevas un tiempo programando en Java, seguramente ya has sufrido la limitación más frustrante de los arrays: una vez que los creas, su tamaño está grabado en piedra. ¿Necesitas añadir un elemento más? Mala suerte. ¿No sabes de antemano cuántos elementos vas a tener? Peor todavía.

Java resuelve este problema con las colecciones: estructuras de datos dinámicas, flexibles y bien diseñadas que vienen de serie en el paquete java.util. Hay varios tipos, y elegir el adecuado para cada situación marca la diferencia entre un código claro y eficiente y uno que te da más problemas de los que resuelve. En este artículo repasamos los tres tipos fundamentales y te damos las claves para elegir bien.


List (Listas): el array de toda la vida, pero mejor

Una List es una colección ordenada (cada elemento tiene su posición) que admite repeticiones. Es lo más parecido a un array, con la diferencia de que crece y se encoge según necesites.

La implementación que usarás el 90% de las veces es ArrayList:

List<String> compra = new ArrayList<>();
compra.add("leche");
compra.add("pan");
compra.add("pan");       // sí, repetido; una List lo permite
compra.add("huevos");

System.out.println(compra.size());    // 4
System.out.println(compra.get(1));    // "pan"
compra.remove("pan");                 // elimina la primera ocurrencia
System.out.println(compra);          // [leche, pan, huevos]

El detalle de Integer en lugar de int

Las colecciones solo pueden almacenar objetos, no tipos primitivos. Esto significa que no puedes hacer List<int>. Tienes que usar la clase envoltorio correspondiente: Integer para int, Double para double, Boolean para boolean, etc.

List<Integer> notas = new ArrayList<>();
notas.add(7);    // Java convierte automáticamente int → Integer
notas.add(9);
notas.add(6);

int primera = notas.get(0);   // y también Integer → int al sacarlo

Esta conversión automática se llama autoboxing y Java la hace en silencio, así que en la práctica casi no se nota. Lo importante es recordar que en la declaración del tipo hay que escribir Integer, no int.

¿Cuándo usar List?

Usa una List cuando el orden importe, cuando necesites acceder por posición o cuando quieras permitir repeticiones. Lista de tareas, historial de acciones, colección de notas de un alumno: todo eso es territorio List.


Obtener una lista a partir de un array

String[] arrayNormal = {"Primera", "Segunda", "Tercera"};  // Array normal
System.out.println(Arrays.toString(arrayNormal)); // Imprimir un array normal: utilidad Arrays.toString()

List<String> listaInmutable = Arrays.asList(arrayNormal); // Inmutable: no puedo añadir ni borrar elementos
System.out.println(listaInmutable); // Imprimir directamente

List<String> listaNormal = new ArrayList<>(Arrays.asList(arrayNormal)); // ArrayList normal
System.out.println(listaNormal); // Imprimir una lista: directamente

Recorrer una colección: for-each e Iterator

La forma más sencilla de recorrer cualquier colección es el bucle for-each:

List<String> compra = new ArrayList<>(Arrays.asList("leche", "pan", "huevos"));

for (String producto : compra) {
   System.out.println(producto);
}

Sin embargo, el for-each tiene una limitación importante: no se pueden añadir ni eliminar elementos durante el recorrido. Si lo intentas, Java lanza una ConcurrentModificationException en tiempo de ejecución. Cuando necesites eliminar elementos mientras recorres la colección, tienes que usar un Iterator:

List<String> compra = new ArrayList<>(Arrays.asList("leche", "pan", "huevos", "pan"));

Iterator<String> iter = compra.iterator(); // El iterador antes del primer elemento de la colección

while (iter.hasNext()) {
   String producto = iter.next();
   if (producto.equals("pan")) {
       iter.remove();   // eliminación segura
  }
}

System.out.println(compra);  // [leche, huevos]

El iterador mantiene un cursor interno que va avanzando elemento a elemento. Su método remove() está coordinado con la colección, por lo que la eliminación es siempre segura. ¡Pero sigues sin poder añadir!

Los métodos de Iterator:

  • hasNext()
  • next()
  • remove()

ListIterator: recorrer una lista al revés

Las listas ofrecen un iterador más potente, ListIterator: es capaz de recorrer la colección en los dos sentidos, eliminar elementos durante el recorrido y permite también modificarlos con set() e incluso insertar nuevos con add().

Para recorrer una lista de atrás hacia adelante, se posiciona el cursor al final y se usa hasPrevious() y previous():

List<String> palabras = new ArrayList<>(Arrays.asList("uno", "dos", "tres", "cuatro"));

ListIterator<String> iter = palabras.listIterator(palabras.size()); // Iterador después del último
while (iter.hasPrevious()) {
   System.out.print(iter.previous() + " ");
}
// Salida: cuatro tres dos uno

listIterator(palabras.size()) sitúa el cursor justo después del último elemento, listo para empezar a retroceder. Es el equivalente a posicionarse al final de la cinta antes de darle al rebobinar.

Los métodos de ListIterator:

  • hasNext()
  • next()
  • hasPrevious()
  • previous()
  • add(), set(), remove()

En resumen:

Operación durante el recorridofor-eachIteratorListIterator
Leer elementos
Modificar estado interno del elemento
Sustituir el elemento en la colección✅ (set())
Eliminar elemento actual
Añadir elementos✅ (add())

Set (Conjuntos): cuando los repetidos son el enemigo

Un Set implementa el concepto matemático de conjunto: no admite elementos repetidos y, en su versión más común (HashSet), no garantiza ningún orden particular.

Set<String> etiquetas = new HashSet<>();
etiquetas.add("java");
etiquetas.add("programación");
etiquetas.add("java");       // ignorado: ya existe
etiquetas.add("colecciones");

System.out.println(etiquetas.size());  // 3, no 4

El truco del siglo: eliminar duplicados de una lista

Una de las operaciones más frecuentes en programación es eliminar los elementos repetidos de una lista. Con un Set esto se hace en una sola línea:

String[] arrayNormal = {"rosa", "tulipán", "rosa", "margarita", "tulipán", "rosa"};

List<String> listaInmutable = new ArrayList<>(Arrays.asList(arrayNormal));

Set<String> cjtoSinRepetidos = new HashSet<>(listaInmutable);
System.out.println(cjtoSinRepetidos);  // [rosa, tulipán, margarita] (en algún orden)

List<String> listaSinRepetidos = new ArrayList<>(cjtoSinRepetidos); // Lista sin repetidos

El constructor de HashSet acepta cualquier colección como argumento y, al añadir los elementos uno a uno, descarta automáticamente los que ya están. No garantiza ningún orden concreto. Si además quieres que el resultado esté ordenado:

  • Orden natural: alfabéticamente, de menor a mayor -> usa TreeSet
  • Orden de inserción -> usa LinkedHashSet

El peaje: equals() y hashCode()

Aquí viene la parte que a mucha gente le pilla por sorpresa. El Set funciona de maravilla con String e Integer porque Java ya sabe cómo comparar esos tipos. Pero si intentas usar un Set con objetos de tus propias clases, tienes que enseñarle a Java qué significa que dos objetos sean “iguales”.

Imagina que tienes una clase Cancion:

public class Cancion {
   private String titulo;
   private String artista;

   public Cancion(String titulo, String artista) {
       this.titulo = titulo;
       this.artista = artista;
  }
}

Si haces esto:

Set<Cancion> favoritas = new HashSet<>();
favoritas.add(new Cancion("Bohemian Rhapsody", "Queen"));
favoritas.add(new Cancion("Bohemian Rhapsody", "Queen"));  // ¿duplicado?

System.out.println(favoritas.size());  // 2 😱

El conjunto contiene dos elementos, cuando esperabas uno. ¿Por qué? Porque Java, por defecto, compara objetos por su referencia en memoria, no por su contenido. Las dos canciones son objetos distintos aunque tengan los mismos datos.

La solución es sobrescribir dos métodos en tu clase: equals(), que define cuándo dos objetos son iguales, y hashCode(), que genera un código numérico usado internamente por el HashSet para localizar elementos rápidamente. La regla de oro es que si dos objetos son iguales según equals(), deben producir el mismo hashCode().

@Override
public boolean equals(Object o) {
   if (!(o instanceof Cancion)) {
       return false;
  }
   Cancion otra = (Cancion) o;
   return titulo.equals(otra.titulo) && artista.equals(otra.artista);
}

@Override
public int hashCode() {
   return Objects.hash(titulo, artista);
}

Objects.hash() es un método utilitario de Java que calcula un hashCode razonable a partir de los atributos que le pases. Úsalo siempre: es sencillo, correcto y evita errores.

Con estos dos métodos añadidos a Cancion, el conjunto ya funciona como esperas:

favoritas.add(new Cancion("Bohemian Rhapsody", "Queen"));
favoritas.add(new Cancion("Bohemian Rhapsody", "Queen"));

System.out.println(favoritas.size());  // 1 ✅

Método equals() alternativo

El método equals() resuelto con instanceof, da por válida cualquier clase derivada. Si quieres limitar a la clase concreta, es mejor usar la siguiente versión (¡Pero te obliga a comprobar que o no es null!):

@Override
public boolean equals(Object o) {
   if ( (o==null) || (o.getClass()!=this.getClass()) ) {
       return false;
  }
   Cancion otra = (Cancion) o;
   return titulo.equals(otra.titulo) && artista.equals(otra.artista);
}

¿Cuándo usar Set?

Usa un Set cuando los duplicados no tengan sentido en tu problema: etiquetas, identificadores únicos, palabras distintas en un texto. Y recuerda sobreescribir equals() y hashCode() si los elementos son de tus propias clases.

Map (Mapas): cada cosa tiene su etiqueta

Un Map almacena pares clave → valor. Las claves no se pueden repetir; cada clave tiene exactamente un valor asociado. Es la estructura perfecta para representar relaciones: nombre → teléfono, producto → precio, palabra → número de apariciones.

Map<String, Integer> puntuaciones = new HashMap<>();
puntuaciones.put("Ana", 10);
puntuaciones.put("Luis", 7);
puntuaciones.put("Eva", 9);

System.out.println(puntuaciones.get("Ana"));    // 10
System.out.println(puntuaciones.get("Mario"));  // null: no existe

puntuaciones.put("Ana", 8);  // reemplaza el valor anterior
System.out.println(puntuaciones.get("Ana"));    // 8

Un ejemplo muy típico es contar cuántas veces aparece cada palabra en un texto:

String[] palabras = {"java", "es", "genial", "java", "mola", "java"};
Map<String, Integer> conteo = new HashMap<>();

for (String palabra : palabras) {
   if (conteo.containsKey(palabra)) {
       conteo.put(palabra, conteo.get(palabra) + 1);
  } else {
       conteo.put(palabra, 1);
  }
}

System.out.println(conteo);  // {java=3, es=1, genial=1, mola=1}

Para recorrer un mapa necesitas hacerlo a través de sus claves, sus valores, o ambos a la vez:

// Solo claves
for (String nombre : puntuaciones.keySet()) {
   System.out.println(nombre + " → " + puntuaciones.get(nombre));
}

// Clave y valor a la vez (más eficiente)
for (Map.Entry<String, Integer> entrada : puntuaciones.entrySet()) {
   System.out.println(entrada.getKey() + " → " + entrada.getValue());
}

Para hacer operaciones sobre Maps, es habitual obtener el conjunto de las claves con el método keySet():

Set<String> nombres = puntuaciones.keySet();

A partir del conjunto de las claves, podríamos también obtener un ArrayList con las claves, que nos puede servir para recorrerlas ordenadamente:

List<String> listaNombres = new ArrayList<>(puntuaciones.keySet());

En las colecciones, como List o Set, se puede pasar al constructor otra colección como argumento. Sería equivalente a utilizar el método addAll():

List<String> listaNombres = new ArrayList<>();
listaNombres.addAll(puntuaciones.keySet());

¿Cuándo usar Map?

Usa un Map cuando tengas una relación clave → valor y necesites buscar por la clave. Si te encuentras usando dos listas en paralelo (una de nombres y otra de valores) para mantener esa correspondencia, es una señal clara de que lo que necesitas es un Map.


Dos clases que te harán la vida más fácil

Arrays: el puente entre arrays y colecciones

La clase Arrays (plural) ofrece métodos estáticos para operar sobre arrays. Los más útiles:

int[] nums = {5, 3, 8, 1, 9};

// Imprimir un array (sin esto obtendrías algo como [I@4e50df2u)
System.out.println(Arrays.toString(nums));       // [5, 3, 8, 1, 9]

// Ordenar
Arrays.sort(nums);
System.out.println(Arrays.toString(nums));       // [1, 3, 5, 8, 9]

// Buscar (solo en arrays ya ordenados)
int pos = Arrays.binarySearch(nums, 8);         // pos = 3

// Convertir array a lista (para pasarlo a una colección)
List<String> lista = new ArrayList<>(Arrays.asList("a", "e", "i", "o", "u"));

Ese último patrón, new ArrayList<>(Arrays.asList(...)), es uno de los más usados en Java para crear una lista con valores iniciales de forma compacta.

Collections: utilidades para colecciones

La clase Collections (también plural, y fácil de confundir con el interface Collection en singular) ofrece métodos estáticos para operar sobre colecciones:

List<Integer> nums = new ArrayList<>(Arrays.asList(3, 1, 4, 1, 5, 9));

Collections.sort(nums);     // [1, 1, 3, 4, 5, 9]
Collections.reverse(nums);  // [9, 5, 4, 3, 1, 1]
Collections.shuffle(nums);  // orden aleatorio (útil para juegos, tests...)

System.out.println(Collections.min(nums));         // mínimo
System.out.println(Collections.max(nums));         // máximo
System.out.println(Collections.frequency(nums, 1)); // cuántas veces aparece el 1

Resumen: ¿cuál uso?

SituaciónColección recomendada
Necesito orden y puedo tener repetidosListArrayList
No quiero repetidos y el orden no importaSetHashSet
No quiero repetidos y quiero orden de inserciónSetLinkedHashSet
No quiero repetidos y quiero orden alfabéticoSetTreeSet
Necesito asociar claves a valoresMapHashMap
Como el anterior pero en orden de inserciónMapLinkedHashMap
Como el anterior pero ordenado por claveMapTreeMap

Y una última regla que resume mucho: declara siempre la variable con el tipo del interface (List, Set, Map), no con el de la clase concreta (ArrayList, HashSet, HashMap). Así, si un día decides cambiar de implementación, solo tendrás que tocar una línea de código.

// Así no
ArrayList<String> lista = new ArrayList<>();

// Así sí
List<String> lista = new ArrayList<>();

Las colecciones de Java son una de esas partes del lenguaje que, una vez que las conoces bien, no entiendes cómo vivías sin ellas. ¡Elige bien y que el código fluya!

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