Если вы использовали 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 // 3Foundation предлагает метод 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 предоставляют авторам библиотек доступ к высокоуровневому поведению, ранее зарезервированному для языковых функций. Их потенциал для улучшения читаемости и уменьшения сложности кода огромен, и мы только поверхностно рассмотрели возможности этого инструмента.
Используете ли вы обёртки свойств в своих проектах? Пишите в комментариях!
lostmsu
Не понятно зачем ограничили применение нескольких обёрток. Очевидно же, что "красный маленький слон" надо парсить как "красный (маленький слон)". Выразительность сильно пострадала, имхо, вплоть до полной бесполезности.
mkll
Это поведение, отличное от принятого. Сравните:
и
Есть разница? Ее нет. А вы предлагаете, чтобы применительно к property wrappers (и только к ним) разница была.