Senior iOS-разработчик Noveo Александр продолжает делиться хитростями работы со Swift. В этом посте присматриваемся к Property Wrappers.
Я бы не сказал, что Property Wrappers очень сложны для понимания, но стоит в них разобраться получше, т.к. есть и нюансы. Итак, что такое property wrapper? Из самого названия можно догадаться, что это обертка над свойством, которая добавляет логику к этому свойству.
Возможность добавлять к свойствам обертки в Swift добавили в рамках предложения SE-0258 Property Wrappers. В основном это было сделано для SwiftUI. Чтобы было проще работать с данными, добавили @State, @Binding, @ObservedObject и т.д.
Перед тем как углубляться в более сложные примеры, давайте создадим простейшую обертку-пустышку, которая по сути ничего не делает, просто хранит значение. Исходя из SE-0258, чтобы создать свою обертку, должны быть выполнены 2 условия:
- Перед типом стоит атрибут @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 с исходниками из статьи доступен здесь.
Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: