PEP 533 – 이터레이터를 위한 결정론적 정리
- Author:
- Nathaniel J. Smith
- BDFL-Delegate:
- Yury Selivanov <yury at edgedb.com>
- Status:
- Deferred
- Type:
- Standards Track
- Created:
- 18-Oct-2016
- Post-History:
- 18-Oct-2016
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
__(a)iterclose__슬롯을 새로 추가하여 이터레이터 프로토콜을 확장할 것을 제안합니다. 이 슬롯은 (async) for 루프가 어떤 방식으로 종료되든 루프 종료 시 자동으로 호출됩니다. 이를 통해 가비지 컬렉터에 의존하지 않고 이터레이터가 보유한 리소스를 편리하고 결정론적으로 정리할 수 있습니다. 이는 비동기 제너레이터에 특히 유용합니다.
시점에 관한 참고 사항
실질적으로 이 제안은 두 부분으로 나뉩니다. 하나는 가능한 한 빨리(ASAP) 구현해야 하는 비동기 이터레이터 처리이고, 다른 하나는 규모가 더 크지만 일정상 여유가 있는 일반 이터레이터 처리로, 빨라도 3.7 이전에는 시작할 수 없는 프로젝트입니다. 그러나 두 변경 사항은 밀접하게 관련되어 있고, 장기적으로 비동기 이터레이터와 일반 이터레이터가 서로 달라지는 상황은 피하고 싶을 가능성이 크므로, 이들을 함께 살펴보는 것이 유용해 보입니다.
배경 및 동기
Python의 이터러블은 정리가 필요한 리소스를 보유하는 경우가 많습니다. 예를 들어 file객체는 닫아야 하고, WSGI spec은 일반 이터레이터 프로토콜에 close 메서드를 추가하며 소비자가 적절한 시점에 이를 호출하도록 요구합니다(이를 잊는 것이 빈번한 버그의 원인이기는 하지만). 또한 PEP 342는 PEP 325를 기반으로 제너레이터 객체에 close 메서드를 추가하여 제너레이터가 스스로 정리할 수 있도록 확장했습니다.
일반적으로 스스로 정리해야 하는 객체는 객체가 가비지 컬렉션될 때 이러한 정리가 결국 수행되도록 __del__메서드도 정의합니다. 그러나 이처럼 가비지 컬렉터에 정리를 의존하면 여러 경우에 심각한 문제가 발생합니다.
- 참조 카운팅을 사용하지 않는 Python 구현(예: PyPy, Jython)에서는
__del__호출이 임의로 지연될 수 있지만, 많은 상황에서는 리소스를 신속하게 정리해야 합니다. 정리가 지연되면 파일 디스크립터 고갈로 인한 충돌이나 잘못된 시간을 수집하는 WSGI 타이밍 미들웨어와 같은 문제가 발생합니다. - 비동기 제너레이터(PEP 525)는 적절한 코루틴 실행기의 감독 아래에서만 정리를 수행할 수 있습니다.
__del__은 코루틴 실행기에 접근할 수 없으며, 실제로 코루틴 실행기가 제너레이터 객체보다 먼저 가비지 컬렉션될 수도 있습니다. 따라서 어떤 형태로든 언어 확장이 없으면 가비지 컬렉터에 의존하는 것은 사실상 불가능합니다. (PEP 525는 이러한 확장을 제공하지만 여러 제한 사항이 있으며, 이 제안은 이를 해결합니다. 자세한 논의는 아래의 “대안” 절을 참조하십시오.)
다행히 Python은 리소스를 더 구조적인 방식으로 정리하기 위한 표준 도구인 with 블록을 제공합니다. 예를 들어 다음 코드는 파일을 열지만 파일을 닫는 작업을 가비지 컬렉터에 의존합니다.:
def read_newline_separated_json(path):
for line in open(path):
yield json.loads(line)
for document in read_newline_separated_json(path):
...
그리고 최신 버전의 CPython은 ResourceWarning을 발생시켜 이를 지적하며, with 블록을 추가하여 수정하도록 유도합니다.:
def read_newline_separated_json(path):
with open(path) as file_handle: # <-- with block
for line in file_handle:
yield json.loads(line)
for document in read_newline_separated_json(path): # <-- outer for loop
...
그러나 여기에는 with 블록과 제너레이터의 상호작용으로 인해 발생하는 미묘한 문제가 있습니다. with 블록은 Python에서 정리를 관리하는 주요 도구이며 강력한 도구이기도 합니다. 리소스의 수명을 스택 프레임의 수명에 고정하기 때문입니다. 그러나 이는 누군가가 스택 프레임을 정리해 준다는 것을 전제로 하며, 제너레이터의 경우 이를 위해서는 누군가가 제너레이터를 close해야 합니다.
이 경우 with 블록을 추가하는 것만으로도 ResourceWarning을 억제하기에는 충분하지만, 이는 오해의 소지가 있습니다. 여기서 파일 객체의 정리는 여전히 가비지 컬렉터에 의존하기 때문입니다. with 블록은 read_newline_separated_json제너레이터가 닫힐 때에만 해제됩니다. 바깥쪽 for 루프가 끝까지 실행되면 정리가 즉시 수행되지만, 이 루프가 break 또는 예외로 일찍 종료되면 제너레이터 객체가 가비지 컬렉션될 때까지 with 블록이 실행되지 않습니다.
올바른 해결책은 이 API의 모든 사용자가 모든 for루프를 각자의 with 블록으로 감싸도록 요구하는 것입니다.:
with closing(read_newline_separated_json(path)) as genobj:
for document in genobj:
...
복잡한 파이프라인을 여러 개의 중첩된 제너레이터로 분해하는 관용구를 고려하면 상황은 더욱 나빠집니다.:
def read_users(path):
with closing(read_newline_separated_json(path)) as gen:
for document in gen:
yield User.from_json(document)
def users_in_group(path, group):
with closing(read_users(path)) as gen:
for user in gen:
if user.group == group:
yield user
일반적으로 N개의 중첩된 제너레이터가 있다면 파일 하나를 정리하기 위해 N+1개의 with 블록이 필요합니다. 또한 훌륭한 방어적 프로그래밍이라면 제너레이터를 사용할 때마다 그 호출 스택(잠재적으로 전이되는 호출 스택) 어딘가에 현재 또는 미래에 적어도 하나의 with 블록이 있을 가능성을 가정하고, 항상 제너레이터를 with로 감싸야 한다고 제안할 것입니다. 그러나 실제로는 거의 아무도 이렇게 하지 않습니다. 프로그래머는 번거롭고 반복적인 코드를 작성하기보다는 버그가 있는 코드를 작성하는 편을 택하기 때문입니다. 이와 같은 단순한 경우에는 훌륭한 Python 개발자들이 알고 있는 몇 가지 우회 방법이 있습니다(예를 들어 이 단순한 경우에는 경로 대신 파일 핸들을 전달하고 리소스 관리를 최상위 수준으로 옮기는 것이 관용적입니다). 그러나 일반적으로 제너레이터 내부에서 with/finally를 사용하는 것을 피할 수 없으므로, 어떤 방식으로든 이 문제를 처리해야 합니다. 아름다움과 정확성이 충돌하면 아름다움이 이기는 경향이 있으므로, 올바른 코드를 아름답게 만드는 것이 중요합니다.
그럼에도 불구하고, 이것을 수정할 가치가 있습니까? 비동기 제너레이터가 등장하기 전까지는 그렇다고 주장했겠지만, 모두가 어떻게든 괜찮게 해 나가는 듯하므로 우선순위가 낮다고도 주장했을 것입니다. – 하지만 비동기 제너레이터 때문에 이 문제는 훨씬 더 시급해졌습니다. 사람들이 실제로 사용할 결정적 정리 메커니즘이 없다면 비동기 제너레이터는 정리를 전혀 수행할 수 없으며, 비동기 제너레이터는 파일 디스크립터와 같은 리소스를 보유할 가능성이 특히 높습니다. (결국 I/O를 수행하지 않는다면 비동기 제너레이터가 아니라 제너레이터일 것입니다.) 따라서 우리는 무언가를 해야 하며, 기저 문제를 포괄적으로 수정하는 편이 좋을 것입니다. 그리고 비동기 제너레이터가 처음 도입되는 지금 이 문제를 수정하는 것이, 나중에 수정하는 것보다 훨씬 쉽습니다.
제안 자체의 개념은 간단합니다. 이터레이터 프로토콜에 __(a)iterclose__ 메서드를 추가하고, 루프가 종료될 때 (비동기) for루프가 이를 호출하도록 하는 것입니다. 종료가 break나 예외 전파 해제를 통해 발생하는 경우에도 마찬가지입니다. 사실상 현재의 번거로운 관용구(with블록 + for루프)를 가져와 더 정교한 for문으로 합치는 것입니다. 이는 비직교적으로 보일 수 있지만, 제너레이터가 존재한다는 것은 with블록이 안정적으로 작동하려면 실제로 이터레이터 정리에 의존한다는 점과, 이터레이터 정리가 그 자체로도 바람직한 기능인 경우가 많다는 경험을 고려하면 타당합니다.
대안
PEP 525 비동기 제너레이터 훅
PEP 525 proposes a set of global thread-local hooks는 새로운 sys.{get/set}_asyncgen_hooks() 함수로 관리되는 전역 스레드 로컬 훅 집합을 제안하며, 이 훅을 통해 이벤트 루프가 가비지 컬렉터와 통합되어 비동기 제너레이터의 정리를 수행할 수 있습니다. 원칙적으로 이 제안과 PEP 525은 with블록과 __del__이 상호 보완적인 것과 같은 방식으로 상호 보완적입니다. 이 제안은 대부분의 경우 결정적 정리를 보장하는 일을 담당하고, PEP 525의 GC 훅은 누락된 정리를 수행합니다. 그러나 __aiterclose__는 GC 훅만 사용하는 것보다 여러 가지 장점을 제공합니다.
- GC 훅의 의미론은 추상 비동기 이터레이터 프로토콜의 일부가 아니라, 구체적으로 비동기 제너레이터 구체 타입에만 제한됩니다. 다음과 같이 클래스를 사용하여 구현된 비동기 이터레이터가 있다면:
class MyAsyncIterator: async def __anext__(): ...
의미론을 변경하지 않고 이를 비동기 제너레이터로 리팩터링할 수 없으며, 그 반대도 마찬가지입니다. 이는 매우 비파이썬적인 방식으로 보입니다. (또한 클래스 기반 비동기 이터레이터가 정확히 무엇을 해야 하는지도 여전히 의문으로 남습니다. 비동기 제너레이터와 정확히 동일한 정리 문제에 직면하기 때문입니다.) 반면
__aiterclose__는 프로토콜 수준에서 정의되므로 덕 타이핑 친화적이며, 제너레이터뿐 아니라 모든 이터레이터에서 작동합니다. - PyPy와 같은 비 CPython 구현에서 작동해야 하는 코드는 일반적으로 정리를 GC에 의존할 수 없습니다.
__aiterclose__가 없다면 CPython에서 개발하고 테스트한 개발자가 PyPy에서 사용할 때 리소스가 누수되는 라이브러리를 만들 가능성이 거의 확실합니다. 대체 구현을 대상으로 하려는 개발자는 모든for루프를with블록으로 감싸는 방어적 접근 방식을 취하거나, 어떤 제너레이터에 정리 코드가 포함될 가능성이 있는지 파악하기 위해 코드를 세심하게 감사한 다음 해당 제너레이터 주위에만with블록을 추가해야 합니다.__aiterclose__를 사용하면 이식 가능한 코드를 쉽고 자연스럽게 작성할 수 있습니다. - 견고한 소프트웨어를 구축하는 데 중요한 부분은 예외가 손실되지 않고 항상 올바르게 전파되도록 하는 것입니다. 기존 콜백 기반 시스템과 비교했을 때 async/await의 가장 흥미로운 점 중 하나는 수동으로 연결할 필요 없이 런타임이 이제 오류 전파라는 어려운 작업을 수행할 수 있어 견고한 코드를 작성하기가 훨씬 쉬워진다는 것입니다. 그러나 이 아름다운 새로운 그림에는 한 가지 큰 결함이 있습니다. 제너레이터 정리를 GC에 의존하면 정리 중에 발생한 예외가 손실됩니다. 따라서 다시 말해
__aiterclose__를 사용하면 이러한 견고성을 중요하게 여기는 개발자는 모든for루프를with블록으로 감싸는 방어적 접근 방식을 취하거나, 어떤 제너레이터에 정리 코드가 포함될 가능성이 있는지 파악하기 위해 코드를 세심하게 감사해야 합니다.__aiterclose__는 호출자의 컨텍스트에서 정리를 수행하여 이 문제를 해결하므로, 더욱 견고한 코드를 작성하는 것이 가장 저항이 적은 방법이 됩니다. - WSGI의 경험은 CPython에서도 GC에 의존할 수 없고 즉각적인 정리가 필요한 중요한 이터레이터 기반 API가 존재한다는 점을 보여 줍니다. 예를 들어 async/await와 비동기 이터레이터를 기반으로 하는 가상의 WSGI 유사 API를 생각해 보십시오. 여기서 응답 핸들러는 요청 헤더와 요청 본문을 순회하는 비동기 이터레이터를 받아 응답 헤더와 응답 본문을 산출하는 비동기 제너레이터입니다. (이는 실제로 제가 처음 비동기 제너레이터에 관심을 갖게 된 사용 사례이므로, 가상이 아닙니다.) WSGI를 따라 자식 이터레이터를 제대로 닫도록 요구한다면,
__aiterclose__가 없을 때 시스템에서 가능한 한 가장 단순한 미들웨어는 대략 다음과 같은 형태가 됩니다.:async def noop_middleware(handler, request_header, request_body): async with aclosing(handler(request_body, request_body)) as aiter: async for response_item in aiter: yield response_item
일반 코드에서는 제너레이터의 내부 구현을 이해하고 있다고 얼마나 확신하는지에 따라
for루프 주위의with블록을 생략해도 괜찮다고 주장할 수 있습니다. 그러나 여기서는 임의의 응답 핸들러를 처리해야 하므로__aiterclose__가 없다면 이with구문은 모든 미들웨어의 필수 구성 요소입니다.__aiterclose__를 사용하면 모든 미들웨어에서 필수적인 상용구 코드와 추가 들여쓰기 수준을 제거할 수 있습니다.:async def noop_middleware(handler, request_header, request_body): async for response_item in handler(request_header, request_body): yield response_item
따라서 __aiterclose__방식은 GC 훅보다 상당한 장점을 제공합니다.
이로써 GC 후크와 __aiterclose__를 조합할지, 아니면 __aiterclose__만 사용할지에 관한 문제가 열려 있습니다. 제너레이터의 압도적 다수는 for 루프 또는 이에 준하는 방식을 사용해 이터레이트되므로, __aiterclose__는 GC가 관여할 기회를 얻기 전에 대부분의 상황을 처리합니다. GC 후크가 추가적인 가치를 제공하는 경우는 수동 이터레이션을 수행하는 코드에서입니다. 예를 들면 다음과 같습니다.:
agen = fetch_newline_separated_json_from_url(...)
while True:
document = await type(agen).__anext__(agen)
if document["id"] == needle:
break
# doesn't do 'await agen.aclose()'
GC 후크와 __aiterclose__를 함께 사용하는 접근 방식을 택하면, 이 제너레이터는 결국 GC가 제너레이터의 __del__메서드를 호출함으로써 정리되며, 그러면 해당 메서드는 후크를 사용해 이벤트 루프에 다시 호출하여 정리 코드를 실행합니다.
GC 후크를 사용하지 않는 접근 방식을 택하면, 이 제너레이터는 결국 가비지 컬렉션되며 다음과 같은 효과가 발생합니다.
- 해당 객체의
__del__메서드는 제너레이터가 닫히지 않았다는 경고를 발생시킵니다(기존의 “코루틴이 await되지 않음” 경고와 유사합니다). - 관련된 기반 리소스는 여전히 정리됩니다. 제너레이터 프레임도 여전히 가비지 컬렉션되어, 보유하고 있던 파일 핸들이나 소켓에 대한 참조를 제거하고, 그 결과 해당 객체의
__del__메서드가 실제 운영 체제 리소스를 해제하기 때문입니다. - 그러나 제너레이터 자체에 들어 있는 정리 코드(예: 로깅이나 버퍼 플러시)는 실행될 기회를 얻지 못합니다.
여기서의 해결책은 경고가 나타내듯이 __aiterclose__를 호출하도록 코드를 수정하는 것입니다. 예를 들어 with 블록을 사용하면 됩니다.:
async with aclosing(fetch_newline_separated_json_from_url(...)) as agen:
while True:
document = await type(agen).__anext__(agen)
if document["id"] == needle:
break
기본적으로 이 접근 방식의 규칙은 이터레이터 프로토콜을 수동으로 구현하려면 그 모든 부분을 구현할 책임이 있으며, 이제는 __(a)iterclose__도 여기에 포함된다는 것입니다.
GC 훅은 (a) 새로운 전역 인터프리터 상태, (b) 다소 복잡한 제어 흐름(예를 들어, 비동기 제너레이터의 GC에는 항상 부활이 포함되므로 PEP 442의 세부 사항이 중요합니다), (c) 사용자가 적절한 시점에 호출해야 한다는 점을 기억해야 하는 asyncio의 새로운 공개 API(await loop.shutdown_asyncgens())라는 형태로 상당한 복잡성을 추가합니다. (특히 이 마지막 점은 GC 훅이 정리를 보장하는 안전한 백업을 제공한다는 주장을 어느 정도 약화시킵니다. shutdown_asyncgens()가 올바르게 호출되지 않으면 제너레이터가 정리 코드를 호출하지 않은 채 조용히 폐기될 수 있다고 제가 생각하기 때문입니다. 최악의 경우에도 적어도 경고가 출력되는 __aiterclose__만 사용하는 접근 방식과 비교해 보십시오.) 이는 수정할 수 있을지도 모릅니다. 이 모든 점을 고려하면, GC 후크는 수동으로 __anext__를 호출하려 하지만 수동으로 __aiterclose__를 호출하려 하지는 않는 사람들에게만 도움이 된다는 점에서 아마도 그만한 가치가 없습니다. 그러나 Yury는 이 점에서 저와 의견이 다릅니다 :-). 두 선택지 모두 실행 가능합니다.
항상 리소스를 주입하고 모든 정리를 최상위 수준에서 수행하십시오.
python-dev와 python-ideas의 여러 논평자는 이러한 문제를 피하는 패턴으로 항상 위에서 리소스를 전달하자고 제안했습니다. 예를 들어 read_newline_separated_json은 경로가 아니라 파일 객체를 받고, 정리는 최상위 수준에서 처리하도록 하는 방식입니다.:
def read_newline_separated_json(file_handle):
for line in file_handle:
yield json.loads(line)
def read_users(file_handle):
for document in read_newline_separated_json(file_handle):
yield User.from_json(document)
with open(path) as file_handle:
for user in read_users(file_handle):
...
이는 단순한 경우에는 잘 작동하며, 여기서는 “N+1 with 블록 문제”를 피할 수 있게 해 줍니다. 그러나 안타깝게도 상황이 더 복잡해지면 빠르게 한계에 부딪힙니다. 파일에서 읽는 대신 제너레이터가 스트리밍 HTTP GET 요청에서 읽고 있으며, OAUTH를 통해 리디렉션과 인증을 처리한다고 생각해 보십시오. 그렇다면 소켓은 최상위 수준이 아니라 HTTP 클라이언트 라이브러리 내부 깊숙한 곳에서 관리되기를 정말 원할 것입니다. 게다가 제너레이터 내부에 포함된 finally 블록이 그 자체로 중요한 다른 경우도 있습니다. 예를 들면 데이터베이스 트랜잭션 관리, 정리 중 로깅 정보 출력(WSGI close의 주요 동기 부여 사용 사례 중 하나) 등이 있습니다. 따라서 이는 일반적인 해결책이 아니라 단순한 경우를 위한 우회책에 불과합니다.
__(a)iterclose__의 더 복잡한 변형
__(a)iterclose__의 의미는 어느 정도 with 블록에서 영감을 얻었지만, 컨텍스트 관리자는 더 강력합니다. __(a)exit__는 정상적인 종료와 예외 전파에 따른 스택 풀기를 구분할 수 있으며, 예외가 발생한 경우 예외 세부 정보를 검사하고 전파를 선택적으로 억제할 수 있습니다. 여기서 제안한 __(a)iterclose__에는 이러한 기능이 없지만, 그러한 기능을 갖춘 대안 설계를 생각해 볼 수 있습니다.
그러나 이는 정당화되지 않는 복잡성으로 보입니다. 경험상 이터러블이 close 메서드를 갖는 경우와 self.close()를 호출하는 __exit__메서드를 갖는 경우는 흔하지만, __exit__의 모든 기능을 활용하는 일반적인 사례는 알지 못합니다. 또한 이것이 유용할 만한 예도 생각나지 않습니다. 그리고 예외를 삼켜 이터레이터가 흐름 제어에 영향을 줄 수 있도록 허용하는 것은 불필요하게 혼란스러워 보입니다. 정말로 그러한 기능이 필요한 상황이라면 어쨌든 실제 with 블록을 사용해야 할 것입니다.
사양
이 절에서는 최종적으로 도달하려는 상태를 설명하지만, 하위 호환성 문제로 인해 여기로 곧바로 이동할 수는 없습니다. 이후 절에서 전환 계획을 설명합니다.
기본 원칙
일반적으로 __(a)iterclose__구현은 다음과 같아야 합니다.
- 멱등적이어야 합니다,
__(a)iterclose__가 호출된 후에는 이터레이터를 다시 사용하지 않는다는 가정하에 적절한 정리 작업을 수행하십시오. 특히,__(a)iterclose__가 호출된 후__(a)next__를 호출하면 정의되지 않은 동작이 발생합니다.
일반적으로, 이터러블을 소진할 의도로 반복을 시작하는 모든 코드는 이터레이터가 실제로 소진되었는지 여부와 관계없이 __(a)iterclose__가 결국 호출되도록 해야 합니다.
반복에 대한 변경 사항
핵심 제안은 for 루프의 동작을 변경하는 것입니다. 다음 Python 코드가 주어지면:
for VAR in ITERABLE:
LOOP-BODY
else:
ELSE-BODY
이를 다음과 동등한 형태로 변환합니다.:
_iter = iter(ITERABLE)
_iterclose = getattr(type(_iter), "__iterclose__", lambda: None)
try:
traditional-for VAR in _iter:
LOOP-BODY
else:
ELSE-BODY
finally:
_iterclose(_iter)
여기서 “전통적인 for 문”은 기존의 3.5 이하 for 루프 의미 체계를 간략히 나타내는 표현입니다.
최상위 for 문 외에도 Python에는 이터레이터가 소비되는 여러 다른 위치가 있습니다. 일관성을 위해 이러한 위치에서도 위와 동등한 의미 체계를 사용하여 __iterclose__를 호출해야 합니다. 여기에는 다음이 포함됩니다.
- 컴프리헨션 내부의
for루프 *언패킹list(it),tuple(it),itertools.product(it1, it2, ...)와 같이 이터러블을 받아 완전히 소비하는 함수 및 기타 함수
또한 호출된 제너레이터를 성공적으로 소진하는 yield from은 마지막 단계로 해당 제너레이터의 __iterclose__메서드를 호출해야 합니다. (이유: yield from은 이미 호출하는 제너레이터와 호출된 제너레이터의 수명을 연결하므로, yield from을 수행하는 도중 호출하는 제너레이터가 닫히면 호출된 제너레이터도 자동으로 닫히기 때문입니다.)
비동기 반복에 대한 변경 사항
새로운 슬롯의 이름이 __aiterclose__이고, await되는 비동기 메서드라는 점을 제외하면 비동기 반복 구문에도 유사한 변경을 적용합니다.
기본 이터레이터 유형에 대한 수정 사항
제너레이터 컴프리헨션으로 생성된 것을 포함한 제너레이터 객체:
__iterclose__는self.close()를 호출합니다.__del__은 현재와 동일하게self.close()를 호출하고, 제너레이터가 소진되지 않은 경우ResourceWarning도 발생시킵니다. 이 경고는 기본적으로 숨겨져 있지만, 자신도 모르게 CPython 전용 GC 의미 체계에 의존하고 있지 않은지 확인하려는 사용자는 활성화할 수 있습니다.
비동기 제너레이터 컴프리헨션으로 생성된 것을 포함한 비동기 제너레이터 객체:
__aiterclose__는self.aclose()를 호출합니다.aclose가 호출되지 않은 경우__del__은RuntimeWarning을 발생시킵니다. 이는 “코루틴이 한 번도 await되지 않음” 경고와 유사하게 잠재적인 버그를 나타낼 가능성이 높기 때문입니다.
질문: 파일 객체가 파일을 닫도록 __iterclose__를 구현해야 합니까? 한편으로는 이 변경이 더 큰 영향을 미치게 되지만, 다른 한편으로는 사람들이 for line in open(…): …을 작성하는 것을 정말 좋아하며, 이터레이터가 자체적으로 정리 작업을 수행하는 데 익숙해지면 파일만 그렇지 않은 경우 매우 이상하게 느껴질 수 있습니다.
새로운 편의 함수
operator모듈에 다음과 동등한 의미 체계를 갖는 두 개의 새로운 함수가 추가됩니다.:
def iterclose(it):
if not isinstance(it, collections.abc.Iterator):
raise TypeError("not an iterator")
if hasattr(type(it), "__iterclose__"):
type(it).__iterclose__(it)
async def aiterclose(ait):
if not isinstance(it, collections.abc.AsyncIterator):
raise TypeError("not an iterator")
if hasattr(type(ait), "__aiterclose__"):
await type(ait).__aiterclose__(ait)
itertools모듈에 새로운 __iterclose__동작을 선택적으로 비활성화하는 데 사용할 수 있는 새로운 이터레이터 래퍼가 추가됩니다.:
# QUESTION: I feel like there might be a better name for this one?
class preserve(iterable):
def __init__(self, iterable):
self._it = iter(iterable)
def __iter__(self):
return self
def __next__(self):
return next(self._it)
def __iterclose__(self):
# Swallow __iterclose__ without passing it on
pass
파일 객체가 __iterclose__를 구현한다고 가정한 사용 예:
with open(...) as handle:
# Iterate through the same file twice:
for line in itertools.preserve(handle):
...
handle.seek(0)
for line in itertools.preserve(handle):
...
@contextlib.contextmanager
def iterclosing(iterable):
it = iter(iterable)
try:
yield preserve(it)
finally:
iterclose(it)
이터레이터 래퍼를 위한 __iterclose__ 구현
Python은 다른 이터레이터를 래핑하는 역할을 하는 여러 이터레이터 형식을 제공합니다. 여기에는 map, zip, itertools.accumulate, csv.reader 등이 있습니다. 이러한 이터레이터는 기반 이터레이터에서 차례로 __iterclose__를 호출하는 __iterclose__ 메서드를 정의해야 합니다. 예를 들어 map은 다음과 같이 구현할 수 있습니다.:
# Helper function
map_chaining_exceptions(fn, items, last_exc=None):
for item in items:
try:
fn(item)
except BaseException as new_exc:
if new_exc.__context__ is None:
new_exc.__context__ = last_exc
last_exc = new_exc
if last_exc is not None:
raise last_exc
class map:
def __init__(self, fn, *iterables):
self._fn = fn
self._iters = [iter(iterable) for iterable in iterables]
def __iter__(self):
return self
def __next__(self):
return self._fn(*[next(it) for it in self._iters])
def __iterclose__(self):
map_chaining_exceptions(operator.iterclose, self._iters)
def chain(*iterables):
try:
while iterables:
for element in iterables.pop(0):
yield element
except BaseException as e:
def iterclose_iterable(iterable):
operations.iterclose(iter(iterable))
map_chaining_exceptions(iterclose_iterable, iterables, last_exc=e)
일부 경우에는 약간의 세심한 처리가 필요합니다. 예를 들어, itertools.tee는 모든 복제 이터레이터에 대해 호출될 때까지 기반 이터레이터에서 __iterclose__를 호출해서는 안 됩니다.
예제 / 근거
이 모든 작업의 성과는 이제 다음과 같은 간단한 코드를 작성할 수 있다는 점입니다.:
def read_newline_separated_json(path):
for line in open(path):
yield json.loads(line)
복잡한 경우에도 최종 사용자가 특별히 어떤 작업을 하지 않아도 파일이 결정론적으로 정리된다는 확신을 가질 수 있습니다. 예를 들어, 다음과 같은 우스꽝스러운 파이프라인을 생각해 보십시오.:
list(map(lambda key: key.upper(),
doc["key"] for doc in read_newline_separated_json(path)))
파일에 doc["key"]가 정수로 판명되는 문서가 들어 있다면 다음과 같은 사건 순서가 발생합니다.
key.upper()가AttributeError를 발생시키고, 이 예외가map에서 전파되어list내부의 암시적finally블록을 트리거합니다.list의finally블록이 map 객체에서__iterclose__()를 호출합니다.map.__iterclose__()가 제너레이터 컴프리헨션 객체에서__iterclose__()를 호출합니다.- 이로 인해 현재 컴프리헨션의
for루프 본문에서 실행이 일시 중지된 제너레이터 컴프리헨션 본문에GeneratorExit예외가 주입됩니다. - 예외가
for루프 밖으로 전파되어for루프의 암시적finally블록을 트리거하고, 이 블록은read_newline_separated_json호출을 나타내는 제너레이터 객체에서__iterclose__를 호출합니다. - 이로 인해 현재
yield에서 실행이 일시 중지된read_newline_separated_json의 본문에 내부GeneratorExit예외가 주입됩니다. - 내부
GeneratorExit가for루프 밖으로 전파되어for루프의 암시적finally블록을 트리거하고, 이 블록은 파일 객체에서__iterclose__()를 호출합니다. - 파일 객체가 닫힙니다.
- 내부
GeneratorExit가 계속 전파되어 제너레이터 함수의 경계에 도달하고,read_newline_separated_json의__iterclose__()메서드가 성공적으로 반환되도록 합니다. - 제어가 제너레이터 컴프리헨션 본문으로 돌아가고 외부
GeneratorExit가 계속 전파되어 컴프리헨션의__iterclose__()가 성공적으로 반환될 수 있게 합니다. - 나머지
__iterclose__()호출도 문제없이 해제되어list본문으로 돌아갑니다. - 원래의
AttributeError가 다시 전파됩니다.
(위의 세부 사항은 file.__iterclose__를 구현한다고 가정한 것입니다. 구현하지 않는다면 read_newline_separated_json에 with 블록을 추가하면 되며, 본질적으로 동일한 로직이 진행됩니다.)
물론 사용자의 관점에서는 이를 다음과 같이 간단히 줄여 쓸 수 있습니다.
1. int.upper()가 AttributeError를 발생시킵니다.
1. 파일 객체가 닫힙니다.
2. AttributeError가 list에서 전파됩니다.
따라서 사용자가 이에 대해 생각할 필요 없이 이 기능이 “그냥 작동”하도록 만든다는 목표를 달성했습니다.
전환 계획
기존 for 루프의 대부분은 계속 동일한 결과를 생성하지만, 제안된 변경 사항은 일부 경우에 하위 호환성이 없는 동작을 만들어 냅니다. 예제:
def read_csv_with_header(lines_iterable):
lines_iterator = iter(lines_iterable)
for line in lines_iterator:
column_names = line.strip().split("\t")
break
for line in lines_iterator:
values = line.strip().split("\t")
record = dict(zip(column_names, values))
yield record
이 코드는 이전에는 올바른 코드였지만, 이 제안이 구현된 후에는 첫 번째 for 루프에 itertools.preserve 호출을 추가해야 합니다.
질문: 현재 제너레이터를 닫은 다음 해당 제너레이터를 이터레이트하려고 하면 Stop(Async)Iteration만 발생하므로, 동일한 제너레이터 객체를 여러 for 루프에 전달하면서 itertools.preserve를 사용하는 것을 잊은 코드는 명확한 오류를 확인하지 못합니다. 두 번째 for 루프는 즉시 종료될 뿐입니다. 닫힌 제너레이터를 이터레이션하려고 하면 RuntimeError 가 발생하도록 하는 편이 더 낫지 않을까요? 파일에는 이러한 문제가 없다는 점에 유의하십시오. 닫힌 파일 객체를 이터레이션하려고 하면 이미 ValueError 가 발생합니다.]
구체적으로 호환성 문제는 다음 요소가 모두 결합될 때 발생합니다.
__(a)iterclose__의 자동 호출이 활성화되어 있습니다.- 이터러블이 이전에는
__(a)iterclose__를 정의하지 않았습니다. - 이터러블이 이제
__(a)iterclose__를 정의합니다. for루프가 종료된 후 이터러블을 다시 사용합니다.
따라서 문제는 이 전환을 어떻게 관리할 것인가이며, 이것이 우리가 조정할 수 있는 수단입니다.
먼저, __aiterclose__ 를 추가하려는 유일한 비동기 이터러블은 비동기 제너레이터라는 점을 살펴보십시오. 현재 비동기 제너레이터를 사용하는 기존 코드가 없으므로(물론 이는 곧 바뀌기 시작할 것입니다), 비동기 변경 사항은 어떠한 하위 호환성 문제도 일으키지 않습니다. (비동기 이터레이터를 사용하는 기존 코드는 있지만, 기존 비동기 이터레이터에는 __aiterclose__ 가 없으므로 새로운 비동기 for 루프에서 기존 비동기 이터레이터를 사용하는 것은 무해합니다.) 또한 PEP 525 는 잠정적인 기준으로 승인되었으며, 비동기 제너레이터는 이 PEP에서 제안하는 변경 사항으로부터 가장 큰 혜택을 받는 대상입니다. 따라서 async for 루프와 비동기 제너레이터에 __aiterclose__ 를 최대한 빨리, 이상적으로는 3.6.0 또는 3.6.1에 활성화하는 방안을 진지하게 고려해야 한다고 생각합니다.
비동기가 아닌 영역에서는 상황이 더 어렵지만, 다음과 같은 전환 경로를 고려할 수 있습니다.
3.7에서는 다음과 같습니다.
우리의 목표는 기존의 안전하지 않은 코드가 경고를 발생시키기 시작하도록 하면서, 향후 기능을 사용하려는 사용자는 즉시 이를 선택할 수 있게 하는 것입니다.
- 위에서 설명한 모든
__iterclose__메서드를 즉시 추가합니다. from __future__ import iterclose가 적용되면for루프와*언패킹이 위에서 지정한 대로__iterclose__를 호출합니다.- future가 활성화되지 않은 경우
for루프와*언패킹은__iterclose__를 호출하지 않습니다. 대신__iterclose_warning__와 같은 다른 메서드를 호출합니다. - 마찬가지로
list같은 함수는 스택 인트로스펙션(!!)을 사용하여 직접 호출자에게__future__.iterclose가 활성화되어 있는지 확인하고, 그 결과에 따라__iterclose__또는__iterclose_warning__를 호출할지 결정합니다. - 모든 래퍼 이터레이터에
__iterclose_warning__메서드도 추가하여, 기반 이터레이터의__iterclose_warning__메서드 또는 이터레이터들의 해당 메서드로 전달합니다. - 제너레이터(그리고 그렇게 하기로 결정한다면 파일)의 경우
__iterclose_warning__는 내부 플래그를 설정하도록 정의하고, 객체의 다른 메서드들은 이 플래그를 확인하도록 수정합니다. 플래그가 설정된 것을 발견하면PendingDeprecationWarning을 발생시켜, 앞으로는 이 순서가 닫은 후 사용(use-after-close) 상황으로 이어지며 사용자는preserve()를 사용해야 한다고 알립니다.
3.8에서는:
PendingDeprecationWarning에서DeprecationWarning으로 전환합니다
3.9에서는:
__future__를 무조건 활성화하고__iterclose_warning__관련 항목을 모두 제거합니다.
이 방식은 이런 종류의 전환에 필요한 일반적인 요구사항 – 초기에는 선택적 참여로 시작하고, 영향을 받을 경우에 정확히 맞춘 경고를 제공하며, 긴 사용 중단 주기를 두는 것 – 을 충족한다고 생각합니다.
아마 이 중 가장 논란이 되거나 위험한 부분은 스택 인트로스펙션을 사용해 이터러블을 소비하는 함수들이 __future__ 설정에 반응하도록 만드는 것이겠지만, 아직까지는 실제로 문제가 생길 만한 상황을 떠올리지 못했습니다…
감사의 말
이 아이디어의 초기 버전에 대해 유익한 논의를 나눠준 Yury Selivanov, Armin Rigo, Carl Friedrich Bolz에게 감사드립니다.
Copyright
This document has been placed in the public domain.