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

Python 개선 제안 한국어 번역

PEP 649 – 디스크립터를 사용한 어노테이션의 지연 평가

Author:
Larry Hastings <larry at hastings.org>
Discussions-To:
Discourse thread
Status:
Final
Type:
Standards Track
Topic:
Typing
Created:
11-Jan-2021
Python-Version:
3.14
Post-History:
11-Jan-2021, 12-Apr-2021, 18-Apr-2021, 09-Aug-2021, 20-Oct-2021, 20-Oct-2021, 17-Nov-2021, 15-Mar-2022, 23-Nov-2022, 07-Feb-2023, 11-Apr-2023
Replaces:
563
Resolution:
08-May-2023

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at Annotations.

×

See PEP 1 for how to propose changes.

초록

어노테이션은 Python 함수, 클래스 및 모듈에 대한 타입 정보와 기타 메타데이터를 표현할 수 있게 하는 Python 기술입니다. 그러나 Python의 어노테이션에 대한 원래 의미론에서는 어노테이션이 적용된 객체가 바인딩되는 시점에 이를 즉시 평가해야 했습니다. 이로 인해 순방향 참조 및 순환 참조 문제 때문에 “타입 힌트”를 사용하는 정적 타입 분석 사용자에게 만성적인 문제가 발생했습니다.

Python은 PEP 563을 수용하여 이를 해결했으며, 어노테이션을 Python이 자동으로 문자열로 변환하는 “문자열화된 어노테이션”이라는 새로운 접근 방식을 도입했습니다. 이를 통해 순방향 참조 및 순환 참조 문제가 해결되었고, 어노테이션 메타데이터의 흥미로운 새로운 활용도 촉진되었습니다. 그러나 문자열화된 어노테이션은 반대로 어노테이션의 런타임 사용자에게 만성적인 문제를 일으켰습니다.

이 PEP는 어노테이션을 표현하고 계산하기 위한 새롭고 포괄적인 세 번째 접근 방식을 제안합니다. 이 방식은 __annotate__라는 새로운 객체 메서드를 통해 필요할 때 어노테이션을 느리게 계산하는 새로운 내부 메커니즘을 추가합니다. 이 접근 방식은 어노테이션 값을 대체 형식으로 강제 변환하는 새로운 기법과 결합하여 위의 모든 문제를 해결하고, 기존의 모든 사용 사례를 지원하며, 어노테이션에 대한 향후 혁신을 촉진할 것으로 기대됩니다.

개요

이 PEP는 어노테이션을 지원하는 객체, 즉 함수, 클래스 및 모듈에 새로운 더블 언더스코어 속성을 추가합니다. 새로운 속성은 __annotate__라고 하며, 해당 객체의 어노테이션 딕셔너리를 계산하고 반환하는 함수에 대한 참조입니다.

컴파일 시 객체의 정의에 어노테이션이 포함되어 있으면 Python 컴파일러는 어노테이션을 계산하는 표현식을 자체 함수에 기록합니다. 실행되면 이 함수는 어노테이션 딕셔너리를 반환합니다. 그런 다음 Python 컴파일러는 이 함수에 대한 참조를 객체의 __annotate__에 저장합니다.

또한 __annotations__는 이 어노테이션 함수를 한 번 호출하고 그 결과를 캐시하는 “데이터 디스크립터”가 되도록 재정의됩니다.

이 메커니즘은 어노테이션을 검사할 때까지 어노테이션 표현식의 평가를 지연하여 많은 순환 참조 문제를 해결합니다.

이 PEP는 Python 표준 라이브러리의 두 함수인 inspect.get_annotationstyping.get_type_hints에 대한 새로운 기능도 정의합니다. 이 기능은 새로운 키워드 전용 매개변수인 format을 통해 액세스합니다. format을 사용하면 사용자가 이러한 함수에서 특정 형식의 어노테이션을 요청할 수 있습니다. 형식 식별자는 항상 미리 정의된 정수 값입니다. 이 PEP에서 정의하는 형식은 다음과 같습니다.

  • inspect.VALUE = 1

    기본값입니다. 함수는 어노테이션에 대한 관례적인 Python 값을 반환합니다. 이 형식은 Python 3.11에서 이러한 함수가 반환하는 값과 동일합니다.

  • inspect.FORWARDREF = 2

    함수는 어노테이션에 대한 관례적인 Python 값을 반환하려고 시도합니다. 그러나 정의되지 않은 이름이나 아직 값과 연결되지 않은 자유 변수를 만나면, 해당 표현식에서 그 값을 대신하는 프록시 객체(ForwardRef)를 동적으로 생성한 다음 평가를 계속합니다. 그 결과로 생성된 딕셔너리에는 프록시와 실제 값이 혼합되어 포함될 수 있습니다. 함수가 호출되는 시점에 모든 실제 값이 정의되어 있다면 inspect.FORWARDREFinspect.VALUE는 동일한 결과를 생성합니다.

  • inspect.SOURCE = 3

    이 함수는 어노테이션 표현식의 원본 소스 코드를 포함하는 문자열로 값이 대체된 어노테이션 딕셔너리를 생성합니다. 이러한 문자열은 원본 소스 코드를 보존하는 대신 다른 형식에서 역공학된 것일 수 있으므로 다소 근사적일 수 있지만, 차이는 사소합니다.

승인된다면 이 PEP는 PEP 563대체하며, PEP 563의 동작은 더 이상 사용되지 않도록 하고 결국 제거합니다.

어노테이션 시맨틱 비교

Note

이 절에 제시된 코드는 단순화되어 있으며 명확성을 위한 것으로, 몇 가지 중요한 측면에서 의도적으로 부정확하게 작성되었습니다. 이 예제는 세부 사항에 빠지지 않고 관련된 상위 수준의 개념을 전달하기 위한 것일 뿐입니다. 그러나 실제 구현은 몇 가지 중요한 측면에서 상당히 다르다는 점에 유의해야 합니다. 이 PEP가 기술적 관점에서 제안하는 내용을 훨씬 더 정확하게 설명한 이 PEP 뒤쪽의 Implementation 섹션을 참조하십시오.

다음 예제 코드를 살펴보십시오.

def foo(x: int = 3, y: MyType = None) -> float:
    ...
class MyType:
    ...
foo_y_annotation = foo.__annotations__['y']

여기에서 볼 수 있듯이, 어노테이션은 함수, 클래스 및 모듈의 __annotations__속성을 통해 런타임에 사용할 수 있습니다. 이러한 객체 중 하나에 어노테이션이 지정되면 __annotations__는 필드 이름을 해당 필드의 어노테이션으로 지정된 값에 매핑하는 딕셔너리입니다.

Python의 기본 동작은 함수, 클래스 또는 모듈이 바인딩되는 시점에 어노테이션의 표현식을 평가하고 어노테이션 딕셔너리를 만드는 것입니다. 런타임에 위 코드는 실제로 다음과 비슷하게 동작합니다.

