Immutable Data - Python
Object oriented programming is built around the encapsulation of state transitions to occur with in the data objects. For example in the below Tracker class I want to hold a variable that describes the current version of the object.
@dataclasses.dataclass
class Tracker:
version: int = 0
def update_version(self) -> int:
self.version += 1
return self.versionState is managed by manipulating the internal reference which I would do like so
current_item = Tracker()
... need to change the version now ...
new_version = current_item.update_version()
This is a simple example, but what always happens is that the interaction between objects eventually falls apart as far as boundaries go and you will have compound objects manipulating internal state of other objects. That means in the above example someone will do this
current_item = Tracker(4)
...
current_item.version = -5
... later someone uses the normal expected update method
new_version = current_item.update_version() # this yields a negative number which is unexpected to say the leastIn this case it is best to separate data from logic and that means state transitions are always managed away from the containing objects so it is clear what is happening and the preferred way I find to do this is with immutable data, so I would create a class like this
@dataclasses.dataclass(frozen=True, kw_only=True)
class Tracker:
version: int = field(default=0)
def __post_init__(self) -> None:
if self.version < 0:
raise ValueError(f'version {self.version} is not valid')Key points
Here are that the version field is initialized to 0 as expected and there is a check with the __post_init__ method to validate that the version variable is initialized always as expected. Second point is that by freezing the data class and using the kw_only parameter the following is not allowed
current_item = Tracker()
current_item.version = -1 # not allowed
current_item.version = 3 # not allowed
current_item.verions += 1 # not allowedWhat you should do is this
current_item = Tracker()
current_item = dataclass.replace(current_item, version=current_item.version + 1)Test method: Dataclasses
def immutable_dataclass_test() -> None:
current = Tracker(version=1)
current = dataclasses.replace(current, version=current.version + 1)
current = dataclasses.replace(current, version=0)
ic(current)I prefer to use dataclasses, but to do the same in Pydantic is supported and valid. However Pydantic update method assumes data validation has happened on the input as it is updated as trusted data and is why I prefer using dataclasses instead of Pydantic models.
Example with a little bit more detail.
@dataclasses.dataclass(frozen=True, kw_only=True)
class HelpLink:
url: str
@dataclasses.dataclass(frozen=True, kw_only=True)
class Complex:
name: str
help_link: HelpLink
class TrackerPydantic(pydantic.BaseModel):
version: int
inner_data: Complex
model_config = ConfigDict(revalidate_instances='always', frozen=True)Test method: Pydantic
def immutable_pydantic_test() -> None:
current = TrackerPydantic(version=1, inner_data=Complex(name='test', help_link=HelpLink(url='https://example.com')))
# locked = TrackerPydantic(version=1)
# locked.version = 5
current1 = current.model_copy(update={'version': current.version + 1}, deep=True)
ic(current1)
current2 = current.model_copy(update={'inner_data': Complex(name=current.inner_data.name, help_link=HelpLink(url="https://asfas.com"))}, deep=True)
ic(current2)
current3 = current.model_copy(update={'inner_data.help_link.url': 'https://afsdf.com'}, deep=True)
ic(current3)