TIL 42. Generator

Drageon Lee·2022년 3월 3일
0

TIL_Python

목록 보기
8/11

Today's topic

지난 posting을 통해 first class function, closure에 이어 decorator에 대해 정리하였다. 이번 posting에서는 'generator'에 대해 정리해 보고자 한다. Code를 작성하면서 generator에 대해 만나본 적은 없지만 어떤 기능을 하고 어떤 역할을 하는 지에 대해서는 자세히 알지 못했는데 이번 posting을 통해 제대로 정리해야 겠다.

👉 What is "Generator"?

'iterator(반복자)'와 같은 loop의 작용을 컨트롤 하기 위해 쓰여지는 특별한 함수 or 루틴 이다.

즉, 풀어서 설명하면 generator는 iterator를 생성해주는 함수이며 모든 generator는 iterator이다. Generator는 호출을 할 수 있는 parameter를 가지고 있고, 연속적인 값들을 만들어 낸다는 점에서 일반적으로 배열이나 list를 return하는 함수와 비슷하나 한번에 모든 값들을 포함한 배열을 만들어서 return하는 대신 yield를 이용해 한번 호출 될 때 하나의 값만 return한다는 점에서 일반적인 함수 및 iterator과는 차이가 있다.

Generator 함수에서는 return 대신 yield를 사용하는 데, generator 함수가 가지고 있는 내장 함수 next가 호출 되기 전까지는 현상태를 유지하다가 next가 호출이 되면 하나씩 값을 반환 해 준다.

Generator는 iternext 내장함수를 가지고 있는 iterator이며, yield를 사용하여 next 호출 시 하나씩 값을 반환하며, 수행하던 code들은 끝나지 않고 다음 code를 수행한다는 점이 특징이다.

👉 What is different between "return" & "yield"?

Return과 yield 사용 시에는 어떠한 점이 있을까?
먼저, "return"을 사용했을 시에는 함수에서 값을 반환하고 다음 code는 시행하지 않고 끝나버린다.
간단한 예시를 들어보자.
먼저, 일반 함수를 보면...

def normal_func():
    return 1
    return 2
    return 3

a = normal_func()
print(a)	#1
print(dir(a))
# result
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__',
'__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__',
'__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__',
'__getnewargs__', '__gt__', '__hash__', '__index__', '__init__',
'__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__',
'__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__',
'__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__',
'__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__',
'__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__',
'__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__',
'__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__',
'__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator',
'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']
print(a)	#1
print(a)	#1
print(a)	#1

dir() 함수를 통해 내장 함수 확인 시 __next__ 함수가 없어서 next로 호출을 할 수 없을 뿐만 아니라, 첫 번째 return에서 끝나기에 두 번째, 세 번째 return 값인 2, 3을 불러올 수 없다.

하지만 generator 함수를 보면...

def gen_func():
	yield 1
    yield 2
    yield 3

b = gen_func()
print(b)	#<generator object gen_func at 0x7fba3830af20>
print(dir(b))
# result
['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
'__init__', '__init_subclass__', '__iter__', '__le__', '__lt__',
'__name__', '__ne__', '__new__', '__next__', '__qualname__', '__reduce__',
'__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
'__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running',
'gi_yieldfrom', 'send', 'throw']
print(next(b))
print(next(b))
print(next(b))

__iter____next__ 함수를 가지고 있기에 next로 호출을 할 수 있다. generator 함수 자체만 print로 호출 했을 때는 generator 형태로만 출력이 되었지만 next 호출로 호출했을 시에는 첫번째 yield 값인 1뿐만 아니라 두번째, 세번째 값인 2와 3으로 출력되는 것을 볼 수 있다.

이렇게 return과 yield는 뒤에 있는 code를 진행 가능 여부의 차이가 있다.

👉 Why use 'generator'?

그럼 generator를 왜 쓸까?
Generator의 장점은 한 번 값을 반환하고 끝나지 않고, 현 상태를 기억하고 있다가 호출되면 이어서 진행할 수 있다는 장점이 있다. 또한, return할 모든 값을 메모리에 저장하지 않고 하나씩 반환하므로 메모리 또한 적게 소모가 된다는 장점이 있다.

👉 'generator' form

그럼 generator의 형태는 어떨까? list comprehension과의 비교를 통해 형태를 알아보자.

먼저 list comprehension은

list = [i for i in range(10)]
print(list)		#[1,2,3,4,5,6,7,8,9,10]

[] 대괄호 형태로 감싸진다.

그런데, 이 형태를 () 소괄호로 감싸면,

generator = (i for i in range(10))
print(generator)	#<generator object <genexpr> at 0x7f7fd808f4a0>

