[Final] PEP 253 - Subtyping Built-in Types

원문 링크: PEP 253 - Subtyping Built-in Types

상태: Final 유형: Standards Track 작성일: 14-May-2001

개요 (Abstract)

이 PEP(Python Enhancement Proposal)는 C 및 Python에서 내장 타입(built-in types)의 서브타입(subtype) 생성을 가능하게 하는 타입 객체 API(Type Object API) 추가를 제안합니다.

  • 편집자 주: 이 PEP에서 설명하는 아이디어는 이미 Python에 통합되었습니다. 따라서 이 PEP는 현재 구현을 정확하게 설명하지는 않습니다.

서론 (Introduction)

전통적으로 Python의 타입은 PyTypeObject 타입의 전역 변수를 선언하고 정적 초기화(static initializer)를 통해 정적으로 생성되었습니다. 타입 객체(Type Object)의 슬롯(slot)들은 Python 인터프리터와 관련된 Python 타입의 모든 측면을 설명합니다. 몇몇 슬롯은 인스턴스의 기본 할당 크기(basic allocation size)와 같은 차원 정보를 포함하고, 다른 슬롯들은 다양한 플래그(flag)를 포함하며, 대부분의 슬롯은 다양한 종류의 동작을 구현하는 함수 포인터(function pointer)입니다. NULL 포인터는 해당 타입이 특정 동작을 구현하지 않음을 의미하며, 이 경우 시스템은 기본 동작을 제공하거나 타입 인스턴스에 대해 동작이 호출될 때 예외를 발생시킬 수 있습니다. 일반적으로 함께 정의되는 일부 함수 포인터 컬렉션은 더 많은 함수 포인터를 포함하는 추가 구조체에 대한 포인터를 통해 간접적으로 얻어집니다.

PyTypeObject 구조체를 초기화하는 세부 사항은 문서화되지 않았지만, 소스 코드의 예제를 통해 쉽게 알 수 있으며, 이 PEP는 독자가 C에서 새로운 Python 타입을 생성하는 전통적인 방식에 충분히 익숙하다고 가정합니다.

이 PEP는 다음 기능을 소개합니다.

  • 타입(type)은 해당 인스턴스(instance)를 위한 팩토리 함수(factory function)가 될 수 있습니다.
  • 타입은 C에서 서브타입으로 만들 수 있습니다.
  • 타입은 class 문(statement)을 사용하여 Python에서 서브타입으로 만들 수 있습니다.
  • 타입으로부터의 다중 상속(multiple inheritance)이 지원됩니다 (실용적인 범위 내에서 – 여전히 listdictionary로부터 다중 상속할 수는 없습니다).
  • 표준 타입 변환 함수(예: int, tuple, str 등)는 해당 타입 객체로 재정의되며, 이들은 자체 팩토리 함수 역할을 합니다.
  • 클래스 문은 새 클래스를 만드는 데 사용될 메타클래스(metaclass)를 지정하는 __metaclass__ 선언을 포함할 수 있습니다.
  • 클래스 문은 지원되는 인스턴스 변수의 특정 이름을 지정하는 __slots__ 선언을 포함할 수 있습니다.

이 PEP는 타입에 표준 인트로스펙션(introspection)을 추가하는 PEP 252를 기반으로 합니다. 예를 들어, 특정 타입 객체가 tp_hash 슬롯을 초기화할 때, 해당 타입 객체는 인트로스펙션 시 __hash__ 메서드를 가집니다. PEP 252는 또한 모든 메서드를 포함하는 딕셔너리를 타입 객체에 추가합니다. Python 레벨에서는 이 딕셔너리가 내장 타입에 대해 읽기 전용이지만, C 레벨에서는 직접 접근할 수 있습니다 (초기화의 일부가 아닌 한 수정되어서는 안 됩니다).

이진 호환성(binary compatibility)을 위해 tp_flags 슬롯의 플래그 비트(flag bit)는 아래에 소개된 타입 객체에 있는 다양한 새 슬롯의 존재 여부를 나타냅니다. tp_flags 슬롯에 Py_TPFLAGS_HAVE_CLASS 비트가 설정되지 않은 타입은 모든 서브타이핑 슬롯에 NULL 값을 가진다고 가정합니다.

현재 Python에서는 타입과 클래스가 구별됩니다. 이 PEP는 PEP 254와 함께 이러한 구분을 제거할 예정입니다. 그러나 하위 호환성(backwards compatibility)을 위해 이 구분은 앞으로도 몇 년 동안 유지될 가능성이 높으며, PEP 254가 없다면 그 구분은 여전히 큽니다. 타입은 궁극적으로 내장 타입을 기본 클래스로 가지는 반면, 클래스는 궁극적으로 사용자 정의 클래스에서 파생됩니다. 따라서 이 PEP의 나머지 부분에서는 가능한 한 “타입(type)”이라는 단어를 사용합니다. 여기에는 기본 타입(base type) 또는 슈퍼타입(supertype), 파생 타입(derived type) 또는 서브타입(subtype), 그리고 메타타입(metatype)이 포함됩니다. 그러나 때로는 용어가 반드시 혼합될 때가 있습니다. 예를 들어 객체의 타입은 __class__ 속성으로 주어지며, Python에서의 서브타이핑은 class 문으로 표현됩니다. 추가 구분이 필요한 경우 사용자 정의 클래스는 “클래식(classic)” 클래스로 지칭될 수 있습니다.

메타타입 (About metatypes)

필연적으로 메타타입(또는 메타클래스, metaclasses)에 대한 논의가 나옵니다. 메타타입은 Python에서 새로운 개념이 아닙니다. Python은 항상 타입의 타입에 대해 이야기할 수 있었습니다.

>>> a = 0
>>> type(a)
<class 'int'>
>>> type(type(a))
<class 'type'>
>>> type(type(type(a)))
<class 'type'>

이 예제에서 type(a)는 “일반” 타입이고, type(type(a))는 메타타입입니다. 모든 타입이 동일한 메타타입( PyType_Type, 이는 또한 그 자체의 메타타입이기도 함)을 가지지만, 이것이 요구 사항은 아니며, 실제로 유용하고 관련성 있는 서드파티 확장(Jim Fulton의 ExtensionClasses)은 추가 메타타입을 생성합니다. types.ClassType으로 알려진 클래식 클래스의 타입도 별개의 메타타입으로 간주될 수 있습니다.

