Swift y Objective-C

[Swift #7] Wrappers: por qué @State no es magia

Los wrappers de propiedades no son sintaxis de SwiftUI, sino una función del lenguaje incorporada en Swift 5.1 mediante SE-0258. Explicamos cómo el compilador traduce @Clamped y qué son wrappedValue y projectedValue($), además de sus límites de uso.

6 min de lectura
Imagen de portada de [Swift #7] Wrappers: por qué @State no es magia

Quienes desarrollan con SwiftUI escriben @State y @Published decenas de veces al día. Pero, cuando preguntas qué hace exactamente esa arroba, las respuestas varían. «¿No es sintaxis de SwiftUI?» es un error común. No. Un wrapper de propiedades (property wrapper) es una función del lenguaje incorporada en Swift 5.1 mediante la propuesta de Swift Evolution SE-0258; SwiftUI solo es un usuario destacado de ella.

Este es el episodio 7 de la serie intermedia. Entenderemos el funcionamiento de los wrappers de propiedades implementando uno, y resumiremos qué son wrappedValue y projectedValue($), además de cómo evitar su abuso.

Planteamiento — lógica de envoltura repetida en cada propiedad

En el episodio sobre propiedades vimos el patrón de validar valores con didSet. Pero ¿qué ocurre si varias propiedades necesitan la misma validación? Si quieres limitar el volumen, el brillo y el progreso al rango 0…1, acabarás copiando tres bloques didSet.

var volume: Double = 0.5 {
    didSet { volume = min(max(volume, 0), 1) }
}
var brightness: Double = 0.5 {
    didSet { brightness = min(max(brightness, 0), 1) }
}
// El mismo código sigue...

Es la misma lógica reescrita para cada propiedad: la versión aplicada a propiedades del problema «¿duplicación o seguridad?» que vimos en el episodio sobre genéricos. Un wrapper de propiedades permite poner nombre a esa lógica de envoltura y reutilizarla.

@propertyWrapper
struct Clamped {
    private var value: Double = 0
    var wrappedValue: Double {
        get { value }
        set { value = min(max(newValue, 0), 1) }
    }
}

struct Player {
    @Clamped var volume: Double
    @Clamped var brightness: Double
}

Aunque asignes player.volume = 1.5, en realidad se almacenará 1.0. La lógica de validación solo existe en Clamped.

Cómo funciona — la traducción que hace la arroba

Para comprobar que @Clamped no es magia, basta con observar el resultado de la traducción del compilador. @Clamped var volume: Double se expande aproximadamente así.

private var _volume = Clamped()          // Almacenamiento real: instancia del wrapper
var volume: Double {                     // El nombre que usamos: propiedad computada
    get { _volume.wrappedValue }
    set { _volume.wrappedValue = newValue }
}

La clave está en dos líneas. Lo que realmente se almacena es la instancia del wrapper con guion bajo, y el nombre al que accedemos es una propiedad computada que redirige al wrappedValue del wrapper. ¿Ves cómo la distinción entre almacenamiento y cálculo, trabajada en el episodio sobre propiedades, se usa directamente como material? Un wrapper empaqueta «propiedad almacenada + propiedad computada + lógica repetida» en un único tipo y lo distribuye mediante la arroba.

Una vez entiendes esta traducción, las restricciones de los wrappers también resultan naturales. Que añadir didSet a una propiedad con wrapper produzca un comportamiento confuso, o que no pueda aplicarse a variables locales ni a propiedades computadas (las locales se permiten desde Swift 5.5), son problemas de «dónde se crea el almacenamiento con guion bajo».

Diagrama del compilador que traduce la declaración de @Clamped a un almacenamiento con guion bajo y una propiedad computada
@Clamped var volume se traduce a un almacenamiento con guion bajo y una propiedad computada

projectedValue — qué representa el signo de dólar

En SwiftUI, probablemente hayas visto un signo de dólar, como en $text. Esto también es una función de los wrappers. Si declaras una propiedad llamada projectedValue en el tipo wrapper, el compilador crea una tercera vía denominada $이름.

  • volume → wrappedValue (el valor en sí)
  • _volume → la instancia del wrapper (solo accesible dentro del tipo que lo declara)
  • $volume → projectedValue (algo que el wrapper expone adicionalmente)

Qué sea ese «algo que expone adicionalmente» depende del diseñador del wrapper. @State de SwiftUI expone un Binding —un canal de lectura y escritura— mediante $, mientras que @Published de Combine expone un Publisher, un flujo de cambios. La misma sintaxis $ produce objetos distintos porque esto es una decisión de diseño de cada wrapper, no una regla del lenguaje. Lo mismo ocurre al crear uno propio. Si añades a Clamped un projectedValue que indique si el valor fue recortado, $volume se convierte en una API para comprobar si la asignación anterior estaba fuera de rango.

Receta práctica — Aprender diseño con un wrapper de UserDefaults

Uno de los patrones de wrappers personalizados más usados en producción es el acceso a UserDefaults. Vamos a construir uno para comprobar también el principio.

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T

    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

enum Settings {
    @UserDefault(key: "hasSeenOnboarding", defaultValue: false)
    static var hasSeenOnboarding: Bool
}

La lógica repetitiva para errores tipográficos en las claves, conversiones y valores predeterminados queda dentro del wrapper, y el punto de uso se reduce a una sola línea: Settings.hasSeenOnboarding = true. Este ejemplo también muestra que el genérico <T> y los parámetros de init (key, defaultValue) se aplican al wrapper sin cambios. Los paréntesis después del signo @ invocan el init del wrapper.

Estos son los escenarios naturales para un wrapper: cambiar el almacenamiento (UserDefaults, llavero), envolver el acceso (bloqueos de hilos, logging) y ajustar valores (limitar rangos, recortar espacios). Todos comparten que son preocupaciones técnicas de almacenamiento y acceso, independientes del significado del valor. Desde la perspectiva de la separación de responsabilidades, un wrapper separa las preocupaciones técnicas de la declaración de propiedades.

Límites del abuso — Complejidad oculta tras el signo @

El riesgo de un wrapper nace directamente de su fortaleza: código arbitrario puede ocultarse detrás de una sola asignación. Es una función en tensión directa con el principio de mínima sorpresa.

Propongo tres criterios. Primero, dentro de un wrapper solo debe hacerse lo predecible. Ajustar valores o cambiar el almacenamiento está bien, pero ocultar efectos secundarios pesados, como solicitudes de red o transiciones de pantalla, detrás de una asignación convierte la depuración en un infierno. Segundo, usa solo wrappers conocidos por el equipo. Los estándares del ecosistema, como @State, y los wrappers documentados en el código del equipo son activos; pero si aparece un @ nuevo en cada archivo, revisar código se convierte en buscar definiciones de wrappers. Tercero, deja la lógica de un solo uso en didSet. Los wrappers existen para reutilizarse, así que conviene promoverla cuando aparezca un segundo uso. Es el principio YAGNI (You Aren’t Gonna Need It — no lo construyas hasta que lo necesites).

Por último, una dirección reciente. A medida que Swift y SwiftUI avanzan hacia un enfoque basado en macros, han aparecido signos @ que son macros y no wrappers, como @Observable del framework Observation. Que algo tenga un @ ya no significa necesariamente que sea un property wrapper. Trataremos qué son las macros y en qué se diferencian de los wrappers en una serie avanzada.

Ilustración de una caja fuerte de propiedades con tres puertas: nombre, guion bajo y signo de dólar
Nombre, guion bajo y signo de dólar: una propiedad tiene tres puertas

Resumen

  • Los property wrappers no son exclusivos de SwiftUI; son una función del lenguaje de Swift 5.1 (SE-0258) que agrupa en un tipo reutilizable la lógica de envoltura repetida de cada propiedad.
  • El principio es una traducción. @Wrapper var x se expande a «una instancia del wrapper con guion bajo (almacenamiento) + una propiedad calculada llamada x (canal de acceso)».
  • $x es un tercer canal de acceso llamado projectedValue, y lo que expone depende del diseño del wrapper (@State expone Binding y @Published expone Publisher).
  • Encajan bien en responsabilidades técnicas como cambiar la ubicación de almacenamiento, envolver el acceso y ajustar valores; los efectos secundarios pesados y la lógica puntual no deben ir en un wrapper.

La próxima entrega es KeyPath, la última de la serie intermedia. Explicará cómo la sintaxis de barra invertida \.name permite tratar las propiedades como valores y por qué es posible usar map(.name).


Referencias

Seguir leyendo