본문 바로가기

더 나은 엔지니어가 되기 위해/파이썬을 파이썬스럽게

슬기로운 파이썬 트릭 4 - 파이썬의 일반 데이터 구조

이 글은 슬기로운 파이썬 트릭을 읽고 핵심만 빠르게 정리한 글이다.


5.1. 딕셔너리, 맵, 해시테이블

dict : 믿음직한 딕셔너리

phonebook = {
    "bob": 1234,
    "alice": 3719
}
  • 딕셔너리의 키는 불변타입(immutable) 만 가능하다.
  • 조회, 삽입, 갱신 및 삭제의 시간복잡도는 O(1) 이다.
  • 사용하지않을 이유가 거의 없다.

collections.OrderedDict : 키 삽입 순서 기억

>>> import collections
>>> d = collections.OrderedDict(one=1, two=2, three=3)
>>> d
OrderedDict([('one', 1), ('two', 2), ('three', 3)])
  • 해시의 특성상 데이터의 순서 개념이 없는데, OrderedDict 는 삽입 순서를 기억함.
  • 개인적으로 딕셔너리로 json 만들 때 유용하다고 생각.

collections.defaultdict : 누락된 키의 기본값 반환

>>> from collections import defaultdict
>>> dd = defaultdict(list)
>>> dd['dogs'].append('Rufus')
>>> dd['dogs'].append('Kathrin')
>>> dd
defaultdict(<class 'list'>, {'dogs': ['Rufus', 'Kathrin']})
  • 키가 없는 경우, 기본 값을 알아서 반환한 뒤 동작 (위의 경우 list 반환)
  • defaultdict 인자로 callable 한 객체(따라서 함수도 가능)를 넣어주면 됨.
  • 개인적으로 평소에 굉장히 유용하게 잘 쓰고있음. 특히 위 같이 list 를 가지는 딕셔너리의 경우.

types.MappingProxyType : 읽기 전용 딕셔너리 래퍼

>>> from types import MappingProxyType
>>> writable = {'one': 1, 'two': 2}
>>> read_only = MappingProxyType(writable)

# 프락시는 읽기 전용이다.
>>> read_only['one']
1
>>> read_only['one'] = 23
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'mappingproxy' object does not support item assignment

# 원본 업데이트가 프락시에 반영된다.
>>> writable['one'] = 42
>>> read_only
mappingproxy({'one': 42, 'two': 2})
  • 나도 처음보는데, 견고하게 프로그래밍하는데 굉장히 유용할거 같음...
  • 파이썬 3.3부터 들어온 클래스라고 함.

5.2. 배열 데이터 구조

list : 가변 동적 배열

>>> arr = ['one', 'two', 'three']
>>> arr[0]
'one'
>>> arr[0] = 1
>>> arr
[1, 'two', 'three']

tuple : 불변 컨테이너

>>> arr = 'one', 'two', 'three'
>>> arr[0]
'one'
>>> arr[1] = 'hello'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

array.array : 기본적인 타입 지정 배열

>>> import array
>>> arr = array.array('f', [1., 2., 3.])
>>> arr
array('f', [1.0, 2.0, 3.0])

# array 는 같은 타입으로 수정가능하다.
>>> arr[0] = 23.

# 다른 타입으로는 수정이 불가능하다.
>>> arr[1] = "hello"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: must be real number, not str
  • 리스트나 튜플보다는 공간 효율적이다.
  • 나도 처음 보긴 하는건데, 견고하게 프로그래밍 짜야될 때 좋지 않을까 싶다.

str : 유니코드 문자의 불변 배열

>>> arr = "abcd"
>>> arr[1]
'b'

# 문자 배열은 수정할 수 없다.
>>> arr[1] = 'e'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment

bytes : 단일 바이트의 불변 배열

>>> arr = bytes((0, 1, 2, 3))
>>> arr[1]
1

# 바이트 리터럴은 자체 문법이 있다.
>>> arr
b'\x00\x01\x02\x03'

# 유효한 '바이트'(0 ~ 255 범우의 정수)만 허용된다.
>>> bytes((0, 300))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: bytes must be in range(0, 256)

# 바이트는 변경할 수 없다.
>>> arr[1] = 23
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

