C++20과 C++23을 활용한 “파이썬스러운” API 구현 #9: count, zip_longest, pairwise

우리가 지금까지 다룬 파이썬 itertools 함수들 외에도, 파이썬에서 제공하는 편리한 반복 툴이 여전히 많이 남아 있습니다. 이번 글에서는 그중에서도 자주 쓰이는 세 가지를 골라봤습니다.

 

먼저, 파이썬으로 몇 가지 예제를 보여드릴게요:

from itertools import count, zip_longest, pairwise

# count는 start부터 step 간격으로 무한히 증가하는 숫자를 생성
for i in count(10, 2):
    if i > 20: 
        break
    print(i, end=' ') 
# 출력: 10 12 14 16 18 20

# zip_longest는 가장 긴 이터러블이 끝날 때까지 묶음을 만들고, 부족한 곳은 fillvalue로 채워줌
list_a = [1, 2, 3]
list_b = ['a', 'b']
for x, y in zip_longest(list_a, list_b, fillvalue='X'):
    print((x, y))
# 출력:
# (1, 'a')
# (2, 'b')
# (3, 'X')  # b가 끝났으니 'X'로 채움

# pairwise는 연속된 원소들로 이루어진 튜플을 만듦 (Python 3.10+)
# 예: [10,20,30] -> (10,20), (20,30)
for p in pairwise([10,20,30,40]):
    print(p)
# 출력:
# (10, 20)
# (20, 30)
# (30, 40)

 

이제 이 기능들을 C++20/23을 활용해 흉내 내보는 방법을 살펴보겠습니다.

1. count 구현 (기본 형태)

count(start, step)는 주어진 start부터 step 간격으로 무한히 증가하는 정수 스트림을 만듭니다. 파이썬에서는 무한 시퀀스를 반환하지만, C++에서는 실제 무한을 처리할 수 없으니, 필요할 때 break로 끊거나 특정 조건을 두어야 합니다.

template<typename T>
class count_view {
public:
    count_view(T start, T step = 1) : current_(start), step_(step) {}

    class iterator {
    public:
        iterator(T val, T step, bool end=false)
            : val_(val), step_(step), end_(end) {}

        iterator& operator++() {
            val_ += step_;
            return *this;
        }

        // 무한 시퀀스를 표현하기 위해 end 조건을 안쓰는 트릭: end를 가짜로 둠
        bool operator!=(const iterator& other) const {
            // 무한히 돌고 싶다면 end_를 안쓰면 되지만
            // 여기서는 다른 iterator와 비교 시 항상 true 반환해서 끝나지 않도록 할 수도 있음.
            // 일단 end_를 활용해 제한할 수도 있음.
            return !end_;
        }

        T operator*() const { return val_; }

    private:
        T val_;
        T step_;
        bool end_; 
    };

    auto begin() {
        return iterator(current_, step_);
    }
    // end iterator를 무한히 돌려주지 않으려면, 특정 조건에서 end를 생성할 수도 있음.
    // 여기서는 무한히 돌게 하기 위해 end를 같은 객체로 반환
    auto end() {
        return iterator(T{}, step_, true); 
    }

private:
    T current_;
    T step_;
};

template<typename T>
auto count_func(T start, T step = 1) {
    return count_view<T>(start, step);
}

 

사용 예제:

for (auto x : count_func(10,2)) {
    std::cout << x << " "; 
    if (x >= 20) break; // 10 12 14 16 18 20
}
std::cout << "\n";

2. zip_longest 구현 (기본 형태)

zip_longest는 여러 컨테이너를 동시 순회하되, 가장 긴 컨테이너가 끝날 때까지 진행하고, 짧은 컨테이너는 fillvalue로 채워줍니다. 여기서는 간단히 2개의 컨테이너만 처리하고, fillvalue를 하나만 받는 형태로 예를 들어보겠습니다(variadic template을 쓰면 더 많은 컨테이너도 가능).

template<typename C1, typename C2, typename T>
class zip_longest_view {
public:
    zip_longest_view(C1& c1, C2& c2, T fill)
        : c1_(c1), c2_(c2), fill_(fill) {}

    class iterator {
    public:
        using it1 = decltype(std::begin(std::declval<C1&>()));
        using it2 = decltype(std::begin(std::declval<C2&>()));

        iterator(it1 i1, it1 e1, it2 i2, it2 e2, T fill)
            : i1_(i1), e1_(e1), i2_(i2), e2_(e2), fill_(fill) {}

        iterator& operator++() {
            if (i1_ != e1_) ++i1_;
            if (i2_ != e2_) ++i2_;
            return *this;
        }

        bool operator!=(const iterator& other) const {
            // 둘 다 끝나면 종료
            bool end_self = (i1_ == e1_ && i2_ == e2_);
            bool end_other = (other.i1_ == other.e1_ && other.i2_ == other.e2_);
            return end_self != end_other;
        }

