tutoriales.com

Desbloqueando la Magia de los 'Property Wrappers' en Swift: Simplificando la Lógica de Propiedades

Este tutorial explora a fondo los Property Wrappers en Swift, una poderosa característica para encapsular y reutilizar la lógica común de propiedades. Aprenderás qué son, cómo crearlos y aplicarlos para resolver problemas cotidianos como la validación, la conversión de tipos y la persistencia de datos, mejorando la legibilidad y mantenibilidad de tu código.

Intermedio15 min de lectura7 views
Reportar error

🚀 Introducción a los Property Wrappers en Swift

Swift es un lenguaje conocido por su expresividad y sus potentes características que ayudan a los desarrolladores a escribir código limpio y eficiente. Una de esas características, introducida en Swift 5.1, son los Property Wrappers (envoltorios de propiedades). Esta característica permite encapsular lógica común que se aplica a múltiples propiedades, como validación, transformación o almacenamiento, en un solo lugar reutilizable.

Antes de los Property Wrappers, si necesitabas, por ejemplo, que una propiedad siempre tuviera un valor mayúsculo, o que se guardara automáticamente en UserDefaults, tendrías que repetir esa lógica en cada setter de cada propiedad. Esto lleva a código duplicado y más difícil de mantener. Los Property Wrappers resuelven este problema al permitirte envolver una propiedad con un tipo que gestiona su almacenamiento y comportamiento subyacente de forma transparente.

En este tutorial, nos sumergiremos en el mundo de los Property Wrappers. Aprenderemos su sintaxis, cómo crearlos desde cero, y exploraremos ejemplos prácticos que te mostrarán cómo pueden simplificar tu código y hacerlo más robusto y modular. Prepárate para desbloquear una nueva forma de pensar sobre las propiedades en Swift.

💡 Consejo: Los Property Wrappers son la base de muchas de las características de SwiftUI, como `@State`, `@Binding`, `@EnvironmentObject`, entre otros. Entenderlos te dará una comprensión más profunda de cómo funciona SwiftUI.

📖 ¿Qué son los Property Wrappers? Conceptos Fundamentales

En esencia, un Property Wrapper es un tipo de estructura, enumeración o clase que define cómo una propiedad es almacenada y accedida. Cuando aplicas un Property Wrapper a una propiedad, Swift delega la gestión de esa propiedad a una instancia del Property Wrapper.

Anatomía de un Property Wrapper

Un Property Wrapper se define con el atributo @propertyWrapper y debe contener una propiedad wrappedValue. Esta propiedad wrappedValue es el valor real de la propiedad que estás envolviendo. Opcionalmente, puedes añadir una propiedad projectedValue (valor proyectado) para exponer funcionalidad adicional del envoltorio.

@propertyWrapper
struct MyPropertyWrapper<Value> {
    private var _value: Value

    init(wrappedValue: Value) {
        self._value = wrappedValue
        print("\(wrappedValue) inicializado por el wrapper")
    }

    var wrappedValue: Value {
        get { _value }
        set { _value = newValue }
    }
}

En este ejemplo básico:

  • @propertyWrapper indica que MyPropertyWrapper es un Property Wrapper.
  • wrappedValue: Value es la propiedad que Swift llama cuando accedes o modificas la propiedad envuelta.
  • El init(wrappedValue:) es el inicializador que Swift usa para configurar el envoltorio con el valor inicial de la propiedad.

Cómo se Usan

Una vez que tienes un Property Wrapper definido, puedes aplicarlo a cualquier propiedad de una estructura, clase o enumeración utilizando la sintaxis @NombreDelWrapper.

struct User {
    @MyPropertyWrapper var name: String
    @MyPropertyWrapper var age: Int
}

var user = User(name: "Alice", age: 30)
print("Nombre: \(user.name)") // Accede al wrappedValue
user.age = 31
print("Edad: \(user.age)")

Cuando escribes @MyPropertyWrapper var name: String, Swift genera código sintáctico que es similar a tener una propiedad subyacente de tipo MyPropertyWrapper<String> y luego name accede a su wrappedValue.

