Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 646 – 가변 인자 제네릭

Author:
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>
Sponsor:
Guido van Rossum <guido at python.org>
Status:
Final
Type:
Standards Track
Topic:
Typing
Created:
16-Sep-2020
Python-Version:
3.11
Post-History:
07-Oct-2020, 23-Dec-2020, 29-Dec-2020
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at TypeVarTuple and typing.TypeVarTuple.

×

See PEP 1 for how to propose changes.

초록

PEP 484는 TypeVar를 도입하여 단일 타입으로 매개변수화된 제네릭을 만들 수 있게 했습니다. 이 PEP에서는 TypeVarTuple을 도입하여 임의의 개수의 타입으로 매개변수화할 수 있게 합니다. 즉, 가변 인자 타입 변수를 도입하여 가변 인자 제네릭을 가능하게 합니다. 이를 통해 매우 다양한 사용 사례를 지원할 수 있습니다. 특히 NumPy나 TensorFlow 같은 수치 계산 라이브러리에서 배열과 유사한 구조의 타입을 배열의 형상으로 매개변수화할 수 있게 되므로, 정적 타입 검사기가 이러한 라이브러리를 사용하는 코드에서 형상과 관련된 버그를 잡아낼 수 있습니다.

수락

이 PEP는 타입 표현식 안의 여러 언패킹에 관한 세부 사항이 정확하게 명시되어 있지 않다는 단서와 함께 Python 3.11에 승인되었습니다. 이로 인해 개별 타입 검사기에 어느 정도 재량이 주어지지만, 향후 PEP에서 더 엄격하게 정할 수 있습니다.

동기

가변 인자 제네릭은 수많은 사용 사례를 위해 오랫동안 요청되어 온 기능입니다 [4]. 그중 한 가지 사용 사례, 즉 잠재적으로 큰 영향을 미칠 수 있으며 이 PEP가 주로 겨냥하는 사례는 수치 라이브러리에서의 타입 지정에 관한 것입니다.

NumPy나 TensorFlow 같은 라이브러리를 사용하는 수치 계산에서는 변수의 형상이 변수의 타입만큼이나 중요한 경우가 많습니다. 예를 들어, 비디오 배치 [1]를 그레이스케일로 변환하는 다음 함수를 생각해 보십시오:

def to_gray(videos: Array): ...

시그니처만 보고서는 videos 인자에 어떤 형상의 배열 [2]을 전달해야 하는지 분명하지 않습니다. 가능한 형상으로는 예를 들어 다음과 같은 것들이 있습니다.

batch × time × height × width × channels

그리고

time × batch × channels × height × width. [3]

이는 다음 세 가지 이유로 중요합니다:

  • 문서. 요구되는 형상이 시그니처에 명확히 드러나지 않으면, 사용자는 입력/출력 형상 요구 사항이 무엇인지 알아내기 위해 독스트링이나 해당 코드를 뒤져야 합니다.
  • 런타임 전에 형상 버그 잡기. 이상적으로는 잘못된 형상을 사용하는 것이 정적 분석을 통해 미리 잡아낼 수 있는 오류여야 합니다. (이는 반복 실행 시간이 길 수 있는 머신 러닝 코드에서 특히 중요합니다.)
  • 미묘한 형상 버그 방지. 최악의 경우, 잘못된 형상을 사용하면 프로그램이 겉보기에는 잘 실행되는 것처럼 보이지만 추적하는 데 며칠이 걸릴 수 있는 미묘한 버그가 생깁니다. (특히 악질적인 예로는 널리 알려진 머신 러닝 튜토리얼의 this exercise를 참고하십시오.)

이상적으로는 타입 시그니처에서 형상 요구 사항을 명시적으로 나타낼 방법이 있어야 합니다. 여러 제안 [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] 같은 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 기준으로 타입 변수 튜플은 불변(invariant)입니다.

타입 변수 튜플 동등성

같은 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 타입의 요소 0개 이상으로 이루어진 튜플 타입입니다. 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는 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가 키워드 전용 인자가 되기 때문입니다.

언패킹된 길이가 정해지지 않은 튜플을 사용하는 것은 int 타입의 값을 0개 이상 받는 *args: int의 PEP 484 동작과 동등합니다:

def foo(*args: *Tuple[int, ...]) -> None: ...

# equivalent to:
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,))    # 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)

(자세한 내용은 Unpacking Unbounded Tuple Types 절을 참고하십시오.)

이렇게 하면 라이브러리가 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 예제(Summary Examples 참고)와 관련하여 중요한 점은, 이를 통해 고정된 형태나 데이터 타입을 갖는 배열을 위한 편의 별칭을 정의할 수 있다는 것입니다:

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, ...]로 취급됩니다(Behaviour when Type Parameters are not Specified와 유사합니다):

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 하나 다음에 0개 이상의 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을 모두 가진 별칭

Aliases에서 별칭이 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]

Multiple Type Variable Tuples: Not Allowed에 따라, 별칭의 타입 매개변수에는 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]와 동등합니다. 즉, 0개 이상의 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 하나 다음에 0개 이상의 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는 Pyre의 초기 구현에서 사용된 이름을 따라 ListVariadic으로 시작했습니다.

그런 다음 저희는 a) TypeVar와의 유사성을 더 잘 강조하고, b) ‘list’의 의미가 ‘variadic’이라는 전문 용어보다 더 쉽게 이해된다는 근거로 이를 TypeVar(list=True)로 변경했습니다.

가변 인자 타입 변수가 Tuple처럼 동작해야 한다고 결정한 후, 저희는 TypeVar(bound=Tuple)도 고려했습니다. 이는 마찬가지로 직관적이며 TypeVar에 새로운 인자를 요구하지 않고도 저희가 원하던 것 대부분을 달성합니다. 그러나 예를 들어 가변 타입 변수에 대해서는 타입 경계나 변성이 TypeVar의 의미가 암시하는 것과 약간 다르게 동작하기를 원할 경우, 이 방식이 향후 우리를 제약할 수 있다는 것을 깨달았습니다. 또한 저희는 나중에 일반 타입 변수에서는 지원되지 않아야 하는 인자(예: arbitrary_len [10])를 지원하고 싶어질 수도 있습니다.

따라서 저희는 TypeVarTuple로 결정했습니다.

명시되지 않은 타입 매개변수: 튜플(Tuple) 대 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를 반환하는 것이 유일한 선택지일 가능성이 훨씬 높습니다. 진정으로 임의의 형태를 가진 배열을 다루는 경우는 드뭅니다. 특정 경우에는 형태의 일부가 임의일 수 있지만(예를 들어 시퀀스를 다룰 때 형태의 처음 두 부분은 흔히 ‘batch’와 ‘time’입니다), 이러한 경우는 향후 PEP에서 Array[Batch, Time, ...]와 같은 문법으로 명시적으로 지원할 계획입니다.

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

대안

수치 라이브러리에서 형태 검사 문제를 해결하기 위해 이 PEP에서 제시한 접근 방식이 가능한 유일한 접근 방식은 아니라는 점에 유의해야 합니다. 런타임 검사에 기반한 더 가벼운 대안의 예로는 ShapeGuard [13], tsanley [11], PyContracts [12] 등이 있습니다.

이러한 기존 접근 방식은 길고 장황한 assert 문을 통해서만 형태 검사가 가능한 기본 상황을 크게 개선하지만, 그중 어느 것도 형태 정확성에 대한 정적 분석을 가능하게 하지는 않습니다. Motivation에서 언급했듯이, 이는 라이브러리와 인프라의 복잡성으로 인해 비교적 간단한 프로그램조차 긴 시작 시간을 감수해야 하는 머신 러닝 애플리케이션에서 특히 바람직합니다. 이러한 기존 런타임 기반 접근 방식에서 필요한 것처럼 프로그램이 충돌할 때까지 실행하며 반복하는 것은 지루하고 답답한 경험이 될 수 있습니다.

이 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: TypeVarTuple로서의 *args

두 번째 변경은 함수 정의에서 *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

이렇게 하면 런타임 어노테이션이 args의 어노테이션에 Starred 노드를 사용하는 AST 표현과 일관될 수 있으며, 이는 다시 mypy처럼 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                 # 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가 특별하다는 점을 여전히 드러내면서도 훨씬 쉽게 훑어볼 수 있습니다.
  • 직관성. 우리는 사용자가 Unpack[Ts]의 의미보다 *Ts의 의미를 직관적으로 이해할 가능성이 더 높다고 생각합니다. 특히 Ts가 TypeVar**Tuple**, 즉 타입 변수 튜플이라는 것을 보면 더욱 그렇습니다. (이는 사용자가 다른 맥락에서 별표 언패킹에 익숙하다고 가정합니다. 사용자가 가변 제네릭을 사용하는 코드를 읽거나 작성하고 있다면 이는 합리적인 가정으로 보입니다.)

