PEP 697 – 불투명 타입 확장을 위한 제한된 C API
- Author:
- Petr Viktorin <encukou at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 23-Aug-2022
- Python-Version:
- 3.12
- Post-History:
- 24-May-2022, 06-Oct-2022
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
코드가 특정 (서브)클래스에 고유한 데이터만 다루도록 하여, 일부 타입을 불투명 데이터로 확장할 수 있게 하는 제한된 C API 지원을 추가합니다.
이 메커니즘은 PyHeapTypeObject와 함께 사용할 수 있어야 합니다.
이 PEP는 tuple이나 int처럼 동적으로 크기가 정해지지 않는 가변 크기 객체의 확장을 허용하자고 제안하지 않습니다. 이러한 객체는 메모리 레이아웃이 다르고, 이를 확장하려는 수요도 없는 것으로 보이기 때문입니다. 이 PEP는 필요하다면 향후 같은 메커니즘으로 이를 지원할 여지를 남겨 둡니다.
동기
이 PEP가 해결하려는 핵심 문제는 사용자 정의 타입, 즉 메타클래스(type의 서브클래스)에 C 수준 상태를 붙이는 것입니다.
이는 다른 타입 시스템(예: C++, Java, Rust)을 Python 클래스로 노출하는 “래퍼”에서 자주 필요합니다. 이러한 래퍼는 일반적으로 “래핑된” 비 Python 클래스에 대한 정보를 Python 타입 객체에 첨부해야 합니다.
언어 래퍼나 코드 생성기로 안정 ABI 확장을 만들 수 있도록, 이 작업은 제한된 API에서도 가능해야 합니다. (안정 ABI 제공의 이점은 PEP 652를 참조하십시오.)
type을 확장하는 것은 더 일반적인 문제의 한 사례입니다. 그 문제란 느슨한 결합을 유지하면서, 즉 슈퍼클래스가 사용하는 메모리 레이아웃에 의존하지 않으면서 클래스를 확장하는 것입니다. (전문 용어가 많으므로, list를 확장하는 구체적인 예는 근거 절을 참조하십시오.)
근거
불투명 타입 확장하기
제한된 API에서 대부분의 struct는 불투명합니다. 즉, 크기와 메모리 레이아웃이 노출되지 않으므로 CPython의 새 버전(또는 C API의 대체 구현)에서 변경될 수 있습니다.
이는 일반적인 서브클래싱 패턴, 즉 베이스 타입의 인스턴스에 사용되는 struct를 파생 타입의 인스턴스에 사용되는 struct의 첫 번째 요소로 두는 방식이 동작하지 않는다는 뜻입니다. 코드로 설명하자면, 튜토리얼의 예제는 다음 struct를 사용하여 PyListObject (list)를 확장합니다.
typedef struct {
PyListObject list;
int state;
} SubListObject;
PyListObject는 (기능과 최적화가 구현됨에 따라 변경될 수 있도록) 불투명하므로, 이 코드는 제한된 API에서 컴파일되지 않습니다.
대신 이 PEP는 서브클래스에 필요한 상태만 담은 struct를 사용하자고 제안합니다. 즉, 다음과 같습니다.
typedef struct {
int state;
} SubListState;
// (or just `typedef int SubListState;` in this case)
이제 서브클래스는 슈퍼클래스의 메모리 레이아웃(과 크기)에서 완전히 분리될 수 있습니다.
이는 현재도 가능합니다. 이러한 구조체를 사용하려면 다음과 같이 하십시오.
- 클래스를 생성할 때
PyListObject->tp_basicsize + sizeof(SubListState)를PyType_Spec.basicsize로 사용하십시오. - 데이터에 접근할 때
PyListObject->tp_basicsize를 인스턴스(PyObject*)에 대한 오프셋으로 사용하십시오.
하지만 이 방식에는 단점이 있습니다.
- 베이스의
basicsize가 올바르게 정렬되어 있지 않을 수 있으며, 이를 완화하지 않으면 일부 아키텍처에서 문제가 발생합니다. (새 릴리스에서 정렬이 바뀌면 이러한 문제는 특히 골치 아플 수 있습니다.) PyTypeObject.tp_basicsize는 제한된 API에 노출되지 않으므로, 제한된 API를 지원하는 확장은PyObject_GetAttrString(obj, "__basicsize__")를 사용해야 합니다. 이는 번거로우며, 경계 사례에서는 안전하지 않습니다(Python 속성이 재정의될 수 있습니다).- 가변 크기 객체는 처리되지 않습니다(아래의 Extending variable-size objects를 참조하십시오).
이를 쉽게 만들고 (최대 성능보다 느슨한 결합을 선택하는 프로젝트에서는 모범 사례가 되도록 하기 위해), 이 PEP는 다음을 수행하는 API를 제안합니다.
- 클래스를 생성할 때
list에 대한 추가 세부 정보를 전달하지 않고도SubListState가PyListObject에 “덧붙여져야” 함을 지정합니다. (tp_basicsize같은 필요한 모든 정보는 인터프리터가 직접 베이스에서 가져옵니다.)이는 음수
PyType_Spec.basicsize로 지정됩니다:-sizeof(SubListState). - 인스턴스와 서브클래스
PyTypeObject*가 주어지면SubListState에 대한 포인터를 얻습니다. 이를 위해 새 함수PyObject_GetTypeData가 추가됩니다.
물론 베이스 클래스는 PyListObject로 제한되지 않습니다: 인스턴스 struct가 불투명하거나, 릴리스마다 불안정하거나, 전혀 노출되지 않은 모든 베이스 클래스를 확장하는 데 사용할 수 있습니다 – 여기에는 type (PyHeapTypeObject)이나 서드파티 확장(예를 들어 NumPy 배열 [1])도 포함됩니다.
추가 상태가 필요하지 않은 경우에는 basicsize를 0으로 지정하는 것이 허용됩니다: 이 경우 베이스의 tp_basicsize가 상속됩니다. (이는 현재도 동작하지만, 명시적인 문서와 테스트가 없습니다.)
새 클래스의 tp_basicsize는 계산된 전체 크기로 설정되므로, 클래스를 검사하는 코드는 이전과 같이 계속 동작합니다.
가변 크기 객체 확장하기
느슨한 결합을 유지하면서 가변 크기 객체를 서브클래싱하려면 추가로 고려할 사항이 있습니다: 가변 크기 데이터가 서브클래스 데이터(위 예제의 SubListState)와 충돌할 수 있습니다.
현재 CPython은 이러한 충돌을 방지하는 방법을 제공하지 않습니다. 따라서 제안된 불투명 클래스 확장 메커니즘(음수 base->tp_itemsize)은 기본적으로 실패합니다.
여기서 멈출 수도 있지만, 이 제안의 동기가 된 타입 — PyHeapTypeObject — 이 가변 크기이므로 이를 안전하게 서브클래싱할 수 있는 방법이 필요합니다. 먼저 약간의 배경을 살펴보겠습니다:
가변 크기 레이아웃
가변 크기 객체에는 두 가지 주요 메모리 레이아웃이 있습니다.
int나 tuple 같은 타입에서는 가변 데이터가 고정된 오프셋에 저장됩니다. 서브클래스에 추가 공간이 필요하다면, 그 공간은 모든 가변 크기 데이터 뒤에 추가되어야 합니다:
PyTupleObject:
┌───────────────────┬───┬───┬╌╌╌╌┐
│ PyObject_VAR_HEAD │var. data │
└───────────────────┴───┴───┴╌╌╌╌┘
tuple subclass:
┌───────────────────┬───┬───┬╌╌╌╌┬─────────────┐
│ PyObject_VAR_HEAD │var. data │subclass data│
└───────────────────┴───┴───┴╌╌╌╌┴─────────────┘
PyHeapTypeObject 같은 다른 타입에서는 가변 크기 데이터가 항상 인스턴스 메모리 영역의 끝에 위치합니다:
heap type:
┌───────────────────┬──────────────┬───┬───┬╌╌╌╌┐
│ PyObject_VAR_HEAD │Heap type data│var. data │
└───────────────────┴──────────────┴───┴───┴╌╌╌╌┘
type subclass:
┌───────────────────┬──────────────┬─────────────┬───┬───┬╌╌╌╌┐
│ PyObject_VAR_HEAD │Heap type data│subclass data│var. data │
└───────────────────┴──────────────┴─────────────┴───┴───┴╌╌╌╌┘
첫 번째 레이아웃은 항목 배열에 빠르게 접근할 수 있게 해 줍니다. 두 번째 레이아웃은 서브클래스가 가변 크기 배열을 무시할 수 있게 해 줍니다(서브클래스가 자신의 데이터에 접근할 때 객체 시작 지점으로부터의 오프셋을 사용한다고 가정합니다).
이 PEP는 PyHeapTypeObject에 초점을 맞추므로, 두 번째 방식에 대해 서브클래싱을 허용하는 API를 제안합니다. 첫 번째 방식에 대한 지원은 나중에 API 호환 변경으로 추가할 수 있습니다(다만 이 PEP 작성자는 그만한 노력을 들일 가치가 있을지 의문스럽게 생각합니다).
PyHeapTypeObject와 같은 레이아웃을 가진 클래스 확장하기
이 PEP는 PyHeapTypeObject와 같은 레이아웃임을 나타내는 타입 플래그 Py_TPFLAGS_ITEMS_AT_END를 제안합니다. 이 플래그는 두 가지 방법으로 설정할 수 있습니다:
- 슈퍼클래스가 플래그를 설정하여, 서브클래스 작성자가
itemsize가 관여한다는 사실에 신경 쓰지 않아도 되게 하거나, - 새 서브클래스가 플래그를 설정하여, 작성자가 슈퍼클래스가 적합하다는 것을 알고 있다고 단언할 수 있습니다(다만 슈퍼클래스는 아직 이 플래그를 사용하도록 갱신되지 않았을 수 있습니다).
음수 basicsize를 사용하여 가변 크기 타입을 확장하려면 이 플래그가 필요합니다.
플래그의 대안으로는 서브클래스 작성자가 베이스가 호환되는 레이아웃을 사용한다는 것을 (예를 들어 문서를 통해) 알도록 요구하는 방법이 있습니다. 이 PEP의 이전 버전에서는 이를 위해 새로운 PyType_Slot을 제안했습니다. 이 방식은 설명하기 어려운 것으로 드러났으며, 서브클래스를 베이스 레이아웃으로부터 분리한다는 취지에도 어긋납니다.
새 플래그는 가변 크기 타입을 안전하게 확장할 수 있도록 하는 데 사용됩니다: spec->basicsize < 0이고 base->tp_itemsize > 0인 타입을 생성하려면 이 플래그가 필요합니다.
또한 이 PEP는 주어진 인스턴스가 새로운 Py_TPFLAGS_ITEMS_AT_END 플래그를 사용하는 경우, 그 인스턴스의 가변 크기 데이터를 얻는 헬퍼 함수를 제안합니다. 이는 필요한 포인터 산술을 API 뒤에 숨기며, 이 API는 향후 다른 레이아웃(잠재적으로 VM이 관리하는 레이아웃 포함)에 맞게 조정될 수 있습니다.
전체 그림
모든 경우를 다루었는지 쉽게 확인할 수 있도록, 다소 무서워 보이는 전체 결정 트리를 제시합니다.
Note
개별 경우는 따로 떼어 놓고 보면 더 쉽게 설명할 수 있습니다(초안 문서는 참조 구현을 참고하십시오).
spec->basicsize > 0: 현재 상태에서 바뀌는 것이 없습니다. (베이스 클래스 레이아웃을 알고 있습니다.)spec->basicsize == 0: (basicsize 상속)base->tp_itemsize == 0: 항목 크기는spec->tp_itemsize로 설정됩니다. (현재 상태에서 바뀌는 것이 없습니다.)base->tp_itemsize > 0: (가변 크기 클래스 확장)spec->itemsize == 0: 항목 크기가 상속됩니다. (현재 상태에서 바뀌는 것이 없습니다.)spec->itemsize > 0: 항목 크기가 설정됩니다. (안전하게 사용하기는 어렵지만, 이것이 CPython의 현재 동작입니다.)
spec->basicsize < 0: (basicsize 확장)base->tp_itemsize == 0: (고정 크기 클래스 확장)spec->itemsize == 0: 항목 크기는 0으로 설정됩니다.spec->itemsize > 0: 실패합니다. (ob_size를 추가해야 하는데, 이는 단순한 타입에서만 가능하며 그 단순한 레이아웃도 알고 있어야 합니다.)
base->tp_itemsize > 0: (가변 크기 클래스 확장)spec->itemsize == 0: (itemsize 상속)Py_TPFLAGS_ITEMS_AT_END를 사용하는 경우: itemsize가 상속됩니다.Py_TPFLAGS_ITEMS_AT_END를 사용하지 않는 경우: 실패합니다. (충돌 가능성이 있습니다.)
spec->itemsize > 0: 실패합니다. (항목 크기를 변경하거나 확장하는 작업은 안전하게 수행할 수 없습니다.)
spec->itemsize < 0으로 설정하는 것은 항상 오류입니다. 이 PEP는 tp->itemsize를 단순히 상속하는 것이 아니라 확장하는 메커니즘은 제안하지 않습니다.
상대 멤버 오프셋
퍼즐의 또 다른 조각은 PyMemberDef.offset입니다. 서브클래스 전용 struct(위의 SubListState)를 사용하는 확장 모듈은 “절대” 오프셋(PyObject 구조체 기준) 대신 “상대” 오프셋(이 struct를 기준으로 한 오프셋)을 지정하는 방법을 얻게 됩니다.
한 가지 방법은 새 API로 클래스를 생성할 때 자동으로 “상대” 오프셋을 가정하는 것입니다. 그러나 이러한 암묵적인 가정은 지나치게 예상 밖의 동작이 될 것입니다.
보다 명시적으로 하기 위해, 이 PEP는 “상대” 오프셋을 위한 새로운 플래그를 제안합니다. 적어도 초기에는 이 플래그가 오용을 막는 검사(그리고 리뷰어를 위한 힌트)의 역할만 합니다. 이 플래그는 새 API와 함께 사용하는 경우 반드시 있어야 하며, 그 외의 경우에는 사용해서는 안 됩니다.
명세
아래 코드 블록에서는 함수 헤더만 명세에 포함됩니다. 그 밖의 코드(크기/오프셋 계산)는 초기 CPython 구현의 세부 사항이며 변경될 수 있습니다.
상대 basicsize
PyType_Spec의 basicsize 멤버에 0이나 음수를 지정할 수 있게 됩니다. 이 경우 그 절댓값은 새 클래스의 인스턴스가 베이스 클래스의 basicsize에 더해 얼마만큼의 추가 저장 공간을 필요로 하는지를 지정합니다. 즉, 결과 클래스의 basicsize는 다음과 같습니다:
type->tp_basicsize = _align(base->tp_basicsize) + _align(-spec->basicsize);
여기서 _align은 alignof(max_align_t)의 배수로 올림합니다.
spec->basicsize가 0이면 대신 basicsize가 그대로 상속됩니다. 즉, 정렬 없이 base->tp_basicsize로 설정됩니다. (이는 이미 동작하며, 명시적인 테스트와 문서가 추가될 것입니다.)
인스턴스에서 서브클래스에 고유한 메모리 영역, 즉 서브클래스가 베이스에 더해 예약하는 “추가 공간”은 새 함수 PyObject_GetTypeData를 통해 사용할 수 있습니다. CPython에서 이 함수는 다음과 같이 정의됩니다:
void *
PyObject_GetTypeData(PyObject *obj, PyTypeObject *cls) {
return (char *)obj + _align(cls->tp_base->tp_basicsize);
}
이 메모리 영역의 크기를 얻는 또 다른 함수가 추가됩니다:
Py_ssize_t
PyType_GetTypeDataSize(PyTypeObject *cls) {
return cls->tp_basicsize - _align(cls->tp_base->tp_basicsize);
}
결과는 -basicsize로 요청한 것보다 클 수 있습니다. 그 전부를 사용해도 안전합니다(예: memset으로).
새 *Get* 함수에는 문서에 명시될 중요한 주의 사항이 있습니다: 이 함수들은 음수 PyType_Spec.basicsize를 사용하여 만든 클래스에만 사용할 수 있습니다. 다른 클래스에 대해서는 동작이 정의되지 않습니다. (이 덕분에 위 코드는 cls->tp_base가 NULL이 아니라고 가정할 수 있다는 점에 유의하십시오.)
itemsize 상속
spec->itemsize가 0이면 tp_itemsize는 베이스에서 상속됩니다. (이는 이미 동작하며, 명시적인 테스트와 문서가 추가될 것입니다.)
새 타입 플래그 Py_TPFLAGS_ITEMS_AT_END가 추가됩니다. 이 플래그는 tp_itemsize가 0이 아닌 타입에만 설정할 수 있습니다. 이 플래그는 인스턴스의 가변 크기 부분이 인스턴스 메모리의 끝에 저장됨을 나타냅니다.
기본 메타타입(PyType_Type)은 이 플래그를 설정합니다.
새 플래그가 있는 타입에서 가변 크기 내용을 위해 예약된 메모리에 접근하기 위한 새 함수 PyObject_GetItemData가 추가됩니다. CPython에서 이 함수는 다음과 같이 정의됩니다:
void *
PyObject_GetItemData(PyObject *obj) {
if (!PyType_HasFeature(Py_TYPE(obj), Py_TPFLAGS_ITEMS_AT_END) {
<fail with TypeError>
}
return (char *)obj + Py_TYPE(obj)->tp_basicsize;
}
이 함수는 처음에는 제한된 API에 추가되지 않을 것입니다.
음수 spec->basicsize를 사용하여 양수 base->itemsize를 가진 클래스를 확장하는 것은 베이스나 spec->flags 중 한 곳에 Py_TPFLAGS_ITEMS_AT_END가 설정되어 있지 않으면 실패합니다. (자세한 설명은 Extending variable-size objects를 참조하십시오.)
음수 spec->basicsize를 사용하여 양수 spec->itemsize를 가진 클래스를 확장하는 것은 실패합니다.
상대 멤버 오프셋
음수 PyType_Spec.basicsize를 사용하여 정의된 타입에서 Py_tp_members를 통해 정의된 멤버의 오프셋은 전체 PyObject 구조체가 아니라 서브클래스의 추가 데이터를 기준으로 한 상대값이어야 합니다. 이는 PyMemberDef.flags의 새 플래그로 표시됩니다: Py_RELATIVE_OFFSET.
초기 구현에서 새 플래그는 중복됩니다. 이 플래그는 오프셋의 바뀐 의미를 분명히 하고 실수를 방지하는 데 도움을 주는 역할만 합니다. 음수 basicsize와 함께 Py_RELATIVE_OFFSET을 사용하지 않는 것은 오류가 되며, 그 밖의 다른 맥락(즉, PyDescr_NewMember, PyMember_GetOne, PyMember_SetOne의 직접 또는 간접 호출)에서 이를 사용하는 것도 오류가 됩니다.
CPython은 타입을 초기화할 때 오프셋을 조정하고 Py_RELATIVE_OFFSET 플래그를 해제합니다. 이는 다음을 의미합니다:
- 생성된 타입의
tp_members는 입력 정의의Py_tp_members슬롯과 일치하지 않으며, tp_members를 읽는 코드는 이 플래그를 처리할 필요가 없습니다.
새 API 목록
다음과 같은 새 함수/값을 제안합니다.
다음은 제한된 API/안정 ABI에 추가됩니다:
void * PyObject_GetTypeData(PyObject *obj, PyTypeObject *cls)Py_ssize_t PyType_GetTypeDataSize(PyTypeObject *cls)PyTypeObject.tp_flags용Py_TPFLAGS_ITEMS_AT_END플래그PyMemberDef.flags용Py_RELATIVE_OFFSET플래그
다음은 공개 C API에만 추가됩니다:
void *PyObject_GetItemData(PyObject *obj)
하위 호환성
알려진 하위 호환성 문제는 없습니다.
가정
이 구현은 인스턴스 메모리 중 type->tp_base->tp_basicsize 오프셋과 type->tp_basicsize 오프셋 사이의 영역이 type에 “속한다”고 가정합니다(가변 길이 타입은 제외). 이는 명시적으로 문서화되어 있지는 않지만, 3.11 버전까지의 CPython은 서브클래스에 __dict__를 추가할 때 이 가정에 의존했으므로 안전할 것입니다.
보안 영향
알려진 바 없습니다.
지지 의견
pybind11의 저자가 처음 이 문제의 해결을 요청했으며(이 목록의 2번 항목 참조), 구현을 검증해 왔습니다.
HPy 프로젝트의 Florian은 API가 전반적으로 좋아 보인다고 말했습니다. (성능 우려에 대한 가능한 해결책은 아래를 참조하십시오.)
교육 방법
초기 구현에는 레퍼런스 문서와 What’s New 항목이 포함되며, 대상 독자인 C 확장 라이브러리 작성자에게는 이것으로 충분할 것입니다.
참조 구현
참조 구현은 encukou/cpython GitHub 저장소의 extend-opaque 브랜치에 있습니다.
향후 가능한 개선 사항
정렬 및 성능
인스턴스 구조체가 alignof(max_align_t)보다 작은 정렬을 필요로 하는 경우, 제안된 구현은 일부 공간을 낭비할 수 있습니다. 또한 정렬을 처리해야 하므로, base->tp_basicsize가 서브타입에 맞게 올바르게 정렬되어 있다고 전제할 수 있는 경우보다 계산이 느려집니다.
다시 말해, 제안된 구현은 안전성과 사용 편의성에 중점을 두며, 이를 위해 공간과 시간을 희생합니다. 이것이 문제로 드러나면, API를 깨뜨리지 않고 구현을 조정할 수 있습니다:
- 타입별 버퍼에 대한 오프셋을 저장해 둘 수 있으므로,
PyObject_GetTypeData는 사실상(char *)obj + cls->ht_typedataoffset형태가 됩니다. 이렇게 하면 클래스에 포인터가 하나 더 필요한 대신 속도가 빨라질 수 있습니다. - 그다음, 새로운
PyType_Slot으로 원하는 정렬을 지정하여 인스턴스에 필요한 공간을 줄일 수 있습니다.
가변 크기 타입을 위한 다른 레이아웃
Extending variable-size objects에서 설명한 “튜플과 유사한” 레이아웃을 나타내기 위해 Py_TPFLAGS_ITEMS_AT_END 같은 플래그를 추가할 수 있으며, 이 PEP가 제안하는 모든 메커니즘이 이를 지원하도록 조정할 수 있습니다. 다른 레이아웃도 추가할 수 있습니다. 그러나 실질적인 이점은 매우 적어 보이므로, 이는 이론적인 가능성일 뿐입니다.
기각된 아이디어
음수 spec->basicsize 대신 새로운 PyType_Spec 플래그를 추가할 수도 있었습니다. 이 경우에도 필드 값의 의미가 바뀌므로, 이 변경 사항을 알지 못한 채 이러한 내부 구조에 접근하는 기존 코드에는 똑같은 영향을 미쳤을 것입니다.
각주
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.