앞선 글들에서 우리는 파이썬의 range, enumerate, zip와 유사한 C++ 구현 방식을 살펴보았습니다. 파이썬의 직관적 문법을 C++20/23의 언어와 라이브러리 기능들을 통해 비교적 쉽게 재현할 수 있음을 확인했죠.
이번 글에서는 파이썬에서 자주 사용하는 함수형 스타일의 API인 map과 filter를 C++에서 어떻게 “파이썬스럽게” 구현할 수 있는지 다뤄보겠습니다. 파이썬의 map(function, iterable), filter(predicate, iterable)를 C++20/23에서 비슷한 느낌으로 쓸 수 있다면, 복잡한 로직을 더 간결하고 깔끔하게 표현할 수 있습니다.
글의 구성은 다음과 같습니다:
- 일반적인 C++ 구현 (Before): 람다, std::transform, std::copy_if를 이용하는 기존 방식.
- 단순한 Python 같은 C++ 구현 (After: 첫 단추): 마치 파이썬의 map, filter처럼 바로 뷰(view)로써 다루는 간결한 구현.
- 정교한(혹은 확장된) 구현: 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++ 코드에 녹여낼 수 있습니다.
'개발 이야기 > C++' 카테고리의 다른 글
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #5: accumulate, groupby (0) | 2024.12.12 |
---|---|
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #4: itertools 스타일 – chain, takewhile, dropwhile (1) | 2024.12.12 |
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #2: zip (0) | 2024.12.10 |
C++20과 C++23을 활용한 “파이썬스러운” API 구현 #1: range와 enumerate (1) | 2024.12.10 |
[C++23 새기능 소개] std::views::stride (0) | 2024.12.09 |