company_banner

Swift: Копируй-изменяй

  • Tutorial


Часто бывает так, что нам нужно скопировать объект, изменив некоторые его свойства, но сохранив остальные неизменными. Для этой задачи существует функция 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<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(&copy)
        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 и выше.


На этом все. Буду рад обратной связи в комментариях. Пока!

HeadHunter
HR Digital

Похожие публикации

Комментарии 6

    0

    Можно пояснить вот этот момент?
    Что значит лишает нас гарантии?


    Изменение таких полей будет невозможным, если не объявить мутабельным и новый экземпляр структуры, а это лишает гарантии, что он не модифицируется где-то еще
      0

      Имеется в виду, что экземпляр становится открытым для нежелательных изменений в будущем по неосторожности.


      Например, в случае захвата экземпляров структур в замыкании фактически это происходит по ссылке, пока не будет явно указано обратное ([steveJobs] in):


      func doSomething(closure: @escaping () -> Void) {
          DispatchQueue.main.async {
              closure()
          }
      }
      
      let steve = User(id: 1, name: "Steve", age: 21)
      
      var steveJobs = steve
      steveJobs.name = "Steve Jobs"
      
      doSomething {
          print(steveJobs.age) // Распечатается 41
      }
      
      steveJobs.age = 41

      Разработчики могут легко пропустить такие тонкости, ожидая захват копированием того состояния, который был до выполнения метода doSomething. В случае же немутабельных экземпляров в этом плане все безопаснее.

        0

        Ну данный пример это скорее плохие практики кода. Мое личное мнение что ваше решение хоть и интересное но избыточное.

          +1

          Да, статья ни в коем случае не является призывом хоронить другие практики, мы их тоже продолжаем использовать, когда это действительно удобнее.


          Changeable здорово выручает нас с редактированием больших или часто изменяемых моделей, и мы решили, что сообществу этот подход тоже пригодится. Как минимум полезно для расширения кругозора и знакомства с возможностями Swift на практике реального проекта.

            +1
            This! Согласен на все 100, в начале было не совсем понятно зачем это нужно и когда применять, но позже для себя увидел применения в больших моделях или при частом копировании/изменении. Круто, спасибо!
      +1
      Очень полезная практика, особенно для структур с большим количеством свойств, которое много где используется. И когда собирать такую структуру надо на 3-х экранах через builder. Спасибо!

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое