파라미터로 객체의 리스트를 받는 함수에서 리스트를 여러 번 순회해야 할 때가 종종 있습니다. 예를 들어 미국 텍사스주의 여행자 수를 분석하고 싶다고 할 때 데이터 집합은 각 도시의 방문자 수(연도별 백만 명 단위)라고 가정하고 각 도시에서 전체 여행자 중 몇 퍼센트를 받아들이는지 알고 싶을 것입니다.

이런 작업을 하려면 정규화 함수가 필요합니다. 정규화 함수에서는 입력을 합산해서 연도별 총 여행자 수를 구합니다. 그러고 나서 각 도시의 방문자 수를 전체 방문자 수로 나누어 각 도시가 전체에서 차지하는 비중을 알아냅니다.

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


visits = [15, 35, 80]
percentages = normalize(visits)
print(percentages)


# 결과
# [11.538461538461538, 26.923076923076923, 61.53846153846154]


방문 리스트를 확대라혀면 텍사스주의 모든 도시가 들어 있는 파일에서 데이터를 읽어야 합니다. 이 작업을 수행하는 제너레이터를 정의할텐데, 그러면 나중에 같은 함수를 재사용하여 더 큰 데이터 세트인 전 세계의 여행자 수를 계산할 수 있기 떄문입니다.

# my_numbers.txt
15
35
80


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

def read_visits(data_path):
    with open(data_path) as f:
        for line in f:
            yield int(line)
it = read_visits('my_numbers.txt')
percentages = normalize(it)
print(percentages)


# 결과
# []


이런 결과가 나오는 건 이터레이터가 결과를 한 번만 생성하기 때문입니다. 이미 StopIteration 예외를 일으킨 이터레이터나 제너레이터를 순회하면 어떤 결과도 얻을 수 없습니다.

it = read_visits('my_numbers.txt')
print(list(it))
print(list(it)) # 이미 소진함
 
# 결과
# [15, 35, 80]
# []


이미 소진한 이터레이터를 순회하더라도 오류가 일어나지 않아 혼란스러울 수 있습니다. for 루프와 list 생성자, 파이썬 표준 라이브러리의 많은 함수는 정상적인 동작 과정에서 StopIteration 예외가 일어날 것이라고 기대합니다. 이런 함수는 결과가 없는 이터레이터와 결과가 있었지만 이미 소진한 이터레이터의 차이를 알려주지 않습니다.

이 문제를 해결하려면 입력 이터레이터를 명시적으로 소진하고 전체 콘텐츠의 복사본을 리스트에 저장해야 합니다. 그러고 나면 리스트 버전의 데이터를 필요한 만큼 순회할 수 있습니다. 다음은 이전과 동일하지만 입력 이터레이터를 방어적으로 복사하는 함수입니다.

def normalize(numbers):
    numbers = list(numbers) # 이터레이터를 복사함
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

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

it = read_visits('my_numbers.txt')
percentages = normalize(it)
print(percentages)
 
# 결과
# [11.538461538461538, 26.923076923076923, 61.53846153846154]


이 방법의 문제는 입력받은 이터레이터 콘텐츠의 복사본이 클 수도 있다는 점입니다. 이런 이터레이터를 복사하면 프로그램의 메모리가 고갈되어 동작을 멈출 수도 있습니다. 이 문제를 피하는 한 가지 방법은 호출될 때마다 새 이터레이터를 반환하는 함수를 받게 만드는 것입니다.

def normalize_func(get_iter):
    total = sum(get_iter())  # 새 이터레이터
    result = []
    for value in get_iter():  # 새 이터레이터
        percent = 100 * value / total
        result.append(percent)
    return result

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

percentages = normalize_func(lambda: read_visits('my_numbers.txt'))
print(percentages)

# 결과
# [11.538461538461538, 26.923076923076923, 61.53846153846154]

위와 같이 normalize_func를 사용하려면 람다 표현식을 넘겨주면 됩니다. 코드는 잘 동작하지만 람다 함수를 넘겨주는 방법은 세련되지 못합니다. 같은 결과를 얻는 더 좋은 방법은 이터레이터 프로토콜 (iterator protocol)을 구현한 새 컨테이너 클래스를 제공하는 것입니다.

이터레이터 프로토콜은 파이썬의 for 루프와 관련 표현식이 컨테이너 타입의 콘텐츠를 탐색하는 방법을 나타냅니다. 

