[Final] PEP 692 - Using TypedDict for more precise **kwargs typing

원문 링크: PEP 692 - Using TypedDict for more precise **kwargs typing

상태: Final 유형: Standards Track 작성일: 29-May-2022

PEP 692 – 보다 정밀한 **kwargs 타입 지정을 위한 TypedDict 활용

개요

이 PEP는 **kwargs에 포함된 키워드 인자들의 타입이 서로 다를 때, TypedDict를 사용하여 보다 정밀하게 **kwargs의 타입을 지정할 수 있는 새로운 방법을 제안합니다. 현재 **kwargs는 모든 키워드 인자가 동일한 타입이어야만 타입 힌트가 가능하여 활용도가 제한적이었습니다. 이 새로운 접근 방식은 TypedDict를 통해 여러 타입을 포함하는 **kwargs를 타입 지정할 수 있도록 합니다.

동기 (Motivation)

기존에는 **kwargs를 특정 타입 T로 어노테이션하면, 이는 dict[str, T]를 의미했습니다. 예를 들어 def foo(**kwargs: str) -> None:foo 함수의 모든 키워드 인자가 문자열임을 뜻했습니다. 이러한 방식은 모든 키워드 인자의 타입이 동일한 경우에만 **kwargs 타입 어노테이션을 가능하게 하여, 키워드 이름에 따라 타입이 달라지는 경우에는 **kwargs 타입 지정이 불가능했습니다. 이는 특히 기존 코드베이스에서 적절한 타입 어노테이션을 도입하기 위한 리팩토링 노력이 비효율적이라고 간주될 수 있는 경우에 문제가 되었습니다.

**kwargs는 또한 공개 API의 최상위 함수가 여러 헬퍼 함수를 호출하고, 이 헬퍼 함수들이 동일한 키워드 인자들을 기대할 때 코드 중복을 줄이는 데 유용합니다. 그러나 이 경우에도 키워드 인자들이 다른 타입을 가질 때 **kwargs를 사용하는 헬퍼 함수의 타입 힌트는 어려웠습니다. 심지어 같은 타입이라 할지라도, 함수가 실제로 예상하는 키워드 이름으로 호출되는지 확인할 방법이 없었습니다.

**kwargs는 기본값이 없는 선택적 키워드 전용 인자를 수용해야 할 때도 편리하게 사용될 수 있습니다. None과 같이 사용자 입력이 없음을 나타내는 기본값이 사용자에게 전달되어 유효하지만 기본값이 아닌 동작을 유발할 수 있는 경우에 이러한 패턴이 필요할 수 있습니다.

근거 (Rationale)

PEP 589에서 도입된 TypedDict는 문자열 키와 잠재적으로 다른 타입의 값을 포함하는 딕셔너리 타입을 지원합니다. **kwargs와 같은 이중 별표로 시작하는 형식 매개변수로 표현되는 함수의 키워드 인자는 딕셔너리 형태로 받게 됩니다. 또한, 이러한 함수들은 키워드 인자를 제공하기 위해 언팩된(unpacked) 딕셔너리를 사용하여 호출되는 경우가 많습니다. 이 때문에 TypedDict는 보다 정밀한 **kwargs 타입 지정을 위한 완벽한 후보입니다. TypedDict를 사용하면 정적 타입 분석 중에 키워드 이름을 고려할 수 있습니다.

기존 **kwargs 동작을 유지하면서 TypedDictkwargs 타입으로 지원하기 위해 PEP 646에서 처음 도입된 Unpack을 재사용하는 것을 제안합니다. Unpack은 제공된 TypedDict에서 키워드 인자를 “언팩”하려는 의도에 적합하고 직관적인 이름을 가지고 있습니다. 또한, *args의 현재 타입 지정 방식이 **kwargs로 확장되어 유사하게 동작하며, 새로운 특별한 형식을 도입할 필요가 없습니다. 이 PEP에서 설명된 목적을 위한 Unpack 사용은 PEP 646에서 설명된 사용 사례와 충돌하지 않습니다.

명세 (Specification)

Unpack을 사용하여 **kwargs를 어노테이션하는 새로운 방법을 소개합니다. 예를 들어:

class Movie(TypedDict):
    name: str
    year: int

