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

Python 增强提案

PEP 742 – 使用 TypeIs 缩小类型

作者:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
主题:
类型标注
创建日期:
2024年2月7日
Python 版本:
3.13
发布历史:
2024年2月11日
取代:
724
决议:
2024年4月3日

目录

重要

本 PEP 是一份历史文档:有关最新的规范和文档,请参阅TypeIstyping.TypeIs。规范的类型规范在 类型规范网站 维护;运行时类型行为在 CPython 文档中描述。

×

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

摘要

本 PEP 提出了一个新的特殊形式 TypeIs,以允许标注函数,这些函数可以用于缩小值的类型,类似于内置的 isinstance()。与现有的 typing.TypeGuard 特殊形式不同,TypeIs 可以在条件语句的 ifelse 分支中缩小类型。

动机

类型化的 Python 代码通常要求用户根据条件缩小变量的类型。例如,如果一个函数接受两种类型的联合,它可能会使用 isinstance() 检查来区分这两种类型。类型检查器通常支持基于各种内置函数和操作的类型缩小,但有时,使用用户定义的函数来执行类型缩小会很有用。

为了支持此类用例,PEP 647 引入了 typing.TypeGuard 特殊形式,它允许用户定义类型守卫。

from typing import assert_type, TypeGuard

def is_str(x: object) -> TypeGuard[str]:
    return isinstance(x, str)

def f(x: object) -> None:
    if is_str(x):
        assert_type(x, str)
    else:
        assert_type(x, object)

不幸的是,typing.TypeGuard 的行为有一些限制,使其在许多常见用例中不太有用,PEP 724 的“动机”部分也对此进行了说明。特别是:

  • 如果类型守卫返回 True,类型检查器必须精确地将 TypeGuard 返回类型用作缩小后的类型。它们不能使用关于变量类型的预先存在的知识。
  • 在类型守卫返回 False 的情况下,类型检查器无法应用任何额外的缩小。

标准库函数 inspect.isawaitable() 可以作为一个例子。它返回参数是否是可等待对象,typeshed 目前将其标注为:

def isawaitable(object: object) -> TypeGuard[Awaitable[Any]]: ...

一位用户 向 mypy 报告 了关于此函数行为的问题。他们观察到以下行为:

import inspect
from collections.abc import Awaitable
from typing import reveal_type

async def f(t: Awaitable[int] | int) -> None:
    if inspect.isawaitable(t):
        reveal_type(t)  # Awaitable[Any]
    else:
        reveal_type(t)  # Awaitable[int] | int

此行为与 PEP 647 一致,但与用户的预期不符。相反,他们期望在 if 分支中,t 的类型被缩小为 Awaitable[int],而在 else 分支中,类型被缩小为 int。本 PEP 提出了一个正是这样做的新的构造。

由于 TypeGuard 当前行为引起的其他问题包括:

基本原理

typing.TypeGuard 当前行为的问题迫使我们改进类型系统,以允许不同的类型缩小行为。PEP 724 提议改变现有 typing.TypeGuard 构造的行为,但我们认为这种更改的向后兼容性影响过于严重。相反,我们建议添加一个具有所需语义的新特殊形式。

我们承认这会导致一个不幸的情况,即存在两个目的相似、语义相近的构造。我们认为用户更可能希望使用本 PEP 中提出的新形式 TypeIs 的行为,因此我们建议文档强调 TypeIs 优于 TypeGuard,因为它是一种更普遍适用的工具。然而,TypeGuard 的语义偶尔也有用,我们不建议弃用或删除它。从长远来看,大多数用户应该使用 TypeIs,而 TypeGuard 应该保留用于其行为是特别需要的罕见情况。

规范

一个新的特殊形式 TypeIs 被添加到 typing 模块中。其用法、行为和运行时实现与 typing.TypeGuard 类似。

它接受一个参数,并且可以用作函数的返回类型。被标注为返回 TypeIs 的函数被称为类型缩小函数。类型缩小函数必须返回 bool 值,类型检查器应验证所有返回路径都返回 bool

