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

[Python] 데코레이터와 클로저

by 별준 2022. 3. 18.

References

  • Fluent Python

Contents

  • Decorators Basic
  • Registration Decorators
  • Variable Scope Rules
  • Closures
  • nonlocal
  • Decorators in the Standard Library : cache, lru_cache, singledispatch
  • Parameterized Decorators

함수 데코레이터(function decorator)는 소스 코드에 있는 함수를 '표시(mark)'해서 함수의 동작을 개선할 수 있게 해줍니다. 강력한 기능이지만, 이를 제대로 사용하기 위해서는 먼저 클로저(closure)에 대해서 알아야 합니다.

 

파이썬 3.0에서 추가된 nonlocal은 예약된 키워드 중 하나입니다. 클래스 중심의 엄격한 객체지향 방식을 고수한다면 이 기능을 사용하지 않고도 파이썬을 작성하는데 아무런 지장을 받지 않을 수 있습니다. 하지만 자신만의 데코레이터를 구현하고자 한다면 클로저를 깊게 이해해야 하며, 그러고 나면 nonlocal이 필요해집니다.

 

데코레이터에서 사용하는 것 외에도, 클로저는 콜백을 이용한 효율적인 비동기 프로그래밍과 필요에 따라 함수형 스타일로 코딩하는 데에도 필수적입니다.

 

이번 포스티의 목표는 가장 단순한 등록 데코레이터(registration decorators)부터 복잡한 매개변수화된 데코레이터(parameterized decorator)까지 함수 데코레이터가 정확히 어떻게 동작하는지 이해하는 것입니다. 하지만 그 전에 다음과 같은 내용을 먼저 살펴봐야 합니다.

  • 파이썬이 데코레이터 구문을 평가하는 방식
  • 파이썬이 변수가 지역 변수인지 판단하는 방식
  • 클로저의 존재 이유와 동작 방식
  • nonlocal로 해결할 수 있는 문제

이러한 내용들을 기반으로 다음과 같은 데코레이터와 관련된 주제를 다룰 수 있습니다.

  • 잘 동작하는 데코레이터 구현
  • 표준 라이브러리에서 제공하는 강력한 데코레이터: @cache, @lru_cache, @singledispatch
  • 매개변수화된 데코레이터 구현

 


Decorators Basic

데코레이터는 다른 함수를 인수로 받는 콜러블(the decorated function)입니다. 데코레이터는 데코레이트된 함수에 어떤 처리를 수행하고, 함수를 반환하거나 함수를 다른 함수나 콜러블 객체로 대체합니다.

 

예를 들어, 다음 코드에서처럼 decorate라는 이름의 데코레이터가 있다고 가정해봅시다.

@decorate
def target():
    print('running target()')

위 코드는 다음 코드와 동일하게 동작합니다.

def target():
    print('running target()')

target = decorate(target)

결과는 동일합니다. 두 코드를 실행한 후 target은 꼭 원래의 target() 함수를 가리키는 것이 아니며, decorate(target)이 반환한 함수를 가리키게 됩니다.

 

데코레이트된 함수가 대체되었는지 확인하기 위해서 다음 코드를 살펴보겠습니다.

엄밀히 말하자면, 데코레이터는 syntactic sugar일 뿐입니다. 위에서 본 것처럼 데코레이터는 다른 함수를 인수로 전달해서 호출하는 일반적인 콜러블과 동일합니다. 그렇지만 런타임에 프로그램 행위를 변경하는 메타프로그래밍(metaprogramming)을 할 때 데코레이터가 상당히 편리합니다.

 

지금까지 설명의 요약하자면, 첫째, 데코레이터는 데코레이트된 함수를 다른 함수로 대체하는 능력이 있습니다. 둘째, 데코레이터는 모듈이 로딩될 때 바로 실행됩니다. 아래에서 두 번째 성질에 대해서 살펴보겠습니다.

 


When Python Executes Decorators

데코레이터의 핵심은 데코레이트된 함수가 정의된 직후에 실행된다는 것입니다. 이는 일반적으로 파이썬 모듈이 로딩하는 시점, 즉, import time에 실행됩니다.

다음 예제 코드를 살펴보겠습니다.

# registry는 @register로 데코레이트된 함수들에 대한 참조를 담는다
registry = []

# register()는 함수를 인수로 받음
def register(func):
    # 데코레이트된 함수를 출력
    print(f'running register({func})')
    # func을 registry에 추가
    registry.append(func)
    return func # func을 반환

# f1과 f2는 @register로 데코레이트됨
@register
def f1():
    print('rurrning f1()')

@register
def f2():
    print('running f2()')

# f3은 데코레이트되지 않음
def f3():
    print('running f3()')

# main()은 registry를 출력하고, f1, f2, f3을 차례로 호출
def main():
    print('running main()')
    print('registry ->', registry)
    f1()
    f2()
    f3()

