본문 바로가기

python

Fluent Python (챕터 7)

함수 데커레이터와 클로저

 

데커레이터를 자유자재로 사용하기 위해서는 클로저에 대한 이해가 필수이다.

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

 

이 장에서는 함수 데커레이터가 정확히 어떻게 동작하는지 설명한다.

그에 앞서 우선 아래의 내용을 먼저 살펴봐야 한다.

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

 

7.1 데커레이터 기본 지식

 

데커레이터는 다른 함수를 인수로 받는 콜러블이다. 데커레이터는 데커레이트된 함수에 어떤 처리를 수행하고, 함수를 반환하거나 함수를 다른 함수나 콜러블 객체로 대체한다.

 

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

------------------------------

def target():
    print("running target()")
target = decorate(target)

두 코드의 결과는 동일하다. target 은 원래의 target() 함수를 가리키는 것이 아니며, decorate(target)이 반환한 함수를 가리킨다.

 

def deco(func):
    def inner():
        print("running inner()")
    return inner


@deco
def target():
    print("running target()")


target()
> running inner()

print(target)
<function deco.<locals>.inner at 0x10e313670>

- deco() 는 inner 함수 객체를 반환한다.

- target() 을 deco로 데커레이트 했다. 

- 데커레이트된 target() 을 호출하면 실제로는 inner() 를 실행한다.

 

 

7.2 파이썬이 데커레이터를 실행하는 시점

데커레이터의 핵심 특징은 데커레이트된 함수가 정의된 직후에 실행된다는 것이다. 이는 일반적으로 파이썬이 모듈을 로딩하는 시점, 즉 임포트 타임에 실행된다.

registry = []

def register(func):
    print("running register(%s)" % func)
    registry.append(func)
    return func

@register
def f1():
    print("running f1()")

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

def f3():
    print("running f3()")

def main():
    print("running main()")
    print("registry ->", registry)
    f1()
    f2()
    f3()

if __name__ == "__main__":
    main()
    

> running register(<function f1 at 0x100e5bb80>)
running register(<function f2 at 0x100e5bc10>)
running main()
registry -> [<function f1 at 0x100e5bb80>, <function f2 at 0x100e5bc10>]
running f1()
running f2()
running f3()

register() 는 모듈 내의 어떤 함수보다도 먼저 실행된다. 해당 스크립트를 실행하지 않고 임포트 하면 아래와 같이 출력된다.

import registration

> running register(<function f1 at 0x100e5bb80>)
running register(<function f2 at 0x100e5bc10>)

이 때의 registry 를 출력하면 아래와 같은 내용이 들어 있다.

print(registration.registry)

> [<function f1 at 0x10f150ca0>, <function f2 at 0x10f150d30>]

 

위의 예제를 통해 함수 데커레이터는 모듈이 임포트되자마자 실행되지만, 데커레이트된 함수는 명시적으로 호출될 때만 실행됨을 알 수 있다.

데커레이터가 실제 코드에 흔히 사용되는 방식과 비교하여 위의 예제는 두 가지 차이점이 있다.

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

 

7.3 데커레이터로 개선한 전략 패턴

 

데커레이터는 통해 앞의 6장에서 봤던 전략 패턴의 리팩토링에서 구현한 프로모션 할인 코드를 개선하는 데 유용하게 사용할 수 있다.

promos = [fidelity_promo, bulk_item_promo, large_order_promo]

---------------------------------------------------------------

promos = [
    globals()[name]
    for name in globals()
    if name.endswith("_promo") and name != "best_promo"
]

def best_promo(order):
    """최대로 할인받을 금액을 반환한다."""
    return max(promo(order) for promo in promos)

 

앞선 6장의 가장 큰 문제는 함수를 정의할 때, 최대 할인 방식을 결정하는 함수에 의해 사용되는 promos 리스트에 함수명을 반복해서 사용한다는 점이다. 첫 번째 promos 든, 두 번째 방식이든 마찬가지다. *_promo 이름으로 끝나는 함수의 반복이다.

이 같은 점이 문제가 되는 이유는 새로운 프로모션 전략 함수를 추가했는데 promos 리스트에 추가하는 것을 잊었다면, best_promo 함수가 이를 무시하게 되므로 발견하기 힘든 버그가 발생할 수 있다.

 

