C/C++ 디자인 패턴 (15) — 반복자(Iterator)

이진 탐색 트리의 원소를 정렬 순서로 처리하고 싶다. 재귀 함수 하나면 순회는 쉽지만, 두 트리를 한 걸음씩 번갈아 읽으며 병합하거나 원하는 값에서 멈추려면 순회의 “현재 위치”를 함수 밖에서 들고 있어야 한다. 반복자(Iterator)는 컬렉션의 내부 표현을 드러내지 않고 원소에 차례로 접근하는 방법을 제공하며, 순회 상태를 컬렉션과 분리한다. 이 글에서는 GoF가 구분한 내부·외부 반복자, C++20 코루틴으로 만드는 제너레이터, 그리고 C의 readdir와 커널식 list_for_each_entry를 다룬다.

GoF 반복자 인터페이스, STL 스타일 반복자, concept·ranges, 순회 비용, 반복자 무효화는 설계 기법 (11) 반복자에서 다뤘다.

이 시리즈의 다른 글

구조

참여자역할예제
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, 람다 콜백 APISTL 반복자, 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++23 std::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)를 다룬다.

참고

답글 남기기