PEP 742 – TypeIs로 타입 좁히기
- Author:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 07-Feb-2024
- Python-Version:
- 3.13
- Post-History:
- 11-Feb-2024
- Replaces:
- 724
- Resolution:
- 03-Apr-2024
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 새로운 특수 형식인 TypeIs를 제안합니다. 이를 사용하면 내장 isinstance()처럼 값의 타입을 좁히는 데 쓸 수 있는 함수에 어노테이션을 달 수 있습니다. 기존 typing.TypeGuard 특수 형식과 달리, TypeIs는 조건문의 if 브랜치와 else 브랜치 모두에서 타입을 좁힐 수 있습니다.
동기
타입이 지정된 Python 코드에서는 사용자가 조건문에 따라 변수의 타입을 좁혀야 하는 경우가 많습니다. 예를 들어 함수가 두 타입의 유니온을 받는다면, 두 타입을 구별하기 위해 isinstance() 검사를 사용할 수 있습니다. 타입 검사기는 일반적으로 다양한 내장 함수와 연산을 바탕으로 한 타입 좁히기를 지원하지만, 때로는 사용자 정의 함수로 타입 좁히기를 수행하는 것이 유용합니다.
이러한 사용 사례를 지원하기 위해 PEP 647은 사용자가 타입 가드를 정의할 수 있게 하는 typing.TypeGuard 특수 형식을 도입했습니다:
from typing import assert_type, TypeGuard
def is_str(x: object) -> TypeGuard[str]:
return isinstance(x, str)
def f(x: object) -> None:
if is_str(x):
assert_type(x, str)
else:
assert_type(x, object)
안타깝게도 typing.TypeGuard의 동작에는 몇 가지 제약이 있어, PEP 724의 “동기” 절에서도 설명하듯이 흔한 사용 사례 상당수에서 유용성이 떨어집니다. 구체적으로는 다음과 같습니다.
- 타입 가드가
True를 반환하면 타입 검사기는 정확히TypeGuard반환 타입을 좁혀진 타입으로 사용해야 합니다. 변수의 타입에 대해 이미 알고 있는 정보를 활용할 수 없습니다. - 타입 가드가
False를 반환하는 경우, 타입 검사기는 추가적인 좁히기를 전혀 적용할 수 없습니다.
표준 라이브러리 함수 inspect.isawaitable()을 예로 들 수 있습니다. 이 함수는 인자가 어웨이터블 객체인지 여부를 반환하며, typeshed는 현재 이 함수에 다음과 같이 어노테이션을 달고 있습니다:
def isawaitable(object: object) -> TypeGuard[Awaitable[Any]]: ...
한 사용자가 이 함수의 동작에 관한 이슈를 mypy에 보고했습니다. 그 사용자는 다음과 같은 동작을 관찰했습니다:
import inspect
from collections.abc import Awaitable
from typing import reveal_type
async def f(t: Awaitable[int] | int) -> None:
if inspect.isawaitable(t):
reveal_type(t) # Awaitable[Any]
else:
reveal_type(t) # Awaitable[int] | int
이 동작은 PEP 647과 일치하지만, 사용자의 기대와는 맞지 않았습니다. 대신 사용자들은 t의 타입이 if 브랜치에서는 Awaitable[int]로, else 브랜치에서는 int로 좁혀지기를 기대할 것입니다. 이 PEP는 바로 그렇게 동작하는 새로운 구문을 제안합니다.
TypeGuard의 현재 동작에서 비롯된 이슈의 다른 예는 다음과 같습니다.
- Python typing 이슈 (
numpy.isscalar) - Python typing 이슈 (
dataclasses.is_dataclass()) - Pyright 이슈 (
typing.TypeGuard가isinstance()처럼 동작하기를 기대) - Pyright 이슈 (
else브랜치에서 타입이 좁혀지기를 기대한 사례) - Mypy 이슈 (
else브랜치에서 타입이 좁혀지기를 기대한 사례) - Mypy 이슈 (여러 TypeGuard의 결합)
- Mypy 이슈 (
else브랜치에서 타입이 좁혀지기를 기대한 사례) - Mypy 이슈 (
inspect.isawaitable()과 유사한 사용자 정의 함수) - Typeshed 이슈 (
asyncio.iscoroutinefunction)
근거
typing.TypeGuard의 현재 동작에 있는 문제들 때문에 다른 타입 좁히기 동작을 허용하도록 타입 시스템을 개선해야 합니다. PEP 724는 기존 typing.TypeGuard 구문의 동작을 변경할 것을 제안했지만, 저희는 그 변경이 하위 호환성에 미치는 영향이 너무 심각하다고 생각합니다. 대신 원하는 의미를 갖는 새로운 특수 형식을 추가할 것을 제안합니다.
이로 인해 비슷한 목적과 비슷한 의미를 가진 구문이 두 개 존재하는 안타까운 상황이 생긴다는 점을 인정합니다. 저희는 사용자가 이 PEP에서 제안하는 새로운 형식인 TypeIs의 동작을 원할 가능성이 더 높다고 생각하며, 따라서 문서에서 더 일반적으로 적용할 수 있는 도구로 TypeGuard보다 TypeIs를 강조할 것을 권장합니다. 그러나 TypeGuard의 의미는 때때로 유용하므로, 이를 폐지하거나 제거하는 것은 제안하지 않습니다. 장기적으로 대부분의 사용자는 TypeIs를 사용해야 하며, TypeGuard는 그 동작이 특별히 필요한 드문 경우를 위해 남겨 두어야 합니다.
명세
새로운 특수 형식인 TypeIs가 typing 모듈에 추가됩니다. 그 사용법, 동작, 런타임 구현은 typing.TypeGuard와 비슷합니다.
이 형식은 단일 인자를 받으며 함수의 반환 타입으로 사용할 수 있습니다. TypeIs를 반환하도록 어노테이션된 함수를 타입 좁히기 함수라고 합니다. 타입 좁히기 함수는 bool 값을 반환해야 하며, 타입 검사기는 모든 반환 경로가 bool을 반환하는지 검증해야 합니다.
타입 좁히기 함수는 적어도 하나의 위치 인자를 받아야 합니다. 타입 좁히기 동작은 함수에 전달된 첫 번째 위치 인자에 적용됩니다. 함수는 추가 인자를 받을 수 있지만, 추가 인자는 타입 좁히기의 영향을 받지 않습니다. 타입 좁히기 함수가 인스턴스 메서드나 클래스 메서드로 구현된 경우, 첫 번째 위치 인자는 두 번째 매개변수(self 또는 cls 다음)에 대응합니다.
타입 좁히기 동작
TypeIs의 동작을 명세하기 위해 다음 용어를 사용합니다:
- I =
TypeIs입력 타입 - R =
TypeIs반환 타입 - A = 타입 좁히기 함수에 전달된 인자의 타입(좁혀지기 전)
- NP = 좁혀진 타입(긍정;
TypeIs가True를 반환했을 때 사용) - NN = 좁혀진 타입(부정;
TypeIs가False를 반환했을 때 사용)
def narrower(x: I) -> TypeIs[R]: ...
def func1(val: A):
if narrower(val):
assert_type(val, NP)
else:
assert_type(val, NN)
반환 타입 R은 I와 일관되어야 합니다. 이 조건이 충족되지 않으면 타입 검사기는 오류를 내야 합니다.
형식적으로, 타입 NP는 A와 R의 교집합인 A∧R로 좁혀져야 하고, 타입 NN은 A와 R의 여집합의 교집합인 A∧¬R로 좁혀져야 합니다. 실제로는 엄격한 타입 가드에 대한 이론적 타입을 Python 타입 시스템에서 정확하게 표현할 수 없습니다. 타입 검사기는 이러한 타입의 실용적인 근사치를 대신 사용해야 합니다. 경험적인 원칙으로, 타입 검사기는 isinstance()를 처리할 때와 같은 타입 좁히기 로직을 사용하고, 그와 일관된 결과를 얻어야 합니다. 이 지침은 향후 타입 시스템이 확장될 경우 변경하고 개선할 수 있는 여지를 남깁니다.
예시
타입 좁히기는 긍정인 경우와 부정인 경우 모두에 적용됩니다:
from typing import TypeIs, assert_type
def is_str(x: object) -> TypeIs[str]:
return isinstance(x, str)
def f(x: str | int) -> None:
if is_str(x):
assert_type(x, str)
else:
assert_type(x, int)
인자의 이전에 알려진 타입에 따른 제약 때문에, 최종적으로 좁혀진 타입은 R보다 더 좁을 수 있습니다:
from collections.abc import Awaitable
from typing import Any, TypeIs, assert_type
import inspect
def isawaitable(x: object) -> TypeIs[Awaitable[Any]]:
return inspect.isawaitable(x)
def f(x: Awaitable[int] | int) -> None:
if isawaitable(x):
# Type checkers may also infer the more precise type
# "Awaitable[int] | (int & Awaitable[Any])"
assert_type(x, Awaitable[int])
else:
assert_type(x, int)
입력 타입과 일관되지 않은 타입으로 좁히는 것은 오류입니다:
from typing import TypeIs
def is_str(x: int) -> TypeIs[str]: # Type checker error
...
서브타이핑
TypeIs는 콜백 프로토콜이나 Callable 특수 형식 등에서 호출 가능 객체의 반환 타입으로도 유효합니다. 이러한 맥락에서는 bool의 서브타입으로 취급됩니다. 예를 들어, Callable[..., TypeIs[int]]는 Callable[..., bool]에 대입할 수 있습니다.
TypeGuard와 달리 TypeIs는 인자 타입에 대해 불변입니다. 즉, 불변 조건으로서 B가 A의 서브타입이더라도 TypeIs[B]는 TypeIs[A]의 서브타입이 아닙니다. 그 이유를 알아보기 위해 다음 예제를 살펴보십시오:
def takes_narrower(x: int | str, narrower: Callable[[object], TypeIs[int]]):
if narrower(x):
print(x + 1) # x is an int
else:
print("Hello " + x) # x is a str
def is_bool(x: object) -> TypeIs[bool]:
return isinstance(x, bool)
takes_narrower(1, is_bool) # Error: is_bool is not a TypeIs[int]
(bool은 int의 서브타입이라는 점에 유의하십시오.) 이 코드는 런타임에 실패합니다. 좁히기 함수가 False를 반환하고(1은 bool이 아니므로) takes_narrower()에서 else 브랜치가 실행되기 때문입니다. takes_narrower(1, is_bool) 호출이 허용된다면 타입 검사기는 이 오류를 감지하지 못할 것입니다.
하위 호환성
이 PEP는 새로운 특수 형식만을 제안하므로 하위 호환성에는 아무런 영향이 없습니다.
보안 영향
알려진 바 없습니다.
교육 방법
타이핑 입문 자료에서는 타입을 좁히는 방법을 다룰 때 isinstance() 같은 다른 좁히기 구문과 함께 TypeIs를 다뤄야 합니다. 문서에서는 typing.TypeGuard보다 TypeIs를 강조해야 합니다. 후자는 폐지 예정이 아니며 그 동작이 때때로 유용하기도 하지만, TypeIs의 동작이 대개 더 직관적일 것으로 예상하므로 대부분의 사용자는 TypeIs를 먼저 사용해야 합니다. 이 절의 나머지 부분에는 사용자 대상 입문 문서에 사용할 수 있는 몇 가지 예시 내용이 담겨 있습니다.
TypeIs를 사용해야 하는 경우
Python 코드에서는 값이 가질 수 있는 여러 타입을 구별하기 위해 isinstance() 같은 함수를 자주 사용합니다. 타입 검사기는 isinstance()와 그 밖의 다양한 검사를 이해하며, 이를 사용하여 변수의 타입을 좁힙니다. 하지만 때로는 더 복잡한 검사를 여러 곳에서 재사용하고 싶거나, 타입 검사기가 이해하지 못하는 검사를 사용하기도 합니다. 이러한 경우에는 검사를 수행하는 TypeIs 함수를 정의하여, 타입 검사기가 이를 사용해 변수의 타입을 좁히도록 할 수 있습니다.
TypeIs 함수는 단일 인자를 받으며 TypeIs[T]를 반환하는 것으로 어노테이션되는데, 여기서 T는 좁히려는 타입입니다. 이 함수는 인자가 T 타입이면 True를, 그렇지 않으면 False를 반환해야 합니다. 그러면 isinstance()를 사용하는 것과 마찬가지로 이 함수를 if 검사에서 사용할 수 있습니다. 예를 들면 다음과 같습니다:
from typing import TypeIs, Literal
type Direction = Literal["N", "E", "S", "W"]
def is_direction(x: str) -> TypeIs[Direction]:
return x in {"N", "E", "S", "W"}
def maybe_direction(x: str) -> None:
if is_direction(x):
print(f"{x} is a cardinal direction")
else:
print(f"{x} is not a cardinal direction")
안전한 TypeIs 함수 작성하기
TypeIs 함수를 사용하면 타입 검사기의 타입 좁히기 동작을 재정의할 수 있습니다. 이는 강력한 도구이지만, 잘못 작성된 TypeIs 함수는 건전하지 않은 타입 검사로 이어질 수 있고 타입 검사기는 이러한 오류를 감지할 수 없으므로 위험할 수 있습니다.
TypeIs[T]를 반환하는 함수가 안전하려면, 인자가 타입 T와 호환되는 경우에만 True를 반환하고 그 외에는 False를 반환해야 합니다. 이 조건을 충족하지 않으면 타입 검사기가 잘못된 타입을 추론할 수 있습니다.
다음은 올바른 TypeIs 함수와 잘못된 TypeIs 함수의 몇 가지 예입니다:
from typing import TypeIs
# Correct
def good_typeis(x: object) -> TypeIs[int]:
return isinstance(x, int)
# Incorrect: does not return True for all ints
def bad_typeis1(x: object) -> TypeIs[int]:
return isinstance(x, int) and x > 0
# Incorrect: returns True for some non-ints
def bad_typeis2(x: object) -> TypeIs[int]:
return isinstance(x, (int, float))
이 함수는 잘못 작성된 TypeIs 함수를 사용할 때 발생할 수 있는 몇 가지 오류를 보여 줍니다. 이러한 오류는 타입 검사기가 감지하지 못합니다:
def caller(x: int | str, y: int | float) -> None:
if bad_typeis1(x): # narrowed to int
print(x + 1)
else: # narrowed to str (incorrectly)
print("Hello " + x) # runtime error if x is a negative int
if bad_typeis2(y): # narrowed to int
# Because of the incorrect TypeIs, this branch is taken at runtime if
# y is a float.
print(y.bit_count()) # runtime error: this method exists only on int, not float
else: # narrowed to float (though never executed at runtime)
pass
다음은 더 복잡한 타입에 대한 올바른 TypeIs 함수의 예입니다:
from typing import TypedDict, TypeIs
class Point(TypedDict):
x: int
y: int
def is_point(x: object) -> TypeIs[Point]:
return (
isinstance(x, dict)
and all(isinstance(key, str) for key in x)
and "x" in x
and "y" in x
and isinstance(x["x"], int)
and isinstance(x["y"], int)
)
TypeIs와 TypeGuard
TypeIs와 typing.TypeGuard는 모두 사용자 정의 함수를 기반으로 변수의 타입을 좁히는 도구입니다. 둘 다 인자를 하나 받아서 입력 인자가 좁혀진 타입과 호환되는지에 따라 불리언을 반환하는 함수에 어노테이션을 다는 데 사용할 수 있습니다. 그런 다음 이러한 함수를 if 검사에서 사용하여 변수의 타입을 좁힐 수 있습니다.
TypeIs는 대개 가장 직관적으로 동작하지만, 더 많은 제약을 도입합니다. 다음과 같은 경우에는 TypeGuard가 적합한 도구입니다:
- 입력 타입과 호환되지 않는 타입으로 좁히려는 경우입니다. 예를 들어
list[object]에서list[int]로 좁히는 경우입니다.TypeIs는 호환되는 타입 사이에서만 좁히기를 허용합니다. - 함수가 좁혀진 타입과 호환되는 모든 입력값에 대해
True를 반환하지는 않는 경우입니다. 예를 들어 양의 정수에 대해서만True를 반환하는TypeGuard[int]를 둘 수 있습니다.
TypeIs와 TypeGuard는 다음과 같은 점에서 다릅니다:
TypeIs는 좁혀진 타입이 입력 타입의 서브타입이어야 하지만,TypeGuard는 그렇지 않습니다.TypeGuard함수가True를 반환하면, 타입 검사기는 변수의 타입을 정확히TypeGuard타입으로 좁힙니다.TypeIs함수가True를 반환하면, 타입 검사기는 변수에 대해 이전에 알려진 타입과TypeIs타입을 결합한 더 정밀한 타입을 추론할 수 있습니다. (기술적으로 이를 교차 타입(intersection type)이라고 합니다.)TypeGuard함수가False를 반환하면, 타입 검사기는 변수의 타입을 전혀 좁힐 수 없습니다.TypeIs함수가False를 반환하면, 타입 검사기는 변수의 타입에서TypeIs타입을 제외하도록 좁힐 수 있습니다.
이 동작은 다음 예에서 확인할 수 있습니다:
from typing import TypeGuard, TypeIs, reveal_type, final
class Base: ...
class Child(Base): ...
@final
class Unrelated: ...
def is_base_typeguard(x: object) -> TypeGuard[Base]:
return isinstance(x, Base)
def is_base_typeis(x: object) -> TypeIs[Base]:
return isinstance(x, Base)
def use_typeguard(x: Child | Unrelated) -> None:
if is_base_typeguard(x):
reveal_type(x) # Base
else:
reveal_type(x) # Child | Unrelated
def use_typeis(x: Child | Unrelated) -> None:
if is_base_typeis(x):
reveal_type(x) # Child
else:
reveal_type(x) # Unrelated
참조 구현
TypeIs 특수 형식은 typing_extensions 모듈에 구현되었으며 typing_extensions 4.10.0에서 릴리스될 예정입니다.
여러 타입 검사기에 대한 구현이 제공됩니다:
- Mypy: 풀 리퀘스트 열림
- Pyanalyze: 풀 리퀘스트
- Pyright: 버전 1.1.351에서 추가됨
기각된 아이디어
TypeGuard의 동작 변경
PEP 724는 이전에 typing.TypeGuard의 명세된 동작을 변경하여, 가드의 반환 타입이 입력 타입과 일관되는 경우 여기에서 TypeIs에 대해 제안한 동작이 적용되도록 하자고 제안했습니다. 이 제안에는 몇 가지 중요한 장점이 있습니다: 런타임 변경이 전혀 필요하지 않으므로 타입 검사기만 변경하면 되고, 따라서 사용자가 대개 더 직관적인 새 동작을 더 쉽게 활용할 수 있습니다.
그러나 이 접근 방식에는 몇 가지 중대한 문제가 있습니다. PEP 647에 명시된 기존 의미 체계를 전제로 TypeGuard 함수를 작성한 사용자는 타입 검사기가 자신의 코드를 해석하는 방식에서 미묘하고 호환성을 깨뜨릴 수도 있는 변화를 겪게 됩니다. 반환 타입이 입력 타입과 일관되면 한 방식으로 동작하고 그렇지 않으면 다른 방식으로 동작하는 TypeGuard의 이원화된 동작은 사용자에게 혼란을 줄 수 있습니다. 타이핑 위원회(Typing Council)는 PEP 724를 지지하는 합의에 이르지 못했습니다. 그 결과 저희는 이 대안 PEP를 제안합니다.
아무것도 하지 않기
이 PEP와 PEP 724에서 제안한 대안 모두 단점이 있습니다. 후자의 단점은 위에서 논의했습니다. 이 PEP의 경우, 의미 체계가 매우 비슷한 두 가지 특수 형식을 도입하며, 현재 TypeGuard를 사용하고 있지만 다른 좁히기 의미 체계가 더 적합한 사용자에게 긴 마이그레이션 경로를 만들 수 있습니다.
그렇다면 한 가지 방안은 아무것도 하지 않고 타입 시스템의 현재 한계를 감수하는 것입니다. 그러나 저희는 “동기” 절에서 설명한 현재 TypeGuard의 한계가 충분히 심각하므로, 이를 해결하기 위해 타입 시스템을 변경할 가치가 있다고 생각합니다. 아무런 변경도 하지 않는다면 사용자는 TypeGuard의 동일한 비직관적인 동작을 계속 겪게 될 것이고, 타입 시스템은 inspect.isawaitable 같은 흔한 타입 좁히기 함수를 제대로 표현할 수 없을 것입니다.
대체 이름
이 PEP는 현재 TypeIs라는 이름을 제안합니다. 이 이름은 특수 형식 TypeIs[T]가 인자가 T 타입인지 여부를 반환한다는 점을 강조하며, TypeScript의 문법을 본뜬 것입니다. 이 PEP의 이전 버전에서 사용한 이름을 포함하여 다른 이름들도 검토했습니다.
선택지는 다음과 같습니다.
IsInstance(Paul Moore의 게시글): 새 구문이 내장 함수isinstance()와 비슷하게 동작한다는 점을 강조합니다.Narrowed또는NarrowedTo:TypeNarrower보다 짧으면서도 “타입 좁히기”와의 연결을 유지합니다 (Eric Traut 제안).Predicate또는TypePredicate: 이 기능에 대한 TypeScript의 명칭인 “타입 술어(type predicates)”를 본뜬 것입니다.StrictTypeGuard(PEP 724의 초기 초안): 새 구문이typing.TypeGuard보다 더 엄격한 형태의 타입 좁히기를 수행한다는 점을 강조합니다.TypeCheck(Nicolas Tessore의 게시글): 검사의 이진적 특성을 강조합니다.TypeNarrower: 함수가 인자의 타입을 좁힌다는 점을 강조합니다. 이 PEP의 이전 버전에서 사용되었습니다.
감사의 글
이 PEP의 동기와 명세 중 상당 부분은 PEP 724에서 비롯되었습니다. 이 PEP는 당면한 문제에 대해 다른 해결책을 제안하지만, PEP 724의 저자인 Eric Traut, Rich Chiodo, Erik De Bonte는 자신들의 제안을 설득력 있게 제시했으며, 그들의 작업이 없었다면 이 제안은 불가능했을 것입니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.