Arturo Parra
Arturo Parra
Ingeniero de Software
Oct 2, 2023 14 min read

Todo Sobre los Registros en Java

thumbnail for this post

La versión 16 de Java introdujo los registros, aunque existe como versión de prueba desde Java 14. El equipo que mantiene el lenguaje Java agregó los registros como una forma de ofrecernos una manipulación y encapsulamiento de datos más sencilla que como lo hacemos con las clases tradicionales de Java.

En este artículo voy a explicar cómo puedes lograr el encapsulamiento de datos de una forma muy sencilla utilizando los registros de Java.

La programación en Java ha evolucionado a lo largo de los años, y con cada versión, el equipo que mantiene el lenguaje Java se ha esforzado por hacer que el desarrollo de software sea más legible y eficiente. Uno de los avances más notables en este sentido es la introducción de los registros de Java, una característica que ha sido bienvenida en la comunidad de desarrolladores al eliminar gran parte del tedioso código repetitivo que solíamos escribir.

En el mundo de la programación orientada a objetos es común utilizar clases POJO (Plain Old Java Objects) para encapsular datos. Sin embargo, esto suele requerir la implementación de constructores con validaciones, métodos “getters” para acceder a los datos y la implementación de algunos métodos de la clase Object, como hashCode, equals, y toString. Este código repetitivo, aunque necesario, a menudo dificulta la lectura y mantenimiento del código.

El encapsulamiento es una práctica fundamental en la programación para proteger las clases de usos inesperados y garantizar la coherencia de los datos. Además, en muchas situaciones, es deseable que las clases POJO sean inmutables, es decir, que las instancias de estas clases no se puedan modificar una vez creadas.

¿Por qué Utilizar Registros en Lugar de Clases?

En respuesta a los desafíos que vimos en la sección anterior, Java introdujo los registros (records).

En este artículo exploraremos en profundidad el concepto de registros en Java y cómo están transformando la forma en que gestionamos y modelamos datos en nuestros programas. Descubriremos cómo los registros simplifican el código, mejoran la legibilidad y ayudan a crear clases inmutables de manera eficiente. ¡Prepárate para un emocionante viaje al mundo de los registros de Java y descubre cómo pueden simplificar tu desarrollo de software!

¿Qué son los Registros en Java?

Un registro en Java es un tipo especializado que se centra en la gestión de datos. Lo que lo hace revolucionario es que el compilador de Java se encarga automáticamente de generar el código repetitivo asociado con las clases POJO, incluyendo las implementaciones de los métodos hashCode, equals, y toString.

No solo eso, los registros en Java también simplifican la nomenclatura. A diferencia de los métodos “getters” tradicionales que comienzan con “get”, los registros simplemente utilizan los nombres de las propiedades para acceder a sus valores.

// "Getter" tradicional
miObjeto.getNombre();

// "Getter" en registros de Java
miObjeto.nombre();

Observa en el código anterior, cómo la llamada al método “nombre()” no antepone el estándar “get” al nombre del método.

Además, los registros generan un constructor por defecto que incluye una lista de parámetros con el mismo tipo de datos y en el mismo orden con que definimos las propiedades del registro. Esto elimina la necesidad de escribir constructores personalizados, lo que ahorra tiempo y reduce la posibilidad de errores.

Para darte una muestra de cómo podemos simplificar la creación de POJOs utilizando registros, veamos primero cómo sería implementar uno antes de Java 16:

public final class CuentaBancaria {
 
    private final int numeroCuenta;
    private final float saldo;
  
    // Constructor para validar los parámetros
    public CuentaBancaria(int numeroCuenta, float saldo) {
        
        if (numeroCuenta < 0) {
            throw new IllegalArgumentException(
                "¡El número de cuenta no puede ser negativo!");            
        }

        this.numeroCuenta = numeroCuenta;
        this.saldo = saldo;
    }

    // Método "getter" para obtener el número de cuenta
    public int getNumeroCuenta(){
        return this.numeroCuenta;
    }

    // Método "getter" para obtener el saldo
    public float getSaldo() {
        return this.saldo;
    }

    @Override
    public String toString() {
        return "CuentaBancaria [numeroCuenta=" 
            + numeroCuenta + ", saldo=" + saldo + "]";
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + numeroCuenta;
        result = prime * result + Float.floatToIntBits(saldo);
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        
        CuentaBancaria other = (CuentaBancaria) obj;
        if (numeroCuenta != other.numeroCuenta)
            return false;
        if (Float.floatToIntBits(saldo) != Float.floatToIntBits(other.saldo))
            return false;
        return true;
    }
}