데커레이터를 통한 예시는 아래와 같다.

promos = []

def promotion(promo_func):
    promos.append(promo_func)
    return promo_func


@promotion
def fidelity(order):
    """충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""
    return order.total() * 0.05 if order.customer.fidelity >= 1000 else 0


@promotion
def bulk_item(order):
    """20개 이상의 동일 상품을 구입하면 10% 할인 적용"""
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * 0.1
    return discount


@promotion
def large_order(order):
    """10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""

    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * 0.07
    return 0


def best_promo(order):
    """최대로 할인받을 금액을 반환한다."""
    return max(promo(order) for promo in promos)

- promotion() 데커레이터는 promo_func 를 promos 리스트에 추가한 후 그대로 반환한다.

- @promotion으로 데커레이트한 함수는 모두 promos 리스트에 추가된다.

- best_promos() 는 promos 에 의존하기 때문에 변경할 필요가 없다.

 

이 방법은 기존에 비해 다음과 같은 장점이 있다.

  • 프로모션 전략 함수명이 특별한 형태로 되어 있을 필요가 없다.(함수명이 반드시 _promo 로 끝나지 않아도 된다.)
  • @promotion 데커레이터는 데커레이트된 함수의 목적을 명확히 알려주며, 임시로 어떤 프로모션을 배제할 수 있다. 방법은 단지 데커레이트만 주석처리 하면 된다.
  • 프로모션 전략 할인을 구현한 함수는 @promotion 데커레이터가 적용되는 한 어느 모듈에서든 정의할 수 있다.

 

데커레이터는 대부분 데커레이트된 함수를 변경한다. 즉, 내부 함수를 정의하고 그것을 반환하여 데커레이트된 함수를 대체한다. 내부 함수를 사용하는 코드는 제대로 작동하기 위해 거의 항상 클로저에 의존한다. 클로저를 이해하기에 앞서 파이썬에서 변수 범위의 작동 방식에 대해 알아본다.

 

 

7.4 변수 범위 규칙

def f1(a):
    print(a)
    print(b)

f1(3)

Traceback (most recent call last):
  File "chapter_07.py", line 86, in <module>
    print(f1(3))
  File "chapter_07.py", line 84, in f1
    print(b)
NameError: name 'b' is not defined

 

함수에서 내, 외부에 정의되지 않은 변수 b 를 프린트하면 당연히 에러가 발생한다. 함수를 호출하기 바로 전에 전역 변수로 b 를 할당하면 작동한다.

 

b = 6
def f2(a):
    print(a)
    print(b)
    b = 9

f2(3)

Traceback (most recent call last):
  File "chapter_07.py", line 88, in <module>
    print(f2(3))
  File "chapter_07.py", line 85, in f2
    print(b)
UnboundLocalError: local variable 'b' referenced before assignment

 

이번에는 전역 변수의 값을 함수 내부에서 다시 재할당하는데, 이 때 print(b) 이후에 재할당을 하니 에러가 발생한다.

이 부분은 좀 예상 밖이다. 왜냐하면 전역 변수 b 가 이미 할당되어 있는 상태이기 때문에 6 이 출력될 것이라고 생각했기 때문이다.

 

파이썬이 함수 본체를 컴파일할 때 b 가 함수 안에서 할당되므로 b를 지역 변수로 판단한다. 즉 함수 내부의 변수는 지역 변수로 할당한다. 만약 인터프리터가 b 를 전역 변수로 다루기 원한다면, 다음과 같이 global 키워드를 이용해서 선언해야 한다.

b = 6
def f3(a):
    global b
    print(a)
    print(b)
    b = 9

f3(3)
> 3
> 6

print(b)
> 9

 

함수의 바이트 코드를 비교해보자.

from dis import dis

dis(f1)

 83           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

 84           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              1 (b)  ----> 전역
             12 CALL_FUNCTION            1
             14 POP_TOP
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

 

가장 먼저 작성했던 f1 함수의 바이트 코드를 보면 다음과 같은 점을 알 수 있다.

- 전역명 print 를 로딩한다.

- 지역명 a 를 로딩한다.

