[fluent python]함수 데코레이터와 클로저

데코레이터

decorator라는 단어처럼 기존의 코드에 여러가지 기능을 추가하는 "장식"역할을 해주는 파이썬 구문

데코레이터는 다른 함수를 인수로 받는 콜러블(데코레이트된 함수) 함수를 반환하거나 함수를 다른 함수나 콜러블 객체로 대체합니다.

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 0x7feb7820b160>

데커레이터는 다른 함수를 인수로 전달해서 호출하는 일반적인 콜러블과 동일하며 편리 구문(syntactic sugar)일 뿐입니다.

  • 데코레이터는 데코레이트된 함수를 다른 함수로 대체하는 능력이 있다
  • 데코레이터는 모듈이 임포트될때 실행된다
registry = []                               # 해당 배열은 @register로 데코레이트된 함수들에 대한 참조를 담는다

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

@register
def f1():                                   # 데코레이트된 함수
    print('running f1()')

@register                                   # 데코레이트된 함수
def f2():
    print('running f2()')

def f3():                                   # 데코레이트 되지 않은 함수
    print('running f3()')
if __name__ == '__main__':
    print('running main()')
    print('registry ->', registry)
    f1()
    f2()
    f3()

---출력 결과---
running register <function f1 at 0x7fb6b820b0d0>
running register <function f2 at 0x7fb6b820b160>
running main()
registry -> [<function f1 at 0x7fb6b820b0d0>, <function f2 at 0x7fb6b820b160>]
running f1()
running f2()
running f3()

출력 결과에서 확인할 수 있듯이 register()는 모듈 내 다른 어떤 함수보다 먼저 실행됩니다. register()가 호출될때 데커레이트된 함수(f1(), f2())를 인수로 받습니다.

해당 모듈 파일을 스크립트로 실행하지 않고 임포트할때 register함수의 프린트문(’running register…’)가 출력되는 것을 볼 수 있습니다. 반면 데코레이트’된’ 함수의 출력문(’running f()’)는 출력되지 않습니다.

import registration
  • 데코레이터는 모듈이 임포트 되자마자 실행 (import time)
  • 데코레이트된 함수는 명시적으로 호출될 떄만 실행됩니다. (runtime)

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

이전 챕터에서 함수도 인스턴스이므로 함수의 인자, 배열의 원자로 들어갈 수 있는 예시를 보여줬습니다. 이전 장에서는 또 다른 할인 방식에 대한 함수가 추가되었을때 이를 best_promo함수에 하드 코딩하거나 promos 배열에 추가하는 방법도 소개했습니다. 하지만 리스트에 함수명을 나열하거나 필요할때마다 인자로 전달하는 방식은 새로운 할인 방법에 대한 함수에 대한 구현부와 이를 인용하는 위치가 점점 멀어져 함수 추가를 깜빡한다면 버그가 발생할 수 있습니다. 이번 챕터에서는 데코레이터를 사용하여 함수를 구현하는 단계에서 이를 챙길 수 있는 예시를 보여줍니다.

promos = []

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

@promotion
def fidelity(order): # @promotion으로 데코레이트된 함수는 promos리스트에 추가됩니다.
    """충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""
    return order.total() * .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() * .1
    return discount

@promotion
def large_order(order):
    """10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""
    discount_items = {item.product for item in order.cart}
    if len(discount_items) >= 10:
        return order.total() * .07
    return 0

def best_promo(order):
    """최대로 할인받을 금액을 반환한다"""
    return max(promo(order) for promo in promos)
  • 프로모션 전략 함수명이 특별한 형태로 되어 있을 필요가 없습니다. @promotion 데코레이터가 붙은 함수는 promotion관련 함수임을 이전처럼 _promo 라는 postfix를 붙이지 않아도 인식할 수 있습니다. 이처럼 데코레이터는 데코레이트되는 함수의 목적을 명확히 알려줍니다.
  • 프로모션 할인 전략을 구현한 함수는 해당 데코레이터가 적용되는 한 어느 모듈에서든 정의할 수 있습니다.

대부분 데코레이터는 내부 함수를 정의하고 이를 반환하여 데코레이트된 함수를 대체합니다. 내부 함수를 사용하는 코드는 제대로 작동하기 위해 거의 항상 클로저에 의존하기 때문에 먼저 파이썬에서 변수 범위의 작동 방식에 대해 자세히 살펴봐야합니다.

변수 범위 규칙

b = 6

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

f1(3)

--출력 결과--
3
Traceback (most recent call last):
  File "/Users/bmoblanc/projs/toyproj/life/study.py", line 8, in <module>
    f1(3)
  File "/Users/bmoblanc/projs/toyproj/life/study.py", line 5, in f1
    print(b)
UnboundLocalError: local variable 'b' referenced before assignment

보통은 전역변수 b의 값인 6도 출력된다고 예상할 수 있습니다. 하지만 파이썬은 함수 본체를 컴파일할 때 b가 함수 안에서 할당되므로 b를 지역 변수로 판단합니다. 따라서 b출력에서 지역 환경에서 변수 b를 가져오려 하고 해당 값이 바인딩되어 있지 않다는 것을 알게됩니다. 이런 현상은 버그가 아니고 지역 변수를 선언하지 않은 경우 자동으로 전역 변수를 사용하여 처리하는 것을 방지하는 설계입니다.

인터프리터가 b를 전역 변수로 다루기 원한다면 global 키워드를 이용해서 선언해야합니다.

b = 6

def f1(a):
        global b
    print(a)
    print(b)
    b = 9

f1(3)
--출력 결과--
3
6

클로저

클로저는 내포된 함수 안에서만 의미있습니다. 함수 본체에서 정의하지 않고 참조하는 nonglobal 변수를 포함한 확장 범위를 가진 함수입니다. 한번에 입력되지 않고 연속해서 받은 값의 평균을 반환하는 로직은 아래와 같이 고위함수로도 구현됩니다.

class Averager():

    def __init__(self):
        self.series = []

    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total / len(series)
  • self.series 객체 속성에 new_value로 전달받는 인자가 추가됩니다.
def make_averager():
    series = []

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

    return averager
  • series는 이 함수의 지역변수입니다.
  • 하지만 avg(10)을 호출할때 make_average()함수는 이미 반환했으므로 지역 범위도 이미 사라진 후가 됩니다.
  • averager 내부의 sereis는 지역 범위에 바인딩되어있지 않은 변수입니다. 이를 자유 변수라고 합니다.

사용할때는 Average()나 make_average()를 호출하여 콜러블 객체를 받습니다. 첫번째 출력의 avg는 Average클래스의 객체를 사용한 예이고 두번째 출력은 make_average의 내부함수인 average()입니다.

if __name__ == '__main__':
    avg = Averager()
    print(avg(10))
    print(avg(11))
    print(avg(12))
    print()

    avg = make_averager()
    print(avg(10))
    print(avg(11))
    print(avg(12))
    print(avg.__code__.co_varnames)
    print(avg.__code__.co_freevars)
    print(avg.__closure__)
    print(avg.__closure__[0].cell_contents)

--출력예시--
10.0
10.5
11.0

10.0
10.5
11.0
('new_value', 'total')
('series',)
(<cell at 0x7ff3082edca0: list object at 0x7ff3082d2280>,)
[10, 11, 12]

series에 대한 바인딩은 colsuer 속성에 저장되어 이를 출력해서 확인할 수 있습니다.

즉, 클로저는 함수를 정의할 때 존재하던 자유 변수에 대한 바인딩을 유지하는 함수입니다. 따라서 함수를 정의하는 범위가 사라진 후에 함수를 호출해도 자유 변수에 접근 가능합니다.

함수가 호출될때마다 append, sum을 다시 계산하지 않고 합계와 항목 수를 저장한 후 이 두 숫자를 이용해서 평균을 구하도록 수정할 수 있습니다.

def make_averager():
    count = 0
    total = 0

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

    return averager

nonlocal을 사용하여 함수 안 변수에 새로운 값을 할당하더라도 그 변수는 자유 변수임을 나타내어 동일한 계산을 더 간단하게 구현할 수 있습니다.

nonlocal을 사용하지 않을때는 UnboundLocalError: local variable 'count' referenced before assignment 에러가 발생하는데 이는 count, total에 대한 += 1 연산 부분에서 count, total을 지역변수로 만듭니다. 이전 예시에서는 상위 함수의 가변형 변수인 list를 사용했기 때문에 이런 현상이 없었습니다.

따라서 인자로 받은 새로운 값을 할당받고 클로저에 저장된 바인딩이 변경할 수 있도록 nonlocal로 자유변수를 명시할 필요가 있습니다.

정리

이번 글에서는 [전문가를 위한 파이썬]를 참고해서 데코레이터에 대한 소개와 예시코드, 데코레이터 개념을 위해 알아야하는 클로저, 이를 알기 위해 파이썬 변수 인식 동작을 살펴볼 수 있었습니다. 책 서술 순서대로 글을 작성하다보니 선행해야할 개념을 뒤에 서술하게 된거 같습니다. 추후 복습하면서 다음 순서로 더 다듬으면 좋을거 같네요:)

  1. 변수가 지역 변수인지 파이썬이 판단하는 방식
  2. 클로저의 존재 이유와 작동 방식
  3. 파이썬이 데코레이터 구문을 평가하는 방식

이전에 함수나 클래스 내부 함수에서 변수를 다루면서 마땅히 쓸 수 있다고 생각한 변수들이 referenced before assignment 발생할때 의문이었고 global 변수로 한번 더 명시하는 식으로 넘어갔는데 이후에는 자유변수를 사용하거나 상황과 변수 목적에 맞게 고민해볼 수 있을거 같습니다.

'CS' 카테고리의 다른 글

[python] 일급 함수 디자인 패턴  (0) 2024.03.31
utf8  (0) 2023.12.10