파이썬 코딩의 기술 - 31

JinWooHyun·2021년 7월 6일
0

인자에 대해 이터레이션할 때는 방어적이 돼라

객체가 원소로 들어 있는 리스트를 함수가 파라미터로 받았을 때, 이 리스트를 여러 번 이터레이션하는 것이 중요할 때가 종종 있다.

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)
assert sum(percentages) == 100.0

# >>> [11.53, 26.92, 61.53]

이 코드의 규모 확장성을 높이려면 모든 도시에 대한 여행자 정보가 들어 있는 파일에서 데이터를 읽어야 한다.
파일을 읽는 함수를 재사용해야 할 수도 있으므로 제너레이터를 정의한다.

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 예외가 발생한 이터레이터나 제너레이터를 다시 이터레이션하면 아무 결과도 얻을 수 없다.

it = read_visits('my_numbers.txt')
print(list(it))
print(list(it))

# >>> [15, 35, 80]
# >>> [] : 이미 모든 원소를 다 소진했다.

혼란스럽게도 이미 소진된 이터레이터에 대해 이터레이션을 수행해도 아무런 오류가 발생하지 않는다.
for 루프, 리스트 생성자, 그 외 파이썬 표준 라이브러리에 있는 많은 함수가 일반적인 연산 도중에 StopIteration 예외가 던져지는 것을 가정한다.
이런 함수들은 출력이 없는 이터레이터와 이미 소진돼버린 이터레이터를 구분할 수 없다.

이 문제를 해결하기 위해 입력 이터레이터를 명시적으로 소진시키고 이터레이터의 전체 내용을 리스트에 넣을 수 있다.

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

이 함수는 read_visits 제너레이터가 반환하는 값에 대해서도 잘 작동한다.

it = read_visits('my_numbers.txt')
percentages = normalize_copy(it)
print(percentages)
assert sum(percentages) == 100.0

# >>> [11.53, 26.92, 61.53]

이런 접근 방식의 문제점은 입력 이터레이터의 내용을 복사하면 메모리를 엄청나게 많이 사용할 수 있다는 것이다.
이 문제는 처음에 read_visits를 제너레이터로 바꿔 쓰기로 결정했던 근본적인 이유인 규모 확장성 문제와 같다.
이 문제를 해결하는 방법은 호출될 때마다 새로 이터레이터를 반환하는 함수를 받는 것이다.

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

normalize_func를 사용할 때, 매번 제너레이터를 호출해서 새 이터레이터를 만들어내는 lambda 식을 전달할 수 있다.

path = 'my_numbers.txt'
percentages = normalize_func(lambda: read_visits(path))
print(percentages)
assert sum(percentages) == 100.0

# >>> [11.53, 26.92, 61.53]

작동하기는 하지만, 이렇게 람다 함수를 넘기는 것은 보기에 좋지 않다.
같은 결과를 달성하는 더 나은 방법은 이터레이터 프로토콜(iterator protocol)을 구현한 새로운 컨테이너 클래스를 제공하는 것이다.

이터레이터 프로토콜은 파이썬의 for 루프나 그와 연관된 식들이 컨테이너 타입의 내용을 방문할 때 사용하는 절차다.
파이썬에서 for x in foo와 같은 구문을 사용하면, 실제로는 iter(foo)를 호출 한다.
iter 내장 함수는 foo.__iter__라는 특별 메서드를 호출한다.
__iter__ 메서드는 반드시 이터레이터 객체(이 객체는 __next__ 특별 메서드를 정의해야 한다)를 반환해야 한다.
for 루프는 반환받는 이터레이터 객체가 데이터를 소진(이터레이터는 StopIteration 예외를 던진다)할 때까지 반복적으로 이터레이터 객체에 대해 __next__ 내장 함수를 호출한다.

실제로 코드를 작성할 때 정의하는 클래스에서 __iter__ 메서드를 제너레이터로 구현하기만 하면 이 모든 동작을 만족시킬 수 있다.

class ReadVisits:
    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)

이 새로운 컨테이너 타입을 원래의 normalize 함수에 넘기면 코드를 전혀 바꾸지 않아도 함수가 잘 작동ㅎ나다.

visits = ReadVisits(path)
percentages = normalize(visits)
print(percentages)
assert sum(percentages) == 100.0

# >>> [11.53, 26.92, 61.53]

이 코드가 잘 작동하는 이유는 normalize 함수 안의 sum 메서드가 ReadVisits.__iter__를 호출해서 새로운 이터레이터 객체를 할당하기 때문이다.
각 숫자를 정규화하기 위한 for 루프도 __iter__를 호출해서 두 번째 이터레이터 객체를 만든다.
두 이터레이터는 서로 독립적으로 진행되고 소진된다.
이 접근 방법의 유일한 단점은 입력 데이터를 여러 번 읽는다는 것이다.

프로토콜에 따르면, 이터레이터가 iter 내장 함수에 전달 되는 경우에는 전달받은 이터레이터가 그대로 반환된다.
반대로 컨테이너 타입이 iter에 전달되면 매번 새로운 이터레이터 객체가 반환된다.

따라서 입력값이 이런 동작을 하는지 검사해서 반복적으로 이터레이션 할 수 없는 인자인 경우에는 TypeError를 발생시켜서 인자를 거부할 수 있다.

def normalize_defensive(numbers):
    if iter(numbers) is numbers: # 이터레이터 X
        raise TypeError('컨테이너를 제공해야 합니다')
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

다른 대안으로 collections.abc 내장 모듈은 isinstance를 사용해 잠재적인 문제를 검사할 수 있는 Iterator 클래스를 제공한다.

from collections.abc import Iterator

def normalize_defensive(numbers):
    if isinstance(numbers, Iterator): # 반복 가능한 이터레이터인지 검사하는 다른 방법
        raise TypeError('컨테이너를 제공해야 합니다')
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

이런 식으로 컨테이너를 사용하는 방법은 앞의 normalize_copy 함수처럼 전체 입력 이터레이터를 복사하고 싶지 않을 때 유용하지만, 입력 데이터를 여러 번 이터레이션해야 한다.
normalize_defensive 함수는 리스트와 ReadVisits에 대해 모두 제대로 작동한다.
리스트나 ReadVisits 모두 이터레이터 프로토콜을 따르는 이터러블 컨테이너이기 때문이다.

이 함수는 입력이 컨테이너가 아닌 이터레이터면 예외를 발생시킨다.

visits = [15, 35, 80]
it = iter(visits)
normalize_defensive(it)

# >>> TypeError!

비동기 이터레이터에 대해서도 같은 접근 방식을 사용할 수 있다.

기억해야 할 내용

  • 입력 인자를 여러 번 이터레이션하는 함수나 메서드를 조심하라. 입력받은 인자가 이터레이터면 함수가 이상하게 작동하거나 결과가 없을 수 있다.
  • 파이썬의 이터레이터 프로토콜은 컨테이너와 이터레이터가 iter, next 내장 함수나 for 루프 등의 관련 식과 상호작용하는 절차를 정의한다.
  • __iter__ 메서드를 제너레이터로 정의하면 쉽게 이터러블 컨테이너 타입을 정의할 수 있다.
  • 어떤 값이 이터레이터인지 감지하려면, 이 값을 iter 내장 함수에 넘겨서 반환되는 값이 원래 값과 같은지 확인하면 된다. 다른 방법으로 collections.abc.Iterator 클래스를 isinstance와 함께 사용할 수도 있다.
profile
Unicorn Developer

0개의 댓글