python 用类和示例注解类型

7rfyedvj  于 2023-01-08  发布在  Python
关注(0)|答案(1)|浏览(127)

我正在创建一个半单例类Foo,它可以有(也可以是半单例)子类,构造函数有一个参数,我们称之为slug,并且每个(子)类对于slug的每个值最多只能有一个示例。
假设我有一个Foo的子类Bar,下面是一个调用的例子:

  1. Foo("a slug")-〉返回使用键(Foo, "a slug")保存的Foo的新示例。
  2. Foo("some new slug")-〉返回一个新的示例Foo,用键(Foo, "some new slug")保存。
  3. Foo("a slug")-〉我们有相同的类和步骤1中的slug,所以这将返回步骤1中返回的相同示例。
  4. Bar("a slug")-〉我们有和以前一样的slug,但是一个不同的类,所以这返回一个新的Bar示例,用键(Bar, "a slug")保存。
  5. Bar("a slug")-〉这将返回与我们在步骤4中获得的Bar相同的示例。
    我知道如何实现这一点:类字典将typestr的元组关联到示例,覆盖__new__等。简单的东西。
    我的问题是这本词典怎样打注解?
    我试着做的事情是这样的:
FooSubtype = TypeVar("FooSubtype", bound="Foo")

class Foo:
    _instances: Final[dict[tuple[Type[FooSubtype], str], FooSubtype]] = dict()

因此,其思想是"无论键的第一个元素是什么类型(将其"赋值"给FooSubtype类型变量),值都需要是相同类型的示例"。
这在Type variable "FooSubtype" is unbound中失败了,我有点明白为什么了。
如果我这样拆分它,也会得到同样的错误:

FooSubtype = TypeVar("FooSubtype", bound="Foo")
InstancesKeyType: TypeAlias = tuple[Type[FooSubtype], str]

class Foo:
    _instances: Final[dict[InstancesKeyType, FooSubtype]] = dict()

错误指向本例中的最后一行,这意味着问题出在值类型上,而不是键类型。
mypy也建议使用Generic,但我不知道在这个特定的示例中如何使用,因为值的类型应该以某种方式与键的类型相关,而不是单独的泛型类型。
这是可行的:

class Foo:
    _instances: Final[dict[tuple[Type["Foo"], str], "Foo"]] = dict()

但是它允许_instance[(Bar1, "x")]Bar2类型(这里Bar1Bar2Foo的不同子类),这不是一个大问题,我可以这样保留它,但是我想知道是否有更好(更严格)的方法。

hc8w905p

hc8w905p1#

这是一个非常好的问题。首先我看了一下,说"不,你根本不能",因为你不能表达dict键和值之间的任何关系。然而,后来我意识到你的建议几乎是可以实现的。
首先,让我们定义一个描述所需行为的协议:

from typing import TypeAlias, TypeVar, Protocol

_T = TypeVar("_T", bound="Foo")
# Avoid repetition, it's just a generic alias
_KeyT: TypeAlias = tuple[type[_T], str]  

class _CacheDict(Protocol):
    def __getitem__(self, __key: _KeyT[_T]) -> _T: ...
    def __delitem__(self, __key: _KeyT['Foo']) -> None: ...
    def __setitem__(self, __key: _KeyT[_T], __value: _T) -> None: ...

它是如何工作的?它定义了一个具有项访问权限的任意数据结构,例如cache_dict[(Foo1, 'foo')]解析为Foo1类型。(或collections.abc.MutableMapping),但类型稍有不同。Dunder参数名几乎等同于仅定位参数(使用/)。如果您需要其他方法(例如getpop),也可以将它们添加到此定义中(您可能希望使用overload)。您几乎肯定需要__contains__,它应该与__delitem__具有相同的签名。
那么现在

class Foo:
    _instances: Final[_CacheDict] = cast(_CacheDict, dict())

class Foo1(Foo): pass
class Foo2(Foo): pass

reveal_type(Foo._instances[(Foo, 'foo')])  # N: Revealed type is "__main__.Foo"
reveal_type(Foo._instances[(Foo1, 'foo')])  # N: Revealed type is "__main__.Foo1"

哇,我们正确地推断出了值类型!我们将dict转换为所需的类型,因为我们的类型与dict定义不同。
它还有一个问题:你能做的

Foo._instances[(Foo1, 'foo')] = Foo2()

因为_T在这里正好解析为Foo,但是这个问题是完全不可避免的:即使我们有一些infer关键字或Infer特殊形式拼写def __setitem__(self, __key: _KeyT[Infer[_T]], __value: _T) -> None,它不会正常工作:

foo1_t: type[Foo] = Foo1  # Ok, upcasting
foo2: Foo = Foo2()  # Ok again
Foo._instances[(foo1_t, 'foo')] = foo2  # Ough, still allowed, _T is Foo again

