Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

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

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이터레이터 프로토콜에 새로운 __(a)iterclose__ 슬롯을 추가하여 확장할 것을 제안합니다. 이 슬롯은 (async) for 루프가 어떤 방식으로 종료되든 루프를 빠져나갈 때 자동으로 호출됩니다. 이를 통해 가비지 컬렉터에 의존하지 않고도 이터레이터가 보유한 자원을 편리하고 결정론적으로 정리할 수 있습니다. 이는 특히 비동기 제너레이터에 유용합니다.

시기에 관한 참고 사항

실질적으로 이 제안은 두 부분으로 나뉩니다. 하나는 이상적으로는 가능한 한 빨리 구현되어야 하는 비동기 이터레이터의 처리이고, 다른 하나는 규모는 더 크지만 일정상 여유가 있으며 빨라도 3.7 전에는 시작할 수 없는 일반 이터레이터의 처리입니다. 하지만 두 변경 사항은 밀접하게 관련되어 있고, 장기적으로 비동기 이터레이터와 일반 이터레이터가 서로 달라지는 상황은 아마 바라지 않을 것이므로, 둘을 함께 살펴보는 것이 유용해 보입니다.

배경 및 동기

Python 이터러블은 정리가 필요한 리소스를 보유하는 경우가 많습니다. 예를 들어, file 객체는 닫아야 합니다. WSGI 명세는 일반 이터레이터 프로토콜 위에 close 메서드를 추가하고, 소비자가 적절한 시점에 이를 호출하도록 요구합니다(다만 이를 잊어버리는 것은 자주 발생하는 버그의 원인입니다). 또한 PEP 325에 기반한 PEP 342는 제너레이터 객체에 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개의 중첩된 제너레이터가 있다면 파일 1개를 정리하기 위해 N+1개의 with 블록이 필요합니다. 그리고 좋은 방어적 프로그래밍 관점에서는, 제너레이터를 사용할 때마다 현재든 미래든 그 (잠재적으로 전이적인) 호출 스택 어딘가에 적어도 하나의 with 블록이 있을 수 있다는 가능성을 가정하고, 따라서 항상 제너레이터를 with로 감싸야 한다고 할 것입니다. 하지만 실제로는 거의 아무도 이렇게 하지 않습니다. 프로그래머들은 지루하고 반복적인 코드보다는 차라리 버그가 있는 코드를 작성하려 하기 때문입니다. 이처럼 단순한 경우에는 숙련된 Python 개발자들이 알고 있는 몇 가지 우회 방법이 있습니다(예를 들어 이 단순한 경우에는 경로 대신 파일 핸들을 전달하고 리소스 관리를 최상위로 옮기는 것이 관용적인 방법입니다). 그러나 일반적으로는 제너레이터 내부에서 with/finally를 사용하는 것을 피할 수 없으며, 따라서 어떤 식으로든 이 문제를 다루어야 합니다. 아름다움과 올바름이 충돌하면 아름다움이 이기는 경향이 있으므로, 올바른 코드를 아름답게 만드는 것이 중요합니다.

그렇다면 이 문제를 고칠 가치가 있습니까? 비동기 제너레이터가 등장하기 전이었다면 저는 그렇다고 주장했을 것입니다. 다만 모두가 그럭저럭 헤쳐 나가고 있는 것처럼 보이므로 우선순위는 낮다고 덧붙였을 것입니다 – 하지만 비동기 제너레이터 때문에 이 문제는 훨씬 더 시급해졌습니다. 사람들이 실제로 사용할 결정론적 정리 메커니즘이 없으면 비동기 제너레이터는 정리를 전혀 할 수 없으며, 비동기 제너레이터는 특히 파일 디스크립터 같은 자원을 붙잡고 있을 가능성이 높습니다. (결국 I/O를 하지 않는다면 비동기 제너레이터가 아니라 그냥 제너레이터였을 것입니다.) 따라서 무언가를 해야 하며, 이왕이면 근본적인 문제를 포괄적으로 고치는 편이 낫습니다. 그리고 나중에 고치는 것보다 비동기 제너레이터가 처음 도입되는 지금 고치는 것이 훨씬 쉽습니다.

제안 자체는 개념적으로 단순합니다: 이터레이터 프로토콜에 __(a)iterclose__ 메서드를 추가하고, 루프를 빠져나갈 때 (비동기) for 루프가 이 메서드를 호출하게 합니다. 루프를 빠져나가는 방식이 break나 예외 되감기라도 마찬가지입니다. 사실상 현재의 번거로운 관용구(with 블록 + for 루프)를 하나로 합쳐 더 멋진 for로 만드는 것입니다. 이것이 직교적이지 않아 보일 수도 있습니다. 하지만 제너레이터가 존재하기 때문에 with 블록이 안정적으로 동작하려면 사실 이터레이터 정리에 의존해야 한다는 점, 그리고 이터레이터 정리가 그 자체로도 바람직한 기능인 경우가 많다는 경험을 생각하면 타당합니다.

대안

