Статьи
May 15, 2023

Разбираем модуль pickle в Python

Модуль pickle можно использоваться для сериализации и десериализации объектов в Python. Он широко используется во многих известных фреймворках, однако магия, скрытая за кулисами pickle, понятна не каждому специалисту, особенно начинающему. Периодически люди сталкиваются с ошибками в нём, которые, на первый взгляд, никак не поддаются исправлению. А это, в свою очередь, приводит к тому, что люди изобретают собственный велосипед, вместо того, чтобы корректно использовать возможности модуля.

Рассмотрим базовые кейсы использования возможностей модуля, ответив на 3 вопроса.

Какие объекты может обрабатывать pickle?

Прежде чем приступить, немного взглянем на pickle изнутри.

Данный модуль разделяет логику сериализации и десериализации на две части, используя своего рода промежуточное представление (IR — intermediate representation) объекта. В процессе сериализации объект сначала преобразуется в IR, а затем преобразовывается в байтовое представление. Десериализация, в свою очередь, работает в точности наоборот. В большинстве случаев мы, пользователи пользователи модуля, должны думать только о первом аспекте, то есть о преобразовании между объектами Python и IR.

Для универсальности данных операций внутри модуля реализовано несколько принципов, которые единообразно будут работать с большинством объектов, которые создает пользователь. Нам не нужно напрямую работать с IR, а сами принципы можно описать так:

  1. pickle поддерживает обработку большинства встроенных типов Python и их экземпляров.
  2. pickle может работать с составными структурами (списками, словарями, множествами и пр.) если они в себе содержать только те элементы, которые сами по себе совместимы с pickle.
  3. Классы или функции при условии правильной настройки также поддаются преобразованию.
  4. Объекты доступны для pickle-преобразования, если для него доступно поле __dict__ .
  5. Объекты, работающие со внешними ресурсами (open(), socket.socket() и так далее) обычно НЕ поддаются преобразованию в поток байтов.

Принципы 1 и 2 гарантируют, что мы можем легко преобразовывать в поток байтов встроенные типы (int, str и так далее), конкретные значения (42, "foobar" и так далее) и составные структуры с ними (например, [int, {42: "foobar"}]), чего, как правило, нам уже достаточно.

Если вы хотите работать с объектами, которые определены пользователем, в игру вступают принципы 3 и 4. Если вы определяете свой класс на верхнем уровне модуля (а обычно так и должно быть), класс автоматически доступен для преобразования. Объекты, созданные на основе этого класса, также доступны для обработки, если в его __dict__ содержатся такие же атрибуты.

Как удобно! Ничего не нужно, кроме простого pickle.load или pickle.dump, чтобы насладиться возможностями модуля.

Принцип 3 сопровождается пометкой "при правильной настройке". Технически значения можно преобразовать, только если они имеют правильное поле __qualname__. __qualname__ хранит имя переменной, которая ссылается на значение. Это выполняется автоматически, если значение является классом или функцией:

class C: pass
C.__qualname__  # 'C'

def f(): pass
f.__qualname__  # 'f'

Это позволяет выполнять преобразование для пользовательских классов или функций.

Но это не относится к лямбдам. Лямбда по умолчанию будет иметь __qualname__ == "<lambda>", что не соответствует имени переменной, которая ссылается на объект. Таким образом, pickle не может с ними нормально работать.

>>> f = lambda: 1
>>> pickle.dumps(f)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>_pickle.PicklingError: Cant pickle <function <lambda> at 0x7f7ed81ad3a0>: attribute look up <lambda> on __main__ failed
>>> f.__qualname__
'<lambda>'

Чтобы сделать это возможным, можно назначить __qualname__ для функции вручную:

>>> f.__qualname__ = 'f'
>>> pickle.dumps(f)
b'\x80\x04\x95\x12\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x01f\x94\x93\x94.'

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

Допустим, мы хотим поместить объект сокета в pickle и затем загрузить его. Существует множество причин, почему десериализация может не сработать: сбой узла-получателя, обрыв связи или нехватка системных ресурсов. Даже если десериализация проходит успешно, загруженный объект не идентичен оригинальному, поскольку он ссылается на внешний ресурс с другим внутренним состоянием.

Таким образом, pickle отключает возможность преобразования, но при желании вы можете управлять им, настроив поведение сериализации / десериализации. Здесь мы подходим к следующему вопросу.

Почему мой объект не поддаётся сериализации?

С этого момента вам нужно будет узнать кое-что об IR, а также об объектном протоколе у pickle.

Базово, при сериализации объекта вызывается метод __reduce__(). Ожидаемое поведение метода подробно описано в документации. Неформально мы могли бы рассматривать возвращаемое значение как своего рода IR, который диктует, как найти объект в определённом модуле, или необходимую информацию для восстановления объекта с нуля.

Согласно документу, __reduce__() должна возвращать либо строку, либо кортеж. Сейчас мы разберём эти два случая по отдельности.

