Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 612 – 参数规范变量

作者:
Mark Mendoza <mendoza.mark.a at gmail.com>
发起人:
Guido van Rossum <guido at python.org>
BDFL 委托
Guido van Rossum <guido at python.org>
讨论至:
Typing-SIG 邮件列表
状态:
最终版
类型:
标准跟踪
主题:
类型标注
创建日期:
2019年12月18日
Python 版本:
3.10
发布历史:
2019年12月18日, 2020年7月13日

目录

重要

本 PEP 是一份历史文档:请参阅 ParamSpectyping.ParamSpec 以获取最新的规范和文档。规范的类型规范保存在 类型规范网站;运行时类型行为在 CPython 文档中描述。

×

有关如何提议更改类型规范的信息,请参阅类型规范更新过程

摘要

目前有两种方法可以指定可调用对象的类型:PEP 484 中定义的 Callable[[int, str], bool] 语法,以及 PEP 544 中的回调协议。这两种方法都不支持将一个可调用对象的参数类型转发给另一个可调用对象,这使得函数装饰器的注解变得困难。本 PEP 提出了 typing.ParamSpectyping.Concatenate 来支持表达这类关系。

动机

现有的高阶函数注解标准并未提供足够的工具来令人满意地注解以下常见的装饰器模式

from typing import Awaitable, Callable, TypeVar

R = TypeVar("R")

def add_logging(f: Callable[..., R]) -> Callable[..., Awaitable[R]]:
  async def inner(*args: object, **kwargs: object) -> R:
    await log_to_database()
    return f(*args, **kwargs)
  return inner

@add_logging
def takes_int_str(x: int, y: str) -> int:
  return x + 7

await takes_int_str(1, "A")
await takes_int_str("B", 2) # fails at runtime

add_logging 是一个装饰器,在每次进入被装饰函数之前进行日志记录,它是 Python 惯用法的一个实例,即一个函数将其接收到的所有参数传递给另一个函数。这通过参数和参数中的 *args**kwargs 特性的组合来实现。当定义一个函数(如 inner),它接受 (*args, **kwargs) 并继续调用另一个带有 (*args, **kwargs) 的函数时,包装函数只能以被包装函数可以安全调用的所有方式安全调用。为了对这个装饰器进行类型标注,我们希望能够在可调用对象 f 的参数和返回函数的参数之间建立依赖关系。PEP 484 支持单个类型之间的依赖关系,例如 def append(l: typing.List[T], e: T) -> typing.List[T]: ...,但目前没有现有方法可以对函数参数这样复杂的实体进行此类操作。

由于现状的限制,add_logging 示例将通过类型检查但在运行时失败。inner 会将字符串“B”传递给 takes_int_str,后者会尝试将 7 添加到其中,从而触发类型错误。这未被类型检查器捕获,因为被装饰的 takes_int_str 被赋予了类型 Callable[..., Awaitable[int]](参数类型位置的省略号表示我们不对参数进行任何验证)。

在无法定义不同可调用类型参数之间的依赖关系的情况下,目前无法使 add_logging 与所有函数兼容,同时仍保留对被装饰函数参数的强制执行。

通过本 PEP 提出的 ParamSpec 变量的添加,我们可以重写前面的示例,以保持装饰器的灵活性和被装饰函数参数的强制执行。

from typing import Awaitable, Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def add_logging(f: Callable[P, R]) -> Callable[P, Awaitable[R]]:
  async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
    await log_to_database()
    return f(*args, **kwargs)
  return inner

@add_logging
def takes_int_str(x: int, y: str) -> int:
  return x + 7

await takes_int_str(1, "A") # Accepted
await takes_int_str("B", 2) # Correctly rejected by the type checker

另一个以前无法类型化的常见装饰器模式是添加或删除被装饰函数参数的做法。例如

class Request:
  ...

def with_request(f: Callable[..., R]) -> Callable[..., R]:
  def inner(*args: object, **kwargs: object) -> R:
    return f(Request(), *args, **kwargs)
  return inner

@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
  # use request
  return x + 7

takes_int_str(1, "A")
takes_int_str("B", 2) # fails at runtime

