company_banner

Swift Property Wrappers

Автор оригинала: Mattt
  • Перевод

Если вы использовали SwiftUI, то наверняка обращали внимание на такие ключевые слова, как @ObservedObject, @EnvironmentObject, @FetchRequest и так далее. Property Wrappers (далее «обёртки свойств») — новая возможность языка Swift 5.1. Эта статья поможет вам понять, откуда же взялись все конструкции с @, как использовать их в SwiftUI и в своих проектах.



Автор перевода: Евгений Заволожанский, разработчик FunCorp.


Прим.пер.: К моменту подготовки перевода часть исходного кода оригинальной статьи потеряла свою актуальность из-за изменений в языке, поэтому некоторые примеры кода намеренно заменены.


Обёртки свойств впервые были представлены на форумах Swift ещё в марте 2019 года, за несколько месяцев до объявления SwiftUI. В своём первоначальном предложении Дуглас Грегор ( Douglas Gregor), член команды Swift Core, описал эту конструкцию (тогда она называлась property delegates) как «доступное пользователю обобщение функциональности, в настоящее время предоставляемой такой языковой конструкцией, как, например, lazy».


Если свойство объявлено с ключевым словом lazy, это значит, что оно будет инициализировано при первом обращении к нему. Например, отложенную инициализацию свойства можно было бы реализовать с помощью закрытого свойства, доступ к которому осуществляется через вычисляемое свойство. Но с помощью ключевого слова lazy это сделать гораздо легче.


struct Structure {
    // Отложенная инициализация свойства с помощью lazy
    lazy var deferred = …

    // Аналогичная реализация с помощью закрытого и вычисляемого свойства
    private var _deferred: Type?
    var deferred: Type {
        get {
            if let value = _deferred { return value }
            let initialValue = …
            _deferred = initialValue
            return initialValue
        }

        set {
            _deferred = newValue
        }
    }
}

В SE-0258: Property Wrapper отлично объясняется дизайн и реализация обёрток свойств. Поэтому, вместо того чтобы пытаться улучшить описание в официальной документации, рассмотрим несколько примеров, которые можно реализовать с помощью обёрток свойств:


  • ограничение значений свойств;
  • преобразование значений при изменении свойств;
  • изменение семантики равенства и сравнения свойств;
  • логирование доступа к свойству. 

Ограничение значений свойств


SE-0258: Property Wrapper даёт несколько практических примеров, включая @Clamping, @Copying, @Atomic, @ThreadSpecific, @Box, @UserDefault. Рассмотрим обёртку @Clamping, которая позволяет ограничить максимальное или минимальное значение свойства.


@propertyWrapper
struct Clamping<Value: Comparable> {
    var value: Value
    let range: ClosedRange<Value>

    init(initialValue value: Value, _ range: ClosedRange<Value>) {
        precondition(range.contains(value))
        self.value = value
        self.range = range
    }

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

@Clamping можно использовать, например, для моделирования кислотности раствора, величина которой может принимать значение от 0 до 14.


struct Solution {
    @Clamping(0...14) var pH: Double = 7.0
}

let carbonicAcid = Solution(pH: 4.68)

Попытка установить значение pH, выходящее за диапазон от (0...14), приведёт к тому, что свойство примет значение, ближайшее к минимуму или максимуму интервала.


let superDuperAcid = Solution(pH: -1)
superDuperAcid.pH // 0

Обёртки свойств могут использоваться при реализации других обёрток свойств. Например, обёртка @UnitInterval ограничивает значение свойства интервалом (0...1), используя @Clamping(0...1):


@propertyWrapper
struct UnitInterval<Value: FloatingPoint> {
    @Clamping(0...1)
    var wrappedValue: Value = .zero

    init(initialValue value: Value) {
        self.wrappedValue = value
    }
}

Похожие идеи


