Property Wrappers в Swift
Углубляемся в property wrapper'ы — обертки над свойством, которые добавляют логику к этому свойству.
6К открытий6К показов
Рассказывает Александр, старший iOS-разработчик в Noveo
Я бы не сказал, что Property Wrappers очень сложны для понимания, но стоит в них разобраться получше, т.к. есть и нюансы. Итак, что такое property wrapper? Из самого названия можно догадаться, что это обертка над свойством, которая добавляет логику к этому свойству.
Возможность добавлять к свойствам обертки в Swift добавили в рамках предложения SE-0258 Property Wrappers. В основном это было сделано для SwiftUI. Чтобы было проще работать с данными, добавили @State
, @Binding
, @ObservedObject
и т.д.
Перед тем как углубляться в более сложные примеры, давайте создадим простейшую обертку-пустышку, которая по сути ничего не делает, просто хранит значение. Исходя из SE-0258, чтобы создать свою обертку, должны быть выполнены 2 условия:
- Перед типом стоит атрибут
@propertyWrapper
, - Тип обязан содержать переменную
wrappedValue
с уровнем доступа не ниже, чем у самого типа.
Тогда простейший пример будет выглядеть так:
Попробуем применить нашу обертку:
В консоли будет выведено test.
Но если внимательно изучить proposal, то мы обнаружим, как внутри объекта раскрываются property wrapper’ы на самом деле:
За счет приватности снаружи мы не можем получить доступ к wrapper’у — print(simplest._value)
выдаст ошибку.
Но изнутри типа мы вполне можем получить доступ к самому wrapper’у напрямую:
Это выведет
что подтверждает, что _value
— реальная обертка, а value
== _value.wrappedValue
== String
.
Разобравшись с простейшим примером, попробуем создать что-то чуть более полезное, к примеру, обертку для целых чисел со следующей логикой: если присваивается отрицательное число — делаем его положительным, по сути обертка над функцией abs
:
В консоли будет
Логику мы поместили в set
для wrappedValue
; в совокупности с инициализатором, в котором мы присваиваем изначальное значение в свойство wrappedValue
, это позволяет нам получить нужное поведение как при инициализации переменной с оберткой, так и при дальнейшем ее изменении, в результате отрицательного числа не может быть в value
в принципе. Обращаю внимание: важно, чтобы в инициализаторе первым параметром шел параметр с именем wrappedValue
, это дает возможность swift’у под капотом позволять вот такие вот присваивания, когда мы в переменную, помеченную оберткой, можем присвоить значение того типа, который она содержит:
Если мы поменяем, к примеру, на
это уже не будет работать.
Стоит отметить, что т.к. по факту реализуют @propertyWrapper
самые обычные типы, мы можем параметризовать обертки.
Например, создадим обертку Uppercased
, которая так же принимает на вход число символов, которое необходимо конвертировать в upper case с начала строки.
В консоли будет
Еще я хотел бы обратить внимание на «магию»: этот пример не будет компилироваться, если в TestUppercased
мы уберем присваивание строки, т.е. под капотом
вызывает init(wrappedValue: String, count: Int)
, в качестве wrappedValue
как раз передается значение, которое мы присваиваем в value
.
Чтобы обойти это ограничение, придется инициализацию проводить в конструкторе:
Если вы успели поработать со SwiftUI, то, думаю, обратили внимание на переменные, предваренные знаком доллара $value
: их мы обычно передаем в дочернюю View, у которой переменная определена как @Binding
. Proposal поясняет, для чего это нужно. Вспомним, что происходит, если объявить переменную как PropertyWrapper
, — снаружи типа невозможно будет получить к ней доступ:
А что, если мы хотим, чтобы пользователи структуры TestSimplest
имели доступ к логике обертки ее свойства? Для этого надо в property wrapper определить свойство projectedValue
.
Вывод в лог:
Таким образом,
развернется во что-то вроде
Важно отметить, что тип 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()
.
Кстати, хотя обертки можно комбинировать, есть один нюанс. Комбинирование происходит по принципу матрешки, и, например, такой код:
выдаст в лог
а не ожидаемые
На вход в VarWithMemor
y приходит переменная не типа Int,
а типа Abs<Int>
. А если бы обертки были не Generic
, а принимали, допустим, только строки, то это даже бы не скомпилировалось. Красивого решения нет; как вариант, можно делать специализированные версии оберток, чтобы один тип принимал в конструкторе второй, а внутри уже работать со внутренним типом второго.
Подводя итоги
Какие достоинства у property wrapper’ов? Они позволяют спрятать кастомную логику за простым определением переменной, добавив @<Тип>
.
Какие минусы? С точки зрения практического применения они исходят из их главного плюса: сложность обертки скрыта от глаз, даже сам факт, что ты работаешь с оберткой, не очевиден, пока не посмотришь определение переменной. Поэтому я порекомендовал бы аккуратно использовать их в своем проекте.
Какие альтернативы?
- Для создании логики типа Observer — использовать
willSet/didSet
у свойств. - Для добавления логики модификации/места хранения — использовать
get/set
у свойств.
Playground с исходниками из статьи доступен здесь.
6К открытий6К показов