C++20과 C++23을 활용한 “파이썬스러운” API 구현 #7: cycle, repeat, islice, tee

여러분, 지난 글들에서는 파이썬 itertools의 꽤 많은 기능을 C++에서 “파이썬스럽게” 흉내 내보았습니다. 이제 남은 인기 있는 기능 중 몇 가지를 더 살펴보고, 이 시리즈를 어느 정도 마무리할 때가 된 듯하네요. 이번에는 cycle, repeat, islice, tee 네 가지를 다뤄보겠습니다.

  • cycle(iterable): 주어진 이터러블을 무한히 반복시키는 제너레이터입니다. 파이썬에서는 cycle([1,2,3]) 하면 1,2,3,1,2,3,1,2,3... 끝없이 이어집니다.
  • repeat(elem, n=None): 특정 원소를 n번(또는 무한히) 반복하는 제너레이터입니다.
  • islice(iterable, start, stop, step): 이터러블에서 특정 범위만 슬라이싱해주는 함수입니다. 파이썬의 리스트 슬라이싱과 유사하지만, 이터러블에도 적용 가능합니다.
  • tee(iterable, n=2): 하나의 이터러블을 n개의 독립적인 이터러블로 복제합니다. 파이썬에서는 tee를 통해 같은 데이터 스트림을 여러 번 순회할 수 있게 하죠.

C++에서 이 모두를 완벽히 재현하는 것은 쉽지 않지만, 개념적으로 접근해 간단한 버전 정도는 만들어볼 수 있습니다.

1. 일반적인 C++ 구현 (Before)

  • cycle: 끝없이 반복하려면 보통 무한 루프를 돌면서 모듈 연산이나 조건을 통해 시작 지점으로 돌아갑니다.
  • repeat: 같은 값을 여러 번 찍고 싶으면 그냥 for루프 n번 돌거나 무한 루프로 찍습니다.
  • islice: 컨테이너면 그냥 인덱스로 slice를 구현하면 되지만, 이터레이터 기반이라면 수동으로 advance()를 호출하면서 건너뛰는 방식이 필요합니다.
  • tee: 하나의 이터레이터를 여러 번 돌려야 한다면, 데이터를 임시 버퍼에 저장해두었다가 나중에 다시 읽는 식으로 구현할 수 있습니다.

이런 식으로 수동으로 제어하면 됩니다만, 파이썬스러운 간결함과 추상화는 기대하기 어렵습니다.

2. 단순한 Python 같은 C++ 구현 (After: 첫 단추)

cycle 구현(기본 형태)

cycle은 간단히 말해, 주어진 컨테이너의 끝에 도달하면 다시 시작 지점으로 돌아가는 뷰입니다. 단, 이걸 진짜 무한히 돌리는 것은 실용적이지 않으니, 여기서는 무한 반복을 시도하되, 실제로는 break로 벗어나는 식의 예제를 보여줄게요.

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

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

        iterator& operator++() {
            ++current_;
            if (current_ == end_) {
                current_ = begin_;
            }
            return *this;
        }

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

        // cycle은 끝을 정의하지 않기 때문에, 여기서는 != 연산자를 형태상 구현하지만
        // 실제론 무한 반복. 필요하다면 별도 조건을 외부에서 처리.
        bool operator!=(const iterator&) const { return true; }

    private:
        base_it begin_, end_, current_;
    };

    auto begin() {
        return iterator(std::begin(c_), std::end(c_), std::begin(c_));
    }
    auto end() {
        // cycle은 끝이 없으므로, end를 그냥 begin과 같은 위치에 두고 ==를 사용하지 않도록 하는 등
        // 비표준적이나마 처리할 수도 있지만, 여기서는 단순히 같은 iterator 반환.
        return iterator(std::begin(c_), std::end(c_), std::begin(c_));
    }

private:
    Container& c_;
};

template<typename Container>
auto cycle_func(Container& c) {
    return cycle_view<Container>(c);
}

사용 예제:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> data = {1,2,3};
    int count = 0;
    for (auto x : cycle_func(data)) {
        std::cout << x << " "; // 1 2 3 1 2 3 1 2 3 ...
        if (++count == 9) break; // 임의로 9번만 출력하고 중단
    }
    std::cout << "\n";
}

repeat 구현(기본 형태)