# 스크립트로 실행할 때만 main()이 호출되도록 설정
if __name__ == '__main__':
    main()

위 코드를 스크립트로 실행한 결과는 다음과 같습니다.

register()는 모듈 내의 다른 어떠한 함수보다 먼저 실행(2번)되는 것을 확인할 수 있습니다. register()가 호출될 때 데코레이트된 함수를 인수로 받습니다.

 

모듈이 로딩된 후 registry는 데코레이트된 두 개의 함수 f1()과 f2()에 대한 참조를 가집니다. 이 두 함수(f3()도 포함하여)는 main()에 의해 명시적으로 호출될 때만 실행됩니다.

 

위 코드를 스크립트로 실행하지 않고 import하면 다음과 같이 출력됩니다. (registration.py로 저장)

이때, registry를 살펴보면 다음과 같은 내용이 들어 있습니다.

registration.py 코드를 통해 함수 데코레이터는 모듈이 import되자마자 실행되지만, 데코레이트된 함수는 명시적으로 호출될 때만 실행됨을 알 수 있습니다. 이 예제는 임포트 타임(import time)런타임(runtime)의 차이를 명확히 보여줍니다.

 


Registration Decorators

데코레이터가 실제 코드에서 흔히 사용되는 방식과 비교해서 위에서 살펴본 registration.py 코드는 다음의 2가지 차이점이 있습니다.

  • 데코레이터 함수가 데코레이트된 함수와 동일한 모듈에 정의되어 있습니다. 일반적으로 실제 코드에서는 데코레이터를 정의하는 모듈과 데코레이터를 적용하는 모듈을 분리해서 구현합니다.
  • register() 데코레이터가 인수로 전달된 함수와 동일한 함수를 반환합니다. 실제 코드에서는 대부분의 데코레이터는 내부 함수를 정의해서 반환합니다.

방금 살펴본 예제 코드에서 register() 데코레이터가 데코레이트된 함수를 그대로 반환하기는 하지만, 이 기법이 쓸모없는 것은 아닙니다. 여러 파아썬 웹 프레임워크에서 이와 비슷한 데코레이터가 사용되는데, URL 패턴을 HTTP 응답 생성 함수에 매핑하는 레지스트리 등 함수를 어떤 중앙의 레지스트리에 추가하기 위해서 사용됩니다. 이러한 등록 데코레이터들은 데코레이트된 함수를 변경할 수도 있고 아닐 수도 있습니다. 

 

대부분의 데코레이터는 데코레이트된 함수를 변경합니다. 이들은 보통 내부 함수를 정의하고 데코레이트된 함수를 대체하도록 내부 함수를 반환합니다. 내부 함수를 사용하는 코드는 거의 항상 클로저에 의존하여 올바르게 동작합니다. 

 

아래에서 클로저를 이해하기 위해, 파이썬에서 변수 스코프가 어떻게 동작하는지 살펴보겠습니다.

 


Variable Scope Rules

다음 예제 코드에서는 함수 매개변수로 정의된 지역 변수 a와 함수 내부에 정의되지 않은 변수 b, 2개의 변수를 읽는 함수를 정의하고 테스트합니다.

예상한 대로 에러가 발생합니다. 그런데 전역 변수 b에 값을 할당하고 f1()을 호출하면 다음과 같이 제대로 동작합니다.

 

조금 더 신기한 예제를 살펴보겠습니다. 처음 두 줄은 위의 f1()의 코드와 동일합니다. 그 이후에 b에 값을 할당합니다.

먼저 print(a) 문이 출력되면서 3이 출력됩니다. 그러나 두 번째 문장 print(b)는 실행되지 않습니다. 전역 변수 b가 있고 print(b) 다음에 지역 변수 b에 할당하는 문장이 나오므로 전역 변수의 값인 6이 출력될 것이라고 생각되었지만 그러지 않았습니다.

 

그러나 사실은 파이썬이 함수 본체를 컴파일 할 때, b가 함수 안에서 할당되므로 b를 지역 변수로 판단합니다. 생성된 바이트코드를 보면 이 판단에 의해 로컬에서 변수 b를 가져오려고 한다는 것을 알 수 있습니다. 나중에 f2(3)을 호출할 때 f2의 바디는 지역 변수 a를 출력하지만, 지역 변수 b의 값을 가져오려 할 때 b가 바인딩되어 있지 않다는 것을 발견합니다.

 

이 현상은 버그가 아닌 design choice입니다. 파이썬은 변수가 선언되어 있기를 요구하지 않지만, 함수 본체 안에서 할당된 변수는 지역 변수로 판단합니다. 이런 방식은 파이썬과 마찬가지로 변수 선언을 요구하지는 않지만, var를 이용해서 지역 변수를 선언하지 않은 경우 자동으로 전역 변수를 사용해버리는 자바스크립트의 방식보다 훨씬 좋습니다.

