Можно ли предотвратить чтение из замороженного класса данных Python?

У меня есть ситуация, когда я хотел бы иметь возможность обрабатывать замороженный экземпляр dataclass как всегда имеющий самые свежие данные. Или, другими словами, я хотел бы иметь возможность определять, вызывается ли экземпляр класса данных replace, и генерировать исключение. Это также должно применяться только к этому конкретному экземпляру, чтобы создание / замена других экземпляров класса данных того же типа не влияли друг на друга.

Вот пример кода:

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class AlwaysFreshData:
    fresh_data: str


def attempt_to_read_stale_data():
    original = AlwaysFreshData(fresh_data="fresh")
    unaffected = AlwaysFreshData(fresh_data="not affected")

    print(original.fresh_data)

    new = replace(original, fresh_data="even fresher")

    print(original.fresh_data) # I want this to trigger an exception now

    print(new.fresh_data)

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

Можно ли это сделать? Либо через базовый класс, либо каким-то другим методом?

РЕДАКТИРОВАТЬ: намерение здесь состоит в том, чтобы иметь способ принудительно / проверять семантику владения для классов данных, даже если это только во время выполнения.

Вот конкретный пример проблемной ситуации с обычными классами данных.

@dataclass
class MutableData:
    my_string: str

def sneaky_modify_data(data: MutableData) -> None:
    some_side_effect(data)
    data.my_string = "something else" # Sneaky string modification

x = MutableData(my_string="hello")

sneaky_modify_data(x)

assert x.my_string == "hello" # as a caller of 'sneaky_modify_data', I don't expect that x.my_string would have changed!

Этого можно избежать, используя замороженные классы данных! Но все же существует ситуация, которая может привести к потенциальным ошибкам, как показано ниже.

@dataclass(frozen=True)
class FrozenData:
    my_string: str

def modify_frozen_data(data: FrozenData) -> FrozenData:
   some_side_effect(data)
   return replace(data, my_string="something else")

x = FrozenData(my_string="hello")

y = modify_frozen_data(x)

some_other_function(x) # AHH! I probably wanted to use y here instead, since it was modified!

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

Некоторым эта ситуация может быть знакома как подобная семантике владения в чем-то вроде Rust.

Что касается моей конкретной ситуации, у меня уже есть большой объем кода, который использует эту семантику, за исключением экземпляров NamedTuple. Это работает, потому что изменение функции _replace в любом экземпляре позволяет сделать экземпляры недействительными. Эта же стратегия не работает так чисто для классов данных, поскольку dataclasses.replace не является функцией самих экземпляров.


person Sanchit Uttam    schedule 01.07.2020    source источник
comment
Похоже, что вам действительно нужен non -frozen dataclass, поэтому вы можете обновить значение fresh_data.   -  person jonrsharpe    schedule 01.07.2020
comment
Похоже, это проблема X-Y. Вы изменяете данные (используя replace), но хотите, чтобы данные были заморожены, поэтому вы создаете новый экземпляр. В то же время вы не хотите, чтобы использовался старый экземпляр, потому что он больше не действителен. Почему бы просто не сделать объект незамороженным, как предложил @jonrsharpe? Тогда у вас не будет никаких проблем, которые вы сейчас пытаетесь решить. Объясните вам исходную проблему X, вместо того, чтобы просить решения для Y.   -  person zvone    schedule 03.07.2020
comment
@zvone, честно говоря, основная цель replace - вызываться с замороженными классами данных. Хорошо указать, что проблема намекает на проблему дизайна, но обстоятельства OP или любого, кто найдет этот пост через Google, могут сделать редизайн невозможным.   -  person Arne    schedule 03.07.2020
comment
@zvone Я обновил свой вопрос, подробно объяснив, почему я ищу именно это решение, а также почему незамороженные классы данных не подходят.   -  person Sanchit Uttam    schedule 03.07.2020
comment
@Arne цель replace - сделать копию, и это нормально, но вопрос здесь в том, чтобы пометить исходный замороженный объект как использованный, и это всегда очень подозрительный дизайн.   -  person zvone    schedule 03.07.2020
comment
@SanchitUttam Вы пытаетесь избежать скрытых изменений, сделанных в some_side_effect, потому что не доверяете этому? Вы ничего не добьетесь, если не доверяете тем, что делают функции. Во-вторых, интерфейс modify_frozen_data не должен ограничиваться доверием какой-либо другой функции. Вы хотите изменить данные в modify_frozen_data, поэтому их можно изменить. Если вы действительно не можете доверять some_side_effect (но подумайте об этом еще раз!), Тогда не присылайте ему свой объект - отправьте копию.   -  person zvone    schedule 03.07.2020
comment
@zvone Для копий copy.copy и copy.deepcopy отлично работают с классами данных, а _ 3_ предназначен, в частности, для обработки замороженных классов данных, когда копирование + обновление не работает.   -  person Arne    schedule 04.07.2020
comment
@zvone Думаю, тут недоразумение. some_side_effect ничего не меняет, это просто способ представить некую работу, которая не меняет данные. Идея использования замороженного класса данных состоит в том, чтобы предотвратить модификацию без явного указания. Однако это по-прежнему не предотвращает использование неизмененных данных, что может привести к незаметным, трудно отслеживаемым ошибкам. Очевидно, что с подобными вещами можно справиться, если проводить более тщательный и качественный анализ кода, но было бы неплохо иметь возможность выявлять случаи, когда это происходит, до того, как они вызовут ошибки в производственной среде.   -  person Sanchit Uttam    schedule 06.07.2020
comment
@SanchitUttam Нет никакого недоразумения. Вы можете взглянуть на этот же вопрос с разных сторон, но какой бы угол вы ни выбрали, я считаю, что то, что вы пытаетесь сделать, неправильно. Вы не должны принимать это. Это тебе решать ;)   -  person zvone    schedule 06.07.2020


