Написать пост

Property Wrappers в Swift

Аватар Типичный программист

Углубляемся в property wrapper'ы — обертки над свойством, которые добавляют логику к этому свойству.

Рассказывает Александр, старший iOS-разработчик в Noveo

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

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

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

  1. Перед типом стоит атрибут @propertyWrapper,
  2. Тип обязан содержать переменную wrappedValue с уровнем доступа не ниже, чем у самого типа.

Тогда простейший пример будет выглядеть так:

			@propertyWrapper
struct Simplest {
    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(wrappedValue: "test") type: Simplest
_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 {
    private var _current: T
    private (set) var previousValues: [T] = []

    var wrappedValue: T {
        get { _current }
        set {
            previousValues.append(_current)
            _current = newValue
        }
    }

    var projectedValue: VarWithMemory {
        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 = VarWithMemory(wrappedValue: "")

public var value: String {
  get { _value.wrappedValue }
  set { _value.wrappedValue = newValue }
}

public var $value: VarWithMemory {
  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 с исходниками из статьи доступен здесь.

Следите за новыми постами
Следите за новыми постами по любимым темам
5К открытий5К показов