메타타입과 밀접하게 관련된 기능은 “Don Beaudry hook”입니다. 이는 메타타입이 호출 가능(callable)하면, 해당 인스턴스(일반 타입)를 Python class 문을 사용하여 서브클래스화(실제로는 서브타입화)할 수 있다고 말합니다. 이 규칙을 사용하여 내장 타입의 서브타이핑을 지원할 것이며, 사실 이는 항상 단순히 메타타입을 호출하도록 클래스 생성 로직을 크게 단순화합니다. 기본 클래스가 지정되지 않은 경우 기본 메타타입이 호출됩니다. 기본 메타타입은 “ClassType” 객체이므로, 클래스 문은 일반적인 경우와 동일하게 동작합니다. (이 기본값은 전역 변수 __metaclass__를 설정하여 모듈별로 변경할 수 있습니다.)

Python은 Smalltalk와는 다른 방식으로 메타타입 또는 메타클래스 개념을 사용합니다. Smalltalk-80에서는 일반 클래스 계층 구조를 반영하는 메타클래스 계층 구조가 있으며, 메타클래스는 클래스와 1대1로 매핑되고(계층 구조의 루트에서 일부 특이한 경우를 제외하고), 각 class 문은 일반 클래스와 해당 메타클래스를 모두 생성하며, 클래스 메서드는 메타클래스에, 인스턴스 메서드는 일반 클래스에 배치합니다.

Smalltalk의 맥락에서는 좋을지 모르지만, Python의 전통적인 메타타입 사용 방식과는 호환되지 않으며, Python 방식을 계속 따르는 것을 선호합니다. 이는 Python 메타타입이 일반적으로 C로 작성되며, 많은 일반 타입들 간에 공유될 수 있음을 의미합니다. (Python에서 메타타입을 서브타입으로 만드는 것이 가능하므로, 메타타입을 사용하기 위해 C를 작성하는 것이 절대적으로 필요하지는 않습니다. 그러나 Python 메타타입의 기능은 제한적일 것입니다. 예를 들어, Python 코드가 원시 메모리(raw memory)를 할당하고 임의로 초기화하는 것은 허용되지 않습니다.)

메타타입은 타입이 호출될 때 발생하는 일, 동적 타입의 정도(타입의 __dict__가 생성된 후 수정될 수 있는지 여부), 메서드 해석 순서(MRO), 인스턴스 속성(attribute)이 조회되는 방식 등 타입에 대한 다양한 정책을 결정합니다.

이 문서에서는 다중 상속을 최대한 활용하려면 좌측 우선 깊이 우선(left-to-right depth-first) 방식이 최선의 해결책이 아니라고 주장합니다. 또한 다중 상속에서 서브타입의 메타타입은 모든 기본 타입의 메타타입의 자손이어야 한다고 주장합니다. 메타타입에 대해서는 나중에 다시 다룹니다.

타입 인스턴스 생성을 위한 팩토리로서의 타입 (Making a type a factory for its instances)

전통적으로 각 타입에는 해당 타입의 인스턴스를 생성하는 C 팩토리 함수(예: PyTuple_New(), PyInt_FromLong() 등)가 하나 이상 있었습니다. 이러한 팩토리 함수는 객체에 대한 메모리 할당과 해당 메모리 초기화를 모두 담당합니다. Python 2.0부터는 타입이 가비지 컬렉션(garbage collection)에 참여하기로 선택하는 경우(선택 사항이지만, 다른 객체에 대한 참조를 포함할 수 있고 따라서 참조 순환(reference cycle)에 참여할 수 있는 “컨테이너” 타입의 경우 강력히 권장됨) 가비지 컬렉션 서브시스템과 인터페이스해야 합니다.

이 제안에서는 타입 객체가 해당 인스턴스에 대한 팩토리 함수가 될 수 있으므로, Python에서 타입을 직접 호출할 수 있게 됩니다. 이는 클래스가 인스턴스화되는 방식을 모방합니다. 다양한 내장 타입의 인스턴스를 생성하기 위한 C API는 유효하며 경우에 따라 더 효율적일 것입니다. 모든 타입이 자체 팩토리 함수가 되지는 않습니다.

타입 객체는 tp_new라는 새 슬롯을 가지며, 이는 타입 인스턴스의 팩토리 역할을 할 수 있습니다. PyType_Type (메타타입)에 tp_call 슬롯이 설정되어 있기 때문에 이제 타입은 호출 가능합니다. 이 함수는 호출되는 타입의 tp_new 슬롯을 찾습니다.

설명: 일반 타입 객체(예: PyInt_Type 또는 PyList_Type)의 tp_call 슬롯은 해당 타입의 인스턴스가 호출될 때 발생하는 일을 정의합니다. 특히, 함수 타입인 PyFunction_Typetp_call 슬롯은 함수를 호출 가능하게 만드는 핵심입니다. 또 다른 예로, PyInt_Type.tp_callNULL입니다. 왜냐하면 정수는 호출 가능하지 않기 때문입니다. 새로운 패러다임은 타입 객체를 호출 가능하게 만듭니다. 타입 객체는 메타타입(PyType_Type)의 인스턴스이므로, 메타타입의 tp_call 슬롯(PyType_Type.tp_call)은 어떤 타입 객체가 호출될 때 호출되는 함수를 가리킵니다. 이제 각 타입은 자신만의 인스턴스를 생성하기 위해 다른 작업을 수행해야 하므로, PyType_Type.tp_call은 호출되는 타입의 tp_new 슬롯으로 즉시 위임합니다. PyType_Type 자체도 호출 가능합니다. PyType_Typetp_new 슬롯은 새 타입을 생성합니다. 이는 클래스 문에서 사용됩니다(Don Beaudry hook을 공식화, 위 참조). 그렇다면 PyType_Type을 호출 가능하게 만드는 것은 무엇일까요? PyType_Type의 메타타입의 tp_call 슬롯입니다. 하지만 PyType_Type은 그 자체의 메타타입이므로, 그것은 그 자체의 tp_call 슬롯입니다!

타입의 tp_new 슬롯이 NULL이면 예외가 발생합니다. 그렇지 않으면 tp_new 슬롯이 호출됩니다. tp_new 슬롯의 시그니처는 다음과 같습니다.

PyObject *tp_new(PyTypeObject *type, PyObject *args, PyObject *kwds)

