Как использовать обертки для свойств Swift

Опубликовано: 2022-03-11

Проще говоря, оболочка свойства — это универсальная структура, которая инкапсулирует доступ для чтения и записи к свойству и добавляет к нему дополнительное поведение. Мы используем его, если нам нужно ограничить доступные значения свойств, добавить дополнительную логику к доступу для чтения/записи (например, с использованием баз данных или пользовательских значений по умолчанию) или добавить некоторые дополнительные методы.

Обертки свойств в Swift 5.1

Эта статья посвящена новому подходу Swift 5.1 к переносу свойств, который представляет новый, более чистый синтаксис.

Старый подход

Представьте, что вы разрабатываете приложение и у вас есть объект, содержащий данные профиля пользователя.

 struct Account { var firstName: String var lastName: String var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email = "[email protected]" print(account.email)

Вы хотите добавить подтверждение по электронной почте — если адрес электронной почты пользователя недействителен, свойство email должно иметь значение nil . Было бы неплохо использовать оболочку свойств для инкапсуляции этой логики.

 struct Email<Value: StringProtocol> { private var _value: Value? init(initialValue value: Value?) { _value = value } var value: Value? { get { return validate(email: _value) ? _value : nil } set { _value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64}" let pred = NSPredicate(format: "SELF MATCHES %@", regex) return pred.evaluate(with: email) } }

Мы можем использовать эту обертку в структуре учетной записи:

 struct Account { var firstName: String var lastName: String var email: Email<String> }

Теперь мы уверены, что свойство электронной почты может содержать только действительный адрес электронной почты.

Все выглядит хорошо, кроме синтаксиса.

 let account = Account(firstName: "Test", lastName: "Test", email: Email(initialValue: "[email protected]")) account.email.value = "[email protected]" print(account.email.value)

С оболочкой свойств синтаксис для инициализации, чтения и записи таких свойств становится более сложным. Итак, можно ли избежать этого осложнения и использовать обертки свойств без изменения синтаксиса? Со Swift 5.1 ответ положительный.

Новый путь: аннотация @propertyWrapper

Swift 5.1 предоставляет более элегантное решение для создания оболочек свойств, где допускается пометка оболочки свойства аннотацией @propertyWrapper . Такие обертки имеют более компактный синтаксис по сравнению с традиционными, что приводит к более компактному и понятному коду. У аннотации @propertyWrapper есть только одно требование: ваш объект-оболочка должен содержать нестатическое свойство, называемое wrappedValue .

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

Чтобы определить такое обернутое свойство в коде, нам нужно использовать новый синтаксис.

 @Email var email: String?

Итак, мы отметили свойство аннотацией @ . Тип свойства должен соответствовать типу оболочки `wrappedValue`. Теперь с этим свойством можно работать как с обычным.

 email = "[email protected]" print(email) // [email protected] email = "invalid" print(email) // nil

Отлично, теперь это выглядит лучше, чем со старым подходом. Но у нашей реализации оболочки есть один недостаток: она не допускает начального значения для обернутого значения.

 @Email var email: String? = "[email protected]" //compilation error.

Чтобы решить эту проблему, нам нужно добавить в оболочку следующий инициализатор:

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

Вот и все.

 @Email var email: String? = "[email protected]" print(email) // [email protected] @Email var email: String? = "invalid" print(email) // nil

Окончательный код обертки ниже:

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? init(wrappedValue value: Value?) { self.value = value } var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

Настраиваемые обертки

Возьмем другой пример. Вы пишете игру, и у вас есть свойство, в котором хранятся результаты пользователей. Требование состоит в том, чтобы это значение было больше или равно 0 и меньше или равно 100. Этого можно добиться с помощью оболочки свойства.

 @propertyWrapper struct Scores { private let minValue = 0 private let maxValue = 100 private var value: Int init(wrappedValue value: Int) { self.value = value } var wrappedValue: Int { get { return max(min(value, maxValue), minValue) } set { value = newValue } } } @Scores var scores: Int = 0

Этот код работает, но не кажется универсальным. Вы не можете повторно использовать его с другими ограничениями (не 0 и 100). Более того, он может ограничивать только целочисленные значения. Было бы лучше иметь одну настраиваемую оболочку, которая может ограничивать любой тип, соответствующий протоколу Comparable. Чтобы сделать нашу оболочку настраиваемой, нам нужно добавить все параметры конфигурации через инициализатор. Если инициализатор содержит атрибут wrappedValue (начальное значение нашего свойства), он должен быть первым параметром.

 @propertyWrapper struct Constrained<Value: Comparable> { private var range: ClosedRange<Value> private var value: Value init(wrappedValue value: Value, _ range: ClosedRange<Value>) { self.value = value self.range = range } var wrappedValue: Value { get { return max(min(value, range.upperBound), range.lowerBound) } set { value = newValue } } }

Чтобы инициализировать обернутое свойство, мы определяем все атрибуты конфигурации в скобках после аннотации.

 @Constrained(0...100) var scores: Int = 0

Количество атрибутов конфигурации не ограничено. Вам нужно определить их в круглых скобках в том же порядке, что и в инициализаторе.

Получение доступа к самой обертке

Если вам нужен доступ к самой оболочке (а не к обернутому значению), вам нужно добавить подчеркивание перед именем свойства. Например, возьмем нашу структуру учетной записи.

 struct Account { var firstName: String var lastName: String @Email var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email // Wrapped value (String) account._email // Wrapper(Email<String>)

Нам нужен доступ к самой обертке, чтобы использовать дополнительный функционал, который мы в нее добавили. Например, мы хотим, чтобы структура учетной записи соответствовала протоколу Equatable. Две учетные записи равны, если их адреса электронной почты совпадают, и адреса электронной почты не должны учитывать регистр.

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs.email?.lowercased() == rhs.email?.lowercased() } }

Это работает, но это не лучшее решение, потому что мы должны помнить о добавлении метода нижнего регистра() везде, где мы сравниваем электронные письма. Лучшим способом было бы сделать структуру Email равной:

 extension Email: Equatable { static func ==(lhs: Email, rhs: Email) -> Bool { return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased() } }

и сравните обертки вместо обернутых значений:

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs._email == rhs._email } }