annotations = {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
    ...
foo.__annotations__ = annotations
class MyType:
    ...
foo_y_annotation = foo.__annotations__['y']

여기서 중요한 세부 사항은 값 int, MyTypefloat가 함수 객체가 바인딩되는 시점에 조회되고, 이러한 값이 어노테이션 딕셔너리에 저장된다는 점입니다. 그러나 이 코드는 실행되지 않습니다—첫 번째 줄에서 NameError를 발생시키는데, 아직 MyType이 정의되지 않았기 때문입니다.

PEP 563의 해결책은 컴파일 중에 표현식을 다시 문자열로 디컴파일하고, 해당 문자열을 어노테이션 딕셔너리의 값으로 저장하는 것입니다. 이에 상응하는 런타임 코드는 다음과 비슷할 것입니다.

annotations = {'x': 'int', 'y': 'MyType', 'return': 'float'}
def foo(x = 3, y = "abc"):
    ...
foo.__annotations__ = annotations
class MyType:
    ...
foo_y_annotation = foo.__annotations__['y']

이제 이 코드는 성공적으로 실행됩니다. 그러나 foo_y_annotation은 더 이상 MyType에 대한 참조가 아니라 문자열 'MyType'입니다. 문자열을 실제 값 MyType으로 변환하려면 사용자는 eval, inspect.get_annotations 또는 typing.get_type_hints를 사용하여 문자열을 평가해야 합니다.

이 PEP는 어노테이션을 자체 함수에서 계산하여 평가를 지연하는 세 번째 접근법을 제안합니다. 이 PEP가 활성화되어 있다면 생성된 코드는 다음과 비슷하게 동작할 것입니다.

class function:
    # __annotations__ on a function object is already a
    # "data descriptor" in Python, we're just changing
    # what it does
    @property
    def __annotations__(self):
        return self.__annotate__()

# ...

def annotate_foo():
    return {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
    ...
foo.__annotate__ = annotate_foo
class MyType:
   ...
foo_y_annotation = foo.__annotations__['y']

중요한 변경 사항은 어노테이션 딕셔너리를 구성하는 코드가 이제 함수에 있다는 점입니다—여기서는 annotate_foo()라고 합니다. 그러나 이 함수는 foo.__annotations__의 값을 요청할 때까지 호출되지 않으며, MyType의 정의가 끝난 까지는 이를 요청하지 않습니다. 따라서 이 코드도 성공적으로 실행되며, foo_y_annotation은 이제 올바른 값—클래스 MyType—을 가집니다. 어노테이션이 정의된 까지 MyType이 정의되지 않았는데도 그렇습니다.

2017년 11월 이 접근법에 대한 잘못된 거부

2017년 11월 comp.lang.python-dev의 스레드에서, PEP 563를 둘러싼 논의가 시작된 초기에는 어노테이션 평가를 지연하기 위해 코드를 사용하는 아이디어가 잠시 논의되었습니다. 당시 이 기법은 “암시적 람다 표현식”이라고 불렸습니다.

당시 Python의 BDFL이었던 Guido van Rossum은 이러한 “암시적 람다 표현식”이 작동하지 않을 것이라고 주장하며 답변했습니다. 모듈 수준 스코프에서만 기호를 확인할 수 있기 때문입니다.

제 생각에는 메서드의 어노테이션에서 클래스 수준 정의를 참조할 수 없다는 점 때문에 이 아이디어는 사실상 성립하지 않습니다.

https://mail.python.org/pipermail/python-dev/2017-November/150109.html

이는 클래스 수준 스코프에 대한 참조를 유지하여 메서드의 람다화된 어노테이션이 클래스 수준 정의를 참조할 수 있도록 확장하는 방안에 대한 짧은 논의로 이어졌습니다. 이 아이디어 역시 빠르게 거부되었습니다.

PEP 563는 위 논의를 요약합니다

이 PEP에서 채택한 접근 방식에는 이러한 제한이 없습니다. 어노테이션은 모듈 수준 정의, 클래스 수준 정의, 심지어 지역 변수와 자유 변수에도 접근할 수 있습니다.

동기

어노테이션의 역사

Python 3.0에는 PEP 3107에서 정의된 새로운 구문 기능인 “어노테이션”이 포함되었습니다. 이를 통해 Python 함수의 매개변수 또는 해당 함수가 반환하는 값과 연결될 Python 값을 지정할 수 있었습니다. 달리 말하면, 어노테이션은 Python 사용자에게 함수 매개변수나 반환값에 대한 풍부한 메타데이터(예: 타입 정보)를 제공할 인터페이스를 제공했습니다. 함수의 모든 어노테이션은 새 속성 __annotations__에 함께 저장되었으며, “어노테이션 딕셔너리”에서 매개변수 이름(또는 반환 어노테이션의 경우 'return'이라는 이름)을 해당 Python 값에 매핑했습니다.

실험을 장려하기 위해 Python은 이 메타데이터가 어떤 형식을 취해야 하는지 또는 어떤 값을 사용해야 하는지를 의도적으로 정의하지 않았습니다. 사용자 코드는 거의 즉시 이 새로운 기능을 실험하기 시작했습니다. 그러나 이 기능을 활용하는 인기 라이브러리가 등장하기까지는 오랜 시간이 걸렸습니다.

별다른 진전이 없던 수년 후, BDFL은 정적 타입 정보를 표현하기 위한 특정 접근 방식을 선택했으며, PEP 484에서 정의된 이 방식을 타입 힌트,라고 불렀습니다. Python 3.5에는 새로운 typing 모듈이 포함되었으며, 이 모듈은 빠르게 큰 인기를 얻었습니다.

Python 3.6에는 PEP 526에서 제안된 접근 방식을 사용하여 지역 변수, 클래스 속성 및 모듈 속성에 어노테이션을 지정하는 구문이 추가되었습니다. 정적 타입 분석은 계속해서 인기를 얻었습니다.

그러나 정적 타입 분석 사용자들은 점점 더 불편한 문제인 순방향 참조 때문에 좌절했습니다. 기존 Python에서는 클래스 C가 나중에 정의되는 클래스 D에 의존하더라도 일반적으로 문제가 되지 않습니다. 사용자 코드는 보통 둘 다 정의될 때까지 기다린 후 어느 하나를 사용하기 때문입니다. 그러나 어노테이션은 새로운 복잡성을 추가했습니다. 어노테이션이 지정된 객체(함수, 클래스 또는 모듈)가 바인딩되는 시점에 어노테이션이 계산되었기 때문입니다. 클래스 C의 메서드에 타입 D로 어노테이션을 지정하고 이 어노테이션 표현식이 메서드가 바인딩되는 시점에 계산된다면, D는 아직 정의되지 않았을 수 있습니다. 또한 D의 메서드에도 타입 C로 어노테이션을 지정하면 해결할 수 없는 순환 참조 문제가 발생합니다.

처음에 정적 타입 사용자들은 문제가 되는 어노테이션을 문자열로 정의하여 이 문제를 우회했습니다. 타입 힌트를 포함하는 문자열도 정적 타입 분석 도구에서 똑같이 사용할 수 있었기 때문에 이 방법은 효과가 있었습니다. 또한 정적 타입 분석 도구 사용자는 런타임에 어노테이션을 검사하는 일이 거의 없으므로, 이 표현 자체는 불편하지 않았습니다. 그러나 타입 힌트를 수동으로 문자열화하는 작업은 번거롭고 오류가 발생하기 쉬웠습니다. 또한 코드베이스에 점점 더 많은 어노테이션이 추가되면서 이를 생성하고 바인딩하는 데 점점 더 많은 CPU 시간이 소요되었습니다.

이러한 문제를 해결하기 위해 BDFL은 PEP 563을 승인했으며, 이 PEP는 Python 3.7에 “문자열화된 어노테이션”이라는 새로운 기능을 추가했습니다. 이는 미래 임포트를 사용하여 활성화되었습니다.:

from __future__ import annotations

일반적으로 어노테이션 표현식은 객체가 바인딩되는 시점에 평가되었으며, 그 값은 어노테이션 딕셔너리에 저장되었습니다. 문자열화된 어노테이션이 활성화되면 이러한 의미 체계가 변경됩니다. 대신 컴파일 시점에 컴파일러가 해당 모듈의 모든 어노테이션을 소스 코드의 문자열 표현으로 변환합니다. 즉, 사용자의 어노테이션을 자동으로 문자열로 변환하므로 이전처럼 이를 수동으로 문자열화할 필요가 없어집니다. PEP 563에서는 런타임에 실제 값이 필요한 경우 사용자가 eval로 이 문자열을 평가할 수 있다고 제안했습니다.

(이후 이 PEP에서는 PEP 3107PEP 526의 고전적 의미 체계, 즉 어노테이션 표현식의 값이 객체가 바인딩되는 시점에 계산되는 의미 체계를 새로운 PEP 563의 “문자열화된” 어노테이션 의미 체계와 구별하기 위해 “스톡” 의미 체계,로 지칭합니다.)

어노테이션 사용 사례의 현재 상태

어노테이션에는 구체적인 사용 사례가 많지만, 이 PEP를 둘러싼 논의에서 어노테이션 사용자는 대체로 다음 네 가지 범주 중 하나에 속했습니다.

정적 타이핑 사용자

정적 타이핑 사용자는 코드에 타입 정보를 추가하기 위해 어노테이션을 사용합니다. 그러나 이들은 대부분 런타임에 어노테이션을 검사하지 않습니다. 대신 정적 타입 분석 도구(mypy, pytype)를 사용하여 소스 트리를 검사하고 코드가 타입을 일관되게 사용하고 있는지 판단합니다. 이는 현재 어노테이션의 사용 사례 중 거의 확실히 가장 일반적인 것입니다.

많은 어노테이션은 PEP 484에 따른 타입 힌트,(그리고 그 이후의 여러 PEP)에 해당합니다. 타입 힌트는 수동적인 객체이며 타입 정보의 단순한 표현일 뿐입니다. 실제 작업을 수행하지 않습니다. 타입 힌트는 다른 타입이나 다른 타입 힌트를 매개변수화하는 경우가 많습니다. 실제 값이 무엇인지에 구애받지 않으므로 타입 힌트는 ForwardRef 프록시 객체와도 잘 작동합니다. 정적 타입 힌트 사용자는 스톡 의미 체계에서 광범위하게 타입 힌트를 사용하면 해결하기 어려운 대규모 순환 참조 및 순환 임포트 문제가 자주 발생한다는 사실을 발견했습니다. PEP 563은 특별히 이 문제를 해결하기 위해 설계되었으며, 이 해결책은 이러한 사용자에게 매우 효과적이었습니다. 문자열화된 어노테이션을 실제 값으로 변환하는 일이 어려웠지만, 이 사용자들은 런타임에 어노테이션을 검사하는 일이 드물었기 때문에 대체로 불편을 겪지 않았습니다.

정적 타이핑 사용자는 런타임에 타입 힌트가 로드되지 않도록 if typing.TYPE_CHECKING 관용구와 PEP 563을 함께 사용하는 경우가 많습니다. 이는 이들이 런타임에 문자열화된 어노테이션을 평가하여 실제 값을 생성하지 못하는 경우가 많다는 의미입니다. 드물게 런타임에 어노테이션을 검사하는 경우에도, 이들은 대개 eval을 사용하지 않고 문자열화된 어노테이션에 직접 어휘 분석을 적용합니다.

이 PEP에서 정적 타이핑 사용자는 아마도 FORWARDREF 또는 SOURCE 형식을 선호할 것입니다.

런타임 어노테이션 사용자

런타임 어노테이션 사용자는 함수와 클래스에 대한 풍부한 메타데이터를 표현하는 수단으로 어노테이션을 사용하며, 이를 런타임 동작의 입력으로 사용합니다. 구체적인 사용 사례로는 런타임 타입 검증(Pydantic)과 다른 도메인에 Python API를 노출하기 위한 접착 로직(FastAPI, Typer)이 있습니다. 어노테이션은 타입 힌트일 수도 있고 아닐 수도 있습니다.

런타임 어노테이션 사용자는 런타임에 어노테이션을 검사하므로, 전통적으로 스톡 의미 체계가 이들에게 더 적합했습니다. 이 사용 사례는 특히 if typing.TYPE_CHECKING 관용구와 함께 사용할 때 PEP 563과 대체로 호환되지 않습니다.

이 PEP에서 런타임 어노테이션 사용자는 대부분 VALUE 형식을 선호할 것이지만, 일부 사용자는(예를 들어 데코레이터에서 어노테이션을 즉시 평가하고 순방향 참조를 지원하려는 경우) FORWARDREF 형식도 사용할 수 있습니다.

래퍼

래퍼는 사용자 함수나 클래스를 감싸고 기능을 추가하는 함수 또는 클래스입니다. 예로는 dataclass(), functools.partial(), attrswrapt가 있습니다.

래퍼는 런타임 어노테이션 사용자의 뚜렷한 하위 범주입니다. 래퍼는 런타임에 어노테이션을 사용하지만, 자신이 감싸는 객체의 어노테이션을 실제로 검사할 수도 있고 검사하지 않을 수도 있습니다. 이는 래퍼가 제공하는 기능에 따라 달라집니다. 일반적으로 래퍼는 자신이 생성하는 래퍼에 감싸인 객체의 어노테이션을 전달해야 하지만, 해당 어노테이션을 수정할 가능성도 있습니다.

래퍼는 일반적으로 기본 의미론에서 잘 작동하도록 설계되었습니다. 래핑된 객체의 어노테이션을 어느 정도까지 검사하는지에 따라 PEP 563 의미 체계에서 제대로 작동하는지 여부가 달라집니다. 래퍼는 흔히 값 자체에는 관심이 없으며, 어노테이션에 관한 특정 정보만 필요로 합니다. 그렇더라도 PEP 563if typing.TYPE_CHECKING 관용구는 래퍼가 런타임에 필요한 정보를 안정적으로 판단하기 어렵게 만들 수 있습니다. 이는 현재까지도 지속되는 만성적인 문제입니다. 이 PEP에서는 래퍼가 내부 로직에 FORWARDREF 형식을 선호할 가능성이 높습니다. 그러나 감싼 객체는 사용자를 위해 모든 형식을 지원해야 합니다.

문서화

PEP 563의 문자열화된 어노테이션은 문서를 기계적으로 작성하는 도구에 큰 도움이 되었습니다.

문자열화된 타입 힌트는 훌륭한 문서를 만들어 줍니다. 소스 코드에 표현된 타입 힌트는 흔히 간결하고 읽기 쉽습니다. 그러나 런타임에는 이와 동일한 타입 힌트가 런타임 값을 생성할 수 있으며, 그 값의 repr은 장황하고 중첩되어 읽을 수 없는 난잡한 형태가 될 수 있습니다. 따라서 문서화 사용자는 PEP 563로부터 충분한 도움을 받았지만, 기본 의미론에서는 제대로 지원받지 못했습니다.

이 PEP에서는 문서화 사용자가 SOURCE 형식을 사용할 것으로 예상합니다.

이 PEP의 동기

Python의 어노테이션에 대한 원래 의미론은 전방 참조 문제로 인해 정적 타입 분석에 사용하기가 어려웠습니다. PEP 563은 전방 참조 문제를 해결했으며, 많은 정적 타입 분석 사용자가 일찍부터 이를 반기는 도입자가 되었습니다. 그러나 이 독특한 해결책은 앞서 언급한 두 가지 사용 사례, 즉 런타임 어노테이션 사용자와 래퍼에 새로운 문제를 만들었습니다.

첫째, 문자열화된 어노테이션에서는 지역 변수나 자유 변수를 참조할 수 없었으므로, 유용하고 합리적인 어노테이션 작성 방법 중 상당수가 더 이상 실행 가능하지 않았습니다. 이는 기존 함수와 클래스를 감싸는 데코레이터에 특히 불편했는데, 이러한 데코레이터는 흔히 클로저를 사용하기 때문입니다.

둘째, 문자열화된 어노테이션에서 eval이 전역을 올바르게 조회하려면 먼저 올바른 모듈에 대한 참조를 얻어야 합니다. 그러나 클래스 객체는 자신의 전역 변수에 대한 참조를 유지하지 않습니다. PEP 563sys.modules에서 클래스의 모듈을 이름으로 조회할 것을 제안하는데, 이는 언어 수준 기능으로서는 놀라운 요구 사항입니다.

또한 복잡하지만 적법한 구성으로 인해 문자열화된 어노테이션을 올바르게 평가하기 위해 eval에 전달할 올바른 전역 및 지역 딕셔너리를 결정하기가 어려울 수 있습니다. 더 나쁜 점은 어떤 상황에서는 이것이 단순히 실행 불가능할 수 있다는 것입니다.

예를 들어 일부 라이브러리(예: typing.TypedDict, dataclasses)는 사용자 클래스를 감싼 다음, 해당 클래스의 모든 베이스 클래스에 있는 모든 어노테이션을 하나의 누적 어노테이션 딕셔너리로 병합합니다. 이러한 어노테이션이 문자열화되어 있다면 나중에 eval을 호출해도 제대로 작동하지 않을 수 있습니다. eval에 사용되는 전역 딕셔너리가 user class이 정의된 모듈이 되는데, 이 모듈은 annotation이 정의된 모듈과 동일하지 않을 수 있기 때문입니다. 그러나 전방 참조 문제로 인해 어노테이션이 문자열화된 경우에는 전방 참조를 아직 확인할 수 없으므로 어노테이션에 대해 일찍 eval을 호출해도 작동하지 않을 수 있습니다. 이는 조정하기 어려운 문제로 드러났습니다. 아래에 연결된 세 개의 버그 보고서 중 수정된 것으로 표시된 것은 하나뿐입니다.

올바른 전역 변수와 and 로컬 변수가 있더라도 문자열화된 어노테이션에서 eval은 신뢰할 수 없을 수 있습니다. 어노테이션에서 참조하는 모든 기호가 정의되어 있어야만 eval이 성공할 수 있습니다. 문자열화된 어노테이션이 정의된 기호와 정의되지 않은 기호를 함께 참조하면, 해당 문자열에 대한 단순한 eval은 실패합니다. 이는 어노테이션을 검사해야 하는 라이브러리에 문제가 됩니다. 이러한 라이브러리는 문자열화된 어노테이션을 실제 값으로 안정적으로 변환할 수 없기 때문입니다.

  • 일부 라이브러리(예: dataclasses)는 실제 값을 사용하지 않고 문자열화된 어노테이션의 어휘 분석을 수행하여 이 문제를 해결했지만, 이를 올바르게 구현하려면 많은 작업이 필요합니다.
  • 다른 라이브러리에는 여전히 이 문제가 남아 있으며, 이로 인해 런타임 동작이 예상과 달라질 수 있습니다. https://github.com/python/cpython/issues/97727

또한 eval()은 느리고 항상 사용할 수 있는 것도 아니며, 특정 플랫폼에서는 공간상의 이유로 제거되기도 합니다. MicroPython의 eval()locals 인자를 지원하지 않으므로, 런타임에 문자열화된 어노테이션을 실제 값으로 변환하기가 더욱 어렵습니다.

마지막으로 PEP 563은 Python 구현이 어노테이션을 문자열화하도록 요구합니다. 이는 놀라운 동작입니다. 언어 수준 기능으로서는 전례가 없고 구현도 복잡하며, 언어에 새 연산자가 추가될 때마다 업데이트해야 합니다.

이러한 문제로 인해 어노테이션 사용자가 직면한 문제를 해결할 새로운 접근법을 찾기 위한 연구가 시작되었으며, 그 결과 이 PEP가 만들어졌습니다.

구현

어노테이션 표현식에 대해 관찰되는 의미

어노테이션을 지원하는 모든 객체 o에 대해, 어노테이션 표현식에서 평가되는 모든 이름이 o가 정의되기 전에 바인딩되고 이후에 다시 바인딩되지 않는다면, “기본” 의미 체계가 활성화된 경우와 이 PEP가 활성화된 경우 모두 o.__annotations__은 동일한 어노테이션 딕셔너리를 생성합니다. 특히 이름 확인은 두 시나리오에서 동일하게 수행됩니다.

이 PEP가 활성화되면 o.__annotations__의 값은 o.__annotations__자체가 처음 평가될 때까지 계산되지 않습니다. 어노테이션 표현식의 모든 평가는 이 시점까지 지연되며, 이는 또한 다음을 의미합니다.

  • 어노테이션 표현식에서 참조되는 이름은 이 시점의 현재 값을 사용합니다.
  • 어노테이션 표현식을 평가할 때 예외가 발생하면 해당 예외도 이 시점에 발생합니다.

o.__annotations__이 처음으로 성공적으로 계산되면 이 값이 캐시되고, 이후 o.__annotations__에 대한 요청에서 반환됩니다.

__annotate__ 및 __annotations__

Python은 함수, 클래스, 모듈이라는 세 가지 유형에서 어노테이션을 지원합니다. 이 PEP는 이 세 유형 모두의 의미 체계를 유사한 방식으로 수정합니다.

먼저 이 PEP는 새로운 “더블 언더스코어” 속성인 __annotate__를 추가합니다. __annotate__는 “데이터 디스크립터”여야 하며, 가져오기, 설정, 삭제라는 세 가지 동작을 모두 구현해야 합니다. __annotate__ 속성은 항상 정의되어 있으며, None 또는 호출 가능 객체 중 하나로만 설정할 수 있습니다. (__annotate__는 삭제할 수 없습니다.) 객체에 어노테이션이 없다면, __annotate__는 빈 딕셔너리를 반환하는 함수가 아니라 None으로 초기화해야 합니다.

__annotate__ 데이터 디스크립터에는 해당 값에 대한 참조를 저장할 전용 저장 공간이 객체 내부에 있어야 합니다. 런타임에서 이 저장 공간의 위치는 구현 세부 사항입니다. Python 코드에서 볼 수 있더라도 여전히 내부 구현 세부 사항으로 간주해야 하며, Python 코드는 __annotate__ 속성을 통해서만 이 저장 공간과 상호작용하는 것을 우선해야 합니다.

__annotate__에 저장되는 호출 가능 객체는 format이라는 단일 필수 위치 인자를 받아야 하며, 이 인자는 항상 int 또는 int의 서브클래스입니다. 반드시 딕셔너리(또는 딕셔너리의 서브클래스)를 반환하거나 NotImplementedError()를 발생시켜야 합니다.

다음은 Python 언어 레퍼런스의 “매직 메서드” 절에 수록될 __annotate__의 형식적 정의입니다.

__annotate__(format: int) -> dict

속성/매개변수 이름을 해당 어노테이션 값에 매핑하는 새로운 딕셔너리 객체를 반환합니다.

어노테이션 값을 제공할 형식을 지정하는 format 매개변수를 받습니다. 다음 중 하나여야 합니다.

inspect.VALUE (int 상수 1과 동일함)

값은 어노테이션 표현식을 평가한 결과입니다.

inspect.FORWARDREF (int 상수 2와 동등합니다)

값은 정의된 값에 대해서는 (inspect.VALUE 형식에 따른) 실제 어노테이션 값이고, 정의되지 않은 값에 대해서는 ForwardRef 프록시입니다. 실제 객체가 ForwardRef 프록시 객체에 노출되거나, 그러한 객체에 대한 참조를 포함할 수 있습니다.

inspect.SOURCE (int 상수 3과 동등합니다)

값은 소스 코드에 나타나는 어노테이션의 텍스트 문자열입니다. 근사값일 수 있으며, 공백이 정규화될 수 있고 상수 값이 최적화될 수 있습니다. 이러한 문자열의 정확한 값은 향후 Python 버전에서 변경될 수 있습니다.

__annotate__ 함수가 요청된 형식을 지원하지 않으면 NotImplementedError()를 발생시켜야 합니다. __annotate__ 함수는 항상 1 (inspect.VALUE) 형식을 지원해야 하며, format=1로 호출될 때 NotImplementedError()를 발생시켜서는 안 됩니다.

format=1로 호출될 때 __annotate__ 함수는 NameError를 발생시킬 수 있지만, 다른 형식을 요청하여 호출될 때는 NameError를 발생시켜서는 안 됩니다.

객체에 어노테이션이 없으면 __annotate__는 삭제할 수 없으므로 빈 딕셔너리를 반환하는 함수로 설정하기보다는 가급적 None으로 설정해야 합니다.

Python 컴파일러가 어노테이션이 있는 객체를 컴파일할 때 적절한 어노테이트 함수도 동시에 컴파일합니다. 이 함수는 단일 위치 인자 inspect.VALUE를 전달받아 해당 객체에 정의된 어노테이션 딕셔너리를 계산하고 반환합니다. Python 컴파일러와 런타임은 함수가 적절한 네임스페이스에 바인딩되도록 협력합니다:

  • 함수와 클래스의 경우 globals 딕셔너리는 객체가 정의된 모듈이 됩니다. 객체 자체가 모듈인 경우 globals 딕셔너리는 해당 모듈 자체의 딕셔너리가 됩니다.
  • 클래스의 메서드와 클래스의 경우 locals 딕셔너리는 클래스 딕셔너리가 됩니다.
  • 어노테이션이 자유 변수를 참조하는 경우 클로저는 자유 변수에 대한 셀을 포함하는 적절한 클로저 튜플이 됩니다.

둘째로, 이 PEP는 기존 __annotations__가 가져오기, 설정 및 삭제의 세 가지 동작을 모두 구현하는 “데이터 디스크립터”여야 한다고 요구합니다. __annotations__는 어노테이션 딕셔너리에 대한 참조를 캐시하는 데 사용하는 자체 내부 저장 공간도 가져야 합니다:

  • 클래스 객체와 모듈 객체는 __annotations__를 키로 사용하여 어노테이션 딕셔너리를 자신의 __dict__에 캐시해야 합니다. 이는 하위 호환성을 위해 필요합니다.
  • 함수 객체의 경우 어노테이션 딕셔너리 캐시를 위한 저장 공간은 구현 세부 사항입니다. 이는 함수 객체 내부에 두고 Python에서 보이지 않게 하는 것이 바람직합니다.

이 PEP는 이를 구현하는 세 가지 유형 모두에 대해 __annotations____annotate__가 상호 작용하는 방식의 의미를 정의합니다. 다음 예제에서 fn은 함수를, cls는 클래스를, mod는 모듈을, o는 이 세 유형 중 하나에 해당하는 객체를 나타냅니다:

  • o.__annotations__를 평가할 때 o.__annotations__의 내부 저장 공간이 설정되어 있지 않고 o.__annotate__가 호출 가능 객체로 설정되어 있으면, o.__annotations__의 게터는 o.__annotate__(1)을 호출한 다음 결과를 내부 저장 공간에 캐시하고 그 결과를 반환합니다.
    • 여러 번 제기된 질문 하나를 명확히 설명하면, 이 o.__annotations__캐시는 이 PEP에서 정의하는 유일한 캐싱 메커니즘입니다. 이 PEP에서 정의하는 다른 캐싱 메커니즘은 없습니다. Python 컴파일러가 생성하는 __annotate__함수는 계산하는 값 중 어떤 것도 명시적으로 캐시하지 않습니다.
  • o.__annotate__를 호출 가능 객체로 설정하면 캐시된 어노테이션 딕셔너리가 무효화됩니다.
  • o.__annotate__None으로 설정해도 캐시된 어노테이션 딕셔너리에는 아무런 영향이 없습니다.
  • o.__annotate__를 삭제하면 TypeError가 발생합니다. __annotate__는 항상 설정되어 있어야 하며, 이를 통해 어노테이션이 없는 서브클래스가 베이스 클래스 중 하나의 __annotate__메서드를 상속하는 것을 방지합니다.
  • o.__annotations__를 적법한 값으로 설정하면 o.__annotate__는 자동으로 None으로 설정됩니다.
    • cls.__annotations__ 또는 mod.__annotations__None으로 설정하는 경우에도 다른 속성과 동일하게 작동하며, 해당 속성이 None으로 설정됩니다.
    • fn.__annotations__None으로 설정하면 캐시된 어노테이션 딕셔너리가 무효화됩니다. fn.__annotations__에 캐시된 어노테이션 값이 없고 fn.__annotate__None이면, fn.__annotations__ 데이터 디스크립터가 새로운 빈 딕셔너리를 생성하고 캐시한 뒤 반환합니다. (이는 PEP 3107 의미 체계와의 하위 호환성을 위한 것입니다.)

허용되는 어노테이션 구문의 변경 사항

이제 __annotate__는 나중에 __annotations__가 참조될 때까지 어노테이션 평가를 지연합니다. 이는 또한 어노테이션이 해당 객체가 바인딩된 원래 컨텍스트가 아니라 새로운 함수에서 평가된다는 의미입니다. 표준 의미 체계에서는 상당한 런타임 부작용이 있는 네 개의 연산자가 허용되었지만, from __future__ import annotations가 활성화되면 허용되지 않으며, 이 PEP가 활성화될 때에도 허용되지 않아야 합니다.

  • :=
  • yield
  • yield from
  • await

inspect.get_annotationstyping.get_type_hints의 변경 사항

(이 PEP에서는 이 두 함수를 자주 참조합니다. 앞으로는 이 함수들이 사용자 코드가 어노테이션을 처리하도록 돕는다는 점에서 이들을 통칭하여 “도우미 함수”라고 부르겠습니다.)

이 두 함수는 객체에서 어노테이션을 추출하여 반환합니다. inspect.get_annotations는 어노테이션을 변경하지 않고 반환하며, 정적 타이핑 사용자의 편의를 위해 typing.get_type_hints는 어노테이션을 반환하기 전에 일부를 수정합니다.

이 PEP는 이 두 함수에 새로운 키워드 전용 매개변수인 format을 추가합니다. format은 어노테이션 딕셔너리의 값이 어떤 형식으로 반환되어야 하는지를 지정합니다. 이 두 함수의 format매개변수는 앞에서 정의한 __annotate__ 매직 메서드의 format매개변수와 동일한 값을 허용하지만, 이러한 format매개변수의 기본값도 inspect.VALUE입니다.

객체에서 __annotations__또는 __annotate__중 하나가 갱신되면 다른 속성은 이제 최신 상태가 아니므로 역시 갱신하거나 삭제해야 합니다. (삭제할 수 없는 __annotate__의 경우에는 None으로 설정해야 합니다.) 일반적으로 이전 절에서 정립한 의미 체계가 이를 자동으로 보장합니다. 그러나 실질적으로 자동으로 처리할 수 없는 한 가지 경우가 있습니다. o.__annotations__가 캐시한 딕셔너리 자체가 수정되거나, 해당 딕셔너리 내부의 변경 가능한 값이 수정되는 경우입니다.

이는 코드에서 처리할 수 없으므로 문서에서 처리해야 합니다. 이 PEP는 다음과 같이 inspect.get_annotations의 문서를 수정할 것을 제안합니다. (typing.get_type_hints에 대해서도 유사하게 적용됩니다.)

객체의 __annotations__딕셔너리를 직접 수정하면, 기본적으로 해당 객체에 대해 SOURCE 또는 FORWARDREF 형식을 요청할 때 inspect.get_annotations가 반환하는 딕셔너리에 이러한 변경 사항이 반영되지 않을 수 있습니다. __annotations__딕셔너리를 직접 수정하는 대신, 원하는 값으로 어노테이션 딕셔너리를 계산하는 함수로 해당 객체의 __annotate__ 메서드를 교체하는 것을 고려하십시오. 그렇게 할 수 없다면, SOURCEFORWARDREF 형식에 대해 inspect.get_annotations가 오래된 결과를 생성하지 않도록 객체의 __annotate__ 메서드를 None으로 덮어쓰는 것이 가장 좋습니다.

stringizerfake globals 환경

원래 제안된 대로라면 이 PEP는 많은 런타임 어노테이션 사용자 사용 사례와 많은 정적 타입 사용자 사용 사례를 지원했습니다. 그러나 이것으로는 충분하지 않았습니다–이 PEP는 모든 기존 사용 사례를 충족하기 전에는 수용될 수 없었습니다. Carl Meyer가 아래에 설명된 “stringizer” 및 “fake globals” 환경을 제안할 때까지 이것이 이 PEP의 오랜 장애물이 되었습니다. 이러한 기법을 통해 이 PEP는 FORWARDREFSOURCE 형식을 모두 지원하여 남아 있는 모든 사용 사례를 훌륭하게 충족할 수 있습니다.

간단히 말해, 이 기법은 Python 컴파일러가 생성한 __annotate__함수를 특이한 런타임 환경에서 실행하는 것과 관련됩니다. 이 함수의 일반적인 globals딕셔너리는 이른바 “fake globals” 딕셔너리로 대체됩니다. “fake globals” 딕셔너리는 한 가지 중요한 차이점이 있는 딕셔너리입니다. 즉, 매핑되지 않은 키를 이 딕셔너리에서 “가져올” 때마다 해당 키에 대한 새 값을 생성하고 캐시한 다음 반환합니다(딕셔너리의 __missing__콜백에 따른 동작입니다). 이 값은 “stringizer”라고 하는 새로운 타입의 인스턴스입니다.

“stringizer”는 매우 특이한 동작을 하는 Python 클래스입니다. 모든 문자열화기는 자신의 “value”로 초기화되며, 처음에는 “fake globals” 딕셔너리에서 누락된 키의 이름이 그 값이 됩니다. 그런 다음 stringizer는 연산자를 구현하는 데 사용되는 모든 Python “dunder” 메서드를 구현하며, 각 메서드가 반환하는 값은 해당 연산을 텍스트로 표현한 값을 가진 새로운 stringizer입니다.

이러한 stringizer를 표현식에서 사용하면, 표현식의 결과는 해당 표현식을 텍스트로 나타내는 이름을 가진 새로운 stringizer가 됩니다. 예를 들어, 값이 'f'로 초기화된 stringizer를 참조하는 f변수가 있다고 가정하겠습니다. 다음은 f에 수행할 수 있는 연산과 해당 연산이 반환하는 값의 몇 가지 예입니다.:

>>> f
Stringizer('f')
>>> f + 3
Stringizer('f + 3')
>> f["key"]
Stringizer('f["key"]')

이를 모두 종합하면, Python이 생성한 __annotate__함수를 실행하되 해당 함수의 globals를 “fake globals” 딕셔너리로 대체할 경우, 함수가 참조하는 정의되지 않은 모든 심볼은 해당 심볼을 나타내는 stringizer 프록시 객체로 대체되며, 이러한 프록시에서 수행되는 모든 연산은 다시 해당 표현식을 나타내는 프록시가 됩니다. 이를 통해 __annotate__함수가 완료되고 어노테이션 딕셔너리를 반환할 수 있으며, 그렇지 않았다면 평가할 수 없었을 이름과 전체 표현식을 stringizer 인스턴스가 대신 나타내게 됩니다.

실제로 “stringizer” 기능은 현재 typing모듈에 정의된 ForwardRef객체에 구현됩니다. ForwardRef는 모든 stringizer 기능을 구현하도록 확장되며, 포함된 문자열을 평가하여 실제 값을 생성하는 기능도 지원하도록 확장됩니다(참조되는 모든 심볼이 정의되어 있다고 가정합니다). 이는 ForwardRef객체가 표현식을 평가하는 데 필요한 적절한 “globals”, “locals”, 나아가 “closure” 정보에 대한 참조를 유지한다는 의미입니다.

이 기법은 inspect.get_annotationsFORWARDREF형식과 SOURCE형식을 지원하는 방식의 핵심입니다. 처음에는 inspect.get_annotations가 원하는 형식을 요청하면서 객체의 __annotate__메서드를 호출합니다. 이 호출에서 NotImplementedError가 발생하면 inspect.get_annotations는 “fake globals” 환경을 구성한 다음 객체의 __annotate__메서드를 호출합니다.

  • inspect.get_annotations는 새로운 빈 “fake globals” 딕셔너리를 생성하고, 이를 객체의 __annotate__메서드에 바인딩한 다음, VALUE형식을 요청하여 메서드를 호출하고, 결과 딕셔너리의 각 ForwardRef객체에서 문자열 “value”를 추출하여 SOURCE형식을 생성합니다.
  • inspect.get_annotations는 새로운 빈 “fake globals” 딕셔너리를 생성하고, __annotate__메서드의 globals 딕셔너리의 현재 내용으로 미리 채운 다음, “fake globals” 딕셔너리를 객체의 __annotate__메서드에 바인딩하고, VALUE형식을 요청하여 메서드를 호출한 뒤 그 결과를 반환하여 FORWARDREF형식을 생성합니다.

이 전체 기법이 작동하는 이유는 컴파일러가 생성하는 __annotate__함수가 Python 자체의 제어를 받으며 단순하고 예측 가능하기 때문입니다. 이러한 함수는 사실상 어노테이션 딕셔너리를 계산하여 반환하는 단일 return문입니다. 어노테이션을 계산하는 데 필요한 대부분의 연산은 Python에서 dunder 메서드를 사용하여 구현되며, stringizer가 관련된 모든 dunder 메서드를 지원하므로 이 접근법은 안정적이고 실용적인 해결책입니다.

그러나 아무 __annotate__메서드에나 이 기법을 적용하려고 하는 것은 합리적이지 않습니다. 이 PEP는 서드파티 라이브러리가 자체 __annotate__메서드를 구현할 수 있다고 가정하며, 그러한 함수는 이 “fake globals” 환경에서 실행될 경우 거의 확실히 올바르게 작동하지 않습니다. 이러한 이유로 이 PEP는 코드 객체에 플래그를 할당합니다. 즉, co_flags의 사용되지 않는 비트 중 하나를 “이 코드 객체는 ‘fake globals’ 환경에서 실행할 수 있음”을 의미하도록 할당합니다. 이에 따라 “fake globals” 환경은 명시적으로 선택해야만 사용되며, Python 컴파일러가 생성한 __annotate__메서드만 이 플래그를 설정할 것으로 예상됩니다.

이 기법의 약점은 객체의 dunder 메서드에 직접 대응하지 않는 연산자를 처리할 때 나타납니다. 이러한 연산자는 모두 분기 또는 반복과 같은 일종의 흐름 제어를 구현합니다.

  • 단락 평가 or연산자
  • 단락 평가 and연산자
  • 삼항 연산자(if / then 연산자)
  • 제너레이터 표현식
  • 리스트 / 딕셔너리 / 집합 컴프리헨션
  • 이터러블 언패킹

일반적으로 이러한 기법은 어노테이션에서 사용되지 않으므로, 실제로는 문제가 되지 않습니다. 그러나 최근 Python에 TypeVarTuple이 추가되면서 이터러블 언패킹을 사용합니다. 관련된 던더 메서드(__iter____next__)로는 이터레이션 사용 사례를 구분할 수 없습니다. 어떤 사용 사례가 관련되었는지 정확히 감지하려면 단순한 “가짜 전역”과 “문자열화기”만으로는 충분하지 않으며, SOURCEFORWARDREF 형식 생성을 목적으로 특별히 설계된 사용자 정의 바이트코드 인터프리터가 필요합니다.

다행히도 잘 작동하는 지름길이 있습니다. 문자열화기는 자신의 이터레이션 던더 메서드가 호출될 때 TypeVarTuple이 수행하는 이터레이터 언패킹을 위한 것이라고 단순히 가정합니다. 이 동작을 하드코딩합니다. 따라서 이터레이션을 사용하는 다른 기법은 작동하지 않지만, 실제로는 실제 사용 사례에 불편을 초래하지 않습니다.

마지막으로 “가짜 전역” 환경에서는 대응하는 “가짜 지역” 딕셔너리도 구성해야 하며, FORWARDREF 형식에서는 관련 지역 딕셔너리가 미리 채워집니다. “가짜 전역” 환경에서는 __annotate__ 메서드가 참조하는 자유 변수의 이름으로 미리 생성된 ForwardRef 객체들의 튜플인 가짜 “클로저”도 만들어야 합니다.

자유 변수를 참조하는 __annotate__ 메서드에서 생성된 ForwardRef 프록시는 해당 자유 변수의 이름과 클로저 값을 지역 딕셔너리에 매핑하여, eval이 해당 이름에 올바른 값을 사용하도록 합니다.

컴파일러가 생성한 __annotate__ 함수

이전 섹션에서 언급했듯이, 컴파일러가 생성한 __annotate__ 함수는 단순합니다. 주로 어노테이션 딕셔너리를 계산하여 반환하는 단일 return 문으로 구성됩니다.

그러나 inspect.get_annotationsFORWARDREF 또는 SOURCE 형식을 요청하는 프로토콜에서는 먼저 __annotate__ 메서드에 해당 형식을 생성하도록 요청해야 합니다. Python 컴파일러가 생성한 __annotate__ 메서드는 이러한 형식을 지원하지 않으며 NotImplementedError()를 발생시킵니다.

서드파티 __annotate__ 함수

서드파티 클래스와 함수는 자체 __annotate__ 메서드를 구현해야 할 가능성이 높으며, 이를 통해 해당 객체의 downstream 사용자가 어노테이션을 최대한 활용할 수 있습니다. 특히 래퍼는 래핑된 객체가 생성한 어노테이션 딕셔너리를 변환해야 할 가능성이 높습니다. 즉, 어떤 방식으로든 딕셔너리에 항목을 추가하거나 제거하거나 수정해야 합니다.

대부분의 경우 서드파티 코드는 기존 upstream 객체에서 inspect.get_annotations를 호출하여 자체 __annotate__ 메서드를 구현합니다. 예를 들어 래퍼는 래핑된 객체에 대한 어노테이션 딕셔너리를 자신에게 요청된 형식으로 요청한 다음, 반환된 어노테이션 딕셔너리를 적절히 수정하여 반환할 가능성이 높습니다. 이를 통해 서드파티 코드는 “가짜 전역” 기법을 이해하거나 이에 직접 참여하지 않고도 활용할 수 있습니다.

Python의 PEP-649 이전 및 이후 버전을 모두 지원하는 서드파티 라이브러리는 두 버전을 모두 지원하기 위한 자체 모범 사례를 고안해야 합니다. 한 가지 합리적인 접근 방식은 래퍼가 항상 __annotate__를 지원하도록 한 다음, VALUE 형식을 요청하여 호출하고 그 결과를 래퍼 객체의 __annotations__로 저장하는 것입니다. 이렇게 하면 PEP-649 이전 Python의 의미 체계를 지원하면서 PEP-649 이후 의미 체계와도 하위 호환성을 확보할 수 있습니다.

의사 코드

다음은 inspect.get_annotations를 위한 고수준 의사 코드입니다.:

def get_annotations(o, format):
    if format == VALUE:
        return dict(o.__annotations__)

    if format == FORWARDREF:
        try:
            return dict(o.__annotations__)
        except NameError:
            pass

    if not hasattr(o.__annotate__):
        return {}

    c_a = o.__annotate__
    try:
        return c_a(format)
    except NotImplementedError:
        if not can_be_called_with_fake_globals(c_a):
            return {}
        c_a_with_fake_globals = make_fake_globals_version(c_a, format)
        return c_a_with_fake_globals(VALUE)

다음은 Python 컴파일러가 생성한 __annotate__ 메서드를 Python으로 작성했다면 어떻게 보일 수 있는지를 나타낸 것입니다.:

def __annotate__(self, format):
    if format != 1:
        raise NotImplementedError()
    return { ... }

다음은 서드파티 래퍼 클래스가 __annotate__를 구현하는 방법입니다. 이 예에서 래퍼는 functools.partial처럼 작동하며, 래핑된 호출 가능 객체의 매개변수 하나를 미리 바인딩합니다. 단순화를 위해 해당 매개변수의 이름은 arg이어야 합니다.:

def __annotate__(self, format):
    ann = inspect.get_annotations(self.wrapped_fn, format)
    if 'arg' in ann:
        del ann['arg']
    return ann

Python 런타임에 대한 기타 수정 사항

이 PEP는 정확한 구현 방식을 규정하지 않으며, 이는 언어 구현 유지 관리자에게 맡겨집니다. 그러나 이 PEP를 가장 잘 구현하려면 기존 Python 객체에 추가 정보를 넣어야 할 수 있으며, 이는 이 PEP가 수용됨으로써 암묵적으로 허용됩니다.

예를 들어 클래스 객체에 __globals__ 특성을 추가하여 해당 클래스의 __annotate__ 함수를 필요할 때만 지연 바인딩해야 할 수 있습니다. 또한 클래스에서 정의된 메서드에 정의된 __annotate__ 함수는 해당 클래스에 바인딩된 이름을 올바르게 평가하기 위해 클래스의 __dict__를 참조해야 할 수 있습니다. 이 PEP의 CPython 구현에는 이 두 가지 새 특성이 모두 포함될 것으로 예상합니다.

기존 Python 객체에 추가되는 이러한 모든 새 정보는 물론 구현 세부 사항이므로 “던더” 특성을 사용해야 합니다.

대화형 REPL 셸

이 PEP에서 확립된 의미 체계는 Python의 대화형 REPL 셸에서 코드를 실행할 때도 적용되지만, 대화형 모듈 자체(__main__)의 모듈 어노테이션에는 적용되지 않습니다. 해당 모듈은 결코 “완료”되지 않으므로 __annotate__ 함수를 컴파일할 특정 시점이 없습니다.

단순화를 위해 이 경우에는 지연 평가를 사용하지 않습니다. REPL 셸의 모듈 수준 어노테이션은 “기본 의미 체계”에서와 정확히 동일하게 계속 작동하며, 즉시 평가하고 그 결과를 __annotations__ 딕셔너리 내부에 직접 설정합니다.

함수 내부의 지역 변수 어노테이션

Python은 함수 내부의 지역 변수 어노테이션을 위한 구문을 지원합니다. 그러나 이러한 어노테이션은 실행 시간 효과가 없습니다–컴파일 시 버려집니다. 따라서 이 PEP에서는 해당 어노테이션을 지원하기 위해 아무것도 할 필요가 없으며, 이는 기본 의미 체계 및 PEP 563과 동일합니다.

프로토타입

이 PEP의 최초 프로토타입 구현은 다음에서 확인할 수 있습니다.

https://github.com/larryhastings/co_annotations/

이 글을 작성하는 현재 이 구현은 매우 오래되었으며, Python 3.10을 기반으로 하고 2021년 초에 작성된 이 PEP의 첫 번째 초안의 의미 체계를 구현합니다. 곧 업데이트될 예정입니다.

성능 비교

이 PEP를 사용할 때의 성능은 대체로 우수합니다. 고려할 시나리오는 네 가지입니다.

  • 어노테이션이 정의되지 않은 경우의 실행 시간 비용
  • 어노테이션이 정의되었지만 참조되지 않는 경우의 실행 시간 비용
  • 어노테이션이 정의되고 객체로 참조되는 경우의 실행 시간 비용
  • 어노테이션이 정의되고 문자열로 참조되는 경우의 실행 시간 비용

어노테이션에 대한 세 가지 의미 체계인 기본 의미 체계, PEP 563, 이 PEP의 맥락에서 이러한 각 시나리오를 살펴보겠습니다.

어노테이션이 없는 경우 세 가지 의미 체계의 실행 시간 비용은 모두 동일하게 0입니다. 어노테이션 딕셔너리가 생성되지 않으며 이를 위한 코드도 생성되지 않습니다. 이를 위해 실행 시간 프로세서 시간이 필요하지 않고 메모리도 소비하지 않습니다.

어노테이션이 정의되었지만 참조되지 않는 경우 이 PEP를 사용하는 Python의 실행 시간 비용은 PEP 563과 대략 동일하며, 기본 의미 체계보다 개선됩니다. 구체적인 내용은 어노테이션이 적용되는 객체에 따라 달라집니다.

  • 기본 의미 체계에서는 어노테이션 딕셔너리가 항상 생성되고 어노테이션이 적용되는 객체의 특성으로 설정됩니다.
  • 함수 객체의 경우, PEP 563 의미 체계에서는 사전 컴파일된 상수(특수하게 구성된 튜플)가 함수의 속성으로 설정됩니다. 클래스 및 모듈 객체의 경우, 어노테이션 딕셔너리가 항상 생성되어 클래스 또는 모듈의 속성으로 설정됩니다.
  • 이 PEP에서는 어노테이션이 적용되는 객체의 속성으로 단일 객체가 설정됩니다. 대부분의 경우 이 객체는 상수(코드 객체)이지만, 어노테이션에 클래스 네임스페이스 또는 클로저가 필요한 경우에는 이 객체가 바인딩 시점에 구성된 튜플이 됩니다.

어노테이션이 객체로 정의되고 참조되는 경우, 이 PEP를 사용하는 코드는 PEP 563보다 훨씬 빠르며 표준 의미론과 같거나 더 빠릅니다. PEP 563의 의미론에서는 어노테이션 딕셔너리 내부의 모든 값에 대해 eval()을 호출해야 하므로 매우 느립니다. 또한 이 PEP의 구현은 표준 의미론보다 클래스 및 모듈 어노테이션에 대해 측정 가능한 수준으로 더 효율적인 바이트코드를 생성합니다. 함수 어노테이션의 경우에는 이 PEP와 표준 의미론의 속도가 거의 같아야 합니다.

이 PEP가 PEP 563보다 눈에 띄게 느려지는 유일한 경우는 어노테이션을 문자열로 요청하는 경우입니다. “이미 문자열입니다”라는 상황을 능가하기는 어렵습니다. 그러나 문자열화된 어노테이션은 온라인 문서화 사용 사례를 위한 것이므로, 성능이 핵심 요소일 가능성은 낮습니다.

세 가지 의미론적 맥락 모두에서 세 가지 시나리오의 메모리 사용량도 서로 비슷해야 합니다. 첫 번째와 세 번째 시나리오에서는 모든 경우에 메모리 사용량이 대략 동일해야 합니다. 두 번째 시나리오에서 어노테이션은 정의되었지만 참조되지 않는 경우, 이 PEP의 의미론을 사용하면 함수/클래스/모듈이 사용되지 않는 코드 객체 하나를 저장하게 됩니다(사용되지 않는 함수 객체에 바인딩될 수도 있습니다). 다른 두 의미론에서는 사용되지 않는 딕셔너리 또는 상수 튜플 하나를 저장합니다.

하위 호환성

표준 의미론과의 하위 호환성

이 PEP는 표준 의미론에서 어노테이션의 기존 동작을 거의 모두 보존합니다.

  • __annotations__ 속성에 저장되는 어노테이션 딕셔너리의 형식은 변경되지 않습니다. 어노테이션 딕셔너리에는 PEP 563에 따른 문자열이 아니라 실제 값이 포함됩니다.
  • 어노테이션 딕셔너리는 변경 가능하며, 이에 대한 모든 변경 사항이 보존됩니다.
  • __annotations__ 속성은 명시적으로 설정할 수 있으며, 이러한 방식으로 설정된 모든 적법한 값은 보존됩니다.
  • __annotations__ 속성은 del문을 사용하여 삭제할 수 있습니다.

표준 의미론에서 작동하는 대부분의 코드는 이 PEP가 활성화된 경우에도 수정 없이 계속 작동해야 합니다. 그러나 다음과 같은 예외가 있습니다.

첫째, 클래스 어노테이션에 액세스하는 잘 알려진 관용구가 이 PEP가 활성화된 경우 올바르게 작동하지 않을 수 있습니다. 클래스 어노테이션의 원래 구현에는 버그라고만 할 수 있는 문제가 있었습니다. 클래스가 자체 어노테이션을 전혀 정의하지 않았지만 기반 클래스 중 하나가 어노테이션을 정의한 경우, 해당 클래스가 그 어노테이션을 “상속”했습니다. 이러한 동작은 결코 바람직하지 않았으므로 사용자 코드는 해결 방법을 찾았습니다. 클래스의 어노테이션에 cls.__annotations__를 통해 직접 액세스하는 대신, cls.__dict__.get("__annotations__", {})와 같이 클래스의 딕셔너리를 통해 액세스하는 것입니다. 클래스는 어노테이션을 __dict__에 저장했기 때문에 이 관용구가 작동했으며, 이러한 방식으로 액세스하면 기반 클래스에서의 조회를 피할 수 있었습니다. 이 기법은 CPython의 구현 세부 사항에 의존했으므로, 필요하기는 했지만 지원되는 동작은 아니었습니다. 그러나 이 PEP가 활성화된 경우 클래스에 어노테이션이 정의되어 있어도 __annotate__를 아직 호출하여 결과를 캐시하지 않았을 수 있으며, 이 경우 이 접근 방식은 해당 클래스에 어노테이션이 없다고 잘못 판단하게 만듭니다. 어쨌든 이 버그는 Python 3.10부터 수정되었으므로, 이 관용구를 더 이상 사용해서는 안 됩니다. 또한 Python 3.10부터 어노테이션 작업을 위한 모범 사례를 정의하는 Annotations HOWTO가 제공됩니다. 이러한 지침을 따르는 코드는 이 PEP가 활성화된 경우에도 올바르게 작동합니다. 지침에서 코드가 실행되는 Python 버전에 따라 클래스 객체에서 어노테이션을 가져오는 서로 다른 접근 방식을 사용하도록 제안하기 때문입니다.

어노테이션을 인트로스펙션할 때까지 평가를 지연하면 언어의 의미론이 변경되므로, 이는 언어 내부에서 관찰할 수 있습니다. 따라서 어노테이션이 바인딩 시점에 평가되는지 접근 시점에 평가되는지에 따라 다르게 동작하는 코드를 작성하는 것이 가능합니다. 예를 들면 다음과 같습니다.

mytype = str
def foo(a:mytype): pass
mytype = int
print(foo.__annotations__['a'])

일반적인 의미론에서는 <class 'str'>를 출력하고, 이 PEP가 활성화되면 <class 'int'>를 출력합니다. 따라서 이는 하위 호환성이 없는 변경입니다. 그러나 이 예제는 좋지 않은 프로그래밍 방식이므로, 이러한 변경은 허용할 만해 보입니다.

일반적인 의미론에서는 작동하지만 이 PEP가 활성화되면 더 이상 작동하지 않는 클래스 및 모듈 어노테이션과 관련된 흔하지 않은 상호작용이 두 가지 있을 수 있습니다. 이 두 상호작용은 금지해야 합니다. 다행히도 둘 다 흔하지 않으며, 어느 쪽도 좋은 관행으로 간주되지 않습니다. 실제로 Python 자체의 회귀 테스트 모음 외부에서는 거의 찾아볼 수 없습니다. 다음과 같습니다.

  • 모듈 또는 클래스 속성에 대한 어노테이션을 어떤 종류의 흐름 제어문 내부에서 설정하는 코드. 현재 if 또는 try 문 내부에서 어노테이션이 포함된 모듈 및 클래스 속성을 설정할 수 있으며, 예상대로 작동합니다. 이 PEP가 활성화되었을 때 이러한 동작을 지원하는 것은 지속할 수 없습니다.
  • 모듈 또는 클래스 스코프에서 로컬 __annotations__딕셔너리를 직접 참조하거나 수정하는 코드. 현재 모듈 또는 클래스 속성에 대한 어노테이션을 설정할 때 생성된 코드는 단순히 로컬 __annotations__ 딕셔너리를 만든 다음, 필요한 만큼 여기에 매핑을 추가합니다. 사용자 코드가 이 딕셔너리를 직접 수정하는 것은 가능하지만, 이는 의도된 기능으로 보이지 않습니다. 이 PEP가 활성화된 후 어떤 방식으로든 이를 지원할 수는 있겠지만, 그 의미론은 아마도 놀라움을 불러일으킬 것이며 누구도 만족시키지 못할 것입니다.

이 두 가지 모두 정적 타입 검사기에서도 문제가 되는 지점이며, 해당 도구에서는 지원되지 않는다는 점에 유의하십시오. 두 가지 모두 적어도 지원되지 않는다고 선언하고, 이를 사용하면 정의되지 않은 동작이 발생한다고 하는 것이 합리적으로 보입니다. 컴파일 시간 검사로 이를 명시적으로 금지하기 위해 약간의 노력을 기울일 가치는 있을 수 있습니다.

마지막으로, 이 PEP가 활성화된 경우 어노테이션 값에 if / else 삼항 연산자를 사용해서는 안 됩니다. o.__annotations__에 접근하거나 도우미 함수에서 inspect.VALUE를 요청할 때는 올바르게 작동하지만, 일부 이름이 정의된 경우 불리언 표현식이 inspect.FORWARDREF에서는 올바르게 계산되지 않을 수 있으며 inspect.SOURCE에서는 훨씬 덜 정확할 것입니다.

PEP 563 의미론과의 하위 호환성

PEP 563은 어노테이션의 의미론을 변경했습니다. 해당 의미론이 활성화되면 어노테이션은 모듈 수준 또는 클래스 수준 스코프에서 평가된다고 가정해야 합니다. 더 이상 현재 함수나 이를 둘러싼 함수의 로컬 변수를 직접 참조할 수 없습니다. 이 PEP는 이러한 제한을 제거하며, 어노테이션은 모든 로컬 변수를 참조할 수 있습니다.

PEP 563은 문자열화된 어노테이션을 “실제” 값으로 변환하기 위해 eval을 사용하도록 요구합니다. (또는 eval을 대신 사용해 주는 typing.get_type_hintsinspect.get_annotations와 같은 도우미 함수를 사용해야 합니다.) 문자열화된 어노테이션을 활성화하고 문자열을 다시 실제 값으로 변환하기 위해 eval()을 직접 호출하는 기존 코드는 eval() 호출만 간단히 제거하면 됩니다. 도우미 함수를 사용하는 기존 코드는 변경 없이 계속 작동하지만, 이러한 함수의 사용은 선택 사항이 될 수 있습니다.

정적 타이핑 사용자는 비활성 타입 힌트 정의만 포함하고 실행 코드는 포함하지 않는 모듈을 자주 사용합니다. 이러한 모듈은 정적 타입 검사를 실행할 때만 필요하며, 런타임에는 사용되지 않습니다. 그러나 기본 의미론에서는 런타임이 어노테이션을 평가하고 계산하려면 이러한 모듈을 임포트해야 합니다. 한편 이러한 모듈은 해결하기 어렵거나 심지어 해결할 수 없는 순환 임포트 문제를 자주 일으켰습니다. PEP 563은 사용자가 두 가지 방법으로 이러한 순환 임포트 문제를 해결할 수 있도록 했습니다. 첫째, 모듈에서 PEP 563을 활성화하면 어노테이션이 상수 문자열이 되므로, 어노테이션을 계산할 수 있도록 실제 심볼을 정의할 필요가 없었습니다. 둘째, 이를 통해 사용자는 문제가 되는 모듈을 if typing.TYPE_CHECKING 블록 안에서만 임포트할 수 있었습니다. 따라서 정적 타입 검사기는 모듈과 그 안의 타입 정의를 임포트할 수 있었지만, 런타임에는 해당 모듈이 임포트되지 않았습니다. 지금까지 이 PEP가 활성화되면 이 접근 방식은 변경 없이 작동합니다. if typing.TYPE_CHECKING은 지원되는 동작입니다.

그러나 일부 코드베이스는 어노테이션에 사용된 정의를 임포트하지 않고 if typing.TYPE_CHECKING기법을 사용하는 경우에도 실제로 어노테이션을 런타임에 조사하기도 했습니다. 이러한 코드베이스는 어노테이션 문자열을 평가하지 않고 검사했으며, 대신 문자열에 대한 동일성 검사나 간단한 어휘 분석에 의존했습니다.

이 PEP는 이러한 기법도 지원합니다. 그러나 사용자는 코드를 이 PEP에 맞게 포팅해야 합니다. 첫째, 사용자 코드는 어노테이션에 접근할 때 inspect.get_annotations 또는 typing.get_type_hints를 사용해야 하며, 객체에서 __annotations__속성을 단순히 가져올 수는 없습니다. 둘째, 해당 함수를 호출할 때 formatinspect.FORWARDREF 또는 inspect.SOURCE 중 하나를 지정해야 합니다. 이는 모든 심볼이 정의되지 않은 경우에도 도우미 함수가 어노테이션 딕셔너리를 성공적으로 생성할 수 있음을 의미합니다. 문자열화된 어노테이션을 예상하는 코드는 inspect.SOURCE로 형식화된 어노테이션 딕셔너리와 함께 수정 없이 작동해야 합니다. 그러나 사용자는 inspect.FORWARDREF로 전환하는 것을 고려해야 합니다. 이를 사용하면 분석이 더 쉬워질 수 있기 때문입니다.

마찬가지로 PEP 563은 이전에는 불가능했던 방식으로 어노테이션이 지정된 클래스에 클래스 데코레이터를 사용할 수 있도록 했습니다. 일부 클래스 데코레이터(예: dataclasses)는 클래스의 어노테이션을 검사합니다. @ 데코레이터 구문을 사용하는 클래스 데코레이터는 클래스 이름이 바인딩되기 전에 실행되므로 해결할 수 없는 순환 정의 문제를 일으킬 수 있습니다. 클래스 자체를 참조하는 참조로 클래스의 속성에 어노테이션을 지정하거나, 여러 클래스의 속성에 서로를 순환 참조하는 어노테이션을 지정한 경우, 어노테이션을 검사하는 데코레이터를 사용하여 @ 데코레이터 구문으로 해당 클래스를 데코레이트할 수 없습니다. PEP 563은 데코레이터가 문자열을 어휘적으로 검사하고 이를 평가하기 위해 eval을 사용하지 않는 한(또는 추가적인 우회 방법으로 NameError를 처리하는 한) 이를 가능하게 했습니다. 이 PEP가 활성화되면 데코레이터는 도우미 함수를 사용하여 inspect.SOURCE 또는 inspect.FORWARDREF 형식으로 어노테이션 딕셔너리를 계산할 수 있습니다. 따라서 데코레이터는 자신이 선호하는 형식으로 정의되지 않은 심볼을 포함한 어노테이션을 분석할 수 있습니다.

관련 PEP 563의 초기 도입자들은 “문자열화된” 어노테이션이 자동 생성 문서에 유용하다는 사실을 발견했습니다. 사용자는 이 사용 사례를 실험했으며, Python의 pydoc도 이 기법에 어느 정도 관심을 표명했습니다. 이 PEP는 이 사용 사례를 지원합니다. 문서를 생성하는 코드는 도우미 함수를 사용하여 inspect.SOURCE 형식으로 어노테이션에 접근하도록 업데이트해야 합니다.

마지막으로, 어노테이션에서 if / else 삼항 연산자를 사용하는 것에 대한 경고는 PEP 563의 사용자에게도 동일하게 적용됩니다. 현재는 이들에게 작동하지만, 도우미 함수에서 일부 형식을 요청할 때 잘못된 결과를 생성할 수 있습니다.

이 PEP가 승인되면 PEP 563은 사용 중단되고 결국 제거됩니다. 현재 그 의미론에 의존하는 PEP 563의 초기 사용자가 이러한 전환을 원활하게 진행할 수 있도록 inspect.get_annotationstyping.get_type_hints는 특별한 편의 기능을 구현합니다.

이 PEP가 받아들여지더라도, PEP 563의미론이 활성화된 모듈에서 정의된 객체에 대해서는 Python 컴파일러가 어노테이션 코드 객체를 생성하지 않습니다. 따라서 정상적인 상황에서는 헬퍼 함수에 inspect.SOURCE 형식을 요청하면 빈 딕셔너리가 반환됩니다. 전환을 용이하게 하기 위한 편의 기능으로, 헬퍼 함수가 객체가 PEP 563이 활성화된 모듈에서 정의되었음을 감지하고 사용자가 inspect.SOURCE 형식을 요청하면, 현재 __annotations__ 딕셔너리의 값을 반환합니다. 이 경우 해당 값은 문자열화된 어노테이션입니다. 이를 통해 문자열화된 어노테이션을 어휘적으로 분석하는 PEP 563 사용자는 헬퍼 함수에 inspect.SOURCE 형식을 요청하도록 즉시 전환할 수 있으며, 이는 PEP 563에서 벗어나는 전환을 원활하게 해 줄 것으로 기대됩니다.

거부된 아이디어

“문자열을 그냥 저장하기”

SOURCE 형식을 지원하기 위해 제안된 한 가지 아이디어는 Python 컴파일러가 어노테이션 값의 실제 소스 코드를 어딘가에 내보내고, 사용자가 SOURCE 형식을 요청할 때 이를 제공하도록 하는 것이었습니다.

이 아이디어는 거부되었다기보다는 “아직은 아님”으로 분류되었습니다. FORWARDREF 형식을 지원해야 한다는 점은 이미 알고 있으며, 해당 기법을 몇 줄만 수정하여 SOURCE 형식을 지원하도록 적용할 수 있습니다. 이 접근 방식에는 아직 답하지 못한 질문이 많습니다.

  • 문자열은 어디에 저장합니까? 어노테이션이 지정된 객체가 생성될 때 항상 로드합니까, 아니면 요청 시 지연 로드합니까? 그렇다면 지연 로드는 어떻게 작동합니까?
  • “소스 코드”에 원본의 줄 바꿈과 주석이 포함됩니까? 들여쓰기와 순전히 서식 지정을 위해 사용된 추가 공백을 비롯한 모든 공백을 보존합니까?

SOURCE 값의 원본 소스 코드에 대한 충실도를 높이는 일이 충분히 중요하다고 판단되면, 향후 이 주제를 다시 검토할 가능성이 있습니다.

감사의 말

Carl Meyer, Barry Warsaw, Eric V. Smith, Mark Shannon, Jelle Zijlstra, Guido van Rossum께 지속적인 피드백과 격려를 보내 주신 데 감사드립니다.

이 제안의 가장 뛰어난 측면 중 일부가 된 핵심 아이디어를 제공해 주신 여러 분께 특히 감사드립니다.

  • Carl Meyer는 FORWARDREFSOURCE 형식을 가능하게 한 “문자열화기” 기법을 제안했으며, 이를 통해 겉보기에는 해결할 수 없어 보이는 문제로 1년 동안 진전 없이 머물러 있던 이 PEP에서 진전을 이루는 일이 가능해졌습니다. 또한 PEP 563 사용자를 위한 편의 기능으로 inspect.SOURCE가 문자열화된 어노테이션을 반환하도록 하는 방안과 그 밖에도 많은 제안을 해 주셨습니다. Carl은 이 PEP를 논의하는 비공개 이메일 스레드의 주요 연락 담당자이기도 했으며, 지칠 줄 모르는 지식의 원천이자 이성적인 목소리였습니다. Carl의 기여가 없었다면 이 PEP가 수용되었을 가능성은 거의 확실히 없었을 것입니다.
  • Mark Shannon은 전체 어노테이션 딕셔너리를 하나의 코드 객체 안에서 구성하고, 필요할 때만 이를 함수에 바인딩하는 방안을 제안했습니다.
  • Guido van Rossum은 __annotate__ 함수가 “기본” 의미론에서 어노테이션에 적용되는 이름 가시성 규칙을 그대로 복제해야 한다고 제안했습니다.
  • Jelle Zijlstra는 피드백뿐만 아니라–코드까지 기여했습니다!

참고 자료