Límites y alternativas: dónde el wrapper no llega
Lo que un property wrapper no puede hacer por construcción, los costes que esconde, y por qué las macros —con `@Observable` como caso ejemplar— han ocupado el terreno que los wrappers no alcanzaban. Con un criterio claro para elegir entre ambos.
Los property wrappers llegaron en 2019 con una ambición amplia: si el patrón de accesores podía empaquetarse en un tipo, quizá una parte importante de la metaprogramación cotidiana cabría en ellos. Seis años después el veredicto es matizado. Siguen siendo la herramienta más barata y más clara para el comportamiento que pertenece a una propiedad aislada, y ahí nada los sustituye. Pero allí donde el comportamiento involucra al tipo entero —sincronizar propiedades entre sí, generar miembros nuevos, añadir conformidades, notificar al objeto que las contiene— el mecanismo choca contra un muro que no es un descuido de diseño, sino su definición. Ese muro es exactamente el que las macros derriban.
- Enumerar las limitaciones estructurales del mecanismo y deducirlas de la transformación del compilador.
- Reconocer los costes ocultos en rendimiento, API y diagnósticos.
- Explicar con
@Observablequé puede hacer una macro que un wrapper no puede. - Aplicar un criterio de elección entre wrapper, macro y código escrito a mano.
Lo que un wrapper no puede hacer
Casi todos los límites se deducen de una sola frase: el wrapper es un valor almacenado en una propiedad y solo ve el valor que envuelve. De ahí sale la lista completa.
No ve a quien lo contiene. Un wrapper no puede leer otra propiedad del tipo, llamar a un método suyo ni avisarle de un cambio. Existe un mecanismo con nombre subrayado, un subscript estático que recibe la instancia contenedora, usado durante años por bibliotecas de Apple; nunca se oficializó, y esa decisión marcó el destino de la propuesta.
Compone mal. Se pueden apilar varios atributos sobre una propiedad, pero la composición resultante es un anidamiento de tipos genéricos donde el orden importa y casi nunca es el que uno esperaba:
@propertyWrapper struct Recortado { /* envuelve String */ }
@propertyWrapper struct Registrado<Valor> { /* envuelve cualquier cosa */ }
struct Perfil {
// El tipo real del almacenamiento es Registrado de Recortado,
// no dos comportamientos sumados sobre un String
@Registrado @Recortado var apodo = ""
}
El wrapper exterior no envuelve el String: envuelve al wrapper interior. Por eso el orden de los atributos cambia la semántica, por eso el exterior debe ser genérico para admitir al interior, y por eso la mayoría de wrappers de sistema —los de SwiftUI el primero— simplemente no admiten compañía. Dos comportamientos ortogonales no se suman aplicando dos atributos.
No entra en protocolos. Un protocolo no puede exigir que una propiedad venga envuelta, porque el atributo describe cómo se implementa el almacenamiento, no la interfaz. Tampoco puede sobrescribirse en una subclase ni aplicarse a una propiedad de una extensión.
No puede fallar ni esperar. El acceso pasa por accesores ordinarios: un wrappedValue no puede declararse throws ni async para el patrón de envoltura, de modo que ningún wrapper puede validar lanzando un error ni cargar de forma asíncrona. Se ve muy bien en la práctica:
// Lo que te gustaria escribir y no puedes
@propertyWrapper
struct Validado<Valor> {
var wrappedValue: Valor {
get throws { ... } // no encaja con el patron de wrapper
}
}
Y quedan las prohibiciones sintácticas menores, todas ellas coherentes: solo sobre var, nunca sobre let, ni sobre propiedades calculadas, ni junto a lazy, ni en casos de enum, ni combinadas con weak o unowned declarados aparte.
Los costes que no se ven
Aun dentro de su terreno, un wrapper no es gratis. Hay tres facturas que conviene tener presentes.
La primera es de rendimiento. Cada acceso pasa por una llamada a un accesor que a su vez toca el wrapper. En un tipo sencillo, el optimizador suele hacerla desaparecer; en cuanto el wrapper es una clase, captura clausuras o cruza fronteras de módulo sin optimización entre módulos, la llamada se queda y aparece un recuento de referencias donde antes había un campo desnudo. Una propiedad envuelta en un bucle caliente merece una medición, no una suposición.
La segunda es de superficie pública. El wrapper cambia el inicializador por miembros sintetizado, expone $nombre con el nivel de acceso de la propiedad y ata el tipo del wrapper a tu API. Cambiar más tarde Preferencia por otro mecanismo no es un refactor interno: rompe a todo el que usara la proyección.
La tercera es de diagnóstico. Un error de tipos dentro de un wrapper genérico se reporta sobre código que nadie escribió, y el mensaje habla de wrappedValue y de parámetros que el programador no ve. Es el mismo peaje de toda metaprogramación, y conviene contarlo entre los costes.
Si al escribir un wrapper necesitas pasarle el objeto contenedor, una clausura de notificación o una referencia al gestor de turno para que pueda hacer su trabajo, el mecanismo se te ha quedado corto. Ese acoplamiento manual es la firma de un problema de tipo, no de propiedad, y hoy corresponde a una macro.
Por qué las macros ocupan su lugar
El ejemplo canónico es @Observable. El problema es viejo: que leer una propiedad de un modelo suscriba a la vista que la leyó, y que escribirla invalide solo a quienes dependían de esa propiedad concreta. Con wrappers se intentó durante años y el resultado fue @Published sobre Combine, que exigía marcar propiedad por propiedad, obligaba a conformar la clase a ObservableObject, notificaba el objeto entero sin distinguir qué había cambiado y no podía hacerlo de otro modo, porque un wrapper no sabe en qué objeto vive. Con una macro el planteamiento cambia por completo:
@Observable
final class Carrito {
var articulos: [String] = []
var cupon: String? = nil
}
Aquí no hay atributo en ninguna propiedad. La macro recibe el árbol sintáctico de la clase entera y lo reescribe: convierte cada propiedad almacenada en una calculada, añade un almacenamiento privado, inserta un registrador de observación como miembro nuevo, y declara la conformidad al protocolo. Aproximadamente esto:
// Expansion conceptual
final class Carrito: Observable {
private let registrador = ObservationRegistrar()
private var _articulos: [String] = []
var articulos: [String] {
get { registrador.access(self, keyPath: \.articulos); return _articulos }
set { registrador.withMutation(of: self, keyPath: \.articulos) { _articulos = newValue } }
}
}
Cada capacidad de esa expansión está fuera del alcance de un wrapper, y por razones distintas: añadir miembros nuevos al tipo, declarar una conformidad, nombrar la instancia contenedora con self dentro del accesor y operar sobre todas las propiedades a la vez sin que el programador anote ninguna. Una macro trabaja sobre sintaxis en tiempo de compilación, con la declaración completa delante; un wrapper trabaja sobre un valor en tiempo de ejecución, con una sola propiedad delante. La diferencia de alcance es la diferencia entre ver el tipo y ver el campo.
flowchart TB q1[El comportamiento afecta solo al valor de una propiedad] q1 -->|si| w[Property wrapper] q1 -->|no| q2[Necesita ver o modificar el tipo entero] q2 -->|si| q3[Necesita generar miembros o conformidades] q3 -->|si| m[Macro adjunta] q3 -->|no| p[Protocolo con extension o composicion normal] q2 -->|no| q4[Se usa en un solo lugar] q4 -->|si| c[Propiedad calculada escrita a mano] style w fill:#a6e3a1,color:#11111b style m fill:#cba6f7,color:#11111b style c fill:#89b4fa,color:#11111b
Radio de visión
Un wrapper ve un valor; una macro ve la declaración entera. Ese radio decide qué problemas puede resolver cada uno, y no hay forma de ampliarlo.
Momento de actuación
El wrapper actúa en ejecución, en cada acceso. La macro actúa una sola vez, en compilación, y luego desaparece dejando código ordinario.
Coste de adopción
Un wrapper es un tipo más en tu módulo. Una macro exige un objetivo aparte, SwiftSyntax y un plugin que se compila y se ejecuta durante la build.
Cómo elegir hoy
La regla práctica cabe en tres líneas. Si el comportamiento pertenece al valor y se repite en varias propiedades, escribe un wrapper: acotar, recortar, persistir, memorizar. Si pertenece al tipo —generar miembros, sincronizar propiedades entre sí, añadir conformidades, reaccionar a cambios en nombre del objeto—, escribe o usa una macro. Y si el comportamiento aparece una sola vez, escribe una propiedad calculada a mano y no le des más vueltas: un wrapper de un solo uso añade un tipo, un nombre y una expansión mental a cambio de nada.
Hay una pregunta que ayuda a decidir en el caso dudoso: ¿el comportamiento seguiría teniendo sentido si moviera la propiedad a otro tipo cualquiera? Si la respuesta es sí, pertenece al valor y el wrapper es la herramienta correcta. Si la respuesta es que depende de sus vecinas, del ciclo de vida del objeto o de la identidad del contenedor, pertenece al tipo y ningún wrapper lo va a resolver sin muletas.
Conviene evitar la conclusión perezosa de que las macros los han jubilado. Los wrappers siguen siendo más baratos de escribir, más fáciles de leer y de depurar, no requieren un objetivo de compilación aparte ni dependen de SwiftSyntax, y no imponen el coste de compilación de un plugin. De hecho, la relación más común hoy no es de sustitución sino de cooperación: una macro que reescribe un tipo puede generar propiedades envueltas, y varios wrappers de sistema conviven sin problema dentro de tipos anotados con macros.
Vista con distancia, la historia de los property wrappers es un capítulo de un movimiento mucho mayor, y entenderlo es más valioso que memorizar sus límites. Swift nació con un puñado de comportamientos mágicos cocinados dentro del compilador: lazy, @NSCopying, la síntesis de Codable, el inicializador por miembros. Cada uno resolvía un caso real y ninguno era extensible; si tu necesidad se parecía pero no coincidía, no había nada que hacer salvo proponer un cambio del lenguaje y esperar años. Los property wrappers fueron la primera apertura sistemática de ese privilegio, y por eso mismo llevan grabadas las cicatrices de un primer intento: resolvieron con elegancia el caso de la propiedad aislada, permitieron que naciera SwiftUI, y se estrellaron contra todo lo que exigía ver el tipo completo. Las macros son la generalización que faltaba: en vez de interceptar un acceso, transforman sintaxis, con la declaración entera delante y capacidad para generar cualquier cosa que pudieras haber escrito a mano. La moraleja no es que las macros sean superiores, sino que cada mecanismo tiene un radio de visión y ese radio determina qué problemas puede resolver: un wrapper ve un valor, una macro ve una declaración, un plugin de compilador vería el módulo. La ambición correcta de un lenguaje maduro no es acumular casos especiales, sino ofrecer mecanismos generales con radios distintos y dejar que quien construye elija el más pequeño que resuelva su problema. Ese principio —el mecanismo más modesto que baste— es también el mejor consejo de ingeniería que puedes llevarte de este nivel, porque un wrapper mal elegido paga en opacidad, y una macro mal elegida paga en complejidad, tiempo de compilación y una expansión que alguien tendrá que leer a las tres de la madrugada.
Un wrapper no ve al tipo que lo contiene, no compone bien, no cabe en protocolos y no puede lanzar ni esperar. Sus costes son rendimiento en caminos calientes, superficie pública ampliada y peores diagnósticos. Las macros cubren lo que él no alcanza porque transforman la declaración entera. Elige wrapper para comportamiento de valor repetido, macro para comportamiento de tipo, y propiedad calculada para un caso único.
- Intenta escribir un wrapper que, al cambiar una propiedad, invalide una caché guardada en otra propiedad del mismo tipo; documenta dónde te atascas y por qué.
- Toma una clase con tres propiedades marcadas con
@Publishedy reescríbela con@Observable; compara qué desaparece del código fuente. - Usa la expansión de macros de Xcode sobre un tipo anotado con
@Observabley localiza en el resultado los miembros que un wrapper no habría podido generar. - Mide el coste de acceso a una propiedad envuelta frente a una desnuda en un bucle de un millón de iteraciones, con y sin optimización.
- Elige tres comportamientos de un proyecto tuyo y clasifícalos con el árbol de decisión: wrapper, macro o propiedad calculada, justificando cada elección.