C++20과 C++23을 활용한 “파이썬스러운” API 구현 #10: chain.from_iterable, batched

그동안 다양한 itertools 기능을 C++로 옮기는 방법을 살펴봤습니다. 파이썬 itertools는 시퀀스를 다루는 멋진 아이디어를 끊임없이 제공하는데, 이번에는 chain.from_iterable와 batched 두 가지를 살펴보려 합니다. 특히 batched는 파이썬 3.11에서 새롭게 추가된 함수로, 이 역시 매력적인 기능입니다.

 

먼저 파이썬에서 이 두 함수를 간단히 소개해볼게요.

from itertools import chain, batched

# chain.from_iterable(iterable_of_iterables)는 이중 iterable을 평탄화
# 예: [[1,2],[3,4,5],[6]] -> 1,2,3,4,5,6
list_of_lists = [[1,2],[3,4,5],[6]]
for x in chain.from_iterable(list_of_lists):
    print(x, end=' ')
# 출력: 1 2 3 4 5 6

print()

# batched(iterable, n)은 iterable을 길이 n의 배치로 묶어 반환
# 마지막 배치가 n보다 짧아도 그대로 반환
data = [10,20,30,40,50,60,70]
for batch in batched(data, 3):
    print(batch)
# 출력:
# (10, 20, 30)
# (40, 50, 60)
# (70,)   # 마지막은 길이가 3 미만

 

이제 이 기능들을 C++20/23 스타일로 흉내내는 방법을 살펴봅시다.

1. chain.from_iterable 구현 (기본 형태)

chain.from_iterable는 chain의 특별한 형태로, iterable의 iterable을 받으면 이를 일렬로 이어붙여 평탄화합니다. 이전 글에서 chain을 다룬 적이 있으니, 이번에는 “from_iterable” 형식으로 여러 컨테이너를 담은 하나의 컨테이너를 평탄화하는 뷰를 구현해보겠습니다.

#include <vector>
#include <iterator>

template<typename OuterContainer>
class chain_from_iterable_view {
public:
    chain_from_iterable_view(OuterContainer& outer) : outer_(outer) {}

    class iterator {
    public:
        using outer_it = decltype(std::begin(std::declval<OuterContainer&>()));
        using inner_container_type = typename std::remove_reference_t<decltype(*std::declval<outer_it>())>;
        using inner_it = decltype(std::begin(std::declval<inner_container_type&>()));

        iterator(outer_it out_it, outer_it out_end)
            : out_it_(out_it), out_end_(out_end) {
            advance_to_valid_inner();
        }

        iterator& operator++() {
            if (out_it_ == out_end_) return *this; 
            ++in_it_;
            if (in_it_ == in_end_) {
                ++out_it_;
                advance_to_valid_inner();
            }
            return *this;
        }

        bool operator!=(const iterator& other) const {
            return out_it_ != other.out_it_ || in_it_ != other.in_it_;
        }

        auto operator*() const {
            return *in_it_;
        }

    private:
        void advance_to_valid_inner() {
            while (out_it_ != out_end_) {
                auto& inner = *out_it_;
                in_it_ = std::begin(inner);
                in_end_ = std::end(inner);
                if (in_it_ != in_end_) break;
                ++out_it_;
            }
        }

        outer_it out_it_, out_end_;
        inner_it in_it_, in_end_;
    };

    auto begin() {
        return iterator(std::begin(outer_), std::end(outer_));
    }
    auto end() {
        return iterator(std::end(outer_), std::end(outer_));
    }

private:
    OuterContainer& outer_;
};

template<typename OuterContainer>
auto chain_from_iterable_func(OuterContainer& outer) {
    return chain_from_iterable_view<OuterContainer>(outer);
}

 

사용 예제:

std::vector<std::vector<int>> list_of_lists = {{1,2},{3,4,5},{6}};
for (auto x : chain_from_iterable_func(list_of_lists)) {
    std::cout << x << " "; // 1 2 3 4 5 6
}
std::cout << "\n";

