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

Python 개선 제안 한국어 번역

PEP 692 – 더 정확한 **kwargs 타이핑에 TypedDict 사용하기

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

Table of Contents

번역·라이선스 안내

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

Important

This PEP is a historical document: see Unpack for keyword arguments for up-to-date specs and documentation. Canonical typing specs are maintained at the typing specs site; runtime typing behaviour is described in the CPython documentation.

×

See the typing specification update process for how to propose changes to the typing spec.

초록

현재 **kwargs는 이를 통해 지정되는 모든 키워드 인자의 타입이 동일한 경우에 한해 타입 힌트를 지정할 수 있습니다. 그러나 이러한 동작은 매우 제한적일 수 있습니다. 따라서 이 PEP에서는 더욱 정확한 **kwargs타이핑을 가능하게 하는 새로운 방법을 제안합니다. 새로운 접근 방식은 서로 다른 타입의 키워드 인자로 구성된 **kwargs의 타입을 지정하기 위해 TypedDict를 사용하는 것을 중심으로 합니다.

동기

현재 **kwargs에 타입 T를 어노테이션한다는 것은 kwargs의 타입이 실제로 dict[str, T]임을 의미합니다. 예를 들면 다음과 같습니다.:

def foo(**kwargs: str) -> None: ...

이는 foo의 모든 키워드 인자가 문자열임을 의미합니다(즉, kwargsdict[str, str]타입입니다). 이러한 동작은 **kwargs에 타입 어노테이션을 지정할 수 있는 경우를 모든 인자가 동일한 타입인 경우로만 제한합니다. 그러나 **kwargs로 전달되는 키워드 인자의 타입이 키워드 이름에 따라 달라지는 경우가 많습니다. 이러한 경우에는 **kwargs에 타입 어노테이션을 지정할 수 없습니다. 이는 적절한 타입 어노테이션을 도입하기 위해 코드를 리팩터링해야 할 필요성이 노력할 가치가 없다고 여겨질 수 있는 기존 코드베이스에서 특히 문제가 됩니다. 이는 결과적으로 타입 힌트가 제공할 수 있는 모든 이점을 프로젝트가 얻지 못하게 합니다.

또한 공개 API의 일부인 최상위 함수가 동일한 키워드 인자를 기대하는 여러 도우미 함수를 호출하는 경우, **kwargs를 사용하면 필요한 코드의 양을 줄일 수 있습니다. 안타깝게도 이러한 도우미 함수가 **kwargs를 사용한다면, 해당 함수가 기대하는 키워드 인자의 타입이 서로 다른 경우 적절한 타입 힌트를 지정할 방법이 없습니다. 게다가 키워드 인자의 타입이 동일하더라도 함수가 실제로 기대하는 키워드 이름으로 호출되고 있는지 확인할 방법이 없습니다.

Intended Usage 섹션에서 설명한 것처럼 **kwargs를 사용하는 것이 항상 해당 작업에 가장 적합한 도구인 것은 아닙니다. 그럼에도 불구하고 이는 여전히 널리 사용되는 패턴입니다. 그 결과 더욱 정확한 **kwargs타이핑 지원을 둘러싼 논의가 많이 이루어졌으며, 이는 Python 커뮤니티의 상당 부분에 가치가 있을 기능이 되었습니다. 이는 mypy GitHub issue 4441 에 잘 나타나 있으며, 여기에는 이 제안의 혜택을 받을 수 있는 실제 사례가 많이 포함되어 있습니다.

**kwargs가 편리한 또 다른 사용 사례로 언급할 만한 것은 함수가 기본값이 없는 선택적 키워드 전용 인자를 수용해야 하는 경우입니다. None과 같이 사용자 입력이 없음을 나타내기 위한 기본값으로 일반적으로 사용되는 값을 사용자가 전달할 수 있고, 그 결과 유효한 비기본 동작이 이루어져야 할 때 이러한 패턴이 필요할 수 있습니다. 예를 들어 인기 있는 httpx라이브러리에서 came up 문제가 발생했습니다.

근거

