PEP 655 – 将单个 TypedDict 项标记为必需或可能缺失
- 作者:
- David Foster <david at dafoster.net>
- 发起人:
- Guido van Rossum <guido at python.org>
- 讨论至:
- Typing-SIG 讨论串
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2021年1月30日
- Python 版本:
- 3.11
- 发布历史:
- 2021年1月31日,2021年2月11日,2021年2月20日,2021年2月26日,2022年1月17日,2022年1月28日
- 决议:
- Python-Dev 消息
摘要
PEP 589 定义了用于声明所有键都必需的 TypedDict 的表示法,以及用于定义所有键都可能缺失的 TypedDict 的表示法,但它没有提供一种机制来声明某些键是必需的,而另一些是可能缺失的。本 PEP 引入了两种新的表示法:Required[],可用于 TypedDict 的单个项以将其标记为必需;以及 NotRequired[],可用于单个项以将其标记为可能缺失。
本 PEP 未对 Python 语法进行任何更改。TypedDict 中必需键和可能缺失键的正确使用仅由静态类型检查器强制执行,无需由 Python 本身在运行时强制执行。
动机
定义一个 TypedDict,其中一些键是必需的,而另一些是可能缺失的,这种情况并不少见。目前,定义此类 TypedDict 的唯一方法是声明一个 total 值为一个的 TypedDict,然后从另一个 total 值不同的 TypedDict 继承它。
class _MovieBase(TypedDict): # implicitly total=True
title: str
class Movie(_MovieBase, total=False):
year: int
为此目的声明两种不同的 TypedDict 类型是繁琐的。
本 PEP 引入了两种新的类型限定符,typing.Required 和 typing.NotRequired,它们允许定义一个同时包含必需键和可能缺失键的 *单个* TypedDict。
class Movie(TypedDict):
title: str
year: NotRequired[int]
本 PEP 还使得可以在 替代函数式语法 中定义 TypedDict,其中包含必需键和可能缺失键的混合,这目前是完全不可能的,因为替代语法不支持继承。
Actor = TypedDict('Actor', {
'name': str,
# "in" is a keyword, so the functional syntax is necessary
'in': NotRequired[List[str]],
})
基本原理
人们可能会觉得提出一种优先标记 *必需* 键而不是 *可能缺失* 键的表示法很不同寻常,就像 TypeScript 等其他语言中常见的那样。
interface Movie {
title: string;
year?: number; // ? marks potentially-missing keys
}
困难在于标记可能缺失键的最佳词语 Optional[] 在 Python 中已被用于完全不同的目的:标记可以是特定类型或 None 的值。特别是,以下代码无效:
class Movie(TypedDict):
...
year: Optional[int] # means int|None, not potentially-missing!
尝试使用“optional”的任何同义词来标记可能缺失的键(例如 Missing[])会与 Optional[] 太相似,容易混淆。
因此,决定专注于必需键的肯定形式措辞,这很容易拼写为 Required[]。
然而,通常情况下,人们希望扩展一个常规的 (total=True) TypedDict,只希望添加少量可能缺失的键,这就需要一种方式来标记 *不* 必需且可能缺失的键,因此我们也允许在这种情况下使用 NotRequired[] 形式。
规范
typing.Required 类型限定符用于指示在 TypedDict 定义中声明的变量是必需的键。
class Movie(TypedDict, total=False):
title: Required[str]
year: int
此外,typing.NotRequired 类型限定符用于指示在 TypedDict 定义中声明的变量是可能缺失的键。
class Movie(TypedDict): # implicitly total=True
title: str
year: NotRequired[int]
在 TypedDict 项之外的任何位置使用 Required[] 或 NotRequired[] 都是错误的。类型检查器必须强制执行此限制。
即使对于冗余的项,也可以使用 Required[] 和 NotRequired[],以便在需要时增加明确性。
class Movie(TypedDict):
title: Required[str] # redundant
year: NotRequired[int]
同时使用 Required[] 和 NotRequired[] 是错误的。
class Movie(TypedDict):
title: str
year: NotRequired[Required[int]] # ERROR
类型检查器必须强制执行此限制。Required[] 和 NotRequired[] 的运行时实现也可以强制执行此限制。
TypedDict 的替代函数式语法也支持 Required[] 和 NotRequired[]。
Movie = TypedDict('Movie', {'name': str, 'year': NotRequired[int]})
与 total=False 的交互
任何使用 total=False 声明的 PEP 589 风格的 TypedDict 等同于一个隐式定义为 total=True 的 TypedDict,其所有键都被标记为 NotRequired[]。
因此
class _MovieBase(TypedDict): # implicitly total=True
title: str
class Movie(_MovieBase, total=False):
year: int
等价于
class _MovieBase(TypedDict):
title: str
class Movie(_MovieBase):
year: NotRequired[int]
与 Annotated[] 的交互
Required[] 和 NotRequired[] 可以与 Annotated[] 一起使用,以任何嵌套顺序。
class Movie(TypedDict):
title: str
year: NotRequired[Annotated[int, ValueRange(-9999, 9999)]] # ok
class Movie(TypedDict):
title: str
year: Annotated[NotRequired[int], ValueRange(-9999, 9999)] # ok
特别是,允许 Annotated[] 作为项的最外层注解,可以更好地与非类型化注解的用法互操作,因为非类型化注解可能总是希望 Annotated[] 作为最外层注解。[3]
运行时行为
与 get_type_hints() 的交互
应用于 TypedDict 的 typing.get_type_hints(...) 默认会去除任何 Required[] 或 NotRequired[] 类型限定符,因为这些限定符预计对于随意自省类型注解的代码来说不方便。
但是,typing.get_type_hints(..., include_extras=True) *会* 保留 Required[] 和 NotRequired[] 类型限定符,以便高级代码自省类型注解并希望保留原始源中的 *所有* 注解。
class Movie(TypedDict):
title: str
year: NotRequired[int]
assert get_type_hints(Movie) == \
{'title': str, 'year': int}
assert get_type_hints(Movie, include_extras=True) == \
{'title': str, 'year': NotRequired[int]}
与 get_origin() 和 get_args() 的交互
typing.get_origin() 和 typing.get_args() 将更新以识别 Required[] 和 NotRequired[]。
assert get_origin(Required[int]) is Required
assert get_args(Required[int]) == (int,)
assert get_origin(NotRequired[int]) is NotRequired
assert get_args(NotRequired[int]) == (int,)
与 __required_keys__ 和 __optional_keys__ 的交互
标有 Required[] 的项将始终出现在其封闭 TypedDict 的 __required_keys__ 中。同样,标有 NotRequired[] 的项将始终出现在 __optional_keys__ 中。
assert Movie.__required_keys__ == frozenset({'title'})
assert Movie.__optional_keys__ == frozenset({'year'})
向后兼容性
本 PEP 未进行向后不兼容的更改。
如何教授此内容
要定义一个大多数键是必需的而少数键可能缺失的 TypedDict,请像往常一样定义一个 TypedDict(不带 total 关键字),并用 NotRequired[] 标记少数可能缺失的键。
要定义一个大多数键可能缺失而少数键是必需的 TypedDict,请定义一个 total=False 的 TypedDict,并用 Required[] 标记少数必需的键。
如果某些项除了常规值之外还接受 None,建议优先使用 TYPE|None 表示法而不是 Optional[TYPE] 来标记此类项值,以避免在同一个 TypedDict 定义中同时使用 Required[] 或 NotRequired[] 和 Optional[]。
是
from __future__ import annotations # for Python 3.7-3.9
class Dog(TypedDict):
name: str
owner: NotRequired[str|None]
可以(Python 3.5.3-3.6 必需)
class Dog(TypedDict):
name: str
owner: 'NotRequired[str|None]'
否
class Dog(TypedDict):
name: str
# ick; avoid using both Optional and NotRequired
owner: NotRequired[Optional[str]]
在 Python <3.11 中的用法
如果您的代码支持 Python <3.11 并希望使用 Required[] 或 NotRequired[],那么它应该使用 typing_extensions.TypedDict 而不是 typing.TypedDict,因为后者无法理解 (Not)Required[]。特别是,结果 TypedDict 类型上的 __required_keys__ 和 __optional_keys__ 将不正确。
是(仅限 Python 3.11+)
from typing import NotRequired, TypedDict
class Dog(TypedDict):
name: str
owner: NotRequired[str|None]
是(Python <3.11 和 3.11+)
from __future__ import annotations # for Python 3.7-3.9
from typing_extensions import NotRequired, TypedDict # for Python <3.11 with (Not)Required
class Dog(TypedDict):
name: str
owner: NotRequired[str|None]
否(Python <3.11 和 3.11+)
from typing import TypedDict # oops: should import from typing_extensions instead
from typing_extensions import NotRequired
class Movie(TypedDict):
title: str
year: NotRequired[int]
assert Movie.__required_keys__ == frozenset({'title', 'year'}) # yikes
assert Movie.__optional_keys__ == frozenset() # yikes
参考实现
mypy 0.930、pyright 1.1.117 和 pyanalyze 0.4.0 类型检查器支持 Required 和 NotRequired。
运行时组件的参考实现可在 typing_extensions 模块中找到。
被拒绝的想法
TypedDict 项的 *键* 周围的特殊语法
class MyThing(TypedDict):
opt1?: str # may not exist, but if exists, value is string
opt2: Optional[str] # always exists, but may have None value
这种表示法将需要改变 Python 语法,并且人们认为将 TypedDict 项标记为必需或可能缺失不符合进行此类语法更改所需的高标准。
class MyThing(TypedDict):
Optional[opt1]: str # may not exist, but if exists, value is string
opt2: Optional[str] # always exists, but may have None value
这种表示法会导致 Optional[] 根据其位置而具有不同的含义,这不一致且令人困惑。
另外,“我们不要在冒号前放奇怪的语法。”[1]
使用运算符标记必需或可能缺失的键
我们可以使用一元 + 作为标记必需键的简写,一元 - 标记可能缺失的键,或者一元 ~ 标记具有与正常总体性相反的键。
class MyThing(TypedDict, total=False):
req1: +int # + means a required key, or Required[]
opt1: str
req2: +float
class MyThing(TypedDict):
req1: int
opt1: -str # - means a potentially-missing key, or NotRequired[]
req2: float
class MyThing(TypedDict):
req1: int
opt1: ~str # ~ means a opposite-of-normal-totality key
req2: float
此类运算符可以通过 __pos__、__neg__ 和 __invert__ 特殊方法在 type 上实现,而无需修改语法。
决定谨慎地引入长格式表示法(即 Required[] 和 NotRequired[]),然后再引入任何短格式表示法。未来的 PEP 可能会重新考虑引入此或其他短格式表示法选项。
请注意,在重新考虑引入此短格式表示法时,+、- 和 ~ 在 Python 类型化世界中已经具有现有含义:协变、逆变和不变。
>>> from typing import TypeVar
>>> (TypeVar('T', covariant=True), TypeVar('U', contravariant=True), TypeVar('V'))
(+T, -U, ~V)
使用特殊常量标记值的缺失
我们可以引入一个新的类型级常量,当它作为联合成员使用时表示值的缺失,类似于 JavaScript 的 undefined 类型,或许可以称为 Missing。
class MyThing(TypedDict):
req1: int
opt1: str|Missing
req2: float
这样的 Missing 常量也可以用于其他场景,例如仅在有条件时定义的变量的类型。
class MyClass:
attr: int|Missing
def __init__(self, set_attr: bool) -> None:
if set_attr:
self.attr = 10
def foo(set_attr: bool) -> None:
if set_attr:
attr = 10
reveal_type(attr) # int|Missing
与联合类型如何应用于值的不一致
然而,这种 ...|Missing 的用法,等同于 Union[..., Missing],与联合类型通常的含义不太一致:Union[...] 总是描述存在的值的类型。相反,缺失性或非完整性是变量的一个属性。标记变量属性的现有先例包括 Final[...] 和 ClassVar[...],这与 Required[...] 的提议一致。
与联合类型如何细分的不一致
此外,Union[..., Missing] 的用法与联合值通常的分解方式不一致:通常可以使用 isinstance 检查来消除联合类型的组件。
class Packet:
data: Union[str, bytes]
def send_data(packet: Packet) -> None:
if isinstance(packet.data, str):
reveal_type(packet.data) # str
packet_bytes = packet.data.encode('utf-8')
else:
reveal_type(packet.data) # bytes
packet_bytes = packet.data
socket.send(packet_bytes)
但是,如果允许使用 Union[..., Missing],则必须使用 hasattr 对对象属性进行 Missing 情况的消除。
class Packet:
data: Union[str, Missing]
def send_data(packet: Packet) -> None:
if hasattr(packet, 'data'):
reveal_type(packet.data) # str
packet_bytes = packet.data.encode('utf-8')
else:
reveal_type(packet.data) # Missing? error?
packet_bytes = b''
socket.send(packet_bytes)
或者对局部变量进行 locals() 检查
def send_data(packet_data: Optional[str]) -> None:
packet_bytes: Union[str, Missing]
if packet_data is not None:
packet_bytes = packet.data.encode('utf-8')
if 'packet_bytes' in locals():
reveal_type(packet_bytes) # bytes
socket.send(packet_bytes)
else:
reveal_type(packet_bytes) # Missing? error?
或者通过其他方式进行检查,例如对全局变量进行 globals() 检查。
warning: Union[str, Missing]
import sys
if sys.version_info < (3, 6):
warning = 'Your version of Python is unsupported!'
if 'warning' in globals():
reveal_type(warning) # str
print(warning)
else:
reveal_type(warning) # Missing? error?
奇怪且不一致。Missing 根本不是一个值;它是一种定义的缺失,这种缺失应该特殊处理。
难以实现
来自 Pyright 类型检查器团队的 Eric Traut 表示,实现 Union[..., Missing] 风格的表示法将很困难。[2]
在 Python 中引入第二个类似 null 的值
定义一个新的 Missing 类型级常量将非常接近于在运行时引入一个新的 Missing 值级常量,从而在 Python 中除了 None 之外,创建第二个类似 null 的运行时值。在 Python 中拥有两个不同的类似 null 的常量(None 和 Missing)会令人困惑。许多 JavaScript 新手已经很难区分其类似的常量 null 和 undefined。
用 Nullable 替换 Optional。将 Optional 重新用于表示“可选项”。
Optional[] 太普遍了,无法弃用,尽管它的使用 *可能* 会随着时间推移而逐渐减少,转而使用 PEP 604 指定的 T|None 表示法。
在某些上下文中将 Optional 的含义改为“可选项”而不是“可为空”
考虑在 TypedDict 定义上使用一个特殊标志来改变 TypedDict 内部 Optional 的解释,使其表示“可选项”而不是其通常的“可空”含义。
class MyThing(TypedDict, optional_as_missing=True):
req1: int
opt1: Optional[str]
或
class MyThing(TypedDict, optional_as_nullable=False):
req1: int
opt1: Optional[str]
这将给用户带来更多困惑,因为它意味着在 *某些* 上下文中 Optional[] 的含义与在其他上下文中不同,并且很容易忽略该标志。
“可能缺失项”的各种同义词
- 可省略 – 太容易与可选混淆
- OptionalItem, OptionalKey – 两个词;太容易与可选混淆
- MayExist, MissingOk – 两个词
- Droppable – 太类似于 Rust 的
Drop,后者含义不同 - Potential – 太模糊
- Open – 听起来适用于整个结构而不是单个项
- Excludable
- Checked
参考资料
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0655.rst