Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

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.Callabletyping.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 存在一些可用性挑战

  • 它很冗长,特别是对于更复杂的函数签名而言。
  • 它依赖于两级嵌套括号,这与其他任何泛型类型都不一样。当一些类型参数本身是泛型类型时,这可能特别难以阅读。
  • 括号结构在视觉上与函数签名的写法不相似。
  • 它需要显式导入,这与许多其他最常见的类型(如 listdict)不同。

可能是由于这个原因,程序员经常无法编写完整的 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 中第五常见的复杂类型,仅次于 OptionalTupleUnionList

其他类型已经通过 PEP 604PEP 585 改进了其语法,并消除了对导入的需要

  • typing.Optional[int] 写成 int | None
  • typing.Union[int, str] 写成 int | str
  • typing.List[int] 写成 list[int]
  • typing.Tuple[int, str] 写成 tuple[int, str]

类型 typing.Callable 的使用频率几乎与这些其他类型一样高,但更难阅读和编写,而且仍然需要导入和基于括号的语法。

在本提案中,我们选择支持 typing.Callable 的所有现有语义,而不添加对新功能的支持。在检查了每个功能在现有的类型化和无类型开源代码中的使用频率后,我们做出了这个决定。我们确定大多数用例都被涵盖了。

我们考虑过添加对命名参数、可选参数和可变参数的支持。但是,我们决定不包含这些功能,因为我们的分析表明它们很少使用。如果确实需要这些功能,可以使用 回调协议 对其进行类型化。

可调用类型的箭头语法

我们建议为 typing.Callable 提供一种简洁、易于使用的语法,看起来类似于 Python 中的函数标题。我们的建议紧密遵循几种流行语言(如 TypescriptKotlinScala)中使用的语法。

我们的目标是

  • 使用此语法的可调用类型将更容易学习和使用,特别是对于有其他语言经验的开发人员而言。
  • 库作者更有可能对可调用对象使用表达式的类型,这将使类型检查器能够更好地理解代码并查找错误,如上面的 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:
    ...

这更短,需要的导入更少。它也减少了方括号的嵌套级别 - 只有 1 级,而不是原始代码中的 3 级。

用于 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 中,一个简单的加法函数看起来像这样

function higher_order(fn: (a: string) => string): string {
  return fn("Hello, World");
}

Scala 和 Kotlin 使用本质上相同的 : 语法用于返回注释。在这些语言中,: 有意义,因为它们都使用 : 来对参数和变量进行类型注释,并且对函数返回类型的使用类似。

在 Python 中,我们使用 : 来表示函数体开始,使用 -> 来表示返回注释。因此,即使我们的提案表面上与其他语言相同,但上下文却不同。当读取包含可调用类型的函数定义时,Python 中可能会出现更多混淆。

这是一个关键问题,我们正在我们的 PEP 草案中寻求反馈;我们提出的一种想法是使用 => 来代替,以便更容易区分。

ML 语言家族

ML 家族中的语言,包括 F#OCamlHaskell,都使用 -> 来表示函数类型。它们都使用无括号语法,包含多个箭头,例如在 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

提议的新语法可以用以下 AST 更改来描述 Parser/Python.asdl

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 604A | 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

