PEP 647 – 사용자 정의 타입 가드
- Author:
- Eric Traut <erictr at microsoft.com>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Typing-SIG list
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 07-Oct-2020
- Python-Version:
- 3.10
- Post-History:
- 28-Dec-2020, 09-Apr-2021
- Resolution:
- Python-Dev thread
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 타입 검사기가 런타임 검사에 기반하여 수행하는 조건부 타입 좁히기에 프로그램이 영향을 줄 수 있는 방법을 명시합니다.
동기
정적 타입 검사기는 일반적으로 “타입 좁히기(type narrowing)”라는 기법을 사용하여 프로그램의 코드 흐름 안에서 표현식의 타입을 더 정확하게 결정합니다. 조건부 코드 흐름 문(if 문과 while 문 등)에 기반하여 코드 블록 안에서 타입 좁히기가 적용될 때, 그 조건 표현식을 “타입 가드”라고 부르기도 합니다. Python 타입 검사기는 일반적으로 다양한 형태의 타입 가드 표현식을 지원합니다.
def func(val: Optional[str]):
# "is None" type guard
if val is not None:
# Type of val is narrowed to str
...
else:
# Type of val is narrowed to None
...
def func(val: Optional[str]):
# Truthy type guard
if val:
# Type of val is narrowed to str
...
else:
# Type of val remains Optional[str]
...
def func(val: Union[str, float]):
# "isinstance" type guard
if isinstance(val, str):
# Type of val is narrowed to str
...
else:
# Type of val is narrowed to float
...
def func(val: Literal[1, 2]):
# Comparison type guard
if val == 1:
# Type of val is narrowed to Literal[1]
...
else:
# Type of val is narrowed to Literal[2]
...
정적 정보만으로는 타입 좁히기를 적용할 수 없는 경우가 있습니다. 다음 예를 살펴보십시오:
def is_str_list(val: List[object]) -> bool:
"""Determines whether all objects in the list are strings"""
return all(isinstance(x, str) for x in val)
def func1(val: List[object]):
if is_str_list(val):
print(" ".join(val)) # Error: invalid type
이 코드는 올바르지만, join 메서드에 전달된 val 값이 List[object] 타입으로 인식되기 때문에 타입 검사기는 타입 오류를 보고합니다. 타입 검사기에는 이 시점에서 val의 타입이 List[str]임을 정적으로 검증할 만큼 충분한 정보가 없습니다.
이 PEP는 is_str_list와 같은 함수를 “사용자 정의 타입 가드”로 정의하는 방법을 도입합니다. 이를 통해 코드에서 타입 검사기가 지원하는 타입 가드를 확장할 수 있습니다.
이 새로운 메커니즘을 사용하면 위 예제의 is_str_list 함수를 약간 수정하게 됩니다. 반환 타입이 bool에서 TypeGuard[List[str]]로 바뀝니다. 이는 반환값이 불리언이라는 것뿐만 아니라, 참이 반환되면 함수의 입력이 지정된 타입이었음을 보장합니다.
from typing import TypeGuard
def is_str_list(val: List[object]) -> TypeGuard[List[str]]:
"""Determines whether all objects in the list are strings"""
return all(isinstance(x, str) for x in val)
사용자 정의 타입 가드는 딕셔너리가 TypedDict의 타입 요구 사항을 충족하는지 판별하는 데에도 사용할 수 있습니다.
class Person(TypedDict):
name: str
age: int
def is_person(val: dict) -> "TypeGuard[Person]":
try:
return isinstance(val["name"], str) and isinstance(val["age"], int)
except KeyError:
return False
def print_age(val: dict):
if is_person(val):
print(f"Age: {val['age']}")
else:
print("Not a person!")
명세
TypeGuard 타입
이 PEP는 typing 모듈에서 내보내는 TypeGuard 심볼을 도입합니다. TypeGuard는 하나의 타입 인자를 받는 특수 형식입니다. 이는 사용자 정의 타입 가드 함수의 반환 타입에 어노테이션을 다는 데 사용됩니다. 타입 가드 함수 안의 return 문은 bool 값을 반환해야 하며, 타입 검사기는 모든 반환 경로가 bool을 반환하는지 검증해야 합니다.
그 밖의 모든 면에서 TypeGuard는 bool과 별개의 타입입니다. TypeGuard는 bool의 서브타입이 아닙니다. 따라서 Callable[..., TypeGuard[int]]는 Callable[..., bool]에 할당할 수 없습니다.
매개변수를 하나 이상 받는 함수나 메서드의 반환 타입에 어노테이션을 다는 데 TypeGuard가 사용되면, 타입 검사기는 그 함수나 메서드를 사용자 정의 타입 가드로 취급합니다. TypeGuard에 제공된 타입 인자는 함수가 검증한 타입을 나타냅니다.
사용자 정의 타입 가드는 다음 예와 같이 제네릭 함수일 수 있습니다:
_T = TypeVar("_T")
def is_two_element_tuple(val: Tuple[_T, ...]) -> TypeGuard[Tuple[_T, _T]]:
return len(val) == 2
def func(names: Tuple[str, ...]):
if is_two_element_tuple(names):
reveal_type(names) # Tuple[str, str]
else:
reveal_type(names) # Tuple[str, ...]
타입 검사기는 사용자 정의 타입 가드에 첫 번째 위치 인자로 전달되는 표현식에 타입 좁히기를 적용해야 한다고 가정해야 합니다. 타입 가드 함수가 둘 이상의 인자를 받는 경우, 추가 인자 표현식에는 타입 좁히기가 적용되지 않습니다.
타입 가드 함수가 인스턴스 메서드나 클래스 메서드로 구현된 경우, 첫 번째 위치 인자는 (“self”나 “cls” 다음의) 두 번째 매개변수에 대응합니다.
다음은 둘 이상의 인자를 받는 사용자 정의 타입 가드 함수의 몇 가지 예입니다:
def is_str_list(val: List[object], allow_empty: bool) -> TypeGuard[List[str]]:
if len(val) == 0:
return allow_empty
return all(isinstance(x, str) for x in val)
_T = TypeVar("_T")
def is_set_of(val: Set[Any], type: Type[_T]) -> TypeGuard[Set[_T]]:
return all(isinstance(x, type) for x in val)
사용자 정의 타입 가드 함수의 반환 타입은 보통 첫 번째 인자의 타입보다 엄격하게 “더 좁은” 타입을 가리킵니다(즉, 더 일반적인 타입에 대입할 수 있는 더 구체적인 타입입니다). 하지만 반환 타입이 반드시 엄격하게 더 좁아야 하는 것은 아닙니다. 이 덕분에 List[str]을 List[object]에 대입할 수 없는 위 예와 같은 경우도 허용됩니다.
조건문에 사용자 정의 타입 가드 함수 호출이 포함되어 있고 그 함수가 참을 반환하면, 정적 타입 검사기는 조건부 코드 블록 안에서 더 좁혀지지 않는 한 타입 가드 함수에 첫 번째 위치 인자로 전달된 표현식이 TypeGuard 반환 타입에 지정된 타입을 갖는다고 간주해야 합니다.
일부 내장 타입 가드는 긍정 검사와 부정 검사 모두(if 절과 else 절 모두)에서 좁히기를 제공합니다. 예를 들어, x is None 형태의 표현식에 대한 타입 가드를 생각해 봅시다. x의 타입이 None과 다른 어떤 타입의 유니온이라면, 긍정의 경우에는 None으로, 부정의 경우에는 그 다른 타입으로 좁혀집니다. 사용자 정의 타입 가드는 긍정의 경우(if 절)에만 좁히기를 적용합니다. 부정의 경우에는 타입이 좁혀지지 않습니다.
OneOrTwoStrs = Union[Tuple[str], Tuple[str, str]]
def func(val: OneOrTwoStrs):
if is_two_element_tuple(val):
reveal_type(val) # Tuple[str, str]
...
else:
reveal_type(val) # OneOrTwoStrs
...
if not is_two_element_tuple(val):
reveal_type(val) # OneOrTwoStrs
...
else:
reveal_type(val) # Tuple[str, str]
...
하위 호환성
이 새로운 기능을 사용하지 않는 기존 코드는 영향을 받지 않습니다.
특히, 표준 라이브러리의 typing 라이브러리와 호환되지 않는 방식으로 어노테이션을 사용하는 코드는 TypeGuard를 임포트하지 않기만 하면 됩니다.
참조 구현
Pyright 타입 검사기는 이 PEP에서 설명하는 동작을 지원합니다.
기각된 아이디어
데코레이터 문법
타입 가드를 정의하는 데 데코레이터를 사용하는 방안이 검토되었습니다.
@type_guard(List[str])
def is_str_list(val: List[object]) -> bool: ...
데코레이터 방식은 타입을 런타임에 평가해야 해서 전방 참조를 쓸 수 없으므로 더 열등합니다. 또한 제안된 방식이 더 이해하기 쉽고 구현하기도 더 간단하다고 판단되었습니다.
엄격한 좁히기 강제
엄격한 타입 좁히기를 강제하는 방안(TypeGuard 타입 인자에 지정된 타입이 첫 번째 매개변수에 지정된 타입의 더 좁은 형태이도록 요구하는 것)이 검토되었지만, 이렇게 하면 이 기능의 유용한 사용 사례가 사라집니다. 예를 들어, 위의 is_str_list 예는 불변성 규칙 때문에 List[str]이 List[object]의 서브타입이 아니므로 유효하지 않은 것으로 간주될 것입니다.
검토된 변형 중 하나는 기본적으로 엄격한 좁히기를 요구하되, 타입 가드 함수가 이 요구 사항을 따르지 않음을 나타내는 플래그를 지정할 수 있도록 하는 것이었습니다. 이 방안은 번거롭고 불필요하다고 판단되어 기각되었습니다.
또 다른 검토 사항은 값의 타입과 TypeGuard에 지정된 좁혀진 타입 사이에 어느 정도 겹치는 부분이 있는지 확인하는, 덜 엄격한 검사를 정의하는 것이었습니다. 이 제안의 문제는 유니온, 프로토콜, 타입 변수, 제네릭 등을 고려하면 타입 호환성 규칙이 이미 매우 복잡하다는 점입니다. 이 기능만을 위해 이러한 제약 중 일부를 완화한 규칙의 변형을 정의하려면, 규칙이 달라지는 미묘한 방식과 어떤 구체적인 상황에서 제약이 완화되는지를 모두 명확히 밝혀야 합니다. 이러한 이유로 모든 검사를 생략하기로 결정했습니다.
엄격한 좁히기를 강제하지 않으면 타입 안전성이 깨질 수 있다는 점이 지적되었습니다. 잘못 작성된 타입 가드 함수는 안전하지 않거나 심지어 말이 안 되는 결과를 낼 수 있습니다. 예를 들면 다음과 같습니다:
def f(value: int) -> TypeGuard[str]:
return True
그러나 작정한 개발자나 잘 모르는 개발자가 타입 안전성을 무너뜨릴 수 있는 방법은 많으며, 가장 흔한 방법은 cast나 Any를 사용하는 것입니다. Python 개발자가 시간을 들여 사용자 정의 타입 가드를 익히고 자신의 코드에 구현한다면, 그 개발자는 타입 안전성에 관심이 있으며 타입 안전성을 해치거나 무의미한 결과를 내는 방식으로 타입 가드 함수를 작성하지 않을 것이라고 가정해도 무방합니다.
TypeGuard 타입의 조건부 적용
표현식의 타입이 TypeGuard 반환 타입에 지정된 타입의 진(proper) 서브타입이라면, 타입 가드 함수의 첫 번째 인자로 전달된 표현식이 기존 타입을 유지해야 한다는 제안이 있었습니다. 예를 들어 타입 가드 함수가 def f(value: object) -> TypeGuard[float]이고 이 함수에 전달된 표현식의 타입이 int라면, 그 표현식은 TypeGuard 반환 타입이 나타내는 float 타입을 취하는 대신 int 타입을 유지하게 됩니다. 이 제안은 복잡성과 비일관성을 더하고, 표현식의 타입이 유니언이나 여러 제약을 가진 타입 변수 같은 복합 타입일 경우 올바른 동작이 무엇인지에 대한 추가적인 질문을 불러일으키기 때문에 기각되었습니다. 부가 가치가 거의 또는 전혀 없다는 점을 고려할 때, 늘어나는 복잡성과 비일관성은 정당화되지 않는다고 결정되었습니다.
임의 매개변수의 좁히기
TypeScript의 사용자 정의 타입 가드 방식에서는 어떤 입력 매개변수든 좁히기를 위해 검사되는 값으로 사용할 수 있습니다. TypeScript 언어 작성자들은 검사 대상 매개변수가 첫 번째 매개변수가 아닌 실제 TypeScript 사례를 하나도 떠올리지 못했습니다. 이러한 이유로, 억지스러운 사용 사례를 지원하기 위해 사용자 정의 타입 가드의 Python 구현에 추가적인 복잡성을 부담시킬 필요는 없다고 결정되었습니다. 향후 그러한 사용 사례가 확인된다면, TypeGuard 메커니즘을 확장할 수 있는 방법이 있습니다. 여기에는 PEP 637에서 제안된 것처럼 키워드 인덱싱을 사용하는 방법이 포함될 수 있습니다.
암묵적인 “self” 및 “cls” 매개변수의 좁히기
이 제안은 첫 번째 위치 인자를 좁히기를 위해 검사되는 값으로 간주한다고 명시합니다. 타입 가드 함수가 인스턴스 메서드나 클래스 메서드로 구현된 경우, 암묵적인 self 또는 cls 인자도 함수에 전달됩니다. self와 cls에 좁히기 로직을 적용하고자 하는 경우가 있을 수 있다는 우려가 제기되었습니다. 이는 흔치 않은 사용 사례이며, 이를 수용하면 사용자 정의 타입 가드의 구현이 상당히 복잡해집니다. 따라서 이를 위한 특별한 규정은 두지 않기로 결정되었습니다. self나 cls의 좁히기가 필요하다면, 해당 값을 타입 가드 함수에 명시적인 인자로 전달할 수 있습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.