PEP 712 – 在 dataclasses.field 中添加“converter”参数
- 作者:
- Joshua Cannon <joshdcannon at gmail.com>
- 赞助商:
- Eric V. Smith <eric at trueblade.com>
- 讨论对象:
- Discourse 帖子
- 状态:
- 拒绝
- 类型:
- 标准跟踪
- 创建:
- 2023 年 1 月 1 日
- Python 版本:
- 3.13
- 历史记录:
- 2022 年 12 月 27 日, 2023 年 1 月 19 日, 2023 年 4 月 23 日
- 决议:
- Discourse 消息
拒绝通知
2024 年指导委员会拒绝的原因包括
- 我们没有找到证据表明这个功能在标准库中存在强烈共识,尽管一些支持者主张为了减少他们对第三方包的依赖而支持它。对于那些需要这种功能的人,我们认为那些现有的第三方库,如 attrs 和 Pydantic(PEP 中引用),是可以接受的替代方案。
- 在我们看来,这个功能似乎是标准库中可能被认为是更多“杂质”的积累,它让我们越来越远离 dataclasses 最适合的“简单”用例。
- 阅读 PEP 中的“如何教授这一点”部分让我们停顿了一下,因为它的陷阱和意外情况很严重,其带来的困惑和复杂性远远超过了任何潜在的益处。
- PEP 似乎更侧重于帮助类型检查器而不是使用库的人。
摘要
PEP 557 将 dataclasses
添加到 Python 标准库中。 PEP 681 添加了 dataclass_transform()
来帮助类型检查器理解几个常见的类似 dataclass 的库,如 attrs、Pydantic 和对象关系映射 (ORM) 包,如 SQLAlchemy 和 Django。
其他库相对于标准库实现提供的一个常见功能是,库能够使用用户提供的转换函数将初始化时给定的参数转换为每个字段所期望的类型。
因此,此 PEP 在 dataclasses.field()
(以及对 dataclasses.Field
和 dataclass_transform()
的必要更改)中添加了一个 converter
参数,以指定用于将每个字段的输入值转换为要存储在 dataclass 中的表示形式的函数。
动机
目前没有现成的标准方法可以使 dataclasses
或第三方类似 dataclass 的库以类型可检查的方式支持参数转换。为了解决这个限制,库作者/用户被迫选择
- 选择加入自定义 Mypy 插件。这些插件帮助 Mypy 理解转换语义,但不能帮助其他工具。
- 将转换职责转移到 dataclass 构造函数的调用者身上。这可能会使某些 dataclass 的构造变得不必要地冗长和重复。
- 提供自定义的
__init__
,它声明“更宽”的参数类型,并在设置适当的属性时进行转换。这不仅会在转换器和__init__
之间重复类型注释,还会让用户放弃dataclasses
提供的许多功能。 - 提供自定义的
__init__
,但对于需要转换的参数类型没有有意义的类型注释。
这些选择都不理想。
基本原理
添加参数转换语义很有用且足够有益,以至于大多数类似 dataclass 的库都提供了支持。将此功能添加到标准库意味着更多用户能够选择使用这些功能,而无需使用第三方库。此外,第三方库能够通过在 dataclass_transform()
中添加支持来向类型检查器暗示自己的转换语义,这意味着这些库的用户也将从中受益。
规范
新的 converter
参数
此规范在 dataclasses.field()
函数中引入了一个名为 converter
的新参数。如果提供,它表示一个单参数可调用对象,用于在为关联的属性赋值时转换所有值。
对于冻结的 dataclass,转换器只在 dataclass
合成的 __init__
中设置属性时使用。对于非冻结的 dataclass,转换器用于所有属性赋值(例如 obj.attr = value
),包括默认值的赋值。
转换器不用于读取属性,因为属性应该已经被转换了。
添加此参数也意味着以下更改
- 将向
dataclasses.Field
添加一个converter
属性。 converter
将被添加到dataclass_transform()
的支持的字段规范器参数列表中。
示例
def str_or_none(x: Any) -> str | None:
return str(x) if x is not None else None
@dataclasses.dataclass
class InventoryItem:
# `converter` as a type (including a GenericAlias).
id: int = dataclasses.field(converter=int)
skus: tuple[int, ...] = dataclasses.field(converter=tuple[int, ...])
# `converter` as a callable.
vendor: str | None = dataclasses.field(converter=str_or_none))
names: tuple[str, ...] = dataclasses.field(
converter=lambda names: tuple(map(str.lower, names))
) # Note that lambdas are supported, but discouraged as they are untyped.
# The default value is also converted; therefore the following is not a
# type error.
stock_image_path: pathlib.PurePosixPath = dataclasses.field(
converter=pathlib.PurePosixPath, default="assets/unknown.png"
)
# Default value conversion extends to `default_factory`;
# therefore the following is also not a type error.
shelves: tuple = dataclasses.field(
converter=tuple, default_factory=list
)
item1 = InventoryItem(
"1",
[234, 765],
None,
["PYTHON PLUSHIE", "FLUFFY SNAKE"]
)
# item1's repr would be (with added newlines for readability):
# InventoryItem(
# id=1,
# skus=(234, 765),
# vendor=None,
# names=('PYTHON PLUSHIE', 'FLUFFY SNAKE'),
# stock_image_path=PurePosixPath('assets/unknown.png'),
# shelves=()
# )
# Attribute assignment also participates in conversion.
item1.skus = [555]
# item1's skus attribute is now (555,).
对类型的影响
converter
必须是接受一个位置参数的可调用对象,并且与该位置参数相对应的参数类型提供了与字段关联的合成 __init__
参数的类型。
换句话说,为 converter 参数提供的值必须与 Callable[[T], X]
兼容,其中 T
是转换器的输入类型,X
是转换器的输出类型。
类型检查 default
和 default_factory
因为默认值是使用 converter
无条件转换的,所以如果在提供 default
或 default_factory
的同时还提供了一个 converter
参数,则应使用 converter
可调用对象的单个参数的类型来检查默认类型(如果提供则为 default
参数,否则为 default_factory
的返回值)。
转换器返回类型
可调用对象的返回类型必须是与字段的声明类型兼容的类型。这包括字段的类型本身,也可以是更专门的类型(例如,转换器为声明为 list
的字段返回一个 list[int]
,或者为声明为 int | str
的字段返回一个 int
)。
允许参数类型的间接
此 PEP 引入的一个缺点是,无法通过阅读 dataclass 直接知道在 dataclass 的 __init__
中和在属性赋值期间允许使用哪些参数类型。允许的类型由转换器定义。
这在阅读源代码时是正确的,但是,与类型相关的辅助工具,如 typing.reveal_type
和 IDE 中的“IntelliSense”,应该使您能够轻松地知道允许使用哪些类型,而无需阅读任何源代码。
向后兼容性
这些更改不会引入任何兼容性问题,因为它们只引入了选择加入的新功能。
安全隐患
这些更改没有直接的安全问题。
如何教授这一点
解释新参数和行为的文档和示例将被添加到文档网站的相关部分(主要是在 dataclasses
上),并从What’s New 文档链接到它们。
添加的文档/示例还将涵盖用户使用转换器时可能会遇到的“常见陷阱”。这些陷阱包括
- 需要处理
None
/哨兵值。 - 需要处理已经是正确类型的的值。
- 避免为转换器使用 lambda 表达式,因为合成
__init__
参数的类型将变为Any
。 - 忘记在冻结的 dataclass 中用户定义的
__init__
的主体中转换值。 - 忘记在非冻结的 dataclass 中用户定义的
__setattr__
的主体中转换值。
此外,还应涵盖可能令人困惑的模式匹配语义
@dataclass
class Point:
x: int = field(converter=int)
y: int
match Point(x="0", y=0):
case Point(x="0", y=0): # Won't be matched
...
case Point(): # Will be matched
...
case _:
...
但是,值得注意的是,这种行为对在其初始化程序中进行转换的任何类型都是正确的,类型检查器应该能够捕获这个陷阱。
match int("0"):
case int("0"): # Won't be matched
...
case _: # Will be matched
...
参考实现
attrs 库已经包含一个converter
参数,在使用@define
类装饰器时,表现出相同的转换语义(在初始化器和属性设置时转换)。
CPython 支持是在作者分支上的一个分支上实现的。
被拒绝的想法
只将“converter”添加到 typing.dataclass_transform
的 field_specifiers
中
将此附加功能隔离到dataclass_transform()
的想法曾短暂地在Typing-SIG 上讨论,建议将其扩展到dataclasses
更普遍。
此外,将此添加到dataclasses
可确保任何人无需使用额外的库即可获得这些好处。
不转换默认值
转换和不转换默认值都有优点和缺点。保留默认值原样允许类型检查器和数据类作者预期默认值的类型与字段的类型匹配。但是,转换默认值具有三个主要优势
- 一致性。无条件地转换分配给属性的所有值,涉及更少的“特殊规则”供用户记住。
- 更简单的默认值。允许默认值与用户提供的值的类型相同,意味着数据类作者获得了与调用者相同的便利。
- 与 attrs 的兼容性。Attrs 无条件地使用转换器来转换默认值。
使用字段类型自动转换
一个想法可能是允许使用指定的字段类型(例如 str
或 int
)作为提供的每个参数的转换器。Pydantic 的数据转换 的语义似乎与这种方法类似。
这对于相当简单的类型很有效,但对于复杂类型(例如泛型)会导致预期行为的歧义。例如,对于 tuple[int, ...]
,不清楚转换器是否应该简单地将可迭代对象转换为元组,或者它还应该将每个元素类型转换为 int
。或者对于 int | None
,它不可调用。
从转换器的返回类型推断属性类型
另一个想法是允许用户在提供带 converter
参数的 field
时省略属性的类型注释。虽然这将减少此 PEP 引入的常见重复(例如 x: str = field(converter=str)
),但目前尚不清楚如何在保持当前数据类语义(即,属性顺序对于合成 __init__
或 dataclasses.fields
等内容保持不变)的情况下最佳地支持这一点。这是因为在 Python 中没有简单的方法(目前)以定义的顺序获得带有注释的属性与不带注释的属性交织在一起。
可以应用一个哨兵注释(例如 x: FromConverter = ...
),但这会破坏类型注释的基本假设。
最后,如果所有字段(包括没有转换器的字段)都被分配到 dataclasses.field
,这是可行的,这意味着类的命名空间本身保存了顺序,但用字段分配的重复代替了类型+转换器的重复。最终的结果是重复没有增加或减少,但增加了数据类语义的复杂性。
此 PEP 并不建议不能或不应该这样做。只是它不包含在此 PEP 中。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以更宽松的方式使用。
来源:https://github.com/python/peps/blob/main/peps/pep-0712.rst