C++20과 C++23을 활용한 “파이썬스러운” API 구현 #1: range와 enumerate

C++ 코드를 작성하다 보면, 파이썬의 range, enumerate 같은 직관적이고 깔끔한 반복 구문이 부러울 때가 있습니다. 예를 들어 for i in range(10)라고만 쓰면 0부터 9까지 편하게 순회할 수 있고, for idx, val in enumerate(obj)로 인덱스와 값을 동시에 받아오는 문법은 가독성을 크게 높여줍니다. 이 글에서는 C++17, C++20, C++23에 걸쳐 제공되는 기능들을 활용해 파이썬스러운 API를 구현하고, 다양한 상황에서의 사용 예제와 성능, 유연성에 대해 살펴보겠습니다.

 

구성은 다음과 같습니다:

  1. 일반적인 C++ 구현 (Before)
    • 기존 C++ 스타일로 인덱스와 값을 처리하는 방식.
  2. 단순한 Python 같은 C++ 구현 (After: 첫 단추)
    • C++17에서도 가능한 단순한 range와 enumerate 구현.
    • 다양한 사용 예제: 기본 스텝, 커스텀 스텝, 마이너스 스텝 등.
  3. 성능 및 유연성 확장을 위한 더 복잡한 구현
    • C++20 코루틴을 사용한 lazy range.
    • 파이썬스럽지만 유연한 시퀀스 생성, 조건부 생성, enumerate와의 결합.
    • 성능 트레이드오프와 메모리 사용 이점.

마지막에는 대표적인 완성 구현 예제를 제시할 예정입니다.

1. 일반적인 C++에서의 구현 (Before)

기존 C++(C++17 이전, 혹은 C++20이지만 특별히 새로운 기능을 쓰지 않는 경우)에서 인덱스와 값을 함께 처리하는 방식은 다음과 같습니다.

#include <vector>
#include <iostream>
#include <string>

int main() {
    std::vector<std::string> fruits = {"apple", "banana", "cherry"};

    // 전형적 인덱스 반복
    for (std::size_t i = 0; i < fruits.size(); ++i) {
        std::cout << i << ": " << fruits[i] << "\n";
    }

    // 범위 기반 for (C++11~), 인덱스는 따로 관리
    std::size_t idx = 0;
    for (auto& f : fruits) {
        std::cout << idx++ << ": " << f << "\n";
    }
}

이 방식은 명시적이고 익숙하지만, 파이썬의 range, enumerate에 비해 장황하고 인덱스를 관리하는 부분이 중복되는 느낌을 줍니다.

2. 단순한 Python 같은 C++ 구현 (After: 첫 단추)

C++17에서도 템플릿, iterator, 구조적 바인딩을 조합하면 조금 더 파이썬스러운 코딩 스타일에 근접할 수 있습니다. 여기서는 단순한 iterator 기반 range와 enumerate를 구현하고 다양한 예제를 통해 사용하는 모습을 보여드리겠습니다.

간단한 range 구현

#include <cstddef>
#include <iterator>

class simple_range {
public:
    class iterator {
    public:
        using value_type = std::size_t;
        using difference_type = std::ptrdiff_t;
        using iterator_category = std::input_iterator_tag;

        iterator(std::size_t current, std::size_t step, std::size_t end, bool ascending)
            : current_(current), step_(step), end_(end), ascending_(ascending) {}

        std::size_t operator*() const { return current_; }

        iterator& operator++() {
            current_ = ascending_ ? current_ + step_ : current_ - step_;
            return *this;
        }

        bool operator!=(const iterator& other) const {
            // ascending이면 current_가 end_ 미만일 때 계속, descending이면 current_가 end_ 초과일 때 계속
            return ascending_ ? (current_ < other.current_) : (current_ > other.current_);
        }

    private:
        std::size_t current_;
        std::size_t step_;
        std::size_t end_;
        bool ascending_;
    };

    // start <= end 이면 ascending, start > end이면 descending으로 간주
    simple_range(std::size_t start, std::size_t end, std::size_t step = 1)
        : start_(start), end_(end), step_(step), ascending_(start <= end)
    {}

