Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

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 是一个历史文档:请参阅 必填和非必填typing.Requiredtyping.NotRequired 以获取最新的规范和文档。规范的类型规范在 类型规范站点 上维护;运行时类型行为在 CPython 文档中描述。

×

请参阅 类型规范更新流程,了解如何提出更改类型规范的建议。

摘要

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.Requiredtyping.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.930pyright 1.1.117pyanalyze 0.4.0 类型检查器支持RequiredNotRequired

运行时组件的参考实现在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 中有两个不同的类似空值的常量(NoneMissing)会令人困惑。许多 JavaScript 新手在区分其类似的常量nullundefined方面已经遇到困难。

用 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

参考文献


来源:https://github.com/python/peps/blob/main/peps/pep-0655.rst

上次修改时间:2024-06-16 22:42:44 GMT