- 전역명 b 를 로딩한다.

 

이번에는 f2 함수의 바이트 코드를 보면 아래와 같다.

dis(f2)

 88           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

 89           8 LOAD_GLOBAL              0 (print)
             10 LOAD_FAST                1 (b)   ----> 지역
             12 CALL_FUNCTION            1
             14 POP_TOP

 90          16 LOAD_CONST               1 (9)
             18 STORE_FAST               1 (b)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

가장 큰 차이점은 변수 b 를 지역 변수로 로딩한다는 점이다. print 문 뒤에 b에 할당하는 부분이 나오지만, 컴파일러는 b 를 지역 변수로 간주하고 있음을 알 수 있다.

 

 

7.5 클로저

클로저는 익명 함수와는 다르다. 보통 익명 함수를 사용하면서 함수 안에 함수를 정의하는 방식을 자주 쓰기 때문에 이 둘을 혼동하는 경우가 있다. 

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

 

예시를 통해 알아보자.

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)


avg = Averager()
print(avg(10))
> 10.0
print(avg(11))
> 10.5
print(avg(12))
> 11.0

 

이번에는 함수 make_averager() 를 구현한 예제이다.

def make_averager():
    series = []

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

    return averager


avg = make_averager()
print(avg(10))
> 10.0
print(avg(11))
> 10.5
print(avg(12))
> 11.0

 

make_averager() 는 호출되면 averager() 함수를 반환한다. averager() 함수는 호출될 때마다 받은 인수를 series 리스트에 추가하고 평균을 계산해서 반환한다.

이는 클래스로 구현한 객체와 상당히 비슷하다. Averager() 나 make_averager() 를 호출해서 콜러블 객체인 avg가 반환되고, avg() 는 series 를 갱신하고 지금까지의 평균을 계산한다. 클래스 예제에서의 avg() 는 Averager 클래스의 객체이고, 함수에서의 avg() 는 내부 함수인 average() 다.

 

Averager 클래스의 avg() 함수가 데이터를 보관하는 방법은 self.series 객체 속성에 저장되기 때문이다. 근데 함수 예제에서의 avg() 함수는 어디에서 series 를 찾는가?

 

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

averager 안에 있는 series 는 자유 변수(free variable) 다. 자유 변수는 지역 범위에 바인딩되어 있지 않은 변수를 의미한다.

 

이미지 출처:

https://velog.io/@jaebig/Python-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0


위 이미지에 있는 설명을 앞선 make_averager() 함수에 적용하면, series = [] 부터 averager() 함수까지가 클로저이다. 그리고 averager() 함수 내에 있는 series 가 자유변수에 속한다.

 

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

 

print(avg.__code__.co_varnames)
> ('new_value', 'total')
print(avg.__code__.co_freevars)
> ('series',)

 

series 에 대한 바인딩은 반환된 avg() 함수의 __closure__ 속성에 저장된다. avg.__closure__ 의 각 항목은 avg.__code__.co.freevars 의 이름에 대응된다. 이 항목은 cell 객체이며, cell_contents 속성에서 실제 값을 찾을 수 있다.

print(avg.__closure__)
> (<cell at 0x10e4d7220: list object at 0x10e5b8ac0>,)
print(avg.__closure__[0].cell_contents)
> [10, 11, 12]

 

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

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

 

 

7.6 nonlocal 선언

 

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

 

def make_averager():
    count = 0
    total = 0

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

    return averager

avg = make_averager()
print(avg(10))

Traceback (most recent call last):
  File "chapter_07.py", line 143, in <module>
    print(avg(10))
  File "chapter_07.py", line 136, in averager
    count += 1
UnboundLocalError: local variable 'count' referenced before assignment

 

위의 코드는 잘못됐다. 분명히 count 와 total 은 자유 변수라서 클로저에 저장될텐데 왜 에러가 날까?

count += 1 은 count = count + 1 을 의미한다. count 를 averager() 안에서 할당하고 있다. 따라서 count 는 지역 변수가 된다. total 도 마찬가지다.

 

