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

앞선 글들에서 우리는 파이썬의 range, enumerate, zip와 유사한 C++ 구현 방식을 살펴보았습니다. 파이썬의 직관적 문법을 C++20/23의 언어와 라이브러리 기능들을 통해 비교적 쉽게 재현할 수 있음을 확인했죠.

 

이번 글에서는 파이썬에서 자주 사용하는 함수형 스타일의 API인 map과 filter를 C++에서 어떻게 “파이썬스럽게” 구현할 수 있는지 다뤄보겠습니다. 파이썬의 map(function, iterable), filter(predicate, iterable)를 C++20/23에서 비슷한 느낌으로 쓸 수 있다면, 복잡한 로직을 더 간결하고 깔끔하게 표현할 수 있습니다.

 

글의 구성은 다음과 같습니다:

  1. 일반적인 C++ 구현 (Before): 람다, std::transform, std::copy_if를 이용하는 기존 방식.
  2. 단순한 Python 같은 C++ 구현 (After: 첫 단추): 마치 파이썬의 map, filter처럼 바로 뷰(view)로써 다루는 간결한 구현.
  3. 정교한(혹은 확장된) 구현: C++20 Ranges와 Concepts를 활용하고, 다양한 상황에 유연하게 대응할 수 있는 보다 발전된 구현을 보여주며 성능과 편의성의 균형점을 찾는 방법을 모색합니다.

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

C++에서는 map과 유사한 동작은 대개 std::transform으로, filter와 유사한 동작은 std::copy_if로 구현합니다. 하지만 이들은 결과를 새로운 컨테이너에 담거나, 별도의 반복문을 일으켜 메모리 복사를 하는 식으로 동작하곤 합니다.

#include <vector>
#include <iostream>
#include <algorithm>

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5};

    // map: 각 원소에 2를 곱해 새로운 벡터에 저장
    std::vector<int> mapped;
    mapped.resize(data.size());
    std::transform(data.begin(), data.end(), mapped.begin(), [](int x){ return x * 2; });

    for (auto x : mapped) {
        std::cout << x << " "; // 2 4 6 8 10
    }
    std::cout << "\n";

    // filter: 짝수만 골라내기
    std::vector<int> filtered;
    std::copy_if(data.begin(), data.end(), std::back_inserter(filtered), [](int x){ return x % 2 == 0; });

    for (auto x : filtered) {
        std::cout << x << " "; // 2 4
    }
    std::cout << "\n";
}

 

이 방식은 명확하지만, 중간 결과를 담기 위한 컨테이너를 매번 준비해야 합니다. 파이썬의 map이나 filter는 lazy evaluation으로 동작하므로, “필요할 때”만 원소를 계산하거나 거릅니다. C++20 Ranges의 등장으로 이런 lazy한 동작을 구현하기가 훨씬 쉬워졌습니다.

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

우선 C++17 정도에서도 간단한 형태의 map_view, filter_view를 직접 구현할 수 있습니다. 여기서는 파이썬의 map과 filter 개념을 따라, 입력 컨테이너를 받아 람다를 통해 변환하거나 거르는 “뷰”를 만들어보겠습니다. 이 뷰는 실제로 원소를 재저장하지 않고, iterator를 통해 순회하면서 조건이나 변환을 적용합니다.

간단한 map 구현

#include <iterator>
#include <type_traits>
#include <utility>

template<typename Container, typename Func>
class map_view {
public:
    map_view(Container& c, Func f) : c_(c), f_(f) {}

    class iterator {
    public:
        using base_iterator = decltype(std::begin(std::declval<Container&>()));
        using base_value_type = decltype(*std::declval<base_iterator>());
        using reference = std::invoke_result_t<Func, base_value_type>;

        iterator(base_iterator it, base_iterator end, Func f) : it_(it), end_(end), f_(f) {}
        iterator& operator++() { ++it_; return *this; }
        bool operator!=(const iterator& other) const { return it_ != other.it_; }
        reference operator*() const { return f_(*it_); }

    private:
        base_iterator it_, end_;
        Func f_;
    };

    auto begin() { return iterator(std::begin(c_), std::end(c_), f_); }
    auto end() { return iterator(std::end(c_), std::end(c_), f_); }

private:
    Container& c_;
    Func f_;
};

template<typename Container, typename Func>
auto map_func(Container& c, Func f) {
    return map_view<Container, Func>(c, f);
}

 

사용 예제:

#include <vector>
#include <iostream>
#include <string>

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    for (auto val : map_func(data, [](int x){ return x * 3; })) {
        std::cout << val << " "; // 3 6 9 12 15
    }
    std::cout << "\n";
}

 

이처럼 map_func를 사용하면 굳이 std::transform으로 별도 컨테이너에 복사할 필요 없이, “지나가며” 변환된 값에 접근할 수 있습니다.

간단한 filter 구현

template<typename Container, typename Pred>
class filter_view {
public:
    filter_view(Container& c, Pred p) : c_(c), p_(p) {}

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

        iterator(base_iterator it, base_iterator end, Pred p) : it_(it), end_(end), p_(p) {
            // 시작 시점부터 조건에 맞는 위치로 이동
            advance_to_valid();
        }

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

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

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

    private:
        void advance_to_valid() {
            while (it_ != end_ && !p_(*it_)) {
                ++it_;
            }
        }

        base_iterator it_, end_;
        Pred p_;
    };

    auto begin() { return iterator(std::begin(c_), std::end(c_), p_); }
    auto end()   { return iterator(std::end(c_), std::end(c_), p_); }

