A banner is created from here
오늘은 회사 코드를 읽던 중
@functools.wraps
라는 데코레이터를 보고
이 함수의 정확한 동작을 이해하기 위해
공식 문서를 읽은 내용을 바탕으로 정리 하였습니다
functools.wraps()
는 무엇을 하는 함수일까요?
궁금할 때는 공식 문서를 읽어보는게 가장 확실한 방법입니다
This is a convenience function for invoking update_wrapper()
as a function decorator when defining a wrapper function.
It is equivalent to
===
partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated).
===
Without the use of this decorator factory,
the name of the example function would have been 'wrapper',
and the docstring of the original example() would have been lost.
위의 내용을 우리말로 옮겨 본다면 다음과 같습니다
wrapper 함수를 정의 할 때 update_wrapper() 를 함수 데코레이터로써 호출하는 편리한 함수입니다.
이것은 아래 식과 동일합니다
===
partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated)
===
이것을 사용하지 않은 decorator factory 는 wrapper 를 반환하지만
원래의 함수의 docstring 은 알 수 없게 됩니다.
그런데, 이 functools.wraps()
함수를 이해하려면
functools.partial()
이라는 함수와, functools.update_wrapper()
라는 함수를
잘 알아봐야 이해할 수 있을것 같습니다
이딴 쓸데없는 생각을 해서 functools.wraps()
를 이해하기 위한
긴 여정이 시작됩니다
그럼 functools.update_wrapper()
함수도 문서를 읽어 보겠습니다
Update a wrapper function to look like the wrapped function. The optional arguments are
tuples to specify which attributes of the original function are assigned directly to the
matching attributes on the wrapper function and which attributes of the wrapper function
are updated with the corresponding attributes from the original function. The default values
for these arguments are the module level constants WRAPPER_ASSIGNMENTS (which assigns to the
wrapper function’s __name__, __module__ and __doc__, the documentation string) and
WRAPPER_UPDATES (which updates the wrapper function’s __dict__, i.e. the instance dictionary)
To allow access to the original function for introspection and other purposes
(e.g. bypassing a caching decorator such as lru_cache()), this function automatically adds
a __wrapped__ attribute to the wrapper that refers to the function being wrapped.
The main intended use for this function is in decorator functions which wrap the
decorated function and return the wrapper. If the wrapper function is not updated,
the metadata of the returned function will reflect the wrapper definition rather than
the original function definition, which is typically less than helpful.
위의 문서의 내용은 대략 아래와 같습니다
wrapper 함수를 wrapped 함수처럼 보이도록 갱신한다. 선택적인 인자는 튜플로써 원래의 함수에서
1. 원래 함수의 어떤 속성을 wrapper 함수에 매칭시킬지
2. wrapper 함수의 어떤 attributes 를 원래 함수의 값으로 업데이트 할지 명시한다.
이 선택적 인수들은 모듈 수준의 상수인 WRAPPER_ASSIGNMENTS (__name__, __module__, __doc__) 와
WRAPPER_UPDATES (wrapper 함수의 __dict__ 즉, 인스턴스 dictionary) 이다.
만약 원래의 함수를 introspection(적당한 우리말 의미가 없네요 죄송. 성찰한다는 의미) 하거나,
다른 목적을 (예를 들어, caching decorator 인 lru_cache() 를 우회한다거나) 으로 접근을 허용하기 위해
이 함수는 자동으로 __wrapped__ attribute 를 추가하여, 원래의 함수를 참조할 수 있도록 합니다
이 함수의 목적은 어떤 함수를 decorated 하여 wrapper 로 반환하는 데코레이터 함수내에서 사용한다.
만약, wrapper 함수가 갱신되지 않는다면, 반환된 함수(wrapper function) 의 메타데이터는 원래의 함수의
정의를 투영하지 않고, 그 정보는 유용하지 않을 것이다.
이 함수는 다른 함수가 원래 함수의 "함수이름", "함수 설명 텍스트", "모듈" 등의 정보를
참조할 수 있도록 전달하기 위해 필요한 것처럼 보입니다
functools.update_wrapper()
의 문서를 읽어 보았으니
이 함수를 활용하여 대략적으로 예시를 만들어 볼게요
그 전에 짚고 넘어갈 점은
Python 의 함수 객체는 몇 가지 추가적인 attributes 가 있다는 사실입니다
이것을 확인하기 위해 Python 의 함수 객체의 cpython 구현부를 살펴 보겠습니다
→ python/cpython Github 에서 가져온 코드입니다
// indclude/funcobject.h # 21
typedef struct {
PyObject_HEAD
PyObject *func_code; /* A code object, the __code__ attribute */
PyObject *func_globals; /* A dictionary (other mappings won't do) */
PyObject *func_defaults; /* NULL or a tuple */
PyObject *func_kwdefaults; /* NULL or a dict */
PyObject *func_closure; /* NULL or a tuple of cell objects */
PyObject *func_doc; /* The __doc__ attribute, can be anything */
PyObject *func_name; /* The __name__ attribute, a string object */
PyObject *func_dict; /* The __dict__ attribute, a dict or NULL */
PyObject *func_weakreflist; /* List of weak references */
PyObject *func_module; /* The __module__ attribute, can be anything */
PyObject *func_annotations; /* Annotations, a dict or NULL */
PyObject *func_qualname; /* The qualified name */
/* Invariant:
* func_closure contains the bindings for func_code->co_freevars, so
* PyTuple_Size(func_closure) == PyCode_GetNumFree(func_code)
* (func_closure may be NULL if PyCode_GetNumFree(func_code) == 0).
*/
} PyFunctionObject;
구조체 중간에 보시면, *func_doc, *func_name, *func_dict, *func_module
이 보입니다
이것들이 기본적인 dunder(더블 언더스코어) attributes 외에 추가된 attributes 입니다
그리고 문서에 등장한 각 상수에 대해 설명하면
WRAPPER_ASSIGNMENTS 는 ***func_doc, *func_name, *func_module**
를
WRAPPER_UPDATES 는 *func_dict
를 의미 합니다
functools.update_wrapper()
함수는 이 값들을 wrapper
함수에 업데이트 해 줍니다
그럼 이제 진짜 예시 코드를 만들어 볼게요
from functools import update_wrapper
def wrapper(*args, **kwargs):
pass
def add(a, b):
""" ADD a + b """
return a + b
print(wrapper.__doc__) # None
print(wrapper.__name__) # wrapper
update_wrapper(wrapper, add)
print(wrapper.__doc__) # None → ADD a + b
print(wrapper.__name__) # wrapper → add
functools.update_wrapper()
함수는
첫 번째 인자로 wrapper 함수, 두 번째 인자로 원래의 함수를 받습니다
add 함수의 __doc__
과 __name__
의 값이
wrapper 함수에 적용된 것을 확인할 수 있습니다
다시 돌아와서, functools.wraps()
는 functools.partial()
과
functools.update_wrapper()
함수의 partial object
라고 했는데요
이제 이 functools.partial()
함수에 대해 알아 보겠습니다
미리 설명하기 전에, 혹시 함수형 프로그래밍의 currying
에 대해 아시거나
partial application
을 아신다면, 그런 역할을 하는 함수라고 이해하시면 됩니다!
아래는 functools.partial()
함수의 문서입니다
Return a new partial object which when called will behave like func called with the positional
arguments args and keyword arguments keywords. If more arguments are supplied to the call,
they are appended to args. If additional keyword arguments are supplied, they extend and
override keywords. Roughly equivalent to:
===python
def partial(func, *args, **keywords):
def newfunc(*fargs, **fkeywords):
newkeywords = keywords.copy()
newkeywords.update(fkeywords)
return func(*(args + fargs), **newkeywords)
newfunc.func = func
newfunc.args = args
newfunc.keywords = keywords
return newfunc
===
The partial() is used for partial function application which “freezes” some portion of a
function’s arguments and/or keywords resulting in a new object with a simplified signature.
For example, partial() can be used to create a callable that behaves like the int() function
where the base argument defaults to two:
>>> from functools import partial
>>> basetwo = partial(int, base=2)
>>> basetwo.__doc__ = 'Convert base 2 string to an int.'
>>> basetwo('00010') # 2
왜 문서는 다 영어로 되어 있을까? 능력자 분들께서 빨리 실시간 번역기 같은거 만들어줬으면...
아무튼 우리말로 바꿔보면 대략 이렇습니다
원래의 함수처럼 positional arguments, keyword arguments 와 함께 호출될 수 있는
*partial object* 를 반환합니다. 더 많은 인수가 제공되면, *args* 에 추가됩니다.
만약 추가적인 keyword arguments 가 제공되면, 기존의 keyword 를 확장하거나 덮어씁니다.
대략적으로 아래의 함수와 같습니다.
===python
def partial(func, *args, **keywords):
def newfunc(*fargs, **fkeywords):
newkeywords = keywords.copy()
newkeywords.update(fkeywords)
return func(*(args + fargs), **newkeywords)
newfunc.func = func
newfunc.args = args
newfunc.keywords = keywords
return newfunc
===
partial() 은 함수의 positional arguments (그리고/또는) keyword arguments 의 일부를
"freeze" 한 단순해진 signature 를 가진 새로운 객체를 결과로 하는 partial function application
을 위해 사용됩니다. 예를 들어, partial() 은 int() 함수에서 base(진법) 를 미리 2진법으로 설정한
새로운 callable 객체를 만들 수 있습니다.
>>> from functools import partial
>>> basetwo = partial(int, base=2)
>>> basetwo.__doc__ = 'Convert base 2 string to an int.'
>>> basetwo('00010') # 2
그리고 위에서 언급한 **partial object
란 functools.partial()
함수의 반환값으로,
`partial object` 는 3개의 읽기 전용 속성**을 갖습니다
partial.func
callable object 또는 함수입니다. partial object 를 호출한다면,
.func attribute 로 새로운 positional arguments, keyword arguments 가 전달 됩니다
partial.args
partial object 호출에 제공될 가장 왼쪽의 positional arguments 로 positional arguments 의 앞에 추가된다
partial.keywords
partial object 가 호출될 때 제공될 keyword arguments 의 dict
우리 말로 써 놓으니 무슨 얘기인지 잘 이해가 안되시죠
부족한 영어 실력을 용서해 주세요
문서도 좋지만 아래의 코드를 한 번 읽어 보시면
이해하는데 도움이 될 것 같습니다
from functools import partial
# adder 함수는 3개의 위치 인자와, 가변 인자를 모두 받는다
def adder(a, b, c, *args): return a + b + c
# 새롭게 생성된 partial application 이다
# 3개의 인자를 받아야 하지만 일부러 5개를 넘겼다
new_adder = partial(adder, 1, 2, 3, 4, 5)
# 답은 15? 6? 뭘까요?
result = new_adder(4, 5, 6)
정답은 15일까요 6일까요?
잠깐만 직접 생각해 보세요!
.
.
.
.
.
.
print(result) # 정답은 6 입니다!
print(new_adder.args) # (1, 2, 3, 4, 5) ← 보시다시피 1, 2, 3 이 덮어씁니다
정답은 6 이었습니다.
위의 설명 처럼 **partial.args**
는 **positional arguments**
의 가장 왼쪽부터
차례로 적용되기 때문에, 새로운 인자를 함수 호출 시에 전달 해도 적용되지 않습니다!
functools.wraps()
를 설명 하려다가 여기 까지 돌아왔네요
사실 functools.partial()
과 functools.update_wrapper()
를 이해하면,
functools.wraps()
는 이 함수들의 조합이기 때문에 이해가 쉬울 것 같습니다
그러니 조금만 더 힘내서 읽어 주세요 🙏
이제 정말 대망의 functools.wraps()
를 알아볼 시간입니다
근데 우리는 위의 개념을 다 이해해서 이제 한 줄 요약이 가능합니다
호고곡
한 줄 요약
functools.wraps(func) == partial(update_wrapper, wrapped=func) # func 는 original_function
→ functools.update_wrapper()
함수에서, 데코레이팅 될 함수만
미리 wrapped 라는 keyword arguments 로 binding 한 새로운 partial object
를 반환합니다
아까 문서의 내용을 다시 상기해 보겠습니다
wrapper 함수를 정의 할 때 update_wrapper() 를 함수 데코레이터로써 호출하는 편리한 함수입니다.
partial(update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated) 과 동일합니다.
이것을 사용하지 않은 decorator factory 는 wrapper 를 반환하지만,
원래의 함수의 docstring 은 알 수 없게 됩니다.
decorator 의 wrapper를 원래의 함수인 것처럼 하여, 디버깅과 문서화를 편리하게 합니다
추가 :
제가 계속 wrapper 라고 하는 것은 보편적으로
decorator 함수를 만들때 내부 함수를 wrapper 라고 합니다. 궁금하셨죠?
조금 더 이해를 돕기 위해 예시를 준비했습니다
함수의 실행 시간을 출력 해주는 timer
라는 decorator 가 있다고 가정합니다
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
ret = func(*args, **kwargs)
print('실행 완료! {0:.2f}초 걸림'.format(time.time() - start))
return ret
return wrapper
# 의미없는 함수인건 알지만 데코레이터 적용을 위해 😭
@timer
def total(iterable):
"""배열의 합을 계산합니다"""
return sum(iterable)
if __name__ == '__main__':
numbers = range(0, int(1e8)) # 1e8 은 1 * 10^8 == 1억 이라는 의미입니다
total(numbers) # 실행 완료! 1.79초 걸림
동작은 잘 합니다만...
만약 total()
함수의 docstring 이나, signature 가 궁금하다면 어떻게 할까요?
보통은 help()
함수나 __doc__
attribute 를 사용합니다.
>>> help(sum)
Help on built-in function sum in module builtins:
sum(iterable, start=0, /)
Return the sum of a 'start' value (default: 0) plus an iterable of numbers
When the iterable is empty, return the start value.
This function is intended specifically for use with numeric values and may
reject non-numeric types.
>>> sum.__doc__
"Return the sum of a 'start' value (default: 0) plus an iterable of numbers\n\nWhen the iterable is empty, return the start value.\nThis function is intended specifically for use with numeric values and may\nreject non-numeric types."
그럼 우리가 만든 total()
함수도 확인해 볼까요?
>>> help(total)
Help on function wrapper in module test:
wrapper(*args, **kwargs)
>>> total.__doc___
>>> total
<function timer.<locals>.wrapper at 0x10e42f268>
🤔🤔🤔 total()
함수 안에 작성한 docstring은 온데간데 없습니다
게다가 total
이라고만 입력 했을 때
다른 함수가 된 것 처럼 timer.<locals>.wrapper
로 표시됩니다
functools.wraps()
는 위와 같은 상황을 방지하고자 사용 합니다
# total 함수는 수정하지 않고, functools.wraps 만 적용합니다
import functools
def timer(func):
@functools.wraps(func) # 이 한 줄을 추가합니다
def wrapper(*args, **kwargs):
start = time.time()
ret = func(*args, **kwargs)
print('실행 완료! {0:.2f}초 걸림'.format(time.time() - start))
return ret
return wrapper
5번째 라인에 @functools.wraps(func)
외에는
위에 작성한 @timer
데코레이터 함수와 완전히 동일합니다
그럼 다시 결과를 확인해 볼게요
>>> help(total)
Help on function total in module __main__:
total(iterable)
배열의 합을 계산합니다
>>> total
<function __main__.total(iterable)>
>>> total.__doc__
'배열의 합을 계산합니다'
이제 정상적으로 출력됩니다 🎉 야호!
그리고 아까 functools.wraps()
는 두 함수의 조합이라고 했는데요
실제로 functools.py 파일을 보면
공식 문서에서 설명한 것 처럼 두 함수 functools.partial()
, functools.update_wrapper()
의 조합된 값을 반환하도록 작성 되어 있습니다
# https://github.com/python/cpython/blob/master/Lib/functools.py#L63
def wraps(wrapped,
assigned = WRAPPER_ASSIGNMENTS,
updated = WRAPPER_UPDATES):
"""Decorator factory to apply update_wrapper() to a wrapper function
Returns a decorator that invokes update_wrapper() with the decorated
function as the wrapper argument and the arguments to wraps() as the
remaining arguments. Default arguments are as for update_wrapper().
This is a convenience function to simplify applying partial() to
update_wrapper().
"""
return partial(update_wrapper, wrapped=wrapped,
assigned=assigned, updated=updated)
저렇게 함수를 조합하면 정말 functools.wraps()
처럼 동작 하는지
한 번 살펴 볼까요?
import time
from functools import partial, update_wrapper, WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
ret = func(*args, **kwargs)
print('실행 완료! {0:.2f}초 걸림'.format(time.time() - start))
return ret
wrapped = partial(update_wrapper, wrapped=func, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES)
return wrapped(wrapper)
@timer
def total(iterables):
return sum(iterables)
numbers = range(0, int(1e8))
total(numbers) # 실행 완료! 1.79초 걸림
정상적으로 functools.wraps()
로 감싼 것과 동일하게 데코레이터 기능을 수행합니다
그리고 help()
, __doc__
또한 정상적으로 표시 되는지 확인 해야죠
>>> help(total)
Help on function total in module __main__:
total(iterable)
배열의 합을 계산합니다
>>> total
<function __main__.total(iterable)>
>>> total.__doc__
'배열의 합을 계산합니다'
잘 나오네요! 역시 믿고 보는 공식 (영문😭)문서
decorator 는 대단히 강력한 기능이지만
동작이 다른 함수로 감싸 진다는 점은 오류 추적 및 문서화에 걸림돌이 되는 부분이었습니다
@functools.wraps()
데코레이터 함수는 signature 를 잘 보존하고
docstring 을 보존해 다른 개발자와의 협업에 유리한 면이 있기 때문에
decorator 함수를 만들때는 항상 사용해야 할 것 같습니다
그럼 긴 글 읽어 주셔서 감사합니다!
(오번역이 있거나 잘못된 내용이 있으면 댓글로 알려주세요!)
굉장히 이해하기 쉽게 설명해주셨네요 감사합니다!