[Final] PEP 646 - Variadic Generics

원문 링크: PEP 646 - Variadic Generics

상태: Final 유형: Standards Track 작성일: 16-Sep-2020

PEP 646 – 가변 제네릭 (Variadic Generics)

개요

PEP 484는 단일 타입으로 매개변수화되는 제네릭을 생성하기 위한 TypeVar를 도입했습니다. 이 PEP에서는 임의의 수의 타입으로 매개변수화를 가능하게 하는 TypeVarTuple을 소개하며, 이를 통해 가변 제네릭(variadic generics)을 지원합니다. 이는 다양한 사용 사례를 가능하게 하며, 특히 NumPy 및 TensorFlow와 같은 수치 연산 라이브러리에서 배열(array)과 같은 구조의 타입을 배열의 “형태(shape)”로 매개변수화하여, 정적 타입 검사기가 형태 관련 버그를 포착할 수 있도록 합니다.

이 PEP는 Python 3.11에 채택되었으며, 타입 표현식에서 여러 언패킹(unpacking)에 대한 세부 사항은 정확히 지정되지 않았습니다. 이는 개별 타입 검사기에 여지를 제공하지만, 향후 PEP에서 강화될 수 있습니다.

도입 배경

가변 제네릭은 오랫동안 다양한 사용 사례에서 요청되어 온 기능입니다. 특히, 잠재적으로 큰 영향을 미치며 이 PEP가 주로 목표로 하는 사용 사례 중 하나는 수치 라이브러리에서의 타입 정의와 관련이 있습니다.

NumPy 및 TensorFlow와 같은 수치 연산 라이브러리에서는 변수의 형태(shape)가 변수 타입만큼 중요합니다. 예를 들어, 동영상 배치(batch)를 그레이스케일로 변환하는 다음 함수를 고려해 봅시다.

def to_gray(videos: Array): ...

이 시그니처만으로는 videos 인수에 어떤 형태의 배열을 전달해야 하는지 명확하지 않습니다. 가능한 형태는 예를 들어 batch × time × height × width × channels 또는 time × batch × channels × height × width 등이 있습니다. 이는 세 가지 이유로 중요합니다.

  1. 문서화: 시그니처에 필요한 형태가 명확하지 않으면, 사용자는 입력/출력 형태 요구사항을 확인하기 위해 Docstring이나 코드 자체를 찾아봐야 합니다.
  2. 런타임 전 형태 버그 포착: 이상적으로는 잘못된 형태 사용이 정적 분석을 통해 미리 포착할 수 있는 오류여야 합니다. (이는 특히 반복 시간이 느릴 수 있는 머신러닝 코드에서 중요합니다.)
  3. 미묘한 형태 버그 방지: 최악의 경우, 잘못된 형태를 사용하면 프로그램이 정상적으로 실행되는 것처럼 보이지만, 며칠 동안 추적해야 할 미묘한 버그가 발생할 수 있습니다.

이상적으로는 타입 시그니처에 형태 요구 사항을 명시적으로 지정하는 방법이 있어야 합니다. 여러 제안에서 이를 위해 표준 제네릭 구문을 사용할 것을 제안했습니다.

def to_gray(videos: Array[Time, Batch, Height, Width, Channels]): ...

하지만 배열은 임의의 랭크(rank)를 가질 수 있습니다. 위에서 사용된 Array는 임의의 수의 축(axis)에 대해 제네릭합니다. 이 문제를 해결하는 한 가지 방법은 각 랭크마다 다른 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, Generic

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에서는 일관성을 위해 의미론적 축 어노테이션을 예시의 기반으로 사용하지만, Array를 사용하는 이 두 가지(또는 다른) 방법 중 어느 것이 더 나은지에 대해서는 중립적이며, 그 결정은 라이브러리 개발자에게 맡겨져 있습니다.

상세 스펙

위의 사용 사례를 지원하기 위해 TypeVarTuple이 도입됩니다. 이는 단일 타입이 아닌 타입 튜플(tuple of types)의 플레이스홀더 역할을 합니다.

또한, 별표(star operator)의 새로운 사용법이 도입되어 TypeVarTuple 인스턴스 및 Tuple[int, str]과 같은 튜플 타입을 ‘언팩(unpack)’할 수 있습니다. TypeVarTuple 또는 튜플 타입을 언팩하는 것은 변수 또는 값 튜플을 언팩하는 것과 동일한 타이핑 개념입니다.

Type Variable Tuples (타입 변수 튜플)

일반적인 타입 변수가 int와 같은 단일 타입의 대리자 역할을 하는 것처럼, 타입 변수 튜플은 Tuple[int, str]과 같은 튜플 타입의 대리자 역할을 합니다.

타입 변수 튜플은 다음과 같이 생성됩니다.

from typing import TypeVarTuple
Ts = TypeVarTuple('Ts')

제네릭 클래스에서 Type Variable Tuples 사용

타입 변수 튜플은 Tuple에 묶인 여러 개별 타입 변수처럼 작동합니다. 이를 이해하기 위해 다음 예시를 고려해 봅시다.

Shape = TypeVarTuple('Shape')
class Array(Generic[*Shape]): ...

Height = NewType('Height', int)
Width = NewType('Width', int)
x: Array[Height, Width] = Array()

여기서 Shape 타입 변수 튜플은 T1T2가 타입 변수인 Tuple[T1, T2]처럼 작동합니다. 이러한 타입 변수를 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()

