C++ 코드를 작성하다 보면, 파이썬의 range, enumerate 같은 직관적이고 깔끔한 반복 구문이 부러울 때가 있습니다. 예를 들어 for i in range(10)라고만 쓰면 0부터 9까지 편하게 순회할 수 있고, for idx, val in enumerate(obj)로 인덱스와 값을 동시에 받아오는 문법은 가독성을 크게 높여줍니다. 이 글에서는 C++17, C++20, C++23에 걸쳐 제공되는 기능들을 활용해 파이썬스러운 API를 구현하고, 다양한 상황에서의 사용 예제와 성능, 유연성에 대해 살펴보겠습니다.
구성은 다음과 같습니다:
- 일반적인 C++ 구현 (Before)
- 기존 C++ 스타일로 인덱스와 값을 처리하는 방식.
- 단순한 Python 같은 C++ 구현 (After: 첫 단추)
- C++17에서도 가능한 단순한 range와 enumerate 구현.
- 다양한 사용 예제: 기본 스텝, 커스텀 스텝, 마이너스 스텝 등.
- 성능 및 유연성 확장을 위한 더 복잡한 구현
- 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 구현을 통해 표현력과 성능 모두를 추구할 수 있습니다.
'개발 이야기 > C++' 카테고리의 다른 글
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #3: map과 filter (0) | 2024.12.12 |
---|---|
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #2: zip (0) | 2024.12.10 |
[C++23 새기능 소개] std::views::stride (0) | 2024.12.09 |
[C++23 새기능 소개] std::spanstream (0) | 2024.12.09 |
[C++23 새기능 소개] std::views::split_when (0) | 2024.12.09 |