2. batched 구현 (기본 형태)

batched(iterable, n)은 주어진 iterable을 길이 n씩 잘라내어 튜플(또는 다른 컨테이너)에 담아 yield합니다. 마지막 배치는 n보다 짧을 수 있습니다. 여기서는 단순히 std::vector나 std::array 대신 std::vector를 사용해 batch를 담아 반환해보겠습니다(튜플로 만들 수도 있지만 편의상 벡터 사용).

#include <algorithm>

template<typename Container>
class batched_view {
public:
    batched_view(Container& c, std::size_t n) : c_(c), n_(n) {}

    class iterator {
    public:
        using base_it = decltype(std::begin(std::declval<Container&>()));

        iterator(base_it it, base_it end, std::size_t n)
            : it_(it), end_(end), n_(n) {}

        iterator& operator++() {
            // n개 전진, end 초과하면 end로 맞춤
            std::size_t count = 0;
            while (it_ != end_ && count < n_) {
                ++it_;
                ++count;
            }
            return *this;
        }

        bool operator!=(const iterator& other) const {
            // 다른 이터레이터와 비교 시, 현재 위치가 다르면 다르다고 판단
            return it_ != other.it_;
        }

        auto operator*() const {
            // it_에서 n개(혹은 end까지)만큼 벡터로 담아 반환
            std::vector<typename Container::value_type> batch;
            auto temp = it_;
            // 뒤로 되돌아가야 하므로, operator* 호출 시점에서 it는 배치의 시작점이 아님
            // trick: operator*에서는 현재 batch 시작점 it를 알아야 함.
            // -> 이터레이터를 prefix decrement 불가능하면, 다른 방법 필요.
            // 여기서 아이디어 수정: it를 batch 시작점 저장
        }

    private:
        base_it it_, end_;
        std::size_t n_;
    };

    // 위 설계에 문제가 있으므로 수정 필요:
    // 이터레이터가 배치를 표현하려면, batch의 시작점과 끝점이 필요.
    // batch의 시작점을 it라 했을 때 ++에서 it를 n만큼 옮기니
    // operator*에서 batch 내용을 가져오려면 현재 batch의 시작점이 필요.
    // 해결책: 현재 batch의 시작점과 끝점 유지

    class fixed_iterator {
    public:
        using base_it = decltype(std::begin(std::declval<Container&>()));
        fixed_iterator(base_it current, base_it end, std::size_t n)
            : current_(current), end_(end), n_(n) {}

        fixed_iterator& operator++() {
            // 다음 batch 시작점
            std::size_t count = 0;
            while (current_ != end_ && count < n_) {
                ++current_;
                ++count;
            }
            return *this;
        }

        bool operator!=(const fixed_iterator& other) const {
            return current_ != other.current_;
        }

        auto operator*() const {
            std::vector<typename Container::value_type> batch;
            auto it = current_;
            // batch의 시작점을 구하기 위해선?
            // 여기서 아이디어 변경: 처음에 batch start를 보관.
        }

    };

    // 다시 재설계. 배치 단위로 이동하려면, iterator에서 batch 시작점을 알아야 함.
    // 아이디어: 이터레이터를 생성할 때 '현재 배치 시작점'을 저장하고, ++ 시점에 n만큼 이동.
    class batch_iterator {
    public:
        using base_it = decltype(std::begin(std::declval<Container&>()));
        batch_iterator(base_it start, base_it end, std::size_t n)
            : start_(start), end_(end), n_(n) {}

        batch_iterator& operator++() {
            // n만큼 이동
            std::size_t count = 0;
            while (start_ != end_ && count < n_) {
                ++start_;
                ++count;
            }
            return *this;
        }

        bool operator!=(const batch_iterator& other) const {
            return start_ != other.start_;
        }