(깜박하게 지역 변수 var를 선언하지 않았다는 것을 일깨워줄 수 있습니다.)

 

만약 인터프리터가 b를 전역 변수로 처리하기를 원한다면 다음과 같이 global 키워드를 이용해서 선언해야 합니다.

위 예제에서 우리는 2개의 스코프를 볼 수 있습니다.

  1. module global scope: 함수 블록이나 클래스 외부에서 값이 할당된 변수
  2. function local scopes: 파라미터로 전달되거나 함수 내부에서 직접 할당되는 변수

여기서 추가로 nonlocal 이라는 스코프가 하나 더 있는데, 이는 뒤에서 살펴보도록 하겠습니다.

 


Closures

종종 클로저를 익명 함수와 혼동하는 경우가 있습니다. 아마도 익명 함수를 이용하면서 함수 안에 함수를 정의하는 방식이 보편화되었기 때문일 수도 있습니다. 그리고 클로저는 중첩된 함수를 가질 때만 의미가 있습니다.

 

실제로 클로저는 함수 본체에서 정의하지 않고 참조하는 비전역(nonglobal) 변수를 포함한 확장된 scope를 가진 함수입니다. 함수가 익명 함수인지는 중요하지 않습니다. 함수 본체 외부에 정의된 비전역 변수에 접근할 수 있다는 것이 중요합니다.

 

개념이 약간 이해하기 힘들 수 있는데, 예제를 통해서 알아보겠습니다.

avg() 함수가 점차 증가하는 일련의 값의 평균을 계산한다고 가정해보겠습니다. 예를 들면, 전체 기간을 통틀어 어떤 상품의 가격 평균을 구하는 경우가 있습니다. 매일 새로운 가격이 추가되고 지금까지의 모든 가격을 고려해서 평균을 구합니다.

 

처음 avg()를 실행한 후 반복 실행하면 다음과 같은 결과를 출력해야 합니다.

avg()는 어떻게 이전 값을 기억하고 있는 걸까요?

먼저 클래스를 이용해서 구현하는 방법은 다음과 같습니다.

class Averager():
    def __init__(self):
        self.series = []
    
    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total/len(self.series)

그리고 정의한 클래스의 인스턴스를 생성해서 호출할 수 있습니다.

 

클래스가 아닌 고차 함수로 구현하면 다음과 같습니다.

def make_averager():
    series = []
    
    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/len(series)
    
    return averager

이 함수가 호출되면 averager() 함수 객체를 반환합니다. averager() 함수는 호출될 때마다 받은 인수를 series 리스트에 추가하고 다음과 같이 현재까지의 평균을 계산해서 출력합니다.

 

클래스와 고차 함수로 구현한 것은 상당히 비슷합니다. Averager()나 make_averager()를 호출해서 콜러블 객체인 avg가 반환되고, avg()는 series를 갱신하고 지금까지의 평균을 계산합니다. 클래스를 사용한 예제의 avg()는 Averager 클래스의 객체이고, 고차 함수를 사용한 예제에서 avg()는 내부 함수인 averager() 입니다. 어쨋든 avg(n)을 호출해서 n을 series에 추가하고 새로운 평균을 가져옵니다.

 

Averager 클래스의 avg() 함수가 데이터를 보관하는 방법은 명확히 알 수 있습니다. 바로 self.series 인스턴스 속성에 저장되기 때문입니다. 그러나 고차 함수를 사용한 예제에서 avg() 함수는 어디에서 series를 찾을 까요?

 

make_averager() 함수 바디 안에서 series = []로 초기화하고 있으므로 series는 이 함수의 지역 변수입니다. 그렇지만 avg(10)을 호출할 때, make_averager() 함수는 이미 반환되었으므로 지역 범위도 이미 사라진 후입니다.

 

averager() 안에서, series는 자유 변수(free variable)입니다. 자유 변수라는 말은 지역 범위에 바인딩되어 있지 않은 변수를 의미합니다. 다음 그림을 살펴보겠습니다.

반환된 averager() 객체를 조사해보면 파이썬이 컴파일된 함수 바디를 나타내는 __code__ 속성 안에 어떻게 지역 변수와 자유 변수의 '이름'을 저장하는 지 알 수 있습니다.

series에 대한 바인딩은 반환된 avg() 함수의 __closure__ 속성에 저장됩니다. avg.__closure__의 각 항목은 avg.__code__.co_freevars의 이름에 대응됩니다. 이 항목은 cell 객체이며, 이 객체의 cell_contents 속성에서 실제 값을 찾을 수 있습니다. 아래 코드는 이 속성들을 보여줍니다.

 

지금까지 살펴본 내용을 정리해보면, 클로저는 정의할 때 존재하던 자유 변수에 대한 바인딩을 유지하는 함수입니다. 따라서 함수를 정의하는 범위가 사라진 후에 함수를 호출해도 자유 변수에 접근할 수 있습니다.

 

함수가 '비전역' 외부 변수를 다루는 경우는 그 함수가 다른 함수 안에 정의된 경우뿐이라는 점에 유의해야 합니다.

 


The nonlocal Declaration

앞에서 구현한 make_averager()는 그리 효율적이지 않습니다. 여기서는 모든 값을 series에 저장하고 averager()가 호출될 때마다 sum을 다시 계산했습니다. 합계와 항목 수를 저장 한 후 이 두 개의 숫자를 이용해서 평균을 구하면 훨씬 더 효율적으로 구현할 수 있습니다.

 

아래 예제 코드를 살펴보겠습니다.

def make_averager():
    count = 0
    total = 0
    
    def averager(new_value):
        count += 1
        total += new_value
        return total / count
    
    return averager

위 함수를 가지고 아래처럼 실행하면 에러가 발생합니다.

count가 수치형이거나 어떤 가변 타입일 때, count += 1은 실제로 count = count + 1을 의미하기 때문에 위와 같은 에러가 발생합니다. 따라서 averager() 바디 내에서 count 변수에 할당하고 있으므로 count를 지역 변수로 취급해버립니다. total 변수에도 동일한 문제가 발생합니다.

 

이전에 정의한 make_averager()에서는 series 변수에 할당하지 않고 append만 호출했기 때문에 이런 문제가 발생하지 않았습니다. 즉, 리스트가 가변형이라는 사실을 이용했을 뿐입니다.

 

그러나 숫자, 문자열, 튜플 등 불변형은 읽을 수만 있고 값은 갱신할 수 없습니다. count = count + 1과 같은 문장으로 변수를 다시 바인딩하면 암묵적으로 count라는 지역 변수를 만듭니다. count가 더 이상 자유 변수가 아니므로 클로저에는 저장되지 않게 됩니다.

 

이 문제를 해결하기 위해 파이썬 3에 nonlocal 키워드가 추가되었습니다. 변수를 nonlocal로 선언하면 함수 안에서 변수에 새로운 값을 할당하더라도 그 변수는 자유 변수임을 나타냅니다. 새로운 값을 nonlocal 변수에 할당하면 클로저에 저장된 바인딩이 변경됩니다. 올바르게 구현하면 다음과 같습니다.

def make_averager():
    count = 0
    total = 0
    
    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count
    
    return averager

이렇게 구현하면 다음과 같이 정상적으로 동작하게 됩니다.

 


Implement a Simple Decorator

이제 중첩 함수로 데코레이터를 효율적으로 구현하는 간단한 예제를 살펴보겠습니다.

아래 예제 코드는 데코레이트된 함수를 호출할 때마다 시간을 측정해서 실행에 소요된 시간, 전달된 인수, 반환값을 출력하는 데코레이터입니다.

import time

