[ 글의 목적: python의 closure와 decorator의 정확한 정의와 활용법을 파악하고 제대로 사용하기 위해 ]
흔히 아주 쉽게 "사용법" 만 익히고,
@함수
로 무지성 사용만 하던 데코레이터와 그 근간을 이루는 클로저에 대해 조금 더 깊은 개념부터 정확하게 살펴보자. 그러기 위해 일급 객체와 내부 함수 그리고 변수의 scope에 대해 깊게 알아보자. 그리고 더 잘 데코레이터를 다뤄보자.
클로저의 진짜 간단하게 생각하면 "함수안에 함수를 구현하고, 런타임에 정의되는 그 내부를 반환하는 함수다." 하지만 이건 클로저의 결과론적인 설명이다. "왜" 클로저가 가능할까
이 개념은 Christopher Strachey 라는 분의 강의 노트에서 처음으로 언급이 되었다고 한다. 해당 노트의 일부분을 발췌 해서 보자면 아래와 같다.
일급 객체와 이급 객체. ALGOL에서 실수는 표현식에서도 나올 수 있고, 변수에 대입할 수도 있고, 프로시저 호출에 매개변수로 넘길 수도 있다. 하지만 프로시저는 다른 프로시저 호출에서 연산자나 실질적 매개변수로 밖에 사용할 수 없다. (실질적 매개변수에 대해서는 다음을 참조) 프로시저를 포함하거나 프로시저를 반환값으로 가지는 표현식은 존재하지 않는다. 따라서 ALGOL에서 프로시저는 이급 시민이다. 이는 다른 요소들 사이에서만 존재할 수 있고, 단일 변수나 표현식으로 나타날 수 없다는 것이다. (형식적 매개변수의 경우를 제외하고)
위 노트에서는 명확하게 정의를 내리지 않고 있다. 이후 Robin Popplestone 가 아래와 같은 명확한 기준을 제시했다.
python은 "순수 객체 지향 언어" 이다. 그리고 기본적으로 존재하는 데이터타입의 객체들은 일급 객체이다. 그리고 "함수 역시 일급 객체" 이다. 그리고 이 개념은 python에서만 국한되는 것이 아니다.
가령 C를 예로 생각해보면, function의 argument로 pointer를 전달 할 수 있어도 function name 그 자체를 넘길 수는 없다. 이런 C를 생각해보면 포인터를 일급 객체라 생각할 수 는 있어도, 함수 자체가 일급 객체가 될 순 없다.
[출처: https://kukuta.tistory.com/321 ]
def add
으로 함수를 선언하면 파이썬은 내부적으로 위와 같은 함수를 관리하기 위한 PyFunctionObject
객체를 생성하게 된다. add
은 단순히 그 객체를 가르키는 "포인터" 일 뿐이다.
함수 객체에는 다른 파이썬 객체들과 마찬가지로 PyObject
에서 파생되어 나온 레퍼런스 카운트(ob_refcnt)와 객체 타입을 가리키는 포인터(ob_type)를 가지고 있고, 그외 함수 객체에 필요한 별도의 속성도 가지고 있다.
즉 python은 함수도 일급 객체이기 때문에 함수에게 함수를 전달하는, 아래와 같은 형태가 가능한 것이다.
def multi(a, b):
return a * b
def run_callback(callback, *args):
print("run callback function")
a = callback(*args)
print("result >>", a)
>>> run_callback(multi, 5, 4)
run callback function
result >> 20
python에서는 다양한 중첩이 허락이 된다. for안에 for, if안에 if, 기본적으로 제공하는 내부함수 sum안에 if, for 등 이런 면모들이 파이써닉(pythonic)
한 모습을 만들어 준다.
그리고 함수안에 함수도 중첩이 된다. 위 (1)의 예시도 사실 일급 객체의 특성을 보여줬지만, 일급 객체이기에 가능한 함수 중첩의 모습도 같이 보인다.
def wrapper_function():
def print_test():
print("inner function")
print("run inner function")
print_test()
return True
>>> result = wrapper_function()
run inner function
inner function
>>> print(result)
True
return True
의 역할을 하는 wrapper_function
내부에 def print_test
로 함수 자체를 하나 더 선언해버렸다. 그리고 선언 후 바로 excute 했다. 그리고 원래 목적대로 return True
를 했다.
그리고 호출한 결과가 위와 같다. 이게 "closure" 가 되기 위한 "충분 조건" 이다.
(scope가 무엇인지 짚고 넘어가진 않겠다.) python은 기본적으로 위의 범위로 scope area를 지원한다. 즉 built-in > global > local
이 가장 기본적인 scope 범위다.
근데 여기엔 enclosed
라는 친구가 껴있다. 정확하게는 "자신을 둘러싸는 상위 범위를 말하는 것" 이다. 이 범위는 중첩 함수나 람다에서 나타나는데, 자신을 둘러싸는 상위 범위는 내부 함수 기준으로 외부 함수의 범위를 말한다고 볼 수 있다. 다시 위 예제를 살펴보자!
def wrapper_function():
c = "variable"
def print_test():
cc = "inner variable"
print("inner function")
print("run inner function")
print_test()
return True
c
라는 변수는 wrapper_function
기준으로 local scope, print_test
기준으로 enclosed scope가 된다. 그리고 cc
라는 변수는 print_test
기준 local scope 이다. scope는 주체가 누구냐에 따라 당연히, 달라진다.
그러면 print_test
기준으로 enclosed scope 인 c
라는 변수에 접근하면 어떻게 될까, 접근할 수 있을까?
UnboundLocalError: local variable 'c' referenced before assignment
c
변수를 사용하고 접근하기 위해 nonlocal
을 선언한다. def wrapper_function():
c = "variable"
def print_test():
nonlocal c
c = "updated variable"
cc = "inner variable"
print(f"inner function, c >> {c}, cc >> {cc}")
def mul(m, n):
def wrapper(n):
return m * n
return wrapper
__call__
을 활용해 closure를 만들어 보자! class Mul:
def __init__(self, m):
self.m = m
def __call__(self, n):
return self.m * n
if __name__ == "__main__":
mul3 = Mul(3)
mul5 = Mul(5)
print(mul3(10)) # 30 출력
print(mul5(10)) # 50 출력
python 은 순수 객체 지향언어 이며 class "ddunder method"가 있다. 특히 __call__
은 class자체를 호출할때 excute되는 ddunder method이다.
그래서 mul3 = Mul(3)
를 통해 Mul
object instance를 생성하고, 변수 mul3(__call__의 파라미터)
를 통해 excute 가능하다!
closure에 의해 정의된 내부 함수, 중첩 함수는 호출 해서 메모리에 할당될 때, 즉 런타임에서 정확하게 행위가 정의 된다! 그래서 재미있는 점은 "함수가 메모리에서 사라져도 값이 유지가 된다는 점" 이다.
def mul(m):
def wrapper(n):
return m * n
return wrapper
mul3 = mul(3)
mul5 = mul(5)
del(mul)
print(mul3(3))
print(mul5(5))
del(mul)
를 통해 아예 함수 메모리 할당 해제를 해도 mul3(3)
과 mul5(5)
는 아주 잘 살아있다!mul3.__closure__[0].cell_contents
mul5.__closure__[0].cell_contents
파이썬 3 기준으로, 클로저 함수는 __closure__
변수를 자동으로 갖고 있다. 이 변수는 튜플 타입으로서 클로저가 enclosing 스코프에서 참조하는 변수들을 담고 있다. 그리고 각 원소의 cell_contents 는 그 값 자체를 갖고 있다. 즉, 런타임에서 함수가 정의가 되기 위해 __closure__
변수가 같이 생성되고 유지되기 된다. 그래서 기존 함수가 삭제되어도 문제없이 클로저를 실행할 수 있다.
그리고 이 closure 의 특성을 바탕으로 우리는 "데코레이터" 를 만들 수 있는 것이다.
이름 자체에서 데코레이터는 장식하다, 꾸미다라는 뜻의 decorate에 er(or)을 붙인 말인데 장식하는 도구 정도를 표현하고 있다. 위 클로저에서 계속 봤듯이, 클로저를 활용해 이 데코레이터를 만든다.
목적은 "데코레이터는 기존 함수를 변경하지 않으면서 함수에 기능을 추가 할 때" 를 위해서 사용한다.
import time
def deco(fun):
def wrapper(*args):
"""우리 이쁜 데코레이터"""
start_time = time.time()
fun(*args)
end_time = time.time()
print(f"함수 실행 시간 : {end_time - start_time}")
return wrapper
@deco
def print_something(a):
print(a)
>>> print_something("데코레이터 사용을 통해 출력해 보자!")
데코레이터 사용을 통해 출력해 보자!
함수 실행 시간 : 0.00024819374084472656
앞서 본 클로저의 개념을 활용해서 내부 함수, 중첩 함수를 만들고, *args
을 통해 원래 함수의 인자를 packing
해서, 내부 함수에서 fun(*args)
로 excute 한다. 그리고 @deco
를 통해 print_something
정의하고 unpacking
을 통해 인자를 전달해서 excute한다.
그러면 이렇게 간단하게, @deco
만 추가해서 기본적으로 수행되는 함수의 로직이 excute되고 우리가 원한대로 "함수 실행 시간" 이 출력된다. 이미 다 아는 개념으로 데코레이터를 만들었다!
여기서 가장 의문인건 어떻게 @만으로 decorator를 처리하느냐 이다! 해당 시리즈의 python 실행되기 까지 글에서 언급한 "AST(abstract syntax tree)" 를 살펴봐야 이 "@" 를 알 수 있다.
이 @는 Cpython의 Python parser 를 통해 AST로 바뀔때 처리된다. 아래 코드 예시를 보자! 들여쓰기 수준이 굉장히 심하니 조심,,
@my_decorator
def my_function():
print("Hello, world!")
# 아래가 AST
FunctionDef(
name='my_function',
args=arguments(
args=[],
vararg=None,
kwarg=None,
defaults=[]
),
body=[
Expr(
value=Call(
func=Name(
id='my_decorator',
ctx=Load()
),
args=[
FunctionDef(
name='my_function',
args=arguments(
args=[],
vararg=None,
kwarg=None,
defaults=[]
),
body=[
Expr(
value=Call(
func=Name(
id='print',
ctx=Load()
),
args=[Str(s='Hello, world!')],
keywords=[]
),
lineno=3,
col_offset=4
)
],
decorator_list=[],
lineno=2,
col_offset=0
)],
keywords=[]
),
lineno=2,
col_offset=0
)
],
decorator_list=[],
lineno=2,
col_offset=0
)
FunctionDef
라는 저수준의 object 정의로 변경되는데, @를 만나면 또 body라는 내부 부분에 FunctionDef
들어간다. 이렇게 sytax sugar를 python parser가 처리해서 handling 해준다!계속 살펴보는 것이 함수도 객체, 일급 객체, 파이썬은 모든게 객체라는 개념이었다. 그리고 함수는 좀 더 특별한 것이 자체 "메타 정보"라는 것을 가지고 있다. 즉, 함수를 정의하면 python이 객체로 처리하면서 함수에 추가하는 속성, attribute 들이 존재한다. 기본 attribute 들이 무엇인지 궁금하면 정의한 함수를 dir(fun_name)
이렇게 출력해 보자!
근데 decorator를 사용하면 이 function이 가지고 있는 기본 meta 정보가 다른 정보로 덮어져 버린다! 위 예시를 그대로 다시 살펴보자!
@deco
def print_something1(a):
"""내 함수야"""
print(a)
def print_something2(a):
"""내 함수야"""
print(a)
print(print_something1.__name__)
print(print_something1.__doc__)
print(print_something2.__name__)
print(print_something2.__doc__)
# 출력
wrapper
우리 이쁜 데코레이터
print_something2
내 함수야
하나는 위에서 만든 데코레이터 (@deco
)를 활용했고 하나는 그냥 순수 함수를 만들었다. 그리고 메타정보의 차이를 살펴보면, 데코레이터를 통해 만들어진 함수는 우리가 deco
를 정의하면서 만든 내부 중첩 함수 wrapper
로 name 이 박혀버린다. 사실 진짜 이름은 그게 아닌데!! (doc 또한!)
이를 해결하기 위해 python 내장 라이브러리 중 functool
라는 모듈이 있는데, 그 내부 함수중 wrap
를 사용한다.
import time
from functools import wraps
def deco(fun):
@wraps(fun)
def wrapper(*args):
"""우리 이쁜 데코레이터"""
start_time = time.time()
fun(*args)
end_time = time.time()
print(f"함수 실행 시간 : {end_time - start_time}")
return wrapper
@deco
def print_something1(a):
"""내 함수야"""
print(a)
def print_something2(a):
"""내 함수야"""
print(a)
print(print_something1.__name__)
print(print_something1.__doc__)
print(print_something2.__name__)
print(print_something2.__doc__)
# 출력
print_something1
내 함수야
print_something2
내 함수야
import time
class DecoratorClass:
def __init__(self, fun):
self.fun = fun
def __call__(self, *args):
start_time = time.time()
self.fun(*args)
end_time = time.time()
print(f"함수 실행 시간 : {end_time - start_time}")
@DecoratorClass
def print_something1(a):
print(a)
print_something1("드가자")
# 출력
드가자
함수 실행 시간 : 0.0002970695495605469
__call__
ddunder method를 활용했다. 이렇게 class based decorator도 바로 만들 수 있다. 그리고 조금 어거지일 수 있으나, @staticmethod
를 통해 class 내부의 데코레이터도 만들 수 있다. import time
class DecoratorClass:
@staticmethod
def method_deco(fun):
def wrapper(*args):
start_time = time.time()
fun(*args)
end_time = time.time()
print(f"함수 실행 시간 : {end_time - start_time}")
return wrapper
@DecoratorClass.method_deco
def print_something1(a):
print(a)
print_something1("1 드가자")