Проверка ввода при изменении класса данных

В Python 3.7 есть эти новые контейнеры класса данных, которые в основном похожи на изменяемые именованные кортежи. Предположим, я создаю класс данных, предназначенный для представления человека. Я могу добавить проверку ввода с помощью функции __post_init__() следующим образом:

@dataclass
class Person:
    name: str
    age: float

    def __post_init__(self):
        if type(self.name) is not str:
            raise TypeError("Field 'name' must be of type 'str'.")
        self.age = float(self.age)
        if self.age < 0:
            raise ValueError("Field 'age' cannot be negative.")

Это позволит получить хорошие входные данные через:

someone = Person(name="John Doe", age=30)
print(someone)

Person(name='John Doe', age=30.0)

В то время как все эти неверные входные данные вызовут ошибку:

someone = Person(name=["John Doe"], age=30)
someone = Person(name="John Doe", age="thirty")
someone = Person(name="John Doe", age=-30)

Однако, поскольку классы данных изменяемы, я могу сделать это:

someone = Person(name="John Doe", age=30)
someone.age = -30
print(someone)

Person(name='John Doe', age=-30)

Таким образом, обход проверки ввода.

Итак, как лучше всего убедиться, что поля класса данных не изменились во что-то плохое после инициализации?


person dain    schedule 02.02.2019    source источник
comment
Используйте @dataclass(frozen=True), чтобы сделать его неизменным   -  person juanpa.arrivillaga    schedule 02.02.2019
comment
@juanpa.arrivillaga, что в первую очередь противоречит цели использования класса данных. Если бы мне нужен был неизменяемый контейнер данных, я бы просто использовал namedtuple. Я намерен иметь возможность обновлять поля через некоторое время после инициализации переменной.   -  person dain    schedule 02.02.2019
comment
Что ж, namedtuples являются кортежами, @dataclass — это просто декоратор, который позволяет вам пропустить написание большого количества шаблонов для создания классов, которые часто встречаются, это не просто изменяемый namedtuple. Но тогда, я полагаю, вам пришлось бы скрывать свои атрибуты за property или чем-то еще, однако это убрало бы часть приятности класса данных для начала.   -  person juanpa.arrivillaga    schedule 02.02.2019


Ответы (3)


Классы данных — это механизм, обеспечивающий инициализацию по умолчанию для принятия атрибутов в качестве параметров и красивое представление, а также некоторые тонкости, такие как хук __post_init__.

К счастью, они не связываются ни с каким другим механизмом доступа к атрибутам в Python, и вы по-прежнему можете создавать свои атрибуты dataclasses как дескрипторы property или собственный класс дескрипторов, если хотите. Таким образом, любой доступ к атрибуту будет проходить через ваши функции получения и установки автоматически.

Единственный недостаток использования встроенного property по умолчанию заключается в том, что вы должны использовать его «по-старому», а не с синтаксисом декоратора, который позволяет создавать аннотации для ваших атрибутов.

Таким образом, «дескрипторы» — это специальные объекты, назначенные атрибутам класса в Python таким образом, что любой доступ к этому атрибуту вызовет методы дескрипторов __get__, __set__ или __del__. Встроенный property удобен для создания дескриптора, передающего от 1 до 3 функций, которые будут вызываться из этих методов.

Итак, без пользовательского дескриптора вы можете сделать:

@dataclass
class MyClass:
   def setname(self, value):
       if not isinstance(value, str):
           raise TypeError(...)
       self.__dict__["name"] = value
   def getname(self):
       return self.__dict__.get("name")
   name: str = property(getname, setname)
   # optionally, you can delete the getter and setter from the class body:
   del setname, getname

Используя этот подход, вам придется писать доступ к каждому атрибуту как два метода/функции, но вам больше не нужно будет писать свой __post_init__: каждый атрибут будет проверять себя.

