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!
尝试使用“可选”的任何同义词来标记可选缺失键(如 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 中引入了第二个空值
定义一个新的Missing
类型级常量非常接近于在运行时引入一个新的Missing
值级常量,除了None
之外,创建第二个类似空值的运行时值。在 Python 中有两个不同的类似空值的常量(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
上次修改时间:2024-06-16 22:42:44 GMT