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

Python 개선 제안 한국어 번역

PEP 560 – typing 모듈 및 제네릭 타입의 핵심 지원

Author:
Ivan Levkivskyi <levkivskyi at gmail.com>
Status:
Final
Type:
Standards Track
Topic:
Typing
Created:
03-Sep-2017
Python-Version:
3.7
Post-History:
09-Sep-2017, 14-Nov-2017
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

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

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at the documentation for __class_getitem__()__mro_entries__().

×

See PEP 1 for how to propose changes.

초록

처음에 PEP 484는 CPython 핵심 인터프리터에 어떠한 변경도 도입하지 않도록 설계되었습니다. 이제 타입 힌트와 typing 모듈은 커뮤니티에서 광범위하게 사용되며, 예를 들어 PEP 526PEP 557은 타입 힌트의 사용을 확장하고, PyPI의 typing 백포트는 월 100만 회의 다운로드를 기록합니다. 따라서 이 제한은 제거할 수 있습니다. 제네릭 타입을 더 잘 지원하기 위해 CPython 핵심에 두 특수 메서드 __class_getitem____mro_entries__ 를 추가할 것을 제안합니다.

근거

CPython 핵심 인터프리터를 수정하지 않는다는 제한은 typing 모듈이 널리 사용되기 시작했을 때 의문스러워진 일부 설계 결정으로 이어졌습니다. 주요 우려 사항은 typing 모듈의 성능, 메타클래스 충돌, 그리고 현재 typing 에서 사용되는 수많은 해크입니다.

성능

typing 모듈은 모든 최적화를 적용한 후에도 표준 라이브러리에서 가장 무겁고 느린 모듈 중 하나입니다. 주된 이유는 첨자가 붙은 제네릭 타입(이 PEP에서 사용되는 용어의 정의는 PEP 484 참조)이 클래스 객체이기 때문입니다([1]도 참조). 제안된 특수 메서드를 활용하여 성능을 개선할 수 있는 주요 방법은 세 가지입니다.

  • 제네릭 클래스 생성은 GenericMeta.__new__가 매우 느리기 때문에 느립니다. 이제 더 이상 이것이 필요하지 않습니다.
  • 제네릭 클래스의 매우 긴 메서드 해석 순서(MRO)는 절반으로 줄어듭니다. 이는 typing 에서 collections.abc 상속 체인을 중복하기 때문에 존재합니다.
  • 제네릭 클래스의 인스턴스화는 더 빨라집니다(다만 이는 부차적인 개선입니다).

메타클래스 충돌

모든 제네릭 타입은 GenericMeta의 인스턴스이므로, 사용자가 사용자 지정 메타클래스를 사용하면 이에 대응하는 클래스를 제네릭으로 만들기 어렵습니다. 사용자가 제어하지 않는 라이브러리 클래스의 경우에는 특히 어렵습니다. 한 가지 해결 방법은 항상 GenericMeta를 믹스인하는 것입니다.:

class AdHocMeta(GenericMeta, LibraryMeta):
    pass

class UserClass(LibraryBase, Generic[T], metaclass=AdHocMeta):
    ...

그러나 이것이 항상 실용적이거나 가능한 것은 아닙니다. 제안된 특수 속성을 활용하면 GenericMeta 메타클래스가 필요하지 않게 됩니다.

이 제안으로 제거될 해크와 버그

  • 호출된 __new__의 타입과 다른 타입을 가진 인스턴스에서는 __init__이 호출되지 않기 때문에 존재하는 _generic_new 해크입니다. C[int]().__class__ is C.
  • 첨자 표기가 새 클래스를 생성하지 않으므로 _next_in_mro 속도 해크는 필요하지 않게 됩니다.
  • 추한 sys._getframe 해크입니다. 이 해크는 typing 외부의 변경 없이는 제거할 수 없는 것처럼 보이기 때문에 특히 좋지 않습니다.
  • 현재 제네릭은 최소한 O(N2)만큼 증가하는 대규모 메모리 소비를 해결하기 위해 비공개 ABC 캐시를 위험하게 조작합니다([2] 참조). 최근 ABCMeta 를 C로 재구현하자는 제안이 있었기 때문에 이 점도 중요합니다.
  • 첨자가 붙은 제네릭 간에 속성을 공유하는 데 문제가 있습니다([3] 참조). 현재 해결책은 이미 __getattr____setattr__ 을 사용하지만 여전히 불완전하며, 현재 제안 없이 이를 해결하기는 어렵고 __getattribute__가 필요합니다.
  • _no_slots_copy 해크는 구독할 때마다 클래스 딕셔너리를 정리하여 __slots__를 사용하는 제네릭을 허용합니다.
  • typing 모듈의 일반적인 복잡성입니다. 새 제안은 앞서 언급한 해크/버그를 제거할 수 있을 뿐만 아니라 구현도 단순화하여 유지 관리하기 쉽게 합니다.