Ответы (1)


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

from dataclasses import dataclass, replace, field
from typing import Any, ClassVar


@dataclass(frozen=True)
class AlwaysFreshData:
    #: sentinel that is used to mark stale instances
    STALE: ClassVar = object()

    fresh_data: str
    #: private staleness indicator for this instance
    _freshness: Any = field(default=None, repr=False)

    def __post_init__(self):
        """Updates a donor instance to be stale now."""

        if self._freshness is None:
            # is a fresh instance
            pass
        elif self._freshness is self.STALE:
            # this case probably leads to inconsistent data, maybe raise an error?
            print(f'Warning: Building new {type(self)} instance from stale data - '
                  f'is that really what you want?')
        elif isinstance(self._freshnes, type(self)):
            # is a fresh instance from an older, now stale instance
            object.__setattr__(self._freshness, '_instance_freshness', self.STALE)
        else:
            raise ValueError("Don't mess with private attributes!")
        object.__setattr__(self, '_instance_freshness', self)

    def __getattribute__(self, name):
        if object.__getattribute__(self, '_instance_freshness') is self.STALE:
            raise RuntimeError('Instance went stale!')
        return object.__getattribute__(self, name)

Что будет вести себя так для вашего тестового кода:

# basic functionality
>>> original = AlwaysFreshData(fresh_data="fresh")
>>> original.fresh_data
fresh
>>> new = replace(original, fresh_data="even fresher")
>>> new.fresh_data
even_fresher

# if fresher data was used, the old instance is "disabled"
>>> original.fresh_data
Traceback (most recent call last):
  File [...] in __getattribute__
    raise RuntimeError('Instance went stale!')
RuntimeError: Instance went stale!

# defining a new, unrelated instance doesn't mess with existing ones
>>> runner_up = AlwaysFreshData(fresh_data="different freshness")
>>> runner_up.fresh_data
different freshness
>>> new.fresh_data  # still fresh
even_fresher
>>> original.fresh_data  # still stale
Traceback (most recent call last):
  File [...] in __getattribute__
    raise RuntimeError('Instance went stale!')
RuntimeError: Instance went stale!

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

>>> original = AlwaysFreshData(fresh_data="fresh")
# calling replace with _freshness=None is a no-no, but we can't prohibit it
>>> new = replace(original, fresh_data="even fresher", _freshness=None)
>>> original.fresh_data
fresh
>>> new.fresh_data
even_fresher

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

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

person Arne    schedule 02.07.2020
comment
Большое спасибо за ответ! К сожалению, это не работает так, как я ожидал. В этом примере: a = AlwaysFreshData("A1") b = AlwaysFreshData("B1") print(a) # This triggers an exception, even though 'a' was not replaced В приведенном вами примере кода созданные экземпляры мешают свежести других экземпляров. - person Sanchit Uttam; 03.07.2020
comment
ха-ха, это довольно большая проблема, которую стоит упустить. Я посмотрю, смогу ли я обновить свой пост. - person Arne; 03.07.2020
comment
@SanchitUttam Я нашел способ. Он даже немного короче, но имеет потенциальные побочные эффекты. - person Arne; 03.07.2020
comment
Спасибо! Это действительно ответ на вопрос, который я задал. К сожалению, я не могу использовать это решение, поскольку оно включает в себя введение свойства по умолчанию, которое вызывает проблемы при попытке наследования от класса данных (как вы упомянули в ссылке в своем сообщении). Несмотря на это, я отмечу вопрос как ответ. Если вам интересно, мне удалось обойти мою проблему, фактически исправив dataclasses.replace обезьяны. Я понимаю, что это не чистое решение, но оно чище, чем все, что я придумал. - person Sanchit Uttam; 03.07.2020
comment
Рад, что вы нашли решение, и спасибо за принятие! - person Arne; 03.07.2020