콘텐츠로 건너뛰기
Home » 파이썬의 반복문과 iterable에 대해

파이썬의 반복문과 iterable에 대해

리스트, 튜플, 문자열, 사전의 공통점은? 모두 for ... in 문에 사용할 수 있다는 점이다. 리스트는 for 문을 통해서 개별 원소에 대한 반복 작업을 할 수 있는데, 튜플과 문자열 역시 이와 똑같은 동작을 수행하며 사전의 경우에는 사전 내의 각 키에 대해서 순회하는 기능을 제공한다. 파이썬에서는 이와 같이 for ... in 구문을 통해서 반복이 가능한 타입들을 묶어서 iterable이라고 부르는데, 이는 파이썬의 기본 개념에서 매우 중요한 위치를 차지한다.

for 문의 백스테이지에 대해

for 문은 일반적인 언어에서의 대표적인 반복문이다. C언어에서는 다음과 같이 쓰인다. 아래 코드는 0~9까지의 정수를 출력하는 예이다.

int i;
for(i=0;i<9;i++){
    printf("%d\n", i);
}

그런데 잘 살펴보면 이때의 for 문은 사실 while 문의 변형에 가깝다. 왜냐하면 for(i=0;i<9;i++) 이라는 구문 자체가 ” i의 초기값은 0인데 i가 9보다 작은 동안 1씩 증가시켜가면서 반복”한다는 의미이기 때문이다.
반면 파이썬에서는 이러한 조건을 만족하는 동안 반복하는 반복문은 오직 while 문만 존재한다. 파이썬의 for 문은 조금 특별한데, 위에서 언급한 리스트, 튜플, 문자열, 사전등의 타입에 대해서 “각 원소에 대해 서 반복한다”라는 단순한 규칙만으로 동작한다. 따라서 0~9까지를 반복하는 위 코드는 파이썬에서는 다음과 같이 쓰인다.

for i in range(9):
  print(i)
## 혹은
for i in [0,1,2,3,4,5,6,7,8,9]:
  print(i)

파이썬의 for 문은 그렇다면 실제로는 어떻게 동작하는 것일까?

for 문과 반복자

파이썬에서는 “반복자”라는 특별한 성질을 가진 타입들이 있다.1 반복자는 영어로 iterator라고 하는데, 파이썬에서 반복자는 기술적으로는 단순히 __next__() 라는 메소드를 가지고 있는 객체를 말한다. (사실 반복자 그 자체는 제너레이터의 일종으로 볼 수도 있고, 기술적으로 제너레이터이다라고 해도 틀린표현은 아니다.) 보통 반복자들은 일련의 연속적인 값 혹은 특정한 규칙을 가진 수열을 계산하는 값을 내부에 가지고 있고, 이를 __next__() 가 호출될 때마다 내부적으로 관리하는 수열의 다음항을 리턴할 수 있는 객체가 된다.
참고로 내장함수의 도움말을 살펴보면 다음과 같다. 여기서는 iterator 라는 표현을 직접적으로 쓰고 있으며, 반복자로부터 다음번 아이템 얻어 리턴한다고 쓰여있다.

In [1]: next?
Docstring:
next(iterator[, default])
Return the next item from the iterator. If default is given and the iterator
is exhausted, it is returned instead of raising StopIteration.
Type: builtin_function_or_method

참고로 반복자가 더 이상 만들어 낼 다음번 항이 없는 경우에는 StopIteration 예외를 일으키고, 이는 곧 순회(iteration)의 끝을 의미한다.

반복가능 (iterable) 프로토콜

파이썬에서 반복가능하다는 말은 결국 for ... in 문에 적용가능하다는 말과 동치이고, 기술적으로는 이터레이터(iterator, 반복자)를 가지고 있는 객체라는 의미이다. 반복가능한 타입의 객체로부터 반복자를 얻어내는 내장함수는 iter() 함수이다. (뒤에서 살펴보겠지만, next(x)x.__next__()를 호출하듯이, iter(x)x.__iter__()를 호출한다. 사실 이것이 반복가능 프로토콜의 핵심이다. )역시 반복 가능한 객체에 대한 힌트를 얻기 위해서 이 함수의 도움말을 살펴보도록 하자.

In [4]: iter?
Docstring:
iter(iterable) -> iterator
iter(callable, sentinel) -> iterator
Get an iterator from an object. In the first form, the argument must
supply its own iterator, or be a sequence.
In the second form, the callable is called until it returns the sentinel.
Type: builtin_function_or_method

우리는 여기서 많은 것을 볼 수 있다.

  1. iter 함수는 어떤 객체로부터 반복자를 얻어서 리턴한다.
  2. 반복가능한 객체는 반복자를 이미 가지고 있거나, 아니면 그 스스로가 연속열이다.
  3. 반복자 뿐만 아니라 특정한 종결값을 리턴할 때까지 계속 어떠한 값을 리턴할 수 있는 함수도 반복가능으로 취급한다.

지금까지의 힌트를 취합하면 다음과 같은 사실들을 알아내었다고 정리할 수 있다.

  1. iter() 함수를 이용하면 iterable한 객체의 반복자를 얻을 수 있다.
  2. next()  함수를 이용하면 반복자의 매 항을 얻을 수 있다.
  3. next() 함수를 이용했을 때 반복자가 더 이상 내 줄 값이 없으면 StopIteration 예외를 일으킨다.
  4. 그리고 range() 함수가 리턴하는 객체는 for ... in 문에 사용할 수 있으니, iterable 하다.

for … in 루프의 구조

그러면 이러한 사실로부터 우리는 파이썬의 for ... in 문을 while 문으로 재구성해볼 수 있다.

for i in range(3):
  print(3)

위 반복문은 우리가 알아낸 사실에 근거하여 다음과 같이 쓸 수 있다.

## range(3)은 iterable 한 객체를 리턴하고
## iter() 함수를 이용하면 그로부터 반복자를 얻을 수 있다.
x = iter(range(3))
while True:
  try:
    i = next(x) ## 반복자로부터 다음 항을 얻는다.
    print(i)
  except StopIteration: ## 반복자가 고갈되면 StopIteration이 뜬다.
    print("iteratrion finished.")
    break
# 0
# 1
# 2
# iteration finished.

그리고 파이썬의 for ... in 문은 실제로 이렇게 돌아간다. 임의의 리스트나 문자열, 튜플 등에 대해서 아래와 같이 반복자를 직접 얻어서 next() 함수를 계속 호출해볼 수 있다.

In [8]: x = iter([1,2,3,4])
In [9]: x
Out[9]: <list_iterator at 0x222958f2b38>
In [10]: next(x)
Out[10]: 1
In [11]: next(x)
Out[11]: 2
In [12]: next(x)
Out[12]: 3
In [13]: next(x)
Out[13]: 4
In [14]: next(x)
---------------------------------------------------------------------------
StopIteration Traceback (most recent call last)
<ipython-input-14-5e4e57af3a97> in <module>()
----> 1 next(x)
StopIteration:

반복자

그렇다면 이번에는 반복자에 대해서 좀 더 알아보도록 하자. 반복자는 앞서 말했듯이 __next__() 메소드를 가지고 있는 객체라고 했다. 내장함수 next()는 모종의 약속에 의해서 인자로 받는 객체의 __next__() 메소드를 호출하고 그 결과를 리턴해주는 역할을 할 뿐이다.

커스텀 반복자 클래스

그렇다면 예를 들어서 피보나치 수열의 항을 순차적으로 리턴할 수 있는 반복자를 만들 수 있지 않을까?

class FibonacciSeq:
  def __init__(self):
    self.a, self.b = 0, 1
  def __next__(self):
    self.a, self.b = self.b, self.a + self.b
    ## 무한 수열이 될 수 있으니, 200보다 큰 값이 만들어지면 끝낸다.
    if self.a > 200:
      raise StopIteration
    return self.a
## 테스트
f = FibonacciSeq()
next(f)
# 1
next(f)
# 1
next(f)
# 3
next(f)
# 5
next(f)
# 8

대략 성공적이다. 하지만 이렇게 반복자를 만들더라도 iterable한 객체는 따로 존재한다. 즉 지금까지는 itrable한 객체가 있고, 여기에 iter() 함수를 통해서 반복자를 만들어서 각 항을 순회했다는 것이다. 즉 for ... in 문에서 필요한 것은 반복자 그 자체가 아닌 반복자를 생성할 수 있는 객체, 즉 iterable 한 객체이다.
하지만 방금 작성한 FibonacciSeq 클래스는 그 자체가 반복자이면서 반복가능한 객체가 될 수 있다. 왜냐하면 next()2함수와 마찬가지로 iter() 함수는 인자로 받은 객체에 대해서 __iter__() 메소드를 호출하여 반복자를 얻기 때문이다. 이 역시 모종의 약속이 미리 정해져 있는 셈이다. 어쨌든 그 스스로가 반복자의 모든 요건을 갖추고 있으므로 __iter__()는 그 자신을 리턴하는 것으로만 간단히 정의하면 된다.
따라서 다음과 같이 피보나치 생성 클래스를 수정해보자. 수정하는 김에 한계값 자체는 생성시에 인자로 받을 수 있게끔 함께 변경한다.

class FibonacciSeq:
  def __init__(self, upto=200):
    self.limit = upto
    self.a, self.b = 0, 1
  def __iter__(self):
    return self
  def __next__(self):
    self.a, self.b = self.b, self.a + self.b
    if self.a > self.limit:
      raise StopIteration
    return self.a
## 테스트 : 이제 for ... in 문에서도 잘 작동한다.
for f in FibonacciSeq(200):
  print(f)

반복가능한 객체

내부에 __iter__(), __next__() 메소드를 가지는 객체를 만들기만 하면 이 객체는 기술적으로는 반복가능한 객체가 된다고 했다. 이를 통해서 특정한 하나의 값을 반복하는 리피터라든지, 여러 개의 연속열을 한꺼번에 순회할 수 있는 체인시퀀스 같은 도구를 만들 수도 있을 것이며, 그외에 내부 속성들을 for … in 문을 통해서 순회할 수 있는 객체도 디자인할 수 있을 것이다. 예를 들면 어떤 학생들의 시험 성적을 관리하는 코드에서 Student라는 클래스를 만들고 이 클래스 내부에 eng, math, sci 등 과목들의 점수를 저장했을 때, __next__()  메소드에서 미리 정한 순서에 따라 해당 속성값을 리턴하도록 한다면 Student 클래스의 인스턴스는 for ... in 문을 통해서 개별 과목의 점수를 순회할 수 있을 것이다.

iterable 프로토콜이 쓰이는 다른 예

내장함수 중에 sum()이라는 엄청 유명한 함수가 있다. 숫자로 된 리스트나 튜플에서 그 합계를 얻는 함수이다. 아무래도 함수 이름 자체가 하는 일을 너무나 명확하게 이야기하고 있기 때문에 이 함수의 도움말을 읽어보는 사람은 거의 없는 것 같다.  아무튼 읽어보자면, 아래와 같이 이제는 익숙한 이름을 찾을 수 있다.

In [28]: sum?
Signature: sum(iterable, start=0, /)
Docstring:
Return the sum of a 'start' value (default: 0) plus an iterable of numbers
When the iterable is empty, return the start value.
This function is intended specifically for use with numeric values and may
reject non-numeric types.
Type: builtin_function_or_method

즉 iterable한 객체는 각 항 혹은 원소가 덧셈을 지원하는3 객체인 경우에 그 합계를 구할 수 있다는 것이다. 따라서 조금 전에 만든 피보나치 수열 생성기라든지, 예로 잠깐 언급만한 Student의 경우 sum을 쓰면 개별 학생의 총점을 낼 수 있게 된다.
앞서 만든 피보나치 수열 생성기를 이용하면 400보다 작은 피보나치 수열의 항의 합계는 다음과 같이 구해진다.

sum(Fibonacci(upto=400))
# 986
# (1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377)

반복자를 만드는 좀 더 손쉬운 방법

앞서 만든 피보나치 수열 생성기는 클래스를 통해서 만들었는데, 클래스를 통해서 간단한 객체를 생성하는 것은 사실 매우 번거로운 일이다.  파이썬에서는 이러한 기능을 쉽게 구현하는 두 가지 방편을 제공하고 있다.

  1. 연속열은 그 자체로 반복 가능하다. 만약 커스텀 클래스에서 특정한 속성값들을 정해진 순서대로 순회하고 싶다면, __iter__ 메소드를 구현할 때 해당 속성들을 리스트로 묶어서 리턴하면 된다.
  2. 특정한 규칙에 의해서 수열을 만드는 경우에는 제너레이터를 쓸 수 있다.

제너레이터 – 반복자를 만드는 함수

제너레이터는 반복자를 쉽게 만드는 함수이다. 예를 들어 피보나치 수열을 다음과 같은 함수에서 만든다고 생각해보자.

def fib(n=200):
  a, b = 0, 1
  while True:
    a, b = b, a + b
    if a > n:
      raise StopIteration
    return a

이 함수는 첫 번째 항인 1을 리턴하고는 더 이상 동작하지 않는다. 왜냐하면 return 키워드는 함수내에서의 실행 흐름이 종료되었다는 것을 의미하며, 따라서 현재 스레드는 해당 함수를 빠져나오고, 함수 내에서 사용하던 로컬 스코프의 모든 값들은 파괴되기 때문이다.
따라서 함수의 범위를 벗어나지 않으면서 계속 반복할 수 있는 구조가 필요한데, 이를 파이썬에서는 yield 라는 키워드로 지원해준다. yield 키워드를 써서 피보나치 수열 생성함수를 다시 쓰면 아래와 같다.

def fib(n=200):
  a, b = 1, 1
  while a <= n:
    yield a
    a, b = b, a + b

yield a next()가 호출되었을 때 a 값을 전달해준다는 의미이다. 재밌는 점은 값을 리턴해주는 것 같은 항 뒤에 다음항을 계산하는 구문이 온다는 것이다.
제너레이터 함수가 일반함수와 다른 점은 return 키워드 대신에 yield 키워드를 쓰는 것 밖에 없다. 이렇게 yield 키워드가 본체에 쓰여진 함수를 만나면 파이썬은 이 함수가 제너레이터라고 판단하고, 해당 함수의 본체를 이용해서 반복자를 자동으로 생성한다. 그래서 실제로 이 함수가 return 하는 시점에 StopIteration 예외를 일으키는 반복자 객체를 생성하여 리턴한다.

In [37]: x = fib()
In [38]: x
Out[38]: <generator object fib at 0x0000022294F5EC50>

그리고 이 반복자 객체는 __next__()__iter__() 메소드를 모두 구현하고 있는 것으로 보인다. 따라서 for ... in 구문은 물론 sum() 과 같은 내장 함수와도 잘 동작한다.

제너레이터 함수의 특징

제너레이터 함수가 만드는 객체는 일종의 함수처럼 동작하는 동시에 next()로 하여금 값을 요청하는 측에 값을 넘겨 준 후에도 실행 흐름을 잃지 않고 마치 “일시정지”한 것처럼 보인다. 따라서 메인 루틴과 별개로 진행과 일시정지를 반복하면서 별개의 흐름을 유지하는 코루틴으로도 사용되는 경우가 많다. 실제로 단순한 역할을 담당하는 간단한 객체를 디자인하는 경우 클래스보다 코루틴을 사용하면 코드 작성량 및 룩업 절차를 간소화할 수 있는 장점도 있다.
또한 합계나 개수를 구하거나 단순히 반복문에 사용하는 용도라면 전체 시퀀스의 데이터가 필요한 것이 아니라 매 반복에 사용될 값이 순차적으로 필요할 따름이고, 이 경우라면 무식하게 전체 데이터를 메모리에 올려둘 필요가 없다. 파이썬2에서 range() 함수는 기본적으로 리스트를 생성했고, 이를 제너레이터로 대체하는 xrange() 함수가 있었다. 파이썬3로 이행하면서 xrange() 함수는 없어졌지만, range() 함수 자체가 제너레이터를 생성하는 함수로 바뀌었다.
아마 이전 파이썬에서 리스트를 사용했던 것은 for 문에서의 성능 문제인데 (매번 __next__()를 호출하는데 드는 비용이 적지 않다.) 오히려 파이썬3에서의 순회는 일반 연속열보다 제너레이터쪽이 더 빠르다.

보너스 – 제너레이터 표현식

리스트의 축약 문법에 대해서는 다들 많이 쓰고 있을 것이라고 생각한다.  그런데 여기서 대괄호 대신에 보통 괄호를 쓰면 이 리스트 축약 리터럴은 제너레이터 표현식이 된다. 제너레이터 표현식으로 쓰여진 식은 메모리상에 모든 원소가 즉시 계산되어 원소로 만들어지는 리스트 축약과는 달리 제너레이터로 만들어지며, 각 항의 계산은 필요한 시점에 lazy하게 계산된다. 예를 들어 100,000 보다 작은 모든 3 또는 5의 배수의 합을 구한다고 생각해보자.

>> print(sum([x for x in range(100_0000) if x % 3 is 0 or x % 5 is 0]))
233333166668
>> print(sum((x for x in range(100_0000) if x % 3 is 0 or x % 5 is 0)))
233333166668

두 코드의 값은 완전히 같지만,  작동하는 과정은 완전히 다르다. 첫번째 코드는 리스트 축약을 사용했고, 이 과정에서 리스트가 만들어진다. (정수 466667개를 포함한다.) 이 리스트의 크기는 대략 4메가 바이트가 안되는 수준이다. 두 번째 예제에서는 제너레이터가 하나 만들어지며, 이 과정에서 소비되는 메모리는 88바이트이다.
참고로 sum() 등의 괄호 안에 들어가는 제너레이터 표현식은 바깥에 괄호가 있는 관계로 아예 괄호를 빼 버릴 수도 있다.
이상으로 파이썬의 반복 가능 프로토콜에 대해서 알아본 내용이었다.


  1. 사실 이 개념은 “상이한 여러 타입들이 공통된 동작을 보유한다”는 의미에서 하스켈의 타입 클래스나 Objective-C/Swift의 프로토콜과 유사한 개념이다. 파이썬에서는 언어적 차원에서 이러한 프로토콜 문법을 지원하고 있지는 않지만, 많은 구현에서 프로토콜을 도입해서 의존하고 있다. 
  2. 앞에서도 언급했듯이 next() 내장함수는 객체의 __next__() 메소드를 호출한다. 이와 비슷한 동작은 내장함수 뿐만 아니라 연산자에서도 적용된다. 예를 들어 + 연산은 객체 내의 __add__(obj)를 호출하여 두 값을 더 한 새로운 객체를 리턴하는 함수로 볼 수 있다. 
  3. 사실 정수(int)와 실수(float)타입의 숫자값도 파이썬에서는 해당 타입의 인스턴스 객체이다. 따라서 1, 2와 같은 정수 값도 사실은 __add__(obj) 와 같은 메소드를 가지고 있는 개별 정수이며, 실제로 1 + 21.__add__(2) 와 같은 식으로 동작한다.