함수에서 Type Variable Tuples 사용

타입 변수 튜플은 일반 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]

Type Variable Tuples는 항상 언팩되어야 합니다

이전 예시에서 __init__shape 인수가 Tuple[*Shape]로 어노테이션되었습니다. ShapeTuple[T1, T2, ...]처럼 작동한다면, shape 인수를 Shape로 직접 어노테이션할 수는 없었을까요?

실제로 이는 의도적으로 불가능합니다. 타입 변수 튜플은 항상 언팩된 형태로 사용되어야 합니다 (즉, 별표 연산자로 접두사 되어야 합니다). 이는 두 가지 이유 때문입니다.

  1. 타입 변수 튜플을 팩(packed) 또는 언팩(unpacked) 형태로 사용할지 여부에 대한 잠재적인 혼란을 피하기 위해.
  2. 가독성을 높이기 위해: 별표는 타입 변수 튜플이 일반 타입 변수가 아님을 명시적으로 시각적으로 나타내는 지표 역할도 합니다.

하위 호환성을 위한 Unpack

이 맥락에서 별표 연산자를 사용하는 것은 문법 변경이 필요하며, 따라서 새로운 Python 버전에서만 사용할 수 있습니다. 이전 Python 버전에서 타입 변수 튜플을 사용할 수 있도록 하기 위해, 별표 연산자 대신 사용할 수 있는 Unpack 타입 연산자가 도입됩니다.

# 새로운 Python 버전에서 별표 연산자를 사용한 언패킹
class Array(Generic[*Shape]): ...

# 이전 Python 버전에서 `Unpack`을 사용한 언패킹
from typing import Unpack
class Array(Generic[Unpack[Shape]]): ...

분산, 타입 제약 및 타입 바운드: 아직 지원되지 않음

이 PEP를 최소한으로 유지하기 위해 TypeVarTuple은 아직 다음 사양을 지원하지 않습니다.

  • 분산 (예: TypeVar('T', covariant=True))
  • 타입 제약 (TypeVar('T', int, float))
  • 타입 바운드 (TypeVar('T', bound=ParentClass))

가변 제네릭이 현장에서 테스트된 후, 이러한 인수가 어떻게 작동해야 하는지에 대한 결정은 미래의 PEP로 미룹니다. 이 PEP 현재, 타입 변수 튜플은 불변(invariant)입니다.

Type Variable Tuple 동등성

동일한 TypeVarTuple 인스턴스가 시그니처 또는 클래스의 여러 위치에서 사용되는 경우, 유효한 타입 추론은 TypeVarTuple을 타입들의 Union 튜플에 바인딩하는 것일 수 있습니다.

def foo(arg1: Tuple[*Ts], arg2: Tuple[*Ts]): ...
a = (0,)
b = ('0',)
foo(a, b) # Ts를 Tuple[int | str]에 바인딩할 수 있을까?

이것은 허용되지 않습니다. 타입 유니언(type unions)은 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)

다중 Type Variable Tuples: 허용되지 않음

이 PEP 현재, 타입 매개변수 목록에는 단 하나의 타입 변수 튜플만 나타날 수 있습니다.

class Array(Generic[*Ts1, *Ts2]): ... # 오류 (Error)

그 이유는 여러 타입 변수 튜플이 어떤 매개변수가 어떤 타입 변수 튜플에 바인딩되는지 모호하게 만들기 때문입니다.

x: Array[int, str, bool] # Ts1 = ???, Ts2 = ???

Type Concatenation (타입 연결)

타입 변수 튜플은 단독으로 사용될 필요가 없습니다. 일반 타입이 접두사 및/또는 접미사로 붙을 수 있습니다.

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)       # 추론된 타입은 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')) # z의 추론된 타입은 Tuple[int, bool, str]

Unpacking Tuple Types (튜플 타입 언팩)

TypeVarTuple이 타입 튜플을 나타낸다고 언급했습니다. TypeVarTuple을 언팩할 수 있으므로, 일관성을 위해 튜플 타입도 언팩하는 것을 허용합니다. 곧 보겠지만, 이는 여러 흥미로운 기능도 가능하게 합니다.

Concrete Tuple Types 언팩

구체적인 튜플 타입을 언팩하는 것은 런타임에 값 튜플을 언팩하는 것과 유사합니다. Tuple[int, *Tuple[bool, bool], str]Tuple[int, bool, bool, str]과 동일합니다.

Unbounded Tuple Types 언팩

무한(unbounded) 튜플을 언팩하면 무한 튜플이 그대로 유지됩니다. 즉, *Tuple[int, ...]*Tuple[int, ...]로 남으며, 더 간단한 형태는 없습니다. 이를 통해 Tuple[int, *Tuple[str, ...], str]와 같은 타입을 지정할 수 있습니다. 이는 첫 번째 요소가 int 타입임을 보장하고, 마지막 요소가 str 타입임을 보장하며, 중간 요소는 0개 이상의 str 타입 요소로 구성된 튜플 타입입니다. Tuple[*Tuple[int, ...]]Tuple[int, ...]와 동일합니다.

무한 튜플을 언팩하는 것은 정확한 요소를 신경 쓰지 않고 불필요한 TypeVarTuple을 정의하고 싶지 않은 함수 시그니처에서도 유용합니다.

from typing import Any

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: 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 호출에서 BatchAny에 바인딩되고 ShapeTuple[Any, ...]에 바인딩됨을 의미합니다. expect_precise_array 호출에서는 Batch, Height, Width, Channels 변수 모두 Any에 바인딩됩니다.

이를 통해 사용자는 동적인 코드를 우아하게 처리하면서도 코드에 안전하지 않음을 명시적으로 표시할 수 있습니다 (y: Array[*Tuple[Any, ...]] 사용). 그렇지 않으면, 레거시 코드베이스를 TypeVarTuple을 사용하도록 마이그레이션하려고 할 때마다 타입 검사기로부터 시끄러운 오류에 직면하게 되어 방해가 될 것입니다.

튜플 내 여러 언패킹: 허용되지 않음

TypeVarTuple과 마찬가지로 튜플 내에서는 하나의 언패킹만 나타날 수 있습니다.

x: Tuple[int, *Ts, str, *Ts2] # 오류 (Error)
y: Tuple[int, *Tuple[int, ...], str, *Tuple[str, ...]] # 오류 (Error)

*args를 Type Variable Tuple로 사용

PEP 484는 *args에 타입 어노테이션이 제공되면 모든 인수가 어노테이션된 타입이어야 한다고 명시합니다. 즉, *argsint 타입으로 지정하면 모든 인수가 int 타입이어야 합니다. 이는 이종(heterogeneous) 인자 타입을 받는 함수의 타입 시그니처를 지정하는 능력을 제한합니다.

그러나 *args가 타입 변수 튜플로 어노테이션되면 개별 인수의 타입은 타입 변수 튜플 내의 타입이 됩니다.

Ts = TypeVarTuple('Ts')
def args_to_tuple(*args: *Ts) -> Tuple[*Ts]: ...

args_to_tuple(1, 'a') # 추론된 타입은 Tuple[int, str]

위 예시에서 TsTuple[int, str]에 바인딩됩니다. 인수가 전달되지 않으면 타입 변수 튜플은 빈 튜플인 Tuple[()]처럼 작동합니다.

일반적으로, 우리는 어떤 튜플 타입이든 언팩할 수 있습니다. 예를 들어, 다른 타입들의 튜플 내부에 타입 변수 튜플을 사용하여 가변 인자 목록의 접두사 또는 접미사를 참조할 수 있습니다.

# os.execle은 'path, arg0, arg1, ..., env' 인수를 받습니다.
def execle(path: str, *args: *Tuple[*Ts, Env]) -> None: ...

이는 다음 코드와 다릅니다.

def execle(path: str, *args: *Ts, env: Env) -> None: ...

위 코드는 env를 키워드 전용(keyword-only) 인수로 만들기 때문입니다.

언팩된 무한 튜플을 사용하는 것은 *args: int의 PEP 484 동작과 동일하며, 이는 0개 이상의 int 타입 값을 받습니다.

def foo(*args: *Tuple[int, ...]) -> None: ...
# 다음과 동일합니다:
def foo(*args: int) -> None: ...

튜플 타입을 언팩하면 이종 *args에 대해 더 정확한 타입을 지정할 수 있습니다. 다음 함수는 시작에 int, 0개 이상의 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,))     # 유효함
foo((0,), (1, 2))   # 오류
foo((0,), ('1',))   # 오류

마지막으로, 타입 변수 튜플은 **kwargs의 타입으로 사용될 수 없습니다. (이 기능에 대한 사용 사례를 아직 알지 못하므로, 잠재적인 미래 PEP를 위해 여지를 남겨두는 것을 선호합니다.)

# 유효하지 않음 (NOT valid)
def foo(**kwargs: *Ts): ...

Callable을 사용하는 Type Variable Tuples

타입 변수 튜플은 Callable의 인수 섹션에서도 사용될 수 있습니다.

from typing import Callable, Tuple

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')) # 유효함
Process(target=func, args=('foo', 0)) # 오류

다른 타입과 일반 타입 변수도 타입 변수 튜플에 접두사/접미사로 붙을 수 있습니다.

T = TypeVar('T')
def foo(f: Callable[[int, *Ts, T], Tuple[T, *Ts]]): ...

언팩된 항목(TypeVarTuple 또는 튜플 타입)을 포함하는 Callable의 동작은 요소들을 *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): ...
# 다음과 동일합니다:
def takes_any_array(arr: Array[*Tuple[Any, ...]]): ...

x: Array[Height, Width]
takes_any_array(x) # 유효함

y: Array[Time, Height, Width]
takes_any_array(y) # 또한 유효함

이는 점진적 타이핑(gradual typing)을 가능하게 합니다. 예를 들어, 일반 TensorFlow Tensor를 받는 기존 함수는 Tensor가 제네릭화되고 호출 코드가 Tensor[Height, Width]를 전달하더라도 여전히 유효합니다.

이는 반대 방향으로도 작동합니다.

def takes_specific_array(arr: Array[Height, Width]): ...
z: Array # Array[*Tuple[Any, ...]]와 동일함
takes_specific_array(z)

(자세한 내용은 “Unpacking Unbounded Tuple Types” 섹션을 참조하십시오.)

이러한 방식으로 라이브러리가 Array[Height, Width]와 같은 타입을 사용하도록 업데이트되더라도, 해당 라이브러리 사용자는 코드 전체에 타입 어노테이션을 적용할 필요가 없습니다. 사용자는 코드의 어떤 부분을 타입 지정하고 어떤 부분을 지정하지 않을지 여전히 선택할 수 있습니다.

Type Aliases (타입 별칭)

제네릭 별칭은 일반 타입 변수와 유사하게 타입 변수 튜플을 사용하여 생성할 수 있습니다.

IntTuple = Tuple[int, *Ts]
NamedArray = Tuple[str, Array[*Ts]]

IntTuple[float, bool] # Tuple[int, float, bool]과 동일
NamedArray[Height]    # Tuple[str, Array[Height]]와 동일

이 예시가 보여주듯이, 별칭에 전달된 모든 타입 매개변수는 타입 변수 튜플에 바인딩됩니다.

원래 Array 예시(요약 예시 참조)에 중요하게도, 이는 고정된 형태 또는 데이터 타입의 배열에 대한 편의 별칭을 정의할 수 있도록 합니다.

Shape = TypeVarTuple('Shape')
DType = TypeVar('DType')

class Array(Generic[DType, *Shape]): ...

# 예: Float32Array[Height, Width, Channels]
Float32Array = Array[np.float32, *Shape]

# 예: Array1D[np.uint8]
Array1D = Array[DType, Any]

명시적으로 빈 타입 매개변수 목록이 주어지면, 별칭의 타입 변수 튜플은 비어 있게 설정됩니다.

IntTuple[()] # Tuple[int]와 동일
NamedArray[()] # 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) # 유효함

def takes_float_array_with_specific_shape(
    y: Float32Array[Height, Width]
): ...
y: Float32Array = Array()
takes_float_array_with_specific_shape(y) # 유효함

일반 TypeVar 인스턴스도 이러한 별칭에서 사용될 수 있습니다.

T = TypeVar('T')
Foo = Tuple[T, *Ts]

Foo[str, int] # T는 str에, Ts는 Tuple[int]에 바인딩됨
Foo[float]    # T는 float에, Ts는 Tuple[()]에 바인딩됨
Foo           # T는 Any에, Ts는 Tuple[Any, ...]에 바인딩됨

별칭의 대체 (Substitution)

이전 섹션에서는 타입 인수가 단순히 단순한 타입인 제네릭 별칭의 간단한 사용법만 다루었습니다. 그러나 더 이국적인 구성도 가능합니다.

타입 인수는 가변적일 수 있습니다.

첫째, 제네릭 별칭에 대한 타입 인수는 가변적일 수 있습니다. 예를 들어, TypeVarTuple은 타입 인수로 사용될 수 있습니다.

Ts1 = TypeVarTuple('Ts1')
Ts2 = TypeVarTuple('Ts2')

IntTuple = Tuple[int, *Ts1]
IntFloatTuple = IntTuple[float, *Ts2] # 유효함

여기서 IntTuple 별칭의 *Ts1Tuple[float, *Ts2]에 바인딩되어, Tuple[int, float, *Ts2]와 동일한 IntFloatTuple 별칭이 됩니다.

언팩된 임의 길이 튜플도 타입 인수로 사용될 수 있으며, 비슷한 효과를 냅니다.

IntFloatsTuple = IntTuple[*Tuple[float, ...]] # 유효함

여기서 *Ts1*Tuple[float, ...]에 바인딩되어, IntFloatsTupleTuple[int, *Tuple[float, ...]]와 동일해집니다. 이는 int와 0개 이상의 float로 구성된 튜플입니다.

가변 인수는 가변 별칭이 필요합니다.

가변 타입 인수는 그 자체로 가변적인 제네릭 별칭에서만 사용될 수 있습니다.

T = TypeVar('T')
IntTuple = Tuple[int, T]

IntTuple[str] # 유효함
IntTuple[*Ts] # 유효하지 않음 (NOT valid)
IntTuple[*Tuple[float, ...]] # 유효하지 않음 (NOT valid)

여기서 IntTuple은 정확히 하나의 타입 인수를 받는 비가변 제네릭 별칭입니다. 따라서 *Ts 또는 *Tuple[float, ...]를 타입 인수로 받을 수 없습니다. 이는 임의의 수의 타입을 나타내기 때문입니다.

TypeVar와 TypeVarTuple을 모두 포함하는 별칭.

“Aliases” 섹션에서 별칭이 TypeVarTypeVarTuple 모두에 대해 제네릭일 수 있음을 간략하게 언급했습니다.

T = TypeVar('T')
Foo = Tuple[T, *Ts]

Foo[str, int]         # T는 str에, Ts는 Tuple[int]에 바인딩됨
Foo[str, int, float]  # T는 str에, Ts는 Tuple[int, float]에 바인딩됨

“Multiple Type Variable Tuples: Not Allowed”에 따라, 별칭의 타입 매개변수에는 최대 하나의 TypeVarTuple만 나타날 수 있습니다. 그러나 TypeVarTuple은 앞뒤로 임의의 수의 TypeVar와 결합될 수 있습니다.

T1 = TypeVar('T1')
T2 = TypeVar('T2')
T3 = TypeVar('T3')

Tuple[*Ts, T1, T2]      # 유효함
Tuple[T1, T2, *Ts]      # 유효함
Tuple[T1, *Ts, T2, T3]  # 유효함

제공된 타입 인수로 이러한 타입 변수를 대체하려면, 타입 매개변수 목록의 시작 또는 끝에 있는 타입 변수가 먼저 타입 인수를 소비하고, 나머지 타입 인수는 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] # 유효하지 않음; Shrubbery는 최소 두 개의 타입 인수가 필요합니다.