repeat는 특정 값 하나를 n번 반복하거나, n을 지정하지 않으면 무한히 반복합니다. 여기서는 n회 반복하는 버전만 구현해볼게요.

template<typename T>
class repeat_view {
public:
    repeat_view(T value, std::size_t count) : value_(value), count_(count) {}

    class iterator {
    public:
        iterator(T value, std::size_t pos, std::size_t end)
            : value_(value), pos_(pos), end_(end) {}

        iterator& operator++() { ++pos_; return *this; }
        bool operator!=(const iterator& other) const { return pos_ != other.pos_; }
        T operator*() const { return value_; }

    private:
        T value_;
        std::size_t pos_;
        std::size_t end_;
    };

    auto begin() { return iterator(value_, 0, count_); }
    auto end() { return iterator(value_, count_, count_); }

private:
    T value_;
    std::size_t count_;
};

template<typename T>
auto repeat_func(T value, std::size_t count) {
    return repeat_view<T>(value, count);
}

사용 예제:

for (auto x : repeat_func(42, 5)) {
    std::cout << x << " "; // 42 42 42 42 42
}
std::cout << "\n";

islice 구현(기본 형태)

islice는 이터러블에서 start부터 stop 전까지, step 간격으로 원소를 뽑는 기능입니다. 여기서는 step은 1이라고 가정하고, 단순 start, stop만 지원해보겠습니다.

template<typename Container>
class islice_view {
public:
    islice_view(Container& c, std::size_t start, std::size_t stop)
        : c_(c), start_(start), stop_(stop) {}

    class iterator {
    public:
        using base_it = decltype(std::begin(std::declval<Container&>()));
        iterator(base_it it, base_it end, std::size_t start, std::size_t stop, std::size_t pos)
            : it_(it), end_(end), start_(start), stop_(stop), pos_(pos) {
            // start까지 건너뛰기
            while (pos_ < start_ && it_ != end_) {
                ++it_; ++pos_;
            }
        }

        iterator& operator++() {
            if (it_ != end_ && pos_ < stop_) {
                ++it_; ++pos_;
            }
            return *this;
        }

        bool operator!=(const iterator& other) const {
            return !(pos_ >= stop_ || it_ == end_) || !(other.pos_ >= other.stop_ || other.it_ == other.end_);
        }

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

    private:
        base_it it_, end_;
        std::size_t start_, stop_, pos_;
    };

    auto begin() {
        return iterator(std::begin(c_), std::end(c_), start_, stop_, 0);
    }
    auto end() {
        // end iterator: stop 위치를 가정해서 생성
        return iterator(std::end(c_), std::end(c_), start_, stop_, stop_);
    }

private:
    Container& c_;
    std::size_t start_, stop_;
};

template<typename Container>
auto islice_func(Container& c, std::size_t start, std::size_t stop) {
    return islice_view<Container>(c, start, stop);
}

사용 예제:

std::vector<int> arr = {10,20,30,40,50,60};
for (auto x : islice_func(arr, 1, 4)) {
    std::cout << x << " "; // 20 30 40
}
std::cout << "\n";

tee 구현(간단한 예)

tee는 하나의 이터러블을 n개의 독립 이터러블로 복제합니다. 이를 위해서는 내부적으로 원소들을 임시 버퍼에 저장하면서, 각 이터러블이 독립적으로 읽을 수 있게 해야 합니다. 이건 조금 복잡한데, 여기서는 간단한 버전으로 구현해보겠습니다. 내부적으로 모든 요소를 한 번 읽어서 벡터에 저장한 뒤, 그 벡터를 참조하는 n개의 이터레이터를 반환합니다. 완벽히 lazy하지는 않지만 개념은 파이썬의 tee와 유사합니다.

#include <memory>

template<typename Container>
class tee_view {
public:
    tee_view(Container& c, std::size_t n) : n_(n) {
        // 모든 원소를 모아서 저장
        for (auto& x : c) {
            buffer_.push_back(x);
        }
    }

    // 각 tee 이터레이터는 buffer_를 참조
    class sub_view {
    public:
        sub_view(std::shared_ptr<std::vector<typename Container::value_type>> buf)
            : buf_(buf) {}
        
