Inmutabilidad

Código 01
public final class Longitud {
        
  private final double medida;
  
  public Longitud(double medida) {
    if (medida <= 0.0) {
      var mensaje = String.format("La medida debería ser positiva. Actualmente es %f", medida);
      throw new IllegalArgumentException(mensaje);
    }
    this.medida = medida;
 }
 
 public Longitud suma(Longitud otra) {
    return new Length(medida + otra.medida);
 }
 
 public double medida() {
    return medida;
  }
} 

Una clase inmutable es una clase cuyos objetos, una vez creados, nunca cambian su estado; por ejemplo, una vez que una Longitud tiene una medida, esa medida nunca cambia mientras que ese objeto este vivo.

Las clases inmutables tienen la ventaja de hacer tu código más fácil de entender y menos propenso a errores.

¡Pero puede que pienses que los objetos que no pueden cambiar su estado son aburridos! El mundo real está lleno de mutabilidad, ¿entonces cómo podemos modelar sistemas de la vida real con esas restricciones? ¡Nunca dijimos nada sobre crear nuevos objetos con el estado actualizado! Así que en la Línea 14, cuando sumamos dos longitudes, en lugar de mutar el objeto referido por «this», creamos uno nuevo con una medida igual a la suma de esta medida y la medida de la otra Longitud.

Hay una receta sencilla para escribir clases inmutables. Hay dos niveles de inmutabilidad: inmutabilidad débil y fuerte

Para que una clase sea débilmente inmutable debes:

  1. Todos los atributos deben tener el modificador final como en la línea 02 del Código 01.
  2. No escribir ningún método que cambie el estado del objeto; en su lugar; crea un nuevo objeto con el estado actualizado, como en la línea 14 del Código 01
  3. Si hay un atributo mutable en tus constructores, haz una copia de él antes de asignarlo a ese objeto.
  4. Si tienes un método accesor a un atributo mutable, haz una copia de él antes de retornarlo

Para que una clase sea fuertemente inmutable debes:

  1. Hacerla débilmente inmutable
  2. Hacer que no sea extensible, usualmente haciéndola final como en la línea 01 del Código 01

Extender una clase se relaciona con el concepto de herencia, un tema por fuera del alcance de este material, pero ten en cuenta que las clases final favorecen la inmutabilidad. Siempre es importante que el código sea claro sobre sus intenciones, así que si no tienes una buena razón para permitir la herencia, deberías prohibirla aún si no estás pensando en la inmutabilidad

La línea 03, como se mencionó, tiene un atributo final; un atributo final es un atributo que, una vez asignado, no puede cambiar su valor. Esto no es estrictamente obligatorio para que la clase sea inmutable, pero es una buena práctica porque obtendrás un error del compilador si intentas modificar el valor de ese atributo en tu código.

¡Ahora la parte más importante de la receta: no escribas tus métodos de manera que cambien el estado del objeto! Una señal común de estos métodos es que sean void. ¡Lo que siempre debes hacer es crear un nuevo objeto con el estado actualizado! Como puedes ver, el valor de «medida» una vez inicializado en el constructor, jamás es modificado.

Este es un consejo diferente al que habrías recibido hace algunos años, cuando los computadore tenían recursos escasos. Pero hoy Java es un lenguaje especializado en recolectar objetos de corta vida, así que no tengas miedo de la inmutabilidad por que crees que puede generar un problema de consumo de memoria. ¡Asume que el diseño inicial de tus clases es uno que favorezca la inmutabilidad!

Las últimas 2 piezas de la receta de clases débilmente inmutables no tienen un ejemplo sencillo parar mostrar, pero observa este código:

Código 02
public final class Foo {
    
  private final int[] números;
          
  public Foo(int[] números) {     
    this.números = números;   
  }

  public int[] obtenerNúmeros() {
    return this.números;  
  }    

  public String toString() {     
    return String.format("%d", números[0]);   
  } 
}
public class Main {
        
  public static void main(String[] args) {     
    var n = new int[] { 0 };
    var miFoo = new Foo(n);     
    System.out.println(miFoo);     
    n[0] = 11;     
    System.out.println(miFoo);     
    var fooNúmeros = miFoo.obtenerNúmeros();     
    fooNúmeros[0] = 101;     
    System.out.println(myFoo);   
  }

}
0
11
101

Como hemos visto antes, ¡trabajar con referencias puede ser complicado! En la línea 05 del Código 02 tenemos una referencia a un arreglo de ints. Un arreglo, como mencionamos antes, es un objeto mutable. En la línea 06, dentro del constructor usamos el operador = para inicializar un atributo mutable. Esto genera potencialmente tener dos referencias a un mismo objeto, una de las cuáles existe por fuera de la clase. En la línea 04 de la clase Main creas un arreglo con un solo número: 0. Luego lo pasas como argumento al constructor de Foo. Después de que se ejecuta el constructor, ahora tienes dos referencias al mismo objeto, una como parte del estado de «miFoo» y además la refencia con identificador «n». Como mencionamos antes, esto es peligroso, y ahora podríamos cambiar el valor del único número del objeto al que apunta «n», que al ser parte del estado de «miFoo» lo mutaría sin ninguna participación de ese objeto. De manera inversa, si tienes un método accesor que devuelve una referencia a un objeto mutable, tendrás el mismo problema riesgoso pero a la inversa, con el mismo resultado: mutar el estado del objeto por fuera del código perteneciente a la clase Foo.

¿Cuál es la solución?

Código 03
public final class Foo {
        
  private final int[] números;

  public Foo(int[] números) {     
    this.números = Arrays.copyOf(números, números.length);   
  }

  public int[] obtenerNúmeros() { 
    return Arrays.copyOf(this.números, this.números.length); 
  }

  public String toString() { 
    return String.format("%d", números[0]); 
  } 
}

Puedes crear copias de los parámetros dados de objetos mutables, y devolver referencias de objetos mutables como en la línea 05 y la línea 09. En la caso de un arreglo puedes usar Arrays.copyOf() pero cada objeto mutable que utilices podría ser diferente, así que consulta la documentación para ver cuál es la forma más sencilla de copiarlo.

Recuerda que esto solo debe hacerse para objetos mutables; los Strings y LocalDate, que usamos antes, son inmutables y no tienen estos problemas. ¡Otra gran razón para hacer tus clases inmutables!

Mutabilidad en el interior

Seamos claros en algo: la mutabilidad no es mala en sí misma; es mala si es una mutabilidad perceptible. Si tienes atributos extra que mutan pero ningún cliente los utilizará, está bien. Si tienes que mutar algo como acción preliminar para crear un nuevo objeto y luego restableces el estado, está bien. Solo te debe preocupar la mutabilidad filtrada, donde se dan múltiples referencias a un objeto mutable y el estado del objeto puede cambiarse en muchos lugares.

¿Inmutabilidad a toda costa?

Por muy atractiva que sea la inmutabilidad, podrías decidir prescindir de ella. Copiar objetos actualizados puede ser costoso cuando son de gran tamaño, p. ej. imagina un arreglos con un millón de objetos. De hecho, ese es el uso más común de una clase que no es inmutable: la mayoría de las clases del framework Collection, fuera del alcance de este material, ya que su consumo de memoria puede volverse grande. Esta fue una decisión consciente para evitar problemas de rendimiento. Casi todas las clases que puedas programar en tu vida deberían ser inmutables, pero como se mencionó antes, solo si un perfilador te muestra que la inmutabilidad de tu clase está teniendo un impacto negativo en el rendimiento, considera convertirla en mutable

Mision Secundaria: Withers

Código 4 - Actualizando estado con withers
public final class Artículo {   
  private final String título;   
  private final String autor;   
  private final LocalDate fechaPublicación;    
          
  public Artículo(String title, String author, LocalDate fechaPublicacion) {     
    this.título = título;     
    this.autor = autor;     
    this.fechaPublicación = fechaPublicación;   
  }    
          
  public Artículo withTítulo(String título) {    
    return new Artículo(título, this.autor, this.fechaPublicación);   
  }    
  
  public Artículo withAutor(String autor) {     
    return new Artículo(this.título, autor, this.fechaPublicación);   
  }    
  
  public Article withFechaPublicación(String fechaPublicación) {     
    return new Article(this.título, this.author, fechaPublicación);   
  } 
} 

Las Líneas 12 - 22 contienen withers. Los withers, al igual que los setters, actualizan el estado de un objeto pero de forma inmutable: creando un nuevo objeto que cambia solo uno de sus campos. Los withers pueden aprovechar el compartido estructural, donde el objeto antiguo y el objeto actualizado comparten la mayoría de sus atributos (siempre que sean inmutables). No necesitas sacrificar la inmutabilidad para permitir que los objetos actualicen su estado.