¡Enorme! Si bien todo ese código extra puede ser autogenerado por los editores modernos, aun así presenta algunas desventajas, principalmente el hecho de que el código que realmente nos interesa queda “enterrado” en todo ese mar de código, dificultando su legibilidad y, por ende, su mantenibilidad.

Otra desventaja es que se vuelve propenso a errores cuando tenemos que modificar el código como parte del ciclo normal de vida de un programa, ya sea para agregar o cambiar su funcionamiento, porque podemos cambiar código que no deberíamos, de forma accidental.

Ahora hagamos lo mismo usando un registro de Java:

public record CuentaBancaria(int numeroCuenta, float saldo) {}

¡Y eso es todo!, con eso obtienes el constructor con los dos parámetros que se asignan a las propiedades del objeto al momento de crearlo, además obtienes los métodos “getters”, mientras que los métodos toString() , hashCode() , y equals() se sobrescriben de manera automática, ¡todo esto gratis!, con tan solo una línea de código, ¿Qué más podemos pedir?.

Bueno, faltaría una pequeña cosa para lograr todo lo que lograríamos con un POJO tradicional, me refiero a la validación de los parámetros del constructor, pero los registros también nos dejan escribir nuestros constructores personalizados, como veremos en la siguiente sección.

Construyendo Registros en Java

Hasta ahora, hemos explorado cómo los registros de Java simplifican la gestión de datos, pero ahora profundizaremos en cómo construir registros y aprovechar al máximo su flexibilidad en la creación de objetos.

Constructores de Formato Largo y Compacto

Cuando creamos registros en Java, tenemos la opción de definir constructores en formato largo o compacto. El formato largo implica escribir un constructor explícito que toma una lista completa de parámetros que coinciden con las propiedades del registro. El formato compacto, por otro lado, nos permite omitir la declaración explícita del constructor y las propiedades se inicializan automáticamente.

Agreguemos un constructor de formato largo a nuestro registro CuentaBancaria , e incluyamos la validación del parámetro numeroCuenta :

public record CuentaBancaria(int numeroCuenta, float saldo) {
  
  public CuentaBancaria(int numeroCuenta, float saldo) {
    
    if (numeroCuenta < 0) {
        throw new IllegalArgumentException(
            "¡El número de cuenta no puede ser negativo!");            
    }

    this.numeroCuenta = numeroCuenta;
    this.saldo = saldo;

  }
}

No hay sorpresas aquí, los constructores de formato largo en los registros de Java son idénticos que los constructores en clases tradicionales.

Es importante destacar que si definimos un constructor en formato largo que contiene la misma lista de parámetros que especificamos en la definición del registro, Java no incluirá automáticamente dicho constructor, respeta el que nosotros incluimos.

Constructor Compacto para Validaciones y Transformaciones

Los constructores de formato compacto se introdujeron para permitir la validación y transformación de un subconjunto de las propiedades sin la necesidad de escribir un constructor completo. La anatomía de un constructor compacto es la siguiente:

public <<NombreRegistro>> {
  // Aquí van las validaciones y transformaciones
}

Es importante destacar que el constructor compacto no lleva paréntesis, lo que lo distingue claramente del constructor en formato largo.

Dicho lo anterior, debido a que en nuestro registro CuentaBancaria solo valida uno de los parámetros del constructor, a saber numeroCuenta , no necesitamos un constructor de formato largo, por lo que lo podemos sustituir por un constructor de formato compacto, dejando nuestro registro de la siguiente manera:

public record CuentaBancaria(int numeroCuenta, float saldo) {
  
  // Constructor de formato compacto
  public CuentaBancaria {

    // Validación de parámetro    
    if (numeroCuenta < 0) {
        throw new IllegalArgumentException(
            "¡El número de cuenta no puede ser negativo!");            
    }

  }

}

Orden de Ejecución de Constructores en Registros de Java

Cuando se crea un objeto de registro, los constructores compactos se ejecutan antes que los constructores largos, si están presentes. Esto significa que cualquier validación o transformación especificada en el constructor compacto se aplicará antes de que se ejecute el constructor en formato largo.

Transformación de Parámetros

Otro uso útil de los constructores compactos es la capacidad de transformar parámetros antes de asignarlos a las propiedades del registro. Esto puede ser especialmente útil para realizar tareas como el formateo de texto o la conversión de tipos de datos antes de almacenarlos en las propiedades del registro.

Digamos que nuestro registro tiene una propiedad adicional llamada alias , y queremos convertirla a mayúsculas antes de inicializar nuestro objeto de registro:

public record CuentaBancaria(int numeroCuenta, float saldo, String alias) {

