파이썬은 현재 가장 널리 쓰이는 프로그래밍 언어이지만, 그러다보니 제대로 알지 못하고 쓰는 기능들이 많다. 특히 데이터 분석 주로 하는 동료들 보면, 데이터 흐름에만 신경 쓰다보니, 정리되지 않은 코드, 잘 모르고 쓰는 기능들이 많이 보인다.
앞으로 알면 유용한 파이썬 기능들을 하나씩 써보고자 한다. 오늘은 반복자(iterator)와 발생자(generator)에 대한 것.
Subscriptable 과 iterable 에 대한 설명 먼저 한다. 파이썬에선 모든 것이 객체다. 객체 가운데 특별한 성질을 갖는 것들이 있는데 subscriptable (몇번째 요소를 가져올 수 있는) 객체가 있고, iterable (반복할 수 있는) 객체가 있다. 다음 예제를 보자.
>>> astr = "Hello world!"
>>> alist = [1, 2, 3]
>>> anum = 120
>>>
>>> astr[2]
'l'
>>> alist[2]
3
>>> anum[2]
Traceback (most recent call last):
File "<python-input-7>", line 1, in <module>
anum[2]
~~~~^^^
TypeError: 'int' object is not subscriptable
>>>
astr과 alist는 모두 subsciptable 하기 때문에 "2"번째 요소를 달라는 "[2]" 구문이 정상 동작한다. 하지만, anum은 숫자형이고, 두번째 요소를 달라는 구문은 subsciptable 하지 않다며 동작하지 않는다. subscriptable 한 객체는 interable 즉, 반복할 수 있으며, for loop 구문에 쓰일 수 있다.
>>> for each in astr:
... print(each, end=" ")
...
H e l l o w o r l d !
>>>
>>> for each in alist:
... print(each, end=" ")
...
1 2 3
>>>
>>> for each in anum:
... print(each)
...
Traceback (most recent call last):
File "<python-input-21>", line 1, in <module>
for each in anum:
^^^^
TypeError: 'int' object is not iterable
>>>
subscriptable 한 객체는 iterable 하며, for loop에 쓰일 수 있다. for loop는 전체 요소를 다 돌게 되지만, 그때 그때 다음 항목이 필요하다면 어떻게 할 수 있을까? 이 때 등장하는 것이 iterator, 반복자이며, iter() 내장함수로 반복자를 만들고, next()로 다음 항목, 또 다음 항목을 가져올 수 있다.
>>> itlist = iter(alist)
>>> next(itlist)
1
>>> next(itlist)
2
>>> next(itlist)
3
>>> next(itlist)
\Traceback (most recent call last):
File "<python-input-35>", line 1, in <module>
next(itlist)
~~~~^^^^^^^^
StopIteration
>>>
next() 를 호출할 때 마다, 다음 항목을 리턴하고, 더이상 항목이 없을 때는 StopIteration 예외를 발생한다. 이것은 반복자의 중요한 특징이다. 큰 데이터를 다룰 때 특히 효율적인데, 데이터 전체를 들고 있을 필요 없이 다음 항목만 넘겨주면 되기 때문이다. 텍스트 파일을 읽을 때 쓰는 open() 함수의 출력이 대표적인 반복자이다.
>>> afile = open('/etc/hosts')
>>> next(afile)
'##\n'
>>> for line in afile:
... print(line)
...
# Host Database
...
파일을 읽은 afile 객체는 라인 단위로 반복하는 반복자이며, next() 함수로 다음줄을 가져오거나, for loop로 모든 라인을 순차적으로 접근할 수 있다. 파일 전체를 메모리로 로드하는 것이 아니라, 한줄한줄 처리한다는 점에 주목하자.
range() 내장함수를 기억하는가? range(3) 하면, [0, 1, 2] 리스트를 리턴하는 함수. 사실 리스트가 아닌 특별한 객체이다. range(100000) 하면 십만개 요소를 가진 배열이 생성되는데, 이를 리스트로 작업하면 전체 데이터를 들고 있게 되고, 메모리 소모가 크다.
>>> ar = range(100000)
>>> arlist = list(ar)
>>>
>>> ar[:10]
range(0, 10)
>>> arlist[:10]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>>
>>> import sys
>>> sys.getsizeof(ar) # 객체의 메모리 크기를 반환하는 함수
48
>>> sys.getsizeof(arlist)
800056
>>>
반복자는 이처럼 데이터 전체를 들고 있지 않은 채로 필요한 기능들을 쓸 수 있게 하기 때문에 메모리 효율성 측면에서 유용하다.
무심코 사용한 list comprehension이 유사한 이유로 메모리를 많이 쓰기도 한다.
>>> lc = [x for x in range(100000)]
>>> sys.getsizeof(lc)
800984
>>>
>>> ge = (x for x in range(100000))
>>> sys.getsizeof(ge)
192
>>>
대괄호 "[]" 를 쓴 것이 list comprehension이고, 소괄호 "()"를 쓴 것이 generator expression (발생자 표현)이다. 변수 lc가 십만개 짜리 전체 데이터를 메모리에 들고 있는데 비해 변수 ge는 반복자의 기능들로 필요한 기능을 갖추고, 메모리를 효율적으로 사용한다. 갑자기 발생자(generator) 용어가 나왔는데, 이것은 반복자를 내가 원하는 형태로 만들 수 있는 것을 의미하며, 위 구문은 list comprehension 을 통해 발생자 처럼 동작하므로 generator expression이라고 한다.
파이썬에서 소괄호는 중복해서 안써도 되므로, 다음처럼도 쓸 수 있다. 만일 최소값을 구한다고 하면,
>>> min((x for x in range(100000)))
0
>>> min(x for x in range(100000))
0
>>>
발생자 표현을 먼저 봤는데, 그렇다면 발생자는 무엇인가? 위의 변수 ge는 다음 코드와 ge2와 정확히 일치한다.
>>> ge = (x for x in range(100000))
>>>
>>> def g():
... for x in range(100000):
... yield x
...
>>>
>>> ge2 = g()
>>> next(ge2)
0
>>> next(ge2)
1
>>>
함수 g 는 return 대신 yield 라는 구문을 쓰게 되고, 이를 호출하면 generator가 생성된다. 이는 반복자의 한 형태가 되고, 함수 구문에 원하는 로직들을 넣으면서 원하는 기능의 반복자를 마음대로 만들 수 있다. yield 로 결과를 보내고, 그 시점에서 기다렸다가 다음 next 호출시 다음 yield까지 동작한다고 보면 된다. 여전히 전체 데이터를 들고 있지 않음이 중요!
큰 데이터를 다루면서, 여러개의 함수로 반복자 형태의 입, 출력을 연결할 때 중요한 점은 계속 반복자 형태를 유지해야 메모리 효율이 유지된다는 것이다. 큰 데이터가 5개의 함수를 거친다고 할 때, 그중에 하나라도 리스트 같은 형태로 반환하면 메모리 아낀 것이 다 소용이 없어진다. 하여, 입출력을 항상 반복자 형태로 다뤄야 하는데, 이를 도와주는 모듈이 바로 itertools 이고, 많은 유용한 함수들이 제공된다.
>>> import itertools as it
>>>
>>> counter = it.count(10) # 10 부터 시작해서 하나씩 세어주는 반복자
>>> next(counter)
10
>>> next(counter)
11
>>>
>>> chainer = it.chain('AB', 'CD') # 두 시퀀스를 연결하여 하나로 만드는 반복자
>>> next(chainer)
'A'
>>> next(chainer)
'B'
>>> next(chainer)
'C'
>>> next(chainer)
'D'
>>>
>>> gb = it.groupby('AAAABBBCCDAABB') # 같은 요소들을 그룹으로 묶고, 그 값과 하위 그룹을 반복자로
>>> next(gb)
('A', <itertools._grouper object at 0x102e17910>)
>>> next(gb)
('B', <itertools._grouper object at 0x102e17af0>)
>>> next(_[1])
'B'
>>>
특히, 입력 배열의 모든 조합을 만들어 주는 combination, 순열을 만들어 주는 permutation 등은 다양한 알고리즘에 적절히 활용할 수 있다. 가령, 45개의 숫자 가운데 6개를 뽑아내는 로또 번호의 모든 조합은 다음처럼 만들 수 있다.
>>> lotto = it.combinations(range(1, 46), 6)
>>> next(lotto)
(1, 2, 3, 4, 5, 6)
>>> next(lotto)
(1, 2, 3, 4, 5, 7)
>>>
앞서 얘기한 듯이, 반복자는 중간에 원래 시퀀스로 돌아가지 않고 반복자를 유지해야 하는데, 특정 요소를 슬라이스해야 한다면, 어쩔 수 없이 리스트로 변환해야 할 것이다. 이때 쓸 수 있는 것이 islice이다.
>>> counter = it.count(10) # 10부터 1씩 세는 카운터
>>> counter_2_5 = it.islice(counter, 2, 5) # counter의 2:5 슬라이스
>>> next(counter_2_5)
12
>>> next(counter_2_5)
13
>>> next(counter_2_5)
14
>>>
이것 외에도 다양한 반복자 관련 유용 함수들이 itertools 모듈에 있다. 찬찬히 읽어보면, 의미도 더 잘 와닿을꺼고, 알고리즘 문제 풀기도 좋으며, 메모리 효율적인 코드를 만들 수 있다. 더 나아간 발생자 고급기능은 파이썬 발생자와 코루틴 편에서.
오늘의 포스팅은 여기까지. 데이터가 크고 메모리를 많이 먹어서 분석이 오래걸린다는 불평은 이 글로 조금 줄기를!
'프로그래밍' 카테고리의 다른 글
파이썬 click 으로 명령행 앱 만들고 uv 로 빌드하기 (1) | 2025.03.01 |
---|---|
파이썬 발생자(generator)와 코루틴(coroutine) (3) | 2025.02.19 |
macOS 터미널 녹화하기 - asciinema 와 agg 활용 (5) | 2025.02.12 |
아이폰 단축어로 그날의 모든것 자동으로 일기쓰기 (1) | 2025.01.25 |
X API v2로 오늘의 내 트윗들을 가져오는 아이폰 단축어 (6) | 2025.01.17 |