PEP 692 – TypedDict를 사용한 더 정밀한 **kwargs 타입 지정
- Author:
- Franek Magiera <framagie at gmail.com>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 29-May-2022
- Python-Version:
- 3.12
- Post-History:
- 29-May-2022, 12-Jul-2022, 12-Jul-2022
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
현재 **kwargs는 이를 통해 지정되는 모든 키워드 인자가 같은 타입인 경우에 한해서만 타입 힌트를 지정할 수 있습니다. 하지만 이러한 동작은 매우 제한적일 수 있습니다. 따라서 이 PEP에서는 더 정밀한 **kwargs 타입 지정을 가능하게 하는 새로운 방법을 제안합니다. 새로운 접근 방식은 서로 다른 타입의 키워드 인자로 이루어진 **kwargs의 타입을 지정하기 위해 TypedDict를 사용하는 것을 중심으로 합니다.
동기
현재 **kwargs에 T 타입을 어노테이션하는 것은 실제로는 kwargs의 타입이 dict[str, T]라는 뜻입니다. 예를 들어 다음 코드는:
def foo(**kwargs: str) -> None: ...
foo의 모든 키워드 인자가 문자열이라는 뜻입니다(즉, kwargs는 dict[str, str] 타입입니다). 이러한 동작 때문에 **kwargs에 타입 어노테이션을 지정할 수 있는 경우가 모든 인자가 같은 타입인 경우로만 제한됩니다. 하지만 **kwargs로 전달되는 키워드 인자가 키워드 이름에 따라 서로 다른 타입을 갖는 경우가 흔합니다. 그러한 경우에는 **kwargs에 타입 어노테이션을 지정할 수 없습니다. 이는 적절한 타입 어노테이션을 도입하기 위해 코드를 리팩터링하는 일이 그만한 노력을 들일 가치가 없다고 여겨질 수 있는 기존 코드베이스에서 특히 문제가 됩니다. 결과적으로 프로젝트는 타입 힌트가 제공할 수 있는 모든 이점을 누리지 못하게 됩니다.
또한 공개 API의 일부인 최상위 함수가 모두 같은 키워드 인자를 기대하는 여러 헬퍼 함수를 호출하는 경우, **kwargs를 사용하여 필요한 코드의 양을 줄일 수 있습니다. 안타깝게도 그 헬퍼 함수들이 **kwargs를 사용한다면, 기대하는 키워드 인자가 서로 다른 타입일 경우 이들에 적절한 타입 힌트를 지정할 방법이 없습니다. 게다가 키워드 인자가 같은 타입이더라도, 함수가 실제로 기대하는 키워드 이름으로 호출되고 있는지 검사할 방법이 없습니다.
Intended Usage 절에서 설명하듯이, **kwargs를 사용하는 것이 항상 작업에 가장 적합한 도구인 것은 아닙니다. 그럼에도 불구하고 이는 여전히 널리 사용되는 패턴입니다. 그 결과 더 정밀한 **kwargs 타입 지정을 지원하는 것에 대해 많은 논의가 있었으며, 이는 Python 커뮤니티의 상당 부분에 가치 있는 기능이 되었습니다. 이는 이 제안의 혜택을 받을 수 있는 실제 사례를 많이 담고 있는 mypy GitHub issue 4441에서 가장 잘 드러납니다.
**kwargs가 편리하게 쓰이는 또 하나의 언급할 만한 사용 사례는 함수가 기본값이 없는 선택적 키워드 전용 인자를 받아들여야 하는 경우입니다. 이러한 패턴은 None처럼 사용자 입력이 없음을 나타내는 기본값으로 흔히 쓰이는 값을 사용자가 전달할 수 있고, 그 값이 기본 동작이 아닌 유효한 동작으로 이어져야 할 때 필요해질 수 있습니다. 예를 들어 널리 쓰이는 httpx 라이브러리에서 이 문제가 제기된 적이 있습니다.
근거
PEP 589는 문자열 키와 서로 다른 타입일 수 있는 값으로 구성된 딕셔너리 타입을 지원하는 TypedDict 타입 생성자를 도입했습니다. **kwargs처럼 이중 별표로 시작하는 형식 매개변수로 표현되는 함수의 키워드 인자는 딕셔너리로 전달됩니다. 또한 이러한 함수는 키워드 인자를 제공하기 위해 언패킹된 딕셔너리를 사용하여 호출되는 경우가 많습니다. 이 때문에 TypedDict는 **kwargs에 더 정밀하게 타입을 지정하는 데 사용하기에 완벽한 후보입니다. 게다가 TypedDict를 사용하면 정적 타입 분석 중에 키워드 이름까지 고려할 수 있습니다. 그러나 앞서 언급했듯이 **kwargs의 타입을 TypedDict로 지정하면 **kwargs로 지정되는 각 키워드 인자가 그 자체로 TypedDict라는 의미가 됩니다. 예를 들어:
class Movie(TypedDict):
name: str
year: int
def foo(**kwargs: Movie) -> None: ...
위 코드는 foo의 각 키워드 인자가 문자열 타입 값을 갖는 name 키와 정수 타입 값을 갖는 year 키를 가진 Movie 딕셔너리 그 자체라는 의미입니다. 따라서 현재 동작을 깨뜨리지 않으면서 kwargs의 타입을 TypedDict로 지정하는 것을 지원하려면 새로운 구문을 도입해야 합니다.
이 사용 사례를 지원하기 위해 처음에 PEP 646에서 도입된 Unpack을 재사용할 것을 제안합니다. 그렇게 하는 데에는 몇 가지 이유가 있습니다.
- 우리의 의도는 제공된
TypedDict에서 키워드 인자를 “언패킹”하는 것이므로, 그 이름이**kwargs에 타입을 지정하는 사용 사례에 매우 적합하고 직관적입니다. *args에 타입을 지정하는 현재 방식이**kwargs로 확장되며, 둘은 비슷하게 동작해야 합니다.- 새로운 특수 형식을 도입할 필요가 없습니다.
- 이 PEP에서 설명하는 목적으로
Unpack을 사용하는 것은 PEP 646에서 설명하는 사용 사례를 방해하지 않습니다.
명세
Unpack을 사용하여 **kwargs에 어노테이션을 다는 새로운 방법을 도입합니다. 앞의 예제를 이어서:
def foo(**kwargs: Unpack[Movie]) -> None: ...
위 코드는 **kwargs가 Movie로 지정된 두 개의 키워드 인자(즉, 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이 사용되면 타입 검사기는 함수 본문 안의 kwargs를 TypedDict로 취급합니다:
def foo(**kwargs: Unpack[Movie]) -> None:
assert_type(kwargs, Movie) # OK!
새로운 어노테이션을 사용해도 런타임에는 아무런 영향이 없으며, 타입 검사기만 이를 고려해야 합니다. 다음 절에서 언급하는 오류는 모두 타입 검사기 오류를 가리킵니다.
표준 딕셔너리를 사용한 함수 호출
**kwargs가 Unpack으로 어노테이션된 함수에 dict[str, object] 타입의 딕셔너리를 **kwargs 인자로 전달하면 반드시 타입 검사기 오류가 발생해야 합니다. 반면 타입이 지정되지 않은 표준 딕셔너리를 사용하는 함수의 동작은 타입 검사기에 따라 다를 수 있습니다. 예를 들어:
def foo(**kwargs: Unpack[Movie]) -> None: ...
movie: dict[str, object] = {"name": "Life of Brian", "year": 1979}
foo(**movie) # WRONG! Movie is of type 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) # Depends on the type checker.
키워드 충돌
**kwargs에 타입을 지정하는 데 사용되는 TypedDict에는 함수 시그니처에 이미 정의된 키가 포함될 수 있습니다. 중복된 이름이 일반 매개변수라면 타입 검사기는 오류를 보고해야 합니다. 중복된 이름이 위치 전용 매개변수라면 오류가 발생하지 않아야 합니다. 예를 들어:
def foo(name, **kwargs: Unpack[Movie]) -> None: ... # WRONG! "name" will
# always bind to the
# first parameter.
def foo(name, /, **kwargs: Unpack[Movie]) -> None: ... # OK! "name" is a
# positional-only parameter,
# so **kwargs can contain
# a "name" keyword.
필수 키와 비필수 키
기본적으로 TypedDict의 모든 키는 필수입니다. 딕셔너리의 total 매개변수를 False로 설정하면 이 동작을 재정의할 수 있습니다. 또한 PEP 655는 새로운 타입 한정자인 typing.Required와 typing.NotRequired를 도입했으며, 이를 사용하면 특정 키가 필수인지 아닌지를 지정할 수 있습니다:
class Movie(TypedDict):
title: str
year: NotRequired[int]
TypedDict를 사용하여 **kwargs의 타입을 지정할 때는 모든 필수 키와 비필수 키가 각각 필수 및 비필수 함수 키워드 매개변수에 대응해야 합니다. 따라서 호출자가 필수 키를 지원하지 않으면 타입 검사기는 오류를 보고해야 합니다.
할당
**kwargs: Unpack[Movie]로 타입이 지정된 함수와 다른 호출 가능 객체 타입 간의 할당은 둘이 호환될 때에만 타입 검사를 통과해야 합니다. 이는 아래에서 설명하는 시나리오에서 발생할 수 있습니다.
소스와 대상이 모두 **kwargs를 포함하는 경우
대상 함수와 소스 함수가 모두 **kwargs: Unpack[TypedDict] 매개변수를 가지고, 대상 함수의 TypedDict를 소스 함수의 TypedDict에 할당할 수 있으며, 나머지 매개변수가 호환되는 경우입니다:
class Animal(TypedDict):
name: str
class Dog(Animal):
breed: str
def accept_animal(**kwargs: Unpack[Animal]): ...
def accept_dog(**kwargs: Unpack[Dog]): ...
accept_dog = accept_animal # OK! Expression of type Dog can be
# assigned to a variable of type Animal.
accept_animal = accept_dog # WRONG! Expression of type Animal
# cannot be assigned to a variable of type Dog.
소스는 **kwargs를 포함하고 대상은 포함하지 않는 경우
대상 호출 가능 객체는 **kwargs를 포함하지 않고, 소스 호출 가능 객체는 **kwargs: Unpack[TypedDict]를 포함하며, 대상 함수의 키워드 인자를 소스 함수 TypedDict의 대응하는 키에 할당할 수 있는 경우입니다. 또한 필수가 아닌 키는 선택적 함수 인자에 대응해야 하고, 필수 키는 필수 함수 인자에 대응해야 합니다. 이 경우에도 나머지 매개변수는 호환되어야 합니다. 앞의 예제를 이어서 살펴보겠습니다:
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!
TypedDict의 키 및 값과 호환되어야 하는 대상 함수의 매개변수는 반드시 키워드 전용이어야 한다는 점을 짚어 둘 필요가 있습니다:
def dest(dog: Dog, string: str, number: int = ...): ...
dog: Dog = {"name": "Daisy", "breed": "labrador"}
dest(dog, "some string") # OK!
dest = src # Type checker error!
dest(dog, "some string") # The same call fails at
# runtime now because 'src' expects
# keyword arguments.
반대로 대상 호출 가능 객체가 **kwargs: Unpack[TypedDict]를 포함하고 소스 호출 가능 객체가 **kwargs를 포함하지 않는 상황은 허용되지 않아야 합니다. 서브클래스의 인스턴스가 베이스 클래스 타입의 변수에 대입된 다음 대상 호출 가능 객체를 호출할 때 언패킹되면, 추가 키워드 인자가 전달되지 않는다고 확신할 수 없기 때문입니다.:
def dest(**kwargs: Unpack[Animal]): ...
def src(name: str): ...
dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog
dest = src # WRONG!
dest(**animal) # Fails at runtime.
TypedDict 간의 호환성은 구조적 서브타이핑에 기반하므로, 상속이 없어도 비슷한 상황이 발생할 수 있습니다.
소스가 타입이 지정되지 않은 **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는 키워드 이름을 검사하지 않기 때문입니다.
요약하면, 함수 매개변수는 반공변적으로 동작해야 하고 함수 반환 타입은 공변적으로 동작해야 합니다.
함수 내부에서 kwargs를 다른 함수로 전달하기
앞선 내용에서는 베이스 클래스 타입을 가진 변수에 서브클래스 인스턴스를 대입하여 추가 키워드 인자가 전달될 수 있는 문제를 언급했습니다. 다음 예제를 살펴봅시다:
class Animal(TypedDict):
name: str
class Dog(Animal):
breed: str
def takes_name(name: str): ...
dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog
def foo(**kwargs: Unpack[Animal]):
print(kwargs["name"].capitalize())
def bar(**kwargs: Unpack[Animal]):
takes_name(**kwargs)
def baz(animal: Animal):
takes_name(**animal)
def spam(**kwargs: Unpack[Animal]):
baz(kwargs)
foo(**animal) # OK! foo only expects and uses keywords of 'Animal'.
bar(**animal) # WRONG! This will fail at runtime because 'breed' keyword
# will be passed to 'takes_name' as well.
spam(**animal) # WRONG! Again, 'breed' keyword will be eventually passed
# to 'takes_name'.
위 예제에서 foo 호출은 런타임에 아무런 문제도 일으키지 않습니다. foo는 Animal 타입의 kwargs를 기대하지만, 필요한 것만 읽어서 사용하고 추가 값은 완전히 무시하므로 추가 인자를 받더라도 문제가 되지 않습니다.
bar와 spam 호출은 예기치 않은 키워드 인자가 takes_name 함수에 전달되므로 실패합니다.
따라서 언패킹된 TypedDict로 타입 힌트가 지정된 kwargs는 언패킹된 kwargs를 전달받는 함수의 시그니처에도 **kwargs가 있는 경우에만 다른 함수에 전달할 수 있습니다. 그런 경우에는 함수를 호출할 때 추가 키워드가 런타임 오류를 일으키지 않기 때문입니다. 그렇지 않으면 타입 검사기는 오류를 발생시켜야 합니다.
위의 bar 함수와 비슷한 경우에는 원하는 필드를 명시적으로 역참조하고 이를 인자로 사용하여 함수 호출을 수행함으로써 이 문제를 피할 수 있습니다.:
def bar(**kwargs: Unpack[Animal]):
name = kwargs["name"]
takes_name(name)
TypedDict 이외의 타입에 Unpack 사용하기
Rationale 섹션에서 설명한 것처럼, TypedDict는 **kwargs의 타입을 지정하는 데 가장 자연스러운 후보입니다. 따라서 **kwargs의 타입을 지정하는 맥락에서는 TypedDict 이외의 타입에 Unpack을 사용하는 것을 허용해서는 안 되며, 그러한 경우 타입 검사기는 오류를 발생시켜야 합니다.
Unpack의 변경 사항
현재 타이핑 맥락에서 Unpack을 사용하는 것은 별표 문법을 사용하는 것과 서로 바꾸어 쓸 수 있습니다.:
>>> Unpack[Movie]
*<class '__main__.Movie'>
따라서 새로운 사용 사례와 호환되도록 Unpack의 repr은 단순히 Unpack[T]로 변경되어야 합니다.
의도된 사용법
이 제안의 의도된 사용 사례는 Motivation 섹션에 설명되어 있습니다. 요약하자면, 더 정밀한 **kwargs 타입 지정은 처음에 **kwargs를 사용하기로 했지만 이제는 타입 힌트를 통해 더 엄격한 계약을 사용할 만큼 성숙해진 기존 코드베이스에 이점을 가져다줄 수 있습니다. 또한 동일한 키워드 인자 집합을 요구하는 함수가 여러 개 있을 때 **kwargs를 사용하면 코드 중복과 복사-붙여넣기의 양을 줄이는 데 도움이 될 수 있습니다. 마지막으로, **kwargs는 함수가 명확한 기본값이 없는 선택적 키워드 인자를 지원해야 하는 경우에 유용합니다.
그러나 어떤 경우에는 이 PEP에서 제안한 대로 TypedDict를 사용하여 **kwargs의 타입을 지정하는 것보다 더 적합한 도구가 있다는 점을 짚고 넘어가야 합니다. 예를 들어, 새 코드를 작성할 때 모든 키워드 인자가 필수이거나 기본값을 가진다면 **kwargs와 TypedDict를 사용하는 것보다 모든 것을 명시적으로 작성하는 편이 더 낫습니다:
def foo(name: str, year: int): ... # Preferred way.
def foo(**kwargs: Unpack[Movie]): ...
마찬가지로, 스텁을 통해 서드파티 라이브러리에 타입 힌트를 지정할 때도 함수 시그니처를 명시적으로 기술하는 편이 더 낫습니다 - 함수에 기본 인자가 있다면 이것이 그러한 함수의 타입을 지정하는 유일한 방법입니다. 이 경우 TypedDict로 함수에 타입 힌트를 지정하려 할 때 발생할 수 있는 또 다른 문제는 일부 표준 함수 매개변수가 키워드 전용으로 취급될 수 있다는 것입니다:
def foo(name, year): ... # Function in a third party library.
def foo(Unpack[Movie]): ... # Function signature in a stub file.
foo("Life of Brian", 1979) # This would be now failing type
# checking but is fine.
foo(name="Life of Brian", year=1979) # This would be the only way to call
# the function now that passes type
# checking.
따라서 이 경우에도 그러한 함수에는 다음과 같이 명시적으로 타입 힌트를 지정하는 것이 바람직합니다:
def foo(name: str, year: int): ...
또한 IDE와 문서 페이지를 위해, 공개 API에 속하는 함수는 가능하면 언제나 명시적인 키워드 매개변수를 우선 사용해야 합니다.
교육 방법
이 PEP는 typing 모듈 문서에 링크될 수 있습니다. 또한 앞서 언급한 문서에 Unpack 사용법에 관한 새로운 섹션을 추가할 수 있습니다. 비슷한 섹션을 mypy 문서와 typing 문서에도 추가할 수 있습니다.
참조 구현
mypy 타입 검사기는 이미 Unpack을 사용한 더 정밀한 **kwargs 타입 지정을 지원합니다.
Pyright 타입 검사기도 이 기능에 대한 잠정적 지원을 제공합니다.
기각된 아이디어
TypedDict 유니언
타입이 지정된 딕셔너리의 유니언을 만드는 것은 가능합니다. 그러나 타입이 지정된 딕셔너리의 유니언으로 **kwargs의 타입을 지정하는 것을 지원하면 이 PEP의 구현 복잡도가 크게 증가하며, 이러한 지원을 정당화할 만한 설득력 있는 사용 사례도 없는 것으로 보입니다. 따라서 이 PEP의 맥락에서 설명한 것처럼 타입이 지정된 딕셔너리의 유니언을 사용하여 **kwargs의 타입을 지정하면 오류가 발생할 수 있습니다:
class Book(TypedDict):
genre: str
pages: int
TypedDictUnion = Movie | Book
def foo(**kwargs: Unpack[TypedDictUnion]) -> None: ... # WRONG! Unsupported use
# of a union of
# TypedDicts to type
# **kwargs
대신, 여러 TypedDict의 유니온을 받는 함수는 오버로드할 수 있습니다:
@overload
def foo(**kwargs: Unpack[Movie]): ...
@overload
def foo(**kwargs: Unpack[Book]): ...
**kwargs 어노테이션의 의미 변경
이 PEP의 목적을 달성하는 한 가지 방법은 **kwargs 어노테이션의 의미를 바꾸는 것입니다. 이렇게 하면 어노테이션이 개별 요소가 아니라 **kwargs 딕셔너리 전체에 적용됩니다. 일관성을 위해 *args 어노테이션에도 비슷한 변경을 해야 합니다.
이 아이디어는 타이핑 커뮤니티 회의에서 논의되었으며, 이 변경은 비용을 들일 만한 가치가 없다는 데 합의가 이루어졌습니다. 명확한 마이그레이션 경로가 없고, *args와 **kwargs 어노테이션의 현재 의미는 생태계에 이미 확고히 자리 잡았으며, 타입 검사기는 현재 적법한 코드에 대해 새로운 오류를 도입해야 합니다.
새로운 문법 도입
이 PEP의 이전 버전에서는 **kwargs 타이핑을 더 정밀하게 지원하기 위해 이중 별표 문법을 사용하는 방안이 제안되었습니다. 이 문법을 사용하면 함수를 다음과 같이 어노테이션할 수 있습니다:
def foo(**kwargs: **Movie): ...
이는 다음과 같은 의미를 가집니다:
def foo(**kwargs: Unpack[Movie]): ...
이 방안은 문법 변경과 Unpack 특수 형식을 위한 새로운 던더 추가를 필요로 하므로 PEP의 범위를 크게 넓혔습니다. 동시에 새로운 문법을 도입할 근거가 충분히 강하지 않았고, 이는 PEP 전체의 걸림돌이 되었습니다. 따라서 이 PEP의 일부로 새로운 문법을 도입하려던 아이디어는 포기하기로 결정했으며, 별도의 PEP에서 다시 제안할 수도 있습니다.
참고 문헌
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.