Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

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 是一份历史文档:有关最新规范和文档,请参阅 required-notrequiredtyping.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!

尝试使用“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.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 中引入第二个类似 null 的值

定义一个新的 Missing 类型级常量将非常接近于在运行时引入一个新的 Missing 值级常量,从而在 Python 中除了 None 之外,创建第二个类似 null 的运行时值。在 Python 中拥有两个不同的类似 null 的常量(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