Property Wrappers в Swift
В рамках предложения SE-0258 Property Wrappers в Swift добавили возможность к свойствам добавлять обертки. В основном это было сделано для SwiftUI. Чтобы было проще работать с данными добавили @State, @Binding, @ObservedObject и т.д.
Я бы не сказал, что Property Wrappers очень сложны для понимания, но стоит в них разобраться получше, т.к. есть и нюансы. Итак, что такое property wrapper? Из самого названия можно догадаться, что это обертка над свойством, которая добавляет логику к этому свойству.
Перед тем как углубляться в более сложные примеры давайте создадим простейшую обертку-пустышку, которая по сути ничего не делает, просто хранит значение. Исходя из SE-0258, чтобы создать свою обертку необходимо
- чтобы перед типом стоял атрибут @propertyWrapper
- тип обязан содержать переменную wrappedValue с уровнем доступа не ниже, чем у самого типа
Итого простейший пример будет выглядеть так:
@propertyWrapper
struct Simplest<T> {
var wrappedValue: T
}
Попробуем применить нашу обертку:
struct TestSimplest {
@Simplest var value: String
}
let simplest = TestSimplest(value: "test")
print(simplest.value)
В консоли будет выведено: test
Но если внимательно изучить proposal, то мы обнаружим как внутри объекта раскрываются property wrapper'ы на самом деле
struct TestSimplest {
@Simplest var value: String
// будет развернуто в
private var _value: Simplest
var value: String { /* доступ через _value.wrappedValue */ }
}
За счет приватности снаружи мы не можем получить доступ к wrapper'у print(simplest._value) выдаст ошибку
Но изнутри типа мы вполне можем получить доступ к самому wrapper'у напрямую
extension TestSimplest {
func describe() {
print("value: \(value) type: \(type(of: value))")
print("_value: \(_value) type: \(type(of: _value))")
print("_value.wrappedValue: \(_value.wrappedValue) type: \(type(of: _value.wrappedValue))")
}
}
let simplest = TestSimplest(value: "test")
simplest.describe()
Это выведет
value: test type: String
_value: Simplest<String>(wrappedValue: "test") type: Simplest<String>
_value.wrappedValue: test type: String
что подтверждает, что _value - реальная обертка, а value == _value.wrappedValue == String
Разобравшись с простейшим примером попробуем создать что то чуть более полезное, к примеру обертку для целых чисел со следующей логикой - если присваивается отрицательное число - делаем его положительным, по сути обертка над функцией abs
@propertyWrapper
struct Abs {
private var value: Int = 0
var wrappedValue: Int {
get { value }
set {
value = abs(newValue)
}
}
init(wrappedValue: Int) {
self.wrappedValue = wrappedValue
}
}
struct TestAbs {
@Abs var value: Int = 0
}
var testAbs = TestAbs(value: -10)
print(testAbs.value)
testAbs.value = 20
print(testAbs.value)
testAbs.value = -30
print(testAbs.value)
В консоли будет
10
20
30
Логику мы поместили в set для wrappedValue, в совокупности с инициализатором в котором мы присваиваем изначальное значение в свойство wrappedValue это позволяет нам получить нужное поведение как при инициализации переменной с оберткой, так и при дальнейшем ее изменении, в результате отрицательного числа не может быть в value в принципе. Обращаю внимание, что важно, чтобы в инициализаторе первым параметром шел параметр с именем wrappedValue, это позволяет swift'у под капотом позволять вот такие вот присваивания, когда мы в переменную помеченную оберткой можем присвоить значение того типа, который она содержит
@Abs var value: Int = 0
если мы поменяем к примеру на
init(custom: Int) {
self.wrappedValue = custom
}
это уже не будет работать
Стоит отметить, что т.к. по факту реализуют @propertyWrapper самые обычные типы, мы можем параметризовать обертки.
К примеру создадим обертку Uppercased, которая принимает на вход так же число символов, которое необходимо конвертировать в upper case с начала строки.
@propertyWrapper
struct Uppercased {
private var count: Int
private var value: String = ""
var wrappedValue: String {
get { value }
set {
let uppercased = String(newValue.prefix(count)).uppercased()
value = uppercased
guard let from = newValue.index(newValue.startIndex, offsetBy: count, limitedBy: newValue.endIndex) else { return }
value += newValue.suffix(from: from)
}
}
init(wrappedValue: String, count: Int) {
self.count = count
self.wrappedValue = wrappedValue
}
}
struct TestUppercased {
@Uppercased(count: 5) var value: String = ""
}
var testAbs = TestUppercased(value: "hello world")
print(testAbs.value)
testAbs.value = "another example"
print(testAbs.value)
testAbs.value = "abc"
print(testAbs.value)
В консоли будет
HELLO world
ANOTHer example
ABC
Так же хотел бы обратить внимание на "магию", этот пример не будет компилироваться, если в TestUppercased мы уберем присваивание строки, т.е. под капотом
@Uppercased(count: 5) var value: String = ""
вызывает init(wrappedValue: String, count: Int)
, в качестве wrappedValue как раз передается значение которое мы присваиваем в value
Чтобы обойти это ограничение придется инициализацию проводить в конструкторе,
struct TestUppercased2 {
@Uppercased var value: String
init(count: Int, example: String) {
_value = Uppercased(wrappedValue: example, count: count)
}
}
var testAbs2 = TestUppercased2(count: 3, example: "super puper")
print(testAbs2.value)
Если вы успели поработать со SwiftUI то думаю обратили внимание на переменные предваренные знаком доллара $value, их мы обычно передаем в дочернюю View, у которой переменная определена как @Binding. Proposal поясняет, для чего это нужно. Вспомним, что происходит если объявить переменную как PropertyWrapper, - снаружи типа невозможно будет получить к ней доступ
struct TestSimplest {
@Simplest var value: String
// будет развернуто в
private var _value: Simplest
var value: String { /* доступ через _value.wrappedValue */ }
}
А что если мы хотим, чтобы пользователи структуры TestSimplest имели доступ к логике обертки ее свойства? Для этого надо в property wrapper определить свойство projectedValue.
@propertyWrapper
struct VarWithMemory<T> {
private var _current: T
private (set) var previousValues: [T] = []
var wrappedValue: T {
get { _current }
set {
previousValues.append(_current)
_current = newValue
}
}
var projectedValue: VarWithMemory<T> {
get { self }
set { self = newValue }
}
init(wrappedValue: T) {
self._current = wrappedValue
}
mutating func clear() {
previousValues.removeAll()
}
}
struct TestVarWithMemory {
@VarWithMemory var value: String = ""
}
var test = TestVarWithMemory(value: "initial")
print("1. current value: \(test.value)")
test.value = "second"
print("2. current value: \(test.value)")
test.value = "third"
print("3. current value: \(test.value)")
// value: String, won't work
// print(test.value.previousValues)
print("4. history: \(test.$value.previousValues)")
print("5. clear")
test.$value.clear()
print("6. current value: \(test.value)")
print("7. history: \(test.$value.previousValues)")
Вывод в лог:
1. current value: initial
2. current value: second
3. current value: third
4. history: ["initial", "second"]
5. clear
6. current value: third
7. history: []
Таким образом:
@VarWithMemory var value: String = ""
развернется во что то вроде
private var _value: VarWithMemory<String> = VarWithMemory(wrappedValue: "")
public var value: String {
get { _value.wrappedValue }
set { _value.wrappedValue = newValue }
}
public var $value: VarWithMemory<String> {
get { _value.projectedValue }
set { _value.projectedValue = newValue }
}
Важно отметит, что тип projectedValue может быть любой и не соответствовать типу в котором определена переменная. Это и позволило для @State при получении projectedValue через $ - получать на выходе не State, а Binding
Какие основные очевидные вариант применения можно придумать?
- когда работа со значением на самом деле проксируется и фактически переменная хранится в базе данных/User Defaults
- когда мы хотим как то преобразовать значение при присваивании, примером этого может быть приведенные выше Abs, Uppercased, ну или из proposal'а Clamping для обрезания значение по min/max границам
- реализация Copy on Write
ну и т.д.
Надо отметить, что есть определенные ограничения применения property wrapper'ов
- в протоколе нельзя указать, что это свойство должно быть обьявлено с таким то Property wrapper'ом
- свойство с property wrapper'ом нельзя использовать в extension и enum
- свойство с property wrapper'ом нельзя переопределить в наследнике класса
- свойство с property wrapper'ом не может быть lazy, @NSCopying, @NSManaged, weak, или unowned.
- свойство с property wrapper'ом не может иметь кастомный get/set
- уровень доступа wrappedValue, и уровни доступа для всего нижеперечисленного (если присутствуют) должны быть идентичны уровню доступа типа в котором они определены: projectedValue, init(wrappedValue:), init()
Кстати, хотя обертки можно комбинировать, - есть один нюанс. Комбинирование происходит по принципу матрешки, и к примеру такой код:
struct TestCombined {
@VarWithMemory @Abs var value: Int = 0
}
var test = TestCombined()
print(test.value)
test.value = -1
test.value = -2
test.value = -3
print(test.value)
print(test.$value.previousValues)
выдаст в лог
0
3
[__lldb_expr_173.Abs(_value: 0), __lldb_expr_173.Abs(_value: 1), __lldb_expr_173.Abs(_value: 2)]
а не ожидаемые
0
3
[0, 1, 2]
На вход в VarWithMemory приходит переменна не типа Int, а типа Abs<Int> А если бы обертки были не Generic, а принимали к примеру только строки, то это даже бы не скомпилировалось. Красивого решения нет, можно к примеру делать специализированные версии оберток, чтобы один тип принимал в конструкторе второй, а внутри уже работать со внутренним типом второго.
Подводя итоги.
Какие достоинства у property wrapper'ов? Они позволяют спрятать кастомную логику за простым определением переменной добавив @<Тип>
Какие минусы?
С точки зрения практического применения они исходят из их главного плюса, сложность обертки скрыта от глаз, даже сам факт, что ты работаешь с оберткой не очевиден, пока не посмотришь определение переменной. Поэтому я порекомендовал бы аккуратно использовать их в своем проекте.
Какие альтернативы?
- для создании логики типа Observer - использовать willSet/didSet у свойств
- для добавления логики модификации/места хранения - использовать get/set у свойств
Playground с исходниками из статьи доступен здесь