그동안 다양한 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를 직접 반환하는 식으로 최적화할 수 있습니다.
관련 링크 및 리소스
- C++ 표준 문서:
- range-v3:
- range-v3 GitHub
view::join를 통해 chain.from_iterable 유사 동작 구현 가능, view::chunk(n)로 batched 유사 기능 구현.
- range-v3 GitHub
- 파이썬 itertools 문서:
마무리
이번 글에서는 chain.from_iterable와 batched를 C++20/23 스타일로 흉내 내며, 파이썬 itertools 패턴의 끝없는 확장 가능성을 다시 한번 확인했습니다. 파이썬과 달리 C++에서는 이런 기능을 표준 라이브러리에서 직접 제공하진 않지만, Ranges, Concepts, 그리고 외부 라이브러리를 조합하면 점점 더 파이썬스럽고 우아한 코드를 작성할 수 있다는 희망을 품어볼 수 있습니다. 이제 여러분도 이 아이디어들을 확장해, 프로젝트에서 더욱 매력적이고 간결한 C++ 코드를 만들어보시길 바랍니다!