파이썬은 for x in foo 같은 문장을 만나면 실제로는 iter(foo)를 호출합니다. 그러면 내장 함수 iter는 특별한 메서드인 foo.__iter__를 호출합니다. __iter__ 메서드는 (__next__라는 특별한 메서드를 구현하는) 이터레이터 객체를 반환해야 합니다. 마지막으로 for 루프는 이터레이터를 모두 소진할 때까지 (StopIteration 예외가 발생할 때까지) 이터레이터 객체에 내장 함수 next를 계속 호출합니다.

복잡해 보이지만 사실 클래스의 __iter__메서드를 제너레이터로 구현하면 이렇게 동작하게 만들 수 있습니다.


다음은 여행자 데이터를 담은 파일을 읽는 이터러블(iterable: 순회가능) 컨테이너 클래스입니다.

class ReadVisits(object):
    def __init__(self, data_path):
        self.data_path = data_path
    def __iter__(self):
        with open(self.data_path) as f:
            for line in f:
                yield int(line)

def normalize(numbers):
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result
visits = ReadVisits('address.txt') percentages = normalize(visits) print(percentages)

동작하는 이유는 normalize의 sum메서드가 새 이터레이터 객체를 할당하려고 ReadVisits.__iter__를 호출하기 때문입니다. 숫자를 정규화하는 for 루프도 두 번째 이터레이터 객체를 할당할 때 __iter__를 호출합니다. 두 이터레이터는 독립적으로 동작하므로 각각의 순회 과정에서 모든 입력 데이터 값을 얻을 수 있습니다. 이 방법의 유일한 단점은 입력 데이터를 여러 번 읽는다는 점입니다. (sum메서드가 새 이터레이터 객체를 할당할 때 1번, for 루프에서 두 번째 이터레이터 객체를 할당 할 때, 총 2번 __iter__ 호출)


이제 ReadVisits와 같은 컨테이너가 어떻게 동작하는지 알았으니 파라미터가 단순한 이터레이터가 아님을 보장하는 함수를 작성할 차례입니다. 프로토콜에 따르면 내장 함수 iter에 이터레이터를 넘기면 이터레이터 자체가 반환됩니다. 반면 iter에 컨테이너 타입을 넘기면 매번 새 이터레이터 객체가 반환됩니다. 따라서 이 동작으로 입력 값을 테스트해서 이터레이터면 TypeError를 일으켜 거부하게 만들면 됩니다.

class ReadVisits(object):
    def __init__(self, data_path):
        self.data_path = data_path
    def __iter__(self):
        with open(self.data_path) as f:
            for line in f:
                yield int(line)

def normalize_defensive(numbers):
    if iter(numbers) is iter(numbers):  # 이터레이터 -- 거부
        raise TypeError('Must supply a container')
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

visits = [15,37,80]
normalize_defensive(visits) # 오류 없음


visits = ReadVisits('/Users/leejinhwan/Documents/atom/address')
normalize_defensive(visits) # 오류 없음


it = iter(visits)
normalize_defensive(it) # TypeError: Must supply a container

normalize_defensive는 normalize_copy처럼 입력 이터레이터 전체를 복사하고 싶지 않지만, 입력 데이터를 여러 번 순회해야 할 때 사용하면 좋습니다. 이 함수는 list와 ReadVisits를 입력으로 받으면 입력이 컨테이너이므로 기대한 대로 동작합니다. 이터레이터 프로토콜을 따르는 어떤 컨테이너 타입에 대해서도 제대로 동작합니다. 하지만 함수가 입력이 이터러블이어도 컨테이너가 아니면 예외를 일으킵니다.

요약

입력 인수를 여러 번 순회하는 함수를 작성할 때 주의

입력 인수가 이터레이터라면 이상하게 동작해서 값을 잃어버릴 수 있음

파이썬의 이터레이터 프로토콜은 컨테이너와 이터레이터가 내장 함수 iter, next와 for 루프 및 관련 표현식의 상호 작용하는 방법을 정의

__iter__메서드를 제너레이터로 구현하면 자신만의 이터러블 컨테이너 타입을 쉽게 정의할 수 있음

어떤 값에 iter를 두번 호출했을 때 같은 결과가 나오고 내장 함수 next로 전진시킬 수 있다면 그 값은 컨테이너가 아닌 이터레이터