여기서 'type'tp_new 슬롯이 호출되는 타입이고, 'args''kwds'는 호출에 대한 순차적 인자(sequential argument)와 키워드 인자(keyword argument)이며, tp_call로부터 변경 없이 전달됩니다. ('type' 인자는 상속과 함께 사용됩니다. 아래 참조.)

반환되는 객체 타입에는 제약이 없지만, 관례적으로 주어진 타입의 인스턴스여야 합니다. 새 객체가 반환될 필요는 없으며, 기존 객체에 대한 참조도 괜찮습니다. 반환 값은 항상 호출자가 소유하는 새 참조여야 합니다.

tp_new 슬롯이 객체를 반환한 후, 결과 객체의 타입에 대한 tp_init() 슬롯이 NULL이 아니면 호출하여 추가 초기화를 시도합니다. 이 함수의 시그니처는 다음과 같습니다.

int tp_init(PyObject *self, PyObject *args, PyObject *kwds)

이는 클래식 클래스의 __init__() 메서드와 더 밀접하게 관련되며, 사실 슬롯/특별 메서드 대응 규칙에 의해 이 메서드에 매핑됩니다. tp_new() 슬롯과 tp_init() 슬롯 간의 책임 차이는 보장하는 불변성(invariants)에 있습니다. tp_new() 슬롯은 객체를 구현하는 C 코드가 손상되지 않도록 하는 가장 필수적인 불변성만을 보장해야 합니다. tp_init() 슬롯은 오버라이드 가능한 사용자 특정 초기화에 사용되어야 합니다. 예를 들어 딕셔너리 타입을 생각해보십시오. 구현은 해시 테이블에 대한 내부 포인터를 가지며, 이는 절대 NULL이어서는 안 됩니다. 이 불변성은 딕셔너리의 tp_new() 슬롯에서 처리됩니다. 반면에 딕셔너리 tp_init() 슬롯은 전달된 인자를 기반으로 딕셔너리에 초기 키와 값 집합을 제공하는 데 사용될 수 있습니다.

불변 객체(immutable object) 타입의 경우, 초기화는 tp_init() 슬롯에서 수행할 수 없습니다. 이는 Python 사용자에게 초기화를 변경할 수 있는 방법을 제공하기 때문입니다. 따라서 불변 객체는 일반적으로 빈 tp_init() 구현을 가지며 모든 초기화를 tp_new() 슬롯에서 수행합니다.

tp_new() 슬롯이 tp_init() 슬롯을 직접 호출해서는 안 되는 이유가 궁금할 수 있습니다. 그 이유는 특정 상황(예: 지속성 객체 지원)에서 필요한 만큼만 초기화하고 특정 타입의 객체를 생성할 수 있는 것이 중요하기 때문입니다. 이는 tp_init()를 호출하지 않고 tp_new() 슬롯을 호출함으로써 편리하게 수행될 수 있습니다. tp_init()가 호출되지 않거나 여러 번 호출될 수도 있습니다. 이러한 비정상적인 경우에도 그 동작은 견고해야 합니다.

일부 객체의 경우 tp_new()가 기존 객체를 반환할 수 있습니다. 예를 들어, 정수에 대한 팩토리 함수는 -1부터 99까지의 정수를 캐시합니다. 이는 tp_new()에 대한 type 인자가 tp_new() 함수를 정의한 타입이고(예제에서 type == &PyInt_Type), 이 타입에 대한 tp_init() 슬롯이 아무것도 하지 않는 경우에만 허용됩니다. type 인자가 다른 경우, tp_new() 호출은 파생 타입의 tp_new()에 의해 시작되어 객체를 생성하고 객체의 기본 타입 부분을 초기화합니다. 이 경우 tp_new()는 항상 새 객체를 반환해야 합니다(또는 예외를 발생시켜야 합니다).

tp_new()tp_init()는 정확히 동일한 argskwds 인자를 받아야 하며, 독립적으로 호출될 수 있으므로 인자가 허용 가능한지 모두 확인해야 합니다.

객체 생성과 관련된 세 번째 슬롯은 tp_alloc()입니다. tp_alloc()의 책임은 객체에 대한 메모리를 할당하고, 참조 횟수(ob_refcnt)와 타입 포인터(ob_type)를 초기화하며, 객체의 나머지 부분을 모두 0으로 초기화하는 것입니다. 또한 타입이 가비지 컬렉션을 지원하는 경우 객체를 가비지 컬렉션 서브시스템에 등록해야 합니다. 이 슬롯은 파생 타입이 초기화 코드와 별개로 메모리 할당 정책(예: 어떤 힙이 사용되는지)을 재정의할 수 있도록 존재합니다. 시그니처는 다음과 같습니다.

PyObject *tp_alloc(PyTypeObject *type, int nitems)

type 인자는 새 객체의 타입입니다. nitems 인자는 일반적으로 0이지만, 가변 할당 크기(variable allocation size)를 가진 객체(기본적으로 문자열, 튜플, 롱)의 경우는 예외입니다. 할당 크기는 다음 표현식으로 주어집니다.

type->tp_basicsize + nitems * type->tp_itemsize

tp_alloc 슬롯은 서브클래스화 가능한 타입에만 사용됩니다. 기본 클래스의 tp_new() 함수는 첫 번째 인자로 전달된 타입의 tp_alloc() 슬롯을 호출해야 합니다. 항목 수를 계산하는 것은 tp_new() 함수의 책임입니다. tp_alloc() 슬롯은 type->tp_itemsize 멤버가 0이 아닌 경우 새 객체의 ob_size 멤버를 설정합니다.

(참고: 특정 디버깅 컴파일 모드에서 타입 구조체에는 이미 tp_alloctp_free 슬롯, 할당 및 할당 해제 횟수를 위한 카운터라는 멤버가 있었습니다. 이들은 tp_allocstp_deallocs로 이름이 변경되었습니다.)

tp_alloc()tp_new()에 대한 표준 구현이 제공됩니다. PyType_GenericAlloc()은 표준 힙에서 객체를 할당하고 적절하게 초기화합니다. 이는 위의 공식을 사용하여 할당할 메모리 양을 결정하고 GC 등록을 처리합니다. 이 구현을 사용하지 않을 유일한 이유는 다른 힙에서 객체를 할당하는 것입니다(일부 매우 작고 자주 사용되는 객체인 inttuple이 그렇게 합니다). PyType_GenericNew()는 매우 적은 것을 추가합니다. nitems에 0을 사용하여 타입의 tp_alloc() 슬롯을 호출할 뿐입니다. 그러나 모든 초기화를 tp_init() 슬롯에서 수행하는 변경 가능한 타입의 경우, 이것이 바로 적절할 수 있습니다.

