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

1

pythonic

목록 보기
4/10

함수

함수의 정의

def문으로 함수를 정의한다.

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

만약, 함수를 호출할 때 인수의 순서와 개수가 함수에서 정의한 매개변수와 다르면 TypeError예외가 발생한다. 이를 방지하기 위해서 매개변수에 default value를 설정할 수도 있다.

def split(line, delmeter=','):
    pass

default value가 있는 매개변수 다음에 default value값이 없는 매개변수를 지정할 수 없다. 이러한 제약 덕분에 default value를 설정한 매개변수 뒤에 따라오는 매개변수는 모두 생략이 가능하다.

default value 매개변수는 함수를 처음 정의할 때 한 번만 평가된다. 따라서, 변경 가능한 객체를 기본값으로 지저할 경우, 의도치 않은 결과를 얻을 수 있다.

def func(x, items=[]):
    items.append(x)
    return items

print(func(1)) # [1]
print(func(2)) # [1,2]
print(func(3)) # [1,2,3]

위 코드에서 기본 인수가 이전 호출에서 변경한 내용을 유지하는 데 주목하도록 하자. 이를 방지하려면 기본값을 None으로 지정하고 다음과 같이 검사를 덧붙이는 것이 좋다.

def func(x, items=None):
    if items is None:
        items = []
    items.append(x)
    return items

일반적으로 이러한 문제를 피하기 위해 변경 불가능한 객체를 기본 인수값(숫자, 문자열, boolean, None 등)으로 사용하는 게 좋다.

가변 길이 인수

매개변수 이름 앞에 asterisk(*)을 추가하면 함수는 여러 개의 인수를 받을 수 있다.

def product(first, *args):
    result = first
    for x in args:
        result = result + x
    return result

print(product(10,20)) # 30
print(product(2,3,4,5)) # 14

이 코드에서 남아있는 인수는 모두 tuple 형태로 args 변수에 저장된다. 표준 sequence연산인 반복, 슬라이스, 언패킹 등을 사용하여 인수와 함께 작업할 수 있다.

키워드 인수

함수 인수를 전달할 때 각 매개변수의 이름과 값을 적접 지정할 수 있다. 이러한 인수를 키워드 인수(keyword argument)라 한다.

def func(w,x,y,z):
    pass

# 키워드 인수 호출
func(x = 3, y = 22, w = "hello", z = [1, 2])

만약 필수 매개변수가 누락되거나 키워드의 이름이 함수에서 정의한 매개변수의 이름 그 어느 것과도 일치하지 않을 경우에 TypeError예외가 발생한다. 키워드 인수는 함수 호출에서 지정한 순서대로 평가된다.

위치 인수(positional argument)와 키워드 인수는 동일한 함수 호출에서 함께 사용할 수 있다. 단, 위치 인수가 먼저 나와야한다. 그리고 생략 불가능한 인수는 값을 지정해야하며, 두 개 이상의 값을 받는 인수 또한 없어야 한다.

def func(w,x,y,z):
    pass

func('hello', 3, z=[1,2], y=22)
func(3, 22,w='hello', z=[1,2]) # TypeError w에 여러 값을 지정하였다.

특정 상황에서 키워드 인수 사용을 강제할 수 있다. 이는 asterisk(*) 인수 뒤에 매개변수를 나열하거나 함수 정의에서 단일 asterisk(*)를 포함하여 수행하면 된다.

def read_data(filename, *, debug=False):
    ...

def product(first, *values, scale=1):
    result = first * scale
    for val in values:
        result = result * val
    return result

이 예에서 read_data()함수의 debug 인수는 키워드로만 지정할 수 있다. 이 제약은 코드 가독성을 높인다.

data = read_data('Data.csv', True) # 실패 TypeError
data = read_data('Data.csv', debug=True) # 성공

product()함수는 임의 개수의 위치 인수와 생략 가능한 키워드 전용 인수를 받아들인다. 다음은 그 예이다.

result = product(2,3,4) # result = 24
result = product(2,3,4, scale=10) # result = 240

가변 길이 키워드 인수

함수 정의에서 마지막 인수의 이름이 **로 시작할 경우, 추가 키워드 인수(다른 매개변수의 이름과 일치하지 않는 인수)는 모두 dict에 저장되어 함수로 전달된다. 이 dict의 항목 순서는 제공된 키워드 인수의 순서와 동일하다.

가변 길이 키워드 인수는 매개변수로 모두 나열하기 어려운 많은 수의 개방형 구성 옵션(configuration option), 인수의 개수가 정의되지 않은 상황을 허용하는 함수를 정의할 때 유용하다.

