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

运行时应该接受声明中的 boundcovariantcontravariant 参数,就像 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]的函数只能用(a, b, *args, **kwargs)调用,当argskwargs分别是P的组成部分,a的类型为Ab的类型为B
  • 声明为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 的参考实现可以在这里找到 here

被拒绝的替代方案

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

我们考虑过尝试使用回调协议来实现类似的功能,该协议的参数化方式是列表类型可变参数和映射类型可变参数,如下所示。

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)会导致来自innocent_wrapper内部added调用的TypeError: problem() got multiple values for argument 'n'

如果我们可以将参数集**不**包含某个名称的约束具体化,例如,就可以避免这种情况,并且可以对这种装饰器进行类型化。

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