본문 바로가기
Programming/Python

파이썬 함수 Deep-Dive

by DUSTIN KANG 2023. 11. 22.

함수

함수를 사용하는 것은 코드를 재사용하는 측면 혹은 가독성 측면에서 이득을 준다. 

실제로, 파이썬에서 함수로 정의한 코드와 그렇지 않은 코드를 비교했을 때 함수로 정의한 코드가 훨씬 속도가 빠르다고 한다.

 

파이썬에서 함수는 다음과 같은 특징을 갖는다.

  • 일급객체(First Class Citizen) : 함수를 변수나 데이터 구조에 직접 담을 수 있고 매개변수로 전달이 가능하며 리턴 값으로 사용할 수 있다는 특징이 있다.
  •  함수를 작성할 땐 camel_case로 작성한다.
  • 함수 외부에 선언된 변수는 전역 변수(Global Variable)이라 하며 내부에 선언된 변수를 로컬 변수(Local Variable)라고 한다.
    • 만약, 전역변수를 내부에 접근하고자 하는 경우 `global` 키워드를 사용해 액세스 할 수 있다.

패킹과 인자

*args

  • 함수에 전달되는 매개변수 수를 알 수 없거나 리스트나 튜플같은 iterable한 객체를 언패킹해서 원소들로 전달하고자 할때 사용한다.
  • 받을 수 있는 결과 값은 항상 튜플로 출력된다.
  • 항상, 일반 변수를 앞에 두고 `*` 표시로 지정해주어야 한다. 즉, 순서도 중요하다.
def log(message, *values):
    if not values:
        print(message)
    else:
        print(message, values)

log("선택한 숫자", [1,2]) # 선택한 숫자 ([1,2],)
log("선택한 숫자") # 선택한 숫자
log("선택한 숫자", 1,2) # 선택한 숫자 (1,2)

 

**kwargs

  • 함수에 전달되는 키워드 매개변수 수를 모르거나 딕셔너리의 키와 값 쌍을 함수에 전달하고자 할때 사용한다.
  • `**kwargs`와 `*args` 둘다 사용하고자 할땐 `*args`를 먼저 두어야 한다.
  • 받을 수 있는 결과 값은 항상 딕셔너리 형태로 출력된다.
def cal(func, **kwargs):
    for k, v in kwargs.items(): # 키와 값으로 전달받는다.
        print(k, v) # n1, 1
        if func == 'add':
            return kwargs['n1'] + kwargs['n2'] # 4
        
print(cal(n1=1, n2=3,func='add'))
더보기

위치, 키워드인자

함수를 정의할 때, 위치와 일치시키는 위치 인자, 매개변수에 이름으로 일치시키는 키워드 인자를 지정해 함수를 실행시킬 수 있다. 여기서 `/` 와 `*`를 이용해 인자들을 강제화하면 함수를 명확하게 하는데 도움을 줄 수 있다. 

def safe_division(id, name, /, score=0, *, check=False):
    # /와 * 사이에 있는 인자는 자유롭게 사용할 수 있는 인자이다.
    print(name)

`/` 앞에 있는 인자 들은 위치 기반 인자이며 `*` 뒤에 있는 인자는 키워드 기반 인자이다. 추가로, 함수를 호출하는 경우에도 `/` 와 `*`을 적어주어야 한다.

언패킹

언패킹(Unpacking)은 패킹과 반대되는 개념으로 하나의 객체를 풀어주는 역할을 한다.

numbers = [1,2,3,4]
strings = 'abcd'

print(*numbers) # 1 2 3 4
print(*strings) # a b c d
print(*strings[1]) # b

네임스페이스

네임스페이스(namespace)는  프로그래밍 언어에서 객체를 이름에 따라 구분할 수 있는 범위를 의미한다. 

예를 들어서, 우리가 다음과 같이 변수에 객체를 할당하면 변수와 연결된 객체는 네임스페이스 안에 저장된다. a라는 이름에 객체 20이 지정되었다는 것을 확인할 수 있다.

a = 20
print(globals()) # 전역 네임스페이스를 확인하는 함수이다.

 

그렇다면 이 네임스페이스는 왜 필요할까?

네임스페이스는 변수와 함수의 이름을 겹치치 않게 범위를 제한하기 위해서이다. 소속된 네임스페이스가 다르면 같은 이름이라도 다른 개체를 가르키도록 하는 것이다.

 

파이썬의 네임스페이스는 3가지로 분류할 수 있다.

  • 전역 네임스페이스 : 모듈 전체에 통용될 수 있는 이름들이 소속된다.
  • 지역 네임스페이스 : 함수나 메서드 별로 존재하며, 함수 내 지역 변수들이 소속된다.
  • 빌트인 네임스페이스  : 기본 내장 함수나 기본 예외들이 소속된다. 파이썬으로 작성된 모든 코드 범위가 포함된다.