  • @Positive / @NonNegative указывает, что значение может быть либо положительным, либо отрицательным числом.
  • @NonZero указывает, что значение свойства не может быть равно 0.
  • @Validated или @Whitelisted / @Blacklisted ограничивает значение свойства определёнными значениями.

Преобразование значений при изменении свойств


Валидация значений текстовых полей — постоянная головная боль разработчиков приложений. Существует очень много вещей, которые нужно отслеживать: от банальностей типа кодировки до злонамеренных попыток ввести код через текстовое поле. Рассмотрим применение обёртки свойства для удаления пробелов, которые ввёл пользователь в начале и в конце строки.


import Foundation

let url = URL(string: " https://habrahabr.ru") // nil

let date = ISO8601DateFormatter().date(from: " 2019-06-24") // nil

let words = " Hello, world!".components(separatedBy: .whitespaces)
words.count // 3

Foundation предлагает метод trimmingCharacters(in:), с помощью которого можно удалить пробелы в начале и в конце строки. Можно вызывать этот метод всегда, когда нужно гарантировать правильность ввода, но это не очень удобно. Для этого можно использовать обёртку свойства.


import Foundation

@propertyWrapper
struct Trimmed {
    private(set) var value: String = ""

    var wrappedValue: String {
        get { return value }
        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }

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

struct Post {
    @Trimmed var title: String
    @Trimmed var body: String
}

let quine = Post(title: "  Swift Property Wrappers  ", body: "…")
quine.title // "Swift Property Wrappers" — без пробелов в начале и в конце

quine.title = "      @propertyWrapper     " // "@propertyWrapper"

Похожие идеи


  • @Transformed  применяет ICU-преобразование к введённой строке.
  • @Rounded / @Truncated округляет или урезает значение строки.

Изменение семантики равенства и сравнения свойств


В Swift две строки равны, если они канонично эквивалентны, т.е. содержат одинаковые символы. Но допустим, мы хотим, чтобы строковые свойства были равны без учёта регистра символов, которые они содержат.


@CaseInsensitive реализует оболочку для свойств, имеющих тип String или SubString.


import Foundation

@propertyWrapper
struct CaseInsensitive<Value: StringProtocol> {
    var wrappedValue: Value
}

extension CaseInsensitive: Comparable {
    private func compare(_ other: CaseInsensitive) -> ComparisonResult {
        wrappedValue.caseInsensitiveCompare(other.wrappedValue)
    }

    static func == (lhs: CaseInsensitive, rhs: CaseInsensitive) -> Bool {
        lhs.compare(rhs) == .orderedSame
    }

    static func < (lhs: CaseInsensitive, rhs: CaseInsensitive) -> Bool {
        lhs.compare(rhs) == .orderedAscending
    }

    static func > (lhs: CaseInsensitive, rhs: CaseInsensitive) -> Bool {
        lhs.compare(rhs) == .orderedDescending
    }
}

let hello: String = "hello"
let HELLO: String = "HELLO"

hello == HELLO // false
CaseInsensitive(wrappedValue: hello) == CaseInsensitive(wrappedValue: HELLO) // true

Похожие идеи


  • @Approximate для приблизительного сравнения свойств, имеющих тип Double или Float.
  • @Ranked для свойств, значения которых имеют порядок (например, ранг игральных карт).

Логирование доступа к свойству


@Versioned позволит перехватывать присвоенные значения и запоминать, когда они были установлены.


import Foundation

@propertyWrapper
struct Versioned<Value> {
    private var value: Value
    private(set) var timestampedValues: [(Date, Value)] = []

    var wrappedValue: Value {
        get { value }

        set {
            defer { timestampedValues.append((Date(), value)) }
            value = newValue
        }
    }

    init(initialValue value: Value) {
        self.wrappedValue = value
    }
}

Класс ExpenseReport позволяет сохранить временные метки состояний обработки отчёта о расходах.


class ExpenseReport {
    enum State { case submitted, received, approved, denied }

