Python类型提示中输入描述符的示例

149 阅读4分钟

描述符协议允许我们完全定制属性访问。 Python 的文档描述了用文字描述的类型的协议。 让我们看看我们如何把这些写成类型提示。

描述符示例

让我们为这个完整的描述符添加类型提示,它验证了只允许正数。

import math


class PositiveFloat:
    def __set_name__(self, owner, name) -> None:
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        if value <= 0.0 or math.isnan(value) or math.isinf(value):
            raise ValueError(f"{self.name} must be a positive real number.")
        obj.__dict__[self.name] = value

    def __delete__(self, obj) -> None:
        if self.name not in obj.__dict__:
            raise AttributeError(self.name)
        del obj.__dict__[self.name]

快速提醒一下所有这些方法。

  • __set_name__()当我们的描述符被分配到一个类的变量时被调用,它允许我们的描述符实例知道它被分配到哪个名字。

  • __get__() 是在属性访问时调用的。当在类上访问时, 是 ,在这种情况下,返回描述符实例是正常的。当在实例上访问时, 包含该实例。obj None obj

    我们的描述符把它的值存储在实例__dict__ ,所以它的实现是直接的。

  • __set__() 在为属性设置新的值时被调用。 就是这个实例。obj

  • __delete__() 在删除属性时被调用。 再次成为实例。obj

描述符不需要实现所有这些方法。 我们在这里只是用一个完整的例子来涵盖所有的类型提示。

我们可以用以下方法检查我们的描述符的运行情况 python -i example.py:

>>> class Widget:
...     rotations = PositiveFloat()
...
>>>
>>> widget = Widget()
>>> # Test set, get, delete happy paths
>>> widget.rotations = 12.0
>>> widget.rotations
12.0
>>> del widget.rotations
>>> # Setting to an invalid value:
>>> widget.rotations = -1.0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../example.py", line 15, in __set__
    raise ValueError(f"{self.name} must be a positive real number.")
ValueError: rotations must be a positive real number.
>>> # Deleting when it’s already been deleted:
>>> del widget.rotations
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../example.py", line 20, in __delete__
    raise AttributeError(self.name)
AttributeError: rotations

好的很好,现在他们的类型提示怎么样了?

Addimus类型提示

好吧,当我们添加类型提示时,我们得到:

from __future__ import annotations

import math
from typing import cast, overload


class PositiveFloat:
    def __set_name__(self, owner: type[object], name: str) -> None:
        self.name = name

    @overload
    def __get__(self, obj: None, objtype: None) -> PositiveFloat:
        ...

    @overload
    def __get__(self, obj: object, objtype: type[object]) -> float:
        ...

    def __get__(
        self, obj: object | None, objtype: type[object] | None = None
    ) -> PositiveFloat | float:
        if obj is None:
            return self
        return cast(float, obj.__dict__[self.name])

    def __set__(self, obj: object, value: float) -> None:
        if value <= 0.0 or math.isnan(value) or math.isinf(value):
            raise ValueError(f"{self.name} must be a positive real number.")
        obj.__dict__[self.name] = value

    def __delete__(self, obj: object) -> None:
        if self.name not in obj.__dict__:
            raise AttributeError(self.name)
        del obj.__dict__[self.name]

让我们来看看这个:

  • __set_name__()owner 参数是描述符被分配到的类。我们知道它是一个类,但没有别的,所以我们可以用type[object] 来表示 "从object派生的任何类",也就是 "任何类"。

    name 是属性的名称,我们不需要返回任何东西。

  • __get__() 这是最复杂的。它需要使用overload(),以准确地涵盖它的两种调用方式。 我们写出了在类( )和实例( )上调用的两种情况。然后实现将这两种情况的类型结合起来。obj: None``obj: object

    我们需要在我们的return 语句中使用cast(),因为Mypy无法判断__dict__ 中的属性必须是float 。它假设从__dict__ 中获取的动态属性可能是Any 。这是一个合理的假设,但是从我们描述符的实现中我们知道值应该是一个float。(...除非一些顽皮的代码直接修改__dict__ )。

    如果没有cast() ,我们会得到这个错误。

    example.py:24: error: Returning Any from function declared to return "Union[PositiveFloat, float]"
    
  • __set__()__delete__() 都是比较直接的。我们声明实例可以是任何类型,并且设置值必须是float

有限的所有者类

在整个类型提示中,我们用type[object] 表示我们的描述符所依附的类型,用object 表示实例的类型。这是因为我们的描述符不以依附于任何特定的类型为目标。

如果我们想限制描述符对特定类型的依附,我们只需要编辑每个类型的提示,将object 换成该类型。例如,设想我们引入了一个类Validatable 。我们将编辑__set_name__() 中的类型,读作。

def __set_name__(self, owner: type[Validatable], name: str) -> None:
    ...

其他的方法也是如此。

然后,如果我们将我们的描述符分配给一个非Validatable 类。

class Widget:
    rotations = PositiveFloat()


widget = Widget()
widget.rotations = 12.0

Mypy会抱怨说overload 的情况不匹配。

$ mypy --strict example.py
example.py:46: error: No overload variant of "__get__" of "PositiveFloat" matches argument types "Widget", "Type[Widget]"
example.py:46: note: Possible overload variants:
example.py:46: note:     def __get__(self, obj: None, objtype: None) -> PositiveFloat
example.py:46: note:     def __get__(self, obj: Validatable, objtype: Type[Validatable]) -> float
example.py:46: error: Argument 1 to "__set__" of "PositiveFloat" has incompatible type "Widget"; expected "Validatable"
Found 2 errors in 1 file (checked 1 source file)

请注意,Mypy在类的定义中没有看到任何问题,只是在我们使用实例的时候。