def clock(func):
    # 내부 함수 clocked()가 임의 개수의 위치 인수를 받을 수 있도록 정의
    def clocked(*args):
        t0 = time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ', '.join(repr(arg) for arg in args)
        print('[%0.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result))
        return result
    # 내부 함수를 반환해서 데코레이터된 함수를 대체
    return clocked

다음 코드는 clock() 데코레이터를 사용하는 예를 보여줍니다.

@clock
def snooze(seconds):
    time.sleep(seconds)

@clock
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

print('*' * 40, 'Calling snooze(.123)')
snooze(.123)
print('*' * 40, 'Calling factorial(6)')
print('6! =', factorial(6))

 

위 함수가 어떻게 동작하는지 살펴보도록 하겠습니다.

@clock
def snooze(seconds):
    time.sleep(seconds)

위의 코드는 실제로 다음 코드로 실행됩니다.

def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

factorial = clock(factorial)

따라서 clock()은 factorial() 함수를 func 인수로 받습니다. 그 후 clocked() 함수를 만들어서 반환하는데, 파이썬 인터프리터가 내부적으로 clocked()를 factorial에 할당했습니다. 실제로 factorial의 __name__ 속성을 조사해보면 다음과 같은 결과가 나옵니다.

그러므로 factorial은 이제 실제로 clocked() 함수를 참조합니다. 이제부터 factorial(n)을 호출하면 clocked(n)이 실행됩니다. 그리고 clocked() 함수는 다음과 같은 연산을 수행합니다.

  1. 초기 시각 t0을 기록
  2. 원래의 factorial() 함수를 호출하고 결과를 저장
  3. 실행 시간을 계산
  4. 수집한 데이터를 포맷팅하고 출력
  5. 2번 단계에서 저장한 결과를 반환

이 예제 코드는 전형적인 데코레이터의 작동 방식을 보여줍니다. 데코레이트된 함수를 동일한 인수를 받는 함수로 교체하고, (일반적으로) 데코레이트된 함수가 반환해야 하는 값을 반환하면서, 추가적인 처리를 수행합니다.

 

다만, 위에서 구현한 clock() 데코레이터는 단점이 몇 가지 존재합니다. 키워드 인수를 지원하지 않으며 데코레이트된 함수의 __name__과 __doc__ 속성을 가립니다.

다음 예제 코드는 functools.wraps() 데코레이터를 이용해서 func에서 clocked로 관련된 속성을 복사합니다. 그리고 아래 버전의 데코레이터에서는 키워드 인수도 제대로 처리됩니다.

import time
import functools

def clock(func):
    @functools.wraps(func)
    def clocked(*args, **kwargs):
        t0 = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - t0
        name = func.__name__
        arg_lst = []
        if args:
            arg_lst.append(', '.join(repr(arg) for arg in args))
        if kwargs:
            paris = ['%s=%r' % (k, w) for k, w in sorted(kwargs.items())]
            arg_lst.append(', '.join(pairs))
        arg_str = ', '.join(arg_lst)
        print('[%0.8fs] %s(%s) -> %r ' % (elapsed, name, arg_str, result))
        return result
    return clocked

 

functools.wraps()는 표준 라이브러리에서 제공하는 데코레이터 중 하나일 뿐입니다. 아래에서는 functools이 제공하는 데코레이터들을 살펴보겠습니다.

 


Decorators in the Standard Library

표준 라이브러리에서 인상깊은 데코레이터가 몇 가지 있는데 그 중에 cache, lru_cache, singledispatch에 대해서 알아보겠습니다. 이들은 모두 functools 모듈에 정의되어 있습니다.

 

Memoization with functools.cache

functools.cache 데코레이터는 메모이제이션(memoization)을 구현합니다. 메모이제이션은 이전에 실행한 cost가 큰 함수의 결과를 저장함으로써 이전에 사용된 인수에 대해 다시 계산할 필요가 없도록 해줍니다.

functools.cache는 파이썬 3.9에서 추가되었습니다. 이전 버전에서는 @cache가 대신 @lru_cache를 사용하면 됩니다. lru_cache는 아래에서 살펴보도록 하겠습니다.

 

@cache를 적용하기 좋은 예제는 피보나치 수열의 n번째 숫자를 생성하는 재귀 함수가 있습니다. 다음과 같이 구현한 피보나치 수열은 매우 cost가 크며 느립니다. 수행 시간을 측정하기 위해서 위에서 구현한 clock 데코레이터를 사용하여 실행한 결과는 다음과 같습니다.

여기서 fibonacci(1)이 8번, fibonacci(2)가 5번 호출되는 등 낭비되는 계산이 많습니다. 그렇지만 cache를 사용하도록 단 2줄만 추가하면 성능이 상당히 개선됩니다.

 

데코레이트된 함수에 전달되는 모든 인수는 반드시 hashable해야 하는데, 기본이되는 lru_cache()가 결과를 저장하기 위해서 딕셔너리를 사용하고, 호출할 때 사용한 위치 인수와 키워드 인수를 키로 사용하기 때문입니다.

 


Using lru_cache

functools.cache 데코레이터는 정말 간단한 래퍼인데, 파이썬 3.8과 이전 버전에서 호환되며 더 유연한 functools.lru_cache 함수가 있습니다.

 

@lru_cache의 주요 장점은 메모리 사용량을 maxsize 파라미터를 통해 제한할 수 있다는 것이며, 기본값은 128입니다. 즉, 매번 최대 128개를 저장하고 있을 수 있습니다. LRU는 'Least Recently Used'의 약자로서, 오랫동안 사용하지 않은 항목을 버림으로써 캐시가 무한정 커지지 않음을 의미합니다.

 

파이썬 3.8 이후부터 lru_cache는 두 가지 방법으로 사용할 수 있습니다.

먼저 다음과 같이 간단하게 사용할 수 있습니다.

@lru_cache
def costly_function(a, b):
    ...

 

다른 방법으로는 ()를 붙여서 함수처럼 사용하는 방법이 있습니다.

@lru_cache()
def costly_function(a, b):
    ...

 

위의 두 방법 모두 기본 파라미터가 사용되는데, 사용되는 파라미터는 다음과 같습니다.

  • maxsize=128
    저장할 항목의 최대 갯수를 설정합니다. 캐시가 가득차면 오래된 결과를 버리고 공간을 확보합니다. 최적의 성능을 위해서 maxsize는 2의 제곱이 되어야 합니다. 만약 maxsize=None을 전달한다면, LRU 로직은 비활성화되며, 캐시가 더 빠르게 동작하지만 저장된 것들을 절대로 버리지 않습니다. 따라서 메모리를 많이 소비할 수 있습니다.
  • typed=False
    다른 인수 타입의 결과를 분리해서 저장할 지 결정합니다. 예를 들어, 기본 설정에서는 실수와 정수 인수를 동일하다고 간주하고 오직 한 번만 저장합니다. 따라서 f(1)과 f(1.0)의 호출을 하나로 저장합니다. 만약 typed=True로 설정되는 경우 이 인수를 따로 저장하게 됩니다.

기본값이 아닌 파라미터를 설정하여 @lru_cache를 사용하려면 다음과 같이 작성하면 됩니다.

@lru_cache(maxsize=2**20, typed=True)
def costly_function(a, b):
    ...

 

Single Dispatch Generic Functions

웹 어플리케이션을 디버깅하는 도구를 만들고 있다고 가정해봅시다. 파이썬 객체의 자료형마다 HTML 코드를 생성하고자 합니다.

 

먼저 다음과 같이 기본적인 함수를 정의할 수 있습니다.

import html

def htmlize(obj):
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

이 코드는 모든 파이썬 자료형을 잘 처리합니다. 그렇지만 일부 자료형에 대해 다음과 같이 고유한 코드를 생성하도록 이 코드를 확장하고자 합니다.

  • str: 개행 문자를 '<br/>\n/'으로 대체하고 <pre> 대신 <p> 태그를 사용한다
  • int: 숫자를 10진수와 16진수로 보여준다
  • list: 각 항목의 자료형에 따라 포맷한 HTML 리스트를 출력한다
  • float and Decimal: 일반적으로 출력하지만, 분수 형태로도 표현한다

이렇게 구현된 htmlize는 다음과 같이 동작해야 합니다.

 

파이썬에서는 메소드나 함수의 오버로딩을 지원하지 않으므로, 서로 다르게 처리하고자 하는 자료형으로 인수를 가진 htmlize()를 만들 수 없습니다. 이때 파이썬에서는 일반적으로 htmlize()를 디스패치 함수로 변경하고, if/elif/... 또는 match/case/... 문을 이용해서 htmlize_str(), htmlize_int()와 같은 특화된 함수를 호출하도록 합니다. 하지만 이 모듈의 사용자가 코드를 확장하기 쉽지 않으며, 다루기도 어렵습니다. 또한 시간이 지날수록 htmlize() 디스패치 코드가 커지고, 디스패치 함수와 특화된 함수 간의 결합이 너무 강해집니다.

 

파이썬 3.4에서 추가된 functools.singledispatch 데코레이터는 다른 모듈들이 하나의 솔루션에 기여할 수 있게 해주며, 우리가 편집할 수 없는 서드파티 패키지에 속한 타입에 대한 특화 함수를 쉽게 제공할 수 있도록 해줍니다. 일반 함수를 @singledispatch로 데코레이트하면, 이 함수는 범용 함수(general function)가 됩니다. 즉, 각각의 타입에 특화된 함수가 되며, 이 함수에 전달되는 타입은 @singledispatch의 첫 번째 인수에 따라 결정됩니다.

다음 코드는 single-dispatch를 사용하여 구현하는 방법을 보여줍니다.

from functools import singledispatch
from collections import abc
import fractions
import decimal
import numbers
import html

# singledispatch는 obj 타입을 다룰 base function을 지정함
@singledispatch
def htmlize(obj):
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

# 각각의 특화 함수는 @<base>.register(<object type>)으로 데코레이트된다
@htmlize.register(str)
def _(text): # 특화 함수의 이름은 필요없으므로 언더바로 지정
    content = html.escape(text).replace('\n', '<br/>\n')
    return f'<p>{content}</p>'

@htmlize.register(abc.Sequence)
def _(seq):
    inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
    return '<ul>\n<li>' + inner + '</li>\n</ul>'

@htmlize.register(numbers.Integral)
def _(n):
    return f'<pre>{n} (0x{n:x})</pre>'

@htmlize.register(bool)
def _(n):
    return f'<pre>{n}</pre>'

@htmlize.register(fractions.Fraction)
def _(x):
    frac = fractions.Fraction(x)
    return f'<pre>{frac.numerator}/{frac.denominator}</pre>'

# 동일한 함수로 여러 타입을 지원하기 위해 register 데코레이터를 여러 개 사용 가능
@htmlize.register(decimal.Decimal)
@htmlize.register(float)
def _(x):
    frac = fractions.Fraction(x).limit_denominator()
    return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'

가능하면 int나 list와 같은 구체적인 클래스보다 numbers.Integral이나 abc.MutableSequence와 같은 추상 클래스를 처리하도록 특화된 함수를 등록하는 것이 좋습니다. 추상 베이스 클래스로 등록하면 호환되는 자료형을 폭넓게 지원할 수 있습니다. 예를 들어, 파이썬 확장은 고정된 비트를 가진 int 타입의 대안으로 numbers.Integral의 서브클래스로 제공할 수 있습니다.

 

파이썬 3.7부터는 Type Hint(타입 힌트)를 지원하는데, 타입 힌트를 사용하게 되면 register의 첫 번째 인수를 생략하고 괄호()도 생략할 수 있습니다. 위에서 작성한 특화 함수들은 최신 문법으로 다음과 같이 작성할 수 있습니다.

from functools import singledispatch
from collections import abc
import fractions
import decimal
import numbers
import html

@singledispatch
def htmlize(obj):
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

@htmlize.register
def _(text: str) -> str:
    content = html.escape(text).replace('\n', '<br/>\n')
    return f'<p>{content}</p>'

@htmlize.register
def _(seq: abc.Sequence) -> str:
    inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
    return '<ul>\n<li>' + inner + '</li>\n</ul>'

@htmlize.register
def _(n: numbers.Integral) -> str:
    return f'<pre>{n} (0x{n:x})</pre>'

@htmlize.register
def _(n: bool) -> str:
    return f'<pre>{n}</pre>'

@htmlize.register
def _(x: fractions.Fraction) -> str:
    frac = fractions.Fraction(x)
    return f'<pre>{frac.numerator}/{frac.denominator}</pre>'

@htmlize.register(decimal.Decimal)
@htmlize.register(float)
def _(x):
    frac = fractions.Fraction(x).limit_denominator()
    return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'

마지막 특화 함수는 decimal.Decimal과 float, 2개의 타입을 처리하는데, typing.Union[decimal.Decimal, float]을 사용해서 2개의 타입에 대한 힌트를 추가할 수 있을 것이라고 생각했으나, 에러가 발생합니다. 아마도 register에서 이를 처리할 수 없는 것 같습니다.

 

singledispatch 메커니즘은 특화 함수를 시스템 어디에나, 어느 모듈에나 등록할 수 있다는 장점이 있습니다. 나중에 새로운 사용자 정의 자료형이 추가된 모듈을 추가할 때도 추각된 자료형을 처리하도록 새로운 특화 함수를 쉽게 추가할 수 있습니다. 그리고 직접 작성하지 않고 변경할 수 없는 클래스에 대한 특화 함수도 추가할 수 있습니다.

 

singledispatch는 많은 고민 끝에 표준 라이브러리에 추가되었으며, 위에서 설명한 것보다 더 많은 기능을 제공합니다. 자세한 내용은 PEP 443(link) 문서를 참조하시길 바랍니다. 다만, 여기에는 타입 힌트와 관련된 내용은 없습니다. 조금 더 최신 내용은 functools 모듈에 대한 문서(link)를 참조하면 좋을 것 같습니다.

 


Parameterized Decorators

소스 코드에서 데코레이터를 파싱할 때 파이썬은 데코레이트된 함수를 가져와서 데코레이터 함수의 첫 번째 인수로 넘겨줍니다. 그러면 어떻게 다른 인수를 받는 데코레이터를 만들 수 있을까요?

해결책은 인수를 받아 데코레이터를 반환하는 데코레이터 팩토리(decorator factory)를 만들고, 데코레이트될 함수에 데코레이터 팩토리를 적용하면 됩니다.

 

조금 복잡할 수 있는데, 포스팅 초반에 구현했던 register()를 예제로 살펴보도록 하겠습니다.

registry = []

def register(func):
    print(f'running register({func})')
    registry.append(func)
    return func

@register
def f1():
    print('running f1()')
    
print('running main()')
print('registry ->', registry)
f1()

 

A Parameterized Registration Decorator

register()가 등록하는 함수를 활성화 혹은 비활성화하기 쉽게 만들기 위해, 옵셔널 인수인 active를 받도록 만들어보겠습니다. active가 False면 데코레이트된 함수를 등록 해제합니다. 아래 코드에서 새로 만든 register() 함수는 개념적으로는 데코레이터가 아닌 데코레이터 팩토리입니다. 호출되면 대상 함수에 적용할 실제 데코레이터를 반환하게 됩니다.

# 함수의 추가와 제거를 더 빠르게 수행하기 위해 set으로 정의
registry = set()

def register(active=True): # register()는 optional 인수를 받음
    def decorate(func): # decorate() 내부 함수가 실제 데코레이터임
        print('running register'
              f'(active={active})->decorate({func})')
        if active: # active 인수가 True 일때만 func을 등록
            registry.add(func)
        else:
            registry.discard(func)
        return func # decorate가 데코레이터이므로 반드시 함수를 리턴
    return decorate # register는 데코레이터 팩토리이므로 decorate를 반환

# @register 팩토리는 원하는 파라미터로 함수처럼 호출
@register(active=False)
def f1():
    print('running f1()')

# 인수를 전달하지 않더라도 register는 여전히 함수처럼 호출해야함
@register()
def f2():
    print('running f2()')
    
def f3():
    print('running f3()')

핵심은 register()가 decorate()를 반환하고, 데코레이트될 함수에 decorate()가 적용된다는 것입니다.

 

위 코드를 registration_param.py에 저장하고, 이를 import하면 다음과 같이 실행됩니다.

f2() 함수만 registry에 남아 있는 것을 확인할 수 있습니다. register() 데코레이터 팩토리에 active=False를 전달했으므로 f1()에 적용된 decorate()는 registry에 f1() 함수를 추가하지 않았습니다.

 

만약 @ 문법을 사용하는 대신, register()를 일반 함수로 사용하려면, 괄호를 사용한 문법이 필요합니다. 만약 함수 f를 registry에 추가하려면 register()(f)로, registry에서 제거하려면 register(active=False)(f)로 호출해야 합니다.

아래 예제 코드는 괄호 구문을 이용해서 함수를 registry에 추가하거나 제거하는 것을 보여줍니다.

parameterized decorator의 동작 방식은 상당히 복잡하며, 위에서 예시로 본 코드는 실제로 사용되는 것보다 상당히 간단합니다. parameterized decorator는 일반적으로 데코레이트된 함수를 대체하고 생성하기 위해 한 단계 더 중첩된 함수를 필요로 합니다. 아래에서 이러한 형태의 데코레이터에 대해 살펴보겠습니다.

 

The Parameterized Clock Decorator

위에서 살펴봤던 clock() 데코레이터 예제를 이용해서 기능을 추가해보도록 하겠습니다. 추가되는 기능은 사용자가 문자열 포맷을 전달하여 함수가 출력할 문자열을 설정하도록 하는 것입니다.

아래 코드를 살펴보겠습니다.

import time

DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'

def clock(fmt=DEFAULT_FMT): # clock()은 parameterized 데코레이터 팩토리임
    def decorate(func): # 실제 데코레이터
        def clocked(*_args): # clocked()는 데코레이트된 함수를 래핑한다
            t0 = time.perf_counter()
            _result = func(*_args)
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)
            result = repr(_result)
            print(fmt.format(**locals())) # fmt가 clocked()의 지역변수를 모두 참조할 수 있게함
            return _result
        return clocked
    return decorate

