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

s-m*_*m-e 29 python type-hinting python-typing

假设我想在Python中编写一个接受任何类型数字的函数,我可以将其注释如下:

from numbers import Number

def foo(bar: Number):
    print(bar)
Run Code Online (Sandbox Code Playgroud)

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

from typing import Type

def foo(bar: Type):
    assert issubclass(bar, Number)
    print(bar)
Run Code Online (Sandbox Code Playgroud)

我想我可以Type用类似的东西代替NumberType(类似于NotImplementedType和朋友,在Python 3.10中重新引入),因为所有数字类型都是以下的子类Number

from numbers import Number
import numpy as np

assert issubclass(int, Number)
assert issubclass(np.uint8, Number)
Run Code Online (Sandbox Code Playgroud)

事实证明(或者至少据我所知),NumberTypePython (3.9) 中不存在泛型这样的东西:

>>> type(Number)
abc.ABCMeta
Run Code Online (Sandbox Code Playgroud)

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

Ale*_*ood 59

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

\n
\n

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

\n
def accepts_int_instances(x: int) -> None:\n    pass\n\n\nclass IntSubclass(int):\n    pass\n\n\naccepts_int_instances(42) # passes MyPy (an instance of `int`)\naccepts_int_instances(IntSubclass(666)) # passes MyPy (an instance of a subclass of `int`)\naccepts_int_instances(3.14) # fails MyPy (an instance of `float` \xe2\x80\x94 `float` is not a subclass of `int`)\n
Run Code Online (Sandbox Code Playgroud)\n
\n

在 MyPy 游乐场上尝试一下吧!

\n
\n

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

\n
def accepts_int_and_subclasses(x: type[int]) -> None:\n    pass\n\n\nclass IntSubclass(int):\n    pass\n\n\naccepts_int_and_subclasses(int) # passes MyPy \naccepts_int_and_subclasses(float) # fails Mypy (not a subclass of `int`)\naccepts_int_and_subclasses(IntSubclass) # passes MyPy\n
Run Code Online (Sandbox Code Playgroud)\n
\n\n
\n
\n

我们如何注释一个函数来表明某个参数应该接受任何数字类?

\n

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

\n

至少,PythonfloatintNumber

\n
>>> from numbers import Number \n>>> issubclass(int, Number)\nTrue\n>>> issubclass(float, Number)\nTrue\n
Run Code Online (Sandbox Code Playgroud)\n

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

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

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

\n
from numbers import Number\nfrom fractions import Fraction\nfrom decimal import Decimal\n\n\nNumberType = type[Number]\n\n\ndef foo(bar: NumberType) -> None:\n    pass\n\n\nfoo(float)  # fails \nfoo(int)  # fails \nfoo(Fraction)  # succeeds!\nfoo(Decimal)  # fails \n
Run Code Online (Sandbox Code Playgroud)\n
\n

在 MyPy 游乐场上尝试一下吧!

\n
\n

当然,Python 不会对我们撒谎,float并且int是它的子类Number. 这是怎么回事?

\n
\n
\n
\n

为什么type[Number]不能用作数字类的静态类型提示

\n
\n

虽然issubclass(float, Number)issubclass(int, Number)都评估为True,但实际上float既不是 的“严格”子类。是一个抽象基类,并且和都注册为 的“虚拟子类” 。这会导致 Python在运行时将和识别为 的“子类” ,即使intnumbers.Numbernumbers.NumberintfloatNumberfloatintNumberNumber不在它们的方法解析顺序中。

\n
\n

请参阅此 StackOverflow 问题,了解类的“方法解析顺序”或“mro”是什么的解释。

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

什么是抽象基类?为什么是numbers.Number抽象基\n类?什么是“虚拟子类化”?

\n\n
\n

问题是MyPy 不理解 ABC 使用的“虚拟子类化”机制(也许永远不会)。

\n

MyPy 确实理解标准库中的一些ABC。例如,MyPy 知道list是 的子类型collections.abc.MutableSequence,尽管MutableSequence它是 ABC,并且list只是 的虚拟子类MutableSequence然而,MyPy理解list为 的子类型MutableSequence ,因为我们一直在关于 的方法解析顺序方面向 MyPy 撒谎list