        class iterator {
        public:
            iterator(std::shared_ptr<std::vector<typename Container::value_type>> buf, std::size_t pos)
                : buf_(buf), pos_(pos) {}
            iterator& operator++() { ++pos_; return *this; }
            bool operator!=(const iterator& other) const { return pos_ != other.pos_; }
            auto operator*() const { return (*buf_)[pos_]; }
        private:
            std::shared_ptr<std::vector<typename Container::value_type>> buf_;
            std::size_t pos_;
        };

        auto begin() { return iterator(buf_, 0); }
        auto end() { return iterator(buf_, buf_->size()); }
    private:
        std::shared_ptr<std::vector<typename Container::value_type>> buf_;
    };

    std::vector<sub_view> get_views() {
        std::vector<sub_view> views;
        auto sp = std::make_shared<std::vector<typename Container::value_type>>(buffer_);
        for (std::size_t i = 0; i < n_; ++i) {
            views.emplace_back(sp);
        }
        return views;
    }

private:
    std::size_t n_;
    std::vector<typename Container::value_type> buffer_;
};

template<typename Container>
auto tee_func(Container& c, std::size_t n = 2) {
    tee_view<Container> tv(c,n);
    return tv.get_views();
}

사용 예제:

std::vector<int> data3 = {1,2,3};
auto tees = tee_func(data3, 2);
auto view1 = tees[0];
auto view2 = tees[1];

for (auto x : view1) std::cout << x << " "; // 1 2 3
std::cout << "\n";
for (auto x : view2) std::cout << x << " "; // 1 2 3
std::cout << "\n";

이렇게 tee를 사용하면 하나의 이터러블을 n개로 복제한 것처럼 쓸 수 있습니다. 다만 여기서는 완벽한 lazy 동작이 아닌, 미리 모든 데이터를 읽어오는 eager 방식이라는 점에 유의하세요.

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

C++20 Ranges와 Concepts를 활용하면 다음과 같은 개선이 가능합니다.

  • Concepts: cycle, repeat, islice, tee에 들어오는 타입이 Range인지를 컴파일 타임에 체크하고, lazy evaluation을 강화할 수 있습니다.
  • 추가 파이프라인 지원: data | cycle_func() | islice_func(0, 10)처럼 연쇄해서 사용할 수 있습니다.
  • range-v3 라이브러리: range-v3를 사용하면 cycle이나 take(n), drop(n) 같은 것은 이미 지원되는 뷰와 쉽게 결합할 수 있습니다. islice와 유사한 효과를 std::views::drop과 std::views::take 조합으로 낼 수도 있습니다.
  • tee: tee는 정말 파이썬 특화 개념이지만, C++에서도 코루틴이나 제너레이터를 활용하면 좀 더 자연스럽게 구현할 여지가 있습니다.

아직 표준 라이브러리에는 이러한 itertools 스타일 기능이 모두 들어있지 않지만, C++23 이후나 range-v3, 기타 커뮤니티 라이브러리를 통해 점점 더 많은 기능을 C++ 코드에 녹여낼 수 있을 것입니다.

성능과 메모리 측면

  • cycle, repeat는 lazy하게 무한 스트림을 생성할 수 있습니다. 물론 실제로 무한 루프를 돌면 프로그램이 끝나지 않으니 적절한 break 조건이 필요합니다.
  • islice는 일부 원소만 골라내므로 메모리 추가 할당 없이도 동작합니다.
  • tee는 현재 구현 예제에서 모든 원소를 복사해 저장하므로 메모리 오버헤드가 있습니다. 더 정교한 구현을 통해 lazy한 버퍼링을 할 수도 있으나, 복잡도가 증가합니다.

관련 링크 및 리소스

마무리

이번 글에서는 파이썬 itertools의 cycle, repeat, islice, tee를 C++20/23 스타일로 흉내내봤습니다. 모든 기능을 완벽히 동일하게 구현하기는 쉽지 않았지만, 최소한 개념적으로 파이썬의 편리한 패턴을 C++에도 어느 정도 도입할 수 있음을 확인할 수 있었습니다. 이로써 파이썬스러운 C++ API 구현 시리즈는 어느 정도 큰 그림을 그려본 듯합니다. 이제 여러분께서 직접 응용해보면서 더 나은 구현과 최적화를 시도해보시길 기대합니다.

 

반응형