PEP 742 – 使用 TypeIs 缩小类型
- 作者:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论列表:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型提示
- 创建:
- 2024年2月7日
- Python 版本:
- 3.13
- 历史记录:
- 2024年2月11日
- 替换:
- 724
- 决议:
- Discourse 消息
摘要
此 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
的行为有一些限制,使其在许多常见用例中不太有用,如 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
的当前行为而产生的其他问题包括
- Python 类型提示问题 (
numpy.isscalar
) - Python 类型提示问题 (
dataclasses.is_dataclass()
) - Pyright 问题(期望
typing.TypeGuard
的工作方式类似于isinstance()
) - Pyright 问题(期望在
else
分支中缩小) - Mypy 问题(期望在
else
分支中缩小) - Mypy 问题(组合多个 TypeGuards)
- Mypy 问题(期望在
else
分支中缩小) - Mypy 问题(类似于
inspect.isawaitable()
的用户定义函数) - Typeshed 问题 (
asyncio.iscoroutinefunction
)
基本原理
typing.TypeGuard
的当前行为存在问题,迫使我们改进类型系统以允许不同的类型缩小行为。PEP 724 提出更改现有 typing.TypeGuard
构造的行为,但我们 认为 该更改的向后兼容性影响过于严重。相反,我们建议添加一个具有所需语义的新特殊形式。
我们承认这会导致一种不幸的情况,即存在两个具有类似目的和类似语义的构造。我们认为用户更有可能想要 TypeIs
(此 PEP 中提出的新形式)的行为,因此我们建议文档强调 TypeIs
而不是 TypeGuard
作为更通用的工具。但是,TypeGuard
的语义有时很有用,我们不建议弃用或删除它。从长远来看,大多数用户应该使用 TypeIs
,而 TypeGuard
应该保留在特定需要其行为的罕见情况下。
规范
一个新的特殊形式 TypeIs
被添加到 typing
模块中。它的用法、行为和运行时实现类似于 typing.TypeGuard
。
它接受一个参数,可以用作函数的返回类型。注释为返回 TypeIs
的函数称为类型缩小函数。类型缩小函数必须返回 bool
值,类型检查器应验证所有返回路径都返回 bool
。
类型缩小函数必须至少接受一个位置参数。类型缩小行为应用于传递给函数的第一个位置参数。函数可以接受其他参数,但它们不受类型缩小的影响。如果类型缩小函数实现为实例方法或类方法,则第一个位置参数映射到第二个参数(在 self
或 cls
之后)。
类型缩小行为
为了指定 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应该缩小到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)
最终缩小的类型可能比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]
的子类型,即使B
是A
的子类型。要了解原因,请考虑以下示例。
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]
(注意bool
是int
的子类型。)这段代码在运行时失败,因为更窄的返回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)
)
TypeIs
和 TypeGuard
TypeIs
和typing.TypeGuard
都是用于根据用户定义的函数缩小变量类型的工具。两者都可用于注释接受参数并根据输入参数是否与缩小类型兼容返回布尔值的函数。然后,这些函数可用于if
检查以缩小变量的类型。
TypeIs
通常具有最直观的行为,但它引入了更多限制。TypeGuard
是在以下情况下使用的正确工具。
- 您希望缩小到与输入类型不兼容的类型,例如从
list[object]
到list[int]
。TypeIs
仅允许在兼容类型之间缩小。 - 您的函数不会对与缩小类型兼容的所有输入值返回
True
。例如,您可以拥有一个TypeGuard[int]
,它仅对正整数返回True
。
TypeIs
和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
模块中实现,并将发布在typing_extensions 4.10.0中。
一些类型检查器提供了实现。
- Mypy:已提交请求
- Pyanalyze:已提交请求
- Pyright:已添加到1.1.351版本中
被拒绝的想法
更改 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 的早期版本。
选项包括
IsInstance
(Paul Moore 的帖子):强调新构造的行为类似于内置的isinstance()
。Narrowed
或NarrowedTo
:比TypeNarrower
更短,但保留了与“类型缩小”的联系(由 Eric Traut 建议)。Predicate
或TypePredicate
:镜像 TypeScript 对该功能的名称,“类型断言”。StrictTypeGuard
(PEP 724 的早期草稿):强调新构造执行的类型缩小版本比typing.TypeGuard
更严格。TypeCheck
(Nicolas Tessore 的帖子):强调检查的二元性。TypeNarrower
:强调该函数缩小其参数类型。在 PEP 的早期版本中使用。
致谢
此 PEP 的大部分动机和规范都源自PEP 724。虽然此 PEP 对手头的问题提出了不同的解决方案,但PEP 724 的作者 Eric Traut、Rich Chiodo 和 Erik De Bonte 为他们的提议提供了有力的论据,并且如果没有他们的工作,此提议将是不可能的。
版权
本文档放置在公共领域或根据 CC0-1.0-Universal 许可证,以较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0742.rst
上次修改时间:2024-09-03 15:34:38 GMT