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

2024-06-28 20:33:44 发布

您现在位置:Python中文网/ 问答频道 /正文

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

from numbers import Number

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

更进一步,我编写的函数接受数字类型,即intfloatnumpy数据类型作为参数。目前,我正在写:

from typing import Type

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

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

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

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


Tags: 函数fromimport类型numberfoodeftype
2条回答

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


虽然numbers.Number看起来像是“数字的类型”,但它不是通用的。例如,^{} is explicitly not a ^{}作为子类、子类型或虚拟子类。特别是对于键入,numbers.Numbernot endorsed by PEP 484 Type Hints

为了有意义地键入提示“数字”,必须明确定义该上下文中的数字。这可能是预先存在的数值类型集,例如int<;:float<;:complex,数值类型的{}/{},定义运算和代数结构的{},或类似

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)

所有这些都表明,“数字的类型”甚至没有什么意义。即使是特定的numbers.Number实际上也没有任何特性:它不能转换为具体类型,也不能实例化为有意义的数字

相反,使用“数字的类型”sometype of 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)

通常,我们如何提示,而不是类的实例


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

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!

另一方面,如果我们有一个类C,并且我们想要提示类C本身(或者C的子类)应该作为参数传递给函数,那么我们使用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

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

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

至少,PythonfloatintNumber的子类:

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

如果我们使用运行时类型检查库,比如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]静态类型检查器一起使用,它似乎不起作用。如果我们通过MyPy运行以下代码段,它会为每个类(除了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的子类。发生什么事了




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


虽然issubclass(float, Number)issubclass(int, Number)都计算为True,但floatint实际上都不是numbers.Number的“严格”子类numbers.Number是一个抽象基类,^{}^{}都注册为Number的“虚拟子类”。这会导致Python在运行时floatint识别为Number的“子类”,即使Number不在它们的方法解析顺序中

See this StackOverflow 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"?

问题是{a5}(和,{a6})

MyPy确实了解标准库中的一些ABC。例如,MyPy knows that ^{} is a subtype of ^{},尽管MutableSequence是ABC,但是^{} is only a virtual subclass of ^{}.只有知道listMutableSequence的一个亚型,因为我们一直在对MyPy撒谎关于^{的方法解析顺序

MyPy与所有其他主要类型检查器一起,使用typeshed repository中的存根对标准库中的类和模块进行静态分析。如果你看一下the stub for ^{} in typeshed,你会发现listcollections.abc.MutableSequence的一个直接子类。那根本不是真的-^{} is written in pure Python,^{} is an optimised data structure written in C。但是对于静态分析,MyPy认为这是真的是有用的。标准库中的其他集合类(例如,^{}^{}^{})按typeshed以大致相同的方式进行特殊大小写,但^{}^{}等数字类型则不是


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

很多人(包括我!)认为我们应该这样做,关于是否应该做出这种改变的讨论已经持续了很长时间(例如,typeshed proposalMyPy issue)。然而,这样做有各种复杂因素

Credit goes to @chepner in the comments for finding the link to the MyPy issue.




可能的解决方案:使用duck类型


这里一个可能的解决方案(尽管稍微icky)可能是使用typing.SupportsFloat


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

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模块自从添加到Python 3.5中的标准库之后就一直在typing模块中


此解决方案的优点

  1. 所有主要数字类型的综合支持*所有主要数字类型的综合支持*所有主要数字类型的综合支持*所有主要数字类型的综合支持*所有主要数字类型::^{},{},{},,{},,{>cd19>},,{},,{}},,,,{}},,,,,,{},{},},{},},>{}、{}、{}、{}、{}和{}它们都有一个__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中有一个__float__方法<;=3.9. 但是,它在typeshed stub for ^{}.中没有__float__方法,因为MyPy(以及所有其他主要类型检查器)使用类型化存根进行静态分析,这意味着它不知道complex有此方法complex.__float__可能从typeshed存根中省略,因为该方法总是引发TypeError;因此,the ^{} method has in fact been removed from the ^{} class in Python 3.10

  3. 任何用户定义的类,即使不是数值类,也可能定义__float__。事实上,标准库中甚至有几个非数值类定义了__float__。例如,尽管Python中的str类型(用C编写)没有__float__方法,但是collections.UserString(用纯Python编写)有。(The source code for ^{} is herethe source code for ^{} 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]抛出到类型提示中,因为它可以捕获一个假设类,该类直接继承自numbers.Number没有__float__方法。我不知道为什么有人会编写这样一个类,但是有一些类直接继承自numbers.Number(例如^{}),从理论上讲,创建Number的直接子类而不使用__float__方法肯定是可能的^{} itself is an empty class that has no methods-它的存在只是为了为标准库中的其他数值类提供“虚拟基类”

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问题的“解决方案”——它更多的是一种变通方法。与{}有关的问题说明了这种方法的危险性。例如,在第三方库中可能存在其他不寻常的数值类型,它们不直接子类numbers.Number或具有__float__方法。要事先知道它们可能是什么样子是非常困难的,特别是在所有的情况下




附录


为什么SupportsFloat而不是^{}

fractions.Fraction__float__方法(inherited from ^{}),但没有__int__方法


为什么SupportsFloat而不是^{}

甚至complex也有一个__abs__方法,所以typing.SupportsAbs乍一看似乎是一个很有前途的选择!但是,标准库中还有其他几个类有__abs__方法,而没有__float__方法,如果说它们都是数值类,那就太夸张了。(^{}对我来说不太像数字。)如果你使用SupportsAbs而不是SupportsFloat,你可能会把你的网拉得太宽,并允许各种非数字类


为什么SupportsFloat而不是^{}

作为^ {CD57>}的替代,您也可以考虑使用^ {CD148>},它接受所有具有^ {CD149>}方法的类。这与SupportsFloat一样全面(它涵盖了除complex之外的所有主要数字类型)。它的另一个优点是collection.UserString没有__round__方法,而如上所述,它确实有__float__方法。最后,第三方非数值类不太可能包含__round__方法

但是,在我看来,如果您选择SupportsRound而不是SupportsFloat,那么排除有效的第三方数值类的风险会更大,因为任何原因,这些类都没有定义__round__

“Having a __float__方法”和“Having a __round__方法”对于类是“数字”意味着什么都是非常糟糕的定义。然而,前者感觉比后者更接近“真实”的定义。因此,指望第三方数值类具有__float__方法比指望它们具有__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__方法,我会质疑是否真的有必要包含SupportsRound


*…除了complex

相关问题 更多 >