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