\n

MyPy 与所有其他主要类型检查器一起使用typeshed 存储库中的存根对标准库中的类和模块进行静态分析。如果您查看in typeshed的存根list,您会发现它list是 的直接子类collections.abc.MutableSequence。这根本不是真的 \xe2\x80\x94MutableSequence是用纯 Python 编写的,list是用 C 编写的优化数据结构但对于静态分析,MyPy认为这是真的是有用的。标准库中的其他集合类(例如tuplesetdict)以大致相同的方式通过 typeshed 进行特殊大小写,但诸如int和 之类的数字类型float则不然。

\n
\n

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

\n

很多人(包括我!)认为我们应该这样做,并且关于是否应该进行此更改的讨论已经持续了很长时间(例如typeshed suggestMyPy issues)。然而,这样做会出现各种复杂情况。

\n
\n

感谢@chepner评论中找到了 MyPy 问题的链接。

\n
\n
\n
\n
\n

可能的解决方案:使用鸭子打字

\n
\n

这里一种可能的(尽管有点恶心)解决方案可能是使用typing.SupportsFloat.

\n
\n

SupportsFloat是一个运行时可检查的协议,具有单个abstractmethod, __float__。这意味着任何具有__float__方法的类都会在运行时和静态类型检查器\xe2\x80\x94 中被识别为 的子类型SupportsFloat,即使SupportsFloat不在类的方法解析顺序中。

\n
\n

什么是协议?什么是鸭子打字?协议如何完成它们的任务?为什么某些协议(而非所有协议)可以在运行时检查?

\n\n
\n

注意:虽然用户定义的协议仅在 Python >= 3.8 中可用,但自从该模块添加到 Python 3.5 的标准库中以来,它就SupportsFloat已经存在于模块中。typing

\n
\n

该解决方案的优点

\n
    \n
  1. 全面支持*所有主要数字类型:fractions.Fraction, decimal.Decimal, int, float, np.int32, np.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__方法。

    \n
  2. \n
  3. 如果我们将函数参数注释为 of type[SupportsFloat]MyPy 会正确接受*符合协议的类型,并正确拒绝不符合协议的类型。

    \n
  4. \n
  5. 这是一个相当通用的解决方案 \xe2\x80\x94 您不需要显式枚举您希望接受的所有可能类型。

    \n
  6. \n
  7. 可与静态类型检查器和运行时类型检查库一起使用,例如typeguard.

    \n
  8. \n
\n

该解决方案的缺点

\n
    \n
  1. 感觉就像(并且确实是)一个黑客。拥有一个__float__方法并不是任何人对抽象中定义“数字”的合理想法。

    \n
  2. \n
  3. Mypy 不识别complex为 的子类型SupportsFloatcomplex事实上,__float__Python <= 3.9 中确实有一个方法。__float__但是,它在的 typeshed 存根complex中没有方法由于 MyPy(以及所有其他主要类型检查器)使用 typeshed 存根进行静态分析,这意味着它不知道有complex此方法。complex.__float__由于该方法总是引发 ; 很可能从 typeshed 存根中省略TypeError;因此,__float__方法实际上已从complexPython 3.10 中的类中删除。

    \n
  4. \n
  5. 任何用户定义的类,即使它不是数字类,也可能定义__float__. 事实上,标准库中甚至有几个定义__float__. 例如,虽然strPython 中的类型(用 C 编写)没有__float__方法,但collections.UserString(用纯 Python 编写)却有。(源代码str在这里源代码collections.UserString在这里。)

    \n
  6. \n
\n
\n

用法示例

\n

对于我测试过的所有数字类型,这都通过了 MyPy,除了complex

\n
from typing import SupportsFloat\n\n\nNumberType = type[SupportsFloat]\n\n\ndef foo(bar: NumberType) -> None:\n    pass\n
Run Code Online (Sandbox Code Playgroud)\n
\n

在 MyPy 游乐场上尝试一下吧!

\n
\n

