시작하기 전에
list comprehension은 기능을 배우는 것보다 이름을 이해하는 것이 더 어렵습니다. list comprehension이라는 용어에 대한 설명은 관심있는 분들을 위해서 이 글의 마지막에 남겨두겠습니다.
소개
list comprehension이 하는 일은 이미 있는 list를 가지고 무엇인가 작업을 해서 새로운 List를 만드는 것입니다. 간단한 예를 하나 보겠습니다. [1, 2, 3, 4]
의 각 값을 제곱해서 [1, 4, 9, 16]
을 만들고 싶다고 가정해보겠습니다.
result = []
for i in [1, 2, 3, 4]:
result.append(i ** 2)
print(result)
[1, 4, 9, 16]
아주 직관적인 코드입니다. Functional Programming의 느낌을 좋아하신다면 map()
을 사용해서 아래와 같이 작성할 수도 있습니다. (아래 내용은 사실 몰라도 됩니다.)
result = list(map(lambda x: x ** 2, [1, 2, 3, 4]))
print(result)
개인적으로 Functional Progamming의 개념을 좋아하지만 Functional Programming의 문법은 이해하기 어려운 면이 많다고 생각합니다. 특히 Python 처럼 간격하고 직관적인 문법 체계를 가진 언어에서는 더더욱 그렇구요. 위와 같은 역할을 하지만 좀 더 Python다운 문법이 바로 list comprehension입니다. 위 코드를 list comprehension을 사용해서 다시 작성해보겠습니다.
result = [i ** 2 for i in [1, 2, 3, 4]]
print(result)
뭔가 더 Python 코드스럽다고 느껴지시나요?
어떻게 작동하는지 들여다보기
사실 list comprehension은 for
loop가 하는 일을 좀 더 간결하게 해주는 부가적인 문법(syntactic sugar)에 가깝습니다. 앞의 두 코드를 비교해보면 이 설명이 조금 더 명확해집니다.
result = []
for i in [1, 2, 3, 4]:
result.append(i ** 2)
print(result)
result = [i ** 2 for i in [1, 2, 3, 4]]
print(result)
list comprehension이 하는 일을 이렇습니다.
- 결과를 저장할 임시 list를 하나 만듭니다.
[1, 2, 3, 4]
의 각 element를 차례대로 방문합니다. 이때 각 차례에 element를 변수i
에 저장합니다.- 변수
i
를 가지고i ** 2
라는 식을 수행하고, 결과값을 앞에서 만든 임시 list에 저장합니다. [1, 2, 3, 4]
의 모든 element를 방문했으면 임시 list를 리턴합니다.
list comprehension 코드 설명이라고 말씀드렸는데, 다시 보면 토씨하나 바꾸지 않고 for
loop 코드를 설명하기 위해서도 사용할 수 있습니다.
똑같다면 왜 쓸까?
그렇다면 똑같은 일을 할 수 있는데 왜 굳이 list comprehension을 사용할까요?
첫번째 이유는 표현이 간결하기 때문입니다. 단순히 코드 수로만 봐도 list comprehension이 짧습니다. 하지만 짧은 코드가 꼭 좋은 것은 아닙니다. 지나치게 코드를 짧게 작성하다보면 가독성이 떨어질 수 있기 때문입니다. 여기에서 간결하다는 이해하기 쉽다는 뜻에 가깝습니다. 문장을 쓸 때 길고 복문으로 쓰는 것 보다는, 짧게 단문으로 쓰는 것이 가독성이 좋은 것과 같은 원리입니다.
두번째 이유는 표현이 명확하기 때문입니다. for
를 사용한 코드의 경우 이 코드가 무엇을 하는지 이해하려면 코드를 한줄 한줄 읽고 동작을 이해해야합니다. 물론 코드를 자주 보다보면 패턴이 보이기 때문에 파악이 쉬워지기는 하지만 근본적인 문제는 여전히 있습니다. 반면에 list comprehension 문법을 보면, "이미 있는 list를 가지고 뭔가 조작해서 새로운 list를 만드려고 하는구나"라는 것을 바로 알 수 있습니다. 이 문법으로 할 수 있는 것은 그것 뿐이니까요.
간결하고 명확한 코드를 작성하는 것이 코딩의 기본 철학 중의 하나인데 list comprehension을 안 쓸 이유가 있을까요?
list comprehension에 if 사용해보기
아래 코드를 먼저 보겠습니다.
result = []
for i in [1, 2, 3, 4]:
if i % 2 == 0:
result.append(i ** 2)
print(result)
맞습니다. 앞에 코드들과 같지만 element 중에 짝수인 경우만 결과를 저장하고 싶은 경우입니다. 이 코드를 list comprehension으로 써보겠습니다.
result = [i ** 2 for i in [1, 2, 3, 4] if i % 2 == 0]
print(result)
마지막에 조건만 추가했습니다.
참고로 Functional Progamming 스타일로 map()
과 function()
을 조합할 수도 있지만, 가독성이 좋지는 않습니다.
result = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, [1, 2, 3, 4])))
print(result)
중첩 list comprehension
list comprehension이 for
를 대체할 수 있고 좀 더 Python스러운 표현이라는 것을 알게되면 list를 다루는데 for
를 볼 때마다 list comprehension으로 바꾸고 싶은 충동을 느낄 수도 있습니다. 실제로 대체가 가능하기도 하구요. 그렇다보면 자연스럽게 중첩(Nested) list comprehension을 사용하게 됩니다. 결론부터 말하면 중첩 list comprehension은 추천하지 않습니다. list comprehension의 장점인 간결성이 떨어지기 때문입니다.
l = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
result = []
for row in l:
for i in row:
result.append(i ** 2)
print(result)
[1, 4, 9, 16, 25, 36, 49, 64, 81]
이 코드는 2차원 list의 각 element를 제곱한 후에 1차원 list에 저장합니다. 같은 일을 하는 list comprehension 코드는 아래와 같습니다.
l = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
result = [i ** 2 for row in l for i in row]
print(result)
어느 정도 취향의 영역이기는 하지만 저는 이 경우에 list comprehension이 for
보다 간결하고 직관적이라고 말하기는 어렵다고 생각합니다.
generator
list comprehension의 커다란 단점이 하나 있습니다. 어디까지나 새로운 list를 만드는 식이기 때문에 전체 element 방문이 끝나야 그 식의 실행이 완료된다는 점입니다. 처음의 코드를 다시 보겠습니다.
result = []
for i in [1, 2, 3, 4]:
result.append(i ** 2)
print(result)
result = [i ** 2 for i in [1, 2, 3, 4]]
print(result)
이 두 코드는 동작이 같은데, 앞의 코드를 보면 1, 2, 3, 4를 각각 거친 후에야 for
loop이 끝난다는 것을 알 수 있습니다. 작은 list를 다룰 때는 괜찮지만 아주 커다란 list를 다룰 경우 이런 방식은 문제가 될 수 있습니다.
- 모든 element에 대한 결과를 최종 list에 저장해야하기 때문에 메모리를 많이 사용합니다. 예를 들어
[1, 2, 3, 4]
가 아니고range(100000000)
라면 결과 저장을 위해서 엄청난 메모리를 사용할 겁니다. Out of memory나 메모리 쓰레싱이 발생할 수 있습니다. - list comprehension로 새 list를 만들고나서, 이 새 list의 element를 순차적으로 접근해서 사용한다고 가정해보겠습니다. 이 경우 당장 필요한 것은 0번째 element임에도 불구하고 모든 결과값이 저장될 때까지 기다려야 합니다.
이 두가지 문제는 큰 list를 다룰 때 빈번하게 발생하는 문제입니다. Python은 이런 문제를 해결하기 위해서 generator를 제공합니다. generator는 별도의 글이 필요한 큰 주제인데 간단히 말하면 아래와 같습니다.
- 전체 list가 만들어지기를 기다리지 않고 바로 리턴
- element를 요청할 때마다 순차적으로 element를 계산해서 리턴
이렇게 하면 전체 list를 저장할 필요가 없고(메모리 문제 해결), 전체 list가 만들어질 때까지 기다릴 필요가 없습니다(시간 문제 해결). lazy loading 개념에 익숙하다면 딱 그 개념이라고 보시면 됩니다.
list comprehension의 결과를 list가 아닌 generator로 받고 싶다면 []
대신에 ()
를 쓰면 됩니다.
result = (i ** 2 for i in [1, 2, 3, 4])
주의할 점은 위 코드의 결과는 list가 아닌 generator이기 때문에 print()
에 직접 사용할 수는 없다는 점입니다.
result = (i ** 2 for i in [1, 2, 3, 4])
print(result)
<generator object <genexpr> at 0x102cb5270>
실제 generator를 사용한 효과를 살펴보겠습니다.
import timeit
def list_comprehension():
for i in [x ** 2 for x in range(100000000)]:
print(i)
return
timeit.timeit(list_comprehension, number=1)
위 코드는 0부터 99,999,999까지 1억개의 정수의 제곱을 계산하고, 첫번째 값을 출력합니다.
(timeit.timeit()
은 인자로 전달된 함수를 실행하는데 걸린 시간을 계산해줍니다. number
인자를 사용해서 반복 횟수도 지정할 수 있습니다.)
29.432229989000007
제 컴퓨터에서는 약 29.4초가 걸렸습니다. 1억개 정수의 제곱을 모두 계산하는데 상당한 시간이 걸리는 셈이죠.
그럼 generator를 사용하는 코드를 보겠습니다.
import timeit
def list_comprehension_generator():
for i in (x ** 2 for x in range(100000000)):
print(i)
return
timeit.timeit(list_comprehension_generator, number=1)
모든 코드가 같고 []
가 ()
으로 바뀌었을 뿐입니다.
0.00019730799976969138
하지만 시간은 어마어마하게 단축됐습니다. 일반적인 list comprehension과 달리 (x ** 2 for x in range(100000000))
라는 코드는 실제 list를 계산하지 않고 바로 generator를 리턴하기 때문입니다.
여담으로 python 2.x 버전이었다면 앞의 코드는 더 오래 걸렸을 겁니다. python 2.x에서는 range()
도 list를 리턴하는 함수였기 때문입니다. python 3.x부터 range()
가 generator와 유사하게 작동하도록 변경되었습니다. 참고로 python 2.x에는 generator와 같이 작동하는 xrange()
라는 함수가 따로 있었습니다.
속도
for loop, map, list comprehension 의 속도는 어떨까요?
import timeit
def for_loop():
result = []
for i in range(1000000):
result.append(i ** 2)
return result
def list_comprehension():
return [i ** 2 for i in range(1000000)]
def map_():
return list(map(lambda x: x ** 2, range(1000000)))
timeit.timeit(for_loop, number=10)
>>> 3.056590561000121
timeit.timeit(list_comprehension, number=10)
>>> 2.8294456790008553
timeit.timeit(map_, number=10)
>>> 3.25087154099856
실행 환경에 따라 다르겠지만 제 컴퓨터에서는 list comprehension의 실행 속도가 가장 빨랐습니다. 다른 벤치마크들을 보더라도 대부분 list comprehension이 빠른 것을 볼 수 있습니다.
문법도 간결하고 속도까지 빠르니 list comprehension을 사용하지 않을 이유가 없습니다.
list comprehension이라는 용어
list comprehension은 영어 원어민이 아닌 우리에게 느낌이 잘 와닿지 않습니다. 학창 시절 기억을 떠올려보면 comprehension은 이해라는 뜻이었는데 리스트 이해라는 것과 이 기능이 어떤 연관이 있을까요? 그런데 비단 이 것은 우리만의 문제는 아닌 것 같습니다.
list comprehension은 사실 Python만의 기능은 아니고, Functional 프로그래밍 언어에서 쉽게 찾아 볼 수 있는 개념입니다. list comprehension에서 comprehension
을 설명하는 몇가지 해석이 있습니다.
첫번째는 comprehension
의 사전적 의미에 주목하는 것입니다. Merriam-Webster 사전에서 comprehension
을 찾아보면 두번째 뜻으로 아래과 같은 정의가 있습니다.
the act or process of comprising
해석하자면 무엇인가를 구성하는 행위나 과정
이라는 뜻입니다. 즉, list comprehension은 list를 만드는 일이라는 뜻입니다.
두번째는 프로그래밍 언어의 이론에 기반한 해석입니다. 집합론(set theory)에서 comprehension
이라는 용어가 묵시적인 생성
을 의미하고, 이것이 LISP 같은 Functional programming에서 차용되면서 현재와 같은 list comprehension 이라는 용어가 정립됐다는 설명입니다.
세번째는 list comprehension
이 언제 기록에 나타났는지에 기반한 설명입니다. list comprehension 이라는 용어가 나타나기 전부터 이미 이 기능은 몇가지 functional 프로그래밍 언어에 존재했다고 합니다. 그 중에 하나가 Rod Burstall과 John Darlington이 1977년에 만든 NPL이라는 언어입니다. 후에 David Turner가 "Some History of Functional Programming Languages"라는 글을 썼는데, 이 글에서 NPL에 이런 기능이 있었고, 이 기능을 Phil Wadler가 list comprehension
이라고 이름을 붙였다는 기록을 남깁니다. 이 설명을 통해서 왜 comprehension
인지를 알 수는 없지만, 누가 어떻게 이 이름을 붙였는지는 알 수 있습니다.
'Python' 카테고리의 다른 글
Python 메소드의 첫번째 인자인 `self`는 무엇인가? (0) | 2022.03.24 |
---|---|
Iterator, Generator (0) | 2021.05.08 |
Jupyter Lab (0) | 2021.05.01 |
*args, **kwargs (2) | 2020.06.23 |
Python Reflection 맛보기 (0) | 2020.06.09 |