Capítulo 4. Inmutabilidad

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Tratar con estructuras de datos -constructos dedicados a almacenar y organizar valores de datos- es una tarea central de casi cualquier programa. En la programación orientada a objetos, esto suele significar tratar con un estado mutable del programa, a menudo encapsulado en objetos. Para un enfoque funcional, sin embargo, la inmutabilidad es la forma preferida de tratar los datos y es un requisito previo para muchos de sus conceptos.

En lenguajes de programación funcional como Haskell, o incluso en lenguajes multiparadigma pero con una mayor inclinación funcional como Scala, la inmutabilidad se trata como una característica prevalente. En esos lenguajes, la inmutabilidad es una necesidad y a menudo se impone estrictamente, no sólo una ocurrencia tardía en su diseño. Como la mayoría de los demás principios introducidos en este libro, la inmutabilidad no se limita a la programación funcional y proporciona muchas ventajas, independientemente del paradigma que elijas.

En este capítulo, aprenderás acerca de los tipos inmutables ya disponibles en el JDK y cómo hacer que tus estructuras de datos sean inmutables para evitar efectos secundarios, ya sea con las herramientas que proporciona el JDK o con la ayuda de bibliotecas de terceros.

Nota

El término "estructura de datos" utilizado en este capítulo representa cualquier construcción que almacene y organice datos, como Colecciones o tipos personalizados.

Mutabilidad y estructuras de datos en POO

Como lenguaje inclinado orientado a objetos, el código Java típico encapsula el estado de un objeto de forma mutable. Su estado suele ser mutable mediante métodos "setter". Este enfoque hace que el estado del programa sea efímero, lo que significa que cualquier cambio en una estructura de datos existente actualiza su estado actual in situ, lo que también afecta a cualquier otra persona que haga referencia a ella, y el estado anterior se pierde.

Echemos un vistazo a las formas más comunes utilizadas para manejar el estado mutable en el código Java de programación orientada a objetos, tal y como se trató en el Capítulo 2: los JavaBeans y los objetos Java simples (POJO). Existe mucha confusión sobre estas dos estructuras de datos y sus distintas propiedades. En cierto sentido, ambos son objetos Java ordinarios que se supone que crean reutilización entre componentes al encapsular todos los estados relevantes. Tienen objetivos similares, aunque sus filosofías y reglas de diseño difieren.

Los POJO no tienen ninguna restricción en cuanto a su diseño. Se supone que "sólo" encapsulan el estado de la lógica de negocio, e incluso puedes diseñarlos para que sean inmutables. Cómo los implementes depende de ti y de lo que mejor se adapte a tu entorno. Suelen proporcionar "getters" y "setters" para sus campos, para que sean más flexibles en un contexto orientado a objetos con un estado mutable.

Los JavaBeans, en cambio, son un tipo especial de POJO que permite una introspección y reutilización más sencillas, lo que requiere que sigan ciertas reglas. Estas reglas son necesarias porque los JavaBeans se diseñaron inicialmente para ser un estado estandarizado legible por la máquina entre componentes, como un widget de interfaz de usuario en tu IDE....1Las diferencias entre POJO y JavaBeans se enumeran en la Tabla 4-1.

Tabla 4-1. POJOs frente a JavaBeans
POJO JavaBean

Restricciones generales

Impuesta por las reglas del lenguaje Java

Impuesta por la especificación de la API JavaBean

Serialización

Opcional

Debe aplicarse java.io.Serializable

Visibilidad del campo

Sin restricciones

private sólo

Acceso al campo

Sin restricciones

Accesible sólo mediante getters y setters

Constructores

Sin restricciones

Debe existir un constructor sin carga

Muchas de las estructuras de datos disponibles en el JDK, como el framework Colecciones,2 están construidas en su mayoría en torno al concepto de estado mutable y cambios in situ. Tomemos como ejemplo List<E>. Sus métodos de mutación, como add(E value) o remove(E value), sólo devuelven un boolean para indicar que se ha producido un cambio, y cambian la Colección in situ, por lo que se pierde el estado anterior. Puede que no necesites pensar mucho en ello en un contexto local, pero en cuanto una estructura de datos sale de tu esfera de influencia directa, ya no está garantizado que permanezca en su estado actual mientras mantengas una referenciaa ella.

El estado mutable engendra complejidad e incertidumbre. Debes incluir todos los posibles cambios de estado en tu modelo mental en cualquier momento para entender y razonar con tu código. Pero esto no se limita a un solo componente. Compartir estado mutable aumenta la complejidad para abarcar toda la vida útil de cualquier componente que tenga acceso a dicho estado compartido. La programación concurrente sufre especialmente bajo las complejidades del estado compartido, donde muchos problemas se originan en la mutabilidad y requieren soluciones intrincadas y a menudo mal utilizadas, como la sincronización de acceso y las referencias atómicas.

Garantizar la corrección de tu código y del estado compartido se convierte en una tarea de Sísifo de interminables pruebas unitarias y validación del estado. Y el trabajo adicional necesario se multiplica en cuanto el estado mutable interactúa con más componentes mutables, lo que da lugar a una verificación aún mayor de su comportamiento.

Ahí es donde la inmutabilidad proporciona otro enfoque para manejar las estructuras de datos y restablecer la razonabilidad.

Inmutabilidad (no sólo) en FP

La idea central de la inmutabilidad es sencilla: las estructuras de datos ya no pueden cambiar tras su creación. Muchos lenguajes de programación funcionales la soportan por diseño en su núcleo. El concepto no está ligado a la programación funcional per se, y tiene muchas ventajas en cualquier paradigma.

Nota

La inmutabilidad proporciona soluciones elegantes a muchos problemas, incluso fuera de los lenguajes de programación. Por ejemplo, el sistema distribuido de control de versiones Git3 utiliza esencialmente un árbol de punteros a blobs y diffs inmutables para proporcionar una representación robusta de los cambios históricos.

Las estructuras de datos inmutables son vistas persistentes de sus datos sin opción directa de cambiarlos. Para "mutar" una estructura de datos de este tipo, debes crear una nueva copia con los cambios previstos. No poder mutar los datos "in situ" puede resultar extraño en Java al principio. Comparado con la naturaleza habitualmente mutable del código orientado a objetos, ¿por qué dar los pasos adicionales necesarios para cambiar simplemente un valor? Esta creación de nuevas instancias copiando los datos conlleva una sobrecarga particular que se acumula rápidamente en las implementaciones ingenuas de la inmutabilidad.

A pesar de la sobrecarga y la extrañeza inicial de no poder cambiar los datos in situ, las ventajas de la inmutabilidad pueden hacer que merezca la pena incluso sin un enfoque más funcional de Java:

Previsibilidad

Las estructuras de datos no cambiarán sin que te des cuenta, porque sencillamente no pueden. Mientras hagas referencia a una estructura de datos, sabrás que es la misma que en el momento de su creación. Aunque compartas esa referencia o la utilices de forma concurrente, nadie puede cambiar tu copia de ella.

Validez

Tras la inicialización, una estructura de datos está completa. Sólo necesita verificarse una vez y permanece válida (o inválida) indefinidamente. Si necesitas construir una estructura de datos en varios pasos, el patrón constructor, que se muestra más adelante en "Creación paso a paso", desacopla la construcción y la inicialización de una estructura de datos.

Sin efectos secundarios ocultos

Ocuparse de los efectos secundarios es un problema realmente difícil en programación, aparte de la nomenclatura y la invalidación de la caché.4Un subproducto de las estructuras de datos inmutables es la eliminación de los efectos secundarios: siempre están como están. Aunque se muevan mucho por distintas partes de tu código o las utilices en una biblioteca de terceros fuera de tu control, no cambiarán sus valores ni te sorprenderán con un efecto secundario no deseado.

Seguridad del hilo

Sin efectos secundarios, las estructuras de datos inmutables pueden moverse libremente entre los límites de los subprocesos. Ningún subproceso puede cambiarlas, por lo que razonar sobre tu programa se vuelve más sencillo al no haber más cambios inesperados ni condiciones de carrera.

Almacenamiento en caché y optimización

Como son tal cual tras su creación, puedes almacenar en caché estructuras de datos inmutables con toda tranquilidad. Las técnicas de optimización, como la memoización, sólo son posibles con estructuras de datos inmutables, como se explica en el Capítulo 2.

Seguimiento de cambios

Si cada cambio da lugar a una estructura de datos completamente nueva, puedes rastrear su historial almacenando las referencias anteriores. Ya no necesitas rastrear intrincadamente los cambios de una sola propiedad para soportar una función de deshacer. Restaurar un estado anterior es tan sencillo como utilizar una referencia anterior a la estructura de datos.

