Swift Property Wrappers

от автора

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

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

ссылка на оригинал статьи https://habr.com/ru/company/funcorp/blog/485008/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *