Inmutabilidad en Dart: Patrones y Prácticas de Diseño

En Dart, la inmutabilidad no es una imposición del lenguaje, sino una intención de diseño que el programador debe declarar explícitamente. A diferencia de otros lenguajes donde la inmutabilidad es la norma, Dart está optimizado para la mutabilidad controlada y la flexibilidad. Esto significa que la inmutabilidad se construye mediante la combinación de tipos de variables, constructores especiales y la protección de colecciones.

Para entenderlo, debemos distinguir tres niveles de restricción. Primero, el uso de final para crear referencias inmutables: una vez que asignas un objeto a una variable final, no puedes apuntar esa variable a otro objeto, pero el contenido de ese objeto puede seguir siendo mutable. Segundo, el uso de const para crear objetos inmutables en tiempo de compilación: aquí, el valor mismo queda congelado por el compilador, permitiendo la canonicalización (dos constantes con los mismos valores comparten la misma instancia en memoria). Finalmente, la protección de colecciones como List, Map o Set mediante métodos como unmodifiable(), que impiden cambios en el contenido durante la ejecución.

Debes aplicar estos patrones cuando estés implementando Value Objects en arquitecturas como DDD, cuando gestiones estados en aplicaciones complejas para evitar efectos secundarios, o cuando necesites pasar datos entre isolates sin preocuparte por mutaciones concurrentes. Si ignoras estas prácticas y confías únicamente en final para objetos que contienen colecciones, permitirás mutaciones accidentales en el interior de tus modelos, lo que derivará en bugs de estado impredecibles y errores de lógica difíciles de rastrear en el runtime.

import 'package:test/test.dart';

// La anotación @immutable [disponible desde Dart 2.12] le indica al analizador
// que todas las propiedades de esta clase deben ser final.
@immutable
class User {
  final String id;
  final String name;
  // Aunque la variable es final, la lista subyacente podría ser mutable.
  // Debemos protegerla explícitamente.
  final List<String> roles;

  // El constructor const es la clave para la inmutabilidad real en Dart.
  // Permite que el compilador cree la instancia en el segmento de datos constantes.
  const User({
    required this.id,
    required this.name,
    required this.roles,
  }) : roles = List.unmodifiable(roles); // Protegemos la colección en el initializer list.

  // El patrón copyWith es el estándar para "modificar" un objeto inmutable.
  // En lugar de cambiar una propiedad, devolvemos una nueva instancia con el cambio.
  User copyWith({
    String? id,
    String? name,
    List<String>? roles,
  }) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
      roles: roles ?? this.roles,
    );
  }

  // Implementar igualdad es vital para que los Value Objects funcionen correctamente.
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is User &&
          id == other.id &&
          name == other.name &&
          roles == other.roles;

  @override
  int get hashCode => id.hashCode ^ name.hashCode ^ roles.hashCode;
}

void main() {
  group('Validación de Inmutabilidad', () {
    test('copyWith debe devolver una nueva instancia con el valor actualizado', () {
      const user = User(id: 'u1', name: 'Alex', roles: ['admin']);
      final updatedUser = user.copyWith(name: 'Alex Smith');

      expect(updatedUser.name, 'Alex Smith');
      expect(user.name, 'Alex'); // El original permanece intacto
      expect(identical(user, updatedUser), isFalse);
    });

    test('List.unmodifiable debe prevenir cambios en la colección', () {
      const user = User(id: 'u2', name: 'Bea', roles: ['editor']);
      
      // Intentar añadir un rol lanzará una UnsupportedError en runtime.
      expect(() => user.roles.add('viewer'), throwsUnsupportedError);
    });

    test('Los Records [disponible desde Dart 3.0] son inmutables por diseño', () {
      // Los Records son la forma más rápida de transportar datos inmutables.
      const punto = (x: 10, y: 20);
      
      // punto.x = 30; // Esto causaría un error de compilación.
      expect(punto.x, 10);
    });
  });
}

Desglose técnico del ejemplo

En el ejemplo, la clase User utiliza el patrón de clase de datos inmutable. Fíjate cómo el constructor utiliza un initializer list para ejecutar List.unmodifiable(roles). Esto es crítico: si simplemente usáramos this.roles = roles, el objeto User sería “profundamente mutable” porque la lista original pasada al constructor podría ser modificada desde fuera. Al envolverla, aseguramos que cualquier intento de llamar a .add() o .remove() lance una excepción en tiempo de ejecución.

El método copyWith es la pieza que hace viable este patrón en la práctica. Como los campos id, name y roles son final, no podemos reasignarlos. copyWith permite simular una mutación creando una nueva instancia de User que hereda los valores actuales mediante el operador null-coalescing (??), pero aplica los nuevos valores solo si se proporcionan.

Finalmente, el uso de const en el constructor de User permite la canonicalización. Si creas dos instancias const User(id: '1', ...) con los mismos valores exactos, la VM de Dart apuntará ambas variables a la misma dirección de memoria, optimizando el uso de memoria y acelerando las comparaciones de igualdad.

El error frecuente

Un error común es asumir que final garantiza la inmutabilidad profunda. Fíjate en este caso de error:

class MalUsuario {
  final List<String> tags; // Error: la referencia es final, pero la lista es mutable
  const MalUsuario(this.tags);
}

void main() {
  final tags = ['dart', 'backend'];
  const user = MalUsuario(tags);

  // Esto no es un error de compilación, pero rompe la inmutabilidad del objeto.
  tags.add('error'); 
  
  print(user.tags); // Imprime [dart, backend, error] - ¡El objeto mutó!
}

Si no proteges las colecciones dentro de tus clases con List.unmodifiable(), tu diseño de inmutabilidad es solo superficial.

112

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio