PEP 724 – 더 엄격한 타입 가드
- Author:
- Rich Chiodo <rchiodo at microsoft.com>, Eric Traut <erictr at microsoft.com>, Erik De Bonte <erikd at microsoft.com>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Withdrawn
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 28-Jul-2023
- Python-Version:
- 3.13
- Post-History:
- 30-Dec-2021, 19-Sep-2023
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
상태
이 PEP는 철회되었습니다. 타이핑 위원회(Typing Council)가 이 제안에 대해 합의에 이르지 못하여, 저자들은 제안을 철회하기로 결정했습니다.
초록
PEP 647은 사용자 정의 타입 가드 함수라는 개념을 도입했습니다. 이 함수는 첫 번째 매개변수에 전달된 표현식의 타입이 반환 TypeGuard 타입과 일치하면 True를 반환합니다. 예를 들어 반환 타입이 TypeGuard[str]인 함수는 첫 번째 입력 매개변수에 전달된 표현식의 타입이 str일 때, 그리고 오직 그때에만 True를 반환한다고 간주됩니다. 이를 통해 타입 검사기는 사용자 정의 타입 가드 함수가 True를 반환할 때 타입을 좁힐 수 있습니다.
이 PEP는 PEP 647에서 도입된 TypeGuard 메커니즘을 개선합니다. 이 PEP는 사용자 정의 타입 가드 함수가 False를 반환할 때에도 타입 검사기가 타입을 좁힐 수 있게 합니다. 또한 타입 가드 함수가 True를 반환할 때 특정 상황에서 타입 검사기가 추가적인(더 정밀한) 타입 좁히기를 적용할 수 있게 합니다.
동기
사용자 정의 타입 가드 함수를 사용하면 표현식이 타입 가드 함수에 인자로 전달될 때 타입 검사기가 그 표현식의 타입을 좁힐 수 있습니다. PEP 647에서 도입된 TypeGuard 메커니즘은 유연하지만, 이 유연성 때문에 몇 가지 제약이 생기며 개발자들은 일부 용도에서 이 제약을 불편하게 여겨 왔습니다.
제약 1: 타입 가드 함수가 False를 반환하는 경우에는 타입 검사기가 타입을 좁히는 것이 허용되지 않습니다. 즉, 부정(“else”) 절에서는 타입이 좁혀지지 않습니다.
제약 2: 타입 가드 함수가 True를 반환하면, 좁히기 이전 타입에 대한 정보를 바탕으로 추가적인 좁히기를 적용할 수 있는지와 관계없이 타입 검사기는 TypeGuard 반환 타입을 사용해야 합니다.
다음 코드 예제는 이 두 가지 제약을 모두 보여 줍니다.
def is_iterable(val: object) -> TypeGuard[Iterable[Any]]:
return isinstance(val, Iterable)
def func(val: int | list[int]):
if is_iterable(val):
# The type is narrowed to 'Iterable[Any]' as dictated by
# the TypeGuard return type
reveal_type(val) # Iterable[Any]
else:
# The type is not narrowed in the "False" case
reveal_type(val) # int | list[int]
# If "isinstance" is used in place of the user-defined type guard
# function, the results differ because type checkers apply additional
# logic for "isinstance"
if isinstance(val, Iterable):
# Type is narrowed to "list[int]" because this is
# a narrower (more precise) type than "Iterable[Any]"
reveal_type(val) # list[int]
else:
# Type is narrowed to "int" because the logic eliminates
# "list[int]" from the original union
reveal_type(val) # int
PEP 647은 반환 TypeGuard 타입이 입력 타입의 서브타입이 아닌 사용 사례를 지원하기 위해 이러한 제약을 두었습니다. 예시는 PEP 647을 참고하십시오.
근거
더 엄격한 TypeGuard가 있었다면 해결될 수 있었을 이슈가 여럿 있습니다:
명세
사용자 정의 타입 가드 함수를 사용할 때는 다섯 가지 타입이 관여합니다:
- I =
TypeGuard입력 타입 - R =
TypeGuard반환 타입 - A = 타입 가드 함수에 전달된 인자의 타입(좁혀지기 전)
- NP = 좁혀진 타입(긍정)
- NN = 좁혀진 타입(부정)
def guard(x: I) -> TypeGuard[R]: ...
def func1(val: A):
if guard(val):
reveal_type(val) # NP
else:
reveal_type(val) # NN
이 PEP는 위에서 논의한 한계를 해결하기 위해 PEP 647을 일부 수정할 것을 제안합니다. 이러한 한계는 특정 조건이 충족될 때에만 안전하게 제거할 수 있습니다. 특히 사용자 정의 타입 가드 함수의 출력 타입 R이 첫 번째 입력 매개변수의 타입(I)과 일관될 때 [1], 타입 검사기는 더 엄격한 타입 가드 의미 체계를 적용해야 합니다.
# Stricter type guard semantics are used in this case because # "Kangaroo | Koala" is consistent with "Animal" def is_marsupial(val: Animal) -> TypeGuard[Kangaroo | Koala]: return isinstance(val, Kangaroo | Koala) # Stricter type guard semantics are not used in this case because # "list[T]"" is not consistent with "list[T | None]" def has_no_nones(val: list[T | None]) -> TypeGuard[list[T]]: return None not in val
더 엄격한 타입 가드 의미 체계가 적용되면, 사용자 정의 타입 가드 함수의 적용 방식이 두 가지 측면에서 달라집니다.
- 부정(“else”) 경우에 타입 좁히기가 적용됩니다.
def is_str(val: str | int) -> TypeGuard[str]:
return isinstance(val, str)
def func(val: str | int):
if not is_str(val):
reveal_type(val) # int
- 해당하는 경우, 긍정 “if” 경우에 추가적인 타입 좁히기가 적용됩니다.
def is_cardinal_direction(val: str) -> TypeGuard[Literal["N", "S", "E", "W"]]:
return val in ("N", "S", "E", "W")
def func(direction: Literal["NW", "E"]):
if is_cardinal_direction(direction):
reveal_type(direction) # "Literal[E]"
else:
reveal_type(direction) # "Literal[NW]"
타입 좁히기에 대한 타입 이론적 규칙은 다음 표에 명시되어 있습니다.
| 엄격하지 않은 타입 가드 | 엄격한 타입 가드 | |
|---|---|---|
| 적용 조건 | R이 I와 일관되지 않음 | R이 I와 일관됨 |
| NP는 .. | R | A∧R |
| NN은 .. | A | A∧¬R |
실제로는 엄격한 타입 가드에 대한 이론적 타입을 Python 타입 시스템에서 정확하게 표현할 수 없습니다. 타입 검사기는 이러한 타입의 실용적인 근사치를 대신 사용해야 합니다. 경험칙으로, 타입 검사기는 “isinstance”를 처리할 때와 동일한 타입 좁히기 로직을 사용하고, 그 처리와 일관된 결과를 얻어야 합니다. 이 지침은 향후 타입 시스템이 확장될 경우 변경과 개선의 여지를 남겨 둡니다.
추가 예제
Any는 다른 모든 타입과 일관되며 [1], 이는 더 엄격한 의미 체계를 적용할 수 있음을 뜻합니다.
# Stricter type guard semantics are used in this case because
# "str" is consistent with "Any"
def is_str(x: Any) -> TypeGuard[str]:
return isinstance(x, str)
def test(x: float | str):
if is_str(x):
reveal_type(x) # str
else:
reveal_type(x) # float
하위 호환성
이 PEP는 TypeGuard의 기존 동작을 변경할 것을 제안합니다. 이는 런타임에는 영향을 주지 않지만, 타입 검사기가 평가하는 타입은 달라집니다.
def is_int(val: int | str) -> TypeGuard[int]:
return isinstance(val, int)
def func(val: int | str):
if is_int(val):
reveal_type(val) # "int"
else:
reveal_type(val) # Previously "int | str", now "str"
이 동작 변경으로 인해 타입 검사기가 평가하는 타입이 달라집니다. 따라서 새로운 타입 오류가 발생하거나 기존 타입 오류가 가려질 수 있습니다.
타입 검사기는 좁히기(narrowing) 로직을 개선하거나 그 로직의 기존 버그를 수정하는 경우가 많으므로, 정적 타이핑 사용자는 이러한 종류의 동작 변경에 익숙할 것입니다.
또한 타입이 지정된 기존 Python 코드가 TypeGuard의 현재 동작에 의존할 가능성은 낮다고 가정합니다. 이 가정을 검증하기 위해 제안된 변경 사항을 pyright에 구현하고, mypy primer를 사용하여 타입이 지정된 약 25개의 코드베이스에서 이 수정된 버전을 실행해 출력에 차이가 있는지 확인했습니다. 예상대로 이 동작 변경의 영향은 미미했습니다. 유일하게 주목할 만한 변화는 일부 # type: ignore 주석이 더 이상 필요하지 않게 되었다는 점이며, 이는 해당 코드베이스들이 이미 TypeGuard의 기존 제약을 우회하고 있었음을 나타냅니다.
호환성을 깨뜨리는 변경
사용자 정의 타입 가드 함수가 이전 동작에 의존할 수 있습니다. 그러한 타입 가드 함수는 새로운 동작에서 제대로 작동하지 않을 수 있습니다.
def is_positive_int(val: int | str) -> TypeGuard[int]:
return isinstance(val, int) and val > 0
def func(val: int | str):
if is_positive_int(val):
reveal_type(val) # "int"
else:
# With the older behavior, the type of "val" is evaluated as
# "int | str"; with the new behavior, the type is narrowed to
# "str", which is perhaps not what was intended.
reveal_type(val)
실제 코드에 그러한 사용자 정의 타입 가드가 존재할 가능성은 낮다고 생각합니다. mypy primer 결과에서도 그러한 사례는 발견되지 않았습니다.
교육 방법
TypeGuard에 익숙하지 않은 사용자는 이 PEP에서 설명하는 동작을 기대할 가능성이 높으므로, TypeGuard를 가르치고 설명하기가 더 쉬워집니다.
참조 구현
이 아이디어의 참조 구현이 pyright에 있습니다.
수정된 동작을 활성화하려면 구성 플래그 enableExperimentalFeatures를 true로 설정해야 합니다. 다음과 같이 주석을 추가하여 파일 단위로 설정할 수 있습니다:
# pyright: enableExperimentalFeatures=true
기각된 아이디어
StrictTypeGuard
새로운 StrictTypeGuard 구성 요소가 제안되었습니다. 이 대안 형식은 TypeGuard와 비슷하지만 더 엄격한 타입 가드 의미론을 적용합니다. 또한 반환 타입이 입력 타입과 일관되도록(consistent) [1] 강제합니다. 자세한 내용은 다음 스레드를 참조하십시오: StrictTypeGuard 제안
이 아이디어는 대부분의 경우 필요하지 않으며 불필요한 복잡성을 더하기 때문에 거부되었습니다. 새로운 특수 형식을 도입해야 하고, 개발자들이 두 형식 간의 미묘한 차이를 배워야 합니다.
두 번째 출력 타입을 가진 TypeGuard
TypeGuard가 부정(“else”) 경우에 좁히기에 사용할 타입을 나타내는 두 번째 선택적 타입 인자를 지원하도록 하자는 또 다른 아이디어가 제안되었습니다.
def is_int(val: int | str) -> TypeGuard[int, str]:
return isinstance(val, int)
이 아이디어는 여기에서 제안되었습니다.
너무 복잡하다고 여겨졌고 TypeGuard의 두 가지 주요 한계 중 하나만 해결했기 때문에 거부되었습니다. 전체 논의는 이 스레드를 참조하십시오.
각주
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.