Прогнозируемая стоимость

Аннотация @propertyWrapper предоставляет еще один синтаксический сахар — проецируемое значение. Это свойство может иметь любой тип, который вы хотите. Чтобы получить доступ к этому свойству, вам нужно добавить префикс $ к имени свойства. Чтобы объяснить, как это работает, мы используем пример из фреймворка Combine.

Оболочка свойства @Published создает издателя для свойства и возвращает его как прогнозируемое значение.

 @Published var message: String print(message) // Print the wrapped value $message.sink { print($0) } // Subscribe to the publisher

Как видите, мы используем сообщение для доступа к обернутому свойству и $message для доступа к издателю. Что вы должны сделать, чтобы добавить прогнозируемое значение в вашу оболочку? Ничего особенного, просто заявите об этом.

 @propertyWrapper struct Published<Value> { private let subject = PassthroughSubject<Value, Never>() var wrappedValue: Value { didSet { subject.send(wrappedValue) } } var projectedValue: AnyPublisher<Value, Never> { subject.eraseToAnyPublisher() } }

Как отмечалось ранее, свойство projectedValue может иметь любой тип в зависимости от ваших потребностей.

Ограничения

Синтаксис новых оберток свойств выглядит хорошо, но он также содержит несколько ограничений, основными из которых являются:

  1. Они не могут участвовать в обработке ошибок. Обернутое значение является свойством (а не методом), и мы не можем пометить геттер или сеттер как throws . Например, в нашем примере с Email невозможно выдать ошибку, если пользователь попытается установить недопустимый адрес электронной почты. Мы можем вернуть nil или завершить работу приложения с помощью fatalError() , что в некоторых случаях может быть неприемлемо.
  2. Применение нескольких оболочек к свойству не допускается. Например, было бы лучше иметь отдельную оболочку @CaseInsensitive и объединить ее с оболочкой @Email вместо того, чтобы делать оболочку @Email нечувствительной к регистру. Но такие конструкции запрещены и приводят к ошибкам компиляции.
 @CaseInsensitive @Email var email: String?

В качестве обходного пути для этого конкретного случая мы можем наследовать оболочку Email от оболочки CaseInsensitive . Однако у наследования тоже есть ограничения — только классы поддерживают наследование, и разрешен только один базовый класс.

Заключение

Аннотации @propertyWrapper упрощают синтаксис оберток свойств, и мы можем работать с обернутыми свойствами так же, как и с обычными. Это делает ваш код как разработчика Swift более компактным и понятным. В то же время у него есть несколько ограничений, которые мы должны учитывать. Я надеюсь, что некоторые из них будут исправлены в будущих версиях Swift.

Если вы хотите узнать больше о свойствах Swift, ознакомьтесь с официальной документацией.