Ejercicios

E12.1

¡Programemos un Juego del 15! Primero programemos una clase auxiliar:

Coordenada:

Constructor
public Coordenada(int fila, int columna)

Crea una coordenada con fila y columna

Métodos
public int fila()
devuelve la fila de la coordenada
public int columna()
devuelve la columna de la coordenada
public Coordenada arriba()
devuelve una Coordenada con estado (fila - 1, columna)
public Coordenada derecha()
devuelve una Coordenada con estado (fila, columna + 1)
public Coordenada abajo()
devuelve una Coordenada con estado (fila + 1, columna)
public Coordinate izquierda()
devuelve una Coordenada con estado (fila, columna - 1)
public Coordenada[] vecinos()
devuelve un arreglo con las 4 posibles Coordenadas adyacentes de «this»
public boolean esAdyacenteA(Coordinada otra)
devuelve true si «otra» es igual a una de las Coordenadas devueltas por vecinos()
public boolean equals(Object obj)
devuelve «true&rraquo; si obj es una Coordenada con la misma fila y la misma columna que el objeto «this»

Asserts

var unoDos = new Coordenada(1, 2); assertEquals(1, unoDos.row()); assertEquals(2, unoDos.column());
var ceroCero = new Coordenada(0, 0); var vecinos = new Coordenada[] { new Coordenada(-1, 0), new Coordenada( 0, 1), new Coordenada( 1, 0), new Coordenada( 0,-1) }; assertTrue(Set.of(neighbors), Set.of(zeroZero.neighbors()));

Programemos la primera versión de una clase JuegoDel15

La clase tendrá una matriz de String con valores "1", "2", …, "15" y "0" que se trata como el hueco.

Constructor
JuegoDel15(String[][] tablero)

Crea un JuegoDel15 con la configuración dada

Métodos
private String[][] copia()
devuelve una copia del tablero actual
private Coordenada buscar(String ficha)
devuelve la coordenada de la «ficha» dada. Si la «ficha» no se encuentra, devuelve null.
public JuegoDel15 mover(String ficha)
crea un nuevo JuegoDel15 tras intercambiar la «ficha» con el "0". Si «ficha» no es adyacente al "0", devuelve el mismo objeto sin cambios.
public boolean equals(Object obj)
comprueba si «obj» es un JuegoDel15 con el mismo tablero. Puedes usar la implementación de tu IDE.
public String toString()
devuelve una representación String de este puzzle, p. ej. la representación String del tablero objetivo se vería así:
+--+--+--+--+
| 1| 2| 3| 4|
+--+--+--+--+
| 5| 6| 7| 8|
+--+--+--+--+
| 9|10|11|12|
+--+--+--+--+
|13|14|15|  |
+--+--+--+--+
Todas las etiquetas en una celda deben tener un ancho de 2. La celda con “0” debe verse vacía.

Asserts

var inicial = new String[][] {     
        { "1",  "2",  "3",  "4"},   
        { "5",  "6",  "7",  "8"},   
        { "9", "10", "11", "12"},   
        {"13", "14", "15",  "0"} 
}; 

var puzzle = new JuegoDel15(initial);  

assertEquals(new JuegoDel15(new String[][] {
        { "1",  "2",  "3",  "4"},
        { "5",  "6",  "7",  "8"},   
        { "9", "10", "11", "12"},  
        {"13", "14",  "0", "15"}
}), inicial.mover("15"));   
    
var inicial = new String[][] {
        { "1",  "2",  "3",  "4"},   
        { "5",  "6",  "7",  "8"},   
        { "9", "10", "11", "12"},   
        {"13", "14",  "0", "15"}
}; 
      
var puzzle = new JuegoDel15(inicial);
      
assertEquals(new JuegoDel15(new String[][] {
        { "1",  "2",  "3",  "4"},   
        { "5",  "6",  "7",  "8"},   
        { "9", "10",  "0", "12"},   
        {"13", "14", "11", "15"}
}), inicial.mover("11"));
var inicial = new String[][] {{ "1",  "2",  "3",  "4"},   
        { "5",  "6",  "7",  "8"},   
        { "9", "10",  "0", "12"},   
        {"13", "14", "11", "15"}
}; 
        
var puzzle = new JuegoDel15(inicial);

assertEquals(new JuegoDel15(new String[][] {
          { "1",  "2",  "3",  "4"},     
          { "5",  "6",  "7",  "8"},     
          { "9", "10", "12",  "0"},     
          {"13", "14", "11", "15"}
      }), initial.move("12"));  
    