注意,我们没有使用上面的任何类型转换,所以这段代码是类型安全的,但肯定与我们的意图相冲突。
因此,我们可能不得不接受__setitem__的不严格性,但至少从项访问中获得适当的类型。
最后,类在_T中不是泛型的,因为否则所有的值都将被推断为声明的类型,而不是函数作用域(您可以尝试使用Protocol[_T]作为基类,并观察发生了什么,这对于更深入地理解mypy类型推断方法是非常好的)。
下面是playground的完整代码链接。
此外,您可以子类化MutableMapping[_KeyT['Foo'], 'Foo']以获得更多的方法,而不是手动定义它们。它将开箱即用地处理__delitem____contains__,但是__setitem____getitem__仍然需要您的实现。
下面是MutableMappingget的替代解决方案(因为get的实现既复杂又有趣)(playground):

from collections.abc import MutableMapping
from abc import abstractmethod
from typing import TypeAlias, TypeVar, Final, TYPE_CHECKING, cast, overload

_T = TypeVar("_T", bound="Foo")
_Q = TypeVar("_Q")
_KeyT: TypeAlias = tuple[type[_T], str]

class _CacheDict(MutableMapping[_KeyT['Foo'], 'Foo']):
    @abstractmethod
    def __getitem__(self, __key: _KeyT[_T]) -> _T: ...
    @abstractmethod
    def __setitem__(self, __key: _KeyT[_T], __value: _T) -> None: ...
    
    @overload  # No-default version
    @abstractmethod
    def get(self, __key: _KeyT[_T]) -> _T | None: ...
    
    # Ooops, a `mypy` bug, try to replace with `__default: _T | _Q`
    # and check Foo._instances.get((Foo1, 'foo'), Foo2())
    # The type gets broader, but resolves to more specific one in a wrong way
    @overload  # Some default
    @abstractmethod
    def get(self, __key: _KeyT[_T], __default: _Q) -> _T | _Q: ...
    
    # Need this because of https://github.com/python/mypy/issues/11488
    @abstractmethod
    def get(self, __key: _KeyT[_T], __default: object = None) -> _T | object: ...

class Foo:
    _instances: Final[_CacheDict] = cast(_CacheDict, dict())

class Foo1(Foo): pass
class Foo2(Foo): pass

reveal_type(Foo._instances)
reveal_type(Foo._instances[(Foo, 'foo')])  # N: Revealed type is "__main__.Foo"
reveal_type(Foo._instances[(Foo1, 'foo')])  # N: Revealed type is "__main__.Foo1"
reveal_type(Foo._instances.get((Foo, 'foo')))  # N: Revealed type is "Union[__main__.Foo, None]"
reveal_type(Foo._instances.get((Foo1, 'foo')))  # N: Revealed type is "Union[__main__.Foo1, None]"
reveal_type(Foo._instances.get((Foo1, 'foo'), Foo1()))  # N: Revealed type is "__main__.Foo1"
reveal_type(Foo._instances.get((Foo1, 'foo'), Foo2()))  # N: Revealed type is "Union[__main__.Foo1, __main__.Foo2]"
(Foo1, 'foo') in Foo._instances  # We get this for free

Foo._instances[(Foo1, 'foo')] = Foo1()
Foo._instances[(Foo1, 'foo')] = object()  # E: Value of type variable "_T" of "__setitem__" of "_CacheDict" cannot be "object"  [type-var]

注意,我们现在不使用Protocol(因为它还需要MutableMapping作为协议),而是使用抽象方法。

诡计,不要用它!

当我在写这个答案的时候,我发现了一个mypy bug,你可以用一种非常有趣的方式在这里滥用它,我们是从这样的东西开始的,对吗?

from collections.abc import MutableMapping
from abc import abstractmethod
from typing import TypeAlias, TypeVar, Final, TYPE_CHECKING, cast, overload

_T = TypeVar("_T", bound="Foo")
_Q = TypeVar("_Q")
_KeyT: TypeAlias = tuple[type[_T], str]

class _CacheDict(MutableMapping[_KeyT['Foo'], 'Foo']):
    @abstractmethod
    def __getitem__(self, __key: _KeyT[_T]) -> _T: ...
    @abstractmethod
    def __setitem__(self, __key: _KeyT[_T], __value: _T) -> None: ...
    
class Foo:
    _instances: Final[_CacheDict] = cast(_CacheDict, dict())

class Foo1(Foo): pass
class Foo2(Foo): pass

Foo._instances[(Foo1, 'foo')] = Foo1()
Foo._instances[(Foo1, 'foo')] = Foo2()

现在,让我们将__setitem__签名更改为一个非常奇怪的东西。**警告:这是一个bug,不要依赖这个行为!**如果我们将__default输入为_T | _Q,我们会神奇地得到"正确的"类型,并严格缩小到第一个参数的类型。

@abstractmethod
    def __setitem__(self, __key: _KeyT[_T], __value: _T | _Q) -> None: ...

现在:

Foo._instances[(Foo1, 'foo')] = Foo1()  # Ok
Foo._instances[(Foo1, 'foo')] = Foo2()  # E: Incompatible types in assignment (expression has type "Foo2", target has type "Foo1")  [assignment]

这是完全错误的,因为_Q联合部分可以解析为任何东西,并且实际上没有使用(此外,它根本不可能是类型变量,因为它在定义中只使用过一次)。
此外,当右侧不是Foo子类时,这允许另一个无效赋值:

Foo._instances[(Foo1, 'foo')] = object()  # passes

我将很快报告这一点,并将问题与此问题联系起来。

相关问题