고민하기

구글 파이썬 스타일 가이드 - 2.12 Default Argument Values (Mutable vs Immutable)

pythaac 2021. 6. 29. 17:13

1. 구글 파이썬 가이드 - Default Argument Values

  "파이썬 알고리즘 인터뷰"라는 책을 쭉 읽으면서 그냥 넘어가지 못한 구간이 있다. 구글 파이썬 스타일 가이드를 설명하던 중, 아래와 같은 글귀와 코드를 보여주었다.

함수의 기본 값으로 가변 객체(Mutable)를 사용하지 않아야 한다.
# No
def foo(a, b=[]):
    pass

# No
def foo(a, b: Mapping = {}):
    pass


# Yes
def foo(a, b=None):
    if b is None:
        b = []

# Yes
def foo(a, b: Optional[Sequence] = None):
    if b is None:
        b = []

 

위 코드에서 "No"라는 comment에 적힌 함수들을 사용하지말고, "Yes"라 적힌 함수처럼 사용하라는 이야기이다. 함수에서 default 값은 함수를 호출할 때 해당 parameter에 arg가 없으면 자동으로 값을 넣어주는 편리한 기능이다. 함수의 paramter에 명시되어있기 때문에 헷갈리지 않고 다양한 arg set들을 사용할 수 있다. 그런데 왜 사용하지 말라는 걸까? 내 눈엔 두 함수가 똑같아보여서 이 부분을 도저히 넘어갈 수 없었다. 특히 이 이야기가 "구글 파이썬 스타일 가이드가 PEP 8에 비해 가독성을 높이기 위한 지침이 많다"는 말 다음으로 나온 내용이라 더 이해가지 않았다.

 

2. 어떤 일이 발생하는가?

  해당 책에서 눈에 띄는 문구가 있었다.

함수가 객체를 수정하면(리스트에 아이템을 추가한다든지 등) 기본값이 변경되기 때문이다

이는 나중에 찾아보니 구글 파이썬 스타일 가이드에 그대로 적힌 문구였다. 처음에 이 말이 무슨 말인지 이해하지 못했다. 결국 여기서 말하는 객체가 기본값으로 들어가는 우변(right)을 의미한다는 것을 깨달았다. 하지만 기본값이 바뀐다는 것 또한 이해하지 못했다. 그래서 아래와 같이 직접 실험을 해보았다.

def foo(a=[]):
	a.append(1)
	print(a)

	
foo()
# [1]
foo()
# [1, 1]
foo()
# [1, 1, 1]

오잉? 분명 foo의 paramter a에 대한 arg가 없으면 default는 비어있는 list인 []가 들어가고, 3번의 함수 호출동안 arg를 넣은 적이 없다. 그렇다면 함수의 parameter default가 int여도 이런 일이 발생할까?

def foo(a=1):
	a = a + 1
	print(a)

	
foo()
# 2
foo()
# 2
foo()
# 2

다행히 이러한 참사는 default가 int일 때는 발생하지 않았다. 그렇다면 이러한 오류는 어디에서 발생하며, 둘은 어떤 차이가 있는 걸까?

 

3. Mutable vs Immutable

  위와 같은 현상을 이해하기위해 먼저 list와 int의 차이를 알아야했다. 이를 위해 Mutable과 Immutable의 차이를 확인해야했다. 이전에도 본 적 있었지만 대수롭지 않게 넘어갔었다. 이름부터 바꿀 수 있는지 없는지를 나타내고, Mutable type에는 append나 delete가 가능하기 때문이다. 하지만 그 정도로 함수의 default value에서 일어나는 일을 이를 이해할 수 없었다.

  Mutable과 Immutable을 검색하여 skimming했을 때, 두 객체는 바뀔 수 있는지 없는지에 따라 나뉜다고 정의되었다. Python에서 Mutable 객체와 Immutable 객체는 다음과 같이 나눌 수 있다[1].

  • Mutable type
    list, dict, set
  • Immutable type
    int, float, bool, string, unicode, tuple

그런데 int도 값이 바뀌지 않는가? bool도 float도 string도 const가 아니면 바꿀 수 있다고 생각하니 이해가지 않았다. 그리고 다른 글에서 이런 정의를 보았다[2].

  • Mutable
    객체에 할당된 값을 수정할 수 있다
  • Immutable
    객체에 할당된 값을 수정할 수 없다.

위 정의를 보고 느낀 것은 내가 이들을 객체로 생각하지 않아서 생긴 착오였다. 객체는 여러 데이터와 메소드를 담고있고, 1이라는 Integer 객체는 값을 나타내는 1 외에도 다양한 데이터를 담고 있을 것이다. 예를 들어, Java의 Integer 객체에서 이 값은 intValue()라는 메소드를 통해 얻을 수 있다.

  다시 정리하자면, Mutable은 이 객체가 가지고 있는 "이 값"이라는 데이터를 바꿀 수 있고, Immutable은 바꿀 수가 없다는 것이다. Java 8의 Integer 객체에서 "이 값"은 BYTES라는 데이터이며, intValue()는 BYTES를 int값으로 반환하는 메소드다. 만약 BYTES가 다른 Integer를 원한다면, Immutable Integer일 경우 BYTES를 바꿀 수 없으므로, 원하는 BYTES를 가진 "다른 객체"를 데려와야한다는 의미이다.

Oracle Java 8 Integer document 일부

 

4. 그래서 함수 Parameter에서는 왜 차이가 발생하는가?

  Mutable 객체와 Immutable 객체가 무엇을 바꿀 수 있고 바꿀 수 없는지 차이를 알았다. 그렇다면 함수 parameter에서 이 두 객체의 차이는 왜 발생하는가? 이를 알기 위해서는 2가지 사실을 알아야한다. 

 

1) Python 객체에게 등호(=)와 연산이란?

  첫 번째는 Python에서 Mutable 객체와 Immutable 객체에 대해 등호(=)와 연산의 결과를 알아야한다.

Python의 등호(=)

  등호는 보통 좌변(left)에 우변(right)의 값을 저장할 때 사용한다. Python에서는 좌변(left)인 변수가 우변(right)의 객체를 지칭하게된다. 아래 예시 코드를 보자.

a = [1]
b = [2]

c = a
c.append(3)
a # [1, 3]
b # [2]
c # [1, 3]

c = b
c.append(4)
a # [1, 3]
b # [2, 4]
c # [2, 4]

 

a와 b는 각각 1과 2를 담는 리스트였다. "c = a" 이후 c의 변화에 a도 함께 변했다. "c = b" 이후에는 c의 변화에 b가 함께 변했고, a는 영향이 없다. 등호에서 객체를 우변(right)로 받았을 때, 좌변(left)의 변화가 우변(right)에 영향을 주었다. 즉, 좌변(left)은 우변(right)과 같은 객체이다. 또 중요한 것은, 해당 객체에 대한 변화를 객체의 메소드 호출로 주었다는 겄이다.

  등호를 기준으로 양변의 객체는 같은 값이며, 해당 객체의 메소드를 호출하더라도 지칭하는 객체는 변하지 않는다(메모리 주소가 변경되지 않는다). 다만, 해당 객체가 가진 값이 변경되는 것이다. 당연한 이야기처럼 들리겠지만, 아래 코드처럼 a = c = [1, 3]일 때, a와 c의 주소는 같지만, [1, 3]과 주소는 다르다. [1, 3]은 해당 값을 가진 새로운 객체를 생성하는 행위이다. 즉, [1, 3]라는 값이 같을 뿐, 이를 담고 있는 객체는 별개다.

 

a = [1] # a와 [1]은 같은 객체
c = a # c와 a와 [1]은 같은 객체

c.append(3)
a # [1, 3]
c # [1, 3]

id(a) # 1833938038912
id(c) # 1833938038912
id([1, 3]) # 1833906327872
id([1, 3]) # 1833937975552
id([1, 3]) # 1833937976960

등호와 연산

  위에서 본 등호는 좌변(left)와 우변(right)가 같은 객체이다. 그렇다면 항상 양변의 객체는 같은 객체일까? 아래 Stackoverflow에서 가져온 Java 예제를 보자[3].

// ex-1
Integer sum = new Integer(2) + new Integer(4);
// ex-2
Integer sum = Integer.valueOf(new Integer(2).intValue() + new Integer(4).intValue());
// ex-3
// _sum = 2 + 4

 

위 ex-1는 ex-2와 같다. 여기서 등장하는 객체는 아래와 같이 총 3개이다.

  • 2라는 값을 가진 Integer 객체 (as A)
  • 4라는 값을 가진 Integer 객체 (as B)
  • sum이라는 Integer 객체
    A에서 가져온 값과 B에서 가져온 값의 합을 값으로 가진 Integer 객체

그리고 Python에서는 ex-3도 ex-1, ex-2와 같다. "_sum = 1 + 2"라는 간단한 식에도 참여하는 객체가 3개라는 사실, 즉 _sum, 1, 2는 각각 다른 객체다. 정리하면 연산이 들어갔을 때 등호(=)에서 좌변(left)와 우변(right)가 서로 다른 객체이다.

  여기에는 또 재미있는 사실이 있다. 위에서 이야기한 [1, 3] 예시에서, [1, 3]은 이 값을 가진 새로운 객체를 생성하는 행위라고 했다. 그렇다면 int 객체인 1도 그럴까?

a = 1
id(a) # 1776238553392
id(1) # 1776238553392

b = 1
id(b) # 1776238553392
id(1) # 1776238553392

id(1) # 1776238553392
id(1) # 1776238553392
id(1) # 1776238553392

 

