A banner is created from here

오늘은 회사 코드를 읽던 중
@functools.wraps 라는 데코레이터를 보고
이 함수의 정확한 동작을 이해하기 위해
공식 문서를 읽은 내용을 바탕으로 정리 하였습니다

📚 Contents


  1. 🎁 functools.wraps?
  2. 🎁 functools.update_wrapper
  3. 🎁 functools.partial
  4. 🎁 functools.wraps!
  5. ✍🏻 마치며

🎁 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


그럼 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.partial


다시 돌아와서, 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() 를 알아볼 시간입니다
근데 우리는 위의 개념을 다 이해해서 이제 한 줄 요약이 가능합니다

호고곡

한 줄 요약

functools.wraps(func) == partial(update_wrapper, wrapped=func) # func 는 original_function

functools.update_wrapper() 함수에서, 데코레이팅 될 함수만
미리 wrapped 라는 keyword arguments 로 binding 한 새로운 partial object 를 반환합니다

functools.wraps 는 그럼 언제 사용할까요?


아까 문서의 내용을 다시 상기해 보겠습니다

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 함수를 만들때는 항상 사용해야 할 것 같습니다

그럼 긴 글 읽어 주셔서 감사합니다!
(오번역이 있거나 잘못된 내용이 있으면 댓글로 알려주세요!)

🌼 References