Часто бывает так, что нам нужно скопировать объект,
изменив некоторые его свойства, но сохранив остальные неизменными.
Для этой задачи существует функция
copy()
.
Это отрывок описания метода copy()
из
документации Kotlin. На нашем родном языке Swift это означает
примерно такую возможность:
struct User { let id: Int let name: String let age: Int}let steve = User(id: 1, name: "Steve", age: 21)// Копируем экземпляр, изменив свойства `name` и `age`let steveJobs = steve.changing { newUser in newUser.name = "Steve Jobs" newUser.age = 41}
Выглядит вкусно, не так ли?
Увы, в Swift отсутствует подобный функционал "из коробки". Это небольшое руководство поможет реализовать его самостоятельно.
В чемпроблема
Почему бы просто не делать свойства изменяемыми, объявляя их
ключевым словом var
вместо let
?
struct User { let id: Int var name: String var age: Int}let steve = User(id: 1, name: "Steve", age: 21)...var steveJobs = stevesteveJobs.name = "Steve Jobs"steveJobs.age = 41
Тут есть несколько проблем:
- Изменение таких полей будет невозможным, если не объявить мутабельным и новый экземпляр структуры, а это лишает гарантии, что он не модифицируется где-то еще.
- Сложнее сделать изменения "атомарными". К примеру, в случае
наблюдаемых свойств блоки
willSet
иdidSet
вызываются при изменении каждого поля. - Субъективно, но такой код нельзя назвать лаконичным и изящным.
Да, есть еще один вариант создавать новый экземпляр, передавая в инициализатор структуры полный набор его параметров:
// Создаем новый экземпляр, изменяя свойство `name`let steveJobs = User( id: steve.id, name: "Steve Jobs", age: steve.age)
Но такое решение выглядит совсем неудобным, особенно, когда нужно неоднократно создавать измененную копию. Для больших структур это и вовсе неприемлемо из-за громоздких конструкций, где изменяемое свойство сходу не разглядеть.
Впрочем, в нашей реализации тоже будет присутствовать вызов инициализатора, но он будет "плоским" и написан один раз для типа, к тому же его легко автоматизировать кодогенерацией.
Как реализовать
План довольно прост:
- Сначала напишем универсальную обертку для копии, все свойства которой будут мутабельными и повторять контент копируемого типа.
- Далее добавим протокол
Changeable
с реализацией по-умолчанию, который позволит копировать экземпляры с измененными свойствами, используя универсальную обертку. - В итоге останется подписать типы под этот протокол, реализовав инициализацию из копии.
Структура изменяемой обертки
Так как обертка должна быть универсальной, а поля конкретного типа нам неизвестны, то потребуется некоторая интроспекция. С этим поможет динамический доступ к свойствам через Key-Path выражения, а фича Key-Path Dynamic Member Lookup из Swift 5.1 сделает все красивым и удобным.
Используя эти синтаксические возможности, получаем небольшую generic-структуру:
@dynamicMemberLookupstruct ChangeableWrapper<Wrapped> { private let wrapped: Wrapped private var changes: [PartialKeyPath<Wrapped>: Any] = [:] init(_ wrapped: Wrapped) { self.wrapped = wrapped } subscript<T>(dynamicMember keyPath: KeyPath<Wrapped, T>) -> T { get { changes[keyPath].flatMap { $0 as? T } ?? wrapped[keyPath: keyPath] } set { changes[keyPath] = newValue } }}
Особое внимание здесь заслуживает сабскрипт, который позволяет
считать и записать значения свойств через динамические ключи
KeyPath
. В геттере мы сначала извлекаем подходящее
значение из словаря изменений, а если ничего не нашлось, то
возвращаем значение оригинального свойства. Сеттер же всегда пишет
новое значение в словарь изменений.
При извлечении значения из словаря недостаточно просто написатьchanges[keyPath] as? T
, потому что в случае опционального типаT
мы получим уже двойную опциональность. Тогда геттер будет возвращатьnil
, даже если свойство не менялось, и в оригинальном экземпляре у него есть значение. Чтобы этого избежать, достаточно приводить тип с помощью методаflatMap(:)
, который выполнится, только если в словареchanges
есть значение для ключа.
Сигнатура нашего сабскрипта и атрибут
@dynamicMemberLookup
позволяют работать с оберткой
так, будто это оригинальная структура, в которой все свойства
объявлены переменными через var
.
При этом для обертки доступны все блага Xcode в виде автодополнения и документации свойств. А строгость типов и проверки на этапе компиляции гарантируют корректность обращений к свойствам: неверные значения и опечатки в названиях не пройдут.
Протокол Changeable
Теперь, чтобы легко копировать экземпляры с измененными
свойствами, напишем простой протокол Changeable
с
реализацией метода копирования:
protocol Changeable { init(copy: ChangeableWrapper<Self>)}extension Changeable { func changing(_ change: (inout ChangeableWrapper<Self>) -> Void) -> Self { var copy = ChangeableWrapper<Self>(self) change(©) return Self(copy: copy) }}
Метод changing(:)
получает в параметрах замыкание,
которое вызывается со ссылкой на изменяемую копию, далее из
модифицированной копии создается новый экземпляр оригинального
типа.
Кроме метода копирования с изменениями, протокол объявляет
требование для инициализатора из копии, который должен быть
реализован в каждом типе для соответствия протоколу
Changeable
:
extension User: Changeable { init(copy: ChangeableWrapper<Self>) { self.init( id: copy.id, name: copy.name, age: copy.age ) }}
Подписав тип под протокол и реализовав этот инициализатор, мы получаем то, что хотели копирование измененных экземпляров:
let steve = User(id: 1, name: "Steve", age: 21)let steveJobs = steve.changing { newUser in newUser.name = "Steve Jobs" newUser.age = 30}
Но это еще не все, есть один момент, который требует маленькой доработки
Вложенные свойства
Сейчас метод changing(:)
удобен, когда изменяются
свойства первого уровня, но часто хочется копировать экземпляры с
изменениями в более глубокой иерархии, например:
struct Company { let name: String let country: String}struct User { let id: Int let company: Company}let user = User( id: 1, company: Company( name: "NeXT", country: "USA" ))
Чтобы в этом примере скопировать экземпляр user
,
изменив поле company.name
, придется написать не самый
приятный код:
let appleUser = user.changing { newUser in newUser.company = newUser.company.changing { newCompany in newCompany.name = "Apple" }}
И чем глубже находится изменяемое свойство, тем больше уровней и строк займет его изменение.
Спокойно. Решение есть и очень простое-необходимо лишь добавить
перегрузку сабскрипта в структуру
ChangeableWrapper
:
subscript<T: Changeable>( dynamicMember keyPath: KeyPath<Wrapped, T>) -> ChangeableWrapper<T> { get { ChangeableWrapper<T>(self[dynamicMember: keyPath]) } set { self[dynamicMember: keyPath] = T(copy: newValue) }}
Этот дополнительный сабскрипт вызывается только для свойств, тип
которых соответствуют протоколу Changeable
. Swift
достаточно умен и в нашем случае не потребует каких-либо уточнений
для выбора перегрузки. Поэтому мы получаем значение свойства через
основной сабскрипт и возвращаем его, завернув в изменяемую
обертку.
Такое небольшое дополнение позволяет изменять свойства на любом уровне вложенности, используя обычный синтаксис доступа через точку:
let appleUser = user.changing { newUser in newUser.company.name = "Apple"}
Так вся конструкция копирования получается очень компактной и удобной, и вот теперь нашу реализацию можно назвать завершенной.
Подводя итог
Безусловно, лучше иметь подобный функционал копирования в составе самого языка, и, надеюсь, это произойдет в скором будущем. Сейчас же синтаксическая мощь Swift позволяет реализовать достаточно красивое решение самостоятельно, и оно лишено всех озвученных проблем своих альтернатив.
Единственным неудобством является ручная реализация инициализатора из копии. И если моделей в проекте много, их структура постоянно меняется, то имеет смысл автоматизировать этот труд. На этот случай есть готовый Stencil-шаблон для Sourcery, который доступен по ссылке.
Финальный код представленного решения, шаблон для кодогенерации и другие полезные вещи собраны в репозитории фреймворка, который легко интегрируется в любой проект, на Swift 5.1 и выше.
На этомвсе. Буду рад обратной связи в комментариях. Пока!