NLP Blog

이펙티브 파이썬 - 17. 인수를 순회할 때는 방어적으로 하자

|

# 기본적인 정규화 함수
def normalize(numbers):
    total = sum(numbers)
    result = []
    for value in numbers:
        percent = 100 * float(value) / total
        result.append(percent)
    return result

visits = [15, 34, 53, 25, 17, 194]
percentages = normalize(visits)
print(percentages)
[4.437869822485207, 10.059171597633137, 15.680473372781066, 7.396449704142012, 5.029585798816568, 57.396449704142015]
# visitor 데이터를 읽는 제너레이터 함수 정의
# 제너레이터로 만들어야지 visitor가 엄청엄청 커도 계산이 가능 (betterway 16 참고)
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 예외를 일으킨 이터레이터나 제너레이터를 순회하면 어떤 결과도 얻을 수 없다.
it2 = read_visits('./my_numbers.txt')
print(list(it2))
print(list(it2))
[3546, 246, 25436, 7356, 35432, 25, 2566, 24632, 264, 25235, 662, 2, 262, 5234, 5263, 236, 26456, 25, 23523, 52, 45, 62, 52, 72, 23, 52, 52, 53, 734, 645, 6345, 6346, 63, 742, 6234, 64, 724, 6245]
[]
  • 이미 소진한 이터레이터를 순회해도 오류가 뜨지 않음
  • for, list 등과 같은 파이썬 표준 라이브러리의 많은 함수는 정상적인 동작과정에서 StopIteration 예외가 일어 날 수 있다고 가정
  • 이런 함수는 결과가 없는 이터레이터와 결과가 있었지만 이미 소진한 이터레이터의 차이를 알려주지 않는다.
# 위 문제를 해결하기 위해 입력 이터레이터를 명시적으로 소진 후 전체 콘텐츠의 복사본을 리스트에 저장
# 그렇게하면 리스트 버전의 데이터를 필요한 만큼 순회 할 수 있다.
# 1번 cell과 같은 함수지만 입력 이터레이터를 방어적으로 복사하는 함수임.
def normalize_copy(numbers):
    numbers = list(numbers)
    total = float(sum(numbers))
    result = []
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result
it3 = read_visits('./my_numbers.txt')
percentages = normalize_copy(it3)
print(percentages) # 제대로 동작 함
[1.6492562998241909, 0.11441541166293033, 11.830367524627219, 3.421299870701283, 16.47954010585751, 0.011627582486070156, 1.1934550663702408, 11.456424471875204, 0.12278727105290085, 11.736881761439216, 0.30789838423113775, 0.0009302065988856125, 0.12185706445401524, 2.434350669283648, 2.4478386649674895, 0.10976437866850228, 12.304772890058882, 0.011627582486070156, 10.940624912793131, 0.024185371571025925, 0.02092964847492628, 0.02883640456545399, 0.024185371571025925, 0.03348743755988205, 0.010697375887184543, 0.024185371571025925, 0.024185371571025925, 0.02465047487046873, 0.3413858217910198, 0.29999162814061003, 2.951080434964606, 2.9515455382640483, 0.029301507864896794, 0.3451066481865622, 2.899453968726454, 0.0297666111643396, 0.33673478879659174, 2.904570105020325]
# 위 방법의 문제는 입력받은 이터레이터 콘텐츠의 복사본이 클 수도 있다는 점
# 저렇게 할거면 그냥 read_visits를 리스트로 받아오는게....
# 만약 my_numbers.txt가 엄청 크면 OOM 뜰 수 있음 (list의 단점)
# 이 문제를 피하려면, 호출 될 때마다 새 이터레이터를 반환하는 함수를 받게 만드는 것
def normalize_func(get_iter):
    total = sum(get_iter()) # 새 이터레이터, total을 계산하는데 필요한 sum 함수를 위한
    result = []
    for value in get_iter(): # 새 이터레이터, 다시 처음부터 percent 계산을 위해 value에 대한
        percent = 100 * value / total
        result.append(percent)
    return result
# normalize_func을 사용하려면 제너레이터를 호출해서 매번 새 이터레이터를 생성하는 람다 표현식을 넘겨주면 됨
path = "./my_numbers.txt"
percentages = normalize_func(lambda: read_visits(path))
print(percentages)
[1.6492562998241909, 0.11441541166293033, 11.830367524627219, 3.421299870701283, 16.47954010585751, 0.011627582486070156, 1.1934550663702408, 11.456424471875204, 0.12278727105290085, 11.736881761439216, 0.30789838423113775, 0.0009302065988856125, 0.12185706445401524, 2.434350669283648, 2.4478386649674895, 0.10976437866850228, 12.304772890058882, 0.011627582486070156, 10.940624912793131, 0.024185371571025925, 0.02092964847492628, 0.02883640456545399, 0.024185371571025925, 0.03348743755988205, 0.010697375887184543, 0.024185371571025925, 0.024185371571025925, 0.02465047487046873, 0.3413858217910198, 0.29999162814061003, 2.951080434964606, 2.9515455382640483, 0.029301507864896794, 0.3451066481865622, 2.899453968726454, 0.0297666111643396, 0.33673478879659174, 2.904570105020325]
  • 잘 동작하긴 함….
  • 근데 님들… 람다함수 잘(자주) 쓰나여…?
  • 이렇게 람다 함수를 넘겨주는 방법은 세련되지 못함
  • 같은 결과를 얻는 더 좋은 방법은 이터레이터 프로토콜(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)
visits = ReadVisits(path)
percentages = normalize(visits)
print(percentages)
[1.6492562998241909, 0.11441541166293033, 11.830367524627219, 3.421299870701283, 16.47954010585751, 0.011627582486070156, 1.1934550663702408, 11.456424471875204, 0.12278727105290085, 11.736881761439216, 0.30789838423113775, 0.0009302065988856125, 0.12185706445401524, 2.434350669283648, 2.4478386649674895, 0.10976437866850228, 12.304772890058882, 0.011627582486070156, 10.940624912793131, 0.024185371571025925, 0.02092964847492628, 0.02883640456545399, 0.024185371571025925, 0.03348743755988205, 0.010697375887184543, 0.024185371571025925, 0.024185371571025925, 0.02465047487046873, 0.3413858217910198, 0.29999162814061003, 2.951080434964606, 2.9515455382640483, 0.029301507864896794, 0.3451066481865622, 2.899453968726454, 0.0297666111643396, 0.33673478879659174, 2.904570105020325]
  • 위 코드가 동작하는 이유는, normalizesum 메서드가 새 이터레이터 객체를 할당하려고 ReadVisits.__iter__를 호출하기 때문
  • 숫자를 정규화하는 for 루프도 두번째 이터레이터 객체를 할당할 때 __iter__를 호출한다.
  • 위 두 이터레이터는 독립적으로 동작하므로 각각의 순회 과정에서 모든 입력 데이터 값을 얻을 수 있다.
  • 이 방법의 유일한 단점은 입력 데이터를 여러 번 읽는다는 점이다.

  • 이제 ReadVisits와 같은 컨테이너가 어떻게 동작하는지 알았으니, 파라미터가 단순한 이터레이터가 아님을 보장하는 함수를 작성해야 함
    • 프로토콜에 따르면 내장함수 iter에 이터레이터를 넘기면 이터레이터 자체가 반환
    • 반면에 iter에 컨테이너 타입을 넘기면 매번 새 이터레이터 객체가 반환 됨
    • 따라서 이 동작으로 입력값을 테스트해서 이터레이터면 TypeError를 일으켜 거부하게 만들면 된다.
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
  • normalize_defensivenormalize_copy처럼 입력 이터레이터 전체를 복사하고 싶지 않지만, 입력 데이터를 여러번 순회해야 할 때 사용하면 좋다.
    • 이 함수는 listReadVisits를 입력으로 받으면 입력이 컨테이너이므로 기대한 대로 동작
    • 이터레이터 프로토콜을 따르는 어떤 컨테이너 타입에 대해서도 제대로 동작
visits = [15, 35, 80]
normalize_defensive(visits) # 오류 없음
visits = ReadVisits(path)
normalize_defensive(visits) # 오류 없음
[1.6492562998241909,
 0.11441541166293033,
 11.830367524627219,
 3.421299870701283,
 16.47954010585751,
 0.011627582486070156,
 1.1934550663702408,
 11.456424471875204,
 0.12278727105290085,
 11.736881761439216,
 0.30789838423113775,
 0.0009302065988856125,
 0.12185706445401524,
 2.434350669283648,
 2.4478386649674895,
 0.10976437866850228,
 12.304772890058882,
 0.011627582486070156,
 10.940624912793131,
 0.024185371571025925,
 0.02092964847492628,
 0.02883640456545399,
 0.024185371571025925,
 0.03348743755988205,
 0.010697375887184543,
 0.024185371571025925,
 0.024185371571025925,
 0.02465047487046873,
 0.3413858217910198,
 0.29999162814061003,
 2.951080434964606,
 2.9515455382640483,
 0.029301507864896794,
 0.3451066481865622,
 2.899453968726454,
 0.0297666111643396,
 0.33673478879659174,
 2.904570105020325]
# 함수는 입력이 이터러블이어도 컨테이너가 아니면 예외를 일으킨다
it = iter(visits)
normalize_defensive(it)
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-15-d893faf1a58b> in <module>()
      1 # 함수는 입력이 이터러블이어도 컨테이너가 아니면 예외를 일으킨다
      2 it = iter(visits)
----> 3 normalize_defensive(it)


<ipython-input-13-462b037875ce> in normalize_defensive(numbers)
      1 def normalize_defensive(numbers):
      2     if iter(numbers) is iter(numbers): # 이터레이터 -- 거부!
----> 3         raise TypeError('Must supply a container')
      4     total = sum(numbers)
      5     result = []


TypeError: Must supply a container

핵심 정리

  • 입력인수를 여러 번 순회하는 함수를 작성할 때 주의하자. 입력 인수가 이터레이터라면 이상하게 동작해서 값을 잃어버릴 수 있다.
  • 파이썬의 이터레이터 프로토콜은 컨테이너와 이터레이터가 내장 함수 iter, nextfor루프 및 관련 표현식과 상호 작용하는 방법을 정의한다.
  • __iter__ 메서드를 제너레이터로 구현하면 자신만의 이터러블 컨테이너 타입을 쉽게 정의할 수 있다.
  • 어떤 값에 iter를 두 번 호출했을 때 같은 결과가 나오고 내장 함수 next

Comments