본문 바로가기
프로그래밍/Python

[Python] Context Mangers and else Blocks

by 별준 2022. 3. 26.

References

  • Fluent Python

Contents

  • with statement and context manager protocol
  • else clause in for, while, and try statements
  • contextlib 패키지 : @contextmanager

이번 포스팅에서는 with문, else절, Context Manager에 대해서 살펴보도록 하겠습니다.

 


else Blocks beyond if

else절은 if뿐만 아니라 for, while, try문에서도 사용할 수 있습니다. 생각보다 이 기능은 잘 알려져 있지 않습니다.

 

for/else, while/else, try/else의 의미는 서로 밀접한 연관이 있지만, if/else와는 상당히 다릅니다.

규칙은 다음과 같습니다.

  • for :
    for 루프가 완전히 실행된 후에(break문으로 중간에 멈추지 않고) else 블록이 실행된다.
  • while :
    조건식이 거짓이 되어 while 루프를 빠져나온 후에(break문으로 중간에 멈추지 않고0 else 블록이 실행된다.
  • try :
    try 블록에서 예외가 발생하지 않을 때만 else 블록이 실행된다. 그리고 else 블록에서 발생한 예외는 else 블록 앞에 나오는 except 블록에서 처리되지 않는다.

그러므로, 예외, return, break, continue 문이 복합문의 주요 블록을 빠져나오게 만들면 else 블록은 실행되지 않습니다.

이러한 구문에서 else를 사용하면 코드의 가독성을 높여주고, 제어 플래그의 사용이나 부수적인 if문을 추가할 필요가 없게 해줍니다.

 

일반적으로 루프에서의 else는 다음과 같은 패턴을 따릅니다.

for item in my_list:
    if item.flavor == 'banana':
        break
else:
    raise ValueError('No banana flavor found!')

 

try/except 블록의 경우, 얼핏보면 else가 필요없는 것처럼 생각될 수 있습니다. 다음 코드에서는 dangerous_call()에서 예외가 발생하지 않는 경우에만 after_call()이 호출됩니다.

try:
    dangerous_call()
    after_call()
except OSError:
    log('OSError...')

그렇지만 try 블록 안에 after_call()을 넣는 것은 그리 좋아 보이지는 않습니다. 코드의 의도를 명확하고 정확히 표현하기 위해 try 블록 안에는 예외를 발생시킬 가능성이 있는 코드만 넣어야 합니다. 따라서 다음과 같이 구현하는 것이 더 깔끔합니다.

try:
    dangerous_call()
except OSError:
    log('OSError...')
else:
    after_call()

이 코드를 보면 try 블록은 after_call()이 아니라 dangerous_call()에서 발생할 수 있는 에러를 처리하기 위한 것임을 명확히 알 수 있습니다. 그리고 after_call()은 try 블록 안에서 예외가 발생하지 않는 경우에만 실행됩니다.

 

파이썬에서는 try/except를 예외 처리뿐만 아니라 일반적인 제어 흐름을 구현하기 위해서도 많이 사용합니다. 파이썬 용오 문서(link)에서는 이에 대한 슬로건을 약자로 만들어 문서화까지 해놓았습니다.

파이썬에서는 EAFP 스타일을 사용하고 있으니 try/except 문에서 else 블록을 잘 이해하고 사용하는 것이 좋습니다.

 

 


Context Managers and with Blocks

이터레이터가 for문을 제어하기 위해 존재하는 것과 마찬가지로, 컨텍스트 매니저 객체는 with문을 제어하기 위해 존재합니다.

 

with문은 try/finally 패턴(이 패턴은 예외, return, sys.exit() 호출 등의 이유로 어떤 블록의 실행이 중단되더라도 이후의 일정한 코드를 반드시 실행할 수 있게 보장해줌)을 단순화하기 위해 설계되었습니다. 일반적으로 finally절 안에 있는 코드는 중요한 리소스를 해제하거나 임시로 변경된 상태를 복원하기 위해 사용됩니다.

 

파이썬 커뮤니티는 새롭고 창의적인 컨텍스트 매니저의 사용법을 찾는데, 표준 라이브러리에서 몇 가지 예제는 다음과 같습니다.

  • Managing transactions in the sqlite3 module
  • Holding locks, conditions, and semaphores in threading code
  • Setting up environments for arithmetic operations with Decimal objects (link)
  • Applying temporary patches to objects for testing (link)

 

컨텍스트 매니저 프로토콜은 __enter__()와 __exit__() 메소드로 구성됩니다. with문이 시작될 때, 컨텍스트 매니저 객체의 __enter__() 메소드가 호출됩니다. 이 메소드는 with 블록의 끝에서 finally절의 역할을 수행합니다.

 

파일 객체를 닫는 작업이 대표적인 예입니다. 다음 예제 코드는 with를 이용해서 파일을 닫습니다. mirror.py은 link를 참조해주세요.

위 예제 코드에서 첫 번째 주석은 중요한 점을 알려줍니다. 컨텍스트 매니저 객체는 with문 뒤의 표현식을 평가한 결과이지만, as 절에 있는 타겟 변수의 값은 컨텍스트 매니저 객체의 __enter__()를 호출한 결과입니다.

open() 함수가 TextIOWrapper 객체를 반환하고, 이 객체의 __enter__() 메소드는 self를 반환했을 뿐입니다. 그러나 __enter__() 메소드는 컨텍스트 매니저 대신 다른 객체를 반환할 수도 있습니다.

 

제어 흐름이 with문을 빠져나온 후에는 __enter__() 메소드가 반환한 객체가 아니라 컨텍스트 매니저 객체의 __exit__() 메소드가 호출됩니다.

 

with문의 as절은 선택적입니다. open()의 경우에는 파일에 대한 참조가 필요하지만, 사용자에게 반환할 적절한 객체가 없어서 None을 반환하는 컨텍스트 매니저도 있습니다.

 

다음 예제 코드는 아주 간단하지만 컨텍스트 매니저와 __enter__() 메소드가 반환하는 객체의 차이를 잘 보여줍니다.

 

LookingGlass 클래스 코드는 다음과 같습니다. (위의 mirror.py에 정의된 내용)

import sys

class LookingGlass:
    # self 인수만으로 __enter__() 메소드를 호출함
    def __enter__(self):
        # 원래대로 되돌리기 위해 객체 속성에 원래 sys.stdout.write() 메소드 객체 저장
        self.original_write = sys.stdout.write
        # sys.stdout.write()를 멍키 패칭해서 직접 만든 메소드로 변경
        sys.stdout.write = self.reverse_write
        # 타겟 변수 what에 무언가를 저장하기 위해 'JABBERWOCKY' 문자열 반환
        return 'JABBERWOCKY'

    # text 인수를 거꾸로 뒤집고 나서 원래의 sys.stdout.write()를 호출
    def reverse_write(self, text):
        self.original_write(text[::-1])

    # 정상적으로 수행이 완료되면 파이썬은 None, None, None 인수로 __exit__() 메소드를 호출한다.
    # 예외가 발생한 경우에는 이 3개의 인수에 예외 데이터가 전달된다.
    def __exit__(self, exc_type, exc_value, traceback):
        # sys.stdout.write()를 원래 메소드로 변경
        sys.stdout.write = self.original_write
        # exception 인수가 None이 아니고 ZeroDivisionError라면 메세지를 출력
        if exc_type is ZeroDivisionError:
            print('Please DO NOT divide by zero!')
            # 그리고 나서 True를 반환하여 예외가 처리되었음을 인터프리터에게 알려준다
            return True
        # __exit__()가 None이나 True이외의 값을 반환하면 with 블록에서 발생한 예외가 상위코드에 전달됨

 

파이썬 인터프리터는 __enter__() 메소드를 호출할 때 self 이외의 인수는 전달하지 않습니다. __exit__() 메소드는 호출할 때 다음의 세 인수를 전달합니다.

  • exc_type :
    ZeroDivisionError 등의 예외 클래스
  • exc_value :
    예외 객체, 예외 메세지 등 exception() 생성자에 전달된 인수는 exc_value.args 속성을 이용해서 볼 수 있다.
  • traceback :
    traceback 객체

다음 예제 코드에서는 컨텍스트 매니저가 동작하는 방식을 자세히 볼 수 있습니다. 여기서는 __enter__()와 __exit__() 메소드를 직접 호출하기 위해 with문 밖에서 LookingGlass 클래스를 사용합니다.

__enter__() 메소드를 호출한 뒤에는 출력되는 문자열의 순서가 바뀐 것을 볼 수 있습니다. 그리고 다시 __exit__() 메소드를 호출하면 원래대로 돌아옵니다.

 


The contextlib Utilities

컨텍스트 매니저 클래스를 직접 구현하기 전에 파이썬 표준 라이브러리 문서(link)에는 다음과 같이 다양하게 응용할 수 있는 클래스와 함수가 있습니다.

  • closing() : close() 메소드를 제공하지만 __enter__()/__exit__() 프로토콜을 구현하지 않는 객체로부터 컨텍스트 매니저를 생성하는 함수
  • suppress : 지정한 예외를 임시로 무시하는 컨텍스트 매니저
  • nullcontext : 적절한 컨텍스트 매니저를 구현하거나 하지 않을 수 있는 객체 주변의 조건 로직을 단순화하기 위해서 아무 작업도 하지 않는 컨텍스트 매니저 래퍼 (Since Python 3.7)

contextlib 모듈은 다음과 같은 클래스와 데코레이터도 제공합니다.

  • @contextmanager : 클래스를 생성하고 프로토콜을 구현하는 대신, 간단한 제너레이터 함수로부터 컨텍스트 매니저를 생성할 수 있게 해주는 데코레이터
  • AbstractContextManager : 컨텍스트 매니저 인터페이스를 공식화하는 ABC, 서브클래싱으로 컨텍스트 매니저 클래스를 더 쉽게 만들 수 있게 해줌 (Since Python 3.6)
  • ContextDecorator : 컨텍스트 매니저를 함수 데코레이터로도 사용할 수 있게 해주는 베이스 클래스
  • ExitStack : 여러 컨텍스트 매니저를 입력할 수 있게 해주는 컨텍스트 매니저. with 블록이 끝나면 ExitStack은 누적된 컨텍스트 매니저들의 __exit__() 메소드를 LIFO 순서로 호출한다. 예를 들어 임의의 파일 리스트에 있는 파일을 한꺼번에 여는 경우처럼, with 블록 안에 들어가기 전에 얼마나 많은 컨텍스트 매니저가 필요한지 사전에 알 수 없을 때 이 클래스를 사용하면 된다.

 

Python 3.7에서 AbstractAsyncContextManager, @asynccontextmanager, AsyncExitStack도 추가되었습니다. 이들은 async가 붙지 않은 동일한 이름의 유틸리티와 유사한데, async with문을 사용한다는 차이점이 있습니다.

 

이 유틸리티 중 @contextmanager 데코레이터가 가장 널리 사용되므로, 이에 대해서 자세히 살펴보겠습니다. @contextmanager는 반복과 상관없는 yield 문에도 사용할 수 있습니다. 이 개념을 잘 이해하면 코루틴을 이해하는데 도움이 됩니다.

 


Using @contextmanager

@contextmanager 데코레이터는 컨텍스트 매니저를 생성할 때 작성하는 코드를 줄여줍니다. __enter__()와 __exit__() 메소드를 가진 클래스 전체를 작성하는 대신 __enter__() 메소드가 반환할 것을 생성하는 yield 문 하나를 가진 제너레이터만 구현하면 됩니다.

 

@contextmanager로 데코레이트된 제너레이터에서 yield는 함수 본체를 두 부분으로 나누기 위해 사용됩니다. yield문 앞에 있는 모든 코드는 with 블록 앞에서 인터프리터가 __enter__()를 호출할 때 실행되고, yield 문 뒤에 있는 코드는 블록의 마지막에서 __exit__()가 호출될 때 실행됩니다.

 

예제 코드를 통해서 살펴보겠습니다. 다음 예제 코드는 위에서 구현한 LookingGlass 클래스를 제너레이터 함수로 교체합니다.

import contextlib
import sys

@contextlib.contextmanager
def looking_glass():
    original_write = sys.stdout.write

    # reverse_write() 함수를 정의. original_write()는 클로저를 통해 접근할 수 있다
    def reverse_write(text):
        original_write(text[::-1])
    
    sys.stdout.write = reverse_write
    yield 'JABBERWOCKY' # with문의 as절에 있는 변수에 바인딩할 값 생성
                        # with문의 본체가 실행되는 동안 이 함수는 여기서 실행을 일시 중단한다
    # with를 빠져나오면 yield문 이후의 코드가 실행된다
    sys.stdout.write = original_write

아래는 방금 정의한 looking_glass() 함수의 사용 예를 보여줍니다.

 

본질적으로 @contextlib.contextmanager 데코레이터는 데코레이트된 함수를 __enter__()와 __exit__() 메소드를 구현하는 클래스 안에 넣을 뿐입니다. 이 클래스의 __enter__() 메소드는 다음과 같은 단계를 실행합니다.

  1. 제너레이터 함수를 호출해서 제너레이터 객체를 보관한다(여기서는 이 객체를 gen이라고 부른다)
  2. next(gen)을 호출해서 yield 키워드 앞까지 실행한다
  3. next(gen)이 생성한 값을 반환해서, 이 값이 as 절의 타겟 변수에 바인딩되게 한다

with 블록이 실행을 마칠 때 __exit__() 메소드는 다음과 같은 단계를 실행합니다.

  1. exc_type에 예외가 전달되었는지 확인한다. 만일 그렇다면 제너레이터 함수 바디 안에 있는 yield 행에서 gen.throw(exception)을 실행해서 예외를 발생시킨 것이다
  2. 그렇지 않다면 next(gen)을 호출해서 제너레이터 함수 바디 안의 yield 다음 코드를 계속 실행한다

위에서 구현한 looking_glass()는 심각한 문제가 있습니다. with 블록 안에서 예외가 발생하면 파이썬 인터프리터가 이 예외를 잡고, looking_glass() 안에 있는 yield 표현식에서 다시 예외를 발생시킵니다. 그러나 그곳에는 예외 처리 코드가 없어서 looking_glass() 함수는 원래의 sys.stdout.write() 메소드를 복원하지 않고 중단하므로, 시스템이 불안정한 상태로 남게 됩니다.

 

다음 코드는 ZeroDivisionError 예외를 특별히 처리해서 클래스 기반의 LookingGlass와 기능상 동일하게 동작합니다.

import contextlib
import sys

@contextlib.contextmanager
def looking_glass():
    original_write = sys.stdout.write

    def reverse_write(text):
        original_write(text[::-1])
    
    sys.stdout.write = reverse_write
    msg = '' # 에러메세지에 대한 변수 생성
    try:
        yield 'JABBERWOCKY'
    except ZeroDivisionError: # 에러 메세지를 설정해여 에러 처리
        msg = 'Please DO NOT divide by zero!'
    finally:
        sys.stdout.write = original_write
        if msg:
            print(msg)

__exit__() 메소드는 예외 처리를 완료했음을 인터프리터에 알려주기 위해 True를 반환합니다. True가 반환되면 인터프리터는 예외를 전파하지 않고 억제합니다. 한편 __exit__()가 명시적으로 값을 반환하지 않으면 인터프리터가 None을 받으므로 예외를 전파합니다. @contextmanager 데코레이터를 사용하면 이 기본동작이 반대로 됩니다. 데코레이터가 제공하는 __exit__() 메소드는 제너레이터에 전달된 예외가 모두 처리되었으므로 억제되어야 한다고 생각합니다. @contextmanager가 예외를 억제하지 않게 하려면 데코레이트된 함수 안에서 명시적으로 예외를 다시 발생시켜야 합니다.

@contextmanager를 사용할 때는 어쩔 수 없이 yield문 주변을 try/finally나 with 블록으로 둘러싸야 합니다. 컨텍스트 매니저의 사용자가 자신의 with 블록 안에서 어떤 일을 할지 모르기 때문입니다.

 

표준 라이브러리 외에 @contextmanager의 흥미로운 실 사례는 in-place file rewriting context manager(link)에서 볼 수 있습니다. 아래 예제 코드는 이 컨텍스트 매니저를 사용하는 방법을 보여줍니다.

import csv

with inplace(csvfilename, 'r', newline='') as (infh, outfh):
    reader = csv.reader(infh)
    writer = csv.writer(outfh)

    for row in reader:
        row += ['new', 'columns']
        writer.writerow(row)

여기서 inplace() 함수가 컨텍스트 매니저이며, 동일한 파일에 대해 두 개의 핸들(infh, outfh)을 반환함으로써 파일을 동시에 읽고 쓸 수 있게 해줍니다. 이 함수는 표준 라이브러리에서 제공하는 fileinput.input() 함수보다 사용하기 쉽습니다.

 

inplace() 함수를 살펴보려면 위의 link에서 yield 키워드를 찾으면 됩니다. yield 앞의 모든 코드는 컨텍스트를 설정하고, 백업 파일을 생성하고, 파일을 연 후 __enter__()가 호출되면 반환할 읽기/쓰기용 파일 핸들에 대한 참조를 생성합니다. __exit__()가 수행하는 yield 뒤의 코드는 파일을 닫고, 어떤 문제가 생긴 경우에는 백업 파일로 복수하는 작업을 수행합니다.

 

@contextmanager 데코레이터와 함께 사용되는 제너레이터 안의 yield문은 반복과 상관없음에 주의해야 합니다. 여기서 본 예제에서 제너레이터는 코루틴과 비슷하게 동작합니다. 코루틴은 어떤 지점까지 실행한 후 호출자가 실행할 수 있도록 멈춘 후, 호출자가 원하면 나머지 작업을 진행합니다.

 

 

댓글