Обновление динамически зависимых атрибутов объекта после мутации

avatar
ivankeller
8 августа 2021 в 17:02
71
1
2

В качестве примера возьмем это классическое решение проблемы обновления атрибутов зависимых объектов:

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]

Работает так, как требуется: при установке нового значения для одного атрибута обновляется другой атрибут:

>>> 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]

Но что, если мы изменим атрибут list вместо установки нового значения?:

>>> 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

Мы хотели бы, чтобы атрибут listsquare был соответствующим образом обновлен, но это не так, потому что сеттеры не вызываются, когда мы изменяем атрибут list.

Конечно, мы могли бы принудительно выполнить обновление, явно вызвав установщик после изменения атрибута, например, выполнив:

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

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

Каков наиболее питонический способ обновления атрибутов при изменении другого атрибута mutable. Как объект может узнать, что один из его атрибутов был изменен?

Источник
Davis Herring
8 августа 2021 в 17:19
1

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

ivankeller
8 августа 2021 в 22:11
0

@DavisHerring Какой шаблон вы имеете в виду под «особым изменяемым типом, чтобы реагировать на такие вещи»? Не могли бы вы уточнить это?

Davis Herring
8 августа 2021 в 23:23
1

Например, ваш собственный тип последовательности, который обновляет противоположный номер в __setitem__. Для практического использования вам необходимо реализовать множество функций (например,, pop и remove, если вам нужен весь интерфейс list). Возможно, было бы проще, чтобы пользовательский тип предоставлял два представления одного базового хранилища данных.

Ответы (1)

avatar
Alex Waygood
9 августа 2021 в 11:25
1

Итак, как сказал Дэвис Херринг в комментариях, это в высшей степени возможно, но не почти как чистое. По сути, вам нужно создать собственную пользовательскую структуру данных, которая поддерживает два списка параллельно, каждый из которых знает о другом, так что, если один обновляется, другой также обновляется. Ниже мой шанс сделать это, что заняло немного больше времени, чем ожидалось. Кажется, работает, но я не проверял его всесторонне.

Я решил наследовать от collections.UserList здесь. Другим вариантом может быть наследование от collections.abc.MutableSequence, что имеет различные плюсы и минусы по сравнению с 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)
ivankeller
9 августа 2021 в 20:34
1

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