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)