python은 매개변수를 넘겨받아 처리할 때 call by object reference를 한다. 그렇다면 call by object reference가 뭘까?
우선 python은 할당하는 모든 데이터를 힙 공간에 저장하고 해당 메모리 공간의 주소를 변수에 담는다. 그러니까 python 변수에 담기는 값은 모두 메모리 공간의 주소인 것이다.
근데 나는 정수 a를 선언하고 그냥 a로 호출하면 값을 읽어오던데?
변수 a를 평가하면 인터프리터가 이름 공간(dict 또는 지역 슬롯)에서 참조를 꺼내 객체를 얻고, print(a)나 a + 1은 객체의 메서드(__str__, __add__)를 호출한다. 즉 값을 읽는 것이 아니라 항상 객체를 얻고 그 객체에게 동작을 시키는 것이고, 정수의 값은 객체 안에 있다.
그럼 python은 전부 주소값을 참조하니까 call by reference인가?
아니다. python이 함수를 호출했을 때 호출자의 원본이 바뀌는지는 함수 안에서 그 객체에 무엇을 하느냐로 결정된다. 다만 immutable 객체는 제자리 변경이 불가능하므로 항상 call by value처럼 보이고, mutable 객체는 직접 변경하면 reference처럼, 재바인딩하면 value처럼 보인다.
mutable(가변) / immutable(불변)은 “생성 후 상태를 바꿀 수 있는가”라는 성질로, python에서 대표적인 mutable 객체는 list, dict, set이 있고 immutable 객체로는 int, float, str, tuple 등이 있다.
정수를 예시로 보자. a = 1이라는 변수에 대해 a = 2로 값을 변경하면, 실제로는 2라는 값을 갖는 객체를 메모리 공간에 새로 할당하고 기존 1이 담긴 객체 주소를 버리고 2가 담긴 객체를 가리키도록 한 것이다.
이러한 이유로 immutable 객체에 대해서는 함수 내부에서 값을 변경하려고 해도, 변경하는 순간 매개변수 이름이 새로운 객체에 바인딩되고 호출자의 이름은 여전히 원래 객체를 가리키기 때문에, 매개변수로 전달된 기존 객체는 변하지 않고 기존 상태를 유지하게 된다.
리스트를 예시로 들면 리스트 변수도 마찬가지로 주소를 저장하고 있다. 다만 가변 객체의 경우 값을 변경할 수 있기 때문에, mutable 변수를 재바인딩하지 않는 한 해당 mutable 객체를 조작하면 변경된 내용이 기존 객체에 그대로 적용된다.
사실 여기서 좀 더 자세히 생각해야 할 부분이 있는데, 리스트가 담고 있는 각 인덱스의 값들도 모두 객체의 주소라는 것이다. 따라서 리스트의 인덱스에 접근해 값을 변경한다는 것은 해당 인덱스에 저장된 주소를 바꾸는 것과 같다.
def f(lst):
lst.append(9) # 객체 변경 → 호출자에 보임
lst[0] = 9 # 칸의 주소 교체 → 호출자에 보임
lst = [] # 매개변수 재바인딩 → 호출자에 안 보임
조금 더 나아가서 얕은 복사와 깊은 복사에 대해 알아보자. 가변 객체 리스트로 설명을 이어가겠다.
얕은 복사는 리스트를 복사할 때 껍데기만 바꾸는 것이다. 내부에 담고 있는 주소들은 그대로지만 리스트만 새로 할당하는 것을 얕은 복사라고 할 수 있다.
a = [[1, 2], [3, 4]]
b = a[:]
이것이 얕은 복사이다. 새로운 리스트에 a가 담고 있는 객체의 주소들을 옮겨 담은 것이다. [1, 2], [3, 4]의 값을 담고 있는 각 인덱스에 담긴 주소는 그대로 옮겼기 때문에 같은 객체를 가리킨다는 것을 알 수 있다.
같은 리스트 객체를 가리키는 대입과는 또 다르다.
a = [[1, 2], [3, 4]]
b = a
대입은 b가 a가 가리키는 객체의 주소를 갖는 것으로, 얕은 복사와는 차이가 있다.
그럼 깊은 복사는 무엇일까. 깊은 복사는 원본과 사본이 상태를 공유하지 않아서, 어느 한쪽을 어떻게 변경해도 다른 쪽이 영향받지 않도록 하는 것이다.
깊은 복사는 copy.deepcopy()를 활용할 수도 있고, 아래와 같이 층마다 얕은 복사를 적용하는 방식으로도 가능하다.
# 1차원 (원소가 immutable인 경우)
b = a[:]
# 2차원
b = [row[:] for row in a]
# 3차원
b = [[r[:] for r in plane] for plane in a]
코드를 보면서 그런 생각이 들 수 있다. 어!? 이렇게 해도 각 인덱스는 같은 메모리 주소를 갖게 되는 거 아닌가?
맞다. 하지만 각 case의 차원을 고려했을 때 마지막 층의 원소는 immutable이라 변경이 불가능하고, 변경하는 순간 그 칸의 주소를 새 객체로 교체하기 때문에 원본에는 영향을 주지 않는다. 그래서 앞서 깊은 복사의 정의를 “모든 값들에 대해 새로운 객체를 할당하는 것”이 아닌 “원본과 사본이 상태를 공유하지 않아 어느 한쪽을 변경해도 다른 쪽이 영향받지 않도록 하는 것”이라고 한 이유이다.
python의 call by object reference의 동작에 대해 설명하다가 mutable/immutable 그리고 shallow copy/deep copy까지 오게 됐는데, 아무튼 이 과정을 설명하면서 결론이 도출됐다.
python은 항상 객체 참조를 넘기고, 호출자의 원본이 바뀌느냐는 함수 본문에서 그 참조로 무엇을 하느냐(직접 변경 / 재바인딩 / 복사본 작업)로 결정된다.
부족한 내용이나 잘못된 정보가 있다면 jeong.jinyeong95@gmail.com으로 연락 바랍니다.