여러분, 지난 글들에서 우리는 C++에서 파이썬의 range, enumerate, zip, map, filter, chain, takewhile, dropwhile 등을 흉내 내는 방법을 쭉 살펴봤죠. 이제는 또 다른 파이썬 itertools의 매력적인 부분으로 넘어가려고 합니다. 바로 accumulate(파이썬 itertools의 accumulate), 그리고 groupby입니다.
accumulate는 요소들을 순회하면서 누적 합이나, 누적 연산 결과를 차곡차곡 모아줍니다. 파이썬에서는 itertools.accumulate([1,2,3,4], operator.mul) 이렇게 사용해 곱셈 누적을, operator.add를 사용해 합 누적을 손쉽게 할 수 있습니다. C++에도 std::accumulate라는 알고리즘이 있지만 결과를 한 번에 내놓고 끝나며, lazy한 sequence로 바라보긴 어렵습니다.
groupby는 연속된 원소를 기준으로 그룹화하는 기능을 합니다. 파이썬에서는 다음처럼 사용할 수 있죠:
from itertools import groupby
data = [1,1,2,2,2,3,3,1]
for key, group in groupby(data):
print(key, list(group))
# 1 [1, 1]
# 2 [2, 2, 2]
# 3 [3, 3]
# 1 [1]
C++에서 이런 동작을 “파이썬스럽게” 구현하려면 어떻게 하면 좋을까요? 이번 글에선 이 두 가지 기능을, 이전 글들처럼 간단한 구현에서 출발해, C++20/23 Ranges와 Concepts를 활용한 좀 더 깔끔한 형태로 발전시켜보겠습니다. 늘 그렇듯, 완벽히 파이썬과 동일하지는 않더라도, 비슷한 사용감을 얻을 수 있다는 것이 핵심이에요.
1. 일반적인 C++ 구현 (Before)
accumulate를 흉내내려면?
C++17까지, 단순 누적 합을 하려면 std::accumulate를 사용할 수 있었습니다. 하지만 이건 한 번 실행하면 최종 결과 하나만 얻고 끝나죠. 중간 과정의 변화를 보고 싶다면 별도로 배열을 만들어서 결과를 저장해야 했습니다.
#include <vector>
#include <numeric>
#include <iostream>
int main() {
std::vector<int> data = {1,2,3,4};
// std::accumulate는 최종 합만 반환
int sum = std::accumulate(data.begin(), data.end(), 0);
std::cout << "sum: " << sum << "\n"; // 10
// 중간 누적 결과를 보고 싶다면?
// 별도 루프나 벡터 필요
std::vector<int> acc;
int running = 0;
for (auto x : data) {
running += x;
acc.push_back(running);
}
for (auto x : acc) std::cout << x << " "; // 1 3 6 10
std::cout << "\n";
}
이건 가능하긴 한데 파이썬 accumulate처럼 for (auto val : accumulate_view(data)) { ... } 식으로 깔끔하지는 않죠.
groupby를 흉내내려면?
groupby도 마찬가지입니다. 연속된 같은 값들을 묶어서 처리하려면, 조건문과 임시 벡터를 만들어가며 제어해야 합니다.
std::vector<int> data = {1,1,2,2,2,3,3,1};
int prev = data[0];
std::vector<int> group;
for (auto x : data) {
if (x == prev) {
group.push_back(x);
} else {
// group완성
std::cout << prev << ":";
for (auto g : group) std::cout << g << " ";
std::cout << "\n";
group.clear();
group.push_back(x);
}
prev = x;
}
// 마지막 그룹 출력
std::cout << prev << ":";
for (auto g : group) std::cout << g << " ";
std::cout << "\n";
이 코드, 솔직히 꽤 번잡하죠? 파이썬 groupby의 간결함에 비하면 아쉬운 부분이 많습니다.
2. 단순한 Python 같은 C++ 구현 (After: 첫 단추)
이제 간단한 view 형태로 accumulate와 groupby를 구현해봅시다. C++17 스타일 정도로, iterator와 함수객체를 이용한 기본적인 형태를 먼저 보여주겠습니다.
accumulate_view 구현(단순 형태)
#include <iterator>
#include <type_traits>
#include <utility>
#include <functional>
template<typename Container, typename BinaryOp = std::plus<>>
class accumulate_view {
public:
accumulate_view(Container& c, typename Container::value_type init = {}, BinaryOp op = {})
: c_(c), init_(init), op_(op) {}
class iterator {
public:
using base_it = decltype(std::begin(std::declval<Container&>()));
using value_type = typename Container::value_type;
iterator(base_it it, base_it end, value_type current, BinaryOp op)
: it_(it), end_(end), current_(current), op_(op), first_(true) {}
iterator& operator++() {
if (it_ != end_) {
if (first_) {
current_ = *it_;
first_ = false;
} else {
current_ = op_(current_, *it_);
}
++it_;
}
return *this;
}
bool operator!=(const iterator& other) const {
return it_ != other.it_;
}
value_type operator*() const {
return current_;
}
private:
base_it it_, end_;
value_type current_;
BinaryOp op_;
bool first_;
};
auto begin() {
auto b = std::begin(c_);
auto e = std::end(c_);
return iterator(b, e, init_, op_);
}
auto end() {
auto e = std::end(c_);
return iterator(e, e, init_, op_);
}
private:
Container& c_;
typename Container::value_type init_;
BinaryOp op_;
};
template<typename Container, typename BinaryOp = std::plus<>>
auto accumulate_func(Container& c, typename Container::value_type init = {}, BinaryOp op = {}) {
return accumulate_view<Container, BinaryOp>(c, init, op);
}
사용 예제:
#include <iostream>
#include <vector>
std::vector<int> data = {1,2,3,4};
for (auto val : accumulate_func(data)) {
std::cout << val << " "; // 1 3 6 10
}
std::cout << "\n";
op를 다른 연산자로 바꾸면 곱셈, 최대값 누적 등 다양한 변형도 가능합니다.
groupby_view 구현(간단 형태)
groupby는 조금 복잡합니다. 기본적으로 현재 값과 이전 값을 비교하면서 그룹 경계를 찾아야 하죠. 여기서는 단순히 “값이 변하면 새 그룹 시작”이라는 기본적인 로직만 구현해보겠습니다.
#include <vector>
template<typename Container>
class groupby_view {
public:
groupby_view(Container& c) : c_(c) {}
// groupby에서는 iterator 대신 group 객체를 반환하고 싶을 수 있지만,
// 여기서는 간단히 하고, key와 그룹 이터레이터 범위를 반환하는 구조를 생각해봅시다.
struct group {
using base_it = decltype(std::begin(std::declval<Container&>()));
typename Container::value_type key;
base_it begin_it;
base_it end_it;
};
class iterator {
public:
using base_it = decltype(std::begin(std::declval<Container&>()));
iterator(base_it it, base_it end)
: it_(it), end_(end), start_(it), key_((it!=end)?*it:typename Container::value_type()) {
advance_group_end();
}
iterator& operator++() {
if (it_ != end_) {
start_ = it_;
key_ = *it_;
advance_group_end();
}
return *this;
}
bool operator!=(const iterator& other) const { return start_ != other.start_; }
group operator*() const {
return group{key_, start_, it_};
}
private:
void advance_group_end() {
while (it_ != end_ && *it_ == key_) {
++it_;
}
}
base_it it_, end_, start_;
typename Container::value_type key_;
};
auto begin() {
return iterator(std::begin(c_), std::end(c_));
}
auto end() {
auto e = std::end(c_);
return iterator(e, e);
}
private:
Container& c_;
};
template<typename Container>
auto groupby_func(Container& c) {
return groupby_view<Container>(c);
}
사용 예제:
std::vector<int> data2 = {1,1,2,2,2,3,3,1};
for (auto grp : groupby_func(data2)) {
std::cout << grp.key << ":";
for (auto it = grp.begin_it; it != grp.end_it; ++it) {
std::cout << *it << " ";
}
std::cout << "\n";
}
// 출력:
// 1:1 1
// 2:2 2 2
// 3:3 3
// 1:1
이렇게 하면 최소한 파이썬 groupby에 어느 정도 가까운 형태를 얻을 수 있습니다. 물론 파이썬처럼 key function을 지원하거나, 내부 이터레이터를 직접 반환해주는 등 세부 구현을 더 정교하게 다듬을 수도 있습니다.
3. 정교한(또는 확장된) 구현: C++20 Ranges와 Concepts 활용
C++20 Ranges 라이브러리로 넘어가면, accumulate나 groupby도 좀 더 파이프라인 스타일로 구현할 수 있습니다. 비록 표준 라이브러리에 accumulate_view나 groupby_view는 아직 없지만, std::views::transform이나 std::views::filter, std::ranges::foldl(추후 추가 가능성) 같은 요소들과 조합하면, 매우 파이썬스러운 코드 작성을 꿈꿀 수 있습니다.
또한, Ranges를 지원하는 외부 라이브러리(예: range-v3)에서는 이미 accumulate에 해당하는 유사한 뷰나 group_by라는 어댑터를 제공하고 있어서, 직접 구현할 필요 없이 편하게 쓸 수 있습니다.
range-v3 예제 (참고용)
// 개념적 코드 (range-v3 사용 가정)
#include <range/v3/view/partial_sum.hpp> // accumulate 유사
#include <range/v3/view/group_by.hpp>
int main() {
std::vector<int> v = {1,2,3,4};
for (auto x : v | ranges::views::partial_sum(std::plus{})) {
std::cout << x << " "; // 1 3 6 10
}
std::cout << "\n";
std::vector<int> w = {1,1,2,2,2,3,3,1};
for (auto group : w | ranges::views::group_by(std::equal_to{})) {
// group은 동일한 값들의 subrange
auto key = *group.begin();
std::cout << key << ": ";
for (auto x : group) std::cout << x << " ";
std::cout << "\n";
}
}
이처럼 range-v3를 쓰면 파이썬 itertools와 거의 유사한 수준까지 간결하게 표현할 수 있습니다.
성능과 메모리 측면
- 위에서 구현한 accumulate_view, groupby_view는 lazy evaluation을 적용해 불필요한 메모리 할당 없이 동작합니다.
- 반복자 이동 및 조건 검사 정도의 비용만 있으므로, 일반적으로 성능상 문제가 없습니다.
- C++20/23 표준 뷰나 range-v3 같은 라이브러리는 컴파일러 최적화를 통해 기본 루프와 큰 성능 차이 없이 동작할 수 있습니다.
관련 링크 및 리소스
- C++ 표준 문서:
- range-v3:
- range-v3 GitHub
- range-v3 documentation
- views::partial_sum, views::group_by 등에 대한 예제를 참고하면 파이썬 itertools의 다양한 기능을 C++에서 유사하게 쓸 수 있습니다.
- 파이썬 itertools 문서:
- 관련 블로그/기사:
마무리
이번 글에서는 파이썬 itertools의 accumulate와 groupby를 C++에서 “파이썬스럽게” 구현해보는 시도를 했습니다. 직접 구현한 간단한 뷰를 통해 기본 개념을 잡고, Ranges 라이브러리나 range-v3 같은 도구를 활용하면 더욱 깔끔하고 확장성 있는 코드를 얻을 수 있음을 확인했습니다. 물론 여기서 끝이 아니죠. 파이썬 itertools에는 더 많은 재미있는 함수가 있으니, C++에서도 그런 패턴을 어떻게 녹여낼지 고민해보면 좋을 것 같습니다.
'개발 이야기 > C++' 카테고리의 다른 글
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #7: cycle, repeat, islice, tee (0) | 2024.12.12 |
---|---|
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #6: product, permutations, combinations (0) | 2024.12.12 |
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #4: itertools 스타일 – chain, takewhile, dropwhile (1) | 2024.12.12 |
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #3: map과 filter (0) | 2024.12.12 |
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #2: zip (0) | 2024.12.10 |