if __name__ == '__main__':
    @clock() # 인수없이 clock()을 호출하므로, 기본 문자열 포맷을 사용
    def snooze(seconds):
        time.sleep(seconds)
    
    for i in range(3):
        snooze(.123)

이렇게 정의한 코드를 커맨드로 실행하면 다음의 결과를 확인할 수 있습니다.

다른 포맷을 사용하고 싶다면, 다음과 같이 사용할 수도 있습니다.

@clock('{name}: {elapsed}s')
def snooze(seconds):
    time.sleep(seconds)

for i in range(3):
    snooze(.123)

@clock('{name}({args}) dt={elapsed:0.3f}s')
def snooze(seconds):
     time.sleep(seconds)
        
for i in range(3):
    snooze(.123)

 

A class-based clock decorator

마지막 예제로, parameterized clock 데코레이터를 __call__을 사용하여 클래스로 구현할 수도 있습니다.

import time

DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'

class clock:
    def __init__(self, fmt=DEFAULT_FMT):
        self.fmt = fmt
    
    def __call__(self, func):
        def clocked(*_args):
            t0 = time.perf_counter()
            _result = func(*_args)
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)
            result = repr(_result)
            print(self.fmt.format(**locals()))
            return _result
        return clocked

제 개인적인 느낌으로는 함수로만 구현했을 때보다 클래스로 구현했을 때가 더 깔끔해보이긴 합니다... !

 


데코레이터는 본질적으로는 간단한 메커니즘이지만 고급 파이썬 프레임워크에서 실제 사용되고 있습니다. 매개변수화된 데코레이터는 거의 항상 최소 2단계의 중첩 함수를 가지고 있으며, 더 고급 기법을 지원하는 데코레이터를 구현하기 위해 @functools.wraps를 사용하는 경우 3단계 이상 중첩되기도 합니다.

 

데코레이터가 실제 동작하는 방식을 이해하려면 import time과 runtime의 차이를 알아야 하며, 변수 범위, 클로저, nonlocal 선언에 대해서도 자세히 알고 있어야 합니다. 클로저와 nonlocal을 완전히 이해하면 데코레이터를 만들 수 있을 뿐만 아니라 GUI 방식의 이벤트 지향 프로그램이나 쿨백을 이용한 비동기 입출력을 구현할 때도 큰 도움이 될 것입니다.. ! 

댓글