제너레이터

hyuckhoon.ko·2024년 1월 29일
0

1️⃣ 제너레이터 만들기

1) 정의

이터레이터를 반환하는 함수

2) 조건

yield 키워드 사용

3) 제너레이터 개요

요구사항 : 구매 정보가 저장된 대용량 CSV파일 ➡️ 최저가, 최고가, 평균가 추출

class PurchaseStatus:

    def __init__(self, purchases):
        self.purchases = iter(purchases)
        self.min_price: float = None
        self.max_price: float = None
        self._total_purchases_price: float = 0.0
        self._total_purchases = 0
        self._initialize()

    def process(self):
        for purchase_value in self.purchases:
            self._update_min(purchase_value)
            self._update_max(purchase_value)
            self._update_avg(purchase_value)
        return self

    def _initialize(self):
        try:
            first_value = next(self.purchases)
        except StopIteration:
            raise ValueError("더 이상 값이 없음")
        self.min_price = self.max_price = first_value
        self._update_avg(first_value)

    def _update_min(self, new_value):
        if new_value < self.min_price:
            self.min_price = new_value

    def _update_max(self, new_value):
        if new_value > self.max_price:
            self.max_price = new_value

    def avg_price(self):
        return self._total_purchases_price / self._total_purchases

    def _update_avg(self, new_value):
        self._total_purchases_price += new_value
        self._total_purchases += 1

    def __str__(self):
        return f"{self.__class__.__name__}({self.min_price}, {self.max_price}, {self.avg_price})"

PurchaseStatus 클래스의 컨스트럭터 인자로 purchases가 필요하다.

(1) 메모리 사용량이 많은 코드
"모든 구매 정보를 일단 다 받아야겠어"

def load_purchases(filename):
    purchases = []
    with open(filename) as f:
        for line in f:
            *_, price_raw = line.partition(",")
            purchases.append(float(price_raw))

    return purchases

(2) 메모리 사용량이 적은 코드
"그때그때 필요한 값만 가져와"

def load_purchases_2(filename):
    with open(filename) as f:
        for line in f:
            *_, price_raw = line.partition(",")
            yield float(price_raw)

4) 제너레이터 표현식

(누군가는 제너레이터 컴프리헨션으로 불러야 한다고 주장한다.)

(1) 리스트 컴프리헨션

>>> [x**2 for x in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

(2) 제너레이터 표현식

>>> (x**2 for x in range(10))
<generator object <genexpr> at 0x102eaa4d0>

(3) 이터러블을 파라미터로 받는 함수와의 조합

>>> sum(x**2 for x in range(10))
285

>>> max(x**2 for x in range(10))
81

>>> min(x**2 for x in range(10))
0

⭐️ 이터러블을 파라미터로 받는 min(), max(), sum() 같은 함수를 사용할 때는 리스트 컴프리헨션 대신 항상 제너레이터 표현식을 사용하자. 그렇게 하는 것이 보다 효율적이고 파이썬스러운 방식이다.

👎🏻🙅🏻 제너레이터를 사용할 수 있는 곳에 리스트를 사용한 bad 케이스

>>> sum([x**2 for x in range(10)])
285

제너레이터는 재사용 할 수없다.

제너레이터는 반복을 완료하면 소모(exhausted)된다. 왜냐하면 모든 데이터를 메모리에 로드하지 않기 때문이다.


2️⃣ 이상적인 반복

1) 관용적인 반복 코드

(1) enumerate 예시

>>> list(enumerate("abcdef"))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

(2) 무한 시퀀스 클래스 만들기

class NumberSequence:
    def __init__(self, start=0):
        self.current = start

    def next(self):
    	current = self.current
		self.current += 1
		return current
        
        
>>> seq = NumberSequence()
>>> seq.next()
1
>>> seq.next()
2
>>> seq.next()
3
>>> seq = NumberSequence(10)
>>> seq.next()
11
>>> 
>>> seq.next()
12

NumberSequence는 반복을 지원하지 않는다.

>>> list(zip(NumberSequence(), "abcdef"))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'NumberSequence' object is not iterable

__iter__ 매직 메서드를 구현하여 객체가 반복 가능하게 만들어야 한다.

class NumberSequence:
	def __init__(self, start=0):
		self.current = start
        
	def __next__(self):
		current = self.current
		self.current += 1
		return current
        
	def __iter__(self):
		return self
        

>>> list(zip(NumberSequence(), "abcdef"))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

>>> NumberSequence(100)
<__main__.NumberSequence object at 0x10af2dfa0>

>>> next(seq)
100

>>> next(seq)
101

요소를 반복할 수 있을 뿐만 아니라,
.next()를 호출할 필요도 없다.

이터레이터 프로토콜은 __iter____next__ 메서드에 의존한다.

(3) next() 함수

next() 내장 함수는 이터레이터를 다음 요소로 이동시키고 기존의 값을 반환한다.

>>> word = iter("hello")
>>> word
<str_iterator object at 0x10af2dfa0>
>>> next(word)
'h'
>>> next(word)
'e'
>>> next(word)
'l'
>>> next(word)
'l'
>>> next(word)
'o'
>>> next(word)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

