for 문은 어떻게 다양한 컬렉션을 순회하는 것일까? 이 절에서는 그 원리를 알아보고, 이를 응용해 개별 원소를 직접 나열하지 않고도 데이터 흐름을 순회하는 방법에 대해 알아본다. 이 절의 내용은 조금 이해하기 어려울 수 있다. 최대한 이해하기 쉽게 쓰려고 노력했지만, 혹시라도 여러 번 읽어봐도 이해가 되지 않는다면 무리해서 정신력을 소모하기보다 다음 장으로 건너뛰었다가 다음에 다시 보는 것도 좋다.

7.4.1 반복자

for 문은 시퀀스뿐 아니라 집합과 사전 등 순서가 없는 컬렉션에서도 동작한다. 이것은 반복자(iterator) 덕분이다. 반복자는 다음에 무엇을 출력할 차례인지를 기억하여 데이터를 순서대로 꺼낼 수 있도록 도와 준다.

next() 함수는 반복자를 입력받아 그 반복자가 다음에 출력해야 할 원소를 반환해주는 함수다. 어떤 데이터의 반복자를 구할 수 있다면 그 데이터의 내부 구조와 동작을 알 필요 없이 next() 함수를 여러 번 호출하는 것만으로 순회할 수 있다.

컬렉션의 반복자를 구하려면 iter() 함수에 컬렉션을 전달하여 실행하면 된다. 직접 반복자를 구한 뒤 next() 함수를 사용해 원소를 차례대로 꺼내 보자.

코드 7-42 사전의 키와 값을 나란히 순회하기

>>> it = iter([1, 2, 3])  # [1, 2, 3]의 반복자 구하기
>>> next(it)              # 반복자의 다음 원소 구하기
1

>>> next(it)              # 반복자의 다음 원소 구하기
2

>>> next(it)              # 반복자의 다음 원소 구하기
3

>>> next(it)              # 더 내어놓을 원소가 없으면 오류 발생
StopIteration

개념 정리

  • 반복 가능한 데이터: iter() 함수로 반복자를 구할 수 있는 데이터
  • 반복자: next() 함수로 값을 하나씩 꺼낼 수 있는 데이터
  • iter() 함수: 반복 가능한 데이터를 입력받아 반복자를 반환하는 함수
  • next() 함수: 반복자를 입력받아 다음 출력값을 반환하는 함수

iter() 함수와 next() 함수를 활용하면 for 문을 사용하지 않고도 컬렉션을 순회할 수 있다. 원한다면 for 문을 흉내내는 함수를 정의할 수도 있을 것이다. 이런 함수를 직접 활용할 일은 드물겠지만 개념을 알아 두면 도움이 된다.

7.4.2 생성기

반복자는 흔히 컬렉션을 순회하는 데 쓰이지만, 모든 반복자가 컬렉션을 위한 것은 아니다. 반복자는 next() 함수에 의해 값을 하나씩 내어놓기만 하면 된다. 그 데이터의 원천이 컬렉션이든 아니든 상관없다.

순서대로 내놓을 값을 함수로 정의하면, 이 함수를 이용해 반복자를 만들 수 있다. 이런 반복자를 생성기(generator)라고 한다.

yield 문

생성기를 이해하려면 yield 문을 알아야 한다. 먼저 예제를 살펴보자. next() 함수에 의해 값 'a', 'b', 'c'를 차례대로 내어 놓는 생성기는 다음과 같이 만들 수 있다.

코드 7-43 생성기 만들기

>>> def abc():
...     "a, b, c를 출력하는 생성기를 반환한다."
...     yield 'a'
...     yield 'b'
...     yield 'c'
... 
>>> abc()  # 생성기 만들기
<generator object abc at 0x7fdcd41583b8>

코드를 보면, 먼저 함수 abc()를 정의하고 있다. 이 함수는 생성기가 아니라 그냥 함수다. 생성기는 yield 문이 실행되는 함수를 실행할 때마다 새로 만들어진다. 예제 코드에서는 abc()를 실행했을 때 그 결과로 생성기 <generator object abc at 0x7fdcd41583b8>가 만들어진 것을 볼 수 있다. 생성기는 next() 함수에 의해 값을 하나씩 출력한다.

코드 7-44 next() 함수로 생성기에서 값 꺼내기

>>> abc_gen = abc()
>>> next(abc_gen)
'a'

>>> next(abc_gen)
'b'

>>> next(abc_gen)
'c'

>>> next(abc_gen)
StopIteration

위 코드에서는 함수 abc()를 호출해 생성기를 만들어 abc_gen 변수에 대입한 후, next() 함수에 이 생성기를 여러 번 입력해 값을 순서대로 내고 있다. 이 때 생성기로부터 꺼내지는 값은 생성기를 만드는 데 사용한 함수 abc()에서 yield 문에 정의된 값이다. yield 문이 세 번에 걸쳐 ‘a’, ‘b’, ‘c’를 내도록 정의되어 있기 때문에, 이 함수를 통해 만들어진 생성기는 ‘a’, ‘b’, ‘c’ 세 값을 내어 놓는다. 그 후 next() 함수로 한 번 더 값을 꺼내도록 지시하면 오류가 발생한다.

즉, yield 문은 함수에 의해 만들어진 생성기가 next()에 의해 내어놓을 값을 정의하는 문법이다. 그런데 yield 문을 포함한 함수는 일반적인 함수와 실행 방식이 다르다. 이 함수는 호출되어도 본문을 실행하지 않고 생성기를 반환할 뿐이며, 본문은 생성기가 next() 함수에 전달되었을 때 비로소 실행된다. 그리고 생성기의 본문은 한 번에 끝까지 실행되지 않는다. 본문에서 yield 문이 등장하면, 생성기는 yield 문에 정의된 값을 반환한 후 실행을 잠시 멈춘다. 나머지 본문은 next() 함수에 의해 생성기가 다시 실행되었을 때 비로소 실행된다. 이 점은 함수의 중간중간에 print() 함수를 통해 진행 상황을 출력하도록 하면 좀 더 분명하게 확인할 수 있다.

코드 7-45 생성기 함수의 본문은 next() 함수에 의해 실행된다

>>> def one_to_three():
...     """1, 2, 3을 반환하는 생성기를 반환한다."""
...     print('생성기가 1을 내어 놓습니다.')
...     yield 1
...     print('생성기가 2를 내어 놓습니다.')
...     yield 2
...     print('생성기가 3을 내어 놓습니다.')
...     yield 3
... 
>>> one_to_three_gen = one_to_three()

>>> next(one_to_three_gen)
생성기가 1을 내어 놓습니다.
1

>>> next(one_to_three_gen)
생성기가 2를 내어 놓습니다.
2

>>> next(one_to_three_gen)
생성기가 3을 내어 놓습니다.
3

이 실험에서 함수 본문의 print() 문은 생성기가 next() 함수에 의해 실행되었을 때 비로소 실행되었다.

개념 정리

  • 생성기는 반복자의 한 종류다.
  • 생성기는 yield 문이 포함된 함수를 실행하여 만들 수 있다.
  • yield 문이 포함된 함수를 실행하면 생성기가 반환된다. 그 함수의 본문은 생성기가 next() 함수에 의해 실행될 때 비로소 실행된다.
  • yield 문은 값을 내어준 후 생성기의 실행을 일시정지한다. next() 함수가 실행되면 정지했던 위치에서부터 다시 실행이 이어진다.

생성기를 어디에 활용할까?

생성기에 대해 알아보았지만, 이 괴상한 문법을 가진 까다로운 물건을 어디에 사용해야 할지는 의문이 들 것이다. 생성기는 다른 반복자와 달리 원본이 되는 데이터를 갖지 않고도 데이터를 출력할 수 있다. 즉, 데이터를 ‘생성’해 낸다. 컴퓨터의 공간 자원(메모리)은 한정되어 있으므로, 필요한 데이터를 미리 다 정의해 두는 것이 불가능한 경우가 있다. 생성기는 데이터를 필요할 때 실시간으로 생성해 내므로 이런 상황에 대처할 수 있다. 예를 들어, 1부터 무한대까지의 모든 자연수를 순서대로 출력하는 컬렉션은 코드에 직접 원소를 입력하여 작성하는 것이 불가능하다. 하지만 생성기를 이용하면 간단히 정의할 수 있다.

코드 7-46 1부터 무한대 범위의 자연수를 출력하는 생성기

>>> def one_to_infinite():
...     """1 - 무한대의 자연수를 순서대로 내는 생성기를 반환한다."""
...     n = 1            # n은 1에서 시작한다
...     while True:      # 무한 반복
...         yield n      # next()에 의해 n을 반환한다
...         n += 1       # n에 1을 더한다
... 
>>> natural_numbers = one_to_infinite()
>>> next(natural_numbers)
1

>>> next(natural_numbers)
2

>>> # ... (계속 실행 가능)

one_to_infinite() 함수의 본문에 무한 반복(while True)이 포함되어 있다. 하지만 yield 문 덕분에 값을 하나씩 출력하고 함수의 실행이 일시정지 된다. 이 특성을 이용하면 next() 함수로 1부터 무한대까지의 값을 원하는만큼 꺼낼 수 있다. 이처럼 생성기는 무한한 범위나 넓은 범위의 데이터를 다룰 때 유용하다. 또, 하나하나의 원소를 연산해 내는 비용이 클 때도 사용될 수 있다.

생성기는 next() 함수로 값을 꺼낼 수 있을 뿐 아니라, 반복자와 마찬가지로 다양한 순회 작업에 사용될 수 있다. 예를 들면 생성기를 for 문으로 순회하거나, 리스트나 튜플로 변환하는 것도 가능하다.

코드 7-47 생성기를 리스트와 튜플로 변환하기

>>> def countdown(start, end):
...     """start(포함)부터 end(비포함)까지 거꾸로 세는 생성기를 반환한다."""
...     n = start              # n은 start에서 시작한다
...     while end < n:         # n이 end에 도달하지 않은 동안 반복
...         yield n            # next()에 의해 n을 반환한다.
...         n -= 1
... 
>>> list(countdown(10, 0))     # 생성기를 리스트로 변환하기
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

>>> tuple(countdown(100, 95))  # 생성기를 튜플로 변환하기
(100, 99, 98, 97, 96)

위 코드에서 보듯 생성기는 그 자체로 리스트나 튜플로 변환할 수 있으며, for 문으로 순회할 수도 있다. 이는 파이썬이 컬렉션의 순회가 필요한 곳에서 내부적으로 반복자와 next() 함수를 이용하기 때문이다. 생성기를 이용하면 그 때 그 때 값을 계산해 내는 함수를 컬렉션처럼 사용할 수 있다.

연습문제

연습문제 7-14 무한 난수 생성기

난수(random number)란 예측할 수 없는 임의의 수를 말한다. 현실에서는 주사위를 던져 난수를 구할 수 있다. 파이썬에서는 random 모듈의 random.randint() 함수를 이용해 매개변수로 지정한 범위 사이의의 난수를 구할 수 있다. (난수와 random 모듈에 관해서는 11장에서 좀더 설명한다.) 다음은 random.randint() 함수를 사용하는 예다.

>>> import random          # random 모듈 임포트
>>> random.randint(0, 63)  # 0 이상 63 이하의 임의의 수
24

>>> random.randint(0, 63)
62

>>> random.randint(0, 63)
0

>>> [random.randint(0, 63) for _ in range(5)]  # 난수 5개 리스트
[39, 38, 43, 46, 29]

random.randint() 함수를 이용해 무한한 개수의 난수를 꺼낼 수 있는 무한 난수 생성기를 만들어 보아라.

7.4.3 생성기 식

생성기 식(generator expression)을 이용하면 좀 더 쉽게 생성기를 만들 수 있다. 생성기 식은 다른 반복 가능한 데이터를 이용해(변형해) 데이터를 생성해 낸다. 생성기 식은 리스트 조건제시법과 관계가 있으므로, 먼저 앞에서 배운 리스트 조건제시법을 다시 기억해 보자.

코드 7-48 리스트 조건제시법으로 표현한 세제곱수 리스트

>>> [e ** 3 for e in range(10)]
[0, 1, 8, 27, 64, 125, 216, 343, 512, 729]

위 코드는 0부터 10개의 세제곱수를 담은 리스트를 리스트 조건제시법으로 표현했다. 리스트 조건제시법은 실행 즉시 원본 리스트를 조건에 따라 계산하여 새 리스트로 변형한다. 이런 방식은 변형할 리스트의 양이 적다면 괜찮다. 그런데 세제곱수를 0부터 10억 개까지 담은 리스트를 만든다면 어떻게 될까?

코드 7-49 리스트 조건제시법으로 세제곱수를 10억 개 만들기

>>> [e ** 3 for e in range(1000000000)]  # 오랜 시간이 걸린다

위 코드를 대화식 셸에서 한 번 실행해보기 바란다. 아마 계산하는 데 매우 오랜 시간이 걸려서 대화식 셸이 멈춘 것처럼 보일 것이다. 컨트롤 + C 키를 누르면 명령을 취소할 수 있다.(취소하는 것이 좋다) 이처럼, 방대한 양의 리스트를 조건제시법으로 변환할 때는 연산 비용이 크다.

생성기 식은 형태와 동작이 리스트 조건제시법과 비슷하다. 하지만 각 원소가 필요할 때만 값을 계산해 낸다는 점이 다르다. 생성기 식을 표현하려면 리스트 조건제시법을 감싸는 대괄호를 괄호로 바꾸기만 하면 된다. 다음은 코드 7-49를 생성기 식으로 수정한 것이다.

코드 7-50 세제곱수를 10억 개 만들어내는 생성기

>>> (e ** 3 for e in range(1000000000))  # 대괄호가 아닌 괄호 사용
<generator object <genexpr> at 0x7fdcd41584c0>

위 코드의 생성기 식을 실행하면, 리스트를 바로 변환하지 않고 <generator object <genexpr> at 0x7fdcd41584c0>와 같은 생성기 객체만 반환한다. 이 생성기를 변수에 대입해 두고, next() 함수에 넘겨 실행시키면 값을 하나씩 내어 놓는다.

코드 7-51 생성기 식으로 만든 생성기를 next() 함수로 실행하기

>>> cube_gen = (e ** 3 for e in range(1000000000))
>>> next(cube_gen)
0

>>> next(cube_gen)
1

>>> next(cube_gen)
8

생성기 식도 결국 데이터를 하나씩 계산해야 한다는 점에서는 리스트 조건제시법과 마찬가지다. 하지만 모든 데이터를 그 자리에서 다 계산해 내는 리스트 조건제시법에 비해 그 때 그 때 필요한 데이터만 순서대로 계산하면 된다는 점이 유리하다. 데이터를 즉시 변환해 사용해야 하는 경우가 아니고, 각 데이터를 구하는 데 드는 비용이 크다면(예를 들어, 각 원소에 해당되는 데이터를 인터넷으로 다운로드해야 할 때) 리스트 조건제시법 대신 생성기 식을 사용하는 것이 유리하다. 방법도 쉬워서, 단순히 대괄호를 괄호로 바꾸기만 하면 된다.

연습문제

연습문제 7-15 실시간 데이터 입력 처리하기

사용자로부터 n개의 이름을 입력받아 리스트로 반환하는 input_names() 함수를 정의했다. 아래의 프로그램은 이 함수를 이용해 이름을 세 개 입력받은 후 그 리스트를 순회하며 각 이름에 대해 인사를 출력한다.

def input_names(n):
    """n개의 이름을 입력받아 리스트로 반환한다."""
    return [input() for _ in range(n)]

# 이름 세 개를 입력받아 각 이름마다 인사를 출력한다.
for name in input_names(3):
    print(name, '님 안녕하세요!')

이 프로그램을 실행한 결과는 다음과 같다. 보다시피 먼저 이름을 세 번 연속 입력받은 후 이어서 인사를 연달아 세 번 출력한다.

티리온
아리아
존
티리온 님 안녕하세요!
아리아 님 안녕하세요!
존 님 안녕하세요!

위 프로그램의 input_names() 함수에서 사용된 리스트 조건제시법을 생성기 식으로 수정하여, 아래의 실행 결과처럼 이름을 입력받을 때마다 인사가 출력되도록 해 보아라. 이를 통해 알 수 있는 점이 무엇인지 설명해 보아라.

티리온
티리온 님 안녕하세요!
아리아
아리아 님 안녕하세요!
존
존 님 안녕하세요!