서브타이핑을 위한 타입 준비 (Preparing a type for subtyping)

서브타이핑의 아이디어는 C++의 단일 상속(single inheritance)과 매우 유사합니다. 기본 타입은 구조체 선언(C++ 클래스 선언과 유사)과 타입 객체(C++ 가상 테이블, vtable과 유사)로 설명됩니다. 파생 타입은 구조체를 확장할 수 있으며(단, 기본 구조체의 멤버 이름, 순서 및 타입은 변경하지 않아야 함), 타입 객체의 특정 슬롯을 재정의하고 다른 슬롯은 그대로 둘 수 있습니다. (C++ vtable과 달리 모든 Python 타입 객체는 동일한 메모리 레이아웃을 가집니다.)

기본 타입은 다음을 수행해야 합니다.

  • tp_flags에 플래그 값 Py_TPFLAGS_BASETYPE을 추가합니다.
  • tp_new(), tp_alloc(), 그리고 선택적으로 tp_init() 슬롯을 선언하고 사용합니다.
  • tp_dealloc()tp_free()를 선언하고 사용합니다.
  • 객체 구조체 선언을 export 합니다.
  • 서브타이핑을 인식하는 타입 검사 매크로(type-checking macro)를 export 합니다.

tp_new(), tp_alloc(), tp_init()의 요구 사항과 시그니처는 위에서 이미 논의되었습니다. tp_alloc()은 메모리를 할당하고 대부분 0으로 초기화해야 합니다. tp_new()tp_alloc() 슬롯을 호출한 다음 최소한으로 필요한 초기화를 진행해야 합니다. tp_init()는 변경 가능한 객체(mutable objects)의 보다 광범위한 초기화에 사용되어야 합니다.

객체 수명 주기 끝에도 유사한 규칙이 있다는 것은 놀라운 일이 아닙니다. 관련된 슬롯은 tp_dealloc() (Python 확장 타입을 구현해본 모든 사람에게 익숙함)과 새로운 tp_free()입니다. (tp_free()tp_alloc()에 해당하고, tp_dealloc()tp_new()에 해당하여 이름이 완전히 대칭적이지는 않습니다. tp_dealloc 슬롯의 이름을 변경해야 할까요?)

tp_free() 슬롯은 메모리를 해제하고 가비지 컬렉션 서브시스템에서 객체를 등록 해제하는 데 사용되어야 하며, 파생 클래스에 의해 재정의될 수 있습니다. tp_dealloc()은 객체를 비초기화하고(일반적으로 다양한 하위 객체에 대해 Py_XDECREF()를 호출하여) 메모리를 할당 해제하기 위해 tp_free()를 호출해야 합니다. tp_dealloc()의 시그니처는 항상 동일합니다.

void tp_dealloc(PyObject *object)

tp_free()의 시그니처도 동일합니다.

void tp_free(PyObject *object)

(이 PEP의 이전 버전에서는 tp_clear() 슬롯에 대한 역할도 있었습니다. 이는 좋지 않은 아이디어로 판명되었습니다.)

C에서 유용하게 서브타이핑되려면, 타입은 서브타입을 파생하는 데 필요하므로 헤더 파일(header file)을 통해 인스턴스에 대한 구조체 선언을 export해야 합니다. 기본 타입에 대한 타입 객체도 export되어야 합니다.

기본 타입이 타입 검사 매크로(예: PyDict_Check())를 가지고 있다면, 이 매크로는 서브타입을 인식하도록 만들어져야 합니다. 이는 기본 클래스 링크를 따라가는 함수를 호출하는 새로운 PyObject_TypeCheck(object, type) 매크로를 사용하여 수행할 수 있습니다.

PyObject_TypeCheck() 매크로는 약간의 최적화를 포함합니다. 먼저 object->ob_typetype 인자와 직접 비교하고, 일치하는 경우 함수 호출을 건너뜁니다. 이는 대부분의 상황에서 충분히 빠르게 만듭니다.

이러한 타입 검사 매크로의 변경은 기본 타입의 인스턴스를 요구하는 C 함수가 파생 타입의 인스턴스로 호출될 수 있음을 의미합니다. 특정 타입의 서브타이핑을 활성화하기 전에, 코드를 확인하여 아무것도 손상되지 않도록 해야 합니다. 프로토타입에서는 내장 Python 객체 타입에 대한 또 다른 타입 검사 매크로를 추가하여 정확한 타입 일치도 확인하는 것이 유용하다는 것이 입증되었습니다(예: PyDict_Check(x)x가 딕셔너리 또는 딕셔너리 서브클래스의 인스턴스인 경우 True이지만, PyDict_CheckExact(x)x가 딕셔너리인 경우에만 True입니다).

C에서 내장 타입의 서브타입 생성 (Creating a subtype of a built-in type in C)

가장 간단한 형태의 서브타이핑은 C에서의 서브타이핑입니다. 이는 C 코드가 일부 문제를 인지하도록 요구할 수 있고, 규칙을 따르지 않는 C 코드가 코어 덤프를 생성하는 것이 허용되기 때문에 가장 간단한 형태입니다. 추가적인 단순화를 위해 단일 상속(single inheritance)으로 제한됩니다.

tp_itemsize가 0인 변경 가능한 기본 타입에서 파생된다고 가정해 봅시다. 서브타입 코드는 GC를 인식하지 못하지만, 기본 타입으로부터 GC 인지 기능을 상속받을 수 있습니다(이는 자동입니다). 기본 타입의 할당은 표준 힙을 사용합니다.

파생 타입은 기본 타입의 구조체를 포함하는 타입 구조체를 선언하는 것으로 시작합니다. 예를 들어, 내장 list 타입의 서브타입에 대한 타입 구조체는 다음과 같습니다.

typedef struct {
    PyListObject list;
    int state;
} spamlistobject;

기본 타입 구조체 멤버(여기서는 PyListObject)는 구조체의 첫 번째 멤버여야 합니다. 그 뒤에 오는 멤버는 추가된 것입니다. 또한 기본 타입이 포인터를 통해 참조되지 않습니다. 해당 구조체의 실제 내용이 포함되어야 합니다! (목표는 서브타입 인스턴스 시작 부분의 메모리 레이아웃이 기본 타입 인스턴스의 메모리 레이아웃과 동일하도록 하는 것입니다.)

다음으로, 파생 타입은 타입 객체를 선언하고 초기화해야 합니다. 타입 객체의 대부분의 슬롯은 0으로 초기화될 수 있으며, 이는 기본 타입 슬롯이 복사되어야 함을 나타내는 신호입니다. 적절하게 초기화되어야 하는 일부 슬롯은 다음과 같습니다.

  • 객체 헤더는 평소와 같이 채워져야 합니다. 타입은 &PyType_Type이어야 합니다.
  • tp_basicsize 슬롯은 서브타입 인스턴스 구조체의 크기(위 예제: sizeof(spamlistobject))로 설정되어야 합니다.
  • tp_base 슬롯은 기본 타입의 타입 객체 주소로 설정되어야 합니다.
  • 파생 슬롯이 포인터 멤버를 정의하는 경우, tp_dealloc 슬롯 함수에 특별한 주의가 필요합니다(아래 참조). 그렇지 않으면, 기본 타입의 할당 해제 함수를 상속받기 위해 0으로 설정할 수 있습니다.
  • tp_flags 슬롯은 일반적인 Py_TPFLAGS_DEFAULT 값으로 설정되어야 합니다.
  • tp_name 슬롯은 설정되어야 합니다. tp_doc도 설정하는 것이 좋습니다(이들은 상속되지 않습니다).

서브타입이 추가 구조체 멤버를 정의하지 않는 경우(새로운 동작만 정의하고 새로운 데이터는 정의하지 않음), tp_basicsizetp_dealloc 슬롯은 0으로 설정된 채로 남겨둘 수 있습니다.

서브타입의 tp_dealloc 슬롯은 특별한 주의가 필요합니다. 파생 타입이 객체가 할당 해제될 때 DECREF되거나 해제되어야 하는 추가 포인터 멤버를 정의하지 않는 경우, 0으로 설정할 수 있습니다. 그렇지 않으면 서브타입의 tp_dealloc() 함수는 PyObject * 멤버에 대해 Py_XDECREF()를 호출하고, 소유하는 다른 포인터에 대해 올바른 메모리 해제 함수를 호출한 다음, 기본 클래스의 tp_dealloc() 슬롯을 호출해야 합니다. 이 호출은 기본 타입의 타입 구조체를 통해 이루어져야 합니다. 예를 들어 표준 list 타입에서 파생될 때 다음과 같습니다.

PyList_Type.tp_dealloc(self);

서브타입이 기본 타입과 다른 할당 힙을 사용하려면, 서브타입은 tp_alloc()tp_free() 슬롯을 모두 재정의해야 합니다. 이들은 각각 기본 클래스의 tp_new()tp_dealloc() 슬롯에 의해 호출될 것입니다.

타입의 초기화를 완료하려면 PyType_InitDict()가 호출되어야 합니다. 이는 서브타입에서 0으로 초기화된 슬롯을 해당 기본 타입 슬롯의 값으로 대체합니다. (또한 tp_dict, 즉 타입의 딕셔너리를 채우고 타입 객체에 필요한 다양한 다른 초기화를 수행합니다.)

서브타입은 PyType_InitDict()가 호출될 때까지 사용할 수 없습니다. 이는 서브타입이 모듈에 속한다고 가정할 때 모듈 초기화 중에 수행하는 것이 가장 좋습니다. Python 코어에 추가된 서브타입(특정 모듈에 속하지 않음)에 대한 대안은 생성자 함수에서 서브타입을 초기화하는 것입니다. PyType_InitDict()를 여러 번 호출하는 것이 허용됩니다. 두 번째 및 그 이후의 호출은 아무런 효과가 없습니다. 불필요한 호출을 피하기 위해 tp_dict==NULL에 대한 테스트를 수행할 수 있습니다.

(Python 인터프리터 초기화 중에는 일부 타입이 실제로 초기화되기 전에 사용됩니다. 실제로 필요한 슬롯, 특히 tp_dealloc이 초기화되는 한 작동하지만, 이는 취약하며 일반적인 관행으로는 권장되지 않습니다.)

서브타입 인스턴스를 생성하려면 서브타입의 tp_new() 슬롯이 호출됩니다. 이는 먼저 기본 타입의 tp_new() 슬롯을 호출한 다음 서브타입의 추가 데이터 멤버를 초기화해야 합니다. 인스턴스를 추가로 초기화하려면 tp_init() 슬롯이 일반적으로 호출됩니다. tp_new() 슬롯은 tp_init() 슬롯을 호출해서는 안 됩니다. 이는 tp_new()를 호출하는 쪽(일반적으로 팩토리 함수)의 책임입니다. tp_init()를 호출하지 않는 것이 적절한 상황이 있습니다.

서브타입이 tp_init() 슬롯을 정의하는 경우, tp_init() 슬롯은 일반적으로 먼저 기본 타입의 tp_init() 슬롯을 호출해야 합니다.

(XXX 여기에 인자 전달에 대한 한두 단락이 있어야 합니다.)

Python에서의 서브타이핑 (Subtyping in Python)

다음 단계는 Python의 class 문을 통해 선택된 내장 타입의 서브타이핑을 허용하는 것입니다. 지금은 단일 상속으로 제한하면, 간단한 클래스 문에 대해 다음과 같은 일이 발생합니다.

class C(B):
    var1 = 1
    def method1(self):
        pass # etc.

클래스 문의 본문은 새로운 환경(기본적으로 로컬 네임스페이스로 사용되는 새 딕셔너리)에서 실행된 다음, C가 생성됩니다. 다음은 C가 어떻게 생성되는지 설명합니다.

B가 타입 객체라고 가정해 봅시다. 타입 객체는 객체이고 모든 객체는 타입을 가지므로, B도 타입을 가집니다. B 자체가 타입이므로, B의 타입도 메타타입이라고 부릅니다. B의 메타타입은 type(B) 또는 B.__class__를 통해 접근할 수 있습니다(후자의 표기법은 타입에 대한 새로운 것입니다. PEP 252에서 도입되었습니다). 이 메타타입을 M (메타타입의 약자)이라고 합시다. 클래스 문은 새로운 타입 C를 생성할 것입니다. CB와 마찬가지로 타입 객체가 될 것이므로, C의 생성을 메타타입 M의 인스턴스화로 간주합니다. 서브클래스 생성을 위해 제공해야 하는 정보는 다음과 같습니다.

  • 이름 (이 예제에서는 문자열 “C”);
  • 기본 클래스 (B를 포함하는 단일 튜플);
  • 클래스 본문 실행 결과, 딕셔너리 형태 (예: {"var1": 1, "method1": <function method1 at ...>, ...}).

