파이썬 코딩의 기술 - 34

JinWooHyun·2021년 7월 8일
0

send로 제너레이터에 데이터를 주입하지 말라

yield 식을 사용하면 제너레이터 함수가 간단하게 이터레이션이 가능한 출력 값을 만들어낼 수 있다. 하지만 이렇게 만들어내는 채널은 단방향이다. 제너레이터가 데이터를 내보내면서 다른 데이터를 받아들일 때 직접 쓸 수 있는 방법이 없는 것처럼 보인다. 하지만 이런 양방향 통신이 있다면 많은 경우에 도움이 될 것이다.

예를 들어 소프트웨어 라디오를 사용해 신호를 내보낸다고 하자.

import math

def wave(amplitude, steps):
    step_size = 2 * math.pi / steps # 2라디안/단계 수
    for step in range(steps):
    	radians = step * step_size
        fraction = math.sin(radians)
        output = amplitude * fraction
        yield output

wave 제너레이터를 이터레이션하면서 진폭이 고정된 파형 신호를 송신할 수 있다.

def transmit(output):
    if output is None:
    	print(f'출력: None')
    else:
    	print(f'출력: {output:>5.1f}')

def run(it):
    for output in it:
    	transmit(output)
        
run(wave(3.0, 8))

>>>
출력: 0.0
출력: 2.1
출력: 3.0
출력: 2.1
출력: 0.0
출력: -2.0
출력: -3.0
출력: -2.1

기본 파형을 생성하는 한 이 코드는 잘 작동한다. 하지만 별도의 입력을 사용해 진폭을 지속적으로 변경해야 한다면 이 코드는 쓸모가 없다. 제너레이터를 이터레이션할 때마다 진폭을 변조할 수 있는 방법이 필요하다.

파이썬 제너레이터는 send 메서드를 사용하면 입력을 제너레이터에 스트리밍하는 동시에 출력을 내보낼 수 있다. 일반적으로 제너레이터를 이터레이션할 때 yield 식이 반환하는 값은 None 이다.

def my_generator():
    received = yield 1
    print(f'받은값 = {received}')
    
it = iter(my_generator())
output = next(it) # 첫 번째 제너레이터의 출력을 얻음 (send 실행 (n=1))
print(f'출력값 = {output}')

try:
    next(it) # 종료될 때까지 제너레이터를 실행한다. (send 실행 (n>1))
except StopIteration:
    pass
    
>>>
출력값 = 1
받은값 = None

for 루프나 next 내장 함수로 제너레이터를 이터레이션하지 않고 send 메서드를 호출하면, 제너레이터가 재개(resume)될 때 yieldsend에 전달된 파라미터 값을 반환한다. 하지만 위 제너레이터는 아직 yield식에 도달하지 못했기 때문에 최초로 send를 호출할 때 인자로 전달할 수 있는 유일한 값은 None뿐이다.

위의 경우 첫번째 next(it) 호출에서 None값이 전달되며, 두번째 next(it)이 실행될 때(resume) received = yield 1yieldNone값을 반환한다.

다른 값을 전달하면 실행 시점에 TypeError: can't send non-None value to a just-started generator 예외가 발생

it = iter(my_generator())
output = it.send(None) # 첫 번째 제너레이터의 출력을 얻음
print(f'출력값 : {output}')

try:
    it.send('안녕!')
except StopIteration:
    pass

>>>
출력값 = 1
받은값 = 안녕!

이런 동작을 활용해 입력 시그널을 바탕으로 사인 파의 진폭을 변조할 수 있다.

def wave_modulating(steps):
    step_size = 2 * math.pi / steps
    amplitude = yield # 초기 진폭을 입력 받음
    for step in range(steps):
    	radians = step * step_size
        fraction = math.sin(radians)
        output = amplitude * fraction
        amplitude = yield output # 다음 진폭을 입력 받음

그 후 run 함수를 변경하여 매 이터레이션마다 변조에 사용할 진폭을 wave_modulating 제너레이터에게 스트리밍하도록 만든다. 아직 제너레이터가 yield 식에 도달하지 못했으므로 send에 보내는 첫 번째 입력은 None이어야 한다.

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_modulatin(wave_modulating(12))

>>>
출력: None
출력: 0.0
출력: 3.5
출력: 6.1
출력: 2.0
...
출력: -10.0
출력: -8.7
출력: -5.0

제너레이터가 첫 번째 yield 식에 도달할 때까지는 amplitude 값을 받지 못하므로, 첫 번째 출력은 None이다.

이 코드의 문제점은 코드를 처음 봤을 때 이해하기 어렵다는 것이다. 대입문의 오른쪽에 yield를 사용하는 것은 직관적이지 않다. 그리고 제너레이터 고급 기능을 잘 모를 경우에는 sendyield 사이의 연결을 알아보기 어렵다.

이제 여러 신호의 시퀀스로 이뤄진 복잡한 파형을 사용해야 한다면, 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())

yield from 식이 단순한 경우를 잘 처리하므로, 이 제너레이터에 send 메서드를 사용해도 잘 작동할 것으로 예상할 수 있다. wave_modulating 제너레이터에 대한 여러 호출을 yield from으로 합성해보면

def complex_wave_modulating():
    yield from wave_modulating(3)
    yield from wave_modulating(4)
    yield from wave_modulating(5)
    
run_modulating(complex_wave_modulating())

>>>
출력: None
...
출력: None
...
출력: None
...

이 코드는 어느 정도 잘 작동한다. 하지만 출력에 None이 여럿 보인다.

내포된 제너레이터에 대한 yield from 식이 끝날 때마다 다음 yield from 식이 실행된다. 각각의 내포된 제너레이터는 send 메서드 호출로부터 값을 받기 위해 아무런 값도 만들어내지 않는 단순한 yield 식으로 시작한다. 이로 인해 부모 제너레이터가 자식 제너레이터를 옮겨갈 때마다 None이 출력된다.

이는 yield fromsend를 따로 사용할 때는 제대로 작용하던 특성이 두 기능을 함께 사용할 때는 깨지기 때문이다. 이 문제를 해결하기 위해 run_modulating 함수의 복잡도를 증가시켜 우회할 수는 있겠지만, send 메서드를 아예 쓰지 않고 더 단순한 접근 방법을 택할 것을 권한다.

가장 쉬운 해결책은 wave 함수에 이터레이터를 전달하는 것이다. 이 이터레이터는 자신에 대해 next 내장 함수가 호출될 때마다 입력으로 받은 진폭을 하나씩 돌려준다. 이런 식으로 이전 제너레이터를 다음 제너레이터의 입력으로 연쇄시켜 연결하면 입력과 출력이 차례로 처리되게 만들 수 있다.

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) # 다음 입력 받기
        output = amplitude * fraction
        yield output

합성에 사용할 여러 제너레이터 함수에 같은 이터레이터를 넘길 수도 있다. 이터레이터는 상태가 있기 때문에 내포된 각각의 제너레이터는 앞에 있는 제너레이터가 처리를 끝낸 시점부터 데이터를 가져와 처리한다.

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()

다만 이 코드는 입력 제너레이터가 완전이 스레드 안정(thread-safe)하다고 가정한다는 단점이 있다. 하지만 제너레이터가 항상 스레드 안전하지는 않다. 따라서 스레드 경계를 넘나들면서 제너레이터를 사용해야 한다면 async 함수가 더 나은 해법일 수 있다.

기억해야 할 내용

  • send 메서드를 사용해 데이터를 제너레이터에 주입할 수 있다. 제너레이터는 send로 주입된 값을 yield 식이 반환하는 값을 통해 받으며, 이 값을 변수에 저장해 활용할 수 있다.
  • sendyield from 식을 함께 사용하면 제너레이터의 출력에 None이 중간중간 나타나는 결과를 얻을 수 있다.
  • 합성할 제너레이터들의 입력으로 이터레이터를 전달하는 방식이 send를 사용하는 방식보다 더 낫다.
profile
Unicorn Developer

0개의 댓글