Recuerda que todas estas ventajas son independientes del paradigma de programación elegido. Incluso si decides que un enfoque funcional puede no ser la solución adecuada para tu base de código, tu tratamiento de datos puede seguir beneficiándose enormemente de la inmutabilidad.

El estado de la inmutabilidad de Java

El diseño inicial de Java no incluía la inmutabilidad como una característica profundamente integrada en el lenguaje ni proporcionaba una variedad de estructuras de datos inmutables. Ciertos aspectos del lenguaje y sus tipos eran siempre inmutables, pero no se acercaba ni de lejos al nivel de soporte ofrecido en otros lenguajes más funcionales. Todo esto cambió cuando se lanzó Java 14 e introdujo Records, una estructura de datos inmutable incorporada a nivel del lenguaje.

Aunque aún no lo sepas, ya estás utilizando tipos inmutables en todos tus programas Java. Las razones que hay detrás de su inmutabilidad pueden ser diferentes, como optimizaciones en tiempo de ejecución o garantizar su uso correcto, pero independientemente de sus intenciones, harán que tu código sea más seguro y menos propenso a errores.

Echemos un vistazo a las distintas partes inmutables disponibles actualmente en el JDK.

java.lang.String

Uno de los primeros tipos que aprende todo desarrollador Java es el tipo String. ¡Las cadenas están por todas partes! Por eso tiene que ser un tipo altamente optimizado y seguro. Una de estas optimizaciones es que es inmutable.

String no es un tipo primitivo basado en valores, como int o char. Aún así, admite el operador + (más) para concatenar un String con otro valor:

String first = "hello, ";
String second = "world!";

String result = first + second;
// => "hello, world!"

Como cualquier otra expresión, la concatenación de cadenas crea un resultado y, en este caso, un nuevo objeto String. Por eso, a los desarrolladores Java se les enseña pronto a no abusar de la concatenación manual String. Cada vez que concatenas cadenas utilizando el operador + (más), se crea una nueva instancia String en el montón, ocupando memoria, como se muestra en la Figura 4-1. Estas instancias recién creadas pueden sumarse rápidamente, sobre todo si la concatenación se realiza en una sentencia de bucle como for o while.

String memory allocation
Figura 4-1. Asignación de memoria para cadenas

Aunque la JVM recogerá de la basura las instancias que ya no necesite, la sobrecarga de memoria que supone la creación interminable de String puede ser una verdadera carga para el tiempo de ejecución. Por eso la JVM utiliza múltiples técnicas de optimización entre bastidores para reducir la creación de String, como sustituir las concatenaciones por java.lang.StringBuilder, o incluso utilizar el opcode invokedynamic para soportar múltiples estrategias de optimización.5

Dado que String es un tipo tan fundamental, es sensato hacerlo inmutable por múltiples razones. Que un tipo base sea seguro para los hilos por diseño resuelve los problemas asociados a la concurrencia, como la sincronización, antes incluso de que existan. La concurrencia ya es lo suficientemente difícil como para preocuparse de que un String cambie sin previo aviso. La inmutabilidad elimina el riesgo de condiciones de carrera, efectos secundarios o un simple cambio involuntario.

String también reciben un tratamiento especial por parte de la JVM.Gracias a la agrupación de cadenas, los literales idénticos se almacenan una sola vez y se reutilizan para ahorrar un valioso espacio en la pila. Si un String pudiera cambiar, cambiaría para todos los que utilizaran una referencia a él en la agrupación. Es posible asignar un nuevo String llamando explícitamente a uno de sus constructores en lugar de crear un literal para eludir la agrupación. También es posible hacer el camino inverso, llamando al método intern en cualquier instancia, que devuelve un String con el mismo contenido de la agrupación de cadenas.

Igualdad de cadenas

El manejo especializado de las instancias y literales de String es la razón por la que nunca debes utilizar el operador de igualdad == (doble-igual) para comparar cadenas. Por eso siempre debes utilizar el método equals o equalsIgnoreCase para comprobar la igualdad.

Sin embargo, el tipo String no es "completamente" inmutable, al menos desde un punto de vista técnico. Calcula su hashCode perezosamente por consideraciones de rendimiento, ya que necesita leer todo el String para hacerlo. Aun así, es una función pura: el mismo String siempre dará como resultado el mismo hashCode.

