Copiar frente a compartir: qué ocurre al asignar
Qué duplica exactamente la asignación de un `struct` y qué comparte la de una `class`. El aliasing, la copia que se detiene en el borde de la referencia y por qué copy-on-write hace barato lo que parece caro.
Asignar es la operación más frecuente y más invisible del lenguaje: escribes b = a y sigues adelante sin pensarlo. En Swift esa línea significa dos cosas radicalmente distintas según el tipo de a. Si es un struct, b recibe un dato nuevo e independiente, un universo paralelo que evoluciona por su cuenta. Si es una class, b recibe una segunda vía de acceso al mismo objeto: un alias. Toda la arquitectura semántica de Swift descansa sobre esa bifurcación, y casi todos los bugs de “quién cambió esto” nacen de no verla.
- Describir qué duplica exactamente la asignación de un
structy hasta dónde llega. - Reconocer el aliasing de las clases y sus efectos a distancia.
- Distinguir la copia semántica de la copia física, y entender copy-on-write.
- Predecir la salida de un programa leyendo solo la palabra clave del tipo.
La asignación de un struct produce un dato independiente
Cuando asignas un valor de tipo estructura, Swift copia sus propiedades almacenadas una por una. Y lo hace de forma recursiva: si una propiedad es a su vez una estructura, se copian también sus propiedades, y así hasta el fondo. El resultado es un agregado completo que no comparte nada con el original.
struct Punto { var x: Int; var y: Int }
struct Segmento { var a: Punto; var b: Punto }
var s1 = Segmento(a: Punto(x: 0, y: 0), b: Punto(x: 3, y: 4))
var s2 = s1 // copia: se duplican los cuatro Int
s2.a.x = 99
print(s1.a.x) // 0 ← s1 vive en su propio universo
Lo importante es que esta regla no es exclusiva del signo igual. Pasar un valor a una función, devolverlo, guardarlo en un array, capturarlo en un closure o almacenarlo en otra estructura son todas variantes de la misma operación: inicializar un espacio nuevo con el valor de otro. Por eso una función que recibe un struct no puede afectar a lo que el llamador conserva, salvo que se use inout —el tema del nivel siguiente—.
Una consecuencia elegante: como el agregado entero es un valor, declararlo let congela todo el árbol. No existe la posibilidad de un rincón mutable escondido en la tercera capa.
let fijo = Segmento(a: Punto(x: 1, y: 1), b: Punto(x: 2, y: 2))
// fijo.a.x = 5 // ERROR: cannot assign to property: 'fijo' is a 'let' constant
La misma regla gobierna a las enumeraciones y a las tuplas, que también son tipos de valor. Un enum con valores asociados se copia con todo su contenido, y una tupla se copia campo a campo. Es decir: en Swift, todo lo que no es una clase, un actor, un closure o un puntero se copia. La excepción es la referencia, no la copia.
La asignación de una clase produce un alias
Con una clase, la variable no contiene el objeto: contiene una referencia a un objeto que vive en el montículo. Asignar copia esa referencia —un puntero y un incremento del contador de retenciones—, nunca el objeto apuntado.
final class Contador { var n = 0 }
let c1 = Contador()
let c2 = c1 // se copia la referencia, no la instancia
c2.n = 42
print(c1.n) // 42 ← una sola instancia con dos nombres
print(c1 === c2) // true
Fíjate en el detalle que desconcierta a todo el mundo: c1 está declarada let y aun así su contenido cambia. No hay contradicción. let congela el binding, no el objeto: garantiza que c1 seguirá apuntando siempre a esa misma instancia, no que la instancia sea inmutable. En un struct, binding y dato son la misma cosa, así que let significa inmutabilidad real; en una class, son dos cosas distintas y let solo alcanza a la primera.
Struct · valor
La variable contiene el dato. Asignar duplica en profundidad. Cada copia es autónoma y let la congela entera. No hay identidad que preguntar.
Class · referencia
La variable contiene una referencia. Asignar duplica el puntero. Todas las copias ven los mismos cambios y let solo fija a quién apuntas.
El aliasing y el efecto a distancia
El aliasing es tener dos o más nombres para el mismo dato mutable. Es inofensivo mientras nadie escriba, y es la fuente de casi todos los bugs difíciles en cuanto alguien lo hace: modificas por una vía y el cambio aparece por otra, en un módulo que ni siquiera sabías que existía.
El caso más traicionero es la copia de una colección de objetos. El Array es una estructura, así que se copia; pero sus elementos son referencias, y esas se copian tal cual.
struct Ajuste { var volumen = 5 }
final class AjusteRef { var volumen = 5 }
var lista = [Ajuste(), Ajuste()]
var copia = lista
copia[0].volumen = 10
print(lista[0].volumen) // 5 ← copia profunda: el array y sus valores
var listaR = [AjusteRef(), AjusteRef()]
var copiaR = listaR
copiaR[0].volumen = 10
print(listaR[0].volumen) // 10 ← el array se copió; los objetos no
La copia funcionó exactamente como debía: duplicó lo que el array almacena. Lo que el array almacena, en el segundo caso, son punteros. La copia se detiene en el primer tipo de referencia que encuentra, y ese borde es invisible en el sitio de la asignación: la línea var copiaR = listaR no delata nada.
Hay una segunda vía por la que el aliasing se propaga, y es aún más silenciosa: quien recibe una referencia puede quedársela. Pasar un valor a una función es una promesa de aislamiento —la función trabajará sobre su copia y se acabó—, mientras que pasar un objeto es conceder un permiso de duración indefinida sobre él.
final class Sensor { var lectura = 0 }
final class Panel {
private var vigilados: [Sensor] = []
func registrar(_ s: Sensor) { vigilados.append(s) } // se queda con el alias
}
let s = Sensor()
Panel().registrar(s) // desde aquí hay dos dueños de la misma instancia
Nada en la firma de registrar anuncia esa retención, y ninguna herramienta del lenguaje te obliga a declararla. Con un tipo de valor el problema ni siquiera puede plantearse: la función se quedaría con una copia, y las copias no espían.
flowchart TB a[Asignacion b igual a] --> v[Se copia el valor de la variable origen] v --> s[Si es struct el valor es el agregado completo] v --> c[Si es class el valor es la referencia] s --> ind[Dato nuevo e independiente] c --> ali[Alias hacia la misma instancia] ali --> ef[Efectos a distancia y aliasing mutable]
La distinción no está en la operación, sino en el tipo. La asignación de Swift hace siempre lo mismo: toma el valor de la expresión de la derecha y lo instala en el espacio de la izquierda. Nunca hay dos reglas compitiendo. Lo que cambia de un caso al otro es la respuesta a una pregunta anterior: qué constituye el valor de una variable de este tipo. Para un struct, el valor es el agregado de sus propiedades, y copiarlo produce un dato nuevo. Para una class, el valor es la referencia —el puntero mismo— y copiarlo produce un segundo camino hacia la misma instancia. La llamada semántica de referencia es, literalmente, semántica de valor aplicada a referencias. Esta relectura no es un juego de palabras: es la que te permite razonar sin excepciones. Explica por qué let sobre una clase no congela el objeto —el valor congelado es el puntero—, por qué la copia de un array de objetos comparte los objetos —el valor de cada casilla es un puntero—, y por qué un struct que contiene una clase deja de comportarse como valor sin que ninguna palabra clave lo anuncie. La semántica de valor no es lo que declaras; es lo que resulta de todo lo alcanzable desde tus propiedades.
Copia semántica frente a copia física
Un temor razonable al leer lo anterior: si cada asignación duplica en profundidad, ¿no es carísimo copiar un array de un millón de elementos? No, porque lo que el lenguaje garantiza es la semántica de la copia, no su ejecución literal. Array, String, Dictionary, Set y Data son estructuras cuyo único campo relevante es una referencia a un búfer en el montículo. Al asignarlas se copia ese puntero: coste constante.
La ilusión se sostiene con copy-on-write: antes de escribir, el tipo comprueba si el búfer tiene más de un dueño; si lo tiene, se duplica primero y muta después. Quien nunca escribe nunca paga.
var uno = [1, 2, 3]
var dos = uno // O(1): se comparte el bufer
dos.append(4) // aquí, y solo aquí, se duplica el almacenamiento
print(uno) // [1, 2, 3]
La duda “esta estructura es grande, ¿la paso por referencia?” casi siempre está mal planteada. Los parámetros de función se pasan como préstamos no propietarios: el compilador no copia nada salvo que el receptor necesite mutar o conservar el valor. Convertir un tipo en class para ahorrar copias cambia el rendimiento por un asignador en el montículo y tráfico atómico de ARC, y te devuelve el aliasing a cambio. Mide antes de sacrificar la semántica.
La asignación copia siempre el valor de la variable. En un struct ese valor es el agregado, y la copia es profunda hasta el primer tipo de referencia. En una class ese valor es el puntero, y la copia produce un alias. let congela el binding: en un valor eso equivale a congelar el dato, en una referencia no. Copy-on-write hace que la copia profunda de las colecciones estándar cueste O(1) hasta la primera escritura.
- Define una
structanidada de tres niveles, cópiala, muta el nivel más profundo de la copia y verifica que el original no cambió. - Repite el experimento con clases y comprueba la identidad con el operador
===. - Declara una constante de tipo clase y muta una propiedad suya: explica por qué compila.
- Crea un array de clases, cópialo, muta un elemento de la copia y razona qué se duplicó y qué no.
- Usa
isKnownUniquelyReferencedsobre el almacenamiento de un array propio para observar copy-on-write en acción.