如何暗示数字*类型*(即数字的子类)——而不是数字本身?

How to hint at number *types* (i.e. subclasses of Number) - not numbers themselves?

假设我想写一个函数接受Python中任意类型的数字,我可以这样注释它:

from numbers import Number

def foo(bar: Number):
    print(bar)

将这个概念更进一步,我正在编写接受数字类型的函数,即 intfloatnumpy dtypes 作为参数。目前,我正在写:

from typing import Type

def foo(bar: Type):
    assert issubclass(bar, Number)
    print(bar)

我想我可以用 NumberType 代替 Type(类似于 NotImplementedType 和朋友 re-introduced in Python 3.10),因为所有数字类型都是 [= 的子类20=]:

from numbers import Number
import numpy as np

assert issubclass(int, Number)
assert issubclass(np.uint8, Number)

事实证明(或者至少据我所知),Python (3.9) 中没有通用 NumberType 这样的东西:

>>> type(Number)
abc.ABCMeta

是否有一种干净的方法(即没有运行时检查)来实现所需类型的注释?

没有通用的方法可以做到这一点。数字一开始就不是严格相关的,它们的类型就更少了。


虽然 numbers.Number 看起来像是“数字类型”,但它并不通用。例如,decimal.Decimal is explicitly not a numbers.Number as either subclass, subtype or virtual subclass. Specifically for typing, numbers.Number is not endorsed by PEP 484 -- Type Hints.

为了有意义地键入提示“数字”,必须明确定义该上下文中的数字。这可能是一个预先存在的数字类型集,例如 int <: float <: complex,一个 typing.Union/TypeVar 的数字类型,一个 typing.Protocol 定义操作和代数结构,或类似的。

from typing import TypeVar
from decimal import Decimal
from fractions import Fraction

#: typevar of rational numbers if we squint real hard
Q = TypeVar("Q", float, Decimal, Fraction)

综上所述,“数字类型的类型”更没有意义。即使是 specific numbers.Number 实际上也没有任何特征:它不能转换为具体类型,也不能实例化为有意义的数字。

而是使用“一些类型的数字”:

from typing import Type

def zero(t: Type[Q]) -> Q:
    return t()  # all Type[Q]s can be instantiated without arguments

print(zero(Fraction))

如果 Type 的唯一目标是创建实例,则最好改为请求 Callable。这涵盖了类型和工厂功能。

def one(t: Callable[[int], Q]) -> Q:
    return t(1)

一般来说,我们如何提示classes,而不是classes的实例?


一般来说,如果我们想告诉类型检查器某个 class 的任何 实例 (或子类的任何 实例class of that class) 应该被接受为函数的参数,我们这样做:

def accepts_int_instances(x: int) -> None:
    pass


class IntSubclass(int):
    pass


accepts_int_instances(42) # passes MyPy (an instance of `int`)
accepts_int_instances(IntSubclass(666)) # passes MyPy (an instance of a subclass of `int`)
accepts_int_instances(3.14) # fails MyPy (an instance of `float` — `float` is not a subclass of `int`)

Try it on MyPy playground here!

另一方面,如果我们有一个 class C,我们想暗示 class C 本身(或 C 的子 class)应该作为参数传递给函数,我们使用 type[C] 而不是 C。 (在 Python <= 3.8 中,您需要使用 typing.Type 而不是内置的 type 函数,但是从 Python 3.9 和 PEP 585 开始,我们可以直接参数化 type。)

def accepts_int_and_subclasses(x: type[int]) -> None:
    pass


class IntSubclass(int):
    pass


accepts_int_and_subclasses(int) # passes MyPy 
accepts_int_and_subclasses(float) # fails Mypy (not a subclass of `int`)
accepts_int_and_subclasses(IntSubclass) # passes MyPy

我们如何注释一个函数来表示 any numeric class 应该被某个参数接受?

intfloat 和所有 numpy 数值类型都是 numbers.Number 的子class,所以我们应该能够如果我们想说所有数字 class 都是允许的,请使用 type[Number]

至少,Python floatintNumber 的子class :

>>> from numbers import Number 
>>> issubclass(int, Number)
True
>>> issubclass(float, Number)
True

如果我们使用 运行time 类型检查库,例如 typeguard,使用 type[Number] 似乎工作正常:

>>> from typeguard import typechecked
>>> from fractions import Fraction
>>> from decimal import Decimal
>>> import numpy as np
>>>
>>> @typechecked
... def foo(bar: type[Number]) -> None:
...     pass
... 
>>> foo(str)
Traceback (most recent call last):
TypeError: argument "bar" must be a subclass of numbers.Number; got str instead
>>> foo(int)
>>> foo(float)
>>> foo(complex)
>>> foo(Decimal)
>>> foo(Fraction)
>>> foo(np.int64)
>>> foo(np.float32)
>>> foo(np.ulonglong)
>>> # etc.

但是等等!如果我们尝试将 type[Number]static 类型检查器一起使用,它似乎不起作用。如果我们通过 MyPy 运行 以下片段,它会为每个 class 引发错误,除了 fractions.Fraction:

from numbers import Number
from fractions import Fraction
from decimal import Decimal


NumberType = type[Number]


def foo(bar: NumberType) -> None:
    pass


foo(float)  # fails 
foo(int)  # fails 
foo(Fraction)  # succeeds!
foo(Decimal)  # fails 

Try it on MyPy playground here!

Python 肯定不会骗我们说 floatintNumber 的子class。怎么回事?




为什么 type[Number] 不能作为数字 classes

的静态类型提示

虽然 issubclass(float, Number)issubclass(int, Number) 的计算结果都是 True,但实际上 floatint 都不是“严格的”子 [= numbers.Number 的 452=]。 numbers.Number 是一个抽象基 Class,int and float 都注册为 Number 的“虚拟子classes”。这导致 Python 在 运行 时间 floatint 识别为 [= 的“subclasses” 34=],即使 Number 不在其中任何一个的方法解析顺序中。

See this Whosebug question for an explanation of what a class's "method resolution order", or "mro", is.

>>> # All classes have `object` in their mro
>>> class Foo: pass
>>> Foo.__mro__
(<class '__main__.Foo'>, <class 'object'>)
>>>
>>> # Subclasses of a class have that class in their mro
>>> class IntSubclass(int): pass
>>> IntSubclass.__mro__
(<class '__main__.IntSubclass'>, <class 'int'>, <class 'object'>)
>>> issubclass(IntSubclass, int)
True
>>>
>>> # But `Number` is not in the mro of `int`...
>>> int.__mro__
(<class 'int'>, <class 'object'>)
>>> # ...Yet `int` still pretends to be a subclass of `Number`!
>>> from numbers import Number 
>>> issubclass(int, Number)
True
>>> #?!?!!??

What's an Abstract Base Class? Why is numbers.Number an Abstract Base Class? What's "virtual subclassing"?

问题是 MyPy does not understand the "virtual subclassing" mechanism that ABCs use (and, perhaps never will).

MyPy 确实理解标准库中的一些 ABC。例如,MyPy knows that list is a subtype of collections.abc.MutableSequence, even though MutableSequence is an ABC, and list is only a virtual subclass of MutableSequence. 然而,MyPy onlylist 理解为 MutableSequence 的子类型,因为我们一直在撒谎MyPy 关于 list.

的方法解析顺序

MyPy 与所有其他主要类型检查器一起使用 typeshed repository for its static analysis of the classes and modules found in the standard library. If you look at the stub for list in typeshed, you'll see that list is given as being a direct subclass of collections.abc.MutableSequence. That's not true at all — MutableSequence is written in pure Python, whereas list is an optimised data structure written in C. But for static analysis, it's useful for MyPy to think that this is true. Other collections classes in the standard library (for example, tuple, set, and dict) are special-cased by typeshed in much the same way, but numeric types such as int and float 中未找到的存根。


如果我们在集合 classes 方面对 MyPy 撒谎,为什么我们不在数字 classes 方面也对 MyPy 撒谎?

很多人(包括我!)认为我们应该这样做,并且关于是否应该进行此更改的讨论已经进行了很长时间(例如 typeshed proposal, MyPy issue)。但是,这样做有各种复杂性。

Credit goes to @chepner in the




可能的解决方案:使用 duck-typing


一个可能的(虽然有点 icky)解决方案可能是使用 typing.SupportsFloat.


SupportsFloat 是一个 运行 时间可检查的协议,它有一个 abstractmethod__float__。这意味着任何具有 __float__ 方法的 classes 都被识别为子类型——在 运行 时间 和静态类型检查器 SupportsFloat,即使 SupportsFloat 不在 class 的方法解析顺序中。

What's a protocol? What's duck-typing? How do protocols do what they do? Why are some protocols, but not all protocols, checkable at runtime?

注意:虽然用户自定义协议只在Python >= 3.8中可用,但SupportsFloat自模块添加到typing以来一直在Python 3.5.

中的标准库

这个解决方案的优点

  1. 全面支持*所有主要数字类型:fractions.Fractiondecimal.Decimalintfloatnp.int32np.int16, np.int8, np.int64, np.int0, np.float16, np.float32, np.float64, np.float128, np.intc, np.uintc, np.int_, np.uint, np.longlong, np.ulonglong, np.half, np.single, np.double , np.longdouble, np.csingle, np.cdouble, 和 np.clongdouble 都有一个 __float__ 方法。

  2. 如果我们将函数参数注释为 type[SupportsFloat]MyPy correctly accepts* the types that conform to the protocol, and correctly rejects the types that do not conform to the protocol.

  3. 这是一个相当的通用解决方案——您不需要显式枚举您希望接受的所有可能类型。

  4. 适用于静态类型检查器和 运行时间类型检查库,例如 typeguard.

此解决方案的缺点

  1. 感觉就像(而且是)黑客。拥有 __float__ 方法并不是任何人对摘要中定义“数字”的合理想法。

  2. Mypy 无法将 complex 识别为 SupportsFloat 的子类型。 complex 实际上在 Python <= 3.9 中有一个 __float__ 方法。但是,它在 typeshed stub for complex. Since MyPy (along with all other major type-checkers) uses typeshed stubs for its static analysis, this means it is unaware that complex has this method. complex.__float__ is likely omitted from the typeshed stub due to the fact that the method always raises TypeError; for this reason, the __float__ method has in fact been removed from the complex class in Python 3.10.

    中没有 __float__ 方法
  3. 任何用户定义的 class,即使它不是数字 class,也可能定义 __float__。事实上,标准库中甚至有几个非数字的 class 定义了 __float__。例如,虽然 Python 中的 str 类型(用 C 编写)没有 __float__ 方法,但 collections.UserString (用纯 Python) 做。 (The source code for str is here, and the source code for collections.UserString is here.)


用法示例

除了 complex:

我测试过的所有数字类型都通过了 MyPy
from typing import SupportsFloat


NumberType = type[SupportsFloat]


def foo(bar: NumberType) -> None:
    pass

Try it on MyPy playground here!

如果我们也希望 complex 也被接受,对此解决方案的天真调整就是使用以下代码片段,特殊外壳 complex。这满足了我能想到的每种数字类型的 MyPy。我还将 type[Number] 放入类型提示中,因为它 可能 有助于捕捉 确实 的假设 class ] 直接继承自 numbers.Number 没有 __float__ 方法。我不知道为什么有人会写这样的 class,但是 一些 class 直接继承自 numbers.Number(例如 fractions.Fraction), and it certainly would be theoretically possible to create a direct subclass of Number without a __float__ method. Number itself is an empty class that has no methods — 它的存在仅仅是为了为标准库中的其他数字 class 提供一个“虚拟基础 class”。