类型缩小函数必须至少接受一个位置参数。类型缩小行为应用于传递给函数的第一个位置参数。该函数可以接受额外的参数,但它们不受类型缩小的影响。如果类型缩小函数实现为实例方法或类方法,则第一个位置参数映射到第二个参数(在 selfcls 之后)。

类型缩小行为

为了指定 TypeIs 的行为,我们使用以下术语:

  • I = TypeIs 输入类型
  • R = TypeIs 返回类型
  • A = 传递给类型缩小函数的参数类型(未缩小前)
  • NP = 缩小后的类型(肯定;当 TypeIs 返回 True 时使用)
  • NN = 缩小后的类型(否定;当 TypeIs 返回 False 时使用)
def narrower(x: I) -> TypeIs[R]: ...

def func1(val: A):
    if narrower(val):
        assert_type(val, NP)
    else:
        assert_type(val, NN)

返回类型 R 必须与 I 一致。如果此条件不满足,类型检查器应发出错误。

形式上,类型 *NP* 应缩小为 AR,即 *A* 和 *R* 的交集,类型 *NN* 应缩小为 A∧¬R,即 *A* 和 *R* 补集的交集。实际上,严格类型守卫的理论类型无法在 Python 类型系统中精确表达。类型检查器应回退到这些类型的实际近似值。作为经验法则,类型检查器应使用相同的类型缩小逻辑——并获得与其处理 isinstance() 一致的结果。此指导允许在未来扩展类型系统时进行更改和改进。

示例

类型缩小在肯定和否定情况下都适用:

from typing import TypeIs, assert_type

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def f(x: str | int) -> None:
    if is_str(x):
        assert_type(x, str)
    else:
        assert_type(x, int)

由于参数先前已知类型的限制,最终缩小后的类型可能比 **R** 更窄。

from collections.abc import Awaitable
from typing import Any, TypeIs, assert_type
import inspect

def isawaitable(x: object) -> TypeIs[Awaitable[Any]]:
    return inspect.isawaitable(x)

def f(x: Awaitable[int] | int) -> None:
    if isawaitable(x):
        # Type checkers may also infer the more precise type
        # "Awaitable[int] | (int & Awaitable[Any])"
        assert_type(x, Awaitable[int])
    else:
        assert_type(x, int)

缩小到与输入类型不一致的类型是一个错误。

from typing import TypeIs

def is_str(x: int) -> TypeIs[str]:  # Type checker error
    ...

子类型

TypeIs 也可作为可调用对象的返回类型,例如在回调协议和 Callable 特殊形式中。在这些上下文中,它被视为布尔类型的子类型。例如,Callable[..., TypeIs[int]] 可赋值给 Callable[..., bool]

TypeGuard 不同,TypeIs 在其参数类型上是不变的:TypeIs[B] 不是 TypeIs[A] 的子类型,即使 BA 的子类型。要了解原因,请看下面的例子:

def takes_narrower(x: int | str, narrower: Callable[[object], TypeIs[int]]):
    if narrower(x):
        print(x + 1)  # x is an int
    else:
        print("Hello " + x)  # x is a str

def is_bool(x: object) -> TypeIs[bool]:
    return isinstance(x, bool)

takes_narrower(1, is_bool)  # Error: is_bool is not a TypeIs[int]

(请注意,boolint 的子类型。)此代码在运行时失败,因为缩小器返回 False(1 不是 bool),并且在 takes_narrower() 中执行 else 分支。如果允许调用 takes_narrower(1, is_bool),类型检查器将无法检测到此错误。

向后兼容性

由于本 PEP 仅提出了一个新的特殊形式,因此对向后兼容性没有影响。

安全隐患

未知。

如何教授此内容

在讨论如何缩小类型时,类型介绍应涵盖 TypeIs,并讨论其他缩小构造,如 isinstance()。文档应强调 TypeIs 优于 typing.TypeGuard;尽管后者并未弃用且其行为偶尔有用,但我们预计 TypeIs 的行为通常更直观,大多数用户应该首先选择 TypeIs。本节的其余部分包含一些可用于面向用户的入门文档的示例内容。

何时使用 TypeIs

