Mutable, Immutable

teal·2023년 8월 12일
0

Python

목록 보기
5/8

저번 편에서는 파이썬의 데코레이터, 클로저의 동작 방식에 대해서 알게 되었다.
그와 같이 스코프에 관련된 이야기도 나왔는데 뭔가 이상한 점을 느끼지 못했는가? LEGB 스코프에서 Local에 있는 것에대해서는 값을 읽거나 수정하는 것이 당연히 가능하다는 것은 모두 알고 있을 것이다. 그런데 함수 외부에 존재하는 Enclosing 값도 읽는 것은 가능한데 어떻게 값을 바꿀수 있을까?

문제

한번쯤은 이런 경험들이 다들 있지 않은가?
간단하게 pow 함수가 몇번 실행되는지 체크하는 기능을 추가했다.

pow_count: int = 0
def pow(n: int) -> int:
    pow_count += 1
    return n ** 2

print(pow(10))
print(pow(5))
print(pow_count)

그런데 결과는

UnboundLocalError: local variable 'pow_count' referenced before assignment

대체 왜 이러는 걸까? 클로저를 설명할때, 메모이제이션 기법을 사용할 때는 cache 딕셔너리를 그냥 참조해도 문제가 없었는데..?

이는 파이썬이 함수 내에서 변수를 사용할 때는 로컬 변수로 간주하고 사용하기 때문이다. 그냥 로컬 변수로 간주하고 사용하기 때문에 아예 그 변수명이 정의되지 않아 발생하는 NameError가 아니면 값을 참조해서 사용하는 경우는 자유롭게 가능하다. 이는 그냥 값을 참조하는 경우는 이 변수가 진짜 로컬 변수인지를 체크하지 않기 때문이다.

그런데 만약 해당 변수의 값을 바꾸려고 한다면 먼저 해당 변수가 진짜 로컬 스코프에 있는지를 먼저 판단한다. 그리고 만약 로컬 스코프에 없으면 수정을 못하는 구조를 가지게 된다. 이는 여러가지 이유가 있지만 내가 생각하는 이유들은 다음과 같다.

  1. 예측 가능한 동작
  2. 데이터의 무결성 유지
  3. 가독성

만약에 외부 스코프의 값을 마음대로 바꿀 수 있게되면 그게 모든 변수가 전역 변수로 동작하는 것과 뭐가 다를까? 만약 변경 가능하면 함수 하나 짤때마다 외부 변수의 값을 혹시 이름을 같게 해서 잘못 적지는 않았을까 두려움에 떨게 되며 개발을 해야할 것이다.

여기까지 이야기를 하고 보면, 그래 외부 스코프의 값을 바꾸지 못하게하는 이유는 알겠다. 그런데 바로 전 글에서 Enclosing 스코프 변수의 값을 참조해서 바꾸지 않았는가? 이는 어떻게 진행된걸까? 예시는 아래와 같다.

def cache(f: Callable) -> Callable:
    cache = {}

    def inner(*arg, **kwargs):
        n = arg[0]

        if cache.get(n):
            return cache[n]

        cache[n] = f(*arg, **kwargs)
        return cache[n]
    return inner


@cache
def fib(n: int) -> int:
    if n < 3:
        return 1
    return fib(n-2) + fib(n-1)

아니 cache라는 딕셔너리는 로컬 변수가 아닌데 inner함수에서 마음대로 값을 수정하고 있다. 도대체 어떻게 이런 구조가 가능한 것일까?

여기서는 이제 mutable, immutable과 관련된 개념이 등장한다.

mutable : 가변
immutable : 불변

바꾸려고 한 int와 같은 str, float 등은 immutable, 불변 객체이기 때문에 수정을 하지 못한다.

cache에서 사용한 dict와 같은 list, set 객체들은 mutable, 가변 객체이기 때문에 수정 가능하다.

immutable

아니 int가 무슨 수정을 하지 못한다는 것인가? 지금까지 개발할때 사용했던 코드들은 대체 어떻게 동작하는 건가? 수정을 하지 못하면 cnt += 1과 같은 코드들은 대체 동작을 어떻게 하고 있었을까?

