C++20 range adaptor: views::filter·transform 파이프라인의 지연 평가와 댕글링 함정
이 글의 핵심
C++20 Range Adaptor(std::views)가 왜 컨테이너 대신 지연 평가되는 뷰를 반환하는지, 그리고 이 설계가 만들어내는 댕글링 뷰·조건자 다중 호출 같은 실전 함정을 실제 겪은 사례로 설명합니다.
Range Adaptor란?
C++20 이전까지 범위를 다루는 표준적인 방법은 std::copy_if, std::transform 같은 알고리즘에 반복자 쌍(begin/end)을 넘기고, 결과를 담을 새 컨테이너를 미리 준비하는 것이었습니다. 필터링 후 변환하는 두 단계 작업만 해도 임시 벡터가 하나 생기고, 세 단계면 임시 벡터가 두 개 생깁니다. Range Adaptor(범위 어댑터)는 이 문제를 근본적으로 다르게 풉니다 — 어댑터는 데이터를 즉시 계산해서 새 컨테이너에 담아 반환하지 않고, 원본 범위를 감싸는 뷰(view) 를 반환합니다. 뷰는 “이 범위를 이렇게 가공해서 보여주겠다”는 계획만 들고 있을 뿐, 실제 연산은 뷰를 순회(iterate)하는 순간까지 미뤄집니다. 이것이 지연 평가(lazy evaluation) 입니다.
지연 평가가 왜 중요한지는 두 가지 관점에서 이해하면 명확해집니다. 첫째, 어댑터를 아무리 길게 체이닝해도 중간 단계마다 컨테이너를 할당하지 않습니다. filter | transform | take 세 단계를 이어도 힙 할당은 0번입니다 — 각 어댑터는 원본 범위에 대한 얇은 래퍼(wrapper)일 뿐이고, 실제 원소 하나하나는 최종 순회 루프가 operator++와 operator*를 호출할 때 비로소 필터 조건을 검사하고 변환 함수를 적용합니다. 둘째, 이 덕분에 무한 범위를 다룰 수 있습니다. std::views::iota(0)는 끝이 없는 정수 시퀀스를 나타내는 뷰인데, 즉시 평가하는 방식이었다면 이 코드는 결코 끝나지 않았을 것입니다. 하지만 지연 평가 덕분에 iota(0) | filter(...) | take(5)처럼 뒤에 take를 붙이면, 실제로는 조건을 만족하는 원소 5개를 찾을 때까지만 무한 시퀀스를 조금씩 당겨오고 멈춥니다.
#include <ranges>
std::vector<int> v = {1, 2, 3, 4, 5};
// 어댑터: 범위 -> 뷰
auto filtered = v | std::views::filter([](int x) { return x > 2; });
이 코드에서 filtered가 생성되는 시점에는 어떤 필터링도 일어나지 않습니다. filtered는 단지 “v를 필터링해서 보여주는 뷰”라는 정보를 담은 객체이고, 실제로 for (int x : filtered) 같은 순회가 시작되어야 x > 2 검사가 실행됩니다. 이 지연성은 편리하지만, 뒤에서 다룰 두 가지 함정(댕글링 범위, 조건자 다중 호출)의 근본 원인이기도 합니다.
지연 평가가 실제로 만드는 함정
지연 평가는 “계산을 미룬다”는 개념 자체가 두 가지 실무 문제를 낳습니다. 하나는 수명 문제이고, 다른 하나는 부작용(side effect) 문제입니다.
flowchart LR
A["뷰 생성\nfilter/transform 체이닝"] -->|"즉시 계산 안 함"| B["뷰 객체\n(파이프라인 계획만 보유)"]
B -->|"for 순회 시작"| C["원소 1개 요청"]
C --> D{"filter 조건 검사"}
D -->|"통과"| E["transform 적용"]
D -->|"탈락"| C
E --> F["소비자에게 전달"]
F -->|"다음 원소 요청"| C
위 다이어그램처럼 실제 계산은 “당겨쓰기(pull-based)” 방식으로, 소비자 쪽 루프가 원소를 요청할 때마다 파이프라인을 거슬러 올라가며 한 원소씩 계산합니다. 컨테이너를 먼저 다 만들고 나중에 순회하는 즉시 평가 모델과는 완전히 다른 실행 순서라는 점을 이해해야 아래 함정들이 왜 발생하는지 납득이 갑니다.
댕글링 범위: 원본이 뷰보다 먼저 죽는 문제
뷰는 기본적으로 원본 범위를 소유하지 않습니다. filter, transform 같은 어댑터는 원본에 대한 참조(또는 반복자)만 들고 있다가, 나중에 순회 시점에 그 참조를 통해 원본에 접근합니다. 문제는 그 원본이 뷰보다 먼저 스코프를 벗어나 소멸해버리는 경우입니다.
// ❌ 임시 객체
auto getView() {
std::vector<int> v = {1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; });
// v 소멸 — 반환된 뷰는 이미 죽은 벡터를 가리키는 댕글링 참조가 된다
}
// ✅ 소유 또는 참조 명확화
auto getView(const std::vector<int>& v) {
return v | std::views::filter([](int x) { return x > 1; });
}
저는 실제로 이 패턴 그대로 사고를 낸 적이 있습니다. API 호출로 받은 결과를 임시 std::vector에 담고, 그 벡터를 바로 views::filter에 파이프해서 함수 반환값으로 넘긴 코드였습니다. 디버그 빌드에서는 이 코드가 신기하게도 잘 동작했습니다 — 디버그 모드는 스택 메모리를 바로 재사용하지 않고, 소멸된 벡터의 힙 버퍼 내용도 한동안 그대로 남아있는 경우가 많기 때문입니다. 그런데 릴리스 빌드로 전환하자마자 같은 코드가 임의의 쓰레기 값을 순회하다가 크래시했습니다. 컴파일러 최적화가 스택 프레임을 더 공격적으로 재사용하면서 벡터가 차지했던 메모리를 다른 지역 변수가 곧바로 덮어썼기 때문입니다. 원인을 찾는 데 반나절이 걸렸는데, 문제는 “필터 조건이 틀렸나”가 아니라 애초에 순회할 원본이 이미 존재하지 않았다는 것이었습니다. 이 경험 이후로 저는 함수가 뷰를 반환하는 시그니처를 볼 때마다 “이 뷰가 참조하는 원본의 수명이 호출자 쪽에서 뷰보다 길게 보장되는가”를 가장 먼저 확인하는 습관이 생겼습니다. 임시 컨테이너를 즉시 뷰로 감싸 반환하는 코드는 컴파일은 되지만 실행 시점에야 터지는 전형적인 UB(정의되지 않은 동작)입니다.
조건자는 여러 번 호출될 수 있다 — 부작용은 금물
views::filter에 넘긴 predicate(조건 함수)는 순회할 때마다, 그리고 구현에 따라 같은 원소에 대해서도 한 번 이상 호출될 수 있습니다. 예를 들어 begin()을 여러 번 호출하거나, 순회 도중 반복자를 복사해서 다시 앞으로 돌리는 코드가 있으면 조건자가 다시 실행됩니다. 표준은 filter view의 조건자 호출 횟수를 엄격하게 못박지 않았기 때문에, 조건자 안에 부작용(side effect)이 있으면 그 결과는 구현/사용 패턴에 따라 달라지는 예측 불가능한 코드가 됩니다.
이것도 제가 실제로 겪은 문제였습니다. 특정 조건을 만족하는 로그 항목 수를 세려고, views::filter의 람다 안에서 캡처한 카운터 변수를 ++시키는 방식으로 “필터링하면서 동시에 개수도 세면 한 번의 순회로 끝나겠지”라고 생각한 적이 있습니다.
// ❌ 필터 조건자 안에서 부작용을 일으키는 코드
int count = 0;
auto view = logs | std::views::filter([&count](const Log& l) {
bool match = l.level == LogLevel::Error;
if (match) ++count; // 부작용: 몇 번 호출되는지에 따라 값이 달라진다
return match;
});
for (const auto& l : view) { /* 처리 */ }
std::cout << count; // 기대한 에러 개수와 다르게 나올 수 있다
결과는 실제 에러 로그 개수보다 더 큰 값이 나왔습니다. 뷰를 두 번 이상 순회하거나(예: 화면에 출력하면서 동시에 파일에도 쓰는 코드가 뷰를 재사용), 알고리즘 내부 구현이 begin()을 다시 계산하며 앞부분을 재검사하는 경로를 타면 같은 원소에 대해 조건자가 다시 호출되기 때문입니다. 정답은 필터 조건자를 순수 함수(같은 입력에 항상 같은 결과, 부작용 없음) 로 유지하고, 개수를 세는 작업은 별도의 std::ranges::count_if나 순회 루프 안의 카운터로 분리하는 것이었습니다. “필터 조건자는 몇 번 호출돼도 안전해야 한다(idempotent해야 한다)“는 규칙은 문서에는 잘 강조되지 않지만, 지연 평가 모델을 쓰는 이상 반드시 지켜야 하는 전제 조건입니다.
파이프라인은 몇 번 순회하는가 — 단일 패스로 융합되는 이유
filter | transform | take처럼 어댑터를 체이닝해도, 원본 컨테이너를 여러 번 순회하는 것이 아닙니다. 각 어댑터는 독립된 루프를 도는 것이 아니라, 최종 소비자(예: for 루프)가 operator++를 한 번 호출할 때마다 그 호출이 파이프라인을 따라 연쇄적으로 전파되는 구조입니다. 즉 filter가 조건을 검사해 통과한 원소 하나에 대해서만 transform이 실행되고, 그 결과 하나가 소비자에게 전달됩니다 — 원소당 한 번의 “패스”로 전체 파이프라인이 융합(fuse)되는 것입니다. 이는 STL 알고리즘을 파이프라인으로 직접 연결해서 쓸 때(std::transform 결과를 벡터에 담고 다시 std::copy_if를 돌리는 식)와 비교하면 확실한 성능 이점입니다 — 중간 결과를 담는 컨테이너가 없으니 캐시 지역성도 좋고 메모리 할당도 없습니다.
다만 이 이점은 “한 번만 순회할 때”에 한정됩니다. 아래 코드처럼 같은 뷰 객체를 두 번 순회하면, 필터와 변환 연산은 매 순회마다 처음부터 다시 실행됩니다. 뷰는 계산 결과를 캐시하지 않기 때문입니다.
// 지연 평가: 매번 재계산
auto view = v | std::views::filter(pred) | std::views::transform(func);
for (int x : view) { /* ... */ } // 계산
for (int x : view) { /* ... */ } // 다시 계산
// ✅ 한 번만 계산 필요 시
std::vector<int> cached(view.begin(), view.end());
for (int x : cached) { /* ... */ } // 캐시 사용
for (int x : cached) { /* ... */ } // 캐시 사용
경험적으로, filter나 transform 함수가 가벼운 산술 연산이라면 두 번 재계산해도 체감 성능 차이가 거의 없습니다. 하지만 조건자나 변환 함수가 정규식 매칭, 파일 I/O, 네트워크 조회처럼 무거운 연산을 포함한다면, 뷰를 여러 번 순회하기 전에 반드시 std::vector나 ranges::to<std::vector>()로 한 번 구체화(materialize)해서 캐시해야 합니다. 이 판단 기준(연산이 가벼운가 무거운가, 몇 번 순회할 것인가)을 코드 리뷰 때 명시적으로 따져보는 습관을 들이면 불필요한 재계산으로 인한 성능 저하를 미리 막을 수 있습니다.
기본 사용
Range Adaptor는 두 가지 문법으로 호출할 수 있습니다 — 함수처럼 범위를 첫 인자로 넘기는 방식과, 파이프 연산자(|)로 범위 뒤에 붙이는 방식입니다. 둘은 동일한 결과를 만들지만, 파이프 문법은 여러 어댑터를 왼쪽에서 오른쪽으로 순서대로 읽을 수 있어 체이닝할 때 가독성이 훨씬 좋습니다. 이 때문에 실무 코드에서는 파이프 문법이 사실상 표준처럼 쓰입니다.
namespace vws = std::views;
std::vector<int> v = {1, 2, 3, 4, 5};
// 어댑터 적용
auto view1 = vws::filter(v, [](int x) { return x > 2; });
// 파이프라인
auto view2 = v | vws::filter([](int x) { return x > 2; });
view1과 view2는 완전히 동일한 타입, 동일한 결과를 만듭니다. vws::filter(v, pred) 형태는 인자가 하나뿐일 때는 괜찮지만, 세 단계 이상 이어붙이면 vws::take(vws::transform(vws::filter(v, pred), func), 3)처럼 괄호가 안쪽에서 바깥쪽으로 겹겹이 쌓여 읽는 순서와 실행 순서가 반대로 뒤집힙니다. 반면 파이프 문법은 v | filter(pred) | transform(func) | take(3)처럼 데이터가 흘러가는 순서 그대로 코드를 작성할 수 있어, 유닉스 셸 파이프라인에 익숙한 개발자라면 특히 자연스럽게 느껴질 것입니다.
실전 예시
예시 1: 파이프라인 조합
#include <ranges>
#include <vector>
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 여러 어댑터 조합
auto pipeline = numbers
| std::views::filter([](int x) { return x % 2 == 0; }) // 짝수
| std::views::transform([](int x) { return x * x; }) // 제곱
| std::views::take(3); // 처음 3개
for (int x : pipeline) {
std::cout << x << " "; // 4 16 36
}
이 예시는 “짝수만 골라서, 제곱한 다음, 앞의 3개만” 이라는 요구사항을 세 개의 독립된 알고리즘 호출과 두 개의 임시 벡터 없이 한 줄로 표현합니다. 앞서 설명한 지연 평가 덕분에, 실제로는 for 루프가 pipeline의 첫 원소를 요청하는 순간부터 numbers를 앞에서부터 훑으면서 짝수를 찾고, 찾을 때마다 제곱해서 반환하는 과정을 3번 반복하고 멈춥니다. numbers에 원소가 100만 개 있어도 take(3)이 있는 한 처음 몇 개의 짝수만 확인하고 나머지는 건드리지 않습니다 — 즉시 평가 방식(전체를 필터링한 벡터를 만들고, 전체를 변환한 벡터를 만들고, 그중 3개만 취하는 방식)과 비교하면 이 차이가 왜 성능상 의미 있는지 명확해집니다.
예시 2: 재사용 가능한 어댑터
#include <ranges>
// 어댑터 저장
auto evenFilter = std::views::filter([](int x) { return x % 2 == 0; });
auto square = std::views::transform([](int x) { return x * x; });
std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v2 = {6, 7, 8, 9, 10};
// 재사용
auto result1 = v1 | evenFilter | square;
auto result2 = v2 | evenFilter | square;
views::filter(pred)처럼 범위 없이 조건자만 넘기면, 그 결과는 아직 어떤 범위와도 결합되지 않은 “부분 적용된 어댑터 객체”가 됩니다. 이 객체는 함수 객체처럼 값으로 복사하거나 변수에 저장해서 여러 범위에 재사용할 수 있습니다. 로그 필터링, 유효성 검증 규칙처럼 “동일한 변환 로직을 여러 데이터셋에 반복 적용”하는 상황에서 이 패턴이 유용합니다. 다만 조건자나 변환 함수가 상태를 캡처하고 있다면(예: 참조로 캡처한 외부 변수), 여러 범위에 재사용할 때 그 상태가 호출 사이에 공유된다는 점을 주의해야 합니다 — 이것이 앞서 설명한 “조건자는 순수해야 한다”는 원칙과 같은 맥락입니다.
예시 3: 커스텀 어댑터
#include <ranges>
// 커스텀 어댑터: 홀수만
auto odds = std::views::filter([](int x) { return x % 2 != 0; });
// 커스텀 어댑터: 3의 배수만
auto multiplesOf3 = std::views::filter([](int x) { return x % 3 == 0; });
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto result = v | odds; // 1 3 5 7 9
여기서 “커스텀 어댑터”라는 표현은 views::filter 자체를 새로 구현한다는 뜻이 아니라, 이미 있는 어댑터에 우리 도메인에 특화된 조건자를 결합해 이름 붙인 조합을 만든다는 뜻입니다. odds, multiplesOf3처럼 의미가 드러나는 이름으로 어댑터 조합을 저장해두면, 호출부 코드가 filter([](int x){ return x % 2 != 0; })라는 람다 표현식 대신 odds라는 의도가 분명한 이름으로 읽혀서 가독성이 좋아집니다. 진짜 의미의 커스텀 어댑터(예: 자체 뷰 클래스를 view_interface로부터 상속해 구현하는 것)는 훨씬 복잡한 주제이므로, 대부분의 실무에서는 이 예시처럼 표준 어댑터의 조합에 이름을 붙이는 수준으로 충분합니다.
예시 4: 조건부 어댑터
#include <ranges>
template<typename Range>
auto conditionalFilter(Range&& r, bool applyFilter) {
if (applyFilter) {
return std::forward<Range>(r)
| std::views::filter([](int x) { return x > 5; });
} else {
return std::forward<Range>(r) | std::views::all;
}
}
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto result = conditionalFilter(v, true);
for (int x : result) {
std::cout << x << " "; // 6 7 8 9 10
}
}
이 예시는 실무에서 자주 마주치는 함정을 하나 숨기고 있습니다. if와 else 분기가 각각 다른 타입의 뷰(filter_view와 all_view)를 반환하기 때문에, 실제로 이 코드는 두 분기의 반환 타입이 같아야 하는 auto 반환 타입 추론 규칙에 걸려 컴파일되지 않는 경우가 많습니다. 실무에서는 이런 조건부 로직을 std::variant로 두 뷰 타입을 감싸거나, 아예 조건자 자체를 “항상 조건을 만족”하는 함수와 “실제 조건”을 검사하는 함수로 다형화해서 반환 타입을 하나로 통일하는 방식을 씁니다. 조건에 따라 뷰의 형태가 완전히 달라지는 상황이라면, “필터를 적용하지 않는다”를 “항상 참을 반환하는 필터”로 바꿔서 반환 타입을 통일하는 편이 더 간단할 때가 많습니다.
주요 어댑터
namespace vws = std::views;
// 필터링
vws::filter(pred)
vws::take(n)
vws::drop(n)
vws::take_while(pred)
vws::drop_while(pred)
// 변환
vws::transform(func)
vws::reverse
// 분할/결합
vws::split(delimiter)
vws::join
// 생성
vws::iota(start)
vws::iota(start, end)
// 기타
vws::all // 전체 뷰
vws::counted(it, n)
vws::common
이 목록은 크게 네 가지 역할로 나뉩니다. filter 계열(filter, take, drop, take_while, drop_while)은 원소를 골라내거나 잘라내는 역할이고, transform과 reverse는 원소 자체나 순회 순서를 바꾸는 역할입니다. split과 join은 중첩된 범위를 나누거나 합치는 역할로, 예를 들어 구분자로 문자열을 토큰화할 때(split) 또는 범위의 범위(vector<vector<int>>)를 평평하게(join) 만들 때 씁니다. 마지막으로 iota, all, counted, common은 기존 데이터가 아니라 새로운 뷰를 “생성”하거나 기존 반복자를 뷰로 감싸는 유틸리티입니다. 실무에서 압도적으로 많이 쓰이는 조합은 filter + transform + take이며, 나머지는 특정 상황(텍스트 파싱에서의 split, 무한 시퀀스 생성의 iota)에서 등장합니다.
자주 발생하는 문제
문제 1: 타입 추론
// ❌ 복잡한 타입
std::vector<int> v = {1, 2, 3};
std::ranges::filter_view<std::ranges::ref_view<std::vector<int>>, /* ... */> filtered =
v | std::views::filter([](int x) { return x > 1; });
// ✅ auto
auto filtered = v | std::views::filter([](int x) { return x > 1; });
뷰의 실제 타입은 람다의 익명 타입까지 포함하기 때문에 사람이 직접 손으로 쓸 수 없는 수준으로 복잡합니다. auto를 쓰지 않고 타입을 명시하려는 시도는 대부분 실패하거나, 컴파일러가 내부적으로 사용하는 구현 세부 타입에 의존하는 코드가 되어 컴파일러 버전이 바뀌면 깨질 위험이 있습니다. 뷰를 다루는 코드에서는 항상 auto로 받는 것이 규칙에 가깝고, 함수의 반환 타입도 auto(C++14 이상의 반환 타입 추론)를 쓰거나 클래스 멤버로 저장해야 한다면 타입 소거를 위해 std::function이나 커스텀 래퍼로 감싸야 합니다.
문제 2: 어댑터 순서
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 순서에 따라 결과 다름
auto r1 = v | std::views::reverse | std::views::take(3);
// 10 9 8
auto r2 = v | std::views::take(3) | std::views::reverse;
// 3 2 1
두 코드는 결과만 다른 것이 아니라 연산량도 다릅니다. r1은 먼저 전체 범위를 뒤집는 뷰를 만들고 그중 앞의 3개만 취하지만, reverse 뷰는 원본이 양방향 반복자를 지원하기만 하면 실제로 원소를 복사하지 않고 순회 방향만 바꾸는 정도라 비용이 거의 없습니다. 반대로 필터처럼 조건 검사가 들어간 어댑터를 먼저 적용하느냐 나중에 적용하느냐는 실질적인 성능 차이를 만듭니다 — 예를 들어 무거운 조건자를 가진 filter를 take(3) 뒤에 두면 전체 범위를 다 검사한 뒤 3개를 취하는 셈이 되어 버립니다(단, take가 뷰의 길이 자체를 미리 알 수 있는 경우는 다름). 일반적인 원칙은 “원소 개수를 줄이는 연산(filter, take)을 최대한 앞쪽에 두고, 값 자체를 바꾸는 연산(transform)을 뒤에 둔다”입니다. 그래야 뒤쪽 연산이 처리해야 할 원소 수가 최소화됩니다.
문제 3: 수명 관리
수명 문제는 앞서 “지연 평가가 실제로 만드는 함정” 섹션에서 실제 겪은 사례와 함께 자세히 다뤘습니다. 핵심만 다시 정리하면, 뷰는 원본을 소유하지 않으므로 뷰를 반환하거나 저장할 때는 항상 “이 뷰가 참조하는 원본이 뷰보다 오래 살아남는가”를 확인해야 합니다.
// ❌ 임시 객체
auto getView() {
std::vector<int> v = {1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; });
// v 소멸
}
// ✅ 소유 또는 참조 명확화
auto getView(const std::vector<int>& v) {
return v | std::views::filter([](int x) { return x > 1; });
}
문제 4: 성능과 재계산
// 지연 평가: 매번 재계산
auto view = v | std::views::filter(pred) | std::views::transform(func);
for (int x : view) { /* ... */ } // 계산
for (int x : view) { /* ... */ } // 다시 계산
// ✅ 한 번만 계산 필요 시
std::vector<int> cached(view.begin(), view.end());
for (int x : cached) { /* ... */ } // 캐시 사용
for (int x : cached) { /* ... */ } // 캐시 사용
같은 뷰를 여러 번 순회해야 한다면, 순회할 때마다 필터·변환 연산이 다시 실행된다는 점을 감안해서 무거운 연산이라면 미리 구체화해야 한다는 내용도 위에서 다뤘습니다. 이 문제와 앞서 설명한 “조건자 다중 호출” 문제는 사실 같은 뿌리에서 나옵니다 — 뷰는 결과를 캐시하지 않는다는 지연 평가의 기본 성질입니다.
문제 5: 필터 조건자의 부작용
views::filter에 넘기는 조건자에 부작용(카운터 증가, 로그 출력, 외부 상태 변경)을 넣으면 안 됩니다. 조건자는 표준이 호출 횟수를 보장하지 않는 함수이기 때문에, 부작용이 있는 조건자는 실행할 때마다 다른 결과를 낼 수 있는 코드가 되어버립니다.
// ❌ 필터 조건자 안에서 부작용
int count = 0;
auto view = logs | std::views::filter([&count](const Log& l) {
bool match = l.level == LogLevel::Error;
if (match) ++count;
return match;
});
// ✅ 개수를 세는 로직은 분리
auto errorView = logs | std::views::filter([](const Log& l) {
return l.level == LogLevel::Error;
});
auto count = std::ranges::distance(errorView);
이 문제도 위에서 실제 겪은 사례로 설명했듯, “필터 조건자는 순수 함수여야 한다”는 원칙을 지키면 예방할 수 있습니다.
어댑터 조합
namespace vws = std::views;
// 파이프라인
auto pipeline = vws::filter(pred)
| vws::transform(func)
| vws::take(n);
// 적용
std::vector<int> v = {1, 2, 3, 4, 5};
auto result = v | pipeline;
부분 적용된 어댑터끼리도 파이프 연산자로 미리 조합할 수 있습니다. vws::filter(pred) | vws::transform(func) | vws::take(n)처럼 범위 없이 어댑터만 먼저 이어붙이면, 그 자체가 하나의 재사용 가능한 “파이프라인 객체”가 되어 나중에 원하는 범위에 한 번에 적용할 수 있습니다. 여러 함수에서 동일한 가공 절차(예: “유효하지 않은 레코드 걸러내고, 정규화하고, 상위 N개만”)를 반복해서 쓴다면 이렇게 조합해둔 파이프라인을 상수나 헬퍼 함수로 노출하는 편이 코드 중복을 줄여줍니다.
FAQ
Q1: Range Adaptor는 정확히 무엇을 반환하나요?
A: 컨테이너가 아니라 뷰(view)를 반환합니다. 뷰는 원본 범위에 대한 얇은 래퍼이며, 실제 필터링·변환 연산은 뷰를 순회하는 시점까지 지연됩니다.
Q2: 파이프라인은 어떻게 구성하나요?
A: | 연산자로 어댑터를 좌에서 우로 이어 붙입니다. v | filter(pred) | transform(func)처럼 데이터 흐름 순서 그대로 읽히는 것이 함수 호출 중첩(transform(filter(v, pred), func))보다 가독성이 좋습니다.
Q3: 지연 평가란 정확히 무엇을 의미하나요?
A: 어댑터를 체이닝하는 시점이 아니라, for 루프 등으로 실제 원소를 하나씩 요청하는 시점에 필터 조건 검사와 변환 함수 호출이 일어난다는 뜻입니다. 이 덕분에 중간 컨테이너 할당 없이 무한 범위까지 다룰 수 있습니다.
Q4: 어댑터를 저장해서 재사용할 수 있나요?
A: 가능합니다. views::filter(pred)처럼 범위 없이 조건자만 넘기면 부분 적용된 어댑터 객체가 되어 여러 범위에 재사용할 수 있습니다. 단, 캡처한 상태가 있다면 공유 여부를 주의해야 합니다.
Q5: 어댑터 순서가 왜 중요한가요?
A: 결과 자체가 달라질 뿐 아니라 연산 비용도 달라집니다. 원소 개수를 줄이는 연산(filter, take)을 앞쪽에 배치하면 뒤따르는 연산이 처리할 원소 수가 줄어듭니다.
Q6: 어댑터 체인이 길어지면 컴파일 시간에 영향이 있나요?
A: 있습니다. 각 어댑터가 템플릿으로 중첩되면서 타입이 깊게 쌓이고, 람다 하나하나가 고유 타입을 만들기 때문에 체인이 길어질수록 컴파일러가 처리해야 할 템플릿 인스턴스화 양도 늘어납니다. 실무에서는 자주 반복되는 파이프라인 조합을 헬퍼 함수로 추출해 재사용하면 컴파일 시간 부담을 줄일 수 있습니다.