Story 09 제너레이터 함수

유동헌·2021년 10월 2일
0

열혈파이썬_중급

목록 보기
8/8

제너레이터에 대한 이해와 제너레이터 함수

이번에 소개하는 제너레이터는 iterator 객체의 한 종류이다. 때문에 제너레이터를 전달하면서 next 함수를 호출하면 값을 하나씩 얻을 수 있다. 그런데 제너레이터는 파이썬의 중요한 특징 중 하나이므로 사용 여부에 상관없이 알고 있을 것이다. 그럼 먼저 제너레이터를 만드는 두 가지 방법을 소개하겠다.

  • 제너레이터 함수 (function) : 제너레이터를 만들기 위한 함수 정의
  • 제너레이터 표현식 (expression) : 제너레이터를 만들기 위한 식

함수를 기반으로 제너레이터를 만든 예는 다음과 같다.

def gen_num():
    print("first number")
    yield 1
    print("second number")
    yield 2
    print("third number")
    yield 3
    
gen = gen_num()
print(gen)

위의 예에서는 gen_num이라는 함수를 정의하였다. 그런데 이 함수에는 yield라는 것이 보인다. 일단 함수 몸체에서 이것이 하나라도 보이면 이는 단순한 함수의 정의가 아닌 제너레이터 함수의 정의가 된다. 그리고 이어서 다음과 같이 이 함수를 호출했는데,

gen = gen_num()

만약에 gen_num이 일반 함수라면 그 안에 있는 모든 내용들이 실행된다. 그러나 이 경우에는 한 문장도 실행되지 않는다. 대신에 제너레이터 객체라는 것이 만들어져서 반환된다. 위의 예에 이어서 실행한 다음 결과는 그러한 사실을 보여주고 있다.

print(type(gen))
<class 'generator'>

먼저 다음과 같이 제너레이터 객체를 전달하면서 next 함수를 호출하면 함수의 첫 번째 문장부터 시작해서 첫 번째 yield문을 만날 때까지 실행을 이어간다. 그리고 이때 yield라는 return의 역할을 하게 되어서 숫자 1을 반환하게 된다.

gen = gen_num()
print(next(gen))

# 
first number
1

이어서 next 함수를 다시 호출하면 앞서 했던 실행의 뒤를 이어서 그 다음 yield문을 만날 때까지 실행을 이어간다.

gen = gen_num()
print(next(gen))
print(next(gen))

#
first number
1
second number
2

이어서 또 next 함수를 호출하면 다시 그 뒤를 이어서 호출이 된다.

그리고 다 실행했음에도 불구하고, 그러니까 마지막 yield문까지 실행되었음에도 불구하고 다시 next 함수를 호출하면 이번에는 StopIteration 예외가 발생한다. 제너레이터 객체 역시 iterator 객체기 때문이다.

참고로 이렇듯 함수 호출 이후에 그 실행의 흐름을 next 함수가 호출될 때까지 미루는(늦추는) 특성을 가리켜 lazy evaluation이라고 한다.

def gen_for():
    for i in [1,2,3]:
        yield i
        
g = gen_for()
print(next(g))
print(next(g))
print(next(g))
print(next(g))

# 
1
2
3
Traceback (most recent call last):
  File "/Users/dongheon/Desktop/development/python study/practice/practice.py", line 21, in <module>
    print(next(g))
StopIteration

위 예제의 경우 제너레이터 함수 안에 for 루프가 존재한다. 그러나 실행 방식은 동일하다. 제너레이터 객체 생성 이후에 next 함수가 호출되면 첫 번째 yield문까지 실행된다. 즉 다음 형태로 for 루프가 한 차례 실행된다.

for i in [1,2,3]:
	yield i # 1

for i in [1,2,3]:
	yield i # 2

for i in [1,2,3]:
	yield i # 3

제너레이터가 갖는 장점

위에서 제너레이터에 대한 기본적인 설명은 다하였다. 그러나 제너레이터가 갖는 의미, 필요성은 설명하지 않았다.

먼저 제너레이터를 사용하지 않는 예를 살펴보자.

def pows(s):
    r = []
    for i in s:
        r.append(i ** 2)
    return r

st = pows([1,2,3,4,5,6,7])

for i in st:
    print(i, end = ' ')

#
1 4 9 16 25 36 49

위 예제에서는 pows 함수 호출을 통해서 주어진 배열을 기반으로 제곱이 된 배열을 새로 생성해서 이 배열에 저장된 값을 하나씩 출력하였다.

그럼 이때 사용한 메모리 공간의 크기를 확인해보자.

print(sys.getsizeof(st))

#
120

import sys → getsizeof 함수 호출하면 객체가 차지하는 메모리 공간의 크기를 확인 가능하다. st 객체는 120 바이트를 차지하고 있다.

다음은 제너레이터 기반의 코드이다.

import sys

def gen_pows(s):
    for i in s:
        yield i ** 2

st = gen_pows([1,2,3,4,5,6,7])

for i in st:
    print(i, end = ' ')
    
print(sys.getsizeof(st))

#
1 4 9 16 25 36 49 / 112

메모리 공간이 적게 사용된 것을 확인!

제너레이터를 사용하는 위의 경우에는 리스트의 길이에 상관없이 사용하는 메모리 공간의 크기가 동일. 제너레이터 객체는 반환할 값들을 미리 만들어서 저장해 두지 않기 때문에.

정리하면, 생성되는 값들을 순서대로 하나씩 가져다 쓰면 되는 상황에서는 이렇듯 제너레이터를 기반으로 코드를 작성하는 것이 합리적. 참고로 앞서 살펴보았던 map과 filter도 사실은 제너레이터 함수이다. 즉 map과 filter 함수가 반환하는 것은 iterator 객체이자 제너레이터 객체이다. 이렇듯 제너레이터의 존재를 알게 되어서 우리는 map과 filter 함수가 갖는 장점을 추가로 알게 되었다.

yield from

제너레이터로 만들어진 0과 1을 순서대로 던져주는 함수

def get_nums():
    ns = [0,1,0,1,0,1]
    for i in ns:
        yield i
        
g = get_nums()
print(next(g))
print(next(g))
print(next(g))

yield from 사용

def get_nums():
    ns = [0,1,0,1,0,1]
    yield from ns
        
g = get_nums()
print(next(g))
print(next(g))
print(next(g))

같은 결과를 반환한다.

profile
지뢰찾기 개발자

0개의 댓글