파이써닉한 파이썬을 배워보자 - 5일차 함수2

0

pythonic

목록 보기
5/10

데코레이터(decorator)

데코레이터는 다른 함수를 포장하는 wrapper를 생성하는 함수를 말한다. 문법적으로 데코레이터는 다음과 같이 특수문자@를 사용하여 표시한다.

@decorate
def func(x):
    ...

이 코드는 다음의 코드를 단순화한 것이다.

def func(x):
    ...
func = decorate(func)

이 예에서 함수 func()이 정의되었다. 그러나 함수가 정의되자마자 함수 객체는 decorate()에 전달되어 원래의 함수를 대체하는 객체를 반환한다.

다음은 구체적인 구현 예시이다. 여기서 데코레이터 @trace가 디버깅 메시지를 함수에 추가한다.

def trace(func):
    def call(*args, **kwargs):
        print('Calling', func.__name__)
        return func(*args, **kwargs)
    return call

@trace
def square(x):
    return x * x

print(square(1))
# Calling square
# 1

이 코드에서 trace()는 wrapper함수를 생성하는데, 이 함수는 디버깅 메시지를 출력한 다음, 원래의 함수 객체를 호출한다. 즉, square()를 호출하면 wrapper안에 있는 print()함수가 출력되는 것을 볼 수 있다.

함수는 함수 이름, 문서화 문자열, 타입 힌트와 같은 메타 데이터를 포함한다. 그러나 함수에 wrapper를 추가하면 이 정보들이 사라진다. 따라서 데코레이터를 작성할 때는 다음과 같이 @wraps() 데코레이터를 사용하는 것이 모범적인 예이다.

from functools import wraps

def trace(func):
    @wraps(func)
    def call(*args, **kwargs):
        print('Calling', func.__name__)
        return func(*args, **kwargs)
    return call

@wraps() 데코레이터는 다양한 함수 메터 데이터를 대체 함수(replacement function)에 복사한다. 이 경우, 제공된 함수 func()의 메타 데이터는 반환된 wrapper함수 call()에 복사된다.

하나 이상의 데코레이터를 사용할 수도 있다.

@decorator1
@decorator2
def func(x):
    pass

이 경우는 다음과 같이 적용된다.

def func(x):
    pass

func = decorator1(decorator2(func))

따라서 데코레이터의 나열 순서가 중요하다. 가령 클래스 정의에서 @classmethod, @staticmethod와 같은 데이코레이터는 가장 바깥쪽에 위치해야 한다. 다음은 그 예이다.

class SomeClass(object):
    @classmethod
    @trace
    def a(cls):
        pass

    @trace # 불가능-실패
    @classmethod
    def b(cls):
        pass

이렇게 순서를 제한하는 이유는 @classmethod에서 반환하는 값고 관련있다. 데코레이터는 일반 함수 이외에 다른 객체를 반환할 수 있다. 가장 바깥쪽 데코레이터가 이를 예상하지 못한다면 문제가 발생할 수 있다. 이 경우, @classmethodclassmethod descriptor 객체를 생성한다. 이를 고려하도록 @trace 데코레이터를 작성하지 않는 한, 데코레이터가 잘못된 순서로 나열되면 실패한다.

데코레이터 또한 인수를 받을 수 있다. 다음과 같이 사용자 정의 메시지를 허용하도록 @trace 데코레이터를 변경하고 싶다고 하자.

@trace("You called {func.__name__}")
def func():
    pass

인수가 제공되면 데코레이터의 동작 방식은 다음과 같다.

def func():
    pass

# 데코레이터 함수 생성
temp = trace("You called {func.__name__}")

# 데코레이터를 func에 적용
func = temp(func)

이 경우, 인수를 받는 가장 바깥쪽 함수는 데코레이터를 생성하는 역할을 한다. 그러고 나서 그 함수는 최종 결과를 얻기 위해 데코레이트할 함수와 함께 호출된다. 데코레이터 구현은 다음과 같다.