        auto operator*() const {
            auto val1 = (i1_ != e1_) ? *i1_ : fill_;
            auto val2 = (i2_ != e2_) ? *i2_ : fill_;
            return std::pair<decltype(val1), decltype(val2)>(val1, val2);
        }

    private:
        it1 i1_, e1_;
        it2 i2_, e2_;
        T fill_;
    };

    auto begin() {
        return iterator(std::begin(c1_), std::end(c1_), std::begin(c2_), std::end(c2_), fill_);
    }
    auto end() {
        // end는 양쪽 이터레이터를 끝으로 맞춘 상태
        return iterator(std::end(c1_), std::end(c1_), std::end(c2_), std::end(c2_), fill_);
    }

private:
    C1& c1_;
    C2& c2_;
    T fill_;
};

template<typename C1, typename C2, typename T>
auto zip_longest_func(C1& c1, C2& c2, T fillvalue) {
    return zip_longest_view<C1,C2,T>(c1, c2, fillvalue);
}

 

사용 예제:

std::vector<int> v1 = {1,2,3};
std::vector<std::string> v2 = {"a","b"};
for (auto [x,y] : zip_longest_func(v1,v2,std::string("X"))) {
    std::cout << "(" << x << "," << y << ")\n";
}
// (1,a)
// (2,b)
// (3,X)

3. pairwise 구현 (기본 형태)

pairwise(iterable)는 (x1,x2), (x2,x3), (x3,x4), ... 형태로 연속된 원소들 쌍을 반환합니다.

template<typename Container>
class pairwise_view {
public:
    pairwise_view(Container& c) : c_(c) {}

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

        iterator(base_it current, base_it end) 
            : current_(current), end_(end) {
            // pairwise를 위해 current와 current+1 둘 다 접근 가능해야 함.
        }

        iterator& operator++() {
            ++current_;
            return *this;
        }

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

        auto operator*() const {
            auto next_it = current_;
            ++next_it;
            return std::pair<decltype(*current_), decltype(*current_)>(*current_, *next_it);
        }

    private:
        base_it current_;
        base_it end_;
    };

    auto begin() {
        auto b = std::begin(c_);
        auto e = std::end(c_);
        if (b == e) return end();
        auto next = b; ++next;
        if (next == e) return end(); // 원소가 1개 이하인 경우 pairwise 없음
        return iterator(b,e);
    }
    auto end() {
        return iterator(std::end(c_),std::end(c_));
    }

private:
    Container& c_;
};

template<typename Container>
auto pairwise_func(Container& c) {
    return pairwise_view<Container>(c);
}

 

사용 예제:

std::vector<int> arr = {10,20,30,40};
for (auto p : pairwise_func(arr)) {
    std::cout << "(" << p.first << "," << p.second << ")\n";
}
// (10,20)
// (20,30)
// (30,40)

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

  • count: Concepts를 활용해 정수형 타입인지, 혹은 산술연산 가능한지 제약할 수 있고, ranges 파이프라인에서 count_func()를 바로 take(10) 같은 뷰와 결합해 무한 시퀀스를 유한하게 만들 수 있습니다.
  • zip_longest: Variadic template으로 확장해 여러 컨테이너를 받을 수도 있고, Concepts를 통해 입력이 Range인지 확인할 수 있습니다.
  • pairwise: Ranges의 std::views::slide(2)와 유사한 개념을 활용하면 더 깔끔하게 구현할 수 있습니다(비공식 기능이지만, range-v3 같은 곳에서 지원).

성능과 메모리 측면

  • count: 추가 메모리 없이 무한 시퀀스 생성.
  • zip_longest: fillvalue 할당 외 별도 메모리 없음. 단, 가장 긴 컨테이너를 기준으로 진행.
  • pairwise: 간단한 iterator 증가, 메모리 오버헤드 없음.

모두 lazy evaluation을 기반으로 하므로, 큰 비용 없이 필요한 원소를 순차적으로 제공할 수 있습니다.

관련 링크 및 리소스

마무리

이번 글에서는 파이썬의 count, zip_longest, pairwise를 C++20/23로 흉내 내봤습니다. 무한 시퀀스 생성, 길이 불일치 시 채우기, 연속된 쌍 생산 등, 파이썬 itertools가 제공하는 편리한 패턴들을 C++에서도 어느 정도 재현할 수 있음을 확인할 수 있었죠. 물론 파이썬만큼 간결하지 않거나 모든 기능을 완벽히 옮기긴 어렵지만, Ranges, Concepts 등의 현대 C++ 기술을 잘 활용하면 계속 파이썬스러운 코드를 구현하는 길은 열려 있습니다.

 

이 시리즈를 통해, 파이썬 itertools가 얼마나 생산적이고 강력한지, 그리고 C++20/23에서 그런 패턴을 얼마나 가까이 재현할 수 있는지 감각을 잡으셨길 바랍니다. 더 나아가, range-v3나 커뮤니티 라이브러리, 그리고 미래의 C++ 표준 확장을 통해 점점 더 파이썬스럽고 우아한 C++ 코드를 작성할 수 있을 거라 믿습니다.

 

반응형