앞선 series 는 변수에 할당하지 않기 때문에 이런 문제가 생기지 않았다. 리스트가 가변형임을 이용했을 뿐이다. 그러나 숫자, 문자열, 튜플 등 불변형은 읽을 수만 있고 값은 갱신할 수 없다. count = count + 1 과 같은 문장으로 변수를 다시 바인딩하면 암묵적으로 count 라는 지역 변수를 만든다. count 가 더 이상 자유 변수가 아니므로 클로저에 저장되지 않는다.

 

이 문제를 해결하기 위해 nonlocal 선언이 파이썬 3에 소개되었다. 변수를 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

avg = make_averager()
print(avg(10))
> 10.0

 

 

7.7 간단한 데커레이터 구현하기

 

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

import time

def clock(func):
    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

자유 변수 func 가 들어가야 코드가 작동한다.

이 데커레이터에 대한 사용 예시는 다음과 같다.

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

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

if __name__ == "__main__":
    print("*" * 40, "Calling snooze(.123)")
    snooze(.123)
    print("*" * 40, "Calling factorial(6)")
    print("6! = ", factorial(6))
    
**************************************** Calling snooze(.123)
[0.12460933s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000149s] factorial(1) -> 1
[0.00002717s] factorial(2) -> 2
[0.00004301s] factorial(3) -> 6
[0.00005593s] factorial(4) -> 24
[0.00006897s] factorial(5) -> 120
[0.00008497s] factorial(6) -> 720
6! =  720

 

7.7.1 작동 과정

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

위 코드는 실제로 다음 코드로 실행된다. 

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

 

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

print(factorial.__name__)
> clocked

그러므로 factorial은 실제로 clocked() 함수를 참조한다. factorial(n) 을 호출하면 clocked(n) 이 실행된다. 

 

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

 

그러나 앞서 구현한 clock() 데커레이터는 단점이 몇 가지 있다. 먼저, 키워드 인수를 지원하지 않으며, 데커레이트된 함수의 __name__과 __doc__ 속성을 가린다.

이를 해결하기 위해 functools.wrap() 데커레이터를 이용해서 func에서 clocked로 관련된 속성을 복사한다.

 

이게 무슨 소리나면, 위에서 봤듯이 factorial 의 __name__ 속성은 데커레이터 함수 이름이 나온다. 데커레이트된 함수 자체의 __name__ 과 그 함수의 docstring 을 그대로 표현하기 위해서 functools.wrap() 데커레이터를 써서 데커레이트된 함수의 속성값을 데커레이터 로 복사한다는 뜻이다.

 

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__
        doc = func.__doc__
        arg_list = []
        if args:
            arg_list.append(", ".join(repr(arg) for arg in args))
        if kwargs:
            pairs = ["%s=%r" % (k, w) for k, w in sorted(kwargs.items())]
            arg_list.append(", ".join(pairs))
        arg_str = ", ".join(arg_list)
        print('[%0.8fs] %s(%s): %s -> %r' % (elapsed, name, doc, arg_str, result))
        return result

    return clocked

@clock
def snooze(seconds):
    """ snooze - doc """
    time.sleep(seconds)

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

print(factorial.__name__)
> factorial

if __name__ == "__main__":
    print("*" * 40, "Calling snooze(.123)")
    snooze(.123)
    print("*" * 40, "Calling factorial(6)")
    print("6! = ", factorial(6))
    

**************************************** Calling snooze(.123)
[0.12353897s] snooze( snooze - doc ): 0.123 -> None
**************************************** Calling factorial(6)
[0.00000191s] factorial( factorial - doc ): 1 -> 1
[0.00003195s] factorial( factorial - doc ): 2 -> 2
[0.00004792s] factorial( factorial - doc ): 3 -> 6
[0.00006485s] factorial( factorial - doc ): 4 -> 24
[0.00008082s] factorial( factorial - doc ): 5 -> 120
[0.00010991s] factorial( factorial - doc ): 6 -> 720
6! =  720

 

수정된 코드의 결과를 보니 데커레이트된 함수의 __name__ 과 __doc__ 속성값이 잘 출력되는 것을 볼 수 있다.

 

7.8 표준 라이브러리에서 제공하는 데커레이터

