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에서 도입된 연쇄 기능은 원인(cause)이나 컨텍스트(context)로서 서로 관련된 예외들을 연결하지만, 스택이 풀리는 동안 서로 관련 없는 여러 예외를 함께 전파해야 하는 상황이 있습니다. 몇 가지 실제 사용 사례를 아래에 나열합니다.
- 동시성 오류. 비동기 동시성 라이브러리는 여러 태스크를 호출하고 그 결과를 한데 모아 반환하는 API를 제공합니다. 현재로서는 이러한 라이브러리가 여러 태스크에서 예외가 발생하는 상황을 처리할 좋은 방법이 없습니다. Python 표준 라이브러리의
asyncio.gather()[1] 함수는 두 가지 선택지를 제공합니다. 첫 번째 예외를 발생시키거나, 결과 리스트에 예외를 담아 반환하는 것입니다. Trio [2] 라이브러리에는 오류 모음을 보고하기 위해 발생시키는MultiError예외 타입이 있습니다. 이 PEP에 대한 작업은 처음에MultiErrors[9] 처리의 어려움에서 시작되었으며, 이 어려움은 개선된 버전인MultiError2[3]의 설계 문서에 자세히 설명되어 있습니다. 그 문서는 우리가 제안하는 언어 변경 없이 여러 오류를 보고하고 처리하는 효과적인 API를 만드는 것이 얼마나 어려운지 보여 줍니다 (Programming Without ‘except *’ 절도 참조하십시오.)Trio 너서리(nursery) [13]에서 영감을 받아 asyncio에 더 나은 태스크 생성 API를 구현하는 것이 이 PEP의 주된 동기였습니다. 그 작업은 현재 Python이 예외 그룹을 언어 수준에서 기본적으로 지원하지 않기 때문에 진행이 막혀 있습니다.
- 작업을 재시도할 때의 여러 실패. Python 표준 라이브러리의
socket.create_connection함수는 서로 다른 여러 주소로 연결을 시도할 수 있으며, 모든 시도가 실패하면 이를 사용자에게 보고해야 합니다. 이러한 오류들을, 특히 서로 다른 오류일 때 어떻게 모을지는 아직 해결되지 않은 문제입니다 (이슈 29980 [4] 참조.) - 여러 사용자 콜백의 실패. Python의
atexit.register()함수를 사용하면 시스템 종료 시 호출되는 함수를 등록할 수 있습니다. 그중 어느 것이든 예외를 발생시키면 마지막 예외만 다시 발생하지만, 모두를 함께 다시 발생시키는 편이 더 좋을 것입니다 (atexit문서 [5] 참조.) 마찬가지로, pytest 라이브러리는 사용자가 정리(teardown) 시점에 실행되는 파이널라이저를 등록할 수 있게 합니다. 이러한 파이널라이저 중 둘 이상이 예외를 발생시키면, 첫 번째 예외만 사용자에게 보고됩니다. pytest 개발자 Ran Benita가 이 이슈에서 설명했듯이, 이는ExceptionGroups로 개선할 수 있습니다 (pytest 이슈 8217 [6] 참조.) - 복잡한 계산에서의 여러 오류. Hypothesis 라이브러리는 자동 버그 축소(버그를 보여 주는 코드를 단순화하는 것)를 수행합니다. 이 과정에서 서로 다른 오류를 일으키는 변형을 찾을 수 있으며, (선택적으로) 그 오류를 모두 보고합니다(Hypothesis 문서 [7] 참고.) 여기서 제안하는
ExceptionGroup메커니즘은 위 링크에서 언급된 디버깅의 어려움 가운데 일부를 해결할 수 있습니다. 이러한 어려움은 컨텍스트/원인 정보가 손실되어 생깁니다(Hypothesis 코어 개발자 Zac Hatfield-Dodds가 전달한 내용). - 래퍼 코드에서의 오류. Python 표준 라이브러리의
tempfile.TemporaryDirectory컨텍스트 관리자에는__exit__에서 정리 작업 중 발생한 예외가, 사용자 코드가 컨텍스트 관리자 범위 안에서 발생시킨 예외를 사실상 가려 버리는 문제가 있었습니다. 사용자의 예외는 정리 오류의 컨텍스트로 연결되었지만, 사용자의 except 절에서 잡히지 않았습니다 (이슈 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을 반환합니다. ExceptionGroup 생성자는 중첩된 예외 중 하나라도 Exception 인스턴스가 아니면 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__가 생성자 인자를 검사해야 하는데, 그 시그니처가 서브클래스의 시그니처와 다르기 때문입니다. 또한 subgroup과 split이 대신 처리해 주므로, 우리의 derive 함수는 __context__, __cause__, __traceback__ 필드를 복사하지 않는다는 점에도 유의하십시오.
위에서 정의한 클래스를 사용하면 다음과 같은 결과를 얻습니다:
>>> 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 안에 있는 Ts의 개수에 관심을 두기보다는 eg.subgroup(T)가 None인지 아닌지를 알고 싶어 할 가능성이 더 높습니다.
그러나 개별 말단 예외를 검사해야 하는 상황도 있습니다. 예를 들어, 예외 그룹 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
일반적인 try..except에서와 마찬가지로 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(Exception 서브클래스가 아니라면 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와, ‘현재 예외’를 다시 발생시키는 네이키드 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* 블록이 발생시키는 ExceptionGroup에는 모든 ValueErrors와 TypeErrors가 원래 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
ExceptionGroup과 BaseExceptionGroup 타입은 except로 잡을 수 있지만, 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
except* 절에서는 continue, break, return이 허용되지 않으며, 사용하면 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*로 감싸야 하는지 분명히 밝혀야 합니다.
참조 구현
우리는 참조 구현 [11]의 도움을 받아 이러한 개념(그리고 이 PEP의 예제)을 개발했습니다.
참조 구현에는 except*를 지원하는 데 필요한 문법, 컴파일러, 인터프리터 변경 외에도 내장 ExceptionGroup과 트레이스백 포매팅 코드의 변경 사항이 포함되어 있습니다. BaseExceptionGroup은 곧 추가될 예정입니다.
두 개의 옵코드가 추가되었습니다. 하나는 ExceptionGroup.split()을 통해 예외 타입 매치 검사를 구현하고, 다른 하나는 try-except 구문의 끝에서 처리되지 않은 예외, 발생한 예외, 다시 발생한 예외를 (있다면) 모두 병합하는 데 사용됩니다. 발생한 예외와 다시 발생한 예외는 런타임 스택의 리스트에 수집됩니다. 이를 위해 각 except* 절의 본문은 발생하는 모든 예외를 포착하는 기존 방식의 try-except로 감싸집니다. 발생한 예외와 다시 발생한 예외는 모두 같은 리스트에 수집됩니다. 이들을 하나의 결과로 병합할 때가 되면, 메타데이터 필드(context, cause, traceback)를 원래 발생한 예외의 메타데이터 필드와 비교하여 발생한 예외와 다시 발생한 예외를 구별합니다. 앞서 언급했듯이 다시 발생한 예외는 원래 예외와 같은 메타데이터를 가지지만, 새로 발생한 예외는 그렇지 않습니다.
기각된 아이디어
예외 그룹을 이터러블로 만들기
예외 그룹을 이터러블로 만들어 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의 대안의 핵심이며, 그 제안에 관한 토론에서는 Python처럼 성숙한 언어에서 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 블록을 대신 사용하여 같은 결과를 얻을 수 있습니다.
except* 대신 try*
try 구문의 절은 모두 except*이거나 모두 그렇지 않으므로, 모든 except* 절 대신 try의 문법을 바꾸는 방안을 검토했습니다. 그렇게 하면 덜 명확해지기 때문에 이 방안은 기각했습니다. 단독 Ts만이 아니라 T의 예외 그룹을 처리한다는 사실은 T를 명시하는 바로 그 자리에서 지정되어야 합니다.
대안 문법 선택지
python-dev 논의에서 except* 문법의 대안들을 평가했으며, 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.