따라서 변경 1조차 너무 큰 변경이라고 여겨진다면, 이 두 번째 대안으로 진행하기 전에 저희가 선택지를 다시 검토하는 편이 나을 수 있습니다.

하위 호환성

이 PEP의 Unpack 버전은 이전 버전의 Python으로 백포트할 수 있어야 합니다.

매개변수화되지 않은 가변 클래스가 임의 개수의 타입 매개변수와 호환되므로 점진적 타이핑이 가능합니다. 이는 기존 클래스를 제네릭으로 만들더라도 a) 해당 클래스를 (매개변수 없이) 사용하던 기존 코드는 모두 계속 동작하고, b) 매개변수화된 버전과 매개변수화되지 않은 버전의 클래스를 함께 사용할 수 있다는 뜻입니다(예를 들어 라이브러리 코드는 매개변수를 사용하도록 갱신되었지만 사용자 코드는 그렇지 않거나, 그 반대인 경우에 해당합니다).

참조 구현

타입 검사 기능의 참조 구현은 두 가지가 있습니다. 하나는 v0.9.0부터의 Pyre에 있고, 다른 하나는 v1.1.108부터의 Pyright에 있습니다.

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)
# Result is Array[Literal[64]]

이러한 이름의 스코프는 순전히 지역적이라는 점에 유의하십시오. 즉, K라는 이름은 matrix_vector_multiply 안에서만 Literal[64]에 바인딩됩니다. 달리 말하면, 서로 다른 시그니처에 있는 K의 값 사이에는 아무 관계도 없습니다. 이 점은 중요합니다. K라는 이름의 모든 축이 프로그램 전체에서 같은 값을 갖도록 제약된다면 불편할 것입니다.