Python 代码通常使用像 isinstance() 这样的函数来区分值的不同可能类型。类型检查器理解 isinstance() 和各种其他检查,并使用它们来缩小变量的类型。然而,有时您希望在多个地方重用更复杂的检查,或者您使用的检查是类型检查器不理解的。在这些情况下,您可以定义一个 TypeIs 函数来执行检查,并允许类型检查器使用它来缩小变量的类型。

一个 TypeIs 函数接受一个参数,并被标注为返回 TypeIs[T],其中 T 是您想要缩小到的类型。如果参数与类型 T 兼容,该函数必须返回 True,否则返回 False。然后,该函数可以在 if 检查中使用,就像您使用 isinstance() 一样。例如:

from typing import TypeIs, Literal

type Direction = Literal["N", "E", "S", "W"]

def is_direction(x: str) -> TypeIs[Direction]:
    return x in {"N", "E", "S", "W"}

def maybe_direction(x: str) -> None:
    if is_direction(x):
        print(f"{x} is a cardinal direction")
    else:
        print(f"{x} is not a cardinal direction")

编写一个安全的 TypeIs 函数

TypeIs 函数允许您覆盖类型检查器的类型缩小行为。这是一个强大的工具,但它可能很危险,因为编写不正确的 TypeIs 函数可能导致不健全的类型检查,并且类型检查器无法检测此类错误。

对于返回 TypeIs[T] 的函数而言,为了安全,当且仅当参数与类型 T 兼容时,它必须返回 True,否则返回 False。如果此条件不满足,类型检查器可能会推断出不正确的类型。

以下是一些正确和不正确的 TypeIs 函数示例:

from typing import TypeIs

# Correct
def good_typeis(x: object) -> TypeIs[int]:
    return isinstance(x, int)

# Incorrect: does not return True for all ints
def bad_typeis1(x: object) -> TypeIs[int]:
    return isinstance(x, int) and x > 0

# Incorrect: returns True for some non-ints
def bad_typeis2(x: object) -> TypeIs[int]:
    return isinstance(x, (int, float))

此函数演示了在使用编写不佳的 TypeIs 函数时可能发生的一些错误。这些错误不会被类型检查器检测到。

def caller(x: int | str, y: int | float) -> None:
    if bad_typeis1(x):  # narrowed to int
        print(x + 1)
    else:  # narrowed to str (incorrectly)
        print("Hello " + x)  # runtime error if x is a negative int

    if bad_typeis2(y):  # narrowed to int
        # Because of the incorrect TypeIs, this branch is taken at runtime if
        # y is a float.
        print(y.bit_count())  # runtime error: this method exists only on int, not float
    else:  # narrowed to float (though never executed at runtime)
        pass

这是一个针对更复杂类型的正确 TypeIs 函数的示例:

from typing import TypedDict, TypeIs

class Point(TypedDict):
    x: int
    y: int

def is_point(x: object) -> TypeIs[Point]:
    return (
        isinstance(x, dict)
        and all(isinstance(key, str) for key in x)
        and "x" in x
        and "y" in x
        and isinstance(x["x"], int)
        and isinstance(x["y"], int)
    )

TypeIsTypeGuard

TypeIstyping.TypeGuard 都是基于用户定义函数缩小变量类型的工具。两者都可以用来标注接受一个参数并根据输入参数是否与缩小类型兼容而返回布尔值的函数。然后,这些函数可以在 if 检查中使用,以缩小变量的类型。

TypeIs 通常具有最直观的行为,但它引入了更多限制。TypeGuard 是在以下情况下使用的正确工具:

  • 您想缩小到与输入类型不兼容的类型,例如从 list[object] 缩小到 list[int]TypeIs 只允许在兼容类型之间缩小。
  • 您的函数不会对所有与缩小类型兼容的输入值返回 True。例如,您可以有一个 TypeGuard[int],它只对正整数返回 True

TypeIsTypeGuard 在以下方面有所不同:

  • TypeIs 要求缩小类型是输入类型的子类型,而 TypeGuard 不要求。
  • TypeGuard 函数返回 True 时,类型检查器会将变量的类型精确地缩小到 TypeGuard 类型。当 TypeIs 函数返回 True 时,类型检查器可以推断出更精确的类型,将变量的先前已知类型与 TypeIs 类型结合起来。(技术上,这被称为交集类型。)
  • TypeGuard 函数返回 False 时,类型检查器根本无法缩小变量的类型。当 TypeIs 函数返回 False 时,类型检查器可以缩小变量的类型以排除 TypeIs 类型。

这种行为可以在以下示例中看到:

from typing import TypeGuard, TypeIs, reveal_type, final

class Base: ...
class Child(Base): ...
@final
class Unrelated: ...

def is_base_typeguard(x: object) -> TypeGuard[Base]:
    return isinstance(x, Base)

def is_base_typeis(x: object) -> TypeIs[Base]:
    return isinstance(x, Base)

def use_typeguard(x: Child | Unrelated) -> None:
    if is_base_typeguard(x):
        reveal_type(x)  # Base
    else:
        reveal_type(x)  # Child | Unrelated

def use_typeis(x: Child | Unrelated) -> None:
    if is_base_typeis(x):
        reveal_type(x)  # Child
    else:
        reveal_type(x)  # Unrelated

参考实现

TypeIs 特殊形式已在 typing_extensions 模块中实现,并将随 typing_extensions 4.10.0 发布。

已为多个类型检查器提供了实现:

被拒绝的想法

改变 TypeGuard 的行为

PEP 724 先前建议更改 typing.TypeGuard 的指定行为,以便如果守卫的返回类型与输入类型一致,则本 PEP 为 TypeIs 提出的行为将适用。此提案有一些重要的优点:因为它不需要任何运行时更改,只需在类型检查器中进行更改,因此用户更容易利用新的、通常更直观的行为。

然而,这种方法存在一些主要问题。那些编写了期望 PEP 647 中指定现有语义的 TypeGuard 函数的用户,将会看到类型检查器在解释其代码时发生细微且可能破坏性的变化。TypeGuard 的行为是分裂的,如果返回类型与输入类型一致,它以一种方式工作;如果不一致,则以另一种方式工作,这可能会让用户感到困惑。类型委员会未能就支持 PEP 724 达成一致;因此,我们提出了这个替代的 PEP。

不作为

本 PEP 和 PEP 724 中提出的替代方案都有其不足之处。后者的缺点已在上面讨论。至于本 PEP,它引入了两个语义非常相似的特殊形式,并且可能会为目前使用 TypeGuard 但更适合不同类型缩小语义的用户,造成漫长的迁移路径。

那么,一种前进的方式是什么都不做,并接受类型系统当前的限制。然而,我们认为当前 TypeGuard 的限制,如“动机”部分所述,足够重要,值得改变类型系统来解决它们。如果我们不做任何改变,用户将继续遇到 TypeGuard 的相同不直观行为,并且类型系统将无法正确表示像 inspect.isawaitable 这样的常见类型缩小函数。

替代名称

本 PEP 目前提议使用名称 TypeIs,强调特殊形式 TypeIs[T] 返回参数是否为类型 T,并借鉴了 TypeScript 的语法。其他名称也曾被考虑过,包括本 PEP 的早期版本。

选项包括:

  • IsInstance (Paul Moore 的帖子):强调新构造的行为类似于内置的 isinstance()
  • NarrowedNarrowedTo:比 TypeNarrower 短,但保留了与“类型缩小”的联系(由 Eric Traut 建议)。
  • PredicateTypePredicate:与 TypeScript 中该功能的名称“类型谓词”相呼应。
  • StrictTypeGuardPEP 724 的早期草案):强调新构造执行比 typing.TypeGuard 更严格的类型缩小。
  • TypeCheck (Nicolas Tessore 的帖子):强调检查的二元性。
  • TypeNarrower:强调该函数缩小其参数类型。本 PEP 的早期版本中使用。

致谢

本 PEP 的大部分动机和规范源自 PEP 724。虽然本 PEP 为当前问题提出了不同的解决方案,但 PEP 724 的作者 Eric Traut、Rich Chiodo 和 Erik De Bonte 为他们的提议提供了强有力的论据,没有他们的工作,本提议是不可能实现的。


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

最后修改:2024-10-17 12:49:39 GMT