Una estructura Swift puede compartir objetos mutables después de una asignación. Para mantener independientes las copias, usa propiedades de tipos por valor o encapsula el almacenamiento de clase mediante copia al escribir. Esta guía crea una calculadora sintética de filas de mosaicos para una vista previa de escritorio en Mac y comprueba que editar una copia no modifica las demás.

¿Por qué copiar la estructura sigue modificando el original?

Supongamos que una vista previa de escritorio calcula el ancho de una fila de mosaicos. Copias su configuración para probar otro ancho de mosaico y esperas que la vista previa original no cambie. Esa expectativa no se cumple cuando ambos valores de configuración contienen una referencia al mismo objeto mutable.

Swift copia los valores almacenados de una estructura al asignarla a otra variable. En el caso de una propiedad de clase, ese valor almacenado es una referencia: el objeto al que apunta sigue compartido. El contenedor sigue siendo una estructura, pero su comportamiento observable puede incluir mutaciones compartidas. Estas reglas se derivan de la distinción documentada por Swift entre tipos por valor y por referencia.

Este ejemplo completo demuestra el problema. Usa solo la biblioteca estándar de Swift, así que no se requieren importaciones.

final class TileDimensions {
    var width: Int

    init(width: Int) {
        self.width = width
    }
}

struct SharedLayout {
    let dimensions: TileDimensions
}

let original = SharedLayout(
    dimensions: TileDimensions(width: 40)
)
let edited = original
edited.dimensions.width = 60

assert(original.dimensions === edited.dimensions)
assert(original.dimensions.width == 60)
print(original.dimensions.width) // 60

Declarar la propiedad o el contenedor con let no congela las propiedades mutables del objeto. Impide reemplazar esa referencia o ese valor de estructura. El capítulo sobre estructuras y clases de Swift distingue explícitamente entre referencias constantes y el estado inmutable de los objetos.

Colocar estos objetos en un array también conserva su identidad compartida. Las copias de los arrays administran sus elementos de manera independiente, pero un elemento cuyo valor es una clase es, en sí mismo, una referencia. Reemplazar un elemento afecta a un solo array; modificar un objeto al que apuntan ambos arrays se refleja en los dos. Apple demuestra esta distinción en la documentación de Array.

¿Qué diseño proporciona valores independientes para el cálculo?

Define el contrato antes de elegir una implementación: después de var edited = original, toda edición admitida de cualquiera de los dos diseños debe dejar sin cambios el ancho calculado del otro. Quienes lean el tipo no deberían tener que saber si internamente comparte el almacenamiento.

La siguiente guía de decisión aplica ese contrato a un modelo de vista previa local. Es una recomendación de diseño derivada de las reglas de copia documentadas.

Situación Diseño adecuado Condición para preservar la independencia
La configuración consta de números, cadenas u otros componentes con semántica de valor Almacena esos valores directamente en la estructura Comprueba que los componentes anidados también proporcionen valores observables independientes
Debe conservarse el almacenamiento existente basado en clases Usa almacenamiento privado con copia al escribir Cada mutación debe separar primero el estado mutable compartido
Solo se necesita una instantánea independiente en una operación concreta Proporciona una operación explícita de copia Documenta que la asignación normal sigue compartiendo objetos, salvo que el contenedor también imponga la independencia
Varias partes del programa editan intencionalmente un mismo objeto Conserva la semántica de referencia Describe en la API el comportamiento del objeto compartido

Para tres valores enteros de configuración, las propiedades directas de estructura son la opción más sencilla. La implementación basada en clases que aparece a continuación resulta útil para adaptar un diseño de almacenamiento existente o aprender cómo funciona este límite; no demuestra una ventaja de rendimiento para este pequeño cálculo.

La copia al escribir permite que dos valores compartan el almacenamiento hasta que uno cambie. Apple describe el uso de una clase dentro de una estructura y la aplicación de copia al escribir como una forma de combinar almacenamiento asignado por separado con semántica de valor en Explorar el rendimiento de Swift. El simple hecho de encapsular la clase no implementa ese comportamiento.

Un método copy() explícito, por sí solo, también deja una brecha: quienes llaman al código aún pueden hacer una asignación normal sin invocarlo. Si la asignación forma parte de tu contrato de valor, las rutas de mutación del contenedor deben imponer la independencia automáticamente.

¿Cómo se implementa y comprueba la copia al escribir?

Usa un único objeto de almacenamiento privado, créalo dentro del contenedor y expón solo valores calculados y métodos de mutación controlados. Justo antes de cambiar el almacenamiento, llama a isKnownUniquelyReferenced. Si no se confirma que el almacenamiento sea único, reemplázalo por una copia independiente. Apple documenta esta secuencia específicamente para implementar la copia al escribir.

El cálculo del ejemplo usa valores ilustrativos: tres mosaicos de 40 puntos de ancho cada uno, con una separación de 10 puntos entre mosaicos adyacentes. Hay dos separaciones, así que el ancho de la fila es de 3 * 40 + 2 * 10 = 140 puntos. Al aumentar el ancho de los mosaicos a 60, el resultado es de 200 puntos.

Para probar el programa completo en un Mac, primero ejecuta swift --version en Terminal para confirmar que hay una cadena de herramientas de Swift disponible, como recomienda la guía de introducción de Swift. Guarda el código siguiente como Layout.swift y luego ejecuta swift Layout.swift desde ese directorio. El programa realiza los cálculos en memoria e imprime los resultados.

struct TileLayout {
    private final class Storage {
        let columns: Int
        var tileWidth: Int
        var gap: Int

        init(columns: Int, tileWidth: Int, gap: Int) {
            self.columns = columns
            self.tileWidth = tileWidth
            self.gap = gap
        }

        func copy() -> Storage {
            Storage(
                columns: columns,
                tileWidth: tileWidth,
                gap: gap
            )
        }
    }

    private var storage: Storage

    init(columns: Int, tileWidth: Int, gap: Int) {
        precondition((1...1000).contains(columns))
        precondition((0...1000).contains(tileWidth))
        precondition((0...1000).contains(gap))
        storage = Storage(
            columns: columns,
            tileWidth: tileWidth,
            gap: gap
        )
    }

    var rowWidth: Int {
        storage.columns * storage.tileWidth
            + (storage.columns - 1) * storage.gap
    }

    private mutating func ensureUniqueStorage() {
        if !isKnownUniquelyReferenced(&storage) {
            storage = storage.copy()
        }
    }

    mutating func setTileWidth(_ width: Int) {
        precondition((0...1000).contains(width))
        ensureUniqueStorage()
        storage.tileWidth = width
    }

    mutating func setGap(_ gap: Int) {
        precondition((0...1000).contains(gap))
        ensureUniqueStorage()
        storage.gap = gap
    }
}

var original = TileLayout(columns: 3, tileWidth: 40, gap: 10)
var edited = original

edited.setTileWidth(60)
assert(original.rowWidth == 140)
assert(edited.rowWidth == 200)

original.setGap(5)
assert(original.rowWidth == 130)
assert(edited.rowWidth == 200)

let snapshot = edited
edited.setGap(20)
assert(snapshot.rowWidth == 200)
assert(edited.rowWidth == 220)

print(original.rowWidth, snapshot.rowWidth, edited.rowWidth)
// Expected output: 130 200 220

El programa no requiere importaciones ni paquetes externos. Los rangos admitidos son restricciones deliberadas para el ejemplo: de una a 1.000 columnas y de cero a 1.000 puntos para el ancho y la separación. Así se mantiene acotado el cálculo con enteros. Un argumento fuera de rango hace que falle una precondición; no se limita silenciosamente. Si más adelante estos métodos aceptan texto editable desde una interfaz de escritorio, valida esa entrada antes de llamarlos y ofrece al usuario una forma de corregirla.

Las comprobaciones abarcan tres requisitos observables: editar el diseño copiado conserva el original, editar el original conserva la copia y mantener una instantánea constante conserva su resultado durante una edición posterior. Comprueban el comportamiento sin exigir una cantidad específica de asignaciones. Swift no estaba disponible en el entorno de investigación, por lo que el ejemplo se inspeccionó, pero no se ejecutó allí; el resultado mostrado es el esperado.

¿Qué debe seguir siendo cierto cuando crezca el contenedor?

La independencia del ejemplo se debe a toda su frontera de API. El almacenamiento es privado, ningún inicializador acepta un objeto de almacenamiento retenido desde el exterior y ningún getter lo devuelve. Ambos métodos de mutación pasan por la misma operación de separación. El getter calcula un resultado sin modificar el almacenamiento.

Al añadir funciones, sigue el rastro de cada nueva ruta que pueda cambiar el estado observable. Un nuevo setter, subíndice o callback de edición necesita el mismo tratamiento que los métodos existentes. Un getter que devuelva un objeto hijo mutable permitiría a quienes lo llaman eludir la separación, aunque el getter sea de solo lectura.

