나만의 Iterator 만들기: Forward부터 Bidirectional까지
들어가며: 자체 컨테이너에 STL 알고리즘을 붙이려면
도메인에 맞춘 컨테이너를 직접 만들면 곧 for (auto x : container)나 std::sort, std::find_if에 그대로 넘길 수 없다는 문제를 만납니다. 표준 vector와 list는 begin()/end()가 반복자를 반환하므로 STL 알고리즘과 범위 기반 for에 바로 쓸 수 있지만, 직접 만든 타입은 반복자를 정의하기 전까지 컴파일 에러가 납니다.
#include <vector>
#include <algorithm>
// 직접 만든 링 버퍼
class RingBuffer {
std::vector<int> data_;
size_t head_ = 0;
public:
void push(int x) { /* ... */ }
int front() const { return data_[head_]; }
};
int main() {
RingBuffer buf;
buf.push(1); buf.push(2); buf.push(3);
// for (auto x : buf) { ... } // 컴파일 에러: begin/end가 없음
// std::sort(buf.begin(), buf.end());
}
링 버퍼의 최근 로그 N개 순회, 연결 리스트 검색, 파일을 한 줄씩 읽어 알고리즘에 넘기기, DB 쿼리 결과를 한 행씩 std::accumulate에 넘기기처럼 상황은 다양하지만 해결책은 같습니다. 반복자 타입을 정의하고 begin()/end()가 그것을 반환하게 하면 됩니다. 이 글은 17-1 반복자 기초를 읽었다고 가정하고, 요구 사항과 iterator_traits를 먼저 본 뒤 Input, Forward, Bidirectional, Random Access 반복자를 차례로 구현합니다.
flowchart LR
subgraph before["Before: 반복자 없음"]
B1[RingBuffer] --> B2[begin/end 없음]
B2 --> B3[for-range 불가]
B2 --> B4["std sort 불가"]
end
subgraph after["After: 커스텀 반복자"]
A1[RingBuffer] --> A2[반복자 구현]
A2 --> A3[for-range 가능]
A2 --> A4["std sort 가능"]
end
| 반복자 종류 | 연산 | 예시 |
|---|---|---|
| output | ++, *it = x (쓰기 전용) | ostream_iterator, back_inserter |
| input | ++, *, == (한 번만 순회) | istream_iterator, 이 글의 LineIterator |
| forward | input + 다중 패스 | forward_list, unordered_map |
| bidirectional | forward + -- | list, map |
| random access | bidirectional + +, -, [], < | vector, deque, 이 글의 SliceIterator |
반복자 요구 사항과 iterator_traits
최소 조건
가장 약한 읽기 반복자(input iterator)도 역참조(operator*), 전위·후위 증가(operator++), 같은 범위의 반복자끼리 비교(operator==, C++20 이전에는 operator!=도)가 필요합니다.
template <typename T>
class MyIterator {
T* ptr_ = nullptr;
public:
T& operator*() const { return *ptr_; }
MyIterator& operator++() { ++ptr_; return *this; }
MyIterator operator++(int) { // 후위: 증가 전 복사본을 반환
auto tmp = *this;
++*this;
return tmp;
}
friend bool operator==(const MyIterator& a, const MyIterator& b) {
return a.ptr_ == b.ptr_;
}
};
타입 별칭과 iterator_traits
알고리즘은 반복자의 성질을 std::iterator_traits<It>로 조회합니다. 반복자 클래스가 다섯 가지 멤버 타입 별칭을 정의해 두면, 기본 iterator_traits가 그것을 그대로 가져오므로 iterator_traits를 따로 특수화할 필요가 없습니다. 특수화는 원시 포인터처럼 멤버를 추가할 수 없는 타입에만 필요합니다.
| 타입 별칭 | 용도 | 예시 |
|---|---|---|
value_type | 원소의 값 타입(참조·const 제거) | int, std::string |
difference_type | 두 반복자 간 거리 (it1 - it2) | std::ptrdiff_t |
iterator_category | 반복자 종류 (알고리즘이 구현을 고르는 데 사용) | std::forward_iterator_tag |
pointer | operator->의 반환 타입 | T* |
reference | *it의 반환 타입 | T& |
template <typename T>
class MyIterator {
public:
using value_type = T;
using difference_type = std::ptrdiff_t;
using iterator_category = std::forward_iterator_tag;
using pointer = T*;
using reference = T&;
// ...
};
이 별칭이 빠지면 C++17 이후의 iterator_traits<MyIterator>는 아무 멤버도 없는 빈 구조체가 되고, std::distance, std::advance, std::reverse_iterator처럼 이 정보를 쓰는 곳에서 “no type named ‘iterator_category’” 같은 에러가 납니다.
iterator_category는 알고리즘이 어떤 구현을 쓸지 고르는 데도 쓰입니다. std::advance(it, n)은 random access 반복자라면 it += n 한 번으로, 그 외에는 ++를 n번 반복하는 식으로 태그에 따라 다른 코드를 선택합니다. std::sort는 random access 반복자를 요구하고, std::find는 input 반복자면 충분합니다.
C++20의 반복자 concept(std::input_iterator, std::forward_iterator 등)은 연산이 실제로 존재하는지와 함께 iterator_concept(없으면 iterator_category) 태그를 확인합니다. static_assert(std::forward_iterator<MyIterator<int>>)처럼 구현 직후 검증해 두면, 연산 하나를 빠뜨렸을 때 알고리즘 깊숙한 곳의 긴 에러 대신 짧은 진단을 받을 수 있습니다.
Input Iterator: 스트림을 한 줄씩 읽는 반복자
파일을 줄 단위로 읽어 std::find나 std::count_if에 넘기고 싶은데, std::istream_iterator<std::string>은 공백 단위로 끊어 읽습니다. 줄 단위 반복자는 직접 만들어야 하고, 스트림은 앞으로만 읽히므로 이 반복자는 input iterator가 됩니다. input iterator는 한 번만 순회할 수 있으며, 반복자를 복사해 두어도 하나를 증가시키면 다른 복사본이 가리키던 값을 다시 읽을 수 있다는 보장이 없습니다.
#include <algorithm>
#include <iostream>
#include <iterator>
#include <sstream>
#include <string>
class LineIterator {
std::istream* stream_ = nullptr;
std::string line_;
bool at_end_ = true;
public:
using value_type = std::string;
using difference_type = std::ptrdiff_t;
using iterator_category = std::input_iterator_tag;
using pointer = const std::string*;
using reference = const std::string&;
LineIterator() = default; // end 반복자
explicit LineIterator(std::istream& is) : stream_(&is) { advance(); }
const std::string& operator*() const { return line_; }
const std::string* operator->() const { return &line_; }
LineIterator& operator++() { advance(); return *this; }
LineIterator operator++(int) { auto tmp = *this; ++*this; return tmp; }
friend bool operator==(const LineIterator& a, const LineIterator& b) {
return a.at_end_ == b.at_end_ && (a.at_end_ || a.stream_ == b.stream_);
}
friend bool operator!=(const LineIterator& a, const LineIterator& b) {
return !(a == b);
}
private:
void advance() {
at_end_ = !(stream_ && std::getline(*stream_, line_));
}
};
class LineRange {
std::istream* stream_ = nullptr;
public:
explicit LineRange(std::istream& is) : stream_(&is) {}
LineIterator begin() const { return LineIterator(*stream_); }
LineIterator end() const { return LineIterator(); }
};
int main() {
std::istringstream iss("apple\nbanana\ncherry\ndate\n");
LineRange lines(iss);
auto it = std::find(lines.begin(), lines.end(), "cherry");
if (it != lines.end()) std::cout << "Found: " << *it << "\n";
}
Found: cherry
operator*는 const 참조를 반환하므로 이 반복자로는 읽기만 할 수 있습니다. 끝 반복자는 기본 생성된 객체이고, 스트림이 끝나면 at_end_가 true가 되어 끝 반복자와 같다고 판정됩니다. LineRange::begin()은 호출할 때마다 스트림을 한 줄 읽으므로, 같은 LineRange를 두 번 순회하면 두 번째는 첫 번째가 남긴 위치부터 이어서 읽는다는 점도 input 범위의 성질입니다.
카테고리별 비교
| 카테고리 | 다중 패스 | 역방향 | 임의 접근 | 추가로 쓸 수 있는 알고리즘 예 |
|---|---|---|---|---|
| output | 아니오 | 아니오 | 아니오 | copy, fill_n의 출력 대상 |
| input | 아니오 | 아니오 | 아니오 | find, count, accumulate |
| forward | 예 | 아니오 | 아니오 | search, unique, rotate, max_element |
| bidirectional | 예 | 예 | 아니오 | reverse, prev, inplace_merge |
| random access | 예 | 예 | 예 | sort, nth_element, binary_search(O(log n) 비교) |
Forward Iterator: 단일 연결 리스트 반복자
forward iterator는 한 방향으로만 움직이지만 다중 패스를 보장합니다. 반복자를 복사해 두면 그 복사본은 나중에도 같은 원소를 가리키고, 같은 범위를 여러 번 순회해도 같은 결과가 나옵니다. 그래서 기본 생성 가능해야 하고, *it가 실제 원소에 대한 참조여야 합니다.
#include <algorithm>
#include <iostream>
#include <iterator>
template <typename T>
struct ListNode {
T value;
ListNode* next = nullptr;
};
template <typename T>
class ForwardListIterator {
ListNode<T>* node_ = nullptr;
public:
using value_type = T;
using difference_type = std::ptrdiff_t;
using iterator_category = std::forward_iterator_tag;
using pointer = T*;
using reference = T&;
ForwardListIterator() = default;
explicit ForwardListIterator(ListNode<T>* n) : node_(n) {}
T& operator*() const { return node_->value; }
T* operator->() const { return &node_->value; }
ForwardListIterator& operator++() { node_ = node_->next; return *this; }
ForwardListIterator operator++(int) { auto tmp = *this; ++*this; return tmp; }
friend bool operator==(const ForwardListIterator& a, const ForwardListIterator& b) {
return a.node_ == b.node_;
}
friend bool operator!=(const ForwardListIterator& a, const ForwardListIterator& b) {
return !(a == b);
}
};
template <typename T>
class ForwardList {
ListNode<T>* head_ = nullptr;
ListNode<T>* tail_ = nullptr;
public:
using iterator = ForwardListIterator<T>;
ForwardList() = default;
ForwardList(const ForwardList&) = delete; // 노드를 소유하므로 얕은 복사 금지
ForwardList& operator=(const ForwardList&) = delete;
~ForwardList() {
while (head_) {
auto* next = head_->next;
delete head_;
head_ = next;
}
}
void push_back(const T& value) {
auto* node = new ListNode<T>{value, nullptr};
if (!head_) head_ = tail_ = node;
else { tail_->next = node; tail_ = node; }
}
iterator begin() { return iterator(head_); }
iterator end() { return iterator(nullptr); }
};
static_assert(std::forward_iterator<ForwardListIterator<int>>); // C++20
int main() {
ForwardList<int> list;
list.push_back(1);
list.push_back(2);
list.push_back(3);
for (auto x : list) std::cout << x << " ";
auto it = std::find(list.begin(), list.end(), 2);
if (it != list.end()) std::cout << "\nFound: " << *it;
}
1 2 3
Found: 2
끝 반복자는 nullptr를 가리키고, operator->는 operator*가 돌려주는 원소의 주소입니다. 끝 반복자에서 ++를 호출하는 것은 표준 반복자에서도 미정의 동작이므로 여기서도 따로 검사하지 않았습니다. 이 예제에는 const 버전이 없어 const ForwardList&로는 순회할 수 없는데, 이를 해결하는 방법은 뒤의 const_iterator 절에서 다룹니다.
Bidirectional Iterator: 이중 연결 리스트 반복자
이중 연결 리스트는 operator--로 역방향 순회가 가능해야 std::reverse나 std::reverse_iterator, std::prev와 함께 쓸 수 있습니다. 여기서 핵심은 end()에서도 --가 동작해야 한다는 점입니다. 끝을 nullptr로 표현하면 --end()로 마지막 원소에 갈 수 없으므로, std::list처럼 원형으로 연결된 sentinel(더미) 노드를 둡니다.
#include <algorithm>
#include <iostream>
#include <iterator>
template <typename T>
struct DListNode {
T value{};
DListNode* prev = nullptr;
DListNode* next = nullptr;
};
template <typename T>
class BidirectionalListIterator {
DListNode<T>* node_ = nullptr;
public:
using value_type = T;
using difference_type = std::ptrdiff_t;
using iterator_category = std::bidirectional_iterator_tag;
using pointer = T*;
using reference = T&;
BidirectionalListIterator() = default;
explicit BidirectionalListIterator(DListNode<T>* n) : node_(n) {}
T& operator*() const { return node_->value; }
T* operator->() const { return &node_->value; }
BidirectionalListIterator& operator++() { node_ = node_->next; return *this; }
BidirectionalListIterator operator++(int) { auto tmp = *this; ++*this; return tmp; }
BidirectionalListIterator& operator--() { node_ = node_->prev; return *this; }
BidirectionalListIterator operator--(int) { auto tmp = *this; --*this; return tmp; }
friend bool operator==(const BidirectionalListIterator& a, const BidirectionalListIterator& b) {
return a.node_ == b.node_;
}
friend bool operator!=(const BidirectionalListIterator& a, const BidirectionalListIterator& b) {
return !(a == b);
}
};
template <typename T>
class BidirectionalList {
DListNode<T> sentinel_; // 값을 담지 않는 더미 노드 (이 예제는 T가 기본 생성 가능하다고 가정)
public:
using iterator = BidirectionalListIterator<T>;
BidirectionalList() { sentinel_.next = sentinel_.prev = &sentinel_; }
BidirectionalList(const BidirectionalList&) = delete;
BidirectionalList& operator=(const BidirectionalList&) = delete;
~BidirectionalList() {
while (sentinel_.next != &sentinel_) {
auto* node = sentinel_.next;
sentinel_.next = node->next;
delete node;
}
}
void push_back(const T& value) {
auto* node = new DListNode<T>{value, sentinel_.prev, &sentinel_};
sentinel_.prev->next = node;
sentinel_.prev = node;
}
iterator begin() { return iterator(sentinel_.next); }
iterator end() { return iterator(&sentinel_); }
};
static_assert(std::bidirectional_iterator<BidirectionalListIterator<int>>); // C++20
int main() {
BidirectionalList<int> list;
list.push_back(1);
list.push_back(2);
list.push_back(3);
for (auto x : list) std::cout << x << " ";
std::cout << "\nReverse: ";
for (auto it = std::make_reverse_iterator(list.end());
it != std::make_reverse_iterator(list.begin()); ++it)
std::cout << *it << " ";
std::cout << "\n";
std::reverse(list.begin(), list.end()); // 원소 값을 교환해 순서를 뒤집음
for (auto x : list) std::cout << x << " ";
}
1 2 3
Reverse: 3 2 1
3 2 1
std::reverse_iterator는 내부적으로 --를 쓰므로 bidirectional 반복자만 있으면 역방향 범위를 공짜로 얻습니다. sentinel 방식은 빈 리스트에서도 begin() == end()가 자연스럽게 성립하고, 삽입·삭제 코드에서 머리와 꼬리를 특별 취급할 필요가 없다는 장점도 있습니다. 실제 라이브러리는 sentinel에 값 공간을 두지 않도록 노드를 “링크 부분”과 “값 부분”으로 나눠 T가 기본 생성 가능하지 않아도 되게 만듭니다.
Random Access Iterator: 슬라이스 반복자
벡터의 일부 구간을 복사 없이 std::sort에 넘기려면 random access 반복자가 필요합니다. 상수 시간에 n칸 이동(+=, +, -), 거리 계산(it2 - it1), 첨자(it[n]), 대소 비교(<, >, <=, >=)를 모두 제공해야 합니다.
#include <algorithm>
#include <iostream>
#include <iterator>
#include <vector>
template <typename T>
class SliceIterator {
T* ptr_ = nullptr;
public:
using value_type = std::remove_cv_t<T>;
using difference_type = std::ptrdiff_t;
using iterator_category = std::random_access_iterator_tag;
using pointer = T*;
using reference = T&;
SliceIterator() = default;
explicit SliceIterator(T* p) : ptr_(p) {}
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
T& operator[](difference_type n) const { return ptr_[n]; } // 음수 n도 허용해야 하므로 부호 있는 타입
SliceIterator& operator++() { ++ptr_; return *this; }
SliceIterator operator++(int) { auto tmp = *this; ++ptr_; return tmp; }
SliceIterator& operator--() { --ptr_; return *this; }
SliceIterator operator--(int) { auto tmp = *this; --ptr_; return tmp; }
SliceIterator& operator+=(difference_type n) { ptr_ += n; return *this; }
SliceIterator& operator-=(difference_type n) { ptr_ -= n; return *this; }
friend SliceIterator operator+(SliceIterator it, difference_type n) { return it += n; }
friend SliceIterator operator+(difference_type n, SliceIterator it) { return it += n; }
friend SliceIterator operator-(SliceIterator it, difference_type n) { return it -= n; }
friend difference_type operator-(const SliceIterator& a, const SliceIterator& b) {
return a.ptr_ - b.ptr_;
}
friend bool operator==(const SliceIterator& a, const SliceIterator& b) { return a.ptr_ == b.ptr_; }
friend bool operator!=(const SliceIterator& a, const SliceIterator& b) { return a.ptr_ != b.ptr_; }
friend bool operator<(const SliceIterator& a, const SliceIterator& b) { return a.ptr_ < b.ptr_; }
friend bool operator>(const SliceIterator& a, const SliceIterator& b) { return b < a; }
friend bool operator<=(const SliceIterator& a, const SliceIterator& b) { return !(b < a); }
friend bool operator>=(const SliceIterator& a, const SliceIterator& b) { return !(a < b); }
};
template <typename T>
class SliceRange {
T* data_ = nullptr;
std::size_t size_ = 0;
public:
SliceRange(T* data, std::size_t size) : data_(data), size_(size) {}
SliceIterator<T> begin() const { return SliceIterator<T>(data_); }
SliceIterator<T> end() const { return SliceIterator<T>(data_ + size_); }
};
static_assert(std::random_access_iterator<SliceIterator<int>>); // C++20
int main() {
std::vector<int> v = {9, 5, 2, 4, 1, 3, 0};
SliceRange<int> middle(v.data() + 1, 5); // v[1]..v[5]만
std::sort(middle.begin(), middle.end());
for (int x : v) std::cout << x << " ";
}
9 1 2 3 4 5 0
슬라이스는 원본 메모리를 그대로 가리키므로 정렬 결과가 원본 벡터에 반영되고, 범위 밖인 v[0]과 v[6]은 그대로 남습니다. operator[]의 인자를 size_t로 받으면 it[-1] 같은 음수 첨자가 거대한 양수로 바뀌어 잘못된 주소에 접근하므로, 인자는 difference_type이어야 합니다. C++20 이후라면 operator<=> 하나를 기본 구현으로 두어 네 비교 연산자를 대신할 수도 있습니다.
링 버퍼 반복자는 논리 인덱스로
링 버퍼 반복자를 물리 인덱스(data_ 안의 위치)로 만들면 문제가 생깁니다. 물리 인덱스는 끝에서 0으로 돌아가므로 it2 - it1이 음수가 될 수 있고, 버퍼가 가득 찬 상태에서는 begin()과 end()의 물리 인덱스가 같아져 빈 범위처럼 보입니다. 해결책은 반복자가 head로부터의 논리 위치(0부터 size까지)를 들고, 역참조할 때만 물리 인덱스로 바꾸는 것입니다.
template <typename T>
class RingIterator {
const RingBuffer<T>* buf_ = nullptr;
std::ptrdiff_t pos_ = 0; // head 기준 논리 위치: 0 = 가장 오래된 원소, size = end
public:
using value_type = T;
using difference_type = std::ptrdiff_t;
using iterator_category = std::random_access_iterator_tag;
// ...
const T& operator*() const { return buf_->physical((buf_->head() + pos_) % buf_->capacity()); }
RingIterator& operator+=(difference_type n) { pos_ += n; return *this; }
friend difference_type operator-(const RingIterator& a, const RingIterator& b) { return a.pos_ - b.pos_; }
friend bool operator<(const RingIterator& a, const RingIterator& b) { return a.pos_ < b.pos_; }
// begin() = {buf, 0}, end() = {buf, size}
};
이렇게 하면 거리와 대소 비교가 단순한 정수 연산이 되어 wrap-around와 무관하게 정확하고, 가득 찬 버퍼에서도 end() - begin() == size가 성립합니다. 유효한 범위에서는 pos_가 0 이상이므로, 물리 인덱스에 -=를 적용할 때 생기는 음수 나머지(C++에서 -1 % 8은 -1) 문제도 피할 수 있습니다.
자주 하는 실수
std::sort에 forward/bidirectional 반복자를 넘김
std::sort(list.begin(), list.end())가 컴파일되지 않는 이유는 sort가 random access 반복자를 요구하기 때문입니다. 연결 리스트에 상수 시간 +=를 만들 수는 없으므로, std::list::sort()처럼 노드 연결을 바꾸는 병합 정렬 멤버 함수를 제공하거나, std::vector로 복사해 정렬하는 것이 맞는 해결책입니다. 태그만 random_access_iterator_tag로 바꾸는 것은 해결책이 아닙니다(다음 항목 참고).
iterator_category 과대 선언
반복자가 실제로 제공하는 것보다 높은 카테고리를 선언하면 문제가 생깁니다. 필요한 연산이 아예 없으면 컴파일 에러로 끝나지만, 더 위험한 것은 연산은 있는데 요구 사항을 지키지 못하는 경우입니다. operator+=를 ++ n번으로 구현해 O(n)이 되거나, 앞의 물리 인덱스 링 버퍼처럼 operator-가 일부 상황에서 틀린 값을 내면, std::sort나 std::lower_bound가 컴파일은 되지만 매우 느려지거나 범위 밖에 접근합니다. 구현이 실제로 만족하는 가장 높은 카테고리까지만 선언합니다.
후위 operator++의 반환 타입
void operator++(int) { ++*this; } // 잘못됨: *it++ 같은 식이 컴파일되지 않음
MyIterator operator++(int) { // 올바름: 증가 전 상태의 복사본 반환
auto tmp = *this;
++*this;
return tmp;
}
input iterator에서는 C++20 concept상 후위 증가가 void를 반환해도 허용되지만, forward 이상은 반복자 복사본을 반환해야 합니다.
const 컨테이너에서 begin()을 호출할 수 없음
const MyContainer&로 받은 컨테이너를 순회하려면 const 멤버 begin() const/end() const가 필요합니다. 이때 일반 iterator를 반환하면 const 컨테이너의 원소를 고칠 수 있게 되므로, 원소를 const 참조로 돌려주는 const_iterator를 따로 반환해야 합니다(아래 const_iterator 절).
순회 중 기반 컨테이너 수정
vector에 push_back해서 재할당이 일어나면 기존 반복자가 모두 무효가 됩니다. 커스텀 반복자도 기반 데이터를 가리키는 포인터를 들고 있으므로 같은 문제가 있습니다.
for (auto it = v.begin(); it != v.end(); ++it) {
v.push_back(0); // 재할당되면 it와 v.end()가 무효 → 미정의 동작
}
std::vector<int> to_add; // 추가할 원소를 모았다가
for (auto x : v) if (x > 0) to_add.push_back(x);
v.insert(v.end(), to_add.begin(), to_add.end()); // 순회가 끝난 뒤 한 번에 추가
end()를 역참조
end()는 마지막 원소의 다음 위치를 가리키는 반복자라 역참조할 수 없습니다. nullptr이나 sentinel 노드로 표현하는 이유가 이것이고, 마지막 원소가 필요하면 bidirectional 이상에서 std::prev(end())를 씁니다.
다른 컨테이너의 반복자 비교
list1.begin() == list2.end()처럼 서로 다른 범위의 반복자를 비교하거나 거리를 구하는 것은 미정의 동작입니다. 커스텀 반복자에서는 디버그 빌드에 한해 반복자가 소속 컨테이너 포인터를 들고 있다가 비교 시 assert로 검사하는 방식이 실수를 빨리 드러냅니다.
출력 대상에 읽기 전용 반복자를 넘김
std::copy(first, last, result)의 result는 *result = value가 가능한 output iterator여야 합니다. LineIterator처럼 const 참조를 돌려주는 반복자에 쓰려고 하면 컴파일 에러가 나므로, 결과는 std::back_inserter(out) 같은 출력 반복자로 받습니다.
std::vector<std::string> out;
std::copy(lines.begin(), lines.end(), std::back_inserter(out));
설계 원칙
필요한 만큼의 카테고리
알고리즘에 필요한 수준까지만 구현하면 유지보수가 쉬워집니다. 순회와 검색만 필요하면 forward, 역방향과 std::reverse가 필요하면 bidirectional, std::sort나 이진 검색이 필요하고 상수 시간 이동이 가능할 때만 random access를 선언합니다.
iterator_concept (C++20)
C++20 concept은 iterator_concept 별칭이 있으면 그것을 iterator_category보다 우선해서 봅니다. 두 별칭을 나눠 쓰는 이유는 기존 C++17 규칙과 C++20 규칙의 요구 사항이 다르기 때문입니다. 예를 들어 *it가 참조가 아니라 값(prvalue)을 돌려주는 반복자는 C++17 규칙에서는 input 이상이 될 수 없지만 C++20 concept으로는 random access가 될 수 있으므로, iterator_category = std::input_iterator_tag와 iterator_concept = std::random_access_iterator_tag를 함께 선언합니다. 포인터를 감싸 메모리가 연속인 SliceIterator라면 iterator_concept = std::contiguous_iterator_tag를 선언해 std::span이나 std::ranges::contiguous_range와 함께 쓸 수도 있습니다.
기본 생성과 noexcept
forward 이상의 반복자는 기본 생성 가능해야 합니다(C++20 std::forward_iterator가 요구하는 std::regular에 포함). 포인터만 들고 있는 반복자라면 복사·이동 생성자를 직접 쓰지 말고 컴파일러가 만들게 두면 자동으로 noexcept가 되며, 직접 정의한다면 noexcept를 붙여 컨테이너 재할당 시 이동이 선택되게 합니다.
const_iterator와 iterator 분리
표준 컨테이너처럼 const 컨테이너에서는 const_iterator를 반환해 *it로 원소를 고칠 수 없게 합니다. 두 클래스를 따로 쓰기보다 bool IsConst 템플릿 인자 하나로 만들고, iterator에서 const_iterator로의 변환만 허용하는 방식이 흔합니다.
template <typename T, bool IsConst>
class BasicIterator {
using node_ptr = std::conditional_t<IsConst, const ListNode<T>*, ListNode<T>*>;
node_ptr node_ = nullptr;
public:
using value_type = T;
using difference_type = std::ptrdiff_t;
using iterator_category = std::forward_iterator_tag;
using reference = std::conditional_t<IsConst, const T&, T&>;
using pointer = std::conditional_t<IsConst, const T*, T*>;
BasicIterator() = default;
explicit BasicIterator(node_ptr n) : node_(n) {}
// iterator → const_iterator 변환만 허용
template <bool C = IsConst, typename = std::enable_if_t<C>>
BasicIterator(const BasicIterator<T, false>& other) : node_(other.node_) {}
reference operator*() const { return node_->value; }
// ++, ==는 앞의 ForwardListIterator와 동일
friend class BasicIterator<T, !IsConst>;
};
template <typename T>
class MyList {
public:
using iterator = BasicIterator<T, false>;
using const_iterator = BasicIterator<T, true>;
iterator begin() { return iterator(head_); }
const_iterator begin() const { return const_iterator(head_); }
const_iterator cbegin() const { return begin(); }
// end(), cend()도 같은 방식
private:
ListNode<T>* head_ = nullptr;
};
무효화 규칙과 수명 문서화
반복자는 기반 컨테이너나 스트림보다 오래 살면 안 됩니다. 함수 안의 지역 컨테이너에서 얻은 반복자를 반환하면, 반환된 순간 이미 해제된 메모리를 가리킵니다. 컨테이너를 만들 때는 어떤 연산이 어떤 반복자를 무효화하는지를 주석으로 남겨 두는 편이 좋습니다. 예를 들어 앞의 논리 인덱스 링 버퍼라면 “용량이 남아 있을 때 push_back은 기존 반복자를 유지하고, 가득 찬 상태의 push_back은 가장 오래된 원소를 덮어쓰므로 모든 반복자가 가리키는 원소가 한 칸씩 밀린다”, “다른 스레드의 동시 수정은 미정의 동작” 같은 식입니다.
응용 패턴
반복자를 직접 만드는 경우는 대부분 다음 몇 가지 형태 중 하나입니다.
- 스트라이드 반복자는
operator++에서 기반 반복자를 N칸씩 전진시켜 N개마다 하나씩 방문합니다. 끝을 넘어가지 않도록 남은 거리를 함께 들고 있어야 합니다. - 인덱스 반복자는 컨테이너 포인터와 인덱스를 들고
operator*에서(*container_)[index_]를 돌려줍니다. 기반 컨테이너가 재할당되어도 인덱스는 유효하다는 장점이 있습니다. - 필터 반복자는
operator++에서 조건을 만족하는 원소가 나올 때까지 기반 반복자를 전진시키고, 변환 반복자는operator*에서f(*it_)를 돌려줍니다. C++20에서는std::views::filter와std::views::transform이 같은 일을 하므로, 레거시 환경이 아니라면 이쪽을 쓰는 편이 낫습니다. - DB 커서 반복자는
operator++에서 다음 행을 가져오는 지연 평가 input iterator입니다. 한 번만 순회할 수 있다는 input의 성질이 커서의 성질과 정확히 맞습니다. - 멀티스레드에서 공유되는 컨테이너는 반복자로 직접 순회하는 대신, 락을 잡고
std::vector<T>스냅샷을 복사해 돌려주는snapshot()함수를 제공하면 순회 중 동시 수정 문제를 피할 수 있습니다.
begin()/end()를 갖춘 타입은 std::ranges::range를 만족하고, 반복자가 random access라면 std::ranges::random_access_range도 만족해 std::ranges::sort(slice)처럼 범위째 넘길 수 있습니다. 범위 타입을 설계하는 방법은 커스텀 Range 작성에서 이어서 다룹니다.