def make_table(data, **params):
    # parms(사전 타입)로 부터 구성 옵션을 가져옴
    fgcolor = params.pop('fgcolor', 'black')
    bgcolor = params.pop('bgcolor', 'white')
    width = params.pop('width', None)
    ...
    # 옵션이 더 존재하지 않음
    if params:
        raise TypeError(f'Unsupported configuration options {list(params)}')

items = []
make_table(items, fgcolor='black s', bgcolor='white s', border=1,borderstyle='grooved', cellpadding=10, width=400)

dictpop메서드는 dict에서 항목을 제거하며 해당 key에 대한 value가 없다면 두번째 인자를 default value로 반환해준다.

인수를 모두 받아들이는 함수

***를 둘 다 사용하면 인수의 조합을 모두 받아들이는 함수를 작성할 수 있다. 위치 인수는 tuple로 키워드 인수는 dict로 전달된다. 다음의 예를 살펴보자

# 가변 길이를 가전 위치 또는 키워드 인수를 받아들인다.
def func(*args, **kwargs):
    # args는 위치 인수 튜플
    # kwargs는 키워드 인수 dict

*args**kwargs의 조합은 일반적으로 wrappers, decorators, proxies 및 유사 함수를 작성할 때 주로 사용된다. 다음 코드와 같이 반복 가능한 객체에서 가져온 텍스트 줄을 분석하는 함수가 있다고 하자.

def parse_lines(lines, separator=',', types=(), debug=False):
    for line in lines:
        ...

이제 filename으로 지정된 파일에서 데이터를 분석하는 특수한 목적의 함수를 작성한다고 하자. 이를 다음과 같이 작성할 수 있다.

def parse_file(filename, *args, **kwargs):
    with open(filename, 'rt') as file:
        return parse_lines(file, *args, **kwargs)

이 접근 방식의 장점은 parse_file 함수가 parse_lines()의 인수에 관해 알 필요가 없다는 점이다. 호출자가 제공하는 추가 인수를 받아들이고 차례로 넘겨주면 된다. 이런 접근 방식은 parse_file()함수의 유지 보수를 단순하게 해주는 장점도 있다. 가령 parse_line()에 새로운 인수가 추가되어도 해당 인수는 parse_file()함수에서 알 필요가 없어 수정사항이 없다.

위치 전용 인수

함수의 시그너처를 보다보면 func(x, y, /)와 같이 slash(/)가 있는 것을 확인할 수 있을 것이다. 이는 slash앞에는 모두 위치 인수로만 지정되어야 한다는 것이다.

따라서 func(2,3)으로 호출이 가능하지만 func(x=2, y=3)은 불가능하다.

함수를 정의할 때 작성자의 의도를 명확히 하기 위해 이러한 문법을 사용한다. 예를 들어 다음 코드와 같이 작성할 수 있다.

def func(x, y, /):
    pass

func(1,2) # 성공
func(1, y=2) # 실패

이러한 함수 정의 형식은 python3.8에서 처음 지원되었으므로 많이 볼 수 없을 것이다. 하지만 인수 간의 잠재적인 이름 충돌을 방지하는데 매우 유용한 방법이다.

import time

def after(seconds, func, /, *args, **kwargs):
    time.sleep(seconds)
    return func(*args, **kwargs)

def duration(*, seconds, minutes, hours):
    return seconds + 60 * minutes + 3600 * hours

after(5, duration, seconds=20, minutes=3, hours=2)

이 코드에서는 seconds가 키워드 인수로 전달되지만, after()로 전달되는 duration함수와 함께 사용하려는 의도가 있다. after()에서 위치 전용 인수를 사용하면 처음 나타나는 seconds인수와 이름이 충돌되는 것을 방지할 수 있다.

함수 이름, 문서화 문자열, 타입 힌트

함수의 naming convention은 소문자로 작성함과 동시에 _로 단어 구분 기호를 사용하는 것이다. 가령 readData()가 아닌 read_data()로 작성한다.

함수가 내부적으로(파일 내부 - private) 도움 역할을 할 뿐 외부에서 직접 사용되지 않을 경우, 하나의 밑줄로 시작한다. 가령 _helper()와 같이 작성한다.

함수 이름은 __name__속성을 통해 얻을 수 있어, 디버깅에 큰 도움이 된다.

def square(x):
    return x * x

print(square.__name__) # square

함수의 첫 번째 문장은 주로 함수의 사용법을 설명하는 문서화 문자열(documentation string)인 경우가 많다. 다음의 예를 보자