def foo(**kwargs: Unpack[Movie]) -> None:
    ...

위 코드는 **kwargsMovie에 의해 지정된 두 개의 키워드 인자(즉, str 타입의 name 키워드와 int 타입의 year 키워드)를 포함함을 의미합니다. 이 함수는 다음과 같이 호출될 수 있습니다:

kwargs: Movie = {"name": "Life of Brian", "year": 1979}
foo(**kwargs) # OK!
foo(name="The Meaning of Life", year=1983) # OK!

Unpack이 사용될 때, 타입 체커는 함수 본문 내의 kwargsTypedDict로 취급합니다.

def foo(**kwargs: Unpack[Movie]) -> None:
    assert_type(kwargs, Movie) # OK!

새로운 어노테이션은 런타임에 아무런 영향을 미치지 않으며, 오직 타입 체커에 의해서만 고려됩니다.

표준 딕셔너리를 사용한 함수 호출

dict[str, object] 타입의 딕셔너리를 Unpack으로 어노테이션된 **kwargs 인자로 전달하면 타입 체커 오류가 발생해야 합니다.

def foo(**kwargs: Unpack[Movie]) -> None: ...
movie: dict[str, object] = {"name": "Life of Brian", "year": 1979}
foo(**movie) # WRONG! Movie는 dict[str, object] 타입입니다.

typed_movie: Movie = {"name": "The Meaning of Life", "year": 1983}
foo(**typed_movie) # OK!

another_movie = {"name": "Life of Brian", "year": 1979}
foo(**another_movie) # 타입 체커에 따라 다릅니다.

키워드 충돌

**kwargs 타입 지정을 위해 사용되는 TypedDict가 함수의 시그니처에 이미 정의된 키를 포함할 수 있습니다. 중복된 이름이 표준 매개변수라면 타입 체커에서 오류를 보고해야 합니다. 중복된 이름이 위치 전용(positional-only) 매개변수라면 오류를 생성해서는 안 됩니다.

def foo(name, **kwargs: Unpack[Movie]) -> None: ... # WRONG! "name"은 항상 첫 번째 매개변수에 바인딩됩니다.
def foo(name, /, **kwargs: Unpack[Movie]) -> None: ... # OK! "name"은 위치 전용 매개변수이므로 **kwargs에 "name" 키워드를 포함할 수 있습니다.

필수 및 비필수 키 (Required and non-required keys)

기본적으로 TypedDict의 모든 키는 필수(required)입니다. total 매개변수를 False로 설정하여 이 동작을 재정의할 수 있습니다. 또한, PEP 655는 특정 키가 필수인지 여부를 지정할 수 있는 typing.Requiredtyping.NotRequired와 같은 새로운 타입 한정자를 도입했습니다.

TypedDict를 사용하여 **kwargs를 타입 지정할 때, 모든 필수 및 비필수 키는 필수 및 비필수 함수 키워드 매개변수에 해당해야 합니다. 따라서, 호출자가 필수 키를 제공하지 않으면 타입 체커에서 오류를 보고해야 합니다.

class Movie(TypedDict):
    title: str
    year: NotRequired[int] # year는 필수가 아닙니다.

할당 (Assignment)

**kwargs: Unpack[Movie]로 타입 지정된 함수와 다른 호출 가능한 타입의 할당은 호환되는 경우에만 타입 검사를 통과해야 합니다.

소스와 대상 모두 **kwargs를 포함하는 경우

대상 함수와 소스 함수 모두 **kwargs: Unpack[TypedDict] 매개변수를 가지고 있고, 대상 함수의 TypedDict가 소스 함수의 TypedDict에 할당 가능하며 나머지 매개변수가 호환될 때 할당이 가능합니다.

class Animal(TypedDict):
    name: str
class Dog(Animal): # Dog는 Animal의 서브타입입니다.
    breed: str

def accept_animal(**kwargs: Unpack[Animal]): ...
def accept_dog(**kwargs: Unpack[Dog]): ...

accept_dog = accept_animal # OK! Dog 타입의 표현식이 Animal 타입의 변수에 할당될 수 있습니다. (공변성)
accept_animal = accept_dog # WRONG! Animal 타입의 표현식이 Dog 타입의 변수에 할당될 수 없습니다. (반공변성)