이제 여기서는 immutable 객체가 어떻게 동작하는지 알아야한다.
파이썬에서 변수 할당은 실제로는 변수가 해당 객체를 참조할 수 있게 하는 것이다. 그래서 변수를 통해 해당 객체에 참조해서 값을 읽거나 바꿀수 있게 해준다. 그런데 때로는 객체의 값을 바꾸는 것이 아닌 참조하는 객체를 다른 객체로 변경하는 로직을 가지게 된다.

간단하게 아래 코드로 보자

a = 10
print(id(a))

a += 5
print(id(a))

id 함수를 통해 해당 변수가 어느 메모리 주소를 참조하고 있는지를 알수 있다.

4300253776
4300253936

응? 값만 추가한것인데 주소의 값이 바뀐다. 즉 a라는 변수가 가리키고 있는 메모리 주소의 값이 바뀐 것이다. 이처럼 immutable 객체는 값을 변경하면 해당 객체의 값이 바뀌는 것이 아니라 a라는 변수가 10을 가리키고있다가 15라는 값을 가진 메모리주소를 가리키는것으로 바뀌게 되는것이다.

그런데 여기서 한가지 테스트를 하면 재미있는 사실을 알 수 있다.

for i in range(6):
    print(i, id(i))

print()
for i in range(10, 5, -1):
    print(i, id(i))
0 4345227536
1 4345227568
2 4345227600
3 4345227632
4 4345227664
5 4345227696

10 4345227856
9 4345227824
8 4345227792
7 4345227760
6 4345227728

immutable 객체를 순서대로 할당해서 32비트 메모리 주소 간격으로 생성되는 것은 간단하게 이해가 가능하다. 그런데 5 -> 10으로 갈 때 왜 간격이 160, int 5개의 간격만큼 멀어지고 6은 5의 다음주소를 가질수 있는걸까? 나는 생성을 0 1 2 3 4 5 10 9 8 7 6 순으로 했는데?

여기선 object interning이라는 기법 때문이다. 파이썬에서는 자주 쓰이는 immutable 객체들에 대해 미리 메모리에 로드/캐싱하는 방법을 사용하기 때문에 해당 객체들은 파이썬 런타임 내내 같은 주소를 갖게 된다. 정수는 -5~256이고 문자는 하나의 문자가 미리 캐싱된다고 한다.

참조

그러면 256을 넘어가는 정수는 메모리 주소가 다르겠지?

a = 257
b = 257
print(id(a), id(b))
4301328272 4301328272

아니 256까지만 한다면서 257도 interning을 통해 같은 메모리를 갖게 된다. 이게 대체 무슨일일까?

num = 1
a = 257
b = 256 + num
c = 256 + 1
print(id(a))
print(id(b))
print(id(c))
4334554672
4334555088
4334554672

이것을 보면 다 같은 257이지만 a는 257 그대로고 b는 256+num(1), c는 256 + 1이다 그런데 여기서 b만 주소가 다른 것을 볼 수 있다. 이는 파이썬이 바이트 코드로 컴파일(?)될때 결정되는 값들은 immutable 객체일 경우 interning을 진행하는 최적화 기법을 통해 퍼포먼스를 끌어올리기 때문이다. 그런데 b = 256 + num의 경우 바이트 코드로 바꿀 때 num이 대체 어떤 값일지 모르기 때문에 interning을 진행할 수 없기 때문에 런타임에 처리를 진행하고 그래서 혼자 주소가 다른것이다.

여튼 immutable 객체가 어떤식으로 저장되고 사용되는지를 알게 되었다. 결국 immutable 객체는 값이 바뀔 수 없고 변수는 가리키는 주소를 바꾸게 된다.
결국 파이썬은 외부 스코프, Enclosing, Global 스코프에 있는 변수를 통해 해당 값을 참조를 할 수는 있지만 해당 변수가 가리키고 있는 주소를 바꾸는 것은 불가능하다는 것이다. 즉 변수에 +1을 시키면 변수가 가리키는 주소가 바뀌기 때문에 로컬 변수가 아니면 변경을 막아둔 것이다.

