Python - PEP 742 – 用 TypeIs 缩小类型

Python - PEP 742 – 用 TypeIs 缩小类型

摘要

本 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 的行为有局限,导致许多常见用例不便。具体有:

  • 类型检查器必须TypeGuard 返回类型作为缩小类型,不能利用变量已知的类型信息。
  • 若类型守卫返回 False,类型检查器不能做进一步缩小。

标准库函数 inspect.isawaitable() 是典型例子。其返回参数是否为 awaitable,typeshed 目前标注为:

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

用户报告如下行为:

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,但不符用户预期。用户希望 tif 分支中缩小为 Awaitable[int],在 elseint。本 PEP 提议的新构造正好满足这一点。

更多相关例子和问题:

原理

现有 typing.TypeGuard 的行为问题促使我们改进类型系统,允许不同的类型缩小行为。PEP 724 曾建议直接更改 TypeGuard 行为,但我们认为这会带来严重的兼容性问题。因此,本 PEP提出增加新的特殊形式,语义如所需。

我们承认,这导致存在两个语义相近的工具。我们认为用户多数会更喜欢本 PEP新提议的 TypeIs,因此建议文档优先介绍 TypeIsTypeGuard 仅在特殊场景下使用。长远看,绝大多数用户应使用 TypeIsTypeGuard 留给极少数特殊用例。

规范

typing 模块新增特殊形式 TypeIs。其用法、行为、运行时实现类似 typing.TypeGuard

TypeIs 接受单一参数,可作为函数返回类型。标注为 TypeIs 返回的函数称为类型缩小函数,必须返回 bool,类型检查器应确保所有返回路径皆为 bool

类型缩小函数需至少一个位置参数,类型缩小只作用于第一个参数。可有更多参数,但不影响缩小。若为实例/类方法,第一个实际参数为第二个参数(即跳过 selfcls)。

类型缩小行为

用以下术语描述 TypeIs 行为:

  • I = TypeIs 输入类型
  • R = TypeIs 返回类型
  • A = 传入缩小函数的参数实际类型(缩小前)
  • NP = True 分支缩小类型(正向缩小)
  • NN = 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 应为 A∧R(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)

最终缩小类型会受到参数原始类型限制:

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):
        # 类型检查器可推断更精确类型 Awaitable[int]
        assert_type(x, Awaitable[int])
    else:
        assert_type(x, int)

若缩小到与输入类型不一致类型,类型检查器应报错:

from typing import TypeIs

def is_str(x: int) -> TypeIs[str]:  # 错误
    ...

子类型关系

TypeIs 也可作为回调协议、Callable 的返回类型,此时视为 bool 的子类型,例如 Callable[..., TypeIs[int]] 可赋给 Callable[..., bool]

TypeGuard 不同,TypeIs 在参数类型上是不变的:TypeIs[B] 不是 TypeIs[A] 的子类型,即便 B 是 A 的子类型。例如:

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

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

takes_narrower(1, is_bool)  # 错误:is_bool 不是 TypeIs[int]

boolint 的子类型)

向后兼容性

本 PEP 仅新增特殊形式,无兼容性影响。

安全影响

无已知安全影响。

如何教学

类型系统入门应在类型缩小一节介绍 TypeIs,并与 isinstance() 等缩小方式对比。文档应强调 TypeIs 优于 typing.TypeGuard;后者虽不弃用并有特殊用处,但多数用户应优先考虑 TypeIs。以下为可供文档采用的示例内容。

何时使用 TypeIs

Python 代码常用 isinstance() 区分值的不同类型。类型检查器能理解 isinstance() 并据此缩小变量类型。但有时你希望复用复杂的检查逻辑,或用类型检查器无法理解的检查。这时可用 TypeIs 函数,让类型检查器也能缩小类型。

TypeIs 函数接收一个参数,返回类型标注为 TypeIs[T],表示想缩小成类型 T。函数必须在参数为 T 时返回 True,否则返回 False。可像 isinstance() 一样在 if 判断中用。例如:

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。否则类型检查器会推断出错误类型。

正确和错误例子:

from typing import TypeIs

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

# 错误:未覆盖所有 int
def bad_typeis1(x: object) -> TypeIs[int]:
    return isinstance(x, int) and x > 0

# 错误:部分非 int 返回 True
def bad_typeis2(x: object) -> TypeIs[int]:
    return isinstance(x, (int, float))

错误用法无法被类型检查器检测,可能导致运行时错误:

def caller(x: int | str, y: int | float) -> None:
    if bad_typeis1(x):  # 缩小为 int
        print(x + 1)
    else:  # 错误地缩小为 str
        print("Hello " + x)  # 若 x 为负 int 会报错

    if bad_typeis2(y):  # 缩小为 int
        # 若 y 是 float,此分支会被执行,导致运行时错误
        print(y.bit_count())  # float 无此方法
    else:
        pass

复杂类型的正确例子:

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 都能用于基于用户自定义函数缩小变量类型。两者区别:

  • 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 实现(PR),将在 typing_extensions 4.10.0 发布。

部分类型检查器已支持:

被拒绝的方案

更改 TypeGuard 行为

PEP 724 曾提议更改 TypeGuard 行为,但这会导致已有依赖 PEP 647 语义的代码出现微妙且破坏性的变化,也会让 TypeGuard 行为变得难以理解。类型理事会未能达成一致。因此本 PEP 采取了新增特殊形式的方案。

什么都不做

PEP 724 和本 PEP 都有缺点。本 PEP会导致两个语义相近的特殊形式,可能导致用户长时间迁移;但如果不做改变,用户会持续遇到 TypeGuard 的不直观行为,类型系统也无法正确表达常见的类型缩小函数,比如 inspect.isawaitable

其他命名方案

本 PEP目前采用 TypeIs,意为“参数是否为类型 T”,呼应 TypeScript 的写法。其他备选名包括:

  • IsInstance:强调类似 isinstance() 行为
  • Narrowed/NarrowedTo:突出“类型缩小”
  • Predicate/TypePredicate:呼应 TypeScript 的名称
  • StrictTypeGuard:强调更严格的类型缩小
  • TypeCheck:突出二元判断特性
  • TypeNarrower:强调缩小作用

致谢

本 PEP 很多动机和规范来自 PEP 724。虽然本 PEP 方案不同,724 的作者 Eric Traut、Rich Chiodo 和 Erik De Bonte 的工作为此案奠定了基础。

版权

本文档可置于公有领域或 CC0-1.0-Universal 许可下,以更宽松者为准。

原文地址:https://peps.python.org/pep-0742/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

csdddn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值