Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

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는 철회되었습니다. 타이핑 위원회는 이 제안에 대한 합의에 도달하지 못했으며, 저자들은 이를 철회하기로 결정했습니다.

초록

PEP 647에서는 첫 번째 매개변수에 전달된 표현식의 타입이 반환 TypeGuard타입과 일치할 때 True를 반환하는 사용자 정의 타입 가드 함수 개념을 도입했습니다. 예를 들어, 반환 타입이 TypeGuard[str]인 함수는 첫 번째 입력 매개변수에 전달된 표현식의 타입이 str일 때 그리고 그때만 True를 반환한다고 간주합니다. 이를 통해 사용자 정의 타입 가드 함수가 True를 반환할 때 타입 검사기가 타입을 좁힐 수 있습니다.

이 PEP는 PEP 647에서 도입된 TypeGuard 메커니즘을 개선합니다. 이를 통해 사용자 정의 타입 가드 함수가 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 AR
NN은 .. A A∧¬R

실제로 엄격한 타입 가드에 대한 이론적 타입은 파이썬 타입 시스템에서 정확하게 표현할 수 없습니다. 타입 검사기는 이러한 타입을 실용적으로 근사한 표현으로 대체해야 합니다. 경험칙으로, 타입 검사기는 “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"

이러한 동작 변경으로 인해 타입 검사기가 평가하는 타입이 달라집니다. 따라서 새로운 타입 오류가 발생하거나 기존 타입 오류가 가려질 수 있습니다.

타입 검사기는 좁히기 로직을 자주 개선하거나 해당 로직의 기존 버그를 수정하므로, 정적 타이핑 사용자는 이러한 동작 변경에 익숙합니다.

또한 기존의 타입이 지정된 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와 유사하지만 더 엄격한 타입 가드 의미론을 적용합니다. 또한 반환 타입이 입력 타입과 일관되도록 [1] 강제합니다. 자세한 내용은 다음 스레드를 참조하십시오: StrictTypeGuard proposal

이 아이디어는 대부분의 경우 불필요하고 불필요한 복잡성을 추가하므로 거부되었습니다. 새로운 특수 형식을 도입해야 하며, 개발자는 두 형식 간의 미묘한 차이에 대해 교육받아야 합니다.

두 번째 출력 타입을 사용하는 TypeGuard

TypeGuard가 음수(“else”) 사례에서 좁히기에 사용해야 하는 타입을 나타내는 두 번째 선택적 타입 인자를 지원할 수 있도록 하자는 또 다른 아이디어가 제안되었습니다.

def is_int(val: int | str) -> TypeGuard[int, str]:
    return isinstance(val, int)

이 아이디어는 여기에서 제안되었습니다.

너무 복잡하다고 여겨졌으며, TypeGuard의 두 가지 주요 제한 사항 중 하나만 다루었기 때문에 거부되었습니다. 전체 논의는 이 thread를 참조하십시오.

각주