Dev/Python

[Python 기초] - Iterator & Generator & Lazy Evaluation

sincerely10 2020. 6. 29. 20:20
반응형

이번 포스트에서는 Iterator와 Generator를 학습하겠습니다.
이 전에 Python을 알아갈 때, 앞선 개념들은 조금이라도 본적은 있지만
Iterator와 Genarator는 거의 처음 본 개념이라 생소했습니다.

포스트의 항목은 다음과 같습니다.
1. Iterator Protocol
2. Iterable
3. Iterator
    3.1 정의
    3.2 __next__
    3.3 iter & next로 사용하기
4. generator
    4.1 generator 정의
    4.2 yield 확인
    4.3 generator의 dir
    4.4 send
    4.5 generator expression(제네레이터 표현식)
5. Lazy Evaluation

1. Iterator Protocol

Iterator Protocol -> Iterable -> Iterator 순으로 확인하고 가겠습니다.
Protocol은 '규약,규칙' 이라는 뜻입니다.
따라서 Iterator Protocol은 'Iterable과 Iterator을 개념적, 실제적으로 구현하는 규칙' 이라는 뜻을 가집니다.
이 규칙에 맞게 구현한다면 Iterable 또는 Iterator 객체를 만들 수 있는 것입니다.

2. Iterable

Iterator를 확인하기 전에 Iterable을 살펴보고 가겠습니다.
사전적으로는 간단하게 '순회가 가능한(반복 가능한)' 정도로 정의할 수 있습니다.
그리고 Python에서는 순회가 가능한 모든 객체를 가리킵니다.
이 Iterable을 확인하는 것은 간단합니다.
for Loop에서 'for <변수> in' 뒤에 올 수 있는 것이 모두 Iterable한 객체 입니다.
Python이 지원하는 자료형(List, Tuple, Set, Dictionary)에서 Iterable한지 확인할 수 있습니다.

>>> L = [1, 2, 3]
>>> print(dir(L))
['__add__', '__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

L이라는 List를 선언하고, dir로 사용할 수 있는 method를 확인하였습니다.
초록색 method 목록중 세 번째줄을 보면 __iter__를 확인할 수 있습니다.
이 method가 있다면 iterable한 것입니다!

그렇지만 위 List L의 상태는 아직 Iterable 하다고만 할 수 있지 Iterator가 된 것은 아닙니다.
__init__을 통해 Iterator를 만들 수가 있습니다.

3. Iterator

3.1 정의

이제 2번 Iterable에서 확인한 '__iter__' method로 Iterator를 만들 수 있습니다.
Iterator라는 뜻을 직감 하셨겠지만, Iterable한 객체를 가리킵니다.
조금 더 정확하게 말하자면, 'Iterator 상태를 유지하며 반환할 수 있는 마지막 값까지 원소를 필요할 때 마다 하나씩 반환하는 것' 입니다.

이제 Iterator를 만들어보겠습니다.

>>> print(L.__iter__())
<list_iterator object at 0x10b60b0d0>

생각보다 간단했습니다. 아래 출력처럼 list_iterator라고 나왔습니다.

Iterator의 method도 있습니다. 바로 확인 해보겠습니다.

>>> print(dir(L.__iter__))
['__call__', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__objclass__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__self__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__text_signature__']

주목할 method는 __next__입니다. __next__를 호출하면 for문의 동작 처럼 하나씩 값을 꺼내올 수 있습니다.

3.2 __next__

이제 __next__를 사용해보겠습니다.

>>> iterator_L = L.__iter__()
>>> print(iterator_L.__next__())
1
>>> print(iterator_L.__next__())
2
>>> print(iterator_L.__next__())
3
>>> print(iterator_L.__next__())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

먼저 __iter__로 iterator로 만들었습니다.
다음은 __next__로 다음 element에 접근을 합니다.
Iterator 정의와 같이 __next__를 사용하면 return 구현 없이도 return 값을 받아옵니다.
하나씩 수행을 해보면, List 값에 맞게 하나씩 return 하고 다음 값으로 이동하는 것을 볼 수 있습니다. 
그리고 마지막에 index를 벗어난 3 이후의 출력시도 시, StopIteration Error가 발생하는 것을 볼 수 있습니다.

3.3 iter & next로 사용하기

__iter__와 __next__는 간편하게 사용할 수 있습니다.
iter는 객체의 __iter__ 함수를 호출해주고, next는 객체의 __next__ 함수를 호출하는 함수입니다.
조금전 위에서 __inter__와 __next__를 대신하여 iter와 next를 사용해보겠습니다. 

I = iter(L)
while True:
    try:
        X = next(I)
    except StopIteration:
        break
    print( X**2, end=" ")
    
1 4 9

위 코드와 같은 형태로 iterator를 사용할 수 있습니다.

4. generator

4.1 generator 정의

1~3을 통해 Iterator를 학습하였습니다. 이 'Iterator를 쉽게 생성하게 해주는 것이 Generator'의 역할입니다.
Python의 함수는 보통 return 후 종료가 되지만, generator는 yield(산출)한다는 특징이 있습니다.
그리고 1에서 언급했던 Iterator Protocol으로 생성된 것이 generator 입니다.
코드를 통해 generator를 만들어 보고, 출력도 해보겠습니다.

def generator_squares():
    for i in range(3):
        yield i ** 2


print("gen object=", end=""), print(generator_squares())

gen object=<generator object generator_squares at 0x10f3b0150>

generator_squares라는 함수를 출력했을 때, object가 generator object라고 출력이 됩니다.

4.2 yield 확인

여기서 yield는 함수의 값을 return 해주며 yield 호출 후 다시 next가 호출 될 때 까지 현재상태에서 머물다
next가 호출되면 이전 상태에서 다음 연산을 수행합니다.
즉, next가 호출되면 yield 뒤의 연산을 수행해주고 return 해줍니다.

4.3 generator의 dir

generator method 또한 비슷합니다.
확인 해보겠습니다.

>>> print("dir gen =", end=""), print(dir(generator_squares()))
dir gen =['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__next__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running', 'gi_yieldfrom', 'send', 'throw']

__iter__와 __next__가 있습니다.
이를 활용하여 출력해보겠습니다.

>>> gen = generator_squares()
>>> print(gen.__next__())
0
>>> print(gen.__next__())
1
>>> print(gen.__next__())
4
>>> print(gen.__next__())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

(gen.__next__()는 next(gen)으로 대체할 수 있습니다.)

4.4 send

generator는 함수 실행중 send 함수로 값을 전달할 수 있습니다.
코드로 확인해보겠습니다.

def generator_send():
    received_value = 0

    while True:

        received_value = yield
        print("received_value = ",end=""), print(received_value)
        yield received_value * 2

gen = generator_send()
next(gen)
print(gen.send(2))

next(gen)
print(gen.send(3))

먼저 gen이라는 함수를 생성해줍니다. (generator_send 함수에는 입력값이 없습니다)
그리고 첫 element를 접근하기 위해 next(gen)으로 넘어갑니다.
여기서 조금 전 send로 함수에 인자 값을 전달합니다.
그리고 yield가 send로 받아온 값을 received_value에 넘겨줍니다.

4.5 generator expression(제네레이터 표현식)

 generator expression은 Lazy Evaluation을 위해 사용될 수 있습니다.
Lazy Evaluation은 말 그대로 '실행을 지연시킨다'라는 뜻 입니다.
구체적인 내용은 '5. Lazy Evaluation'에서 설명하겠습니다.
전체적인 문법표현은 List Comprehension과 비슷합니다.
[ ] (대괄호) 대신 ( ) (일반괄호)를 사용합니다.

예제코드로 확인해보겠습니다.

L = [ 1,2,3]

def generate_square_from_list():
    result = ( x*x for x in L )
    print(result)
    return result

def print_iter(iter):
    for element in iter:
        print(element)

print_iter( generate_square_from_list() )

<generator object generate_square_from_list.<locals>.<genexpr> at 0x10a9d25d0>
1
4
9

print_iter 함수는 for Loop로 element를 하나씩 출력하게 합니다.
즉, next 함수와 같은 역할을 합니다.
그리고 gerate_square_from_list라는 함수는 result라는 generator 객체를 만듭니다.
첫 element가 막 할당되었기 때문에 1,4,9 전에 object 자체가 출력된 것입니다.
결국 generator expression인 '( x*x for x in L )'은 result를 generator object로 만들었습니다.

5. Lazy Evaluation

조금 전 generator expression이 Lazy Evaluation을 위해 사용될 수 있다고 하였습니다.
그렇다면 Lazy Evaluation은 왜 사용하는걸까요?
코드를 통해 비교해보겠습니다.

L = [ 1,2,3]
def print_iter(iter):
    for element in iter:
        print(element)

def lazy_return(num):
    print("sleep 1s")
    time.sleep(1)
    return num

print("comprehension_list=")
comprehension_list = [ lazy_return(i) for i in L ]
print_iter(comprehension_list)

print("generator_exp=")
generator_exp = ( lazy_return(i) for i in L )
print_iter(generator_exp)

#출력결과
comprehension_list=
sleep 1s
sleep 1s
sleep 1s
1
2
3
generator_exp=
sleep 1s
1
sleep 1s
2
sleep 1s
3

출력결과를 비교하면,
comprehension_list는 1s라는 문구가 연속으로 (list L의 길이인) 세 번 보이고 결과를 출력합니다.
반면, generator_exp는 1s가 앞서 나오고 출력결과가 나옵니다.
lazy_return이라는 함수는 1 second를 대기하다가 return 해줍니다.

comprehesion_list가 처음에 3초가 걸리는 것은 [ ](대괄호) 사이의 list comprehension식에 해당하는 메모리에 배열의 크기만큼(예제에서는 3인 배열크기) 미리 할당하기 때문입니다.
반면, Generator Expression 형태의 함수인 generator_exp는 마찬가지로 공간을 생성하지만, 배열의 구체적인 형태를 갖고 있지 않습니다.
즉, generator expression은 지정한 규칙대로 값을 반환할 규칙과 현재 어디까지 반환했는지 등을 관리할 여러 상태값을 담고 있으나, 배열과 달리 값 모두를 generator를 생성할 당시에 메모리에 할당하지 않습니다.

이러한 이유로 generator expression을 사용한 것을 lazy evaluation(loading)이라고 합니다.
또한 list comprehension과 같이 생성과 동시에 메모리에 적재하는 형태를 eager loading이라고 합니다.

결과값은 어쨌거나 동일하게 1, 2, 3이 나올 것입니다.
그렇다면 lazy evaluation을 사용했을 때의 장점이 무엇일까요?

만일 크기가 3이 아닌 조나 경 단위가 넘어간다면, 한 번에 담지 못하고 에러가 날 것입니다.
(물론 서버의 사양에 따라 다르겠지만요.)
반면 Lazy Evaluation을 사용한다면, 사용한 단위마다 evaluate(평가) 하며 메모리를 할당하기 때문에 이러한 부담을 줄일 수 있습니다.
즉 안정성이나, 메모리자원 등의 이유에서 Lazy Evaluation을 사용하는 것입니다.

반응형