def factorial(n):
    '''
    n 계승(factorial)을 계산
    >>> factorial(6)
    720
    >>>
    '''
    if n <= 1:
        return 1
    else:
        return n*factorial(n-1)

print(factorial.__doc__)

결과는 다음과 같다.


    n 계승(factorial)을 계산
    >>> factorial(6)
    720
    >>>

문서화 문자열은 함수의 __doc__속성에 저장되고 IDE에서 큰 도움을 준다.

함수는 타입 힌트(type hint)를 달아놓을 수 있다.

def factorial(n: int) -> int:
    if n <= 1:
        return 1
    else:
        return n * factorial(n-1)

타입 힌트는 함수가 평가될 때 아무것도 변경하지 않는다. 즉, 힌트는 성능상의 이점을 제공하거나 추가 런타임 오류를 검사하지 않는다. 힌트는 함수으 __annotaions__속성에 저장되며, 이 속성은 제공된 힌트에 인수 이름을 매핑하는 사전이다. IDE에 힌트를 제공해준다.

지역 변수에도 타입 힌트를 붙일 수 있다. 다만, 이 힌트는 인터피르터가 완전히 무시한다. 이를 확인, 저장, 평가하지 안흔낟. 그냥 개발할 때 도움을 줄 뿐인 것이다.

함수 적용과 매개변수 전달

함수가 호출되면 함수의 매개변수는 전달된 입력 객체에 묶이는 local names이 된다. 파이썬은 함수에 인수를 전달할 때 추가 복사 없이 제공된 객체를 있는 그대로전달한다. 리스트 도는 사전과 같이 변경 가능한 객체를 전달할 때는 주의해야 한다. 전달된 객체가 수정되면, 수정 내역이 원래 객체에도 반영되기 때문이다.

def square(items):
    for i, x in enumerate(items):
        items[i] = x * x # items를 수정
a = [1,2,3,4,5]
square(a) # [1,4,9,16,25]

이는 함수에서 외부의 상태를 변경하므로 매우 좋지 않은 코드이다.

객체 수정과 변수 이름 재할당을 구분하는 게 중요하다. 다음 함수를 살펴보자.

def sum_squares(items):
    items = [x*x for x in items] # 재할당
    return sum(items)

a = [1,2,3,4,5]
result = sum_squares(a)
print(a) # [1,2,3,4,5]

이 코드에서 sum_squares()함수가 전달받은 변수 items를 덮어쓰는 것처럼 보인다. 정확히 말하자면 외부의 items을 덮어쓰는 것이 아니라, 지역 변수인 items를 새로 선언한 것이다. 변수 이름 할당은 객체 수정과 다르다. 이미 있던 객체를 덮어쓰지 않고 다른 객체에 다시 할당하는 것이 핵심이다.

스타일상 부작용이 있는 함수는 결과로 None을 반환하는 것이 일반적이다. 다음 리스트의 sort() 메서드를 살펴보자

iitems = [10, 3, 2, 9, 5]
items.sort()
print(items) # [2, 3, 5, 9, 10]

sort() 메서드는 리스트 항목에 대한 제자리 정렬(in-place sort)를 수행한다. 즉, 인스턴스 내의 리스트 데이터에 대한 정렬을 수행한다는 것이다. 따라서 결과를 반환하지 않는다. 결과가 없다는 것은 부작용(side-effect)를 나타내는 강력한 지표이다. 이 경우 리스트의 항목들이 재정렬되었다.

때로는 함수에 전달하려는 데이터가 시퀸스 또는 dict와 같은 맵핑 타입일 때가 있다. 이들을 함수의 매개변수로 하나씩 전달할 때는 함수 호출에서 ***을 사용하면 된다. 다음은 그 예이다.

def func(x , y, z):
    ...

s = (1,2,3)

# 시퀸스를 인수로 전달
result = func(*s)

# 매핑을 키워드 인수로 전달
d = {'x':1, 'y':2, 'z':3}
result = func(**d)

리스트나 튜플과 같은 시퀸스 타입의 spread방법은 *s이고 dict와 같은 매핑타입은 **s로 표시하면 된다.

같은 함수 호출 내에서 *, **를 한 번 이상 사용할 수 도 있다. 인수를 누락하거나 중복값을 지정하면 오류가 발생한다. 파이썬은 함수 서명에 만족하지 않는 인수로 함수를 호출하도록 절대 허용하지 않는다.

반환값