LGB 규칙, G와 L 사이 새로 등장한 규칙인 E(Enclosed)가 있는데 내부 함수 기준으로 욉 함수 범위를 말한다. 이범위는 중첩 함수나 람다를 나타낸다.

 

한번 다음 예제를 살펴보자.

a = 3

def outer():
    a = 30
    b = 'outer'
    print(locals())

    def inner():
        a = 300
        b = 'inner'
        print(locals())

    inner()

outer()

print(locals()['a'])

 

결과적으로 파이썬 인터프리터는 Level 0부터 실행될 것이다.

`locals()`는 실행 공간에 namespace를 출력한다. 함수 안에서 `locals()`를 실행했기 때문에 함수 범위 안에서만의 namespace를 출력한다. 이 말은 즉슨, 전역에서 `locals()`를 실행하면 `globals`와 동일하다.

 

네임스페이스는 참조가 가능한데 내부함수가 상위 함수를 참조하는 것은 가능 하지만 외부 함수가 내부를 참조하는 것은 불가능하다.

 

만약, 내부 함수가 외부의 변수를 변경하고 싶으면 `global` 이나 `nonlocal` 키워드를 사용하면 된다.

우리는 메인 모듈을 실행시킬 때 `if __name__ == '__main__': main()` 이라는 코드를 작성한다. 
여기서 왜 이렇게 써야 했냐면 현재 모듈의 네임스페이스가 `__main__`에 해당하면 현재 모듈을 실행하라는 뜻이다. `__main__`은 커맨드 라인상에서 실행했을 경우를 의미한다. 일반 파이썬 문서에서 `globals`로 확인해보면 문서이름을 확인할 수 있다.

 

타입 힌팅(Type Hinting)

함수에서 리턴 값을 반환하지 않는 경우 `None`이 반환된다. 이러한 경우 조건절에서는 `False`로 취급하기 때문에 예상치 못한 문제가 발생한다. 이때 타입 힌트를 사용하면 `None`이 반환되는 문제를 막을 수 있다.

def careful_divide(a: float, b: float) -> float:
    """
    ### Doc String
    a를 b로 나눈다.
    Raises:
        ValueError: b가 0이어서 나눗셈을 할 수 없을 때
    """
    try:
        return a / b
    except ZeroDivisionError as e:
        raise ValueError('잘못된 입력')
        
        
"""
 
from typing import Optional

def log(msg: str, when: Optional[datetime]=None) -> None:

"""

 

위 코드를 보면 개발자들이 코드를 더 이해하기 싶게끔 만들어준다. 추가로, 독 스트링(Doc-String) 까지 넣어주면 금상첨화(?)다.

이렇게 어떤 데이터를 받아야 하는지에 대한 정보를 알려주는 방법이 어노테이션(Annotation) 이라고 있다. 아래 처럼 변수에 대한 설명이나 메타데이터를 추가할 때 사용한다.

result : int = add_numbers(5, 19)
시간과 같은 동적인 디폴트 인자를 지정할 땐 None을 사용하자.
가끔 `datetime.now()`와 같은 동적인 인자를 넣어야 하는 경우가 있다. 이땐, 인자를 None으로 두고 동작시키면 타임스탬프가 고정되지 않고 동적으로 호출할 수 있다. 

클로저와 데코레이터(Decorator)

클로저 함수

클로저 함수(Closure)함수 안에 네임스페이스의 상태값을 기억하는 함수를 말한다. 클로저를 사용하면 네임스페이스(스코프)가 클로저로 생성되기 때문에 변수의 범위가 섞일리 없어 명확해진다는 장점이 있다. 

그라고 어떤 함수가 클로저 함수가 되기 위해 세가지 조건을 만족해야 한다.

  • 해당 함수는 어떤 함수 내의 중첩된 함수여야 한다.
  • 해당 함수는 자신을 둘러싼 함수 내의 상태값을 반드시 참조해야 한다.
  • 해당 함수를 둘러싼 함수는 해당 함수를 반환해야 한다.

 

클로저 함수의 특징으로는 자신을 둘러싼 함수의 상태 값을 참조하는데, 이 값은 함수가 메모리에서 사라져도 그대로 값을 유지할 수 있다.

다음 예시를 보자.

`cal()` 함수는 세금(`fee`)를 변수로 `item_price` 함수를 반환한다. `item_price` 내장함수는 클로저로 상위 스코프의 `fee`를 참조하고 있다. 그러면, 클로저로 들어온 상품가격에 각 세금을 곱해 반환하는 예시이다.

def cal():
    fee = 100
    def item_price(price): # Closure
        result = price + fee
        print(result)
    return item_price

item = cal()
item2 = cal()

item(950) # 1050
item(1200) # 1300
item2(1500) # 1600

 