随着本 PEP 中 Concatenate 运算符的添加,我们甚至可以对这个更复杂的装饰器进行类型标注。

from typing import Concatenate

def with_request(f: Callable[Concatenate[Request, P], R]) -> Callable[P, R]:
  def inner(*args: P.args, **kwargs: P.kwargs) -> R:
    return f(Request(), *args, **kwargs)
  return inner

@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
  # use request
  return x + 7

takes_int_str(1, "A") # Accepted
takes_int_str("B", 2) # Correctly rejected by the type checker

规范

ParamSpec 变量

声明

参数规范变量的定义方式与使用 typing.TypeVar 定义普通类型变量的方式类似。

from typing import ParamSpec
P = ParamSpec("P")         # Accepted
P = ParamSpec("WrongName") # Rejected because P =/= WrongName

运行时应接受声明中的 boundcovariant 以及 contravariant 参数,就像 typing.TypeVar 那样,但目前我们将这些选项语义的标准化推迟到后续的 PEP 中。

有效使用位置

以前,只有参数参数列表 ([A, B, C]) 或省略号(表示“未定义参数”)才能作为 typing.Callable 的第一个“参数”。现在我们增加了两个新选项:参数规范变量 (Callable[P, int]) 或参数规范变量上的连接 (Callable[Concatenate[int, P], int])。

callable ::= Callable "[" parameters_expression, type_expression "]"

parameters_expression ::=
  | "..."
  | "[" [ type_expression ("," type_expression)* ] "]"
  | parameter_specification_variable
  | concatenate "["
                   type_expression ("," type_expression)* ","
                   parameter_specification_variable
                "]"

其中 parameter_specification_variable 是一个 typing.ParamSpec 变量,以上述方式声明,而 concatenatetyping.Concatenate

和以前一样,parameters_expression 本身在期望类型的地方是不被接受的。

def foo(x: P) -> P: ...                           # Rejected
def foo(x: Concatenate[int, P]) -> int: ...       # Rejected
def foo(x: typing.List[P]) -> None: ...           # Rejected
def foo(x: Callable[[int, str], P]) -> None: ...  # Rejected

用户定义的泛型类

正如将一个类定义为继承自 Generic[T] 会使该类对单个参数泛型(当 TTypeVar 时),将一个类定义为继承自 Generic[P] 会使该类对 parameters_expression 泛型(当 PParamSpec 时)。

T = TypeVar("T")
P_2 = ParamSpec("P_2")

class X(Generic[T, P]):
  f: Callable[P, int]
  x: T

def f(x: X[int, P_2]) -> str: ...                    # Accepted
def f(x: X[int, Concatenate[int, P_2]]) -> str: ...  # Accepted
def f(x: X[int, [int, bool]]) -> str: ...            # Accepted
def f(x: X[int, ...]) -> str: ...                    # Accepted
def f(x: X[int, int]) -> str: ...                    # Rejected

根据上述规则,仅对单个 ParamSpec 泛型的类的具体实例进行拼写将需要难看双括号。为了美观,我们允许省略这些。

class Z(Generic[P]):
  f: Callable[P, int]

def f(x: Z[[int, str, bool]]) -> str: ...   # Accepted
def f(x: Z[int, str, bool]) -> str: ...     # Equivalent

# Both Z[[int, str, bool]] and Z[int, str, bool] express this:
class Z_instantiated:
  f: Callable[[int, str, bool], int]

语义

函数调用返回类型的推断规则,其签名包含 ParamSpec 变量,类似于评估带有 TypeVar 的规则。

def changes_return_type_to_str(x: Callable[P, int]) -> Callable[P, str]: ...

def returns_int(a: str, b: bool) -> int: ...

f = changes_return_type_to_str(returns_int) # f should have the type:
                                            # (a: str, b: bool) -> str

f("A", True)               # Accepted
f(a="A", b=True)           # Accepted
f("A", "A")                # Rejected

expects_str(f("A", True))  # Accepted
expects_int(f("A", True))  # Rejected

与传统的 TypeVars 一样,用户可以在同一函数的参数中多次包含相同的 ParamSpec,以表明多个参数之间的依赖关系。在这些情况下,类型检查器可以选择解析为共同的行为超类型(即,一组参数,其中所有有效调用在两个子类型中都有效),但没有义务这样做。

