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

Python 增强提案

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 是一份历史文档。最新的规范文档现在可以在 TypeVarTupletyping.TypeVarTuple 中找到。

×

请参阅 PEP 1,了解如何提出更改。

摘要

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],其中 T1T2 是类型变量。要将这些类型变量用作 Array 的类型参数,我们必须使用星号运算符解包类型变量元组:*Shape。然后,Array 的签名表现得好像我们只是写了 class Array(Generic[T1, T2]): ... 一样。

然而,与 Generic[T1, T2] 相比,Generic[*Shape] 允许我们使用任意数量的类型参数对类进行参数化。也就是说,除了能够定义诸如 Array[Height, Width] 之类的 2 秩数组外,我们还可以定义 3 秩数组、4 秩数组等等

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 的行为类似于 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 绑定到类型联合的 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.

我们也可以在期望 *Ts 的任何地方传递 *Tuple[int, ...]。当我们有特别动态的代码并且无法声明维度的精确数量或每个维度的精确类型时,这很有用。在这些情况下,我们可以平滑地回退到无界元组

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 绑定到 AnyShape 绑定到 Tuple[Any, ...]。在对 expect_precise_array 的调用中,变量 BatchHeightWidthChannels 都绑定到 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

这使得渐进式类型检查成为可能:即使 Tensor 变得泛型并且调用代码传递了 Tensor[Height, Width],接受普通 TensorFlow Tensor 的现有函数仍然有效。

这在反方向上也适用。

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, ...] 作为类型参数,因为它们代表任意数量的类型。

同时具有 TypeVars 和 TypeVarTuples 的别名

别名 中,我们简要提到别名可以在 TypeVarTypeVarTuple 中都是泛型的。

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

拆分任意长度的元组

当将解包的任意长度元组用作由 TypeVarTypeVarTuple 组成的别名的类型参数时,会出现最后一个复杂情况。

Elderberries = Tuple[*Ts, T1]
Hamster = Elderberries[*Tuple[int, ...]]  # valid

在这种情况下,任意长度元组会在 TypeVarTypeVarTuple 之间拆分。我们假设任意长度元组包含的项至少与 TypeVar 的数量一样多,以便内部类型的单个实例 - 这里为 int - 绑定到任何存在的 TypeVar。任意长度元组的“剩余部分” - 这里为 *Tuple[int, ...],因为任意长度元组减去两个项仍然是任意长度 - 绑定到 TypeVarTuple

因此,这里 Hamster 等效于 Tuple[*Tuple[int, ...], int]:一个由零个或多个 int 然后一个最后的 int 组成的元组。

当然,这种拆分只有在必要时才会发生。例如,如果我们改为

Elderberries[*Tuple[int, ...], str]

则不会发生拆分;T1 将绑定到 strTs 将绑定到 *Tuple[int, ...]

在特别棘手的情况下,TypeVarTuple 可能会同时消耗一个类型任意长度元组类型的一部分。

Elderberries[str, *Tuple[int, ...]]

这里,T1 绑定到 intTs 绑定到 Tuple[str, *Tuple[int, ...]]。因此,此表达式等效于 Tuple[str, *Tuple[int, ...], int]:一个由一个 str、然后零个或多个 int、最后以一个 int 结尾的元组。

TypeVarTuples 不能拆分

最后,尽管类型参数列表中的任何任意长度元组都可以在类型变量和类型变量元组之间拆分,但类型参数列表中的 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

未指定的类型参数:元组与 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]

虽然这些现有方法比仅通过冗长且冗余的 assert 语句进行形状检查的默认情况有了显著改进,但它们都不能进行形状正确性的**静态**分析。如动机中所述,这对于机器学习应用尤其重要,因为由于库和基础设施的复杂性,即使是相对简单的程序也必须承受较长的启动时间;像使用这些现有的基于运行时的方法那样,通过运行程序直到它崩溃来迭代,可能是一段乏味且令人沮丧的体验。

我们希望通过本 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__ 生成一个产生 de 的迭代器,则 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 版本开始。

PEP 中 Unpack 版本的一个初步实现已在 CPython 中提供,可在 cpython/23527 找到。基于 PEP 637 的早期实现,使用星号运算符的版本的初步实现也已在 mrahtz/cpython/pep637+646 提供。

附录 A:形状类型用例

为了让那些对数组类型用例特别感兴趣的人更好地理解本 PEP,在本附录中,我们将详细介绍本 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:形状类型与命名轴

与本 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 646https://github.com/python/peps/pull/1904 中关于数组类型的更广泛背景。

作为深度参与 Python 数值计算社区(例如,NumPy、JAX、Xarray)但不太熟悉 Python 类型系统细节的人,看到已经考虑了与命名轴和形状的类型检查相关的各种用例,并且可以构建在本 PEP 中的基础设施之上,这令人欣慰。

NumPy 社区对形状的类型检查非常感兴趣——在 NumPy 的 GitHub 上,相关问题获得的赞同数比其他任何问题都多 (https://github.com/numpy/numpy/issues/7370),并且我们最近添加了一个正在积极开发的“类型”模块。

当然,需要进行实验才能找出使用 ndarray 类型检查的最佳方法,但这份 PEP 看起来是此类工作的绝佳基础。

来自Bas van Beek,他曾在 NumPy 中开展形状泛型的初步支持工作

我非常赞同 Stephan 在此处的观点,并期待将新的 PEP 646 可变参数集成到 numpy 中。

在 numpy(以及张量类型的一般情况下):数组形状的类型是一个相当复杂的话题,可变参数的引入很可能在奠定其基础方面发挥重要作用,因为它允许表达维度以及基本的形状操作。

总而言之,我对 PEP 646 和未来的 PEP 将带我们走向何方以及未来的发展非常感兴趣。

来自 TensorFlow 开发团队的高级软件工程师Dan Moldovan,也是 TensorFlow RFC 的作者,TensorFlow Canonical Type System[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** 在 2019 年的 PyCon 上提出了一个具体的提案,总结在“类型系统改进” [5] 和“Python 数值栈的静态类型化” [6] 的笔记中。

在此基础上,**Mark Mendoza** 和**Vincent Siles** 在 2019 年的 Python 类型峰会上做了一个关于“装饰器和张量的可变参数类型变量”的演示 [8]

关于泛型别名中类型替换的行为方式的讨论在 cpython#91162 中进行。

参考文献


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

上次修改: 2024-06-27 00:41:45 GMT