C++ 커스텀 Range 작성 | range 개념을 만족하는 타입 만들기 [#25-3]

들어가며: 직접 만든 타입이 ranges 알고리즘에 들어가지 않는 이유

도메인 컨테이너나 래퍼를 직접 만들면, 표준 vector·array와 달리 for (auto x : buf)나 std::ranges::sort(buf)에 넣는 순간 컴파일 에러가 납니다.

class SensorBuffer {
    std::vector<double> data_;
public:
    void push(double v) { data_.push_back(v); }
    double* raw_data() { return data_.data(); }
};
int main() {
    SensorBuffer buf;
    buf.push(1.0); buf.push(2.0);
    // for (auto x : buf) { ... }   // 컴파일 에러: begin/end가 없음
    // std::ranges::sort(buf);      // 컴파일 에러
}

원인은 SensorBuffer에 begin()/end()가 없어서 std::ranges::range 개념을 만족하지 못하기 때문입니다. 이 글에서는 최소 조건부터 시작해, 반복자를 직접 만드는 경우, ranges::sort에 필요한 random access 반복자, 반복자 대신 sentinel로 끝을 표시하는 range, 파이프(|)로 쓰는 어댑터를 차례로 구현하고, 각 단계에서 자주 틀리는 부분을 짚습니다.

range가 되기 위한 최소 조건

std::ranges::range<R>는 std::ranges::begin(r)과 std::ranges::end(r)가 유효하고, 앞의 것이 반복자(std::input_or_output_iterator), 뒤의 것이 그 반복자의 sentinel(std::sentinel_for)이면 만족됩니다. ranges::begin은 멤버 r.begin()을 먼저 찾고, 없으면 ADL로 찾은 비멤버 begin(r)을 씁니다.

여기서 “반복자”는 단순히 ++와 *가 되는 타입이 아닙니다. 최소한 difference_type 별칭, 전위·후위 ++, 역참조가 있어야 하고(std::weakly_incrementable), 읽기 전용 반복자라면 value_type도 필요합니다. 범위 기반 for는 이런 개념 검사를 하지 않고 begin/end/!=/++/*만 쓰기 때문에, “for에서는 되는데 ranges 알고리즘에서는 안 되는” 반쪽짜리 반복자가 자주 만들어집니다. 그래서 타입을 만들 때마다 static_assert로 개념 만족을 확인해 두는 것이 좋습니다.

static_assert(std::ranges::range<MyRange>);
static_assert(std::input_iterator<std::ranges::iterator_t<MyRange>>);

가장 쉬운 방법은 반복자를 새로 만들지 않고 내부 컨테이너의 것을 그대로 노출하는 것입니다. 도입부의 SensorBuffer는 다음처럼 고치면 됩니다.

#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>
class SensorBuffer {
    std::vector<double> data_;
public:
    void push(double v) { data_.push_back(v); }
    std::size_t size() const { return data_.size(); }
    auto begin() { return data_.begin(); }
    auto end() { return data_.end(); }
    auto begin() const { return data_.begin(); }  // const 객체 순회용
    auto end() const { return data_.end(); }
};
static_assert(std::ranges::random_access_range<SensorBuffer>);
int main() {
    SensorBuffer buf;
    buf.push(3.0); buf.push(1.0); buf.push(2.0);
    std::ranges::sort(buf);
    for (auto x : buf) std::cout << x << " ";  // 1 2 3
}

vector의 반복자는 random access이므로 SensorBuffer도 그대로 random access range가 되어 ranges::sort까지 쓸 수 있습니다. const 버전 begin()/end()를 빼먹으면 const SensorBuffer&로 받은 함수 안에서 순회할 때 에러가 납니다.


반복자를 직접 만드는 경우

input 반복자

내부에 표준 컨테이너가 없거나, 순회 방식이 특별할 때는 반복자를 직접 만들어야 합니다. 포인터 구간을 순회하는 가장 단순한 input 반복자는 다음과 같습니다.

#include <cstddef>
#include <iostream>
#include <iterator>
#include <ranges>
#include <vector>
template <typename T>
class SliceIterator {
    T* ptr_ = nullptr;
public:
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    using iterator_concept = std::input_iterator_tag;
    SliceIterator() = default;
    explicit SliceIterator(T* p) : ptr_(p) {}
    T& operator*() const { return *ptr_; }
    SliceIterator& operator++() { ++ptr_; return *this; }
    SliceIterator operator++(int) { auto tmp = *this; ++ptr_; return tmp; }
    friend bool operator==(const SliceIterator&, const SliceIterator&) = default;
};
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) {}
    auto begin() const { return SliceIterator<T>(data_); }
    auto end() const { return SliceIterator<T>(data_ + size_); }
};
static_assert(std::input_iterator<SliceIterator<int>>);
static_assert(std::ranges::input_range<SliceRange<int>>);
int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    SliceRange<int> slice(v.data(), 3);
    for (auto x : slice) std::cout << x << " ";  // 1 2 3
}

C++20에서는 반복자 안에 value_type·difference_type·iterator_concept만 정의하면 std::iterator_traits가 나머지를 추론하므로, iterator_traits를 직접 특수화할 필요가 없습니다. 오히려 일부 멤버만 담아 특수화하면 기본 추론을 막아 버려 C++17식 알고리즘에서 reference나 pointer를 찾지 못하는 문제가 생길 수 있습니다. operator!=는 C++20에서 operator==로부터 자동으로 만들어집니다.

random access 반복자 (ranges::sort 지원)

std::ranges::sort는 random_access_range를 요구합니다. random access 반복자가 되려면 양방향 이동(--), 정수만큼 이동(+=, -=, +, -), 두 반복자의 거리(it1 - it2), 첨자([]), 대소 비교가 모두 필요합니다. C++20에서는 operator<=> 하나를 기본값으로 선언하면 대소 비교 네 개가 한 번에 해결됩니다.

#include <compare>
#include <cstddef>
#include <iterator>
template <typename T>
class RandomAccessSliceIterator {
    T* ptr_ = nullptr;
public:
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    using iterator_concept = std::random_access_iterator_tag;
    RandomAccessSliceIterator() = default;
    explicit RandomAccessSliceIterator(T* p) : ptr_(p) {}
    T& operator*() const { return *ptr_; }
    T& operator[](difference_type n) const { return ptr_[n]; }
    RandomAccessSliceIterator& operator++() { ++ptr_; return *this; }
    RandomAccessSliceIterator operator++(int) { auto t = *this; ++ptr_; return t; }
    RandomAccessSliceIterator& operator--() { --ptr_; return *this; }
    RandomAccessSliceIterator operator--(int) { auto t = *this; --ptr_; return t; }
    RandomAccessSliceIterator& operator+=(difference_type n) { ptr_ += n; return *this; }
    RandomAccessSliceIterator& operator-=(difference_type n) { ptr_ -= n; return *this; }
    friend RandomAccessSliceIterator operator+(RandomAccessSliceIterator it, difference_type n) { return it += n; }
    friend RandomAccessSliceIterator operator+(difference_type n, RandomAccessSliceIterator it) { return it += n; }
    friend RandomAccessSliceIterator operator-(RandomAccessSliceIterator it, difference_type n) { return it -= n; }
    friend difference_type operator-(const RandomAccessSliceIterator& a, const RandomAccessSliceIterator& b) {
        return a.ptr_ - b.ptr_;
    }
    friend bool operator==(const RandomAccessSliceIterator&, const RandomAccessSliceIterator&) = default;
    friend auto operator<=>(const RandomAccessSliceIterator&, const RandomAccessSliceIterator&) = default;
};
template <typename T>
class RandomAccessSliceRange {
    T* data_ = nullptr;
    std::size_t size_ = 0;
public:
    RandomAccessSliceRange(T* data, std::size_t size) : data_(data), size_(size) {}
    auto begin() const { return RandomAccessSliceIterator<T>(data_); }
    auto end() const { return RandomAccessSliceIterator<T>(data_ + size_); }
};
static_assert(std::random_access_iterator<RandomAccessSliceIterator<int>>);
// 사용:
// std::vector<int> v = {5, 2, 4, 1, 3};
// RandomAccessSliceRange<int> r(v.data(), v.size());
// std::ranges::sort(r);  // v가 정렬됨

iterator_category를 random_access_iterator_tag로 적어 두기만 하고 연산자를 빠뜨리면, 태그만 보는 C++17 알고리즘은 컴파일되다가 엉뚱한 곳에서 실패하고, C++20 개념은 만족하지 않습니다. static_assert(std::random_access_iterator<...>)가 실패하면 컴파일러가 어느 요구 사항이 빠졌는지 알려 주므로, 그 메시지를 따라 하나씩 채우는 것이 가장 빠릅니다. 이 반복자는 메모리가 연속이므로 iterator_concept를 std::contiguous_iterator_tag로 올리고 operator->를 추가하면 contiguous 반복자로도 만들 수 있습니다.


Sentinel로 끝을 표시하는 Range

C++20부터 end()는 반복자와 다른 타입(sentinel)을 반환해도 됩니다. 끝을 “어떤 위치”가 아니라 “어떤 조건”으로 판단해야 할 때 유용합니다.

null 종료 문자열

C 문자열을 반복자 쌍으로 표현하려면 end()를 만들기 위해 먼저 strlen으로 끝까지 한 번 훑어야 합니다. sentinel을 쓰면 끝 위치를 미리 계산하지 않고, 순회하면서 *it == '\0'인지 검사하는 것으로 끝을 판단합니다.

#include <algorithm>
#include <cstddef>
#include <iostream>
#include <iterator>
#include <ranges>
class CStringIterator {
    const char* ptr_ = nullptr;
public:
    using value_type = char;
    using difference_type = std::ptrdiff_t;
    CStringIterator() = default;
    explicit CStringIterator(const char* p) : ptr_(p) {}
    char operator*() const { return *ptr_; }
    CStringIterator& operator++() { ++ptr_; return *this; }
    CStringIterator operator++(int) { auto tmp = *this; ++ptr_; return tmp; }
    bool is_end() const { return *ptr_ == '\0'; }
};
struct CStringSentinel {
    friend bool operator==(const CStringIterator& it, CStringSentinel) { return it.is_end(); }
};
class CStringRange {
    const char* str_ = nullptr;
public:
    explicit CStringRange(const char* s) : str_(s) {}
    auto begin() const { return CStringIterator(str_); }
    auto end() const { return CStringSentinel{}; }
};
static_assert(std::ranges::input_range<CStringRange>);
int main() {
    CStringRange r("Hello");
    for (char c : r) std::cout << c;               // Hello
    std::cout << "\n" << std::ranges::count(r, 'l') << "\n";  // 2
}

C++20에서는 operator==를 한 방향으로만 정의해도 컴파일러가 인자 순서를 뒤집은 sentinel == it과 !=를 자동으로 만들어 주므로, 대칭 버전을 따로 쓸 필요가 없습니다(C++17 이전 코드와 함께 컴파일해야 한다면 네 가지를 모두 정의해야 합니다).

스트림의 줄 단위 읽기

EOF처럼 “더 읽을 것이 없음”도 sentinel로 표현할 수 있습니다.

#include <cstddef>
#include <istream>
#include <iterator>
#include <string>
class LineIterator {
    std::istream* stream_ = nullptr;
    std::string line_;
public:
    using value_type = std::string;
    using difference_type = std::ptrdiff_t;
    LineIterator() = default;
    explicit LineIterator(std::istream& s) : stream_(&s) { ++*this; }  // 첫 줄 읽기
    const std::string& operator*() const { return line_; }
    LineIterator& operator++() {
        if (!std::getline(*stream_, line_)) stream_ = nullptr;
        return *this;
    }
    void operator++(int) { ++*this; }
    friend bool operator==(const LineIterator& it, std::default_sentinel_t) {
        return it.stream_ == nullptr;
    }
};
class LineRange {
    std::istream* stream_;
public:
    explicit LineRange(std::istream& s) : stream_(&s) {}
    LineIterator begin() const { return LineIterator(*stream_); }
    std::default_sentinel_t end() const { return {}; }
};
// 사용: for (const auto& line : LineRange(std::cin)) { ... }

끝을 나타내는 데 별도 정보가 필요 없다면 빈 구조체를 새로 만들지 않고 표준의 std::default_sentinel_t를 쓰면 됩니다. 스트림 반복자는 한 번 읽은 줄을 다시 읽을 수 없는 단일 패스(input) 반복자이므로, C++20에서는 후위 ++가 void를 반환해도 됩니다. 또 begin()을 호출할 때마다 스트림에서 한 줄을 소비하므로, 같은 LineRange를 두 번 순회하면 두 번째에는 이어지는 줄부터 읽힌다는 점도 문서화해 두어야 합니다.

무한 range

끝나지 않는 range는 반복자와 절대 같지 않은 sentinel로 표현하고, 쓸 때 std::views::take로 자릅니다. 이 경우에도 반복자에 value_type과 difference_type이 있어야 views::take 같은 어댑터에 넘길 수 있습니다.

#include <cstddef>
#include <iostream>
#include <iterator>
#include <ranges>
class InfiniteIota {
    int start_ = 0;
public:
    class Iterator {
        int value_ = 0;
    public:
        using value_type = int;
        using difference_type = std::ptrdiff_t;
        Iterator() = default;
        explicit Iterator(int v) : value_(v) {}
        int operator*() const { return value_; }
        Iterator& operator++() { ++value_; return *this; }
        Iterator operator++(int) { auto tmp = *this; ++value_; return tmp; }
    };
    explicit InfiniteIota(int start = 0) : start_(start) {}
    Iterator begin() const { return Iterator(start_); }
    std::unreachable_sentinel_t end() const { return {}; }
};
int main() {
    for (auto x : InfiniteIota(0) | std::views::take(5)) std::cout << x << " ";  // 0 1 2 3 4
}

표준에는 이 용도의 std::unreachable_sentinel_t가 이미 있고, 모든 반복자와의 ==가 항상 false를 돌려주도록 정의되어 있어 비교 연산자를 따로 만들 필요도 없습니다. 직접 만든 무한 range를 take 없이 순회하면 당연히 끝나지 않으며, int 값이 최댓값을 넘어가면 부호 있는 정수 오버플로라 미정의 동작입니다.


파이프로 쓰는 어댑터 만들기

표준 std::views::filter·transform으로 충분하다면 그것을 쓰는 것이 맞습니다. 직접 만들어 보는 이유는 어댑터가 내부에서 무엇을 하는지, 그리고 표준 구현이 왜 그렇게 복잡한지 이해하기 위해서입니다. 다음은 조건을 만족하는 원소만 순회하는 filter view의 최소 구현입니다.

#include <functional>
#include <iostream>
#include <iterator>
#include <ranges>
#include <vector>
template <std::ranges::input_range V, typename Pred>
    requires std::ranges::view<V>
class FilterView : public std::ranges::view_interface<FilterView<V, Pred>> {
    V base_;
    Pred pred_;
public:
    FilterView(V base, Pred pred) : base_(std::move(base)), pred_(std::move(pred)) {}

    class Iterator {
        std::ranges::iterator_t<V> it_;
        std::ranges::sentinel_t<V> end_;
        const Pred* pred_;
        void skip() { while (it_ != end_ && !std::invoke(*pred_, *it_)) ++it_; }
    public:
        using value_type = std::ranges::range_value_t<V>;
        using difference_type = std::ranges::range_difference_t<V>;
        using iterator_concept = std::input_iterator_tag;
        Iterator(std::ranges::iterator_t<V> it, std::ranges::sentinel_t<V> end, const Pred* p)
            : it_(std::move(it)), end_(std::move(end)), pred_(p) { skip(); }
        decltype(auto) operator*() const { return *it_; }
        Iterator& operator++() { ++it_; skip(); return *this; }
        void operator++(int) { ++*this; }
        friend bool operator==(const Iterator& i, std::default_sentinel_t) { return i.it_ == i.end_; }
    };

    Iterator begin() { return Iterator(std::ranges::begin(base_), std::ranges::end(base_), &pred_); }
    std::default_sentinel_t end() { return {}; }
};

template <typename Pred>
struct FilterClosure {
    Pred pred;
    template <std::ranges::viewable_range R>
    friend auto operator|(R&& r, FilterClosure c) {
        return FilterView(std::views::all(std::forward<R>(r)), std::move(c.pred));
    }
};
inline constexpr auto filter = []<typename Pred>(Pred pred) {
    return FilterClosure<Pred>{std::move(pred)};
};

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    for (int x : v | filter([](int n) { return n % 2 == 0; })) std::cout << x << " ";  // 2 4
}

핵심은 std::views::all입니다. lvalue vector를 넘기면 복사 대신 참조를 감싼 ref_view가 되고, 임시 vector를 넘기면 그 임시를 소유하는 owning_view가 됩니다(C++20에 결함 보고로 소급 적용된 P2415로, 최근 버전의 GCC·Clang·MSVC 표준 라이브러리가 지원). 그래서 get_vector() | filter(pred)처럼 임시에 어댑터를 붙여도 원본이 사라지지 않습니다. 반대로 FilterView가 생성자에서 R을 값으로 받기만 하면, lvalue를 넘길 때 컨테이너 전체가 복사되고 원본을 수정해도 view에 반영되지 않는 문제가 생깁니다.

이 최소 구현에는 표준 filter_view와의 차이도 있습니다. 첫째, 반복자가 view 안의 pred_를 포인터로 가리키므로, view를 이동하면 이미 만든 반복자는 무효가 됩니다(표준 구현도 같은 제약이 있습니다). 둘째, 조건자가 람다면 람다는 복사 대입 연산자가 없어 FilterView가 std::ranges::view 개념(이동 대입 가능이 필요)을 만족하지 못합니다. 표준 구현은 내부 래퍼(movable-box)로 이 문제를 해결하므로, 이 view를 다시 std::views 어댑터에 이어 붙여야 한다면 같은 처리가 필요합니다. 셋째, 표준 filter_view는 begin() 결과를 캐시하고 bidirectional 반복도 지원합니다. begin()이 비const인 것도 이 캐시 때문이며, 그래서 const filter_view는 순회할 수 없습니다.

N개마다 하나씩 건너뛰는 stride 어댑터는 C++23에 std::views::stride로 들어왔으므로, 직접 만들기 전에 사용하는 표준 라이브러리 버전을 먼저 확인하세요. 여러 range를 이어 붙이는 std::views::concat은 C++26에 추가되었습니다.

view_interface가 해 주는 일

std::ranges::view_interface<Derived>를 상속하면 begin()/end()만 구현해도 empty(), operator bool, front()가 생기고, 반복자가 조건을 만족하면 back(), size(), operator[], data()도 자동으로 제공됩니다. 또 상속 자체가 “이 타입은 view다”라는 표시(enable_view)가 되어, 어댑터가 이 타입을 다시 감싸지 않고 그대로 받습니다. view는 이동이 가능해야 하고, 복사가 가능하다면 그 비용이 원소 수와 무관해야 합니다. 그래서 데이터를 소유하는 컨테이너 같은 타입은 view_interface를 상속하지 않는 것이 맞습니다.


자주 막히는 지점

static_assert(std::ranges::range<…>)가 실패한다

begin()이 돌려주는 타입이 반복자 요건을 다 갖추지 못한 경우가 대부분입니다. difference_type이나 value_type 별칭 누락, 후위 operator++(int) 누락, 반복자와 sentinel 사이의 operator== 누락이 흔한 원인입니다. std::ranges::range<R>만 검사하면 메시지가 모호하므로, std::input_iterator<std::ranges::iterator_t<R>>, std::sentinel_for<std::ranges::sentinel_t<R>, std::ranges::iterator_t<R>>처럼 한 단계씩 나눠 검사하면 어디서 막혔는지 빨리 드러납니다.

ranges::sort가 “does not satisfy random_access_iterator”로 실패한다

sort는 random access 반복자와 원소 교환 가능성을 요구합니다. 태그를 random access로 바꾸는 것만으로는 부족하고, 앞에서 본 연산자를 모두 구현해야 합니다. iterator_concept(C++20 개념용)와 iterator_category(C++17 이하 알고리즘용)가 모두 있으면 C++20 개념은 iterator_concept를 우선해서 봅니다.

const 객체로는 순회되지 않는다

begin()/end()가 비const 멤버뿐이면 const MyRange&로 받은 곳에서 순회할 수 없습니다. 데이터를 소유하는 타입이라면 const 버전을 추가하고, 그 버전은 const 반복자(수정 불가)를 돌려주게 합니다.

순회 중 기반 컨테이너를 수정했다

range나 view가 기반 컨테이너를 참조하는 경우, 순회 중에 push_back 등으로 재할당이 일어나면 반복자가 무효화되어 미정의 동작이 됩니다. 커스텀 range가 데이터를 소유하는지, 참조만 하는지, 어떤 연산이 반복자를 무효화하는지를 주석으로 남겨 두는 것이 좋습니다.

value_type에 참조를 넣었다

value_type은 원소의 “값” 타입이므로 T&나 const T가 아니라 cv·참조를 뗀 T여야 합니다. operator*가 참조를 반환하든 임시값을 반환하든, 그 반환 타입은 std::iter_reference_t로 따로 추론됩니다. value_type을 T&로 두면 std::indirectly_readable 검사에서 실패합니다.


같이 보면 좋은 글


이전 글: C++ 실전 가이드 #25-2: 뷰와 파이프라인 다음 글: [C++ 실전 가이드 #26-1] constexpr 함수와 변수: 컴파일 타임에 계산하기