Propiedades lazy en Swift: qué son, cómo funcionan y por qué fallan en SwiftUI
Arturo Rivas Arias
💤 Una propiedad lazy en Swift es una propiedad almacenada cuyo valor no se calcula hasta que se accede a ella por primera vez. A diferencia de las propiedades almacenadas habituales, que se inicializan en el momento en que se crea la instancia, las propiedades lazy difieren ese trabajo hasta que realmente se necesita. La sintaxis es directa: basta con anteponer la palabra clave lazy a una declaración de var.
⚙️ El mecanismo interno es más explícito de lo que parece. Swift implementa una propiedad lazy como un opcional oculto que empieza siendo nil. En el primer acceso, ejecuta el bloque de inicialización, guarda el resultado y lo reutiliza en accesos posteriores. Por eso solo puede declararse con var: la mutación es necesaria en ese primer acceso para almacenar el valor calculado. Un let lazy es una contradicción que el compilador rechaza.
🧩 Una de sus propiedades más útiles es que puede referenciar self dentro de su bloque de inicialización. Esto es posible porque, cuando se ejecuta la inicialización diferida, la instancia ya está completamente construida. En propiedades almacenadas normales, self aún no está disponible durante la fase de inicialización, lo que obliga a construir ciertos valores de forma independiente. Con lazy, ese problema desaparece.
final class PushNotificationConfig {
let appID: String
let environment: String
lazy var channelIdentifier: String = {
return "\(appID).\(environment).notifications"
}()
init(appID: String, environment: String) {
self.appID = appID
self.environment = environment
}
}
let config = PushNotificationConfig(appID: "com.miapp", environment: "production")
// channelIdentifier aún no se ha calculado
print(config.channelIdentifier) // → "com.miapp.production.notifications"
// A partir de aquí el valor está cacheado
🎯 Los casos de uso reales donde lazy aporta valor son aquellos en los que la inicialización es costosa y/o el uso no está garantizado. Piensa en un parseador de JSON que procesa un esquema grande, un formateador de fechas con configuración regional compleja, o un motor de búsqueda en memoria que indexa miles de registros. Crear cualquiera de esos objetos incondicionalmente al arranque es un gasto que puede no materializarse jamás si el usuario nunca llega a la pantalla que los necesita.
final class DocumentExporter {
let locale: Locale
// Solo se crea si realmente se exporta algo
lazy var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateStyle = .long
formatter.timeStyle = .short
return formatter
}()
lazy var numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = locale
formatter.numberStyle = .currency
return formatter
}()
init(locale: Locale = .current) {
self.locale = locale
}
}
🔒 Una restricción importante que conviene no ignorar: las propiedades lazy no son seguras para entornos concurrentes. Si dos hilos acceden simultáneamente a una propiedad lazy que aún no se ha inicializado, ambos pueden ejecutar el bloque de inicialización a la vez. No hay ningún mecanismo de sincronización implícito. En código Swift con concurrencia async/await o con accesos desde múltiples colas de Grand Central Dispatch, esto puede provocar comportamientos no deterministas o incluso corrupción de estado. La solución habitual es gestionar la sincronización manualmente con un actor o un mecanismo de exclusión explícito.
// ❌ No seguro en entornos concurrentes
final class CacheManager {
lazy var storage: [String: Data] = [:]
}
// ✅ Alternativa segura usando un actor
actor CacheManager {
private var storage: [String: Data] = [:]
func value(forKey key: String) -> Data? {
return storage[key]
}
func store(_ data: Data, forKey key: String) {
storage[key] = data
}
}
🚫 Donde lazy empieza a generar problemas reales es en SwiftUI. Las vistas en SwiftUI son structs, tipos valor, y body es una propiedad computada marcada como no mutable. El primer acceso a una propiedad lazy requiere mutar la instancia para guardar el resultado. Esas dos condiciones son incompatibles: el compilador lo rechaza directamente. No es un aviso, es un error de compilación.
// ❌ No compila: mutating getter en un contexto no mutante
struct SettingsView: View {
lazy var validator: InputValidator = InputValidator()
var body: some View {
// Error: cannot use mutating getter on immutable value
Text(validator.validate("test@email.com") ? "Válido" : "Inválido")
}
}
📦 La solución no es forzar el patrón lazy dentro de la vista, sino entender qué herramienta de SwiftUI cumple el mismo propósito. Para objetos que necesitan inicializarse una sola vez y mantenerse estables durante el ciclo de vida de la vista, @StateObject o @State con un tipo @Observable son la respuesta correcta. SwiftUI garantiza que esa instancia se crea una única vez y persiste mientras la vista permanece en la jerarquía, que es exactamente lo que se espera de una propiedad lazy en una clase.
// ✅ Equivalente correcto en SwiftUI
@Observable
final class InputValidator {
func validate(_ email: String) -> Bool {
return email.contains("@") && email.contains(".")
}
}
struct SettingsView: View {
@State private var validator = InputValidator()
var body: some View {
Text(validator.validate("test@email.com") ? "Válido" : "Inválido")
}
}
🏗️ Si la lógica que quieres diferir vive en una clase auxiliar —un ViewModel, un servicio, un repositorio— lazy funciona perfectamente ahí. Las clases tienen identidad estable y el compilador no impone restricciones de mutabilidad sobre sus propiedades. Este es el patrón más limpio: mantener la lógica lazy donde tiene sentido semántico, y dejar que SwiftUI gestione el ciclo de vida del objeto contenedor.
final class LocationViewModel: ObservableObject {
// Solo se construye si el usuario abre el mapa
lazy var geocoder: CLGeocoder = CLGeocoder()
// Solo se carga si hay historial que mostrar
lazy var recentPlaces: [CLPlacemark] = loadRecentPlaces()
private func loadRecentPlaces() -> [CLPlacemark] {
// Lectura desde persistencia local
return []
}
}
struct MapView: View {
@StateObject private var viewModel = LocationViewModel()
var body: some View {
// geocoder y recentPlaces solo se inicializan cuando se accede a ellos
Text("Mapa cargado")
}
}
🆚 Vale la pena distinguir lazy de las propiedades computadas, porque simple vista se parecen. Una propiedad computada se recalcula en cada acceso: no almacena nada, siempre ejecuta su bloque de código. Una propiedad lazy ejecuta su bloque exactamente una vez y almacena el resultado. Si el valor que necesitas puede cambiar con el tiempo, la propiedad computada es la elección correcta. Si es costoso de construir, invariable tras la primera creación, y no siempre necesario, lazy es la herramienta adecuada.
🛠️ En la práctica, lazy encaja bien en tres situaciones concretas: objetos de soporte que dependen de propiedades del propio tipo (como en el ejemplo de channelIdentifier), cachés de resultados de cómputo intensivo que no cambian una vez calculados, y servicios o dependecias que solo se necesitan en flujos secundarios. Fuera de esas situaciones, añadir lazy por defecto suele ser optimización prematura que complica la lectura del código sin aportar un beneficio medible.
👨💻 Entender lazy en profundidad es entender el contrato entre inicialización, mutación y ciclo de vida en Swift. Las restricciones que impone el compilador en SwiftUI no son arbitrarias: son consecuencia directa del modelo de tipos valor que hace que SwiftUI sea predecible y componible. Conocer dónde aplica cada herramienta evita horas de depuración buscando por qué algo que “debería funcionar” no compila o se comporta de forma inesperada.