>>> del arr[1]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object doesn't support item deletion

bytearray : 단일 바이트의 가변 배열

>>> arr = bytearray((0, 1, 2, 3))
>>> arr[1]
1
>>> arr
bytearray(b'\x00\x01\x02\x03')

# 바이트 배열은 수정할 수 있다.
>>> arr[1] = 23
>>> arr
bytearray(b'\x00\x17\x02\x03')
  • byte 는 불변인 반면 bytearray 는 수정가능하다.
  • 둘 다 내가 쓸 일이 있겠냐만은... 누군가 써놓은 코드 이해할려면 알아가는 것도 나쁘진 않겠다.

5.3. 레코드, 구조체, 데이터 전송 객체

배열과 비교하여 레코드 데이터 구조는 고정된 수의 필드를 제공하며, 각 필드는 이름을 가질 수 있고, 서로 다른 타입을 담을 수 있다.

어떤 자료구조를 써야 레코드를 올바르게 구현할 수 있을까. 이러한 의사 결정 가이드를 알아보자.

dict : 간단한 데이터 객체

car1 = {
    'color': 'red',
    'mileage': 3812.4,
    'automatic': True
}

# 딕셔너리에는 멋진 repr이 있다.
>>> car1
{'color': 'red', 'mileage': 3812.4, 'automatic': True}
  • 딕셔너리를 사용하여 작성된 데이터 객체는 변경 가능하며, 필드는 언제든지 바뀔 수 있다.
  • 편리하지만, 그만큼 버그를 유발하기 쉽다. 이는 거의 늘 트레이드 오프 관계다.

tuple : 불변 객체 그룹

>>> car1 = ('red', 3812.4, True)

# 튜플에는 멋진 repr 이 있다.
>>> car1
('red', 3812.4, True)
  • 튜플은 딕셔너리와 달리 불변 객체다. 따라서 변경에 따른 버그를 유발하지는 않는다.
  • 다만, 필드의 이름을 나타낼 수 없기 때문에 순서를 기억해야하고, 가독성이 떨어지는 문제가 있다.

사용자 정의 클래스 작성

class Car:
    def __init__(self, color, mileage, automatic):
        self.color = color
        self.mileage = mileage
        self.automatic = automatic

>>> car1 = Car('red', 3812.4, True)

# 문자열 표현을 위해 손수 __repr__ 메서드를 추가해야 한다.
>>> car1
<Car object at 0x1081e69e8>
  • 클래스를 사용하면 데이터 객체에 대한 "청사진"을 정의하여 모든 객체가 동일한 필드 집합을 제공하도록 할 수 있다.
  • 자신만의 __repr__ 메써드를 추가하여, 객체의 문자열 표현방식을 구현해야 한다.
  • 메써드를 추가함으로써 동작을 구현할 수 있다. 다만 이러한 구현은 이 클래스가 더 이상 일반 데이터 객체가 아니라는 것을 의미하기도 한다.

collections.namedtuple : 편리한 데이터 객체

>>> from collections import namedtuple
>>> Car = namedtuple('Car', 'color mileage automatic')
>>> car1 = Car('red', 3812.4, True)

# namedtuple 에는 멋진 repr이 있다.
>>> car1
Car(color='red', mileage=3812.4, automatic=True)

# 필드는 변경할 수 없다.
>>> car1.mileage = 12
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
  • 잘 사용하면 클래스보다는 가볍고, 딕셔너리보다는 안정성있고(불변이기 때문) 의도가 명확한 레코드를 만들 수 있다.
  • 내부적으로는 클래스로 구현된다. 다만 메모리 사용량이 일반 클래스보다 더 좋으며 튜플만큼 효율적이다.

typing.NamedTuple : 개선된 네임드튜플

from typing import NamedTuple
class Car(NamedTuple):
     color: str
     mileage: float
     automatic: bool

>>> car1 = Car('red', 3812.4, True)

# NamedTuple 에는 멋진 repr이 있다.
>>> car1
Car(color='red', mileage=3812.4, automatic=True)
  • namedtuple 과 매우 흡사하며, 주요 차이점은 새로운 레코드 타입을 정의하고, 타입 힌트를 지원할 수 있다는 점이다.

struct.struct : 직렬화된 C 구조체

그다지 불필요한 내용일거 같아 생략

types.SimpleNamespace : 세련된 속성 접근

from types import SimpleNamespace
car1 = SimpleNamespace(color='red',
                       mielage=3812.4,
                       automatic=True)

# SimpleNamespace 에는 멋진 repr이 있다.
>>> car1
namespace(automatic=True, color='red', mielage=3812.4)

# 속성 변경, 삭제 등이 가능하다.
>>> car1.mileage = 12
>>> car1.windshield = 'broken'
>>> del car1.automatic
>>> car1
namespace(color='red', mielage=3812.4, mileage=12, windshield='broken')
  • 파이썬 3.3에서 추가됐다.
  • 딕셔너리에서 사용하는 obj['key'] 문법 대신 obj.key 방식으로 속성에 접근할 수 있다.

레코드 구조체 선택 가이드

  • 몇 개(2~3)의 필드만 갖고 있다.
    • 필드 순서를 기억하기 쉽거나, 필드명이 불필요 한 경우. 튜플을 사용하자.
    • ex. 삼차원 공간에서의 점 : (x, y, z)
  • 불변 필드가 필요하다.
    • 일반 튜플, collections.namedtuple, typing.NamedTuple 가 좋은 옵션이다.
  • 오타가 발생하지 않도록 필드 이름을 고정할 필요가 있다.
    • collections.namedtuple, typing.NamedTuple 사용
  • 간단하게 유지하기를 원한다.
    • 일반 딕셔너리 객체는 JSON 과 매우 비슷한 편리한 구문을 제공하므로, 좋은 선택일 수 있다.
  • 데이터 구조를 완전히 제어할 필요가 있다.
    • @property 의 세터와 게터를 사용하여 사용자 정의 클래스를 작성해야 한다.
  • 객체에 동작을 추가해야 한다.
    • 사용자 정의 클래스를 작성하거나, collections.namedtuple 혹은 typing.NamedTuple을 확장하여 작성한다.
  • 데이터를 디스크에 저장하거나, 네트워크로 전송해야 해서 데이터를 일려로 빽빽하게 담아야 한다.
    • struct.Struct 를 사용하는 것이 좋다.
  • 안정적이고 기본적인 선택은?
    • typing.NamedTuple 이 파이썬 3.x 에서 레코드 구조체 구현을 위한 일반적인 권장사항이다.

5.4. 세트와 멀티세트

set : 믿음직한 세트

>>> vowels = {'a', 'e', 'i', 'o', 'u'}
>>> 'e' in vowels
True
>>> vowels.add('x')
>>> vowels
{'a', 'e', 'i', 'o', 'u', 'x'}
  • 변경 가능한 해시 자료구조로, 데이터 자료구조에 접근하는데 O(1) 의 시간복잡도가 걸림.

frozenset : 불변세트

>>> vowels = frozenset({'a', 'e', 'i', 'o', 'u'})
>>> vowels.add('x')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'add'
  • set 지만 불변이다.

collections.Counter : 멀티세트(bag)

>>> from collections import Counter
>>> inventory = Counter()
>>> loot = {'sword': 1, 'bread': 3}
>>> inventory.update(loot)
>>> inventory
Counter({'bread': 3, 'sword': 1})

>>> more_loot = {'sword': 1, 'apple': 1}
>>> inventory.update(more_loot)
>>> inventory
Counter({'bread': 3, 'sword': 2, 'apple': 1})

# len 은 고유 요소의 개수를 반환한다.
>>> len(inventory)
3

# sum은 총 요소를 반환한다.
>>> sum(inventory.values())
6
  • 요소의 수를 담는, 일종의 가방(bag) 역할을 한다.

5.5. 스택 (LIFO)

list : 간단한 내장 스택

>>> s = []
>>> s.append('eat')
>>> s.append('sleep')
>>> s.append('code')
>>> s.pop()
'code'
>>> s.pop()
'sleep'
>>> s
['eat']
  • 리스트는 내부적으로 동적 배열로 구현된다.
  • 리스트 특정 요소 접근에 시간 복잡도 O(1) 만큼 소요된다.

collections.deque : 빠르고 강력한 스택

  • 스택과 큐로 모두 사용가능한 자료구조다.
    스택으로서 사용법은 리스트와 동일함.
  • 데크는 내부적으로는 이중 연결리스트로 구현되어 있다.
  • 따라서 특정 요소 접근에 시간 복잡도 O(n) 만큼 소요된다.
  • 개인적인 생각으론, 스택은 그냥 리스트로. 큐는 데크로 짜는게 맞다고 본다.

####queue.LifoQueue : 병렬 컴퓨팅을 위한 잠금 체계

>>> from queue import LifoQueue
>>> s = LifoQueue()
>>> s.put('eat')
>>> s.put('sleep')
>>> s.put('code')
>>> s
<queue.LifoQueue object at 0x1098ffba8>
>>> s.get()
'code'
>>> s.get()
'sleep'
>>> s.get()
'eat'
>>> s.get_nowait()
_queue.Empty
>>> s.get()
# 블록되어 영원히 기다린다.
  • 동시에 여러 생산자와 소비자를 지원하는 잠금 체계를 제공한다.
  • 잠금 체계가 필요치 않다면, 불필요한 부하가 발생할 수 있다.

5.6. 큐 (FIFO)

collections.deque : 빠르고 강력한 스택

>>> from collections import deque
>>> q = deque()
>>> q.append('eat')
>>> q.append('sleep')
>>> q.append('code')
>>> q.popleft()
'eat'
>>> q.popleft()
'sleep'
>>> q.popleft()
'code'

queue.Queue: 병렬 컴퓨팅을 위한 잠금 체계

>>> from queue import Queue
>>> q = Queue()
>>> q.put('eat')
>>> q.put('sleep')
>>> q.put('code')
>>> q.get()
'eat'
>>> q.get()
'sleep'
>>> q.get()
'code'
>>> q.get_nowait()
_queue.Empty
>>> q.get()
# 블록되어 영원히 기다린다.
  • 동시에 여러 생산자와 소비자를 지원하는 잠금 체계를 제공한다.
  • 잠금 체계가 필요치 않다면, 불필요한 부하가 발생할 수 있다.

multiprocessing.Queue : 공유 작업 큐

>>> from multiprocessing import Queue
>>> q = Queue()
>>> q.put('eat')
>>> q.put('sleep')
>>> q.put('code')
>>> q.get()
'eat'
>>> q.get()
'sleep'
>>> q.get()
'code'
>>> q.get()
# 블록되어 영원히 기다린다.
  • 멀티 프로세스 환경에서, 큐를 공유해야할 때 사용한다.

5.7. 우선순위 큐

heapq : 리스트 기반 이진 힙

>>> import heapq
>>> q = []
>>> heapq.heappush(q, (2, 'code'))
>>> heapq.heappush(q, (1, 'eat'))
>>> heapq.heappush(q, (3, 'sleep'))
>>> while q:
...     next_item = heapq.heappop(q)
...     print(next_item)
...
(1, 'eat')
(2, 'code')
(3, 'sleep')
  • heapq.heappush(리스트, (우선순위, 아이템))heapq.heappop(...) 로 우선순위 힙을 구현한다.
  • 최소 힙 구현만 제공하기 때문에 기본적으로 우선순위 값이 오름차순으로 뽑힌다.
  • 최대 힙을 구현하려면 이 우선순위를 그대로 가져가되, 음수(-1)을 곱해주면 된다.

queue.PriorityQueue : 병렬 컴퓨팅을 위한 우선순위 큐

>>> from queue import PriorityQueue
>>> q = PriorityQueue()
>>> q.put((2, 'code'))
>>> q.put((1, 'eat'))
>>> q.put((3, 'sleep'))
>>>
>>> while q:
...     next_item = q.get()
...     print(next_item)
...
(1, 'eat')
(2, 'code')
(3, 'sleep')
# 블록되어 영원히 기다린다.
  • 내부적으로는 heapq 를 사용하고 동일한 시간과 공간 복잡성을 공유한다.
  • 다른 점은 동기 방식이며, 여러 생산자와 소비자를 지원하는 잠금 체계를 제공한다는 것.