클래스 문은 다음 호출을 발생시킵니다.

C = M("C", (B,), dict)

여기서 dict는 클래스 본문 실행의 결과인 딕셔너리입니다. 즉, 메타타입(M)이 호출됩니다.

예제에 기본 클래스가 하나뿐이더라도, 여전히 (단일 항목) 기본 클래스 시퀀스를 전달한다는 점에 유의하십시오. 이는 다중 상속의 경우와 인터페이스를 통일시킵니다.

현재 Python에서는 이것이 발명자의 이름을 따서 “Don Beaudry hook”이라고 불립니다. 이는 기본 클래스가 일반 클래스가 아닌 경우에만 호출되는 예외적인 경우입니다. 일반 기본 클래스(또는 기본 클래스가 지정되지 않은 경우)의 경우 현재 Python은 클래스를 위한 C 레벨 팩토리 함수인 PyClass_New()를 직접 호출합니다.

새로운 시스템에서는 Python이 항상 메타타입을 결정하고 위에서 주어진 대로 호출하도록 변경됩니다. 하나 이상의 기본 클래스가 주어지면 첫 번째 기본 클래스의 타입이 메타타입으로 사용됩니다. 기본 클래스가 주어지지 않으면 기본 메타타입이 선택됩니다. 기본 메타타입을 “클래식” 클래스의 메타타입인 PyClass_Type으로 설정함으로써 클래스 문의 클래식 동작이 유지됩니다. 이 기본값은 전역 변수 __metaclass__를 설정하여 모듈별로 변경할 수 있습니다.

여기에는 두 가지 추가적인 개선 사항이 있습니다. 첫째, 메타타입을 직접 지정할 수 있는 유용한 기능이 있습니다. 클래스 스위트(class suite)가 __metaclass__ 변수를 정의하는 경우, 해당 변수가 호출될 메타타입입니다. (모듈 레벨에서 __metaclass__를 설정하는 것은 기본 클래스나 명시적인 __metaclass__ 선언이 없는 클래스 문에만 영향을 미치지만, 클래스 스위트에서 __metaclass__를 설정하는 것은 기본 메타타입을 무조건 재정의합니다.)

둘째, 여러 기본 클래스가 있는 경우 모든 기본 클래스가 동일한 메타타입을 가질 필요는 없습니다. 이를 메타클래스 충돌(metaclass conflict)이라고 합니다. 일부 메타클래스 충돌은 주어진 다른 모든 메타타입에서 파생된 메타타입을 기본 클래스 집합에서 검색하여 해결할 수 있습니다. 그러한 메타타입을 찾을 수 없으면 예외가 발생하고 클래스 문이 실패합니다.

이 충돌 해결은 메타타입 생성자(metatype constructor)에 의해 구현될 수 있습니다. 클래스 문은 첫 번째 기본 클래스(또는 __metaclass__ 변수로 지정된)의 메타타입을 호출하고, 이 메타타입의 생성자는 가장 많이 파생된 메타타입을 찾습니다. 그것이 자기 자신인 경우 진행하고, 그렇지 않으면 해당 메타타입의 생성자를 호출합니다. (궁극적인 유연성: 다른 메타타입은 모든 기본 클래스가 동일한 메타타입을 가져야 한다거나, 기본 클래스가 하나만 있어야 한다거나 하는 등의 요구 사항을 선택할 수 있습니다.)

(에서는 주어진 모든 메타클래스의 서브클래스인 새 메타클래스가 자동으로 파생됩니다. 그러나 다양한 메타클래스의 충돌하는 메서드 정의를 Python에서 어떻게 병합해야 하는지는 의문이므로, 이는 실현 가능하다고 생각하지 않습니다. 필요하다면 사용자가 그러한 메타클래스를 수동으로 파생하고 __metaclass__ 변수를 사용하여 지정할 수 있습니다. 이를 수행하는 새 메타클래스를 갖는 것도 가능합니다.)

M을 호출하려면 M 자체가 타입을 가져야 합니다. 즉, 메타-메타타입(meta-metatype)이 필요합니다. 그리고 메타-메타타입은 타입을 가지며, 메타-메타-메타타입이 됩니다. 이는 일반적으로 어떤 수준에서 메타타입이 자기 자신의 메타타입이 되도록 하여 단축됩니다. 실제로 Python에서는 PyType_Typeob_type 참조가 &PyType_Type으로 설정됩니다. 서드파티 메타타입이 없는 경우, PyType_Type은 Python 인터프리터에서 유일한 메타타입입니다.

(이 PEP의 이전 버전에서는 추가적인 메타 레벨이 하나 더 있었고, “turtle”이라는 메타-메타타입이 있었습니다. 이는 불필요한 것으로 판명되었습니다.)

어떤 경우든 C 생성을 위한 작업은 Mtp_new() 슬롯에 의해 수행됩니다. Mtp_new() 슬롯은 “확장된” 타입 구조체(extended type structure)를 위한 공간을 할당합니다. 이 구조체에는 타입 객체, 보조 구조체(as_sequence 등), 타입 이름(타입 객체가 여전히 참조하는 동안 이 객체가 할당 해제되지 않도록 보장하기 위함)을 포함하는 문자열 객체, 그리고 일부 보조 저장 공간(나중에 설명될 것임)이 포함됩니다. Mtp_new() 슬롯은 이 저장 공간을 몇 가지 중요한 슬롯을 제외하고 모두 0으로 초기화하고(예: tp_name은 타입 이름을 가리키도록 설정됨) tp_base 슬롯을 B를 가리키도록 설정합니다. 그런 다음 PyType_InitDict()가 호출되어 B의 슬롯을 상속합니다. 마지막으로, Ctp_dict 슬롯은 네임스페이스 딕셔너리(M에 대한 호출의 세 번째 인자)의 내용으로 업데이트됩니다.

다중 상속 (Multiple inheritance)

