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