📌 Nota: Los Property Wrappers no introducen una nueva sintaxis fundamental al lenguaje, sino que proporcionan una forma conveniente de aplicar patrones de acceso a propiedades existentes de manera declarativa y reutilizable.

🛠️ Creando tus Propios Property Wrappers: Ejemplos Prácticos

La verdadera potencia de los Property Wrappers reside en su capacidad para encapsular lógica. Vamos a ver algunos ejemplos comunes.

1. Validación de Propiedades

Imagina que necesitas que una propiedad age siempre sea un número positivo o que un email tenga un formato válido.

@Positive Property Wrapper

@propertyWrapper
struct Positive {
    private var number: Int

    init(wrappedValue: Int) {
        if wrappedValue < 0 {
            print("Advertencia: El valor inicial \(wrappedValue) no es positivo. Se establecerá a 0.")
            self.number = 0
        } else {
            self.number = wrappedValue
        }
    }

    var wrappedValue: Int {
        get { number }
        set {
            if newValue < 0 {
                print("Advertencia: No se puede asignar un valor negativo (\(newValue)). Se mantendrá el valor actual de \(number).")
            } else {
                number = newValue
            }
        }
    }
}

struct Person {
    var name: String
    @Positive var age: Int
}

var person = Person(name: "Bob", age: 25)
print("Edad inicial: \(person.age)") // 25

person.age = -5 // Advertencia: No se puede asignar un valor negativo (-5). Se mantendrá el valor actual de 25.
print("Edad después de asignar -5: \(person.age)") // 25

person.age = 30
print("Nueva edad: \(person.age)") // 30

var invalidPerson = Person(name: "Eve", age: -10) // Advertencia: El valor inicial -10 no es positivo. Se establecerá a 0.
print("Edad de Eve: \(invalidPerson.age)") // 0

@EmailValid Property Wrapper

@propertyWrapper
struct EmailValid {
    private var email: String = ""

    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue // Usa el setter para la validación inicial
    }

    var wrappedValue: String {
        get { email }
        set {
            if isValidEmail(newValue) {
                email = newValue
            } else {
                print("Advertencia: '" + newValue + "' no es un email válido. El email actual sigue siendo '" + email + "'.")
            }
        }
    }

    private func isValidEmail(_ email: String) -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}

struct Contact {
    @EmailValid var primaryEmail: String = "default@example.com"
    @EmailValid var secondaryEmail: String
}

var contact = Contact(primaryEmail: "john.doe@example.com", secondaryEmail: "jane.doe@test.org")
print("Email principal: \(contact.primaryEmail)") // john.doe@example.com

contact.primaryEmail = "invalid-email"
print("Email principal después de inválido: \(contact.primaryEmail)") // john.doe@example.com

contact.secondaryEmail = "another@valid.email"
print("Email secundario: \(contact.secondaryEmail)") // another@valid.email

var contact2 = Contact(primaryEmail: "not_an_email") // Advertencia: 'not_an_email' no es un email válido. El email actual sigue siendo ''.
print("Email 2: \(contact2.primaryEmail)") // (Vacío porque se inicializa a "" y el primer valor es inválido)

2. Persistencia con UserDefaults

Los Property Wrappers son excelentes para abstraer la lógica de persistencia. Aquí, un envoltorio que guarda y carga valores automáticamente de UserDefaults.

import Foundation

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

    init(key: String, defaultValue: Value) {
        self.key = key
        self.defaultValue = defaultValue
    }

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

struct AppSettings {
    @UserDefault(key: "has_completed_onboarding", defaultValue: false) 
    var completedOnboarding: Bool

    @UserDefault(key: "user_nickname", defaultValue: "Guest") 
    var nickname: String

    @UserDefault(key: "app_theme_id", defaultValue: 0) 
    var themeId: Int
}

var settings = AppSettings()

print("Onboarding completado: \(settings.completedOnboarding)") // false (o lo que esté en UserDefaults)
print("Nickname: \(settings.nickname)") // Guest (o lo que esté en UserDefaults)

settings.completedOnboarding = true
settings.nickname = "SwiftMaster"
settings.themeId = 1

