C++ Generator로 지연 시퀀스 만들기: co_yield, 무한 수열, filter·map·take 파이프라인
들어가며
0부터 100만까지 정수가 필요한데 실제로는 앞 10개만 쓰고 끝내는 경우를 생각해 보겠습니다. std::vector<int>에 전부 채우면 약 4MB가 한 번에 할당되고, 99만 9,990개는 계산만 하고 버려집니다. 큰 로그 파일에서 조건에 맞는 줄 몇 개만 찾거나, 피보나치·소수처럼 끝이 없는 수열을 다룰 때는 이 방식이 아예 성립하지 않습니다.
제너레이터(Generator)는 값을 요청받을 때마다 하나씩 만들어 주는 지연(lazy) 시퀀스입니다. C++20 코루틴의 co_yield로 “값 하나를 내보내고 멈춘다”를 표현하면, 호출자가 다음 값을 요청할 때까지 나머지 계산이 미뤄집니다. 이 글에서는 Generator 타입을 직접 구현하고, 무한 시퀀스와 filter·map·take 조합, 수명(lifetime) 관련 실수, vector와의 성능 차이, 파일 스트리밍 예제를 차례로 다룹니다.
예제는 C++20(g++ -std=c++20 또는 clang++ -std=c++20) 기준입니다. C++23 이상이라면 표준 std::generator(<generator>)를 쓸 수 있습니다.
벡터로 시퀀스를 만들 때의 메모리 문제와 Generator
벡터 방식의 한계
대용량 시퀀스를 만들 때 std::vector를 쓰면 전체를 한 번에 메모리에 올립니다.
// ❌ 100만 개 전부 할당 — 앞 10개만 쓰는데 4MB 사용
std::vector<int> createSequence(int n) {
std::vector<int> vec;
vec.reserve(n);
for (int i = 0; i < n; ++i) {
vec.push_back(i);
}
return vec;
}
int main() {
auto seq = createSequence(1'000'000);
for (int i = 0; i < 10; ++i) { // 10개만 사용
process(seq[i]);
}
// 99만 9,990개는 메모리에 그대로 — 낭비
}
이 방식은 n에 비례하는 메모리를 쓰고, 시퀀스 전체가 만들어질 때까지 첫 값을 쓸 수 없으며, 앞부분만 쓰면 나머지 계산은 모두 낭비가 됩니다.
Generator로 해결
Generator는 호출할 때마다 다음 값 하나만 만들어 줍니다. 필요한 만큼만 계산하며, 나머지는 생성하지 않습니다.
// ✅ Generator: 필요한 만큼만 계산
Generator<int> count(int n) {
for (int i = 0; i < n; ++i) {
co_yield i; // i를 넘기고 일시 정지
}
}
int main() {
auto gen = count(1'000'000);
for (int i = 0; i < 10; ++i) {
auto opt = gen.next(); // 10번만 계산
if (opt) process(*opt);
}
// 나머지 99만 9,990개는 계산하지 않음
}
Generator는 코루틴 프레임 하나와 현재 값만 유지하므로 메모리가 시퀀스 길이와 무관하고, 첫 값을 바로 꺼낼 수 있으며, 요청하지 않은 값은 계산하지 않습니다.
시각화
flowchart TB
subgraph vector[벡터 방식]
V1[createSequence 호출] --> V2[100만 개 전부 할당]
V2 --> V3[메모리 ~4MB]
V3 --> V4[10개만 사용]
V4 --> V5[99만 9,990개 낭비]
end
subgraph generator[Generator 방식]
G1[count 호출] --> G2[첫 co_yield까지 대기]
G2 --> G3[next 10번 호출]
G3 --> G4[10개만 계산]
G4 --> G5[나머지 미계산]
end
같은 구조는 트리 순회처럼 방문 순서대로 노드를 내보내는 경우나, primes()에서 조건을 만족하는 첫 소수를 찾을 때까지만 계산하는 조기 종료에도 그대로 쓰입니다. 다만 이 글의 Generator는 동기식입니다. 값이 네트워크나 센서에서 비동기로 도착한다면 co_await를 지원하는 비동기 제너레이터가 필요합니다.
Lazy 평가: 필요할 때만 값을 만든다
Lazy vs Eager
Eager(즉시 평가): 시퀀스를 한 번에 전부 계산합니다. vector에 push_back으로 채우는 방식이 여기에 해당합니다.
Lazy(지연 평가): 값이 필요할 때만 계산합니다. co_yield는 “이 값을 넘기고 일시 정지”하므로, 호출자가 다음 값을 요청할 때까지 다음 계산이 미뤄집니다.
// Eager: 100만 개 전부 계산 후 반환
std::vector<int> eager = createSequence(1'000'000); // 즉시 100만 개 생성
// Lazy: 10개만 요청하면 10개만 계산
auto gen = count(1'000'000);
for (int i = 0; i < 10; ++i) {
auto v = gen.next(); // i번째 요청 시에만 i번째 값 계산
}
Lazy 평가 흐름
sequenceDiagram
participant C as 호출자
participant G as Generator
C->>G: next() 1회
G->>G: co_yield 0
G-->>C: 0 반환, 일시 정지
C->>C: 0 사용
C->>G: next() 2회
G->>G: co_yield 1
G-->>C: 1 반환
Note over G: 2, 3, 4... 는 아직 계산 안 함
count(1'000'000)을 호출해도 100만 개를 만들지 않습니다. next()를 10번 호출하면 10개만 계산하며, 나머지는 생성되지 않습니다.
Lazy의 장점
| 항목 | Eager (vector) | Lazy (Generator) |
|---|---|---|
| 메모리 | O(n) | O(1) |
| 초기 지연 | 전체 생성 완료까지 | 첫 값만 |
| 조기 종료 | 나머지도 이미 계산됨 | 요청 안 하면 미계산 |
| 무한 시퀀스 | 불가 | 가능 |
promise_type과 iterator로 Generator 구현하기
promise_type 설계
Generator의 동작은 promise_type으로 제어합니다. 이전 글(#23-1)에서 다룬 코루틴 기초를 바탕으로, co_yield에 맞는 promise를 정의합니다.
#include <coroutine>
#include <exception>
#include <optional>
template <typename T>
class Generator {
public:
struct promise_type {
T current_value;
std::exception_ptr exception;
// co_yield value 호출 시: 값을 저장하고 일시 정지
std::suspend_always yield_value(T value) {
current_value = std::move(value);
return {};
}
// 코루틴 진입 직후 일시 정지 — 첫 next()에서 resume
std::suspend_always initial_suspend() { return {}; }
// co_return 후 일시 정지 — 호출자가 done() 확인 가능
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { exception = std::current_exception(); }
Generator get_return_object() {
return Generator{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}
};
// ... (아래 계속)
};
co_yield v는 컴파일러가 co_await promise.yield_value(v)로 바꿔 부르므로, yield_value가 값을 저장하고 suspend_always를 돌려주면 그 자리에서 멈춥니다. initial_suspend가 suspend_always이면 코루틴은 만들어지자마자 멈춰 있다가 첫 resume()에서야 본문을 실행합니다. final_suspend도 멈추게 해 두어야 끝난 뒤에도 프레임이 남아 done()을 확인하고 소멸자에서 destroy()할 수 있습니다. 이 구현은 current_value를 멤버로 들고 있으므로 T가 기본 생성 가능한 값 타입이어야 합니다.
iterator와 범위 기반 for
begin()/end()를 제공하면 범위 기반 for로 사용할 수 있습니다.
struct iterator {
std::coroutine_handle<promise_type> handle;
bool done;
iterator(std::coroutine_handle<promise_type> h, bool d)
: handle(h), done(d) {}
T operator*() const {
return handle.promise().current_value;
}
iterator& operator++() {
handle.resume();
if (handle.promise().exception)
std::rethrow_exception(handle.promise().exception);
done = handle.done();
return *this;
}
bool operator!=(const iterator& other) const {
return done != other.done;
}
};
explicit Generator(std::coroutine_handle<promise_type> h) : handle_(h) {}
~Generator() {
if (handle_) handle_.destroy();
}
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& other) noexcept : handle_(other.handle_) {
other.handle_ = nullptr;
}
Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (handle_) handle_.destroy();
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}
iterator begin() {
handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
return iterator{handle_, handle_.done()};
}
iterator end() { return iterator{handle_, true}; }
private:
std::coroutine_handle<promise_type> handle_;
};
next() 인터페이스 (선택)
next()로 값을 하나씩 꺼내는 인터페이스도 추가할 수 있습니다.
std::optional<T> next() {
if (!handle_ || handle_.done()) return std::nullopt;
handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
if (handle_.done()) return std::nullopt;
return handle_.promise().current_value;
}
완전한 예제
// g++ -std=c++20 -o generator_basic generator_basic.cpp && ./generator_basic
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
template <typename T>
class Generator {
public:
struct promise_type {
T current_value;
std::exception_ptr exception;
std::suspend_always yield_value(T value) {
current_value = std::move(value);
return {};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { exception = std::current_exception(); }
Generator get_return_object() {
return Generator{
std::coroutine_handle<promise_type>::from_promise(*this)};
}
};
struct iterator {
std::coroutine_handle<promise_type> handle;
bool done;
iterator(std::coroutine_handle<promise_type> h, bool d)
: handle(h), done(d) {}
T operator*() const { return handle.promise().current_value; }
iterator& operator++() {
handle.resume();
if (handle.promise().exception)
std::rethrow_exception(handle.promise().exception);
done = handle.done();
return *this;
}
bool operator!=(const iterator& other) const {
return done != other.done;
}
};
explicit Generator(std::coroutine_handle<promise_type> h) : handle_(h) {}
~Generator() { if (handle_) handle_.destroy(); }
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
Generator(Generator&& other) noexcept : handle_(other.handle_) {
other.handle_ = nullptr;
}
Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (handle_) handle_.destroy();
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}
iterator begin() {
handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
return iterator{handle_, handle_.done()};
}
iterator end() { return iterator{handle_, true}; }
std::optional<T> next() {
if (!handle_ || handle_.done()) return std::nullopt;
handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
if (handle_.done()) return std::nullopt;
return handle_.promise().current_value;
}
private:
std::coroutine_handle<promise_type> handle_;
};
Generator<int> count(int n) {
for (int i = 0; i < n; ++i) {
co_yield i;
}
}
int main() {
for (int x : count(5)) {
std::cout << x << " "; // 0 1 2 3 4
}
std::cout << "\n";
}
실행 결과: 0 1 2 3 4
co_yield 흐름 다이어그램
sequenceDiagram
participant C as 호출자
participant G as Generator
participant P as promise_type
participant Co as 코루틴
C->>G: begin()
G->>Co: handle_.resume()
Co->>P: yield_value(0)
P-->>Co: suspend_always
Co-->>G: 일시 정지
G-->>C: iterator(0 반환)
C->>C: *it → 0
C->>G: ++it
G->>Co: handle_.resume()
Co->>P: yield_value(1)
Note over Co: ... 반복 ...
무한 카운터와 소수 생성기
끝이 없는 시퀀스
Generator는 끝이 없는 시퀀스도 표현할 수 있습니다. co_return이 없으면 이론적으로 무한히 co_yield할 수 있으며, 호출하는 쪽에서 필요한 만큼만 꺼내 쓰면 됩니다.
// 무한 피보나치 수열
Generator<long long> fibonacci() {
long long a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
int main() {
int count = 0;
for (long long x : fibonacci()) {
std::cout << x << " ";
if (++count >= 15) break; // 15개만 출력
}
// 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377
}
무한 카운터
Generator<int> counter(int start = 0) {
int i = start;
while (true) {
co_yield i++;
}
}
// 사용: zip 등에 활용
auto gen = counter(10);
auto it = gen.begin();
for (int i = 0; i < 5; ++i) {
std::cout << *it << " ";
++it;
}
// 10 11 12 13 14
소수 생성기
Generator<int> primes() {
co_yield 2;
for (int n = 3; ; n += 2) {
bool is_prime = true;
for (int d = 3; d * d <= n; d += 2) {
if (n % d == 0) { is_prime = false; break; }
}
if (is_prime) co_yield n;
}
}
// 처음 10개 소수
int n = 0;
for (int p : primes()) {
std::cout << p << " ";
if (++n >= 10) break;
}
// 2 3 5 7 11 13 17 19 23 29
주의사항
무한 Generator를 범위 기반 for에 그대로 넣으면 무한 루프가 됩니다. 반드시 break나 개수 제한을 두어야 합니다.
// ❌ 위험: 무한 루프
for (auto x : fibonacci()) {
process(x); // 절대 끝나지 않음
}
// ✅ 안전: 개수 제한
int n = 0;
for (auto x : fibonacci()) {
process(x);
if (++n >= 100) break;
}
filter, map, take, zip으로 제너레이터 조합
filter: 조건에 맞는 값만
다른 Generator를 받아 조건을 만족하는 값만 co_yield하는 Generator를 만들 수 있습니다.
template <typename T, typename Pred>
Generator<T> filter(Generator<T> src, Pred pred) {
for (T x : src) {
if (pred(x)) co_yield x;
}
}
// 사용: 짝수만
auto nums = count(20);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });
for (int x : evens) {
std::cout << x << " "; // 0 2 4 6 8 10 12 14 16 18
}
map: 값 변환
template <typename T, typename F>
Generator<std::invoke_result_t<F, T>> map(Generator<T> src, F f) {
for (T x : src) {
co_yield f(x);
}
}
// 사용: 제곱
auto nums = count(5);
auto squares = map(std::move(nums), [](int x) { return x * x; });
for (int x : squares) {
std::cout << x << " "; // 0 1 4 9 16
}
파이프라인
filter와 map을 연결해 파이프라인을 구성할 수 있습니다.
// count(100) → 짝수만 → 제곱 → 10개만
auto nums = count(100);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });
auto squares = map(std::move(evens), [](int x) { return x * x; });
int n = 0;
for (int x : squares) {
std::cout << x << " ";
if (++n >= 10) break;
}
// 0 4 16 36 64 100 144 196 256 324
take: 상위 N개만
template <typename T>
Generator<T> take(Generator<T> src, size_t n) {
if (n == 0) co_return;
size_t count = 0;
for (T x : src) {
co_yield x;
if (++count >= n) break; // n번째 값을 내보낸 직후 멈춤
}
}
// 사용
for (int x : take(count(1000), 5)) {
std::cout << x << " "; // 0 1 2 3 4
}
검사를 co_yield 앞에 두고 if (count++ >= n) break;로 쓰면 출력 개수는 맞지만, 멈추기 위해 원본에서 n+1번째 값을 한 번 더 꺼냅니다. 원본이 파일 읽기나 비싼 계산이면 불필요한 일이고, 무한 원본에서는 문제가 없더라도 의도와 다르게 동작하는 코드입니다. 위처럼 값을 내보낸 직후에 개수를 확인하면 정확히 n번만 원본을 재개합니다.
zip: 두 시퀀스 병합
template <typename T, typename U>
Generator<std::pair<T, U>> zip(Generator<T> a, Generator<U> b) {
auto it_a = a.begin();
auto it_b = b.begin();
auto end_a = a.end();
auto end_b = b.end();
while (it_a != end_a && it_b != end_b) {
co_yield {*it_a, *it_b};
++it_a;
++it_b;
}
}
Generator<char> letters(char from, char to) {
for (char c = from; c <= to; ++c) co_yield c;
}
// 사용: 짧은 쪽에서 멈춤
for (auto [n, c] : zip(count(5), letters('a', 'c'))) {
std::cout << "(" << n << "," << c << ") "; // (0,a) (1,b) (2,c)
}
enumerate, distinct
enumerate는 Python의 enumerate()처럼 인덱스와 값을 쌍으로 내보내고, distinct는 이미 본 값을 std::unordered_set(<unordered_set>)에 기록해 중복을 거릅니다. distinct는 지연 평가이긴 하지만 서로 다른 값의 개수만큼 메모리를 쓰므로, 무한 시퀀스에 걸면 메모리가 계속 늘어납니다.
template <typename T>
Generator<std::pair<size_t, T>> enumerate(Generator<T> src) {
size_t idx = 0;
for (T x : src) co_yield {idx++, x};
}
template <typename T>
Generator<T> distinct(Generator<T> src) {
std::unordered_set<T> seen;
for (T x : src)
if (seen.insert(x).second) co_yield x;
}
dangling 참조, 이동 후 사용, 핸들 수명
Dangling 참조 (죽은 참조)
코루틴은 호출되면 프레임을 만들고 initial_suspend에서 바로 멈춥니다. 매개변수는 프레임에 복사되지만, 매개변수 자체가 참조나 string_view이면 가리키는 대상까지 복사되지는 않습니다. 대상이 임시 객체라면 호출식이 끝날 때 소멸하고, 나중에 본문이 실행될 때는 이미 사라진 메모리를 읽게 됩니다.
// ❌ 위험: s는 임시 string을 가리키는 view
Generator<std::string_view> splitBad(std::string_view s) {
size_t pos = 0;
while (pos < s.size()) {
auto end = s.find(' ', pos);
if (end == std::string_view::npos) end = s.size();
co_yield s.substr(pos, end - pos);
pos = end == s.size() ? end : end + 1;
}
}
auto parts = splitBad(std::string("a b c")); // 임시 string은 이 줄 끝에서 소멸
for (auto part : parts) {
// 본문이 처음 실행되는 시점에 s는 이미 dangling — UB
}
// ✅ 안전: 문자열을 값으로 받아 프레임이 소유
Generator<std::string> splitGood(std::string s) {
size_t pos = 0;
while (pos < s.size()) {
auto end = s.find(' ', pos);
if (end == std::string::npos) end = s.size();
co_yield s.substr(pos, end - pos);
pos = end == s.size() ? end : end + 1;
}
}
범위 기반 for의 초기화식 안에서 바로 splitBad(std::string("a b c"))를 쓰는 경우는 표준 버전에 따라 결과가 다릅니다. C++20까지는 초기화식의 임시 객체 중 범위 객체(여기서는 Generator)만 수명이 연장되어 임시 string은 루프 전에 소멸하고, C++23(P2718)부터는 초기화식의 모든 임시 객체가 루프 끝까지 연장됩니다. 어느 쪽이든 매개변수를 값으로 받으면 고민할 필요가 없습니다.
Generator 이동 후 사용
Generator는 이동 전용입니다. filter나 map에 넘긴 뒤 원본을 다시 쓰면 안 됩니다.
auto nums = count(10);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });
// nums의 handle_은 nullptr
for (int x : nums) { } // ❌ UB: begin()이 빈 핸들을 resume
for (int x : evens) { } // ✅ OK
핸들 수명 관리
coroutine_handle은 프레임을 가리키는 포인터일 뿐 소유권이 없습니다. 핸들을 따로 복사해 두고 Generator 객체가 소멸한 뒤 resume하면, 소멸자가 이미 destroy()한 프레임에 접근하게 됩니다.
// ❌ 위험 (설명을 위해 handle()이라는 접근자가 있다고 가정)
std::coroutine_handle<> dangerous;
{
auto gen = count(5);
dangerous = gen.handle();
} // gen 소멸 → destroy() 호출
dangerous.resume(); // UB: 이미 해제된 프레임
// ✅ 안전: Generator 객체가 살아 있는 동안만 사용
auto gen = count(5);
for (int x : gen) { /* ... */ }
iterator 무효화
iterator는 핸들을 복사해 들고 있으므로, 순회 도중 Generator에 다른 값을 이동 대입하면 기존 프레임이 파괴되고 iterator가 무효화됩니다.
auto gen = count(5);
auto it = gen.begin();
gen = count(10); // 이동 대입 — 이전 프레임 destroy, it의 handle은 dangling
++it; // ❌ UB
참조 타입을 내보내고 싶을 때
이 글의 구현은 T current_value;를 멤버로 들고 있어서 Generator<const std::string&>처럼 참조 타입을 넣으면 컴파일되지 않습니다. 복사를 피하고 싶다면 C++23 std::generator를 쓰는 편이 낫습니다. std::generator<const std::string&>는 co_yield local;로 프레임 안 지역 변수의 참조를 그대로 내보내는데, 이 참조는 다음 재개 전까지만 유효합니다. 따라서 호출자가 참조를 저장해 두었다가 다음 값을 꺼낸 뒤 쓰면 덮어쓰였거나 소멸한 값을 보게 됩니다. 값을 오래 보관할 필요가 있으면 받는 쪽에서 복사해야 합니다.
반복 begin() 호출
begin()은 호출할 때마다 코루틴을 한 번 재개합니다. 두 번 부르면 첫 값을 건너뛰고, 두 iterator가 같은 프레임을 공유하므로 순회가 꼬입니다. 범위 기반 for는 begin()을 한 번만 부르므로 안전합니다.
// ❌ 위험: begin() 두 번 호출
auto gen = count(5);
auto it1 = gen.begin(); // 0까지 실행
auto it2 = gen.begin(); // 다시 재개되어 1까지 실행, it1도 같은 프레임을 봄
// ✅ 안전
for (int x : count(5)) { /* ... */ }
재귀 Generator의 비용
트리 DFS를 for (auto x : dfs(child)) co_yield x;처럼 재귀로 쓰면 깊이 d마다 Generator가 하나씩 중첩됩니다. 프레임은 힙에 있지만, 값 하나를 꺼낼 때마다 가장 바깥 Generator부터 가장 안쪽까지 resume 호출이 연쇄되므로 호출 스택이 깊이만큼 쌓이고 값당 비용도 O(d)가 됩니다. 깊은 트리에서는 명시적 스택을 쓰는 반복 버전이 안전합니다. C++23 std::generator는 co_yield std::ranges::elements_of(dfs(child));로 중첩 제너레이터를 직접 이어 붙이는 기능을 제공해 이 연쇄 비용을 줄여 줍니다.
Generator를 설계할 때 지킬 원칙
| 항목 | 권장 | 비권장 |
|---|---|---|
| 타입 | 값 타입 Generator<std::string> | 참조 Generator<std::string_view> (lifetime 위험) |
| 무한 시퀀스 | take() 또는 루프 내 break | 상한 없이 순회 |
| 이동 | filter(std::move(nums), pred) 후 nums 미사용 | 이동 후 원본 재사용 |
| 예외 | 호출 측 try/catch | 예외 무시 |
| 성능 | 파이프라인 단계 최소화, co_yield std::move(x) | filter→map→take 과다 연결 |
성능 비교: generator vs vector
시나리오: 0 ~ 1,000,000 정수 시퀀스
| 방식 | 메모리 | 초기 지연 | 10개만 사용 시 |
|---|---|---|---|
| vector<int> | 약 4MB (100만 × 4바이트) | 전체 생성 완료까지 | 100만 개 모두 계산, 4MB 유지 |
| Generator<int> | 코루틴 프레임 하나 (크기는 컴파일러가 결정, 이 예제는 작음) | 거의 없음 | 10개만 계산 |
벤치마크 예시
#include <chrono>
#include <numeric>
#include <vector>
// 벡터: 100만 개 전부 할당
void bench_vector() {
auto start = std::chrono::steady_clock::now();
std::vector<int> vec(1'000'000);
std::iota(vec.begin(), vec.end(), 0);
int sum = 0;
for (int i = 0; i < 10; ++i) sum += vec[i];
auto end = std::chrono::steady_clock::now();
// 할당 + 100만 번 쓰기 + 10개 접근
}
// Generator: 10개만 계산
void bench_generator() {
auto start = std::chrono::steady_clock::now();
auto gen = count(1'000'000);
int sum = 0;
int n = 0;
for (int x : gen) {
sum += x;
if (++n >= 10) break;
}
auto end = std::chrono::steady_clock::now();
// 프레임 할당 한 번 + resume 10번
}
절대값은 환경에 따라 다르지만, 연산 횟수로 보면 차이는 분명합니다. vector는 4MB 할당과 100만 번의 쓰기(iota)가 끝나야 첫 원소를 읽을 수 있으므로 시간이 전체 원소 수에 비례합니다. Generator는 프레임 할당 한 번과 재개 10번만 일어나므로 시퀀스 길이와 거의 무관합니다. 대신 원소 하나를 꺼낼 때마다 재개 비용(간접 호출과 상태 저장)이 있어, 전체를 순회한다면 vector가 더 빠를 수 있습니다. 직접 잴 때는 결과(sum)를 출력하는 등으로 사용하지 않으면 최적화로 루프 자체가 사라질 수 있으니 주의해야 합니다.
언제 어떤 방식을 쓸까?
| 상황 | 권장 |
|---|---|
| 전체 순회가 확정, 여러 번 순회 | vector (캐시 친화적, 단순) |
| 앞 N개만 필요 | Generator |
| 무한/메모리에 다 올릴 수 없는 시퀀스 | Generator |
| 요소를 하나씩 처리하고 버릴 수 있음 | Generator |
| 랜덤 접근 필요 | vector |
성능을 위해 고려할 점
co_yield할 때마다 값이 promise의 current_value로 옮겨집니다. int, double 같은 작은 타입은 비용이 거의 없고, std::string처럼 큰 타입은 co_yield std::move(str)로 넘기면 복사 대신 이동이 일어납니다.
Generator<std::string> lines(std::istream& in) {
std::string line;
while (std::getline(in, line)) {
co_yield std::move(line); // 이동 후 line은 getline이 다시 채움
}
}
filter → map → take처럼 여러 Generator를 연결하면, 값 하나마다 단계 수만큼 코루틴이 resume/suspend를 반복합니다. 단계가 많고 값이 작다면 한 Generator에서 조건을 함께 처리하는 편이 빠릅니다.
// 3단계: 값마다 filter, map, take가 각각 재개됨
auto r = take(map(filter(count(100), pred), f), 10);
// 1단계: 한 Generator에서 처리
Generator<int> combined() {
int n = 0;
for (int i = 0; i < 100 && n < 10; ++i) {
if (pred(i)) { co_yield f(i); ++n; }
}
}
코루틴 프레임은 기본적으로 힙에 할당됩니다. 컴파일러는 Generator의 수명이 호출자 범위 안에 있고 코루틴이 인라인될 수 있으면 이 힙 할당을 생략할 수 있는데(HALO, heap allocation elision), 최적화 수준과 컴파일러에 따라 적용 여부가 다르고 Clang이 비교적 적극적으로 수행합니다. 최소한 -O2 이상으로 빌드하고, 핫 루프라면 실제로 할당이 사라졌는지 프로파일러로 확인하는 것이 좋습니다.
벤치마크를 작성할 때는 “첫 값까지의 지연”과 “값 하나당 비용”을 따로 재는 것이 좋습니다. 전체 순회 시간 하나만 보면 두 효과가 섞여 어느 쪽이 유리한지 판단하기 어렵습니다.
파일 라인, CSV, 네트워크 청크 스트리밍
파일 라인별 읽기
대용량 파일을 한 줄씩 읽을 때 Generator를 쓰면 메모리에 한 줄만 유지하면서 범위 기반 for로 처리할 수 있습니다.
#include <fstream>
#include <stdexcept>
#include <string>
// path를 값으로 받습니다. const std::string&로 받으면 readLines("huge.log")에서
// 만들어진 임시 string이 본문 실행 전에 소멸해 dangling이 됩니다.
Generator<std::string> readLines(std::string path) {
std::ifstream f(path);
if (!f) {
throw std::runtime_error("Cannot open: " + path);
}
std::string line;
while (std::getline(f, line)) {
co_yield line;
}
}
// 사용: 큰 로그 파일도 한 줄씩만 메모리에
for (const auto& line : readLines("huge.log")) {
if (line.find("ERROR") != std::string::npos) {
processError(line);
}
}
initial_suspend가 suspend_always이므로 throw는 readLines()를 호출할 때가 아니라 첫 begin()에서 본문이 실행될 때 일어나고, unhandled_exception이 저장한 예외가 begin()에서 다시 던져집니다. 파일 열기 실패를 호출 시점에 알고 싶다면 파일을 바깥에서 열어 스트림을 넘기는 방법도 있습니다.
CSV 파싱 스트리밍
Generator<std::vector<std::string>> parseCsv(std::string path) {
std::ifstream f(path);
std::string line;
while (std::getline(f, line)) {
std::vector<std::string> row;
// 간단한 CSV 파싱 (쉼표 구분, 따옴표 처리 없음)
size_t pos = 0;
while (pos < line.size()) {
auto end = line.find(',', pos);
if (end == std::string::npos) end = line.size();
row.push_back(line.substr(pos, end - pos));
pos = end == line.size() ? end : end + 1;
}
co_yield std::move(row);
}
}
네트워크 청크 스트리밍
// 개념적 예: Socket은 호출자가 소유하고, Generator보다 오래 살아 있어야 합니다
Generator<std::vector<std::byte>> receiveChunks(Socket& sock, size_t chunk_size) {
std::vector<std::byte> buffer(chunk_size);
while (true) {
size_t n = sock.read(buffer.data(), chunk_size);
if (n == 0) break;
buffer.resize(n);
co_yield std::move(buffer);
buffer.resize(chunk_size); // 이동 후 상태와 무관하게 다시 크기를 맞춤
}
}
배치 처리 파이프라인
// 로그 파일 → 필터(ERROR만) → 파싱 → 상위 100개
auto lines = readLines("app.log");
auto errors = filter(std::move(lines),
[](const std::string& s) {
return s.find("ERROR") != std::string::npos;
});
auto parsed = map(std::move(errors), parseLogLine);
int count = 0;
for (const auto& entry : parsed) { // operator*가 값을 반환하므로 auto&는 컴파일되지 않음
handleError(entry);
if (++count >= 100) break;
}
Generator는 호출자가 값을 요청할 때만 다음 값을 만드는 pull 방식이라, 소비가 느리면 생산도 자연히 느려집니다. 별도의 백프레셔 장치 없이도 읽기 속도가 처리 속도에 맞춰지는 것은 이 구조의 장점입니다. 반대로 파싱 실패 줄을 건너뛸지 전체를 중단할지는 parseLogLine이 예외를 던지는지, std::optional을 돌려주는지로 정해야 하는 설계 결정입니다.
참고 자료
같이 보면 좋은 글
- C++20 코루틴 기초: co_await·co_yield와 Generator 구현
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ 캐시 친화 코드: AoS vs SoA, 데이터 지향 설계, False Sharing
- C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
- C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]
- C++20 Modules
- 이전 글: C++ 코루틴 기초 (#23-1)
- 다음 글: C++ 비동기 코루틴 (#23-3)