본문 바로가기

진리는어디에/Python

[Python] 제너레이터(generator)

이번 포스트에서는 파이썬의 제너레이터(generator)에 대해 살펴 보도록 한다.

강의 순서상으로 함수를 배운 뒤에 언급 되어야 하지만 다음에 나올 시퀀스 자료형에 대한 강의 전에 알아두는 것이 효과적일것 같아 순서를 조정 했다.

본 포스트를 이해하기 위해서는 파이썬 함수의 기본 문법iterator의 개념을 이해하고 있어야 한다. 강의 순서가 꼬여서 혼란할 수 있지만 앞의 개념을 모른다면 먼저 살펴 보고 오도록 하자. 직접 마주 보고 있다면 중간 중간 끼어 드는 부분들에 대해서 좀 더 효율적이고 쉽게 전달 할 수 있었을 텐데 지면을 이용하다 보니 이런 방법 밖에 없다. 미안하게 생각한다.

제너레이터(generator)란?

제너레이터(generator)를 한마디로 정의하면 "특이한 iterable 객체"라고 할 수 있다. 우리가 제너레이터를 가리킬 때, 정확하게는 함수에서 리턴 되는 객체를 말하는 것이지만 일반적으로 제너레이터를 리턴하는 함수 또한 제너레이터라고 부르기도 한다.

먼저 제너레이터의 문법적인 측면 부터 살펴 보자. 제네레이터는 일반 함수와 매우 비슷하지만 아래와 같은 차이가 있다.

# 제너레이터(generator) 예제
def foo() :
    value = 1
    yield value       # 1을 return이 아니라 yield 한다

g = foo()             # 제너레이터 함수 호출

print(type(g))        # <class 'generator'>, 정수가 아니다
  • 함수에서 return 키워드가 아닌 yield 키워드를 이용하여 반환한다
  • 반환 값이 "generator" 객체다.
    ※ 함수의 호출자는 yield 키워드를 통해 넘겨준 값이 아니라 generator 객체를 반환 받는다.
  • generator반복자(iterator)와 유사하게 동작한다.

그리고 우리가 주목해야 할 몇가지 기능적인 측면이 있다.

  • 게으른 평가(Lazy Evalueation)
    게으른 연산, 늦은 평가 등 다양한 이름으로 불리운다. 핵심은 연산의 시점을 미룰수 있는 만큼 최대한 뒤로 미룬다는 것이다. 예를 들어, 1씩 증가하는 숫자들의 집합을 만들기 위해서 리스트는 숫자 100개를 미리 계산하여 메모리에 저장하고 있어야 하지만, 제너레이터를 사용하면 필요할 때마다 1씩 증가하는 숫자를 생성할 수 있다. 보다 자세한 설명은 아래 예제를 통해서 더 알아 보도록 한다.
  • 함수 내부 로컬 변수 유지
    일반 함수는 return 문을 이용해 함수를 "종료"하게 되면 내부의 로컬 변수들이 모두 해제된다. 하지만 제너레이터 함수의 경우 yield문을 이용해 함수를 "중단"하게 되면 내부의 로컬 변수들을 유지한 상태로 다음 번 호출 때 다시 사용 할 수 있다.
  • 다양한 함수 진입점
    일반 함수는 함수를 호출 할 때마다 함수의 처음 부터 시작한다. 하지만 제너레이터는 이전에 yield로 중단 했던 지점 다음 부터 다시 시작한다.

아래 코드는 위에서 설명한 제너레이터의 특징을 설명하기 위해 작성 되었다.

foo() 함수 안에 지역 변수로 함수의 호출 횟수를 기억하기 위해 call_count를 선언하고 return문 대신 yield를 사용하고 있다. foo() 함수의 반환값이 제너레이터 객체라는 것, 반복자를 사용하는 것과 동일하게 next() 표준 함수를 통해 접근하고 있다는 것에 주목 하며 아래 예제를 살펴 보자.

def foo() :
    call_count = 0     # 함수 호출 횟수를 저장. 지역 변수
    
    call_count += 1
    print('foo ' + str(call_count))
    yield call_count
    
    call_count += 1
    print('foo ' + str(call_count))
    yield call_count
    
    call_count += 1
    print('foo ' + str(call_count))
    yield call_count
    
g = foo()

print(type(g)) # <class 'generator'>

print(next(g))
print(next(g))
print(next(g))
print(next(g)) # StopIteration
  • foo() 함수의 6, 10, 14라인에서 return 키워드 대신 yield를 이용해 call_count를 리턴하고 있다.
  • 16라인에서 foo() 함수를 호출하면 foo()함수가 실행 되는 대신 18라인에서 확인할 수 있는것 처럼 generator라는 클래스의 객체를 리턴한다.
  • 20에서 22라인에 걸쳐 반복자를 순회하듯이 generator 객체를 순회한다. 이 때 아래 그림 처럼 리턴한다고 foo()함수의 콜스택이 해제되는것이 아니라 콜스택을 유지하고 있다가 다음 반복때 종료 했던 지점 다음 부터 다시 재개하게 된다.

  • 23라인에서 next를 호출하게 되면 더 이상 실행할 것이 없으므로 StopIteration 예외를 발생 시킨다.

제너레이터(generator) 표현식

위에서 return 대신 yield를 이용해 반환하는 함수를 generator라고 했다. 하지만 함수를 만들지 않고 표현식으로 제너레이터를 만드는 문법을 "제너레이터 표현식"이라고 한다. 함수를 정의하고 yield 키워드를 사용해야만 하는 함수 형태보다 간단하게 사용할 수 있는 축약형 제너레이터라고 생각하면 된다.

def foo() :
    for i in range(0, 100, 5) :
        yield i
        
g1 = foo()

g2 = ( i for i in range(0, 100, 5) )

print(next(g2))
print(next(g2))
print(next(g2))

7라인의 ( i for i in range(0, 100, 5) )는 위의 foo()함수와 동일한 작업을 수행한다.

generator 표현식을 쓰기 위해서는 먼저 괄호로 묶여야 하며, 가장 앞에는 yield 할 변수를 적어준다. 그 뒤에 i를 어떤 식으로 리턴할 것인지에 대한 기술을 한다. 위의 표현식을 풀이하면 i라는 변수를 리턴할 것인데, 이는 0부터 100까지 5씩 증가하는 숫자다라는 의미다.

NOTE - generator 표현식은 괄호로 묶여야 한다.

조금 더 복잡한 표현식 예제를 살펴보자.

g = ( i + j for i in range(10)
            for j in range(11, 13) )
            
print(next(g)) # 0 + 11
print(next(g)) # 0 + 12
print(next(g)) # 1 + 11
print(next(g)) # 1 + 12

어려운 예제는 아니다. 다만 generator 표현식에서 단일 for문만 사용할 수 있는 것이 아니라 중복 for문도 사용가능 하다는 것을 보여주기 위해 작성한 예제다.

마치며

위 내용을 요약하면 generator는 코루틴 처럼 사용할 수 있는 iterator라고 이해하면 된다. iterable 객체이므로 for 같은 반복문에서도 사용이 가능하다. 특히 generator 표현식은 list, tuple들의 시퀀스를 만들 때 편리하게 사용 될 수 있으므로 제대로 숙지 하도록 하자.

다음 포스트 부터는 파이썬의 시퀀스 자료형들을 살펴 보도록 하겠다.

부록 1. 같이 보면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!