from functools import wraps

def trace(message):
    def decorate(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(message.format(func=func))
            return func(*args, **kwargs)
        return wrapper
    return decorate

이 구현에서 흥미로운 점은 외부 함수가 일종의 decorator factory라는 것이다. 다음과 같은 코드를 작성한다고 하자.

@trace("You called {func.__name__}")
def func1():
    pass

@trace("You called {func.__name__}")
def func2():
    pass

이렇게 작업하면 귀찮으니 외부 데코레이터 함수를 한 번 더 호출하고 결과를 재사용하여 단순화할 수 있다.

logged = trace("You called {func.__name__}")

@logged
def func1():
    pass

@logged
def func2():
    pass

데코레이터가 원 함수를 반드시 변경할 필요는 없다. 때때로 데코레이터는 등록과 같은 단순한 작업만 수행하기도 한다. 가령, 이벤트 처리기의 레지스트리를 구축한다면 다음과 같이 동작하는 데코레이터를 정의할 수 있다.

@eventHandler("BUTTON")
def handle_button(msg):
    ...

@eventHandler("RESET")
def handle_reset(msg):
    ... 

이를 관리하는 데코레이터는 다음과 같다.

# 이벤트 처리기 데코레이터
_event_handlers = {}
def eventHandler(event):
    def register_function(func):
        _event_handlers[event] = func
        return func
    return register_function

Map, Filter, Reduce

map, filter, reduce는 리스트 컴프리헨션, 제너레이터 표현식에서 많이 제공된다.

def square(x):
    return x * x

nums = [1,2,3,4,5]
squares = [square(x) for x in nums] # [1,4,9,16,25]

위의 코드를 다음과 같이 변경할 수 있다.

squares = [x * x for x in nums]

리스트 컴프리헨션과 함께 필터링을 수행할 수 있다.

a = [x for x in nums if x > 2] # [3,4,5]

제너레이터 표현식을 사용하면 반복을 통해 점진적으로 결과를 만드는 제너레이터를 얻을 수 있다.

squares = (x*x for x in nums) # 제너레이터 생성
for n in squares:
    print(n)

파이썬은 내장 함수 map()을 제공하는데 이 함수는 제너레이터 표현식으로 함수를 매핑하는 것과 같은 기능을 지닌다. 가령 위 예제는 다음과 같이 사용할 수 있다.

nums = [1,2,3,4,5]
squares = map(lambda x: x*x, nums)
for n in squares:
    print(n)

map(함수, 리스트)를 넣으면 리스트의 각 원소마다 함수를 호출하고 그 반환값을 새로운 리스트의 원소로 하나씩 넣어주는 것이다. 단, 반환값 자체는 map이라는 객체이므로 list형식으로 바꾸고 싶다면 list를 해주어야 한다.

filter는 값을 거르는 제너레이터를 만든다.

nums = [1,2,3,4,5]
for n in filter(lambda x: x > 2, nums):
    print(n) # 3 4 5

functools.reduce()을 사용하면 값을 누적하거나 줄일 수 있다. 다음은 한 예이다.

from functools import reduce
nums = [1,2,3,4,5]
total = reduce(lambda x,y: x + y, nums) # 15

즉, reduce는 aggregator 역할을 할 수 있는 것이다.

reduce()는 일반 형식에서 두 개의 인수를 받아들이는 함수, 즉 반복 가능하며 초기화되어 있는 객체를 받아들인다. 다음의 예를 살펴보자.

reduce()는 반복 가능한 객체에서 값을 왼쪽에서 오른쪽으로 누적한다. 이를 left-fold 연산이라 한다. 다음은 reduce(func, items, initial)에 대한 pseudocode이다.

def reduce(func, items, initial):
    result = initial
    for item in items:
        result = func(result, item)
    return result

함수 조사, 속성 및 서약

함수는 객체이다. 함수는 변수에 할당되고 자료구조에 보관될 수 있으며, 프로그램에서 다른 데이터와 동일한 방식으로 사용될 수 있다. 또한, 함수는 다양한 속성을 가지는데, 대부분의 속성은 디버깅, 로깅, 그리고 함수와 관련된 다른 작업에 유용하게 쓰인다.

  • 함수 속성
    | 속성 | 설명 |
    |----------------------|---------------------|
    |f.name | 함수 이름 |
    |f.qualname| 정규화된 이름(중첩된 경우) |
    |f.module| 함수가 정의된 모듈 이름|
    |f.doc| 문서화 문자열 |
    |f.anotations|타입 힌트|
    |f.globals| 전역 네임스페이스 dict|
    |f.closure| 클로저 변수 |
    |f.code | 기본 코드 객체 |

f.__name__속성은 함수를 정의할 때 사용된 이름을 담고 f.__qualname__은 함수를 감싸고 있는 정의 환경 정보가 추가된 긴 이름이다.

f.__module__속성은 함수가 정의되어 있는 모듈의 이름을 담고 있고 f._globals__속성은 함수의 전역 네임스페이스 역할을 하는 dict이다. 일반적으로 해당 모듈 객체에 연결된 dict와 동이라하다.

f.__doc__속성은 함수 문서화 문자열을 담고 있다. f.__annotations__속성은 타입 힌트가 있는 경우 이를 담고 있는 사전이다.

f.__closure__ 속성은 중첩 함수에 대한 클로저 변수값의 참조를 담고 있다. 숨어 있지만 다음 예제는 이값을 보는 법을 보여준다.

def add(x,y):
    def do_add():
        return x + y
    return do_add

a = add(2,3)
print(a.__closure__)
# (<cell at 0x7f71b52f8dc8: int object at 0xa69ac0>, <cell at 0x7f71b69d33a8: int object at 0xa69ae0>)
print(a.__closure__[0].cell_contents) # 2

f.__code__객체는 함수 본문을 컴파일한 인터프리터 바이트 코드를 보여준다. 함수는 임의의 속성을 붙일 수 있다.

def func():
    ...
func.secure = 1
func.private = 1

속성은 함수 본문에서 볼 수 없다. 이는 지역변수가 아니며 실행 환경에서 이름으로 표시되지 않는다. 함수 속성의 주요 용도는 추가 메타 데이터를 저장하는 것이다. 때로 프레임워크 또는 다양한 메타 프로그래밍 기술은 함수 태깅(function tagging, 함수에 속성을 부여하는 것)을 활용한다. 함수 태깅의 한 예는 추상 기본 클래스 내의 메서드에서 사용되는 @abstractmethod 데코레이터이다. 해당 데코레이터가 하는 일은 속성을 첨부하는 것이다.

def abstractmethod(func):
    func.__isabstractmethod__ = True
    return func

일부 다른 코드 비트(이 경우, 메타 클래스)는 이 속성을 찾고 속성을 사용하여 인스턴스 생성을 위한 추가 검사를 더 한다.

함수 매개변수에 대해 더 알고싶다면 inspect.signature()함수를 사용하여 함수의 signature를 얻으면 된다.

import inspect

def func(x: int,y: float, debug=False) -> float:
    pass

sig = inspect.signature(func)

signature객체는 매개변수에 대한 상세한 정보를 출력하거나 가져오는 편리한 기능을 제공한다.

import inspect

def func(x: int,y: float, debug=False) -> float:
    pass

sig = inspect.signature(func)
# 세련된 형태로 signature 출력
print(sig) # (x:int, y:float, debug=False) -> float
# 인수 이름 리스트를 가져옴
print(list(sig.parameters)) # ['x', 'y', 'debug']
# 매개변수를 반복하고 다양한 메타 데이터를 출력
for p in sig.parameters.values():
    print('name', p.name)
    print('annotation', p.annotation)
    print('kind', p.kind)
    print('default', p.default)

for-loop의 결과는 다음과 같다.

name x
annotation <class 'int'>
kind POSITIONAL_OR_KEYWORD
default <class 'inspect._empty'>
name y
annotation <class 'float'>
kind POSITIONAL_OR_KEYWORD
default <class 'inspect._empty'>
name debug
annotation <class 'inspect._empty'>
kind POSITIONAL_OR_KEYWORD
default False

signature은 함수의 특성(함수 호출 방법, 타입 힌트 등)을 설명하는 메타 데이터이다. signature로 할 수 있는 다양한 작업이 있으며 그 중 하나는 비교이다. 가령 두 함수가 동일한 signature를 가지는 지 확인하는 방법은 다음과 같다.

import inspect

def func1(x,y):
    pass

def func2(x,y):
    pass

assert inspect.signature(func1) == inspect.signature(func2) # True

이러한 종류의 비교는 프레임워크 내에서 유용하다. 가령 프레임워크는 signature를 비교하여 함수나 메서드가 예상 프로토콜 타입을 준수하는 지 확인할 수 있다.

함수의 __signature__속성이 저장되어있다면 signature는 도움말 메시지에 표시되고 inspect.signature()를 사용할 때 반환된다. 다음의 예를 살펴보자.

def func(x, y, z=None):
    ...
func.__signature__ = inspect.signature(lambda x,y: None)

이 예에서 추가 인수 z는 추가 검사에서 숨는다. 대신 첨부된 signature은 inspect.signature()로 반환된다.

실행 환경 조사

함수는 내부 함수 globals()locals()를 사용하여 실행 환경을 살펴볼 수 있다. globals()는 전역 네임스페이스 역할을 하는 dict를 반환한다. 이는 func.__globals__속성과 같으며, 일반적으로 둘러싸고 있는 모듈의 내용을 담고 있는 dict와 같다. locals()는 지역 및 클로저 변수의 값을 모두 지닌 dict를 반환한다. 이 dict는 이러한 변수를 보관할 때 사용되는 실제 자료구조가 아니다. 지역 변수는 클로저를 통해 외부 함수에서 가져오거나 내부적으로 정의될 수 있다.

locals()는 이러한 변수를 모두 수집하고 dict에 넣는다. locals() dict에는 항목을 변경해도 기본 변수에는 영향을 주지 않는다.

def func():
    y = 20
    locs = locals()
    locs['y'] = 30 # y 변경
    print(locs['y']) # 30
    print(y) # 20

func()

함수는 inspect.currentframe()을 사용하여 자신의 stack frame을 얻을 수 있다. 함수는 프레임의 f.f_back속성으로 스택 흔적을 따라가면서 호출자의 스택 프레임도 얻을 수 있다.

import inspect

def spam(x,y):
    z = x + y
    grok(z)

def grok(a):
    b = a * 10
    # {'b': 50, 'a': 5}
    print(inspect.currentframe().f_locals)
    # {'z': 5, 'y': 3, 'x': 2}
    print(inspect.currentframe().f_back.f_locals)

spam(2,3)

때때로 inspect.currentframe() 대신 sys._getframe()함수를 사용해, 얻은 스택 프레임을 살펴볼 수 있다.

import sys

def grok(a):
    b = a * 10
    print(sys._getframe(0).f_locals) # 자기 자신 
    print(sys._getframe(1).f_locals) # 상위 호출자

grok(10)

아래는 검사에 유용한 속성들이다.

  • 프레임 속성
    | 프레임 | 설명 |
    |-----------------------------|----------------------|
    |f.f_back | 이전 스택 프레임 |
    |f.f_code | 실행 중인 코드 객체 |
    |f.f_locals | 지역 변수를 위한 dict(locals()) |
    |f.f_globals | 전역 변수를 위한 dict(globals()) |
    |f.f_builtins | 내장 이름을 사용하는 dict |
    |f.f_lineno | 줄 번호 |
    |f.f_lasti | 현재 명령어 f._code에 들어 있는 바이트 코드 문자열에 대한 인덱스 |
    |f.f_trace | 각 소스 코드 줄의 시작 지점에서 호출된 함수 |

디버깅과 코드 검사 시 스택 프레임을 살펴보는 것이 유용하다.

동적 코드 실행과 생성

exec(str [, globals [, locals]])함수는 임의의 파이썬 코드를 가진 문자열을 실행한다. exec()에 제공된 코드는 마치 코드가 exec연산을 대신하는 것처럼 실행된다.

a = [3,5,10,13]
exec('for i in a: print(i)') # 3 5 10 13

exec()에 입력된 코드는 호출자의 지역 및 전역 네임스페이스 내에서 실행된다. 하지만 지역 변수를 변경하더라도 영향을 끼치지 않는다. 다음의 예를 보자.

def func():
    x = 10
    exec('x=20')
    print(x)

func() # 10

이유는 지역 변수가 실제 지역 변수가 아닌 수집된 지역 변수의 dict이기 때문이다.

exec()은 실행할 코드에 대한 전역과 지역 네임스페이스 역할을 하는 하나 또는 두 개의 dict객체를 선택적으로 받아들인다.

globs = {
    'x': 7,
    'y': 10,
    'birds': ['Parrot', 'Swallow', 'Albatross']
}

locs = {}

exec('z= 3 * 4 * y', globs, locs)
print(locs) # 120
exec('for b in birds: print(b)', globs, locs)

네임스페이스 중 하나 또는 둘 다 생략하면, 현재의 전역과 지역 네임스페이스 값이 사용된다. 전역 네임스페이스 dict만 제공하면 이 dict이 모든 전역과 지역에서 사용된다.

동적 코드 실행의 일반적인 용도는 함수와 메서드를 생성하는 것이다. 다음은 이름이 제공된 클래스에서 __init()__메서드를 생성하는 함수이다.

def make_init(*names):
    parms = ','.join(names)
    code = f'def __init__(self, {parms}):\n'
    for name in names:
        code += f'    self.{name} = {name}\n'
    d = {}
    print(code)
    exec(code, d)
    return d['__init__'] # 함수 이름이 `__init___이기 때문에 함수를 정의하면 `__init__`에 생성됨

# 사용 예제
class Vector:
    __init__ = make_init('x', 'y', 'z')

vector = Vector('x', 'y', 'z')
print(vector.x, vector.y, vector.z)

결과는 다음과 같다.

def __init__(self, x,y,z):
    self.x = x
    self.y = y
    self.z = z

x y z

이 기술은 표준 라이브러리의 다양한 곳에서 사용된다. 가령, nametuple(), @dataclass 및 유사한 기능들이 exec()를 사용하여 동적 코드를 생성한다.

비동기 함수와 await

파이썬의 비동기 함수 async 함수(또는 코루틴)과 awaitable이 있다. 이들 대부분은 동시성과 asyncio 모듈을 포함한 프로그램에서 사용된다. 하지만 다른 라이브러리도 이를 기반으로 구축할 수 있다.

비동기 함수 또는 코루틴 함수는 함수 정의 앞에 async 추가 키둬으를 사용하여 정의한다. 다음은 그 예이다.

async def greeting(name):
    print(f'Hello {name}')

이 함수를 호출하면 일반적인 방식으로 실행되지 않는다는 사실을 알게된다. 실제로 이 함수는 전혀 실행되지 않는다. 대신에 코루틴 객체의 인스턴스를 얻는다. 다음의 예를 보자.

<coroutine object greeting at 0x7f2e03101308>

함수를 실행하려면 다른 코드의 감독하에 실행해야 한다. 일반적으로 asyncio를 사용하며 다음은 그 예이다.

import asyncio

async def greeting(name):
    print(f'Hello {name}')

asyncio.run(greeting("Guido")) # Hello Guido

위를 통해 알 수 있는 것은 비동기 함수는 스스로 실행되지 않는다는 것이다. 함수 실행을 위해서는 항상 일종의 관리자 또는 라이브러리 코드가 필요하다. 이 코드처럼 반드시 asyncio가 필요한 것은 아니지만, 비동기 함수를 실행하기 위해서는 항상 무언가가 필요하다.

관리된다는 것을 제외한다면 비동기 함수는 다른 파이썬 함수와 동일한 방식으로 평가된다. 문장들은 순서대로 실행되고 제어 흐름 기능 역시 모두 동작한다. 결과를 반환하려면 return문을 사용한다. 다음의 예를 살펴보자.

import asyncio

async def greeting(name):
    return f'Hello {name}'

return 값은 비동기 함수를 사용할 때 asyncio.run함수의 반환값으로 사용된다.

import asyncio

async def greeting(name):
    return f'Hello {name}'

a = asyncio.run(greeting("Guido")) 
print(a) # Hello Guido

비동기 함수는 다음과 같이 await 표현식을 사용하여 비동기 함수를 호출할 수 있다.

import asyncio

async def greeting(name):
    return f'Hello {name}'

async def main():
    for name in ['Paula', 'Thomas', 'Lewis']:
        a = await greeting(name)
        print(a)

asyncio.run(main())

결과는 다음과 같다.

Hello Paula
Hello Thomas
Hello Lewis

await은 감싸고 있는 비동기 함수의 정의 내에서만 유효하게 사용할 수 있다. 또한 이 표현식은 async함수를 실행하기 위해 필요한 요소이다. await을 중단하면 코드가 멈추게 된다.

await 사용을 요구하는 것은 비동기 함수가 가진 일반적인 사용 문제를 암시한다. 즉, 비동기 함수와 일반 함수의 서로 다른 평가(evaluation) 모델로 인해 비동기 함수는 파이썬의 다른 부분과 함께 사용되지 못한다.

특히 비동기가 아닌 함수에서 await으로 비동기 함수를 호출하는 코드를 작성하는 것은 불가능하다. 때문에 동기 함수에서는 비동기 함수를 호출하기위해 asyncio.run을 사용할 수 밖에 없는 것이다.

import asyncio

async def twice(x):
    return 2 * x

def main():
    print(asyncio.run(twice(2))) # 4
    print(twice(2)) # 에러. 함수가 실행되지 않음
    print(await twice(2)) # 에러. 여기서 await를 사용할 수 없다.

main()

같은 application에서 비동기와 동기 기능을 결합하는 것은 복잡한 주제이다. 특히 고차함수, callback, 데코레이터와 관계된 프로그래밍 기술을 고려할 때는 더욱 복잡하다. 대부분의 경우 비동기 함수를 지원하는 일은 특별한 경우로 한정해야 한다.

파이썬은 iterator와 컨텍스트 관리자 프로토콜에서 이 작업을 정확하게 수행한다. 가령 비동기 컨텍스트 관리자는 다음과 같이 __aenter__()__aexit__()메서드를 사용하여 정의할 수 있다.

class AsyncManager(object):
    def __init__(self, x):
        self.x = x
    async def yow(self):
        print(self.x)

    async def __aenter__(self):
        print("enter")
        return self
    
    async def __aexit__(self, ty, val, tb):
        print("exit")

이 메서드들은 비동기 함수이므로 await를 사용하여 다른 비동기 함수를 실행할 수 있다. 이 manager를 사용하려면 async함수에서만 유효한 특수 async with문법을 사용해야 한다.

async def main():
    async with AsyncManager(42) as m:
        await m.yow()

asyncio.run(main())

결과는 다음과 같다.

enter
42
exit

클래스는 __aiter__()__anext()__() 메서드를 정의하여 비동기 이터레이터를 유사하게 정의할 수 있다. 이들은 비동기 함수에서만 나오는 async for문에서 사용된다.

실제로 async함수는 일반 함수와 동일하게 동작한다. 다른 점은 asyncio와 같은 관리 환경에서 실행해야 한다는 것 뿐이다. 비동기 처리를 다루지 않는 환경이라면 비동기 함수를 사용하지 않는 것이 좋다.

0개의 댓글