Utilizar la evaluación perezosa para ocultar los costosos cálculos just-in-time y conseguir la inmutabilidad lógica requiere un cuidado extra durante el diseño y la implementación de un tipo para garantizar que sigue siendo seguro para los hilos y predecible.

Todas estas propiedades hacen que String sea algo entre un tipo primitivo y un tipo objeto, al menos desde el punto de vista de la usabilidad. Las posibilidades de optimización del rendimiento y la seguridad podrían haber sido las principales razones de su inmutabilidad, pero las ventajas implícitas de la inmutabilidad siguen siendo una adición bienvenida a un tipo tan fundamental.

Colecciones inmutables

Otro grupo fundamental y ubicuo de tipos que se benefician significativamente de la inmutabilidad son las Colecciones, como Set, List, Map, etc.

Aunque el marco de trabajo de la Colección de Java no se diseñó con la inmutabilidad como principio básico, tiene una forma de proporcionar cierto grado de inmutabilidad con tres opciones:

  • Colecciones no modificables

  • Métodos de fábrica de la Colección Inmutable (Java 9+)

  • Copias inmutables (Java 10+)

Todas las opciones no son tipos public que puedas instanciar simplemente utilizando la palabra clave new. En su lugar, los tipos correspondientes tienen métodos de conveniencia static para crear las instancias necesarias.Además, sólo son superficialmente inmutables, lo que significa que no puedes añadir ni eliminar ningún elemento, pero no se garantiza que los propios elementos sean inmutables. Cualquiera que tenga una referencia a un elemento puede cambiarlo sin que lo sepa la Colección en la que reside actualmente.

Inmutabilidad superficial

Las estructuras de datos superficialmente inmutables sólo proporcionan inmutabilidad en su nivel superior. Esto significa que la referencia a la propia estructura de datos no puede modificarse. Sin embargo, la estructura de datos referenciada -en el caso de una Colección, sus elementos- sí puede mutar.

Para tener una Colección totalmente inmutable, también tienes que tener sólo elementos totalmente inmutables. No obstante, las tres opciones siguen proporcionándote herramientas útiles que puedes utilizar contra modificaciones no intencionadas.

Colecciones no modificables

La primera opción, Colecciones no modificables, se crea a partir de una Colección existente llamando a uno de los siguientes métodos genéricos static de java.util.Collections:

  • Collection<T> unmodifiableCollection(Collection<? extends T> c)

  • Set<T> unmodifiableSet(Set<? extends T> s)

  • List<T> unmodifiableList(List<? extends T> list)

  • Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)

  • SortedSet<T> unmodifiableSortedSet(SortedSet<T> s)

  • SortedMap<K, V> unmodifiableSortedMap(SortedMap<K, ? extends V> m)

  • NavigableSet<T> unmodifiableNavigableSet(NavigableSet<T> s)

  • NavigableMap<K, V> unmodifiableNavigableMap(NavigableMap<K, V> m)

Cada método devuelve el mismo tipo que el proporcionado para el único argumento del método. La diferencia entre la instancia original y la devuelta es que cualquier intento de modificar la instancia devuelta lanzará un UnsupportedOperationException, como se demuestra en el código siguiente:

List<String> modifiable = new ArrayList<>();
modifiable.add("blue");
modifiable.add("red");

List<String> unmodifiable = Collections.unmodifiableList(modifiable);

unmodifiable.clear();
// throws UnsupportedOperationException

El inconveniente obvio de una "vista no modificable" es que sólo es una abstracción sobre una Colección existente. El código siguiente muestra cómo la Colección subyacente sigue siendo modificable y afecta a la vista no modificable:

List<String> original = new ArrayList<>();
original.add("blue");
original.add("red");

List<String> unmodifiable = Collections.unmodifiableList(original);

original.add("green");

System.out.println(unmodifiable.size());
// OUTPUT:
// 3

La razón de que siga siendo modificable a través de la referencia original es la forma en que la estructura de datos se almacena en memoria, como se ilustra en la Figura 4-2. La versión no modificada es sólo una vista de la lista original, por lo que cualquier cambio realizado directamente en la original elude la naturaleza no modificable prevista de la vista.

Memory layout of unmodifiable Collections
Figura 4-2. Disposición de la memoria de las Colecciones no modificables

El uso habitual de las vistas no modificables es congelar las colecciones para evitar modificaciones no deseadas antes de utilizarlas como valor de retorno.

Métodos de fábrica de la Colección inmutable

