본문 바로가기

프로그래밍/Python

Python3의 range()는 메모리를 많이 차지하는가?

Python에서 일정한 규칙의 요소를 참조하기 위해 range()를 사용하는 경우가 자주 있다.

>>> for i in range(5):
...     print(i)
...
0
1
2
3
4

이는 다음과 같은 코드와 동일한 결과를 가진다. 그렇다면 range(5)는 [0, 1, 2, 3, 4]와 동일하다고 할 수 있을까?

>>> for i in [0, 1, 2, 3, 4]:
...     print(i)
...
0
1
2
3
4

결론부터 말하면 그렇지 않다. range(start, stop, step) 함수는 단순히 start부터 step씩 증가하며 stop 전까지의 값을 반환하는 함수가 아니고 파이썬에서 정의되어 있는 클래스의 생성자다. 이 생성자 함수는 불변 순서 타입(immutable sequence type)이며 값을 변경할 수 없는, 순서대로 내부 값을 반환하는 객체를 생성한다고 할 수 있다. 그렇다면 이게 [0, 1, 2, 3, 4]와 무슨 차이가 있을까?

 

대표적인 것은 immutable인 것처럼 중간에 값을 변경할 수 없다는 것이다. 다음과 같은 코드를 보자.

>>> l
[1, 2, 3, 4]
>>> for i in l:
...     l[0] += 10
...     l[1] += 10
...     l[2] += 10
...     l[3] += 10
...
>>> l
[41, 42, 43, 44]

리스트 l 내부에 있는 요소들에 대해 반복문을 돌면서 각 요소들을 10씩 증가시킨 결과 4번 반복했기 때문에 모든 요소들이 40씩 증가하게 되었다. range 클래스로 얻은 값도 range(5)[0], range(5)[3]처럼 인덱싱이 가능한데 그렇다면 이 리스트처럼 중간에 값이 바뀔 수 있을까? 확인 결과 TypeError가 발생하는 것을 볼 수 있었다.

>>> range(5)[0]
0
>>> range(5)[3]
3
>>> maybe_list = range(5)
>>> for i in maybe_list:
...     maybe_list[0] += 10
...     maybe_list[1] += 10
...     maybe_list[2] += 10
...     maybe_list[3] += 10
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: 'range' object does not support item assignment

range 객체에는 값을 할당할 수 없다는 오류를 발생시키고 있는데 이는 range 클래스가 immutable, 즉 불변 자료형에 해당하기 때문이다. 타입을 확인해보면 리스트와는 다른 타입인 것을 볼 수 있다.

>>> list = [0,1,2,3,4]
>>> maybe_list = range(5)
>>> type(list)
<class 'list'>
>>> type(maybe_list)
<class 'range'>

Python에서 immutable 객체는 문자열, 튜플, range 등이 있으며 mutable 객체는 int, bytearray, set, dict 등이 있다. 확실한 차이점은 전자는 값을 변경할 수 없지만 후자는 가능하다는 것이다. 그래서 range 객체는 값을 수정할 수 없지만 리스트 객체는 값을 수정할 수 있다. 아래 코드를 보면 문자열의 4번째 요소(인덱스 3)를 변경하려고 했지만 실패했고 리스트에서는 성공적으로 변경된 것을 볼 수 있다.

>>> txt="12345"
>>> txt
'12345'
>>> txt[3]='a'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
>>> txt_list=['1','2','3','4','5']
>>> txt_list
['1', '2', '3', '4', '5']
>>> txt_list[3]='a'
>>> txt_list
['1', '2', '3', 'a', '5']

그렇다면 range(5)나 [0, 1, 2, 3, 4]나 불변 여부만 다를 뿐 동일한 게 아닐까? 결론적으로 이 둘의 차이는 값을 미리 저장하고 있는지 아니면 참조 시마다 값을 계산해서 돌려주는지의 차이가 있다.

>>> pre_defined_storage = [0] * 100000
>>> dynamically_defined_storage = range(0, 100000, 1)
>>> import sys
>>> sys.getsizeof(pre_defined_storage)
800056
>>> sys.getsizeof(dynamically_defined_storage)
48

해당 변수가 차지하는 메모리 크기를 구하기 위해 sys 모듈의 getsizeof 함수를 사용해서 비교해봤다. 그 결과 0이 10만 개 저장된 pre_defined_storage는 800056 바이트를, 0부터 10만까지 반환하도록 하는 range 객체는 48 바이트를 차지하고 있다는 것을 알 수 있었다. 둘 다 아까처럼 반복문에 비교해봐도 동일하게 10만 번 돌아간다는 것을 알 수 있는데 왜 이렇게 메모리 크기가 차이 나는 것일까? 이는 range 객체에서는 모든 값을 계산해서 미리 기억하고 있는 것이 아니라 요구할 때마다 매개변수로 받은(또는 기본값으로 정해진) 값들을 기반으로 연산하여 그때마다 결과를 돌려주기 때문이다(문서).

The advantage of the range type over a regular list or tuple is that a range object will always take the same (small) amount of memory, no matter the size of the range it represents (as it only stores the start, stop and 
step values, calculating individual items and subranges as needed).

range 객체에서는 start, stop, step 이 세 가지 값만 유지하고 있으면 된다. 이는 range(5)나 range(0, 5, 2)처럼 전달되는 매개변수로 range 객체에 대하여 인덱싱을 수행한다면 "start + step * i" 같은 공식을 계산하여 그 값을 반환해줄 수 있기 때문이다. 만약 range(0, 10000, 1) 객체의 3000번째 인덱스를 참조했을 때 이 객체는 (0 + 1 * 3000), 즉 3000을 반환해주는 식으로 동작하는 것이다. 그래서 리스트처럼 해당 갯수만큼 모든 값을 갖고 있지 않아도 특정 인덱스를 참조할 수 있다.

 

그렇다면 많은 경우에 메모리 효율도 좋고 간단한 이 range 객체만 사용하면 되지 않을까? 하지만 아쉽게도 immutable, 즉 변경 불가능한 객체기 때문에, 그리고 오직 정수값만 다룰 수 있기 때문에 리스트만큼 범용성이 있진 않다. 대신 일정 수만큼 반복해야 하는 카운터 역할을 수행할 때는 아주 유용할 것이다.