print("Onboarding completado (actualizado): \(settings.completedOnboarding)") // true
print("Nickname (actualizado): \(settings.nickname)") // SwiftMaster

// En una nueva instancia de AppSettings, los valores persisten
let newSettings = AppSettings()
print("Nickname en nueva instancia: \(newSettings.nickname)") // SwiftMaster
🔥 Importante: Para tipos complejos (como structs personalizadas) en `UserDefaults`, necesitarás codificación y decodificación (usando `Codable` y `JSONEncoder`/`JSONDecoder`), o implementar una lógica de persistencia más sofisticada en tu Property Wrapper.

✨ El projectedValue: Exponiendo Funcionalidad Adicional

Además del wrappedValue, un Property Wrapper puede exponer una propiedad projectedValue (valor proyectado). Esta propiedad proporciona una forma de acceder a la funcionalidad del envoltorio en sí, no al valor que contiene. Se accede a ella usando el signo de dólar ($) delante del nombre de la propiedad envuelta.

El projectedValue puede ser de cualquier tipo y es extremadamente útil para exponer información de estado del envoltorio, métodos de control, o para interactuar con el envoltorio de una manera diferente al simple acceso o modificación del wrappedValue.

Ejemplo: Trimmed con projectedValue para saber si se ha recortado

Vamos a crear un Trimmed Property Wrapper que recorta los espacios en blanco de una cadena. Además, usaremos projectedValue para indicar si la cadena fue realmente recortada.

@propertyWrapper
struct Trimmed {
    private var _value: String = ""
    var wasTrimmed: Bool = false

    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue
    }

    var wrappedValue: String {
        get { _value }
        set {
            let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
            if trimmedValue != newValue {
                wasTrimmed = true
            } else {
                wasTrimmed = false
            }
            _value = trimmedValue
        }
    }

    var projectedValue: Trimmed { // El projectedValue es el propio wrapper para exponer 'wasTrimmed'
        return self
    }
}

struct BlogPost {
    @Trimmed var title: String
    @Trimmed var content: String
}

var post = BlogPost(title: "  Mi Título  ", content: "Contenido del post.")

print("Título original: \(post.title)") // Mi Título
print("¿Se recortó el título?: \(post.$title.wasTrimmed)") // true

post.content = "Contenido sin espacios"
print("Contenido: \(post.content)")
print("¿Se recortó el contenido?: \(post.$content.wasTrimmed)") // false

post.title = "Otro Título"
print("Título nuevo: \(post.title)")
print("¿Se recortó el título con el nuevo valor?: \(post.$title.wasTrimmed)") // false

En SwiftUI, el projectedValue es extensivamente utilizado. Por ejemplo, en @State var value: Type, $value te da un Binding<Type>, que es el projectedValue que permite la comunicación bidireccional entre la vista y el estado.

Estructura del Property Wrapper Propiedad Envuelta @MyWrapper var property wrappedValue Acceso directo al valor (Llamada estándar: property) projectedValue Interfaz adicional ($) (Llamada: $property) GET/SET PROYECCIÓN

🔄 Inicialización y Argumentos en Property Wrappers

Los Property Wrappers no solo se pueden inicializar con un wrappedValue. También puedes proporcionar inicializadores personalizados para pasar argumentos adicionales al envoltorio, permitiendo una mayor configuración.

Parámetros al Inicializar

Puedes añadir cualquier número de parámetros a los inicializadores de tu Property Wrapper. Estos se especificarán en los paréntesis cuando apliques el envoltorio.

@propertyWrapper
struct Clamped<Value: Comparable> {
    private var _value: Value
    let min: Value
    let max: Value

    init(wrappedValue: Value, min: Value, max: Value) {
        precondition(min <= max, "Minimun value cannot be greater than maximum value")
        self.min = min
        self.max = max
        self._value = wrappedValue // Usa el setter para la lógica de clamp
        self.wrappedValue = wrappedValue // Inicializa y asegura que se aplique la lógica del setter
    }

    var wrappedValue: Value {
        get { _value }
        set {
            if newValue < min {
                _value = min
            } else if newValue > max {
                _value = max
            } else {
                _value = newValue
            }
        }
    }
}

