Каморка сурового программиста, дубль 2

Property Wrappers в Swift

В рамках предложения SE-0258 Property Wrappers в Swift добавили возможность к свойствам добавлять обертки. В основном это было сделано для SwiftUI. Чтобы было проще работать с данными добавили @State, @Binding, @ObservedObject и т.д.

Я бы не сказал, что Property Wrappers очень сложны для понимания, но стоит в них разобраться получше, т.к. есть и нюансы. Итак, что такое property wrapper? Из самого названия можно догадаться, что это обертка над свойством, которая добавляет логику к этому свойству.

Перед тем как углубляться в более сложные примеры давайте создадим простейшую обертку-пустышку, которая по сути ничего не делает, просто хранит значение. Исходя из SE-0258, чтобы создать свою обертку необходимо

  1. чтобы перед типом стоял атрибут @propertyWrapper
  2. тип обязан содержать переменную 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 с исходниками из статьи доступен здесь

Tagged with: