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

Python 增强提案

PEP 586 – 字面量类型

作者:
Michael Lee <michael.lee.0x2a at gmail.com>,Ivan Levkivskyi <levkivskyi at gmail.com>,Jukka Lehtosalo <jukka.lehtosalo at iki.fi>
BDFL 委托
Guido van Rossum <guido at python.org>
讨论至:
Typing-SIG 邮件列表
状态:
最终版
类型:
标准跟踪
主题:
类型标注
创建日期:
2019 年 3 月 14 日
Python 版本:
3.8
发布历史:
2019 年 3 月 14 日
决议:
Typing-SIG 消息

目录

重要

本 PEP 是一份历史文档:有关最新规范和文档,请参阅 字面量typing.Literal。规范的类型规范保存在 类型规范网站;运行时类型行为在 CPython 文档中描述。

×

有关如何提议更改类型规范的信息,请参阅类型规范更新过程

摘要

本 PEP 提议将 字面量类型 添加到 PEP 484 生态系统。字面量类型表示某个表达式字面上具有特定值。例如,以下函数将只接受字面上具有值“4”的表达式

from typing import Literal

def accepts_only_four(x: Literal[4]) -> None:
    pass

accepts_only_four(4)   # OK
accepts_only_four(19)  # Rejected

动机与原理

Python 有许多 API,它们根据提供的某些参数的值返回不同的类型。例如

  • open(filename, mode) 返回 IO[bytes]IO[Text],具体取决于第二个参数是 r 还是 rb 等。
  • subprocess.check_output(...) 返回字节或文本,具体取决于 universal_newlines 关键字参数是否设置为 True

这种模式在许多流行的第三方库中也相当常见。例如,这里是 pandas 和 numpy 的两个示例

  • pandas.concat(...) 将返回 SeriesDataFrame,具体取决于 axis 参数是设置为 0 还是 1。
  • numpy.unique 将返回单个数组或包含两到四个数组的元组,具体取决于三个布尔标志值。

类型问题跟踪器包含一些 其他示例和讨论

目前无法表达这些函数的类型签名:PEP 484 不包含任何机制来编写返回类型根据传入值而变化的签名。请注意,即使我们将这些 API 重新设计为接受枚举,此问题仍然存在:MyEnum.FOOMyEnum.BAR 都被认为是 MyEnum 类型。

目前,类型检查器通过为重要的内置函数和标准库函数添加临时扩展来解决此限制。例如,mypy 捆绑了一个插件,它试图为 open(...) 推断更精确的类型。虽然这种方法适用于标准库函数,但它通常不可持续:期望第三方库作者为 N 个不同的类型检查器维护插件是不合理的。

我们提议添加 字面量类型 来解决这些空白。

核心语义

本节概述了字面量类型的基线行为。

核心行为

字面量类型表示变量具有特定且具体的值。例如,如果我们将变量 foo 定义为 Literal[3] 类型,我们声明 foo 必须精确等于 3,而不是任何其他值。

给定作为类型 T 成员的某个值 v,类型 Literal[v] 应被视为 T 的子类型。例如,Literal[3]int 的子类型。

父类型中的所有方法都将直接被字面量类型继承。因此,如果我们的变量 foo 的类型是 Literal[3],那么执行 foo + 5 等操作是安全的,因为 foo 继承了 int 的 __add__ 方法。foo + 5 的结果类型是 int

这种“继承”行为与我们 处理 NewType 的方式相同。

两个字面量的等效性

当以下两个条件都为真时,两个类型 Literal[v1]Literal[v2] 等效

  1. type(v1) == type(v2)
  2. v1 == v2

例如,Literal[20]Literal[0x14] 是等效的。但是,Literal[0]Literal[False] 不等效,尽管在运行时 0 == False 评估为“true”:0 的类型是 int,而 False 的类型是 bool

缩短字面量的联合

字面量用一个或多个值参数化。当字面量用多个值参数化时,它被视为与这些类型的联合完全等效。也就是说,Literal[v1, v2, v3] 等效于 Union[Literal[v1], Literal[v2], Literal[v3]]

这个快捷方式有助于使为接受许多不同字面量的函数(例如 open(...) 等函数)编写签名更加符合人体工程学

# Note: this is a simplification of the true type signature.
_PathType = Union[str, bytes, int]

@overload
def open(path: _PathType,
         mode: Literal["r", "w", "a", "x", "r+", "w+", "a+", "x+"],
         ) -> IO[Text]: ...
@overload
def open(path: _PathType,
         mode: Literal["rb", "wb", "ab", "xb", "r+b", "w+b", "a+b", "x+b"],
         ) -> IO[bytes]: ...

# Fallback overload for when the user isn't using literal types
@overload
def open(path: _PathType, mode: str) -> IO[Any]: ...

提供的值不必都属于相同的类型。例如,Literal[42, "foo", True] 是合法的类型。

但是,Literal 必须 用至少一个类型参数化。诸如 Literal[]Literal 之类的类型是非法的。

类型推断

本节描述了一些关于类型推断和字面量的规则,以及一些示例。

向后兼容性

当类型检查器添加对字面量的支持时,重要的是它们以最大限度地向后兼容的方式进行。类型检查器应确保以前进行类型检查的代码在添加字面量支持后仍能继续进行,并尽最大努力。

这在执行类型推断时尤为重要。例如,给定语句 x = "blue"x 的推断类型应该是 str 还是 Literal["blue"]

一个天真的策略是总是假定表达式旨在成为字面量类型。因此,在上面的例子中,x 的推断类型将始终是 Literal["blue"]。这种天真的策略几乎肯定会带来太多干扰——它会导致像下面这样的程序在以前没有失败的情况下开始失败

# If a type checker infers 'var' has type Literal[3]
# and my_list has type List[Literal[3]]...
var = 3
my_list = [var]

# ...this call would be a type-error.
my_list.append(4)

此策略失败的另一个示例是在对象中设置字段时

class MyObject:
    def __init__(self) -> None:
        # If a type checker infers MyObject.field has type Literal[3]...
        self.field = 3

m = MyObject()

# ...this assignment would no longer type check
m.field = 4

另一种 确实 在所有情况下都保持兼容性的策略是,除非明确注明,否则总是假定表达式 不是 字面量类型。使用此策略的类型检查器将始终推断出上面第一个示例中 x 的类型是 str

这不是唯一可行的策略:类型检查器应该随意尝试更复杂的推断技术。本 PEP 不强制规定任何特定策略;它只强调向后兼容的重要性。

在字面量上下文中使用非字面量

字面量类型遵循现有的子类型规则,没有额外的特殊处理。例如,以下程序是类型安全的

def expects_str(x: str) -> None: ...
var: Literal["foo"] = "foo"

# Legal: Literal["foo"] is a subtype of str
expects_str(var)

这也意味着非字面量表达式通常不应自动转换为字面量。例如

def expects_literal(x: Literal["foo"]) -> None: ...

def runner(my_str: str) -> None:
    # ILLEGAL: str is not a subclass of Literal["foo"]
    expects_literal(my_str)

注意: 如果用户希望其 API 支持同时接受字面量 原始类型——也许是出于遗留目的——他们应该实现一个回退重载。请参阅 与重载的交互

与其他类型和功能的交互

本节讨论字面量类型如何与其他现有类型交互。

结构化数据的智能索引

字面量可用于“智能索引”结构化类型,如元组、具名元组和类。(注意:这不是一个详尽的列表)。

例如,当使用对应于有效索引的整数键索引元组时,类型检查器应推断出正确的值类型

a: Literal[0] = 0
b: Literal[5] = 5

some_tuple: Tuple[int, str, List[bool]] = (3, "abc", [True, False])
reveal_type(some_tuple[a])   # Revealed type is 'int'
some_tuple[b]                # Error: 5 is not a valid index into the tuple

当使用 getattr 等函数时,我们期望有类似的行为

class Test:
    def __init__(self, param: int) -> None:
        self.myfield = param

    def mymethod(self, val: int) -> str: ...

a: Literal["myfield"]  = "myfield"
b: Literal["mymethod"] = "mymethod"
c: Literal["blah"]     = "blah"

t = Test()
reveal_type(getattr(t, a))  # Revealed type is 'int'
reveal_type(getattr(t, b))  # Revealed type is 'Callable[[int], str]'
getattr(t, c)               # Error: No attribute named 'blah' in Test

注意: 有关如何以更紧凑的方式表达上述变量声明的提案,请参阅 与 Final 的交互

与重载的交互

字面量类型和重载不需要以特殊方式交互:现有规则运行良好。

然而,类型检查器必须支持的一个重要用例是当用户不使用字面量类型时能够使用 回退。例如,考虑 open

_PathType = Union[str, bytes, int]

@overload
def open(path: _PathType,
         mode: Literal["r", "w", "a", "x", "r+", "w+", "a+", "x+"],
         ) -> IO[Text]: ...
@overload
def open(path: _PathType,
         mode: Literal["rb", "wb", "ab", "xb", "r+b", "w+b", "a+b", "x+b"],
         ) -> IO[bytes]: ...

# Fallback overload for when the user isn't using literal types
@overload
def open(path: _PathType, mode: str) -> IO[Any]: ...

如果我们将 open 的签名更改为仅使用前两个重载,我们将破坏任何不传入字面字符串表达式的代码。例如,像这样的代码将被破坏

mode: str = pick_file_mode(...)
with open(path, mode) as f:
    # f should continue to be of type IO[Any] here

更广泛地说:我们建议在 typeshed 中添加一项策略,规定每当我们向现有 API 添加字面量类型时,我们还应始终包含一个回退重载以保持向后兼容性。

与泛型的交互

Literal[3] 这样的类型只是 int 的普通旧子类。这意味着你可以在任何可以使用普通类型的地方使用 Literal[3] 这样的类型,例如与泛型一起使用。

这意味着使用字面量类型参数化泛型函数或类是合法的

A = TypeVar('A', bound=int)
B = TypeVar('B', bound=int)
C = TypeVar('C', bound=int)

# A simplified definition for Matrix[row, column]
class Matrix(Generic[A, B]):
    def __add__(self, other: Matrix[A, B]) -> Matrix[A, B]: ...
    def __matmul__(self, other: Matrix[B, C]) -> Matrix[A, C]: ...
    def transpose(self) -> Matrix[B, A]: ...

foo: Matrix[Literal[2], Literal[3]] = Matrix(...)
bar: Matrix[Literal[3], Literal[7]] = Matrix(...)

baz = foo @ bar
reveal_type(baz)  # Revealed type is 'Matrix[Literal[2], Literal[7]]'

同样,构建涉及字面量类型的带值限制或边界的 TypeVar 也是合法的

T = TypeVar('T', Literal["a"], Literal["b"], Literal["c"])
S = TypeVar('S', bound=Literal["foo"])

……尽管尚不清楚何时构建一个具有字面量上限的 TypeVar 会有用。例如,上述示例中的 S TypeVar 基本上毫无意义:我们可以通过使用 S = Literal["foo"] 来获得等效行为。

注意: 字面量类型和泛型故意只以非常基本和有限的方式交互。特别是,想要对包含大量数字或 numpy 风格操作的代码进行类型检查的库,几乎肯定会发现本 PEP 中提议的字面量类型不足以满足其需求。

我们考虑了几种不同的解决方案,但最终决定将整数泛型的问题推迟到以后。有关更多详细信息,请参阅 被拒绝或超出范围的想法

与枚举和穷尽性检查的交互

当处理具有有限数量变体的字面量类型(如枚举)时,类型检查器应该能够执行穷尽性检查。例如,类型检查器应该能够推断出最终的 else 语句的类型必须是 str,因为 Status 枚举的所有三个值都已穷尽

class Status(Enum):
    SUCCESS = 0
    INVALID_DATA = 1
    FATAL_ERROR = 2

def parse_status(s: Union[str, Status]) -> None:
    if s is Status.SUCCESS:
        print("Success!")
    elif s is Status.INVALID_DATA:
        print("The given data is invalid because...")
    elif s is Status.FATAL_ERROR:
        print("Unexpected fatal error...")
    else:
        # 's' must be of type 'str' since all other options are exhausted
        print("Got custom status: " + s)

上面描述的交互并非新事物:它已 在 PEP 484 中编纂。然而,许多类型检查器(如 mypy)由于预期的实现复杂性尚未实现这一点。

一旦引入字面量类型,其中一些复杂性将得到缓解:我们可以将枚举视为大致等同于其值的联合,而不是完全特殊处理枚举,并利用类型检查器可能已经实现的关于联合、穷尽性、类型窄化、可达性等的任何现有逻辑。

因此,在这里,Status 枚举可以被视为大致等效于 Literal[Status.SUCCESS, Status.INVALID_DATA, Status.FATAL_ERROR],并相应地窄化 s 的类型。

与窄化的交互

类型检查器可以选择对枚举和非枚举字面量类型执行超出上述部分描述的额外分析。

例如,基于包含或相等性检查等进行窄化可能很有用

def parse_status(status: str) -> None:
    if status in ("MALFORMED", "ABORTED"):
        # Type checker could narrow 'status' to type
        # Literal["MALFORMED", "ABORTED"] here.
        return expects_bad_status(status)

    # Similarly, type checker could narrow 'status' to Literal["PENDING"]
    if status == "PENDING":
        expects_pending_status(status)

考虑到涉及字面量布尔值的表达式进行窄化也可能很有用。例如,我们可以结合 Literal[True]Literal[False] 和重载来构建“自定义类型守卫”

@overload
def is_int_like(x: Union[int, List[int]]) -> Literal[True]: ...
@overload
def is_int_like(x: object) -> bool: ...
def is_int_like(x): ...

vector: List[int] = [1, 2, 3]
if is_int_like(vector):
    vector.append(3)
else:
    vector.append("bad")   # This branch is inferred to be unreachable

scalar: Union[int, str]
if is_int_like(scalar):
    scalar += 3      # Type checks: type of 'scalar' is narrowed to 'int'
else:
    scalar += "foo"  # Type checks: type of 'scalar' is narrowed to 'str'

与 Final 的交互

PEP 591 提议向类型生态系统添加一个“Final”限定符。此限定符可用于声明某个变量或属性不能被重新赋值

foo: Final = 3
foo = 4           # Error: 'foo' is declared to be Final

请注意,在上面的示例中,我们知道 foo 将始终精确等于 3。类型检查器可以使用此信息推断出 foo 在任何期望 Literal[3] 的上下文中使用是有效的

def expects_three(x: Literal[3]) -> None: ...

expects_three(foo)  # Type checks, since 'foo' is Final and equal to 3

Final 限定符用作声明变量 实际上是字面量 的简写。

如果本 PEP 和 PEP 591 都被接受,类型检查器应支持此快捷方式。具体来说,给定形式为 var: Final = value 的变量或属性赋值,其中 valueLiteral[...] 的有效参数,类型检查器应理解 var 可以在任何期望 Literal[value] 的上下文中使用。

类型检查器没有义务理解 Final 的任何其他用法。例如,以下程序是否进行类型检查未指定

# Note: The assignment does not exactly match the form 'var: Final = value'.
bar1: Final[int] = 3
expects_three(bar1)  # May or may not be accepted by type checkers

# Note: "Literal[1 + 2]" is not a legal type.
bar2: Final = 1 + 2
expects_three(bar2)  # May or may not be accepted by type checkers

被拒绝或超出范围的想法

本节概述了一些明确超出范围的潜在功能。

真正的依赖类型/整数泛型

此提案本质上描述了向 PEP 484 生态系统添加一个非常简化的依赖类型系统。一个显而易见的扩展是实现一个成熟的依赖类型系统,允许用户根据其值以任意方式谓词类型。这将使我们能够编写如下签名

# A vector has length 'n', containing elements of type 'T'
class Vector(Generic[N, T]): ...

# The type checker will statically verify our function genuinely does
# construct a vector that is equal in length to "len(vec1) + len(vec2)"
# and will throw an error if it does not.
def concat(vec1: Vector[A, T], vec2: Vector[B, T]) -> Vector[A + B, T]:
    # ...snip...

至少,添加某种形式的整数泛型将很有用。

尽管这种类型系统肯定会有用,但它超出了本 PEP 的范围:与当前提案相比,它需要更多实质性的实现工作、讨论和研究才能完成。

我们完全有可能将来会回过头来重新审视这个话题:我们很可能需要某种形式的依赖类型以及其他扩展,例如可变参数泛型,以支持像 numpy 这样的流行库。

本 PEP 应被视为实现此目标的垫脚石,而不是提供全面解决方案的尝试。

添加更简洁的语法

对本 PEP 的一个反对意见是,不得不显式地编写 Literal[...] 感觉很冗长。例如,与其编写

def foobar(arg1: Literal[1], arg2: Literal[True]) -> None:
    pass

……不如这样写

def foobar(arg1: 1, arg2: True) -> None:
    pass

不幸的是,这些缩写在运行时无法与现有的 typing 实现配合使用。例如,以下代码片段在 Python 3.7 中运行时会崩溃

from typing import Tuple

# Supposed to accept tuple containing the literals 1 and 2
def foo(x: Tuple[1, 2]) -> None:
    pass

运行此代码会产生以下异常

TypeError: Tuple[t0, t1, ...]: each t must be a type. Got 1.

我们不希望用户记住何时可以省略 Literal,因此我们要求 Literal 始终存在。

更广泛地说,我们认为彻底改革 Python 中类型的语法不属于本 PEP 的范围:最好在单独的 PEP 中进行讨论,而不是将其附加到本 PEP 中。因此,本 PEP 故意不尝试创新 Python 的类型语法。

回溯 Literal 类型

一旦本 PEP 被接受,Literal 类型将需要回溯到捆绑了旧版 typing 模块的 Python 版本。我们计划通过将 Literal 添加到包含各种其他回溯类型的 typing_extensions 第三方模块来实现此目的。

实施

mypy 类型检查器目前已实现本规范中描述行为的很大一部分,但枚举字面量和上面描述的一些更复杂的窄化交互除外。

致谢

感谢 Mark Mendoza、Ran Benita、Rebecca Chen 和 typing-sig 的其他成员对本 PEP 的评论。

特别感谢 mypy 和 typing 问题跟踪器的各位参与者,他们为本 PEP 提供了许多动机和理由。


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

最后修改:2025-02-01 07:28:42 GMT