如何使用 Python 中元类插入的方法对类进行类型检查?

Posted

技术标签:

【中文标题】如何使用 Python 中元类插入的方法对类进行类型检查?【英文标题】:How to typecheck class with method inserted by metaclass in Python? 【发布时间】:2021-11-23 18:14:06 【问题描述】:

在下面的代码中some_method已经被元类添加了:

from abc import ABC
from abc import ABCMeta
from typing import Type


def some_method(cls, x: str) -> str:
    return f"result x"


class MyMeta(ABCMeta):
    def __new__(mcs, *args, **kwargs):
        cls = super().__new__(mcs, *args, **kwargs)
        cls.some_method = classmethod(some_method)
        return cls


class MyABC(ABC):
    @classmethod
    def some_method(cls, x: str) -> str:
        return x


class MyClassWithSomeMethod(metaclass=MyMeta):
    pass


def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"

不过,MyPy 还是很出乎意料的unhappy about it:

minimal_example.py:27: error: "Type[MyClassWithSomeMethod]" has no attribute "some_method"
Found 1 error in 1 file (checked 1 source file)

有什么优雅的方式告诉类型检查器,类型真的没问题吗?优雅,我的意思是我不需要到处更改这些类型的定义:

class MyClassWithSomeMethod(metaclass=MyMeta): ...

请注意,我不想使用子类化(例如上面代码中的MyABC)。也就是说,我的类将使用metaclass= 定义。

有哪些选择?

我也试过Protocol

from typing import Protocol

class SupportsSomeMethod(Protocol):
    @classmethod
    def some_method(cls, x: str) -> str:
        ...


class MyClassWithSomeMethod(SupportsSomeMethod, metaclass=MyMeta):
    pass


def call_some_method(cls: SupportsSomeMethod) -> str:
    return cls.some_method("A")

但这会导致:

TypeError:元类冲突:派生类的元类必须是其所有基类的元类的(非严格)子类

【问题讨论】:

您在元类的__new__ 方法中将类方法猴子修补到类上,而不是仅仅将方法作为元类中的实例方法,是否有特殊原因? (由于类是其元类的实例,因此元类上的实例方法与类上的类方法非常相似:***.com/questions/59341761/…) 好收获!至少对于我的想法(codereview.stackexchange.com/questions/268544/…),我找不到充分的理由......确实,这些方法可以在元类中,它解决了问题!这可能是答案 它会为我解决它,但寻找相同问题的其他人可能会有不同的情况。 【参考方案1】:

正如the MyPy documentation 中所解释的,MyPy 对元类的支持仅限于此:

Mypy 不会也无法理解任意元类代码。

问题在于,如果您在元类的 __new__ 方法中将方法修补到类上,您可能会将 anything 添加到类的定义中。这对于 Mypy 来说太动态了。

但是,一切都没有丢失!这里有几个选项。

选项 1:将方法静态定义为元类上的实例方法

类是其元类的实例,因此元类上的实例方法 work very similarly 到 classmethods 在类中定义。因此,您可以将minimal_example.py 改写如下,MyPy will be happy:

from abc import ABCMeta
from typing import Type


class MyMeta(ABCMeta):
    def some_method(cls, x: str) -> str:
        return f"result x"


class MyClassWithSomeMethod(metaclass=MyMeta):
    pass


def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"

元类实例方法与您的平均 classmethod 之间的唯一大区别是元类实例方法不适用于使用元类的类实例:

>>> from abc import ABCMeta
>>> class MyMeta(ABCMeta):
...     def some_method(cls, x: str) -> str:
...         return f"result x"
...         
>>> class MyClassWithSomeMethod(metaclass=MyMeta):
...     pass
...     
>>> MyClassWithSomeMethod.some_method('foo')
'result foo'
>>> m = MyClassWithSomeMethod()
>>> m.some_method('foo')
Traceback (most recent call last):
  File "<string>", line 1, in <module>
AttributeError: 'MyClassWithSomeMethod' object has no attribute 'some_method'
>>> type(m).some_method('foo')
'result foo'

选项 2:向 MyPy 承诺一个方法存在,但没有实际定义它

在很多情况下,您将使用元类,因为您希望比静态定义方法更动态。例如,您可能希望动态生成方法定义并将它们添加到使用您的元类的类中。在这些情况下,选项 1 根本行不通。

在这些情况下,另一种选择是“承诺”MyPy 一个方法存在,而不实际定义它。您可以使用标准注释语法来做到这一点:

from abc import ABCMeta
from typing import Type, Callable