struct GameSettings {
    @Clamped(min: 0, max: 100) var volume: Int = 50
    @Clamped(min: 0.0, max: 1.0) var brightness: Double = 0.8
}

var settings = GameSettings()

print("Volumen inicial: \(settings.volume)") // 50
settings.volume = 120
print("Volumen después de 120: \(settings.volume)") // 100
settings.volume = -10
print("Volumen después de -10: \(settings.volume)") // 0
settings.volume = 75
print("Volumen después de 75: \(settings.volume)") // 75

print("Brillo inicial: \(settings.brightness)") // 0.8
settings.brightness = 1.5
print("Brillo después de 1.5: \(settings.brightness)") // 1.0

Inicializadores por Defecto y Valores por Defecto

Si tu Property Wrapper tiene un inicializador init(wrappedValue:) y no proporcionas argumentos adicionales, puedes omitir los paréntesis. Si proporcionas un valor inicial a la propiedad, se pasará automáticamente como wrappedValue.

@propertyWrapper
struct DefaultToOne {
    private var _value: Int

    init(wrappedValue: Int) {
        self._value = wrappedValue
    }

    init() { // Inicializador por defecto si no se proporciona wrappedValue
        self.wrappedValue = 1
    }

    var wrappedValue: Int {
        get { _value }
        set { _value = newValue }
    }
}

struct MyStruct {
    @DefaultToOne var counter: Int // Usará init()
    @DefaultToOne var anotherCounter: Int = 5 // Usará init(wrappedValue: 5)
}

var myStruct = MyStruct()
print("Counter: \(myStruct.counter)") // 1
print("Another Counter: \(myStruct.anotherCounter)") // 5
📌 Nota: Si el Property Wrapper solo tiene `init(wrappedValue:)` y no se proporciona un valor inicial para la propiedad, la compilación fallará. Necesitarás un inicializador sin parámetros (`init()`) o proporcionar siempre un valor inicial a la propiedad.

📈 Comparación con Métodos de Propiedad y didSet/willSet

Es natural preguntarse cuándo usar Property Wrappers en lugar de otras técnicas como didSet/willSet o métodos computados para propiedades. Aquí hay una tabla comparativa para aclarar:

CaracterísticadidSet/willSet (Observadores)Propiedades ComputadasProperty Wrappers
------------
Propósito PrincipalEjecutar código antes/después de un cambio de valor.Calcular un valor dinámicamente sin almacenamiento.Encapsular lógica de almacenamiento y acceso reutilizable.
ReusabilidadBaja (lógica específica de la propiedad).Baja (lógica específica de la propiedad).Alta (se define una vez, se usa muchas veces).
------------
LógicaSe define dentro de la propiedad.Se define dentro de la propiedad.Se define fuera de la propiedad (en el wrapper).
Estado InternoNo gestiona un estado interno del wrapper.No tiene estado de almacenamiento.Puede tener su propio estado interno (_value, wasTrimmed).
------------
SintaxisVerbosa para lógica repetitiva.Clara para cálculos simples.Declarativa y concisa (@Wrapper).
TransparenciaLógica visible en cada propiedad.Lógica visible en cada propiedad.Lógica abstraída detrás del @Wrapper.
------------
Uso ComúnActualizar UI, notificar cambios.fullName de firstName y lastName.Validación, persistencia, transformación, sincronización.
💡 Consejo: Usa Property Wrappers cuando necesites aplicar la **misma lógica transversal** a **múltiples propiedades** y quieras **abstraer** esa lógica para mejorar la **reusabilidad y legibilidad** del código.
Property Wrappers • Encapsulación • Reutilización • Abstracción de Lógica Observers didSet / willSet Comportamiento en cambios Propiedades Computadas Sin almacenamiento Cálculo dinámico

⚠️ Consideraciones y Mejores Prácticas

Aunque los Property Wrappers son increíblemente útiles, es importante usarlos con sabiduría.

Cuándo Usarlos (y Cuándo No)