소스는 **kwargs를 포함하고 대상은 포함하지 않는 경우

대상 호출 가능(callable)이 **kwargs를 포함하지 않고, 소스 호출 가능이 **kwargs: Unpack[TypedDict]를 포함하며, 대상 함수의 키워드 인자가 소스 함수의 TypedDict의 해당 키에 할당 가능할 때 할당이 가능합니다. 또한, NotRequired 키는 선택적 함수 인자에, Required 키는 필수 함수 인자에 해당해야 합니다.

class Example(TypedDict):
    animal: Animal
    string: str
    number: NotRequired[int]

def src(**kwargs: Unpack[Example]): ...
def dest(*, animal: Dog, string: str, number: int = ...): ...

dest = src # OK!

이 경우, 대상 함수의 매개변수는 키워드 전용이어야 합니다.

소스가 언타입된 **kwargs를 포함하는 경우

대상 호출 가능이 **kwargs: Unpack[TypedDict]를 포함하고, 소스 호출 가능이 언타입된 **kwargs를 포함하는 경우:

def src(**kwargs): ...
def dest(**kwargs: Unpack[Movie]): ...
dest = src # OK!

소스가 전통적으로 타입 지정된 **kwargs: T를 포함하는 경우

대상 호출 가능이 **kwargs: Unpack[TypedDict]를 포함하고, 소스 호출 가능이 전통적으로 타입 지정된 **kwargs: T를 포함하며, 대상 함수 TypedDict의 각 필드가 타입 T의 변수에 할당 가능할 때 할당이 가능합니다.

class Vehicle: ...
class Car(Vehicle): ...
class Motorcycle(Vehicle): ...

class Vehicles(TypedDict):
    car: Car
    moto: Motorcycle

def dest(**kwargs: Unpack[Vehicles]): ...
def src(**kwargs: Vehicle): ...
dest = src # OK!

반대로, 대상 호출 가능이 언타입되거나 전통적으로 타입 지정된 **kwargs: T를 포함하고, 소스 호출 가능이 **kwargs: Unpack[TypedDict]를 사용하여 타입 지정된 경우 오류가 발생해야 합니다. 이는 전통적으로 타입 지정된 **kwargs가 키워드 이름을 확인하지 않기 때문입니다.

요약하자면, 함수 매개변수는 반공변(contravariantly)적으로 동작해야 하고, 함수 반환 타입은 공변(covariantly)적으로 동작해야 합니다.

함수 내에서 kwargs를 다른 함수로 전달

언팩된 TypedDict로 힌트된 kwargs는, 언팩된 kwargs가 전달되는 함수 또한 시그니처에 **kwargs를 가지고 있는 경우에만 다른 함수로 전달될 수 있습니다. 그렇지 않으면 타입 체커는 오류를 생성해야 합니다. 예를 들어, 다음 코드는 takes_name 함수가 예상치 못한 키워드 인자를 받으므로 런타임에 실패할 것입니다.

class Animal(TypedDict):
    name: str
class Dog(Animal):
    breed: str

def takes_name(name: str): ...

dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog # Dog 인스턴스가 Animal 타입 변수에 할당됨

def bar(**kwargs: Unpack[Animal]):
    takes_name(**kwargs) # WRONG! 'breed' 키워드가 takes_name으로 전달되어 런타임에 실패합니다.

bar(**animal)

이러한 문제의 해결책으로, 필요한 필드를 명시적으로 참조하여 인자로 사용하는 방법이 있습니다.

def bar(**kwargs: Unpack[Animal]):
    name = kwargs["name"]
    takes_name(name)

TypedDict 이외의 타입과 Unpack 사용

**kwargs 타입 지정의 맥락에서, TypedDict 이외의 타입과 Unpack을 사용하는 것은 허용되지 않아야 하며, 이러한 경우 타입 체커는 오류를 생성해야 합니다.

Unpack의 변경 사항

Unpackrepr (표현)은 새로운 사용 사례와의 호환성을 위해 단순히 Unpack[T]로 변경되어야 합니다.

의도된 사용법 (Intended Usage)

