PEP 677 – 可调用类型语法
- 作者:
- Steven Troxler <steven.troxler at gmail.com>, Pradeep Kumar Srinivasan <gohanpra at gmail.com>
- 发起人:
- Guido van Rossum <guido at python.org>
- 讨论至:
- Python-Dev 列表
- 状态:
- 已拒绝
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2021年12月13日
- Python 版本:
- 3.11
- 发布历史:
- 2021年12月16日
- 决议:
- Python-Dev 消息
摘要
本 PEP 引入了一种简洁友好的可调用类型语法,支持与 typing.Callable 相同的功能,但采用了受类型函数签名语法启发的箭头语法。这使得像 Callable[[int, str], bool] 这样的类型可以写成 (int, str) -> bool。
提议的语法支持 typing.Callable 和 typing.Concatenate 提供的所有功能,并旨在作为即插即用替换。
动机
使代码更安全、更容易分析的一种方法是确保函数和类得到良好的类型提示。在 Python 中,我们有类型注解,其框架定义在 PEP 484 中,用于提供类型提示,这些提示可以发现错误,同时也有助于编辑器工具,如选项卡补全、静态分析工具和代码审查。
考虑以下未类型化的代码
def flat_map(func, l):
out = []
for element in l:
out.extend(func(element))
return out
def wrap(x: int) -> list[int]:
return [x]
def add(x: int, y: int) -> int:
return x + y
flat_map(wrap, [1, 2, 3]) # no runtime error, output is [1, 2, 3]
flat_map(add, [1, 2, 3]) # runtime error: `add` expects 2 arguments, got 1
我们可以为这个例子添加类型提示来检测运行时错误
from typing import Callable
def flat_map(
func: Callable[[int], list[int]],
l: list[int]
) -> list[int]:
....
...
flat_map(wrap, [1, 2, 3]) # type checks okay, output is [1, 2, 3]
flat_map(add, [1, 2, 3]) # type check error
我们在这里可以看到 `Callable` 有一些可用性方面的挑战
- 它很冗长,尤其是对于更复杂的函数签名。
- 它依赖于两个嵌套级别的括号,这与其他泛型类型不同。当某些类型参数本身是泛型类型时,这可能尤其难以阅读。
- 括号结构在视觉上与函数签名的书写方式不相似。
- 它需要显式导入,这与其他许多最常见的类型(如
list和dict)不同。
也许因此,程序员经常未能编写完整的 Callable 类型。这种未类型化或部分类型化的可调用类型不会检查给定可调用对象的参数类型或返回类型,从而否定了静态类型的好处。例如,他们可能会写成这样
from typing import Callable
def flat_map(
func: Callable[..., Any],
l: list[int]
) -> list[int]:
....
...
flat_map(add, [1, 2, 3]) # oops, no type check error!
这里有一些部分类型信息——我们至少知道 `func` 是可调用的。但是,我们丢失了太多的类型信息,以至于类型检查器无法找到错误。
通过我们的提案,示例看起来是这样的
def flat_map(
func: (int) -> list[int],
l: list[int]
) -> list[int]:
out = []
for element in l:
out.extend(f(element))
return out
...
类型 (int) -> list[int] 更简洁,使用了与函数头中表示返回类型的箭头相似的箭头,避免了嵌套括号,并且不需要导入。
基本原理
`Callable` 类型被广泛使用。例如,截至 2021 年 10 月,它在 typeshed 中是第五大最常见的复杂类型,仅次于 Optional、Tuple、Union 和 List。
其他类型已经通过 PEP 604 或 PEP 585 改进了它们的语法并消除了导入的需要
typing.Optional[int]被写成int | Nonetyping.Union[int, str]被写成int | strtyping.List[int]被写成list[int]typing.Tuple[int, str]被写成tuple[int, str]
`typing.Callable` 类型几乎与这些其他类型一样常用,但更难读写,并且仍然需要导入和基于括号的语法。
在本提案中,我们选择支持 `typing.Callable` 的所有现有语义,而不添加对新功能的支持。在检查了每个功能在现有类型化和未类型化开源代码中的使用频率后,我们做出了这个决定。我们确定绝大多数用例都已涵盖。
我们考虑过添加对命名参数、可选参数和可变参数的支持。然而,我们决定不包含这些功能,因为我们的分析表明它们很少使用。当确实需要时,可以使用 回调协议 来类型化这些。
可调用类型的箭头语法
我们正在提议一种用于 `typing.Callable` 的简洁易用的语法,其外观类似于 Python 中的函数头。我们的提案紧密遵循了几个流行语言的语法,例如 TypeScript、Kotlin 和 Scala。
我们的目标是
- 使用此语法的可调用类型将更容易学习和使用,特别是对于有其他语言经验的开发人员。
- 库作者将更有可能使用富有表现力的可调用类型,这些类型能够让类型检查器更好地理解代码并发现错误,如上面
decorator的示例所示。
考虑一个来自 Web 服务器的简化实际示例,使用现有的 `typing.Callable` 编写
from typing import Awaitable, Callable
from app_logic import Response, UserSetting
def customize_response(
response: Response,
customizer: Callable[[Response, list[UserSetting]], Awaitable[Response]]
) -> Response:
...
通过我们的提案,这段代码可以简化为
from app_logic import Response, UserSetting
def customize_response(
response: Response,
customizer: async (Response, list[UserSetting]) -> Response,
) -> Response:
...
这样更短,需要的导入也更少。它还大大减少了方括号的嵌套——只有一级,而原始代码中有三级。
紧凑的 ParamSpec 语法
库作者在可调用类型上省略类型信息的一个特别常见的情况是定义装饰器时。考虑以下
from typing import Any, Callable
def with_retries(
f: Callable[..., Any]
) -> Callable[..., Any]:
def wrapper(retry_once, *args, **kwargs):
if retry_once:
try: return f(*args, **kwargs)
except Exception: pass
return f(*args, **kwargs)
return wrapper
@with_retries
def f(x: int) -> int:
return x
f(y=10) # oops - no type error!
在上面的代码中,很清楚装饰器应该产生一个函数,其签名与参数 f 相似,只是多了一个 retry_once 参数。但是,使用 ... 阻止了类型检查器看到这一点,也无法提醒用户 f(y=10) 是无效的。
使用 PEP 612,可以正确地对装饰器进行类型提示,如下所示
from typing import Any, Callable, Concatenate, ParamSpec, TypeVar
R = TypeVar("R")
P = ParamSpec("P")
def with_retries(
f: Callable[P, R]
) -> Callable[Concatenate[bool, P] R]:
def wrapper(retry_once: bool, *args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapper
...
通过我们提议的语法,正确类型化的装饰器示例变得简洁,并且类型表示在视觉上具有描述性
from typing import Any, ParamSpec, TypeVar
R = TypeVar("R")
P = ParamSpec("P")
def with_retries(
f: (**P) -> R
) -> (bool, **P) -> R:
...
与其他语言的比较
许多流行的编程语言使用与我们提议的语法相似的箭头语法。
TypeScript
在 TypeScript 中,函数类型以几乎与我们提议的语法相同的语法表示,但箭头标记是 =>,并且参数带有名称
(x: int, y: str) => bool
参数的名称实际上与类型无关。所以,例如,这是一个相同的可调用类型
(a: int, b: str) => bool
Kotlin
Kotlin 中的函数类型允许与我们提议的语法完全相同的语法,例如
(Int, String) -> Bool
它还允许选择性地为参数添加名称,例如
(x: Int, y: String) -> Bool
与 TypeScript 一样,参数名称(如果提供)仅用于文档目的,不是类型本身的一部分。
Scala
Scala 使用 => 箭头表示函数类型。除此之外,它们的语法与我们提议的语法相同,例如
(Int, String) => Bool
Scala 像 Python 一样,能够按名称提供函数参数。函数类型可以选择性地包含名称,例如
(x: Int, y: String) => Bool
与 TypeScript 和 Kotlin 不同,如果提供了名称,这些名称就是类型的一部分——任何实现该类型的函数都必须使用相同的名称。这与我们在 已拒绝的替代方案 部分描述的扩展语法提案类似。
函数定义与可调用类型注解
在上面列出的所有语言中,函数定义的类型注解使用 : 而不是 ->。例如,在 TypeScript 中,一个简单的 add 函数看起来像这样
function higher_order(fn: (a: string) => string): string {
return fn("Hello, World");
}
Scala 和 Kotlin 使用本质上相同的 : 语法用于返回注解。冒号在这些语言中是有意义的,因为它们都使用冒号来表示参数和变量的类型注解,返回类型的用法与之类似。
在 Python 中,我们使用冒号来表示函数体的开始,使用箭头表示返回注解。因此,即使我们的提案在表面上与其他语言相同,上下文也不同。在阅读包含可调用类型的函数定义时,Python 中存在更多混淆的可能性。
这是我们正在为草案 PEP 征求反馈的关键问题;我们提出的一个想法是改用 =>,以方便区分。
ML 语言家族
ML 家族的语言,包括 F#、OCaml 和 Haskell,都使用 -> 来表示函数类型。它们都使用无括号的语法和多个箭头,例如在 Haskell 中
Integer -> String -> Bool
使用多个箭头(这与我们的提案不同)对于该家族的语言来说是有意义的,因为它们使用自动 柯里化 函数参数,这意味着一个多参数函数表现得像一个返回函数的单参数函数。
规范
类型行为
类型检查器应以与 typing.Callable 完全相同的语义来处理新语法。
因此,类型检查器应将以下对视为完全相同
from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVarTuple
P = ParamSpec("P")
Ts = TypeVarTuple('Ts')
f0: () -> bool
f0: Callable[[], bool]
f1: (int, str) -> bool
f1: Callable[[int, str], bool]
f2: (...) -> bool
f2: Callable[..., bool]
f3: async (str) -> str
f3: Callable[[str], Awaitable[str]]
f4: (**P) -> bool
f4: Callable[P, bool]
f5: (int, **P) -> bool
f5: Callable[Concatenate[int, P], bool]
f6: (*Ts) -> bool
f6: Callable[[*Ts], bool]
f7: (int, *Ts, str) -> bool
f7: Callable[[int, *Ts, str], bool]
语法和 AST
新语法的提议可以通过对 Parser/Python.asdl 的这些 AST 更改来描述
expr = <prexisting_expr_kinds>
| AsyncCallableType(callable_type_arguments args, expr returns)
| CallableType(callable_type_arguments args, expr returns)
callable_type_arguments = AnyArguments
| ArgumentsList(expr* posonlyargs)
| Concatenation(expr* posonlyargs, expr param_spec)
这是我们对《Python 语法 <https://docs.pythonlang.cn/3/reference/grammar.htm>》的提议更改。
expression:
| disjunction disjunction 'else' expression
| callable_type_expression
| disjunction
| lambdef
callable_type_expression:
| callable_type_arguments '->' expression
| ASYNC callable_type_arguments '->' expression
callable_type_arguments:
| '(' '...' [','] ')'
| '(' callable_type_positional_argument* ')'
| '(' callable_type_positional_argument* callable_type_param_spec ')'
callable_type_positional_argument:
| !'...' expression ','
| !'...' expression &')'
callable_type_param_spec:
| '**' expression ','
| '**' expression &')'
如果 PEP 646 被接受,我们打算以两种方式支持解包类型。为了支持 PEP 646 中提议的“星号解包”语法,我们将修改 callable_type_positional_argument 的语法如下
callable_type_positional_argument:
| !'...' expression ','
| !'...' expression &')'
| '*' expression ','
| '*' expression &')'
通过此更改,形式为 (int, *Ts) -> bool 的类型应评估为 AST 形式
CallableType(
ArgumentsList(Name("int"), Starred(Name("Ts")),
Name("bool")
)
并被类型检查器视为等同于 Callable[[int, *Ts], bool] 或 Callable[[int, Unpack[Ts]], bool]。
语法的影响
→ 的优先级
→ 在类型和函数签名中,都比其他运算符的绑定更松散,因此以下两个可调用类型是等效的
(int) -> str | bool
(int) -> (str | bool)
→ 在类型和函数签名中,都向右关联。因此,以下对是等效的
(int) -> (str) -> bool
(int) -> ((str) -> bool)
def f() -> (int, str) -> bool: pass
def f() -> ((int, str) -> bool): pass
def f() -> (int) -> (str) -> bool: pass
def f() -> ((int) -> ((str) -> bool)): pass
因为运算符的绑定比 → 更紧密,所以当箭头类型打算作为 | 等运算符的参数时,需要括号。
(int) -> () -> int | () -> bool # syntax error!
(int) -> (() -> int) | (() -> bool) # okay
我们讨论了所有这些行为,并认为它们是可取的
- 并集类型(根据 PEP 604 表示为
A | B)在函数签名返回中是有效的,因此我们需要允许在返回位置使用运算符以保持一致性。 - 鉴于运算符的绑定比 → 更紧密,所以像
bool | () -> bool这样的类型必须是语法错误,这是正确的。我们应该确保错误消息清晰,因为这可能是一个常见的错误。 - 将 → 向右关联,而不是要求显式括号,与其他语言(如 TypeScript)一致,并尊重有效表达式通常可以互换使用的原则。
async 关键字
所有绑定规则仍然适用于异步可调用类型
(int) -> async (float) -> str | bool
(int) -> (async (float) -> (str | bool))
def f() -> async (int, str) -> bool: pass
def f() -> (async (int, str) -> bool): pass
def f() -> async (int) -> async (str) -> bool: pass
def f() -> (async (int) -> (async (str) -> bool)): pass
尾部逗号
- 遵循函数签名的先例,在空参数列表中放置逗号是无效的:
(,) -> bool是一个语法错误。 - 再次遵循先例,尾部逗号在其他情况下始终是允许的
((int,) -> bool == (int) -> bool ((int, **P,) -> bool == (int, **P) -> bool ((...,) -> bool) == ((...) -> bool)
允许尾部逗号也为自动格式化程序提供了更多灵活性,当将可调用类型拆分到多行时,这在遵循标准 Python 空白规则时始终是合法的。
禁止将 ... 用作参数类型
在正常情况下,任何有效表达式都允许在我们需要类型注解的位置,而 `...` 是一个有效表达式。这在语义上永远不是有效的,并且所有类型检查器都会拒绝它,但如果我们不明确阻止它,语法是允许的。
由于 `...` 作为类型没有任何意义,并且存在可用性问题,我们的语法规则排除了它,因此以下是语法错误
(int, ...) -> bool
我们决定这样做是有充分理由的
- 对于任何有效类型 T,
(...) -> bool的语义与(T) -> bool不同:(...)是表示AnyArguments的特殊形式,而T是参数列表中的类型参数。 - `...` 在存根和回调协议中用作占位符默认值,以表示可选参数。允许它出现在类型位置很容易导致混淆,甚至可能因为拼写错误而产生错误。
- 在 `tuple` 泛型类型中,我们专门处理 `...` 表示“更多相同”,例如
tuple[int, ...]表示包含一个或多个整数的元组。我们在可调用类型中不以类似方式使用 `...`,因此为了避免误解,阻止这样做是有意义的。
与 * 和 ** 其他可能用法的兼容性
使用 `**P` 支持 PEP 612 `ParamSpec` 规则排除了任何未来使用裸 **<some_type> 来类型化 kwargs 的提案。这似乎是可以接受的,因为
- 如果我们确实想要这样的语法,最好还是要求一个参数名。这也会使类型看起来更像函数签名。换句话说,如果我们支持在可调用类型中类型化 `kwargs`,我们更倾向于
(int, **kwargs: str)而不是(int, **str)。 - PEP 646 解包语法将排除使用 `*<some_type>` 来表示 `args`。`kwargs` 的情况足够相似,这使得我们不会使用裸 `**<some_type>`。
与基于箭头的 lambda 语法的兼容性
据我们所知,目前没有关于箭头风格 lambda 语法的活跃讨论,但我们仍然值得考虑采纳此提案将排除哪些可能性。
如果采纳相同的基于括号的 `->` 箭头语法用于 lambda,例如 (x, y) -> x + y 来表示 lambda x, y: x + y,这与本提案不兼容。
我们的观点是,如果我们将来想要 lambda 的箭头语法,那么使用 `=>` 会是更好的选择,例如 (x, y) => x + y。许多语言对 lambda 和可调用类型使用相同的箭头标记,但 Python 独特之处在于类型是表达式,并且必须评估为运行时值。我们认为这值得使用单独的标记,考虑到函数签名中已使用的 `->` 表示返回类型,对于可调用类型使用 `->`,对于 lambda 使用 `=>` 会更连贯。
运行时行为
新的 AST 节点需要评估为运行时类型,我们对这些运行时类型的行为有两个目标
- 它们应该公开一个结构化的 API,该 API 具有描述性和强大性,足以与扩展类型以包含命名参数和可变参数等新功能兼容。
- 它们还应该公开一个与 `typing.Callable` 向后兼容的 API。
评估和结构化 API
我们打算创建新的内置类型,新的 AST 节点将评估为这些类型,并将它们暴露在 `types` 模块中。
我们的计划是公开一个结构化的 API,就好像它们是这样定义的
class CallableType:
is_async: bool
arguments: Ellipsis | tuple[CallableTypeArgument]
return_type: object
class CallableTypeArgument:
kind: CallableTypeArgumentKind
annotation: object
@enum.global_enum
class CallableTypeArgumentKind(enum.IntEnum):
POSITIONAL_ONLY: int = ...
PARAM_SPEC: int = ...
求值规则用以下伪代码表示
def evaluate_callable_type(
callable_type: ast.CallableType | ast.AsyncCallableType:
) -> CallableType:
return CallableType(
is_async=isinstance(callable_type, ast.AsyncCallableType),
arguments=_evaluate_arguments(callable_type.arguments),
return_type=evaluate_expression(callable_type.returns),
)
def _evaluate_arguments(arguments):
match arguments:
case ast.AnyArguments():
return Ellipsis
case ast.ArgumentsList(posonlyargs):
return tuple(
_evaluate_arg(arg) for arg in args
)
case ast.ArgumentsListConcatenation(posonlyargs, param_spec):
return tuple(
*(evaluate_arg(arg) for arg in args),
_evaluate_arg(arg=param_spec, kind=PARAM_SPEC)
)
if isinstance(arguments, Any
return Ellipsis
def _evaluate_arg(arg, kind=POSITIONAL_ONLY):
return CallableTypeArgument(
kind=POSITIONAL_ONLY,
annotation=evaluate_expression(value)
)
向后兼容的 API
为了与现有的 `types.Callable` API 向后兼容,该 API 依赖于 `__args__` 和 `__parameters__` 字段,我们可以将它们定义为就像是用以下方式编写的
import itertools
import typing
def get_args(t: CallableType) -> tuple[object]:
return_type_arg = (
typing.Awaitable[t.return_type]
if t.is_async
else t.return_type
)
arguments = t.arguments
if isinstance(arguments, Ellipsis):
argument_args = (Ellipsis,)
else:
argument_args = (arg.annotation for arg in arguments)
return (
*arguments_args,
return_type_arg
)
def get_parameters(t: CallableType) -> tuple[object]:
out = []
for arg in get_args(t):
if isinstance(arg, typing.ParamSpec):
out.append(t)
else:
out.extend(arg.__parameters__)
return tuple(out)
types.CallableType 的附加行为
正如 PEP 604 中引入的并集类型的 A | B 语法一样
- `__eq__` 方法应将等效的 `typing.Callable` 值视为等于使用内置语法构造的值,否则应表现得像 `typing.Callable` 的 `__eq__`。
- `__repr__` 方法应生成一个箭头语法表示,该表示在求值时将返回一个等效的 `types.CallableType` 实例。
被拒绝的替代方案
我们考虑的许多替代方案都比 `typing.Callable` 更具表现力,例如增加了对描述包含命名、可选和可变参数的签名。
为了确定我们需要支持哪些功能,以便获得可调用类型语法,我们对现有项目进行了广泛的分析
我们决定采用一个简单的提案,改进现有 `Callable` 类型的语法,因为绝大多数回调都可以被现有的 `typing.Callable` 语义正确描述。
- 位置参数:到目前为止,最重要的情况是处理简单的带位置参数的可调用类型,例如
(int, str) -> bool - ParamSpec 和 Concatenate:下一个最重要的功能是对 PEP 612 `ParamSpec` 和 `Concatenate` 类型(如
(**P) -> bool和(int, **P) -> bool)的良好支持。这些主要用于 Python 代码中大量的装饰器模式。 - TypeVarTuples:下一个最重要的功能是支持解包类型,这在包装器将 `*args` 传递给其他函数的情况下很常见,前提是 PEP 646 被接受。
其他更复杂的提案将支持的功能占我们发现的用例的不到 2%。这些已经可以使用回调协议来表达,并且因为它们不常见,所以我们决定更倾向于采用更简单的语法。
支持命名参数和可选参数的扩展语法
另一个替代方案是提供一个兼容但更复杂的语法,该语法可以表达本 PEP 中的所有内容,还可以处理命名、可选和可变参数。在这个“扩展”语法提案中,以下类型将是等效的
class Function(typing.Protocol):
def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
...
Function = (int, y: float, *, z: bool = ..., **kwargs: str) -> bool
此语法的优点包括:- 该 PEP 提案的大部分优点(简洁性、PEP 612 支持等)- 此外,处理命名、可选和可变参数的能力
我们决定不提出它,原因如下
- 实现会更困难,并且使用情况统计数据显示,不到 3% 的用例会从任何新增功能中受益。
- 辩论这些提案的团队对于这些变化是否可取意见不一。
- 一方面,它们使可调用类型更具表现力。另一方面,它们很容易让没有阅读完整可调用类型语法规范的用户感到困惑。
- 我们认为本 PEP 中提议的更简单的语法,它不引入新语义,并且紧密模仿了 Kotlin、Scala 和 TypeScript 等其他流行语言的语法,不太可能让用户感到困惑。
- 我们打算以一种向后兼容的方式实现当前提案,这种方式可以向前兼容更复杂的扩展语法。如果社区在更多经验和讨论后决定我们想要额外功能,那么将来提出它们应该会很直接。
- 即使是完整的扩展语法也无法取代回调协议用于重载。例如,没有一种封闭形式的可调用类型可以表示一个将布尔值映射到布尔值,将整数映射到浮点数的函数,就像这个回调协议一样。
from typing import overload, Protocol class OverloadedCallback(Protocol) @overload def __call__(self, x: int) -> float: ... @overload def __call__(self, x: bool) -> bool: ... def __call__(self, x: int | bool) -> float | bool: ... f: OverloadedCallback = ... f(True) # bool f(3) # float
我们通过 实现 了此扩展语法的语法和 AST,并附带测试,验证了本 PEP 提出的语法具有期望的行为,从而确认了当前提案向后兼容扩展语法。
更接近函数签名的语法
我们之前考虑过的一种替代方案是采用一种更接近函数签名的语法。
在此提案中,以下类型将是等效的
class Function(typing.Protocol):
def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
...
Function = (x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool
该提案的好处将包括
- 签名和可调用类型之间的完美语法一致性。
- 支持本 PEP 不支持的函数签名(命名、可选、可变参数)的更多功能。
导致我们拒绝该想法的关键缺点包括以下几点
- 绝大多数用例只使用位置参数。对于该用例,此语法将更冗长,因为它需要参数名和一个显式的
/,例如(int, /) -> bool,而我们的提案允许(int) -> bool - 位置参数需要显式
/存在导致频繁错误的风险很高——这些错误通常不会被单元测试检测到——库作者会意外地使用带有命名参数的类型。 - 我们的分析表明,对
ParamSpec的支持是关键,但 PEP 612 中概述的作用域规则将使此变得困难。
考虑过的其他提案
函数即类型
我们很早就考虑过的一种想法是 允许使用函数作为类型。这个想法是允许函数代表其自身的调用签名,其语义与回调协议的 __call__ 方法大致相同。
def CallableType(
positional_only: int,
/,
named: str,
*args: float,
keyword_only: int = ...,
**kwargs: str
) -> bool: ...
f: CallableType = ...
f(5, 6.6, 6.7, named=6, x="hello", y="world") # typechecks as bool
这可能是一个好主意,但我们不认为它是可行的可调用类型替代方案。
- 处理
ParamSpec会很困难,而我们认为这是支持的关键功能。 - 当使用函数作为类型时,可调用类型不是一等值。相反,它们需要一个单独的、离行的函数定义来定义类型别名。
- 它不会比回调协议支持更多功能,而且似乎更像是编写它们的一种简写方式,而不是
Callable的替代品。
混合关键字-箭头语法
在 Rust 语言中,关键字 fn 用来表示函数,这与 Python 的 def 类似,而可调用类型则用混合箭头语法 Fn(i64, String) -> bool 表示。
我们可以使用 `def` 关键字来表示 Python 中的可调用类型,例如,我们的两个参数布尔函数可以写成 def(int, str) -> bool。但我们认为这可能会让读者误认为 def(A, B) -> C 是一个 lambda,特别是因为 JavaScript 的 function 关键字用于命名函数和匿名函数。
无括号语法
我们考虑过一种无括号语法,它会更简洁
int, str -> bool
我们反对它,因为这在视觉上与现有的函数头语法不那么相似。此外,它在视觉上与 lambda 相似,lambda 使用无括号来绑定名称:lambda x, y: x == y。
要求外部括号
当前提案的一个担忧是可读性,尤其是在返回类型位置使用可调用类型时,这会导致多个顶层 `->` 标记,例如
def make_adder() -> (int) -> int:
return lambda x: x + 1
我们考虑过一些想法来改变括号规则以防止这种情况。一种是将括号移到外面,这样两个参数的布尔函数就写成 (int, str -> bool)。通过这种更改,上面的示例变成
def make_adder() -> (int -> int):
return lambda x: x + 1
这使得许多难以理解的嵌套示例变得清晰,但我们拒绝了它,因为
- 目前在 Python 中,逗号的绑定非常松散,这意味着人们可能经常误读
(int, str -> bool)为一个第一个元素是 int 的元组,而不是一个两个参数的可调用类型。 - 它与函数头语法不太相似,而我们的目标之一是模仿函数头的熟悉语法。
- 这种语法对于深度嵌套的可调用类型可能更易读,但深度嵌套并不常见。通过样式指南鼓励在返回位置的可调用类型使用额外的括号,可以获得大部分可读性优势,而不会带来缺点。
我们也考虑过要求在参数列表和外部都使用括号,例如 ((int, str) -> bool)。通过这种更改,上面的示例变成
def make_adder() -> ((int) -> int):
return lambda x: x + 1
我们拒绝了这个更改,因为
- 外部括号仅在某些情况下有助于提高可读性,主要是在可调用类型用作返回注解时。在许多其他情况下,它们会损害可读性而不是帮助。
- 我们同意,在某些情况下鼓励使用外部括号可能是有益的,特别是对于函数返回注解中的可调用类型。但是
- 我们认为,在样式指南、linter 和自动格式化程序中鼓励这一点比将其硬编码到解析器中并抛出语法错误更合适。
- 此外,如果类型足够复杂以至于可读性是问题,我们总是可以使用类型别名,例如
IntToIntFunction: (int) -> int def make_adder() -> IntToIntFunction: return lambda x: x + 1
使 → 绑定比 | 更紧密
为了允许在类型表达式中使用 `->` 和 `|` 标记,我们必须选择优先级。在当前提案中,这是一个返回可选布尔值的函数
(int, str) -> bool | None # equivalent to (int, str) -> (bool | None)
我们曾考虑让 `->` 绑定得更紧密,以便表达式可以解析为 ((int, str) -> bool) | None。这有两个优点
- 这意味着我们不再需要将
None | (int, str) -> bool视为语法错误。 - 查看今天的 typeshed,可选的可调用参数非常常见,因为使用 `None` 作为默认值是标准的 Python 惯用法。让 `->` 绑定得更紧密将使这些更容易编写。
我们因为几个原因决定不这样做
- 函数头
def f() -> int | None: ...是合法的,表示一个返回可选 int 的函数。为了与函数头保持一致,可调用类型也应该如此。 - TypeScript 是我们所知的另一个使用 `->` 和 `|` 标记的流行语言,它们在类型表达式中,并且它们让 `|` 绑定得更紧密。虽然我们不必效仿,但我们倾向于这样做。
- 我们确实承认可选的可调用类型很常见,并且让 `|` 绑定得更紧密会强制使用额外的括号,这使得这些类型更难编写。但代码的阅读次数比编写次数多,我们认为对于像
((int, str) -> bool) | None这样的可选可调用类型,要求使用外部括号更有利于可读性。
引入类型字符串
另一个想法是添加一种新的“特殊字符串”语法,并将类型放在其中,例如 t”(int, str) -> bool”。我们拒绝了它,因为它的可读性不高,并且似乎与 Python 核心开发者委员会关于确保类型表达式不偏离 Python 其他语法 的指导方针不符。
提高索引可调用类型的可用性
如果我们不想为可调用类型添加新语法,我们可以考虑如何使现有类型更易于阅读。一个提案是使内置的 `callable` 函数可索引化,以便它可以用作类型
callable[[int, str], bool]
此更改类似于 PEP 585,它使得像 `list` 和 `dict` 这样的内置集合可以用作类型,并且将使导入更方便,但它对类型本身的可读性帮助不大。
为了减少复杂可调用类型所需的括号数量,可以使用元组来表示参数列表
callable[(int, str), bool]
这实际上是多参数函数可读性的一项重大改进,但问题在于它使得具有单个参数(这是最常见的参数数量)的可调用类型难以编写:因为 (x) 被评估为 x,它们必须写成 callable[(int,), bool]。我们觉得这很别扭。
此外,这些想法都没有像当前提案那样在减少冗长性方面提供多少帮助,也没有像参数类型和返回类型之间的 `->` 那样引入强大的视觉提示。
替代运行时行为
我们运行时 API 的硬性要求是:
- 它必须通过 `__args__` 和 `__params__` 保持与 `typing.Callable` 的向后兼容性。
- 它必须提供一个结构化的 API,如果将来我们尝试支持命名参数和可变参数,它应该是可扩展的。
替代 API
我们考虑过让运行时数据 `types.CallableType` 使用更结构化的 API,其中会有单独的字段用于 `posonlyargs` 和 `param_spec`。当前提案受到 `inspect.Signature` 类型的启发。
我们在字段和类型名称中使用“参数”(argument),而不是像 `inspect.Signature` 那样的“参数”(parameter),以避免与遗留 API 中指代类型参数而不是可调用参数的 `callable_type.__parameters__` 字段混淆。
在 __args__ 中使用纯返回类型表示异步类型
关于我们是否需要保留 `__args__` 对于异步可调用类型(如 async (int) -> str)的向后兼容性,这一点是可以争论的。原因是有人可能认为它们不能直接通过 `typing.Callable` 来表达,因此将 `__args__` 设置为 (int, int) 而不是 (int, typing.Awaitable[int]) 是可以的。
但我们认为这将是成问题的。通过保留向后兼容 API 的外观,但实际上破坏了异步类型的语义,我们将导致试图使用 `__args__` 来解释 `Callable` 的运行时类型库静默失败。
正是出于这个原因,我们自动将返回类型包装在 `Awaitable` 中。
向后兼容性
本 PEP 提出了对 `typing.Callable` 的重大语法改进,但静态语义保持不变。
因此,我们为向后兼容性所需要做的就是确保通过新语法指定的类型与它们旨在替代的等效 `typing.Callable` 和 `typing.Concatenate` 值具有相同的行为。
此提案与 `from __future__ import annotations` 之间没有特别的交互——就像任何其他类型注解一样,它将在模块导入时被解析为字符串,并且 `typing.get_type_hints` 应该在可能的情况下正确地求值结果字符串。
这在运行时行为部分有更详细的讨论。
参考实现
我们有一个可用的 实现,包含 AST 和语法,并附有测试,验证本 PEP 提出的语法具有期望的行为。
运行时行为尚未实现。正如在规范的“运行时行为”部分所讨论的,我们有一个详细的计划,包括向后兼容的 API 和更结构化的 API,在一个单独的文档中,我们对讨论和替代想法持开放态度。
未解决的问题
运行时 API 详细信息
我们已尝试在本 PEP 的“运行时行为”部分提供完整的行为规范。
但可能还有更多细节,直到我们构建完整的参考实现,我们才意识到需要定义它们。
优化 SyntaxError 消息
当前的参考实现具有功能齐全的解析器,并且此处提出的所有边缘情况都已通过测试。
但是,有一些已知情况下,错误消息不如我们期望的那样有信息量。例如,因为 (int, ...) -> bool 是非法的,但 (int, ...) 是一个有效的元组,我们当前生成的语法错误将 `->` 标记为问题,即使错误的真正原因是在参数类型中使用 `...`。
这本身并不是规范的一部分,但它是我们在实现中需要解决的一个重要细节。解决方案可能涉及在 `python.gram` 中添加 `invalid_.*` 规则并自定义错误消息。
资源
背景和历史
PEP 484 规定了一个非常相似的语法,用于在需要兼容 Python 2.7 的代码和过渡代码中使用的函数类型提示 *注释*。例如
def f(x, y):
# type: (int, str) -> bool
...
当时,我们使用索引操作来指定泛型类型,例如 `typing.Callable`,因为我们决定不为类型添加语法。然而,此后我们开始这样做,例如使用 PEP 604。
Maggie 在 PyCon Typing Summit 2021 的一个更广泛的 关于类型简化的演示文稿 中提议了更好的可调用类型语法。
Steven 在 typing-sig 邮件列表中 提出了这个提案。我们举行了几次会议来讨论替代方案,而 这个演示文稿 使我们有了当前的提案。
Pradeep 将此提案 提交给 python-dev 以获取反馈。
致谢
感谢以下人员对 PEP 的反馈以及在规划参考实现方面提供的帮助
Alex Waygood, Eric Traut, Guido van Rossum, James Hilton-Balfe, Jelle Zijlstra, Maggie Moss, Tuomas Suutari, Shannon Zhu.
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0677.rst