Cuando diseñas una API o un componente que expone colecciones internas, te enfrentas a un dilema de encapsulamiento: si devuelves la lista privada, cualquier consumidor puede modificar el estado interno de tu clase (haciendo list.clear(), por ejemplo); pero si devuelves una copia, incurres en un coste de tiempo y memoria $O(n)$ cada vez que alguien consulta los datos.
Los wrappers de dart:collection resuelven este problema mediante el uso de vistas (views). En lugar de duplicar los datos, creas un objeto que implementa la misma interfaz de la colección original (como List, Set o Map), pero que intercepta todas las operaciones de mutación para lanzar un UnsupportedError.
Existen dos formas de lograr “inmutabilidad” al exponer datos, y confundirlas es un error común de arquitectura. Por un lado, List.unmodifiable(lista) crea una copia física de los elementos; es una nueva instancia en memoria que no tiene relación con la original. Por otro lado, UnmodifiableListView(lista) crea una vista lógica; no hay copia de datos, solo un objeto que envuelve (wraps) a la lista original. Esto significa que la vista es extremadamente eficiente ($O(1)$ en creación), pero es una “ventana” dinámica: si la lista original cambia, la vista también lo hará.
Debes usar estas vistas cuando quieras proteger la integridad de tu clase pero permitas que el mundo exterior vea cambios en tiempo real sin el overhead de copiar colecciones grandes. Si lo que necesitas es una captura de estado fija que no cambie aunque la fuente original lo haga, debes usar la versión que crea una copia. Si intentas mutar una vista, el runtime de Dart lanzará una excepción porque el wrapper ha interceptado la llamada de escritura.
import 'dart:collection';
class UserRegistry {
// Nuestra fuente de verdad privada.
final List<String> _users = ['Alice', 'Bob', 'Charlie'];
/// Retorna una vista inmutable sobre la lista original.
/// No hay copia de los elementos, es una referencia con restricciones.
UnmodifiableListView<String> get users => UnmodifiableListView(_users);
/// Retorna una instantánea (snapshot) de la lista.
/// Se crea una copia física de los elementos actuales.
List<String> get snapshot => List.unmodifiable(_users);
void register(String name) => _users.add(name);
void removeAll() => _users.clear();
}
void main() {
final registry = UserRegistry();
print('--- Comportamiento de UnmodifiableListView (Vista) ---');
print('Usuarios actuales: ${registry.users}');
// La vista es una ventana al estado actual.
registry.register('David');
print('Tras registrar a David: ${registry.users}');
try {
// Intentar mutar la vista directamente.
registry.users.add('Hacker');
} on UnsupportedError catch (e) {
print('Error esperado: ${e.message}');
}
print('\n--- Comportamiento de List.unmodifiable (Copia) ---');
// La copia es independiente del estado futuro de la clase.
final fixedSnapshot = registry.snapshot;
print('Snapshot inicial: $fixedSnapshot');
registry.register('Eve');
print('Usuarios en el registry: ${registry.users}');
print('Snapshot tras cambio: $fixedSnapshot'); // Mantiene los datos originales
registry.removeAll();
print('Usuarios tras limpiar registry: ${registry.users}');
print('Snapshot tras limpiar: $fixedSnapshot'); // No se ve afectado
}
Desglose técnico
En el ejemplo, UserRegistry es la clase que gestiona el estado. La propiedad users utiliza UnmodifiableListView, que es un wrapper que delega las operaciones de lectura (add, remove, etc.) a la lista _users original, pero bloquea las de escritura. A nivel de la VM, esto es muy eficiente porque no se ha vuelto a asignar memoria para los strings, solo se ha creado un objeto pequeño que actúa como intermediario.
Cuando llamamos a registry.register('David'), el objeto UnmodifiableListView simplemente lee el nuevo estado de la lista _users. Por eso, en el print, vemos que la vista se actualiza. En cambio, al llamar a registry.snapshot, estamos invocando a List.unmodifiable, lo que le dice a la VM: “crea un nuevo array en memoria y copia cada referencia de esta lista actual”. Por eso el fixedSnapshot permanece inalterado aunque el UserRegistry se vacíe.
Esta jerarquía de wrappers es la base para construir colecciones personalizadas. Si alguna vez necesitas crear una colección que solo permita leer elementos en índices pares, puedes extender de DelegatingList para delegar la lógica de acceso a una lista interna, manteniendo un control total sobre qué operaciones son permitidas.
El error frecuente
Un error clásico ocurre cuando esperas que una vista inmutable se comporte como una copia para garantizar la consistencia de un proceso asíncrono.
// ERROR: La vista es una referencia viva. final vista = registry.users; // Si este proceso asíncrono tarda, la 'vista' podría cambiar // de contenido inesperadamente si otra parte del código // modifica el registry original. await Future.delayed(Duration(seconds: 1)); print(vista.length); // El valor puede ser distinto al que esperabas.
Si la integridad de tus cálculos depende de que los datos no cambien durante una operación asíncrona, no uses UnmodifiableListView; usa List.unmodifiable para asegurar que tienes una copia estática y segura.
N° 81