PEP 525 asyncgen 훅

PEP 525는 전역 스레드 로컬 훅 집합을 제안합니다. 이 훅은 새로운 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를 생각해 봅시다. 이 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 루프나 그에 상응하는 방식으로 이터레이션되므로, 대부분의 상황은 GC가 개입하기 전에 __aiterclose__가 처리합니다. 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되지 않음”(coroutine never awaited) 경고와 비슷합니다).
  • 관련된 기반 자원은 그래도 정리됩니다. 제너레이터 프레임이 가비지 컬렉션되면서 프레임이 보유한 파일 핸들이나 소켓에 대한 참조를 해제하고, 이어서 해당 객체들의 __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__는 수동으로 호출하고 싶지 않은 사람들뿐이므로, GC 훅은 그만한 가치가 없다고 볼 수 있습니다. 하지만 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 블록 문제”를 피할 수 있게 해 줍니다. 하지만 안타깝게도 상황이 더 복잡해지면 이 방식은 금방 무너집니다. 제너레이터가 파일에서 읽는 대신, 리다이렉트와 OAUTH 인증을 처리하면서 스트리밍 HTTP GET 요청에서 읽는다고 생각해 보십시오. 그렇다면 소켓은 최상위 수준이 아니라 HTTP 클라이언트 라이브러리 내부에서 관리되기를 정말로 원할 것입니다. 게다가 제너레이터 안에 들어 있는 finally 블록이 그 자체로 중요한 경우도 있습니다: db 트랜잭션 관리, 정리 과정에서의 로깅 정보 출력(WSGI close를 도입한 주요 동기가 된 사용 사례 중 하나) 등이 그렇습니다. 따라서 이것은 단순한 경우를 위한 우회책일 뿐, 일반적인 해결책이 아닙니다.

더 복잡한 __(a)iterclose__ 변형

__(a)iterclose__의 의미 체계는 어느 정도 with 블록에서 영감을 받았지만, 컨텍스트 관리자가 더 강력합니다: __(a)exit__는 정상적인 종료와 예외에 의한 되감기(unwinding)를 구별할 수 있고, 예외가 발생한 경우에는 예외 세부 정보를 검사하고 선택적으로 전파를 억제할 수 있습니다. 여기서 제안하는 __(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

이를 다음과 동등한 코드로 디슈가링(desugar)합니다:

_iter = iter(ITERABLE)
_iterclose = getattr(type(_iter), "__iterclose__", lambda: None)
try:
    traditional-for VAR in _iter:
        LOOP-BODY
    else:
        ELSE-BODY
finally:
    _iterclose(_iter)

여기서 “traditional-for statement”는 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()를 호출합니다
  • __del__은 aclose가 호출되지 않았으면 RuntimeWarning을 발생시킵니다. 이는 “coroutine never awaited”(코루틴이 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__가 호출되기 전까지는 기반 이터레이터에 대해 __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"]가 정수인 문서가 들어 있다면, 다음과 같은 순서로 일이 일어납니다.

  1. key.upper()가 AttributeError를 발생시키고, 이 예외는 map 밖으로 전파되어 list 내부의 암묵적인 finally 블록을 실행시킵니다.
  2. list의 finally 블록이 map 객체에 대해 __iterclose__()를 호출합니다.
  3. map.__iterclose__()가 제너레이터 컴프리헨션 객체에 대해 __iterclose__()를 호출합니다.
  4. 이로 인해 제너레이터 컴프리헨션 본문에 GeneratorExit 예외가 주입되는데, 이 본문은 현재 컴프리헨션의 for 루프 본문 안에서 일시 중단된 상태입니다.
  5. 예외가 for 루프 밖으로 전파되어 for 루프의 암묵적인 finally 블록을 실행시키고, 이 블록은 read_newline_separated_json 호출을 나타내는 제너레이터 객체에 대해 __iterclose__를 호출합니다.
  6. 이로 인해 현재 yield에서 일시 중단된 read_newline_separated_json의 본문에 내부 GeneratorExit 예외가 주입됩니다.
  7. 내부 GeneratorExit가 for 루프 밖으로 전파되어 for 루프의 암묵적인 finally 블록을 실행시키고, 이 블록은 파일 객체에 대해 __iterclose__()를 호출합니다.
  8. 파일 객체가 닫힙니다.
  9. 내부 GeneratorExit가 다시 전파되기 시작하여 제너레이터 함수의 경계에 도달하고, read_newline_separated_json의 __iterclose__() 메서드가 성공적으로 반환되게 합니다.
  10. 제어가 제너레이터 컴프리헨션 본문으로 돌아오고, 외부 GeneratorExit가 계속 전파되어 컴프리헨션의 __iterclose__()가 성공적으로 반환되게 합니다.
  11. 나머지 __iterclose__() 호출들도 아무 문제 없이 풀리면서 list의 본문으로 돌아옵니다.
  12. 원래의 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__가 없으므로 오래된 비동기 이터레이터에 새로운 async 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에게 감사드립니다.