파이썬에서는 메서드를 데커레이트하기 위해 property(), classmethod(), staticmethod() 등 총 3개의 내장 함수를 제공한다. 이 외에도 자주 볼 수 있는 데커레이터 중에서 functools.wraps() 가 있다. 이 외에도 lrn_cache(), singledispatch() 가 있는데 이에 대해 좀 더 알아본다.

 

7.8.1 functools.lru_cache() 를 이용한 메모이제이션

functools.lru_cache() 는 실제로 쓸모가 많은 데커레이터로서, 메모이제이션(memoization) 을 구현한다. 메모이제이션은 이전에 실행한 값비싼 함수의 결과를 저장함으로써 이전에 사용된 인수에 대해 다시 계산할 필요가 없게 해준다. 이름 앞에 붙은 LRU는 'Least Recently Used' 의 약자로서, 오랫동안 사용하지 않은 항목을 버림으로써 캐시가 무한정 커지지 않음을 의미한다.

 

@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

if __name__ == "__main__":
    print(fibonacci(6))
    
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00002098s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000882s] fibonacci(2) -> 1
[0.00002098s] fibonacci(3) -> 2
[0.00005317s] fibonacci(4) -> 3
[0.00000000s] fibonacci(1) -> 1
[0.00000095s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000906s] fibonacci(2) -> 1
[0.00001788s] fibonacci(3) -> 2
[0.00000095s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00001097s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000095s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000882s] fibonacci(2) -> 1
[0.00001836s] fibonacci(3) -> 2
[0.00003695s] fibonacci(4) -> 3
[0.00006294s] fibonacci(5) -> 5
[0.00012422s] fibonacci(6) -> 8
8

 

위의 예제는 피보나치 수열을 구현한 함수인데, fibonacci(1) 이 8번, fibonacci(2) 가 5번 호출되는 등 계산 낭비가 엄청나다. 그렇지만 lur_cache() 를 사용하기 위해 단 두줄만 추가하면 성능이 상당히 개선된다.

 

import functools

@functools.lru_cache()
@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

if __name__ == "__main__":
    print(fibonacci(6))
    

[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00002098s] fibonacci(2) -> 1
[0.00000095s] fibonacci(3) -> 2
[0.00003195s] fibonacci(4) -> 3
[0.00000095s] fibonacci(5) -> 5
[0.00004411s] fibonacci(6) -> 8
8

lru_cache() 데커레이터를 일반 함수처럼 호출해야 한다는 점을 주의해야 한다. 설정 매개변수를 추가로 받기 때문이다.

실행 시간이 절반 이상으로 줄었고, 각 n 에 대해서 함수가 한 번만 호출된다.

 

functools.lru_cache(maxsize=18, typed=False)

lru_cache() 는 두 개의 선택적 인수를 이용해서 설정할 수 있다.

maxsize 인수는 얼마나 많은 호출을 저장할지 결정한다. 캐시가 가득차면 가장 오래된 결과를 버리고 공간을 확보한다. 최적을 내기 위해 maxsize 는 2의 제곱이 되어야 한다. typed 인수는 True 로 설정되는 경우 인수의 자료형이 다르면 결과를 따로 저장한다. 예를 들어 일반적으로 1과 1.0은 동일하다고 가정하지만 실수형 인수와 정수형 인수를 구분해야 하는 경우가 있을 것이다.

또한 lru_cache() 가 결과를 저장하기 위해 딕셔너리를 사용하고, 호출할 때 사용한 위치 인수와 키워드 인수를 키로 사용하므로, 데커레이트된 함수가 받는 인수는 모두 해시 가능 해야 한다.

 

 

7.8.2 단일 디스패치를 이용한 범용 함수

이번에는 functools.singledispatch() 에 대해서 알아본다.

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

 

import html

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

위와 같은 함수를 정의할 수 있다.

여기에 다음의 조건을 만족하는 방향으로 코드를 확장하고자 한다.

  • str : 개행 문자를 '<br>\n' 으로 대체하고 <pre> 대신 <p> 태그를 사용한다.
  • int : 숫자를 10진수와 16진수로 보여준다.
  • list : 각 항목을 자료형에 따라 포맷한 HTML 리스트를 출력한다.

예상되는 결과물은 다음과 같다.

htmlize({1, 2, 3})
> <pre>{1, 2, 3}</pre>

