Сохранение состояния pickle и классы наследники

При возникновении ситуации, когда pickle отказывается сохранять (сериализовать) некоторые атрибуты, как класса наследника, так и родительского класса (либо они сами имеют очевидные зависимости от текущего состояния: дескрипторы открытых файлов и т.п.), кажется предпочтительным, что бы каждый класс решал проблемы своих собственных атрибутов самостоятельно.

Для случая без наследования, в документации pickle описано, как действовать с атрибутами, которые нельзя замариновать (Handling Stateful Objects, сделать копию __dict__, удалить и т.п.).

В случае чисто древовидной иерархии наследования, после учёта ряда принципиальных замечаний @Vitalizzare, способ работы с pickle может быть таким (слов много, зато кода мало):

  1. Базовые классы, если содержат немаринуемые атрибуты, модифицируются как обычно, согласно Handling Stateful Objects, с одним дополнением, что если им требуются временные атрибуты, то они должны быть приватными атрибутами класса (см. MySuper ниже);

  2. Промежуточные классы, если содержат только хорошие атрибуты, остаются без изменений;

  3. Если класс наследник содержит немаринуемый атрибут, то, при сохранении, он, при необходимости, может создавать приватные атрибуты класса, после чего должен вызвать super().__getstate__() и удалить немаринуемые атрибуты. А при восстановлении, первым делом, вызвать super().__setstate__(state).

class MySuper:
    def __getstate__(self):
        ...  # Добавляем начинающиеся с `__` приватные атрибуты класса, 
             # если они необходимы.
        state = self.__dict__.copy()
        ...  # Только удаляем то, что спасти нельзя.
        return state

    def __setstate__(self, state):
        self.__dict__.update(state)
        ...  # Воссоздаём неспасаемое.
    ...


class MyIntermediate(MySuper):
    ...


class MyDerive1(MyIntermediate):
    def __getstate__(self):
        ...  # Добавляем начинающиеся с `__` приватные атрибуты класса, 
             # если они необходимы.
        state = super().__getstate__()
        ...  # Только удаляем то, что спасти нельзя.
        return state

    def __setstate__(self, state):
        super().__setstate__(state)
        ...  # Воссоздаём неспасаемое.
    ...

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

Похоже на то, что, как вышесказанное, так и даже написанное в Handling Stateful Objects, верно только, если класс не участвует во множественном наследовании, никаким боком.

В общем же случае, одно из двух, либо атрибуты класса совместимы с pickle, тогда класс неизменен, либо его надо модифицировать следующим образом:

class Generic:
    def __getstate__(self):
        ...  # Добавляем начинающиеся с `__` приватные атрибуты класса, 
             # если они необходимы.
        state = super().__getstate__().copy()
        ...  # Только удаляем то, что спасти нельзя.
        return state

    def __setstate__(self, state):
        getattr(super(), '__setstate__', self.__dict__.update)(state)
        ...  # Воссоздаём неспасаемое.
    ...

А дальше уж, присвоение можно оптимизировать в общем виде, как:

state = super().__getstate__()
if state is self.__dict__:
    state = state.copy()

, либо с учётом положение класса в иерархии наследования.

Я ничего не забыл? Нет ли каких подводных граблей в таком подходе?

Макет примера использования: https://github.com/Serge3leo/temp-cola/blob/main/ruSO/1614688-pickle-и-классы-наследники.ipynb

P.S.

Полезное приспособление для контроля совместимости класса с pickle и определения проблемных атрибутов, в противном случае: https://stackoverflow.com/a/7218986/8585880 .

Тема взаимодействия __weakref__ и pickle, хотя и интересна (в том же Matplotlib они есть), но это, наверное, другой вопрос, который я пока не могу сформулировать.


Ответы (1 шт):

Автор решения: Vitalizzare ушел в монастырь

Я полагаю, что когда вы говорите "функции родительского класса работают с тем же __dict__, что и функции наследника", то под функциями класса наверное имеете ввиду методы объекта. Они работают не с атрибутом __dict__, а с самим объектом, который неявно передаётся первым аргументом при их вызове. И в этом смысле содержимое self.__dict__ для родительских методов будет тем же, что и для методов, заданных объекту классом-наследником (просто потому, что self в обоих случаях - это один и тот же объект).

Предположим, что вы нигде не используете slots, weakref и дескрипторы. Тогда в вашем коде остаётся потенциальный изъян в методах наследника. В вызове super().__setstate__(state) нужно передать не state, созданный и сохраненный наследником, а ту его часть, с которой может работать родитель:

class MyDerive1(MySuper):
    ...
    def __setstate__(self, state):
        superstate = ...    # извлекаем родительскую часть данных
        super().__setstate__(superstate)
        ...  
    ...

