[High Python] - 컴프리헨션과 제너레이터에 대해서 알아보자

Introduction 

파이썬에서는 컴프리헨션(Comprehension) 이라는 특별한 구문을 사용해서 리스트, 딕셔너리, 집합 등의 데이터 타입을 간결하게 이터레이션하면서 원소로부터 파생되는 데이터 구조를 생성할 수 있습니다. 컴프리헨션 코딩 스타일은 제너레이터(Generator)를 사용하는 함수로 확장할 수 있습니다. 

 

제너레이터는 함수가 점진적으로 반환하는 값으로 이뤄지는 스트림을 만들어줍니다. 이터레이터를 사용할 수 있는 곳이라면 어디에서나 제너레이터 함수를 호출한 결과를 사용할 수 있습니다. 제너레이터를 사용하면 성능을 향상시키고 메모리 사용을 줄이며 가독성을 높일 수 있습니다. 개인적으로 파이썬에 대해서 최소한의 이해를 하고 있다고 자신있게 말하기 위해서는 Iterable, Iterator, Generator에 대해 문제없이 구분하고 활용할 수 있어야 한다고 생각합니다.

 

Comprehension

Comprehension은 보통 '리스트 표현식' 혹은 '리스트 조건식'과 같이 번역해서 사용하는 경우가 많습니다. 그런데 표현식은 정규 표현식과 같은 expression을 떠올리게 하고 조건식은 'if'문을 떠올리게 합니다.

제가 공부한 내용에 따르면 저 위의 번역은 완벽하다고 볼 수 없습니다. 그래서 저는 '리스트 컴프리헨션'이라는 단어를 그대로 사용하도록 하겠습니다. 

 

Map과 filter 함수 대신 컴프리헨션 사용하기

파이썬에서는 다른 시퀀스나 이터러블에서 새 리스트를 만들어내는 간결한 구문을 '리스트 컴프리헨션'이라고 합니다.
예를 들어 리스트에 있는 모든 원소의 제곱을 계산한다고 하면 for loop 문을 통해서 간단히 구현할 수 있습니다.

 

a = [1,2,3,4,5,6,7,8,9,10]

squares = []

for x in a:
    squares.append(x**2)
print(squares)

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

 


리스트 컴프리헨션을 사용해서 루프로 처리할 대상인 입력 시퀀스의 원소에 적용할 변환식을 지정함으로써 같은 결과를 더 '짧은' 코드로 구현할 수 있습니다.

 

squares = [x**2 for x in a]
print(squares)

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

 

이런 계산에 'map' 기능을 사용하려면 반드시 lambda 함수를 정의해야 하는데, 시각적으로 그렇게 좋아보이지 않습니다.

 

alt = map(lambda x: x**2 , a)
alt

 

본격적으로 map과 filter를 사용하는 경우와 컴프리헨션을 사용하는 경우를 비교해보면 컴프리헨션 코드가 훨씬 가독성이 높고 효율적인 것을 확인할 수 있습니다.

- 예시 : 2로 나눠 떨어지는 수(짝수)의 제곱만 계산하고 싶다고 해보자. 이런 계산을 수행하려면 다음 코드처럼 리스트 컴프리헨션에서 루프 뒤에 조건식을 추가하면 됩니다.

 

even_squares = [x**2 for x in a if x % 2 == 0]

print(even_squares)

[4, 16, 36, 64, 100]

 

lambda 함수를 사용해서 위와 같은 결과를 얻을 수 있지만, 이렇게 만든 코드는 읽기가 어렵습니다.

alt = map(lambda x : x**2, filter(lambda x: x % 2 ==0, a))
assert even_squares == list(alt)

 

딕셔너리 집합에도 리스트 컴프리헨션과 동등한 컴프리헨션이 있습니다. 각각 딕셔너리 컴프리헨션과 집합 컴프리헨션이라고 합니다.

이를 사용하면 알고리즘을 작성할 때 딕셔너리나 집합에서 파생된 데이터 구조를 쉽게 만들 수 있습니다.

 

even_squares_dict = {x: x**2 for x in a if x % 2 ==0}
threes_cubed_set = {x**3 for x in a if x % 3 == 0}

print(even_squares_dict)
print(threes_cubed_set)

{2: 4, 4: 16, 6: 36, 8: 64, 10: 100}
{216, 729, 27}

 

사용 방법은 그렇게 복잡하지 않습니다. 각각의 호출을 적절한 생성자로 감싸면 같은 결과를 map과 filter를 사용해 만들 수도 있습니다. 

하지만 이렇게 생성자로 감싸서 작성한 코드는 너무 길기 때문에 여러 줄에 나눠 써야 하고, 그러면 노이즈가 더 늘어나므로 가능하면 map과 filter를 사용하는 것을 피하는 편이 좋습니다.

 

alt_dict = dict(map(lambda x : (x, x**2),
                    filter(lambda x: x % 2 == 0, a)))

alt_set = set(map(lambda x : x**3,
                  filter(lambda x : x%3 ==0, a)))

Summary

- 리스트 컴프리헨션을 사용하면 lambda 식을 사용하지 않기 때문에 같은 일을 하는 map or filter 내장 함수를 사용하는 것보다 더 명확합니다.

- 리스트 컴프리헨션을 사용하면 쉽게 입력 리스트의 원소를 건너뛸 수 있습니다. 하지만 map을 사용하는 경우에는 filter의 도움을 받아야만 합니다. 

- 딕셔너리와 집합도 컴프리헨션으로 생성할 수 있습니다.

 

 

추가 

위의 예시에서 봤던 것처럼 if문을 사용할 경우 조건을 걸 수가 있습니다. 이렇게 리스트 컴프리헨션에서 조건문을 통해 특정 값만 필터링을 할 수 있다고 말씀드렸죠 ? 

 

size = 10
arr = [n for n in range(1, 11) if n % 2 == 0]

print(arr)

[2, 4, 6, 8, 10]

 


그렇다면 조건문 여러 개를 사용할 수 있을까요 ? 

만약 1부터 10까지의 값을 루프하는 리스트를 만든다고 가정해봅시다. 

조건은 '2의 배수이고 3의 배수'인 수만 필터링하거나, '2의 배수이거나 3의 배수'인 수만 필터링을 하는 것입니다.

첫 번째 조건에 해당하는 코드는 역시 다음과 같이 한 줄로 작성할 수 있습니다.

그런데 여기서 이상한 점이 있는데요,  '2의 배수이고 3의 배수'라면 AND 조건을 사용해야 하는데 'and'를 사용하지 않습니다. 

 

arr = [n for n in range(1, 31) if n % 2 == 0 if n % 3 == 0]

arr

[6, 12, 18, 24, 30]

 

놀랍게도 and를 명시적으로 넣어주면 SyntaxError가 발생하는 것을 볼 수 있습니다. 

 

arr = [n for n in range(1, 31) if n % 2 == 0 and if n % 3 == 0]

arr

SyntaxError: invalid syntax

 

반대로 다중 if 조건문에 대한 OR 연산은 아예 안 됩니다. 명시적으로 or 연산자를 입력하면 SyntaxError가 발생하고 쓰지 않으면 AND로 해석됩니다. 따라서 이런 경우에는 if 문을 여러 개 쓰지 말고, 한 if 문에서 ‘or’ 연산자로 논리 연산을 묶어줘야 합니다.

 

arr = [n for n in range(1, 16) if n % 2 == 0 or n % 3 == 0]

print(arr)

[2, 3, 4, 6, 8, 9, 10, 12, 14, 15]
반응형