10  함수(functions)

함수는 요청을 받았을 때 실행되는 문장(statements)의 집합이다.

함수 실행을 요청하는 것을 함수 호출(function call)이라고 한다. 함수를 호출할 때 arguments를 전달하고, 이렇게 전달된 값은 함수가 실행될 때 데이터로 사용된다. 파이썬에서 하나의 함수는 항상 하나의 결과를 반환한다: None 또는 하나의 값을 계산의 결과로 반환한다. 클래스 문 안에서 정의되는 함수는 메서드(method)라고 한다.

파이썬에서 함수는 객체(값)의 하나로, 다른 객체와 똑같이 취급된다. 그렇기 때문에 다음과 같은 것들이 가능하다. 파이썬에서 함수가 일반적인 객체라는 것을 함수는 일급 객체이다라고 표현하기도 한다.

10.1 함수 정의, 호출, 반환값, parameters와 arguments

함수를 정의하는 방법은 다음과 같다. def 키워드를 쓰고, 다음에 함수의 이름, 그 다음에 괄호 안에 파리미터들을 코마로 구분하여 넣는다. 그 다음 콜론(:)을 쓴다.

def function_name(parameters):
   statement(s)

파이썬 인터프리터가 def를 만나면 함수의 body를 compile하여 함수 객체(function object)를 만들고, 이것을 function_name에 바인딩한다. 다음은 twice라는 함수를 정의하는 예이다. 파이썬 인터프리터가 이 def 문을 실행하면, twice라는 함수 객체를 만들고, 이 객체 안에 여러 attributes에 함수와 관련된 정보를 저장한다.

파라미터(parameters)는 함수를 호출할 때 전달된 값들을 받아서 함수 안에서 로컬 변수로 사용되는 것들을 말한다.

함수는 하나의 값을 반환한다(None 또는 하나의 값), return 문을 사용하고, 뒤에 오는 값이 이 함수의 반환값이 된다. return 문이 없는 경우에는 함수의 body 끝에 가서 실행이 멈추고 None을 반환한다.

def twice(x):
    return x*2

함수를 실행하는 것을 함수 호출(function call) 또는 실행(function invocation)이라고 하는데, function_name(arguments)과 같이 함수 이름 끝에 괄호를 붙여서 함수를 호출한다. 함수를 호출할 때 괄호 안에 넣는 값들을 arguments라고 하고, 이것들은 parameters에 매칭되어 함수 안으로 넘겨진다.

twice(3)
6

10.2 함수 객체의 attributes

앞에서 파이썬 함수는 일급 객체(first-class object)이기 때문에, 함수 역시도 다른 객체와 마찬가지로 attributes를 가질 수 있다.

파이썬 인터프리터가 def문을 통해서 함수를 정의하면서 자동으로 만들어지는 함수 속성들이 있다. 다음과 같이 함수를 정의한다고 하자.

def example_function(a, b=2):
    """This is an example function."""
    return a + b
  • __name__은 함수의 이름이다.
example_function.__name__
'example_function'
  • __doc__은 함수의 docstring이다.
example_function.__doc__
'This is an example function.'
  • __defaults__는 named parameters의 값들을 가진다. named parameters가 없으면 None이 된다.
example_function.__defaults__
(2,)
  • __code__는 함수 body를 컴파일한 code object를 나타낸다1.
example_function.__code__
<code object example_function at 0x103e88370, file "/var/folders/6k/1d6wwd8x3057x_f57q9q7s1r0000gn/T/ipykernel_9661/2276525134.py", line 1>

10.3 Docstrings

Docstrings은 파이썬 함수와 메서드, 클래스, 모듈에 관한 정보를 지정하는 방법이다.

함수 docstring은 함수 body의 첫 번째 문장으로 triple quotes를 사용하여 만든다. 이 값은 function_name.__doc__으로 접근할 수 있고, help() 함수에서도 사용된다.

def example_function(a, b=2):
    """This is an example function.
    
    Args:
        a (int): The first parameter.
        b (int, optional): The second parameter. Defaults to 2.
    
    Returns:
        int: The sum of a and b.
    """
    return a + b

help(example_function)
Help on function example_function in module __main__:

example_function(a, b=2)
    This is an example function.

    Args:
        a (int): The first parameter.
        b (int, optional): The second parameter. Defaults to 2.

    Returns:
        int: The sum of a and b.

10.4 함수의 반환값과 부수 효과(side effect)

파이썬 함수는 호출했을 때 하나의 값을 반환한다.

파이썬 함수를 호출했을 때 return 문을 만나면 함수가 종료되고, return 다음에 오는 표현식이 이 함수의 반환값이 된다. return 문이 없는 함수도 있는데, 이 경우에는 None을 반환한다.

def my_fun():
    print("Hello, World!")

k = my_fun()
print(k)
Hello, World!
None

위 함수와 그 결과들을 보자.

  • my_fun이라는 함수는 return 문을 가지고 있지 않다.
  • “Hello, World!”가 출력되는 이유는 k = my_fun() 코드가 실행되면서, 함수가 호출되기 때문이다.
  • 이 함수는 return 문이 없고 호출한 결과를 변수 k에 할당했는데, 그 값은 None이라는 것을 확인할 수 있다.
  • return 문이 없는 함수는 함수의 body 마지막에 이르렀을 때 종료되고, None 값을 반환한다.

이와 같이 함수가 (None 값 말고) 값을 반환하지 않고, 함수 외부에 어떤 일을 하는 경우 부수 효과(side effect)를 가진다고 말한다.

어떤 경우 함수가 하나의 값이 아니라 복수의 값을 반환하는 것처렴 보일 수도 있다. 대표적인 경우가 나눗셈의 몫과 나머지를 계산하는 divmod() 함수이다.

q, r = divmod(19, 3)
print(q)
print(r)
6
1

divmod() 함수는 그 결과를 2개의 값을 독립적으로 반환하지 않고, 그것을 하나의 튜플로 묶어서 반환하기 때문에 실제로 하나의 값을 반환하는 것이다. q, r = divmod(19, 3) 코드는 그렇게 반환되는 튜플에 대하여 unpacking을 하는 것이다.

divmod() 함수와 유사한 함수를 만들어 보면 다음과 같이 할 수 있다.

def my_divmod(x, y):
    return x // y, x % y

my_divmod(19, 3)
(6, 1)

return x // y, x % y를 볼 때, 이것을 2개의 값으로 보면 안 되고 return (x // y, x % y)로 하나의 튜플인데 괄호가 생략된 것임을 알아 볼 수 있어야 한다.

10.5 Parameters

함수 정의에 사용되는 용어로, 함수가 호출될 때 arguments로 전달된 값을 받아서 함수 body 안에서 사용되는 변수로 사용되는 이름(name)을 parameters라고 한다.

  • parameters: 함수를 정의할 때
  • arguments: 함수를 호출할 때

파이썬 함수는 다양한 종류의 파라미터를 제공한다.

10.5.1 Positional Parameters와 Named Parameters

함수를 정의할 때

  • positonal parameter는 이름만 단독으로 지정하는 것
  • named parameters(keyword/optional/default parameters 라고도 함)은 이름 = 값 형태로 지정하는 것
def my_fun1(x):
    print(x)

위 예에서 x는 단독으로 존재하는 positional parameter이다. 이렇게 정의된 경우 다음과 같은 방법으로 호출할 수 있다.

  • 3만 전달하는 경우를 positional argment로 사용한 예
  • x=2인 경우는 named argument로 사용한 예

따라서 positional parameter로 정의된 경우는, positional argument 또는 named argument로 호출할 수 있다.

my_fun1(3)
my_fun1(x=2)

