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는 내장 isinstance()와 유사하게 값의 타입을 축소하는 데 사용할 수 있는 함수를 어노테이션할 수 있도록 새로운 특수 형식인 TypeIs를 제안합니다. 기존 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]]: ...
한 사용자가 이 함수의 동작에 관해 reported를 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과 일치하지만, 사용자의 예상과는 달랐습니다. 대신 사용자는 if 분기에서 t의 타입이 Awaitable[int]로, else 분기에서 int로 축소되기를 예상했습니다. 이 PEP는 정확히 그렇게 동작하는 새로운 구문을 제안합니다.
TypeGuard의 현재 동작에서 비롯된 문제의 다른 예는 다음과 같습니다.
- Python typing issue (
numpy.isscalar) - Python typing issue (
dataclasses.is_dataclass()) - Pyright issue (
typing.TypeGuard가isinstance()처럼 동작하기를 기대함) - Pyright issue (
else분기에서의 축소를 기대함) - Mypy issue (
else분기에서의 축소를 기대함) - Mypy issue (여러 TypeGuard 결합)
- Mypy issue (
else분기에서의 축소를 기대함) - Mypy issue (
inspect.isawaitable()과 유사한 사용자 정의 함수) - Typeshed issue (
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는 인자 타입에 대해 불변입니다. TypeIs[B]는 B가 A의 서브타입인 경우에도 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는 새로운 특수 형식만 제안하므로 하위 호환성에 미치는 영향은 없습니다.
보안 관련 사항
알려진 사항이 없습니다.
이것을 가르치는 방법
타입을 좁히는 방법을 설명할 때 타입 입문에서는 TypeIs를 다루고, isinstance()와 같은 다른 타입 좁히기 구문도 함께 설명해야 합니다. 문서에서는 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 함수가 sound하지 않은 타입 검사를 초래할 수 있고 타입 검사기가 이러한 오류를 감지할 수 없으므로 위험할 수 있습니다.
TypeIs[T]를 반환하는 함수가 안전하려면 인자가 T형과 호환될 때 그리고 그럴 때에만 True를 반환하고, 그렇지 않을 때에는 False를 반환해야 합니다. 이 조건이 충족되지 않으면 타입 검사기가 잘못된 타입을 추론할 수 있습니다.
아래에는 올바른 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타입을 결합하여 더 정확한 타입을 추론할 수 있습니다. (기술적으로 이를 교집합 타입이라고 합니다.)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특수 형식은 has been implemented이 typing_extensions 모듈에 구현되었으며 typing_extensions 4.10.0에서 릴리스됩니다.
여러 타입 검사기에 대한 구현을 사용할 수 있습니다.
- Mypy: pull request open
- Pyanalyze: pull request
- Pyright: added in version 1.1.351
거부된 아이디어
TypeGuard의 동작 변경
PEP 724는 이전에 가드의 반환 타입이 입력 타입과 일치하는 경우 여기에서 제안하는 TypeIs의 동작을 적용하도록 typing.TypeGuard의 지정된 동작을 변경할 것을 제안했습니다. 이 제안에는 몇 가지 중요한 장점이 있습니다. 런타임 변경이 필요하지 않으므로 타입 검사기만 변경하면 되며, 이를 통해 사용자가 새롭고 일반적으로 더 직관적인 동작을 쉽게 활용할 수 있습니다.
그러나 이 접근 방식에는 몇 가지 중대한 문제가 있습니다. 기존 의미론이 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의 이름인 “타입 조건자”를 본뜬 이름입니다.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.