지난 글에서는 파이썬의 변수가 객체에 연결된 이름이며, 재대입과 객체 자체의 변경은 서로 다른 동작이라는 것을 살펴봤다.
그렇다면 이름이 참조하는 객체 자체는 모두 변경할 수 있을까?
리스트는 다음과 같이 원소를 바꿀 수 있다.
numbers = [1, 2, 3]
numbers[0] = 10
print(numbers) # [10, 2, 3]
하지만 문자열에 같은 방식으로 접근하면 오류가 발생한다.
text = "hello"
text[0] = "H" # TypeError 발생
둘 다 특정 위치의 항목을 바꾸려고 했는데, 왜 결과가 다를까?
이 차이를 가변 객체와 불변 객체의 관점에서 살펴보려고 한다.
파이썬의 객체는 변경 가능성에 따라 구분할 수 있다.
| 구분 | 의미 | 대표적인 타입 |
|---|---|---|
| 변경 가능(mutable) | 같은 객체를 유지하면서 내용을 변경할 수 있다. | list, dict, set |
| 변경 불가능(immutable) | 객체 자체를 변경할 수 없다. 컨테이너라면 직접 참조하는 항목들의 구성을 바꿀 수 없다. | int, float, bool, str, tuple |
각각 가변 객체, 불변 객체라고 부르기도 한다.
여기서 변경 가능성은 변수 이름이 아니라 객체의 성질이다.
a = 10
a = 20
print(a) # 20
정수는 불변 객체지만 위 코드는 정상적으로 실행된다.
기존 정수 객체 10을 수정한 것이 아니라, 이름 a가 정수 객체 20을 참조하도록 연결을 바꿨기 때문이다.
불변이라는 것은 변수에 다른 객체를 대입할 수 없다는 뜻이 아니다. 기존 객체 자체의 값을 변경할 수 없다는 뜻이다.
튜플처럼 다른 객체를 참조하는 불변 객체는 내부 객체의 변경 가능성도 구분해야 한다. 이 부분은 뒤에서 살펴볼 것이다.
리스트를 변경하기 전후에 객체의 정체성이 유지되는지 확인해보자.
numbers = [1, 2, 3]
before_id = id(numbers)
numbers[0] = 10
numbers.append(4)
print(numbers) # [10, 2, 3, 4]
print(id(numbers) == before_id) # True
첫 번째 원소를 교체하고 마지막에 원소를 추가했지만, id()의 값은 유지된다.
이름 numbers가 다른 리스트를 참조하게 된 것이 아니라, 기존 리스트 자체의 내용이 변경된 것이다.
a = [1, 2, 3]
b = a
a[0] = 10
print(a) # [10, 2, 3]
print(b) # [10, 2, 3]
a와 b는 같은 리스트를 참조한다. 따라서 a를 통해 리스트를 변경하면 b로 확인해도 변경된 내용이 보인다.
여기서 a[0] = 10은 정수 객체 1의 값을 바꾸는 동작이 아니다.
리스트의 첫 번째 항목이 참조하는 대상을 정수 객체 10으로 교체하는 동작이다.
정수 객체는 불변이어도, 그 정수 객체를 항목으로 참조하는 리스트는 변경할 수 있다.
문자열에는 리스트처럼 특정 위치에 다른 문자를 대입할 수 없다.
text = "hello"
text[0] = "H" # TypeError 발생
그렇다면 "hello"를 "Hello"로 바꾸려면 어떻게 해야 할까?
원하는 내용의 문자열을 얻어 이름에 다시 연결하면 된다.
text = "hello"
original = text
text = "H" + text[1:]
print(text) # Hello
print(original) # hello
print(text is original) # False
기존 "hello" 객체는 그대로 유지된다. 이름 text가 결과 문자열 "Hello"를 참조하게 된 것이다.
replace()도 원본을 수정하지 않는다문자열을 치환하는 replace() 역시 기존 문자열을 직접 변경하지 않는다.
text = "hello"
result = text.replace("h", "H")
print(text) # hello
print(result) # Hello

결과를 기존 이름으로 사용하려면 다시 대입해야 한다.

text = text.replace("h", "H")
문자열을 가공한 결과를 얻는 것과 기존 문자열 객체 자체를 수정하는 것은 구분해야 한다.
+=라도 동작이 다를 수 있다a += b는 흔히 a = a + b를 짧게 쓴 표현이라고 배운다.
하지만 객체를 변경하는 방식까지 항상 같은 것은 아니다.
+=a = 10
b = a
a += 5
print(a) # 15
print(b) # 10
print(a is b) # False