PEP 589에서는 문자열 키와 잠재적으로 서로 다른 타입의 값으로 구성된 딕셔너리 타입을 지원하는 TypedDict 타입 생성자를 도입했습니다. 두 별표로 시작하는 형식 매개변수로 표현되는 함수의 키워드 인자는 **kwargs와 같이 딕셔너리로 전달됩니다. 또한 이러한 함수는 키워드 인자를 제공하기 위해 언패킹된 딕셔너리를 사용하여 호출되는 경우가 많습니다. 따라서 TypedDict는 보다 정확한 **kwargs 타입 지정을 위해 사용하기에 매우 적합합니다. 또한 TypedDict를 사용하면 정적 타입 분석 중에 키워드 이름을 고려할 수 있습니다. 그러나 TypedDict**kwargs의 타입을 지정한다는 것은 앞서 언급했듯이 **kwargs로 지정되는 각 키워드 인자 자체가 TypedDict라는 의미입니다. 예를 들어 다음과 같습니다.:

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

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

이는 foo의 각 키워드 인자가 문자열 타입 값을 갖는 name 키와 정수 타입 값을 갖는 year 키를 포함하는 Movie 딕셔너리 자체라는 의미입니다. 따라서 현재 동작을 깨뜨리지 않고 TypedDictkwargs타입을 지정할 수 있도록 하려면 새로운 구문을 도입해야 합니다.

이 사용 사례를 지원하기 위해 처음에는 PEP 646에서 도입된 Unpack을 재사용할 것을 제안합니다. 그렇게 하는 데에는 몇 가지 이유가 있습니다.

  • 제공된 TypedDict에서 키워드 인자를 “unpack”하려는 의도에 비추어 볼 때, 그 이름은 **kwargs 타입 지정 사용 사례에 매우 적합하고 직관적입니다.
  • *args의 현재 타입 지정 방식이 **kwargs로 확장되며, 둘은 유사하게 동작해야 합니다.
  • 새로운 특수 형식을 도입할 필요가 없습니다.
  • 이 PEP에서 설명하는 목적을 위한 Unpack의 사용은 PEP 646에서 설명하는 사용 사례를 방해하지 않습니다.

사양

Unpack을 사용하여 **kwargs에 어노테이션을 지정하는 새로운 방식을 도입합니다. 앞의 예를 계속 살펴보면:

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를 가진 함수에 **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.Requiredtyping.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를 호출해도 런타임에 문제가 발생하지 않습니다. fooAnimal타입의 kwargs를 예상하지만, 필요한 항목만 읽고 사용하며 추가 값은 완전히 무시하므로 추가 인자를 전달받더라도 문제가 되지 않습니다.

barspam을 호출하면 예상치 못한 키워드 인자가 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'>

따라서 새로운 사용 사례와 호환되도록 Unpackrepr을 단순히 Unpack[T]로 변경해야 합니다.

의도된 사용

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

그러나 일부 경우에는 이 PEP에서 제안하는 방식으로 **kwargs를 타입 지정하기 위해 TypedDict를 사용하는 것보다 더 적합한 도구가 있다는 점을 지적해야 합니다. 예를 들어 새 코드를 작성할 때 모든 키워드 인자가 필수이거나 기본값을 가진다면 **kwargsTypedDict를 사용하는 것보다 모든 항목을 명시적으로 작성하는 편이 낫습니다.:

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 documentationtyping documentation에도 유사한 섹션을 추가할 수 있습니다.

참조 구현

mypy 타입 검사기는 이미 지원 기능을 통해 Unpack을 사용한 더 정밀한 **kwargs 타입 지정을 제공합니다.

Pyright type checkerthis feature 에 대해 provides provisional support 를 제공합니다.

거부된 아이디어

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

대신 TypedDicts의 합집합을 기대하는 함수는 오버로드할 수 있습니다.:

@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]): ...

이로 인해 PEP의 범위가 크게 확대되었습니다. 문법을 변경하고 Unpack 특수 형식을 위한 새로운 던더를 추가해야 하기 때문입니다. 동시에 새로운 구문을 도입할 정당화 근거가 충분히 강하지 않았으며, 전체 PEP의 장애물이 되었습니다. 따라서 이 PEP의 일부로 새로운 구문을 도입한다는 아이디어를 포기하기로 결정했으며, 별도의 PEP에서 다시 제안할 수도 있습니다.

참고 문헌