지난 글에서 우리는 파이썬의 range, enumerate를 C++20/23 스타일로 구현해보며 파이썬스러운 간결성과 C++의 성능을 결합하는 방법을 살펴보았습니다. 이번 글에서는 파이썬에서 자주 사용하는 또 하나의 유용한 함수 zip을 다뤄보겠습니다.
파이썬의 zip은 여러 이터러블(리스트, 튜플 등)을 병렬로 순회하며 각 원소를 튜플로 묶어 돌려줍니다. 예를 들어:
for x, y in zip([1,2,3], ['a','b','c']):
print(x, y)
이렇게 하면 (1, 'a'), (2, 'b'), (3, 'c') 순으로 받을 수 있습니다. C++에서 이와 비슷한 기능을 구현하려면 어떻게 할까요? C++17 이전에는 주로 인덱스를 수동 관리하거나, Boost 라이브러리를 쓰거나, 범위 기반 for 문에 억지로 인덱스를 집어넣는 식으로 처리했습니다. 하지만 C++20 이후 Ranges, Concepts, 그리고 C++23에서의 개선 덕분에 직접 “zip 뷰(zip view)”를 구현하고, 파이썬스럽게 여러 컨테이너를 깔끔하게 동시에 순회할 수 있습니다.
이번 글은 다음과 같은 흐름으로 진행합니다.
- 일반적인 C++ 구현 (Before): 파이썬의 zip 없이 다중 컨테이너 순회하기.
- 단순한 Python 같은 C++ 구현 (After): 직접 zip-like view를 구현해 Python zip에 가까운 문법으로 사용하는 방법.
- 성능 및 유연성 확장을 위한 더 복잡한 구현: C++20 코루틴, Concepts, Ranges 라이브러리를 적극 활용해 더 범용적이고 최적화된 zip을 구현하고, 다양한 상황(컨테이너 길이 불일치, 다양한 타입 혼합, 성능 분석)에서 활용하는 모습.
1. 일반적인 C++에서의 구현 (Before)
여러 컨테이너를 병렬로 순회할 때, 파이썬의 zip 없이 C++에서 흔히 하는 방식은 인덱스를 수동으로 관리하는 것입니다.
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<int> numbers = {1, 2, 3};
std::vector<std::string> words = {"one", "two", "three"};
// 동일 길이라고 가정
for (std::size_t i = 0; i < numbers.size() && i < words.size(); ++i) {
std::cout << numbers[i] << " " << words[i] << "\n";
}
}
이렇게 하면 되긴 하지만, 반복문 안에서 인덱스 관리가 번거롭고, 컨테이너가 더 많아지면 가독성이 급격히 떨어집니다. 파이썬처럼 for (auto [n, w] : zip(numbers, words)) { ... }라고 쓸 수 있다면 더 깔끔할 텐데 말이죠.
2. 단순한 Python 같은 C++ 구현 (After: 첫 단추)
C++17에서도 템플릿과 iterator를 조합하면 비슷한 기능을 구현할 수 있습니다. 여기서는 개념을 단순화해 zip을 두 개의 컨테이너에만 적용한다고 가정해보겠습니다. C++20 Ranges 없이도 가능한 수준이며, 구현 원리는 크게 어렵지 않습니다.
단순 zip 구현 (2개의 컨테이너)
#include <utility>
#include <cstddef>
#include <iterator>
template<typename C1, typename C2>
class zip_view {
public:
zip_view(C1& c1, C2& c2) : c1_(c1), c2_(c2) {}
class iterator {
public:
using it1_type = decltype(std::begin(std::declval<C1&>()));
using it2_type = decltype(std::begin(std::declval<C2&>()));
using ref1_type = decltype(*std::declval<it1_type>());
using ref2_type = decltype(*std::declval<it2_type>());
iterator(it1_type it1, it2_type it2, it1_type end1, it2_type end2)
: it1_(it1), it2_(it2), end1_(end1), end2_(end2) {}
iterator& operator++() { ++it1_; ++it2_; return *this; }
bool operator!=(const iterator& other) const {
// 두 컨테이너 중 하나라도 끝에 도달하면 끝
return (it1_ != end1_) && (it2_ != end2_);
}
auto operator*() const {
return std::pair<ref1_type, ref2_type>(*it1_, *it2_);
}
private:
it1_type it1_, end1_;
it2_type it2_, end2_;
};
auto begin() {
return iterator(std::begin(c1_), std::begin(c2_), std::end(c1_), std::end(c2_));
}
auto end() {
return iterator(std::end(c1_), std::end(c2_), std::end(c1_), std::end(c2_));
}
private:
C1& c1_;
C2& c2_;
};
template<typename C1, typename C2>
auto zip(C1& c1, C2& c2) {
return zip_view<C1,C2>(c1, c2);
}
이제 zip을 파이썬처럼 사용할 수 있습니다.
사용 예제:
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<int> nums = {10, 20, 30, 40};
std::vector<std::string> words = {"ten", "twenty", "thirty"};
for (auto [n, w] : zip(nums, words)) {
std::cout << n << " " << w << "\n";
}
// nums는 4개, words는 3개. zip 구현상 가장 짧은 컨테이너 기준으로 종료.
// 출력:
// 10 ten
// 20 twenty
// 30 thirty
}
파이썬에서 zip이 짧은 이터러블에 맞춰 멈추는 것처럼, 여기서도 가장 짧은 컨테이너가 끝나면 반복이 종료됩니다.
추가적으로, zip을 세 개 이상의 컨테이너에 대해 구현하려면 템플릿 파라미터 팩을 이용한 좀 더 복잡한 구현이 필요하지만, 기본 개념은 같습니다.
다양한 예제
세 컨테이너 동시 순회(단순화 예시)
여러 컨테이너를 지원하기 위해선 variadic template을 써야 하지만 여기서는 개념만 간단히 예시:이 경우에도 가장 짧은 컨테이너의 길이에 맞춰 반복이 끝나야 합니다.
// zip(nums, words, chars)을 구현한다면?
for (auto [n, w, c] : zip(nums, words, chars)) { ... }
다른 타입의 컨테이너 병렬 순회
다양한 타입을 자유롭게 묶어서 순회할 수 있습니다.
std::vector<double> measurements = {0.1, 0.2, 0.3};
std::vector<int> indexes = {1, 2, 3};
for (auto [idx, val] : zip(indexes, measurements)) {
std::cout << "Index: " << idx << ", Value: " << val << "\n";
}
빈 컨테이너
std::vector<int> empty_vec;
std::vector<int> full_vec = {1, 2, 3};
for (auto [a, b] : zip(empty_vec, full_vec)) {
// 이 루프는 아예 실행되지 않음.
}
성능 분석
- 이 구현은 단순 iterator 증가와 비교 연산으로 동작하므로, 별도의 메모리 할당 없이도 다중 컨테이너 순회가 가능합니다.
- 컴파일러 최적화를 통해 기본 for 루프와 큰 성능 차이가 나지 않습니다.
- 파이썬에 비해 정적 타이핑과 템플릿 인라이닝 덕에 오버헤드는 훨씬 작을 수 있습니다.
3. 성능 및 유연성 확장을 위한 더 복잡한 구현
C++20 Ranges와 Concepts, 그리고 C++23에서의 추가 개선을 활용하면 훨씬 더 범용적이고 강력한 zip 뷰를 만들 수 있습니다. 예를 들어 C++23에서 std::ranges::zip_view는 아직 정식 표준에 포함되어 있지 않지만, 언어와 라이브러리 발전 방향을 보면 머지않아 공식 지원이 가능해질 것으로 기대해볼 수 있습니다.
C++20 Ranges 기반 zip 구현 아이디어
C++20 Ranges를 쓰면 zip_view를 Ranges 정식 뷰처럼 다룰 수 있습니다. 개념적으로:
- zip_view는 여러 range를 인자로 받고,
- begin()과 end() 호출 시, 모든 range의 begin(), end()를 통해 병렬 iterator를 구성,
- dereference 시 tuple(혹은 pair)의 형태로 반환.
또한 Concepts를 통해 인자로 들어오는 Range들이 모두 ForwardRange인지, CommonRange인지 등을 제약(condition)할 수 있어, 가독성과 안정성을 높일 수 있습니다.
(간략 예시 - 개념 코드)
#include <ranges>
#include <tuple>
#include <iostream>
#include <string>
#include <vector>
template<std::ranges::input_range... Ranges>
requires (sizeof...(Ranges) > 0)
class zip_view : public std::ranges::view_base {
std::tuple<Ranges...> ranges_;
struct iterator {
std::tuple<std::ranges::iterator_t<Ranges>...> its;
std::tuple<std::ranges::sentinel_t<Ranges>...> ends;
// ++ 연산, != 연산, * 연산을 구현
// * 연산 시 tuple로 모든 current iterator deref 결과를 반환
// != 연산 시 모든 iterator가 끝나지 않았는지 체크 (혹은 가장 짧은 range 기준 종료)
};
public:
zip_view(Ranges... r) : ranges_(std::move(r)...) {}
auto begin() {
return iterator{/* 모든 range의 begin()... */, /* 모든 range의 end()... */};
}
auto end() {
// end는 dummy iterator를 반환하거나, sentinel을 구현
}
};
template<std::ranges::input_range... Ranges>
auto zip(Ranges&&... r) {
return zip_view<std::decay_t<Ranges>...>(std::forward<Ranges>(r)...);
}
사용 예제 (C++20 스타일)
int main() {
std::vector<int> v1 = {1, 2, 3};
std::vector<std::string> v2 = {"one", "two", "three"};
std::vector<double> v3 = {0.1, 0.2, 0.3, 0.4};
// C++20 zip_view를 가정한 pseudo-code:
for (auto [i, s, d] : zip(v1, v2, v3)) {
// i : int, s : std::string&, d : double
// 반복은 v1, v2, v3 중 가장 짧은 길이에 맞춰 종료 -> 여기서는 v1,v2가 길이 3, v3가 4
// 따라서 3회 반복
std::cout << i << " " << s << " " << d << "\n";
}
}
성능 분석 (확장판)
- 여러 컨테이너를 하나의 for 루프로 처리하므로 캐시 친화적인 패턴을 만들기 쉽습니다(특히 transform-like 작업).
- 템플릿 인라인과 Concepts를 통한 최적화로, 런타임 오버헤드는 최소화됩니다.
- 다양한 타입의 컨테이너를 결합할 때도 성능 저하는 크지 않으며, lazy evaluation으로 메모리 사용량도 낮게 유지할 수 있습니다.
다양한 상황에 대한 예제
- 컨테이너 길이 불일치: 가장 짧은 컨테이너에 맞춰 순회 종료.
- 정적 배열, 다른 이터러블 지원:
int arr1[] = {1, 2, 3, 4}; int arr2[] = {10, 20, 30}; for (auto [a, b] : zip(arr1, arr2)) { std::cout << a << ", " << b << "\n"; } // 1,10 // 2,20 // 3,30 // arr2 끝나면 종료
- 조건부 zip: zip_view를 다른 view와 결합해 필터링, 변환 등을 할 수도 있습니다(예: std::views::filter, std::views::transform와 결합).
완성된 대표 구현 예제
아래는 앞서 본 단순 구현을 조금 더 정리하고, Variadic Template를 이용해 여러 컨테이너를 지원하는 간단한 zip 구현 예시(개념적)입니다. 완벽한 Ranges 정식 구현은 아니지만, 아이디어를 잘 보여줍니다.
#include <tuple>
#include <utility>
#include <cstddef>
#include <iterator>
#include <iostream>
#include <string>
#include <vector>
template<typename... Containers>
class multi_zip_view {
std::tuple<Containers&...> containers_;
template<std::size_t... I>
auto begin_impl(std::index_sequence<I...>) {
return std::tuple(std::begin(std::get<I>(containers_))...);
}
template<std::size_t... I>
auto end_impl(std::index_sequence<I...>) {
return std::tuple(std::end(std::get<I>(containers_))...);
}
static constexpr std::size_t N = sizeof...(Containers);
public:
multi_zip_view(Containers&... cs) : containers_(cs...) {}
class iterator {
public:
using tuple_it = std::tuple<decltype(std::begin(std::declval<Containers&>()))...>;
using tuple_end = std::tuple<decltype(std::end(std::declval<Containers&>()))...>;
iterator(tuple_it it, tuple_end end) : it_(it), end_(end) {}
iterator& operator++() {
std::apply([](auto&... i) { ((++i), ...); }, it_);
return *this;
}
bool operator!=(const iterator&) const {
// 모든 컨테이너 중 하나라도 끝에 도달하면 종료
bool finished = false;
std::apply([&](auto&... i) {
std::apply([&](auto&... e){
// i와 e를 페어로 비교
finished = ((i == e) || ...) ;
}, end_);
}, it_);
return !finished;
}
auto operator*() const {
// iterator deref 결과를 tuple로 반환
return std::apply([](auto&... i){ return std::tuple<decltype(*i)...>(*i...); }, it_);
}
private:
tuple_it it_;
tuple_end end_;
};
auto begin() {
auto b = begin_impl(std::make_index_sequence<N>{});
auto e = end_impl(std::make_index_sequence<N>{});
return iterator(b, e);
}
auto end() {
// end는 모든 end iterator를 모은 tuple
auto e = end_impl(std::make_index_sequence<N>{});
return iterator(e, e);
}
};
template<typename... C>
auto zip(C&... cs) {
return multi_zip_view<C...>(cs...);
}
int main() {
std::vector<int> v1 = {1,2,3};
std::vector<std::string> v2 = {"one","two","three"};
std::vector<double> v3 = {0.1,0.2,0.3,0.4};
for (auto [i,s,d] : zip(v1, v2, v3)) {
std::cout << i << " " << s << " " << d << "\n";
}
return 0;
}
이 예시에서는 세 개 이상의 컨테이너를 처리하기 위해 Variadic Template을 활용하였고, 가장 짧은 컨테이너 기준으로 반복을 멈춥니다. 한편 코드 내 조건 체크나 컨테이너 공백 상황 처리 등은 간략화했지만, 아이디어는 충분히 전달됩니다.
마무리
이번 글에서는 파이썬의 zip과 유사한 기능을 C++에서 구현하고 사용하는 방법을 알아보았습니다. 단순한 C++17 스타일 구현에서 출발해, C++20 Ranges와 Concepts, 그리고 Variadic Template을 통한 다중 컨테이너 대응까지 살펴보며, 파이썬스럽게 여러 시퀀스를 동시에 순회하는 우아한 코드를 C++에서도 구현할 수 있음을 확인했습니다.
'개발 이야기 > C++' 카테고리의 다른 글
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 구현 #1: range와 enumerate (1) | 2024.12.10 |
[C++23 새기능 소개] std::views::stride (0) | 2024.12.09 |
[C++23 새기능 소개] std::spanstream (0) | 2024.12.09 |