Hoy queremos tocar el tema de la inmutabilidad y ver si este problema merece una consideración más seria.
Los objetos inmutables son un fenómeno inconmensurablemente poderoso en la programación. La inmutabilidad lo ayuda a evitar todo tipo de problemas de concurrencia y una serie de errores, pero comprender las construcciones inmutables puede ser complicado. Echemos un vistazo a qué son y cómo usarlos.
Primero, eche un vistazo a un objeto simple:
class Person {
public String name;
public Person(
String name
) {
this.name = name;
}
}
Como puede ver, el objeto
Person
toma un parámetro en su constructor y luego lo coloca en una variable pública name
. En consecuencia, podemos hacer cosas como esta:
Person p = new Person("John");
p.name = "Jane";
Simple, ¿verdad? En cualquier momento, leemos o modificamos los datos como nos plazca. Pero hay un par de problemas con este método. El primero y más importante de ellos es que usamos una variable en nuestra clase
name
y, por lo tanto, introducimos irrevocablemente el almacenamiento interno de la clase en la API pública. En otras palabras, no hay forma de que podamos cambiar la forma en que se almacena el nombre dentro de la clase, a menos que reescribamos una parte significativa de nuestra aplicación.
Algunos lenguajes (por ejemplo, C #) ofrecen la posibilidad de insertar una función getter para solucionar este problema, pero en la mayoría de los lenguajes orientados a objetos tienes que actuar explícitamente:
class Person {
private String name;
public Person(
String name
) {
this.name = name;
}
public String getName() {
return name;
}
}
Hasta ahora tan bueno. Si ahora desea cambiar el almacenamiento interno del nombre, por ejemplo, al nombre y apellido, puede hacer esto:
class Person {
private String firstName;
private String lastName;
public Person(
String firstName,
String lastName
) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getName() {
return firstName + " " + lastName;
}
}
Si no profundiza en los graves problemas asociados con dicha representación de nombres , es obvio que la API
getName()
no ha cambiado externamente .
¿Qué hay de establecer nombres? ¿Qué necesita agregar para no solo obtener el nombre, sino también configurarlo así?
class Person {
private String name;
//...
public void setName(String name) {
this.name = name;
}
//...
}
A primera vista luce genial, porque ahora podemos volver a cambiar el nombre. Pero hay una falla fundamental en esta forma de modificar los datos. Tiene dos caras: filosófica y práctica.
Comencemos con un problema filosófico. El objeto está
Person
destinado a representar a una persona. De hecho, el apellido de una persona puede cambiar, pero sería mejor nombrar una función para este propósito changeName
, ya que tal nombre implica que estamos cambiando el apellido de la misma persona. También debe incluir la lógica empresarial para cambiar el apellido de una persona, y no solo actuar como un organizador. El nombre setName
lleva a una conclusión completamente lógica de que podemos cambiar voluntaria y obligatoriamente el nombre almacenado en el objeto persona, y no obtendremos nada por ello.
La segunda razón tiene que ver con la práctica: el estado mutable (datos almacenados que pueden cambiar) es propenso a errores. Tomemos este objeto
Person
y definamos una interfaz PersonStorage
:
interface PersonStorage {
public void store(Person person);
public Person getByName(String name);
}
Tenga en cuenta que esto
PersonStorage
no indica dónde se almacena exactamente el objeto: en la memoria, en el disco o en una base de datos. La interfaz tampoco requiere una implementación para crear una copia del objeto que almacena. Por tanto, puede surgir un error interesante:
Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);
¿Cuántas personas hay actualmente en la tienda personal? ¿Uno o dos? Además, si aplicas el método ahora
getByName
, ¿a quién regresará?
Como puede ver, aquí son posibles dos opciones: o
PersonStorage
copiará el objeto Person
, en cuyo caso se guardarán dos registros Person
, o no lo hará, y solo guardará la referencia al objeto pasado; en el segundo caso, solo se guardará un objeto con el nombre “Jane”
. La implementación de la segunda opción podría verse así:
class InMemoryPersonStorage implements PersonStorage {
private Set<Person> persons = new HashSet<>();
public void store(Person person) {
this.persons.add(person);
}
}
Peor aún, los datos almacenados se pueden cambiar sin siquiera llamar a la función
store
. Dado que el repositorio contiene solo una referencia al objeto original, cambiar el nombre también cambiará la versión guardada:
Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
Entonces, en esencia, los errores se infiltran en nuestro programa precisamente porque estamos tratando con un estado mutable. No hay duda de que este problema se puede sortear anotando explícitamente el trabajo de crear una copia en el almacenamiento, pero hay una forma mucho más sencilla: trabajar con objetos inmutables. Consideremos un ejemplo:
class Person {
private String name;
public Person(
String name
) {
this.name = name;
}
public String getName() {
return name;
}
public Person withName(String name) {
return new Person(name);
}
}
Como puede ver, en lugar de un método,
setName
ahora se usa un método withName
que crea una nueva copia del objeto Person
. Si creamos una nueva copia cada vez, lo haremos sin estado mutable y sin los problemas correspondientes. Por supuesto, esto viene con algunos gastos generales, pero los compiladores modernos pueden manejarlo y si tiene problemas de rendimiento, puede solucionarlos más tarde.
Recuerda:
La optimización prematura es la raíz de todos los males (Donald Knuth)
Se podría argumentar que el nivel de persistencia que hace referencia al objeto en vivo es un nivel de persistencia roto, pero tal escenario es realista. Existe un código incorrecto y la inmutabilidad es una herramienta valiosa para ayudar a prevenir tales roturas.
En escenarios más complejos, donde los objetos pasan a través de múltiples capas de la aplicación, los errores inundan fácilmente el código y la inmutabilidad evita que se produzcan errores de estado. Entre los ejemplos de este tipo se incluyen, por ejemplo, el almacenamiento en memoria caché en memoria o las llamadas a funciones desordenadas.
Cómo ayuda la inmutabilidad con el procesamiento paralelo
Otra área importante en la que la inmutabilidad resulta útil es el procesamiento en paralelo. Más precisamente, multihilo. En aplicaciones multiproceso se ejecutan en paralelo varias líneas de código que, al mismo tiempo, acceden a la misma área de memoria. Considere una lista muy simple:
if (p.getName().equals("John")) {
p.setName(p.getName() + "Doe");
}
Este código no tiene errores en sí mismo, pero cuando se ejecuta en paralelo comienza a adelantarse y puede ensuciarse. Vea cómo se ve el fragmento de código anterior con un comentario:
if (p.getName().equals("John")) {
// , John
p.setName(p.getName() + "Doe");
}
Esta es una condición de carrera. El primer hilo verifica si el nombre es igual
“John”
, pero luego el segundo hilo cambia el nombre. El primer hilo continúa ejecutándose, aún asumiendo que el nombre es igual John
.
Por supuesto, uno podría usar el bloqueo para asegurarse de que solo un hilo ingrese a la parte crítica del código en un momento dado, sin embargo, puede haber un cuello de botella. Sin embargo, si los objetos son inmutables, tal escenario no puede desarrollarse, ya que el mismo objeto siempre se almacena en p. Si otro hilo quiere influir en el cambio, crea una nueva copia que no estará en el primer hilo.
Salir
Básicamente, mi consejo sería asegurarse siempre de que el estado mutable se minimice en su aplicación. Si lo usa, restrinja estrictamente con API bien diseñadas, no permita que se filtre a otras áreas de la aplicación. Cuantos menos fragmentos de código tenga que contengan el estado, menos probable es que se produzcan errores de estado.
Por supuesto, la mayoría de los problemas de programación no se pueden resolver si no recurre al estado en absoluto. Pero si consideramos que todas las estructuras de datos son inmutables de forma predeterminada, habrá muchos menos errores aleatorios en el código. Si realmente se ve obligado a introducir mutabilidad en el código, tendrá que hacerlo con cuidado y pensar en las consecuencias, y no comenzar todo el código con eso.