htmlize("Heimlich & Co.\n- a game")
> <p>Heimlich &amp; Co.<br>\n- a game</p>

htmlize(42)
> <pre> 42 (0x2a)</pre>

print(htmlize(['alpha', 66, {3, 2, 1}]))
> <ul>
<li><p>alpha</p></li>
<li><pre>66 (0x42)</pre></li>
<li><pre>{1, 2, 3}</pre></li>
</ul>

 

파이썬에서는 메서드나 함수의 오버로딩을 지원하지 않는다.

오버로딩은 동일한 이름의 메서드나 함수가 매개 변수에 따라 다르게 동작하는 것을 말한다.

따라서 htmlize() 를 서로 다른 자료형에 따라 동작하게 만들 수 없다. 이 때 일반적으로 htmlize() 를 인자를 받고 결과를 리턴해주는 중간 함수로 만들고, 내부적인 조건문을 통해 htmlize_str(), htmlize_int() 등의 특화적 함수를 호출한다.

 

그러나 이러한 방식은 조건이 늘어날 때마다 점차 htmlize() 코드 자체가 커지며, 특수 함수와의 결합이 너무 강해진다.

이 때 사용하는 것이 functools.singledispatch() 데커레이터이며, 이를 사용하면 데커레이트된 함수는 범용 함수가 된다.

수정된 예시를 보자.

from functools import singledispatchmethod
from collections import abc
import numbers
import html


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

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

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

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

- @singledispatch()는 객체형을 다룰 기반 함수를 표시한다.

- 각각의 특화된 함수는 @<기반함수>.register(<객체형>) 으로 데커레이트 된다.

- 특화된 함수의 이름은 필요 없으므로 언더바로 함수명을 지정한다.

- 특별하게 처리할 자료형을 추가할 때마다 새로운 함수를 등록한다. numbers.Integral 은 int 의 가상 슈퍼클래스다.

- 동일한 함수로 여러 자료형을 지원하기 위해 register 데커레이터를 여러 개 쌓아올릴 수 있다.

 

가능하면 int, list 같은 구상 클래스보다는 numbers.Integral 이나 abc.MutableSequence 와 같은 추상 베이스 클래스를 처리하도록 특화된 함수를 등록하는 것이 좋다. 보다 폭넓게 자료형을 지원할 수 있기 때문이다.

 

@singledistpatch 는 메서드 오버로딩을 파이썬에 적용하기 위해 설계된 것이 아니다. 단일 유닛 코드에 너무 많은 책임을 부여하지 않도록 모듈화된 확장을 지원하기 위함이다.

 

 

7.9 누적된 데커레이터

하나의 함수 f() 에 두 데커레이터 @d1 과 @d2 를 차례대로 적용하면, 결과는 f = d1(d2(f))와 같다.

@d1
@d2
def f():
    print("f")
-----------------

def f():
    print("f")

f = d1(d2(f))

 

7.10 매개변수화된 데커레이터

 

소스 코드에서 데커레이터를 파싱할 때 파이썬은 데커레이트된 함수를 가져와서 데커레이터 함수의 첫 번째 인수로 넘겨준다.

 

7.10.1 매개변수화된 등록 데커레이터

앞서 봤던 register() 예시를 다시 보자.

registry = []

def register(func):
    print("running register(%s)" % func)
    registry.append(func)
    return func
    
@register
def f1():
    print("running f1()")
    

def main():
    print("running main()")
    print("registry ->", registry)
    f1()

 

여기서 register() 가 등록하는 함수를 활성화 혹은 비활성화하기 쉽게 만들기 위해, 선택적인 인수 active를 받도록 만들어보자.

active 가 False 이면 데커레이트된 함수를 해제한다.

registry = set()

def register(active=True):
    def decorate(func):
        print("running register(active=%s) -> decorate(%s)" % (active, func))
        if active:
            registry.add(func)
        else:
            registry.discard(func)
        return func
    return decorate

@register(active=False)
def f1():
    print("running f1()")

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

def f3():
    print("running f3()")
    
print(register().__code__.co_varnames)
> ('func',)

print(register().__code__.co_freevars)
> ('active',)

 

active 인수는 decorater 클로저 안에서 자유 변수이므로 읽어올 수 있다. 

 