La segunda opción-métodos de fábrica de Colecciones inmutables-está disponible desde Java 9 y no se basa en Colecciones preexistentes, sino que los elementos deben proporcionarse directamente a los métodos de conveniencia static disponibles en los siguientes tipos de Colecciones:

  • List<E> of(E e1, …​)

  • Set<E> of(E e1, …​)

  • Map<K, V> of(K k1, V v1, …​)

Cada método de fábrica existe con cero o más elementos y utiliza un tipo interno de Colección optimizado en función del número de elementos utilizados.

Copias inmutables

La tercera opción, las copias inmutables, está disponible en Java 10+ y proporciona un nivel más profundo de inmutabilidad llamando al método static copyOf en estos tres tipos:

  • Set<E> copyOf(Collection<? extends E> coll)

  • List<E> copyOf(Collection<? extends E> coll)

  • Map<K, V> copyOf(Map<? extends K, ? extends V> map)

En lugar de ser una mera vista, copyOf crea un nuevo contenedor, que contiene sus propias referencias a los elementos:

// SETUP ORIGINAL LIST
List<String> original = new ArrayList<>();
original.add("blue");
original.add("red");

// CREATE COPY
List<String> copiedList = List.copyOf(original);

// ADD NEW ITEM TO ORIGINAL LIST
original.add("green");

// CHECK CONTENT
System.out.println(original);
// [blue, red, green]
System.out.println(copiedList);
// [blue, red]

La Colección copiada impide cualquier adición o eliminación de elementos a través de la lista original, pero los elementos reales siguen estando compartidos, como se ilustra en la Figura 4-3, y abiertos a cambios.

Memory layout of copied Collections
Figura 4-3. Disposición de la memoria de las Colecciones copiadas

La opción de Colecciones inmutables que elijas depende de tu contexto y de tus intenciones. Si una Colección no puede crearse en una sola llamada, como en un bucle for, una vista inmodificable o una copia inmutable es un enfoque sensato. Utiliza una Colección mutable localmente y "congélala" devolviendo una vista inmodificable o cópiala cuando los datos salgan de tu ámbito actual. Los métodos de fábrica de Colecciones inmutables no admiten una Colección intermedia que pueda modificarse; requieren que conozcas todos los elementos de antemano.

Primitivas y envolturas de primitivas

Hasta ahora, has aprendido sobre todo acerca de los tipos de objeto inmutables, pero no todo en Java es un objeto. Los tipos primitivos de Java -byte, char, short, int, long, float, double, boolean- se manejan de forma diferente a los tipos de objeto. Son valores simples que se inicializan mediante un literal o una expresión. Al representar un único valor, son prácticamente inmutables.

Además de los tipos primitivos propiamente dichos, Java proporciona los correspondientes tipos envolventes de objetos, como Byte o Integer. Encapsulan sus respectivas primitivas en un tipo de objeto concreto para hacerlas utilizables en escenarios en los que las primitivas no están permitidas (todavía), como los genéricos. De lo contrario, el autoboxing -la conversión automática entre los tipos envolventes de objetos y su correspondiente tipo primitivo- podría dar lugar a un comportamiento incoherente.

Matemáticas inmutables

La mayoría de los cálculos sencillos en Java se basan en primitivas como int o long para números enteros, y float o double para cálculos en coma flotante.El paquete java.math, sin embargo, tiene dos alternativas inmutables para cálculos de enteros y decimales más seguros y precisos, que son ambas inmutables: java.math.BigInteger y java.math.BigDecimal.

Nota

En este contexto, "entero" significa un número sin componente fraccionario y no del tipo int o Integer de Java. La palabra entero procede del latín y se utiliza en matemáticas como término coloquial para representar números enteros en el rango comprendido entre - a + incluido el cero.

Al igual que con String, ¿por qué deberías cargar tu código con la sobrecarga de la inmutabilidad? Porque permiten realizar cálculos sin efectos secundarios en un rango mayor y con mayor precisión.

Sin embargo, el escollo de utilizar objetos matemáticos inmutables es la posibilidad de olvidarse simplemente de utilizar el resultado real de un cálculo. Aunque los nombres de métodos como add o subtract sugieren una modificación, al menos en un contexto OO, los tipos java.math devuelven un nuevo objeto con el resultado, como se indica a continuación:

BigDecimal theAnswer = new BigDecimal(42);

BigDecimal result = theAnswer.add(BigDecimal.ONE);