from typing import SupportsFloat, Union 
from numbers import Number


NumberType = Union[type[SupportsFloat], type[complex], type[Number]]

# You can also write this more succinctly as:
# NumberType = type[Union[SupportsFloat, complex, Number]]
# The two are equivalent.

# In Python >= 3.10, we can even write it like this:
# NumberType = type[SupportsFloat | complex | Number]
# See PEP 604: https://www.python.org/dev/peps/pep-0604/


def foo(bar: NumberType) -> None:
    pass

Try it on MyPy playground here!

翻译成英文,NumberType这里相当于:

Any class, if and only if:

  1. It has a __float__ method;
  2. AND/OR it is complex;
  3. AND/OR it is a subclass of complex;
  4. AND/OR it is numbers.Number;
  5. AND/OR it is a "strict" (non-virtual) subclass of numbers.Number.

我不认为这是 complex 问题的“解决方案”——它更像是一种变通方法。 complex 的问题说明了这种方法的一般危险。第三方库中可能还有其他不常见的数字类型,例如,不直接 subclass numbers.Number 或具有 __float__ 方法。事先知道它们可能是什么样子,并且将它们全部特殊化是非常困难的。




附录


为什么SupportsFloat而不是typing.SupportsInt

fractions.Fraction 有一个 __float__ 方法 (inherited from numbers.Rational) 但没有 __int__ 方法。


为什么SupportsFloat而不是SupportsAbs

甚至 complex 也有一个 __abs__ 方法,所以 typing.SupportsAbs 乍一看似乎是一个很有前途的选择!但是,标准库中还有其他几个 classes 具有 __abs__ 方法但没有 __float__ 方法,并且争论它们都是数字是一种延伸classes。 (datetime.timedelta 对我来说 感觉 不是很像数字。)如果你使用 SupportsAbs 而不是 SupportsFloat,你可能会撒网太宽了,允许各种非数字 classes.


为什么SupportsFloat而不是SupportsRound

作为 SupportsFloat 的替代方案,您还可以考虑使用 typing.SupportsRound,它接受所有具有 __round__ 方法的 classes。这与 SupportsFloat 一样全面(它涵盖了 complex 以外的所有主要数字类型)。它还具有 collection.UserString 没有 __round__ 方法的优点,而如上所述,它确实有 __float__ 方法。最后,第三方非数字 classes 似乎不太可能包含 __round__ 方法。

但是,如果您选择 SupportsRound 而不是 SupportsFloat,在我看来,您将 运行 排除有效第三方数字的风险更大 class是的,无论出于何种原因,都没有定义 __round__.

“有一个 __float__ 方法”和“有一个 __round__ 方法”对于 class 是一个“数字”的含义的定义都很差。然而,前者感觉比后者更接近“真实”定义。因此,与 __round__方法。

如果您想在确保您的函数接受有效的第三方数字类型时“特别安全”,我看不出有什么特别的危害在扩展 NumberType 甚至 进一步 SupportsRound:

from typing import SupportsFloat, SupportsRound, Union
from numbers import Number

NumberType = Union[type[SupportsFloat], type[SupportsRound], type[complex], type[Number]]

但是,考虑到任何具有 __round__ 方法的类型很可能具有还有一个 __float__ 方法。


*...除了complex

这不是完全原始问题的答案。 (Alex Waygood's answer is properly selected as responsive.) However, I have attempted to generalize my own work-arounds for the sharp edges between numbers and typing in Python. Those work-arounds now live in numerary (having extracted it via c-section from dyce,构思的地方)。

我没有在命名上花很多时间,希望它是短暂的。 Docs are online. It should be considered experimental, but it is rapidly approaching stability. Feedback, suggestions, and contributions 非常感谢。