즉 파이썬에서 변수를 사용할때는 로컬 변수로 가정하고 사용을 하게되어 참조는 마음껏 할수 있으나 수정을 요청할 경우 로컬 변수가 아니면 수정을 진행하면 안되므로 로컬 변수인지 체크를 하여 아까와 같은 UnboundLocalError가 발생하게 되는 것이다.

mutable

그런데 immutable객체가 그런식으로 변경이 불가능하다면 대체 외부 스코프에 존재하는 mutable 객체는 어떻게 참조하고 값 변경이 가능하다는 것인가? mutable 객체, list같은 객체는 append 등을 진행해도 주소가 동일하다는 말인가?

맞다. 아래의 코드에서 그것을 확인해보자

arr = [1, 2, 3]
print(id(arr))

arr.append(4)
print(id(arr))
4367482432
4367482432

mutable 객체, 배열을 예시로 들면 배열이 시작되는 주소를 변수가 가리키고 있기 때문에 append 등을 진행해도 주소값이 변경될 일이 없다.

그렇기 때문에 리스트를 사용할때 arr.insert(0, value)와 같은 방법은 지양해야한다. 배열의 시작부분은 그대로인데 원소를 맨 앞에 넣으려면 대체 무슨일을 진행하겠는가? 나머지 원소를 전부 뒤로 한칸씩 밀어버리는 무식한 방법을 진행할 수 밖에 없으므로 이런 일이 많이 필요하면 deque의 사용을 고려해야한다.

위의 결론은 결국 변수가 가리키고 있는 mutable 객체에 대한 수정을 발생시켜도 변수가 가리키는 mutable 객체의 주소값은 그대로 이므로 외부 스코프에 존재하는 mutable 객체도 수정이 가능하다는 것이다. 그래서 상단에 존재하는 cache 함수가 동작할 수 있게 된다.

변수가 가리키는 주소 수정

그러면 외부 스코프의 변수가 가리키는 주소는 변경이 불가능한 것인가?
아니다. 특정 방법을 통해 수정이 가능하다.

input_values = []
pow_count = 0
def pow(n: int) -> int:
    global input_values
    global pow_count
    input_values = None
    pow_count += 1
    return n ** 2

print(pow(10))
print(pow(20))

print(input_values)
print(pow_count)
100
400
None
2

global 키워드를 통해 해당 변수가 global 스코프에 존재한다는 것을 명시해주면 된다 이를 통해 input_values, pow_count와 같은 변수들, 즉 외부 스코프에 존재하는 변수가 가리키는 주소를 바꿀 수 있게 된다. 그러면 전역 변수만 가능한가? 그것도 아니다. 외부 함수 스코프 즉, Enclosing 스코프의 경우 nonlocal 키워드로 접근이 가능하다.

그리고 위와같은 로직 때문에 다음과 같은 경우는 오류가 발생한다.

pow_cnt = 10
def pow(n: int) -> int:
    print(pow_cnt)
    pow_cnt = 1
    return n ** 2

print(pow(10))
print(pow(20))

print(pow_cnt)
UnboundLocalError: local variable 'pow_cnt' referenced before assignment

구조만보면 이상할 것은 없어보인다. pow 함수에서 외부 스코프에 있는 pow_count를 출력하고 내부에선 pow_cnt를 지역변수로 선언해서 값을 다루고 싶은 것이다.

그런데 pow_cnt를 지역변수로 선언하면 LEGB의 순서에 따라 pow_cnt의 네임을 가지고있는 변수는 내부에서 지역변수로 찾게되고 pow_cnt는 지역변수로서 선언도 안됐는데 print(pow_cnt)로 참조하려고 하기에 오류가 발생한다. 그래서 외부 스코프에 있는 값을 참조하면서 내부에서 다른 값을 다르게 다루고 싶은 경우 다른 이름으로 선언해서 외부 스코프의 pow_cnt는 global 스코프에서 찾게 해줘야한다.

profile
고양이를 키우는 백엔드 개발자

0개의 댓글