// RESULT OF THE CALCULATION
System.out.println(result);
// OUTPUT:
// 43

// UNCHANGED ORIGINAL VALUE
System.out.println(theAnswer);
// OUTPUT:
// 42

Los tipos matemáticos inmutables siguen siendo objetos con la sobrecarga habitual y utilizan más memoria para alcanzar su precisión. No obstante, si la velocidad de cálculo no es tu factor limitante, siempre debes preferir el tipo BigDecimal para la aritmética de coma flotante debido a su precisión arbitraria.6

El tipo BigInteger es el equivalente entero a BigDecimal, también con inmutabilidad incorporada. Otra ventaja es el rango ampliado de al menos7 desde -22.147.483.647 hasta22.147.483.647 (ambos excluyentes), en comparación con el rango de int de -231 a231.

API de tiempo de Java (JSR-310)

Java 8 introdujo la API de tiempo de Java(JSR-310), diseñada con la inmutabilidad como principio básico. Antes de su lanzamiento, sólo disponías de tres tipos en el paquete8 tipos del paquete java.util para todas tus necesidades relacionadas con la fecha y la hora: Date, Calendar, y TimeZone. Realizar cálculos era una tarea pesada y propensa a errores. Por eso, la biblioteca Joda Time se convirtió en el estándar de facto para las clases de fecha y hora antes de Java 8 y, posteriormente, se convirtió en la base conceptual de la JSR-310.

Nota

Al igual que con las matemáticas inmutables, cualquier cálculo con métodos como plus o minus no afectará al objeto sobre el que se llaman, sino que tendrás que utilizar el valor devuelto.

En lugar de los tres tipos anteriores de java.util, ahora hay varios tipos relacionados con la fecha y la hora con distintas precisiones, con y sin zonas horarias, disponibles en el paquete java.time. Todos son inmutables, lo que les proporciona todas las ventajas relacionadas, como la ausencia de efectos secundarios y el uso seguro en entornos concurrentes.

Enums

Las enum de Java son tipos especiales formados por constantes. Y las constantes son, bueno, constantes, y por tanto inmutables. Además de los valores constantes, una enum puede contener campos adicionales que no son implícitamente constantes.

Normalmente, para estos campos se utilizan las primitivas final o las cadenas, pero nadie te impide utilizar un tipo de objeto mutable o un setter para una primitiva. Lo más probable es que dé problemas, y te lo desaconsejo encarecidamente. Además, se considera un olor a código.9

La palabra clave final

Desde los inicios de Java, la palabra clave final proporciona una cierta forma de inmutabilidad dependiendo de su contexto, pero no es una palabra clave mágica para hacer inmutable cualquier estructura de datos. Entonces, ¿qué significa exactamente que una referencia, método o clase sea final?

La palabra clave final es similar a la palabra clave const del lenguaje de programación C. Tiene varias implicaciones si se aplica a clases, métodos, campos o referencias:

  • final no pueden subclasificarse.

  • final no pueden anularse.

  • final deben asignarse exactamente una vez -ya sea por los constructores o en la declaración- y nunca pueden reasignarse.

  • final Las referencias a variables se comportan como un campo, ya que se pueden asignar exactamente una vez, en la declaración. La palabra clave sólo afecta a la referencia en sí, no al contenido de la variable referenciada.

La palabra clave final otorga una forma particular de inmutabilidad a los campos y variables. Sin embargo, su inmutabilidad puede no ser la que esperas, porque la referencia en sí se vuelve inmutable, pero no la estructura de datos subyacente. Eso significa que no puedes reasignar la referencia, pero sí puedes cambiar la estructura de datos, como se muestra en el Ejemplo 4-1.

Ejemplo 4-1. Colecciones y referencias final
final List<String> fruits = new ArrayList<>(); 1

System.out.println(fruits.isEmpty());
// OUTPUT:
// true

fruits.add("Apple"); 2

System.out.println(fruits.isEmpty());
// OUTPUT:
// false

fruits = List.of("Mango", "Melon"); 3
// => WON'T COMPILE
1

La palabra clave final sólo afecta a la referencia fruits, no a la referencia real ArrayList.

2

El propio ArrayList no tiene ningún concepto de inmutabilidad, por lo que puedes añadirle libremente nuevos elementos, aunque su referencia sea final.

3

Está prohibido reasignar una referencia final.