P = ParamSpec("P")

def foo(x: Callable[P, int], y: Callable[P, int]) -> Callable[P, bool]: ...

def x_y(x: int, y: str) -> int: ...
def y_x(y: int, x: str) -> int: ...

foo(x_y, x_y)  # Should return (x: int, y: str) -> bool

foo(x_y, y_x)  # Could return (__a: int, __b: str) -> bool
               # This works because both callables have types that are
               # behavioral subtypes of Callable[[int, str], int]


def keyword_only_x(*, x: int) -> int: ...
def keyword_only_y(*, y: int) -> int: ...
foo(keyword_only_x, keyword_only_y) # Rejected

ParamSpec 泛型的用户定义类的构造函数应以相同的方式进行评估。

U = TypeVar("U")

class Y(Generic[U, P]):
  f: Callable[P, str]
  prop: U

  def __init__(self, f: Callable[P, str], prop: U) -> None:
    self.f = f
    self.prop = prop

def a(q: int) -> str: ...

Y(a, 1)   # Should resolve to Y[(q: int), int]
Y(a, 1).f # Should resolve to (q: int) -> str

Concatenate[X, Y, P] 的语义是它代表由 P 表示的参数,并预置了两个仅限位置的参数。这意味着我们可以使用它来表示添加、删除或转换可调用对象的有限数量参数的高阶函数。

def bar(x: int, *args: bool) -> int: ...

def add(x: Callable[P, int]) -> Callable[Concatenate[str, P], bool]: ...

add(bar)       # Should return (__a: str, x: int, *args: bool) -> bool

def remove(x: Callable[Concatenate[int, P], int]) -> Callable[P, bool]: ...

remove(bar)    # Should return (*args: bool) -> bool

def transform(
  x: Callable[Concatenate[int, P], int]
) -> Callable[Concatenate[str, P], bool]: ...

transform(bar) # Should return (__a: str, *args: bool) -> bool

这也意味着,虽然任何返回 R 的函数都可以满足 typing.Callable[P, R],但只有可以在第一个位置用 X 进行位置调用的函数才能满足 typing.Callable[Concatenate[X, P], R]

def expects_int_first(x: Callable[Concatenate[int, P], int]) -> None: ...

@expects_int_first # Rejected
def one(x: str) -> int: ...

@expects_int_first # Rejected
def two(*, x: int) -> int: ...

@expects_int_first # Rejected
def three(**kwargs: int) -> int: ...

@expects_int_first # Accepted
def four(*args: int) -> int: ...

这些功能仍然不支持某些类型的装饰器

  • 那些添加/删除/更改 可变 数量参数的装饰器(例如,即使在此 PEP 之后,functools.partial 仍将无法类型化)
  • 那些添加/删除/更改仅关键字参数的装饰器(有关详细信息,请参阅连接关键字参数)。

ParamSpec 的组成部分

一个 ParamSpec 捕获位置和关键字可访问参数,但不幸的是,运行时中没有对象能将这两者同时捕获。相反,我们被迫将它们分别分离为 *args**kwargs。这意味着我们需要能够将一个 ParamSpec 分割成这两个组件,然后将它们重新组合成一个调用。为此,我们引入 P.args 来表示给定调用中的位置参数元组,并引入 P.kwargs 来表示相应的关键字到值的 Mapping

有效使用位置

这些“属性”只能用作 *args**kwargs 的注解类型,并且必须从已在作用域内的 ParamSpec 访问。

def puts_p_into_scope(f: Callable[P, int]) -> None:

  def inner(*args: P.args, **kwargs: P.kwargs) -> None:      # Accepted
    pass

  def mixed_up(*args: P.kwargs, **kwargs: P.args) -> None:   # Rejected
    pass

  def misplaced(x: P.args) -> None:                          # Rejected
    pass

def out_of_scope(*args: P.args, **kwargs: P.kwargs) -> None: # Rejected
  pass

