Swift: Копируй-изменяй
Часто бывает так, что нам нужно скопировать объект, изменив некоторые его свойства, но сохранив остальные неизменными. Для этой задачи существует функция 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 = steve
steveJobs.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-структуру:
@dynamicMemberLookup
struct ChangeableWrapper {
private let wrapped: Wrapped
private var changes: [PartialKeyPath: Any] = [:]
init(_ wrapped: Wrapped) {
self.wrapped = wrapped
}
subscript(dynamicMember keyPath: KeyPath) -> 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)
}
extension Changeable {
func changing(_ change: (inout ChangeableWrapper) -> Void) -> Self {
var copy = ChangeableWrapper(self)
change(©)
return Self(copy: copy)
}
}
Метод changing(:)
получает в параметрах замыкание, которое вызывается со ссылкой на изменяемую копию, далее из модифицированной копии создается новый экземпляр оригинального типа.
Кроме метода копирования с изменениями, протокол объявляет требование для инициализатора из копии, который должен быть реализован в каждом типе для соответствия протоколу Changeable
:
extension User: Changeable {
init(copy: ChangeableWrapper) {
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(
dynamicMember keyPath: KeyPath
) -> ChangeableWrapper {
get {
ChangeableWrapper(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 и выше.
На этом все. Буду рад обратной связи в комментариях. Пока!