Python - PEP 742 – 用 TypeIs 缩小类型
摘要
本 PEP 提议新增特殊形式 TypeIs,用于标注可缩小值类型的函数,作用类似内建 isinstance()。不同于现有 typing.TypeGuard 特殊形式,TypeIs 可同时在条件的 if 和 else 分支缩小类型。
动机
类型化 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,但不符用户预期。用户希望 t 在 if 分支中缩小为 Awaitable[int],在 else 为 int。本 PEP 提议的新构造正好满足这一点。
更多相关例子和问题:
- Python typing issue(
numpy.isscalar) - Python typing issue(
dataclasses.is_dataclass()) - Pyright issue(期待
TypeGuard行为类似isinstance()) - Pyright issue(期待
else分支也缩小) - Mypy issue(期待
else分支缩小) - Mypy issue(多个 TypeGuard 组合)
- Mypy issue(期待
else分支缩小) - Mypy issue(用户自定义类似
inspect.isawaitable()的函数) - Typeshed issue(
asyncio.iscoroutinefunction)
原理
现有 typing.TypeGuard 的行为问题促使我们改进类型系统,允许不同的类型缩小行为。PEP 724 曾建议直接更改 TypeGuard 行为,但我们认为这会带来严重的兼容性问题。因此,本 PEP提出增加新的特殊形式,语义如所需。
我们承认,这导致存在两个语义相近的工具。我们认为用户多数会更喜欢本 PEP新提议的 TypeIs,因此建议文档优先介绍 TypeIs,TypeGuard 仅在特殊场景下使用。长远看,绝大多数用户应使用 TypeIs,TypeGuard 留给极少数特殊用例。
规范
typing 模块新增特殊形式 TypeIs。其用法、行为、运行时实现类似 typing.TypeGuard。
TypeIs 接受单一参数,可作为函数返回类型。标注为 TypeIs 返回的函数称为类型缩小函数,必须返回 bool,类型检查器应确保所有返回路径皆为 bool。
类型缩小函数需至少一个位置参数,类型缩小只作用于第一个参数。可有更多参数,但不影响缩小。若为实例/类方法,第一个实际参数为第二个参数(即跳过 self 或 cls)。
类型缩小行为
用以下术语描述 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]
(bool 是 int 的子类型)
向后兼容性
本 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)
)
TypeIs 与 TypeGuard
TypeIs 和 typing.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 发布。
部分类型检查器已支持:
- Mypy: PR已开
- Pyanalyze: PR
- Pyright: 1.1.351 起支持
被拒绝的方案
更改 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/
68

被折叠的 条评论
为什么被折叠?



