yield
개념
- 함수를 대상으로 하는 키워드
- 일반적인 함수는 한 번에 결과를 반환(return) 하고 끝내는 데 반해
- yield 키워드가 적용된 함수는 제너레이터(generator) 로 변환되어, 호출될 때마다 값을 하나씩 생성해내게 된다.
기본 사용법
1
2
3
4
5
6
7
8
9
10
11
| def sample_func():
yield "Hello"
yield "world"
yield "it's"
yield "yield"
gen = sample_func()
for it in gen:
print(it)
print("-----")
|
1
2
3
4
5
6
7
8
| Hello
-----
world
-----
it's
-----
yield
-----
|
핵심 개념
상태 유지
- 일반적인 함수는
return 키워드를 만나면 값을 반환하고 함수의 모든 상태(지역 변수 등)가 소멸된다.
- 반면
yield 를 사용하는 함수는 값을 반환한 뒤, 함수의 실행을 잠시 일시 정지하고
- 이때 함수 내부 상태(지역변수, 실행위치)를 그대로 기억한다.
지연 평가 Lazy Evaluation
- 모든 결과값을 한꺼번에 메모리에 올리지 않고, 필요할 때마다 하나씩 생성한다.
- 이러한 지연 평가는 제너레이터의 특징이다.
반환 타입 : 제너레이터 객체
yield가 포함된 함수를 호출하면 함수 내부 코드가 즉시 실행되는 게 아니라, 제너레이터 객체를 반환한다.
- 제너레이터는 yield를 사용해 만든 특별한 종류의 이터레이터이다.
- 따라서
for 문에 넣거나 next() 함수의 인자로 사용할 수 있다.
일반 함수와의 작동 비교
| 구분 |
return(일반 함수) |
yield(제너레이터) |
| 종료 여부 |
값을 반환하고 함수가 완전히 종료됨 |
값을 반환하고 그 자리에 일시 정지됨 |
| 메모리 |
모든 결과값을 한 번에 메모리에 담음 |
한 번에 하나의 값만 생성해 메모리에 담음 |
| 재호출시 |
처음부터 다시 시작 |
정지했던 시점부터 다시 실행 |
작동 방식 뜯어보기
반환값은 generator
yield가 적용된 함수의 반환값은 generator
1
2
3
4
5
6
| def sample_func():
yield "Hello"
yield "world"
gen = sample_func()
print(gen)
|
1
| <generator object sample_func at 0x000001BE7105DFE0>
|
이터레이터를 순회할 때만 실행된다
단순 호출 시에는 실행되지 않고, 이터레이터를 순회할 때만 실행된다.
1
2
3
4
5
6
| def sample_func():
yield "Hello"
yield "world"
gen = sample_func()
print(gen)
|
1
| <generator object sample_func at 0x000001BE7105DFE0>
|
1
2
3
4
5
6
7
8
| def sample_func():
yield "Hello"
yield "world"
gen = sample_func()
for it in gen:
print(it)
|
1
2
3
4
5
6
7
8
| def sample_func():
yield "Hello"
yield "world"
gen = sample_func()
print(next(gen))
print(next(gen))
|
yield와 return의 공존
그렇다면 yield와 return은 공존할 수 있을까? 결론적으로는 하나의 함수 안에 return과 yield를 모두 사용할 수 있다. 둘의 역할은 다르기 때문에 둘을 배타적인 개념으로 보면 옳지 않다.
- yield가 하나라도 있으면 그 함수는 무조건 제너레이터가 된다.
- 제너레이터 안의 return은, 제너레이터가 종료된다는 종료신호를 의미한다.
1
2
3
4
5
6
7
8
9
10
| def mixed_generator():
yield 1
yield 2
return "끝났습니다" # 제너레이터 종료 신호
yield 3 # 이 코드는 절대 실행되지 않음
gen = mixed_generator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # StopIteration: 끝났습니다 (에러 발생하며 종료)
|
1
2
3
4
5
6
7
8
9
10
| 1
2
---------------------------------------------------------------------------
StopIteration Traceback (most recent call last)
Cell In[12], line 10
8 print(next(gen)) # 1
9 print(next(gen)) # 2
---> 10 print(next(gen))
StopIteration: 끝났습니다
|
뜻과 어원
뜻과 어원
- yield는 양보하다, 제품 등을 산출하다, 이익을 낳다, 굴복하다 등의 뜻을 가진다.
- 이는 중세 영어 yelden 으로부터 비롯되었다. : 비자발적으로 포기하다, 지배나 지시에 복종하다.
- 더 거슬러 올라가면 고대 영어 gieldan/geldan : 지불하다, 보상하다, 숭배하다, 희생하다.
왜 yield 라는 키워드명이 선택되었나?
- 프로그래밍을 하면서 가장 묘한 의미를 가진 키워드로 생각된다.
- 대체로 “양보하다”라는 의미로 사용되었다고 생각한다.
- 함수가 실행되다가
yield를 만나면, CPU 실행 권한(제어권)을 함수 외부의 호출자에게 양보한다.
내가 여기까지 계산했고, 일단 이 값을 네게 줄게. 네가 할 일(루프 등)을 다 하고 나서 다시 나를 불러줘
yield를 사용하는 이유(장단점)
장점
- 메모리 효율성 : 예로, 100만개 정수를 리스트로 만들면 약 8MB -> yield 적용시 192 바이트 가량
- 응답성 : 데이터가 전부 준비(로딩)될 때까지 기다릴 필요 없이, 첫 번째 데이터가 생성되는 즉시 처리 시작 가능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # 메모리 비교
import sys
# 1. 리스트 방식 (모든 데이터를 메모리에 즉시 할당)
list_data = [i for i in range(1000000)]
# 2. 제너레이터 방식 (데이터 생성 규칙만 저장)
generator_data = (i for i in range(1000000))
# 결과 출력
print(f"리스트의 메모리 크기: {sys.getsizeof(list_data)} 바이트")
print(f"제너레이터의 메모리 크기: {sys.getsizeof(generator_data)} 바이트")
# 리스트 크기를 MB 단위로 환산하면 약 8MB 정도 됩니다.
# 반면 제너레이터는 데이터 양에 상관없이 항상 일정한 크기를 유지합니다.
|
1
2
| 리스트의 메모리 크기: 8448728 바이트
제너레이터의 메모리 크기: 192 바이트
|
제너레이터 표현식 : 소괄호로 반복객체를 감싸면 제너레이터 객체가 됨
단점
제너레이터 객체는 단순히 다음 값 외로 제너레이터 객체 자체의 속성정보, 로컬 변수의 스택, 포인터 등을 담기 때문에 소량의 메모리가 필요하다. 따라서 소수의 요소가 있는 경우, 제너레이터가 메모리를 더 사용할 수도 있다. 물론 그 차이는 미미하다.
1
2
3
4
5
6
7
8
9
10
11
| import sys
# 1. 소수의 리스트
few_list = [1, 2, 3]
# 2. 제너레이터 방식
generator_data = (i for i in range(1))
# 결과 출력
print(f"정수형 한개의 메모리 크기: {sys.getsizeof(few_list)} 바이트")
print(f"제너레이터의 메모리 크기: {sys.getsizeof(generator_data)} 바이트")
|
1
2
| 정수형 한개의 메모리 크기: 88 바이트
제너레이터의 메모리 크기: 192 바이트
|
이터레이터의 연산 관점에서, 리스트가 제너레이터보다 빠르다. 그 이유는 리스트는 이미 메모리(RAM)에 연속적으로 배치된 상태이며, CPU는 이들을 일련적으로 처리만 하면 된다. 반면, 제너레이터는 next()를 호출할 때마다 함수 상태 복구, 코드 실행, 값 계산, 상태 저장의 작업이 동반되기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| import sys
import time
# 이터레이터 내의 모든 값을 더하는 함수
def sum_func(iter):
start = time.time()
result = 0
for it in iter:
result += it
print(f"결과값 : {result}")
print(f"소요시간 : {time.time() - start}\n")
# 반복 횟수
num = 50000000
# 1. 리스트 방식 (모든 데이터를 메모리에 즉시 할당)
list_data = [i for i in range(num)]
sum_func(list_data)
# 2. 제너레이터 방식 (데이터 생성 규칙만 저장)
generator_data = (i for i in range(num))
sum_func(generator_data)
|
1
2
3
4
5
| 결과값 : 1249999975000000
소요시간 : 1.740260124206543
결과값 : 1249999975000000
소요시간 : 3.548307418823242
|
전체 연산을 내는 속도는 리스트가 빠르지만, 첫 값을 내는 작업은 제너레이터가 빠르다.
제너레이터의 특징 중 하나로, 한 번 순회하면 다시 사용할 수 없다는 한계가 있다. 리스트는 바로 재사용이 가능한 반면, 제너레이터는 다시 쓰려면 객체를 새로 생성해야 한다는 한계가 있다.
마찬가지로 제너레이터의 특징 중 하나로, gen[0] 처럼 특정 위치의 값을 바로 가져올 수 없고, 반드시 순차적으로 접근해야 한다.
yield를 사용하는 경우
- 대용량 파일이나 데이터의 처리 : 메모리의 효율성 확보 가능
- 끝을 알 수 없는 시퀀스를 처리할 때 : 챗봇의 stream 형식 출력, 피보나치 수열, 소수값 찾기 등 정확히 몇개의 요소를 가진 이터레이터인지 모를 때
- 네트워크 스트리밍 및 API 데이터 : 모든 데이터를 다 받고 처리하는 것보다, 먼저 도착한 데이터부터 처리해 보여주면 사용자 경험 향상
Reference
https://en.dict.naver.com/#/entry/enko/0d1cc99a97c14dc981ba2af2b1a58e73
https://www.etymonline.com/search?q=yield
https://www.w3schools.com/python/ref_keyword_yield.asp
Comments