Как определить PyCharm-полезный объект значения в Python?

Мне интересно, как определить объект значения в Python. В Википедии: объект ценности - это небольшой объект, который представляет собой простой объект, чье равенство не основано на идентичности: т.е. два объекта значения равны, когда они имеют одинаковое значение, не обязательно являясь одним и тем же объектом ". В Python по существу означает переопределенные методы __eq__ и __hash__, а также неизменность.

Стандартная namedtuple кажется почти идеальным решением, за исключением того, что они не хорошо сочетаются с современной Python IDE, такой как PyCharm. Я имею в виду, что среда IDE действительно не даст никакой полезной информации о классе, определенном как namedtuple. Хотя можно прикрепить docstring к такому классу, используя трюк, подобный этому:

class Point2D(namedtuple("Point2D", "x y")):
    """Class for immutable value objects"""
    pass

просто нет места, где можно задать описание аргументов конструктора и указать их типы. PyCharm достаточно умен, чтобы угадать аргументы для Point2D "constructor", но по типу он слеп.

Этот код содержит некоторую информацию о типе, но это не очень полезно:

class Point2D(namedtuple("Point2D", "x y")):
    """Class for immutable value objects"""
    def __new__(cls, x, y):
        """
        :param x: X coordinate
        :type x: float

        :param y: Y coordinate
        :type y: float

        :rtype: Point2D
        """
        return super(Point2D, cls).__new__(cls, x, y)

point = Point2D(1.0, 2.0)

PyCharm будет видеть типы при построении новых объектов, но не поймает, что point.x и point.y являются float, поэтому не помогли бы обнаружить их неправильное использование. И мне также не нравится идея переопределения "магических" методов на обычной основе.

Итак, я ищу что-то, что будет:

  • так же легко определить, как обычный класс Python или namedtuple
  • предоставлять семантику ценности (равенство, хэши, неизменность)
  • легко документировать, что будет хорошо работать с IDE

Идеальное решение может выглядеть так:

class Point2D(ValueObject):
    """Class for immutable value objects"""
    def __init__(self, x, y):
        """
        :param x: X coordinate
        :type x: float

        :param y: Y coordinate
        :type y: float
        """
        super(Point2D, self).__init__(cls, x, y)

Или что:

class Point2D(object):
    """Class for immutable value objects"""

    __metaclass__ = ValueObject

    def __init__(self, x, y):
        """
        :param x: X coordinate
        :type x: float

        :param y: Y coordinate
        :type y: float
        """
        pass

Я пытался найти что-то подобное, но безуспешно. Я подумал, что было бы разумно попросить о помощи, прежде чем реализовать ее самостоятельно.

UPDATE: С помощью user4815162342 мне удалось придумать что-то, что работает. Здесь код:

class ValueObject(object):
    __slots__ = ()

    def __repr__(self):
        attrs = ' '.join('%s=%r' % (slot, getattr(self, slot)) for slot in self.__slots__)
        return '<%s %s>' % (type(self).__name__, attrs)

    def _vals(self):
        return tuple(getattr(self, slot) for slot in self.__slots__)

    def __eq__(self, other):
        if not isinstance(other, ValueObject):
            return NotImplemented
        return self.__slots__ == other.__slots__ and self._vals() == other._vals()

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(self._vals())

    def __getstate__(self):
        """
        Required to pickle classes with __slots__
        Must be consistent with __setstate__
        """
        return self._vals()

    def __setstate__(self, state):
        """
        Required to unpickle classes with __slots__
        Must be consistent with __getstate__
        """
        for slot, value in zip(self.__slots__, state):
            setattr(self, slot, value)

Это очень далеко от идеального решения. Объявление класса выглядит следующим образом:

class X(ValueObject):
    __slots__ = "a", "b", "c"

    def __init__(self, a, b, c):
        """
        :param a:
        :type a: int
        :param b:
        :type b: str
        :param c:
        :type c: unicode
        """
        self.a = a
        self.b = b
        self.c = c

Всего четыре раза перечислить все атрибуты: в __slots__, в аргументах ctor, в docstring и в корпусе ctor. До сих пор я понятия не имею, как сделать это менее неудобным.

Ответ 1

Ваши требования, хотя и тщательно выраженные, мне не совсем понятны, отчасти потому, что я не использую GUI PyCharm. Но вот попытка:

class ValueObject(object):
    __slots__ = ()

    def __init__(self, *vals):
        if len(vals) != len(self.__slots__):
            raise TypeError, "%s.__init__ accepts %d arguments, got %d" \
                % (type(self).__name__, len(self.__slots__), len(vals))
        for slot, val in zip(self.__slots__, vals):
            super(ValueObject, self).__setattr__(slot, val)

    def __repr__(self):
        return ('<%s[0x%x] %s>'
                % (type(self).__name__, id(self),
                   ' '.join('%s=%r' % (slot, getattr(self, slot))
                            for slot in self.__slots__)))

    def _vals(self):
        return tuple(getattr(self, slot) for slot in self.__slots__)

    def __eq__(self, other):
        if not isinstance(other, ValueObject):
            return NotImplemented
        return self.__slots__ == other.__slots__ and self._vals() == other._vals()

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(self._vals())

    def __setattr__(self, attr, val):
        if attr in self.__slots__:
            raise AttributeError, "%s slot '%s' is read-only" % (type(self).__name__, attr)
        super(ValueObject, self).__setattr__(attr, val)

Использование выглядит так:

class X(ValueObject):
  __slots__ = 'a', 'b'

Это дает вам конкретный класс значений с двумя слотами только для чтения и автогенерированным конструктором __eq__ и __hash__. Например:

>>> x = X(1.0, 2.0, 3.0)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 5, in __init__
TypeError: X.__init__ accepts 2 arguments, got 3
>>> x = X(1.0, 2.0)
>>> x
<X[0x4440a50] a=1.0 b=2.0>
>>> x.a
1.0
>>> x.b
2.0
>>> x.a = 10
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 32, in __setattr__
AttributeError: X slot 'a' is read-only
>>> x.c = 10
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 33, in __setattr__
AttributeError: 'X' object has no attribute 'c'
>>> dir(x)
['__class__', '__delattr__', '__dict__', '__doc__', '__eq__', '__format__', '__getattribute__', '__hash__', '__init__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', '__weakref__', '_vals', 'a', 'b']
>>> x == X(1.0, 2.0)
True
>>> x == X(1.0, 3.0)
False
>>> hash(x)
3713081631934410656
>>> hash(X(1.0, 2.0))
3713081631934410656
>>> hash(X(1.0, 3.0))
3713081631933328131

Если вы хотите, вы можете определить свой собственный __init__ с помощью docstring, который (предположительно) предоставляет вашей среде IDE подсказки типа аннотации.