  // Constructor de formato compacto
  public CuentaBancaria {

    // Validación de parámetro numeroCuenta  
    if (numeroCuenta < 0) {
        throw new IllegalArgumentException(
            "¡El número de cuenta no puede ser negativo!");            
    }

    // Transformación de parámetro alias
    if (alias != null) {
      alias = alias.toUpperCase();
    }

  }
}

Así, estamos utilizando un constructor de formato compacto para validar uno de los parámetros, y para transformar a mayúsculas otro de los parámetros. Los parámetros que no estamos “tocando” en este constructor, serán inicializados por el constructor que Java crea automáticamente.

Inmutabilidad en Constructores Compactos

Debo decir que, al igual que en cualquier otro constructor de un registro, no se pueden mutar las propiedades del objeto en los constructores compactos. Esto garantiza que los objetos de registro sigan siendo inmutables después de su creación, lo que es fundamental para mantener la integridad de los datos.

Por ejemplo, la asignación que tenemos para el parámetro alias en nuestro registro CuentaBancaria , es solo para el parámetro en sí mismo, y no para la propiedad, eventualmente Java llama al constructor con todos los parámetros necesarios, incluyendo los que cambiamos en nuestro constructor de formato compacto, y es hasta entonces que el valor se asigna a la propiedad.

Lo anterior significa que los constructores de formato compacto en realidad actúan como un proxy del constructor real, en mi opinión, debieron llamarse proxis de constructor en lugar de constructores compactos.

Preferencia por Constructores Compactos

En general, te recomiendo utilizar constructores compactos en lugar de constructores largos siempre que sea posible. Los constructores compactos simplifican la creación de objetos y reducen la cantidad de código necesario para inicializar un registro. Esto concuerda con el principio de mantener el código simple y legible, lo que hace que los registros sean aún más atractivos como una forma eficiente de gestionar datos en Java.

Construyendo Registros con Flexibilidad

Los registros en Java ofrecen una flexibilidad significativa en la creación de objetos mediante constructores sobrecargados y personalizados. Aquí exploramos en detalle cómo puedes adaptar la construcción de registros a tus necesidades específicas.

1. Constructores Sobrecargados en Registros

Uno de los aspectos más versátiles de los registros es su capacidad para contener constructores sobrecargados. Esto permite que un registro tenga múltiples constructores, cada uno aceptando diferentes combinaciones de parámetros. Esta característica es especialmente útil cuando deseas crear objetos de registro de diversas maneras o cuando algunos parámetros son opcionales.

Por ejemplo, podríamos sobrecargar el constructor de nuestro registro CuentaBancaria , si queremos que la propiedad alias sea opcional:

public record CuentaBancaria(int numeroCuenta, float saldo, String alias) {

  // Sobrecargando el constructor para hacer opcional el parámetro "alias"
  public CuentaBancaria(int numeroCuenta, float saldo) {
    this(numeroCuenta, saldo, null);
  }

  // Constructor de formato compacto
  public CuentaBancaria {

    // Validación de parámetro numeroCuenta  
    if (numeroCuenta < 0) {
        throw new IllegalArgumentException(
            "¡El número de cuenta no puede ser negativo!");            
    }

    // Transformación de parámetro alias
    if (alias != null) {
      alias = alias.toUpperCase();
    }

  }
}

En un constructor sobrecargado de un registro, la primera línea debe ser una llamada explícita a otro constructor dentro del mismo registro utilizando el método this(). Esto garantiza que la inicialización de las propiedades se realice de manera coherente en todos los constructores del registro. Esta llamada a this() es obligatoria incluso si solo tienes un constructor, en cuyo caso se llamará al constructor por defecto con la lista completa de parámetros.

2. Transformaciones de Parámetros en la Primera Línea

Es importante destacar que cualquier transformación que desees aplicar a los parámetros debe realizarse en la primera línea del constructor. Cualquier transformación que ocurra después de la llamada a this() será ignorada. Esto garantiza que los parámetros se procesen de manera consistente en todos los constructores.

3. Evitar Dependencias Circulares

Al igual que en las clases normales, no pueden existir dependencias circulares entre constructores en registros. Java arrojará un error de compilación si intentas crear una estructura de constructores que dependan entre ellos.

4. Combinación de Constructores Sobrecargados y Compactos

Los registros en Java pueden contener tanto constructores sobrecargados como compactos. Esta combinación ofrece una gran flexibilidad en la creación de objetos de registro, lo que te permite adaptar la inicialización de propiedades según tus necesidades.

