PEP 646 – 可变参数泛型
- 作者:
- Mark Mendoza <mendoza.mark.a at gmail.com>、Matthew Rahtz <mrahtz at google.com>、Pradeep Kumar Srinivasan <gohanpra at gmail.com>、Vincent Siles <vsiles at fb.com>
- 发起人:
- Guido van Rossum <guido at python.org>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2020年9月16日
- Python 版本:
- 3.11
- 发布历史:
- 2020年10月7日,2020年12月23日,2020年12月29日
- 决议:
- Python-Dev 消息
摘要
PEP 484 引入了 TypeVar
,允许创建用单个类型参数化的泛型。在此 PEP 中,我们引入了 TypeVarTuple
,允许用任意数量的类型进行参数化——即,一个可变参数类型变量,实现可变参数泛型。这使得各种用例成为可能。特别是,它允许 NumPy 和 TensorFlow 等数值计算库中类似数组的结构类型用数组形状进行参数化,从而使静态类型检查器能够捕获使用这些库的代码中与形状相关的错误。
接受
此 PEP 已被 Python 3.11 接受,但有一个警告:类型表达式中多重解包的细节尚未精确指定。这为各个类型检查器留下了一些余地,但可以在未来的 PEP 中收紧。
动机
可变参数泛型长期以来一直是许多用例所要求的功能 [4]。一个特别的用例——一个可能产生巨大影响的用例,也是此 PEP 主要针对的用例——涉及数值库中的类型化。
在 NumPy 和 TensorFlow 等库进行数值计算的背景下,变量的形状通常与变量类型同样重要。例如,考虑以下将一批 [1] 视频转换为灰度图的函数
def to_gray(videos: Array): ...
仅从签名来看,不清楚我们应该为 videos
参数传递什么形状的数组 [2]。可能性包括,例如:
批次 × 时间 × 高度 × 宽度 × 通道
和
时间 × 批次 × 通道 × 高度 × 宽度。[3]
这很重要,原因有三:
- 文档。如果签名中未明确所需形状,用户必须在文档字符串或相关代码中查找以确定输入/输出形状要求。
- 在运行时之前捕获形状错误。理想情况下,使用不正确形状应该是一个我们可以通过静态分析提前捕获的错误。(这对于机器学习代码尤其重要,因为迭代时间可能很慢。)
- 防止微妙的形状错误。在最坏的情况下,使用错误的形状会导致程序看似运行正常,但存在一个微妙的错误,可能需要数天才能追踪到。(请参阅流行机器学习教程中的 此练习,以了解一个特别恶劣的例子。)
理想情况下,我们应该有某种方法在类型签名中明确形状要求。多个提案 [6] [7] [9] 建议为此目的使用标准泛型语法。我们会写
def to_gray(videos: Array[Time, Batch, Height, Width, Channels]): ...
然而,请注意数组可以是任意维度的——上面使用的 Array
是在任意数量的轴上泛型的。一种解决方法是为每个维度使用不同的 Array
类……
Axis1 = TypeVar('Axis1')
Axis2 = TypeVar('Axis2')
class Array1(Generic[Axis1]): ...
class Array2(Generic[Axis1, Axis2]): ...
……但这会很麻烦,对用户来说(他们必须在代码中散布1、2等数字)和对数组库的作者来说(他们必须在多个类中重复实现)都是如此。
可变参数泛型对于将任意数量轴泛型化的 Array
清晰地定义为单个类是必要的。
摘要示例
直接地说,此 PEP 允许使用新引入的任意长度类型变量 TypeVarTuple
定义一个在其形状(和数据类型)上泛型的 Array
类,如下所示
from typing import TypeVar, TypeVarTuple
DType = TypeVar('DType')
Shape = TypeVarTuple('Shape')
class Array(Generic[DType, *Shape]):
def __abs__(self) -> Array[DType, *Shape]: ...
def __add__(self, other: Array[DType, *Shape]) -> Array[DType, *Shape]: ...
这样的 Array
可以用于支持多种不同类型的形状注解。例如,我们可以添加描述每个轴语义含义的标签
from typing import NewType
Height = NewType('Height', int)
Width = NewType('Width', int)
x: Array[float, Height, Width] = Array()
我们还可以添加描述每个轴实际大小的注解
from typing import Literal as L
x: Array[float, L[480], L[640]] = Array()
为了一致性,我们在本 PEP 的示例中以语义轴注解为基础,但本 PEP 对这两种(或可能其他的)使用 Array
的方式哪种更可取持不可知态度;该决定留给库作者。
(另请注意,为简洁起见,在本 PEP 的其余部分,我们使用 Array
的简化版本,该版本仅在形状上泛型——不在数据类型上。)
规范
为了支持上述用例,我们引入了 TypeVarTuple
。它不是单个类型的占位符,而是元组类型的占位符。
此外,我们为星号操作符引入了一种新用法:用于“解包”TypeVarTuple
实例和元组类型,例如 Tuple[int, str]
。解包 TypeVarTuple
或元组类型相当于在类型系统中解包变量或值元组。
类型变量元组
与普通类型变量是像 int
这样的单个类型的替身一样,类型变量元组是像 Tuple[int, str]
这样的元组类型的替身。
类型变量元组的创建方式如下:
from typing import TypeVarTuple
Ts = TypeVarTuple('Ts')
在泛型类中使用类型变量元组
类型变量元组的行为类似于打包在 Tuple
中的多个独立类型变量。为了理解这一点,请考虑以下示例
Shape = TypeVarTuple('Shape')
class Array(Generic[*Shape]): ...
Height = NewType('Height', int)
Width = NewType('Width', int)
x: Array[Height, Width] = Array()
这里的 Shape
类型变量元组的行为类似于 Tuple[T1, T2]
,其中 T1
和 T2
是类型变量。要将这些类型变量用作 Array
的类型参数,我们必须使用星号运算符解包类型变量元组:*Shape
。Array
的签名然后表现得好像我们只写了 class Array(Generic[T1, T2]): ...
。
然而,与 Generic[T1, T2]
不同,Generic[*Shape]
允许我们用任意数量的类型参数来参数化类。也就是说,除了能够定义像 Array[Height, Width]
这样的二维数组之外,我们还可以定义三维数组、四维数组等等
Time = NewType('Time', int)
Batch = NewType('Batch', int)
y: Array[Batch, Height, Width] = Array()
z: Array[Time, Batch, Height, Width] = Array()
在函数中使用类型变量元组
类型变量元组可以像普通 TypeVar
一样在任何地方使用。这包括上面所示的类定义,以及函数签名和变量注解。
class Array(Generic[*Shape]):
def __init__(self, shape: Tuple[*Shape]):
self._shape: Tuple[*Shape] = shape
def get_shape(self) -> Tuple[*Shape]:
return self._shape
shape = (Height(480), Width(640))
x: Array[Height, Width] = Array(shape)
y = abs(x) # Inferred type is Array[Height, Width]
z = x + x # ... is Array[Height, Width]
类型变量元组必须始终解包
请注意,在前面的示例中,__init__
的 shape
参数被注解为 Tuple[*Shape]
。为什么这是必要的——如果 Shape
behaves like Tuple[T1, T2, ...]
,我们不能直接将 shape
参数注解为 Shape
吗?
这实际上是故意不可能的:类型变量元组必须始终以解包形式使用(即,前缀为星号运算符)。这有两个原因
- 为了避免关于是使用打包形式还是解包形式的类型变量元组的潜在混淆(“嗯,我应该写‘
-> Shape
’,还是‘-> Tuple[Shape]
’,还是‘-> Tuple[*Shape]
’……?”) - 为了提高可读性:星号也作为显式视觉指示器,表明类型变量元组不是普通类型变量。
Unpack
用于向后兼容
请注意,在此上下文中使用星号运算符需要语法更改,因此仅在 Python 的新版本中可用。为了在旧版本 Python 中启用类型变量元组的使用,我们引入了 Unpack
类型运算符,可用于代替星号运算符
# Unpacking using the star operator in new versions of Python
class Array(Generic[*Shape]): ...
# Unpacking using ``Unpack`` in older versions of Python
class Array(Generic[Unpack[Shape]]): ...
方差、类型约束和类型界限:尚不支持
为了使本 PEP 保持最小化,TypeVarTuple
尚不支持以下规范
- 方差(例如
TypeVar('T', covariant=True)
) - 类型约束 (
TypeVar('T', int, float)
) - 类型界限 (
TypeVar('T', bound=ParentClass)
)
我们将这些参数的行为决定留给未来的 PEP,届时可变参数泛型已在实践中经过测试。截至本 PEP,类型变量元组是不变的。
类型变量元组相等性
如果同一个 TypeVarTuple
实例在签名或类中的多个位置使用,有效的类型推断可能是将 TypeVarTuple
绑定到类型 Union
的 Tuple
def foo(arg1: Tuple[*Ts], arg2: Tuple[*Ts]): ...
a = (0,)
b = ('0',)
foo(a, b) # Can Ts be bound to Tuple[int | str]?
我们不允许这样做;类型联合不得出现在 Tuple
中。如果类型变量元组出现在签名中的多个位置,则类型必须完全匹配(类型参数列表的长度必须相同,并且类型参数本身必须相同)
def pointwise_multiply(
x: Array[*Shape],
y: Array[*Shape]
) -> Array[*Shape]: ...
x: Array[Height]
y: Array[Width]
z: Array[Height, Width]
pointwise_multiply(x, x) # Valid
pointwise_multiply(x, y) # Error
pointwise_multiply(x, z) # Error
多个类型变量元组:不允许
根据本 PEP,类型参数列表中只能出现一个类型变量元组
class Array(Generic[*Ts1, *Ts2]): ... # Error
原因是多个类型变量元组会使哪些参数绑定到哪个类型变量元组变得模糊不清
x: Array[int, str, bool] # Ts1 = ???, Ts2 = ???
类型连接
类型变量元组不必单独使用;普通类型可以作为前缀和/或后缀
Shape = TypeVarTuple('Shape')
Batch = NewType('Batch', int)
Channels = NewType('Channels', int)
def add_batch_axis(x: Array[*Shape]) -> Array[Batch, *Shape]: ...
def del_batch_axis(x: Array[Batch, *Shape]) -> Array[*Shape]: ...
def add_batch_channels(
x: Array[*Shape]
) -> Array[Batch, *Shape, Channels]: ...
a: Array[Height, Width]
b = add_batch_axis(a) # Inferred type is Array[Batch, Height, Width]
c = del_batch_axis(b) # Array[Height, Width]
d = add_batch_channels(a) # Array[Batch, Height, Width, Channels]
普通 TypeVar
实例也可以作为前缀和/或后缀
T = TypeVar('T')
Ts = TypeVarTuple('Ts')
def prefix_tuple(
x: T,
y: Tuple[*Ts]
) -> Tuple[T, *Ts]: ...
z = prefix_tuple(x=0, y=(True, 'a'))
# Inferred type of z is Tuple[int, bool, str]
解包元组类型
我们提到 TypeVarTuple
代表一个类型元组。既然我们可以解包 TypeVarTuple
,为了保持一致性,我们也允许解包元组类型。正如我们将看到的,这也支持了许多有趣的特性。
解包具体元组类型
解包一个具体元组类型类似于在运行时解包一个值元组。Tuple[int, *Tuple[bool, bool], str]
等效于 Tuple[int, bool, bool, str]
。
解包无界元组类型
解包一个无界元组会保持无界元组的原样。也就是说,*Tuple[int, ...]
仍然是 *Tuple[int, ...]
;没有更简单的形式。这使得我们能够指定像 Tuple[int, *Tuple[str, ...], str]
这样的类型——一个元组类型,其中第一个元素保证是 int
类型,最后一个元素保证是 str
类型,中间的元素是零个或多个 str
类型的元素。请注意,Tuple[*Tuple[int, ...]]
等效于 Tuple[int, ...]
。
在函数签名中解包无界元组也很有用,当我们不关心确切元素并且不想定义不必要的 TypeVarTuple
时
def process_batch_channels(
x: Array[Batch, *Tuple[Any, ...], Channels]
) -> None:
...
x: Array[Batch, Height, Width, Channels]
process_batch_channels(x) # OK
y: Array[Batch, Channels]
process_batch_channels(y) # OK
z: Array[Batch]
process_batch_channels(z) # Error: Expected Channels.
我们还可以将 *Tuple[int, ...]
传递给任何期望 *Ts
的地方。这在代码特别动态且无法说明维度的精确数量或每个维度的精确类型时非常有用。在这些情况下,我们可以平稳地回退到无界元组。
y: Array[*Tuple[Any, ...]] = read_from_file()
def expect_variadic_array(
x: Array[Batch, *Shape]
) -> None: ...
expect_variadic_array(y) # OK
def expect_precise_array(
x: Array[Batch, Height, Width, Channels]
) -> None: ...
expect_precise_array(y) # OK
Array[*Tuple[Any, ...]]
代表一个具有任意数量 Any
类型维度的数组。这意味着,在调用 expect_variadic_array
时,Batch
绑定到 Any
,Shape
绑定到 Tuple[Any, ...]
。在调用 expect_precise_array
时,变量 Batch
、Height
、Width
和 Channels
都绑定到 Any
。
这使得用户能够优雅地处理动态代码,同时仍然明确将代码标记为不安全(通过使用 y: Array[*Tuple[Any, ...]]
)。否则,每当用户尝试使用变量 y
时,类型检查器都会发出嘈杂的错误,这将阻碍他们在将旧代码库迁移到使用 TypeVarTuple
时。
元组中多次解包:不允许
与 TypeVarTuples
一样,在一个元组中只允许出现一个解包
x: Tuple[int, *Ts, str, *Ts2] # Error
y: Tuple[int, *Tuple[int, ...], str, *Tuple[str, ...]] # Error
*args
作为类型变量元组
PEP 484 指出,当为 *args
提供类型注解时,每个参数都必须是所注解的类型。也就是说,如果我们将 *args
指定为 int
类型,那么所有参数都必须是 int
类型。这限制了我们指定接受异构参数类型函数的类型签名的能力。
然而,如果 *args
被注解为类型变量元组,那么单个参数的类型就成为类型变量元组中的类型
Ts = TypeVarTuple('Ts')
def args_to_tuple(*args: *Ts) -> Tuple[*Ts]: ...
args_to_tuple(1, 'a') # Inferred type is Tuple[int, str]
在上面的例子中,Ts
绑定到 Tuple[int, str]
。如果没有传递参数,类型变量元组的行为就像一个空元组 Tuple[()]
。
像往常一样,我们可以解包任何元组类型。例如,通过在其他类型元组中使用类型变量元组,我们可以引用可变参数列表的前缀或后缀。例如
# os.execle takes arguments 'path, arg0, arg1, ..., env'
def execle(path: str, *args: *Tuple[*Ts, Env]) -> None: ...
请注意,这与
def execle(path: str, *args: *Ts, env: Env) -> None: ...
因为这会使 env
成为一个仅限关键字的参数。
使用解包的无界元组等同于 PEP 484 中 *args: int
的行为,它接受零个或多个 int
类型的值。
def foo(*args: *Tuple[int, ...]) -> None: ...
# equivalent to:
def foo(*args: int) -> None: ...
解包元组类型也允许对异构 *args
进行更精确的类型定义。以下函数期望开头有一个 int
,零个或多个 str
值,结尾有一个 str
。
def foo(*args: *Tuple[int, *Tuple[str, ...], str]) -> None: ...
为了完整性,我们提到解包具体元组允许我们指定固定数量异构类型的 *args
def foo(*args: *Tuple[int, str]) -> None: ...
foo(1, "hello") # OK
请注意,为了与类型变量元组必须始终解包使用的规则保持一致,将 *args
注解为普通类型变量元组实例是不允许的
def foo(*args: Ts): ... # NOT valid
*args
是唯一可以将参数直接注解为 *Ts
的情况;其他参数应该使用 *Ts
来参数化其他内容,例如 Tuple[*Ts]
。如果 *args
本身被注解为 Tuple[*Ts]
,则旧行为仍然适用:所有参数都必须是使用相同类型参数化的 Tuple
。
def foo(*args: Tuple[*Ts]): ...
foo((0,), (1,)) # Valid
foo((0,), (1, 2)) # Error
foo((0,), ('1',)) # Error
最后,请注意,类型变量元组不能用作 **kwargs
的类型。(我们尚未发现此功能的用例,因此我们倾向于为未来潜在的 PEP 留出空间。)
# NOT valid
def foo(**kwargs: *Ts): ...
带 Callable
的类型变量元组
类型变量元组也可以用在 Callable
的参数部分
class Process:
def __init__(
self,
target: Callable[[*Ts], None],
args: Tuple[*Ts],
) -> None: ...
def func(arg1: int, arg2: str) -> None: ...
Process(target=func, args=(0, 'foo')) # Valid
Process(target=func, args=('foo', 0)) # Error
其他类型和普通类型变量也可以作为类型变量元组的前缀/后缀
T = TypeVar('T')
def foo(f: Callable[[int, *Ts, T], Tuple[T, *Ts]]): ...
包含解包项的 Callable(无论是 TypeVarTuple
还是元组类型)的行为是将其元素视为 *args
的类型。因此,Callable[[*Ts], None]
被视为函数的类型
def foo(*args: *Ts) -> None: ...
Callable[[int, *Ts, T], Tuple[T, *Ts]]
被视为函数的类型
def foo(*args: *Tuple[int, *Ts, T]) -> Tuple[T, *Ts]: ...
未指定类型参数时的行为
当一个由类型变量元组参数化的泛型类在没有任何类型参数的情况下使用时,它的行为就好像类型变量元组被替换为 Tuple[Any, ...]
def takes_any_array(arr: Array): ...
# equivalent to:
def takes_any_array(arr: Array[*Tuple[Any, ...]]): ...
x: Array[Height, Width]
takes_any_array(x) # Valid
y: Array[Time, Height, Width]
takes_any_array(y) # Also valid
这使得渐进式类型化成为可能:现有的接受普通 TensorFlow Tensor
的函数仍将有效,即使 Tensor
被泛型化,并且调用代码传递了 Tensor[Height, Width]
。
这在相反方向也有效
def takes_specific_array(arr: Array[Height, Width]): ...
z: Array
# equivalent to Array[*Tuple[Any, ...]]
takes_specific_array(z)
(详情请参阅 解包无界元组类型 部分。)
这样,即使库更新为使用 Array[Height, Width]
等类型,这些库的用户也不会被迫将类型注解应用于他们所有的代码;用户仍然可以选择对代码的哪些部分进行类型化,哪些部分不进行类型化。
别名
泛型别名可以使用类型变量元组以与常规类型变量类似的方式创建
IntTuple = Tuple[int, *Ts]
NamedArray = Tuple[str, Array[*Ts]]
IntTuple[float, bool] # Equivalent to Tuple[int, float, bool]
NamedArray[Height] # Equivalent to Tuple[str, Array[Height]]
如本例所示,传递给别名的所有类型参数都绑定到类型变量元组。
对于我们最初的 Array
示例(参见 摘要示例),重要的是,这允许我们为固定形状或数据类型的数组定义便捷别名。
Shape = TypeVarTuple('Shape')
DType = TypeVar('DType')
class Array(Generic[DType, *Shape]):
# E.g. Float32Array[Height, Width, Channels]
Float32Array = Array[np.float32, *Shape]
# E.g. Array1D[np.uint8]
Array1D = Array[DType, Any]
如果给出明确的空类型参数列表,则别名中的类型变量元组将设置为空。
IntTuple[()] # Equivalent to Tuple[int]
NamedArray[()] # Equivalent to Tuple[str, Array[()]]
如果类型参数列表完全省略,未指定的类型变量元组将被视为 Tuple[Any, ...]
(类似于 未指定类型参数时的行为)
def takes_float_array_of_any_shape(x: Float32Array): ...
x: Float32Array[Height, Width] = Array()
takes_float_array_of_any_shape(x) # Valid
def takes_float_array_with_specific_shape(
y: Float32Array[Height, Width]
): ...
y: Float32Array = Array()
takes_float_array_with_specific_shape(y) # Valid
常规 TypeVar
实例也可以用于此类别名
T = TypeVar('T')
Foo = Tuple[T, *Ts]
# T bound to str, Ts to Tuple[int]
Foo[str, int]
# T bound to float, Ts to Tuple[()]
Foo[float]
# T bound to Any, Ts to an Tuple[Any, ...]
Foo
别名中的替换
在上一节中,我们只讨论了泛型别名的简单用法,其中类型参数只是简单类型。然而,一些更奇特的构造也是可能的。
类型参数可以是可变参数
首先,泛型别名的类型参数可以是可变参数。例如,TypeVarTuple
可以用作类型参数。
Ts1 = TypeVar('Ts1')
Ts2 = TypeVar('Ts2')
IntTuple = Tuple[int, *Ts1]
IntFloatTuple = IntTuple[float, *Ts2] # Valid
这里,IntTuple
别名中的 *Ts1
绑定到 Tuple[float, *Ts2]
,从而生成一个等效于 Tuple[int, float, *Ts2]
的别名 IntFloatTuple
。
解包的任意长度元组也可以用作类型参数,具有类似的效果。
IntFloatsTuple = IntTuple[*Tuple[float, ...]] # Valid
这里,*Ts1
绑定到 *Tuple[float, ...]
,导致 IntFloatsTuple
等效于 Tuple[int, *Tuple[float, ...]]
:一个由一个 int
和零个或多个 float
组成的元组。
可变参数需要可变参数别名
可变类型参数只能用于本身是可变参数的泛型别名。例如
T = TypeVar('T')
IntTuple = Tuple[int, T]
IntTuple[str] # Valid
IntTuple[*Ts] # NOT valid
IntTuple[*Tuple[float, ...]] # NOT valid
这里,IntTuple
是一个非可变泛型别名,它恰好接受一个类型参数。因此,它不能接受 *Ts
或 *Tuple[float, ...]
作为类型参数,因为它们代表任意数量的类型。
同时包含 TypeVar 和 TypeVarTuple 的别名
在 别名 中,我们简要提到别名可以同时是 TypeVar
和 TypeVarTuple
的泛型。
T = TypeVar('T')
Foo = Tuple[T, *Ts]
Foo[str, int] # T bound to str, Ts to Tuple[int]
Foo[str, int, float] # T bound to str, Ts to Tuple[int, float]
根据 多个类型变量元组:不允许,在别名的类型参数中最多只能出现一个 TypeVarTuple
。然而,TypeVarTuple
可以与任意数量的 TypeVar
结合使用,无论是在其之前还是之后。
T1 = TypeVar('T1')
T2 = TypeVar('T2')
T3 = TypeVar('T3')
Tuple[*Ts, T1, T2] # Valid
Tuple[T1, T2, *Ts] # Valid
Tuple[T1, *Ts, T2, T3] # Valid
为了用提供的类型参数替换这些类型变量,类型参数列表开头或结尾的任何类型变量首先会消耗类型参数,然后任何剩余的类型参数都将绑定到 TypeVarTuple
。
Shrubbery = Tuple[*Ts, T1, T2]
Shrubbery[str, bool] # T2=bool, T1=str, Ts=Tuple[()]
Shrubbery[str, bool, float] # T2=float, T1=bool, Ts=Tuple[str]
Shrubbery[str, bool, float, int] # T2=int, T1=float, Ts=Tuple[str, bool]
Ptang = Tuple[T1, *Ts, T2, T3]
Ptang[str, bool, float] # T1=str, T3=float, T2=bool, Ts=Tuple[()]
Ptang[str, bool, float, int] # T1=str, T3=int, T2=float, Ts=Tuple[bool]
请注意,在这种情况下,类型参数的最小数量由 TypeVar
的数量决定。
Shrubbery[int] # Not valid; Shrubbery needs at least two type arguments
拆分任意长度元组
当解包的任意长度元组用作包含 TypeVar
和 TypeVarTuple
的别名的类型参数时,会出现最后一个复杂情况。
Elderberries = Tuple[*Ts, T1]
Hamster = Elderberries[*Tuple[int, ...]] # valid
在这种情况下,任意长度元组在 TypeVar
和 TypeVarTuple
之间进行拆分。我们假设任意长度元组包含的项数至少与 TypeVar
的数量相同,以便内部类型(此处为 int
)的单个实例绑定到存在的任何 TypeVar
。任意长度元组的“其余部分”(此处为 *Tuple[int, ...]
,因为任意长度元组减去两项后仍然是任意长度)绑定到 TypeVarTuple
。
因此,在这里,Hamster
等价于 Tuple[*Tuple[int, ...], int]
:一个由零个或多个 int
组成的元组,然后是一个最后的 int
。
当然,只有在必要时才会发生这种拆分。例如,如果我们改为
Elderberries[*Tuple[int, ...], str]
那么就不会发生拆分;T1
将绑定到 str
,而 Ts
将绑定到 *Tuple[int, ...]
。
在特别棘手的情况下,TypeVarTuple
可能会同时消耗一个类型和一个任意长度元组类型的一部分
Elderberries[str, *Tuple[int, ...]]
在这里,T1
绑定到 int
,Ts
绑定到 Tuple[str, *Tuple[int, ...]]
。因此,此表达式等效于 Tuple[str, *Tuple[int, ...], int]
:一个由 str
组成,然后是零个或多个 int
,最后以一个 int
结尾的元组。
TypeVarTuple 不能被拆分
最后,尽管类型参数列表中的任意长度元组可以拆分到类型变量和类型变量元组之间,但参数列表中的 TypeVarTuple
则不然
Ts1 = TypeVarTuple('Ts1')
Ts2 = TypeVarTuple('Ts2')
Camelot = Tuple[T, *Ts1]
Camelot[*Ts2] # NOT valid
这是不可能的,因为与解包的任意长度元组不同,没有办法“查看”TypeVarTuple
内部以查看其各个类型。
用于访问单个类型的重载
对于需要访问类型变量元组中每个单独类型的情况,可以使用重载,其中单个 TypeVar
实例代替类型变量元组
Shape = TypeVarTuple('Shape')
Axis1 = TypeVar('Axis1')
Axis2 = TypeVar('Axis2')
Axis3 = TypeVar('Axis3')
class Array(Generic[*Shape]):
@overload
def transpose(
self: Array[Axis1, Axis2]
) -> Array[Axis2, Axis1]: ...
@overload
def transpose(
self: Array[Axis1, Axis2, Axis3]
) -> Array[Axis3, Axis2, Axis1]: ...
(特别是对于数组形状操作,为每个可能的秩指定重载当然是一个相当繁琐的解决方案。然而,在没有额外的类型操作机制的情况下,这是我们能做到的最好方法。我们计划在未来的 PEP 中引入这些机制。)
原理和被拒绝的想法
形状算术
特别是考虑到数组形状的用例,请注意,截至本 PEP,尚无法描述数组维度的算术变换——例如,def repeat_each_element(x: Array[N]) -> Array[2*N]
。我们认为这超出了当前 PEP 的范围,但计划在未来的 PEP 中提出额外的机制,以实现这一点。
通过别名支持可变参数
正如引言中所述,确实可以通过简单地为每个可能的类型参数数量定义别名来避免可变参数泛型
class Array1(Generic[Axis1]): ...
class Array2(Generic[Axis1, Axis2]): ...
然而,这似乎有些笨拙——它要求用户在代码中不必要地为每个必要的维度散布1、2等数字。
TypeVarTuple
的构造
TypeVarTuple
最初是 ListVariadic
,基于它在 Pyre 早期实现中的命名。
我们后来将其更改为 TypeVar(list=True)
,基于 a) 它更好地强调了与 TypeVar
的相似性,b) “list”的含义比“variadic”的行话更容易理解。
一旦我们决定可变类型变量应该像 Tuple
一样,我们也考虑了 TypeVar(bound=Tuple)
,它同样直观,并且在不要求 TypeVar
任何新参数的情况下实现了我们大部分想要的功能。然而,我们意识到这可能会在未来限制我们,例如,如果我们希望可变类型变量的类型界限或方差与 TypeVar
的语义所暗示的行为略有不同。此外,我们以后可能希望支持不应被常规类型变量支持的参数(例如 arbitrary_len
[10])。
因此,我们确定了 TypeVarTuple
。
未指定的类型参数:元组与类型变量元组
为了支持渐进式类型化,本 PEP 指出以下两个示例都应正确进行类型检查
def takes_any_array(x: Array): ...
x: Array[Height, Width]
takes_any_array(x)
def takes_specific_array(y: Array[Height, Width]): ...
y: Array
takes_specific_array(y)
请注意,这与 Python 中目前唯一存在的变量类型 Tuple
的行为相反
def takes_any_tuple(x: Tuple): ...
x: Tuple[int, str]
takes_any_tuple(x) # Valid
def takes_specific_tuple(y: Tuple[int, str]): ...
y: Tuple
takes_specific_tuple(y) # Error
Tuple
的规则是经过深思熟虑选择的,使得后一种情况是一个错误:人们认为程序员犯错的可能性更大,而不是函数期望特定类型的 Tuple
但传递的特定类型的 Tuple
对类型检查器来说是未知的。此外,Tuple
有点特殊,因为它用于表示不可变序列。也就是说,如果对象的类型被推断为未参数化的 Tuple
,这不一定是因为类型不完整。
相比之下,如果一个对象的类型被推断为未参数化的 Array
,则用户很可能只是尚未完全注解他们的代码,或者形状操作库函数的签名尚无法使用类型系统表达,因此返回一个普通的 Array
是唯一的选择。我们很少处理真正任意形状的数组;在某些情况下,形状的某些部分将是任意的——例如,在处理序列时,形状的前两部分通常是“批次”和“时间”——但我们计划在未来的 PEP 中通过 Array[Batch, Time, ...]
等语法明确支持这些情况。
因此,我们决定让 Tuple
以外的可变参数泛型表现不同,以赋予用户更大的灵活性来注解其代码的多少部分,并实现旧的未注解代码与使用这些类型注解的库新版本之间的兼容性。
备选方案
应该指出,本 PEP 中概述的解决数值库中形状检查问题的方法并非唯一可能的方法。基于运行时检查的更轻量级替代方案包括 ShapeGuard [13]、tsanley [11] 和 PyContracts [12]。
虽然这些现有方法显著改进了通过冗长断言语句才能进行形状检查的默认情况,但它们都无法实现形状正确性的静态分析。如 动机 中所述,这对于机器学习应用程序尤为重要,因为由于库和基础设施的复杂性,即使相对简单的程序也必须经历漫长的启动时间;通过运行程序直到崩溃来迭代(现有基于运行时的方法所必需的)可能是一种繁琐而令人沮丧的体验。
我们希望通过本 PEP 开始将泛型类型注解编码为一种官方的、语言支持的处理形状正确性的方式。随着标准的到位,从长远来看,这有望催生一个蓬勃发展的工具生态系统,用于分析和验证数值计算程序的形状属性。
语法变更
本 PEP 需要两次语法更改。
变更1:索引中的星号表达式
第一个语法更改允许在索引操作中使用星号表达式(即在方括号内),这是支持 TypeVarTuple 星号解包所必需的。
DType = TypeVar('DType')
Shape = TypeVarTuple('Shape')
class Array(Generic[DType, *Shape]):
...
之前
slices:
| slice !','
| ','.slice+ [',']
之后
slices:
| slice !','
| ','.(slice | starred_expression)+ [',']
与在其他上下文中的星号解包一样,星号运算符在被调用者上调用 __iter__
,并将生成的迭代器内容添加到传递给 __getitem__
的参数中。例如,如果执行 foo[a, *b, c]
,并且 b.__iter__
生成一个产生 d
和 e
的迭代器,那么 foo.__getitem__
将收到 (a, d, e, c)
。
换句话说,请注意 x[..., *a, ...]
会产生与 x[(..., *a, ...)]
相同的结果(其中 ...
中的任何切片 i:j
都替换为 slice(i, j)
,只有一个特殊情况,即 x[*a]
变为 x[(*a,)]
)。
TypeVarTuple 实现
有了这个语法更改,TypeVarTuple
的实现如下。请注意,此实现仅为了 a) 正确的 repr()
和 b) 运行时分析器而有用;静态分析器不会使用此实现。
class TypeVarTuple:
def __init__(self, name):
self._name = name
self._unpacked = UnpackedTypeVarTuple(name)
def __iter__(self):
yield self._unpacked
def __repr__(self):
return self._name
class UnpackedTypeVarTuple:
def __init__(self, name):
self._name = name
def __repr__(self):
return '*' + self._name
影响
这种语法更改意味着许多本 PEP 未要求的额外行为更改。我们选择允许这些额外更改,而不是在语法层面禁止它们,以尽可能缩小语法更改的范围。
首先,语法更改允许在索引操作中解包其他结构,例如列表。
idxs = (1, 2)
array_slice = array[0, *idxs, -1] # Equivalent to [0, 1, 2, -1]
array[0, *idxs, -1] = array_slice # Also allowed
其次,一个索引中可以出现多个星号解包实例
array[*idxs_to_select, *idxs_to_select] # Equivalent to array[1, 2, 1, 2]
请注意,本 PEP 不允许在单个类型参数列表中出现多个解包的 TypeVarTuple。因此,此要求需要在类型检查工具本身中实现,而不是在语法层面。
第三,切片可以与带星号的表达式共存
array[3:5, *idxs_to_select] # Equivalent to array[3:5, 1, 2]
然而,请注意,涉及星号表达式的切片仍然无效
# Syntax error
array[*idxs_start:*idxs_end]
变更2:*args
作为 TypeVarTuple
第二个更改允许在函数定义中使用 *args: *Ts
。
之前
star_etc:
| '*' param_no_default param_maybe_default* [kwds]
| '*' ',' param_maybe_default+ [kwds]
| kwds
之后
star_etc:
| '*' param_no_default param_maybe_default* [kwds]
| '*' param_no_default_star_annotation param_maybe_default* [kwds] # New
| '*' ',' param_maybe_default+ [kwds]
| kwds
其中
param_no_default_star_annotation:
| param_star_annotation ',' TYPE_COMMENT?
| param_star_annotation TYPE_COMMENT? &')'
param_star_annotation: NAME star_annotation
star_annotation: ':' star_expression
我们还需要处理由此构造产生的 star_expression
。通常,star_expression
发生在一个列表等上下文中,因此 star_expression
的处理方式是本质上在带星号的对象上调用 iter()
,并将生成的迭代器的结果插入到列表中相应的位置。然而,对于 *args: *Ts
,我们必须以不同的方式处理 star_expression
。
我们通过为 *args: *Ts
产生的 star_expression
制作一个特殊情况,发出等同于 [annotation_value] = [*Ts]
的代码。也就是说,我们通过调用 Ts.__iter__
从 Ts
创建一个迭代器,从迭代器中获取一个值,验证迭代器已耗尽,并将该值设置为注解值。这将导致解包的 TypeVarTuple
直接设置为 *args
的运行时注解。
>>> Ts = TypeVarTuple('Ts')
>>> def foo(*args: *Ts): pass
>>> foo.__annotations__
{'args': *Ts}
# *Ts is the repr() of Ts._unpacked, an instance of UnpackedTypeVarTuple
这使得运行时注解与使用 Starred
节点作为 args
注解的 AST 表示保持一致——这对于依赖 AST 的工具(如 mypy)正确识别构造至关重要。
>>> print(ast.dump(ast.parse('def foo(*args: *Ts): pass'), indent=2))
Module(
body=[
FunctionDef(
name='foo',
args=arguments(
posonlyargs=[],
args=[],
vararg=arg(
arg='args',
annotation=Starred(
value=Name(id='Ts', ctx=Load()),
ctx=Load())),
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Pass()],
decorator_list=[])],
type_ignores=[])
请注意,此语法更改允许 *Ts
作为直接注解(而不是包装在例如 Tuple[*Ts]
中)的唯一场景是 *args
。其他用法仍然无效。
x: *Ts # Syntax error
def foo(x: *Ts): pass # Syntax error
影响
与第一次语法更改一样,此更改也带来了一些副作用。特别是,*args
的注解可以设置为 TypeVarTuple
之外的带星号的对象——例如,以下无意义的注解是可能的
>>> foo = [1]
>>> def bar(*args: *foo): pass
>>> bar.__annotations__
{'args': 1}
>>> foo = [1, 2]
>>> def bar(*args: *foo): pass
ValueError: too many values to unpack (expected 1)
同样,防止此类注解需要通过静态检查器等工具来完成,而不是在语法层面。
替代方案(为什么不直接使用 Unpack
?)
如果这些语法更改被认为过于繁重,则有两种替代方案。
第一种是 支持变更 1 但不支持变更 2。可变参数泛型对我们来说比注解 *args
的能力更重要。
第二个替代方案是 改用 ``Unpack``,不需要任何语法更改。然而,我们认为这是一个次优解决方案,原因有二:
- 可读性。
class Array(Generic[DType, Unpack[Shape]])
有点拗口;Unpack
的长度和多余的方括号打断了阅读的流畅性。class Array(Generic[DType, *Shape])
更容易扫读,同时仍然将Shape
标记为特殊。 - 直观性。我们认为用户更有可能直观地理解
*Ts
的含义——特别是当他们看到Ts
是一个 TypeVar**Tuple** 时——而不是Unpack[Ts]
的含义。(这假设用户熟悉其他上下文中的星号解包;如果用户正在阅读或编写使用可变泛型的代码,这似乎是合理的。)
因此,如果即使更改 1 也被认为是一个过于重大的更改,那么在我们进行第二个替代方案之前,最好重新考虑我们的选择。
向后兼容性
PEP 的 Unpack
版本应该可以向后移植到以前的 Python 版本。
渐进式类型化之所以得以实现,是因为未参数化的可变类与任意数量的类型参数兼容。这意味着,如果现有类被泛型化,a) 该类的所有现有(未参数化)用法仍将有效,并且 b) 该类的参数化和未参数化版本可以一起使用(例如,如果库代码更新为使用参数而用户代码未更新,反之亦然)。
参考实现
目前存在两个类型检查功能的参考实现:一个在 Pyre 中(v0.9.0 起),另一个在 Pyright 中(v1.1.108 起)。
CPython 中 PEP Unpack
版本的初步实现可在 cpython/23527 中找到。使用星号运算符的版本的初步版本,基于 PEP 637 的早期实现,也可在 mrahtz/cpython/pep637+646 中找到。
附录 A:形状类型用例
为了给那些对数组类型化用例特别感兴趣的人提供额外的背景信息,本附录将详细阐述本 PEP 可用于指定基于形状的子类型的不同方式。
用例1:指定形状值
参数化数组类型最简单的方法是使用 Literal
类型参数——例如 Array[Literal[64], Literal[64]]
。
我们可以使用普通类型变量为每个参数附加名称
K = TypeVar('K')
N = TypeVar('N')
def matrix_vector_multiply(x: Array[K, N], y: Array[N]) -> Array[K]: ...
a: Array[Literal[64], Literal[32]]
b: Array[Literal[32]]
matrix_vector_multiply(a, b)
# Result is Array[Literal[64]]
请注意,此类名称具有纯粹的局部范围。也就是说,名称 K
仅在 matrix_vector_multiply
内部绑定到 Literal[64]
。换句话说,不同签名中的 K
值之间没有关系。这很重要:如果每个名为 K
的轴都被限制在整个程序中具有相同的值,那将很不方便。
这种方法的缺点是,我们无法在不同的调用之间强制执行形状语义。例如,我们无法解决 动机 中提到的问题:如果一个函数返回一个前导维度为“时间 × 批次”的数组,而另一个函数接受同一个数组并假定前导维度为“批次 × 时间”,我们无法检测到这一点。
主要优点是,在某些情况下,轴尺寸确实是我们关心的。这对于简单的线性代数操作(如上述矩阵操作)和更复杂的变换(如神经网络中的卷积层)都适用,在这些层中,程序员能够使用静态分析检查每层后的数组大小将非常有益。为了帮助实现这一点,我们将来希望探索其他类型运算符的可能性,以实现数组形状的算术运算——例如
def repeat_each_element(x: Array[N]) -> Array[Mul[2, N]]: ...
这种算术类型运算符只有当 N
等名称指代轴尺寸时才有意义。
用例2:指定形状语义
第二种方法(本 PEP 中大多数示例所基于的方法)是放弃实际轴尺寸的注解,转而注解轴类型。
这将使我们能够解决跨调用强制执行形状属性的问题。例如
# lib.py
class Batch: pass
class Time: pass
def make_array() -> Array[Batch, Time]: ...
# user.py
from lib import Batch, Time
# `Batch` and `Time` have the same identity as in `lib`,
# so must take array as produced by `lib.make_array`
def use_array(x: Array[Batch, Time]): ...
请注意,在这种情况下,名称是全局的(只要我们在不同的地方使用相同的 Batch
类型)。然而,由于名称仅指轴类型,这并不限制某些轴的值在整个过程中保持相同(也就是说,这并不限制所有名为 Height
的轴都具有例如 480 的值)。
支持这种方法的论点是,在许多情况下,轴类型是更重要的验证项;我们更关心哪个轴是哪个轴,而不是每个轴的具体大小。
它也不排除我们希望在事先不知道类型的情况下描述形状转换的情况。例如,我们仍然可以编写
K = TypeVar('K')
N = TypeVar('N')
def matrix_vector_multiply(x: Array[K, N], y: Array[N]) -> Array[K]: ...
然后我们可以这样使用:
class Batch: pass
class Values: pass
batch_of_values: Array[Batch, Values]
value_weights: Array[Values]
matrix_vector_multiply(batch_of_values, value_weights)
# Result is Array[Batch]
缺点与用例1的优点相反。特别是,这种方法不适合轴类型上的算术运算:Mul[2, Batch]
将与 2 * int
一样毫无意义。
讨论
请注意,用例1和用例2在用户代码中是互斥的。用户可以验证大小或语义类型,但不能两者兼顾。
截至本 PEP,我们对哪种方法能带来最大益处持不可知态度。然而,由于本 PEP 中引入的功能与这两种方法都兼容,我们留下了开放的可能性。
为什么不兼得?
考虑以下“正常”代码
def f(x: int): ...
请注意,我们同时拥有表示事物值(x
)和事物类型(int
)的符号。为什么我们不能对轴做同样的事情?例如,使用假想语法,我们可以写
def f(array: Array[TimeValue: TimeType]): ...
这将允许我们通过符号 TimeValue
访问轴大小(例如 32),并通过符号 TypeType
访问类型。
这甚至可能通过现有语法实现,通过第二层参数化
def f(array: array[TimeValue[TimeType]]): ..
然而,我们将这种方法的探索留给未来。
附录 B:形状类型 vs 命名轴
与此 PEP 解决的问题相关的一个问题是轴选择。例如,如果我们有一个存储在形状为 64×64x3 的数组中的图像,我们可能希望通过计算第三个轴上的均值将其转换为黑白图像,mean(image, axis=2)
。不幸的是,简单的拼写错误 axis=1
很难发现,并且会产生完全不同的结果(同时很可能允许程序继续运行,导致一个严重但无声的错误)。
作为回应,一些库实现了所谓的“命名张量”(在此上下文中,“张量”与“数组”同义),其中轴不是通过索引而是通过标签选择——例如 mean(image, axis='channels')
。
关于本 PEP,我们经常被问到的一个问题是:为什么不直接使用命名张量?答案是,我们认为命名张量方法不足,主要有两个原因:
- 形状正确性的静态检查是不可能的。正如 动机 中所述,这在机器学习代码中是一个非常理想的功能,因为默认情况下迭代时间很慢。
- 这种方法仍然无法实现接口文档。如果一个函数只愿意接受具有图像状形状的数组参数,这无法通过命名张量来规定。
此外,还存在采用率低的问题。截至撰写本文时,命名张量仅在少数数值计算库中实现。可能的原因包括实现难度(整个 API 必须修改以允许按轴名称而不是索引进行选择),以及由于轴排序约定通常足够强,轴名称提供的益处不大(例如,处理图像时,3D 张量基本上总是高 × 宽 × 通道)。然而,最终我们仍然不确定这是为什么。
命名张量方法能否与本 PEP 中提倡的方法结合使用?我们不确定。一个重叠之处在于,在某些情况下,我们可以这样做,例如
Image: Array[Height, Width, Channels]
im: Image
mean(im, axis=Image.axes.index(Channels)
理想情况下,我们可能会写类似 im: Array[Height=64, Width=64, Channels=3]
的内容——但这在短期内是不可能的,因为 PEP 637 被拒绝了。无论如何,我们对此的态度主要是“等待观望,然后再采取进一步措施”。
脚注
认可
可变参数泛型有广泛的用途。对于涉及数值计算的范围,相关库实际使用本 PEP 中提出的功能的可能性有多大?
我们就这个问题咨询了一些人,并收到了以下认可。
来自 NumPy 核心开发成员 Stephan Hoyer:[14]
我只想感谢 Matthew 和 Pradeep 撰写了这份 PEP,并澄清了 PEP 646 在 https://github.com/python/peps/pull/1904 中数组类型化的更广泛上下文。作为一名深度参与 Python 数值计算社区(例如 NumPy、JAX、Xarray),但对 Python 类型系统细节不甚了解的人,我感到欣慰的是,与命名轴和形状的类型检查相关的广泛用例已被考虑,并且可以建立在本 PEP 的基础设施之上。
NumPy 社区对形状的类型检查非常感兴趣——NumPy GitHub 上的相关问题获得的赞比任何其他问题都多(https://github.com/numpy/numpy/issues/7370),我们最近添加了一个“typing”模块,目前正在积极开发中。
弄清楚如何最好地使用 ndarray 的类型检查肯定需要实验,但这份 PEP 看起来是这项工作的绝佳基础。
来自 Bas van Beek,他在 NumPy 中为形状泛型提供了初步支持
来自 TensorFlow 开发团队的高级软件工程师,TensorFlow RFC 作者 Dan Moldovan,TensorFlow 标准类型系统:[15]
我感兴趣的是使用本 PEP 中定义的机制来定义 TensorFlow 中的秩泛型张量类型,这对于以 Pythonic 方式使用类型注解来指定tf.function
签名非常重要(而不是我们今天使用的自定义input_signature
机制——参见此问题:https://github.com/tensorflow/tensorflow/issues/31579)。可变参数泛型是创建一套优雅的张量和形状类型定义的最后几个缺失部分之一。
(为透明起见,我们也联系了第三个流行的数值计算库 PyTorch 的相关人员,但没有收到他们的认可声明。我们的理解是,尽管他们对一些相同的问题感兴趣——例如静态形状推断——但他们目前正专注于通过 DSL 而非 Python 类型系统来实现这一点。)
致谢
感谢 Alfonso Castaño、Antoine Pitrou、Bas v.B.、David Foster、Dimitris Vardoulakis、Eric Traut、Guido van Rossum、Jia Chen、Lucio Fernandez-Arjona、Nikita Sobolev、Peilonrayz、Rebecca Chen、Sergei Lebedev 和 Vladimir Mikulik 对本 PEP 草案提出的有益反馈和建议。
特别感谢 Lucio 提出了星号语法(这使得本提案的多个方面更加简洁直观),以及 Stephan Hoyer 和 Dan Moldovan 的认可。
资源
Python 中关于可变参数泛型的讨论始于 2016 年,在 python/typing GitHub 仓库上的 Issue 193 中 [4]。
受此讨论的启发,Ivan Levkivskyi 在 PyCon 2019 提出了一个具体方案,总结在“类型系统改进” [5] 和“Python 数值栈的静态类型化” [6] 的笔记中。
在这些想法的基础上,Mark Mendoza 和 Vincent Siles 在 2019 年 Python 类型峰会上发表了题为“装饰器和张量的可变类型变量”的演讲 [8]。
关于泛型别名中类型替换行为的讨论在 cpython#91162 中进行。
参考资料
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0646.rst