Clases, objetos y records

Durante veinte años, escribir una clase de datos en Java costaba cincuenta líneas de código mecánico. Esta lección enseña a escribirla entera y luego a sustituirla por una sola línea, que es lo que hoy se hace.

Una clase, pieza a pieza

Una clase es un molde: dice qué datos guarda cada objeto y qué sabe hacer. Esta es completa:

class Libro {
    private final String titulo;
    private final String autor;
    private int prestados;

    Libro(String titulo, String autor) {
        if (titulo == null || titulo.isBlank())
            throw new IllegalArgumentException("el título no puede estar vacío");
        this.titulo = titulo;
        this.autor = autor;
    }

    String titulo() { return titulo; }
    int prestados() { return prestados; }
    void prestar()  { prestados++; }

    @Override
    public String toString() { return "«" + titulo + "», de " + autor; }
}
salida«Rayuela», de Cortázar — prestado 2 veces
rechazado: el título no puede estar vacío

Lo que hay ahí dentro, en orden de importancia:

  • private en los campos. Nadie de fuera los toca; se accede por métodos. Así la clase puede cambiar por dentro sin romper a quien la usa, y puede garantizar que sus datos siempre tienen sentido.
  • El constructor valida. Es el único sitio por el que se crea un Libro, así que si rechaza los títulos vacíos, no existe ningún Libro con el título vacío. Eso vale más que comprobarlo en cada sitio donde se use.
  • this.titulo = titulo; distingue el campo del parámetro, que se llaman igual.
  • final en los campos que no deben cambiar después de crear el objeto.
  • toString() es lo que sale al imprimir el objeto. Sin él sale Libro@1b6d3586.

Y aquí aparece el problema

Libro a = new Libro("Rayuela", "Cortázar");
Libro b = new Libro("Rayuela", "Cortázar");
IO.println("a == b      -> " + (a == b));
IO.println("a.equals(b) -> " + a.equals(b));
salidaa == b      -> false
a.equals(b) -> false

Dos libros con el mismo título y el mismo autor, y Java dice que no son iguales. Lo del == ya lo sabíamos: compara referencias. Pero equals también dice que no, porque el equals que se hereda de serie hace exactamente lo mismo que ==.

Para arreglarlo hay que escribir equals y, obligatoriamente con él, hashCode. Son unas treinta líneas de código mecánico que además hay que acordarse de actualizar cada vez que se añade un campo. Durante veinte años, eso fue Java.

Los records, que son la clase que querías escribir

record Punto(int x, int y) { }

Eso es todo. Esa línea genera los campos final, el constructor, los métodos de acceso x() e y(), equals, hashCode y toString:

salidap            = Punto[x=3, y=4]
p.x()        = 3
p.equals(q)  = true
mismo hash   = true
en un HashSet= 1 elemento(s)
Set.of los rechaza: duplicate element: Punto[x=3, y=4]

Fíjate en las tres últimas líneas: como equals y hashCode están bien hechos, dos puntos iguales ocupan una sola posición en un conjunto. Y Set.of, que no admite repetidos, se niega a aceptarlos: es la prueba de que los considera el mismo valor.

Un record también tiene cuerpo

record Punto(int x, int y) {
    // constructor compacto: valida antes de asignar
    Punto {
        if (x < 0 || y < 0) throw new IllegalArgumentException("nada de negativos: " + x + "," + y);
    }
    double distanciaAlOrigen() { return Math.sqrt(x * x + y * y); }
    static Punto origen() { return new Punto(0, 0); }
}
salidadistancia    = 5.0
origen       = Punto[x=0, y=0]
rechazado: nada de negativos: -1,0

El constructor compacto —Punto { ... }, sin paréntesis ni asignaciones— se ejecuta antes de guardar los campos. Es el sitio para validar y para normalizar: lo que dejes en los parámetros es lo que se guarda.

Deconstruir

if (p instanceof Punto(int a, int b)) IO.println("deconstruido: a=" + a + " b=" + b);
salidadeconstruido: a=3 b=4

Un record se puede abrir en sus partes directamente en el instanceof o en un switch, sin pasar por los métodos de acceso. Combinado con lo que vimos en la lección anterior, esto es lo que hace que el código moderno de Java se lea tan distinto del de hace diez años.

Cuándo un record y cuándo una clase. Un record es inmutable y transparente: sus datos son públicos por definición. Es perfecto para lo que de verdad es un conjunto de valores —un punto, una coordenada, una línea de un CSV, la respuesta de una API—. Si el objeto tiene estado que cambia, o quiere esconder cómo guarda las cosas, necesitas una clase. El Libro de arriba, que lleva la cuenta de los préstamos, no puede ser un record.

static: lo que pertenece a la clase

static Punto origen() { return new Punto(0, 0); }   // se llama Punto.origen()

Un miembro static pertenece a la clase, no a cada objeto. Se usa para dos cosas: constantes (Integer.MAX_VALUE) y métodos de fábrica como ese origen(), que dan un nombre a una forma concreta de crear el objeto. Lo que no es static es un cajón para variables globales: si un campo estático cambia, lo comparte todo el programa, y eso trae problemas en cuanto hay más de un hilo.

Lo esencial

Si el objeto… Escribe
Es un conjunto de valores inmutable un record
Tiene estado que cambia una class con campos private
Debe validarse al crearse la comprobación en el constructor
Se va a imprimir toString(), o un record que ya lo trae
Se va a comparar o meter en un Set o Map equals y hashCode, o un record
Necesita una forma con nombre de crearse un método static de fábrica

Todo el código de esta lección se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicarla; las salidas y los errores del compilador están copiados de esa ejecución.