generator가 된다.
흥미로운 점은 list comprehension 내의 값만 print로 출력했을 때도 generator인 것으로 나온다.

print(i for i in range(10))	#<generator object <genexpr> at 0x7f7af02eaf20>

이 점을 토대로 보면, generator에서 [ ]만 씌우면 list형태가 된다는 것을 알 수 있다. 하지만 list로 바꾸면 generator의 특성을 잃게 된다.

👉 Example with data

그러면 data를 사용해서 일반 함수와 generator 함수의 performance를 비교해 보도록 하자.
각 함수에 1000000개의 data를 생성하는 code를 작성하여 (1) list와 generator 형태의 object를 만드는 데와 (2) for 문을 돌려 data를 출력하는데 소요되는 시간 및 메모리를 비교해본다.

from __future__ import division
import os
import psutil
import random
import time

names = ['드래건', '재떨이', '정비', 'John', 'Tony']
jobs = ['백엔드개발자', '공기업', '공무원', '승무원', '프론트엔드개발자']

process = psutil.Process(os.getpid())
mem_before = process.memory_info().rss / 1024 / 1024

def people_list(num_people):
    result = []
    for i in range(num_people):
        person = {
            'id' : i,
            'name' : random.choice(names),
            'jobs' : random.choice(jobs)
        }
        result.append(person)
    return result

def people_generator(num_people):
    for i in range(num_people):
        person = {
            'id' : i,
            'name' : random.choice(names),
            'jobs' : random.choice(jobs)
        }
        yield person

t1 = time.time()

people = people_list(1000000)

t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print(f'시작 전 memory 사용량: {mem_before}')
print(f'시작 후 memory 사용량: {mem_after}')
print(f'소요 시간 : {total_time}')

#result
시작 전 memory 사용량: 13.03515625
시작 후 memory 사용량: 280.3984375
소요 시간 : 1.6060760021209717

list 형태의 data로 만드는데 약 266 MB 정도의 용량이 소요되었으며, 1.6초가 걸렸다.

t1 = time.time()

people = people_generator(1000000)

t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print(f'시작 전 memory 사용량: {mem_before}')
print(f'시작 후 memory 사용량: {mem_after}')
print(f'소요 시간 : {total_time}')

#result
시작 전 memory 사용량: 13.0703125
시작 후 memory 사용량: 13.07421875
소요 시간 : 1.6689300537109375e-06

generator 형태의 data에서는 용량이 거의 소요되지 않았으며, 소요 시간도 0초에 가깝다.
이 비교를 통해 generator의 performance가 더 좋다는 것을 알 수 있다.

그럼 이 list와 generator를 가지고 data를 출력했을 경우를 비교해 보자.

t1 = time.time()

people = people_list(1000000)
for i in people:
	print(i)
    
t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print(f'시작 전 memory 사용량: {mem_before}')
print(f'시작 후 memory 사용량: {mem_after}')
print(f'소요 시간 : {total_time}')

#result
시작 전 memory 사용량: 12.98046875
시작 후 memory 사용량: 291.16796875
소요 시간 : 23.434614896774292

약 280 MB의 용량이 소요가 되며, 23.4초가 소요된다.

Generator의 경우는

t1 = time.time()

people = people_generator(1000000)
for i in people:
	print(i)

t2 = time.time()
mem_after = process.memory_info().rss / 1024 / 1024
total_time = t2 - t1
print(f'시작 전 memory 사용량: {mem_before}')
print(f'시작 후 memory 사용량: {mem_after}')
print(f'소요 시간 : {total_time}')

#result
시작 전 memory 사용량: 12.94140625
시작 후 memory 사용량: 12.96875
소요 시간 : 29.361313819885254

Generator를 data를 출력하는 경우는 용량은 거의 소요되지 않으나, 29.3초가 걸리며 list 보다 약 6초 정도가 더 소요된다.

List와 generator object를 생성할 시에는 generator가 월등히 성능이 좋은 것으로 확인하였으나, data를 출력 시에는 메모리와 소요 시간에서 다른 성능을 보이는 것을 확인 하였다.

이 결과를 통해 대용량 data를 처리 시 메모리의 효율을 높이려면 generator를 사용하며, 처리 시간 적인 효율을 높이려면 list object를 사용하면 된다는 것을 알 수 있다.

📖 출처 :

My opinion

이번 posting에서는 generator에 대해 정리해 보았다. Generator가 iterator이며 yield를 통해 메모리 효율성을 높인다는 것을 알 수 있었다. Return으로만 값을 반환하는 줄 알았는데, yield로 반환할 수도 있다는 사실이 흥미로웠다. Python의 세계는 알면 알수록 더 흥미로운 것 같다.

profile
운동하는 개발자

0개의 댓글