PEP 654 – 예외 그룹과 except*
- Author:
- Irit Katriel <irit at python.org>, Yury Selivanov <yury at edgedb.com>, Guido van Rossum <guido at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 22-Feb-2021
- Python-Version:
- 3.11
- Post-History:
- 22-Feb-2021, 20-Mar-2021, 03-Oct-2021
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 문서는 프로그램이 서로 관련 없는 여러 예외를 동시에 발생시키고 처리할 수 있도록 하는 언어 확장을 제안합니다:
- 함께 전파되는 서로 관련 없는 예외 그룹을 나타내는 새로운 표준 예외 타입인
ExceptionGroup이 추가됩니다. ExceptionGroups를 처리하기 위한 새로운 구문except*이 추가됩니다.
동기
현재 인터프리터는 한 번에 최대 하나의 예외만 전파할 수 있습니다. 관련 PEP 3134에서 도입된 연쇄 기능은 원인이나 컨텍스트로서 서로 관련된 예외를 연결하지만, 스택이 되감기면서 서로 관련 없는 여러 예외를 함께 전파해야 하는 상황도 있습니다. 다음에는 실제 환경의 몇 가지 사용 사례가 나열되어 있습니다.
- 동시성 오류. 비동기 동시성을 위한 라이브러리는 여러 작업을 호출하고 그 결과를 집계하여 반환하는 API를 제공합니다. 현재 이러한 라이브러리가 여러 작업에서 예외가 발생하는 상황을 처리할 수 있는 좋은 방법은 없습니다. Python 표준 라이브러리의
asyncio.gather()[1] 함수는 첫 번째 예외를 발생시키거나 예외를 결과 목록에 반환하는 두 가지 옵션을 제공합니다. Trio [2] 라이브러리에는 오류 모음을 보고하기 위해 발생시키는MultiError예외 타입이 있습니다. 이 PEP의 작업은 처음에MultiErrors[9]를 처리하는 데 따르는 어려움에서 비롯되었으며, 이러한 어려움은 개선된 버전인MultiError2[3]를 위한 설계 문서에 자세히 설명되어 있습니다. 해당 문서는 우리가 제안하는 언어 변경 없이는 여러 오류를 보고하고 처리하기 위한 효과적인 API를 만들기가 얼마나 어려운지를 보여 줍니다( Programming Without ‘except *’ 섹션도 참조하십시오).Trio 너서리 [13]에서 영감을 받아 asyncio에 더 나은 작업 생성 API를 구현하는 것이 이 PEP의 주요 동기였습니다. 현재 Python에는 예외 그룹에 대한 네이티브 언어 수준 지원이 없기 때문에 이 작업은 진행되지 못하고 있습니다.
- 작업 재시도 중 발생하는 여러 실패. Python 표준 라이브러리의
socket.create_connection함수는 서로 다른 주소에 연결을 시도할 수 있으며, 모든 시도가 실패하면 이를 사용자에게 보고해야 합니다. 특히 오류가 서로 다를 때 이러한 오류를 집계하는 방법은 아직 미해결 문제입니다(issue 29980 [4]참조). - 여러 사용자 콜백의 실패. Python의
atexit.register()함수는 시스템 종료 시 호출되는 함수를 사용자가 등록할 수 있도록 합니다. 그중 어느 것이라도 예외를 발생시키면 마지막 예외만 다시 발생하지만, 모든 예외를 함께 다시 발생시키는 편이 더 좋습니다(atexit문서 [5]참조). 이와 마찬가지로 pytest 라이브러리는 종료 처리 시 실행되는 파이널라이저를 사용자가 등록할 수 있도록 합니다. 이러한 파이널라이저 중 둘 이상이 예외를 발생시키면 첫 번째 예외만 사용자에게 보고됩니다. pytest 개발자 Ran Benita가 이 이슈에서 설명한 것처럼ExceptionGroups를 사용하면 이를 개선할 수 있습니다(pytest issue 8217 [6]참조). - 복잡한 계산에서 발생하는 여러 오류. Hypothesis 라이브러리는 자동으로 버그를 축소합니다(버그를 보여 주는 코드를 단순화합니다). 이 과정에서 서로 다른 오류를 생성하는 변형을 발견할 수 있으며, (선택적으로) 그러한 오류를 모두 보고합니다(Hypothesis 문서 [7]참조). 여기서 제안하는
ExceptionGroup메커니즘은 위 링크에서 언급된 디버깅의 어려움 중 일부를 해결할 수 있으며, 이러한 어려움은 컨텍스트/원인 정보의 손실로 인해 발생합니다(Hypothesis 핵심 개발자 Zac Hatfield-Dodds가 전달함). - 래퍼 코드의 오류. Python 표준 라이브러리의
tempfile.TemporaryDirectory컨텍스트 관리자는__exit__에서 정리 중 발생한 예외가 컨텍스트 관리자 범위 내부에서 사용자 코드가 발생시킨 예외를 사실상 가리는 문제를 겪었습니다. 사용자 예외가 정리 오류의 컨텍스트로 연쇄되기는 했지만 사용자의 except 절에서 포착되지는 않았습니다(issue 40857 [8]참조).이 문제는 정리 코드가 오류를 무시하도록 만들어 여러 예외 문제를 우회하는 방식으로 해결되었습니다. 여기서 제안하는 기능을 사용하면
__exit__가 자체 오류와 사용자 오류를 함께 포함하는ExceptionGroup을 발생시킬 수 있으며, 이를 통해 사용자는 예외 타입별로 자신의 예외를 포착할 수 있습니다.
근거
여러 예외를 함께 그룹화하는 작업은 컨테이너 예외 유형을 만들기만 하면 언어를 변경하지 않고도 수행할 수 있습니다. Trio [2]는 MultiError [9] 유형에서 이 기법을 활용한 라이브러리의 한 예입니다. 그러나 이러한 접근 방식에서는 호출 코드가 컨테이너 예외 유형을 포착한 다음, 발생한 오류의 유형을 확인하고, 처리하려는 오류를 추출하며, 나머지를 다시 발생시켜야 합니다. 또한 Python의 예외에는 __traceback__및 __cause__및 __context__필드에 중요한 정보가 연결되어 있으므로, 이 정보의 무결성을 보존하는 컨테이너 유형을 설계하려면 주의가 필요합니다. 예외를 집합으로 모으는 것만큼 간단하지 않습니다.
기존 예외 처리 메커니즘의 방식으로 예외 그룹에 대한 지원을 확장하려면 언어를 변경해야 합니다. 최소한 처리하기로 선택한 유형의 예외가 포함된 경우에만 예외 그룹을 포착할 수 있기를 바랍니다. 같은 그룹에 있는 다른 유형의 예외는 자동으로 다시 발생시켜야 합니다. 그렇지 않으면 사용자 코드가 처리하지 않는 예외를 실수로 삼켜 버리기 쉽기 때문입니다.
이 목적을 위해 하위 호환 방식으로 except의 의미를 수정할 수 있는지 검토했지만, 불가능하다는 결론을 내렸습니다. 이에 관한 자세한 내용은 Rejected Ideas 섹션을 참조하십시오.
따라서 이 PEP의 목적은 인터프리터에 ExceptionGroup 내장 유형과 예외 그룹을 처리하기 위한 except* 구문을 추가하는 것입니다. except* 구문에 요구되는 의미는 현재의 예외 처리 의미와 충분히 다르므로, except키워드의 동작을 수정하는 대신 새로운 except* 구문을 추가하고자 합니다.
우리의 전제는 예외 그룹과 except*가 필요한 경우에만 선택적으로 사용된다는 것입니다. 이것들이 예외 처리를 위한 기본 메커니즘이 될 것이라고는 예상하지 않습니다. 라이브러리에서 예외 그룹을 발생시키기로 하는 결정은 신중하게 검토해야 하며 API 호환성을 깨는 변경으로 간주해야 합니다. 일반적으로는 기존 API를 수정하기보다 새로운 API를 도입하여 이를 수행할 것으로 예상합니다.
명세
ExceptionGroup 및 BaseExceptionGroup
다음 두 가지 새로운 내장 예외 유형을 추가할 것을 제안합니다: BaseExceptionGroup(BaseException) 및 ExceptionGroup(BaseExceptionGroup, Exception). 이들은 Exception.__cause__및 Exception.__context__에 할당할 수 있으며, raise ExceptionGroup(...) 및 try: ... except ExceptionGroup: ... 또는 raise BaseExceptionGroup(...) 및 try: ... except BaseExceptionGroup: ...를 사용하여 모든 예외와 마찬가지로 발생시키고 처리할 수 있습니다.
두 유형 모두 메시지 문자열과 중첩된 예외의 시퀀스라는 두 개의 위치 전용 인자를 받는 생성자를 가지며, 이 인자들은 message 및 exceptions 필드로 노출됩니다. 예를 들면 다음과 같습니다: ExceptionGroup('issues', [ValueError('bad value'), TypeError('bad type')]). 두 유형의 차이점은 ExceptionGroup은 Exception의 서브클래스만 감쌀 수 있는 반면 BaseExceptionGroup은 모든 BaseException의 서브클래스를 감쌀 수 있다는 점입니다. BaseExceptionGroup 생성자는 중첩된 예외를 검사하여 모두 Exception의 서브클래스인 경우 BaseExceptionGroup 대신 ExceptionGroup을 반환합니다. 중첩된 예외 중 하나라도 Exception인스턴스가 아니면 ExceptionGroup 생성자는 TypeError를 발생시킵니다. 이 문서의 나머지 부분에서 예외 그룹이라고 할 때는 ExceptionGroup 또는 BaseExceptionGroup을 의미합니다. 둘을 구분해야 하는 경우에는 클래스 이름을 사용합니다. 간결하게 하기 위해 두 유형 모두에 해당하는 코드 예제에서는 ExceptionGroup을 사용합니다.
예외 그룹은 중첩될 수 있으므로 예외의 트리를 나타냅니다. 여기서 잎은 일반 예외이고 각 내부 노드는 프로그램이 서로 관련 없는 예외를 새 그룹으로 묶어 함께 발생시킨 시점을 나타냅니다.
BaseExceptionGroup.subgroup(condition)메서드는 원래 그룹과 동일한 메타데이터(메시지, 원인, 컨텍스트, 트레이스백) 및 동일한 그룹의 중첩 구조를 가지지만, 조건이 참인 예외만 포함하는 예외 그룹을 얻는 방법을 제공합니다:
>>> eg = ExceptionGroup(
... "one",
... [
... TypeError(1),
... ExceptionGroup(
... "two",
... [TypeError(2), ValueError(3)]
... ),
... ExceptionGroup(
... "three",
... [OSError(4)]
... )
... ]
... )
>>> import traceback
>>> traceback.print_exception(eg)
| ExceptionGroup: one (3 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 1
+---------------- 2 ----------------
| ExceptionGroup: two (2 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 2
+---------------- 2 ----------------
| ValueError: 3
+------------------------------------
+---------------- 3 ----------------
| ExceptionGroup: three (1 sub-exception)
+-+---------------- 1 ----------------
| OSError: 4
+------------------------------------
>>> type_errors = eg.subgroup(lambda e: isinstance(e, TypeError))
>>> traceback.print_exception(type_errors)
| ExceptionGroup: one (2 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 1
+---------------- 2 ----------------
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: 2
+------------------------------------
>>>
일치 조건은 내부 노드(예외 그룹)에도 적용되며, 일치하면 이 노드를 루트로 하는 전체 하위 트리가 결과에 포함됩니다.
위 예제의 ExceptionGroup("three")의 경우와 같이, 비어 있는 중첩 그룹은 결과에서 제외됩니다. 조건에 일치하는 예외가 하나도 없으면 subgroup은 빈 그룹이 아니라 None을 반환합니다. 원래 eg는 subgroup에 의해 변경되지 않지만, 반환되는 값이 반드시 완전히 새로 복사된 객체인 것은 아닙니다. 리프 예외는 복사되지 않으며, 결과에 완전히 포함된 예외 그룹도 복사되지 않습니다. 포함된 예외 중 일부에 대해서만 조건이 성립하여 그룹을 분할해야 하는 경우에는 새로운 ExceptionGroup 또는 BaseExceptionGroup 인스턴스가 생성되지만, __cause__, __context__ 및 __traceback__ 필드는 참조로 복사되므로 원래 eg와 공유됩니다.
서브그룹과 그 여집합이 모두 필요한 경우에는 BaseExceptionGroup.split(condition) 메서드를 사용할 수 있습니다.
>>> type_errors, other_errors = eg.split(lambda e: isinstance(e, TypeError))
>>> traceback.print_exception(type_errors)
| ExceptionGroup: one (2 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 1
+---------------- 2 ----------------
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: 2
+------------------------------------
>>> traceback.print_exception(other_errors)
| ExceptionGroup: one (2 sub-exceptions)
+-+---------------- 1 ----------------
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: 3
+------------------------------------
+---------------- 2 ----------------
| ExceptionGroup: three (1 sub-exception)
+-+---------------- 1 ----------------
| OSError: 4
+------------------------------------
>>>
분할이 자명하여 한쪽이 비어 있으면 다른 쪽에는 None이 반환됩니다.
>>> other_errors.split(lambda e: isinstance(e, SyntaxError))
(None, ExceptionGroup('one', [
ExceptionGroup('two', [
ValueError(3)
]),
ExceptionGroup('three', [
OSError(4)])]))
예외 유형별 분할은 매우 일반적인 사용 사례이므로, subgroup과 split은 예외 유형 또는 예외 유형의 튜플을 받아 해당 유형과 일치하는 것의 축약형으로 처리할 수 있습니다. eg.split(T)은 eg를 유형 T와 일치하는 리프 예외의 서브그룹과 일치하지 않는 예외의 서브그룹으로 나눕니다(일치 여부에는 except와 동일한 검사를 사용합니다).
예외 그룹 서브클래싱
예외 그룹을 서브클래싱할 수 있지만, 그렇게 할 때는 일반적으로 subgroup()과 split()이 분할된 결과의 일치하는 부분 또는 일치하지 않는 부분에 대해 새 인스턴스를 어떻게 생성해야 하는지 지정해야 합니다. BaseExceptionGroup은 derive(self, excs)라는 인스턴스 메서드를 제공하며, 이 메서드는 subgroup과 split이 새 예외 그룹을 생성해야 할 때마다 호출됩니다. excs 매개변수는 새 그룹에 포함할 예외의 시퀀스입니다. derive 메서드는 self에 접근할 수 있으므로, self의 데이터를 새 객체에 복사할 수 있습니다. 예를 들어, 추가 오류 코드 필드를 가진 예외 그룹 서브클래스가 필요하다면 다음과 같이 작성할 수 있습니다.
class MyExceptionGroup(ExceptionGroup):
def __new__(cls, message, excs, errcode):
obj = super().__new__(cls, message, excs)
obj.errcode = errcode
return obj
def derive(self, excs):
return MyExceptionGroup(self.message, excs, self.errcode)
__init__ 대신 __new__를 재정의한다는 점에 유의하십시오. 이는 BaseExceptionGroup.__new__가 생성자 인자를 검사해야 하고, 그 시그니처가 서브클래스의 시그니처와 다르기 때문입니다. 또한 derive 함수가 __context__, __cause__ 및 __traceback__ 필드를 복사하지 않는다는 점에 유의하십시오. subgroup과 split이 이를 대신 처리하기 때문입니다.
위에서 정의한 클래스를 사용하면 다음과 같은 결과를 얻습니다.
>>> eg = MyExceptionGroup("eg", [TypeError(1), ValueError(2)], 42)
>>>
>>> match, rest = eg.split(ValueError)
>>> print(f'match: {match!r}: {match.errcode}')
match: MyExceptionGroup('eg', [ValueError(2)], 42): 42
>>> print(f'rest: {rest!r}: {rest.errcode}')
rest: MyExceptionGroup('eg', [TypeError(1)], 42): 42
>>>
derive를 재정의하지 않으면 split은 BaseExceptionGroup에 정의된 메서드를 호출하며, 포함된 모든 예외의 유형이 Exception이면 ExceptionGroup의 인스턴스를 반환하고, 그렇지 않으면 BaseExceptionGroup의 인스턴스를 반환합니다. 예를 들면 다음과 같습니다.
>>> class MyExceptionGroup(BaseExceptionGroup):
... pass
...
>>> eg = MyExceptionGroup("eg", [ValueError(1), KeyboardInterrupt(2)])
>>> match, rest = eg.split(ValueError)
>>> print(f'match: {match!r}')
match: ExceptionGroup('eg', [ValueError(1)])
>>> print(f'rest: {rest!r}')
rest: BaseExceptionGroup('eg', [KeyboardInterrupt(2)])
>>>
예외 그룹의 트레이스백
일반 예외의 경우 트레이스백은 예외가 발생한 프레임에서 예외가 잡힌 프레임까지, 또는 아직 잡히지 않았다면 프로그램 실행이 현재 진행 중인 프레임까지 이어지는 단순한 프레임 경로를 나타냅니다. 이 목록은 인터프리터가 구성하며, 현재 예외가 존재하는 경우 인터프리터는 종료하는 각 프레임을 현재 예외의 트레이스백에 추가합니다. 효율적으로 추가할 수 있도록 트레이스백의 프레임 목록에 있는 링크는 가장 오래된 프레임에서 가장 최신 프레임으로 연결됩니다. 새 프레임을 추가하는 작업은 예외의 __traceback__ 필드가 참조하는 연결 리스트에 새 헤드를 삽입하는 것에 불과합니다. 중요한 점은 트레이스백의 프레임 목록이 불변이라는 것입니다. 즉, 프레임은 헤드에 추가하기만 하면 되고 제거할 필요는 없습니다.
이 데이터 구조를 변경할 필요는 없습니다. 예외 그룹 인스턴스의 __traceback__ 필드는 포함된 예외들이 그룹으로 결합된 후 함께 거쳐 온 경로를 나타내며, 중첩된 각 예외의 동일한 필드는 해당 예외가 병합 프레임에 도달한 경로를 나타냅니다.
변경해야 하는 것은 트레이스백을 해석하고 표시하는 모든 코드입니다. 이제 다음 예제와 같이 중첩된 예외의 트레이스백까지 계속 처리해야 하기 때문입니다.
>>> def f(v):
... try:
... raise ValueError(v)
... except ValueError as e:
... return e
...
>>> try:
... raise ExceptionGroup("one", [f(1)])
... except ExceptionGroup as e:
... eg = e
...
>>> raise ExceptionGroup("two", [f(2), eg])
+ Exception Group Traceback (most recent call last):
| File "<stdin>", line 1, in <module>
| ExceptionGroup: two (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "<stdin>", line 3, in f
| ValueError: 2
+---------------- 2 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "<stdin>", line 3, in f
| ValueError: 1
+------------------------------------
>>>
예외 그룹 처리
프로그램이 예외 그룹을 포착하고 처리할 때는 일반적으로 subgroup이나 split을 사용하여 특정 조건을 만족하는 리프 예외가 있는지 질의하거나, traceback 모듈의 메서드를 사용하여 예외 형식을 지정할 것으로 예상합니다.
개별 리프 예외를 순회하는 것은 유용할 가능성이 낮습니다. 그 이유를 알아보기 위해, 애플리케이션이 asyncio.gather() 호출에서 발생한 예외 그룹을 포착했다고 가정해 보겠습니다. 이 단계에서는 각 특정 예외의 컨텍스트가 손실됩니다. 이 예외에 대한 복구는 다른 예외들과 그룹화되기 전에 수행되었어야 합니다 [10]. 또한 애플리케이션은 특정 예외 타입의 인스턴스가 몇 개이든 동일한 방식으로 반응할 가능성이 높으므로, eg.subgroup(T)가 None인지 아닌지를 알고 싶어 할 가능성이 eg에 있는 Ts의 개수에 관심을 가질 가능성보다 높습니다.
그러나 개별 리프 예외를 검사해야 하는 상황도 있습니다. 예를 들어, eg라는 예외 그룹이 있고 특정 오류 코드를 가진 OSErrors를 기록한 다음 나머지는 모두 다시 발생시키고 싶다고 가정해 보겠습니다. 다음과 같이 부작용이 있는 함수를 subgroup에 전달하여 이를 수행할 수 있습니다:
def log_and_ignore_ENOENT(err):
if isinstance(err, OSError) and err.errno == ENOENT:
log(err)
return False
else:
return True
try:
. . .
except ExceptionGroup as eg:
eg = eg.subgroup(log_and_ignore_ENOENT)
if eg is not None:
raise eg
앞의 예제에서 리프 예외에 log_and_ignore_ENOENT를 호출하면, 이 예외의 트레이스백 중 __traceback__필드에서 참조되는 부분만 액세스할 수 있습니다. 전체 트레이스백이 필요한 경우에는 루트에서 이 리프까지의 경로에 있는 예외들의 트레이스백을 연결한 것을 살펴보아야 합니다. 다음과 같이 직접 재귀적으로 순회하여 이를 얻을 수 있습니다:
def leaf_generator(exc, tbs=None):
if tbs is None:
tbs = []
tbs.append(exc.__traceback__)
if isinstance(exc, BaseExceptionGroup):
for e in exc.exceptions:
yield from leaf_generator(e, tbs)
else:
# exc is a leaf exception and its traceback
# is the concatenation of the traceback
# segments in tbs.
# Note: the list returned (tbs) is reused in each iteration
# through the generator. Make a copy if your use case holds
# on to it beyond the current iteration or mutates its contents.
yield exc, tbs
tbs.pop()
그런 다음 리프 예외의 전체 트레이스백을 처리할 수 있습니다:
>>> import traceback
>>>
>>> def g(v):
... try:
... raise ValueError(v)
... except Exception as e:
... return e
...
>>> def f():
... raise ExceptionGroup("eg", [g(1), g(2)])
...
>>> try:
... f()
... except BaseException as e:
... eg = e
...
>>> for (i, (exc, tbs)) in enumerate(leaf_generator(eg)):
... print(f"\n=== Exception #{i+1}:")
... traceback.print_exception(exc)
... print(f"The complete traceback for Exception #{i+1}:")
... for tb in tbs:
... traceback.print_tb(tb)
...
=== Exception #1:
Traceback (most recent call last):
File "<stdin>", line 3, in g
ValueError: 1
The complete traceback for Exception #1
File "<stdin>", line 2, in <module>
File "<stdin>", line 2, in f
File "<stdin>", line 3, in g
=== Exception #2:
Traceback (most recent call last):
File "<stdin>", line 3, in g
ValueError: 2
The complete traceback for Exception #2:
File "<stdin>", line 2, in <module>
File "<stdin>", line 2, in f
File "<stdin>", line 3, in g
>>>
except*
예외 그룹 작업을 간소화하기 위해 try..except 구문의 새로운 변형을 도입할 것을 제안합니다. * 기호는 각 except* 절이 여러 예외를 처리할 수 있음을 나타냅니다:
try:
...
except* SpamError:
...
except* FooError as e:
...
except* (BarError, BazError) as e:
...
기존의 try-except 문에서는 처리할 예외가 하나뿐이므로, 최대 하나의 except 절 본문만 실행됩니다. 즉, 예외와 일치하는 첫 번째 절이 실행됩니다. 새로운 구문에서는 except* 절이 발생한 예외 그룹의 하위 그룹과 일치할 수 있으며, 남은 부분은 뒤따르는 except* 절과 일치합니다. 다시 말해, 하나의 예외 그룹으로 인해 여러 except* 절이 실행될 수 있지만, 각 절은 그룹에서 일치하는 모든 예외에 대해 최대 한 번만 실행되며, 각 예외는 정확히 하나의 절에 의해 처리되거나(해당 타입과 일치하는 첫 번째 절에 의해) 마지막에 다시 발생합니다. try-except*블록이 각 예외를 처리하는 방식은 그룹의 다른 어떤 예외와도 독립적입니다.
예를 들어, 위의 try 블록 본문이 eg = ExceptionGroup('msg', [FooError(1), FooError(2), BazError()]) 를 발생시킨다고 가정해 보겠습니다. except*절은 unhandled예외 그룹에 split 을 호출하여 순서대로 평가됩니다. 이 그룹은 처음에는 eg와 같으며, 예외가 일치하여 추출될 때마다 축소됩니다. 첫 번째 except*절에서 unhandled.split(SpamError)는 (None, unhandled) 를 반환하므로 이 블록의 본문은 실행되지 않고 unhandled는 변경되지 않습니다. 두 번째 블록에서는 unhandled.split(FooError)가 match = ExceptionGroup('msg', [FooError(1), FooError(2)]) 및 rest = ExceptionGroup('msg', [BazError()]) 인 의미 있는 분할 (match, rest) 를 반환합니다. 이 except*블록의 본문이 실행되며, e의 값과 sys.exc_info()는 match로 설정됩니다. 그런 다음 unhandled는 rest로 설정됩니다. 마지막으로 세 번째 블록이 남은 예외와 일치하므로 실행되며, 이때 e와 sys.exc_info()는 ExceptionGroup('msg', [BazError()])로 설정됩니다.
예외는 서브클래스 검사를 사용하여 매칭됩니다. 예를 들어:
try:
low_level_os_operation()
except* OSError as eg:
for e in eg.exceptions:
print(type(e).__name__)
다음과 같이 출력될 수 있습니다:
BlockingIOError
ConnectionRefusedError
OSError
InterruptedError
BlockingIOError
except*절의 순서는 일반적인 try..except와 마찬가지로 중요합니다:
>>> try:
... raise ExceptionGroup("problem", [BlockingIOError()])
... except* OSError as e: # Would catch the error
... print(repr(e))
... except* BlockingIOError: # Would never run
... print('never')
...
ExceptionGroup('problem', [BlockingIOError()])
재귀적 매칭
예외 그룹에 대한 except*절의 매칭은 split() 메서드를 사용하여 재귀적으로 수행됩니다:
>>> try:
... raise ExceptionGroup(
... "eg",
... [
... ValueError('a'),
... TypeError('b'),
... ExceptionGroup(
... "nested",
... [TypeError('c'), KeyError('d')])
... ]
... )
... except* TypeError as e1:
... print(f'e1 = {e1!r}')
... except* Exception as e2:
... print(f'e2 = {e2!r}')
...
e1 = ExceptionGroup('eg', [TypeError('b'), ExceptionGroup('nested', [TypeError('c')])])
e2 = ExceptionGroup('eg', [ValueError('a'), ExceptionGroup('nested', [KeyError('d')])])
>>>
일치하지 않은 예외
예외 그룹의 모든 예외가 except* 절과 일치하지 않은 경우, 그룹의 나머지 부분은 다음과 같이 전파됩니다:
>>> try:
... try:
... raise ExceptionGroup(
... "msg", [
... ValueError('a'), TypeError('b'),
... TypeError('c'), KeyError('e')
... ]
... )
... except* ValueError as e:
... print(f'got some ValueErrors: {e!r}')
... except* TypeError as e:
... print(f'got some TypeErrors: {e!r}')
... except ExceptionGroup as e:
... print(f'propagated: {e!r}')
...
got some ValueErrors: ExceptionGroup('msg', [ValueError('a')])
got some TypeErrors: ExceptionGroup('msg', [TypeError('b'), TypeError('c')])
propagated: ExceptionGroup('msg', [KeyError('e')])
>>>
래핑되지 않은 예외
try 본문 내부에서 발생한 예외가 ExceptionGroup 또는 BaseExceptionGroup 유형이 아닌 경우, 이를 naked 예외라고 합니다. 해당 유형이 except* 절 중 하나와 일치하면, 빈 메시지 문자열을 가진 ExceptionGroup(또는 BaseException의 서브클래스가 아닌 경우 BaseExceptionGroup)으로 래핑되어 포착됩니다. 이는 e의 유형을 일관되게 유지하고 정적으로 알려지도록 하기 위한 것입니다:
>>> try:
... raise BlockingIOError
... except* OSError as e:
... print(repr(e))
...
ExceptionGroup('', [BlockingIOError()])
그러나 일반 예외가 포착되지 않으면 원래의 일반 예외 형태로 전파됩니다:
>>> try:
... try:
... raise ValueError(12)
... except* TypeError as e:
... print('never')
... except ValueError as e:
... print(f'caught ValueError: {e!r}')
...
caught ValueError: ValueError(12)
>>>
except* 블록에서 예외 발생시키기
기존의 except 블록에서 예외를 발생시키는 방법은 두 가지입니다. 예외 객체 e 를 명시적으로 발생시키는 raise e 와 ‘current exception’을 다시 발생시키는 raise 입니다. e 가 현재 예외인 경우, 다시 발생시키기는 현재 프레임을 스택에 추가하지 않으므로 두 형식은 동일하지 않습니다:
def foo(): | def foo():
try: | try:
1 / 0 | 1 / 0
except ZeroDivisionError as e: | except ZeroDivisionError:
raise e | raise
|
foo() | foo()
|
Traceback (most recent call last): | Traceback (most recent call last):
File "/Users/guido/a.py", line 7 | File "/Users/guido/b.py", line 7
foo() | foo()
File "/Users/guido/a.py", line 5 | File "/Users/guido/b.py", line 3
raise e | 1/0
File "/Users/guido/a.py", line 3 | ZeroDivisionError: division by zero
1/0 |
ZeroDivisionError: division by zero |
이는 예외 그룹에도 적용되지만, 여러 except* 절에서 예외가 발생하거나 다시 발생할 수 있고 전파되어야 하는 처리되지 않은 예외도 있을 수 있으므로 상황이 더 복잡합니다. 인터프리터는 이러한 모든 예외를 하나의 결과로 결합한 다음 이를 발생시켜야 합니다.
다시 발생한 예외와 처리되지 않은 예외는 원래 그룹의 하위 그룹이며, 해당 그룹의 메타데이터(원인, 컨텍스트, 트레이스백)를 공유합니다. 반면 명시적으로 발생한 각 예외는 자체 메타데이터를 가집니다. 트레이스백에는 해당 예외가 발생한 줄이 포함되고, 원인은 명시적으로 연결된 대상이 되며, 컨텍스트는 예외를 발생시킨 except* 절에서 sys.exc_info() 의 값이 됩니다.
집계된 예외 그룹에서 다시 발생한 예외와 처리되지 않은 예외는 원래 예외에서와 동일한 상대적 구조를 가지며, 마치 하나의 subgroup 호출에서 함께 분리된 것과 같습니다. 예를 들어 아래 코드 조각에서 내부 try-except* 블록은 모든 ValueErrors 와 TypeErrors 를 원래 ExceptionGroup 에서의 동일한 형태로 다시 병합하여 포함하는 ExceptionGroup 을 발생시킵니다:
>>> try:
... try:
... raise ExceptionGroup(
... "eg",
... [
... ValueError(1),
... TypeError(2),
... OSError(3),
... ExceptionGroup(
... "nested",
... [OSError(4), TypeError(5), ValueError(6)])
... ]
... )
... except* ValueError as e:
... print(f'*ValueError: {e!r}')
... raise
... except* OSError as e:
... print(f'*OSError: {e!r}')
... except ExceptionGroup as e:
... print(repr(e))
...
*ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])])
*OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])])
ExceptionGroup('eg', [ValueError(1), TypeError(2), ExceptionGroup('nested', [TypeError(5), ValueError(6)])])
>>>
예외가 명시적으로 발생하면 원래 예외 그룹과 독립적이므로 해당 그룹과 병합할 수 없습니다(자체 원인, 컨텍스트 및 트레이스백을 가집니다). 대신 이러한 예외는 위에서 설명한 다시 발생했거나 처리되지 않은 하위 그룹도 포함하는 새로운 ExceptionGroup(또는 BaseExceptionGroup)으로 결합됩니다.
다음 예제에서는 ValueErrors 가 발생했으므로 자체 ExceptionGroup 에 포함되고, OSErrors 는 다시 발생했으므로 처리되지 않은 TypeErrors 와 병합됩니다.
>>> try:
... raise ExceptionGroup(
... "eg",
... [
... ValueError(1),
... TypeError(2),
... OSError(3),
... ExceptionGroup(
... "nested",
... [OSError(4), TypeError(5), ValueError(6)])
... ]
... )
... except* ValueError as e:
... print(f'*ValueError: {e!r}')
... raise e
... except* OSError as e:
... print(f'*OSError: {e!r}')
... raise
...
*ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])])
*OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])])
| ExceptionGroup: (2 sub-exceptions)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 15, in <module>
| File "<stdin>", line 2, in <module>
| ExceptionGroup: eg (2 sub-exceptions)
+-+---------------- 1 ----------------
| ValueError: 1
+---------------- 2 ----------------
| ExceptionGroup: nested (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: 6
+------------------------------------
+---------------- 2 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: eg (3 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 2
+---------------- 2 ----------------
| OSError: 3
+---------------- 3 ----------------
| ExceptionGroup: nested (2 sub-exceptions)
+-+---------------- 1 ----------------
| OSError: 4
+---------------- 2 ----------------
| TypeError: 5
+------------------------------------
>>>
연쇄
명시적으로 발생한 예외 그룹은 모든 예외와 마찬가지로 연쇄됩니다. 다음 예에서는 ExceptionGroup “one”의 일부가 ExceptionGroup “two”의 컨텍스트가 되고, 다른 일부는 새로운 ExceptionGroup에 포함되어 그것과 결합되는 과정을 보여 줍니다.
>>> try:
... raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
... except* ValueError:
... raise ExceptionGroup("two", [KeyError('x'), KeyError('y')])
...
| ExceptionGroup: (2 sub-exceptions)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
|
| During handling of the above exception, another exception occurred:
|
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 4, in <module>
| ExceptionGroup: two (2 sub-exceptions)
+-+---------------- 1 ----------------
| KeyError: 'x'
+---------------- 2 ----------------
| KeyError: 'y'
+------------------------------------
+---------------- 2 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: b
+------------------------------------
>>>
새 예외 발생시키기
앞의 예제에서는 명시적으로 발생시킨 예외가 포착된 예외였으므로, 완전성을 위해 연쇄를 사용하여 새 예외를 발생시키는 경우를 보여 줍니다:
>>> try:
... raise TypeError('bad type')
... except* TypeError as e:
... raise ValueError('bad value') from e
...
| ExceptionGroup: (1 sub-exception)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| TypeError: bad type
+------------------------------------
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "<stdin>", line 4, in <module>
ValueError: bad value
>>>
한 except* 절에서 발생한 예외는 동일한 try 문에 속한 다른 절과 일치할 수 없다는 점에 유의하십시오:
>>> try:
... raise TypeError(1)
... except* TypeError:
... raise ValueError(2) from None # <- not caught in the next clause
... except* ValueError:
... print('never')
...
Traceback (most recent call last):
File "<stdin>", line 4, in <module>
ValueError: 2
>>>
일반 예외의 새 인스턴스를 발생시킨다고 해서 이 예외가 예외 그룹으로 감싸지는 것은 아닙니다. 오히려 예외는 있는 그대로 발생하며, 다른 전파된 예외와 결합해야 하는 경우 이를 위해 생성된 새 예외 그룹의 직접적인 자식이 됩니다:
>>> try:
... raise ExceptionGroup("eg", [ValueError('a')])
... except* ValueError:
... raise KeyError('x')
...
| ExceptionGroup: (1 sub-exception)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: eg (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
|
| During handling of the above exception, another exception occurred:
|
| Traceback (most recent call last):
| File "<stdin>", line 4, in <module>
| KeyError: 'x'
+------------------------------------
>>>
>>> try:
... raise ExceptionGroup("eg", [ValueError('a'), TypeError('b')])
... except* ValueError:
... raise KeyError('x')
...
| ExceptionGroup: (2 sub-exceptions)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: eg (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
|
| During handling of the above exception, another exception occurred:
|
| Traceback (most recent call last):
| File "<stdin>", line 4, in <module>
| KeyError: 'x'
+---------------- 2 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: eg (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: b
+------------------------------------
>>>
마지막으로 제안된 의미론이 예외 그룹을 효과적으로 다루는 데 어떻게 도움이 되는지를 보여 주는 예로, 다음 코드는 모든 EPIPE OS 오류를 무시하면서 다른 모든 예외는 전파합니다.
try:
low_level_os_operation()
except* OSError as errors:
exc = errors.subgroup(lambda e: e.errno != errno.EPIPE)
if exc is not None:
raise exc from None
포착된 예외 객체
except* 절에서 e에 바인딩되는 예외 그룹은 일시적인 객체라는 점을 짚고 넘어가는 것이 중요합니다. raise 또는 raise e를 통해 이를 발생시켜도 원래 예외 그룹의 전체 구조는 변경되지 않습니다. e에 대한 수정 사항은 대부분 손실됩니다:
>>> eg = ExceptionGroup("eg", [TypeError(12)])
>>> eg.foo = 'foo'
>>> try:
... raise eg
... except* TypeError as e:
... e.foo = 'bar'
... # ^----------- ``e`` is an ephemeral object that might get
>>> # destroyed after the ``except*`` clause.
>>> eg.foo
'foo'
금지된 조합
동일한 try 문에서 전통적인 except 블록과 새로운 except* 절을 모두 사용할 수 없습니다. 다음은 SyntaxError입니다:
try:
...
except ValueError:
pass
except* CancelledError: # <- SyntaxError:
pass # combining ``except`` and ``except*``
# is prohibited
except를 사용하여 ExceptionGroup 및 BaseExceptionGroup 형식을 포착할 수 있지만, 후자는 모호하므로 except*로는 포착할 수 없습니다:
try:
...
except ExceptionGroup: # <- This works
pass
try:
...
except* ExceptionGroup: # <- Runtime error
pass
try:
...
except* (TypeError, ExceptionGroup): # <- Runtime error
pass
의미가 혼란스러울 수 있으므로 비어 있는 “무엇이든 일치”하는 except* 블록은 지원되지 않습니다:
try:
...
except*: # <- SyntaxError
pass
continue, break, return은 except* 절에서 허용되지 않으며, 그 결과 SyntaxError가 발생합니다. 이는 ExceptionGroup의 예외들이 서로 독립적이라고 가정하기 때문이며, except* 절이 다른 절을 통한 제어 흐름을 변경하도록 허용할 경우 발생할 수 있듯이, 예외 중 하나의 존재 여부가 다른 예외의 처리를 좌우해서는 안 됩니다.
하위 호환성
하위 호환성은 설계의 요구 사항이었으며, 이 PEP에서 제안하는 변경 사항은 기존 코드를 중단시키지 않습니다:
- 새로운 내장 예외 형식인
ExceptionGroup및BaseExceptionGroup의 추가는 기존 프로그램에 영향을 주지 않습니다. 기존 예외가 처리되고 표시되는 방식은 어떤 식으로도 변경되지 않습니다. except의 동작은 변경되지 않으므로 기존 코드는 계속 작동합니다. 프로그램이 예외 그룹과except*을 사용하기 시작한 이후에만 이 PEP에서 제안하는 변경 사항의 영향을 받습니다.- 중요한 우려 사항은
except Exception:이 거의 모든 예외를 계속 포착해야 한다는 것이었으며,ExceptionGroup이Exception을 상속하도록 하여 이것이 보장되도록 했습니다.BaseExceptionGroups은 포착되지 않으며, 이는except Exception으로 포착되지 않았을 예외를 포함하므로 적절합니다.
프로그램이 이러한 기능을 사용하기 시작하면 고려해야 할 마이그레이션 문제가 발생합니다:
- 이제 예외 그룹을 발생시킬 가능성이 있는 코드를 감싸는
except T:절은except* T:로 변경해야 할 수 있으며, 해당 본문도 업데이트해야 할 수 있습니다. 이는 예외 그룹을 발생시키는 것이 API를 중단시키는 변경이며, 기존 API에 추가하기보다는 새로운 API에서 수행될 가능성이 높다는 의미입니다. - 이전 Python 버전을 지원해야 하는 라이브러리는
except*을 사용하거나 예외 그룹을 발생시킬 수 없습니다.
이를 가르치는 방법
예외 그룹과 except*은 언어 표준의 일부로 문서화됩니다. asyncio와 같이 예외 그룹을 발생시키는 라이브러리는 문서에서 이를 명시하고, 어떤 API 호출을 try-except가 아닌 try-except*로 감싸야 하는지 명확히 해야 합니다.
참조 구현
이 개념과 이 PEP의 예제는 참조 구현 [11]의 도움으로 개발되었습니다.
여기에는 내장 ExceptionGroup과 함께 트레이스백 형식 지정 코드의 변경 사항이 포함되어 있으며, except*을 지원하는 데 필요한 문법, 컴파일러 및 인터프리터 변경 사항도 포함되어 있습니다. BaseExceptionGroup은 곧 추가될 예정입니다.
두 개의 옵코드가 추가되었습니다. 하나는 ExceptionGroup.split()을 통해 예외 형식 일치 검사를 구현하고, 다른 하나는 try-except 구성의 끝에서 처리되지 않은 예외, 발생한 예외 및 재발생한 예외를 모두 병합하는 데 사용됩니다(있는 경우). 발생한 예외와 재발생한 예외는 런타임 스택의 목록에 수집됩니다. 이를 위해 각 except* 절의 본문은 발생한 예외를 포착하는 전통적인 try-except로 감싸집니다. 발생한 예외와 재발생한 예외는 모두 동일한 목록에 수집됩니다. 이를 결과로 병합할 때가 되면, 발생한 예외와 재발생한 예외는 해당 메타데이터 필드(컨텍스트, 원인, 트레이스백)를 최초로 발생한 예외의 필드와 비교하여 구분됩니다. 위에서 언급했듯이 재발생한 예외는 원래 예외와 동일한 메타데이터를 가지지만, 발생한 예외는 그렇지 않습니다.
거부된 아이디어
예외 그룹을 이터러블로 만들기
예외 그룹을 이터러블로 만들어 list(eg)가 그룹에 포함된 말단 예외의 평탄화된 목록을 생성하도록 하는 방안을 검토했습니다. 그룹 내 개별 예외의 메타데이터(원인, 컨텍스트 및 트레이스백)가 불완전하여 문제가 발생할 수 있으므로, 이것은 건전한 API가 아니라고 판단했습니다.
또한 Handling Exception Groups 섹션에서 설명했듯이, 말단 예외를 순회하는 기능이 많은 사용 사례를 갖게 될 가능성은 낮다고 판단합니다. 그러나 각 말단 예외의 메타데이터를 올바르게 구성하는 순회 알고리즘의 코드는 해당 섹션에 제공했습니다. 실제로 유용한 것으로 드러난다면, 향후 해당 유틸리티를 표준 라이브러리에 추가하거나 예외 그룹을 이터러블로 만들 수도 있습니다.
ExceptionGroup을 BaseException으로 확장하기
ExceptionGroup을 Exception이 아닌 BaseException만 상속하도록 만드는 방안을 검토했습니다. 그 근거는 예외 그룹이 필요한 곳에서 의도적으로 사용되고, 그렇게 하도록 특별히 설계되고 문서화된 API에서만 발생할 것으로 예상했기 때문입니다. 이러한 맥락에서 발생시키도록 의도되지 않은 API에서 ExceptionGroup이 빠져나오는 것은 버그이며, except Exception이 이를 의도치 않게 삼키지 않도록 “치명적 오류” 상태를 부여하고자 했습니다. 이는 다른 모든 타입에 대해 except T:가 T를 포함하는 예외 그룹을 포착하지 않는 방식과 일관되며, ExceptionGroups가 프로그램에서 나타나야 하는 부분에만 존재하도록 제한하는 데 도움이 되었을 것입니다. 그러나 공개 논의를 통해 T=Exception이 특수한 경우이며, 예외 그룹을 포함하여 “거의 모든 것”을 except Exception:이 포착해야 한다고 강하게 믿는 개발자들이 있다는 점이 분명해졌습니다. 이것이 ExceptionGroup을 Exception의 서브클래스로 만들기로 결정한 이유입니다.
BaseExceptions을 예외 그룹으로 감싸는 것을 불가능하게 만들기
ExceptionGroup이 Exception을 확장하도록 결정한 결과, ExceptionGroup은 KeyboardInterrupt와 같은 BaseExceptions을 감싸서는 안 됩니다. 이러한 예외는 현재 except Exception:에 포착되지 않기 때문입니다. 단순히 BaseExceptions을 감싸는 것을 불가능하게 하는 방안을 검토했지만, 결국 Exception이 아니라 BaseException을 확장하는 BaseExceptionGroup 유형을 통해 이를 가능하게 하기로 결정했습니다. 이를 가능하게 하면 언어의 유연성이 높아지고, 다른 예외는 버리면서 BaseExceptions을 원래 형태 그대로 전파하는 대신 이를 감싸는 이점을 취할지 여부를 프로그래머가 판단할 수 있습니다.
트레이스백 표현
트레이스백 데이터 구조를 트리로 표현하도록 조정하는 방안을 검토했지만, 트레이스백 트리는 그것이 참조하는 예외와 분리되는 순간 의미가 없다는 점이 분명해졌습니다. 단순 경로 트레이스백은 with_traceback()호출로 모든 예외에 연결할 수 있지만, 트레이스백 트리를 예외 그룹에 할당하는 것이 타당한 경우는 상상하기 어렵습니다. 또한 유용한 트레이스백 표시에는 중첩된 예외에 관한 정보가 포함됩니다. 이러한 이유로 트레이스백 메커니즘은 현재대로 유지하고 트레이스백 표시 코드를 수정하는 것이 최선이라고 결정했습니다.
except를 확장하여 예외 그룹 처리하기
except*를 도입하는 대신 except의 의미론을 확장하여 예외 그룹을 처리하도록 하는 방안을 검토했습니다. 여기에는 하위 호환성과 관련된 두 가지 우려가 있었습니다. 첫 번째는 포착된 예외의 타입입니다. 다음 예를 살펴보십시오.
try:
. . .
except OSError as err:
if err.errno != ENOENT:
raise
err에 할당된 값이 발생한 모든 OSErrors를 포함하는 예외 그룹이면, 특성 접근 err.errno이 더 이상 작동하지 않습니다. 따라서 except 절의 본문을 그룹 내 각 예외에 대해 한 번씩 여러 번 실행해야 합니다. 그러나 현재는 except 절이 한 번만 실행된다는 것을 알고 작성하므로, 이 역시 호환성을 깨뜨릴 가능성이 있는 변경입니다. 만약 그곳에 리소스 해제와 같이 멱등적이지 않은 연산이 있다면, 반복 실행이 해로울 수 있습니다.
except가 예외 그룹의 리프 예외들을 순회하도록 만드는 아이디어는 Nathaniel J. Smith의 이 PEP에 대한 대안 제안의 핵심이며, 그 제안에 대한 논의는 파이썬처럼 성숙한 언어에서 except의 의미를 변경하는 것의 함정과, 다른 언어에서 병렬 구문이 갖는 의미로부터 벗어나는 것에 대해서도 더 자세히 다루고 있습니다.
공개 논의에서 나온 또 다른 방안은 except*를 추가하되, except가 ExceptionGroups를 특수한 경우로 처리하게 만드는 것이었습니다. 그러면 except는 그룹에서 일치하는 타입의 예외 하나를 추출하여 처리하는(그리고 그룹 안의 나머지 모든 예외는 버리는) 방식으로 동작하게 됩니다. 이러한 제안의 동기는 예외 그룹의 도입을 더 안전하게 만드는 것이었습니다. 즉, except T가 예외 그룹으로 감싸진 Ts를 포착하도록 하는 것입니다. 이러한 접근 방식은 언어를 더 강력하게 만들지 않으면서 언어 의미론에 상당한 복잡성을 추가한다고 판단했습니다. 예외 그룹의 도입을 약간 더 쉽게 만들 수 있다고 하더라도(전혀 명확하지는 않습니다), 이는 장기적으로 우리가 원하는 의미론이 아닙니다.
새로운 except 대안
생 예외와 예외 그룹을 모두 처리하는 데 사용할 수 있는 새로운 키워드(예: catch)를 도입하는 방안을 고려했습니다. 이 키워드의 의미론은 예외 그룹을 포착할 때 except*와 동일하지만, 생 예외를 감싸서 예외 그룹을 만들지는 않습니다. 이는 except를 catch로 대체하기 위한 장기 계획의 일부가 될 수 있었지만, 현재로서는 except를 더 향상된 키워드로 대체하기 위해 폐기하는 것이 사용자에게 너무 혼란스러울 것이라고 판단했습니다. 따라서 except가 단순 예외에 계속 사용되는 동안 예외 그룹에는 except* 구문을 도입하는 것이 더 적절합니다.
한 번에 하나의 예외에 except* 절 적용하기
기존 코드에서 except 절을 두 번 이상 실행하는 것은 안전하지 않다고 앞서 설명했습니다. 코드가 멱등적이지 않을 수 있기 때문입니다. 하위 호환성에 대한 고려가 존재하지 않는 새로운 except* 절에서는 이를 수행하는 방안을 고려했습니다. 아이디어는 except* 절을 항상 하나의 예외에 대해 실행하되, 여러 예외와 일치할 때 동일한 절을 여러 번 실행할 수도 있도록 하는 것입니다. 대신 각 except* 절을 최대 한 번만 실행하고, 일치하는 모든 예외를 포함하는 예외 그룹을 전달하기로 결정했습니다. 이렇게 결정한 이유는 프로그램이 처리 중인 예외의 구체적인 맥락을 알아야 할 때, 해당 예외가 그룹화되어 다른 예외와 함께 발생하기 전에 처리된다는 관찰에 있습니다.
예를 들어, KeyError는 일반적으로 특정 작업과 관련된 예외입니다. 모든 복구 코드는 오류가 발생한 위치에 국한되며 전통적인 except를 사용합니다.
try:
dct[key]
except KeyError:
# handle the exception
asyncio 사용자가 다음과 같은 작업을 하려고 할 가능성은 낮습니다.
try:
async with asyncio.TaskGroup() as g:
g.create_task(task1); g.create_task(task2)
except* KeyError:
# handling KeyError here is meaningless, there's
# no context to do anything with it but to log it.
프로그램이 예외 그룹으로 집계된 예외 모음을 처리할 때는 일반적으로 특정 실패 작업에서 복구하려고 하지 않습니다. 대신 오류의 유형을 사용하여 해당 오류가 프로그램의 제어 흐름에 어떤 영향을 미쳐야 하는지 또는 어떤 로깅이나 정리가 필요한지를 결정합니다. 그룹에 KeyboardInterrupt 또는 asyncio.CancelledError와 같은 예외의 단일 인스턴스가 포함되어 있든 여러 인스턴스가 포함되어 있든 이 결정은 동일할 가능성이 높습니다. 따라서 except*와 일치하는 모든 예외를 한 번에 처리하는 것이 더 편리합니다. 실제로 필요하다면 핸들러가 예외 그룹을 검사하고 그 안의 개별 예외를 처리할 수 있습니다.
except*에서 생 예외와 일치하지 않기
except* T가 Ts를 포함하는 예외 그룹과만 일치하고 생 Ts와는 일치하지 않도록 하는 선택지를 고려했습니다. 이것이 바람직한 기능이 아니라고 생각한 이유를 살펴보려면, 앞 단락에서 설명한 작업 오류와 제어 흐름 예외의 구분으로 돌아가야 합니다. try 블록의 본문에서 생 예외를 예상해야 하는지 예외 그룹을 예상해야 하는지 알 수 없다면, 우리는 작업 오류를 처리하는 상황에 있지 않습니다. 오히려 상당히 일반적인 함수를 호출하고 있을 가능성이 높으며, 제어 흐름 결정을 내리기 위해 오류를 처리하게 됩니다. 유형이 T인 생 예외를 포착하든 하나 이상의 Ts를 포함하는 예외 그룹을 포착하든, 동일한 작업을 수행할 가능성이 높습니다. 따라서 두 경우를 모두 명시적으로 처리해야 하는 부담은 의미론적 이점을 제공하지 않을 가능성이 높습니다.
구분이 실제로 필요하다면, try-except* 절 안에 추가적인 try-except 절을 중첩하여 except* 절이 이를 예외 그룹으로 감쌀 기회를 갖기 전에 생 예외를 가로채고 처리할 수 있습니다. 이 경우 두 경우를 모두 지정하는 데 따른 부담은 추가적인 부담이 아닙니다. 실제로 각 경우를 처리하기 위한 별도의 코드 블록을 작성해야 합니다.
try:
try:
...
except SomeError:
# handle the naked exception
except* SomeError:
# handle the exception group
동일한 try에서 except:와 except*: 혼합 허용
이 옵션은 유용한 의미론을 추가하지 않으면서 복잡성만 증가시키므로 거부되었습니다. 아마도 의도는 except T: 블록이 T형식의 일반 예외만 처리하고, except* T: 블록은 예외 그룹 내의 T를 처리하도록 하는 것이었을 것입니다. 이것이 실제로 유용할 가능성이 낮은 이유는 앞에서 이미 논의했으며, 필요한 경우 중첩된 try-except 블록을 대신 사용하여 동일한 결과를 얻을 수 있습니다.
try* 대신 except*
try 구문의 절이 모두 except*이거나 하나도 그렇지 않으므로, 모든 except*절 대신 try 구문의 문법을 변경하는 방안을 고려했습니다. 그러나 이는 덜 명확할 것이므로 거부했습니다. 일반 Ts만이 아니라 T의 예외 그룹을 처리한다는 사실은 T를 명시하는 동일한 위치에서 지정해야 합니다.
대체 구문 옵션
except* 구문의 대안은 python-dev에 대한 논의에서 평가되었으며, except group을 사용하자는 제안이 있었습니다. 면밀히 평가한 결과 이는 모호할 수 있으므로 거부되었습니다. 다음 구문은 현재 유효한 구문이며, 여기서 group은 호출 가능 객체로 해석되기 때문입니다. 유효한 식별자라면 무엇이든 마찬가지입니다.
try:
...
except group (T1, T2):
...
‘except *’ 없이 프로그래밍
다음과 같은 except* 구문의 간단한 예를 생각해 보십시오(Trio가 이 제안을 기본적으로 지원한다고 가정합니다).
try:
async with trio.open_nursery() as nursery:
# Make two concurrent calls to child()
nursery.start_soon(child)
nursery.start_soon(child)
except* ValueError:
pass
이 코드는 Python 3.9에서 다음과 같이 보입니다.
def handle_ValueError(exc):
if isinstance(exc, ValueError):
return None
else:
return exc # reraise exc
with MultiError.catch(handle_ValueError):
async with trio.open_nursery() as nursery:
# Make two concurrent calls to child()
nursery.start_soon(child)
nursery.start_soon(child)
이 예는 현재 Python에서 여러 오류를 처리하는 방식이 얼마나 직관적이지 않고 번거로운지를 명확하게 보여 줍니다. 예외 처리 로직은 별도의 클로저에 있어야 하며 상당히 저수준이므로, 작성자는 Python 예외 메커니즘과 Trio API를 모두 상당히 깊이 이해해야 합니다. try..except 블록 대신 with 블록을 사용해야 합니다. 처리하지 않는 예외는 명시적으로 다시 발생시켜야 합니다. 더 많은 예외 유형을 처리하거나 더 복잡한 예외 처리 로직을 구현하면 코드가 읽을 수 없게 될 정도로 더욱 복잡해질 뿐입니다.
함께 보기
감사의 말
Nathaniel J. Smith와 구조적 동시성 작업을 수행한 다른 Trio 개발자들에게 감사드립니다. 예외를 노드로 하는 예외 트리를 구성한다는 아이디어는 MultiError에서, split()API는 MultiError V2 설계 문서에서 차용했습니다. python-dev와 그 밖의 곳에서 이루어진 논의는 PEP의 첫 번째 초안을 설계와 설명 양方面에서 여러 방식으로 개선하는 데 도움이 되었습니다. 따라서 아이디어를 기여하고 좋은 질문을 해 주신 다음 모든 분께 감사드립니다: Ammar Askar, Matthew Barnett, Ran Benita, Emily Bowman, Brandt Bucher, Joao Bueno, Baptiste Carvello, Rob Cliffe, Alyssa Coghlan, Steven D’Aprano, Caleb Donovick, Steve Dower, Greg Ewing, Ethan Furman, Pablo Salgado, Jonathan Goble, Joe Gottman, Thomas Grainger, Larry Hastings, Zac Hatfield-Dodds, Chris Jerdonek, Jim Jewett, Sven Kunze, Łukasz Langa, Glenn Linderman, Paul Moore, Antoine Pitrou, Ivan Pozdeev, Patrick Reader, Terry Reedy, Sascha Schlemmer, Barry Scott, Mark Shannon, Damian Shaw, Cameron Simpson, Gregory Smith, Paul Sokolovsky, Calvin Spealman, Steve Stagg, Victor Stinner, Marco Sulla, Petr Viktorin 및 Barry Warsaw.
승인
참고 자료
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.