Item 27: map과 filter 대신 Comprehensions를 써라
파이썬은 sequence나 iterable로부터 새로운 list를 만드는 간결한 syntax를 제공하고, 이를 list comprehensions라고 부른다.
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
squares = []
for x in a:
squares.append(x**2)
print(squares)
>>>
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
위와 같이 a의 원소들의 제곱의 값을 가지는 list를 만들어낼 수 있다. 이를 list comprehension을 이용하면 훨씬 간결하게 구현이 가능하다.
squares = [x**2 for x in a] # List comprehension
print(squares)
>>>
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
아래와 같이 map을 사용할 수도 있지만 시각적으로 좋아보이지는 않는다.
alt = map(lambda x: x ** 2, a)
list comprehension은 조건식을 이용해서 쉽게 필터링도 해준다. 아래의 코드를 보자.
even_squares = [x**2 for x in a if x % 2 == 0]
print(even_squares)
>>>
[4, 16, 36, 64, 100]
위의 코드는 list comprehension의 if 조건식을 이용해서 짝수인 원소만을 골라낸다. 같은 동작을 하는 코드를 filter와 map을 이용해 아래와 같이 구현할 수 있지만, 가독성이 매우 떨어진다.
alt = map(lambda x: x**2, filter(lambda x: x % 2 == 0, a))
assert even_squares == list(alt)
dictionary와 set에도 list comprehension과 같은 동작을 하는 (dictionary comprehensions, set comprehensions라고 부름) 기능이 있다.
even_squares_dict = {x: x**2 for x in a if x % 2 == 0}
threes_cubed_set = {x**3 for x in a if x % 3 == 0}
print(even_squares_dict)
print(threes_cubed_set)
>>>
{2: 4, 4: 16, 6: 36, 8: 64, 10: 100}
{216, 729, 27}
마찬가지로 map과 filter를 이용해서 같은 동작을 하는 코드를 만들 수는 있지만 마찬가지로 코드가 길어지고 가독성이 떨어지므로 최대한 사용을 자제한다.
alt_dict = dict(map(lambda x: (x, x**2),
filter(lambda x: x % 2 == 0, a)))
alt_set = set(map(lambda x: x**3,
filter(lambda x: x % 3 == 0, a)))
Item 28: Comprehension 내부에 control subexpression을 세 개 이상 사용하지 말 것
Item 27 에서와 같이 기본적인 comprehension 사용법 외에도, comprehension은 multiple level의 loop를 허용한다.
예를 들어, matrix 를 flat한 단일 리스트로 단순화하고 싶다고 하자. 아래와 같이 comprehension에 하위 식을 두 개 포함시키면 이런 처리가 가능하다.
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]
print(flat)
>>>
[1, 2, 3, 4, 5, 6, 7, 8, 9]
위의 예제는 간단하고 읽기가 쉽고, comprehension에서 multiple loops를 다루는 적절한 예이다. 또 다른 적절한 예는 two-level 깊이의 list를 복제하는 경우다. 예를 들어 2차원 행렬의 원소를 제곱하고 싶다고 하자.
squared = [[x**2 for x in row] for row in matrix]
print(squared)
>>>
[[1, 4, 9], [16, 25, 36], [49, 64, 81]]
만약 3차원 이상의 행렬을 예로 들어보자.
my_lists = [
[[1, 2, 3], [4, 5, 6]],
...
]
flat = [x for sublist1 in my_lists
for sublist2 in sublist1
for x in sublist2]
이 때부터는 multiline comprehension이 다른 대안들에 비해 더 길어진다. 예를 들어, normal loop를 사용한 경우를 살펴보자.
flat = []
for sublist1 in my_lists:
for sublist2 in sublist1:
flat.extend(sublist2)
위의 normal loop를 사용한 코드가 three-level-list comprehension 보다 훨씬 명확해 보인다.
같은 level의 loop에 여러 if 조건을 사용하고 싶은 경우 and 를 사용하면 된다.
예를 들어 숫자 list에서 (1) 4보다 큰 (2) 짝수만을 남기고 싶다고 하자.
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
b = [x for x in a if x > 4 if x % 2 == 0]
c = [x for x in a if x > 4 and x % 2 == 0]
위와 같이 c에서 and를 써도 되고, b에서처럼 if 조건식 뒤에 바로 if를 써도 된다(암묵적으로 사이에 and가 있음을 의미).
각 level의 loop에서 for 서브문 뒤에도 조건식을 붙일 수 있다. 아래의 코드를 보자.
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
filtered = [[x for x in row if x % 3 == 0]
for row in matrix if sum(row) >= 10]
print(filtered)
>>>
[[6], [9]]
하지만 가독성이 매우 떨어지고, 혼동할 여지가 크기 때문에 comprehension에 들어가는 sub-expression이 3개 이상 되지 않도록 제한하자.
Item 29: Assignment Expression을 사용해 comprehension 내의 반복 작업을 피할 것
Comprehension에서는 같은 계산을 여러 위치에서 하는 게 common pattern이다.
예를 들어, 어떤 철물 회사에서 주문 관리를 위한 프로그램을 작성한다고 하자.
고객이 새로운 주문을 요청하면, 고객에게 주문을 이행할 수 있는지를 알려줘야한다.(재고가 충분한지, 배송에 필요한 최소 수량을 만족하는지 등)
stock = {
'nails': 125,
'screws': 35,
'wingnuts': 8,
'washers': 24,
}
order = ['screws', 'wingnuts', 'clips']
def get_batches(count, size):
return count // size
result = {}
for name in order:
count = stock.get(name, 0)
batches = get_batches(count, 8)
if batches:
result[name] = batches
print(result)
>>>
{'screws': 4, 'wingnuts': 1}
이 때, dictionary comprehension을 사용하면 더 간결하게 구현이 가능하다.
found = {name: get_batches(stock.get(name, 0), 8)
for name in order
if get_batches(stock.get(name, 0), 8)}
print(found)
>>>
{'screws': 4, 'wingnuts': 1}
위의 코드가 더 간결하긴 하지만 get_batches(stock.get(name, 0), 8) 이 반복된다는 문제가 있다. 이러면 가독성도 나빠지고 항상 두 식을 똑같이 수정해야하므로 오류의 가능성도 높아진다.
위의 문제에 적용할 수 있는 간단한 해결책으로는 python 3.8 부터 도입된 walrus operator (:=)를 사용하는 것이다.
found = {name: batches for name in order
if (batches := get_batches(stock.get(name, 0), 8))}
위와 같이 assignment expression을 사용하면 stock 딕셔너리에서 키를 한 번만 조회하고, get_batches를 한 번만 호출할 수 있다. 따라서 comprehension의 다른 부분에서는 batches 변수를 참조하면 되므로 get_batches를 다시 호출할 필요가 없다.
result = {name: (tenth := count // 10)
for name, count in stock.items() if tenth > 0}
>>>
Traceback ...
NameError: name 'tenth' is not defined
위와 같이 comprehension의 value expression에 assignment expression을 적용을 해도 문법적으로는 문제가 없지만, comprehension에서 evaluate되는 순서 때문에 exception이 발생한다.(if tenth > 0 부분에서 오류 발생)
result = {name: tenth for name, count in stock.items()
if (tenth := count // 10) > 0}
print(result)
>>>
{'nails': 12, 'screws': 3, 'washers': 2}
따라서 위처럼 assignment expression을 condition 으로 옮기는 것이 타당하다.
일반적인 for loop에서 loop variable이 leak되는 것처럼, 조건식이 없는 comprehension의 값 부분에서 walrus 연산자를 사용하면 loop variable이 leak 된다.
half = [(last := count // 2) for count in stock.values()]
print(f'Last item of {half} is {last}')
>>>
Last item of [62, 17, 4, 12] is 12
하지만 comprehension의 loop variable인 경우에는 아래와 같이 leak이 발생하지 않는다.
half = [count // 2 for count in stock.values()]
print(half) # Works
print(count) # Exception because loop variable didn't leak
>>>
[62, 17, 4, 12]
Traceback ...
NameError: name 'count' is not defined
loop variable을 leak하지 않는 편이 낫기 때문에 필자는 assignment expression을 조건식에만 사용하길 권장한다.
assignment expression은 아래 코드와 같이, generator expression에도 똑같이 적용된다.
found = ((name, batches) for name in order
if (batches := get_batches(stock.get(name, 0), 8)))
print(next(found))
print(next(found))
>>>
('screws', 4)
('wingnuts', 1)
Item 30: List를 return 하기보다는 Generator를 사용할 것
sequence를 만들어내는 function을 가장 쉽게 구현하려면 그냥 list를 반환하면 된다.
예를 들어 들어온 text의 단어의 index를 반환하는 코드를 살펴보자.
def index_words(text):
result = []
if text:
result.append(0)
for index, letter in enumerate(text):
if letter == ' ':
result.append(index + 1)
return result
address = 'Four score and seven years ago...'
result = index_words(address)
print(result[:10])
>>>
[0, 5, 11, 15, 21, 27, 31, 35, 43, 51]
하지만 위 함수는 두 가지 문제점이 있다.
첫 번째, 코드가 산만하다.
이 코드는 새로운 결과값을 찾을 때마다 append method를 호출하는데, 이 호출 덩어리가 너무 크기 때문에 리스트에 추가될 값인 index + 1의 중요성을 희석해버린다. 함수에서 공백 제외 130개의 character가 들어가는데 약 75개의 character정도만이 중요하다고 필자는 주장한다.
따라서 이를 개선하기 위해서는 generator를 사용하는 것이다.
generator는 yield expression을 사용하는 함수에 의해 만들어진다. 위의 코드와 같은 작업을 하는 generator 함수를 살펴보자.
def index_words_iter(text):
if text:
yield 0
for index, letter in enumerate(text):
if letter == ' ':
yield index + 1
it = index_words_iter(address)
print(next(it))
print(next(it))
>>>
0
5
위의 generator function이 호출되면 실제로 함수가 바로 실행되는 것은 아니지만, 그 즉시 iterator를 반환한다.
그리고 next 라는 빌트인 함수를 호출할 때마다 iterator는 generator function을 다음 yield식까지 진행시키며, generator가 yield에 전달하는 각 값은 iterator에 의해 caller에 반환된다.
위 함수의 코드는 list를 반환하기 위해 썼던 code가 더 이상 없으므로 훨씬 읽기가 쉽다. 대신 결과는 yield식에 전달된다. generator가 return 하는 iterator를 list 빌트인 함수에 전달하면 쉽게 list로 변환이 가능하다.
result = list(index_words_iter(address))
print(result[:10])
>>>
[0, 5, 11, 15, 21, 27, 31, 35, 43, 51]
두 번째, index_words 함수는 반환하기 전에 list에 모든 결과를 다 저장해야 한다는 것이다.
만약 입력이 매우 크면, 메모리를 다 써서 프로그램이 죽을 수도 있다.
반면 generator로 구현하면, 메모리 크기를 어느정도 제한할 수 있기 때문에 처리하기가 쉽다.
다음은 파일에서 한 번에 한 줄씩 읽어 한 단어씩 출력하는 generator를 정의한 코드이다.
def index_file(handle):
offset = 0
for line in handle:
if line:
yield offset
for letter in line:
offset += 1
if letter == ' ':
yield offset
위 함수의 working memory는 input 중에서 가장 긴 line의 길이로 제한된다.
generator를 실행하면 아래와 같은 결과를 얻을 수 있다.
with open('address.txt', 'r') as f:
it = index_file(f)
results = itertools.islice(it, 0, 10)
print(list(results))
>>>
[0, 5, 11, 15, 21, 27, 31, 35, 43, 51]
generator를 define 할 때 단 한 가지 명심해야 할 점이 있는데, generator가 return 하는 iterator에 상태가 있기 때문에, 재사용 할 수 없다는 것이다.
Item 31: Argument를 Iterating할 때는 방어적일 것
함수가 parameter로 객체로 이루어진 list를 받았을 때, 이 리스트를 여러 번 iteration하는 것이 중요할 때가 종종 있다.
예를 들어, Texas주 여행자 수를 분석하려고 한다. data set이 도시별 방문자라고 가정하고, 각 도시가 전체 여행자 수 중에서 차지하는 비율을 구하고 싶다고 하자.
이 경우, 1년동안의 tourist수를 합한 후 각 도시별 방문자 수를 합으로 나누는 normalization function이 필요하다.
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.538461538461538, 26.923076923076923, 61.53846153846154]
위 코드를 scale up 하려면, Texas의 모든 도시에 대한 여행자 정보가 담겨있는 파일에서 데이터를 읽어야한다.
이 때, generator를 이용한다고 해보자.
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)
>>>
[]
위와 같이 아무 결과도 나오지 않는 이유는, iterator가 결과를 한 번만 만들어내기 때문이다. 이미 StopIteration이 일어난 iterator나 generator를 다시 iterate하면 아무 결과도 얻을 수가 없다.
it = read_visits('my_numbers.txt')
print(list(it))
print(list(it)) # Already exhausted
>>>
[15, 35, 80]
[]
따라서 이런 문제를 해결하기 위해 아래 코드와 같이 input iterator를 명시적으로 소진시키고 iterator의 전체 contents를 list에 copy할 수 있다.
def normalize_copy(numbers):
numbers_copy = list(numbers) # Copy the iterator
total = sum(numbers_copy)
result = []
for value in numbers_copy:
percent = 100 * value / total
result.append(percent)
return result
it = read_visits('my_numbers.txt')
percentages = normalize_copy(it)
print(percentages)
assert sum(percentages) == 100.0
>>>
[11.538461538461538, 26.923076923076923, 61.53846153846154]
위의 방식이 잘 동작은 하지만, input iterator contents의 copy가 매우 클 수도 있는 문제점이 발생한다.(메모리 부족)
이를 해결하는 방법으로는 호출될 때마다 새로 iterator를 반환하는 함수를 받는 것이다.
def normalize_func(get_iter):
total = sum(get_iter()) # New iterator
result = []
for value in get_iter(): # New iterator
percent = 100 * value / total
result.append(percent)
return result
path = 'my_numbers.txt'
percentages = normalize_func(lambda: read_visits(path))
print(percentages)
assert sum(percentages) == 100.0
>>>
[11.538461538461538, 26.923076923076923, 61.53846153846154]
위는 normalize_func를 사용할 때, 매번 generator를 호출해서 새 iterator를 만들어내는 labmda식을 전달한다. 마찬가지로 작동하기는 하지만, 이렇게 lambda 함수를 넘기는 것은 보기 좋지 않다. 더 나은 방법은 iterator protocol을 구현한 새로운 container class를 제공하는 것이다.
iterator protocol은 파이썬의 for loop와, 혹은 그와 관련된 expression들이 container type의 contents를 traverse할 때 사용하는 절차이다. 예를 들어, for x in foo 와 같은 statement에서, 실제로는 iter(foo)를 호출한다. iter 빌트인 함수는 foo.__iter__라는 special method를 호출한다. __iter__ method는 반드시 iterator object(이 객체는 __next__ 라는 special method를 정의해야 함)를 반환해야 한다. 그 후, for loop은 iterator object가 data를 소진하기 전까지 (StopIteration 예외)반복적으로 iterator object에 대해 next 빌트인 함수를 호출한다.
아래의 코드를 보자. 다음은 여행 데이터가 들어있는 파일을 읽는 iterable container class를 정의한다.
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)
위의 새로운 container type은 기존의 normalize 함수에 대해서도 동작을 한다.
visits = ReadVisits(path)
percentages = normalize(visits)
print(percentages)
assert sum(percentages) == 100.0
>>>
[11.538461538461538, 26.923076923076923, 61.53846153846154]
이 코드가 잘 작동하는 이유는, normalize 함수 내의 sum method가 ReadVisits.__iter__를 호출해서 새로운 iterator object를 할당하기 때문이다. 각 숫자를 normalize 하기 위한 for value in numbers 에서도 __iter__를 호출해서 두 번째 iterator object를 만든다. 두 iterator는 독립적이며, 이 접근법의 유일한 단점은 input data를 여러 번 읽는 것이다.
컨테이너 타입이 아니라 iterator가 iter 빌트인 함수에 전달되면 전달받은 iterator가 그대로 반환된다. 따라서 이런 경우도 고려한 조금 더 defensive한 함수를 짤 수도 있다.
def normalize_defensive(numbers):
if iter(numbers) is numbers: # An iterator -- bad!
raise TypeError('Must supply a container')
total = sum(numbers)
result = []
for value in numbers:
percent = 100 * value / total
result.append(percent)
return result
위의 코드에서 colections.abc 빌트인 모듈의 Iterator class를 사용하는 것도 대안이 될 수 있다.
하지만 이와 같은 방법은 normalize_copy 함수처럼 전체 input iterator를 복사하고 싶지 않을 때는 유용하지만, input iterator를 여러 번 iterate 해야하는 단점이 있다.
위의 normalize_defensive 함수는 list나 ReadVisits에 대해 모두 제대로 작동한다. list나 ReadVisits 모두 iterator protocol을 따르는 iterable container 이기 때문이다.
visits = [15, 35, 80]
percentages = normalize_defensive(visits)
assert sum(percentages) == 100.0
visits = ReadVisits(path)
percentages = normalize_defensive(visits)
assert sum(percentages) == 100.0
하지만 위에서 설명한 대로 입력이 container가 아닌 iterator가 들어오면 exception을 발생시킨다.
visits = [15, 35, 80]
it = iter(visits)
normalize_defensive(it)
>>>
Traceback ...
TypeError: Must supply a container
Item 32: 긴 list comprehension보다는 generator expression을 사용할 것
list comprehension의 문제점은, input sequence와 같은 수의 원소를 가진 list instance를 만들어낼 수 있으므로 입력이 커지면 memory 사용이 지나치게 커질 수 있다는 것이다.
value = [len(x) for x in open('my_file.txt')]
print(value)
>>>
[100, 57, 15, 1, 12, 75, 5, 86, 89, 11]
예를 들어 위와 같은 코드에서 input file이 짧은 경우가 아니라 엄청 크다면(network socket과 같이) 문제가 될 수 있다.
이와 같은 문제 해결을 위해 python은 generator expression을 제공한다. generator expression은 list comprehension과 generator를 일반화 한 것이다. Generator expression을 실행해도 전체 output sequence를 materialize하지는 않고, 대신 generator expression에 들어있는 expression으로부터 원소를 하나씩 만들어내는 iterator가 생성된다.
it = (len(x) for x in open('my_file.txt'))
print(it)
>>>
<generator object <genexpr> at 0x108993dd0>
Generator expression을 사용하면 메모리적인 걱정을 할 필요가 없다.
print(next(it))
print(next(it))
>>>
100
57
Generator expression의 또다른 장점은 두 generator expression을 합성할 수 있다는 것이다.
roots = ((x, x**0.5) for x in it)
print(next(roots))
>>>
(15, 3.872983346207417)
이 iterator를 advance 시키면 내부의 iterator도 advance 되면서 차례대로 loop가 실행된다.
따라서, 아주 큰 input stream에 대해서 여러 기능을 합성해 적용해야 하면, generator식을 선택하라.
Item 33: yield from을 사용해 여러 generator를 compose 해라
generator는 매우 유용하기 때문에 layers of generators를 함께 묶어서 쓰는 경우도 많다.
예를 들어, generator를 사용해 화면의 이미지를 움직이게 하는 graphical program이 있다고 하자. 원하는 visual effect를 얻기 위해서는 먼저 image가 빨리 움직여야 하고, 잠시 멈춘 후, 다시 이미지가 느리게 이동해야 한다.
def move(period, speed):
for _ in range(period):
yield speed
def pause(delay):
for _ in range(delay):
yield 0
위는 animation의 각 부분에서 필요한 이동 delta를 만들 때 사용하는 두 가지 generator 코드이다.
final animation을 만들기 위해서는, move와 pause를 combine해서 onscreen delta의 single sequence를 만들어내야 한다.
animation의 각 단계마다 generator를 호출해서 차례로 iterate하고 각 iteration에 나오는 delta를 순서대로 내보내는 방식이다.
def animate():
for delta in move(4, 5.0):
yield delta
for delta in pause(3):
yield delta
for delta in move(2, 3.0):
yield delta
이렇게 만든 화면상 delta를 single animation에서 만들어진 것처럼 화면에 표시한다.
def render(delta):
print(f'Delta: {delta:.1f}')
# Move the images onscreen
...
def run(func):
for delta in func():
render(delta)
run(animate)
>>>
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 0.0
Delta: 0.0
Delta: 0.0
Delta: 3.0
Delta: 3.0
위 코드는 animate가 너무 반복적이어서 가독성이 떨어진다. 이를 해결하기 위해선 yield from expression을 사용하는 게 바람직하다. 이는 고급 generator 기능으로, 부모 generator에게 control을 반환하기 이전에 nested generator 로부터의 모든 값을 내보낸다.
def animate_composed():
yield from move(4, 5.0)
yield from pause(3)
yield from move(2, 3.0)
run(animate_composed)
>>>
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 5.0
Delta: 0.0
Delta: 0.0
Delta: 0.0
Delta: 3.0
Delta: 3.0
훨씬 명확하고 직관적인 코드가 됐다. yield from은 python interpreter가 우리 대신 nested for loop와 yield expression을 처리하도록 하며, 성능도 더욱 좋아진다.
다음은 timeit built-in module을 통해 성능이 개선되는지 살펴본 코드이다.
import timeit
def child():
for i in range(1_000_000):
yield i
def slow():
for i in child():
yield i
def fast():
yield from child()
baseline = timeit.timeit(
stmt='for _ in slow(): pass',
globals=globals(),
number=50)
print(f'Manual nesting {baseline:.2f}s')
comparison = timeit.timeit(
stmt='for _ in fast(): pass',
globals=globals(),
number=50)
print(f'Composed nesting {comparison:.2f}s')
reduction = -(comparison - baseline) / baseline
print(f'{reduction:.1%} less time')
>>>
Manual nesting 4.02s
Composed nesting 3.47s
13.5% less time
따라서 필자는 generator를 compose할 때는, 가급적 yield from을 사용하길 권장한다.
Item 34: send를 이용해 generator에 data를 주입하지 말 것
software-defined radio를 이용해 신호를 보내는 경우, 다음 코드는 주어진 points 에 따른 사인파(sine wave)를 생성한다.
import math
def wave(amplitude, steps):
step_size = 2 * math.pi / steps
for step in range(steps):
radians = step * step_size
fraction = math.sin(radians)
output = amplitude * fraction
yield output
이제 wave generator를 iterate하면서 진폭이 고정된 wave signal을 보낼 수 있다.
def transmit(output):
if output is None:
print(f'Output is None')
else:
print(f'Output: {output:>5.1f}')
def run(it):
for output in it:
transmit(output)
run(wave(3.0, 8))
>>>
Output: 0.0
Output: 2.1
Output: 3.0
Output: 2.1
Output: 0.0
Output: -2.1
Output: -3.0
Output: -2.1
Basic한 waveform을 생성할 때는 위의 코드가 잘 동작하지만, 별도의 input를 이용해 amplitude를 계속 변경해야 하는 경우에는 쓸 수가 없다.
파이썬 generator는 send 메소드를 지원하는데, 이는 yield expression을 two-way channel이 되게 해준다. send 메소드를 이용하면 입력을 generator에 streaming하는 동시에 출력을 내보낼 수도 있다. 일반적으로 generator를 iterating 할 때, yield expression의 값은 None이다.
def my_generator():
received = yield 1
print(f'received = {received}')
it = iter(my_generator())
output = next(it) # Get first generator output
print(f'output = {output}')
try:
next(it) # Run generator until it exits
except StopIteration:
pass
>>>
output = 1
received = None
하지만 for loop나 next로 generator를 iterate하지 않고 send를 쓰면 generator가 다시 시작될 때 yield가 send에 전달된 파라미터 값을 반환한다. 하지만 막 시작한 generator는 아직 yield에 다다르지 못했으므로 최초로 send를 호출할 때 parameter로 전달할 수 있는 값은 None뿐이다.
it = iter(my_generator())
output = it.send(None) # Get first generator output
print(f'output = {output}')
try:
it.send('hello!') # Send value into the generator
except StopIteration:
pass
>>>
output = 1
received = hello!
위의 동작을 이용하면 진폭을 변조할 수 있다. 먼저 yield expression이 반환한 진폭 값을 amplitude에 저장하고, 다음 yield 출력 시 이 aplitude를 활용하도록 wave generator를 변경해야 한다.
def wave_modulating(steps):
step_size = 2 * math.pi / steps
amplitude = yield # Receive initial amplitude
for step in range(steps):
radians = step * step_size
fraction = math.sin(radians)
output = amplitude * fraction
amplitude = yield output # Receive next amplitude
그 후 run 함수를 변경해서 매 iteration마다 변조에 사용할 amplitude를 wave_modulating generator에게 streaming 하도록 한다.
def run_modulating(it):
amplitudes = [
None, 7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10
]
for amplitude in amplitudes:
output = it.send(amplitude)
transmit(output)
run_modulating(wave_modulating(12))
>>>
Output is None
Output: 0.0
Output: 3.5
Output: 6.1
Output: 2.0
Output: 1.7
Output: 1.0
Output: 0.0
Output: -5.0
Output: -8.7
Output: -10.0
Output: -8.7
Output: -5.0
위 코드는 input 신호에 따라 출력 신호의 진폭을 변동시키면서 잘 동작한다.
하지만 이 코드의 문제는 이해하기 어렵다는 것이다. 대입문 오른쪽에 yield를 사용하는 것은 직관적이지 못하고, generator의 고급 기능을 잘 모르면 send와 yield 사이의 상호작용을 알아보기 어렵다.
이제 프로그램이 조금 더 복잡해져서, 단순한 사인파를 carrier signal로 사용하는 대신, 여러 신호의 sequence로 이루어진 complex waveform을 사용해야 한다고 하자. 이를 구현하는 하나의 방법으로 yield from을 이용해 여러 generator를 compose하는 것이 있다.
다음은 진폭이 고정된 경우 yield from을 사용한 코드이다.
def complex_wave():
yield from wave(7.0, 3)
yield from wave(2.0, 4)
yield from wave(10.0, 5)
run(complex_wave())
>>>
Output: 0.0
Output: 6.1
Output: -6.1
Output: 0.0
Output: 2.0
Output: 0.0
Output: -2.0
Output: 0.0
Output: 9.5
Output: 5.9
Output: -5.9
Output: -9.5
잘 동작하므로, send 메소드를 사용해도 잘 작동할 것 같다.
def complex_wave_modulating():
yield from wave_modulating(3)
yield from wave_modulating(4)
yield from wave_modulating(5)
run_modulating(complex_wave_modulating())
>>>
Output is None
Output: 0.0
Output: 6.1
Output: -6.1
Output is None
Output: 0.0
Output: 2.0
Output: 0.0
Output: -10.0
Output is None
Output: 0.0
Output: 9.5
Output: 5.9
잘 동작하는 것처럼 보이나, 중간중간 None이 보인다. Nested generator에 대한 yield from expression이 끝날 때마다 다음 yield from expression이 실행된다. 각 nested generator는 send 메소드 호출로부터 값을 받기 위해 아무런 value도 아직 만들어내지 않는 yield expression으로 시작하고, 이로 인해 부모 generator가 자식 generator를 옮겨갈 때마다 None이 출력된다.
복잡한 방법으로 해결할 방안이 있긴 하지만, 필자는 더 단순한 접근 방법을 권한다.
가장 쉬운 방법은 wave 함수에 iterator를 전달하는 것이다. 이전 generator를 다음 generator에 입력으로 연결하면, 입력과 출력이 차례대로 처리될 수 있다.
def wave_cascading(amplitude_it, steps):
step_size = 2 * math.pi / steps
for step in range(steps):
radians = step * step_size
fraction = math.sin(radians)
amplitude = next(amplitude_it) # Get next input
output = amplitude * fraction
yield output
Compose에 사용할 여러 generator 함수에 같은 iterator를 넘길 수도 있다. Iterator는 상태가 있으므로, nested generator는 앞에 있는 generator가 처리를 끝낸 시점부터 데이터를 가져와 처리한다.
def complex_wave_cascading(amplitude_it):
yield from wave_cascading(amplitude_it, 3)
yield from wave_cascading(amplitude_it, 4)
yield from wave_cascading(amplitude_it, 5)
def run_cascading():
amplitudes = [7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10]
it = complex_wave_cascading(iter(amplitudes))
for amplitude in amplitudes:
output = next(it)
transmit(output)
run_cascading()
>>>
Output: 0.0
Output: 6.1
Output: -6.1
Output: 0.0
Output: 2.0
Output: 0.0
Output: -2.0
Output: 0.0
Output: 9.5
Output: 5.9
Output: -5.9
Output: -9.5
위 접근 방법이 좋은 이유는 아무 데서나 iterator를 가져올 수 있고, iterator가 완전히 동적인 경우에도 동작한다는 것이다. 다만 위의 코드는 thread-safe 하지는 않다.
Item 35: generator 내에서 throw로 상태 변화를 하지 말 것
generator의 고급 기능으로, generator 내에서 exception을 다시 던질 수 있는 throw 메서드가 있다. 동작 방식은, 어떤 generator에 대해 throw가 호출되면 이 generator는 값을 내놓은 yield로부터 평소처럼 generator 실행을 이어나가는 대신 throw가 제공한 exceptiond을 다시 던진다.
class MyError(Exception):
pass
def my_generator():
yield 1
yield 2
yield 3
it = my_generator()
print(next(it)) # Yield 1
print(next(it)) # Yield 2
print(it.throw(MyError('test error')))
>>>
1
2
Traceback ...
MyError: test error
throw를 호출해서 generator에 예외를 주입해도, generator는 try/except 을 사용해 마지막으로 실행된 yield문을 둘러싸서 이 예외를 잡을 수 있다.
def my_generator():
yield 1
try:
yield 2
except MyError:
print('Got MyError!')
else:
yield 3
yield 4
it = my_generator()
print(next(it)) # Yield 1
print(next(it)) # Yield 2
print(it.throw(MyError('test error')))
>>>
1
2
Got MyError!
4
위 기능은 generator와 generator를 호출하는 쪽 사이의 two-way communication channel을 제공한다.
예를 들어, sporadic하게 시간을 reset할 수 있는 timer가 있는 프로그램을 짠다고 하자. 다음은 throw 메서드에 의존하는 generator를 통해 타이머를 구현한 코드다.
class Reset(Exception):
pass
def timer(period):
current = period
while current:
current -= 1
try:
yield current
except Reset:
current = period
yield expression에서 Reset 예외가 발생할 때마다 period로 재설정된다.
def check_for_reset():
# Poll for external event
...
def announce(remaining):
print(f'{remaining} ticks remaining')
def run():
it = timer(4)
while True:
try:
if check_for_reset():
current = it.throw(Reset())
else:
current = next(it)
except StopIteration:
break
else:
announce(current)
run()
>>>
3 ticks remaining
2 ticks remaining
1 ticks remaining
3 ticks remaining
2 ticks remaining
3 ticks remaining
2 ticks remaining
1 ticks remaining
0 ticks remaining
위 코드는 잘 동작하지만 가독성이 너무 떨어지, 어렵고 산만하다.
더 단순한 접근 방법은, iterable container object를 사용해 상태가 있는 closure를 정의하는 것이다.
class Timer:
def __init__(self, period):
self.current = period
self.period = period
def reset(self):
self.current = self.period
def __iter__(self):
while self.current:
self.current -= 1
yield self.current
이렇게 하면, run 메서드에서 for를 사용해 훨씬 단순하게 iteration을 할 수 있어 가독성이 좋아진다.
def run():
timer = Timer(4)
for current in timer:
if check_for_reset():
timer.reset()
announce(current)
run()
>>>
3 ticks remaining
2 ticks remaining
1 ticks remaining
3 ticks remaining
2 ticks remaining
3 ticks remaining
2 ticks remaining
1 ticks remaining
0 ticks remaining
throw를 사용하는 경우보다 훨씬 이해하기 쉽다. 따라서 필자는 generator와 예외를 섞어야하는 작업이 있다면, iterable class 사용을 권한다.
Item 36: Iterator나 generator를 다룰 때는 itertools를 사용하라
여러 iterator 연결하기
1. chain
여러 iterator를 하나의 순차적인 iterator로 합치고 싶을 때
it = itertools.chain([1, 2, 3], [4, 5, 6])
print(list(it))
>>>
[1, 2, 3, 4, 5, 6]
2. repeat
한 값을 반복해서 내놓고 싶을 때 사용. 두 번째 인자로 최대 횟수 지정.
it = itertools.repeat('hello', 3)
print(list(it))
>>>
['hello', 'hello', 'hello']
3. cycle
어떤 iterator가 내놓는 원소들을 계속 반복하고 싶을 때
it = itertools.cycle([1, 2])
result = [next(it) for _ in range (10)]
print(result)
>>>
[1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
4. tee
한 iterator를 병렬적으로 두 번째 paramter로 지정된 개수의 iterator로 만들고 싶을 때 사용.
이 함수로 만들어진 iterator를 소비하는 속도가 같지 않으면, 메모리 사용량이 늘어남(처리 덜 된 iterator의 원소를 큐에 담아둬야 하므로)
it1, it2, it3 = itertools.tee(['first', 'second'], 3)
print(list(it1))
print(list(it2))
print(list(it3))
>>>
['first', 'second']
['first', 'second']
['first', 'second']
5. zip_longest
zip 내장함수를 좀 수정한 것으로, 여러 iterator중 짧은 쪽 iterator의 원소를 다 사용한 경우 fillvalue로 지정한 값을 채워 넣어줌.(default는 None)
keys = ['one', 'two', 'three']
values = [1, 2]
normal = list(zip(keys, values))
print('zip: ', normal)
it = itertools.zip_longest(keys, values, fillvalue='nope')
longest = list(it)
print('zip_longest:', longest)
>>>
zip: [('one', 1), ('two', 2)]
zip_longest: [('one', 1), ('two', 2), ('three', 'nope')]
iterator에서 원소 거르기
1. islice
iterator를 복사하지 않으면서 원소 index를 이용해 slicing하고 싶을 때.
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
first_five = itertools.islice(values, 5)
print('First five: ', list(first_five))
middle_odds = itertools.islice(values, 2, 8, 2)
print('Middle odds:', list(middle_odds))
>>>
First five: [1, 2, 3, 4, 5]
Middle odds: [3, 5, 7]
2. takewhile
iterator에서 주어진 predicate가 False를 반환하는 첫 원소가 나타날 때까지 원소를 돌려줌.
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
less_than_seven = lambda x: x < 7
it = itertools.takewhile(less_than_seven, values)
print(list(it))
>>>
[1, 2, 3, 4, 5, 6]
3. dropwhile
takewhile의 반대. 즉, predicate이 False를 반환하는 첫 번째 원소를 찾을 때까지 iterator의 원소를 건너뜀.
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
less_than_seven = lambda x: x < 7
it = itertools.dropwhile(less_than_seven, values)
print(list(it))
>>>
[7, 8, 9, 10]
4. filterfalse
filter 내장 함수의 반대. 즉, 주어진 iterator에서 predicate이 False를 반환하는 동안 모든 원소를 돌려줌.
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = lambda x: x % 2 == 0
filter_result = filter(evens, values)
print('Filter: ', list(filter_result))
filter_false_result = itertools.filterfalse(evens, values)
print('Filter false:', list(filter_false_result))
>>>
Filter: [2, 4, 6, 8, 10]
Filter false: [1, 3, 5, 7, 9]
iterator에서 원소의 조합 만들어내기
1. accumulate
parameter를 두 개 받는 함수를 반복 적용하면서 iterator 원소를 값 하나로 줄여줌.
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
sum_reduce = itertools.accumulate(values)
print('Sum: ', list(sum_reduce))
def sum_modulo_20(first, second):
output = first + second
return output % 20
modulo_reduce = itertools.accumulate(values, sum_modulo_20)
print('Modulo:', list(modulo_reduce))
>>>
Sum: [1, 3, 6, 10, 15, 21, 28, 36, 45, 55]
Modulo: [1, 3, 6, 10, 15, 1, 8, 16, 5, 15]
2. product
하나 이상의 iterator에 들어 있는 아이템들의 cartesian product를 반환. Deepy nested list comprehension을 사용하는 것보다 훨씬 좋은 방법.
single = itertools.product([1, 2], repeat=2)
print('Single: ', list(single))
multiple = itertools.product([1, 2], ['a', 'b'])
print('Multiple:', list(multiple))
>>>
Single: [(1, 1), (1, 2), (2, 1), (2, 2)]
Multiple: [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]
3. permutations
iterator가 내놓는 원소들로부터 만들어낸 길이 N인 순열을 돌려줌.
it = itertools.permutations([1, 2, 3, 4], 2)
print(list(it))
>>>
[(1, 2),
(1, 3),
(1, 4),
(2, 1),
(2, 3),
(2, 4),
(3, 1),
(3, 2),
(3, 4),
(4, 1),
(4, 2),
(4, 3)]
4. combinations
iterator가 내놓는 원소들로부터 만들어낸 길이 N인 조합을 돌려줌.
it = itertools.combinations([1, 2, 3, 4], 2)
print(list(it))
>>>
[(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)]
5. combinations_with_replacement
combinations와 같지만 원소의 반복을 허용.(중복 조합)
it = itertools.combinations_with_replacement([1, 2, 3, 4], 2)
print(list(it))
>>>
[(1, 1),
(1, 2),
(1, 3),
(1, 4),
(2, 2),
(2, 3),
(2, 4),
(3, 3),
(3, 4),
(4, 4)]
/ /문제제기 및 피드백 언제든지 감사히 받겠습니다.
'Computer Science > Effective Python' 카테고리의 다른 글
Chapter 5. Classes and Interfaces (0) | 2023.01.13 |
---|---|
Chapter 3. Functions (0) | 2023.01.07 |
Chapter 2. Lists and Dictionaries (0) | 2023.01.06 |
Chapter 1. Pythonic Thinking (0) | 2023.01.03 |