def some_method(cls, x: str) -> str:
    return f"result x"


class MyMeta(ABCMeta):
    some_method: Callable[['MyMeta', str], str]
    
    def __new__(mcs, *args, **kwargs):
        cls = super().__new__(mcs, *args, **kwargs)
        cls.some_method = classmethod(some_method)
        return cls


class MyClassWithSomeMethod(metaclass=MyMeta):
    pass


def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"

这个passes MyPy 很好,实际上相当干净。但是,这种方法存在局限性,因为可调用的全部复杂性无法使用简写 typing.Callable 语法来表达。

选项 3:对 MyPy 撒谎

第三种选择是对 MyPy 撒谎。有两种明显的方法可以做到这一点。

选项 3(a)。使用 typing.TYPE_CHECKING 常量对 MyPy 撒谎

对于静态类型检查器,typing.TYPE_CHECKING 常量始终为 True,在运行时始终为 False。因此,您可以使用此常量将与您在运行时使用的定义不同的类定义提供给 MyPy。

from typing import Type, TYPE_CHECKING
from abc import ABCMeta 

if not TYPE_CHECKING:
    def some_method(cls, x: str) -> str:
        return f"result x"


class MyMeta(ABCMeta):
    if TYPE_CHECKING:
        def some_method(cls, x: str) -> str: ...
    else:
        def __new__(mcs, *args, **kwargs):
            cls = super().__new__(mcs, *args, **kwargs)
            cls.some_method = classmethod(some_method)
            return cls

class MyClassWithSomeMethod(metaclass=MyMeta):
    pass

def call_some_method(cls: Type[MyClassWithSomeMethod]) -> str:
    return cls.some_method("A")


if __name__ == "__main__":
    mc = MyClassWithSomeMethod()
    assert isinstance(mc, MyClassWithSomeMethod)
    assert call_some_method(MyClassWithSomeMethod) == "result A"

这个passes MyPy。这种方法的主要缺点是,在您的代码库中检查 if TYPE_CHECKING 实在是太难看了。

选项 3(b):使用 .pyi 存根文件欺骗 MyPy

对 MyPy 撒谎的另一种方法是使用 .pyi 存根文件。你可以有一个像这样的minimal_example.py 文件:

from abc import ABCMeta

def some_method(cls, x: str) -> str:
    return f"result x"


class MyMeta(ABCMeta):
    def __new__(mcs, *args, **kwargs):
        cls = super().__new__(mcs, *args, **kwargs)
        cls.some_method = classmethod(some_method)
        return cls

你可以在同一目录中有一个minimal_example.pyi 存根文件,如下所示:

from abc import ABCMeta


class MyMeta(ABCMeta):
    def some_method(cls, x: str) -> str: ...

如果 MyPy 在同一目录中找到 .py 文件和 .pyi 文件,它将始终忽略 .py 文件中的定义,而使用 .pyi 文件中的存根。同时,在运行时,Python 做相反的事情,完全忽略 .pyi 文件中的存根,而完全支持 .py 文件中的运行时实现。因此,您可以在运行时随心所欲地保持动态,而 MyPy 也不会更聪明。

(如您所见,不需要在.pyi 文件中复制完整的方法定义。MyPy 只需要这些方法的签名,因此约定只是将函数的主体填充到@ 987654353@ 带有文字省略号 ... 的文件。)

这个解决方案比使用TYPE_CHECKING 常量更简洁。但是,我不会对使用.pyi 文件感到厌烦。尽可能少地使用它们。如果您的 .py 文件中有一个类,而您的存根文件中没有副本,MyPy 将完全不知道它的存在并引发各种误报错误。请记住:如果您有一个 .pyi 文件,MyPy 将完全忽略其中包含您的运行时实现的 .py 文件。

.pyi 文件中复制类定义会违反 DRY,并且存在您将更新 .py 文件中的运行时定义但忘记更新 .pyi 文件的风险。如果可能,您应该将真正需要一个单独的.pyi 存根的代码隔离到一个单独的短文件中。然后,您应该在项目的其余部分中照常注释类型,并在其余代码中需要它们时照常从 very_dynamic_classes.py 导入必要的类。

【讨论】:

以上是关于如何使用 Python 中元类插入的方法对类进行类型检查?的主要内容,如果未能解决你的问题,请参考以下文章

PHP的反射类ReflectionClassReflectionMethod使用实例

python中元类(metaclass)的理解

如何使用私有构造函数对类进行单元测试?

python中元类

如何在 RandomForest 实现中对类进行加权?

如何按整数属性对类列表进行排序? [复制]