반환값을 지정하지 않거나 return문을 생략하면 None이 반환된다.

이름있는 튜플을 통해서 여러개의 tuple에 이름을 붙여 한꺼번에 반환할 수 있다. 이를 사용하기 위해서 클래스를 만들고 NamedTuple을 상속받으면 된다.

from typing import NamedTuple

class ParseResult(NamedTuple):
    name: str
    value: str

def parse_value(text):
    '''
    name=val 형태의 텍스트를 (name, val)로 분할
    '''
    parts = text.split('=', 1)
    return ParseResult(parts[0].strip(), parts[1].strip())

r = parse_value('url=http://www.python.org')
print(r.name, r.value) # url http://www.python.org

이름있는 튜플은 일반 튜플과 동일하게 연산을 수행하거나 언패킹할 수 있다.

즉, 다음과 같은 것도 가능하다.

name, value = parse_value('url=http://www.python.org')
print(name, value) # url http://www.python.org

에러 처리

함수에서 원하는 결과가 나오지 않으면 예외를 발생시키는 것이 좋은 방법이다. 그러나 try-catch를 너무 사용하면 성능이 너무 많이 떨어지고 가독성이 좋아지지 않는다. 함수에서는 차라리 None,False,-1또는 특수한 값을 반환하는 것이 더 좋은 선택이다.

유효 범위 규칙

함수를 실행할 때마다 새로운 local namespace가 생성된다. 이 namespace는 매개변수의 이름과 값뿐만 아니라 함수 본문 안에서 할당된 변수 일체를 담는 환경이다.

name binding은 함수가 정의되고 함수 본문에서 할당된 모든 이름이 local 환경에 묶일 때 결정된다. 함수 본문에서 할당되지 않았지만 사용되는 이름(자유 변수(free variable))은 모두 global namespace에서 찾을 수 있는데, global namespace는 언제나 함수를 정의하는 모듈을 에워싼다.