Если возвращена строка, то это должно быть имя локальной переменной относительно модуля объекта. Помните трюк с __qualname__ выше? Они выполняют одну и ту же работу. pickle обрабатывает возвращаемую строку как __qualname__ объекта, сохраняя ее вместе с именем модуля __module__ при сериализации. Впоследствии десериализатор будет искать атрибут в модуле с этой строкой в качестве имени и извлекать объект напрямую, не создавая с нуля.

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

Когда возвращается кортеж, это немного сложно. Кортеж должен содержать от 2 до 6 элементов (в версиях Python ниже 3.8 таких элементов максимум 5). Значение элементов будет описано позже, но прежде чем перейти к этому, давайте разберемся, как создается объект.

В модуле pickle объект состоит из скелета и состояний. Скелет — это начальная версия объекта, которую возвращает вызываемый объект, называемый "конструктором". Обычно конструктором является метод new(). Это также может быть какая-то фабричная функция. Состояния относятся ко всем другим атрибутам или элементам, которые содержит объект и которые могут быть простыми объектами Python или внешними ресурсами. При десериализации pickle сначала создал бы скелет, вызвав конструктор, а затем заполнил бы состояния. Любой из этих шагов можно настроить в соответствии с логикой вашего приложения.

Теперь мы можем поговорить о схеме возвращаемого кортежа.

1-й и 2-й элементы описывают, как создать скелет. 1-й элемент является вызываемым конструктором, а 2-й — кортежем позиционных аргументов, которые принимает конструктор. Оба этих элемента являются обязательными. Если аргумент не требуется, следует оставить пустой кортеж.

Остальные элементы являются необязательными и описывают состояния. pickle использует различные стратегии для восстановления состояний. Если предоставлен 6-й элемент, он должен быть вызываемой функцией с сигнатурой (obj, state), которая обновляет состояние объекта и использует 3-й элемент в качестве аргумента. Если не предоставлен, то pickle будет искать метод с именем __setstate__ для объекта, который имеет ту же сигнатуру, и если он найден, он используется в качестве средства обновления состояния. В противном случае pickle ожидает, что 3-й элемент будет словарём, который затем будет добавлен к объекту __dict__. 4-й и 5-й элементы специализированы для list- или dict-подобных объектов и используются реже. Если они предоставлены, то они должны быть списком и словарем соответственно, которые обновляют объект с помощью методов .extend() и .update().

Непосредственное внедрение __reduce__() может привести к ошибкам, поэтому pickle предоставляет другие протоколы объектов для упрощения задачи. Список специальных методов можно найти здесь. Пользователи могут реализовать некоторые из них для достижения той же цели, например, __getnewargs_ex__() или __getnewargs__() для 2-го элемента и __getstate__() для 3-го элемента.

Теперь давайте вернемся к названию – что, если мой объект не может быть сериализован? Ответ заключается в реализации собственной логики сериализации / десериализации с помощью __reduce__() или других специальных методов. Этот раздел в документации демонстрирует хороший пример, где объект поддерживает файл, который должен быть переоткрыт и найден при десериализации.

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

Что, если объект находится вне моего контроля?

В некоторых случаях может возникнуть необходимость изменить поведение сериализации для определенного типа, например, если он не поддерживается или сериализованный байт-поток недостаточно эффективен. Тип управляется некоторыми библиотеками и находится вне вашего контроля, поэтому вы не можете изменить его метод __reduce__() в соответствии с вашими требованиями. pickle предлагает другие интерфейсы для решения проблемы с разных сторон.

Таблицы диспетчеризации

Это рекомендуемый способ сериализации объектов без влияния на другой внешний код. Помимо поиска специальных методов в объекте, pickle также полагается на модуль copyreg для поиска редукторов. Функция copyreg.pickle(type, reducer) связывает вызываемый редуктор с функцией редуктора типа type. reducer должен принимать аргументы и возвращать IR точно так же, как метод __reduce__(), и он затеняет исходный __reduce__() по типу. Документация демонстрирует этот материал на простом примере.

Постоянный ID

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

В отличие от таблиц диспетчеризации, разрешение постоянных идентификаторов не определено в pickle. Следует создать подклассы Pickler и Unpickler и переписать persistent_id() и persistent_load() соответственно. Пример выбора записей таблицы с постоянными идентификаторами можно найти здесь.

reducer_override()

При задании только типов пользователи могут по-прежнему не иметь возможности определить поведение серилазации для некоторых объектов. Если вы хотите преобразовать пользовательские классы (а не их экземпляры) с помощью таблиц диспетчеризации, все __reduce__() будут делегированы одному и тому же методу, поскольку в Python все пользовательские классы наследуют от type. Python 3.8 вводит метод reducer_override() в классе Pickler, который позволяет обрабатывать пользовательскую сериализацию в произвольных условиях. Вы можете ознакомиться с техническими деталями и примерами по этой ссылке.

Заключение

Это был долгий путь, но мы его преодолели!

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

Спасибо за внимание!

PythonTalk в Telegram

Чат PythonTalk в Telegram

PythonTalk на Кью

Предложить материал | Поддержать канал