티스토리 뷰

반응형

파이썬의 모든 변수는 특정한 객체에 대한 참조이며, 따라서 변수에는 "대입(assignment)"라는 표현을 쓰지 않고, "바인딩(binding)"이라는 표현을 쓴다고 했다. 보통 파이썬의 변수나 값 특성에 대해서 언급하는 내용은 여기까지인데, 파이썬의 구조에 대한 이해를 좀 더 깊이있게 가지기 위해서는 개별 값의 변경 가능성(mutability)에 대해서도 조금 생각해보자.


파이썬 내의 모든 것은 객체라고 했다. 기본적으로 집합의 성격을 가지는 리스트와 사전(그리고 set)을 제외한 모든 기본 객체는 변경 불가능(immutable)하다. 우리가 표면적으로 프로그래밍 언어를 접할 때에는 실제의 값이 변수 뒤에 가려진다고 느끼기 때문에 변수명이 곧 그 값이라는 생각을 하게 된다. 일차적으로 이러한 개념은 매우 충실한 표현이다. 실제 객체들은 보이지 않고, 바인딩된 이름으로 접근할 수 있기 때문이다.


하지만 파이썬에서는 "상수변수"는 존재하지 않는다. 한 번 정의된 변수이름은 그 대상이 어떤 타입인지에 상관없이 언제, 어디서나 다른 객체로 참조를 바꿀 수 있다. 이것은 특정한 변수 이름에 대해서 그것이 고정된 것인지 변경가능한 것인지에 대한 개념이고, 이 글에서 다루고자 하는 것은 변수가 가리키는 객체, 그 값 자체의 변경 가능성이다.

다음 예에서 출발하자.

a = 1
a = a + 1
print(a)
### 2

첫번째 줄에서 a라는 이름은 1이라는 int 타입 객체를 가리키도록 바인딩됐다. 두 번째 줄은 흔히 "a의 값을 1 올린다"라고 해석한다. 엄밀하게 말해서 이 표현은 틀렸다.

만약 똑같은 코드로 C 코드를 짠다면 이렇게 될 것이다.

int a = 1;
a = a + 1;
printf("%d\n", a);
### 2

코드도 거의 동일하고 동작마저 동일하지만, 이 코드가 동작하는 방식은 완전히 다르다. 우선 행별로 C 코드를 해석하면 다음과 같다.

  1. a 라는 이름의 정수형 변수를 선언하고, 4바이트의 메모리 공간을 할당한 다음, 여기에 1 이라는 값을 써 넣는다.
  2. 두 번째 라인에서 a라는 변수가 가리키는 메모리 공간에는 원래의 값인 1에 1을 더한 2라는 값을 써 넣는다. 즉 값 자체가 바뀌었다.
  3. 세 번째 라인에서 a라는 변수가 가리키는 메모리에 들어있는 2라는 값을 읽고 이를 출력한다.

우리는 이 코드를 "a에 1을 넣었다가 다시 1 올린 다음에 출력한다"라고 흔히 이야기했다. 뭐 어느 정도는 틀린 말이 아니다. 왜냐하면 C에서 정수 1은 원시 값이며, 메모리에 쓰여있는 수 그 자체이기 때문이다.

하지만 파이썬 코드에서 a = a + 1 은 완전히 다르게 해석된다.

  1. 우변의 a는 정수 객체 1 을 가리킨다.
  2. 따라서 a + 11 + 1이며 이는 1.__add__(1) 이라는 정수 1의 메소드 호출이다. 이 결과는 2라는 정수타입 객체가 된다.
  3. 결국 a = 2라는 새로운 바인딩이 구성된다. 여기서 변하는 것은 a가 가리키는 객체이지, 1이라는 값 그 자체가 변경되지는 않았다.

즉 파이썬에서는 기본적으로 정의되어 있는 대부분의 타입의 값들은 그 내용이 변하거나 손상되지 않는다. 계산의 결과로 인해서 값이 바뀐다는 개념은 사실 계산의 결과로 인해서 새로운 값이 만들어지고, 변수는 그 새로운 값으로 참조를 변경할 뿐이다.


그렇기 때문에 문자열이나 튜플과 같은 값 역시 "내부의 값 구성"이 결코 변하지 않는다. 따라서 리스트에 대해서는 aList[3] = 5 와 같이 내부 구성 요소를 변경하는 명령이 가능하지만, aString[3] = "a"과 같은 동작이 허용되지 않는 것이다.

변경 가능한 값

파이썬에서 변경 가능한 기본 타입은 리스트와 사전 (그리고 세트) 뿐이다. 이 들은 그 자체가 값으로 큰 의미를 가지기 보다는 다른 객체들을 보관하여 집합으로서 기능한다. 즉 객체들을 담는 주머니로서 의미를 가지기 때문에 "넣었다 뺐다"하는 동작을 기본적으로 상정한다.

arr = [1,2,3,4,5]
arr.append(6)
## arr = [1,2,3,4,5,6]
arr = arr + [7]
## arr = [1,2,3,4,5,6,7]

위 코드에서 두 번째 행은 arr이 참조하고 있는 리스트 객체에 6 이라는 새로운 원소를 추가했다. 따라서 arr 은 5개의 원소를 가지는 리스트에서 6개의 원소를 가지는 리스트로 확장됐다. 하지만 이 과정에서 새로운 배열이 생성되거나 하는 일은 없고, arr 이 가리키는 원래의 리스트 자체가 조작됐다.


세 번째 행은 어째, append(7)하는 것과 비슷해 보이지만, 살짝 다르다. 두 리스트 객체를 더하는 것은 두 리스트가 연결된 제 3의 리스트를 생성하는 것과 동일하다. 따라서 원래의 arr이 가리키고 있던 [1,2,3,4,5,6] 이라는 리스트는 버려지고(다른 이름이 참조하고 있지 않았다면, 파괴될 것이다), arr은 덧셈 연산의 결과로 만들어진 새로운 리스트를 참조하게 된다.


뒤집기와 정렬 역시 비슷한 관점에서 바라볼 수 있다. 리스트의 sort(), reverse()는 모두 IN-PLACE 변경, 즉 추가적인 메모리 사용없이 객체 내부에서 원소들의 자리바꿈이 일어난다. 초보들이 흔히하는 실수는 다음과 같은 것이다.

arr = [3,2,5,7,4].sort()
## arr = None

sort()는 제자리 정렬이라고 했다. 이는 원소들의 재배치가 일어나지만, 그것은 내부에서만 일어날 뿐이고 겉으로봤을 때는 이 메소드가 어떤 외부와의 상호작용(어떤 값을 리턴하는 등)을 전혀 하지 않는다. 따라서 sort()는 어떤 값로 리턴하지 않으며, 그 결과로 arr은 정렬된 배열을 얻게 되는 것이 아니라 None이 된다.


따라서 올바른 구현은 다음의 두 가지가 되어야 한다.

arr = [3,2,5,7,4]
arr.sort()

## 더 선호되는 방법
arr = sorted([3,2,5,7,4])

거의 항상 정렬된 사본을 얻는 후자의 방법을 추천한다. 예를 들어 리스트 리터럴이 아니라 다른 이름이 붙은 리스트를 같은 식으로 조작한다고 하면, .sort()를 쓰는 순간 원래의 이름이 가리키던 리스트가 정렬되어 버리고 이는 "원래의 상태"를 잃어버린다는 의미이기 때문이다. 만약 원래 상태가 역순으로 정렬되었다거나 하는 상태가 아니라면 이 과정은 비가역적이며, 최악의 경우에는 다른 쪽에서 해당 이름을 참조하는 경우에 예기치 못한 결과를 낼 수 있다.

정리

  • 파이썬의 값들은 리스트와 사전을 제외하면 변경 불가능하다. 연산의 결과는 값을 변형하는게 아니라, 다른 값을 만들어낸다. 1 + 1 은 1을 2로 바꾸는게 아니다.
  • 리스트와 사전은 일종의 "객체 주머니"이기 때문에 값을 넣었다 뺐다하기 위해서 변경가능한 구조로 되어 있다. 하지만 가능하면 값을 넣었다 빼는 외의 암시적인 변경은 사용하지 않도록 하는것이 좋은 습관이다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함