C++20과 C++23을 활용한 “파이썬스러운” API 구현 #2: zip

지난 글에서 우리는 파이썬의 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)”를 구현하고, 파이썬스럽게 여러 컨테이너를 깔끔하게 동시에 순회할 수 있습니다.

이번 글은 다음과 같은 흐름으로 진행합니다.

  1. 일반적인 C++ 구현 (Before): 파이썬의 zip 없이 다중 컨테이너 순회하기.
  2. 단순한 Python 같은 C++ 구현 (After): 직접 zip-like view를 구현해 Python zip에 가까운 문법으로 사용하는 방법.
  3. 성능 및 유연성 확장을 위한 더 복잡한 구현: 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으로 메모리 사용량도 낮게 유지할 수 있습니다.

다양한 상황에 대한 예제

  1. 컨테이너 길이 불일치: 가장 짧은 컨테이너에 맞춰 순회 종료.
  2. 정적 배열, 다른 이터러블 지원:
    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 끝나면 종료
    
  3. 조건부 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++에서도 구현할 수 있음을 확인했습니다.

반응형