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 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
거부 공지
우리는 이 PEP를 논의했으며 거부하기로 결정했습니다. 우리는 이 방식의 마법 같은 동작과 잠재적인 호환성 파괴 위험이 그 이점을 정당화하지 못한다고 판단했습니다. 하지만 잠재적인 컨텍스트 관리자 v2 API나__leave__를 탐색하는 것은 전적으로 지지합니다.
초록
이 PEP는 __exit__() / __aexit__() 메서드가 예외 인스턴스 하나만 받는 컨텍스트 관리자를 인터프리터가 받아들이도록 하되, 하위 호환성을 위해 현재의 (typ, exc, tb) 시그니처도 계속 지원할 것을 제안합니다.
이 제안은 언어에서 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 (인터프리터의 eval 루프 구현)에서 약 100줄의 코드가 제거되었으며, 이후 POP_EXCEPT_AND_RERAISE 옵코드도 제거되었습니다. 이 옵코드는 범용 스택 조작 명령어로 대체될 수 있을 만큼 단순해졌기 때문입니다. 마이크로 벤치마크 결과 예외를 잡고 발생시키는 작업과 제너레이터를 생성하는 작업이 약 10% 빨라졌습니다. 요약하자면, Python 내부의 이러한 중복을 제거함으로써 인터프리터가 단순해지고 더 빨라졌습니다.
컨텍스트 관리자를 벗어날 때 __exit__/__aexit__ 호출 성능도 다중 인자 함수 호출을 단일 인자 함수 호출로 바꾸어 개선할 수 있습니다. 마이크로 벤치마크 결과, 단일 인자 __exit__를 사용하는 컨텍스트 관리자에 진입하고 빠져나오는 작업이 약 13% 더 빨랐습니다.
언어 자체의 단순화
Python이 인기 있는 이유 중 하나는 단순함입니다. sys.exc_info() 삼중항은 처음 배우는 사람에게는 난해하며, 이를 이해하는 사람에게는 그 안의 중복이 혼란을 줍니다.
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는 다음과 같은 경우에 단일 인자로 호출됩니다:
- (
self를 포함하여)argcount가2이고 가변 인자가 없는PyMethod이거나, METH_O플래그가 있는PyCFunction인 경우입니다.
인트로스펙션의 성능 비용은 특수화를 통해 완화할 수 있으므로, 어떤 이유로 이보다 더 정교하게 만들어야 하더라도 문제가 되지 않는다는 점에 유의하십시오.
하위 호환성
인터프리터는 세 개의 인자를 받을 수 있는 컨텍스트 관리자를 항상 세 개의 인자로 호출하므로, 이전에 동작하던 모든 컨텍스트 관리자는 계속 같은 방식으로 동작합니다. 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__만 호출하면 되고, 그러면 __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.