Разбираем модуль pickle в Python
Модуль pickle
можно использоваться для сериализации и десериализации объектов в Python. Он широко используется во многих известных фреймворках, однако магия, скрытая за кулисами pickle
, понятна не каждому специалисту, особенно начинающему. Периодически люди сталкиваются с ошибками в нём, которые, на первый взгляд, никак не поддаются исправлению. А это, в свою очередь, приводит к тому, что люди изобретают собственный велосипед, вместо того, чтобы корректно использовать возможности модуля.
Рассмотрим базовые кейсы использования возможностей модуля, ответив на 3 вопроса.
Какие объекты может обрабатывать pickle
?
Прежде чем приступить, немного взглянем на pickle
изнутри.
Данный модуль разделяет логику сериализации и десериализации на две части, используя своего рода промежуточное представление (IR — intermediate representation) объекта. В процессе сериализации объект сначала преобразуется в IR, а затем преобразовывается в байтовое представление. Десериализация, в свою очередь, работает в точности наоборот. В большинстве случаев мы, пользователи пользователи модуля, должны думать только о первом аспекте, то есть о преобразовании между объектами Python и IR.
Для универсальности данных операций внутри модуля реализовано несколько принципов, которые единообразно будут работать с большинством объектов, которые создает пользователь. Нам не нужно напрямую работать с IR, а сами принципы можно описать так:
- pickle поддерживает обработку большинства встроенных типов Python и их экземпляров.
- pickle может работать с составными структурами (списками, словарями, множествами и пр.) если они в себе содержать только те элементы, которые сами по себе совместимы с pickle.
- Классы или функции при условии правильной настройки также поддаются преобразованию.
- Объекты доступны для pickle-преобразования, если для него доступно поле
__dict__
. - Объекты, работающие со внешними ресурсами (
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, основные протоколы объектов сериализации, а также некоторые другие интерфейсы для настройки. Надеюсь, что эти материалы могут дать вам общее представление об этом модуле и помочь в решении проблем с сериализацией.