다음 예는 x를 named parameter로 정의한 경우이다.

  • x=3으로 값에 이름을 지정하여 named parameter라 한다.
  • 이렇게 하면 x의 default 값을 정해서, 호출할 때 굳이 값을 지정하지 않아도 되고 지정해도 때문에 optional parameter라 불리기도 하고, default 값을 사용하여 함수가 호출되어 default parameter라고 부른다.
  • 관행적으로 keyword parameter라는 용어가 사용되어 왔다.
def my_fun2(x=3):
    print(x)

my_fun2()
my_fun2(4)
my_fun2(x=5)
3
4
5

함수를 정의할 때, positional parameter와 names parameter를 둘 다 사용하는 경우에는 항상 positional parameter가 먼저 나오고, 그 다음 named parameters가 와야 한다.

def my_fun3(x, y=2):
   return x + y 

my_fun3(1)
3

그렇게 하지 않으면 오류가 발생한다.

def my_fun4(y=2, x):
    return x + y
  Cell In[15], line 1
    def my_fun4(y=2, x):
                     ^
SyntaxError: parameter without a default follows parameter with a default

다음 my_fun5() 함수는 다양한 방법으로 호출할 수 있다. Arguments가 Parameters에 제대로 매칭되는 경우 함수 호출이 제대로 이뤄진다. 이 과정에 대해서는 Python in a Nutshell를 읽는다(복잡하다).

def my_fun5(x, y=2):
   return x, y

print("1: ", my_fun5(1))
print("2: ", my_fun5(1, 3))
print("3: ", my_fun5(y=2, x=1))
print("4: ", my_fun5(x=2))
print("5: ", my_fun5(1, y=3))
1:  (1, 2)
2:  (1, 3)
3:  (1, 2)
4:  (2, 2)
5:  (1, 3)

10.5.2 임의의 개수의 인자를 받을 수 있게 하는 방법

내장 함수 print()는 인자의 개수가 사전에 정해지지 않게 되어 있다. print(a), print(a, b, c) 등으로 모구 가능하다. 이와 같이 여러 인자들을 모을 수 있도록 정의하는 것을 positional argument collector라고 한다. 이 파라미터는 다음과 같이 정의하고 작동한다.

  • *로 시작한다. 이름은 반드시 args라고 하지 않고 사용자가 임의로 정할 수 있다. 그런데 많은 사람들이 이렇게 사용한다.
  • *args는 positional argument collector로 positional arguments 들만 수집한다(named arguments는 수집하지 않는다).
  • *args는 argument-parameter 매칭후 남은 같들을 하나의 튜플(tuple)로 수집하고, 함수 body에서 args라는 이름의 튜플로 사용된다.

이 지식을 이용하여 임의의 값들을 받아서 그 합을 계산하는 sum2() 함수를 만들어 보자. 참고로 내장 함수 sum()은 하나의 iterable을 받는다.

def sum2(*args):
    return sum(args)

print(sum2(1, 3))
print(sum2(2, 4, 5))
print(sum2(3, 4, 5, 6, 8))
4
11
26

함수의 시그너처가 positional argument collector를 가지는 경우, 함수 호출할 때 뒤에 오는 named parameter는 positional argument로 함수를 호출할 수 없다. 반드시 named argument로 지정해 주어야 한다.

*args는 positional arguments을 수집하는 반면 **kwargs는 named arguments를 수집한다.

  • **로 시작하고, 임의의 이름을 사용할 수 있다. 관행적으로 **kwargs으로 사용해 왔다.
  • named arguments을 수집하여, kwargs라는 이름의 파이썬 딕셔너리(dict)에 이름: 값 항목들로 만든다.
  • 함수 body 안에서 kwargs라는 딕셔너리로 사용된다.