Python class 문은 다중 상속을 지원하며, 내장 타입과 관련된 다중 상속도 지원할 것입니다.

그러나 몇 가지 제한 사항이 있습니다. C 런타임 아키텍처는 몇 가지 퇴화된 경우(degenerate cases)를 제외하고는 두 개의 다른 내장 타입의 의미 있는 서브타입을 갖는 것을 실현 불가능하게 만듭니다. 완전히 일반적인 다중 상속을 지원하도록 C 런타임을 변경하는 것은 코드베이스에 너무 큰 혼란을 줄 것입니다.

서로 다른 내장 타입으로부터의 다중 상속의 주요 문제는 내장 타입의 C 구현이 구조체 멤버에 직접 접근한다는 사실에서 비롯됩니다. C 컴파일러는 객체 포인터에 대한 오프셋(offset)을 생성하며 그것으로 끝입니다. 예를 들어 listdictionary 타입 구조체는 각각 여러 개의 다르지만 겹치는 구조체 멤버를 선언합니다. list를 예상하는 객체에 접근하는 C 함수는 dictionary가 전달될 때 작동하지 않으며, 그 반대도 마찬가지입니다. listdictionary에 접근하는 모든 코드를 다시 작성하지 않고는 이 문제에 대해 할 수 있는 일이 많지 않을 것입니다. 이는 너무 많은 작업이므로 수행하지 않을 것입니다.

다중 상속의 문제는 충돌하는 구조체 멤버 할당으로 인해 발생합니다. Python에서 정의된 클래스는 일반적으로 인스턴스 변수를 구조체 멤버에 저장하지 않고 인스턴스 딕셔너리에 저장합니다. 이것이 부분적인 해결책의 핵심입니다. 다음 두 클래스가 있다고 가정해 봅시다.

class A(dictionary):
    def foo(self): pass
class B(dictionary):
    def bar(self): pass
class C(A, B): pass

(여기서 ‘dictionary’는 내장 딕셔너리 객체의 타입이며, type({}) 또는 {}.__class__ 또는 types.DictType과 동일합니다.) 구조체 레이아웃을 살펴보면, A 인스턴스는 딕셔너리 레이아웃 뒤에 __dict__ 포인터가 오고, B 인스턴스는 동일한 레이아웃을 가집니다. 구조체 멤버 레이아웃 충돌이 없으므로 괜찮습니다.

또 다른 예입니다.

class X(object):
    def foo(self): pass
class Y(dictionary):
    def bar(self): pass
class Z(X, Y): pass

(여기서 ‘object’는 모든 내장 타입의 기본 클래스이며, 해당 구조체 레이아웃에는 ob_refcntob_type 멤버만 포함됩니다.) 이 예제는 더 복잡합니다. 왜냐하면 X 인스턴스의 __dict__ 포인터는 Y 인스턴스의 __dict__ 포인터와 다른 오프셋을 가지기 때문입니다. Z 인스턴스의 __dict__ 포인터는 어디에 있을까요? __dict__ 포인터에 대한 오프셋은 하드코딩되지 않고 타입 객체에 저장됩니다.

특정 머신에서 ‘object’ 구조체가 8바이트이고, ‘dictionary’ 구조체가 60바이트이며, 객체 포인터가 4바이트라고 가정해 봅시다. 그러면 X 구조체는 12바이트(객체 구조체 뒤에 __dict__ 포인터)이고, Y 구조체는 64바이트(딕셔너리 구조체 뒤에 __dict__ 포인터)입니다. Z 구조체는 이 예제에서 Y 구조체와 동일한 레이아웃을 가집니다. 각 타입 객체(X, Y, Z)는 __dict__ 포인터를 찾는 데 사용되는 “dict 오프셋”을 가집니다. 따라서 인스턴스 변수를 조회하는 방법은 다음과 같습니다.

  1. 인스턴스의 타입을 얻습니다.
  2. 타입 객체에서 __dict__ 오프셋을 얻습니다.
  3. 인스턴스 포인터에 __dict__ 오프셋을 더합니다.
  4. 결과 주소에서 딕셔너리 참조를 찾습니다.
  5. 해당 딕셔너리에서 인스턴스 변수 이름을 찾습니다.

물론 이 방법은 C에서만 구현될 수 있으며, 일부 세부 사항은 생략했습니다. 그러나 이를 통해 클래식 클래스와 유사한 다중 상속 패턴을 사용할 수 있습니다.

(XXX 기본 클래스 호환성을 결정하기 위한 완전한 알고리즘을 여기에 작성해야 하지만, 지금은 귀찮습니다. 아래에 언급된 구현의 typeobject.c에서 best_base()를 참조하십시오.)

MRO: 메서드 해석 순서 (MRO: Method resolution order (the lookup rule))

다중 상속에는 메서드 해석 순서(Method Resolution Order, MRO) 문제가 수반됩니다. 이는 주어진 이름의 메서드를 찾기 위해 클래스 또는 타입과 그 기본 클래스를 검색하는 순서입니다.

클래식 Python에서는 규칙이 다음과 같은 재귀 함수로 주어지며, 이는 좌측 우선 깊이 우선(left-to-right depth-first) 규칙으로도 알려져 있습니다.

def classic_lookup(cls, name):
    if name in cls.__dict__:
        return cls.__dict__[name]
    for base in cls.__bases__:
        try:
            return classic_lookup(base, name)
        except AttributeError:
            pass
    raise AttributeError(name)

이 문제점은 “다이아몬드 다이어그램(diamond diagram)”을 고려할 때 명확해집니다.

    class A:
    ^       ^
    |       |  def save(self): ...
   /         \
  /           \
 /             \
class B       class C:
    ^       ^  def save(self): ...
     \     /
      \   /
       \ /
      class D

화살표는 서브타입에서 해당 기본 타입(들)로 향합니다. 이 특정 다이어그램은 BCA에서 파생되고, DBC에서 파생됨을 의미합니다(따라서 간접적으로 A에서도 파생됩니다).

A에 정의된 save() 메서드를 C가 재정의한다고 가정해 봅시다. (C.save()는 아마 A.save()를 호출한 다음 자체 상태를 저장할 것입니다.) BDsave()를 재정의하지 않습니다. D 인스턴스에서 save()를 호출할 때 어떤 메서드가 호출될까요? 클래식 조회 규칙에 따르면 C.save()를 무시하고 A.save()가 호출됩니다!

