Изучение копирования при записи в Swift

В настоящее время я работаю над проектом приложения для iOS на основе TCA (компонуемой архитектуры). Но я не буду подробно рассказывать об архитектуре. Короче говоря, эта архитектура позволяет разделить систему на небольшие независимые компоненты, которые можно объединить в более крупную и сложную систему. Эта архитектура вдохновлена ​​Redux и очень похожа на то, как она работает. Если вы не знакомы с ней, вы можете узнать больше на их странице GitHub.

Эта архитектура была действительно забавной и идеально подходила для продукта, над которым я работал, и, конечно же, для SwiftUI. Но однажды, прежде чем наш код был завершен, мы объединили все наши PR, а затем начали тестирование разработки, прежде чем отправить сборку в отдел контроля качества. Плохие вещи случаются! Наша функция дает сбой только при запуске на реальном устройстве. Появляется ошибка Thread 1: EXC_BAD_ACCESS (code=2, address=0x16b6enff8), у меня всегда болит голова, если я получаю эту ошибку, потому что ее будет сложно отлаживать, потому что она обычно связана с ошибкой памяти и времени выполнения. Пробовал много раз, может это только периодическая ошибка. Но я ошибаюсь! это всегда происходит. У меня кружится голова!

Прочитав несколько тем на различных форумах по программированию, я просветлен. Это должно быть переполнение стека, потому что TCA использует структуры для состояния в качестве модели данных, потому что SwiftUI использует типы значений, учитывая, что изменение свойства приводит к созданию нового экземпляра структуры, который SwiftUI под капотом может обнаружить и инициировать обновление представления.

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

Но почему сбой происходит только на реальных устройствах?

Это тоже был мой вопрос в то время. Подробности можно найти здесь. Короче говоря, симулятор использует другой размер стека, чем реальное устройство. Судя по статье, у устройства iOS всего 1 МБ стека, тогда как у симулятора больше памяти, но я не уверен, сколько, если он соответствует размеру стека памяти Mac, это означает, что он составляет около 8 МБ. Почему это? только Apple может ответить.

Собственно, Apple уже упоминала этот механизм на своем GitHub, вы также можете прочитать другие советы по оптимизации от инженеров Apple здесь. Но если у нас нет такой проблемы, возможно, мы даже не узнаем, что страница GitHub существует.

Благодаря этому «инциденту» я узнал об этом механизме Copy-on-Write и внедрил его в свой проект, теперь он отлично работает! Больше никаких переполнений стека.

За всем, что происходит, стоит мудрость. Верно? :)

Без лишних слов, давайте углубимся в тему.

Что такое копирование при записи и чем оно полезно в программировании?

Копирование при записи, также известное как COW, — это метод оптимизации производительности, используемый в некоторых языках программирования для уменьшения накладных расходов на копирование объектов. При традиционном копировании и мутации новый объект создается и инициализируется значениями существующего объекта, и любые изменения в новом объекте также влияют на исходный. Это может быть неэффективно, особенно при работе с большими объектами или при создании множества копий одного и того же объекта, а приложение работает на мобильных устройствах с очень ограниченным объемом памяти.

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

Swift — это язык программирования, который поддерживает как семантику значений, так и семантику ссылок. Это означает, что в некоторых случаях объекты передаются по значению, и каждый раз при их назначении или передаче в функцию создается новая копия. В других случаях объекты передаются по ссылкам, и несколько ссылок могут указывать на одни и те же базовые данные.

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

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

Реализация Swift копирования при записи

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

Как я уже упоминал выше, массив — это тип значения в Swift. Но почему в приведенном выше примере array2 указывает на тот же адрес памяти, хотя мы уже скопировали значение в другую переменную, которая должна иметь другой адрес памяти, почему это выглядит как ссылочный тип? Сбивает с толку? Давайте сделаем что-нибудь дальше…

