$ sudo teach IT

Зачем нужны property wrapper-ы

Представьте, что вы пишете структуру Speaker с громкостью и в сеттере свойства volume нужно всегда обрезать значение отрезком от 0 до 1: пользователь может передать 1.5 или -0.3, а хранить нужно только допустимое число. Ту же самую логику вы бы повторили для яркости экрана, для процента скидки, для любого другого ограниченного значения — везде одинаковая проверка границ внутри set. Копировать её по всем свойствам неудобно и легко забыть хотя бы в одном месте.

Property wrapper решает именно эту задачу: логику хранения и проверки значения вы пишете один раз в отдельном типе, а потом применяете её к любому свойству одной строкой — атрибутом перед объявлением.

Как это называется в документации Apple

Тип, помеченный атрибутом @propertyWrapper, называется property wrapper («обёртка свойства»). Внутри него обязательно должно быть свойство wrappedValue — именно оно хранит и отдаёт настоящее значение. Когда вы пишете @ИмяОбёртки var x = 5, компилятор превращает это в создание экземпляра обёртки с этим значением, а все обращения к x дальше по коду становятся обращениями к wrappedValue этой обёртки.

Простая обёртка

@propertyWrapper
struct Clamped01 {
    private var value: Double

    var wrappedValue: Double {
        get { value }
        set { value = min(1, max(0, newValue)) }
    }

    init(wrappedValue: Double) {
        self.value = min(1, max(0, wrappedValue))
    }
}

Здесь два места ограничивают значение: инициализатор — на случай, если стартовое значение уже выходит за границы, и сеттер вычисляемого свойства wrappedValue — на каждое следующее присваивание. Само хранимое значение спрятано в приватном value, наружу видна только уже проверенная версия.

Применение обёртки к свойству

struct Speaker {
    @Clamped01 var volume: Double = 0
}

var speaker = Speaker()
speaker.volume = 1.5
print(speaker.volume) // 1.0

Атрибут @Clamped01 перед var volume говорит: значением этого свойства управляет обёртка Clamped01. Присваивание 0 при объявлении уходит в init(wrappedValue:), а каждое следующее speaker.volume = ... — в сеттер wrappedValue. Снаружи структуры Speaker никто не видит обёртку: код, использующий speaker.volume, выглядит как обращение к обычному свойству типа Double.

projectedValue: доступ через $

Обёртка может хранить не только основное значение, но и дополнительную информацию — например, пришлось ли значение обрезать. Для этого у неё бывает второе свойство, projectedValue, к которому обращаются через знак доллара перед именем свойства.

@propertyWrapper
struct ClampedReport {
    private var value: Double
    private(set) var wasClamped = false

    var wrappedValue: Double {
        get { value }
        set {
            let limited = min(1, max(0, newValue))
            wasClamped = limited != newValue
            value = limited
        }
    }

    var projectedValue: Bool { wasClamped }

    init(wrappedValue: Double) {
        self.value = min(1, max(0, wrappedValue))
        self.wasClamped = wrappedValue != self.value
    }
}

Если бы у Speaker было свойство @ClampedReport var volume: Double, то speaker.volume возвращало бы само число, а speaker.$volume — Bool: пришлось ли его обрезать. Именно так устроены знакомые по чужому коду на SwiftUI @State и @Published: @State — это обёртка над значением экрана, которая через wrappedValue отдаёт само значение, а через projectedValue ($name) — привязку для передачи в другие компоненты; @Published устроен похоже, но публикует изменения значения наружу. В этом курсе SwiftUI не изучается, но теперь строчки вида @State private var и $someValue из чужих примеров перестанут быть магией: это тот же самый механизм property wrapper.

Частые ошибки

  • Забыть добавить init(wrappedValue:), если у обёртки есть только другие параметры инициализации — тогда запись @Wrapper var x = 5 не скомпилируется: компилятору нечем передать стартовое значение.
  • Ограничивать значение только в сеттере wrappedValue, забыв про инициализатор — тогда стартовое значение, переданное при объявлении свойства, проходит без проверки.
  • Путать wrappedValue и саму обёртку целиком: обращение speaker.volume — это всегда wrappedValue, а не экземпляр структуры-обёртки.
  • Считать @propertyWrapper чем-то из SwiftUI. Это обычная возможность языка Swift, SwiftUI просто активно её использует для своих задач.

Резюме

  • Property wrapper — тип с атрибутом @propertyWrapper и обязательным свойством wrappedValue, который переносит логику хранения и проверки значения в одно место.
  • Атрибут @ИмяОбёртки перед свойством подключает эту логику одной строкой.
  • Стартовое значение при объявлении свойства уходит в init(wrappedValue:) обёртки.
  • projectedValue — необязательное второе значение обёртки, доступное через $имяСвойства.
  • На этом механизме построены @State и @Published в SwiftUI.

Проверьте себя

3 вопроса

Ограничитель громкости

Реализуйте property wrapper Clamped01, который всегда хранит число типа Double в границах от 0 до 1 включительно: значения меньше 0 он должен заменять на 0, значения больше 1 — на 1. Обёртка уже применена к свойству volume структуры Speaker. Допишите функцию speakerVolume(_:) так, чтобы она создавала Speaker, присваивала свойству volume переданное число и возвращала итоговое значение после ограничения.