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 526과 PEP 557은 타입 힌트의 사용 범위를 넓히며, PyPI에 있는 typing 백포트는 월 100만 회 다운로드됩니다. 따라서 이 제약은 없앨 수 있습니다. 제네릭 타입을 더 잘 지원하기 위해 코어 CPython에 __class_getitem__과 __mro_entries__라는 두 특수 메서드를 추가할 것을 제안합니다.

근거

코어 CPython 인터프리터를 수정하지 않는다는 제약 때문에 몇 가지 설계 결정이 내려졌는데, typing 모듈이 널리 사용되기 시작하면서 이 결정들은 의문의 여지가 있게 되었습니다. 주요 우려 사항은 세 가지로, typing 모듈의 성능, 메타클래스 충돌, 그리고 현재 typing에서 사용되는 수많은 핵입니다.

성능

typing 모듈은 온갖 최적화를 적용했는데도 표준 라이브러리에서 가장 무겁고 느린 모듈 중 하나입니다. 주된 원인은 첨자가 붙은 제네릭 타입(이 PEP에서 사용하는 용어의 정의는 PEP 484를 참조하십시오)이 클래스 객체라는 점입니다([1]도 참조하십시오). 제안된 특수 메서드를 활용하면 크게 세 가지 방식으로 성능을 개선할 수 있습니다:

  • GenericMeta.__new__가 매우 느리기 때문에 제네릭 클래스 생성이 느립니다. 이제 더는 이것이 필요하지 않게 됩니다.
  • 제네릭 클래스의 매우 긴 메서드 결정 순서(MRO)가 절반 길이로 줄어듭니다. 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__를 사용한 해석은 클래스 정의 문의 베이스에서만 일어납니다. 그 밖에 클래스 객체가 필요한 모든 상황에서는 이러한 해석이 일어나지 않으며, isinstance와 issubclass 내장 함수도 여기에 해당합니다.

참고: 이 두 메서드 이름은 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,
};

이러한 클래스는 Python 타입 어노테이션에서 일반적인 제네릭처럼 사용할 수 있습니다(정적 타입 검사기를 위해 대응하는 스텁 파일을 제공해야 하며, 자세한 내용은 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 모듈은 아직 잠정적(provisional) 상태입니다. 유일한 두 가지 예외는, 현재 issubclass(List[int], List)가 True를 반환하지만 이 제안에서는 TypeError를 발생시킨다는 점, 그리고 첨자가 붙지 않은 사용자 정의 제네릭의 repr()은 조정할 수 없으며 일반(제네릭이 아닌) 클래스의 repr()과 같아진다는 점입니다.

참조 구현으로 측정한 결과, 일반(제네릭이 아닌) 클래스에 대한 성능 영향은 무시할 만한 수준이었습니다(마이크로벤치마크에서 1% 미만). 동시에 제네릭의 성능은 크게 향상됩니다:

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

참고 문헌