Cuando tenemos tanto constructores compactos como constructores sobrecargados en nuestro registro, los constructores compactos siempre se ejecutan antes que los constructores de formato largo, ya sean sobrecargados o el constructor con toda la lista de parámetros correspondiente a las propiedades el registro. De esa forma Java se asegura de que todas las validaciones y/o transformaciones tengan efecto antes de crear el objeto de registro.

5. Sobrescritura de Métodos Proveídos

Los registros permiten sobrescribir los métodos generados automáticamente, como los métodos de acceso (getters), equals(), hashCode(), y toString(). Cuando sobrescribes estos métodos, es una buena práctica utilizar la anotación @Override para indicar claramente que estás reemplazando una implementación generada automáticamente.

6. Registros y Componentes Anidados

Un registro puede incluir clases anidadas, interfaces, anotaciones, enumeraciones (enums) y, de hecho, otros registros. Esta capacidad de anidamiento permite organizar y estructurar tu código de manera eficiente y coherente.

7. Limitaciones en las Propiedades

Es importante tener en cuenta que no puedes agregar propiedades (variables de instancia) adicionales en un registro que no estén incluidas en su definición. Incluso si estas propiedades son privadas, Java arrojará un error de compilación si intentas hacerlo. Todas las inicializaciones de propiedades deben realizarse en algún constructor, y no se permiten inicializadores de instancia.

Conclusión

Los registros en Java representan una evolución significativa en la forma en que gestionamos los datos en nuestros programas. Con su sintaxis concisa y características versátiles, los registros permiten la creación eficiente de clases de datos inmutables. A lo largo de este artículo, hemos explorado las numerosas ventajas que los registros aportan a la programación en Java y cómo podemos utilizarlos de manera efectiva.

Hemos visto cómo los registros eliminan el código “boilerplate” al proporcionar implementaciones predeterminadas para métodos comunes como equals(), hashCode(), toString() y getters. Además, hemos aprendido a construir registros con constructores sobrecargados y compactos, lo que nos brinda flexibilidad en la creación de objetos. La capacidad de sobrescribir métodos generados nos permite personalizar aún más el comportamiento de nuestros registros según nuestras necesidades específicas.

También destacamos la importancia de mantener las propiedades inmutables en los registros, lo que contribuye a la integridad de los datos y la seguridad en entornos de múltiples hilos.

Es importante recordar que los registros no solo son una característica aislada, sino que se integran perfectamente con otros elementos del lenguaje Java, como clases anidadas, interfaces y más. Esto proporciona un enfoque coherente y limpio para estructurar nuestros programas.

En resumen, los registros en Java son una herramienta poderosa que simplifica drásticamente la gestión de datos en nuestras aplicaciones. Su diseño elegante y su capacidad para reducir la verbosidad del código hacen que sean una elección natural para representar estructuras de datos simples. Al incorporar registros en nuestras prácticas de programación, podemos escribir código más limpio, legible y eficiente, lo que contribuye a un desarrollo de software más eficaz y de mayor calidad en el mundo de Java.

Preguntas Frecuentes sobre los Registros en Java

¿Por qué los Registros son Definitivos en Java?

Se debe al patrón de diseño de encapsulamiento, el cual dicta que se deben proteger del exterior los datos que utiliza una unidad de software, en el caso de una Clase o un Registro que están orientados a datos, es necesario que marquemos las propiedades y la clase y/o registro como final , lo cual los vuelve efectivamente definitivos.

En el caso de una clase, debes marcarla como final explícitamente, pero en los registros esto se hace de forma implícita, o sea, puedes omitir la palabra reservada final y Java lo marcará como final de manera automática.

¿Es un Registro de Java una Clase?

La respuesta corta es que sí, pero es una clase especializada, la cual nos permite escribir unidades de software compactas, que nos evita escribir código repetitivo y tedioso, pero a final de cuentas, el compilador de Java transforma los registros en clases, por eso podemos construir objetos tanto de clases como de registros de la misma forma.

¿Cuál es la Diferencia entre Registro y Clase en Java?

Un registro es una clase especializada, generalmente orientada a la transportación de datos, mientras que una clase es más general, con la cual podemos escribir comportamientos del programa que no necesariamente están orientados a datos, por ejemplo pueden definir las reglas de negocio de un sistema.

Referencias

Selikoff, S. & Boyarsky, J. (2022). OCA: Oracle Certified Professional Java SE 17 Developer I Study Guide: Exam 1Z0-829. Hoboken.


Santana, O (2023)

“Exploring Java Records beyond Data Transfer Objects”

InfoQ.


Single Responsability Principle (Parte de SOLID)

Oloruntoba, S (2021)

“SOLID: The First 5 Principles of Object Oriented Design”

DigitalOcean.