제너레이터에 대한 이해와 제너레이터 함수
제너레이터(Generators)는 iterator 객체의 한 종류이다. 때문에 제너레이터를 전달하면서 next 함수를 호출하면 다음 값을 하나씩 얻을 수 있다. 제너레이터를 만드는 두 가지 방법이 있다.
- 제너레이터 함수(function) : 제너레이터를 만들기 위한 함수 정의
- 제너레이터 표현식 (expression) : 제너레이터를 만들기 위한 식
함수를 기반으로 제너레이터를 만든 예는 다음과 같다.
def gen_num(): # 제너레이터 함수의 정의
print('first number')
yield 1 # yield가 하나라도 들어가면 제너레이터가 된다.
print('second number')
yield 2
print('third number')
yield 3
get = gen_num() # 제너레이터 객체 생성
위의 예에서는 gen_num이라는 함수를 정의하였다. 이 함수에는 yield라는 것이 보인다. 일단 함수 몸체에서 이것이 하나라도 보이면 이는 단순한 함수의 정의가 아닌 제너레이터 함수의 정의가 된다. 그리고 이어서 다음과 같이 이 함수를 호출했다.
gen = gen_num()
만약에 gen_num이 일반 함수라면 그 안에 있는 모든 내용들이 실행된다. 그러나 이 경우에는 한 문장도 실행되지 않는다. 대신에 제너레이터 객체라는 것이 만들어져서 반환된다. 위의 예에 이어서 실행한 다음 결과는 그러한 사실을 보여주고 있다.
type(gen) # gen이 참조하는 것이 제너레이터 객체임을 확인
=> <class 'generator'>
그럼 이렇게 생성된 제너레이터 객체는 정체가 무엇일까?? 이는 다음 함수의 몸체 부분을 실행하는 도구이다.
def gen_num():
print('first number')
yield 1 # 첫 번째 next 호출에서 이 문장까지 실행됨
print('second number')
yield 2 # 두 번째 next 호출에서 이 문장까지 실행됨
print('third number')
yield 3 # 세 번째 next 호출에서 이 문장까지 실행됨
먼저 다음과 같이 제너레이터 객체를 전달하면서 next 함수를 호출하면 함수의 첫 번째 문장부터 시작해서 첫 번째 yield문을 만날 때까지 실행을 이어간다. 그리고 이 때 yield는 return의 역할을 하게 되어 숫자 1을 반환하게 된다.
이어서 next 함수를 다시 호출하면 앞서 했던 실행의 뒤를 이어서 그 다음 yield문을 만날 때까지 실행을 이어간다.
next(gen) # 두 번째 next 함수 호출
second number
-> 2
이어서 또 next 함수를 다시 호출하면 다시 그 뒤를 이어서 그 다음 yield문을 만날 때까지 실행을 이어간다.
next(gen) # 세 번째 next 함수 호출
third number
-> 3
마지막 yield문까지 실행되었음에도 불구하고 다시 next 함수를 호출하면 이번에는 StopIteration 예외가 발생한다. 제너레이터 객체 역시 iterator 객체이기 때문이다. 이렇듯 함수 호출 이후에 그 실행의 흐름을 next 함수가 호출될 때까지 미루는 특성을 가리켜 lazy evaluation이라 한다.
lazy evaluation를 풀이하자면 ‘필요할 때 만든다’라고 해석할 수 있다.
이어서 다음 예를 보자.
def gen_for():
for i in [1, 2, 3]:
yield i # for 루프를 돌 때마다 매번 yield문을 실행하게 된다.
g = gen_for()
next(g)
=> 1
next(g)
=> 2
next(g)
=> 3
next(g)
Traceback (most recent call last):
File "<pyshell#78>", line 1, in <module>
next(g)
StopIteration
위 예제의 경우 제너레이터 함수 안에 for 루프가 존재한다. 그러나 실행 방식은 동일하다. 제너레이터 객체 생성 이후에 next 함수가 호출되면 첫 번째 yield문까지 실행된다. 즉, 다음 형태로 for 루프가 한차례 실행된다.
for i in [1, 2, 3]:
yield i # 이 때 i의 값은 1, 따라서 1이 반환된다.
이어서 두 번째로 next 함수가 호출되면 다음 형태로 for 루프가 실행된다.
for i in [1, 2, 3]:
yield i # 이 때 i의 값은 2, 따라서 2이 반환된다.
마지막으로 next 함수가 호출되면 다음 형태로 for 루프의 마지막 실행이 진행된다.
for i in [1, 2, 3]:
yield i # 이 때 i의 값은 3, 따라서 3이 반환된다.
제너레이터가 갖는 장점
위에서 제너레이터에 대한 기본 설명만 있을뿐 제너레이터가 갖는 의미, 필요성은 살펴보지 않았다. 단지 iterator 객체처럼 동작한다는 설명이 있었다. 제너레이터가 갖는 의미를 살펴보자. 일단 제너레이터를 사용하지 않은 예이다.
def pows(s):
r = []
for i in s:
s.append(i ** 2)
return r
st = pows([1, 2, 3, 4, 5, 6, 7, 8, 9])
for i in st:
print(i, end = ' ')
=> 1 4 9 16 25 36 49 64 81
위 예제에서는 pows 함수 호출을 통해서 배열에 저장된 값을 하나씩 출력하였다. 이 때 사용한 메모리 공간의 크기를 확인해보자.
import sys
sys.getsizeof(st) # 변수 st에 담긴 객체의 메모리 크기 정보 반환
=> 100
sys 모듈을 import하고 이 모듈의 getsizeof 함수를 호출하면 객체가 차지하는 메모리 공간의 크기를 확인할 수 있다. 즉, 위의 실행 결과에서는 st에 담긴 객체가 100 바이트를 사용하고 있음을 보여준다.
다음은 제너레이터를 기반으로 작성된 예제이다.
def gpows(s): # 제너레이터 함수
for i in s:
yield i ** 2
st = pows([1, 2, 3, 4, 5, 6, 7, 8, 9])
for i in st:
print(i, end = ' ')
=> 1 4 9 16 25 36 49 64 81
내용과 실행 결과는 앞서 보인 예와 동일하다. 그러나 메모리 공간의 차이가 있다.
import sys
sys.getsizeof(st)
=> 64
언뜻 보면 큰 차이가 없는 것 같다. 그러나 앞서 작성한 예에서 사용하는 메모리 공간의 크기는 리스트의 길이에 비례해서 늘어난다. 하지만 제너레이터를 사용하는 위의 경우에는 리스트의 길이에 상관없이 사용하는 메모리 공간의 크기가 동일하다. 이유는 단순하다. 제너레이터 객체는 반환할 값들을 미리 만들어서 저장해 두지 않기 때문이다.
정리하면 생성되는 값들을 순서대로 하나씩 가져다 쓰면 되는 상황에서는 이렇듯 제너레이터 기반으로 코드를 작성하는 것이 합리적이다. 참고로 앞서 소개했던 map과 filter도 사실은 제너레이터 함수이다. 즉 map과 filter 함수가 반환하는 것은 iterator 객체이자 제너레이터 객체이다.
yield from
제너레이터 함수 관련 파이썬 3.3 이상에서 사용할 수 있는 문법 하나를 더 살펴보자.
def get_nums():
ns = [0, 1, 0, 1, 0]
for i in ns:
yield i
g = get_nums()
next(g)
=> 0
next(g)
=> 1
리스트에 있는 값을 하나씩 yield 문을 통해서 전달하기 위해 for 루프가 다음과 같이 작성되었다.
for i in ns:
yield i
그런데 이를 다음과 같이 간단히 쓸 수 있다. 그리고 이것이 의미하는 바는 위의 for 루프와 완전히 동일하다.
yield from ns # ns에 있는 값들을 하나씩 yield한다.
다음 예는 이러한 사실을 보여준다.
def get_nums():
ns = [0, 1, 0, 1, 0]
yield from ns
g = get_nums()
next(g)
=> 0
next(g)
=> 1
'python' 카테고리의 다른 글
dict & defaultdict (0) | 2022.04.06 |
---|---|
파이썬의 메모리 관리 (0) | 2022.04.06 |
리스트 컴프리헨션(2) (0) | 2022.03.02 |
map과 filter (0) | 2022.03.01 |
리스트 컴프리핸션 (0) | 2022.01.24 |