PEP 707 – __exit__ 및 __aexit__의 단순화된 시그니처
- Author:
- Irit Katriel <irit at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Rejected
- Type:
- Standards Track
- Created:
- 18-Feb-2023
- Python-Version:
- 3.12
- Post-History:
- 02-Mar-2023
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
거부 통지
SC에 따르면:
PEP를 논의한 결과 이를 거부하기로 결정했습니다. 잠재적인 호환성 파괴의 신비성과 위험이 이점을 정당화할 만큼 크지 않다고 판단했습니다. 다만 잠재적인 컨텍스트 관리자 v2 API 또는__leave__를 탐구하는 데에는 전적으로 찬성합니다.
초록
이 PEP는 현재의 (typ, exc, tb) 시그니처에 대한 하위 호환성을 유지하면서, __exit__() / __aexit__() 메서드가 단일 예외 인스턴스만 받는 컨텍스트 관리자를 인터프리터가 허용하도록 제안합니다.
이 제안은 3개 항목으로 구성된 예외 표현의 중복을 언어에서 제거하려는 지속적인 노력의 일환으로, 이제는 언어 사용자에게 혼란을 주면서 인터프리터에 복잡성과 오버헤드를 더하는 이전 Python 버전의 유물입니다.
제안된 구현은 이 사용 사례의 요구 사항에 맞게 조정된 인트로스펙션을 사용합니다. 이 해결책은 모호하지 않은 경우에만 새 기능을 지원하여 안전성을 보장합니다. 특히 세 개의 인자를 받을 수 있는 모든 시그니처는 세 개의 인자를 받을 것으로 간주합니다.
Python에서는 현재 호출 가능 객체를 신뢰성 있게 인트로스펙션할 수 없으므로, 여기서 제안하는 해결책은 일반적인 단일 인자 호출 가능 객체 유형만 그러한 것으로 식별하고, 일부 더 특이한 유형은 계속 세 개의 인자로 호출하는 것으로 제한됩니다. 이 불완전한 해결책은 실용성의 관점에서 여러 불완전한 대안 중에서 선택되었습니다. 이 PEP에 관한 논의가 다른 선택지를 탐구하고 최선의 방향으로 이끌기를 바라며, 그 방향은 불완전한 현재 상태를 유지하는 것일 수도 있습니다.
동기
과거에는 Python의 여러 부분에서 예외를 세 요소로 이루어진 튜플, 즉 예외의 유형, 값 및 트레이스백으로 표현했습니다. 당시에는 이러한 설계를 채택할 만한 타당한 이유가 있었지만, 이제는 예외 인스턴스에서 유형과 트레이스백을 신뢰성 있게 추론할 수 있으므로 더 이상 유효하지 않습니다. 지난 몇 년 동안 예외 표현을 단순화하려는 여러 노력이 있었습니다.
3.10부터 CPython PR #70577에서는 traceback 모듈의 함수가 위에서 설명한 3-튜플이나 단일 인자인 예외 인스턴스를 허용합니다.
내부적으로 인터프리터는 더 이상 예외를 세 항목으로 표현하지 않습니다. 이는 3.11에서 처리된 예외에 대해 제거되었고, 3.12에서 발생한 예외에 대해서도 제거되었습니다. 그 결과, 트리플을 노출하는 여러 API를 이제 더 간단한 대안으로 대체할 수 있습니다:
| 레거시 API | 대안 | |
|---|---|---|
| 처리된 예외 가져오기(Python) | sys.exc_info() |
sys.exception() |
| 처리된 예외 가져오기(C) | PyErr_GetExcInfo() |
PyErr_GetHandledException() |
| 처리된 예외 설정하기(C) | PyErr_SetExcInfo() |
PyErr_SetHandledException() |
| 발생한 예외 가져오기(C) | PyErr_Fetch() |
PyErr_GetRaisedException() |
| 발생한 예외 설정하기(C) | PyErr_Restore() |
PyErr_SetRaisedException() |
| 3-튜플로부터 예외 인스턴스 구성하기(C) | PyErr_NormalizeException() |
해당 없음 |
현재의 제안은 이 과정의 한 단계이며, 3-튜플 표현이 언어에 노출된 사례 하나에 대해 앞으로 나아갈 방법을 고려합니다. 이 모든 작업의 동기는 두 가지입니다.
언어 구현 단순화
처리된 예외의 인터프리터 내부 표현을 단일 객체로 줄여 얻은 단순화는 상당했습니다. 이전에는 인터프리터가 예외와 관련된 작업을 수행할 때마다 스택에 세 항목을 푸시하거나 스택에서 팝해야 했습니다. 이로 인해 스택 깊이가 증가했으며(캐시와 레지스터에 대한 부담이 커졌으며), 일부 바이트코드가 복잡해졌습니다. 이를 한 항목으로 줄이면서 ceval.c(인터프리터의 평가 루프 구현)에서 약 100줄의 코드가 제거되었고, 이후 POP_EXCEPT_AND_RERAISE 옵코드도 제거되었습니다. 이 옵코드는 충분히 단순해져 일반적인 스택 조작 명령으로 대체되었습니다. 마이크로 벤치마크 결과 예외를 포착하고 발생시키는 작업과 제너레이터를 생성하는 작업에서 약 10%의 속도 향상이 나타났습니다. 요약하면, Python 내부의 이러한 중복을 제거함으로써 인터프리터가 단순해지고 더 빨라졌습니다.
컨텍스트 관리자를 종료할 때 __exit__/__aexit__을 호출하는 성능은 다중 인자 함수 호출을 단일 인자 함수 호출로 대체하여 개선할 수도 있습니다. 마이크로 벤치마크 결과, 단일 인자 __exit__를 사용하는 컨텍스트 관리자에 진입하고 종료하는 작업이 약 13% 더 빠른 것으로 나타났습니다.
언어 자체를 단순화합니다.
Python이 인기 있는 이유 중 하나는 단순성입니다. 새로운 학습자에게 sys.exc_info()3-튜플은 이해하기 어렵고, 이를 이해하는 사람에게도 그 안의 중복성이 혼란스럽습니다.
sys.exc_info()를 더 이상 사용하지 않도록 할 수 있는 시점에 도달하려면 여러 릴리스가 필요합니다. 그러나 새로운 학습자가 적어도 레거시 코드를 유지 관리하게 될 때까지는 그것이나 3-튜플 표현을 알 필요가 없는 단계에 비교적 빠르게 도달할 수 있습니다.
근거
오늘날 언어에서 3-튜플이 남아 있는 마지막 사용 사례를 제거하는 데 반대할 유일한 이유는 그러한 변경이 초래할 수 있는 혼란에 대한 우려입니다. 이 PEP의 목표는 __exit__의 경우에 이러한 변경을 안전하고 점진적이며 혼란을 최소화하는 방식으로 수행할 방법을 제안하고, 이를 통해 해당 메서드 시그니처를 발전시킬 수 있는 선택지에 대한 논의를 시작하는 것입니다.
관련 traceback 모듈 API의 경우, 함수가 혼합 시그니처를 갖도록 발전시키는 것은 비교적 간단하고 안전합니다. 함수는 위치 인자 하나와 선택적 인자 두 개를 받고, 해당 인자의 타입에 따라 해석합니다. 기본값에 센티널을 사용하면 안전합니다. 사용자 프로그램이 정의하는 콜백의 시그니처는 발전시키기가 더 어렵습니다.
가장 안전한 방법은 추가 속성으로 표시하거나 다른 이름을 지정하여 콜백이 어떤 시그니처를 예상하는지 사용자가 명시적으로 나타내도록 하는 것입니다. 예를 들어, 인터프리터가 컨텍스트 관리자에서 __leave__ 메서드를 찾고, 해당 메서드가 있으면 단일 인자로 호출하도록 할 수 있습니다(그렇지 않으면 __exit__를 찾아 현재와 같이 계속 처리합니다). 여기서 제안하는 인트로스펙션 기반 대안은 사용자가 새 코드를 더 편리하게 작성할 수 있도록 하는 것을 목표로 합니다. 사용자는 단일 인자 버전을 사용하고 레거시 API를 인식하지 않아도 되기 때문입니다. 그러나 인트로스펙션의 한계가 지나치게 심각하다고 판단되면 명시적 선택지를 고려해야 합니다. 비슷한 기능을 가진 __exit__와 __leave__를 모두 5~10년 동안 유지하는 것은 이상적이지 않지만, 하나의 선택지입니다.
이제 현재 제안의 한계를 살펴봅시다. 2-인자 Python 함수와 METH_O C 함수를 단일 인자 시그니처를 가진 것으로 식별하고, 그 외의 모든 것은 3개 인자를 예상한다고 가정합니다. 물론 이 휴리스틱으로 거짓 음성(식별하지 못하는 단일 인자 호출 가능 객체)이 발생할 수 있습니다. 이러한 방식으로 작성된 컨텍스트 관리자는 작동하지 않으며, 해당 __exit__ 함수가 세 개의 인자로 호출될 때 현재와 마찬가지로 계속 실패합니다.
실제로는 문제가 되지 않을 것이라고 생각합니다. 첫째, 작동 중인 모든 코드는 계속 작동하므로 이는 기존 코드에 영향을 미치는 문제가 아니라 새 코드의 한계입니다. 둘째, 특이한 호출 가능 객체 타입이 __exit__에 사용되는 경우는 드물며, 필요한 경우 해당 호출 가능 객체에 작업을 위임하는 일반적인 메서드로 항상 감쌀 수 있습니다. 예를 들어 다음과 같이 작성할 수 있습니다.:
class C:
__enter__ = lambda self: self
__exit__ = ExoticCallable()
다음과 같이:
class CM:
__enter__ = lambda self: self
_exit = ExoticCallable()
__exit__ = lambda self, exc: CM._exit(exc)
이 PEP에서 이 문제의 실제 영향에 대해 논의하면서, 대부분의 __exit__ 함수는 인수로 아무 작업도 하지 않는다는 점을 주목할 필요가 있습니다. 일반적으로 컨텍스트 관리자는 종료 시 일부 정리 작업이 수행되도록 구현됩니다. __exit__ 함수가 컨텍스트 내부에서 발생한 예외를 처리하는 것은 적절한 경우가 드물며, 예외는 일반적으로 __exit__에서 호출 함수로 전파되도록 허용됩니다. 이는 대부분의 __exit__ 함수가 인수에 전혀 접근하지 않는다는 의미이며, Python 사용자층에 대한 여러 해결책의 영향을 평가할 때 이를 고려해야 합니다.
사양
컨텍스트 관리자의 __exit__/__aexit__ 메서드는 단일 인자 시그니처를 가질 수 있으며, 이 경우 인터프리터는 예외 인스턴스 또는 None과 같은 값을 인자로 하여 이를 호출합니다:
>>> class C:
... def __enter__(self):
... return self
... def __exit__(self, exc):
... print(f'__exit__ called with: {exc!r}')
...
>>> with C():
... pass
...
__exit__ called with: None
>>> with C():
... 1/0
...
__exit__ called with: ZeroDivisionError('division by zero')
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero
__exit__/__aexit__가 다른 시그니처를 가지면, 현재와 마찬가지로 3-튜플 (typ, exc, tb)을 인자로 하여 호출합니다:
>>> class C:
... def __enter__(self):
... return self
... def __exit__(self, *exc):
... print(f'__exit__ called with: {exc!r}')
...
>>> with C():
... pass
...
__exit__ called with: (None, None, None)
>>> with C():
... 1/0
...
__exit__ called with: (<class 'ZeroDivisionError'>, ZeroDivisionError('division by zero'), <traceback object at 0x1039cb570>)
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero
이러한 __exit__ 메서드도 3-튜플을 인자로 하여 호출합니다:
def __exit__(self, typ, *exc):
pass
def __exit__(self, typ, exc, tb):
pass
CPython PR #101995에 참조 구현이 제공됩니다.
인터프리터가 컨텍스트 관리자의 범위 끝에 도달하여 관련 __exit__ 또는 __aexit__ 함수를 호출하려고 하면, 이 함수를 검사하여 단일 인자 버전인지 레거시 3-인자 버전인지 확인합니다. 초안 PR에서는 is_legacy___exit__ 함수가 이 검사를 수행합니다:
static int is_legacy___exit__(PyObject *exit_func) {
if (PyMethod_Check(exit_func)) {
PyObject *func = PyMethod_GET_FUNCTION(exit_func);
if (PyFunction_Check(func)) {
PyCodeObject *code = (PyCodeObject*)PyFunction_GetCode(func);
if (code->co_argcount == 2 && !(code->co_flags & CO_VARARGS)) {
/* Python method that expects self + one more arg */
return false;
}
}
}
else if (PyCFunction_Check(exit_func)) {
if (PyCFunction_GET_FLAGS(exit_func) == METH_O) {
/* C function declared as single-arg */
return false;
}
}
return true;
}
이것은 일반적인 검사 함수가 아니라, 우리의 사용 사례를 위해 특별히 설계된 함수라는 점에 유의해야 합니다. exit_func는 컨텍스트 관리자 클래스의 속성이고(__enter__를 제공한 객체의 타입에서 가져옴), 일반적으로 함수라는 사실을 알고 있습니다. 또한 이것이 유용하려면 충분한 수의 단일 인자 형식을 식별해야 하지만, 모든 형식을 식별할 필요는 없습니다. 하위 호환성에서 중요한 점은 레거시 exit_func를 단일 인자 형식으로 잘못 식별하는 일이 절대 없어야 한다는 것입니다. 따라서 예를 들어 __exit__(self, *args)와 __exit__(self, exc_type, *args)는 하나의 인자로 호출될 수도 있지만 둘 다 레거시 형식입니다.
요약하면, exit_func는 다음 조건을 만족할 때 단일 인자로 호출됩니다:
PyMethod이고argcount가self를 포함하여2이며 가변 인자가 없거나,PyCFunction이고METH_O플래그를 가집니다.
검사에 따른 성능 비용은 specialization을 통해 완화할 수 있으므로, 어떤 이유로 이 검사를 현재보다 더 정교하게 만들어야 하더라도 문제가 되지 않는다는 점에 유의하십시오.
하위 호환성
이전에 정상적으로 작동하던 모든 컨텍스트 관리자는 동일한 방식으로 계속 작동합니다. 인터프리터가 3개의 인자를 받을 수 있는 경우에는 항상 3개의 인자로 호출하기 때문입니다. 이전에는 exit_func가 한 개의 인자를 예상하여 작동하지 않던 컨텍스트 관리자가 있을 수 있습니다. 따라서 __exit__ 호출로 인해 TypeError 예외가 발생했지만, 이제는 호출이 성공합니다. 이로 인해 기존 코드의 동작이 이론적으로 변경될 수 있지만, 실제로 문제가 될 가능성은 낮습니다.
라이브러리가 컨텍스트 관리자를 여러 인자 시그니처에서 단일 인자 시그니처로 마이그레이션하려고 할 때 일부 경우에 하위 호환성 문제가 나타납니다. 인터프리터의 평가 루프가 아닌 다른 코드에서 __exit__ 또는 __aexit__를 호출하면 검사가 자동으로 수행되지 않습니다. 예를 들어 컨텍스트 관리자를 서브클래싱하고 파생된 __exit__에서 해당 __exit__ 메서드를 직접 호출하는 경우에 이런 일이 발생합니다. 이러한 컨텍스트 관리자는 사용자와 함께 단일 인자 버전으로 마이그레이션해야 하며, 기존 API를 중단하는 대신 병렬 API를 제공할 수도 있습니다. 또는 슈퍼클래스가 __exit__(self, *args) 시그니처를 유지하면서 한 개의 인자와 세 개의 인자를 모두 지원할 수 있습니다. 대부분의 컨텍스트 관리자는 __exit__ 인자의 값을 사용하지 않고 예외가 계속 전파되도록 할 뿐이므로, 이것이 일반적인 접근 방식이 될 가능성이 높습니다.
보안 관련 사항
알고 있는 사항이 없습니다.
교육 방법
언어 튜토리얼에서는 단일 인자 버전을 소개하고, 컨텍스트 관리자 문서에는 __exit__ 및 __aexit__의 레거시 시그니처에 관한 절을 포함합니다.
참조 구현
CPython PR #101995는 이 PEP의 제안을 구현합니다.
거부된 아이디어
__leave__(self, exc) 지원
새로운 서명을 사용하는 __leave__와 같은 새 이름의 메서드를 지원하는 방안이 고려되었습니다. 이는 기본적으로 프로그래머가 사용하려는 서명을 명시적으로 선언하게 하며, 인트로스펙션이 필요하지 않도록 합니다.
이 아이디어의 여러 변형에는 __leave__와 __exit__사이의 동등성을 자동화하는 데 도움이 되는 다양한 수준의 마법이 포함됩니다. 예를 들어, Mark Shannon은 클래스에 둘 중 하나가 정의될 때마다 타입 생성자가 __exit__와 __leave__각각에 대한 기본 구현을 추가하도록 제안했습니다. 이 기본 구현은 사용자의 함수를 호출하는 트램펄린 역할을 합니다. 이렇게 하면 상속이 원활하게 작동하고, 특정 클래스에서 __exit__에서 __leave__로 마이그레이션하는 작업도 원활해집니다. 인터프리터는 그저 __leave__를 호출하면 되며, 그러면 필요할 때 __exit__를 호출합니다.
이 제안은 현재 제안보다 몇 가지 장점이 있지만, 두 가지 단점도 있습니다. 첫 번째는 데이터 모델에 새로운 던더 이름을 추가한다는 점이며, 결과적으로 같은 의미를 가지면서 서명만 약간 다른 두 개의 던더를 갖게 됩니다. 두 번째는 모든 __exit__를 __leave__로 마이그레이션해야 한다는 점입니다. 반면 인트로스펙션을 사용하면 인자에 접근하지 않는 많은 __exit__(*arg)메서드는 변경할 필요가 없습니다. __exit__를 grep하는 것만큼 간단하지는 않지만, 여러 인자를 받을 수 있고 해당 인자에 접근하는 __exit__메서드를 탐지하는 AST 방문자를 작성할 수 있습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.