이터레이터가 더 이상의 값을 가지고 있지 않다면 StopIteration 예외가 발생한다.

SteopIteration 에러를 캐치하는 것 외에도 두 번째 파라미터에 기본값을 제공할 수도 있다.

>>> next(word, "default_value")
'default_value'
>>> next(word, "default_value")
'default_value'
>>> next(word, "default_value")

(4) 제너레이터 사용하기

def sequence(start=0):
	while 1:
		yield start
		start += 1

>>> seq = sequence(10)
>>> next(seq)
10
>>> next(seq)
11
>>> next(seq)
12
>>> list(zip(sequence(), "abcdef")
...
... )
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

>>> list(zip(sequence(5), "abcdef"))
[(5, 'a'), (6, 'b'), (7, 'c'), (8, 'd'), (9, 'e'), (10, 'f')]
>>>

이터러블을 사용하여 구현할 수도 있지만, 제너레이터로 구현하는 것을 권장한다.(간단한 구조 / 가독성)

(5) Itertools

위에서 다뤘던 코드(구매 정보가 저장된 대용량 CSV파일 ➡️ 최저가, 최고가, 평균가 추출)에 추가 요구사항이 생겼다.
1000원을 넘는 구매에 대해서만 값들을 추출하려고 한다.

class PurchaseStatus:

    ...
    
    def process(self):
    	for purchase in purchases:
        	if purchase > 1000.0:
            	...

그러나 위 방식은 나쁜 코드다.

조건을 필터링하는 것은 PurchaseStatus 클래스의 책임이 아니다.
-> 클라이언트의 요구로부터 독립되어야 한다.(이 클래스의 책임이 작을수록 클라이언트는 재사용성이 높아진다.)
-> 앞으로 변경에 취약한 클래스가 된다.

어떻게 하면 될까?

1000개 넘게 구매한 이력의 처음 10개에 대해서만 값을 추출

>>> from itertools import islice
>>> purchases = islice(filter(lambda p: p > 1000.0, purchases), 10)
>>> stats = PurchasesStatus(purchases).process()
  • 제너레이터는 게으르게(lazy) 평가되므로 메모리의 손해가 없다.
  • 메모리 사용과 CPU 사용 간의 트레이드오프를 고려하자.

(6) 이터레이터를 사용한 코드 간소화

  • 여러 번 반복하기
def process_purchases(purchases):
    min_, max_, avg = itertools.tee(purchases, 3)
    return min(min_), max(max_), median(avg)

itertools.tee는 원래의 이터러블을 세 개의 새로운 이터레이터로 분할한다.

참고) itertools tee

from itertools import tee

i1, i2, i3 = tee(range(10), 3)
>>> i1
<itertools._tee object at 0x104668380>
>>> i2
<itertools._tee object at 0x1046684c0>
>>> i3
<itertools._tee object at 0x104668500>

>>> list(i1)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(i1)
[]

>>> list(i2)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(i2)
[]

>>> list(i3)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(i3)
[]

tee는 마치 제너레이터를 사용하는 for문이 여러 개 있다고 볼 수 있다.

for 문이 3개가 있다고 해서 메모리 사용량이 기존 대비 3배가 되는 것은 아니다.
다만, 이터러블을 여러 개 생성한다면 성능상의 불리함을 가져온다.O(N)

또한, 불필요하게 이터레이터를 생성하는 것은 아닌지 판단해야 한다. 
리스트에 한 번에 가져와 순회하는 게 더 효율적일 수도 있다.	
  • 중첩 루프

다음은 피해야 할 코드다.
종료 플래그를 사용한 이중 for문 탈출 방식이다.

def search_nested_bad(array, desired_value):
	coords = None
	for i, row in enumerate(array):
		for j, cell in enumerate(row):
			if cell == desired_value:
				coords = (i, j)
				break
		if coords is not None:
			break
	if coords is None:
		raise ValueError(f"{desired_value} 값을 찾을 수 없음")
	return coords

다음은 제너레이터를 활용하여 반복을 추상화한 방식이다.

def _iterate_array2d(array2d):
	for i, row in enumerate(array2d):
		for j, cell in enumerate(row):
			yield (i, j), cell

def search_nested(array, desired_value):
	try:
		coord = next(
        	coord
            for (coord, cell) in _iterate_array2d(array)
			if cell == desired_value
		)
	except StopIteration:
		raise ValueError(f"{desired_value} 값을 찾을 수 없음")
	return coord

3️⃣ 코루틴

코루틴 핵심

특정 시점에 실행을 일시 중단했다가 나중에 재시작할 수있는 함수를 만드는 것

따라서, 메인 루틴과 서브루틴이 대응한 관계로 협력할 수 있게 된다.

제너레이터 인터페이스의 메서드

close()

이 메서드를 호출하면 제너레이터에서 GeneratorExit 예외가 발생한다.

throw(ex_type[, ex_value[, ex_traceback]])


4️⃣ 비동기 프로그래밍


5️⃣ 요약

0개의 댓글