함수를 실행하는 동안 발생할 수 있는 이름 관련 오류에는 두 가지 유형이 있다. global variable에서 정의되지 않은 free variable 이름에 접근하면 NameError예외가 발생한다. 아직 값이 할당되지 않은 local variable에 접근하면 UnboundLocalError` 예외가 발생한다.

말이 조금 어려웠는데, 결국 함수 안에 있는 변수는 지역 변수 - 전역 변수 순서로 매겨진다. 이때 변수가 지역 변수인지, 전역 변수인지가 결정되는 유효 범위는 함수를 정의할 때 결정된다.

x = 42
def func():
    print(x) # 실패 UnboundLocalError
    x = 14

func()

이 예제에서 print()함수는 전역 변수 x의 값을 출력하는 것처럼 보일 수 있는데, 이후 나오는 x의 할당은 x를 지역 변수로 처리한다. 값이 할당되지 않은 지역 변수에 접근하였으므로 오류가 발생한 것이다. 실제로 x = 14를 없애면 x는 전역변수로 평가되어 42를 출력하고 에러도 생기지 않는다.

이전 코드에서 print()함수를 제거하면 전역 변수값을 재할당하는 것처럼 보인다.

x = 42
def func():
    x = 13

func()
print(x) # 42

함수 안에서 변수에 값을 할당하면 그 변수는 지역 변수에 묶인다. 그 결과 함수 본문에 있는 변수 x는 밖에 있는 변수 x가 아닌 13을 가지는 새로운 객체를 가리키게 된다. 이러한 동작 방식을 변경하려면 global문을 사용한다. global문은 어떤 이름이 global namespace에 속한다는 것을 선언하며 전역 변수를 수정하려고 할 때 꼭 필요하다. 다음 예를 살펴보자.

x = 42
y = 37
def func():
    global x # 'x'는 global namespace의 'x'를 의미한다.
    x = 13
    y = 0
func() # x는 13이 되지만 y는 여전히 37이다.

그러나 global문의 사용은 일반적으로 좋은 방법은 아니다. 함수가 함수 밖의 상태를 변경하고 싶다면 코드를 작성할 때 클래스를 정의하고 인스턴스 또는 클래스 변수를 수정함으로서 상태를 변경하도록 하자.

class Config:
    x = 42

def func():
    Config.x = 14

print(func(), Config.x) # None 14

파이썬은 중첩 함수 선언을 지원한다. 다음은 그 예이다.

def countdown(start):
    n = start
    def display(): # 중첩 함수 선언
        print('T-minus', n)
    while n > 0:
        display()
        n -= 1

중첩 함수 내의 변수 이름은 어휘 유효 범위(lexical scoping)에 따라 묶인다. 즉, local scope를 먼저 찾고, 가장 안쪽부터 가장 바깥쪽에 이르기 까지 연속으로 둘러싸인 유효 범위에서 찾는다. 다시 말해, 이는 호출 될 때마다 달라지는 동적인 과정이 아니다. naming binding은 구문에 따라 함수를 선언할 때 결정된다. 전역 변수를 처리할 때와 마찬가지로 내부 함수는 바깥쪽 함수에서 정의한 지역 변수값을 재할당할 수 없다.

def countdown(start):
    n = start
    def display():
        print('T-minus', n)
    def decrement():
        n -= 1 # UnboundLocalError: local variable 'n' referenced before assignment
    while n > 0:
        display()
        decrement()

countdown(10)

n -= 1은 내부적인 def decrement scope안에 변수인 n이 없기 때문에 에러가 발생한 것이다.

이를 해결하기 위해 그 위의 local변수인 n을 가져와야한다. 이때 사용되는 것은 nonlocal이다.

def countdown(start):
    n = start
    def display():
        print('T-minus', n)
    def decrement():
        nonlocal n
        n -= 1
    while n > 0:
        display()
        decrement()

countdown(10)

nonlocal은 전역 변수를 참조하기 위해서는 사용할 수 없다. 반드시 외부 범위에 있는 지역 변수를 참조해야한다. 따라서 함수가 전역에 할당하려 한다면 앞서 설명한 대로 global문을 선언해 사용해야 한다.

중첩 함수 및 nonlocal선언문을 사용하는 것은 일반적으로 흔치 않다. 가령 내부 함수는 외부를 볼 수 없으므로 테스트와 디버깅을 어렵게 한다.

lambda 표현식

다음과 같이 lambda 표현식을 사용하여 익명 함수를 만들 수 있다.

lambda args: expression

args는 comma로 분리된 인수 목록이며 expression은 인수와 관련된 표현식이다. 다음 예를 살펴보자

a = lambda x, y: x+y
r = a(2,3) # r=5

lambda와 함께 사용하는 코드는 반드시 유효한 표현식이어야 한다. 여러 문장 또는 try, while문과 같이 표현식이 아닌 문장은 lambda표현식에서 사용할 수 없다. lambda 표현식의 유효 범위 규칙은 함수와 동일하다.

lambda 표현식은 간단한 콜백 함수를 구현할 때 주로 사용된다. 다음과 같이 sorted()와 같은 내장 연산과 함계 사용하는 것을 볼 수 있다.

# 단어 리스트를 고유 문자수로 정렬
words = ['ab','abcd', 'aaabbc', 'aaaaa','aabb'  ]
result = sorted(words, key=lambda word: len(set(word)))
print(result) # ['aaaaa', 'ab', 'aabb', 'aaabbc', 'abcd']

lambda표현식에 자유 변수(매개변수로 지정되지 않음)가 있으면 주의해야한다.

words = ['ab','abcd', 'aaabbc', 'aaaaa','aabb'  ]
result = sorted(words, key=lambda word: len(set(word)))
print(result) # ['aaaaa', 'ab', 'aabb', 'aaabbc', 'abcd']

이 예제에서 f(10)을 호출하면 x가 2였으므로 20이 출력될 것으로 예상했을 것이다. 하지만 그렇지 않다. f(10)의 평가는 평가 시점에 변수 x가 갖는 값을 사용한다. 따라서 lambda함수가 정의되었을 때의 값과 다를 수 있다. 이러한 동작 방식을 late binding이라 한다.

람다 함수를 정의할 당시의 변수값을 담고 있으려면 기본 인수(default argument)를 사용하자

x = 2
f = lambda y, x=x: x * y
x = 3
g = lambda y, x=x: x * y
print(f(10)) # 20
print(g(10)) # 30

기본 인수값은 함수를 정의할 때 평가되므로 함수가 정의될 당시의 x값을 담고 있어 이 코드는 의도대로 동작한다.

고차 함수(higher-order function)

파이썬은 고차 함수(higher-order function)을 지원한다. 즉, 함수를 다른 함수의 인수로 전달할 수 있으며 자료구조 안에 넣을 수 있다. 이는 함수가 1급 객체(first object)이기 때문인데, 정리하면 함수나 다른 데이터를 처리하는 방법에 차이가 없다는 것이다.

import time

def after(seconds, func):
    time.sleep(seconds)
    func()

# 사용 예제
def greeting():
    print("hello world!")

after(10, greeting) # 10초후에 "hello world" 출력

이 예제에서 after()func인수는 callback function으로 알려져 있다. 이는 after()함수가 인수로 제공된 함수를 callback한다는 사실을 나타낸다.

함수를 데이터로 전달하면 함수를 정의한 환경과 관련 정보를 암묵적으로 전달한다. 다음 코드와 같이 greeting()함수가 변수를 하나 사용한다고 하자.

def main():
    name = 'Guido'
    def greeting():
        print('hello', name)
    after(10, greeting)
main() # hello Guido

위 예제에서 namemain의 지역 변수이지만 호출되는 것은 그 안의 greeting함수에서 호출된다. greetingname의 값을 기억하고 after에 전달된 다음 name을 사용하는 것이다. 이를 클로저(closure)라고 한다. 즉, closure은 함수 본문을 실행하기 위해 필요한 변수를 모두 환경에 묶는 함수이다.

클로저와 중첩함수는 게으른 평가(lazy evaluation) 또는 지연 평가(delayed evaluation) 개념에 기초하여 코드를 작성할 때 유용하다. after함수를 보면 함수를 받지만 지금 평가하지 않고 특정 시점에 평가한다. 이는 다른 컨텍스트에서 발생한 무언가를 다루는 일반적인 프로그래밍 패턴이다.

가령, 어떤 프로그램에는 키 누름, 마우스 이동, 네트워크 패킷 도착과 같은 이벤트에 응답할 때만 실행하는 함수가 있을 수 있다. 이 경우 함수는 이벤트가 발생하기 전까지 평가되지 않는다. 함수가 최종적으로 실행될 때, 클로저는 함수에 필요한 모든 것을 얻을 수 있도록 보장한다.

다른 함수를 만들고 반환하는 함수 역시 작성할 수 있다. 다음의 예를 살펴보자

def make_greeting(name):
    def greeting():
        print('Hello', name)
    return greeting

f = make_greeting('Guido')
g = make_greeting('Ada')

f() # Hello Guido
g() # Hello Ada

greeting()함수는 오직 해당 함수를 실행할 때 평가된다. make_greeting이 종료되도 name 변수를 greeting이 기억하고 있고 이를 사용한다. 이것은 함수 클로저의 일부이다.

클로저에서 한 가지 주의할 점은 변수 이름 바인딩은 snapshot이 아니라 동적 프로세스라는 것이다. 이는 클로저가 변수 name과 이 변수에 할당된 가장 최근의 값을 가리킨다는 것이다. 다음은 이 현상이 발생할 수 있는 코드를 보여준다.

def make_greetings(names):
    funcs = []
    for name in names:
        funcs.append(lambda: print('hello', name))
    return funcs

a, b, c = make_greetings(['Guido', 'Ada', 'Margaret'])
a() # hello Margaret
b() # hello Margaret
c() # hello Margaret

이 코드는 서로 다른 클로저 함수가 lambda로 만들어진다. 그러나 namefor-loop를 반복하면서 값이 바뀐다. 하지막 값인 Margaret만이 name에 있기 때문에 실행할 때 평가되는 클로저는 Margaret만 출력하게된다.

이러한 문제를 해결하고 싶다면 함수가 정의될 때 평가된다는 성질을 이용하면된다.

def make_greetings(names):
    funcs = []
    for name in names:
        funcs.append(lambda name=name : print('hello', name))
    return funcs

a, b, c = make_greetings(['Guido', 'Ada', 'Margaret'])
a() # hello Guido
b() # hello Ada
c() # hello Margaret

즉, 함수의 매개변수에 default value를 주어 함수가 선언되는 당시의 name를 hold하게 하는 것이다.

콜백 함수에서 인수 전달

콜백 함수에서 한 가지 어려운 점은 제공된 함수에 인수를 전달하는 것이다. 앞서 살펴본 after()함수를 다시 보자

import time

def add(x,y):
    print(f'{x} + {y} -> {x+y}')
    return x + y

def after(seconds, func):
    time.sleep(seconds)
    func()

after(10, add(2,3)) # 2 + 3 -> 5 TypeError: 'int' object is not callable

위 코드가 실패한 이유는 add함수 객체를 넘긴것이 아니라 add(2,3)함수의 실행 결과인 5를 넘겼기 때문이다. add()를 원하는 인수와 함께 호출하더라도 이를 동작하게 만드는 확실한 방법은 없는 것으로 보인다.

이 문제는 일반적으로 함수와 함수형 프로그래밍 사용에 관한 더 큰 설계 이슈(함수 합성, function composition)이라는 것을 넌지시 알려준다. 함수를 다양한 방법으로 결합할 때, 함수의 입력과 출력이 함께 연결되는 방법을 생각해볼 필요가 있다. 이 문제는 단순하지 않다.

이 경우 한가지 해결책은 lambda를 사용하여 인수가 없는 함수로 계산을 패키지화하는 것이다.

after(10, lambda: add(2,3))

이처럼 인수가 없는 함수를 thunk(청크)라고 한다. 청크는 기본적으로 인수가 없는 함수로 호출될 때 나중에 평가되는 표현식이다. 이는 표현식의 평가를 다음에 하도록 지연하는 범용적인 방법이다. lambda에 표현식을 넣고 실제로 값이 필요할 때 함수를 호출한다.

lambda를 사용하는 대신 functools.partial()을 사용하여 다음과 같이 부분적으로 평가되는 함수를 생성할 수도 있다.

from functools import partial
import time

def add(x,y):
    print(f'{x} + {y} -> {x+y}')
    return x + y

def after(seconds, func):
    time.sleep(seconds)
    func()

after(10, partial(add, 2, 3)) 

partial()은 이미 지정했거나 캐시된 하나 이상의 인수를 호출할 수 있는 객체를 생성한다. 콜백 및 기타 응용 프로그램에서 예상되는 function signature과 일치하지 않는 함수를 만들 대 유용한 방법이 될 수 있을 것이다.

다음은 partial의 사용법이다.

from functools import partial
import time

def func(a,b,c,d):
    print(a,b,c,d)

f = partial(func, 1, 2) # a = 1, b = 2로 고정
f(3,4) # func(1,2,3,4)
f(10,20) # func(1,2,10,20)

g = partial(func, 1, 2, d = 4) # a = 1, b = 2, d = 4로 고정
g(3) # func(1,2,3,4)
g(10) # func(1,2,10,4)

partiallambda는 비슷한 목적으로 사용할 수 있지만 두 기술 사이에는 의미적으로 중요한 차이점이 있다.

partial을 사용하면 partial함수를 처음 정의할 때 인수가 평가되고 묶이게 된다. 인수가 없는 lambda를 사용하면 lambda함수가 다음에 실제로 실행될 때 인수가 평가되고 묶인다.(평가는 모두 지원된다.) 다음 코드를 살펴보자

from functools import partial
import time

def func(a,b):
    print(a,b)

a = 2
b = 3
f = lambda: func(a,b)
g = partial(func, a, b)
f() # 2 3 
g() # 2 3

a = 10
b = 20
f() # 10 20
g() # 2 3 

partial은 사용할 할 때 매개변수를 전달하여 완전히 평가된다. 이러한 특징을 이용하여 partial()로 생성한 호출 가능한 객체는 바이트로 직렬화되어 파일에 저장되고 네트워크 연결을 통해 전송할 수 있다. 가령 pickle 표준 라이브러리 모듈을 사용한 경우이다. 이는 lambda함수에서는 불가능하다.

따라서 다른 프로세스나 장치에서 실행되는 파이썬 인터프리터에 함수를 전달하는 응용 프로그램에서는 partial()이 더 적합하다는 것을 알 수 있다.

partial 함수 응용 프로그램은 커링(currying)이라 알려진 개념과 밀접하게 관련되어있다. 커링은 다중 인수 함수를 중첩된 단일 인수 함수의 연쇄로 표현하는 함수형 프로그래밍 기술이다. 다음은 그 예이다.

def f(x,y,z):
    return x + y + z

# 커링된 버전
def fc(x):
    return lambda y: (lambda z: x + y + z)

# 사용 예제
a = f(2,3,4)
print(a) # 9
b = fc(2)(3)(4)
print(b) # 9

이는 그닥 실용적이지는 않다.

다시 인수 전달 문제로 돌아가서 콜백 함수에 인수를 전달하는 또 다른 방법은 외부 호출 함수에 별도의 인수로 전달하는 것이다. after()함수를 살펴보자.

import time

def add(x,y):
    print( x + y)

def after(seconds, func, *args):
    time.sleep(seconds)
    func(*args)

after(10, add, 2, 3) # 5

만약 after함수의 func에 키워드 인수를 넣고 싶다면 다음과 같은 방법도 가능하다.

def after(seconds, func, /,*args,**kwargs ):
    time.sleep(seconds)
    func(*args, **kwargs)

그런데, 특정 키워드만 집어서 넣고 싶다면 어떻게 해야할까??

func()에 키워드 인수를 지정하고 싶다면 partial()을 사용하면 가능하다.

after(10, partial(add, y=3), 2)

그러나, 너무 복잡하게 callback함수에 인수를 지정하려고 하지말도록 하자. 보기좋지 않은 복잡한 코드는 시스템을 더욱 어렵게만 만들 뿐이다.

콜백에서 결과를 반환

다음과 같이 수정된 after함수를 살펴보자.

def after(seconds, func, *args):
    time.sleep(seconds)
    return func(*args)

이 코드는 제대로 동작하지만, 시사하는 점 몇 가지가 있다.

첫번째는 예외처리와 관련되어 있다. 다음의 두 코드를 시도해보자.

after("1", add, 2, 3) # 실패: TypeError(정수를 입력해야 함)
after(1, add, "2", 3) # 실패 TypeError(정수를 문자열에 연결할 수 없다.)

두 경우 모두 TypeError가 발생하지만 이는 서로 다른 이유와 다른 함수로 인해 발생한다. 첫번째 에러는 after()함수 자체의 문제이다. time.sleep()에 잘못된 인수가 지정되었기 때문이다. 두번째 에러는 콜백함수 func(*args)의 실행 문제 때문이다.

이 두 경우를 구별하는 게 중요하다면 몇 가지 방법이 있다. 첫번째 방법은 콜백 오류를 다른 에어와 구분지어 처리하도록 패키징하는 것이다.

import time

class CallbackError(Exception):
    pass

def add(x,y):
    print( x + y)

def after(seconds, func, *args):
    time.sleep(seconds)
    try:
        func(*args)
    except Exception as err:
        raise CallbackError('Callback function failed') from err

이 수정 코드에서는 제공된 callback 오류를 자체 예외 범주로 분리해버린다. 다음 코드와 같이 사용하면 된다.

try:
    after(2, add, x ,y)
except CallbackError as err:
    print("It failed. Reason", err.__cause__)

after()자체를 실행하는 데 문제가 있다면 해당 예외는 잡히지 않고 전파된다. 반면 제공된 콜백 함수 실행과 관련된 문제는 접혀서 CallbackError로 보고된다. 이 모든 상황이 아주 미묘하지만 실제로 에러를 관리하는 것은 어렵다. 이 방식은 책임 소재를 명확히 하며 after()의 동작 방식을 더 쉽게 문서화 할 수 있게 만든다.

또 다른 방법은 콜백 함수의 결과를 값과 오류를 모두 포함하는 일종의 결과 인스턴스로 패키징하는 것이다. 가령 다음과 같은 클래스를 정의한다.

class Result:
    def __init__(self, value=None, exc=None):
        self._value = value
        self._exc = exc
    def result(self):
        if self._exc:
            raise self._exc
        else:
            return self._value

그런 다음 이 클래스를 사용하여 after()함수의 결과를 반환한다.

def after(seconds, func, *args):
    time.sleep(seconds)
    try:
        return Result(value=func(*args))
    except Exception as err:
        return Result(exc=err)

# 사용 예제
r = after(1, add, 2, 3)
print(r.result()) # 5

s = after("1", add, 2, 3) # TypeError

t = after(1, add, "2", 3) # "Result"를 반환
print(t.result()) # TypeError 발생

두번째 방식은 콜백함수의 결과를 별도의 단계로 지연시켜 동작한다. after()에 문제가 발생하면 즉시 보고되는 반면, 콜백 func()에 무제가 발생하면 사용자가 result()메서드를 호출하여 결과를 얻으려고 할 때 보고된다.

결과를 특수한 인스턴스로 감쌌다가 나중에 푸는 방식은 현대 프로그래밍 언어에서 자주 사용하는 패턴이다. 이를 사용하는 이유는 타입 검사가 용이하기 때문이다. 가령, after()에 타입 힌트를 추가하면 함수의 동작 방식이 완전히 정의된다. 즉, 항상 Result를 반환하고 그 외는 반환하지 않는다.

def after(seconds, func, *args) -> Result:
    ...

이러한 패턴은 파이썬 코드에서 흔히 볼 수 없지만 스레드 및 프로세스와 같은 동시성 기본 연산을 수행할 때 살펴볼 수 있다. 가령, Future라 불리는 인스턴스는 thread pool과 함께 작업할 때 다음과 같이 동작한다.

from, concurrent.futures import THreadPoolExecutor

pool = ThreadPoolExecutor(16)
r = pool.submit(add, 2 ,3)
print(r.result())

0개의 댓글