实例和基类之间具有不同重载签名的类方法

问题描述 投票:0回答:2

我正在尝试编写一个具有接受额外值的附加构造方法的类。这些额外值的计算成本很高,并且会在程序结束时保存,因此

.initialize()
有效地充当注入,以避免在程序的后续运行中再次重新计算它们。

class TestClass:
    init_value: str

    secondary_value: int

    @overload
    @classmethod
    def initialize(cls: type["TestClass"], init_value: str, **kwargs) -> "TestClass":
        ...

    @overload
    @classmethod
    def initialize(cls: "TestClass", **kwargs) -> "TestClass":
        # The erased type of self "TestClass" is not a supertype of its class "Type[TestClass]
        ...

    @classmethod
    def initialize(cls: type["TestClass"] | "TestClass", init_value: str | None = None, **kwargs) -> "TestClass":
        if isinstance(cls, type):
            instance = cls(init_value=init_value) 
            # Argument "init_value" to "TestClass" has incompatible type "Optional[str]"; expected "str"
        else:
            instance = cls

        for extra_key, extra_value in kwargs.items():
            setattr(instance, extra_key, extra_value)
        return instance

    def __init__(self, init_value: str) -> None:
        self.init_value = init_value


instance1 = TestClass.initialize(init_value="test", secondary_value="test2")
instance2 = TestClass(init_value="test").initialize(secondary_value="test2")
            # Missing positional argument "init_value" in call to "initialize" of "TestClass" 
instance1.init_value
instance2.init_value
instance1.secondary_value
instance2.secondary_value

如何才能完成上述工作,以便

TestClass(init_value).initialize()
不需要将 init_value 传递给
.initialize()
,因为它已经在
__init__
中声明,而
TestClass.initialize()
则需要?

简而言之,如何根据是在实例上还是在类上调用来定义具有不同类型的类方法?

这些额外的值不能在

__init__
中声明,因为类的内部结构很复杂,在这里重复太长了。

python type-hinting mypy python-typing class-method
2个回答
0
投票

这里有两个不同的问题:

  • 如何声明可以在实例或类对象本身上调用的方法
  • 如何向 mypy 解释该 hack

让我们从简单的部分开始,暂时忘记 mypy。

当在实例对象上调用方法时(意味着它是在对象的类型上声明的),Python 知道这是一个方法调用,并在传递的参数前面加上对象本身:这是方法的常见用法。

当在类对象上调用方法时(意味着它是在对象本身上声明的),Python 在普通函数调用中看到它,并简单地转发传递的参数。因此,如果

foo
Bar
类的方法,并且
bar
Bar
实例,则以下代码片段严格等效:

bar.foo(args...)
Bar.foo(bar, args...)

这意味着要从类对象或其实例调用您的

initialize
方法,应该声明它:

def initialize(init_value, **kwargs)-> "TestClass":
    if isinstance(init_value, str):    # handle a str from the class object
        instance = TestClass(init_value)
    elif not isinstance(init_value, TestClass): # reject any other call...
        raise TypeError("Argument of initialize must be str")
    else:                              # handle a call from an instance object
        instance = init_value

    for extra_key, extra_value in kwargs.items():
        setattr(instance, extra_key, extra_value)
    return instance

此代码在运行时可以正常工作。


现在是 mypy 部分...

不幸的是,mypy 的目标是帮助 Python 程序员提供一致且易于维护的代码。为此,它提供了一个静态类型分析器来确保运行时参数与声明一致并且多个重载声明都一致。这里开始满足您的要求:

  • 要从类对象调用,该方法需要类方法重载
  • 要从实例对象调用并仍然可以访问该实例对象,最终声明不应是类方法或静态方法

换句话说,无法进行重载声明...

所以我发现的唯一方法就是说实话:您在

TestClass
上定义了一个属性,它是一个函数,当它作为方法调用时,可以接收
TestClass
对象作为其第一个参数,或者接收字符串当它被类对象本身调用时

...
def initialize(init_value: Union['TestClass', str], **kwargs)-> "TestClass"::
    if isinstance(init_value, str):
        instance = TestClass(init_value)
...

0
投票

您可以使用描述符根据访问位置返回两种不同的方法。这是一个实现:

from __future__ import annotations

from typing import Any, Callable, Generic, Optional, Type, Union, overload
from typing_extensions import Concatenate, ParamSpec, TypeVar

T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')

# Generic-bound typevars
PI = ParamSpec('PI')  # Instance method params
RI = TypeVar('RI')  # Instance method return
PC = ParamSpec('PC')  # Class method params
RC = TypeVar('RC')  # Class method return


class dualmethod(Generic[PI, RI, PC, RC]):
    """Decorate a method to have a classmethod variant."""

    def __init__(
        self,
        fget: Callable[Concatenate[Any, PI], RI],
        cget: Optional[Callable[Concatenate[Any, PC], RC]] = None,
    ) -> None:
        self.fget = self.__wrapped__ = fget
        self.cget = cget

    def classmethod(
        self,
        cget: Union[Callable[Concatenate[Any, P], R], classmethod[Any, P, R]],
    ) -> dualmethod[PI, RI, P, R]:
        if isinstance(cget, classmethod):
            cget = cget.__func__
        return dualmethod(self.fget, cget)

    @overload
    def __get__(self, obj: None, cls: Type[T]) -> Callable[PC, RC]:
        ...

    @overload
    def __get__(self, obj: T, cls: Type[T]) -> Callable[PI, RI]:
        ...

    def __get__(
        self, obj: Optional[T], cls: Type[T]
    ) -> Union[Callable[PI, RI], Callable[PC, RC]]:
        if obj is None:
            if self.cget is None:
                # No classmethod available so return an unbound method, although this
                # means our actual return type is `Callable[Concatenate[T, PI], RI]`.
                # Since we can't specify an overload based on self.cget, the only fix is
                # to split this class into two, the first for before the classmethod has
                # been supplied, and the second for after.
                # Before classmethod:
                #   __get__ -> Callable[PI, RI] | Callable[Concatenate[T, PI], RI]
                # With classmethod:
                #   __get__ => Callable[PI, RI] | Callable[PC, RC]
                return self.fget.__get__(obj, cls)
            return self.cget.__get__(cls, cls)  # Return classmethod
        return self.fget.__get__(obj, cls)  # Return instance method


class Host:
    @dualmethod
    def initialize(self, **kwargs) -> Host:
        return self

    @initialize.classmethod  # type: ignore[no-redef]
    @classmethod
    def initialize(cls, init_var) -> Host:
        return cls()

在测试时,我发现Mypy丢失了

self
/
cls
类型的信息,并且似乎通过忽略它来处理重新定义,因此它根本无法识别类方法。 Pyright 没有这些问题,所以我已经提交了错误报告

© www.soinside.com 2019 - 2024. All rights reserved.