3

Let's take as example this classical solution to the problem of updating dependent object attributes:

class SomeClass(object):
    def __init__(self, n):
        self.list = range(0, n)

    @property
    def list(self):
        return self._list
    @list.setter
    def list(self, val):
        self._list = val
        self._listsquare = [x**2 for x in self._list ]

    @property
    def listsquare(self):
        return self._listsquare
    @listsquare.setter
    def listsquare(self, val):
        self.list = [int(pow(x, 0.5)) for x in val]

It works as required: when a new value is set for one attribute, the other attribute is updated:

>>> c = SomeClass(5)
>>> c.listsquare
[0, 1, 4, 9, 16]
>>> c.list
[0, 1, 2, 3, 4]
>>> c.list = range(0,6)
>>> c.list
[0, 1, 2, 3, 4, 5]
>>> c.listsquare
[0, 1, 4, 9, 16, 25]
>>> c.listsquare = [x**2 for x in range(0,10)]
>>> c.list
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

But, what if we mutate the attribute list instead of setting it to a new value?:

>>> c.list[0] = 10
>>> c.list
[10, 1, 2, 3, 4, 5, 6, 7, 8, 9]  # this is ok
>>> c.listsquare
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]  # we would like 100 as first element

We would like listsquare attribute to be updated accordingly, but it's not the case because the setters are not invoked when we mutate the list attribute.

Of course we could force the update by explicitly invoking the setter after we modify the attribute, for example by doing:

>>> c.list[0] = 10
>>> c.list = c.list. # invoke setter
>>> c.listsquare
[100, 1, 4, 9, 16, 25, 36, 49, 64, 81]

but it looks somewhat cumbersome and error prone for the user, we would prefer that it occurs implicitly.

What would be the most pythonic way for having the attributes updated when another mutable attribute is modified. How the object can know that one of his attributes has been modified?

ivankeller
  • 1,923
  • 1
  • 19
  • 20
  • 1
    You have to use a special mutable type to react to such things. That doesn’t seem like an answer, but it suggests a more specific question. – Davis Herring Aug 08 '21 at 17:19
  • @DavisHerring What pattern do you have in mind with "special mutable type to react to such things"? Could you elaborate on it? – ivankeller Aug 08 '21 at 22:11
  • 1
    Your own sequence type that updates its opposite number in its `__setitem__`, for instance. For practical use you have to implement many functions (*e.g.*, `pop` and `remove` if you want the whole `list` interface). It might be easier to have the custom type provide two *views* of one underlying data store. – Davis Herring Aug 08 '21 at 23:23

1 Answers1

1

So, as Davis Herring was saying in the comments, this is eminently possible but not nearly as clean. You essentially have to build your own custom data structure that maintains two lists in parallel, each one aware of the other, so that if one is updated, the other is also updated. Below is my shot at doing that, which took, um, a little longer than expected. Seems to work, but I haven't comprehensively tested it.

I've chosen to inherit from collections.UserList here. The other option would be to inherit from collections.abc.MutableSequence, which has various pros and cons compared to UserList.

from __future__ import annotations

from collections import UserList
from abc import abstractmethod

from typing import (
    Sequence,
    TypeVar,
    Generic, 
    Optional,
    Union,
    Any, 
    Iterable,
    overload,
    cast
)


### ABSTRACT CLASSES ###

# Initial type
I = TypeVar('I')

# Transformed type
T = TypeVar('T') 

# Return type for methods that return self
C = TypeVar('C', bound="AbstractListPairItem[Any, Any]")


