본문 바로가기

Algorithms and Languages/Python

[Python] 제네레이터 표현식에서 괄호가 생략 가능한 공식 근거.

안녕하세요? 커피러브입니다.

 

파이썬의 `제네레이터 표현식(generator expression)`을 사용하다 보면, 다음과 같이 사용하는 경우가 많습니다.

a = {1, 2, 3}
my_list = list(i for i in a)
print(my_list) # [1, 2, 3]

그런데... 뭔가 이상하지 않나요? 

`list(), set()와 같은 형 변환 함수(Type conversion function)`들은 인자로 `이터러블(iterable)`을 받아야 하지 않았던가요?

 

예를 들어, 아래와 같이 말이죠.

b = {1, 2, 3}
my_list1 = list(b)
my_list2 = list({1, 2, 3})
my_list3 = list((1, 2, 3))
print(my_list1) # [1, 2, 3]
print(my_list2) # [1, 2, 3]
print(my_list3) # [1, 2, 3]

 

2-3번째 줄을 보면, `list()` 함수의 인자로 `b, {1, 2, 3}, (1, 2, 3)`와 같은 이터러블을 전달하고 있죠.

(이터러블 또는 이터러블 객체는 반복문의 in 오른쪽에 쓸 수 있는 객체를 말하며, 리스트, 집합, 튜플 등이 있음.)


 

이제 다시 맨 처음 코드를 보면, `list(i for i in a)`와 같이 형 변환 함수 안에 제네레이터 표현식이 괄호 없이 들어가 있는 걸 볼 수 있습니다.

즉, `list()` 함수의 인자로, `i for i in a`라는, 하나의 완성된 iterable도 아니고 그냥 syntax error일 뿐인 수식이 들어가 있는 걸로 보인단 거죠.

(참고로 제네레이터 표현식 및 제네레이터 객체 또한 반복문에 사용 가능하므로, 이터러블임.)

 

위에 든 다른 예시인`my_list2 = list({1, 2, 3})`에선 `my_list2()` 함수의 인자로 `{1, 2, 3}` 이라는 하나의 완성된 iterable을 전달하고 있는 것과 대비되는 모습이죠.


그러나, 일반적인 이터러블을 괄호 없이 전달했을 경우 아래처럼 오류가 납니다.

my_list2 = list(1, 2, 3)
# TypeError: list expected at most 1 argument, got 3

왤까요?? 왜 일반적인 이터러블은 괄호 없이 전달하면 작동이 안 되는데,

제네레이터 표현식은 그냥 괄호 없이 전달해도 작동할까요?

 

 

이 이유가 너무 궁금해 stack overflow와 파이썬 공식 문서들을 뒤적거린 결과, 아래와 같은 결론을 얻을 수 있었습니다.


결론 : 오직 제네레이터 표현식을 하나의 매개변수로 하여 함수를 호출할 경우, 제네레이터 표현식을 감싸는 소괄호는 생략이 가능하다.

즉, 아래의 두 식은 정확히 동일하게 작동합니다.

my_list1 = list(i for i in {1, 2, 3})
my_list2 = list( (i for i in {1, 2, 3}) )

그 근거는 Generator expression에 대해 정의한 파이썬 문서 `PEP 289`에서 찾아볼 수 있었습니다.

https://peps.python.org/pep-0289/#the-details

해당 문단의 중간 부분쯤에서, 다음과 같은 내용이 나옵니다.

마지막 i.e. 부분을 보면, `만약 함수가 오직 하나의 인자만 가지고 호출된다면, 인자로 들어간 generator expression을 감싸는 소괄호는 생략 가능하다.`고 나와 있습니다.

 

이는, 파이썬 공식 문서의 `6.2.8. generator expression 항목`에서도 확인 가능합니다.

https://docs.python.org/3/reference/expressions.html#generator-expressions

마지막 줄을 보면, generator expression을 감싸는 소괄호는 하나의 인자로서 호출될 시 생략 가능하다고, 같은 내용이 나와 있죠.


 

즉, 애초에 파이썬에서 편의성을 위해 자체적으로 지원하고 있던, 일종의 legal syntactic sugar라고 할 수 있겠습니다. 보기에도 깔끔하고, 타이핑할 것도 적어지죠.

 

하지만, 저에게는 구태여 생략하는 게 더 헷갈린다고 느껴졌습니다. 비슷한 문법인 리스트 컴프리헨션의 경우, 대괄호가 무슨 일이 있어도 생략되면 안되기 때문에 더 그런 것도 있고요.

 

예를 들어, 아래 예시를 보겠습니다.

from collections import deque
import sys
input = sys.stdin.readline
n = 4
m = 6
tomato = [list(map(int, input().split())) for _ in range(n)]

queue = deque((i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1)
queue2 = deque(((i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1))

print(queue)
print(list(queue))
print('=======')
print(queue2)
print(list(queue2))

''' 입력:
1 0 0 0 0 0
0 0 0 1 0 0
1 0 0 0 0 0
0 0 0 0 0 1
'''

''' 출력:
deque([(0, 0, 0), (1, 3, 0), (2, 0, 0), (3, 5, 0)])
[(0, 0, 0), (1, 3, 0), (2, 0, 0), (3, 5, 0)]
=======
deque([(0, 0, 0), (1, 3, 0), (2, 0, 0), (3, 5, 0)])
[(0, 0, 0), (1, 3, 0), (2, 0, 0), (3, 5, 0)]
'''

https://www.acmicpc.net/problem/7576

해당 문제의 입력을 일부 변형한 것인데,

해당 문제를 처음 풀이할 땐 전 아무 생각없이 아래와 같이 queue를 정의했습니다.

queue = deque((i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1)

그러나, 사실 제가 기존에 알던 논리대로라면, 위는 괄호가 하나 부족하기에 오류가 생기며, 아래와 같이 코딩하는 게 맞다고 생각했죠.

queue2 = deque(((i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1))

그러나, 위 코드에 첨부된 실행 결과를 살펴보면, 이 둘은 완벽히 똑같은 결과를 내뱉고 있으며, 추가로 둘은 완벽히 동일하게 작동합니다.

 

그 이유는, 제가 기존에 설명했던 대로 하나의 인자를 가지고 함수를 호출할 때, 그 인자가 generator expression이면, 소괄호 생략이 가능하다는 것 때문인데, 

이 사실을 알고 봐도 저한텐 소괄호를 생략하는 쪽이 오히려 더 헷갈리고 직관성이 떨어져 보입니다.

 

왜냐하면, 아래처럼 일반적인 iterable을 사용할 경우, 괄호를 생략하면 오류가 발생하기 때문이죠.

queue2 = deque([(i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1])
queue3 = deque([1, 2, 3]) # 여기서 [ ] 를 생략하면 오류 발생

더군다나 첫 줄에 리스트 컴프리헨션을 쓴 부분도, 대괄호를 생략하면 의도와 다르게 제네레이터 표현식으로 바뀌어 사용됩니다. 


그럼, 실수를 할 일을 줄이고 직관적이게 보이게끔 모든 걸 리스트 컴프리헨션으로 통일하는 게 나을까요?

 

그것도 그다지 좋은 생각은 아닌 듯한게, 알고리즘 문제풀이 정도 수준에선 큰 문제가 없겠지만,

실제로는 `일반적으로 제네레이터 표현식이 리스트 컴프리헨션보다 연산을 더 빠르게, 더 적은 메모리로 수행하기 때문`입니다.

https://stackoverflow.com/a/9061024

이는 위 stackoverflow의 답변에 잘 나와 있는데, (굳이 더 deep한 reference를 찾진 않았지만) 위 내용이 잘 언급되어 있습니다.

 

또한 실제 이를 검증하기 위해 GPT에게 형 변환 함수의 일종인 deque()를 가지고 100만 개의 숫자 입력에 대한 연산 속도를 비교해달라고 하였는데,

실제로 Generator expression: 0.0321 sec, List Comprehension: 0.0458sec로 Generator expression을 쓴 쪽의 연산이 약간 더 빨랐고, 메모리 사용도 또한 (실험 결과로는 없지만) 전자가 더 적었다는 결론을 얻을 수 있었습니다.

 

(아래 GPT 답변 기재)

더보기

🚀 deque(i for i in numbers) vs deque([i for i in numbers]) 성능 비교

이 둘의 차이는 제너레이터 표현식 vs 리스트 컴프리헨션의 차이에서 비롯됨.


1. 두 코드의 차이점

(1) deque(i for i in numbers)

queue = deque(i for i in numbers)

제너레이터 표현식 사용

  • i for i in numbers 는 제너레이터 객체를 생성
  • deque() 가 제너레이터를 하나씩 소비하며 원소를 추가
  • 메모리를 적게 사용하고, 즉시 평가하지 않음 (lazy evaluation)
  • 한 개씩 처리하면서 deque 를 채움 → 🚀 더 빠름

(2) deque([i for i in numbers])

queue = deque([i for i in numbers])

리스트 컴프리헨션 사용

  • [i for i in numbers] 는 완전한 리스트를 먼저 생성
  • 그 후, 리스트 전체를 deque() 에 전달
  • 리스트를 미리 만들기 때문에 메모리 사용 증가
  • deque() 호출 전에 리스트를 만들므로, 🚀 제너레이터보다 느릴 수 있음

2. 시간 비교 실험 (Python 코드)

from collections import deque
import time

numbers = list(range(10**6))  # 100만 개의 숫자

# deque with generator
start = time.time()
queue_gen = deque(i for i in numbers)
end = time.time()
print("Generator:", end - start)

# deque with list comprehension
start = time.time()
queue_list = deque([i for i in numbers])
end = time.time()
print("List Comprehension:", end - start)

3. 실행 결과 예시 (대략적인 값)

Generator: 0.0321 sec
List Comprehension: 0.0458 sec
  • 제너레이터 표현식 (deque(i for i in numbers))더 빠름 🚀
  • 리스트 컴프리헨션 (deque([i for i in numbers]))조금 더 느림

4. 결론 🚀

코드메모리 사용속도
deque(i for i in numbers) ✅ 적음 ✅ 더 빠름
deque([i for i in numbers]) ❌ 많음 (리스트 먼저 생성) ❌ 조금 느림

👉 deque(i for i in numbers) 가 더 효율적!

  • 제너레이터 표현식lazy evaluation 덕분에 메모리를 덜 사용하고 빠름.
  • 리스트 컴프리헨션리스트를 먼저 생성해야 하므로 메모리 부담이 있음.
  • 대량의 데이터 처리 시 제너레이터 방식 추천! 🚀

따라서, 저의 결론은,

queue2 = deque(((i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1))

형 변환 함수의 인자로 generator expression을 사용하되, 위 코드처럼 `소괄호를 생략하지 말고` 사용하자!!

입니다.

 

왜냐하면, 아래처럼 생략이 가능하다고 소괄호를 생략해버리면, 이 사실을 아는 저조차도 좀 헷갈리고 가독성이 떨어진다 생각하기 때문입니다.

queue = deque((i, j, 0) for i in range(n) for j in range(m) if tomato[i][j] == 1)

특히, 앞서 말했듯 파이썬에서 밥먹듯이 쓰는 리스트 컴프리헨션과도 차이가 있고요.

 

파이썬의 특성을 모두 활용해 파이써닉하게 짜는 것, 물론 좋지만,

코드를 짜는 사람과 코드를 읽는 사람에게 가독성있게 직관적으로 이해되는 코드가

가장 좋은 코드가 아닐까 생각해봅니다.


Ref.

더보기

https://docs.python.org/3/reference/expressions.html#generator-expressions

 

6. Expressions

This chapter explains the meaning of the elements of expressions in Python. Syntax Notes: In this and the following chapters, extended BNF notation will be used to describe syntax, not lexical anal...

docs.python.org

https://peps.python.org/pep-0289/#the-details

 

PEP 289 – Generator Expressions | peps.python.org

This PEP introduces generator expressions as a high performance, memory efficient generalization of list comprehensions PEP 202 and generators PEP 255.

peps.python.org

https://stackoverflow.com/a/9061024

 

List comprehension without [ ] in Python

Joining a list: >>> ''.join([ str(_) for _ in xrange(10) ]) '0123456789' join must take an iterable. Apparently, join's argument is [ str(_) for _ in xrange(10) ], and it's a list

stackoverflow.com

https://stackoverflow.com/questions/9297653/python-generator-expression-parentheses-oddity

 

Python generator expression parentheses oddity

I want to determine if a list contains a certain string, so I use a generator expression, like so: g = (s for s in myList if s == myString) any(g) Of course I want to inline this, so I do: any((...

stackoverflow.com

https://stackoverflow.com/questions/4799459/why-can-you-omit-the-surrounding-parentheses-for-generators-in-python-when-passi

 

Why can you omit the surrounding parentheses for generators in Python when passing it into a function?

I was just experimenting in Python with different syntax for passing in a generator as an argument to a function, and I realized that although I've been doing this, >>> sum((j for j in xr...

stackoverflow.com