Como se ha comentado en "Efectivamente final", tener referencias efectivamente final es una necesidad para las expresiones lambda. Hacer que cada referencia de tu código sea final es una opción; sin embargo, yo no lo recomendaría. El compilador detecta automáticamente si una referencia se comporta como una referencia final incluso sin añadir una palabra clave explícita. La mayoría de los problemas creados por la falta de inmutabilidad provienen de la propia estructura de datos subyacente y no de las referencias reasignadas. Para asegurarte de que una estructura de datos no cambiará inesperadamente mientras esté en uso activo, debes elegir una estructura de datos inmutable desde el principio.La última incorporación a Java para lograr este objetivo es Records.

Registros

En 2020, Java 14 introdujo un nuevo tipo de clase con su propia palabra clave para complementar o incluso sustituir a los POJOs y JavaBeans en determinados casos: Los Registros.

Los registros son agregados de "datos planos" con menos ceremonias que los POJO o los Java Beans. Su conjunto de características se reduce al mínimo absoluto para servir a ese propósito, haciéndolos tan concisos como son:

public record Address(String name,
                      String street,
                      String state,
                      String zipCode,
                      Country country) {
  // NO BODY
}

Los registros son soportes de datos superficialmente inmutables que consisten principalmente en la declaración de su estado. Sin código adicional, el registro Address proporciona getters generados automáticamente para los componentes nombrados, comparación de igualdad, métodos toString y hashCode, y mucho más.

El capítulo 5 profundizará en los Registros sobre cómo crearlos y utilizarlos en distintos escenarios.

Cómo conseguir la inmutabilidad

Ahora que conoces las partes inmutables que proporciona la JVM, es hora de ver cómo combinarlas para conseguir la inmutabilidad del estado de tu programa.

La forma más sencilla de hacer que un tipo sea inmutable es, en primer lugar, no darle la oportunidad de cambiar. Sin ningún setter, una estructura de datos con campos final no cambiará después de su creación porque no puede hacerlo. Sin embargo, para el código del mundo real, la solución puede no ser tan sencilla.

La inmutabilidad requiere una nueva forma de pensar sobre la creación de datos, porque las estructuras de datos compartidas rara vez se crean de una sola vez. En lugar de mutar una única estructura de datos a lo largo del tiempo, debes trabajar con construcciones inmutables a lo largo del proceso, si es posible, y componer al final una estructura de datos "final" e inmutable.La Figura 4-4 muestra la idea general de diferentes componentes de datos que contribuyen a un Registro inmutable "final". Aunque los componentes individuales no sean inmutables, siempre debes esforzarte por envolverlos en una estructura inmutable, Registro o de otro tipo.

Records as data aggregators
Figura 4-4. Los registros como agregadores de datos

Llevar un registro de los componentes necesarios y su validación puede ser un reto en estructuras de datos más complicadas. En el Capítulo 5, hablaré de herramientas y técnicas que mejoran la creación de estructuras de datos y reducen la complejidad cognitiva necesaria.

Prácticas comunes

Al igual que el enfoque funcional en general, la inmutabilidad no tiene por qué ser un enfoque de todo o nada. Debido a sus ventajas, tener sólo estructuras de datos inmutables suena intrigante, y tu objetivo clave debería ser utilizarlas y utilizar referencias inmutables como enfoque por defecto. Sin embargo, convertir las estructuras de datos mutables existentes en inmutables suele ser una tarea bastante compleja que requiere mucha refactorización o rediseño conceptual. En lugar de eso, podrías introducir la inmutabilidad gradualmente siguiendo prácticas comunes como las que se indican a continuación y tratando tus datos como si ya fueran inmutables:

Inmutabilidad por defecto

Cualquier estructura de datos nueva, como los objetos de transferencia de datos, los objetos de valor o cualquier tipo de estado, debe diseñarse como inmutable. Si el JDK u otro marco o biblioteca que utilices proporciona una alternativa inmutable, debes considerarla por encima de un tipo mutable. Tratar la inmutabilidad desde el principio con un tipo nuevo influirá y dará forma a cualquier código que lo utilice.

Espera siempre inmutabilidad

Asume que todas las estructuras de datos son inmutables, a menos que las hayas creado tú o se indique explícitamente lo contrario, sobre todo cuando se trate de tipos tipo Colección. Si necesitas modificar una, es más seguro crear una nueva basada en la existente.

Modificar tipos existentes