La copia del almacenamiento también debe abarcar todo el estado mutable pertinente para el valor. Aquí, Storage.copy() copia tres enteros, lo cual basta. Si el almacenamiento contiene más adelante un hijo mutable cuyo valor sea una clase, asignar ese hijo al nuevo almacenamiento conservaría otra referencia compartida. Copia ese hijo de forma adecuada o sustitúyelo por un componente que ya proporcione semántica de valor. El comportamiento de los elementos de clase documentado para Array ilustra el mismo límite.

Sigue esta secuencia al ampliar la calculadora:

  1. Copia un diseño antes de probar cada nueva operación de edición.
  2. Comprueba que el diseño sin modificar conserva el resultado anterior.
  3. Repite el proceso intercambiando los roles.
  4. Conserva una instantánea, realiza varias ediciones posteriores y vuelve a comprobarla.
  5. Inspecciona cada propiedad devuelta y argumento de inicializador para detectar referencias mutables que puedan escapar del contenedor.

Una clase de almacenamiento final impide que las subclases añadan comportamiento, pero no vuelve inmutables sus propiedades. Del mismo modo, marcar una operación como mutating permite que esta actualice la estructura; la lógica de separación es lo que preserva la independencia de este ejemplo.

¿Cómo se deben copiar los objetos de Foundation?

Si un modelo existente almacena objetos de Foundation, como NSMutableString, usa el comportamiento de copia documentado para el tipo concreto al implementar la separación del almacenamiento. La asignación de Swift no llama automáticamente a los métodos de copia de Foundation.

El protocolo NSCopying de Foundation define la copia funcional, aunque el significado exacto depende de la clase. Su documentación también permite devolver el objeto original cuando tanto la clase como su contenido son inmutables. Por consiguiente, la identidad del objeto por sí sola no es una prueba universal de que una copia cumpla tu contrato de valor.

Cuando necesites un resultado mutable de un tipo que tenga formas mutables e inmutables distintas, consulta NSMutableCopying. Apple documenta mutableCopy(with:) y el método de conveniencia mutableCopy() para ese propósito. Las clases sin esa distinción deberían adoptar NSCopying.

Para la calculadora de mosaicos, el límite práctico es sencillo: un objeto de Foundation aceptado como entrada no debe seguir siendo un alias mutable externo de una configuración que se supone independiente. Almacena una instantánea independiente adecuada al recibirlo, sepárala de forma apropiada antes de las ediciones y devuelve instantáneas de valores en lugar del objeto mutable interno. Antes de confiar en la operación de copia, verifica cómo trata la clase concreta cualquier contenido mutable anidado.

¿La copia al escribir hace que el tipo sea Sendable?

La independencia de los valores y la conformidad con los requisitos de concurrencia requieren análisis por separado. La documentación de Sendable de Apple exige que los miembros almacenados de una estructura comprobada como sendable también sean sendable. Una clase de almacenamiento mutable privada no cumple ese requisito solo porque el tipo que la contiene sea una estructura.

La comprobación de unicidad tampoco es sincronización. Apple advierte que isKnownUniquelyReferenced puede devolver true durante un acceso simultáneo y exige una sincronización adecuada en torno a ese uso. Comprueba las referencias fuertes; aún pueden existir alias débiles o sin propietario. Por eso es importante que el almacenamiento sea inaccesible desde fuera del contenedor, además de comprobar su unicidad. Consulta los límites documentados de la función de la biblioteca estándar.

Mantén este ejemplo dentro de un mismo contexto de cálculo síncrono. Si una versión posterior necesita pasar la configuración entre dominios de concurrencia, la alternativa directa es una estructura con propiedades de valor sendable y conformidad comprobada con Sendable. Añadir @unchecked Sendable transfiere la responsabilidad de garantizar la corrección a quien lo implementa; no proporciona la coordinación que falta. Apple explica estos requisitos en la referencia de Sendable.

Para un cálculo pequeño de escritorio, prefiere propiedades de valor directas siempre que sirvan para la tarea. Cuando sea necesario conservar el almacenamiento de clase mutable, preserva el contrato mediante propiedad privada, copias completas del almacenamiento, ediciones controladas y comprobaciones observables de independencia. El resultado es predecible: cambiar la configuración de trabajo de una vista previa deja cada valor de diseño conservado con su propio resultado calculado.

Fuentes