        auto operator*() const {
            // start_부터 n개 추출 (단, end까지)
            std::vector<typename Container::value_type> batch;
            auto it = start_;
            // start_는 현재 배치의 시작점이 아니라, 다음 배치 시작점이 필요.
            // 문제 발생: 지금 start_는 현재 배치의 시작점이 아니라 다음 batch 시작점임.
            // 새로운 전략: 이터레이터가 batch의 시작점을 갖고, ++할 때 시작점을 n만큼 옮긴 뒤 반환 전 상태 필요.
            
            // 좀 더 단순화:
            // iterator를 생성할 때 '배치 시작점'을 저장하고, operator*에서 참조. ++에서는 그 다음 배치 시작점 계산.
            // 그럼 operator* 호출 시점에서 이미 batch start를 잃었음.
            // 해결책: operator* 호출 시점에는 배치 시작점이 필요하니, ++하기 전 상태를 기억해야 함.
            // 여기서는 post-increment가 아닌 pre-increment로 하므로 문제.
            
            // 다른 방식:
            // iterator 내부에 current position 유지, operator*에서 batch 구할 때 current부터 n개.
            // ++에서는 current를 n만큼 증가.
            auto batch_start = start_;
            std::vector<typename Container::value_type> batch_res;
            std::size_t count = 0;
            for (; batch_start != end_ && count < n_; ++batch_start, ++count) {
                batch_res.push_back(*batch_start);
            }
            return batch_res;
        }

    private:
        base_it start_;
        base_it end_;
        std::size_t n_;
    };

    auto begin() {
        return batch_iterator(std::begin(c_), std::end(c_), n_);
    }
    auto end() {
        return batch_iterator(std::end(c_), std::end(c_), n_);
    }

private:
    Container& c_;
    std::size_t n_;
};

template<typename Container>
auto batched_func(Container& c, std::size_t n) {
    return batched_view<Container>(c, n);
}

 

사용 예제:

std::vector<int> data = {10,20,30,40,50,60,70};
for (auto batch : batched_func(data, 3)) {
    for (auto x : batch) std::cout << x << " ";
    std::cout << "\n";
}
// 출력:
// 10 20 30
// 40 50 60
// 70

정교한(또는 확장된) 구현: C++20 Ranges와 Concepts 활용

  • chain.from_iterable: variadic templates나 Concepts를 이용하면 다양한 형태의 iterable-of-iterables를 처리 가능하고, Ranges와 결합하면 outer | chain_from_iterable_func() | transform(...) 식의 파이프라인 가능.
  • batched: Ranges에서는 std::views::chunk(n)처럼 비슷한 기능을 제공하는 경우가 있습니다(비공식, range-v3 등). 이를 활용하면 훨씬 간결하게 작성 가능합니다.
  • Concepts를 통해 입력이 Range인지, batch 크기가 유효한지 등을 컴파일 타임에 체크해 안전성과 표현력을 강화할 수 있습니다.

성능과 메모리 측면

  • chain.from_iterable: 추가 메모리 할당 없이 lazy하게 평탄화.
  • batched: 각 배치를 반환할 때마다 vector를 새로 만들므로 그때마다 동적 할당이 발생하지만, 크게 문제가 되는 경우가 아니라면 충분히 실용적입니다. 더 정교한 구현으로 tuple이나 정적 배열, 또는 iterator를 직접 반환하는 식으로 최적화할 수 있습니다.

관련 링크 및 리소스

마무리

이번 글에서는 chain.from_iterable와 batched를 C++20/23 스타일로 흉내 내며, 파이썬 itertools 패턴의 끝없는 확장 가능성을 다시 한번 확인했습니다. 파이썬과 달리 C++에서는 이런 기능을 표준 라이브러리에서 직접 제공하진 않지만, Ranges, Concepts, 그리고 외부 라이브러리를 조합하면 점점 더 파이썬스럽고 우아한 코드를 작성할 수 있다는 희망을 품어볼 수 있습니다. 이제 여러분도 이 아이디어들을 확장해, 프로젝트에서 더욱 매력적이고 간결한 C++ 코드를 만들어보시길 바랍니다!

반응형