Swift Property Wrappers

в 12:13, , рубрики: funcorp, iOS разработка, swift, wrapper, Блог компании FunCorp, разработка мобильных приложений, разработка под iOS

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

Swift Property Wrappers - 1

Автор перевода: Евгений Заволжанский, разработчик 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 предоставляют авторам библиотек доступ к высокоуровневому поведению, ранее зарезервированному для языковых функций. Их потенциал для улучшения читаемости и уменьшения сложности кода огромен, и мы только поверхностно рассмотрели возможности этого инструмента.

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

Автор: gayka_m8

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js