我们决定这样做有令人信服的理由

  • (...) -> bool 的语义不同于任何有效类型 T 的 (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 ParamSpecConcatenate 类型(如 (**P) -> bool(int, **P) -> bool)的良好支持。这些主要是因为 Python 代码中大量使用装饰器模式。
  • TypeVarTuples:假设 PEP 646 被接受,下一个最重要的功能是针对解包类型的功能,这些类型很常见,因为包装器将 *args 传递给其他函数的用例。

其他更复杂的提案将支持的功能只占我们发现的用例的不到 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
    

我们通过 实现 本 PEP 的语法的参考实现之上的这种扩展语法的语法和 AST,确认了当前提案与扩展语法向前兼容。

更接近函数签名的语法

我们曾经提出过一个语法,它与函数签名非常相似。

在这个提案中,以下类型将是等价的

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 会很困难,而我们认为 ParamSpec 是一个必须支持的关键功能。
  • 当使用函数作为类型时,可调用类型不是一等公民。相反,它们需要一个单独的、独立于行的函数定义来定义类型别名。
  • 它不会比回调协议支持更多功能,而且看起来更像是回调协议的简短写法,而不是 Callable 的替代品。

混合关键字-箭头语法

在 Rust 语言中,关键字 fn 用于指示函数,就像 Python 中的 def 一样,而可调用类型则使用混合箭头语法 Fn(i64, String) -> bool 表示。

我们可以在 Python 的可调用类型中使用 def 关键字,例如,我们的两个参数布尔函数可以写成 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, 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: ... 是合法的,表示一个返回可选整数的函数。为了与函数头保持一致,可调用类型也应该这样做。
  • TypeScript 是我们知道的另一个在类型表达式中同时使用 ->| 标记的流行语言,他们让 | 绑定得更紧密。虽然我们不必效仿他们的做法,但我们更倾向于这样做。
  • 我们确实承认可选的可调用类型很常见,并且让 | 绑定更紧密会导致额外的括号,这使得这些类型更难编写。但是代码比编写更常被阅读,我们认为对于像 ((int, str) -> bool) | None 这样的可选可调用类型,要求使用外层括号更有利于可读性。

引入类型字符串

另一个想法是添加一个新的“特殊字符串”语法,并将类型放在其中,例如 t”(int, str) -> bool”。我们拒绝了这个想法,因为它不那么易读,并且似乎与 指导 不一致,指导来自指导委员会,要求类型表达式不偏离 Python 语法的其他部分。

提高索引可调用类型的可用性

如果我们不想为可调用类型添加新的语法,我们可以看看如何使现有的类型更容易阅读。一个提议是使内置的 callable 函数可索引,以便它可以被用作类型

callable[[int, str], bool]

此更改类似于 PEP 585,它使得内置的集合,例如 listdict,可以用作类型,并将使导入更方便,但它不会对类型的可读性本身产生很大帮助。

为了减少在复杂可调用类型中所需的括号数量,可以允许元组用于参数列表

callable[(int, str), bool]

这实际上对于多参数函数来说是一个显著的可读性改进,但问题在于它使只有一个参数的可调用函数(这是最常见的元数)难以编写:因为 (x) 评估为 x,它们必须写成 callable[(int,), bool]。我们发现这很笨拙。

此外,这些想法都没有像当前提议那样,在减少冗长性方面有那么大的帮助,也没有像参数类型和返回值类型之间的 -> 那样提供那么强的视觉提示。

替代运行时行为

我们对运行时 API 的硬性要求是

  • 它必须通过 __args____params__ 保持与 typing.Callable 的向后兼容性。
  • 它必须提供一个结构化的 API,该 API 应该是可扩展的,如果将来我们尝试支持命名参数和可变参数。

替代 API

我们考虑让运行时数据 types.CallableType 使用一个更结构化的 API,其中将有单独的字段用于 posonlyargsparam_spec。当前提议的灵感来自 inspect.Signature 类型。

我们在字段和类型名称中使用“参数”,而不是像 inspect.Signature 中那样使用“参数”,以避免与来自旧 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.Callabletyping.Concatenate 值表现相同。

此提议与 from __future__ import annotations 之间没有特别的交互作用——就像任何其他类型注释一样,它将在模块导入时被解析为字符串,并且 typing.get_type_hints 应该在可能的情况下正确评估结果字符串。

这在“运行时行为”部分中有更详细的讨论。

参考实现

我们有一个工作的 实现,包括 AST 和语法,并使用测试验证此处提出的语法具有所需的行为。

运行时行为尚未实现。正如在规范的 运行时行为 部分中所讨论的那样,我们有一个详细的计划,包括一个向后兼容的 API 和一个更结构化的 API,它们在 单独的文档 中,我们也乐于讨论和接收其他想法。

开放性问题

运行时 API 的细节

我们试图在本文的 运行时行为 部分提供一个完整的行为规范。

但可能还有更多细节,直到我们构建一个完整的参考实现,我们才意识到我们需要定义这些细节。

优化 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。


来源:https://github.com/python/peps/blob/main/peps/pep-0677.rst

最后修改时间:2023-09-09 17:39:29 GMT