1을 사용한 순간 이 값을 가진 객체가 생성되었을 것이다. 신기하게도 해당 값을 가진 객체는 단 하나다. 이제부터 1은 생성된 이 객체를 지칭하는 의미가 된 것이다. 이러한 점에서 Mutable 객체와 Immutable 객체는 차이가 있다.

 

2) Python에서 함수는 객체다

  두 번째 알아야 할 사실은 Python의 함수는 객체라는 점이다. "def"를 이용해 함수를 선언할 때 이 함수 객체가 생성되고, 그와 동시에 default value가 이 객체의 member data로 저장되는 것으로 보인다. 이는 아래를 통해 알 수 있다.

def foo(a=[]):
    a.append(1)
    print(a)
    
# ex-1
foo()
# [1]
foo()
# [1, 1]

# ex-2
foo([2])
# [2, 1]
foo([2])
# [2, 1]

#ex-3
foo()
# [1, 1, 1]

 

위 코드 중 ex-1부터 보자. foo함수의 parameter인 a는 default value로 list 객체인 []를 등호(=)로 저장하고 있다. 이는 우변(right)에 있는 객체를 지칭하는 것이며, a = []가 함수의 실행마다 실행된다면 위에서 본 바와 같이 새로운 list 객체를 생성하고 이를 지칭해야한다. 그런데 a의 변화가 저장되고 있다는 것은, 우변(right)의 list가 foo 함수 객체의 member data로 저장되었고, 함수의 호출로 인한 a의 default value 대입은 저장된 이 member data를 우변(right)에 두고 있다라고 추측해볼 수 있다.

  한 가지 더 의구심이 들었던 점은 default value가 계산되는 시기이다. 혹시 함수 객체의 인스턴스가 생성될 때 한 번만 default value가 계산된다면, parameter인 a가 arg를 저장하고 있을 수도 있을까? 이런 의심을 풀기 위해 ex-2와 ex-3을 진행했다.

  foo 함수의 arg로 [2]를 입력한 한 것이 ex-2, 다시 arg가 없는 foo()를 실행시킨 것이 ex-3이다. 결론은 "함수가 호출될 때마다 default value를 계산한다"이다. ex-3에서 parameter a는 다시 ex-1에서 대상으로 한 객체를 수정했다. 즉, 함수 객체는 호출 때마다 default value를 성실히 대입하고있다. 함수의 default value는 아래와 같이 확인할 수 있다.

foo.__defaults__
# ([1, 1, 1],)

 

5. 결론

결론적으로, 객체들 중에서도 Mutable 객체를 default value로 사용하지 말라는 이유는 메소드 호출을 통한 데이터 변경이 가능하기 때문이다. 함수 객체는 default value를 저장하고, 연산이 없는 등호는 이 객체를 지칭하게되며, 메소드 호출은 지칭하는 객체가 바뀌지 않은 상태로 이 객체의 값을 바꾼다. 만약 아래처럼 연산을 사용하여 새로운 객체를 지칭하도록 만들면 위와 같은 문제는 발생하지 않는다. 하지만 함수 내에서 메소드를 호출하여 문제가 발생될 여지는 여전히 남아있다.

def foo(a=[]):
    a = a + [1]
    print(a)

foo() # [1]
foo() # [1]
foo() # [1]

 

  종합적으로 정리해보자. Python에서 함수 parameter의 default value를 작성할 때, list, set, dict와 같은 Mutable 객체를 사용하면 안된다. 그 이유는 2가지로 설명할 수 있다. 첫 번째로는 Mutable 객체는 메소드를 통해 객체가 가진 값을 바꿀 수 있지만, Immutable 객체는 등호와 연산을 사용하므로 새로운 객체를 생성해낸다. 두 번째로는 Python에서 함수가 객체이며 default value를 member data로 저장하기 때문에, Mutable 객체가 default value일 경우 메소드 호출을 통해 이 값이 변경될 수 있다. 그러므로 구글 파이썬 스타일 가이드에서는 함수의 default value를 Immutable 값인 None으로 정의하고, arg가 없는 None인 경우 초기화해주는 방식으로 사용하도록 이야기하고있다.

 

참고내용

[1] https://www.geeksforgeeks.org/mutable-vs-immutable-objects-in-python/

[2] https://velog.io/@hyoniii_log/Python%EA%B0%80%EB%B3%80%EA%B0%9D%EC%B2%B4%EB%B6%88%EB%B3%80%EA%B0%9D%EC%B2%B4

[3] https://stackoverflow.com/questions/9391569/operation-inside-when-we-add-two-integer-objects

[4] https://stackoverflow.com/questions/37535694/why-are-integers-immutable-in-python

[5] https://stackoverflow.com/questions/9391569/operation-inside-when-we-add-two-integer-objects

[6] https://frhyme.github.io/python-basic/default_parameter_value_in_python/

[7] https://stackoverflow.com/questions/1132941/least-astonishment-and-the-mutable-default-argument

[8] https://web.archive.org/web/20200221224620/http://effbot.org/zone/default-values.htm