def print_details(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_details(name="Alice", age=30, city="New York")
print("\n")
print_details(title="Inception", director="Christopher Nolan", year=2010)
name: Alice
age: 30
city: New York


title: Inception
director: Christopher Nolan
year: 2010

10.5.3 positional-only, keyword-only Parameters

Positinal-only paramenter는 함수 정의에서 /라는 기호를 사용하여 포함한다. 이 / 앞에서 명시된 파라미터들에 대한 값은 반드시 positional arguments로 전달해야 한다.

다음과 같이 position_only 함수가 정의되면, 이 함수를 호출할 때 x, y 파라미터에 대응하는 arguments는 반드시 positional arguments로 주어야 한다.

def position_only(x, y, /):
    return x + y 

그래서 다음과 같은 코드는 이 함수를 호출할 수 있다.

position_only(1, 2)
3

그러나, 다음과 같이 호출할 수는 없다.

position_only(x=1, 2)
  Cell In[21], line 1
    position_only(x=1, 2)
                        ^
SyntaxError: positional argument follows keyword argument

내장 함수 sum()의 도움말을 보자.

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.

따라서 이 함수를 호출할 때는 반드시 첫 번째 argument는 positional argument로 하나의 iterable로 전달해야 한다.

# 가능
sum([1, 2, 3])
6
# 불가능
sum(iterable=[1, 2, 3])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[24], line 2
      1 # 불가능
----> 2 sum(iterable=[1, 2, 3])

TypeError: sum() takes at least 1 positional argument (0 given)

Positional-only parameter와 반대되는 개념이 keyword-only parameters이다. Positional argument collector *args 뒤에 정의되는 것들이 모두 keyword-only parameters이다. 여기에 Python 3.8부터는 *args 뿐만 아니라 * 단독으로 사용해도 된다. 따라서 * 또는 *args 뒤에 오는 것들은 모두 keyword-only parameters가 된다.

주의할 것은 함수를 정의할 때 keyword-only parameters를 반드시 keyword parameter로 정의해야 된다는 뜻은 아니다. Positional parameter, Named(keyword) parameter 모두 가능한다.

다음 함수를 보자. 이 함수는 b를 positional parameter로 c는 named parameter로 정의했다.

def f(a, *, b, c=56):
    return a, b, c

이 함수는 다음과 같이 실행할 수 없다.

f(1, 34)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[26], line 1
----> 1 f(1, 34)

TypeError: f() takes 1 positional argument but 2 were given

다음과 같이 하면 실행된다.

# b는 keyword-only argument이다.
f(1, b=34)
(1, 34, 56)

10.6 파라미터 지정 순서

  1. positional parameters
  2. named parameters,
  3. positional argument collectors
  4. named argument collectors

Positional-only marker(/)는 아무 곳에나 넣을 수 있다.

10.7 Scoping, Namespaces

함수의 parameters와 함수 body 안에서 바인딩된 이름들(names)이 해당 함수의 local namespace(또는 local scope)를 구성한다. 이들 변수들을 함수의 local variables이라고 한다.

함수의 local namespace를 구성하는 것
  • 함수의 파라미터들
  • 함수 body 안에서 바인딩된 이름들

local이 아닌 변수들(함수 밖의 변수들)을 global variables이라고 하고, 이것은 모듈 객체의 attributes이다. 만약 함수 안의 변수가 밖의 변수와 이름이 같은 경우에는 안의 변수가 사용된다(로컬 변수가 글로벌 변수를 hiding한다고 말한다).

10.7.1 Global

다음 코드 블록이 42를 출력하는 이유를 생각해 보자.

x = 42
def my_func():
    x = 13
my_func()
print(x)
42
  • 정수 42와 바인된 x는 글로벌 변수이고, my_func 함수의 body에서 정수 13에 바인딩된 x는 로컬 변수이다.
  • 이 변수는 같은 이름을 가지고 있지만 있는 위치(scope)이 다르다.
  • 따라서 my_func() 함수를 호출했을 때 이 함수의 로컬 스콥에서 x가 13과 바인딩하지만 함수가 종료되면 이 로컬 스콥은 사라진다.
  • global scope에 있는 x는 영향을 받지 않기 때문에 원래의 값에 바인딩된 상태를 유지한다.

만약 함수 body 안에서 글로벌 변수의 값을 바꾸고 싶은 경우에는 global 문을 사용해서 변수가 글로벌 변수라는 것을 명시적으로 알려야 한다.

x = 42
def my_func():
    global x
    x = 13
my_func()
print(x)
13

다음은 이런 사실을 바탕으로 어떤 함수를 호출된 횟수를 저장하게 할 수 있다.

_count = 0
def counter():
    global _count
    _count += 1
    return _count

counter()
counter()
counter()
print(_count)
3

전반적으로 global을 선언하는 것은 함수가 외부에 영향을 주는 것으로 가급적 피해야 하는 것이므로 그 사용은 꼭 필요한 경우로 제한되어야 한다.

10.7.2 내포된 함수, Closure(클로저), nonlocal

파이썬 함수의 body 안에서 def문을 사용하여 내포된 함수(nested function)을 정의할 수 있다. 이 경우에 다음과 같은 명칭을 사용한다.

  • outer function: 바깥 함수, 안에 def문을 가지고 있는 함수
  • nested fucntion: 안쪽 함수

nested function의 body에서는 outer function의 local variables에 접근할 수 있다. nested funciton 입장에서 이런 변수를 free variables이라고 한다.

다음은 주어진 값을 2제곱, 3제곱, 4제곱 등을 계산하는 함수를 만들어 주는 함수이다. 함수가 반환하는 함수이다.

def my_raise_power(n):
    def my_power(x):
        return x ** n
    return my_power
 
p2 = my_raise_power(2)
p3 = my_raise_power(3)
p4 = my_raise_power(4)
print(p2(3))
print(p3(3))
print(p4(3))
9
27
81

함수의 정의를 보면 outer function의 n이 nested funtion인 my_power에서 사용되고 있음을 볼 수 있다. my_power 함수에서는 x는 local variable이고 이 n은 free variable이다.

함수를 인자로 받거나, 함수를 반환하는 함수를 고수준 함수(high-order function)이라고 한다. 이 경우 my_raise_power() 함수는 함수를 반환하기 때문에 high-order function이라고 할 수 있다.

위에서 p2, p3, p4는 모두 함수 my_raise_power()에 의해서 반환된 함수들이다. 이렇게 필요에 따라 여러 가지 함수를 함수를 만들 수 있는 함수를 factory function이라고 한다.

p2(3)가 계산되는 과정을 보자. 먼저 p2my_raise_power(2) 함수로 만들어진다. my_raise_power(2)가 실행되면서 n = 2 값을 사용하여 my_power 함수가 반환된다. 이 반환된 함수는 n = 2이라는 값을 기억하고 있다가 p2(3) 호출될 때 x=3이라는 값을 사용하여 x ** n을 계산한다. 이때 안에 함수는 이미 반환되었어도 free varibles의 값을 기억하고 있는 것이다. 이것을 클로저(closure)라고 한다.

다음도 클로저를 이용한 예이다. 다음 after() 함수는 함수를 인자로 받는 higher-order function이다. 이 함수는 주어진 초 동안 멈추었다가, 주어진 함수를 실행한다. main() 함수 안의 nested function인 greeting()이 free variable인 name 변수의 값을 기억하고 있다. 그리고 greeting() 함수를 after() 함수로 전달될 때 , 이 기억된 정보가 함께 넘어간다.

import time

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

def main():
    name = 'Guido'
    def greeting():
        print('Hello', name)
    after(3, greeting)        

main()
Hello Guido

다음은 nonlocal 문에 대해서 알아보자. nonlocal은 nested function에서, 함수 밖에 있는 변수에 접근하고자 할 때 사용한다.

def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

c1 = make_counter()
c2 = make_counter()
c3 = make_counter()

for n in range(1, 5):
    print(c1())

for n in range(1, 6):
    print(c2())

for n in range(1, 8):
    print(c3())
1
2
3
4
1
2
3
4
5
1
2
3
4
5
6
7

10.8 Decorator 함수

Decorator 함수를 이해하려면 몇 가지 사전 지식이 필요한데 무엇보다, 파이썬에서 함수는 first-class object로 다른 객체와 똑같이 취급되기 때문에, 함수를 다른 함수의 인자로 쓸 수도 있고, 다른 함수의 반환값이 되기도 한다는 점을 이해할 수 있어야 한다.

이런 decorator는 유연하고 강력한 기능의 하나로 Python Framework 등에서 아주 자주 사용된다.

Decorator 함수는 다른 함수를 받아서, 받은 함수의 기능을 보충하고, 이후에도 그 함수의 이름의 이름을 그대로 사용할 수 있게 한다.

Decorator 함수를 d라고 하고, 받은 함수를 ded라고 정의되어 있을 때, 다음과 같은 문법으로 사용된다.

def d(f):
    ...

def ded(...):
    ...

이라고 정의된 경우, 다음과 같은 문법으로 사용한다.

@d
def ded(...):
    ...

이것은 다음과 같은 의미를 가진다.

ded = d(ded)

Decoration 되기 전에는 원래 정의된 대로 행동하다가, Decoration 된 다음부터는 기능이 보강된 대로 행동하는 것이다. 함수의 이름이 바뀌지는 않는다. 구체적인 예로 살펴보자.

다음 trace() 함수는 함수를 받아서, 받은 함수의 이름(func.__name)을 출력한 다음 그대로 함수를 반환하도록 하는 함수이다.

def trace(func):
    def call(*args, **kwargs):
        print('호출한 함수:', func.__name__)
        return func(*args, **kwargs)
    return call
노트

내부 함수 call()의 파리미터가 *args, **kwargs로 지정했기 때문에, 이 함수는 거의 임의의 arguments를 받을 수 있다. 이렇게 해야 decoration 되는 함수의 파라미터에 어떤 형태로 주든 그것을 받을 수 있다. 또 그렇게 받은 인자들을 return func(*args, **kwargs) 코드를 통해 func 함수로 전달하고 있다.

이 함수를 decorator로 사용하고자 한다. 내부에 사용할 함수를 하나 만들어 보자.

def add(x, y):
    """
    Add two values.
    """
    return x + y 

Decoration 되기 전에는 원래대로 행동한다.

add(3, 4)
7

이제 trace() 함수로 decoration 한다.

@trace
def add(x, y):
    """
    Add two values.
    """
    return x + y 

이렇게 하고 난 다음에 다시 함수를 실행해 보자.

add(3, 4)
호출한 함수: add
7

이전과 다르게 호출한 함수: add를 출력하고 결과를 반환하는 것을 볼 수 있다(함수의 기능이 보강되었다).

그런데 약간의 문제는 남아 있다. 함수를 출력해 보면 이름도 바뀌어 있고, 함수의 docstring도 원래의 것이 아님을 확인할 수 있다.

# 원래 함수가 아니라 decorator 이름을 반환
print(add)
<function trace.<locals>.call at 0x14800d260>
# 원래의 docstring이 보이지 않음
help(add)
Help on function call in module __main__:

call(*args, **kwargs)

이와 같이 되는 것은 전달된 함수가 call 함수로 바뀌어 반환되기 때문이다. 이와 같은 효과는 디버깅 등을 할 때 혼돈을 줄 수 있다.

그래서 원래 함수의 메타 데이터를 유지할 수 있게 만들어 놓은 도구가 functools.wraps라는 decorator이고 다음과 같은 패턴으로 사용한다.

from functools import wraps
def trace(func):
    @wraps(func)
    def call(*args, **kwargs):
        print('호출한 함수:', func.__name__)
        return func(*args, **kwargs)
    return call

이렇게 만들어진 decorator를 다시 사용해 보자.

@trace
def add(x, y):
    """
    Add two values.
    """
    return x + y 

그 결과 원래 함수의 메타 데이터들이 유지됨을 확인할 수 있다.

# 원래 함수의 이름
print(add)
<function add at 0x14800d440>
# 원래 함수의 docstring
help(add)
Help on function add in module __main__:

add(x, y)
    Add two values.

더 구체적인 예는 『Effective Python』의 Item 38: Define Function Decorators with functools.wraps을 참고한다.

다음은 numpy 패키지에 있는 np.vectorize() 함수를 decorator로 사용한 예이다. 이 함수는 받은 함수가 작동하는 방식을 벡터화시킨다.

import numpy as np 

@np.vectorize
def my_func(item):
    return item ** 2

my_func([1, 2, 3])
array([1, 4, 9])

10.9 Call By Sharing

함수를 호출할 때 arguments는 결국 parameters로 여러 가지 방식으로 매칭된다. 매칭 규칙이 어긋나면 함수가 실행되지 않는다. Arguments의 값들이 어떻게 parameters의 값들로 매칭되는지 잘 이해하고 있어야 한다.

파이썬은 call by sharing 방식으로 argument의 정보를 parameter로 넘긴다. call by sharing이란 여기서 넘겨지는 정보가 객체의 값이 아니라 객체에 대한 레퍼펀스(또는 alias)라는 뜻이다. 맥락을 이해하기 위해서 다음 예를 보자. 파이썬의 Mutability와 관련된 내용이다.

a = [1, 2, 3]
b = a
a[0] = 11
b
[11, 2, 3]

위 코드에서 b = a라는 코드 때문에 객체 b[1, 2, 3]을 가리키는 a와 레퍼런스를 공유하게 된다. 따라서 a를 업데이트 했을 때 b도 자동으로 업데이트 되는 것이다. 이렇게 되는 것은 a가 리스트로 mutable 객체이기 때문이기도 하다. 만약 tuple을 사용하는 경우에는 이런 현상이 일어나지 않는다.

c = (1, 2, 3)
d = c  
c = c + (4, )
c, d
((1, 2, 3, 4), (1, 2, 3))

10.9.1 함수의 argument로 mutable 객체로 사용하는 경우

이 맥락이 함수의 arguments와 parameters 사이에도 그대로 적용된다. 우리는 함수가 함수 밖에 상태에 영향을 받지 않고, 주어진 값이 있다면 언제 호출하더라도 같은 값이 반환되는 함수를 원하는 경우가 많다(이런 성질을 idempotence라고 부른다).

만약, 어떤 함수를 호출할 때 mutable 객체를 argument로 지정하는 경우, 함수 안에서 이 객체가 바뀔 수 있음을 조심해야 한다.

다음과 같이 함수를 정의했다고 생각해 보자. 이 함수의 바디에서 a += b라는 코드는 a가 immutable인 경우에는 a = a + b를, mutable인 경우에는 a가 in-place로 수정된다.

def my_f(a, b):
    a += b
    return a

Immutable인 경우에는 아래 예와 같이 간단히 생각할 수 있고, 함수 밖에 있는 x, y가 바뀌지 않는다.

x = 1
y = 2
print(my_f(x, y))
x, y
3
(1, 2)

Muable 객체인 경우에는 생각이 필요하다.

l = [1, 2]
m = [3, 4]
print(my_f(l, m))
l, m
[1, 2, 3, 4]
([1, 2, 3, 4], [3, 4])

이 경우 l이 함수 호출을 거치고 난 후에 값이 바뀌었다. 이렇게 되는 이유는 함수 밖에 있는 l이 mutable 객체이고 함수의 argument로 사용될 때, 이 객체의 레퍼런스가 함수의 parameter a와 공유되기 때문이다. 따라서 함수 안에서 파라미터 a를 업데이트 하면 밖에 있는 l이 바뀌게 된다.

10.9.2 파라미터 옵션 값으로 mutable 객체를 사용하는 경우

Python에서 파라미터를 주거나 주지 않아도 되는 함수(optional parameter를 가진 함수)는 중요하게 사용된다. 그런데 디폴트 값으로 mutable 객체를 사용하는 경우 주의를 요한다. 다음 함수를 가지고 생각해 보자.

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

파이썬 인터프리터는 def 문을 만났을 때(즉, 함수를 정의할 때), 새로운 함수 객체를 생성하고, 그 안에서 named parameter를 만나면 그 값을 계산하여 이 함수 객체의 __defaults__라는 속성에 그 값을 저장한다. 그렇게 하기 때문에 이 함수를 처음 호출할 때도 이 값을 가져와 사용하기 때문에 사용자가 argument의 값을 생략할 수 있는 것이다. 위 함수의 경우 __defaults__에 빈 리스트가 저장된다.

func.__defaults__
([],)

이 함수는 named parameter에 argument를 지정하지 않게 호출해 보자. 일부러 __defaults__가 어떻게 바뀌는지 확인하게 했다.

func(1)
[1]
func.__defaults__
([1],)
func(2)
[1, 2]
func.__defaults__
([1, 2],)
func(3)
[1, 2, 3]
func.__defaults__
([1, 2, 3],)

이렇게 함수를 호출할 때, 이전 상태에 따라 반환되는 값이 다르는 것을 알 수 있다. 마치 함수가 이전 상태를 기억하는 듯하다. 실제로 그러하다. 하나씩 생각해 보자.

첫 번째 func(1)은 당연히 이해가 될 것이다. func(2)의 경우에는 다음과 진행된다.

  • 함수 func이 정의되면서 함수의 __defaults__의 속성으로 빈 리스트 []가 만들어진다.

  • func(1)이 호출되면서 이 빈 리스트에 1이 추가되고 이것이 반환된다. 리스트는 mutable object이기 때문에 이 내용은 __defaults__에 있는 리스트에도 반영된다. 이 값이 [1]이 된다.

  • func(2)가 호출되면, 디폴트로 저장된 [1]을 가지고 여기에 2가 추가된다. 그리고 [1, 2]가 반환된다. 리스트는 mutable object이기 때문에 이 내용은 __defaults__의 리스트에도 반영되어 이 값이 [1, 2]가 된다.

이렇게 하지 않게(즉, 함수가 이전 호출과 상관없이 실행될 수 잇게 하려면) 다음과 같은 패턴을 사용한다.

  • None 값을 갖는 파라미터 사용하고,
  • 함수 body에서 if name is None:을 사용하여, 값을 arugment로 주지 않은 경우와 지정해 주는 경우에 대응하는 코드를 작성한다.
def new_fun(x, items=None):
     if items is None:
         items = []
     items.append(x)
     return items

이와 같이 named parameter의 디폴트 값을 mutable object를 사용함으로서 생기는 문제를 피할 수 있게 된다. 어떻게 이것이 가능한지 살펴보자.

이렇게 정의되면 파이썬 인터프리터가 실행될 때 함수 객체의 __defaults__ attributes에 None이 저장된다.

new_fun.__defaults__
(None,)

이제 함수를 호출해 보자.

new_fun(1)
[1]
new_fun.__defaults__
(None,)

위 결과에서 보듯이 new_fun(1)을 호출할 때 items는 저장된 None을 가지고 함수를 시작하게 되고, 바디 안의 if 문에 의해서 items라는 변수는 빈 리스트에 할당된다. 이 리스트에 1이 추가되어, 이것이 반환된다. 이 리스트에 1이 추가된다고 해도 이것은 디폴트의 None과 전혀 상관이 없다.

다시 이 함수를 호출해 보자.

new_fun(2)
[2]
new_fun.__defaults__
(None,)

이 경우에도 new_fun(1)을 호출할 때와 똑같이 작동한다. 그래서 결국은 함수가 호출될 때 파라미터의 디폴트에 영향을 받지 않는다.


  1. https://docs.python.org/3/reference/datamodel.html#code-objects↩︎