Что такое property wrapper в Swift и для чего он нужен?

«Что такое property wrapper в Swift и для чего он нужен?» — вопрос из категории SwiftUI, который задают на 10% собеседований IOS Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Property Wrapper — это механизм в Swift, который позволяет инкапсулировать общую логику работы с getter/setter свойства в переиспользуемый тип. Он объявляется с помощью атрибута @propertyWrapper.

Основная идея: Вынести стандартные операции (валидация, трансформация, синхронизация с хранилищем) из самих свойств в отдельную обертку, что делает код чище и DRY (Don't Repeat Yourself).

Структура Property Wrapper:

@propertyWrapper // 1. Объявление атрибута
struct WrapperName<T> {
    // 2. Хранимое приватное значение
    private var value: T

    // 3. Обязательное свойство wrappedValue
    var wrappedValue: T {
        get { value }
        set { value = newValue } // Здесь можно добавить логику
    }

    // 4. Инициализаторы (опционально)
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
}

// 5. Использование
struct MyStruct {
    @WrapperName var myProperty: String = "default" // Логика из WrapperName применяется к myProperty
}

Практический пример: обертка для автоматического обновления UI в UserDefaults

import SwiftUI

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

    var wrappedValue: T {
        get {
            userDefaults.object(forKey: key) as? T ?? defaultValue
        }
        set {
            userDefaults.set(newValue, forKey: key)
            // Важно: Для SwiftUI можно уведомить об изменении
            // objectWillChange.send() если обертка внутри ObservableObject
        }
    }
}

// Использование в настройках приложения
class SettingsViewModel: ObservableObject {
    private let defaults = UserDefaults.standard

    @UserDefault(key: "isDarkMode", defaultValue: false, userDefaults: .standard)
    var isDarkMode: Bool

    @UserDefault(key: "userName", defaultValue: "Гость", userDefaults: .standard)
    var userName: String
}

// Изменение свойства автоматически сохраняется в UserDefaults
let settings = SettingsViewModel()
settings.isDarkMode = true // Значение сразу записывается в UserDefaults

Другие распространенные сценарии использования:

  • Валидация: @Email, @PositiveNumber
    @propertyWrapper struct Email {
    private var value = ""
    var wrappedValue: String {
        get { value }
        set { 
            // Проверка regex email
            if newValue.contains("@") { value = newValue }
        }
    }
    }
  • Трансформация: @Capitalized, @Trimmed (как в исходном примере).
  • Логирование: Запись в лог при каждом изменении значения.
  • Thread-safety: Обеспечение атомарного доступа к свойству (@Atomic).
  • В SwiftUI: Встроенные обертки @State, @Binding, @Published, @Environment — это property wrappers, которые управляют жизненным циклом и потоком данных в рамках фреймворка.

Дополнительная возможность: projectedValue Property wrapper может предоставлять дополнительное значение через свойство projectedValue, доступное с префиксом $.

@propertyWrapper struct Clamped<T: Comparable> {
    private var value: T
    let range: ClosedRange<T>

    var wrappedValue: T {
        get { value }
        set { value = min(max(range.lowerBound, newValue), range.upperBound) }
    }

    // projectedValue предоставляет саму обертку или ее часть
    var projectedValue: Clamped { self }

    init(wrappedValue: T, _ range: ClosedRange<T>) {
        self.range = range
        self.value = min(max(range.lowerBound, wrappedValue), range.upperBound)
    }
}

struct Game {
    @Clamped(0...100) var health: Int = 100
}

var game = Game()
game.health = 150 // Будет зажато до 100
game.health = -20 // Будет зажато до 0
print(game.$health.range) // Используем projectedValue через $ для доступа к диапазону: 0...100