[파이썬 튜토리얼] tuple 타입

PlanB·2022년 12월 31일
4

파이썬 튜토리얼

목록 보기
17/21

Level 1

tuple은 list처럼 연속된 요소의 나열을 다루기 위한 타입이다. 문법은 list와 비슷하다.

a = (1, 2, 3, 4, 5)

print(a)

결과

(1, 2, 3, 4, 5)

요소들을 콤마로 구분해 나열하고, 이를 소괄호로 감싸는 것으로 tuple을 표현할 수 있다. 다음과 같이 소괄호를 생략해도 tuple로 해석된다.

a = 1, 2, 3, 4, 5

print(a)

결과

(1, 2, 3, 4, 5)

특별히 기억해야 할 것은, 요소가 하나 뿐인 tuple을 정의하는 방법이다. 단일 요소를 소괄호로 감싸기만 하는 것으로는 이를 표현할 수 없다.

a = (1)

print(a)

결과

1

a = (1)a = 1로 해석된다. '산술 연산자' 단원에서 배웠던 것처럼, 소괄호가 단지 우선순위를 높여주는 역할로 사용되어서 그렇다. 떄문에, 모양이 조금 이상하긴 하지만 요소의 뒤에 콤마를 붙여줘야 한다.

a = (1,)
b = 1,

print(a)
print(b)

결과

(1,)
(1,)

연습문제

tuple 찾기

다음 코드에서, tuple 타입에 해당하는 변수가 무엇인지 찾아보자.

a = [1, 2, 3]
b = (1,)
c = (1)
d = 1,
e = 1
f = 1, 2
g = ([] + [])
h = ([] + []),
i = ([] + [],)

Level 3

list와 tuple의 차이

list와 tuple은 Sequence에 속한다. 순서 있는 요소의 집합을 다룬다는 점은 동일하다는 것이다. 그러나 두 타입의 구조가 완전히 동일한 것은 아니다. 둘의 가장 큰 차이는 Mutability(가변성)다. list는 Mutable(가변), tuple은 Immutable(불변)하다고 말한다.

Mutability에 따라 자체적으로 갖는 특징이 있으므로, 상황에 맞게 타입을 잘 선택하는 것이 좋다.

내용 수정

한 번 정의된 뒤 내용을 수정할 수 있으면 Mutable, 수정할 수 없으면 Immutable하다고 말한다. 앞에서 얘기했듯 list는 Mutable, tuple은 Immutable하다. list와 tuple에 대해, 내용을 수정하는 코드를 작성해 이 차이를 알아볼 수 있다.

l = [1, 2, 3]
t = (1, 2, 3)

l[0] = 0
t[0] = 0

결과

Traceback (most recent call last):
  File "example.py", line 5, in <module>
    t[0] = 0
TypeError: 'tuple' object does not support item assignment

list는 내용 수정이 가능하므로 비교적 더 자유롭다. 이것만 보면 tuple을 쓸 이유가 없어 보인다. 하지만 tuple은 Immutable이라는 것 하나만으로 강점을 갖는다.

Constant Folding

계산이 굳이 런타임에서 이루어질 필요가 없는 경우가 있다. 컴파일 타임에 표현식을 평가해서, 런타임의 일을 덜어주는 것을 Constant Folding이라고 한다. 예를 들어, 1 + 2 + x3 + x로 생략된다. 다음은 바이트코드를 disassemble해주는 dis 모듈을 이용해 constant folding이 되는 것을 확인하는 예제다.

from dis import dis


def f(x):
    return 1 + 2 + x

dis(f)

결과

  5           0 LOAD_CONST               1 (3)
              2 LOAD_FAST                0 (x)
              4 BINARY_ADD
              6 RETURN_VALUE

list는 런타임에 요소들을 load해 만들어지지만, tuple은 constant folding된다.

from dis import dis

dis(compile('[1, 2, 3]', '', 'eval'))
dis(compile('(1, 2, 3)', '', 'eval'))

결과

  1           0 LOAD_CONST               0 (1)
              2 LOAD_CONST               1 (2)
              4 LOAD_CONST               2 (3)
              6 BUILD_LIST               3
              8 RETURN_VALUE
  1           0 LOAD_CONST               0 ((1, 2, 3))
              2 RETURN_VALUE

따라서, list보다 tuple이 시간복잡도 면에서 유리할 수밖에 없다. list는 요소의 개수만큼 LOAD_CONST가 수행되므로, 요소의 개수와 리터럴의 평가 시간이 비례하기 때문이다. 다음은 timeit을 통해 벤치마크를 수행한 결과다.

$ python -m timeit "x=(1,2,3,4,5,6,7,8)"
10000000 loops, best of 5: 22 nsec per loop

$ python -m timeit "x=[1,2,3,4,5,6,7,8]"
2000000 loops, best of 5: 105 nsec per loop

$ python -m timeit "x=(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15)"
10000000 loops, best of 5: 22.5 nsec per loop

$ python -m timeit "x=[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]"
2000000 loops, best of 5: 177 nsec per loop

copy

tuple은 copy되지 않고, 객체 그대로 재사용된다.

from copy import copy

l = [1, 2, 3]
l_copy = copy(l)

t = (1, 2, 3)
t_copy = copy(t)

print(l is l_copy)
print(t is t_copy)

결과

False
True

Overallocation

list는 appendextend 메소드, + 연산자 등을 통해 확장 가능하다.

l = [1, 2, 3, 4, 5]
l.append(6)
l.extend([7])
l += [8]

print(l)

결과

[1, 2, 3, 4, 5, 6, 7, 8]

이는 모두 list의 요소 개수를 늘린다. 포인터 배열을 생각해 보면, 이러한 확장 동작은 메모리 재할당을 일으키게 된다. 그런데 list를 대상으로 append를 100번 수행한다고 해서, 메모리 재할당도 똑같이 100번 일어나지 않는다. 파이썬은 list의 현재 크기보다 더 많은 양의 메모리를 확보해 두기 때문이다. 이를 Overallocation이라고 부른다.

sys 모듈의 getsizeof 함수는 객체에 할당된 메모리 크기를 바이트 단위로 반환한다. 이를 이용해 Overallocation을 테스트해 보자. list가 기본으로 56바이트를 사용하고, 요소 하나가 8바이트를 사용하는 환경에서 테스트했다.

from sys import getsizeof

l = []

for i in range(100):
    print(i, getsizeof(l))

    l.append(0)

결과

0 56 
1 88 
2 88 
3 88 
4 88 
5 120
6 120
... (생략)
97 920
98 920
99 920

list의 길이가 0에서 1이 될 때 메모리가 재할당되고, 길이가 4가 될 때까지 재할당이 이루어지지 않았다. list에 추가된 요소는 하나지만, 요소 4개만큼의 메모리(32바이트)를 미리 할당받았다는 것을 알 수 있다.

물론 언제나 요소 4개만큼의 메모리를 할당받는 것은 아니다. list의 크기에 비례해 추가 할당할 메모리의 양을 계산하도록 만들어져 있다. 이 최적화 덕분에, 빈 list로 시작해 append를 10만 번 하더라도 메모리 재할당은 66번 밖에 실행되지 않는다.

from sys import getsizeof

l = []

latest_size = getsizeof(l)
resize_count = 0

for _ in range(100000):
    l.append(0)

    current_size = getsizeof(l)

    if latest_size != current_size:
        resize_count += 1
        latest_size = current_size

print(resize_count)

결과

66

Overallocation은 매우 소중한 최적화다. 그러나, List Comprehension과 같이 list가 평가되는 시점에 길이가 결정되는 것이 더 유리할 수 있다. 만약 [1, 2, 3, ... 10000]처럼 길이가 10000인 list 만들어야 한다고 치자. 빈 list([])로 시작해 append를 1만 번 수행하는 것보다 List Comprehension을 사용하는 것이 더 빠르다.

import timeit


def list_with_append():
    l = []

    for n in range(1, 10001):
        l.append(n)


def list_with_comprehension():
    l = [n for n in range(1, 10001)]


print(round(timeit.timeit(list_with_append, number=1000), 4))
print(round(timeit.timeit(list_with_comprehension, number=1000), 4))

결과

0.5725
0.2781

tuple은 Immutable하기 때문에, 확장에 대비할 필요가 없으므로 Overallocation 또한 이루어지지 않는다. 보통 tuple은 list보다 더 적은 메모리를 사용한다.

from sys import getsizeof

l = list(range(10))
t = tuple(range(10))

print(getsizeof(l))
print(getsizeof(t))

결과

104
68
profile
백엔드를 주로 다룹니다. 최고가 될 수 없는 주제로는 글을 쓰지 않습니다.

0개의 댓글