var initial = new String[][] {
        { "1",  "2",  "3",  "4"},
        { "5",  "6",  "7",  "8"},
        { "9", "10", "12", "15"},
        {"13", "14", "11",  "0"}
}; 
        
var puzzle = new JuegoDel15(inicial);

assertEquals(new JuegoDel15(new String[][] {
          { "1",  "2",  "3",  "4"},   
          { "5",  "6",  "7",  "8"},   
          { "9", "10", "12",  "0"},   
          {"13", "14", "11", "15"}
        }), inicial.mover("15"));   
    

Ahora que esto funciona, amplíemos nuestra clase:

Constructor
JuegoDel15(String[][] tablero)
Crea un JuegoDel15 con la configuración dada. Asegúrate de inicializar un atributo con cómo debería verse el tablero cuando esté completo. Ver objetivo() en el resumen de Métodos
public JuegoDel15()
Crea un JuegoDel15 aleatorio. Para hacerlo, primero llama al otro constructor pasando como argumento el valor devuelto por objetivo(4) y luego llama al método mezclar(). Ver el resumen de Métodos para descripciones de ambos métodos.

Agrega o modifica estos métodos en la clase:

Métodos
private static int puntaje(String[][] tablero)
devuelve una puntuación para el tablero dado; cuanto mayor sea la puntuación, más lejos está de resolverse; un tablero resuelto debe tener puntuación 0. Una posible implementación es recorrer el tablero dado y para cada etiqueta encontrar la coordenada en la que se encuentra en el tablero objetivo, y luego sumar a la puntuación el valor absoluto de la diferencia entre la fila y columna donde está y la fila y columna donde debe estar. Debes usar esta función como parte de la implementación de mezclar().
private void scramble()
toma el tablero actual y realiza algunos movimientos válidos aleatorios. Una posible implementación es hacer un bucle, digamos 30 veces. En cada iteración, comprueba dónde está el “0” y cuál es la puntuación actual obteniendo sus vecinos. Si están dentro del tablero, comprueba la puntuación de mover el “0” y guarda el índice si es mayor que el actual. Cuando hayas comprobado todos los vecinos, realiza el movimiento y repite.
private static String[][] target()
devuelve una matriz cuadrada de tamaño 4 rellena con Strings de izquierda a derecha, de arriba a abajo, que comienzan en “1”, terminan en “15” y la esquina inferior derecha es “0”
public boolean estaResuelto()
devuelve true si el puzzle ha sido resuelto, es decir, si el tablero interno es igual al tablero objetivo.

Como con todos los ejercicios, ¡siéntete libre de crear más métodos en la clase si los necesitas!

¡Ahora usa el siguiente código en tu proyecto para jugar al Puzzle 15!

Código 04
import java.util.Scanner;  
          
          public class Main {    
            
            public static void main(String[] args) {     
              try (var in = new Scanner(System.in)) {       
                var puzzle = new PuzzleQuince();       
                System.out.println(puzzle);        
                
                var command = in.nextLine();       
                while (!command.equals("END")) {         
                  puzzle = puzzle.move(command);         
                  System.out.println(puzzle);         
                  if (puzzle.estaResuelto()) {            
                    break;         
                  }         
                  command = in.nextLine();       
                }     
              }   
            } 
          }  
        

Scanner es una de las opciones que Java te ofrece para leer entradas de la entrada estándar de tu computadora (generalmente el teclado). Los detalles de Scanner están fuera del alcance de este material, pero una vez que creas uno, como en la línea 06, puedes llamar al método nextLine() para devolver todo lo que el usuario ha ingresado hasta el siguiente carácter de fin de línea. En Scanner hay otros métodos para leer otros tipos de datos, como números. Luego creamos un puzzle, lo imprimimos y tomamos un comando del usuario, p. ej. una celda para mover. Luego tenemos el bucle del juego: movemos la celda elegida por el usuario, imprimimos y luego comprobamos si ya está resuelto. No podemos comprobar esto en la condición del while porque significaría que, incluso si está resuelto, el usuario necesitaría ingresar un comando antes de que se verifique la condición. Si no está resuelto, pedimos al usuario el siguiente movimiento y repetimos.

© 2022 Carlos Obregón. This work is licensed under Creative Commons Attribution 4.0 International (CC BY 4.0)

Scroll to Top