이진 탐색 트리의 원소를 정렬 순서로 처리하고 싶다. 재귀 함수 하나면 순회는 쉽지만, 두 트리를 한 걸음씩 번갈아 읽으며 병합하거나 원하는 값에서 멈추려면 순회의 “현재 위치”를 함수 밖에서 들고 있어야 한다. 반복자(Iterator)는 컬렉션의 내부 표현을 드러내지 않고 원소에 차례로 접근하는 방법을 제공하며, 순회 상태를 컬렉션과 분리한다. 이 글에서는 GoF가 구분한 내부·외부 반복자, C++20 코루틴으로 만드는 제너레이터, 그리고 C의 readdir와 커널식 list_for_each_entry를 다룬다.
GoF 반복자 인터페이스, STL 스타일 반복자, concept·ranges, 순회 비용, 반복자 무효화는 설계 기법 (11) 반복자에서 다뤘다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (13) — 책임 연쇄(Chain of Responsibility)
- C/C++ 디자인 패턴 (14) — 커맨드(Command)
- C/C++ 디자인 패턴 (16) — 중재자(Mediator)
- C/C++ 디자인 패턴 (17) — 메멘토(Memento)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Iterator | 원소 접근·이동 인터페이스 | done(), current(), next() |
| ConcreteIterator | 순회 상태(현재 위치) 보관 | InorderIterator(스택), 코루틴 프레임 |
| Aggregate | 반복자를 만드는 컬렉션 | 이진 탐색 트리, 디렉터리 |
// tree.hpp — 순회 대상: 이진 탐색 트리
#pragma once
#include <memory>
struct TreeNode {
int key;
std::unique_ptr<TreeNode> left, right;
explicit TreeNode(int k) : key(k) {}
};
inline void insert(std::unique_ptr<TreeNode> &n, int k)
{
if (!n) { n = std::make_unique<TreeNode>(k); return; }
insert(k < n->key ? n->left : n->right, k);
}내부 반복자와 외부 반복자
// internal_external.cpp — 내부 반복자(컬렉션이 돌린다) vs 외부 반복자(클라이언트가 돌린다)
#include <iostream>
#include <stack>
#include "tree.hpp"
// 내부 반복자: 순회 로직은 컬렉션에, 클라이언트는 원소마다 할 일만 넘긴다
template <class F>
void for_each_inorder(const TreeNode *n, F &&f)
{
if (!n) return;
for_each_inorder(n->left.get(), f);
f(n->key);
for_each_inorder(n->right.get(), f);
}
// 외부 반복자: 순회 상태(스택)를 반복자 객체가 들고, 클라이언트가 한 걸음씩 진행한다
class InorderIterator {
public:
explicit InorderIterator(const TreeNode *root) { pushLeft(root); }
bool done() const { return st_.empty(); }
int current() const { return st_.top()->key; }
void next()
{
const TreeNode *n = st_.top();
st_.pop();
pushLeft(n->right.get());
}
private:
void pushLeft(const TreeNode *n) { for (; n; n = n->left.get()) st_.push(n); }
std::stack<const TreeNode *> st_;
};
int main()
{
std::unique_ptr<TreeNode> a, b;
for (int k : {50, 30, 70, 20, 40, 60, 80}) insert(a, k);
for (int k : {45, 25, 65, 35, 55}) insert(b, k);
std::cout << "internal: ";
for_each_inorder(a.get(), [](int k) { std::cout << k << ' '; });
std::cout << '\n';
// 외부 반복자만 할 수 있는 일: 두 트리를 동시에 진행하며 병합, 중간에 멈추기
std::cout << "merge : ";
InorderIterator x(a.get()), y(b.get());
int printed = 0;
while ((!x.done() || !y.done()) && printed < 9) {
bool takeX = !x.done() && (y.done() || x.current() <= y.current());
std::cout << (takeX ? x.current() : y.current()) << ' ';
takeX ? x.next() : y.next();
++printed;
}
std::cout << "... (stopped after " << printed << ")\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o internal_external internal_external.cpp && ./internal_external
internal: 20 30 40 50 60 70 80
merge : 20 25 30 35 40 45 50 55 60 ... (stopped after 9)
| 구분 | 내부 반복자 | 외부 반복자 |
|---|---|---|
| 누가 루프를 돌리나 | 컬렉션 | 클라이언트 |
| 구현 | 재귀 그대로, 간단 | 순회 상태를 직접 관리(스택 등) |
| 두 컬렉션 동시 진행 | 불가 | 가능(위의 병합) |
| 중간 정지 | 콜백 반환값 등 별도 규약 필요 | 루프를 끝내면 됨 |
| 예 | std::for_each, 람다 콜백 API | STL 반복자, readdir |
코루틴 제너레이터: 둘의 장점을 합치기
외부 반복자의 불편함은 재귀로 쉽게 표현되는 순회를 스택으로 풀어 써야 한다는 점이다. C++20 코루틴은 함수 실행을 co_yield에서 멈췄다가 이어갈 수 있어서, 재귀 순회 코드를 그대로 외부 반복자로 만들 수 있다. g++ 13에는 C++23의 std::generator가 없어서 최소한의 제너레이터를 직접 만들었다.
// generator.hpp — C++20 코루틴으로 만든 최소 제너레이터(C++23 std::generator의 축소판)
#pragma once
#include <coroutine>
#include <exception>
#include <utility>
template <class T>
class Generator {
public:
struct promise_type {
T value;
std::exception_ptr error;
Generator get_return_object() { return Generator{Handle::from_promise(*this)}; }
std::suspend_always initial_suspend() noexcept { return {}; } // 첫 next까지 대기
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T v) { value = std::move(v); return {}; }
void return_void() {}
void unhandled_exception() { error = std::current_exception(); }
};
using Handle = std::coroutine_handle<promise_type>;
struct iterator { // range-for용 외부 반복자
Handle h;
iterator &operator++() { h.resume(); rethrow(); return *this; }
const T &operator*() const { return h.promise().value; }
bool operator==(std::default_sentinel_t) const { return !h || h.done(); }
void rethrow() const { if (h.promise().error) std::rethrow_exception(h.promise().error); }
};
iterator begin() { h_.resume(); iterator it{h_}; it.rethrow(); return it; }
std::default_sentinel_t end() { return {}; }
explicit Generator(Handle h) : h_(h) {}
Generator(Generator &&o) noexcept : h_(std::exchange(o.h_, {})) {}
~Generator() { if (h_) h_.destroy(); }
private:
Handle h_;
};// coro.cpp — 순회 상태를 지역 변수와 실행 위치로 표현하는 반복자
#include <iostream>
#include "generator.hpp"
#include "tree.hpp"
Generator<long> fibonacci() // 끝이 없는 수열
{
long a = 0, b = 1;
for (;;) {
co_yield a;
a = std::exchange(b, a + b);
}
}
Generator<int> inorder(const TreeNode *n) // 재귀 순회를 그대로 쓴다
{
if (!n)
co_return;
for (int k : inorder(n->left.get()))
co_yield k;
co_yield n->key;
for (int k : inorder(n->right.get()))
co_yield k;
}
int main()
{
int count = 0;
for (long f : fibonacci()) { // 필요한 만큼만 계산된다
if (++count > 12)
break;
std::cout << f << ' ';
}
std::cout << '\n';
std::unique_ptr<TreeNode> root;
for (int k : {50, 30, 70, 20, 40, 60, 80})
insert(root, k);
for (int k : inorder(root.get())) {
std::cout << k << ' ';
if (k == 60)
break; // 중간에 멈추면 남은 코루틴 프레임은 소멸자가 정리
}
std::cout << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -fsanitize=address -o coro coro.cpp && ./coro
0 1 1 2 3 5 8 13 21 34 55 89
20 30 40 50 60
끝없는 피보나치 수열도 필요한 12개만 계산됐고, 트리 순회는 60에서 멈췄다. 멈춘 시점에 남은 코루틴 프레임은 Generator 소멸자가 해제해서 AddressSanitizer도 누수를 보고하지 않았다.
C 구현: readdir와 list_for_each_entry
POSIX 디렉터리 스트림은 C의 대표적인 외부 반복자다. DIR *가 순회 상태를 숨기고, readdir이 다음 원소를 돌려준다.
/* dir_iter.c — POSIX 디렉터리 스트림: opendir/readdir/closedir는 C 표준적인 외부 반복자다 */
#define _DEFAULT_SOURCE
#include <dirent.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv)
{
const char *path = argc > 1 ? argv[1] : ".";
DIR *d = opendir(path); /* 반복자 생성 */
struct dirent *e;
int files = 0, dirs = 0;
if (!d) {
perror(path);
return 1;
}
while ((e = readdir(d)) != NULL) { /* next() + done() */
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
continue;
e->d_type == DT_DIR ? dirs++ : files++;
}
closedir(d); /* 반복자 해제 */
printf("%s: %d files, %d dirs\n", path, files, dirs);
return 0;
}$ mkdir -p sample/sub1 sample/sub2 && touch sample/a.txt sample/b.txt sample/c.log && gcc -std=c11 -Wall -Wextra -O2 -o dir_iter dir_iter.c && ./dir_iter sample && ./dir_iter /nonexistent
sample: 3 files, 2 dirs
/nonexistent: No such file or directory
리눅스 커널은 반대로 반복자 객체 없이 매크로로 순회를 펼친다. 리스트 링크를 원소 구조체 안에 넣고(침습 리스트), 링크 주소에서 원소 주소를 container_of로 계산한다.
/* list_iter.c — 커널식 침습 리스트와 순회 매크로(list_for_each_entry) 축소판 */
#include <stddef.h>
#include <stdio.h>
struct list_head { struct list_head *next, *prev; };
#define LIST_HEAD_INIT(name) { &(name), &(name) }
#define container_of(ptr, type, member) ((type *)((char *)(ptr) - offsetof(type, member)))
#define list_entry(ptr, type, member) container_of(ptr, type, member)
#define list_for_each_entry(pos, head, member) \
for (pos = list_entry((head)->next, __typeof__(*pos), member); \
&pos->member != (head); \
pos = list_entry(pos->member.next, __typeof__(*pos), member))
static void list_add_tail(struct list_head *n, struct list_head *head)
{
n->prev = head->prev;
n->next = head;
head->prev->next = n;
head->prev = n;
}
struct task {
int pid;
const char *comm;
struct list_head node; /* 리스트 링크를 원소 안에 품는다 */
};
int main(void)
{
struct list_head tasks = LIST_HEAD_INIT(tasks);
struct task t[] = { {1, "systemd", {0}}, {512, "sshd", {0}}, {4096, "bash", {0}} };
struct task *p;
for (size_t i = 0; i < sizeof(t) / sizeof(t[0]); i++)
list_add_tail(&t[i].node, &tasks);
list_for_each_entry(p, &tasks, node) /* 반복자 객체 없이 for문으로 펼쳐진다 */
printf("%5d %s\n", p->pid, p->comm);
return 0;
}$ gcc -std=gnu11 -Wall -Wextra -O2 -o list_iter list_iter.c && ./list_iter
1 systemd
512 sshd
4096 bash
매크로가 평범한 for문으로 펼쳐지므로 함수 호출 비용이 없고, 원소 하나가 여러 리스트에 동시에 들어갈 수도 있다. __typeof__는 GNU 확장이라 -std=gnu11로 빌드했다.
주의사항
- 중첩 제너레이터의 비용: 위
inorder는 원소 하나를 꺼낼 때마다 깊이만큼의 코루틴을 거쳐 올라온다(O(깊이)). 깊고 큰 트리라면 명시적 스택 반복자가 빠르다. C++23std::generator는co_yield std::ranges::elements_of(...)로 이 비용을 줄인다. - 제너레이터와 대상의 수명: 코루틴은
initial_suspend에서 멈춘 채 반환되므로 본문은 나중에 실행된다.for (int k : inorder(make_tree().get()))처럼 순회 대상을 임시 객체로 넘기면, C++20까지는 그 임시 객체가 루프 시작 전에 파괴되어 첫 재개부터 해제된 메모리를 읽는다(C++23은 range-for 초기화식의 임시 객체 수명을 연장한다). d_type은 믿을 수 없을 때가 있다: 일부 파일 시스템은DT_UNKNOWN을 돌려준다. 정확한 종류가 필요하면fstatat()으로 확인한다.- 순회 중 삭제:
list_for_each_entry안에서 현재 원소를 지우면 다음 링크를 잃는다. 커널은 다음 원소를 미리 저장하는list_for_each_entry_safe를 따로 둔다. 외부 반복자도 같은 문제를 가진다. - 외부 반복자는 상태를 숨기는 게 목적: 예제의
InorderIterator가 스택을 그대로 노출하면 클라이언트가 내부 구조에 의존하게 된다. 노출하는 연산은done/current/next정도로 제한한다.
마무리
반복자의 핵심은 “순회 상태를 어디에 두는가”다. 컬렉션의 콜 스택에 두면 내부 반복자, 별도 객체에 두면 외부 반복자, 코루틴 프레임에 두면 둘의 장점을 함께 얻는다. 다음 글에서는 여러 객체 사이의 복잡한 통신을 한 객체로 모으는 중재자(Mediator)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Iterator
- cppreference — Coroutines (C++20)
- cppreference — std::generator (C++23)
- man7.org — readdir(3)