Python | yield

Jihun Kim·2022년 2월 17일
0

파이썬

목록 보기
9/11
post-thumbnail

이 글은 "파이썬 코딩의 기술"(by 브렛 슬라킨)을 읽고 정리를 위해 작성했습니다.



일단 yield를 사용하기 위해서는 제너레이터에 대해 알아야 한다.


제너레이터

제너레이터는 함수가 점진적으로 반환하는 값으로 이뤄지는 스트림을 만들어 준다.

  • 이터레이터를 사용할 수 있는 곳(for 루프, 별표 식 등)이라면 어디에서나 제너레이터 함수를 호출한 결과를 사용할 수 있다.
  • 제너레이터를 이용하면 성능을 향상시킬 수 있으며 메모리 사용을 줄이고 가독성 또한 높일 수 있다.

리스트를 반환하기 보다는 제너레이터를 사용하라

[예시1] 리스트 사용

def index_words(text):
	result = []
    if text:
    	result.append(0)
    for index, letter in enumerate(text):
    	if letter == '':
        	result.append(index + 1)
    return result

[예시2] 제너레이터 사용

def index_words_iter(text):
	result = []
    if text:
    	yield 0
    for index, letter in enumerate(text):
    	if letter == '':
        	yield index + 1

가독성이 떨어지는 문제

  • 예시1의 메서드 호출의 경우 덩어리가 너무 크다(result.append)는 단점이 있다.
    - 이 때문에 리스트에 추가될 값(index + 1)의 중요성이 희석 된다.

  • 이를 개선하는 방법은 예시2에서와 같이 제너레이터를 사용하는 것이다.
    - 제너레이터는 yield식을 사용하는 함수를 통해 만들 수 있다.
    - 제너레이터를 생성할 경우 return을 하지 않고 yield만 한다.

  • 예시2에서 index_words_iter를 호출하면 제너레이터 함수가 실제로 실행되지는 않으며 이터레이터를 반환한다.
    - 이터레이터가 next 내장 함수를 호출할 때마다 이터레이터는 제너레이터 함수를 다음 yield 식까지 진행 시킨다.
    - 아래의 예시를 보면 알 수 있다.

       it = index_words_iter(address)
        print(next(it))
        print(next(it))
    
        >>>
        0  # 첫 번째 yield 0
        8  # for문 안의 두 번째 yield 8
  • 예시2의 경우 반환하는 리스트와 상호작용 하는 코드가 사라져 훨씬 가독성이 높아졌다.
    - 단, 결과는 yield 식에 의해 전달 된다.

  • 예시2에서 제너레이터가 반환하는 이터레이터를 리스트 내장 함수에 넘기면 필요할 때 제너레이터를 쉽게 리스트로 변환할 수 있다.

    result = list(index_words_inter(address))
    print(result[:10])

메모리 문제

  • 예시1의 index_words의 두 번째 문제점은 반환하기 전에 리스트에 모든 결과를 다 저장해야 한다는 것이다.
    - 입력이 매우 크면 프로그램이 메모리를 소진해 중단될 수 있다.
  • 같은 함수를 예시2에서와 같이 제너레이터 버전으로 만들면 사용하는 메모리 크기를 어느 정도 제한할 수 있어 입력 길이가 아무리 길어도 쉽게 처리할 수 있다.
    - 아래의 경우, index_file의 작업 메모리는 입력 중 가장 긴 줄의 길이로 제한된다.
    def index_file(handle):
    	offset = 0
        for line in handle:
        	if line:
            	yield offset
            for letter in line:
            	offset += 1
                if letter == ' ':
                	yield offset
  • 주의해야 할 점은 제너레이터가 반환하는 이터레이터에는 상태가 있기 때문에 호출하는 쪽에서 재사용이 불가능하다는 점이다.


인자에 대해 이터레이션 할 때는 방어적이어야 한다

def normalize(numbers):
	total = sum(numbers)
    result = []
    for value in numbers:
    	percent = 100 * value / total
        result.append(percent)
    return result

위 메서드는 도시별 방문자 수(단위: 100만 명 / 년) 데이터 집합이 주어질 때 각 도시가 전체 여행자 수 중에서 차지하는 비율을 계산하기 위한 것이다.

아래에서 위 코드의 규모 확장성을 높이기 위한 방법을 알아본다.

방법1: 이터레이터의 결과를 list에 담기

인자가 파일로 전달될 수도 있기 때문에 모든 도시에 대한 여행자 정보가 들어 있는 파일에서 데이터를 읽을 수 있는 함수를 만들어야 한다. 또한 파일을 읽는 함수를 재사용해야 할 수도 있으므로 제너레이터를 정의한다.

def read_visits(data_path):
	with open(data_path) as f:
    	for line in f:
        	yield int(line)

그 다음 아래와 같이 위에서 정의했던 normalize 함수에 read_visits의 반환 값을 전달하면 아무 결과도 나오지 않는다.

it = read_visits('my_numbers.txt')
percentages = normalize(it)
print(percentages)

>>>
[]

이유는 다음과 같다.

  • 이터레이터가 결과를 단 한 번만 만들어내기 때문이다.
    - 이미 StopIteration 예외가 발생한 이터레이터나 제너레이터를 다시 이터레이션하면 아무 결과도 얻을 수 없다.
    - 아래와 같이 print(list(it))를 한 번 실행헤 StopIteration 예외가 발생한 뒤에 또 다시 print할 경우 빈 리스트가 출력 된다.
    it = read_visits('my_numbers.txt')
     print(list(it))
     print(list(it))
    
     >>> 
     [15, 35, 80]
     []
  • 이미 소진된 이터레이터에 대해 이터레이션을 수행해도 아무런 오류가 발생하지 않는다.
    - 왜냐하면, for 루프나 리스트 생성자 등 많은 함수들은 일반적인 연산 도중 StopItereation 예외가 던져지는 것을 이미 가정하고 있기 때문에 StopIteration이 던져지더라도 오류로 생각하지 않기 때문이다.
    - 그렇기 때문에 이런 함수들은 출력이 없는 이터레이터와 이미 소진된 이터레이터를 구분할 수 없다.
  • 위의 문제를 해결하려면 일단 입력 데이터를 list()에 넣어 명시적으로 소진시키는 방법이 있다.
    - 즉, 이터레이터의 전체 내용을 리스트에 넣는 것이다.
    - 그 다음, 담아둔 리스트에 대해서 원하는 만큼 이터레이션을 수행하면 된다.
    - 그러나 이 경우, 이터레이터의 내용을 복사하기 때문에 메모리를 많이 사용하게 될 수 있다는 문제점이 있다.
     def normalize_copy(numbers):
     	numbers_copy = list(numbers)
       	total = sum(numbers_copy)
       	result = []
       	for value in numbers_copy:
       		percent = 100 * value / total
           	result.append(percent)
       return result

방법2: 호출 때마다 새로운 이터레이터 반환

def normalize_func(get_iter):
	total = sum(get_iter())
    result = []
    for value in get_iter():
    	percent = 100 * value / total
        result.append(percent)
    return result
  • 위 함수를 이용하면 함수가 실행될 때마다 매번 제너레이터를 호출한 뒤 새 이터레이터를 만들어내는 lambda 식을 인자로 전달할 수 있다.
    path = 'my_numbers.txt'
     percentages = normalize_func(lambda: read_visits(path))
     print(percentages)
     assert sum(percentages) == 100.0
    • 이 경우 잘 작동하기는 하지만 람다 함수를 넘기는 것은 가독성이 떨어진다.

방법3: 이터레이터 프로토콜 구현

방법2보다 더 나은 방법은 이터레이터 프로토콜을 구현한 새로운 컨테이너 클래스를 제공하는 것이다.

  • 파이썬에서 for x in foo와 같은 구문을 사용하면 내부적으로는 iter(foo)를 호출한다.
    - iter 내장함수는 foo.__iter__라는 특별 메서드를 호출하며 해당 메서드는 반드시 이터레이터 객체를 반환해야 한다.
  • for 루프는 반환 받은 이터레이터 객체가 데이터를 소진할 때까지 반복적으로 이터레이터 객체에 대해 next 내장 함수를 호출한다.
    - 이는 사용자가 정의한 클래스에서 __iter__ 메서드를 제너레이터로 구현하기만 하면 된다.
  • 아래에 정의한 새로운 컨테이너 타입을 normalize 함수에 넘기면 함수가 잘 작동한다.
    - 즉, 이 방법은 인자에 lambda 함수를 구현하지 않고 객체를 생성해 해당 객체를 인자로 넘겨주면 된다는 뜻이다. 이렇게 하면 가독성이 더 좋아진다.
    class ReadVisits:
    	def __init__(self, data_path):
        	self.data_path = data_path
            
        def __iter__(self):
        	# 이터레이터를 구현한다.
        	with open(self.data_apth) as f:
            	for line in f:
                	yield int(line)
     
    visits = ReadVisits()
    percentages = normalize(visits)
    print(percentages)
    assert sum(percentages) == 100.0
    
    >>>
    [11.XXXXX, 26.XXXXX, 61.XXXXX]
    • 그러면 이제 normalize 함수가 visits를 호출할 경우 ReadVisits.__iter__를 호출해 새로운 이터레이터 객체를 할당하게 된다.
    • 각 숫자를 정규화 하기 위한 for 루프도 역시 __iter__를 호출해 두 번째 이터레이터 객체를 만든다.
      • 두 이터레이터는 서로 독립적으로 진행되고 소진 된다(sum(numbers)를 위해 한 번, for 루프를 위해 한 번 호출되어 이터레이터는 총 두 번 호출됨).

이 접근 방식의 유일한 단점은 입력 데이터를 여러번 읽는다는 것이다.


주의할 점

  • 이터레이터가 iter 내장 함수에 전달 되는 경우에는 전달 받은 이터레이터가 그대로 반환된다.
    - 즉, 반복적인 이터레이션이 불가능하다.
  • 반면, 컨테이너 타입이 iter에 전달 되면 이 때는 새로운 이터레이터 객체가 반환된다.
    - 즉, 반복적인 이터레이션이 가능하다.

따라서, 방어 로직을 작성해 반복적으로 이터레이션 할 수 없는 인자인 경우에는 TypeError를 발생시켜 인자를 거부할 수 있다.

[타입 검사 방법1]: iter() 이용

def normalize_defensive(numbers):
	if iter(numbers) is numbers:  # iter()로 감쌌으나 그대로 이터레이터(객체)인 경우
    	raise TypeError('컨테이너를 제공해야 합니다!')
        
    total = sum(numbers)
    result = []
    for value in get_iter():
    	percent = 100 * value / total
        result.append(percent)
    return result

[타입 검사 방법2]: isinstance() 이용

from colletions.abc import Iterator

def normalize_defensive(numbers):
	if isinstance(numbers, Iterator):
    	raise TypeError('컨테이너를 제공해야 합니다!')
        
    total = sum(numbers)
    result = []
    for value in get_iter():
    	percent = 100 * value / total
        result.append(percent)
    return result

이러한 방식으로 컨테이너를 사용하는 것은 앞에서 사용한 normalize_copy 함수처럼 전체 입력 이터레이터를 복사하고 싶지 않을 때 유용하다.
하지만, 입력 데이터를 여러 번 이터레이션 해야 한다는 단점이 있다.



컨테이너란?
컨테이너는 무언가를 담는 '그릇' 정도로 생각하면 된다. 파이썬에서는 리스트, 딕셔너리, 세트, 튜플, 문자열 이 이에 해당한다.


이터레이터란?
파이썬에서 이터레이터는 여러개의 요소를 가지는 컨테이너(리스트, 튜플, 셋, 사전, 문자열)에서 각 요소를 하나씩 꺼내 어떤 처리를 수행하는 간편한 방법을 제공하는 객체이다. 즉, 반복문을 활용해 데이터를 순회하면서 처리하는 것을 말한다.

for element in [1,2,3]: 
	print(element)
  • for문의 경우 내부적으로, 주어진 컨테이너 객체에 대해 iter() 메소드를 호출해서 이터레이터 객체를 구한다.
  • 그 다음, 내부의 요소를 하나씩 가져오기 위해 __next__()를 호출한다.
    - 이 메소드는 하나의 요소를 반환하고 그 다음 요소를 가리킨다.
  • 만약 더 이상 가져올 요소가 없으면 StopIteration 예외를 발생시킨다.
  • 이터레이터에서 다음 요소를 직접 가져오고 싶다면 next() 내장함수를 사용할 수 있다.



참고
이터레이터
"파이썬 코딩의 기술"(by 브렛 슬라킨)

profile
쿄쿄

0개의 댓글