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
决议:
Discourse 消息

目录

注意

此 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 一致,但与用户的期望不符。相反,他们希望 t 的类型在 if 分支中缩小为 Awaitable[int],在 else 分支中缩小为 int。此 PEP 提出了一种新的构造,它正是这样做的。

由于 TypeGuard 的当前行为而产生的其他问题包括

基本原理

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

我们承认这会导致一种不幸的情况,即存在两个具有类似目的和类似语义的构造。我们认为用户更有可能想要 TypeIs(此 PEP 中提出的新形式)的行为,因此我们建议文档强调 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,即AR的交集,类型NN应该缩小到A∧¬R,即AR的补集的交集。在实践中,严格类型守卫的理论类型无法在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特殊形式中。在这些上下文中,它被视为bool的子类型。例如,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的指定行为,以便如果守卫的返回类型与输入类型一致,则此处针对TypeIs提出的行为将适用。此提议有一些重要的优势:因为它不需要任何运行时更改,它只需要在类型检查器中进行更改,从而使用户更容易利用新的、通常更直观的行为。

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

不做任何事情

这个PEP和在PEP 724中提出的替代方案都存在缺点。后者在上面进行了讨论。至于这个PEP,它引入了两个语义非常相似的特殊形式,并且它可能为当前使用TypeGuard并且更适合使用不同缩小语义的用户创建了漫长的迁移路径。

因此,一种前进的途径是无所作为,并忍受类型系统当前的局限性。但是,我们认为当前TypeGuard的局限性(如“动机”部分所述)非常显著,因此值得更改类型系统以解决这些问题。如果我们不做任何更改,用户将继续遇到来自TypeGuard的相同反直觉的行为,并且类型系统将无法正确表示诸如inspect.isawaitable之类的常用类型缩小函数。

替代名称

此 PEP 目前建议使用名称TypeIs,强调特殊形式TypeIs[T]返回参数是否为T类型,并镜像TypeScript 的语法。其他名称也在考虑之中,包括此 PEP 的早期版本。

选项包括

  • IsInstancePaul Moore 的帖子):强调新构造的行为类似于内置的isinstance()
  • NarrowedNarrowedTo:比TypeNarrower更短,但保留了与“类型缩小”的联系(由 Eric Traut 建议)。
  • PredicateTypePredicate:镜像 TypeScript 对该功能的名称,“类型断言”。
  • StrictTypeGuardPEP 724 的早期草稿):强调新构造执行的类型缩小版本比typing.TypeGuard更严格。
  • TypeCheckNicolas 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-09-03 15:34:38 GMT