Также обратите внимание, что в этом примере был использован обычный подход к сохранению атрибутов в файле экземпляра __dict__. В примерах в Интернете практика заключается в использовании обычного доступа к атрибутам, но с добавлением перед именем _. Это приведет к тому, что эти атрибуты загрязнят dir в вашем последнем экземпляре, а частные атрибуты останутся незащищенными.

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

def positive_validator(name, value):
    if value <= 0:
        raise ValueError(f"values for {name!r}  have to be positive")

class MyAttr:
     def __init__(self, type, validators=()):
          self.type = type
          self.validators = validators

     def __set_name__(self, owner, name):
          self.name = name

     def __get__(self, instance, owner):
          if not instance: return self
          return instance.__dict__[self.name]

     def __delete__(self, instance):
          del instance.__dict__[self.name]

     def __set__(self, instance, value):
          if not isinstance(value, self.type):
                raise TypeError(f"{self.name!r} values must be of type {self.type!r}")
          for validator in self.validators:
               validator(self.name, value)
          instance.__dict__[self.name] = value

#And now

@dataclass
class Person:
    name: str = MyAttr(str)
    age: float = MyAttr((int, float), [positive_validator,])

Вот и все — создание собственного класса дескриптора требует немного больше знаний о Python, но приведенный выше код должен быть удобен для использования даже в производственной среде — вы можете его использовать.

Обратите внимание, что вы можете легко добавить множество других проверок и преобразований для каждого из ваших атрибутов, а код в самом __set_name__ можно изменить так, чтобы он анализировал __annotations__ в классе owner для автоматического принятия к сведению типов, чтобы параметр type не нужны для самого класса MyAttr. Но, как я уже говорил, вы можете сделать это настолько изощренным, насколько захотите.

person jsbueno    schedule 02.02.2019
comment
Обратите внимание, что использование property заставляет его вести себя как поле со значением по умолчанию, то есть его нельзя использовать перед полем без значения по умолчанию (TypeError: аргумент не по умолчанию 'второй' следует за аргументом по умолчанию). В итоге я использовал __setattr__ в обернутом классе данных, чтобы аннулировать некоторый кеш, если установлено поле/атрибут. - person blueyed; 26.02.2019
comment
Да - классы данных будут принимать любое поле с дескриптором как имеющее значение по умолчанию - единственный способ изменить это - назначить дескрипторы после запуска декоратора @dataclass - для этого потребуется как другой декоратор, так и способ аннотировать сами дескрипторы. - person jsbueno; 19.04.2019
comment
@jsbueno, я только начинаю понимать классы Python, но правильно ли я понимаю, что в вашем первом примере "name" следует заключать в кавычки внутри self.__dict__.get(name)? - person bland328; 16.05.2019
comment
Да, это должно было быть процитировано. исправляюсь сейчас. - person jsbueno; 16.05.2019
comment
Вместо того, чтобы явно создавать свой собственный класс дескриптора, может быть проще, в зависимости от того, что вы делаете, просто использовать встроенную функцию property() для его создания — аналогично функции typed_property(), показанной на этом ответ мой. - person martineau; 23.04.2020

Возможно, заблокируйте атрибут с помощью геттеров и сеттеров вместо того, чтобы изменять атрибут напрямую. Если вы затем извлечете свою логику проверки в отдельный метод, вы сможете проверить ее таким же образом как из вашего сеттера, так и из функции __post_init__.

person benjarwar    schedule 02.02.2019

Простое и гибкое решение может заключаться в переопределении метода the__setattr__:

@dataclass
class Person:
    name: str
    age: float

    def __setattr__(self, name, value):
        if name == 'age':
            assert value > 0, f"value of {name} can't be negative: {value}"
        self.__dict__[name] = value
person creyesk    schedule 19.10.2020
comment
Пока это работает, это не масштабируется. Лучше использовать что-то более мета-/интроспективное. - person Rebs; 26.10.2020
comment
@Rebs Почему это не масштабируется? - person creyesk; 27.10.2020