此外,由于 Python 中参数的默认类型 ((x: int)) 既可以通过位置也可以通过其名称进行寻址,因此 (*args: P.args, **kwargs: P.kwargs) 函数的两次有效调用可能会给出相同参数集的不同划分。因此,我们需要确保这些特殊类型只能一起引入并一起使用,以使我们的用法对所有可能的划分都有效。

def puts_p_into_scope(f: Callable[P, int]) -> None:

  stored_args: P.args                           # Rejected

  stored_kwargs: P.kwargs                       # Rejected

  def just_args(*args: P.args) -> None:         # Rejected
    pass

  def just_kwargs(**kwargs: P.kwargs) -> None:  # Rejected
    pass

语义

满足这些要求后,我们现在可以利用这种设置所赋予的独特属性

  • 在函数内部,args 的类型是 P.args,而不是正常注解下的 Tuple[P.args, ...]**kwargs 同理)
    • 这种特殊情况是必要的,用于封装给定调用中 args/kwargs 的异构内容,这些内容无法通过不确定的元组/字典类型来表达。
  • 类型为 Callable[P, R] 的函数可以与 (*args, **kwargs) 一起调用,当且仅当 args 的类型为 P.argskwargs 的类型为 P.kwargs,并且这些类型都源自同一函数声明。
  • 声明为 def inner(*args: P.args, **kwargs: P.kwargs) -> X 的函数,其类型为 Callable[P, X]

凭借这三个属性,我们现在能够完全类型检查保留参数的装饰器。

def decorator(f: Callable[P, int]) -> Callable[P, None]:

  def foo(*args: P.args, **kwargs: P.kwargs) -> None:

    f(*args, **kwargs)    # Accepted, should resolve to int

    f(*kwargs, **args)    # Rejected

    f(1, *args, **kwargs) # Rejected

  return foo              # Accepted

为了将其扩展到包含 Concatenate,我们声明以下属性

  • 类型为 Callable[Concatenate[A, B, P], R] 的函数只能在 argskwargsP 的相应组件,a 的类型是 Ab 的类型是 B 时,才能与 (a, b, *args, **kwargs) 一起调用。
  • 声明为 def inner(a: A, b: B, *args: P.args, **kwargs: P.kwargs) -> R 的函数,其类型为 Callable[Concatenate[A, B, P], R]。禁止将仅关键字参数放置在 *args**kwargs 之间。
def add(f: Callable[P, int]) -> Callable[Concatenate[str, P], None]:

  def foo(s: str, *args: P.args, **kwargs: P.kwargs) -> None:  # Accepted
    pass

  def bar(*args: P.args, s: str, **kwargs: P.kwargs) -> None:  # Rejected
    pass

  return foo                                                   # Accepted


def remove(f: Callable[Concatenate[int, P], int]) -> Callable[P, None]:

  def foo(*args: P.args, **kwargs: P.kwargs) -> None:
    f(1, *args, **kwargs) # Accepted

    f(*args, 1, **kwargs) # Rejected

    f(*args, **kwargs)    # Rejected

  return foo

请注意,在 ParamSpec 组件之前的参数名称在结果 Concatenate 中未提及。这意味着这些参数不能通过命名参数进行寻址

def outer(f: Callable[P, None]) -> Callable[P, None]:
  def foo(x: int, *args: P.args, **kwargs: P.kwargs) -> None:
    f(*args, **kwargs)

  def bar(*args: P.args, **kwargs: P.kwargs) -> None:
    foo(1, *args, **kwargs)   # Accepted
    foo(x=1, *args, **kwargs) # Rejected

  return bar

这并非为了实现方便,而是健全性要求。如果允许第二种调用方式,那么以下代码片段就会出现问题。

@outer
def problem(*, x: object) -> None:
  pass

problem(x="uh-oh")

bar 内部,我们会得到 TypeError: foo() got multiple values for argument 'x'。要求这些连接参数通过位置寻址可以避免这种问题,并简化了这些类型拼写的语法。请注意,这也是我们必须拒绝 (*args: P.args, s: str, **kwargs: P.kwargs) 形式签名 的原因(有关详细信息,请参阅连接关键字参数)。

如果这些前置位置参数之一包含自由 ParamSpec,我们将其视为在作用域内,以便提取该 ParamSpec 的组件。这使我们能够这样拼写

def twice(f: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> int:
  return f(*args, **kwargs) + f(*args, **kwargs)

上述示例中 twice 的类型是 Callable[Concatenate[Callable[P, int], P], int],其中 P 由外部 Callable 绑定。这具有以下语义

def a_int_b_str(a: int, b: str) -> int:
  pass

twice(a_int_b_str, 1, "A")       # Accepted

twice(a_int_b_str, b="A", a=1)   # Accepted

twice(a_int_b_str, "A", 1)       # Rejected

向后兼容性

typing 中现有功能唯一必要的更改是允许这些 ParamSpecConcatenate 对象成为 Callable 的第一个参数和 Generic 的参数。目前,Callable 期望那里有一个类型列表,而 Generic 期望单个类型,因此它们目前是互斥的。否则,不引用新接口的现有代码将不受影响。

参考实现

Pyre 类型检查器支持上述所有行为。用于这些用途所需的运行时组件的参考实现可在 pyre_extensions 模块中找到。CPython 的参考实现可在此处找到:https://github.com/python/cpython/pull/23702

被拒绝的替代方案

使用列表可变参数和映射可变参数

我们曾考虑过尝试用一个基于列表类型的可变参数和一个基于映射类型的可变参数的回调协议来实现类似的功能,如下所示

R = typing.TypeVar(“R”)
Tpositionals = ...
Tkeywords = ...
class BetterCallable(typing.Protocol[Tpositionals, Tkeywords, R]):
  def __call__(*args: Tpositionals, **kwargs: Tkeywords) -> R: ...

然而,对于给定的可调用对象,试图为这些类型变量找到一致的解决方案存在一些问题。即使是最简单的可调用对象,也会出现这个问题

def simple(x: int) -> None: ...
simple <: BetterCallable[[int], [], None]
simple <: BetterCallable[[], {“x”: int}, None]
BetterCallable[[int], [], None] </: BetterCallable[[], {“x”: int}, None]

每当一个类型能够以多种不兼容的方式实现一个协议时,我们都可能会遇到信息丢失的情况。如果我们要使用这个协议来创建一个装饰器,我们就必须选择一个首选的调用约定。

def decorator(
  f: BetterCallable[[Ts], [Tmap], int],
) -> BetterCallable[[Ts], [Tmap], str]:
  def decorated(*args: Ts, **kwargs: Tmap) -> str:
    x = f(*args, **kwargs)
    return int_to_str(x)
  return decorated

@decorator
def foo(x: int) -> int:
  return x

reveal_type(foo) # Option A: BetterCallable[[int], {}, str]
                 # Option B: BetterCallable[[], {x: int}, str]
foo(7)   # fails under option B
foo(x=7) # fails under option A

这里的核心问题是,默认情况下,Python 中的参数既可以按位置调用,也可以作为关键字参数调用。这意味着我们实际上有三类(仅位置、位置或关键字、仅关键字),我们试图将其塞进两类中。这与我们讨论 .args.kwargs 时简要提到的问题相同。从根本上讲,为了在有些事物可以属于任一类别的情况下捕获两个类别,我们需要一个更高级别的原语 (ParamSpec) 来捕获所有三个类别,然后将其拆分出来。

定义 ParametersOf

我们考虑的另一个提议是定义 ParametersOfReturnType 运算符,它们将操作于新定义的 Function 类型的领域。Function 将可调用,并且仅可与 ParametersOf[F] 一起调用。ParametersOfReturnType 将仅对具有此精确绑定的类型变量进行操作。这三个功能的组合可以表达我们使用 ParamSpecs 可以表达的一切。

F = TypeVar("F", bound=Function)

def no_change(f: F) -> F:
  def inner(
    *args: ParametersOf[F].args,
    **kwargs: ParametersOf[F].kwargs
  ) -> ReturnType[F]:
    return f(*args, **kwargs)
  return inner

def wrapping(f: F) -> Callable[ParametersOf[F], List[ReturnType[F]]]:
  def inner(
    *args: ParametersOf[F].args,
    **kwargs: ParametersOf[F].kwargs
  ) -> List[ReturnType[F]]:
    return [f(*args, **kwargs)]
  return inner

def unwrapping(
  f: Callable[ParametersOf[F], List[R]]
) -> Callable[ParametersOf[F], R]:
  def inner(
    *args: ParametersOf[F].args,
    **kwargs: ParametersOf[F].kwargs
  ) -> R:
    return f(*args, **kwargs)[0]
  return inner

我们决定采用 ParamSpec 而不是这种方法,原因有几个

  • 此更改的范围将更大,因为我们需要两个新的运算符和一个新类型,而 ParamSpec 仅引入一个新变量。
  • 到目前为止,Python 类型化一直避免支持运算符,无论是用户定义的还是内置的,而倾向于解构。因此,基于 ParamSpec 的签名看起来更像现有的 Python。
  • 缺乏用户定义的运算符使得常见的模式难以拼写。unwrapping 读起来很奇怪,因为 F 实际上没有引用任何可调用对象。它只是作为一个容器,用于我们希望传播的参数。如果我们能够定义一个运算符 RemoveList[List[X]] = X,然后 unwrapping 可以接受 F 并返回 Callable[ParametersOf[F], RemoveList[ReturnType[F]]],那么读起来会更好。没有这个,我们不幸地陷入了必须使用 Function 变量作为临时 ParamSpec 的境地,因为我们实际上从未绑定返回类型。

总之,在这两种功能等效的语法中,ParamSpec 更自然地融入了现状。

连接关键字参数

原则上,连接作为修改有限数量位置参数的方法可以扩展到包含关键字参数。

def add_n(f: Callable[P, R]) -> Callable[Concatenate[("n", int), P], R]:
  def inner(*args: P.args, n: int, **kwargs: P.kwargs) -> R:
    # use n
    return f(*args, **kwargs)
  return inner

然而,关键的区别在于,虽然在有效的可调用类型前加上仅位置参数总是会产生另一个有效的可调用类型,但添加仅关键字参数却不能这样说。正如上面所暗示的,问题在于名称冲突。参数 Concatenate[("n", int), P] 仅在 P 本身尚未包含名为 n 的参数时才有效。

def innocent_wrapper(f: Callable[P, R]) -> Callable[P, R]:
  def inner(*args: P.args, **kwargs: P.kwargs) -> R:
    added = add_n(f)
    return added(*args, n=1, **kwargs)
  return inner

@innocent_wrapper
def problem(n: int) -> None:
  pass

调用 problem(2) 运行良好,但调用 problem(n=2) 会导致 TypeError: problem() got multiple values for argument 'n',这是由于在 innocent_wrapper 内部调用 added 引起的。

这种情况是可以避免的,如果我们可以具体化一组参数 包含特定名称的约束,例如

P_without_n = ParamSpec("P_without_n", banned_names=["n"])

def add_n(
  f: Callable[P_without_n, R]
) -> Callable[Concatenate[("n", int), P_without_n], R]: ...

然后,innocent_wrapper 内部对 add_n 的调用可以被拒绝,因为无法保证该可调用对象不已经有一个名为 n 的参数。

然而,强制执行这些约束将需要足够的额外实现工作,以至于我们认为此扩展超出了本 PEP 的范围。幸运的是,ParamSpec 的设计允许我们在需求足够时再回到这个想法。

命名为 ParameterSpecification

我们认为 ParameterSpecification 对于在此处使用来说有点太冗长了,并且这种缩写名称的风格使其看起来更像 TypeVar。

命名为 ArgSpec

我们认为将其命名为 ParamSpec 比 ArgSpec 更准确,因为可调用对象具有参数,这些参数与在给定调用站点传递给它们的参数是不同的。ParamSpec 的给定绑定是一组函数参数,而不是调用站点的参数。

致谢

感谢 Pyre 团队的所有成员对本 PEP 初稿的评论,以及他们在参考实现方面的帮助。

还要感谢整个 Python 类型化社区在 Python 类型化聚会上对这个想法的早期反馈,这直接促成了更简洁的 .args/.kwargs 语法。


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

上次修改:2024-06-11 22:12:09 GMT