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
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
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타입 변수 튜플은 T1과 T2가 타입 변수인 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()
함수에서 타입 변수 튜플 사용하기
타입 변수 튜플은 일반 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을 여러 타입의 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와 마찬가지로, 튜플에는 only one 언패킹만 나타날 수 있습니다.
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가 키워드 전용 인자가 되기 때문입니다.
언패킹된 바운드가 없는 튜플을 사용하는 것은 0개 이상의 int 타입 값을 허용하는 PEP 484의 *args: int 동작과 동등합니다.
def foo(*args: *Tuple[int, ...]) -> None: ...
# equivalent to:
def foo(*args: int) -> None: ...
튜플 타입을 언패킹하면 서로 다른 타입의 *args에 대해 더 정확한 타입도 지정할 수 있습니다. 다음 함수는 처음에 int 하나, 그 뒤에 str 값 0개 이상, 마지막에 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]]): ...
언패킹된 항목을 포함하는 호출 가능 객체의 동작은 해당 항목이 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]에 바인딩되며, 그 결과 IntFloatTuple은 Tuple[int, float, *Ts2]와 동등한 별칭이 됩니다.
언패킹된 임의 길이 튜플도 유사한 효과로 타입 인자로 사용할 수 있습니다.
IntFloatsTuple = IntTuple[*Tuple[float, ...]] # Valid
여기서 *Ts1은 *Tuple[float, ...]에 바인딩되며, 그 결과 IntFloatsTuple은 Tuple[int, *Tuple[float, ...]]와 동등합니다. 즉, int 하나와 0개 이상의 floats로 구성된 튜플입니다.
가변 인자에는 가변 별칭이 필요합니다.
가변 타입 인수는 그 자체가 가변적인 제네릭 별칭에만 사용할 수 있습니다. 예를 들면 다음과 같습니다:
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에서 별칭이 TypeVars와 TypeVarTuples 모두에 대해 제네릭일 수 있다고 간략히 언급했습니다:
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은 앞뒤에 있는 임의의 개수의 TypeVars와 결합할 수 있습니다:
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]
이러한 경우 타입 인수의 최소 개수는 TypeVars의 개수로 정해집니다:
Shrubbery[int] # Not valid; Shrubbery needs at least two type arguments
임의 길이 튜플 분할
TypeVars와 TypeVarTuple을 모두 포함하는 별칭에 압축 해제된 임의 길이 튜플을 타입 인수로 사용할 때 마지막으로 한 가지 복잡한 문제가 발생합니다:
Elderberries = Tuple[*Ts, T1]
Hamster = Elderberries[*Tuple[int, ...]] # valid
이러한 경우 임의 길이 튜플은 TypeVars와 TypeVarTuple 사이에서 분할됩니다. 임의 길이 튜플에 TypeVars의 개수 이상에 해당하는 항목이 포함되어 있다고 가정하므로, 내부 타입(여기서는 int)의 개별 인스턴스가 존재하는 모든 TypeVars에 바인딩됩니다. 임의 길이 튜플의 ‘나머지’ 부분(여기서는 *Tuple[int, ...]입니다. 임의 길이 튜플에서 두 항목을 빼도 여전히 임의 길이이기 때문입니다)은 TypeVarTuple에 바인딩됩니다.
따라서 여기서 Hamster는 Tuple[*Tuple[int, ...], int]와 동등합니다. 즉, 0개 이상의 ints와 마지막 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개 이상의 ints, 마지막 int 하나로 구성된 튜플입니다.
TypeVarTuple은 분할할 수 없습니다
마지막으로 타입 인수 목록의 임의 길이 튜플은 타입 변수와 타입 변수 튜플 사이에서 분할할 수 있지만, 인수 목록의 TypeVarTuples에는 동일한 규칙이 적용되지 않습니다:
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에서 이러한 메커니즘을 도입할 계획입니다.)
근거 및 거부된 아이디어
셰이프 산술
특히 배열 shape의 사용 사례를 고려할 때, 이 PEP의 현재 시점에서는 배열 차원의 산술적 변환을 설명할 수 없다는 점에 유의하십시오. 예를 들면 def repeat_each_element(x: Array[N]) -> Array[2*N]과 같습니다. 이는 현재 PEP의 범위를 벗어난다고 판단하지만, 향후 PEP에서 이를 가능하게 할추가 메커니즘을 제안할 계획입니다.
별칭을 통한 가변성 지원
서론에서 언급했듯이, 가능한 각 타입 매개변수 개수에 대해 별칭을 단순히 정의하면 가변 제네릭을 피하는 것이 가능합니다:
class Array1(Generic[Axis1]): ...
class Array2(Generic[Axis1, Axis2]): ...
그러나 이는 다소 번거로워 보입니다. 필요한 각 rank에 대해 사용자가 코드에 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로 추론되었다면, 사용자가 아직 코드에 타입 어노테이션을 완전히 추가하지 않았거나, shape를 조작하는 라이브러리 함수의 시그니처를 아직 타이핑 시스템으로 표현할 수 없어서 일반 Array를 반환하는 것이 유일한 선택지일 가능성이 훨씬 높습니다. 실제로 임의의 shape를 가진 배열을 다루는 경우는 드뭅니다. 특정 상황에서는 shape의 일부 부분이 임의적일 수 있습니다. 예를 들어 시퀀스를 다룰 때 shape의 처음 두 부분은 흔히 ‘batch’와 ‘time’입니다. 그러나 이러한 경우를 Array[Batch, Time, ...]와 같은 구문으로 향후 PEP에서 명시적으로 지원할 계획입니다.
따라서 Tuple이외의 다른 가변 제네릭이 다르게 동작하도록 결정했습니다. 이를 통해 사용자가 자신의 코드 중 어느 정도까지 타입 어노테이션을 작성할지에 더 큰 유연성을 제공하고, 이러한 타입 어노테이션을 사용하는 라이브러리의 새 버전과 기존의 어노테이션이 없는 코드 간의 호환성을 가능하게 하기 위해서입니다.
대안
수치 라이브러리에서 shape 검사 문제를 해결하기 위해 이 PEP에서 제시하는 접근 방식이 가능한 유일한 접근 방식은 아니라는 점에 유의해야 합니다. 런타임 검사에 기반한 더 경량인 대안의 예로는 ShapeGuard [13], tsanley [11], PyContracts [12]가 있습니다.
이러한 기존 접근 방식은 장황하고 긴 assert 문을 통해서만 shape 검사가 가능한 기본 상황을 크게 개선하지만, 어느 것도 shape 정확성에 대한 정적 분석을 가능하게 하지는 않습니다. Motivation에서 언급했듯이, 이는 라이브러리와 인프라의 복잡성으로 인해 비교적 단순한 프로그램조차 긴 시작 시간을 감수해야 하는 머신 러닝 애플리케이션에서 특히 바람직합니다. 이러한 기존 런타임 기반 접근 방식에서 필요한 것처럼 프로그램이 충돌할 때까지 실행하며 반복하는 작업은 지루하고 답답한 경험이 될 수 있습니다.
이 PEP를 통해 제네릭 타입 어노테이션을 shape 정확성을 다루는 공식적이고 언어가 지원하는 방법으로 정립하기 시작할 수 있기를 바랍니다. 어느 정도의 표준이 마련되면, 장기적으로 수치 계산 프로그램의 shape 속성을 분석하고 검증하는 도구의 활발한 생태계가 조성될 수 있기를 바랍니다.
문법 변경 사항
이 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에 Ts.__iter__를 호출하여 이터레이터를 만들고, 이터레이터에서 단일 값을 가져온 다음 이터레이터가 소진되었는지 확인하고, 해당 값을 어노테이션 값으로 설정합니다. 그 결과 언패킹된 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를 특별한 것으로 표시하면서도 훨씬 더 쉽게 훑어볼 수 있습니다. - 직관성. 저희는 사용자가
*Ts의 의미를Unpack[Ts]의 의미보다 직관적으로 이해할 가능성이 더 높다고 생각합니다. 특히Ts가 TypeVar**Tuple**임을 볼 때 더욱 그렇습니다. (이는 사용자가 다른 맥락에서 별표 언패킹에 익숙하다고 가정합니다. 사용자가 가변 제네릭을 사용하는 코드를 읽거나 작성하고 있다면 이는 합리적인 가정으로 보입니다.)
따라서 변경 1조차 너무 큰 변경이라고 여겨진다면, 이 두 번째 대안을 추진하기에 앞서 저희가 선택지를 재고하는 편이 더 나을 수 있습니다.
하위 호환성
Unpack 버전의 PEP는 이전 버전의 Python으로 백포트할 수 있어야 합니다.
매개변수가 지정되지 않은 가변 클래스가 임의 개수의 타입 매개변수와 호환된다는 사실을 통해 점진적 타이핑이 가능합니다. 이는 기존 클래스를 제네릭으로 만들 경우 a) 기존의 모든 (매개변수가 지정되지 않은) 클래스 사용이 계속 작동하고, b) 매개변수가 지정된 버전과 지정되지 않은 버전의 클래스를 함께 사용할 수 있음을 의미합니다. (예를 들어 라이브러리 코드는 매개변수를 사용하도록 업데이트되었지만 사용자 코드는 그렇지 않은 경우 또는 그 반대의 경우와 관련이 있습니다.)
참조 구현
타입 검사 기능의 참조 구현은 두 가지가 존재합니다. 하나는 v0.9.0 기준의 Pyre에 있고, 다른 하나는 v1.1.108 기준의 Pyright에 있습니다.
CPython의 Unpack 버전 PEP에 대한 예비 구현은 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]): ...
이를 통해 축 크기(예를 들어 32)는 TimeValue를 통해, 및 타입은 TypeType을 통해 접근할 수 있습니다.
이는 두 번째 수준의 매개변수화를 통해 기존 문법을 사용하여 가능할 수도 있습니다:
def f(array: array[TimeValue[TimeType]]): ..
그러나 이 접근법에 대한 탐색은 앞으로의 과제로 남겨 둡니다.
부록 B: 셰이프가 지정된 타입과 명명된 축
이 PEP에서 다루는 문제와 관련된 문제로 축 선택이 있습니다. 예를 들어 64×64x3 형태의 배열에 저장된 이미지가 있다면, 세 번째 축에 대한 평균을 계산하여 흑백으로 변환하고 싶을 수 있습니다. mean(image, axis=2) 안타깝게도 단순한 오타인 axis=1는 발견하기 어렵고 완전히 다른 의미의 결과를 생성합니다(프로그램이 계속 실행되도록 할 가능성이 높은 가운데, 심각하지만 조용히 발생하는 버그가 됩니다).
이에 대응하여 일부 라이브러리는 이른바 ‘명명된 텐서’를 구현했습니다(이 맥락에서 ‘텐서’는 ‘배열’과 동의어입니다). 여기서는 축을 인덱스가 아니라 레이블로 선택합니다. 예를 들면 mean(image, axis='channels')와 같습니다.
이 PEP에 관해 자주 받는 질문은 다음과 같습니다. 명명된 텐서를 그냥 사용하면 안 되는 이유는 무엇입니까? 답은 두 가지 주요 이유로 명명된 텐서 접근법이 불충분하다고 생각하기 때문입니다.
- 형태의 정확성을 정적 검사는 수행할 수 없습니다. Motivation에서 언급했듯이, 이는 기본적으로 반복 시간이 느린 머신 러닝 코드에서 매우 바람직한 기능입니다.
- 이 접근법으로는 인터페이스 문서화도 여전히 불가능합니다. 함수가 오직 이미지와 유사한 형상을 가진 배열 인자만 받도록 하려는 경우, 명명된 텐서로는 이를 명시할 수 없습니다.
또한 낮은 채택률이라는 문제도 있습니다. 작성 시점에 명명된 텐서는 소수의 수치 계산 라이브러리에서만 구현되어 있습니다. 가능한 설명으로는 구현의 어려움(인덱스 대신 축 이름으로 선택할 수 있도록 전체 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 Steering Council의 구성원인 Stephan Hoyer로부터: [14]
이 PEP를 작성하고 배열 타이핑을 위한 PEP 646의 더 넓은 맥락을 명확히 설명해 주신 Matthew와 Pradeep에게 감사드리고 싶었습니다. https://github.com/python/peps/pull/1904.Python 수치 계산 커뮤니티(예: NumPy, JAX, Xarray)에 깊이 관여하고 있지만 Python 타입 시스템의 세부 사항에는 그다지 익숙하지 않은 사람으로서, 이름이 지정된 축과 셰이프의 타입 검사와 관련된 폭넓은 사용 사례가 고려되었으며 이 PEP의 인프라를 기반으로 발전할 수 있다는 점을 보니 안심이 됩니다.
셰이프에 대한 타입 검사는 NumPy 커뮤니티가 매우 큰 관심을 보이는 분야입니다. NumPy의 GitHub에서 관련 이슈(https://github.com/numpy/numpy/issues/7370)에 다른 어떤 이슈보다도 많은 추천이 있으며, 최근 활발히 개발 중인 “typing” 모듈도 추가했습니다.
물론 ndarray에 타입 검사를 사용하는 최선의 방법을 알아내려면 실험이 필요하겠지만, 이 PEP는 그러한 작업을 위한 훌륭한 토대가 될 것으로 보입니다.
NumPy에서 셰이프 제네릭에 대한 예비 지원 작업을 진행한 Bas van Beek에게서:
TensorFlow 개발 팀의 선임 소프트웨어 엔지니어이자 TensorFlow RFC TensorFlow Canonical Type System의 작성자인 Dan Moldovan에게서: [15]
이 PEP에 정의된 메커니즘을 사용하여 TensorFlow에서 랭크 제네릭 Tensor 타입을 정의하는 데 관심이 있습니다. 이러한 타입은 타입 어노테이션을 사용하여 Python다운 방식으로tf.function시그니처를 지정하는 데 중요합니다(현재 사용 중인 사용자 지정input_signature메커니즘이 아니라 다음 이슈를 참고하십시오: 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에게도 감사드립니다.
자료
Python의 가변 제네릭에 대한 논의는 2016년 python/typing GitHub 저장소의 Issue 193에서 시작되었습니다 [4].
이 논의에서 영감을 받아 Ivan Levkivskyi는 PyCon 2019에서 구체적인 제안을 내놓았으며, ‘Type system improvements’ [5] 및 ‘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에서 이루어졌습니다.
참고 문헌
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.