전문가를 위한 파이썬 7. 함수 데코레이터와 클로저
- 프로그래밍 언어/파이썬
- 2023. 10. 6.
들어가기 전
이 글은 전문가를 위한 파이썬 7장을 공부하며 작성한 글입니다.
7.1 데코레이터 기본 지식
# 데코레이터 함수 선언
def deco(func):
print(f'function Name = {func}')
def inner():
print('running inner()')
return inner
# 데코레이터 기반
@deco
def target():
print('running target()')
# 데코레이터와 동일한 효과.
target = deco(target)
파이썬에서 데코레이터는 다음과 같이 선언할 수 있다. 요약하면 다음과 같다.
- target 함수에 @deco를 붙이는 것은 target = deco(target)을 하는 것과 동일하다.
py 파일을 Import 하는 시점에 @deco def target()은 호출된다. 즉, import 하는 시점에 데코레이터 함수가 호출되면서 target() 함수는 새로운 함수로 바꿔치기 된다. 그리고 사용자들은 바꿔치기된 target 함수를 호출하는 형태로 동작한다.
7.2 파이썬이 데코레이터를 실행하는 시점
# registration.py
registry = []
def deco(func):
print(f'register {func = }')
registry.append(func)
return func
@deco
def f1():
print('f1()')
def f2():
print('f2()')
@deco
def f3():
print('f3()')
데코레이터는 Import 시점에 호출된다. 런타임은 __main__ 영역을 호출할 때 부터 실행된다.
registration.py가 이렇게 작성되어 있을 때, 모듈을 Import 하게 되면 Import 시점에 아래 코드가 실행된다. @deco라는 데코레이터가 붙으면 이런 코드가 되기 때문이다.
f1 = deco(f1)
f3 = deco(f3)
7.3 데코레이터로 개선한 전략 패턴
여기서는 위에서 배운 데코레이터의 성질을 이용해 코드를 개선하는 방법을 살펴본다.
# promotion.py
promos = []
def promotion(promo_func):
promos.append(promo_func)
return promo_func
def fidelity(order):
"""충성도 포인트가 1000점 이상인 고객에게 전체 5% 할인 적용"""
return order.total() * .05 if order.customer.fidelity >= 1000 else 0
def bulk_item(order):
"""20개 이상의 동일 상품을 구입하면 10% 할인 적용"""
discount = 0
for item in order.cart:
if item.qunaity >= 20:
discount += item.total() * .1
return discount
def large_order(order):
"""10종류 이상의 상품을 구입하면 전체 7% 할인 적용"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) > 10:
return order.total * .07
return 0
def best_promo(order):
"""최대로 할인받을 금액을 반환한다.
"""
return max(promo(order) for promo in promos)
위의 코드는 최대 할인받을 금액을 구하는 코드다. 이 때 데코레이터를 사용하지 않는다면 다음과 같은 단점이 있다.
- 프로모션과 관련된 코드라는 것을 알리기 위해 Suffix, Prefix 등을 이용해야 한다. 예를 들면 large_order_promo()
- 프로모션 함수를 가지고 있는 프로모션 리스트에 직접 함수를 추가해주어야 한다.
함수의 이름에 Suffix로 직접 분류한다는 것은 꽤나 좋지 않은 방법이고 (오타, 컴파일 등), 또한 새로운 함수를 구현한다하더라도 promos 리스트에 추가하는 것을 깜빡하면 제대로 동작하지 않는 것을 발견하기 쉽지 않다. 이 때는 데코레이터를 이용하면 손쉽게 개선할 수 있다.
- deco() 함수 내에서 promos.append(func)를 통해서 보다 안전한 코드를 작성할 수 있다.
- promotion 관련 코드 이름을 보다 자유롭게 붙여도 괜찮다.
7.4 변수 범위 규칙
대부분의 데코레이터는 내부 함수를 정의하고, 전달된 함수를 내부 함수로 꾸민 후에 반환하는 형태로 사용된다. 내부 함수(함수 내에 선언된 함수)는 대부분 클로저에 의존한다. 클로저를 이해하기 위해 파이썬에서 변수 범위의 작동 방식에 대해서 먼저 살펴본다.
- 먼저 이 코드에서 print(b)에서 b는 컴파일 에러가 발생한다. 이유는 f1() 함수 내에 b가 선언되어 있지 않기 때문이다.
- f1() 함수를 선언하기 전에 글로벌 변수로 b를 선언하면 위의 컴파일 에러가 해결된다.
- f1() 함수 선언 전에 글로벌 변수 b를 선언하고, 함수 내에 b를 선언하는 경우 컴파일 에러가 발생한다.
왜 이런 에러가 발생하는 것일까? 파이썬은 함수 본체 안에서 할당된 변수는 기본적으로 지역변수로 판단한다. 글로벌 변수 b가 선언되어 있지만, f1() 기준으로 print(b)로 되어있어 로컬 변수로 판단한다. 그런데, print(b)를 호출하기 전에 b가 함수 내에 선언되어 있지 않기 때문에 UnboundLocalError: local variable 'b' referenced before assignment 이런 에러가 발생하게 된다.
만약 함수 안에 b를 할당하는 문장이 있지만, 전역변수를 사용하고 싶다면 함수 내에 global 키워드를 사용하면 된다. 위 코드의 실행 결과는 다음과 같다.
1
9
변수 범위 규칙을 살펴보면 다음과 같다.
- 파이썬은 함수 안에서 할당된 변수는 로컬 변수로 판단함.
- 함수 안에서 할당되지 않은 변수는 global 변수로 판단하고, 하나씩 Depth를 올려서 변수를 찾아간다.
b = 9
def outer():
b = 10
def f1(a):
print(a)
print(b)
f1(1)
outer()
>>>
1
10
예를 들어 위의 코드 실행 결과 1, 10을 호출한다. 그런데 outer() 함수 내의 b = 10을 제거하면 실행 결과는 1,9가 된다.
7.5 클로저
정확하지 않을 수 있지만 나는 클로저를 다음과 같이 이해했다.
- 내부 함수에서 사용된 자유 변수의 이름은 함수 객체의 __freevars__에 저장됨.
- 함수 객체에는 __closure__ 라는 속성이 존재함. 이 때, 자유변수에 할당된 값이 저장되는 공간이 __closure__임.
- __closure__ 속성에는 각 자유변수에 대한 cell 객체들이 저장되고, cell 객체들의 cell_contests 속성에 자유변수의 값이 저장됨.
- cell 객체들을 클로저라고 볼 수 있음. 클로저는 함수를 정의할 때 존재하던 자유 변수에 대한 바인딩을 유지하는 함수라고 볼 수 있음.
바꿔 이야기하면 함수 객체가 내부에서 선언된 자유변수를 저장 및 사용하기 위해 따로 만들어진 공간이 클로저라고 이해할 수 있다.
def make_average():
series = [] # 자유변수
def average(new_value):
series.append(new_value)
return sum(series) / len(series)
return average
Avg = make_average()
print(Avg(1))
위 코드에서 내부 함수 average()는 series라는 변수를 사용한다. 그런데 이 변수는 average() 메서드 내부에서 선언된 변수가 아니다. 즉, 로컬 함수에 바인딩 되지 않은 변수이며, 이것을 '자유변수'라고 한다.
Avg = make_average()
print(Avg(1))
print(f'{Avg.__code__.co_freevars = }')
print(f'{Avg.__closure__ = }')
print(f'{Avg.__closure__[0].cell_contents = }')
>>>
Avg.__code__.co_freevars = ('series',)
Avg.__closure__ = (<cell at 0x0000026013578FD0: list object at 0x00000260132841C0>,)
Avg.__closure__[0].cell_contents = [1]
- 함수 객체에서 자유변수의 이름은 __code__ 영역의 freevars에 저장되고, 각 자유변수의 값은 함수 객체의 __closure__에 저장된다.
- __closure__는 Cell 객체들을 담은 녀석이며, Cell 객체의 cell_contests 속성에 실제값이 저장된다.
7.6 nonlocal 선언
def make_average():
count = 0
total = 0
def average(new_value):
nonlocal count, total
count += 1
total += new_value
return total / count
return average
위 코드에서는 nonlocal을 반드시 사용해야 한다. 그 이유는 다음과 같다.
count += 1은 count = count + 1
이런 코드가 된다. 즉, count는 average() 함수 내에서 선언된 지역변수가 된다. 그렇지만 자유변수로 사용해야 average() 함수 호출이 끝나더라도 count, total이라는 변수 값이 유지되기 때문에 nonlocal 키워드를 이용해서 count, total이 자유변수라는 것을 명시해줘야한다.
7.7 간단한 데코레이터 구현하기
여기서는 전형적으로 사용하는 데코레이터를 하나 구현해본다. 그런데 여기서 구현하는 데코레이터에는 한 가지 단점이 있다. 데코레이터에 매개변수를 받을 수 없다는 단점인데, 이것은 이후에 또 알아본다. 요약하면 다음과 같다.
- 현재 수준의 데코레이터는 매개변수를 지원하지 않는다는 단점이 있음.
- @functools.wrap(func)를 내부 함수에 추가하지 않으면, func = deco(func)가 호출되었을 때 func.__name__, func.__doc__ 속성이 deco() 함수에 대한 내용으로 모두 바뀐다.
#clock_decorator.py
def clock(func):
def clocked(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
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() 메서드에 func이 넘겨지면, 내부함수 clocked()로 감싼 후 clocked()를 반환하는 형태로 구현된다.
func = clock(func)
만약 특정 함수에 @clock 데코레이터를 붙인다면 다음과 같이 사용되는 것을 의미한다.
import time
from clock_decorator import clock
@clock
def factorial(n):
return 1 if n < 2 else n * factorial(n-1)
# factorial = clock(factorial) 이렇게 사용된거랑 동일함.
if __name__ == '__main__':
print('*' * 40, 'Calling factorial(6)')
print('6! = ', factorial(6))
print(factorial.__name__)
factorial() 함수는 데코레이터가 붙어있기 때문에 함수가 데코레이팅 된 함수가 반환될 것이다. 이 때, __name__을 출력하면 함수의 이름이 나오는데 print(factorial.__name__)의 결과는 어떨까?
print(factorial.__name__)
>>>
clocked
출력 결과는 factorial이 아닌 clocked가 나온다. 그 이유는 아래에서 볼 수 있는데, clock 데코레이터 함수에 전달된 factorial 함수를 한번 감싼 clocked 함수가 반환되어서 사용되고 있기 때문이다.
#clock_decorator.py
def clock(func):
def clocked(*args, **kwargs):
...
return clocked
데코레이터를 사용하더라도 기존 함수의 __name__, __doc__을 유지하는 것이 좋은데, 이 기능을 functools 모듈에서 제공해준다.
#clock_decorator.py
def clock(func):
@functools.wrap(func) # 이 부분 추가
def clocked(*args, **kwargs):
...
return clocked
---
print(factorial.__name__)
>>>
factorial
@functools.wrap(func) 데코레이터를 내부 함수에 달아둔 후에 다시 한번 __name__을 출력하면 'clocekd'가 아닌 'factorial'이 나오는 것을 확인할 수 있다.
7.8 표준 라이브러리에서 제공하는 데코레이터
functools에서는 @lru_cache, @singledispatch 같은 데코레이터를 제공하는데 유용하게 쓸 수 있다. 여기서는 @singledispatch를 살펴보고자 한다. @singledispatch는 다음과 같이 동작한다.
- 함수의 첫번째 인자에 따라 서로 다른 메서드가 호출될 수 있도록 함.
- 함수에는 복수 개의 인자가 오지만, 어떤 메서드가 호출될지는 오직 첫번째 인자만이 결정함.
결론적으로 @singledispatch 어노테이션을 붙인 메서드는 첫번째 인자에 대한 제네릭 메서드처럼 만들어진다.
from functools import singledispatch
@singledispatch
def add(arg):
global sums
# 어떤 인자 타입도 해당되지 않는 경우, 이 함수가 호출된다.
sums += arg
@add.register
def _(arg: int):
global sums
sums += arg
@add.register
def _(arg: float):
global sums
sums += arg
@add.register
def _(arg: str):
global sums
pass
if __name__ == "__main__":
add(3)
add("abc")
위 코드에서 add() 라는 메서드가 있고, 이곳에 @singledispatch가 붙어있다. 따라서 add 함수는 제네릭 함수가 된다.
add = singledispatch(add)
그리고 이렇게 singledispatch 데코레이터에 의해서 생성된 add 함수는 registry라는 내부 속성을 가진 새로운 함수 객체가 된다. 이 registry에 첫번째 인자 타입에 따라서 서로 다르게 동작하는 함수가 등록된다. 함수를 등록하려면 register()를 호출하거나 @함수이름.register를 호출해서 추가하면 된다.
7.9 누적된 데코레이터
def d1(func):
print('d1')
return func
def d2(func):
print('d2')
return func
@d1
@d2
def f():
print('f()')
하나의 함수에는 여러 개의 데코레이터를 붙일 수 있다. 예를 들어 @d1, @d2 순서로 데코레이터를 붙이면 다음과 같이 데코레이팅 된 함수가 생성된다.
f = d1(d2(f))
7.10 매개변수화된 데코레이터
가장 첫번째 데코레이터는 매개변수를 받을 수 없었다. 매개변수를 받을 수 없는 데코레이터는 한계점이 명확하다. 따라서 매개변수를 가지는 데코레이터를 생성하는 방법을 공부하고자 한다.
데코레이터 팩터리 : 데코레이터 함수를 반환해주는 데코레이터
매개변수화된 데코레이터 팩토리라는 것을 이용해야한다. 위에서 거창하게 이야기 했지만, 데코레이터 팩토리는 결국은 일종의 데코레이터다.
def decorator_factory(active=True):
def decorator(func):
def inner(*args, **kwargs):
if active:
registry.append(func)
return inner
return decorator
@decorator_factory(active=False)
def f1():
print('f1')
위의 코드는 데코레이터 팩토리를 이용해서 매개변수를 사용하는 방법을 보여준다.
- decorator_factory() 메서드는 decorator 함수를 반환한다. 데코레이터 팩토리는 이처럼 데코레이터를 반환해준다.
dec = decorator_factory(active=False)
f = dec(f1)
위의 코드는 다음과 같이 사용될 것이다.
- @decoratr_factory(active=False)는 데코레이터 함수 객체를 반환할 것이다.
- @함수객체로(여기서는 @decorator) 되어있기 때문에 f1() 함수는 decorator() 함수에게 전달될 것이다.
따라서 결과적으로 decorator(f1)이 호출되어 마무리된다. 그렇다면 매개변수는 어떻게 계속 전달될 수 있는 것일까? 그것은 클로저와 관련이 있다.
print(dec.__code__.co_freevars)
print(f.__code__.co_freevars)
>>>
('active',)
('active', 'func')
여기서 각 단계의 클로저를 호출해보면 각 함수객체마다 자유변수를 가지고 있으며, 계속 아래로 전달되는 것을 볼 수 있다. 함수 객체의 클로저를 통해서 데코레이터 내부 함수까지 매개변수가 잘 전달된다.
조심해야 할 부분
def decorator_factory(active=True):
def decorator(func):
def inner(*args, **kwargs):
if active:
registry.append(func)
return inner
return decorator
데코레이터 팩토리를 이용해서 데코레이터 함수를 반환하고, 데코레이터 함수를 이용해 데코레이팅을 원할 때는 반드시 어노테이션을 다음과 같이 사용해줘야 한다.
@decorator_factory()
def f3():
...
@decorator_factory(active=False)
def f3():
...
@decorator_factory(active=True)
def f3():
...
이렇게 하면 @decorator_factory()의 호출 결과로 decorator 함수가 반환되고, 따라서 @decorator_factory()가 @decorator로 치환된다. 이후 @decorator에 정상적으로 func이 전달되어서 기대한 것처럼 동작한다.
## 잘못된 호출
@decorator_factory
def f3():
...
그러나 이렇게 호출할 경우 @decorator_factory 그 자체가 데코레이터처럼 사용된다. 즉, 아래처럼 동작할 것이다.
- decorator_factory의 active라는 매개변수에 f3() 함수 객체가 전달된다.
- active=False에서 active=function f3가 된다.
- decorator 함수가 반환된다.
- 결과적으로 f3 = decorator가 된다.
여기서 기존의 f3 함수객체는 active에 할당되어서 온데간데 없어졌으며, f3에는 decorator 함수만 남게 되었다. 오히려 f4 = f3(f4)로 다시 한번 데코레이터를 해야 사용할 수 있는데, 애초의 목적과는 달라진다.
'프로그래밍 언어 > 파이썬' 카테고리의 다른 글
단단한 파이썬 4. 타입의 제어 (0) | 2023.10.08 |
---|---|
단단한 파이썬 3. 타입 어노테이션 (1) | 2023.10.08 |
단단한 파이썬 8장 : Enum 사용하기 (0) | 2023.09.14 |
Python : asyncio의 시작과 종료 (0) | 2023.02.19 |
Python : 비동기 이터레이터, 비동기 제네레이터 (0) | 2023.02.18 |