Usar cuando:

  • Necesitas aplicar la misma validación a varias propiedades (ej. @NonEmptyString, @PositiveInt).
  • Necesitas persistir automáticamente valores (ej. @UserDefault, @KeychainItem).
  • Necesitas transformar valores al leer/escribir (ej. @Lowercase, @Trimmed).
  • Necesitas sincronización de acceso a propiedades en un entorno concurrente (ej. @Synchronized).
  • Estás desarrollando frameworks donde quieres proporcionar comportamientos de propiedad predefinidos (como en SwiftUI).

Evitar cuando:

  • La lógica es única para una sola propiedad y no se reutilizará. En este caso, didSet/willSet o una propiedad computada podrían ser más simples.
  • La lógica es demasiado compleja para encapsularla de forma limpia en un envoltorio. Podría dificultar la comprensión del flujo de datos.
  • Estás intentando realizar operaciones costosas en los get/set del wrappedValue sin una buena razón, ya que pueden afectar el rendimiento.

Diseño de Property Wrappers Eficientes

  • Claridad de propósito: Cada Property Wrapper debe tener un propósito claro y limitado.
  • Simplicidad: Mantén la lógica dentro del wrappedValue (y projectedValue) tan simple como sea posible. Si la lógica es muy compleja, considera refactorizar.
  • Rendimiento: Ten en cuenta el impacto en el rendimiento de los get/set del wrappedValue, especialmente si el envoltorio se usa con frecuencia o en propiedades de colecciones grandes.
  • Genéricos: Utiliza tipos genéricos (<Value>) para hacer tus Property Wrappers lo más reutilizables posible para diferentes tipos de datos.
  • Documentación: Documenta claramente qué hace tu Property Wrapper, sus inicializadores y su projectedValue si lo tiene.

Ejemplo Avanzado: Sincronización de Acceso a Propiedades

Para entornos multithread, podrías querer un Property Wrapper que asegure que las lecturas y escrituras de una propiedad sean seguras para hilos.

import Foundation

@propertyWrapper
struct Synchronized<Value> {
    private var _value: Value
    private let lock = NSLock()

    init(wrappedValue: Value) {
        self._value = wrappedValue
    }

    var wrappedValue: Value {
        get {
            lock.lock()
            defer { lock.unlock() }
            return _value
        }
        set {
            lock.lock()
            defer { lock.unlock() }
            _value = newValue
        }
    }
}

class DataStore {
    @Synchronized var counter: Int = 0
    @Synchronized var users: [String] = []
}

let store = DataStore()

// Ejemplo de uso en un entorno multithread (simulado)
let queue = DispatchQueue(label: "com.example.concurrentqueue", attributes: .concurrent)
let group = DispatchGroup()

for i in 0..<100 {
    queue.async(group: group) {
        store.counter += 1
        store.users.append("User \(i)")
    }
}

group.notify(queue: .main) {
    print("Contador final: \(store.counter)") // Debería ser 100
    print("Número de usuarios: \(store.users.count)") // Debería ser 100
    // Sin @Synchronized, counter y users.count podrían ser incorrectos debido a condiciones de carrera.
}

Este ejemplo demuestra cómo un Property Wrapper puede manejar complejidades como la sincronización de hilos de forma transparente para el consumidor de la propiedad, lo que resulta en un código más seguro y limpio.


🎯 Conclusión

Los Property Wrappers son una adición fantástica al arsenal de Swift, proporcionando una poderosa herramienta para escribir código más limpio, modular y reutilizable. Al encapsular la lógica de las propiedades en tipos dedicados, puedes abstraer comportamientos repetitivos como la validación, la persistencia y la transformación, haciendo que tus modelos de datos sean más robustos y fáciles de entender.

Desde la validación básica hasta la sincronización avanzada de hilos, los ejemplos que hemos explorado demuestran la versatilidad de esta característica. Al comprender y aplicar Property Wrappers de manera efectiva, no solo mejorarás la calidad de tu código Swift, sino que también obtendrás una comprensión más profunda de cómo funcionan frameworks como SwiftUI.

¡Anímate a experimentar con ellos en tus propios proyectos y descubre cómo pueden simplificar tu flujo de trabajo!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!