사양

__class_getitem__

__class_getitem__의 개념은 간단합니다. 이는 __getitem__에 정확히 대응하지만, 인스턴스가 아니라 이를 정의한 클래스에서 호출된다는 차이가 있습니다. 이를 통해 Iterable[int]와 같은 경우에 GenericMeta.__getitem__을 피할 수 있습니다. __class_getitem__은 자동으로 클래스 메서드가 되므로 @classmethod 데코레이터가 필요하지 않으며(__init_subclass__와 유사합니다), 일반 속성처럼 상속됩니다. 예를 들어 다음과 같습니다.:

class MyList:
    def __getitem__(self, index):
        return index + 1
    def __class_getitem__(cls, item):
        return f"{cls.__name__}[{item.__name__}]"

class MyOtherList(MyList):
    pass

assert MyList()[0] == 1
assert MyList[int] == "MyList[int]"

assert MyOtherList()[0] == 1
assert MyOtherList[int] == "MyOtherList[int]"

이 메서드는 대체 수단으로 사용되므로 메타클래스가 __getitem__을 정의하면 해당 메서드가 우선됩니다.

__mro_entries__

클래스 객체가 아닌 객체가 클래스 정의의 기반 클래스 튜플에 나타나면 해당 객체에서 __mro_entries__ 메서드를 검색합니다. 찾은 경우 원래 기반 클래스 튜플을 인자로 하여 호출합니다. 호출 결과는 튜플이어야 하며, 이 객체 대신 기반 클래스에 풀립니다. (튜플이 비어 있으면 원래 기반 클래스가 단순히 삭제된다는 의미입니다.) __mro_entries__를 가진 객체가 둘 이상이면 모두 동일한 원래 기반 클래스 튜플을 인자로 하여 호출됩니다. 이 단계는 클래스 생성 과정에서 가장 먼저 수행되며, 중복 기반 클래스 검사와 MRO 계산을 포함한 다른 모든 단계는 업데이트된 기반 클래스를 사용하여 정상적으로 수행됩니다.

단순히 속성을 사용하는 대신 메서드 API를 사용하는 것은 일관되지 않은 MRO 오류를 방지하고 현재 GenericMeta.__new__에서 수행하는 다른 조작을 수행하기 위해 필요합니다. 원래 기반 클래스는 클래스 네임스페이스에 __orig_bases__로 저장됩니다(현재도 메타클래스가 이 작업을 수행합니다). 예를 들어 다음과 같습니다.:

class GenericAlias:
    def __init__(self, origin, item):
        self.origin = origin
        self.item = item
    def __mro_entries__(self, bases):
        return (self.origin,)

class NewList:
    def __class_getitem__(cls, item):
        return GenericAlias(cls, item)

class Tokens(NewList[int]):
    ...

assert Tokens.__bases__ == (NewList,)
assert Tokens.__orig_bases__ == (NewList[int],)
assert Tokens.__mro__ == (Tokens, NewList, object)

__mro_entries__를 사용한 해결은 클래스 정의 문장의 베이스에서 오직 발생합니다. 클래스 객체가 필요한 다른 모든 상황에서는 이러한 해석이 발생하지 않으며, 여기에는 isinstanceissubclass 내장 함수가 포함됩니다.

참고: 이 두 메서드 이름은 typing 모듈과 제네릭 타입 처리 메커니즘에서 사용하도록 예약되어 있으며, 다른 용도로 사용하는 것은 권장되지 않습니다. 참조 구현(테스트 포함)은 [4]에서 확인할 수 있으며, 이 제안은 원래 typing 트래커에 게시되어 논의되었습니다. [5]를 참조하십시오.

동적 클래스 생성 및 types.resolve_bases

type.__new__는 MRO 항목 해석을 수행하지 않습니다. 따라서 type('Tokens', (List[int],), {})을 직접 호출하면 실패합니다. 이는 성능상의 이유와 암시적 변환의 수를 최소화하기 위해 수행됩니다. 대신 동적 클래스 생성의 맥락에서 명시적인 __mro_entries__해석을 허용하도록 types 모듈에 도우미 함수 resolve_bases가 추가됩니다. 이에 따라 types.new_class는 하위 호환성을 유지하면서 새로운 클래스 생성 단계를 반영하도록 업데이트됩니다:

def new_class(name, bases=(), kwds=None, exec_body=None):
    resolved_bases = resolve_bases(bases)  # This step is added
    meta, ns, kwds = prepare_class(name, resolved_bases, kwds)
    if exec_body is not None:
        exec_body(ns)
    ns['__orig_bases__'] = bases  # This step is added
    return meta(name, resolved_bases, ns, **kwds)

C 확장에서 __class_getitem__ 사용하기

위에서 언급했듯이 __class_getitem__은 Python 코드에 정의된 경우 자동으로 클래스 메서드가 됩니다. C 확장에서 이 메서드를 정의하려면 METH_O|METH_CLASS 플래그를 사용해야 합니다. 예를 들어 확장 클래스가 제네릭이 되도록 만드는 간단한 방법은 원래 클래스 객체를 단순히 반환하는 메서드를 사용하는 것입니다. 이렇게 하면 런타임에 타입 정보를 완전히 제거하고 모든 검사를 정적 타입 검사기에만 맡길 수 있습니다.:

typedef struct {
    PyObject_HEAD
    /* ... your code ... */
} SimpleGeneric;

static PyObject *
simple_class_getitem(PyObject *type, PyObject *item)
{
    Py_INCREF(type);
    return type;
}

static PyMethodDef simple_generic_methods[] = {
    {"__class_getitem__", simple_class_getitem, METH_O|METH_CLASS, NULL},
    /* ... other methods ... */
};

PyTypeObject SimpleGeneric_Type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    "SimpleGeneric",
    sizeof(SimpleGeneric),
    0,
    .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
    .tp_methods = simple_generic_methods,
};

이러한 클래스는 파이썬 타입 어노테이션에서 일반적인 제네릭으로 사용할 수 있습니다(정적 타입 검사기를 위해 해당하는 스텁 파일을 제공해야 하며, 자세한 내용은 PEP 484를 참조하십시오).:

from simple_extension import SimpleGeneric
from typing import TypeVar

T = TypeVar('T')

Alias = SimpleGeneric[str, T]
class SubClass(SimpleGeneric[T, int]):
    ...

data: Alias[int]  # Works at runtime
more_data: SubClass[str]  # Also works at runtime

typing을 사용하지 않는 사용자에 대한 하위 호환성과 영향

이 제안은 현재 __class_getitem____mro_entries__라는 이름을 사용하는 코드를 깨뜨릴 수 있습니다. (하지만 언어 참조는 문서화되지 않은 모든 던더 이름을 명시적으로 예약해 두었으며 “경고 없는 손상”을 허용합니다. [6]을 참조하십시오.)

이 제안은 현재의 공개 제네릭 타입 API와 거의 완전한 하위 호환성을 지원할 것입니다. 또한 typing 모듈은 여전히 잠정적입니다. 유일한 두 가지 예외는, 현재 issubclass(List[int], List)는 True를 반환하지만 이 제안에서는 TypeError를 발생시킨다는 점과, 첨자를 붙이지 않은 사용자 정의 제네릭의 repr()은 조정할 수 없으며 일반(비제네릭) 클래스의 repr()과 동일해진다는 점입니다.

참조 구현을 사용해 일반(비제네릭) 클래스에 대해 무시할 만한 성능 영향(마이크로벤치마크에서 1% 미만)을 측정했습니다. 동시에 제네릭의 성능은 크게 향상되었습니다:

  • importlib.reload(typing)은 최대 7배 더 빠릅니다
  • 사용자 정의 제네릭 클래스 생성은 최대 4배 더 빠릅니다(빈 본문을 가진 마이크로벤치마크 기준)
  • 제네릭 클래스의 인스턴스화는 최대 5배 더 빠릅니다(빈 __init__을 가진 마이크로벤치마크 기준)
  • 제네릭 타입 및 인스턴스와 관련된 다른 연산(메서드 조회 및 isinstance() 검사 등)은 약 10~20% 개선되었습니다
  • 현재의 개념 증명 구현에서 유일하게 느려지는 측면은 첨자가 붙은 제네릭의 캐시 조회입니다. 하지만 이는 이미 매우 효율적이었으므로, 이 측면이 전체적으로 미치는 영향은 무시할 만합니다.

참고 자료