제너레이터(Generator)란?
제너레이터는 쉽게 설명해서 *이터레이터(Iterator)를 생성하는 객체라고 할 수 있습니다. 즉 모든 제너레이터는 이터레이터에 속합니다. 좀 특별한 이터레이터 정도로 이해하셔도 될 것 같네요.
컴프리헨션(Comprehension) 문법 혹은 함수 내부에 yield 키워드를 사용함으로써 만들 수 있습니다.
가장 큰 특징으로는 lazy하다는 점이 있는데 이는 포스팅 후반에 다시 다뤄보도록 하겠습니다.
(* 이터레이터(Iterator)와 이터러블(Iterable), 컴프리헨션(Comprehension)에 대한 내용은 이전 포스팅을 참조해주세요)
제너레이터 만들기
앞서 컴프리헨션 문법과 yield 키워드를 통해 제너레이터를 생성할 수 있다고 언급했었습니다.
우선 컴프리헨션 문법으로 만들어봅시다.
foo = (i for i in range(1, 6))
print(type(foo)) # <class 'generator'>
이렇게 컴프리헨션 문법을 통해 생성된 제너레이터를 제너레이터 표현식이라고 칭합니다.
[]가 아니라 ()를 사용했음에 주의해주세요.
위의 제너레이터 표현식으로 생성된 제너레이터를 yield 키워드를 이용해 만들어보겠습니다.
def func():
for i in range(1, 6):
yield i
foo2 = func()
print(type(foo2)) # <class 'generator'>
함수 안에 yield 키워드를 사용하면 그 함수는 무조건 제너레이터가 됩니다. (설령 yield 키워드가 함수 내에서 전혀 사용되지 않는다 하더라도 제너레이터로 만들어버립니다.)
이렇게 생성된 foo와 foo2 모두 next 메소드를 통해 값에 접근할 수 있습니다.
또한 제너레이터는 이터레이터에 속하기 때문에 불러올 값이 더 이상 없으면 StopIteration 예외를 발생시킵니다.
yield 키워드
yield 키워드는 그럼 어떻게 동작하는걸까요?
언뜻 보기엔 return과 비슷하게 동작하는 것 같지만 큰 차이가 있습니다.
예제와 함께 알아봅시다.
def func():
yield 1
yield 2
yield 3
foo = func()
print(next(foo)) # 1
print(next(foo)) # 2
print(next(foo)) # 3
제너레이터 함수는 yield를 통해 값을 리턴합니다.
next메소드나 for문 순회 등을 통해 저 값들을 리턴받을 수 있는데
중요한 점은 yield가 호출된다고 해서 함수가 종료되는 것이 아니라는 것 입니다.
yield는 호출되면 값을 반환하고 그 시점에서 함수를 잠시 정지시킵니다. 그리고 다음 next가 호출되면 정지된 시점부터 다시 로직을 실행합니다.
return은 호출되면 함수를 종료하죠? yield와 return의 차이가 바로 이것입니다.
첨언하자면, 제너레이터 내부에서 return을 사용 시 현재 값에 상관없이 무조건 StopIteration 예외를 발생시킵니다.
def func():
print("1 리턴 전")
yield 1
print("1 리턴 후, 2 리턴 전")
yield 2
print("2 리턴 후, 3 리턴 전")
yield 3
print("3 리턴 후")
foo = func()
print(next(foo))
print(next(foo))
print(next(foo))
이해를 돕기 위해 각 yield 사이에 print문을 넣어보았습니다.
출력 결과는 다음과 같습니다.
1 리턴 전
1
1 리턴 후, 2 리턴 전
2
2 리턴 후, 3 리턴 전
3
마치 각 yield를 체크포인트로 두고 로직을 실행하는 것 처럼 보입니다.
이렇게 제너레이터 내에 다양한 로직을 추가할 수 있다는 점에서 yield 키워드는 상당히 유용한 키워드입니다.
yield from
yield로 iterable한 객체의 요소를 하나씩 리턴해 줄 수도 있습니다.
이 경우 yield에 더해 from이라는 키워드를 사용하면 됩니다.
def func():
_list = [1, 2, 3, 4, 5]
for i in _list:
yield i
즉 위와 같이 리스트를 순회하며 각각 값을 yield로 반환하는 대신,
def func():
_list = [1, 2, 3, 4, 5]
yield from _list
이렇게 for문 없이 yield로 반환하는 것도 가능합니다.
사용법은 위의 예제나 아래 예제나 똑같습니다.
def generator(stop_number):
num = 0
while True:
if num >= stop_number:
return
num += 2
yield num
def func(stop_number):
gen = generator(stop_number)
yield from gen
foo = func(10)
추가로 이렇게 yield from 키워드로 제너레이터를 전달하는 것도 가능합니다.
위 예제는 10 이하의 짝수를 foo 객체의 next로 하나씩 꺼내올 수 있습니다.
게으른 제너레이터
포스팅의 시작 문단에서 제너레이터는 lazy하다고 말씀드렸습니다. 이 특성때문에 제너레이터는 지연 평가(Lazy Evaluation) 구현체 라고 부르기도 합니다.
직역하면 제너레이터는 게으르다는 뜻인데 이게 당최 뭔소리일까요?
일반적인, 그러니까 lazy 하지 않은 객체를 먼저 생각해봅시다.
가장 대표적으로 list를 한번 볼까요?
_list = [1,2,3,4,5]
모두 아시다시피 list객체의 각 요소는 이미 값이 정해져 있습니다.
0번 인덱스 : 1
1번 인덱스 : 2
2번 인덱스 : 3 ... 이렇게요.
이런 객체는 몇 번째 어떤 요소가 있는지 손쉽게 확인할 수 있다는 장점이 있지만 그 크기가 커질 수록 메모리의 낭비가 심해진다는 단점이 있습니다.
만약 list의 길이가 100만이 넘는다면? 100만개가 넘는 값을 모두 기억해야 하니 메모리가 살려달라고 몸부림 칠 지도 모릅니다.
하지만 lazy한 객체는 모든 요소를 저장하고 있지 않고
처음에 줘야 할 값, 그 다음부터 줘야 할 값, 값을 언제까지 줘야 하는지 에 대한 정보만을 가지고 있습니다.
이 정보들을 바탕으로 각 호출마다 값을 반환하게 되는 것입니다.
정리하자면 모든 값을 메모리에 올려두지 않고 필요할 때(호출할 때) 값을 평가해서 반환하는 방식입니다.
이 특성을 이용한 재미있는 예시를 하나 봅시다.
def endless_numbers():
num = 2
while True:
yield num
num = num + 2
foo = endless_numbers()
처음 반환할 값 : 2 (num의 초기값)
그 다음부터 줄 값 : num에 2를 더한 값
언제까지? : 명시되지 않았으므로 호출하는 만큼 계속
이렇게 생성된 foo 객체는 호출하는 만큼 짝수를 계속해서 반환합니다. 그리고 이 횟수는 이론상으로 100만, 1000만, 혹은 그 이상도 가능하죠.
같은 로직을 list로 구현하는것은 당연히 불가능하겠죠? 이것이 lazy한 객체의 특징이자 장점입니다.
따라서 불러올 다음 값에 대한 패턴이 존재한다면 lazy한 객체를 사용하는 것이 메모리 측면에서는 이득이라고 할 수 있습니다.
'파이썬' 카테고리의 다른 글
파이썬(Python) - 가상 환경(Virtual Environments) (0) | 2021.07.03 |
---|---|
파이썬(python) - 람다 표현식(Lambda expression) (0) | 2021.02.14 |
파이썬(python) - 이터레이터(Iterator) (2) | 2020.12.28 |
파이썬(python) - MRO(Method Resolution Order) (9) | 2020.12.12 |
파이썬(python) - 컴프리헨션(Comprehension) 문법 (0) | 2020.11.25 |