PEP 655 – 개별 TypedDict 항목을 필수 또는 누락 가능으로 표시하기
- Author:
- David Foster <david at dafoster.net>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Typing-SIG thread
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 30-Jan-2021
- Python-Version:
- 3.11
- Post-History:
- 31-Jan-2021, 11-Feb-2021, 20-Feb-2021, 26-Feb-2021, 17-Jan-2022, 28-Jan-2022
- Resolution:
- Python-Dev message
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 589는 모든 키가 필수인 TypedDict를 선언하기 위한 표기법과 모든 키가 누락 가능한 TypedDict를 정의하기 위한 표기법을 정의하지만, 일부 키는 필수로, 나머지 키는 누락 가능으로 선언하는 방법은 제공하지 않습니다. 이 PEP는 두 가지 새로운 표기법을 도입합니다. Required[]는 TypedDict의 개별 항목에 사용하여 해당 항목을 필수로 표시할 수 있으며, NotRequired[]는 개별 항목에 사용하여 해당 항목을 누락 가능으로 표시할 수 있습니다.
이 PEP는 Python 문법을 변경하지 않습니다. TypedDict의 필수 키와 누락 가능 키를 올바르게 사용하는 것은 정적 타입 검사기만 적용하도록 하며, Python 자체가 런타임에 적용할 필요는 없습니다.
동기
일부 키는 필수이고 다른 키는 누락 가능한 TypedDict를 정의하려는 경우는 드물지 않습니다. 현재 이러한 TypedDict를 정의하는 유일한 방법은 total에 한 값을 사용하여 하나의 TypedDict를 선언한 다음, total에 다른 값을 사용하는 또 다른 TypedDict를 상속하는 것입니다.
class _MovieBase(TypedDict): # implicitly total=True
title: str
class Movie(_MovieBase, total=False):
year: int
이를 위해 서로 다른 두 TypedDict 타입을 선언해야 하는 것은 번거롭습니다.
이 PEP는 두 가지 새로운 타입 한정자인 typing.Required와 typing.NotRequired를 도입하여, 필수 키와 누락 가능 키가 혼합된 단 하나의 TypedDict를 정의할 수 있도록 합니다.
class Movie(TypedDict):
title: str
year: NotRequired[int]
또한 이 PEP를 통해 대체 함수형 구문으로 필수 키와 누락 가능 키가 혼합된 TypedDict를 정의할 수 있습니다. 대체 구문은 상속을 지원하지 않으므로 현재는 이것이 전혀 불가능합니다.
Actor = TypedDict('Actor', {
'name': str,
# "in" is a keyword, so the functional syntax is necessary
'in': NotRequired[List[str]],
})
근거
TypeScript와 같은 다른 언어에서 일반적인 방식처럼 누락 가능 키보다 필수 키를 표시하는 것을 우선하는 표기법을 제안하는 것이 이례적이라고 생각할 수도 있습니다.
interface Movie {
title: string;
year?: number; // ? marks potentially-missing keys
}
어려운 점은 누락 가능 키를 표시하는 데 가장 적합한 단어인 Optional[]가 Python에서 완전히 다른 목적으로 이미 사용되고 있다는 점입니다. 즉, 특정 타입 또는 None 중 하나일 수 있는 값을 표시하는 용도로 사용됩니다. 특히 다음과 같이 사용할 수 없습니다.
class Movie(TypedDict):
...
year: Optional[int] # means int|None, not potentially-missing!
누락 가능 키를 표시하기 위해 “optional”의 동의어(예: Missing[])를 사용하려고 하면 Optional[]와 너무 비슷하여 혼동하기 쉽습니다.
따라서 대신 필수 키에 대한 긍정형 표현에 초점을 맞추기로 했으며, 이는 Required[]로 간단히 표기할 수 있습니다.
그럼에도 불구하고 일반 (total=True) TypedDict를 확장하려는 사용자가 잠재적으로 누락될 수 있는 키를 소수만 추가하려는 경우가 일반적이므로, 필수가 아니며 잠재적으로 누락될 수 있는 키를 표시하는 방법이 필요합니다. 따라서 이러한 경우에 NotRequired[] 형식도 허용합니다.
사양
typing.Required 타입 한정자는 TypedDict 정의에서 선언된 변수가 필수 키임을 나타내는 데 사용합니다.
class Movie(TypedDict, total=False):
title: Required[str]
year: int
또한 typing.NotRequired 타입 한정자는 TypedDict 정의에서 선언된 변수가 누락 가능 키임을 나타내는 데 사용합니다.
class Movie(TypedDict): # implicitly total=True
title: str
year: NotRequired[int]
Required[] 또는 NotRequired[]를 TypedDict의 항목이 아닌 위치에서 사용하는 것은 오류입니다. 타입 검사기는 이 제한을 적용해야 합니다.
중복되는 항목에 대해서도 Required[]와 NotRequired[]를 사용하는 것은 유효하며, 원하는 경우 명시성을 높일 수 있습니다.
class Movie(TypedDict):
title: Required[str] # redundant
year: NotRequired[int]
Required[]와 NotRequired[]를 동시에 사용하는 것은 오류입니다.
class Movie(TypedDict):
title: str
year: NotRequired[Required[int]] # ERROR
타입 검사기는 이 제한을 적용해야 합니다. Required[]와 NotRequired[]의 런타임 구현도 이 제한을 적용할 수 있습니다.
TypedDict의 대체 함수형 구문도 Required[]와 NotRequired[]를 지원합니다.
Movie = TypedDict('Movie', {'name': str, 'year': NotRequired[int]})
total=False와의 상호 작용
total=False로 선언된 모든 PEP 589 스타일 TypedDict는 모든 키가 NotRequired[]로 표시된 암시적 total=True 정의를 사용하는 TypedDict와 동등합니다.
그러므로:
class _MovieBase(TypedDict): # implicitly total=True
title: str
class Movie(_MovieBase, total=False):
year: int
다음과 동등합니다:
class _MovieBase(TypedDict):
title: str
class Movie(_MovieBase):
year: NotRequired[int]
Annotated[]와의 상호 작용
Required[] 및 NotRequired[]는 Annotated[]와 함께 사용할 수 있으며, 중첩 순서는 어느 쪽이든 가능합니다:
class Movie(TypedDict):
title: str
year: NotRequired[Annotated[int, ValueRange(-9999, 9999)]] # ok
class Movie(TypedDict):
title: str
year: Annotated[NotRequired[int], ValueRange(-9999, 9999)] # ok
특히 항목의 가장 바깥쪽 어노테이션으로 Annotated[]를 허용하면, 어노테이션을 타입 지정과 무관하게 사용하는 경우와 더 원활하게 상호 운용할 수 있습니다. 이러한 경우에는 항상 Annotated[]를 가장 바깥쪽 어노테이션으로 사용하려 할 수 있습니다. [3]
런타임 동작
get_type_hints()와의 상호 작용
TypedDict에 적용된 typing.get_type_hints(...)는 기본적으로 Required[] 또는 NotRequired[] 타입 한정자를 제거합니다. 이러한 한정자는 타입 어노테이션을 가볍게 검사하는 코드에 불편할 것으로 예상되기 때문입니다.
그러나 typing.get_type_hints(..., include_extras=True)는 타입 어노테이션을 조사하는 고급 코드가 원본 소스의 모든 어노테이션을 보존하려는 경우를 위해 Required[] 및 NotRequired[] 타입 한정자를 그대로 유지합니다:
class Movie(TypedDict):
title: str
year: NotRequired[int]
assert get_type_hints(Movie) == \
{'title': str, 'year': int}
assert get_type_hints(Movie, include_extras=True) == \
{'title': str, 'year': NotRequired[int]}
get_origin() 및 get_args()와의 상호 작용
typing.get_origin() 및 typing.get_args()가 Required[] 및 NotRequired[]를 인식하도록 업데이트됩니다:
assert get_origin(Required[int]) is Required
assert get_args(Required[int]) == (int,)
assert get_origin(NotRequired[int]) is NotRequired
assert get_args(NotRequired[int]) == (int,)
__required_keys__ 및 __optional_keys__와의 상호 작용
Required[]로 표시된 항목은 해당 항목을 포함하는 TypedDict의 __required_keys__에 항상 나타납니다. 마찬가지로 NotRequired[]로 표시된 항목은 __optional_keys__에 항상 나타납니다.
assert Movie.__required_keys__ == frozenset({'title'})
assert Movie.__optional_keys__ == frozenset({'year'})
하위 호환성
이 PEP에서는 하위 호환성을 깨뜨리는 변경을 하지 않습니다.
이 내용을 가르치는 방법
대부분의 키가 필수이고 일부 키가 누락될 수 있는 TypedDict를 정의하려면, 일반적인 방식으로 단일 TypedDict를 정의하고(total 키워드 없이), 누락될 수 있는 소수의 키를 NotRequired[]로 표시하십시오.
대부분의 키가 누락될 수 있고 일부 키만 필수인 TypedDict를 정의하려면, total=False TypedDict를 정의하고 필수인 소수의 키를 Required[]로 표시하십시오.
일부 항목이 일반적인 값에 더해 None도 허용한다면, 해당 항목 값을 표시할 때 Optional[TYPE]보다 TYPE|None 표기법을 우선하는 것이 좋습니다. 그러면 동일한 TypedDict 정의에서 Required[] 또는 NotRequired[]를 Optional[]와 함께 사용하지 않아도 됩니다:
예:
from __future__ import annotations # for Python 3.7-3.9
class Dog(TypedDict):
name: str
owner: NotRequired[str|None]
가능함 (Python 3.5.3-3.6에 필요):
class Dog(TypedDict):
name: str
owner: 'NotRequired[str|None]'
아니요:
class Dog(TypedDict):
name: str
# ick; avoid using both Optional and NotRequired
owner: NotRequired[Optional[str]]
Python <3.11에서의 사용
코드가 Python <3.11을 지원하고 Required[] 또는 NotRequired[]를 사용하려면, typing.TypedDict 대신 typing_extensions.TypedDict를 사용해야 합니다. 후자는 (Not)Required[]를 인식하지 못하기 때문입니다. 특히 결과 TypedDict 타입의 __required_keys__ 및 __optional_keys__는 올바르지 않습니다:
예 (Python 3.11 이상에서만):
from typing import NotRequired, TypedDict
class Dog(TypedDict):
name: str
owner: NotRequired[str|None]
예 (Python <3.11 및 3.11 이상):
from __future__ import annotations # for Python 3.7-3.9
from typing_extensions import NotRequired, TypedDict # for Python <3.11 with (Not)Required
class Dog(TypedDict):
name: str
owner: NotRequired[str|None]
아니요 (Python <3.11 및 3.11 이상):
from typing import TypedDict # oops: should import from typing_extensions instead
from typing_extensions import NotRequired
class Movie(TypedDict):
title: str
year: NotRequired[int]
assert Movie.__required_keys__ == frozenset({'title', 'year'}) # yikes
assert Movie.__optional_keys__ == frozenset() # yikes
참조 구현
mypy 0.930, pyright 1.1.117, 그리고 pyanalyze 0.4.0 타입 검사기는 Required 및 NotRequired를 지원합니다.
런타임 구성 요소의 참조 구현은 typing_extensions 모듈에서 제공됩니다.
반박된 아이디어
TypedDict 항목의 키주변에 사용하는 특수 구문
class MyThing(TypedDict):
opt1?: str # may not exist, but if exists, value is string
opt2: Optional[str] # always exists, but may have None value
이 표기법을 사용하려면 Python 문법을 변경해야 하며, TypedDict 항목을 필수 또는 잠재적으로 누락될 수 있는 항목으로 표시하는 것이 이러한 문법 변경에 필요한 높은 기준을 충족할 것이라고는 여겨지지 않습니다.
class MyThing(TypedDict):
Optional[opt1]: str # may not exist, but if exists, value is string
opt2: Optional[str] # always exists, but may have None value
이 표기법은 Optional[]이 배치된 위치에 따라 서로 다른 의미를 갖게 하므로 일관성이 없고 혼란스럽습니다.
또한 “콜론 앞에 이상한 구문을 넣지 맙시다.” [1]
연산자로 필수 또는 잠재적으로 누락될 수 있는 키 표시하기
단항 +를 필수 키를 표시하는 축약형으로 사용하거나, 단항 -를 잠재적으로 누락될 수 있는 키를 표시하는 축약형으로 사용하거나, 단항 ~를 일반적인 전체성과 반대되는 전체성을 가진 키를 표시하는 축약형으로 사용할 수 있습니다:
class MyThing(TypedDict, total=False):
req1: +int # + means a required key, or Required[]
opt1: str
req2: +float
class MyThing(TypedDict):
req1: int
opt1: -str # - means a potentially-missing key, or NotRequired[]
req2: float
class MyThing(TypedDict):
req1: int
opt1: ~str # ~ means a opposite-of-normal-totality key
req2: float
이러한 연산자는 문법을 수정하지 않고 type의 __pos__, __neg__ 및 __invert__ 특수 메서드를 통해 구현할 수 있습니다.
어떠한 단축형 표기법을 도입하기 전에 긴 형식의 표기법(즉, Required[] 및 NotRequired[])을 도입하는 것이 현명하다고 결정되었습니다. 향후 PEP에서는 이 표기법이나 다른 단축형 표기법 옵션의 도입을 재고할 수 있습니다.
이 단축형 표기법의 도입을 재고할 때 +, - 및 ~이 Python 타이핑 세계에서 이미 공변, 반공변 및 불변이라는 기존 의미를 갖고 있다는 점에 유의하십시오:
>>> from typing import TypeVar
>>> (TypeVar('T', covariant=True), TypeVar('U', contravariant=True), TypeVar('V'))
(+T, -U, ~V)
특수 상수로 값의 부재 표시하기
유니온 멤버로 사용될 때 값의 부재를 나타내는 새로운 타입 수준 상수를 도입할 수 있으며, JavaScript의 undefined 타입과 유사하게 이를 Missing이라고 부를 수 있습니다:
class MyThing(TypedDict):
req1: int
opt1: str|Missing
req2: float
이러한 Missing 상수는 조건부로만 정의되는 변수의 타입과 같은 다른 상황에도 사용할 수 있습니다:
class MyClass:
attr: int|Missing
def __init__(self, set_attr: bool) -> None:
if set_attr:
self.attr = 10
def foo(set_attr: bool) -> None:
if set_attr:
attr = 10
reveal_type(attr) # int|Missing
유니온이 값에 적용되는 방식과의 불일치
그러나 ...|Missing을 사용하는 방식은 Union[..., Missing]과 동등하지만, 유니온의 일반적인 의미와 잘 맞지 않습니다. Union[...]은 항상 존재하는 값의 타입을 설명합니다. 이와 대조적으로 누락 여부 또는 비전체성은 변수의 속성입니다. 변수의 속성을 표시하는 현재의 선례로는 Final[...] 및 ClassVar[...]이 있으며, 이는 Required[...]에 대한 제안과 방향이 일치합니다.
유니온이 세분화되는 방식과의 불일치
더 나아가 Union[..., Missing]을 사용하는 방식은 유니온 값을 분해하는 일반적인 방법과도 맞지 않습니다. 일반적으로 isinstance검사를 사용하여 유니온 타입의 구성 요소를 제거할 수 있습니다:
class Packet:
data: Union[str, bytes]
def send_data(packet: Packet) -> None:
if isinstance(packet.data, str):
reveal_type(packet.data) # str
packet_bytes = packet.data.encode('utf-8')
else:
reveal_type(packet.data) # bytes
packet_bytes = packet.data
socket.send(packet_bytes)
그러나 Union[..., Missing]을 허용한다면 객체 속성에 대해서는 hasattr로 Missing경우를 제거해야 합니다:
class Packet:
data: Union[str, Missing]
def send_data(packet: Packet) -> None:
if hasattr(packet, 'data'):
reveal_type(packet.data) # str
packet_bytes = packet.data.encode('utf-8')
else:
reveal_type(packet.data) # Missing? error?
packet_bytes = b''
socket.send(packet_bytes)
또는 지역 변수에 대해서는 locals()에 대한 검사를 수행해야 합니다:
def send_data(packet_data: Optional[str]) -> None:
packet_bytes: Union[str, Missing]
if packet_data is not None:
packet_bytes = packet.data.encode('utf-8')
if 'packet_bytes' in locals():
reveal_type(packet_bytes) # bytes
socket.send(packet_bytes)
else:
reveal_type(packet_bytes) # Missing? error?
또는 전역 변수에 대해서는 globals()에 대한 검사와 같은 다른 방법으로 검사해야 합니다:
warning: Union[str, Missing]
import sys
if sys.version_info < (3, 6):
warning = 'Your version of Python is unsupported!'
if 'warning' in globals():
reveal_type(warning) # str
print(warning)
else:
reveal_type(warning) # Missing? error?
이상하고 일관성이 없습니다. Missing은 실제로 값이 아니라 정의되지 않음의 부재이며, 이러한 부재는 특별히 처리해야 합니다.
구현하기 어려움
Pyright 타입 검사기 팀의 Eric Traut은 Union[..., Missing]스타일의 표기법을 구현하기가 어려울 것이라고 밝혔습니다. [2]
Python에 두 번째 null과 유사한 값을 도입함
새로운 Missing 타입 수준 상수를 정의하는 것은 런타임에 새로운 Missing 값 수준 상수를 도입하는 것과 매우 유사하여, None에 더해 두 번째 null과 유사한 런타임 값을 만들게 됩니다. Python에 서로 다른 두 개의 null과 유사한 상수(None 및 Missing)가 존재하면 혼란스러울 것입니다. 많은 JavaScript 입문자는 이미 JavaScript의 유사한 상수인 null 및 undefined를 구별하는 데 어려움을 겪습니다.
Optional을 Nullable로 바꾸십시오. Optional의 용도를 “optional item”을 의미하도록 변경하십시오.
Optional[]은 너무 널리 사용되어 폐기하기 어렵지만, 시간이 지나면서 PEP 604에서 지정한 T|None 표기법을 선호하여 사용이 줄어들 수도 있습니다.
특정 컨텍스트에서 Optional이 “널 가능” 대신 “선택적 항목”을 의미하도록 변경합니다.
TypedDict 정의에 특수 플래그를 사용하여, TypedDict 내부의 Optional의 해석이 일반적인 “널 가능”이 아니라 “선택적 항목”을 의미하도록 변경하는 방안을 고려하십시오:
class MyThing(TypedDict, optional_as_missing=True):
req1: int
opt1: Optional[str]
또는:
class MyThing(TypedDict, optional_as_nullable=False):
req1: int
opt1: Optional[str]
이렇게 하면 일부 컨텍스트에서 Optional[]의 의미가 다른 컨텍스트와 달라지고 플래그를 쉽게 간과할 수 있으므로 사용자에게 더 큰 혼란을 초래합니다.
“잠재적으로 누락될 수 있는 항목”의 다양한 동의어
- Omittable – optional과 혼동하기 너무 쉽습니다.
- OptionalItem, OptionalKey – 두 단어로 되어 있어 optional과 혼동하기 너무 쉽습니다.
- MayExist, MissingOk – 두 단어입니다.
- Droppable – 다른 의미를 갖는 Rust의
Drop과 너무 유사합니다. - Potential – 너무 모호합니다.
- Open – 전체 구조에 적용되는 것처럼 들리며 항목에 적용되는 것처럼 들리지 않습니다.
- Excludable
- Checked
참고 자료
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.