如果我们complex也想被接受,对此解决方案的一个天真的调整就是使用以下代码片段,special-casing complex。这满足了 MyPy 对于我能想到的每种数字类型的要求。我还投入type[Number]了类型提示,因为捕获一个直接继承且没有方法的假设类可能很有用。我不知道为什么有人会编写这样的类,但是有一些类直接继承自(例如),并且理论上当然可以创建没有方法的直接子类。它本身是一个空类,没有方法\xe2\x80\x94 它的存在只是为了为标准库中的其他数字类提供“虚拟基类”。numbers.Number__float__numbers.Numberfractions.FractionNumber__float__Number

\n
from typing import SupportsFloat, Union \nfrom numbers import Number\n\n\nNumberType = Union[type[SupportsFloat], type[complex], type[Number]]\n\n# You can also write this more succinctly as:\n# NumberType = type[Union[SupportsFloat, complex, Number]]\n# The two are equivalent.\n\n# In Python >= 3.10, we can even write it like this:\n# NumberType = type[SupportsFloat | complex | Number]\n# See PEP 604: https://www.python.org/dev/peps/pep-0604/\n\n\ndef foo(bar: NumberType) -> None:\n    pass\n
Run Code Online (Sandbox Code Playgroud)\n
\n

在 MyPy 游乐场上尝试一下吧!

\n
\n

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

\n
\n

任何类,当且仅当:

\n
    \n
  1. 它有一个__float__方法;
  2. \n
  3. 和/或它是complex
  4. \n
  5. 和/或它是一个子类complex和/或它是;
  6. \n
  7. 和/或它是numbers.Number
  8. \n
  9. 和/或它是“严格”(非虚拟)子类numbers.Number
  10. \n
\n
\n

我不认为这是complex\xe2\x80\x94 问题的“解决方案”,它更像是一种解决方法。这个问题complex说明了这种方法的总体危险性。第三方库中可能存在其他不寻常的数字类型,例如,它们不直接子类化numbers.Number或具有__float__方法。提前知道它们可能是什么样子,并对它们进行特殊处理是极其困难的。

\n
\n
\n
\n

附加物

\n
\n

为什么SupportsFloat而不是typing.SupportsInt

\n

fractions.Fraction有一个__float__方法(继承自numbers.Rational)但没有__int__方法。

\n
\n

为什么SupportsFloat而不是SupportsAbs

\n

甚至complex还有一个__abs__方法,所以typing.SupportsAbs乍一看似乎是一个有前途的替代方案!然而,标准库中还有其他几个类有__abs__方法和没有__float__方法,如果说它们都是数字类就有些牵强了。(对我来说,感觉datetime.timedelta不太像数字。)如果您使用而不是,您将面临撒网太广并允许各种非数字类的风险。SupportsAbsSupportsFloat

\n
\n

为什么SupportsFloat而不是SupportsRound

\n

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

\n

但是,如果您选择SupportsRound而不是SupportsFloat,在我看来,您会冒更大的风险排除有效的第三方数字类,无论出于何种原因,这些数字类都不会定义__round__

\n

“拥有一个__float__方法”和“拥有一个__round__方法”对于类作为“数字”的含义来说都是非常糟糕的定义。然而,前者感觉比后者更接近“真实”的定义。因此,依赖具有方法的第三方数字类比依赖它们具有方法更安全。__float____round__

\n

如果您想在确保函数接受有效的第三方数字类型时“格外安全”,我看不出进一步扩展有任何特别的危害NumberTypeSupportsRound

\n
from typing import SupportsFloat, SupportsRound, Union\nfrom numbers import Number\n\nNumberType = Union[type[SupportsFloat], type[SupportsRound], type[complex], type[Number]]\n
Run Code Online (Sandbox Code Playgroud)\n

但是,我会质疑是否真的有必要包含SupportsRound,因为任何具有__round__方法的类型都很可能具有__float__很可能也

\n
\n

*...除了complex

\n

  • 好的!我怀疑这非常准确地满足了大多数人需要的数字! (8认同)
  • 我慢慢地开始了解问题的根源。感谢您的深入解释。 (3认同)
  • 这是我在这个网站上读过的最深入的答案之一 (3认同)
  • 顺便说一句,感谢@AlexWaygood 的解压。正如我[在其他地方提到的](https://github.com/python/mypy/issues/3186#issuecomment-932828168),这绝对是一个不需要写的值得赏金的回复。但我很感激它就在这里。 (2认同)