임의 길이 튜플 분할.

TypeVarTypeVarTuple을 모두 포함하는 별칭에 타입 인수로 언팩된 임의 길이 튜플이 사용될 때 최종적인 복잡성이 발생합니다.

Elderberries = Tuple[*Ts, T1]
Hamster = Elderberries[*Tuple[int, ...]] # 유효함

이러한 경우, 임의 길이 튜플은 TypeVarTypeVarTuple 사이에서 분할됩니다. 우리는 임의 길이 튜플이 TypeVar의 수만큼 항목을 포함한다고 가정하며, 내부 타입의 개별 인스턴스(여기서는 int)는 존재하는 TypeVar에 바인딩됩니다. 임의 길이 튜플의 ‘나머지’ (여기서는 *Tuple[int, ...], 왜냐하면 임의 길이 튜플에서 두 항목을 빼도 여전히 임의 길이기 때문입니다)는 TypeVarTuple에 바인딩됩니다.

따라서 여기서 HamsterTuple[*Tuple[int, ...], int]와 동일합니다. 즉, 0개 이상의 int와 마지막 int로 구성된 튜플입니다.

물론, 이러한 분할은 필요한 경우에만 발생합니다. 예를 들어, 대신 다음과 같이 했다면.

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

분할은 발생하지 않을 것입니다. T1str에 바인딩되고, Ts*Tuple[int, ...]에 바인딩됩니다.

특히 까다로운 경우, TypeVarTuple은 타입과 임의 길이 튜플 타입의 일부를 모두 소비할 수 있습니다.

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

여기서 T1int에 바인딩되고, TsTuple[str, *Tuple[int, ...]]에 바인딩됩니다. 따라서 이 표현식은 Tuple[str, *Tuple[int, ...], int]와 동일합니다. 즉, str 다음에 0개 이상의 int가 오고, 마지막에 int로 끝나는 튜플입니다.

TypeVarTuples는 분할될 수 없습니다.

마지막으로, 타입 인수 목록의 임의 길이 튜플은 타입 변수와 타입 변수 튜플 사이에서 분할될 수 있지만, 인수 목록의 TypeVarTuple에는 동일하게 적용되지 않습니다.

Ts1 = TypeVarTuple('Ts1')
Ts2 = TypeVarTuple('Ts2')

Camelot = Tuple[T, *Ts1]
Camelot[*Ts2] # 유효하지 않음 (NOT valid)

이는 언팩된 임의 길이 튜플의 경우와 달리 TypeVarTuple 내부를 ‘들여다보아’ 개별 타입이 무엇인지 알 방법이 없기 때문에 불가능합니다.

개별 타입 접근을 위한 오버로드

타입 변수 튜플의 각 개별 타입에 접근해야 하는 상황에서는 타입 변수 튜플 대신 개별 TypeVar 인스턴스와 함께 오버로드(overloads)를 사용할 수 있습니다.

from typing import overload

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에서 도입될 예정입니다.)

근거 및 거부된 아이디어

형태 연산 (Shape Arithmetic)

특히 배열 형태의 사용 사례를 고려할 때, 이 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은 Pyre의 초기 구현에서 이름을 따와 ListVariadic으로 시작했습니다.

그 후 TypeVar(list=True)로 변경되었는데, 그 이유는 a) TypeVar와의 유사성을 더 잘 강조하고, b) ‘list’의 의미가 ‘variadic’이라는 전문 용어보다 더 쉽게 이해되기 때문이었습니다.

가변 타입 변수가 Tuple처럼 작동해야 한다고 결정한 후, TypeVar(bound=Tuple)도 고려했습니다. 이는 비슷하게 직관적이며 TypeVar에 새로운 인수를 요구하지 않고 우리가 원했던 대부분을 달성했습니다. 그러나 예를 들어, 타입 바운드(type bounds)나 분산(variance)이 TypeVar의 의미가 암시하는 것과 약간 다르게 작동하도록 하려면 미래에 제약이 될 수 있음을 깨달았습니다. 또한, 나중에 일반 타입 변수에서는 지원되어서는 안 되는 인수(예: arbitrary_len)를 지원하고 싶을 수도 있습니다.

따라서 우리는 TypeVarTuple로 결정했습니다.

지정되지 않은 타입 매개변수: Tuple vs 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) # 유효함

def takes_specific_tuple(y: Tuple[int, str]): ...
y: Tuple
takes_specific_tuple(y) # 오류

Tuple에 대한 규칙은 후자 경우가 오류가 되도록 의도적으로 선택되었습니다. 프로그래머가 실수를 저질렀을 가능성이 더 높고, 함수가 특정 종류의 Tuple을 예상하지만 전달된 특정 종류의 Tuple이 타입 검사기에게 알려지지 않았을 가능성은 낮다고 생각되었습니다. 또한, Tuple은 변경 불가능한 시퀀스(immutable sequences)를 나타내는 데 사용된다는 점에서 다소 특별한 경우입니다. 즉, 객체의 타입이 매개변수화되지 않은 Tuple로 추론되었다고 해서 반드시 불완전한 타이핑 때문인 것은 아닙니다.