데커레이터를 사용할 때 일반적으로 커머셜 앳(@) 을 사용하는데, 그 대신 register() 를 일반 함수로 사용하려면 다음과 같이 데커레이트되는 함수를 뒤에 붙여서 register()(f) 로, 매개변수를 전달하기 위해서는 register(acitve=Flase)(f) 같이 사용하면 된다.

 

7.10.2 매개변수화된 clock 데커레이터

앞서 사용한 예제인 clock() 데커레이터에 기능을 추가해본다.

사용자가 포맷 문자열을 전달해서 데커레이트된 함수가 출력할 문자열을 설정한다.

 

import time

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

def clock(fmt=DEFAULT_FMT):
    def decorate(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(fmt.format(**locals()))
            return _result
        return clocked
    return decorate

if __name__ == "__main__":
    @clock()
    def snooze(seconds):
        time.sleep(seconds)

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


[0.12324174s] snooze(0.123) -> None
[0.12472350s] snooze(0.123) -> None
[0.12662035s] snooze(0.123) -> None

 

- clock() 은 매개변수화된 데커레이터 팩토리다.

- decorate() 가 실제 데커레이터다.

- clocked() 는 데커레이트된 함수를 래핑한다.

- 데커레이트된 함수 결과를 _result 에 저장한다.

- _args 가 실제 clock()의 인수를 담고 있으며, args 는 출력하기 위한 문자열이다.

- result는 출력하기 위해 _result를 문자열로 표현한 것이다.

- **locals() 를 사용하면 fmt가 clocked()의 지역 변수를 모두 참조할 수 있게 해준다.

 

예제에서는 clock() 에 인수를 전달하지 않았지만, 인수를 전달하면 해당 포맷에 맞춰 출력 형태가 달라진다.

if __name__ == "__main__":
    @clock("{name}: {elapsed}s")
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)
        
snooze: 0.125001273s
snooze: 0.12805409899999998s
snooze: 0.12805717300000002s

 

 

정리

데커레이터는 함수를 인수로 받는 콜러블이며, 데커레이트된 함수를 처리하고 이를 반환하거나 다른 함수 혹은 콜러블로 대체한다. 

데커레이터는 임포트 타임에 실행된다.

어떤 변수가 함수 밖에서 정의가 되어 있더라도 함수 내부에서 재할당을 하는 경우에는 파이썬 인터프리터가 지역 변수로 인지한다.

함수 내부에서 재할당이 필요한 경우에는 global 키워드를 사용하여 재할당할 수 있다.

클로저는 비전역 변수에도 접근 가능한 내부 함수를 말하며, 클로저에서 사용하는 비전역 변수를 자유 변수라고 한다.

클로저에서도 마찬가지로 함수를 재할당하는 경우 지역 변수로 인지하여 자유 변수로 바인딩되지 않는다. 이때 nonlocal 키워드를 사용하면 새로운 값이 할당되더라도 자유 변수를 유지한다.

파이썬의 functools 모듈에서 여러 데커레이터를 지원하는데 그 중에서 functools.wraps() / functool.lru_cache() / functools.singledispatch() 가 유용하다.

- functools.wraps() : 데커레이터가 데커레이트된 함수의 name, doc 속성을 그대로 복사하여 가질 수 있다.

- functools.lru_cache() : 가장 오랫동안 사용하지 않은 메모리부터 지워나가면서 현재 실행되는 함수의 결과값을 저장한다. 저장이 되는 함수의 결과는 해시 가능한 값이어야 한다. (메모이제이션)

- functools.singledispatch() : 동일한 함수가 인수로 받는 자료형에 따라서 다른 결과를 반환할 수 있도록 오버로딩이 가능하게 한다.

 

데커레이터가 변수를 받는 경우에는 최소 두 단계의 내포된 함수를 가진다.

'python' 카테고리의 다른 글

Architecture Patterns with Python(6장)  (0) 2021.11.12
Architecture Patterns with Python(5장)  (0) 2021.11.06
Architecture Patterns with Python(4장)  (0) 2021.10.31
Fluent Python (챕터 6)  (0) 2021.10.27
Architecture Patterns with Python(3장)  (0) 2021.10.23