private:
    Container& c_;
    Pred p_;
};

template<typename Container, typename Pred>
auto filter_func(Container& c, Pred p) {
    return filter_view<Container, Pred>(c, p);
}

 

사용 예제:

std::vector<int> data = {1, 2, 3, 4, 5};
for (auto val : filter_func(data, [](int x){ return x % 2 == 1; })) {
    std::cout << val << " "; // 1 3 5
}
std::cout << "\n";

 

위 예제는 짝수만 골라내거나, 특정 조건에 맞는 원소만 가져올 때 별도의 복사 없이 lazy하게 값을 접근할 수 있습니다.

다양한 예제

map, filter를 연쇄해서 사용하기

// 짝수만 골라내고, 거기에 10을 곱하기
for (auto val : map_func(filter_func(data, [](int x){return x%2==0;}), [](int x){return x*10;})) {
    std::cout << val << " "; // 20 40
}

이런 식으로 map과 filter를 연쇄하면 파이썬에서 map(..., filter(...)) 하는 것과 흡사한 코드를 얻을 수 있습니다.

 

문자열 변환

std::vector<std::string> words = {"apple", "banana", "cherry"};
// 길이가 6 이상인 단어만 대문자로 변환
for (auto w : map_func(filter_func(words, [](auto& s){return s.size() >= 6;}), 
                       [](auto& s){ 
                           std::string upper = s;
                           for (auto& c : upper) c = std::toupper(static_cast<unsigned char>(c));
                           return upper;
                       })) {
    std::cout << w << " "; // BANANA CHERRY
}

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

C++20에는 <ranges> 라이브러리가 도입되었고, 여기에 std::views::transform(=map과 유사), std::views::filter가 이미 표준으로 제공됩니다. 즉, 파이썬스럽게 구현할 필요 없이, 표준에 있는 기능을 바로 활용할 수 있습니다!

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> data = {1,2,3,4,5};

    // C++20 Ranges로 map (transform)과 filter를 사용
    for (auto x : data | std::views::filter([](int x){return x%2==0;})
                      | std::views::transform([](int x){return x*10;})) {
        std::cout << x << " "; // 20 40
    }
    std::cout << "\n";
}

 

여기서 파이프(|) 연산자와 람다를 활용하면 파이썬의 map, filter에 상당히 근접한 코드 스타일을 얻을 수 있습니다.

더 나아간 확장성과 정교함

  • C++20 Ranges를 통한 std::views::transform, std::views::filter는 다양한 이터러블과 조합할 수 있으며, lazy evaluation을 기본으로 합니다.
  • Concepts를 활용하면 입력이 실제 Range인지, 변환 함수가 적절한 형태인지, 필터 함수가 bool을 반환하는지 등을 컴파일 타임에 체크할 수 있어 안전성이 높습니다.
  • C++23에선 Ranges 라이브러리가 더욱 안정화, 개선되어 더 정교한 조합이 가능해집니다.
  • 기존 예제에서 직접 구현한 map, filter를 Ranges 스타일로 개선하면, 별도 end iterator 계산, 조건 처리, 반환 타입 추론 등을 더 깔끔하게 할 수 있습니다. 또한 Variadic Template나 Concepts를 통해 다양한 형태의 함수를 받을 수 있고, C++23부터는 std::views::take_while, std::views::drop_while 같은 뷰도 조합할 수 있어 파이썬 itertools와 흡사한 기능을 구현할 수 있습니다.

성능 분석

  • lazy evaluation 덕분에 불필요한 메모리 할당 없이 필요한 원소만 변환, 필터링 가능.
  • 최적화된 빌드에서는 기본 for 루프와 큰 성능 차이가 나지 않습니다. 오히려 가독성이 좋아지는 장점이 있습니다.
  • std::views::transform, std::views::filter는 표준 구현으로 최적화가 잘 되어 있어 커스텀 구현보다 더욱 효율적인 경우가 많습니다.

완성된 대표 구현 예제

아래는 C++20 Ranges를 활용해 파이썬의 map, filter 기능을 유사하게 표현한, 최종적으로 가장 실용적이고 정교한 형태의 코드입니다. 별도의 커스텀 구현 없이 표준 라이브러리를 직접 사용합니다.

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> data = {10, 11, 12, 13, 14, 15};

    // 파이프 라인을 따라 filter -> transform 순으로 적용
    // 파이썬스러운 코드 스타일: 짝수인 원소만 골라내 100을 곱해 출력
    for (auto x : data 
        | std::views::filter([](int x){ return x % 2 == 0; }) 
        | std::views::transform([](int x){ return x * 100; }))
    {
        std::cout << x << " ";
    }
    std::cout << "\n";
    // 출력: 1000 1200 1400
}

 

이 예제는 파이썬의 filter와 map 조합 filter(lambda x: x%2==0, data)와 map(lambda x: x*100, ...)의 작동 방식을 거의 그대로 C++로 옮긴 모습입니다.

마무리

이로써 파이썬의 map, filter 개념을 C++20/23에서 어떻게 파이썬스럽게 구현하고 사용할 수 있는지 살펴보았습니다. 단순한 직접 구현에서 시작해 Ranges 표준 기능을 활용한 정교한 구현까지, C++20 이후 기능을 잘 활용하면 파이썬만큼이나 직관적인 함수형 패턴을 C++ 코드에 녹여낼 수 있습니다.

반응형