이와 대조적으로, 객체의 타입이 매개변수화되지 않은 Array로 추론되었다면, 사용자가 단순히 코드를 완전히 어노테이션하지 않았거나, 형태를 조작하는 라이브러리 함수의 시그니처가 아직 타이핑 시스템을 사용하여 표현될 수 없으므로 일반 Array를 반환하는 것이 유일한 옵션일 가능성이 훨씬 더 높습니다. 우리는 진정으로 임의의 형태를 가진 배열을 거의 다루지 않습니다. 특정 경우, 형태의 일부는 임의적일 수 있습니다 (예: 시퀀스를 다룰 때 형태의 처음 두 부분은 종종 ‘batch’와 ‘time’입니다). 그러나 우리는 미래의 PEP에서 Array[Batch, Time, ...]와 같은 구문으로 이러한 경우를 명시적으로 지원할 계획입니다.

따라서 우리는 사용자에게 코드의 어느 정도를 어노테이션할지 더 많은 유연성을 제공하고, 오래된 어노테이션 없는 코드와 이러한 타입 어노테이션을 사용하는 라이브러리의 새 버전 간의 호환성을 가능하게 하기 위해 Tuple 이외의 가변 제네릭이 다르게 작동하도록 결정했습니다.

대안

수치 라이브러리에서 형태 검사 문제를 해결하기 위해 이 PEP에서 제시된 접근 방식이 유일한 접근 방식은 아닙니다. 런타임 검사에 기반한 더 경량화된 대안의 예시로는 ShapeGuard, tsanley, PyContracts 등이 있습니다.

이러한 기존 접근 방식은 형태 검사가 길고 장황한 assert 문을 통해서만 가능했던 기본 상황을 크게 개선하지만, 형태 정확성에 대한 정적 분석을 가능하게 하는 것은 없습니다. 동기(Motivation)에서 언급했듯이, 이는 라이브러리 및 인프라 복잡성으로 인해 상대적으로 간단한 프로그램도 긴 시작 시간을 겪어야 하는 머신러닝 애플리케이션에서 특히 바람직한 기능입니다. 기존 런타임 기반 접근 방식에서와 같이 프로그램이 충돌할 때까지 실행하여 반복하는 것은 지루하고 실망스러운 경험이 될 수 있습니다.

이 PEP를 통해 우리는 제네릭 타입 어노테이션을 형태 정확성을 다루는 공식적이고 언어가 지원하는 방식으로 성문화하기 시작하기를 희망합니다. 표준이 마련되면 장기적으로는 수치 연산 프로그램의 형태 속성을 분석하고 검증하는 도구의 번성하는 생태계를 가능하게 할 것입니다.

문법 변경

이 PEP는 두 가지 문법 변경을 요구합니다.

변경 1: 인덱스의 별표 표현식

첫 번째 문법 변경은 인덱스 연산(즉, 대괄호 안)에서 별표 표현식 사용을 가능하게 하며, 이는 TypeVarTuple의 별표 언팩을 지원하는 데 필요합니다.

DType = TypeVar('DType')
Shape = TypeVarTuple('Shape')

class Array(Generic[DType, *Shape]): ...

이전 (Before): slices: | slice !',' | ','.slice+ [',']

이후 (After): slices: | slice !',' | ','.(slice | starred_expression)+ [',']

다른 맥락에서의 별표 언팩과 마찬가지로, 별표 연산자는 피호출자(callee)에서 __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

함의 (Implications).

이 문법 변경은 이 PEP에서 요구하지 않는 여러 추가적인 동작 변경을 의미합니다. 우리는 문법 변경을 가능한 한 작게 유지하기 위해 구문 수준에서 이러한 추가 변경을 금지하는 대신 허용하기로 선택합니다.

첫째, 문법 변경은 인덱싱 연산 내에서 목록과 같은 다른 구조의 별표 언팩을 가능하게 합니다.

idxs = (1, 2)
array_slice = array[0, *idxs, -1] # [0, 1, 2, -1]과 동일
array[0, *idxs, -1] = array_slice # 또한 허용됨

둘째, 인덱스 내에 두 개 이상의 별표 언팩 인스턴스가 나타날 수 있습니다.

array[*idxs_to_select, *idxs_to_select] # array[1, 2, 1, 2]와 동일

이 PEP는 단일 타입 매개변수 목록 내에서 여러 언팩된 TypeVarTuple을 허용하지 않습니다. 따라서 이 요구 사항은 구문 수준이 아닌 타입 검사 도구 자체에서 구현되어야 합니다.

셋째, 슬라이스와 별표 표현식이 함께 나타날 수 있습니다.

array[3:5, *idxs_to_select] # array[3:5, 1, 2]와 동일

그러나 별표 표현식을 포함하는 슬라이스는 여전히 유효하지 않습니다.

# 구문 오류
array[*idxs_start:*idxs_end]

변경 2: *argsTypeVarTuple로 사용

두 번째 변경은 함수 정의에서 *args: *Ts 사용을 가능하게 합니다.

이전 (Before): star_etc: | '*' param_no_default param_maybe_default* [kwds] | '*' ',' param_maybe_default+ [kwds] | kwds

이후 (After): 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는 Ts._unpacked의 repr()이며, UnpackedTypeVarTuple의 인스턴스입니다.

이를 통해 런타임 어노테이션이 args의 어노테이션에 Starred 노드를 사용하는 AST 표현과 일치할 수 있습니다. 이는 mypy와 같이 AST에 의존하는 도구가 구조를 올바르게 인식하는 데 중요합니다.

>>> import ast
>>> 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 # 구문 오류
def foo(x: *Ts): pass # 구문 오류

함의 (Implications).

첫 번째 문법 변경과 마찬가지로, 이 변경도 여러 부작용을 가집니다. 특히, *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을 대신 사용하는 것입니다. 그러나 우리는 이를 두 가지 이유로 차선책으로 간주합니다.

  1. 가독성: class Array(Generic[DType, Unpack[Shape]])는 다소 장황하며, Unpack의 길이와 추가적인 대괄호 세트가 읽는 흐름을 방해합니다. class Array(Generic[DType, *Shape])Shape를 특별하게 표시하면서도 훨씬 쉽게 훑어볼 수 있습니다.
  2. 직관성: 사용자는 Unpack[Ts]의 의미보다 *Ts의 의미를 더 직관적으로 이해할 가능성이 높다고 생각합니다 (특히 TsTypeVarTuple임을 알 때). (이는 사용자가 다른 맥락에서 별표 언팩에 익숙하다고 가정하며, 사용자가 가변 제네릭을 사용하는 코드를 읽거나 작성하는 경우 합리적으로 보입니다.)

따라서 변경 1조차 너무 중요한 변경이라고 생각된다면, 이 두 번째 대안을 진행하기 전에 옵션을 재고하는 것이 더 나을 수 있습니다.

하위 호환성 (Backwards Compatibility)

이 PEP의 Unpack 버전은 이전 Python 버전으로 백포팅(back-portable)되어야 합니다.

매개변수화되지 않은 가변 클래스가 임의의 수의 타입 매개변수와 호환된다는 사실로 인해 점진적 타이핑이 가능합니다. 이는 기존 클래스가 제네릭화될 경우 a) 클래스의 모든 기존 (매개변수화되지 않은) 사용은 여전히 작동하며, b) 클래스의 매개변수화된 버전과 매개변수화되지 않은 버전이 함께 사용될 수 있음을 의미합니다 (예를 들어, 라이브러리 코드가 매개변수를 사용하도록 업데이트되었지만 사용자 코드는 업데이트되지 않았거나 그 반대의 경우에 해당합니다).

참조 구현 (Reference Implementation)

타입 검사 기능에 대한 두 가지 참조 구현이 존재합니다. 하나는 Pyre v0.9.0에, 다른 하나는 Pyright v1.1.108에 있습니다.

CPython에서 이 PEP의 Unpack 버전에 대한 예비 구현은 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) # 결과는 Array[Literal[64]]

이러한 이름은 순전히 지역적인 범위(local scope)를 가진다는 점에 유의하십시오. 즉, K라는 이름은 matrix_vector_multiply 내에서만 Literal[64]에 바인딩됩니다. 달리 말하면, 다른 시그니처의 K 값 사이에는 관계가 없습니다. 이는 중요합니다. 프로그램 전체에서 K라는 이름의 모든 축이 동일한 값을 가지도록 제약된다면 불편할 것입니다.

이 접근 방식의 단점은 서로 다른 호출 간에 형태 의미론(shape semantics)을 강제할 수 없다는 것입니다. 예를 들어, 동기(Motivation)에서 언급된 문제를 해결할 수 없습니다. 한 함수가 선행 차원 ‘Time × Batch’를 가진 배열을 반환하고, 다른 함수가 동일한 배열을 선행 차원 ‘Batch × Time’으로 가정하여 받는 경우, 이를 감지할 방법이 없습니다.

주요 장점은 어떤 경우에는 축 크기가 정말로 우리가 신경 쓰는 것이라는 점입니다. 이는 위와 같은 행렬 조작과 같은 간단한 선형 대수 연산뿐만 아니라 신경망의 컨볼루션 레이어와 같은 더 복잡한 변환에서도 해당됩니다. 이러한 경우 정적 분석을 사용하여 각 레이어 후의 배열 크기를 검사할 수 있다면 프로그래머에게 매우 유용할 것입니다. 이를 돕기 위해, 미래에는 배열 형태에 대한 산술 연산을 가능하게 하는 추가 타입 연산자(예: 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`와 `Time`은 `lib`와 동일한 ID를 가지므로,
# `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) # 결과는 Array[Batch]

단점은 사용 사례 1의 장점의 역순입니다. 특히 이 접근 방식은 축 타입에 대한 산술 연산에 잘 맞지 않습니다. Mul[2, Batch]2 * int만큼 무의미할 것입니다.

논의 (Discussion)

사용 사례 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 명명된 축 (Shaped Types vs Named Axes)

이 PEP에서 다루는 문제와 관련된 문제는 축 선택과 관련이 있습니다. 예를 들어, 64x64x3 형태의 배열에 저장된 이미지가 있다면, 세 번째 축에서 평균을 계산하여 흑백으로 변환하고 싶을 수 있습니다 (mean(image, axis=2)). 불행히도, axis=1과 같은 간단한 오타는 발견하기 어렵고 완전히 다른 의미의 결과를 생성할 것입니다 (그러면서도 프로그램은 계속 실행될 가능성이 높으며, 심각하지만 침묵하는 버그로 이어집니다).

이에 대응하여 일부 라이브러리는 축이 인덱스가 아닌 레이블로 선택되는 이른바 ‘명명된 텐서(named tensors)’를 구현했습니다 (이 맥락에서 ‘텐서’는 ‘배열’과 동의어입니다). 예를 들어 mean(image, axis='channels').