Теперь, после изменения array2 путем добавления нового элемента, адрес памяти array2изменился. Да, он следует правилу типа значения, но «с небольшой задержкой» этот механизм называется «Копирование при записи», когда компилятор выделяет новую память только до тех пор, пока объект не изменится. Если мы не мутируем и не модифицируем объект, он только копирует ссылку.

Как насчет структуры или нашего собственного типа данных? Да, это тоже было упомянуто выше, мы должны реализовать это сами :)

Чтобы доказать, что по умолчанию тип значения не использует механизм CoW, мы можем смоделировать его аналогичным образом, как описано выше.

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

Но теперь давайте создадим реализацию CoW для нашей структуры или нашего собственного типа данных. Самый простой способ реализовать это — компоновать с существующими структурами данных CoW, но я не буду этого делать, поскольку Apple также не рекомендует это делать. Вместо этого мы создадим оболочку значения CoW.

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

Хорошо, давайте так!

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

Но давайте изменим сценарий следующим образом:

Вы заметили что-нибудь странное?

Да, переменная b по-прежнему указывает на тот же адрес памяти, что и переменная a. Наша текущая реализация работает только для отношений один-к-одному, но когда есть две структуры, указывающие на одно и то же резервное хранилище, она также заменяет другие данные структуры, а это означает, что изменение данных одной структуры переопределит другую структуру. Конечно, это не то, чего мы хотим. Мы хотим создать отдельную копию, когда несколько человек держат ссылку на резервное хранилище и хотят внести некоторые изменения. Но как этого добиться?

К счастью, Swift предоставляет функцию isKnownUniquelyReferenced(_:), которая может помочь нам решить эту проблему.

Возвращает логическое значение, указывающее, известно ли, что данный объект имеет единственную сильную ссылку.

Другими словами, он скажет нам, являемся ли мы единственными, кто владеет ссылкой, или кто-то еще также владеет ссылкой.

Да, это похоже на то, что нам нужно.

Давайте изменим нашу предыдущую обертку и избавимся от этой ситуации.

Как вы можете видеть после нашей небольшой модификации, он отлично работает и для отношений «один ко многим», и это именно то, что нам нужно, копировать только тогда, когда нам нужно. Если есть одна ссылка, мы можем изменить значение сразу, не копируя его, а если есть более одной ссылки, сначала будет скопировано значение, а затем изменено значение.

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

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

Как видите, мы используем оболочку свойств, чтобы уменьшить шаблон и сделать наш код более «читабельным». Equatable. Вы можете настроить в соответствии с вашими потребностями.

Как насчет производительности?

Вы можете найти этот механизм отличным, но так ли он важен для приложения? кажется, нет никаких причин для его реализации. Что ж, оказывается, этот механизм повышает производительность.

В функции Swift аргументы передаются по копии, а не по ссылке. Другими словами, когда мы передаем в функцию тип значения, компилятор будет дублировать объект. Из этого ясно, что если мы будем передавать тип значения во многие места, он будет много дублировать или копировать, и если объект слишком сложный и дорогой, конечно, процесс также будет долгим. Как насчет нашего стека памяти? Конечно, позже он будет заполнен, и ваше приложение рухнет, как я испытал.

Заключение

При выборе между использованием класса или структуры в вашем коде важно учитывать семантику ваших данных, а не просто учитывать производительность. Структуры могут обеспечивать лучшую производительность при работе с простыми структурами данных, тогда как классы могут работать лучше при работе с более сложными структурами. Однако стоит отметить, что вы можете использовать технику «копирования при записи» для повышения производительности структур, если это необходимо. Имейте в виду, что использование «копирования при записи» может увеличить сложность вашего кода, поэтому важно тщательно профилировать ваше приложение, чтобы убедиться, что оно действительно приводит к повышению производительности. Кроме того, «копирование при записи» полезно не только для оптимизации производительности; его также можно использовать для предоставления семантики значений для сущностей фреймворка на основе классов.

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