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 484 中定义的 Callable[[int, str], bool]
语法,以及来自 PEP 544 的回调协议。这些方法都不支持将一个可调用的参数类型转发到另一个可调用,这使得难以注释函数装饰器。此 PEP 提出 typing.ParamSpec
和 typing.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
运行时应该接受声明中的 bound
、covariant
和 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
变量,其声明方式如上所述,而 concatenate
是 typing.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]
使类对于单个参数(当 T
是 TypeVar
时)成为泛型一样,将类定义为继承自 Generic[P]
使类对于 parameters_expression
成为泛型(当 P
是 ParamSpec
时)。
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.args
,kwargs
的类型为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)
调用,当args
和kwargs
分别是P
的组成部分,a
的类型为A
,b
的类型为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
中现有功能的唯一必要更改是允许这些ParamSpec
和Concatenate
对象成为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
我们考虑的另一个提议是定义ParametersOf
和ReturnType
运算符,这些运算符将作用于新定义的Function
类型的域。Function
只能使用ParametersOf[F]
进行调用。ParametersOf
和ReturnType
仅作用于具有此精确绑定的类型变量。这三个功能的组合可以表达我们可以用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
语法。
版权
本文档放置在公共领域或根据CC0-1.0-Universal许可证,以更宽松的为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0612.rst