Mutabilidad, referencias y copias: por qué cambia una lista que no tocaste
Preparas una lista de espacios para probar una propuesta de niveles. Le pones otro nombre, cambias L01 por L02 y vuelves a consultar los datos originales. También muestran L02. La lista no ha recibido instrucciones a distancia: los dos nombres daban acceso al mismo objeto. «Pero si lo he guardado en otra variable» es una explicación muy humana. El programa, con esa desagradable costumbre de ejecutar lo escrito, no había recibido ninguna orden de copiar.
Vamos a seguir tres operaciones que suelen confundirse: asignar otro nombre, copiar la colección exterior y obtener datos independientes para una propuesta. Usaremos espacios ficticios con identificador y nivel, en Python y C#. El objetivo es predecir qué cambia en cada caso antes de conectar nada a un archivo o un modelo. Mi aportación a la paz del proyecto consiste hoy en impedir que la propuesta corrija los originales mientras alguien todavía está decidiendo si le gusta.
Otro nombre puede dar acceso a la misma lista
Un objeto mutable permite cambiar su contenido después de crearlo. Una lista de Python es mutable: podemos añadir elementos, retirarlos o sustituirlos. Un diccionario también lo es: podemos cambiar el valor asociado a una clave. La asignación de un nombre a otro no implica que esos objetos se dupliquen. Para entender qué va a ocurrir necesitamos seguir el objeto que ambos nombres permiten utilizar, además de leer sus etiquetas.
El programa siguiente crea una lista con un diccionario dentro. Después asigna esa lista a propuesta y modifica el nivel a través de ese segundo nombre. Guarda el bloque completo en un archivo y ejecútalo con Python 3.12; no necesita dependencias externas ni acceso a un modelo.
La salida es L02 y True. is comprueba identidad: aquí pregunta si los dos nombres están vinculados al mismo objeto lista. No pregunta únicamente si sus contenidos son iguales. A este acceso compartido se le suele llamar alias. Cambiar el nivel mediante propuesta alcanza el mismo diccionario que encontramos desde original, porque no hemos creado una segunda colección en ningún punto.
Esto puede ser útil cuando queremos compartir un estado de forma deliberada. El fallo consiste en esperar independencia porque hemos elegido un nombre convincente. copia_segura, simulacion o antes_de_aplicar no tienen poderes especiales sobre la memoria. Antes de confiar en uno de esos nombres conviene localizar la operación que construye los datos separados. Si no existe, el título de la variable está haciendo relaciones públicas.
También debemos distinguir modificación de reasignación. Si después escribiésemos propuesta = [], vincularíamos ese nombre a otra lista; no vaciaríamos automáticamente la lista que sigue accesible desde original. En cambio, una operación que vacía el objeto compartido sí sería visible desde ambos accesos. La pregunta útil es si hemos cambiado a qué objeto apunta un nombre o el contenido del objeto al que ya llegábamos.
Una copia superficial separa la lista, pero comparte sus registros
Una copia superficial crea una colección exterior nueva y conserva referencias a sus elementos. En nuestro ejemplo permite añadir un segundo espacio a la propuesta sin añadirlo a la lista original. Sin embargo, las dos listas todavía contienen el mismo diccionario del primer espacio. Separar el recipiente exterior no separa por sí solo todos los datos que contiene.
Este segundo programa es independiente: vuelve a crear el original antes de copiarlo. Cambia el nivel del primer registro y añade otro registro únicamente a la propuesta. Observa ambas acciones por separado; una afecta a un objeto interior compartido y la otra a una de las listas exteriores.
Obtendrás L02, 1 2, False y True. Las longitudes difieren y las listas tienen identidades distintas, pero su primer elemento sigue siendo el mismo diccionario. Comprobar solo que propuesta is original devuelve falso habría dado una sensación de protección que el contenido no confirma. La independencia tiene un alcance: en este caso llega hasta la lista exterior.
Una lista de textos puede hacer que esta diferencia pase desapercibida al empezar. Las cadenas son inmutables y sustituir una posición de la lista copiada no cambia el texto de la otra. Al introducir diccionarios o listas interiores aparece una operación diferente: modificar el objeto almacenado en la posición. No es que la copia haya empezado a funcionar mal al crecer el ejemplo; es que ahora estamos cambiando otra parte de la estructura.
La documentación del módulo copy de Python distingue asignación, copia superficial y copia profunda. Para nuestro trabajo importa concretar qué nivel necesitamos separar. Si vamos a añadir registros sin editar los existentes, una copia exterior puede ser suficiente. Si vamos a cambiar sus campos, necesitamos decidir también qué ocurre con cada registro. Esa decisión se prueba con una modificación real del ejemplo, no con el nombre del método.
Una copia profunda puede servir para datos, con límites claros
Para esta estructura pequeña, compuesta por listas, diccionarios y textos, deepcopy permite crear una propuesta cuyos registros se puedan modificar sin alterar el original. Recorre la estructura para copiar sus objetos según las reglas del módulo y de los tipos implicados. No equivale a serializar cualquier aplicación ni garantiza que un recurso externo se convierta en una copia independiente.
Las tres líneas son L01, L02 y False. Ahora podemos comparar un antes y una propuesta para E01 porque el cambio no ha sustituido el dato de partida. El identificador se mantiene para relacionar los registros; no hemos creado otro espacio de proyecto por duplicar una representación de sus datos. Tampoco hemos escrito L02 en ninguna aplicación BIM.
No aplicaría esta operación indiscriminadamente a una colección de objetos que entrega una API. Un elemento de Revit, una conexión o un archivo abierto tiene un contexto que copiar datos de Python no reproduce. Para preparar una revisión suele resultar más claro extraer los campos necesarios a una representación propia, conservar su identidad externa y calcular una propuesta sobre esos datos. La escritura posterior tendrá que comprobar de nuevo el estado y las reglas de la aplicación.
Incluso dentro de datos ordinarios, copiar más de lo necesario tiene un coste de memoria y de trabajo. Además, ciertas relaciones compartidas pueden ser intencionadas. No elegiría una copia profunda como amuleto para evitar pensar en ellas. Definiría qué necesita permanecer estable durante la comparación y probaría esa propiedad. Una propuesta de dos campos no tiene por qué llevarse una reproducción de todo el universo administrativo del edificio.
En C# también hay que decidir qué objetos se duplican
Una List<T> de C# es un tipo de referencia. Asignarla a otra variable permite acceder a la misma lista; construir otra lista a partir de ella crea una colección exterior nueva. Si sus elementos son diccionarios mutables, estos siguen compartidos hasta que también construyamos diccionarios nuevos. El siguiente programa reproduce los tres casos sin utilizar objetos de Revit ni una biblioteca adicional.
En una consola de .NET 8 la salida es L02, True, L02, False, True, L01, L02 y False. Las primeras parejas distinguen compartir la lista de compartir el registro. El último recorrido construye una lista y un diccionario nuevo por cada espacio. Como los valores de estos diccionarios son textos inmutables, basta para cambiar sus campos sin alterar los originales.
Ese recorrido no es un algoritmo general de copia profunda. Si un valor fuese una lista de observaciones, copiar el diccionario sin copiar esa lista volvería a dejar un objeto mutable compartido. Habría que definir cómo reconstruirla o elegir otro diseño. La referencia de tipos de C# ayuda a situar estas diferencias, pero la independencia del caso concreto se decide siguiendo la estructura que hemos creado.
Los tipos de valor añaden otros comportamientos, como vimos con las tuplas y los registros. No conviene deducir de ahí que toda asignación de C# copie recursivamente lo que contiene. Una estructura puede copiar sus campos y conservar dentro una referencia a un objeto mutable. La regla útil sigue siendo reconocer qué se ha copiado y qué se comparte.
Una propuesta debe conservar el antes que promete comparar
Para comprobar el diseño, empieza con dos espacios y cambia solo uno en la propuesta. El original debe conservar sus niveles; la propuesta debe conservar los identificadores y mostrar únicamente la modificación prevista. Añade después un espacio a la propuesta y verifica que no aparece en la lista original. Son dos pruebas distintas: independencia de los campos e independencia de la colección exterior.
No necesitas demostrar que todos los objetos de ambos árboles tengan una identidad distinta. Compartir una cadena inmutable puede ser perfectamente válido. Lo que necesitas demostrar es que las operaciones permitidas sobre la propuesta no cambian el estado que has prometido mantener como referencia. Definir esa propiedad evita copiar por ceremonia y permite escoger una solución proporcional al tipo de datos.
También conviene recordar que una copia local no congela un modelo remoto. Mientras preparas el informe, otra persona podría modificar el espacio en la aplicación de origen. Conservar un antes fiable en memoria ayuda a explicar una propuesta; aplicar la propuesta exige comprobar que sigue siendo válida frente al estado actual. Son responsabilidades relacionadas, pero una copia no resuelve por sí sola la concurrencia.
Si te has encontrado un original que cambiaba «solo», cuenta en los comentarios qué estructura estabas copiando: una lista plana, diccionarios o varios niveles de datos. Ese detalle permite discutir el fallo con algo más de precisión que culpar a Python de tener mal carácter. Yo conservaré el mío para las variables llamadas copia que nunca llegaron a copiar nada.
Respuestas