이 제안의 의도된 사용 사례는 동기(Motivation) 섹션에서 설명되었습니다. 요약하자면, 보다 정밀한 **kwargs 타입 지정은 처음에는 **kwargs를 사용하기로 결정했지만, 이제는 타입 힌트를 통해 더 엄격한 계약을 사용할 만큼 성숙한 기존 코드베이스에 이점을 가져올 수 있습니다. 또한, **kwargs 사용은 동일한 키워드 인자 집합을 요구하는 여러 함수가 있을 때 코드 중복 및 복사-붙여넣기 양을 줄이는 데 도움이 될 수 있습니다. 마지막으로, 명확한 기본값이 없는 선택적 키워드 인자를 함수가 지원해야 할 때 **kwargs가 유용합니다.

그러나 이 PEP에서 제안된 것처럼 TypedDict를 사용하여 **kwargs를 타입 지정하는 것보다 더 나은 도구가 있는 경우도 있습니다. 예를 들어, 모든 키워드 인자가 필수이거나 기본값을 가질 때 새로운 코드를 작성한다면, **kwargsTypedDict를 사용하는 것보다 모든 것을 명시적으로 작성하는 것이 더 좋습니다.

def foo(name: str, year: int): ... # 권장되는 방식.
def foo(**kwargs: Unpack[Movie]): ...

유사하게, 스텁(stub) 파일을 통해 서드파티 라이브러리를 타입 힌트할 때는 함수 시그니처를 명시적으로 선언하는 것이 더 좋습니다. 이는 기본 인자가 있는 함수를 타입 지정하는 유일한 방법입니다.

IDE 및 문서 페이지의 이점을 위해, 공개 API의 일부인 함수는 가능할 때마다 명시적인 키워드 매개변수를 선호해야 합니다.

참고 구현 (Reference Implementation)

mypy 타입 체커는 이미 Unpack을 사용한 보다 정밀한 **kwargs 타입 지정을 지원합니다. Pyright 타입 체커 또한 이 기능에 대한 잠정적인 지원을 제공합니다.

거부된 아이디어 (Rejected Ideas)

TypedDict 유니온 (TypedDict unions)

TypedDict 유니온으로 **kwargs를 타입 지정하는 것을 지원하는 것은 이 PEP 구현의 복잡성을 크게 증가시키며, 이를 정당화할 만한 설득력 있는 사용 사례가 없어 거부되었습니다. 대신, TypedDict 유니온을 기대하는 함수는 overload를 사용하여 구현할 수 있습니다.

class Book(TypedDict):
    genre: str
    pages: int
TypedDictUnion = Movie | Book

def foo(**kwargs: Unpack[TypedDictUnion]) -> None: ... # WRONG! TypedDict 유니온을 **kwargs 타입 지정에 사용하는 것은 지원되지 않습니다.

# 대신 다음과 같이 overload를 사용합니다.
from typing import overload

@overload
def foo(**kwargs: Unpack[Movie]): ...
@overload
def foo(**kwargs: Unpack[Book]): ...

**kwargs 어노테이션의 의미 변경

**kwargs 어노테이션의 의미를 변경하여 어노테이션이 개별 요소가 아닌 전체 **kwargs 딕셔너리에 적용되도록 하는 아이디어가 논의되었으나, 마이그레이션 경로가 불분명하고 기존 생태계에 잘 확립된 의미를 변경하는 비용이 크다고 판단되어 거부되었습니다.

새로운 구문 도입

이전 버전의 PEP에서는 **Movie와 같은 이중 별표 구문을 도입하여 보다 정밀한 **kwargs 타입 지정을 지원하는 아이디어가 있었습니다. 이는 문법 변경 및 새로운 dunder 추가를 필요로 하여 PEP의 범위를 크게 확장시켰으며, 새로운 구문 도입의 정당성이 충분히 강하지 않아 PEP의 진행을 막는 요인이 되었습니다. 따라서 이 아이디어는 폐기되었습니다.

이 문서는 퍼블릭 도메인 또는 CC0-1.0-Universal 라이선스 중 더 관대한 조건으로 배포됩니다.


중요: 이 PEP는 역사적인 문서입니다. 최신 사양 및 문서는 Unpack for keyword arguments를 참조하십시오. 정식 타입 지정 사양은 typing specs site에서 유지 관리됩니다. 런타임 타입 동작은 CPython 문서에 설명되어 있습니다.

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

Comments