PEP 579 – C 함수와 메서드 리팩터링
- Author:
- Jeroen Demeyer <J.Demeyer at UGent.be>
- BDFL-Delegate:
- Petr Viktorin
- Status:
- Final
- Type:
- Informational
- Created:
- 04-Jun-2018
- Post-History:
- 20-Jun-2018
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
승인 공지
이 PEP는 PEP 575, PEP 580, PEP 590(그리고 이후에 나올 수 있는 제안)에서 다루는 설계 문제를 설명합니다.
PEP 1에서 언급하듯이:
정보 제공용 PEP가 반드시 Python 커뮤니티의 합의나 권고를 나타내는 것은 아니므로, 사용자와 구현자는 정보 제공용 PEP를 무시하거나 그 조언을 자유롭게 따를 수 있습니다.
이 PEP에서 다루는 문제나 해결책이 타당한지에 대해서는 합의가 없지만, 이 목록은 향후 설계를 이끄는 데 여전히 유용합니다.
초록
이 메타 PEP는 내장 함수(C로 구현된 함수)와 메서드에 대한 CPython의 기존 구현에 있는 여러 문제를 모아 정리합니다.
이 모든 문제를 하나의 PEP에서 해결하기에는 범위가 너무 크므로, 이는 다른 표준 트랙 PEP에 위임합니다. 그러나 이 PEP는 가능한 해결 방법에 대한 간략한 아이디어를 제시합니다. 이는 주로 전체적인 전략을 조율하기 위한 것입니다. 예를 들어, 제안된 해결책이 어느 한 가지 문제만 해결하기에는 너무 복잡해 보일 수 있지만, 여러 문제에 대해서는 전체적으로 최선의 해결책일 수 있습니다.
이 PEP는 순전히 정보 제공용입니다. 모든 문제가 결국 해결된다는 의미도 아니고, 여기서 제안한 해결책을 사용해 해결된다는 의미도 아닙니다.
또한 이 PEP는 요청될 수 있는 기능의 체크리스트 역할을 하여, 특정 수정이 그러한 다른 기능의 구현을 더 어렵게 만들지 않는지 확인하는 데 쓰입니다.
제안하는 주요 변경은 PyMethodDef를 함수/메서드 호출에 필요한 모든 것을 모은 새 구조체 PyCCallDef로 대체하는 것입니다. PyTypeObject 구조체에는 객체 구조체 안의 PyCCallDef *에 대한 오프셋을 제공하는 새 필드 tp_ccalloffset이 추가됩니다.
참고: 이 PEP는 CPython 구현 세부 사항만 다루며, Python 언어나 표준 라이브러리에는 영향을 주지 않습니다.
문제점
여기서는 내장 함수와 메서드의 여러 문제를 해결 계획 및 (해당하는 경우) 세부 사항을 논의하는 표준 트랙 PEP에 대한 참조와 함께 나열합니다.
1. 명명
Python에서는 “built-in”(내장)이라는 단어가 지나치게 많이 사용됩니다. Python 문서를 대강 훑어보면, 이 단어는 대부분 builtins 모듈에 있는 것들을 가리킵니다. 다시 말해, 임포트할 필요 없이 전역 네임스페이스에서 사용할 수 있는 것들입니다. 이는 “built-in(내장)”이라는 단어를 “C로 구현됨”이라는 뜻으로 사용하는 것과 충돌합니다.
해결책: 내장 함수와 메서드를 위한 C 구조체는 이미 PyCFunctionObject라고 불리므로, “built-in function”과 “built-in method” 대신 “cfunction”과 “cmethod”라는 이름을 사용합시다.
2. 확장 불가능
관련된 여러 클래스(예: builtin_function_or_method)는 서브클래싱할 수 없습니다:
>>> from types import BuiltinFunctionType
>>> class X(BuiltinFunctionType):
... pass
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: type 'builtin_function_or_method' is not an acceptable base type
이는 이러한 클래스에 인트로스펙션 지원과 같은 기능을 추가하는 것을 불가능하게 하므로 문제가 됩니다.
추가 기능을 갖춘 함수를 C로 구현하려면, 완전히 새로운 클래스를 처음부터 구현해야 합니다. 이것의 문제는 builtin_function_or_method 같은 기존 클래스가 더 빠른 호출을 위해(예를 들어 METH_FASTCALL을 사용하여) Python 인터프리터에서 특별 취급된다는 점입니다. 현재로서는 같은 최적화를 갖춘 사용자 정의 클래스를 만드는 것이 불가능합니다.
해결책: 기존 최적화를 임의의 클래스에서도 사용할 수 있게 합니다. 이는 PyCCallDef 포인터의 오프셋을 지정하는 새 PyTypeObject 필드 tp_ccalloffset을 추가하여 이루어집니다(또는 이 용도로 tp_print를 재사용할 수 있겠습니까?). 이것은 cfunction을 호출하는 데 필요한 모든 정보를 담는 새 구조체이며, PyMethodDef 대신 사용됩니다. 이것이 새로운 “C 호출” 프로토콜을 구현합니다.
cfunction과 cmethod를 생성할 때는 여전히 PyMethodDef 배열이 사용되지만(예를 들어 tp_methods에서), 그것이 PyMethodDef 구조체에 남은 유일한 용도가 됩니다.
추가로, 일부 함수 클래스를 서브클래싱할 수 있게 만들 수도 있습니다. 하지만 tp_ccalloffset이 생기고 나면 이는 덜 중요해 보입니다.
참고: PEP 580
3. cfunction은 메서드가 되지 않습니다
repr 같은 cfunction은 메서드로 바인딩되기 위한 __get__을 구현하지 않습니다:
>>> class X:
... meth = repr
>>> x = X()
>>> x.meth()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: repr() takes exactly one argument (0 given)
이 예제에서는 메서드의 일반적인 규칙을 적용하여 x.meth()가 repr(x)를 반환하리라고 기대했을 것입니다.
이는 놀라운 일이며 cfunction과 Python 함수 사이의 불필요한 차이입니다. 표준 내장 함수는 메서드로 사용하도록 의도된 것이 아니므로 이는 실제로 문제가 되지 않습니다. 하지만 메서드로 사용할 수 있는 것을 목표로 새 cfunction을 구현하려는 경우에는 문제가 됩니다.
이번에도, cfunction과 똑같이 동작하지만 메서드로 바인딩되는 새 클래스를 만드는 것이 해결책이 될 수 있습니다. 하지만 그렇게 하면 LOAD_METHOD/CALL_METHOD 옵코드와 같은 메서드에 대한 기존 최적화 일부를 잃게 됩니다.
해결책: 앞의 문제와 같습니다. 이는 단지 self와 __get__을 처리하는 것이 새 C 호출 프로토콜의 일부가 되어야 함을 보여 줍니다.
하위 호환성을 위해 cfunction의 기존 비바인딩 동작은 유지합니다. 다만 사용자 정의 클래스에서 이를 허용할 뿐입니다.
참고: PEP 580
4. inspect.isfunction의 의미
현재 inspect.isfunction은 types.FunctionType의 인스턴스에 대해서만 True를 반환합니다. 즉, 진짜 Python 함수에 대해서만 그렇습니다.
inspect.isfunction의 일반적인 사용 사례는 인트로스펙션 가능 여부를 확인하는 것입니다. 예를 들어 이는 inspect.getfile()이 동작함을 보장합니다. 이상적으로는 다른 클래스도 함수로 취급될 수 있어야 합니다.
해결책: 새로운 InspectFunction 추상 베이스 클래스를 도입하고 이를 사용하여 inspect.isfunction을 구현합니다. 또는 inspect.isfunction에 덕 타이핑을 사용합니다([2]에서 제안된 것처럼):
def isfunction(obj):
return hasattr(type(obj), "__code__")
5. C 함수는 함수 객체에 접근할 수 있어야 합니다
현재 cfunction의 기반 C 함수는 self 인자(바운드 메서드의 경우)와 그 뒤에 올 수 있는 여러 인자를 받습니다. C 함수가 실제로 Python cfunction 객체(__call__ 또는 tp_call의 self)에 접근할 방법은 없습니다. 예를 들어 이것이 가능해지면 Python 함수(types.FunctionType)에 대한 C 호출 프로토콜을 구현할 수 있습니다. Python 함수 호출을 구현하는 C 함수는 함수의 __code__ 속성에 접근해야 하기 때문입니다.
이는 PEP 573에도 필요합니다. 이 PEP에서는 모든 cfunction이 자신의 “부모”(모듈 함수의 경우 모듈, 메서드의 경우 이를 정의하는 클래스)에 접근해야 합니다.
해결책: C 함수가 추가 인자, 즉 함수 객체를 (첫 번째 인자로) 받는다는 것을 지정하는 새로운 PyMethodDef 플래그를 추가합니다.
6. METH_FASTCALL은 비공개이며 문서화되어 있지 않습니다
METH_FASTCALL 메커니즘을 사용하면 tuple 대신 Python 객체의 C 배열을 사용하여 cfunction과 cmethod를 호출할 수 있습니다. 이 메커니즘은 Python 3.6에서 위치 인자 전용으로 도입되었고, Python 3.7에서 키워드 인자를 지원하도록 확장되었습니다.
그러나 문서화되어 있지 않으므로 아마도 CPython 자체에서만 사용하도록 의도된 것으로 보입니다.
해결책: 이는 중요한 최적화이므로 모두가 사용하도록 권장해야 합니다. 이제 METH_FASTCALL의 구현이 안정되었으니 문서화하십시오!
C 호출 프로토콜의 일부로 C API 함수도 추가해야 합니다
PyObject *PyCCall_FastCall(PyObject *func, PyObject *const *args, Py_ssize_t nargs, PyObject *keywords)
참고: PEP 580
7. 네이티브 C 인자 허용
cfunction은 항상 인자를 Python 객체(예를 들어 PyObject 포인터의 배열)로 받습니다. cfunction이 실제로는 (예를 들어 ctypes나 Cython 같은 컴파일러에서 온) 네이티브 C 함수를 감싸고 있는 경우에는 이 방식이 비효율적입니다. C 코드에서 C 코드로 호출할 때도 인자를 전달하기 위해 Python 객체를 사용할 수밖에 없기 때문입니다.
C 데이터에 대한 접근을 허용하는 버퍼 프로토콜과 유사하게, 기반이 되는 C 호출 가능 객체에 대한 접근도 허용해야 합니다.
해결책: 네이티브 인자(예를 들어 C long)를 받는 C 함수를 cfunction 안에 감쌀 때, 기반이 되는 C 함수에 대한 함수 포인터도 그 C 시그니처와 함께 저장해야 합니다.
Argument Clinic은 “impl” 함수에 대한 포인터를 저장하여 이를 자동으로 처리할 수 있습니다. 이렇게 하면 인자 처리를 거치지 않고 해당 함수를 호출할 수 있습니다.
8. 복잡성
메서드의 모든 변형을 구현하는 데 관여하는 클래스가 매우 많습니다. 이것 자체는 문제가 아니지만, 다른 문제를 가중시키는 요인입니다.
일반적인 Python 클래스의 경우, 아래 표는 여러 종류의 메서드에 대한 클래스를 보여 줍니다. 각 열은 클래스 __dict__에 있는 클래스, 언바운드 메서드(클래스에 바인딩됨)의 클래스, 바운드 메서드(인스턴스에 바인딩됨)의 클래스를 나타냅니다:
| 종류 | __dict__ | 언바운드 | 바운드 |
|---|---|---|---|
| 일반 메서드 | function |
function |
method |
| 정적 메서드 | staticmethod |
function |
function |
| 클래스 메서드 | classmethod |
method |
method |
| 슬롯 메서드 | function |
function |
method |
다음은 확장 타입(C 클래스)에 해당하는 표입니다:
| 종류 | __dict__ | 언바운드 | 바운드 |
|---|---|---|---|
| 일반 메서드 | method_descriptor |
method_descriptor |
builtin_function_or_method |
| 정적 메서드 | staticmethod |
builtin_function_or_method |
builtin_function_or_method |
| 클래스 메서드 | classmethod_descriptor |
builtin_function_or_method |
builtin_function_or_method |
| 슬롯 메서드 | wrapper_descriptor |
wrapper_descriptor |
method-wrapper |
관련된 클래스가 많고, 이 두 표는 매우 다르게 보입니다. Python 메서드를 C 메서드와 근본적으로 다르게 취급해야 할 타당한 이유는 없습니다. 또한 기능도 약간 다릅니다. 예를 들어 method는 __func__를 지원하지만 builtin_function_or_method는 지원하지 않습니다.
CPython은 이러한 객체 대부분에 대한 호출을 최적화하므로, 이를 처리하는 코드도 복잡해질 수 있습니다. Python/ceval.c의 call_function 함수가 좋은 예입니다.
해결책: 이 모든 클래스가 C 호출 프로토콜을 구현해야 합니다. 그러면 타입 검사를 하는 대신 C 호출 프로토콜(tp_ccalloffset != 0)을 확인하는 방식으로 코드의 복잡성을 대부분 해결할 수 있습니다.
나아가 이 클래스 중 일부를 병합할 수 있는지, 그리고 method를 확장 타입의 바운드 메서드에도 재사용할 수 있는지 검토해야 합니다(후자에 대해서는 PEP 576을 참조하십시오. 다만 이 경우 사소한 하위 호환성 문제가 있을 수 있음을 염두에 두십시오). 이것 자체가 목표는 아니며, 이 클래스들을 작업할 때 염두에 두어야 할 사항일 뿐입니다.
9. PyMethodDef는 너무 제한적입니다
확장 모듈에서 cfunction이나 cmethod를 만드는 일반적인 방법은 PyMethodDef를 사용하여 정의하는 것입니다. 이렇게 정의된 것은 PyModuleDef.m_methods 배열(cfunction의 경우)이나 PyTypeObject.tp_methods 배열(cmethod의 경우)에 저장됩니다. 그러나 안정 ABI(PEP 384) 때문에 PyMethodDef 구조체를 변경할 수 없습니다.
따라서 이 방식으로는 cfunction/cmethod를 만드는 데 필요한 새 필드를 추가할 수 없습니다. __doc__와 __text_signature__가 같은 C 문자열에 저장되는(그리고 __doc__와 __text_signature__ 디스크립터가 각각 해당하는 부분을 추출하는) 편법이 쓰이는 이유도 아마 이 때문일 것입니다.
해결책: PyMethodDef 항목 하나로 cfunction/cmethod를 충분히 기술할 수 있다는 가정을 버립니다. 대신 PyMethodDef 필드 중 하나가 추가 구조체에 대한 포인터임을 뜻하는 플래그를 추가할 수 있습니다. 또는 배열에서 연속된 두 개 이상의 PyMethodDef 항목을 사용하여 더 많은 데이터를 저장하도록 하는 플래그를 추가할 수도 있습니다. 그러면 PyMethodDef 배열은 cfunction/cmethod를 생성하는 데에만 사용되고, 그 이후에는 더 이상 사용되지 않을 것입니다.
10. 슬롯 래퍼에는 개별 문서가 없습니다
현재 __init__이나 __lt__ 같은 슬롯 래퍼에는 해당 클래스에 전혀 특화되지 않은 매우 일반적인 문서만 있습니다:
>>> list.__init__.__doc__
'Initialize self. See help(type(self)) for accurate signature.'
>>> list.__lt__.__doc__
'Return self<value.'
시그니처도 마찬가지입니다:
>>> list.__init__.__text_signature__
'($self, /, *args, **kwargs)'
보시다시피 슬롯 래퍼는 __doc__와 __text_signature__를 지원합니다. 문제는 이것들이 struct wrapperbase에 저장된다는 점인데, 이 구조체는 특정 슬롯의 모든 래퍼가 공유합니다(예를 들어 str.__eq__와 int.__eq__에 같은 wrapperbase가 사용됩니다).
해결책: 인스턴스마다 개별적으로 독스트링(및 텍스트 시그니처)을 가질 수 있도록 슬롯 래퍼 클래스를 재설계합니다.
그래도 확장 모듈이 문서를 어떻게 지정해야 하는지에 대한 문제는 여전히 남습니다. tp_init 같은 PyTypeObject 항목은 단순한 함수 포인터일 뿐이므로, 이것으로는 아무것도 할 수 없습니다. 한 가지 해결책은 독스트링을 추가하기 위한 용도로만 tp_methods 배열에 항목을 추가하는 것입니다. 그러한 항목은 다음과 같은 모습일 수 있습니다
{"__init__", NULL, METH_SLOTDOC, "pointer to __init__ doc goes here"}
11. 정적 메서드와 클래스 메서드는 호출 가능 객체여야 합니다
staticmethod와 classmethod의 인스턴스는 호출 가능 객체여야 합니다. 물론 이에 대한 강력한 사용 사례는 없지만, 가끔 요청된 적이 있습니다(예를 들어 [1]를 참조하십시오).
정적/클래스 메서드를 호출 가능 객체로 만들면 일관성이 높아질 것입니다. 우선, 함수 데코레이터는 일반적으로 함수에 기능을 추가하거나 함수를 수정하지만, 그 결과는 여전히 호출 가능 객체입니다. @staticmethod와 @classmethod에는 이것이 해당하지 않습니다.
둘째, 확장 타입의 클래스 메서드는 이미 호출 가능 객체입니다:
>>> fromhex = float.__dict__["fromhex"]
>>> type(fromhex)
<class 'classmethod_descriptor'>
>>> fromhex(float, "0xff")
255.0
셋째, function, staticmethod, classmethod를 서로 다른 종류의 언바운드 메서드로 볼 수 있습니다. 이들은 모두 바인딩되면 method가 되지만, __get__의 구현이 약간 다릅니다. 이러한 관점에서 보면, function은 호출 가능 객체인데 나머지는 그렇지 않다는 것이 이상해 보입니다.
해결책: staticmethod, classmethod의 구현을 변경할 때는 인스턴스를 호출 가능 객체로 만드는 것을 고려해야 합니다. 이것 자체가 목표는 아니더라도, 구현 과정에서 자연스럽게 이루어질 수 있습니다.
참고 문헌
Copyright
This document has been placed in the public domain.