Qué es un property wrapper: envolver el acceso a una propiedad
Un property wrapper convierte el patrón get/set repetido en un tipo reutilizable. Qué es `wrappedValue`, qué código genera realmente el compilador cuando escribes el atributo, y qué reglas gobiernan dónde puedes aplicarlo.
Casi todo el código de una aplicación consiste en leer y escribir propiedades, y una parte sorprendente de ese código no habla del dominio: acota un valor a un rango, lo persiste en disco, comprueba que no esté vacío, avisa a alguien de que ha cambiado. Antes de Swift 5.1 esa lógica se copiaba a mano, propiedad a propiedad, en un par de accesores escritos por enésima vez. Un property wrapper es la respuesta del lenguaje a esa repetición: un tipo que se interpone entre el nombre de la propiedad y su almacenamiento, y que puede hacer lo que quiera en medio. Lo notable no es el ahorro de líneas, sino lo que implica conceptualmente: en Swift, el acceso a una propiedad es en sí mismo algo que se puede nombrar, empaquetar y reutilizar.
- Reconocer el patrón de accesores repetidos que los wrappers vienen a eliminar.
- Declarar un wrapper con
@propertyWrappery entender el papel exacto dewrappedValue. - Describir la transformación literal que aplica el compilador: almacenamiento con guion bajo más propiedad calculada.
- Enumerar las reglas de aplicación y su efecto sobre el inicializador por miembros.
El patrón que se repetía
Imagina un volumen que debe permanecer entre cero y diez. Sin wrappers, la única forma honesta de garantizarlo es una propiedad calculada que custodie un almacenamiento privado:
struct Ajustes {
private var volumenGuardado: Int = 5
var volumen: Int {
get { volumenGuardado }
set { volumenGuardado = min(max(newValue, 0), 10) }
}
}
Funciona, pero cada propiedad acotada del programa exige repetir la pareja: un almacén privado, un calculado público y la misma aritmética. Y esto es solo una de las variantes. La misma estructura reaparece, idéntica en forma y distinta en contenido, cuando quieres persistir el valor, registrarlo, normalizarlo o comprobarlo. El lenguaje ya tenía un mecanismo así, pero cerrado: lazy es exactamente esto —almacenamiento diferido más accesor— codificado a mano dentro del compilador. La pregunta que dio origen a la propuesta SE-0258 fue si esa clase de comportamiento podía escribirse en una biblioteca en vez de en el compilador.
Anatomía: el atributo y wrappedValue
Un property wrapper es un tipo —normalmente un struct— marcado con el atributo @propertyWrapper que expone una propiedad llamada wrappedValue. Ese nombre no es una convención: es el requisito que el compilador busca.
@propertyWrapper
struct Acotado {
private var valor: Int
private let rango: ClosedRange<Int>
init(wrappedValue: Int, _ rango: ClosedRange<Int>) {
self.rango = rango
self.valor = min(max(wrappedValue, rango.lowerBound), rango.upperBound)
}
var wrappedValue: Int {
get { valor }
set { valor = min(max(newValue, rango.lowerBound), rango.upperBound) }
}
}
Con eso, el uso se reduce a un atributo delante de la declaración, y la lógica de acotado desaparece del tipo que la consume:
struct Ajustes {
@Acotado(0...10) var volumen = 5
@Acotado(0...100) var brillo = 80
}
var a = Ajustes()
a.volumen = 99
print(a.volumen) // 10, acotado sin que Ajustes sepa nada
Fíjate en la firma del inicializador. El argumento wrappedValue es especial: cuando existe, Swift permite escribir el valor inicial con la sintaxis habitual de asignación, y traduce @Acotado(0...10) var volumen = 5 a la llamada Acotado(wrappedValue: 5, 0...10). El resto de argumentos del atributo se pasan tal cual. Sin ese inicializador, tendrías que construir el wrapper explícitamente.
Qué genera el compilador
Aquí es donde conviene abandonar la metáfora y mirar la transformación real, porque el mecanismo no tiene nada de mágico. Al ver el atributo, el compilador sustituye la declaración por dos: una propiedad almacenada cuyo tipo es el wrapper y cuyo nombre lleva un guion bajo delante, y una propiedad calculada con el nombre original que se limita a reenviar a wrappedValue.
// Lo que escribes
struct Ajustes {
@Acotado(0...10) var volumen = 5
}
// Lo que existe realmente tras la transformación
struct Ajustes {
private var _volumen = Acotado(wrappedValue: 5, 0...10)
var volumen: Int {
get { _volumen.wrappedValue }
set { _volumen.wrappedValue = newValue }
}
}
Tres consecuencias se siguen de inmediato, y explican casi todo el comportamiento extraño que encontrarás más adelante. Primero: la instancia del wrapper vive dentro del tipo que la contiene, ocupa su memoria y viaja con sus copias; un Acotado en un struct hereda la semántica de valor de ese struct. Segundo: volumen ya no es una propiedad almacenada, sino calculada, de modo que todo lo que en Swift exige almacenamiento real deja de aplicarse a ella. Tercero: el almacenamiento con guion bajo es accesible por su nombre, con visibilidad private, y es la puerta trasera para hablar con el wrapper en vez de con el valor.
flowchart TB fuente[Declaracion con atributo arroba Acotado var volumen igual 5] --> comp[El compilador reescribe la declaracion] comp --> alm[Propiedad almacenada guion bajo volumen de tipo Acotado] comp --> calc[Propiedad calculada volumen de tipo Int] calc -->|get| alm calc -->|set| alm alm --> logica[wrappedValue aplica la logica de acotado] style fuente fill:#cba6f7,color:#11111b style comp fill:#89b4fa,color:#11111b style logica fill:#a6e3a1,color:#11111b
El wrapper es un valor almacenado
Vive dentro del tipo contenedor, ocupa su memoria y se copia con él. Un wrapper en un struct hereda la semántica de valor de ese struct.
La propiedad pasa a ser calculada
El nombre original ya no designa almacenamiento, sino un par de accesores. Todo lo que en Swift exige una propiedad almacenada deja de aplicarse a ella.
El guion bajo es la puerta trasera
El nombre con guion bajo es private y designa al wrapper entero, no al valor. Es la vía para hablar con el mecanismo en vez de con el contenido.
Dónde se pueden aplicar
Las reglas se deducen casi todas de la transformación anterior. Un wrapper solo puede aplicarse a una declaración var que genere almacenamiento: propiedades de struct y de class, y variables locales de una función. Queda fuera todo lo demás: no puedes envolver un let, ni una propiedad ya calculada, ni una propiedad de una extensión, ni combinarlo con lazy, ni declararlo como requisito de un protocolo, ni sobrescribir con un wrapper una propiedad heredada.
Hay además un efecto que sorprende a todo el mundo la primera vez: el inicializador por miembros que Swift sintetiza para los struct cambia de forma. Si el wrapper ofrece init(wrappedValue:), el parámetro sintetizado usa el tipo envuelto y el valor por defecto se conserva; si no lo ofrece, el parámetro pasa a tener el tipo del wrapper y quien construya el struct deberá pasarlo entero.
struct Ajustes {
@Acotado(0...10) var volumen = 5
}
// Sintetizado: init(volumen: Int = 5), porque Acotado tiene init(wrappedValue:)
let normal = Ajustes(volumen: 3)
La razón de esa asimetría vuelve a ser la transformación. El inicializador por miembros debe poder construir el almacenamiento real, que es de tipo Acotado; si existe una vía canónica para fabricarlo a partir de un Int, Swift la usa y te ahorra el detalle. Si no existe, no le queda más remedio que pedirte el wrapper ya construido, y el mecanismo deja de ser invisible justo donde más molesta: en la creación del tipo.
Un último apunte para situar el mecanismo en la historia del lenguaje. La palabra clave lazy es, conceptualmente, un property wrapper que Apple escribió dentro del compilador antes de que existiera la forma de escribirlo fuera; podrías reproducir casi todo su comportamiento con un wrapper que guarde un Optional y una clausura de fabricación. Casi, porque la inicialización diferida necesita capturar la expresión inicial sin evaluarla y eso exige @autoclosure, y porque lazy interactúa con la mutación de formas que un wrapper genérico no reproduce con exactitud. Ese hueco entre el casi y el del todo es una buena imagen del alcance real del mecanismo: cubre la inmensa mayoría de los patrones de propiedad, pero no todos.
Lo verdaderamente radical de SE-0258 no es la comodidad, sino un movimiento de reificación. En la mayoría de los lenguajes, el par de accesores de una propiedad es una construcción sintáctica: existe en el punto donde lo escribes y en ningún otro sitio; no tiene nombre, no tiene tipo y no se puede pasar a ninguna parte. Swift decidió convertirlo en un valor de primera clase. Un Acotado no es una plantilla de código que se expande: es un objeto real, con su propio estado, que ocupa memoria dentro del tipo que lo hospeda y que puede tener métodos, conformar a protocolos y capturar dependencias. El acceso a la propiedad se ha vuelto una entidad del programa. Ese giro tiene un precedente ilustre —los descriptores de Python, los delegados con by de Kotlin— pero Swift lo lleva más lejos por una razón que se hará evidente en los siguientes niveles: si el intermediario es un tipo, entonces puede ser genérico, puede exponer una segunda cara mediante projectedValue, y un framework puede reconocerlo por reflexión y darle un trato especial. SwiftUI entero descansa sobre esa última posibilidad. Antes de los wrappers, el lenguaje tenía comportamientos de propiedad cerrados y privilegiados, cocinados dentro del compilador: lazy, @NSCopying. Después de ellos, ese privilegio se abrió a cualquiera. Es el mismo patrón que recorre la evolución entera de Swift: tomar algo que solo el compilador sabía hacer y ponerlo en manos de las bibliotecas.
Un property wrapper es un tipo con @propertyWrapper y una propiedad wrappedValue. Al aplicarlo, el compilador reemplaza tu declaración por un almacenamiento con guion bajo de tipo wrapper y una propiedad calculada que reenvía las lecturas y escrituras. De ahí se derivan todas las reglas: solo sobre var con almacenamiento, nunca sobre calculadas, lazy ni protocolos.
- Escribe un wrapper
NoNegativoque fuerce a cero cualquier valor negativo y aplícalo a dos propiedades de unstruct. - Escribe a mano la versión expandida de ese
struct, con el almacenamiento de guion bajo y la propiedad calculada, y comprueba que se comporta igual. - Intenta aplicar tu wrapper a un
let, a una propiedad calculada y a una propiedad declarada en una extensión; anota el error exacto de cada caso y explícalo con la transformación en la mano. - Elimina el
init(wrappedValue:)de tu wrapper y observa cómo cambia la firma del inicializador por miembros sintetizado. - Argumenta por qué
lazypodría reescribirse hoy como un property wrapper de biblioteca y qué le impide serlo por completo.