    @Versioned var state: State = .submitted
}

Но пример выше демонстрирует серьёзное ограничение в текущей реализации обёрток свойств, которое вытекает из ограничения Swift: свойства не могут генерировать исключения. Если бы мы хотели добавить в @Versioned ограничение для предотвращения изменения значения на .approved после того, как оно приняло значения .denied, то наилучший вариант — fatalError(), который плохо подходит для реальных приложений.


class ExpenseReport {
    @Versioned var state: State = .submitted {
        willSet {
            if newValue == .approved,
                $state.timestampedValues.map { $0.1 }.contains(.denied)
            {
                fatalError("Ошибка")
            }
        }
    }
}

var tripExpenses = ExpenseReport()
tripExpenses.state = .denied
tripExpenses.state = .approved // Fatal error: «ошибка» и краш приложения.

Похожие идеи


  • @Audited для логирования доступа к свойству.
  • @UserDefault для инкапсулирования механизма чтения и сохранения данных в UserDefaults.

Ограничения


Свойства не могут генерировать исключения


Как уже было сказано, обёртки свойств могут использовать лишь несколько методов обработки недопустимых значений:


  • игнорировать их;
  • завершить работу приложения при помощи fatalError().

Свойства, имеющие обёртку, не могут быть помечены атрибутом `typealias`


Пример @UnitInterval выше, свойство которого ограничено интервалом (0...1), не может быть объявлен как


typealias UnitInterval = Clamping(0...1)

Ограничение на использование композиции из нескольких обёрток свойств


Композиция обёрток свойств — не коммутативная операция: на поведение будет влиять порядок объявления. Рассмотрим пример, в котором свойство slug, представляющее собой url поста в блоге, нормализуется. В этом случае результат нормализации будет различаться в зависимости от того, когда пробелы будут заменены тире, до или после удаления пробелов. Поэтому на данный момент композиция из нескольких обёрток свойств не поддерживается.


@propertyWrapper
struct Dasherized {
    private(set) var value: String = ""

    var wrappedValue: String {
        get { value }
        set { value = newValue.replacingOccurrences(of: " ", with: "-") }
    }

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

struct Post {
    …
    @Dasherized @Trimmed var slug: String // error: multiple property wrappers are not supported
}

Однако это ограничение можно обойти, если использовать вложенные обёртки свойств.


@propertyWrapper
struct TrimmedAndDasherized {
    @Dasherized
    private(set) var value: String = ""

    var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }

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

struct Post {
    …
    @TrimmedAndDasherized var slug: String
}

Другие ограничения обёрток свойств


  • Нельзя использовать внутри протокола.
  • Экземпляр свойства с обёрткой не может быть объявлен в enum.
  • Свойство с обёрткой, объявленное внутри класса, не может быть переопределено другим свойством.
  • Свойство с обёрткой не может быть lazy, @NSCopying, @NSManaged, weak или unowned.
  • Свойство с обёрткой  должно быть единственным в рамках своего определения (т.е. нельзя @Lazy var (x, y) = /* ... */ ).
  • У свойства с обёрткой  нельзя определить getter и setter.
  • Типы у свойства wrappedValue и у переменной wrappedValue в init(wrappedValue:) должны иметь тот же уровень доступа, что и тип обёртки свойства.
  • Тип свойство projectedValue должен иметь тот же уровень доступа, что и тип обёртки свойства.
  • init() должен иметь тот же уровень доступа, что и тип обёртки свойства.

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


Используете ли вы обёртки свойств в своих проектах? Пишите в комментариях!

FunCorp
Разработка развлекательных сервисов

Похожие публикации

Комментарии 2

    0

    Не понятно зачем ограничили применение нескольких обёрток. Очевидно же, что "красный маленький слон" надо парсить как "красный (маленький слон)". Выразительность сильно пострадала, имхо, вплоть до полной бесполезности.

      +1
      Очевидно же, что «красный маленький слон» надо парсить как «красный (маленький слон)»

      Это поведение, отличное от принятого. Сравните:

      lazy private var

      и

      private lazy var

      Есть разница? Ее нет. А вы предлагаете, чтобы применительно к property wrappers (и только к ним) разница была.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое