C++20과 C++23을 활용한 “파이썬스러운” API 구현 #5: accumulate, groupby

여러분, 지난 글들에서 우리는 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 같은 라이브러리는 컴파일러 최적화를 통해 기본 루프와 큰 성능 차이 없이 동작할 수 있습니다.

관련 링크 및 리소스

마무리

이번 글에서는 파이썬 itertools의 accumulate와 groupby를 C++에서 “파이썬스럽게” 구현해보는 시도를 했습니다. 직접 구현한 간단한 뷰를 통해 기본 개념을 잡고, Ranges 라이브러리나 range-v3 같은 도구를 활용하면 더욱 깔끔하고 확장성 있는 코드를 얻을 수 있음을 확인했습니다. 물론 여기서 끝이 아니죠. 파이썬 itertools에는 더 많은 재미있는 함수가 있으니, C++에서도 그런 패턴을 어떻게 녹여낼지 고민해보면 좋을 것 같습니다.

반응형