Search code examples
pythonpython-3.xoopencapsulationpython-dataclasses

How to achieve encapsulation in python dataclasses with dependencies between variables


I have recently learned about the benefits of dataclasses in reducing boilerplate code when writing classes that primarily act as data containers.

I would like to be able to use dataclasses but would like to maintain the level of encapsulation that I have gotten used to with regular classes using private/protected variables and/or properties.

I would like to be able to encapsulate two fields so that they are only ever updated together according to some expected rules.

as a minimum example:

class EncapsulatedClass:
    def __init__(self, field1, field2):
        validate_args(field1, field2)
        self._field1 = field1
        self._field2 = field2

    def update_fields(self, arg1, arg2):
        if arg1:
            self._field1 += 1
        if arg2:
            self._field2 += 1
        if arg1 and arg2:
            self._field1 = 0

The reason I would like this behaviour is that it guarantees (up to user modification of protected args) that field1 and field2 have some expected relationship, this can be validated on initialisation and then never needs to be validated again.

However, with a standard class, I need to implement __eq__ __repr__ and __init__ which I would rather avoid to reduce boilerplate.

Is there a way to achieve this kind of behaviour using dataclasses with minimal boilerplate?


Solution

  • In general, no. But depending on exactly what you are trying to do, there may be various specific workarounds. For example, here I might make a property that operates on a tuple.

    class EncapsulatedClass:
        def __init__(self, field1, field2):
            self.field = (field1, field2)
    
        @property
        def field(self):
            return (self._field1, self._field2)
    
        @field.setter
        def field(self, value):
            v1, v2 = value  # Here, just letting a possible exception propogate
            if v1:
                self._field1 += 1
            if v2:
                self._field2 += 1
            if v1 and v2:
                self._field1 = 0
    
        # Now it's a wrapper around the setter
        def update_fields(self, field1, field2):
            self.field = (field1, field2)
    

    You can continue using a property in a dataclass. __post_init__ can be defined to initialize the property without having to re-impelement everything that would use the underlying attributes.

    @dataclass
    class EncapsulatedClass:
        _field1: int
        _field2: int
    
        def __post_init__(self):
            self.field = (self._field1, self._field2)
            
        @property
        def field(self):
            return (self._field1, self._field2)
    
        @field.setter
        def field(self, value):
            v1, v2 = value  # Here, just letting a possible exception propogate
            if v1:
                self._field1 += 1
            if v2:
                self._field2 += 1
            if v1 and v2:
                self._field1 = 0
    
        def update_fields(self, field1, field2):
            self.field = (field1, field2)