Если мы определяем __setstate__, то сохраняемые данные не обязаны быть словарём, отображающим содержимое self.__dict__. Например, при воссоздании данных по косвенным указаниям, сохраняемое состояние будет содержать эти указания в каком-то picklable формате, а не данные из self.__dict__. Соответственно, ту часть, что передает нам super().__getstate__(), было бы разумно сохранить отдельным блоком, например, как часть кортежа:

class MyDerive1(MySuper):
    ...
    def __getstate__(self):
        superstate = super().__getstate__()
        state = ...  
        return (superstate, state)
    ...

Если вы этого не делаете, то тогда получается, что класс-наследник диктует родителю определённый формат поведения, что противоречит идее наследования.


P.S. Замечание по добавленному в вопрос примеру. Полагаю, что вы смешали два понятия - содержимое __dict__ сохраняемого объекта и инструкцию для восстановления несохраняемой части. Прокомментирую часть вашего кода:

class MySuper:
    def __getstate__(self):
        state = self.__dict__.copy()
        del state['scbt']

        # !!! Эта часть вероятно описывает состояние, но не является им.
        #     Она НЕ должна добавляться в словарь атрибутов объекта,
        #     потому что: 1) может перекрыть ключи класса потомка;
        #     2) при восстановлении может быть добавлена к объекту, 
        #     повлияв на его последующее поведение.
        state['__scbt_status__'] = self.scbt.get_status()
        state['__figwidth__'] = self.fig.get_figwidth()
        state['__figheight__'] = self.fig.get_figheight()

        return state

    def __setstate__(self, state):
        
        # !!! Эта часть должна выполняться только после того,
        #     как вы удалите из сохраненного словаря 
        #     добавленное описание несохраняемого состояния, см. ниже
        self.__dict__.update(state)

        # !!! Эта часть использует описание состояния, чтобы восстановить
        #     несохраняемую часть. Она не должна оказаться 
        #     в словаре __dict__ атрибутов объекта, потому что
        #     это может неявно повлиять на его поведение.
        self.fig.set_figwidth(state['__figwidth__'])
        self.fig.set_figheight(state['__figheight__'])
        self._MySuper_init_interface(
            scbt_actives=state['__scbt_status__'])
    ...

Повторю ключевые моменты из комментариев к коду:

  • при добавлении в self.__dict__ ключей, описывающих несохраняемое состояние, вы неявно выдвигаете требование к потомкам не использовать эти ключи в своей работе;
  • при восстановлении той части, которая описана добавленными ключами, эти ключи должны быть удалены из state перед восстановлением self.__dict__, иначе само их появление может повлиять на поведение объекта.

Мы могли бы взять пример с дефолтного поведения, когда объект содержит __dict__ и __slots__, т.е. вернуть не один словарь __dict__ с вкраплениями [предположительно] новых ключей описания, а пару (__dict__, state), где state - это описание того, что нужно восстановить:

class MySuper:
    def __getstate__(self):
        __dict__ = self.__dict__.copy()
        del __dict__['scbt']
        state = {
            '__scbt_status__': self.scbt.get_status(),
            '__figwidth__': self.fig.get_figwidth(),
            '__figheight__': self.fig.get_figheight(),
        }
        return __dict__, state

    def __setstate__(self, state):
        __dict__, state = state
        self.__dict__.update(__dict__)
        self.fig.set_figwidth(state['__figwidth__'])
        self.fig.set_figheight(state['__figheight__'])
        self._MySuper_init_interface(
            scbt_actives=state['__scbt_status__'])
    ...

Аналогичное требование к потомку - не смешивать содержимое __dict__ и характеристики восстанавливаемых состояний. Пример:

class MyDerive1(MySuper):
    def __getstate__(self):
        superstate = super().__getstate__()

        # !!! Предполагаем, что родитель вернет 
        #     либо кортеж с __dict__ на первом месте,
        #     либо словарь __dict__; варианты None, 
        #     (None, slots_mapping) и другие пользовательские
        #     форматы исключаем для простоты изложения.
        #     https://docs.python.org/3/library/pickle.html#object.__getstate__
        if isinstance(superstate, tuple):
            __dict__, *superstate = superstate 
        else:
            __dict__, superstate = superstate, ()

        del __dict__['dcbt1']
        state = {'__dcbt1_status__': self.dcbt1.get_status()}
        return __dict__, *superstate, state

    def __setstate__(self, state):
        *superstate, state = state
        super().__setstate__(superstate)
        self._MyDerive1_init_interface(
            dcbt1_actives=state['__dcbt1_status__'])
    ...
→ Ссылка