数据类字段的工厂函数

Factory function for dataclass fields

我正在创建一个库,我想在其中利用数据字段上的元数据class。

为了得到我想要的结果,我可以像下面这样写数据class:

@dataclass
class Foo:
    a: int = field(
        metadata={'my_metadata': {'my_required_key': "c"}}
    )
    b: dict[str, str] = field(
        metadata={'my_metadata': {'my_required_key': "d"}}, default_factory=dict
    )

这似乎有很多样板文件,特别是如果我想制作许多 classes 具有许多这样的字段。我在想我可以写一个工厂函数来包装 dataclass.field 并帮助减少重复量。

但是,我似乎无法获得调用 dataclass.field 的正确类型参数,并且响应类型的正确值对我来说是个谜。我目前拥有的:

from dataclasses import dataclass, field, MISSING, _MISSING_TYPE
from typing import TypeVar, Union, Callable

_T = TypeVar("_T")


def myfield(
    my_required_key: str,
    *,
    default: Union[_MISSING_TYPE, _T] = MISSING,
    default_factory: Union[_MISSING_TYPE, Callable[[], _T]] = MISSING
) -> _T:
    return field(  # type: ignore
        metadata={'my_metadata': {'my_required_key': my_required_key}},
        default=default,
        default_factory=default_factory,
    )
@dataclass
class Foo:
    a: int = myfield("c")
    b: dict[str, str] = myfield("d", default_factory=dict)

此代码将通过 mypy 验证,但 PyCharm 似乎不喜欢它,报告:

Mutable default 'myfield("d", default_factory=dict)' is not allowed. Use 'default_factory'`

我可以忽略 PyCharm 错误,因为 class 似乎可以正常运行,而且我在我的 CICD 中使用 mypy,这似乎很酷.

至于return类型,我目前有myfield(...) -> _T。我觉得签名应该看起来更像 myfield(...) -> Field[_T],但 mypy 拒绝了这个想法,并报告:

error: Incompatible types in assignment (expression has type "Field[<nothing>]", variable has type "int")
error: Incompatible types in assignment (expression has type "Field[Dict[_KT, _VT]]", variable has type "Dict[str, str]")

我也不确定如何输入 defaultdefault_factory 参数。没有 # type: ignore 我会得到:

error: No overload variant of "field" matches argument types "Dict[str, Dict[str, str]]", "Union[_MISSING_TYPE, _T]", "Union[_MISSING_TYPE, Callable[[], _T]]"
note: Possible overload variants:
note:     def [_T] field(*, default: _T, init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ..., metadata: Optional[Mapping[str, Any]] = ...) -> _T
note:     def [_T] field(*, default_factory: Callable[[], _T], init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ..., metadata: Optional[Mapping[str, Any]] = ...) -> _T
note:     def field(*, init: bool = ..., repr: bool = ..., hash: Optional[bool] = ..., compare: bool = ..., metadata: Optional[Mapping[str, Any]] = ...) -> Any

我看到其他库已经开始通过仅制作工厂方法来减少样板代码 return 元数据字典。即

@dataclass
class Fizz:
    a: int = field(metadata=myfield("c"))
    b: dict[str, str] = field(metadata=myfield("d"), default_factory=dict)

这对我来说还是有点难看,但也许这就是要走的路。

如能提供任何有关清理此问题的帮助或想法,我们将不胜感激!

您已经在包装器中“截断”了 fields 的完整签名;我会更进一步:

def myfield(required_key: str, **kwargs):
    kwargs['metadata'] = dict(my_metadata=dict(my_required_key=required_key))
    return field(**kwargs)

不过,这种间接级别似乎可以防止 mypy 检查传递给 myfield 的参数是否具有 field 所期望的正确类型。


或者,本着“优先组合胜过继承”的精神,只需编写一个函数来创建正确的元数据以用作 field 的参数。这使您的用户可以直接调用 field,而不必重复其 intricate hinting

def make_metadata(required_key: str):
    return dict(my_metadata(dict(my_required_key=required_key)))


@dataclass
class Foo:
    a: int = field(metadata=make_metadata("c"))
    b: dict[str, str] = field(metadata=make_metadata("d"), default_factory=dict)

还有一些样板文件,但少了。

在某种程度上,存在一个不可避免的权衡,涉及您可以绑定到动态类型语言上的静态类型的数量,或者更确切地说,类型是如何绑定的。您会在 field 的类型提示中看到 overload 的使用,但 overload 不会 任何事情。它只是在源代码中放置注释以供 mypy 分析的地方;它无论如何都不会改变它的目标(事实上,它只是丢弃它,因为目的是稍后重新定义它)。这就是为什么仅使用 ... 来“实现”暗示的变体,因为主体并不重要:您永远不会使用正在定义的 function 对象,只会使用最终的未修饰函数。


我会建议直接在 Field 对象上设置元数据,除了元数据是唯一涉及 simple assignment in Field.__init__ 以外的属性:

def __init__(self, default, default_factory, init, repr, hash, compare,
             metadata):
    [...]
    self.metadata = (_EMPTY_METADATA
                     if metadata is None else
                     types.MappingProxyType(metadata))
    [...]

从“更喜欢组合...”中撤退,如果 Field 直接公开就好了,这样你就可以像

那样子类化它
class MyField(Field):
    def set_metadata(self, key: str):
        self.metadata = types.MappingProxyType(dict(...))
        return self

并使用

@dataclass
class Foo:
    a: int = Field().set_metadata("c")
    b: dict[str, str] = Field(default_factory=dict).set_metadata("d")

并不是说我提倡像这样直接使用Field,但是....

此外,据我所知,MappingProxyType 仅用于将元数据设置为只读。如果您不介意放松 Field 对象的那部分...

@dataclass
class Foo:
    a: int = field()
    make_metadata(a, "c")
    b: dict[str, str] = field(default_factory=dict)
    make_metadata(b, "d")

Return myfield

类型

关于 return 类型应该是 _T 还是 Field[_T],值得注意的是 typeshed 库——所有主要类型检查器都使用的存根文件的存储库用于检查标准库——仅使用 _T 作为 return 类型。事实上,源代码中有一个非常有启发性的注释说明了为什么会这样:

# NOTE: Actual return type is 'Field[_T]', but we want to help type checkers
# to understand the magic that happens at runtime.

如果它对 typeshed 来说足够好,我想说它可能对你来说足够好!

PyCharm

在这个阶段,我对 PyCharm 错误的类型检查感到非常恼火,所以我建议您忽略有关可变默认字段的恼人​​消息。但是,这并不是特别有用,因为您正在编写一个库,而您的库的许多用户将使用 PyCharm。不幸的是,看起来并没有真正好的解决方案,因为这是 known bug 在 PyCharm 对数据类 fields 的类型检查方面比这个特定问题要广泛得多.

类型:忽略

我认为在您的代码中使用单个 # type: ignore 没有什么不妥。没有类型检查器是完美的,无论如何它都是一个近似值,因为 Python 从根本上说是一种动态语言。对于 MyPy,您可以更具体地告诉它只忽略某些类型的错误(--show-error-codes 选项对于了解引发哪种 MyPy 异常非常有用)。在这种情况下,错误是 MyPy call-overload 错误,因此您可以将 # type: ignore 更改为 # type: ignore[call-overload]。 (遗憾的是,即使在 评论 中使用这种语法也会对 PyCharm 的 linter 造成严重破坏。老实说,我不知道为什么。)

参考资料

这是截至 2021 年 8 月 13 日 dataclasses.field 函数的 full hint

# NOTE: Actual return type is 'Field[_T]', but we want to help type checkers
# to understand the magic that happens at runtime.
if sys.version_info >= (3, 10):
    @overload  # `default` and `default_factory` are optional and mutually exclusive.
    def field(
        *,
        default: _T,
        init: bool = ...,
        repr: bool = ...,
        hash: bool | None = ...,
        compare: bool = ...,
        metadata: Mapping[Any, Any] | None = ...,
        kw_only: bool = ...,
    ) -> _T: ...
    @overload
    def field(
        *,
        default_factory: Callable[[], _T],
        init: bool = ...,
        repr: bool = ...,
        hash: bool | None = ...,
        compare: bool = ...,
        metadata: Mapping[Any, Any] | None = ...,
        kw_only: bool = ...,
    ) -> _T: ...
    @overload
    def field(
        *,
        init: bool = ...,
        repr: bool = ...,
        hash: bool | None = ...,
        compare: bool = ...,
        metadata: Mapping[Any, Any] | None = ...,
        kw_only: bool = ...,
    ) -> Any: ...

else:
    @overload  # `default` and `default_factory` are optional and mutually exclusive.
    def field(
        *,
        default: _T,
        init: bool = ...,
        repr: bool = ...,
        hash: bool | None = ...,
        compare: bool = ...,
        metadata: Mapping[Any, Any] | None = ...,
    ) -> _T: ...
    @overload
    def field(
        *,
        default_factory: Callable[[], _T],
        init: bool = ...,
        repr: bool = ...,
        hash: bool | None = ...,
        compare: bool = ...,
        metadata: Mapping[Any, Any] | None = ...,
    ) -> _T: ...
    @overload
    def field(
        *,
        init: bool = ...,
        repr: bool = ...,
        hash: bool | None = ...,
        compare: bool = ...,
        metadata: Mapping[Any, Any] | None = ...,
    ) -> Any: ...