이 PEP에 대해 자주 묻는 질문 중 하나는 “왜 명명된 텐서를 사용하지 않는가?”입니다. 답변은 명명된 텐서 접근 방식이 두 가지 주요 이유로 불충분하다고 간주하기 때문입니다.

  1. 형태 정확성에 대한 정적 검사가 불가능합니다. 동기(Motivation)에서 언급했듯이, 이는 반복 시간이 기본적으로 느린 머신러닝 코드에서 매우 바람직한 기능입니다.
  2. 이 접근 방식으로는 인터페이스 문서화가 여전히 불가능합니다. 함수가 이미지와 같은 형태를 가진 배열 인수만 받아야 한다고 규정할 수 없습니다.

또한, 낮은 채택률 문제도 있습니다. 현재 명명된 텐서는 소수의 수치 연산 라이브러리에서만 구현되었습니다. 이에 대한 가능한 설명으로는 구현의 어려움(축 이름 대신 인덱스로 선택을 허용하도록 전체 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이 거부되었기 때문에 단기적으로는 불가능할 것입니다. 어쨌든, 이에 대한 우리의 태도는 주로 “어떤 일이 일어나는지 지켜본 후에 추가 조치를 취하는 것”입니다.

추천사 (Endorsements)

가변 제네릭은 광범위한 용도를 가지고 있습니다. 그 범위 중 수치 연산과 관련된 부분에 대해, 관련 라이브러리가 이 PEP에서 제안된 기능을 실제로 사용할 가능성은 얼마나 될까요?

우리는 이 질문으로 여러 사람들에게 연락했으며, 다음과 같은 추천사를 받았습니다.

Stephan Hoyer (NumPy 운영위원회 위원)로부터:

저는 Matthew와 Pradeep에게 이 PEP를 작성하고 NumPy, JAX, Xarray와 같은 Python 수치 연산 커뮤니티에 깊이 관여하고 있지만 Python의 타입 시스템 세부 사항에는 익숙하지 않은 사람으로서, 명명된 축 및 형태의 타입 검사와 관련된 광범위한 사용 사례가 고려되었고 이 PEP의 인프라를 기반으로 구축될 수 있다는 것을 보는 것은 안심이 됩니다. 형태에 대한 타입 검사는 NumPy 커뮤니티가 매우 관심을 가지는 문제입니다. NumPy의 GitHub에서 관련 이슈 [cite: https://github.com/numpy/numpy/issues/7370]에는 다른 어떤 이슈보다도 많은 ‘좋아요’가 있으며, 우리는 최근 활발히 개발 중인 “typing” 모듈을 추가했습니다. ndarray에 대한 타입 검사를 사용하는 가장 좋은 방법을 알아내기 위해서는 분명히 실험이 필요하겠지만, 이 PEP는 그러한 작업의 훌륭한 기반처럼 보입니다.

Bas van Beek (NumPy에서 형태-제네릭의 예비 지원에 참여)으로부터:

저는 여기서 Stephan의 의견에 전적으로 동의하며, 새로운 PEP 646 가변 제네릭을 NumPy에 통합하기를 기대합니다. NumPy (및 텐서 타이핑 일반)의 맥락에서, 배열 형태의 타이핑은 상당히 복잡한 주제이며, 가변 제네릭의 도입은 차원성과 기본 형태 조작을 모두 표현할 수 있게 함으로써 그 기초를 다지는 데 큰 역할을 할 것입니다. 전반적으로, 저는 PEP 646과 미래의 PEP가 우리를 어디로 이끌지 매우 관심이 많으며, 더 많은 발전을 기대합니다.

Dan Moldovan (TensorFlow 개발팀 선임 소프트웨어 엔지니어 및 TensorFlow RFC, TensorFlow Canonical Type System의 저자)으로부터:

저는 이 PEP에 정의된 메커니즘을 사용하여 TensorFlow에서 랭크-제네릭 Tensor 타입을 정의하는 데 관심이 있습니다. 이는 타입 어노테이션을 사용하여 Pythonic 방식으로 tf.function 시그니처를 지정하는 데 중요합니다 (오늘날 우리가 가지고 있는 사용자 정의 input_signature 메커니즘 대신 – 이 이슈를 참조하십시오: [cite: https://github.com/tensorflow/tensorflow/issues/31579]). 가변 제네릭은 텐서 및 형태에 대한 우아한 타입 정의 세트를 만드는 데 필요한 마지막 몇 가지 누락된 부분 중 하나입니다.

(투명성을 위해 - 우리는 세 번째 인기 있는 수치 연산 라이브러리인 PyTorch의 관계자들에게도 연락했지만, 그들로부터 추천 진술을 받지는 못했습니다. 그들은 동일한 문제 중 일부(예: 정적 형태 추론)에 관심이 있지만, 현재 Python 타입 시스템보다는 DSL을 통해 이를 가능하게 하는 데 집중하고 있다고 이해하고 있습니다.)

Acknowledgements (감사의 말씀)

이 PEP 초안에 대한 유용한 피드백과 제안을 주신 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에게 감사드립니다.

특히 별표 구문을 제안하여 이 제안의 여러 측면을 훨씬 더 간결하고 직관적으로 만들어준 Lucio에게, 그리고 추천사를 제공해준 Stephan Hoyer와 Dan Moldovan에게 특별히 감사드립니다.

⚠️ 알림: 이 문서는 AI를 활용하여 번역되었으며, 기술적 정확성을 보장하지 않습니다. 정확한 내용은 반드시 원문을 확인하시기 바랍니다.

Comments