Aunque un tipo preexistente no sea inmutable, los nuevos deberían serlo, si es posible. Puede haber razones para hacerlo mutable, pero la mutabilidad innecesaria aumenta la superficie de errores, y todas las ventajas de la inmutabilidad se desvanecen al instante.

Rompe la inmutabilidad si es necesario

Si no encaja, no lo fuerces, sobre todo en bases de código heredadas. El principal objetivo de la inmutabilidad es proporcionar estructuras de datos más seguras y razonables, lo que requiere que su entorno las soporte en consecuencia.

Tratar las estructuras de datos ajenas como inmutables

Trata siempre como inmutable cualquier estructura de datos que no esté bajo el control de tu ámbito. Por ejemplo, los tipos de tipo Colección recibidos como argumentos de un método deben considerarse inmutables. En lugar de manipularlos directamente, crea una vista envolvente mutable para cualquier cambio, y devuelve un tipo Colección inmodificable. Este enfoque mantiene la pureza del método y evita cualquier cambio involuntario que el receptor de la llamada no haya esperado.

Seguir estas prácticas comunes facilitará la creación de estructuras de datos inmutables desde el principio o la transición gradual a un estado de programa más inmutable a lo largo del camino.

Para llevar

  • La inmutabilidad es un concepto sencillo, pero requiere una nueva mentalidad y un nuevo enfoque para manejar los datos y el cambio.

  • Muchos tipos del JDK ya están diseñados teniendo en cuenta la inmutabilidad.

  • Los registros proporcionan una forma nueva y concisa de reducir la jerga para crear estructuras de datos inmutables, pero carecen deliberadamente de cierta flexibilidad para ser lo más transparentes y sencillos posible.

  • Puedes conseguir la inmutabilidad con las herramientas incorporadas del JDK, y las bibliotecas de terceros pueden proporcionar soluciones sencillas a las piezas que faltan.

  • Introducir la inmutabilidad en tu código no tiene por qué ser un enfoque de todo o nada. Puedes aplicar gradualmente prácticas comunes de inmutabilidad a tu código existente para reducir los errores relacionados con el estado y facilitar los esfuerzos de refactorización.

1 Los JavaBeans se especifican en la Especificación oficial de la API JavaBeans 1.01, que tiene más de cien páginas. Para el alcance de este libro, sin embargo, no necesitas conocerla toda, pero deberías estar familiarizado con las diferencias mencionadas respecto a otras estructuras de datos.

2 Desde Java 1.2, el marco Java Collections proporciona multitud de estructuras de datos reutilizables comunes, como List<E>, Set<E>, etc. La documentación de Oracle Java tiene una visión general de los tipos disponibles incluidos en el marco.

3 Git es un sistema de control de versiones distribuido, gratuito y de código abierto. Su sitio web proporciona amplia documentación sobre su funcionamiento interno.

4 Phil Karton, un consumado ingeniero de software que durante muchos años fue desarrollador principal en Xerox PARC, Digital, Silicon Graphics y Netscape, dijo: "Sólo hay dos cosas difíciles en Informática: invalidar cachés y nombrar cosas". Con los años, se convirtió en un chiste habitual en la comunidad del software, y a menudo se modifica añadiendo "errores puntuales" sin cambiar la cuenta de dos.

5 La Propuesta de Mejora del JDK (JEP) 280, "Indificar la concatenación de cadenas", describe con más detalle el razonamiento para utilizar invokedynamic.

6 La aritmética de precisión arbitraria -también conocida como aritmética bignum, aritmética de precisión múltiple o, a veces, aritmética de precisión infinita- realiza cálculos sobre números cuyos dígitos de precisión sólo están limitados por la memoria disponible, no por un número fijo.

7 El rango real de BigInteger depende de la implementación real del JDK utilizado, como se indica en una nota de implementación de la documentación oficial.

8 Técnicamente existe un cuarto tipo, java.sql.Date, que es una fina envoltura para mejorar la compatibilidad con JDBC.

9 Un olor a código es una característica conocida del código que podría indicar un problema más profundo. No es un fallo o error en sí, pero puede causar problemas a largo plazo. Estos olores son subjetivos y varían según el lenguaje de programación, el desarrollador y los paradigmas. Sonar, la conocida empresa que desarrolla software de código abierto para la calidad y seguridad continuas del código, enumera los enums mutables como la regla RSPEC-3066.

Get Un enfoque funcional de Java now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.