    iterator begin() const { 
        return iterator(start_, step_, end_, ascending_);
    }

    iterator end() const { 
        return ascending_ 
            ? iterator(end_, step_, end_, ascending_) 
            : iterator(end_, step_, end_, ascending_); 
    }

private:
    std::size_t start_, end_, step_;
    bool ascending_;
};

이 구현은 start와 end의 관계에 따라 오름차순 혹은 내림차순 반복을 지원합니다.

사용 예제:

#include <iostream>

int main() {
    // 기본: start < end이면 오름차순
    for (auto i : simple_range(0, 5)) {
        std::cout << i << " "; // 0 1 2 3 4
    }
    std::cout << "\n";

    // 스텝 지정
    for (auto i : simple_range(0, 10, 2)) {
        std::cout << i << " "; // 0 2 4 6 8
    }
    std::cout << "\n";

    // 마이너스 스텝과 유사하게, start > end 인 경우 내림차순
    // 여기서는 step=1이지만 start=10, end=5이므로 10에서 6까지 감소
    // 단, 이 구현은 단순성을 위해 i > end인 동안 순회.
    // end가 5라면, 10 -> 9 -> 8 -> 7 -> 6 (5까지 포함 안함)
    for (auto i : simple_range(10, 5, 1)) {
        std::cout << i << " "; // 10 9 8 7 6
    }
    std::cout << "\n";
}

위 예제는 파이썬의 range(10,5,-1) 같은 느낌을 흉내냅니다.
(만약 완벽히 파이썬 range(start, end, step)의 동작을 재현하고 싶다면, 부호에 따라 조건문을 분기하는 로직을 추가할 수 있습니다.)

간단한 enumerate 구현

#include <utility> // for std::pair
#include <cstddef>

template<typename Container>
class enumerate_view {
public:
    enumerate_view(Container& c) : c_(c) {}

    class iterator {
    public:
        using base_iterator = decltype(std::begin(std::declval<Container&>()));
        using reference_type = decltype(*std::declval<base_iterator>());
        using value_type = std::pair<std::size_t, reference_type>;

        iterator(base_iterator it, std::size_t idx) : it_(it), idx_(idx) {}

        iterator& operator++() { ++it_; ++idx_; return *this; }
        bool operator!=(const iterator& other) const { return it_ != other.it_; }
        value_type operator*() const { return {idx_, *it_}; }

    private:
        base_iterator it_;
        std::size_t idx_;
    };

    iterator begin() { return iterator(std::begin(c_), 0); }
    iterator end()   { return iterator(std::end(c_), 0); }

private:
    Container& c_;
};

template<typename Container>
auto enumerate(Container& c) {
    return enumerate_view<Container>(c);
}

사용 예제:

#include <vector>
#include <string>
#include <iostream>

int main() {
    std::vector<std::string> fruits = {"apple", "banana", "cherry"};

    for (auto [idx, fruit] : enumerate(fruits)) {
        std::cout << idx << ": " << fruit << "\n";
    }

    // range와 결합해 인덱스를 명시적 없이도 다룰 수 있음
    // 예: simple_range를 이용해 배열 인덱싱
    std::vector<int> numbers = {10, 20, 30, 40};
    for (auto i : simple_range(0, numbers.size())) {
        std::cout << i << ": " << numbers[i] << "\n";
    }
}

여기까지 성능과 특징

  • 성능: 거의 기본 for 루프와 비슷한 성능. iterator 증가 연산과 조건 체크 정도의 오버헤드만 있습니다.
  • 메모리 사용: 별도 컨테이너 할당 없이 lazy evaluation.
  • C++17에서도 가능: 이 정도는 C++17로도 충분히 구현 가능. 다만 C++20 이후에는 Concepts, Ranges 라이브러리로 타입 검사와 표현력 강화.

3. 성능, 유연성 확장을 위한 더 복잡한 구현

C++20 코루틴 기반 lazy range 구현

코루틴을 사용하면 파이썬 제너레이터처럼 동작하는 범위 생산자를 만들 수 있습니다. 이 방식은 단순 정수 증가뿐 아니라, 조건부 생성, 무한 시퀀스, 복잡한 계산에도 적합합니다. 또한 enumerate와 조합해 더 풍부한 표현을 할 수 있습니다.

#include <coroutine>
#include <concepts>
#include <iostream>
#include <utility>

template<typename T>
struct Generator {
    struct promise_type {
        T current_value;
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
    };

    std::coroutine_handle<promise_type> coro;
    Generator(std::coroutine_handle<promise_type> h) : coro(h) {}
    Generator(const Generator&) = delete;
    Generator(Generator&& other) noexcept : coro(std::exchange(other.coro, {})) {}
    ~Generator() { if (coro) coro.destroy(); }

    bool next() {
        if (!coro || coro.done()) return false;
        coro.resume();
        return !coro.done();
    }

    T value() const { return coro.promise().current_value; }

    struct iterator {
        Generator* gen;
        bool done;
        iterator(Generator* g, bool d) : gen(g), done(d) {}
        iterator& operator++() {
            done = !gen->next();
            return *this;
        }
        T operator*() const { return gen->value(); }
        bool operator!=(const iterator&) const { return !done; }
    };

    iterator begin() {
        bool d = !next();
        return iterator(this, d);
    }
    iterator end() { return iterator(nullptr, true); }
};

template<std::integral T>
Generator<T> coro_range(T start, T end, T step = 1) {
    if (step == 0) throw std::invalid_argument("step cannot be 0");
    if (step > 0) {
        for (T i = start; i < end; i += step) co_yield i;
    } else {
        // 음수 step 지원: start가 end보다 클 때 역방향
        for (T i = start; i > end; i += step) co_yield i;
    }
}

사용 예제:

#include <iostream>

int main() {
    // 오름차순
    for (auto i : coro_range(0, 5)) {
        std::cout << i << " "; // 0 1 2 3 4
    }
    std::cout << "\n";

    // 스텝 2
    for (auto i : coro_range(0, 10, 2)) {
        std::cout << i << " "; // 0 2 4 6 8
    }
    std::cout << "\n";

    // 음수 스텝
    for (auto i : coro_range(10, 5, -1)) {
        std::cout << i << " "; // 10 9 8 7 6
    }
    std::cout << "\n";

    // enumerate와 결합
    std::vector<int> data = {100, 200, 300};
    for (auto [idx, val] : enumerate(data)) {
        std::cout << idx << ": " << val << "\n";
    }

    // coro_range로 생성한 시퀀스를 enumerate로 감싸는 것도 가능
    // (단, 여기서는 enumerate_view는 컨테이너 참조를 받으므로 별도 변환 필요)
    // 예: 어떤 범위를 std::vector로 모은 후 enumerate
    std::vector<int> generated;
    for (auto i : coro_range(5, 0, -1)) {
        generated.push_back(i);
    }
    for (auto [idx, val] : enumerate(generated)) {
        std::cout << idx << ": " << val << "\n";
    }
}

성능과 메모리 측면

  • 코루틴 기반 구현은 상태 전환 비용이 약간 있지만, 일반 규모의 반복에서는 체감하기 어렵습니다.
  • 메모리를 추가로 할당하지 않고 lazy하게 값을 생산하므로 대규모 범위에서도 효율적입니다.
  • C++20 이후로는 표준 라이브러리의 std::views::iota 등과 조합할 수 있습니다. C++23에서 Ranges 관련 기능은 더욱 정교해졌습니다.

C++17 vs C++20 vs C++23

  • C++17 이전: 코루틴 표준 지원 없음. iterator 기반으로 직접 구현하거나 외부 라이브러리(Boost.Coroutine) 활용 필요.
  • C++20: 코루틴, Concepts, Ranges 라이브러리를 공식 지원. std::views::iota, std::views::transform, std::views::filter 등을 통해 range-like 기능을 쉽게 조합 가능.
  • C++23: Ranges 관련 표준 확장 및 개선으로, 사용자 정의 뷰나 enumerate 유사 기능을 구현하기 더 편해짐. 여전히 enumerate는 표준 미제공이지만, 쉽게 커스텀 가능.

완성된 대표 구현 예제

마지막으로, 여기까지 다룬 내용을 하나의 예제로 정리합니다. 이 예제는 C++20 코루틴을 통한 lazy range, 마이너스 스텝 지원, enumerate 기능을 모두 활용한 파이썬스러운 C++ 코드를 보여줍니다.

#include <coroutine>
#include <concepts>
#include <iostream>
#include <utility>
#include <vector>
#include <string>

// Lazy generator with step and direction
template<typename T>
struct Generator {
    struct promise_type {
        T current_value;
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
    };
    std::coroutine_handle<promise_type> coro;
    Generator(std::coroutine_handle<promise_type> h) : coro(h) {}
    Generator(const Generator&) = delete;
    Generator(Generator&& other) noexcept : coro(std::exchange(other.coro, {})) {}
    ~Generator() { if (coro) coro.destroy(); }

    bool next() {
        if (!coro || coro.done()) return false;
        coro.resume();
        return !coro.done();
    }

    T value() const { return coro.promise().current_value; }

    struct iterator {
        Generator* gen;
        bool done;
        iterator(Generator* g, bool d) : gen(g), done(d) {}
        iterator& operator++() {
            done = !gen->next();
            return *this;
        }
        T operator*() const { return gen->value(); }
        bool operator!=(const iterator&) const { return !done; }
    };

    iterator begin() {
        bool d = !next();
        return iterator(this, d);
    }
    iterator end() { return iterator(nullptr, true); }
};

template<std::integral T>
Generator<T> coro_range(T start, T end, T step = 1) {
    if (step == 0) throw std::invalid_argument("step cannot be 0");
    if (step > 0) {
        for (T i = start; i < end; i += step) co_yield i;
    } else {
        for (T i = start; i > end; i += step) co_yield i;
    }
}

template<typename Range>
struct enumerate_view {
    Range& r;
    struct iterator {
        using base_iterator = decltype(std::begin(std::declval<Range&>()));
        base_iterator it;
        std::size_t idx;
        bool operator!=(const iterator& other) const { return it != other.it; }
        iterator& operator++() { ++it; ++idx; return *this; }
        auto operator*() const { return std::pair{idx, *it}; }
    };
    auto begin() { return iterator{std::begin(r), 0}; }
    auto end() { return iterator{std::end(r), 0}; }
};

template<typename Range>
auto enumerate(Range& r) {
    return enumerate_view<Range>{r};
}

int main() {
    // 양의 스텝
    for (auto i : coro_range(0, 5)) {
        std::cout << i << " "; 
    }
    std::cout << "\n"; // 0 1 2 3 4

    // 음의 스텝
    for (auto i : coro_range(5, 0, -1)) {
        std::cout << i << " "; 
    }
    std::cout << "\n"; // 5 4 3 2 1

    std::vector<std::string> fruits = {"apple", "banana", "cherry"};
    for (auto [idx, fruit] : enumerate(fruits)) {
        std::cout << idx << ": " << fruit << "\n";
    }

    // coro_range로 생성한 시퀀스 -> vector로 수집 -> enumerate
    std::vector<int> seq;
    for (auto x : coro_range(10, 5, -1)) {
        seq.push_back(x);
    }
    for (auto [idx, val] : enumerate(seq)) {
        std::cout << idx << ": " << val << "\n";
    }

    return 0;
}

이로써 파이썬의 range, enumerate와 유사한 사용성을 C++에서 어느 정도 달성할 수 있음을 보았습니다. 스텝을 다루는 다양한 사례(양수, 음수 스텝)와 enumerate 조합, 코루틴을 통한 lazy range 구현을 통해 표현력과 성능 모두를 추구할 수 있습니다.

반응형