파이썬 클린 코드 - 이상적인 반복

HHHHH·2021년 8월 21일
0

모두의 연구소

목록 보기
3/3

이상적인 반복

파이썬에서 반복을 할 때 유용하게 사용할 수 있는 관용적인 코들 살펴보기.

관용적인 반복 코드

enumerate 객체와 비슷한 객체 만들기

list(enumerate("abcdef")) 
# [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]
class NumberSequence:
    def __init__(self, start=0):
        self.current = start
    def next(self):
        current = self.current
        self.current += 1 
        return current
seq = NumberSequence()
seq.next() # 0
seq.next() # 1
seq2 = NumberSequence(10) 
seq2.next() # 10
seq2.next() # 11

그러나 for 루프를 받기위한 인터페이스를 지원하지않고 이터러블 객체의 파라미터로도 사용할 수 없다는 것을 뜻함.

list(zip(NumberSequence(), "abcdef"))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-43-1b55bbe6cdf6> in <module>
----> 1 list(zip(NumberSequence(), "abcdef"))

TypeError: zip argument #1 must support iteration

또한 NumberSequence가 반복가능(iterate)을 지원하지않는 것임. 이 문제를 해결하기 위해서 __iter__()를 구현해서 객체가 반복가능하게 만들어야함. 또한 next() 메서드를 수정해서 __next__를 구현하면 객체는 이터레이터가 됨.

class SequenceOfNumbers:
    
    def __init__(self, start=0):
        self.current = start
        
    def __next__(self):
        current = self.current
        self.current += 1
        return current
    
    def __iter__(self):
        return self
        

이렇게 하면 요소를 반복할 수 있을 뿐만 아니라 .next()메서드를 호출할 필요가 없다. next()를 쓰면 __next__()가 호출되기 때문이다.

list(zip(SequenceOfNumbers(), "abcdef"))

## [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

next() 함수

  1. next() 내장함수는 이터러블을 다음 요소로 이동시키고 기존의 값을 반환한다.

  2. 이터레이터가 더 이상 값을 가지고 있지 않다면 StopIteration 예외가 발생한다.

  3. next(이터러블, 기본값)을 하면 StopIteration을 발생하지않고 기본 값을 반환한다.

제네레이터 사용하기

제네레이터를 사용하면 클래스를 만드는 대신 다음과 같이 필요한 값을 yield하는 함수를 만들면 된다.

def sequence(start = 0):
    while True:
        yield start
        start += 1 
## 사실 이 제네레이터는 itertools.count()와 상당히 유사하다. 하지만 itertools 모듈에는 여러 추가 기능이 있다. 

yield 키워드가 해당 함수를 제네레이터로 만들어 줌. 제네레이터기 때문에 무한 루프를 사용해도 안전!

제네레이터가 호출되면 yield 문장을 만나기 전까지 실행됨. 그리고 값을 생성하고 그 자리에서 멈춤.

itertools

이터러블로 작업하면 코드가 파이썬 자체와 더 잘 어울리는 장점이 있음. 왜냐하면 이터레이션이 언어의 중요한 컴포넌트이기 떄문임. 또한 itertools 모듈을 사용하면 그 기능을 온전히 할수있으면서 여러 추가 기능까지 있음

이터레이터, 제네레이터, itertools와 관련해서 가장 멋진 것은 이들을 서로 연결하여 새로운 객체를 만들 수 있다는 것임.

예를 들어 처음에 예제로 돌아가서 구매 이력에서 지표를 계산하는 과정을 다시 살펴보자. 만약 특정 기준을 넘은 값에 대해서만 연산을 하려면 어떻게 해야 할까? 가장 간단한 방법은 while 문 안에 조건뭉르 추가하는 것이다.

def process(self):
	for purchase in self.purchases:
    	if purchase > 1000.0:
        	##어떤 작업들

파이썬 스럽지 않은것을 넘어 너무 엄격하다.

만약 기준 수치가 1000.0 이 아니라 변경된다면? 파라미터로 전달해야하나? 만약 파라미터가 둘 이상 필요한다면 어떻게 할까? 조건이 특정 기준 이하로 되면 어떻게 할까? 람다 함수를 사용해야 할까?

사실 이번 객체가 이러한 질문들을 해결할 필요가 없다. 이 객채가 지녀야할 책임은 구매 이력에 대해 잘 정의된 지표 값을 가지고 계산하고 출력하는 것일뿐이다. 이러한 요구 사항을 이번 객체에 반영하는 것은 큰 실수이다.

-> 클린 코드는 융통성이 있어야 하고, 외부 요인에 결합력이 높아서는 안된다. 이러한 요구사항은 다른 곳에서 해결되어야 한다.

이 객체는 클라이언트의 요구로부터 독립되어야 한다. 이 클래스의 책임이 작을 수록 클라이언트는 재사용성이 높아지므로 보다 유용하게 된다. 코드를 수정하는 대신 그대로 유지하고 클라이언트 클래스의 요구사항이 무엇이든 그에 맞게 필터링하여 새로운 데이터를 만든다고 가정하다.

예를 들어 이렇게 바꿔도 된다.


from itertools import islice
purchases = islice(filter(lambda p : p > 1000.0, purchases), 10)
stats = PurchasesStats(purchases).process()

이렇게 필터링해도 여전히 제네레이터고 게으른 연산이기 때문에 메모리의 손해가 없음. 마치 전체에서 필터링한 값으로 연산한것처럼 보이지만 실제로는 하나 하나씩 가져와서 모든 것을 메모리에 올리지않았음.

이터레이터를 사용한 코드 간소화

def process_purchases(purchases):
    min_, max_, avg = itertools.tee(purchases, 3)
    return min(min_), max(max_), median(avg)
    ```
    
 itertools.tee는 원래 이터러블을 세개의 새로운 이터러블로 분할한다. 그리고 구매 이력을 세번 반복할 필요 없이 분할된 이터러블을 사용해 필요한 연산을 할 것이다. purchases 파라미터에 다른 이터러블 객체를 넘기면 itertools.tee 함수 덕분에 원했던 것처럼 오직 한 번만 순환하는 것을 확인할 수 있따. 또한 비어있는 시퀀스를 넣으면 min() 함수에서 ValueError를 발생시킬 것이므로 따로 ValueError 예외를 발생시키지 않아도 된다.
 
-> 반복을 여러번 해야하는 경우 itertools.tee를 사용한다.

* 역주: tee는 제네레이터를사용한 for 문이 여러 개 있는 것과 비슷하다. 다만 가장 앞서나가는 for문을 기준으로 위치가 이동하는 셈이다. 따라서 for문이 2개 있다고 해서 전체 데이터가 갑자기 2배로 복사되는 것은 아니지만 적어도 어느 인덱스에서의 요소에 대해서는 2배의 메모리를 사용하게 된다., tee 함수가 제네레이터를 활용했다고 해서 성능 차이가 전혀 없는 것은 아니다. 개별 요소의 크기가 작다면 상관없겠지만 개별 요소가 크고 이터러블을 여러개 복사해야 한다면 유의해야 한다.
마찬가지로 tee함수로 이터러블을 2개 복사하고 첫 번째 복사본에 대해서 전체 작업을 하고, 그 다음에 순차적으로 두 번째 복사본에 대해서 작업을 시작한다면 불필요하게 하나의 복사본을 더 갖고 있는 것이므로 굳이 tee 함수를 사용할 필요 없이 list 함수를 사용하는 것이 좋다. 그러나 개별 요소 하나가 그렇게 크지 않다던가, 복사한 일부 이터레이터가 다른 이터레이터의 순서를 크게 앞지르는 경우가 없다면 tee함수를 사용하여 편리하게 여러번 반복을 할 수 있다.

-> list로 만드는거 추가

중첩 루프

경우에 따라 1차원 이상을 반복해서 값을 찾아야할 수 있다.

해결법 1.

값을 찾으면 루프를 멈추고 break를 통해 벗어나기
그러나 중첩일때는 두단계 이상을 벗어나야함으로 정상적으로 동작하지 않음 x

기타 해결법?

플래그 사용? 예외? 예외는 로직을 제어하기 위한 수단이 아님. 코드를 잘게 나누어 함수에서 반환? 비슷하지만 답은 아님

최고의 해결법은 중첩을 풀어 1차원 루프로 만들기

# 나쁜 예시
def search_nested_bad(array, desired_value):
    coords = None
    for i , row in enumerate(array):
        for j, cell in enumerate(row):
            if cell == desired_value:
                coords = (i, j)
                break
        
        if coords is not None:
            break
    if coords is None:
        raise ValueError(f"{desired_value} not found")
        
    logger.info("[%i, %i]에서 값 %", *coords, desired_value)
    return coords
# 좋은 예시, 어떻게 반복을 추상화했는지 주의깊게 보기. 3차원 이상에서도 그대로 사용하면 됨
def _iterate_array2d(array2d):
   for i, row in enumerate(array2d):
       for j, cell in enumerate(row):
           yield (i, j), cell\

def search_nested(array, desired_value):
   try:
       coord = next(
           coord
           for (coord, cell) in _iterate_array2d(array)
           if cell == desired_value
       )
   except StopIteration:
       raise ValueError("{desired_value} not found")
       
   logger.info("[%i, %i]에서 값 %", *coords, desired_value)
   return coord

파이썬의 이터레이터 패턴

제네레이터는 이터러블 객체의 특별한 경우이지만 파이썬의 반복은 제네레이터 이상의 것으로 훌륭한 이터러블 객체를 만들게 되면 보다 효율적이고 컴팩트하고 가독성이 높은 코드를 작성할 수 있게 된다.

앞의 코드에서는 이터러블 객체이면서 한 이터레이터인 객체를 살펴 보았따. 이터레이터는 __iter__()와 __next__() 매직 메서드를 구현한 객체이다. 일반적으로 이렇게 부르지만 엄밀히는 항상 이 두가지를 꼭 구현할 필요는 없다. __iter__()을 구현한 이터러블 객체와 __next__()을 구현한 이터레이터 객체를 비교해볼 것이다.

이터레이션 인터페이스

이터러블은 반복을 지원하는 객체로 크게 보면 아무 문제 없이 for ... in ... 루프를 실행할 수 있다는 것을 뜻한다. 그러나 이터러블과 이터레이터는 다르다.

일반적으로 이터러블은 반복할 수 있는 어떤 것으로 실제 반복을 할 때는 이터레이터를 사용한다. 즉 __iter__() 매직 메서드를 통해 이터레이터를 반환하고, __next__() 매직 메서드를 통해 반복 로직을 구현하는 것이다.

이터레이터는 단지 내장 next()함수 호출시 일련의 값에 대해 한 번에 하나씩만 어떻게 생성하는지 알고 있는 객체이다. 이터레이터를 호출하지 않은 상태에서 다음 값을 요청 받기 전까지는 그저 얼어있는 상태일 뿐이다. 이러한 의미에서 모든 제네레이터는 이터레이터이다.

파이썬개념매직 매서드비고
이터러블__iter__이터레이터와 함께 반복 로직을 만든다. 이것을 구현한 객체는 for... in .. 구문에서 사용할 수 있다.
이터레이터__next__한 번에 하나씩 값을 생산하는 로직을 정의한다. 더 이상 생산할 값이 없을 경우에는 Stopiteration 예외를 발생시킴. 내장 next() 함수를 사용해 하나씩 값을 읽어올 수 있다.

다음 코드는 이터러블 하지않은 이터레이터 객체의 예이ㅏㄷ. 이것은 오직 한 번에 하나 씩 값을 가져올 수만 있다. 여기서 sequence는 잠시 후 살펴볼 파이썬의 시퀀스가 아니라 일련의 연속된 숫자를 나타낸다.


class SequenceIterator:
    def __init__(self, start = 0, step =1):
        self.current = start
        self.step = step
    
    def __next__(self):
        value = self.current
        self.current += self.step
        return value
next(si)
## 1

next(si)
## 3

next(si)
## 5

for _ in SequenceIterator(): pass
## 
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-11-e6518e14037f> in <module>
----> 1 for _ in SequenceIterator(): pass

TypeError: 'SequenceIterator' object is not iterable

## 시퀀스에서 값을 하나씩 가져올 순 있지만 iteralbe하지 않다. __iter__() 메서드를 구현하지 않았기 때문.

반복을 다른 객체로 분리할 수 있음.( __iter__어ㅣ __next__을 함께 구현하는 것으로 충분함. 그냥 각자의 역할을 설명하기 위해서 분리 가능한것을 소개하는 것)

이터러블이 가능한 시퀀스 객체

앞에서 보았듯이 __iter__() 매직 메서드를 구현한 객체는 for 루프에서 사용할 수 있다. 이것은 큰 특징이지만 꼭 이런 형태여야만 반복이 가능한것은 아니다. 파이썬이 for 루프를 만나면 객체가 __iter__을 구현했는지 확인하고 있으면 그것을 사용한다. 그러나 없을 경우는 다른 대비 옵션을 가동한다.

객체가 시퀀스인경우(즉 __getitem__()과 __len__() 매직 메서드를 구현한 경우)도 반복 가능하다. 이경우 인터프리터는 IndexError 예외가 발생할 떄까지 순서대로 값을 제공한다. IndexError 예외는 앞서 언급한 StopIteration 과 유사하게 반복에 대한 중지를 알리는 역할을 한다.

이러한 동작을 확인하기 위해 특정 숫자 범위에 대해 map()을 구현한 시퀀스 객체를 살펴보자.



이 예제는 오직 일반 for 루프를 사용해 반복가능하다는 것을 보여주기 위한 것이다. __getitem__ 매서드에서는 객체가 반복하는 동안 어떤 값이 전달되었는지 확인하기 위해 로그를 출력한다.

다만 이러한 방법이 있다는 것을 알아두는 것은 좋지만 객체가 __iter__을 구현 하지 않았을 때 동작하는 대비책임에 주의하자. 따라서 단순히 반복가능한 객체를 만드는 것이 아니라 적절한 시퀀스를 만들어 해결하는 것이 바람직하다.

-> 객체가 시퀀스여서 우연히 반복가능할 수 있지만, 기본적으로 반복을 위해 객체를 디자인할 떄는 __iter__매직 메서드를 구현하여 정식 이터러블 객체를 만들어야한다.

profile
공부중

0개의 댓글

관련 채용 정보