중요한 건 여기서 `cal`이라는 감싸는 함수를 삭제해도 값은 그대로 유지된다는 것이다.

`item_price`라는 함수가 외부함수인 `cal`를 참조하기 때문에 cal 함수 내부에 저장하는 클로저가 생성되었고 item을 실행할 때 클로저를 참조해 출력할 수 있게 되는 것이다. 

del(cal)
item(1000)

 

주로, 클로저 함수는 지역 변수와 코드를 묶어 사용하고 싶을 때 활용한다. 또한, 클로저 내부에 있는 지역 변수를 바깥으로 접근을 숨기고 싶을 때 활용하기도 한다. 

 

`fee` 처럼 값 뿐만 아니라 일급객체이기때문에 함수도 사용될 수 있다.

def cal(func):
    def item_price(price):
        result = func(price)
        print(result)
    return item_price

def KRW_fee(price):
    return price + 100

item = cal(KRW_fee)

item(1000) # 1100
item(1300) # 1400
print(item.__closure__[0].cell_contents)
# <function KRW_fee at 0x10c5a1080>

데코레이터

우리가 아는 데코레이터(Decorator)는 클로저와 비슷한 로직으로 이루어져 있다. 

앞서, 나왔듯 데코레이터의 특징은 함수를 인자로 전달한다는 것이다. 내부 함수에서 인자로 받는 함수를 `func`으로 사용한다.

def main_func(origin_func):
    def wrapper_func(*args):
        return origin_func(*args)
    return wrapper_func

def hello():
    print('반갑습니다.')

def bye(name):
    print(f"{name}님 안녕히 가세요.")

hello_deco = main_func(hello) # @심볼로 대체할 수 있음
bye_deco = main_func(bye)

hello_deco()
bye_deco("John")

 

여기서, `bye` 함수나 `hello` 함수는 `main_func`에서 그대로 사용되지 않는다. 데코레이터로 인해 `wrraper_func`라는 이름으로 지정되기 때문이다.  이러한 동작으로 인해 인트로스펙션(Introspection)하는 도구에 문제가 된다고 한다. `wrapper_func` 함수로 내장함수를 호출하거나 직렬화를 실행하면 원래 위치를 찾을 수 없기 때문이다. 그렇지만 이 문제를 해결하기 위해 `wraps`라는 도우미 함수로 데코레이터 작성을 도와준다. 

print(hello_deco)
# <function main_func.<locals>.wrapper_func at 0x1067965c0>
from functools import wraps

def main_func(origin_func):
    @wraps(origin_func)
    def wrapper_func(*args):
        return origin_func(*args)
    return wrapper_func
    
# ...

print(hello_deco)
💡 인트로스펙션(introspection)
인트로스펙션은 실행 시점에 어떻게 실행되는지 관찰하는 것을 의미합니다. 리플렉션(reflection)이랑 헷갈리는 경우가 있는데 리플렉션은 실행 시점에 프로그램을 조작하는 것을 의미합니다.

 

`@` 심볼을 이용하면 데코레이터 함수를 간단하게 작성할 수 있다.

from functools import wraps

def main_func(origin_func):
    @wraps(origin_func)
    def wrapper_func(*args):
        return origin_func(*args)
    return wrapper_func

@main_func
def hello():
    print('반갑습니다.')

@main_func
def bye(name):
    print(f"{name}님 안녕히 가세요.")

hello()
bye("dustin")

 

데코레이터는 중복 함수를 최소화하여 가독성을 높일 수 있다는 장점을 지녔다. 다만, 여러개의 데코레이터를 사용한다면 위치 참조를 찾기 어려워 오히려 가독성이 떨어진다는 단점이 있기때문에 과하게 사용하지 않는게 좋다.


람다 표현식(lambda Expression)

람다함수는 이름이 없는 함수(익명함수)라고 부른다.

보통 한줄로 표현할 수 있는 함수라고 하며 `lambda 인자 : 표현식`의 형식으로 람다 함수를 표현한다. 

함수를 매우 간단하게 사용할 수 있는 식이다. 주로, 정렬 라이브러리를 사용할 때 key 기준으로 사용할 때가 많다.

target = ['yellow', 'blue', 'green', 'red']

print(sorted(target, key= lambda x: len(x)))
# ['red', 'blue', 'green', 'yellow']

print((lambda x: "홀수" if x % 2 == 1 else "짝수")(9)) # 홀수
print((lambda x: "홀수" if x % 2 == 1 else "짝수")(6)) # 짝수

 

Lambda 함수 - 파이썬 공식문서↗

 

 


☕️ 포스팅이 도움이 되었던 자료

 

오늘도 저의 포스트를 읽어주셔서 감사합니다.

설명이 부족하거나 이해하기 어렵거나 잘못된 부분이 있으면 부담없이 댓글로 남겨주시면 감사하겠습니다.