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
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 589는 모든 키가 필수인 TypedDict를 선언하는 표기법과 모든 키가 누락 가능한 TypedDict를 정의하는 표기법을 정의하지만, 일부 키는 필수로, 나머지 키는 누락 가능으로 선언하는 메커니즘은 제공하지 않습니다. 이 PEP는 두 가지 새로운 표기법을 도입합니다. 하나는 TypedDict의 개별 항목에 사용하여 해당 항목을 필수로 표시할 수 있는 Required[]이고, 다른 하나는 개별 항목에 사용하여 해당 항목을 누락 가능으로 표시할 수 있는 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]
TypedDict의 항목이 아닌 위치에서 Required[] 또는 NotRequired[]를 사용하는 것은 오류입니다. 타입 검사기는 이 제한을 강제해야 합니다.
중복되는 항목에도 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를 정의하려면, (total 키워드 없이) 평소처럼 단일 TypedDict를 정의하고 누락될 수 있는 소수의 키를 NotRequired[]로 표시하십시오.
대부분의 키가 누락될 수 있고 소수의 키만 필수인 TypedDict를 정의하려면, total=False TypedDict를 정의하고 필수인 소수의 키를 Required[]로 표시하십시오.
일부 항목이 일반 값 외에 None도 허용한다면, 같은 TypedDict 정의 안에서 Required[] 또는 NotRequired[]를 Optional[]과 함께 사용하는 일을 피하기 위해, 그러한 항목 값을 표시할 때 Optional[TYPE]보다 TYPE|None 표기법을 사용하는 것을 권장합니다:
좋은 예:
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]
연산자로 필수 키 또는 누락 가능 키 표시
단항 +를 필수 키를 표시하는 약식으로, 단항 -를 누락 가능 키를 표시하는 약식으로, 또는 단항 ~를 일반과 반대되는 totality를 갖는 키를 표시하는 약식으로 사용할 수 있습니다:
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
이러한 연산자는 문법을 수정하지 않고도 __pos__, __neg__, __invert__ 특수 메서드를 통해 type에 구현할 수 있습니다.
약식 표기법을 도입하기 전에 긴 형식의 표기법(즉, Required[]와 NotRequired[])을 먼저 도입하는 것이 신중하다고 결정되었습니다. 향후 PEP에서 이 약식 표기법이나 다른 약식 표기법 선택지의 도입을 재검토할 수 있습니다.
이 축약 표기법의 도입을 재검토할 때에는 +, -, ~가 Python 타이핑 세계에서 이미 각각 공변(covariant), 반공변(contravariant), 불변 조건(invariant)이라는 기존 의미를 가지고 있다는 점에 유의하십시오:
>>> 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
유니온이 값에 적용되는 방식과의 불일치
그러나 Union[..., Missing]과 동등한 ...|Missing을 이렇게 사용하는 것은 유니온이 일반적으로 의미하는 바와 잘 맞지 않습니다: Union[...]은 항상 존재하는 값의 타입을 나타냅니다. 반면에 누락 여부나 non-totality는 변수의 속성입니다. 변수의 속성을 표시하는 현재의 선례로는 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를 처음 접하는 많은 사람들은 이미 그에 상응하는 상수인 null과 undefined를 구별하는 데 어려움을 겪고 있습니다.
Optional을 Nullable로 대체합니다. Optional의 용도를 “선택적 항목”을 의미하도록 변경합니다.
Optional[]은 너무 널리 쓰이고 있어 폐기할 수 없습니다. 다만 시간이 지나면서 PEP 604에 명시된 T|None 표기법이 선호되어 사용이 줄어들 수도 있습니다.
특정 맥락에서 Optional이 “nullable” 대신 “선택적 항목”을 의미하도록 변경
TypedDict 정의에 특수 플래그를 두어, TypedDict 내부의 Optional을 일반적인 의미인 “nullable”이 아니라 “선택적 항목”이라는 의미로 해석하도록 바꾸는 방안을 생각해 봅시다:
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.