정수는 불변 객체이므로 기존 10의 값을 수정하지 않는다.
이름 a가 연산 결과인 정수 객체 15를 참조하게 된다. 이름 b는 계속 기존 10을 참조한다.
+=a = [1, 2]
b = a
a += [3]
print(a) # [1, 2, 3]
print(b) # [1, 2, 3]
print(a is b) # True

이 예시에서 내장 리스트의 +=는 기존 리스트를 확장하고 같은 객체를 다시 a에 연결한다.
따라서 a와 b는 계속 같은 리스트를 참조하고, 두 이름 모두에서 변경된 내용이 보인다.
a = a + ...반면 다음 코드는 다르게 동작한다.
a = [1, 2]
b = a
a = a + [3]
print(a) # [1, 2, 3]
print(b) # [1, 2]
print(a is b) # False

리스트의 +는 두 리스트를 연결한 새 리스트를 반환한다. 이후 이름 a가 그 결과에 연결된다.
| 코드 | 리스트에서의 동작 |
|---|---|
a += [3] | 기존 리스트를 확장한다. |
a = a + [3] | 새 리스트를 만들고 이름 a를 연결한다. |
따라서 +=라는 기호만 보고 기존 객체가 변경되는지 판단하면 안 된다. 객체의 타입에 따라 동작이 달라질 수 있다.
튜플은 불변 객체이므로 항목에 다른 객체를 대입할 수 없다.
data = (1, 2, 3)
data[0] = 10 # TypeError 발생
그런데 튜플 안에 리스트가 있다면 다음 코드는 실행된다.
data = ([1, 2], "hello")
data[0].append(3)
print(data) # ([1, 2, 3], 'hello')
튜플은 불변이라고 했는데, 출력되는 내용이 바뀌었다.
이때는 튜플 자체와 튜플이 참조하는 리스트를 구분해야 한다.
튜플의 첫 번째 항목은 여전히 같은 리스트를 참조한다. 바뀐 것은 그 리스트의 내부 내용이다.
반면 첫 번째 항목에 다른 리스트를 대입하려 하면 오류가 발생한다.
data = ([1, 2], "hello")
data[0] = [3, 4] # TypeError 발생
| 코드 | 가능 여부 | 변경하려는 대상 |
|---|---|---|
data[0].append(3) | 가능 | 튜플이 참조하는 리스트의 내용 |
data[0] = [3, 4] | 불가능 | 튜플의 첫 번째 항목이 참조하는 대상 |
튜플이 불변이라는 것은 각 항목의 참조를 바꿀 수 없다는 의미이지, 내부에서 참조하는 모든 객체까지 불변이라는 뜻은 아니다.
리스트가 변경 가능한 객체라고 해서, 리스트를 다루는 모든 연산이 원본을 수정하는 것은 아니다.
정렬을 예로 들어보자.
sorted()는 새 리스트를 반환한다numbers = [3, 1, 2]
result = sorted(numbers)
print(numbers) # [3, 1, 2]
print(result) # [1, 2, 3]
sorted()는 정렬된 새 리스트를 반환한다. 기존 리스트의 순서는 그대로 유지된다.
sort()는 기존 리스트를 정렬한다numbers = [3, 1, 2]
result = numbers.sort()
print(numbers) # [1, 2, 3]
print(result) # None
리스트의 sort()는 원본을 직접 정렬하고 None을 반환한다.
이 때문에 다음과 같이 작성하면 주의해야 한다.
numbers = [3, 1, 2]
numbers = numbers.sort()
print(numbers) # None
리스트 정렬은 수행되지만, 그 뒤 이름 numbers가 반환값인 None에 연결된다.
| 구분 | 원본 리스트 변경 | 반환값 |
|---|---|---|
sorted(numbers) | 변경하지 않음 | 정렬된 새 리스트 |
numbers.sort() | 직접 변경 | None |
객체가 변경 가능한지와 특정 연산이 원본을 변경하는지는 별도로 확인해야 한다.
객체가 가변인지와 어떤 연산이 원본을 바꾸는지는 따로 봐야 한다. 리스트는 변경 가능하지만 sorted()는 새 리스트를 반환하고, 불변인 튜플도 내부에서 가변 리스트를 참조할 수 있었다.
앞으로는 객체의 타입과 함께 실제로 어느 객체를 변경하는 코드인지 확인해보려고 한다.
같은 객체를 공유하지 않고 수정본을 따로 만들고 싶다면 어떻게 해야 할까? 다음 글에서는 단순 대입, 얕은 복사, 깊은 복사를 비교해보겠다.