이 방식의 단점은 서로 다른 호출 사이에서 형태의 의미를 강제할 수 없다는 것입니다. 예를 들어 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` 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과 같은 단순한 오타는 발견하기 어렵고, 완전히 다른 의미의 결과를 만들어 냅니다(게다가 프로그램은 아마 계속 실행될 것이므로, 심각하지만 드러나지 않는 버그로 이어집니다).

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

이 PEP에 대해 자주 받는 질문은 다음과 같습니다. 왜 그냥 명명된 텐서를 사용하지 않습니까? 저희는 두 가지 주된 이유로 명명된 텐서 방식이 충분하지 않다고 생각합니다.

  • 형태가 올바른지 정적으로 검사할 수 없습니다. Motivation에서 언급했듯이, 이는 기본적으로 반복 주기가 느린 머신 러닝 코드에서 매우 바람직한 기능입니다.
  • 이 방식으로는 인터페이스 문서화도 여전히 불가능합니다. 어떤 함수가 이미지와 같은 형태를 가진 배열 인자만 받아야 한다면, 명명된 텐서로는 이를 명시할 수 없습니다.

또한 낮은 채택률이라는 문제도 있습니다. 이 글을 쓰는 시점에 명명된 텐서는 소수의 수치 계산 라이브러리에만 구현되어 있습니다. 그 이유로는 구현의 어려움(인덱스 대신 축 이름으로 선택할 수 있도록 API 전체를 수정해야 합니다)과, 축 순서 관례가 대개 충분히 확고해서 축 이름이 주는 이점이 적기 때문에 유용성이 떨어진다는 점(예를 들어 이미지를 다룰 때 3차원 텐서는 기본적으로 항상 높이 × 너비 × 채널입니다)을 들 수 있습니다. 하지만 결국 저희는 왜 이런 상황인지 아직 확신하지 못합니다.

명명된 텐서 방식을 이 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이 거부되었기 때문에 가까운 시일 내에는 불가능합니다. 어쨌든 이에 대한 저희의 태도는 대체로 “추가 조치를 취하기 전에 어떻게 되는지 지켜봅시다”입니다.

각주

지지 의견

가변 제네릭(variadic generics)은 매우 다양한 용도로 쓰입니다. 그중 수치 계산과 관련된 부분에서, 관련 라이브러리가 이 PEP에서 제안하는 기능을 실제로 사용할 가능성은 얼마나 됩니까?

저희는 여러 사람에게 이 질문을 했고, 다음과 같은 지지 의견을 받았습니다.

NumPy 운영 위원회(Steering Council) 위원인 Stephan Hoyer의 의견: [14]

이 PEP를 작성해 주시고, https://github.com/python/peps/pull/1904 에서 배열 타이핑을 위한 PEP 646의 전반적인 맥락을 명확히 해 주신 Matthew와 Pradeep에게 감사드리고 싶습니다.

저는 Python 수치 계산 커뮤니티(예: NumPy, JAX, Xarray)에 깊이 관여하고 있지만 Python 타입 시스템의 세부 사항에는 그다지 익숙하지 않은 사람으로서, 명명된 축과 형태의 타입 검사와 관련된 폭넓은 사용 사례가 검토되었고 이 PEP의 기반 구조 위에 구축될 수 있다는 점을 보니 안심이 됩니다.

셰이프(shape)에 대한 타입 검사는 NumPy 커뮤니티가 큰 관심을 두고 있는 주제입니다. NumPy GitHub에서 관련 이슈(https://github.com/numpy/numpy/issues/7370)는 다른 어떤 이슈보다 많은 추천(thumbs up)을 받았으며, 저희는 최근 “typing” 모듈을 추가하여 활발히 개발하고 있습니다.

ndarray에 타입 검사를 적용하는 최선의 방법을 알아내려면 분명 여러 실험이 필요하겠지만, 이 PEP는 그러한 작업을 위한 훌륭한 토대로 보입니다.

NumPy에서 셰이프 제네릭의 초기 지원 작업을 해 온 Bas van Beek의 의견:

저는 이 점에서 Stephan의 의견에 전적으로 동의하며, 새로운 PEP 646 가변 인자 제네릭을 numpy에 통합하기를 고대하고 있습니다.

numpy(그리고 텐서 타이핑 전반)의 맥락에서 배열 셰이프의 타이핑은 상당히 복잡한 주제입니다. 가변 인자 제네릭은 차원 수와 기본적인 셰이프 조작을 모두 표현할 수 있게 해 주므로, 그 도입은 이 주제의 토대를 마련하는 데 큰 역할을 할 것으로 보입니다.

전반적으로, 저는 PEP 646과 앞으로의 PEP들이 우리를 어디로 이끌지에 큰 관심을 두고 있으며, 앞으로의 발전을 고대하고 있습니다.

TensorFlow 개발팀의 선임 소프트웨어 엔지니어이자 TensorFlow RFC TensorFlow Canonical Type System의 저자인 Dan Moldovan의 의견: [15]

저는 이 PEP에 정의된 메커니즘을 사용하여 TensorFlow에서 랭크 제네릭 Tensor 타입을 정의하는 데 관심이 있습니다. 이러한 타입은 (현재 사용하는 커스텀 input_signature 메커니즘 대신) 타입 어노테이션을 사용하여 파이썬다운 방식으로 tf.function 시그니처를 지정하는 데 중요합니다(다음 이슈를 참고하십시오: https://github.com/tensorflow/tensorflow/issues/31579). 가변 인자 제네릭은 텐서와 셰이프를 위한 우아한 타입 정의 집합을 만드는 데 아직 부족한 마지막 몇 가지 조각 중 하나입니다.

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

감사의 글

이 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에게도 감사드립니다.

참고 자료

파이썬의 가변 인자 제네릭에 대한 논의는 2016년 python/typing GitHub 저장소의 Issue 193 [4]에서 시작되었습니다.

이 논의에 영감을 받아, Ivan Levkivskyi는 PyCon 2019에서 구체적인 제안을 했으며, 이는 ‘타입 시스템 개선(Type system improvements)’ [5] 및 ‘Python 수치 스택의 정적 타입 지정(Static typing of Python numeric stack)’ [6] 노트에 요약되어 있습니다.

이러한 아이디어를 확장하여, Mark Mendoza와 Vincent Siles는 2019 Python Typing Summit에서 ‘데코레이터와 텐서를 위한 가변 인자 타입 변수(Variadic Type Variables for Decorators and Tensors)’ [8]에 관해 발표했습니다.

제네릭 별칭에서 타입 치환이 어떻게 동작해야 하는지에 대한 논의는 cpython#91162에서 이루어졌습니다.

참고 문헌