나만의 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
forwardinput + 다중 패스forward_list, unordered_map
bidirectionalforward + --list, map
random accessbidirectional + +, -, [], <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
pointeroperator->의 반환 타입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 작성에서 이어서 다룹니다.


참고 자료


같이 보면 좋은 글