class AbstractListPairItem(UserList[I], Generic[I, T]):
    """Base class for AbstractListPairParent  and AbstractListPairChild"""
    
    __slots__ = '_other_list'
    _other_list: AbstractListPairItem[T, I]

    # UserList inherits from `collections.abc.MutableSequence`,
    # which has `abc.ABCMeta` as its metaclass,
    # so the @abstractmethod decorator works fine.
    @abstractmethod
    def __init__(self, initlist: Optional[Iterable[I]] = None) -> None:
        # We inherit from UserList, which stores the sequence as a `list`
        # in a `data` instance attribute
        super().__init__(initlist)
    
    @staticmethod
    @abstractmethod
    def transform(value: I) -> T: ...
    
    @overload
    def __setitem__(self, index: int, value: I) -> None: ...
    
    @overload
    def __setitem__(self, index: slice, value: Iterable[I]) -> None: ...
    
    def __setitem__(
        self, 
        index: Union[int, slice], 
        value: Union[I, Iterable[I]]
    ) -> None:
        
        super().__setitem__(index, value)  # type: ignore[index, assignment]
        
        if isinstance(index, int):
            value = cast(I, value)
            self._other_list.data[index] = self.transform(value)
        elif isinstance(index, slice):
            value = cast(Iterable[I], value)
            for i, val in zip(range(index.start, index.stop, index.step), value):
                self._other_list.data[i] = self.transform(val)
        else:
            raise NotImplementedError
        
    # __getitem__ doesn't need to be altered
        
    def __delitem__(self, index: Union[int, slice]) -> None:
        super().__delitem__(index)
        del self._other_list.data[index]
    
    def __add__(self, other: Iterable[I]) -> list[I]:  # type: ignore[override]
        # Return a normal list rather than an instance of this class
        return self.data + list(other)
        
    def __radd__(self, other: Iterable[I]) -> list[I]:
        # Return a normal list rather than an instance of this class 
        return list(other) + self.data
        
    def __iadd__(self: C, other: Union[C, Iterable[I]]) -> C:
        if isinstance(other, type(self)):
            self.data += other.data
            self._other_list.data += other._other_list.data
        else:
            new = list(other)
            self.data += new
            self._other_list.data += [self.transform(x) for x in new]
        return self 
    
    def __mul__(self, n: int) -> list[I]:  # type: ignore[override]
        # Return a normal list rather than an instance of this class
        return self.data * n

    __rmul__ = __mul__
    
    def __imul__(self: C, n: int) -> C:
        self.data *= n
        self._other_list.data *= n
        return self 
        
    def append(self, item: I) -> None:
        super().append(item)
        self._other_list.data.append(self.transform(item))

    def insert(self, i: int, item: I) -> None:
        super().insert(i, item)
        self._other_list.data.insert(i, self.transform(item))

    def pop(self, i: int = -1) -> I:
        del self._other_list.data[i]
        return self.data.pop(i)

    def remove(self, item: I) -> None:
        i = self.data.index(item)
        del self.data[i]
        del self._other_list.data[i]

    def clear(self) -> None:
        super().clear()
        self._other_list.data.clear()
        
    def copy(self) -> list[I]:  # type: ignore[override]
        # Return a copy of the underlying data, NOT a new instance of this class
        return self.data.copy()
        
    def reverse(self) -> None:
        super().reverse()
        self._other_list.reverse()

    def sort(self, /, *args: Any, **kwds: Any) -> None:
        super().sort(*args, **kwds)
        for i, elem in enumerate(self):
            self._other_list.data[i] = self.transform(elem)

    def extend(self: C, other: Union[C, Iterable[I]]) -> None:
        self.__iadd__(other)


# Initial type for the parent, transformed type for the child.
X = TypeVar('X')

# Transformed type for the parent, initial type for  the child.
Y = TypeVar('Y')

# Return type for methods returning self
P = TypeVar('P', bound='AbstractListPairParent[Any, Any]')



class AbstractListPairParent(AbstractListPairItem[X, Y]):
    __slots__: Sequence[str] = tuple()
    
    child_cls: type[AbstractListPairChild[Y, X]] = NotImplemented
    
    def __new__(cls: type[P], initlist: Optional[Iterable[X]] = None) -> P:
        if not hasattr(cls, 'child_cls'): 
            raise NotImplementedError(
                "'ListPairParent' subclasses must have a 'child_cls' attribute"
                )
        return super().__new__(cls)  # type: ignore[no-any-return]
    
    def __init__(self, initlist: Optional[Iterable[X]] = None) -> None:
        super().__init__(initlist)
        self._other_list = self.child_cls(
            self, 
            [self.transform(x) for x in self.data]
        )



class AbstractListPairChild(AbstractListPairItem[Y, X]):
    __slots__: Sequence[str] = tuple()
    
    def __init__(
        self, 
        parent: AbstractListPairParent[X, Y], 
        initlist: Optional[Iterable[Y]] = None
    ) -> None:
        
        super().__init__(initlist)
        self._other_list = parent
        


### CONCRETE IMPLEMENTATION ###
        

# Return type for methods returning self 
L = TypeVar('L', bound='ListKeepingTrackOfSquares')


# We have to define the child before we define the parent,
# since the parent creates the child
class SquaresList(AbstractListPairChild[int, int]):
    __slots__: Sequence[str] = tuple()
    
    _other_list: ListKeepingTrackOfSquares
    
    @staticmethod
    def transform(value: int) -> int:
        return int(pow(value, 0.5))

    @property
    def sqrt(self) -> ListKeepingTrackOfSquares:
        return self._other_list


class ListKeepingTrackOfSquares(AbstractListPairParent[int, int]):
    __slots__: Sequence[str] = tuple()
    
    _other_list: SquaresList
    child_cls = SquaresList
    
    @classmethod
    def from_squares(cls: type[L], child_list: Iterable[int]) -> L:
        return cls([cls.child_cls.transform(x) for x in child_list])
    
    @staticmethod
    def transform(value: int) -> int:
        return value ** 2
        
    @property
    def squared(self) -> SquaresList:
        return self._other_list



class SomeClass:
    def __init__(self, n: int) -> None:
        self.list = range(0, n)  # type: ignore[assignment]

    @property
    def list(self) -> ListKeepingTrackOfSquares:
        return self._list
        
    @list.setter
    def list(self, val: Iterable[int]) -> None:
        self._list = ListKeepingTrackOfSquares(val)

    @property
    def listsquare(self) -> SquaresList:
        return self.list.squared
        
    @listsquare.setter
    def listsquare(self, val: Iterable[int]) -> None:
        self.list = ListKeepingTrackOfSquares.from_squares(val)


s = SomeClass(10)
Alex Waygood
  • 6,304
  • 3
  • 24
  • 46
  • 1
    Thanks Alex! It looks somewhat complex, I think I will stick with the explicit update by invoking the setter, but it's nice to see how this could be implemented. – ivankeller Aug 09 '21 at 20:34