이것은 좋지 않습니다. 아마도 C를 손상시키고(그 상태가 저장되지 않음), 애초에 C로부터 상속받는 목적을 좌절시킬 것입니다.

클래식 Python에서는 왜 이것이 문제가 되지 않았을까요? 다이아몬드 다이어그램은 클래식 Python 클래스 계층 구조에서는 거의 발견되지 않습니다. 대부분의 클래스 계층 구조는 단일 상속을 사용하고, 다중 상속은 일반적으로 믹스인(mix-in) 클래스로 제한됩니다. 사실, 여기에 보이는 문제는 아마도 클래식 Python에서 다중 상속이 인기가 없는 이유일 것입니다.

새로운 시스템에서는 왜 이것이 문제가 될까요? 타입 계층 구조의 최상위에 있는 ‘object’ 타입은 서브타입이 유용하게 확장할 수 있는 여러 메서드를 정의합니다. 예를 들어 __getattr__()가 있습니다.

(참고: 클래식 Python에서 __getattr__() 메서드는 실제로 속성(attribute) 가져오기 작업의 구현이 아닙니다. 이는 속성을 일반적인 방법으로 찾을 수 없을 때만 호출되는 훅(hook)입니다. 이는 종종 단점으로 지적되었습니다. 일부 클래스 디자인은 모든 속성 참조에 대해 호출되는 __getattr__() 메서드에 대한 정당한 필요성을 가집니다. 그러나 물론 이 메서드는 기본 구현을 직접 호출할 수 있어야 합니다. 가장 자연스러운 방법은 기본 구현을 object.__getattr__(self, name)으로 사용할 수 있도록 하는 것입니다.)

따라서 다음과 같은 클래식 클래스 계층 구조는:

      class B       class C:
    ^       ^  def __getattr__(self, name): ...
     \     /
      \   /
       \ /
      class D

새로운 시스템에서는 다이아몬드 다이어그램으로 변경될 것입니다:

    object:
    ^       ^  __getattr__()
   /         \
  /           \
 /             \
class B       class C:
    ^       ^  def __getattr__(self, name): ...
     \     /
      \   /
       \ /
      class D

원래 다이어그램에서는 C.__getattr__()가 호출되었지만, 클래식 조회 규칙을 사용하는 새 시스템에서는 object.__getattr__()가 호출될 것입니다!

다행히 더 나은 조회 규칙이 있습니다. 설명하기는 다소 어렵지만, 다이아몬드 다이어그램에서 올바른 일을 수행하며, 상속 그래프에 다이아몬드가 없는 경우(트리인 경우) 클래식 조회 규칙과 동일합니다.

새로운 조회 규칙은 검색될 순서대로 상속 다이어그램의 모든 클래스 목록을 구성합니다. 이 구성은 시간을 절약하기 위해 클래스 정의 시점에 수행됩니다. 새로운 조회 규칙을 설명하기 위해 먼저 클래식 조회 규칙에 대한 이러한 목록이 어떻게 생겼을지 생각해 봅시다. 다이아몬드가 있는 경우 클래식 조회는 일부 클래스를 여러 번 방문한다는 점에 유의하십시오. 예를 들어, 위 ABCD 다이아몬드 다이어그램에서 클래식 조회 규칙은 다음 순서로 클래스를 방문합니다.

D, B, A, C, A

목록에 A가 두 번 나타나는 방식에 주목하십시오. 두 번째 나타남은 중복됩니다. 왜냐하면 거기서 찾을 수 있는 모든 것은 첫 번째 나타남을 검색할 때 이미 찾아졌을 것이기 때문입니다.

이 관찰을 사용하여 새로운 조회 규칙을 설명합니다. 클래식 조회 규칙을 사용하여 중복을 포함하여 검색될 클래스 목록을 구성합니다. 이제 목록에 여러 번 나타나는 각 클래스에 대해 마지막 발생을 제외한 모든 발생을 제거합니다. 결과 목록에는 각 조상 클래스가 정확히 한 번씩 포함됩니다(예제의 가장 파생된 클래스인 D 포함).

이 순서로 메서드를 검색하면 다이아몬드 다이어그램에 대해 올바른 작업을 수행합니다. 목록이 구성되는 방식 때문에 다이아몬드가 없는 상황에서는 검색 순서가 변경되지 않습니다.

이것이 하위 호환성이 없지 않을까요? 기존 코드를 손상시키지 않을까요? 모든 클래스에 대한 메서드 해석 순서를 변경한다면 그럴 것입니다. 그러나 Python 2.2에서는 새로운 조회 규칙이 내장 타입에서 파생된 타입에만 적용되며, 이는 새로운 기능입니다. 기본 클래스가 없는 클래스 문은 “클래식 클래스”를 생성하며, 기본 클래스가 그 자체로 클래식 클래스인 클래스 문도 마찬가지입니다. 클래식 클래스에는 클래식 조회 규칙이 사용됩니다. (클래식 클래스에 대한 새로운 조회 규칙을 실험하려면 다른 메타클래스를 명시적으로 지정할 수 있습니다.) 또한 메서드 해석 순서의 변경으로 인해 영향을 받는 메서드를 찾기 위해 클래스 계층 구조를 분석하는 도구를 제공할 것입니다.

(XXX Damian Conway의 새로운 MRO에 대한 동기를 설명하는 또 다른 방법: 아직 탐색하지 않은 파생 클래스에 정의된 메서드를 기본 클래스에 정의된 메서드를 사용하지 않습니다(이전 검색 순서를 사용).)

구현 (Implementation)

이 PEP (및 PEP 252)의 프로토타입 구현은 CVS 및 Python 2.2 알파 및 베타 릴리스 시리즈에서 사용할 수 있습니다. 여기에 설명된 기능의 일부 예제는 파일 Lib/test/test_descr.py 및 확장 모듈 Modules/xxsubtype.c를 참조하십시오.

참조 (References)

(1, 2) “Putting Metaclasses to Work”, by Ira R. Forman and Scott H. Danforth, Addison-Wesley 1999. (http://www.aw.com/product/0,2627,0201433052,00.html)

이 문서는 퍼블릭 도메인(public domain)으로 지정되었습니다.

⚠️ 알림: 이 문서는 AI를 활용하여 번역되었으며, 기술적 정확성을 보장하지 않습니다. 정확한 내용은 반드시 원문을 확인하시기 바랍니다.

Comments