C++20 Ranges: views 파이프라인, projection, 성능 특성
std::sort(v.begin(), v.end())처럼 반복자 쌍을 넘기는 STL 인터페이스는 오래 써 온 만큼 익숙하지만, 불편한 점도 분명합니다. 같은 컨테이너의 begin과 end를 매번 짝지어 넘겨야 하고, 실수로 서로 다른 컨테이너의 반복자를 섞거나 순서를 뒤집어도(sort(v.end(), v.begin())) 컴파일은 통과하고 실행 시 미정의 동작이 됩니다. 구조체를 특정 멤버 기준으로 정렬하려면 두 원소를 받아 멤버를 비교하는 람다를 매번 써야 하고, 이 람다가 코드 곳곳에 비슷한 모양으로 반복됩니다.
std::vector<int> v = {3, 1, 4, 1, 5};
std::sort(v.begin(), v.end());
struct Person { std::string name; int age; };
std::vector<Person> people = {{"Alice", 30}, {"Bob", 20}};
std::sort(people.begin(), people.end(),
[](const Person& a, const Person& b) { return a.age < b.age; });
C++20 Ranges는 이 두 문제를 직접 다룹니다. 알고리즘에 범위 하나를 넘기면 되고, “어떤 멤버를 기준으로 볼지”를 비교자와 분리된 프로젝션으로 넘길 수 있습니다. 여기에 view와 파이프(|)를 쓰면 “필터 → 변환 → 앞 N개” 같은 처리를 중간 컨테이너 없이 연결할 수 있습니다. 이 글은 ranges 알고리즘, 프로젝션, view의 기초, range concept 계층, 그리고 자주 틀리는 지점을 다룹니다. 예제는 g++ -std=c++20 또는 clang++ -std=c++20으로 빌드합니다.
Range: begin과 end를 하나로 묶은 개념
range는 std::ranges::begin(r)과 std::ranges::end(r)로 순회할 수 있는 타입입니다. std::vector, std::list, 내장 배열, 그리고 데이터를 복사하지 않고 범위로만 바라보는 view가 모두 여기에 해당합니다.
#include <algorithm>
#include <ranges>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {3, 1, 2};
static_assert(std::ranges::range<decltype(v)>);
std::ranges::sort(v);
for (int x : v) std::cout << x << " "; // 1 2 3
std::cout << "\n";
}
std::ranges::sort 같은 알고리즘은 <ranges>가 아니라 <algorithm>에 선언되어 있습니다. 구현에 따라 <ranges>만 include해도 컴파일되는 경우가 있지만, 이식성을 생각하면 <algorithm>을 명시적으로 include해야 합니다.
기존 반복자 쌍과 한 가지 다른 점은 끝을 나타내는 값이 반복자와 다른 타입이어도 된다는 것입니다. 이를 sentinel이라고 부릅니다. 예를 들어 널 종료 문자열은 “현재 문자가 '\0'인가”를 검사하는 sentinel로 끝을 표현할 수 있어, 길이를 미리 계산할 필요가 없습니다.
sequenceDiagram
participant Code as 코드
participant Algo as ranges::sort(v)
participant Range as vector v
Code->>Algo: sort(v) 범위 전달
Algo->>Range: ranges::begin(v), ranges::end(v)
Range-->>Algo: 반복자와 sentinel
Algo->>Algo: 반복자 구간 정렬
Algo-->>Code: 끝 반복자 반환, v는 제자리 정렬됨
자주 쓰는 ranges 알고리즘
std::ranges 네임스페이스에는 기존 <algorithm>의 대부분이 범위를 받는 형태로 들어 있습니다. 반환 타입은 기존보다 정보가 많아서, 예를 들어 ranges::transform은 입력과 출력 양쪽의 끝 위치를 담은 구조체를 돌려줍니다.
sort, reverse, count, find
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
std::ranges::sort(v); // {1, 1, 2, 3, 4, 5, 6, 9}
std::ranges::reverse(v); // {9, 6, 5, 4, 3, 2, 1, 1}
auto n = std::ranges::count(v, 1); // 2
auto it = std::ranges::find(v, 4);
if (it != v.end()) {
std::cout << "index: " << (it - v.begin()) << "\n"; // 4
}
auto it2 = std::ranges::find_if(v, [](int x) { return x < 3; });
if (it2 != v.end()) std::cout << *it2 << "\n"; // 2
}
find 계열은 찾지 못하면 끝 반복자를 돌려주므로, 역참조하기 전에 반드시 비교해야 합니다.
transform과 copy_if
#include <algorithm>
#include <iterator>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
std::vector<int> doubled(v.size()); // 출력 공간을 먼저 확보
std::ranges::transform(v, doubled.begin(), [](int x) { return x * 2; });
// doubled == {2, 4, 6, 8, 10}
std::vector<int> even;
std::ranges::copy_if(v, std::back_inserter(even),
[](int x) { return x % 2 == 0; });
// even == {2, 4}
}
출력 반복자를 받는 알고리즘은 출력 쪽 크기를 검사하지 않습니다. doubled를 빈 벡터로 두고 begin()을 넘기면 범위 밖에 쓰게 되므로, 크기를 미리 잡거나 back_inserter를 씁니다.
lower_bound, upper_bound
std::vector<int> v = {1, 2, 3, 4, 4, 4, 5}; // 정렬된 상태여야 함
auto lo = std::ranges::lower_bound(v, 4); // 첫 번째 4
auto hi = std::ranges::upper_bound(v, 4); // 5
auto count4 = hi - lo; // 3
lower_bound는 값 이상인 첫 위치, upper_bound는 값 초과인 첫 위치를 돌려줍니다. 정렬되지 않은 범위에 쓰면 에러 없이 틀린 답이 나옵니다.
unique와 remove: 반환값이 subrange
std::vector<int> v = {3, 1, 2, 1, 3, 2};
std::ranges::sort(v); // {1, 1, 2, 2, 3, 3}
auto [first, last] = std::ranges::unique(v); // 남는 꼬리 구간
v.erase(first, last); // v == {1, 2, 3}
std::vector<int> w = {1, 0, 2, 0, 3};
auto removed = std::ranges::remove(w, 0);
w.erase(removed.begin(), removed.end()); // w == {1, 2, 3}
기존 std::unique는 “유일한 원소 구간의 끝” 반복자 하나를 돌려줬지만, ranges::unique와 ranges::remove는 지워야 할 꼬리 구간 전체를 subrange로 돌려줍니다. 그래서 구조화 바인딩의 first가 지울 구간의 시작입니다. 또 이 알고리즘들은 컨테이너 크기를 줄이지 못하므로 erase를 따로 호출해야 합니다. C++20부터는 std::erase(w, 0)과 std::erase_if가 이 두 단계를 한 번에 해 줍니다.
unique는 인접한 중복만 제거합니다. {1, 2, 1, 3}에 정렬 없이 호출하면 아무것도 제거되지 않으므로, 전체 중복을 없애려면 먼저 정렬해야 합니다.
all_of, count_if, min_element, max_element
std::vector<int> v = {2, 4, 6, 8};
bool allEven = std::ranges::all_of(v, [](int x) { return x % 2 == 0; }); // true
auto bigCount = std::ranges::count_if(v, [](int x) { return x > 5; }); // 2
auto mn = std::ranges::min_element(v);
if (mn != v.end()) { /* *mn == 2 */ }
min_element와 max_element는 빈 범위에서 끝 반복자를 돌려주므로, 역참조 전에 확인해야 합니다. 값 자체가 필요하다면 std::ranges::min(v)도 있지만, 이 함수는 빈 범위에 호출하면 미정의 동작입니다.
프로젝션으로 멤버 기준 정렬하기
많은 ranges 알고리즘은 마지막 인자로 프로젝션을 받습니다. 알고리즘이 원소를 비교하거나 검사하기 전에 프로젝션을 먼저 적용하고, 그 결과를 봅니다. 비교 방법(비교자)과 무엇을 비교할지(프로젝션)가 분리된다는 점이 핵심입니다.
#include <algorithm>
#include <functional>
#include <string>
#include <vector>
struct Person { std::string name; int age; };
int main() {
std::vector<Person> people = {{"Alice", 30}, {"Bob", 20}, {"Charlie", 25}};
std::ranges::sort(people, {}, &Person::age); // 나이 오름차순
std::ranges::sort(people, std::ranges::greater{}, &Person::age); // 나이 내림차순
std::ranges::sort(people, {}, &Person::name); // 이름 오름차순
auto it = std::ranges::find(people, 25, &Person::age); // 나이가 25인 사람
auto oldest = std::ranges::max_element(people, {}, &Person::age);
}
인자 순서는 sort(range, comparator, projection)입니다. 비교자를 기본값(std::ranges::less)으로 두려면 두 번째 자리에 {}를 넣습니다. 멤버 포인터를 프로젝션으로 넘길 수 있는 이유는 알고리즘이 내부에서 std::invoke로 호출하기 때문이며, 같은 이유로 멤버 함수 포인터도 넘길 수 있습니다.
std::ranges::sort(people, &Person::age)처럼 비교자 자리에 프로젝션을 넣으면, 멤버 포인터를 인자 두 개로 호출하려 하게 되어 컴파일 에러가 납니다. 에러 메시지가 concept 만족 실패로 길게 나오기 때문에 원인을 찾기 어려운데, 저는 이 에러를 보면 가장 먼저 인자 순서부터 확인합니다.
복합 키가 필요하면 프로젝션이 튜플을 돌려주게 할 수 있습니다.
// 나이 오름차순, 같은 나이면 이름 오름차순
std::ranges::sort(people, {}, [](const Person& p) {
return std::tie(p.age, p.name); // 참조를 담은 tuple, 문자열 복사 없음
});
프로젝션은 결과가 캐시되지 않고 비교할 때마다 호출됩니다. 정렬이라면 비교가 O(n log n)번 일어나고 비교마다 양쪽 원소에 프로젝션을 적용하므로, 멤버 접근처럼 싼 연산은 인라인되어 사실상 비용이 없지만 문자열을 새로 만드는 프로젝션은 그만큼 반복됩니다. 비싼 키라면 키를 미리 계산해 두고 정렬하는 편이 낫습니다.
View와 adaptor 기초
ranges 알고리즘 중 sort나 reverse처럼 원소를 옮기는 것은 범위를 제자리에서 수정합니다. 반면 view는 원본을 복사하지 않고 “어떻게 볼지”만 기술하는 가벼운 범위입니다. adaptor를 파이프로 연결하면 원소는 순회할 때 하나씩 계산됩니다. 자세한 내용은 다음 글에서 다루고, 여기서는 기본만 봅니다.
#include <ranges>
#include <vector>
#include <iostream>
namespace vw = std::views;
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9};
auto result = v
| vw::filter([](int x) { return x % 2 == 0; }) // 2 4 6 8
| vw::transform([](int x) { return x * x; }) // 4 16 36 64
| vw::take(3); // 앞 3개
for (int x : result) std::cout << x << " "; // 4 16 36
std::cout << "\n";
}
result를 만드는 시점에는 아무 계산도 일어나지 않습니다. for 루프가 원소를 요청할 때마다 filter가 다음 짝수를 찾고, transform이 제곱하고, take가 세 개를 채우면 멈춥니다. 그래서 8은 검사되지만 제곱되지 않고, 9는 아예 검사되지 않습니다.
| adaptor | 동작 |
|---|---|
views::filter(pred) | 조건을 만족하는 원소만 |
views::transform(f) | 각 원소에 함수 적용 |
views::take(n) / views::drop(n) | 앞 n개만 / 앞 n개 건너뛰기 |
views::take_while(pred) | 조건이 참인 동안만 |
views::reverse | 역순 (양방향 범위 필요) |
지연 평가가 공짜는 아닙니다. view는 순회할 때마다 다시 계산하므로 같은 결과를 여러 번 읽으면 변환도 여러 번 일어납니다. 또 transform 뒤에 filter를 두면 filter가 조건을 검사할 때 한 번, 루프 본문이 값을 읽을 때 한 번 변환 함수가 호출됩니다. 변환이 비싸거나 결과를 여러 번 쓸 거라면 컨테이너로 구체화합니다.
auto squared = v | vw::transform([](int x) { return x * x; });
// C++20: transform_view over vector는 common_range이므로 반복자 쌍 생성자를 쓸 수 있음
std::vector<int> out(squared.begin(), squared.end());
// C++23
// auto out = v | vw::transform([](int x) { return x * x; })
// | std::ranges::to<std::vector>();
Range concept 계층과 알고리즘 요구 사항
range는 반복자가 지원하는 연산에 따라 여러 concept으로 나뉘고, 알고리즘마다 요구하는 수준이 다릅니다.
flowchart TD
range[range] --> input_range[input_range]
input_range --> forward_range[forward_range]
forward_range --> bidirectional_range[bidirectional_range]
bidirectional_range --> random_access_range[random_access_range]
random_access_range --> contiguous_range[contiguous_range]
| concept | 의미 | 예 |
|---|---|---|
input_range | 앞으로 한 번 순회 | std::views::istream<int>(std::cin) |
forward_range | 여러 번 순회 가능 | std::forward_list |
bidirectional_range | 역방향 이동 가능 | std::list, std::map |
random_access_range | 임의 위치 O(1) 접근 | std::deque |
contiguous_range | 원소가 메모리에 연속 | std::vector, std::array, 배열 |
view는 보통 원본의 범주를 이어받되 그보다 올라가지는 못합니다. 예를 들어 vector에 filter를 씌우면 몇 번째 원소인지 바로 계산할 수 없으므로 bidirectional_range로 내려가고, 따라서 ranges::sort에 넘길 수 없습니다.
알고리즘별 요구는 대략 다음과 같습니다. sort와 nth_element는 random_access_range, reverse는 bidirectional_range, unique와 remove는 forward_range, find, count, transform은 input_range면 충분합니다.
std::list<int> lst = {3, 1, 2};
// std::ranges::sort(lst); // 컴파일 에러: list는 random_access_range가 아님
lst.sort(); // list는 노드를 다시 잇는 멤버 sort를 제공
std::ranges::find(lst, 1); // find는 input_range면 되므로 가능
템플릿을 작성할 때도 이 concept으로 제약을 걸면, 잘못된 타입을 넘겼을 때 알고리즘 내부 깊은 곳이 아니라 호출 지점에서 짧은 에러가 납니다.
template <std::ranges::random_access_range R>
void sortInPlace(R&& r) {
std::ranges::sort(r);
}
자주 틀리는 지점
view가 원본보다 오래 사는 경우
view는 원본을 참조만 합니다. 지역 컨테이너를 가리키는 view를 함수 밖으로 반환하면 호출자가 순회하는 순간 이미 파괴된 메모리를 읽습니다.
// 위험: v는 함수가 끝나면 파괴되고, 반환된 view는 v를 가리키는 ref_view를 담고 있음
auto evensBad() {
std::vector<int> v = {1, 2, 3, 4};
return v | std::views::filter([](int x) { return x % 2 == 0; });
}
// 안전: rvalue를 파이프에 넣으면 owning_view가 원본을 소유함 (P2415, C++20 결함 보고로 반영)
auto evensOk() {
std::vector<int> v = {1, 2, 3, 4};
return std::move(v) | std::views::filter([](int x) { return x % 2 == 0; });
}
lvalue를 파이프에 넣으면 ref_view로, rvalue를 넣으면 원본을 소유하는 owning_view로 감싸집니다. 다만 owning_view는 비교적 늦게 표준에 반영됐기 때문에, 오래된 표준 라이브러리에서는 rvalue 컨테이너를 파이프에 넣는 코드 자체가 컴파일되지 않을 수 있습니다. 가장 확실한 방법은 view를 원본과 같은 스코프 안에서만 쓰거나, 반환해야 한다면 컨테이너로 구체화해서 반환하는 것입니다.
원소를 수정하면서 filter_view를 다시 순회
filter_view는 첫 번째 begin() 호출 결과를 캐시합니다. 순회 중에 원소를 바꿔 조건 결과가 달라지게 만든 뒤 같은 view를 다시 순회하면, 캐시된 시작 위치 때문에 기대와 다른 결과가 나올 수 있습니다. 이 때문에 filter_view는 const로 순회할 수 없다는 제약도 생깁니다. 원소를 수정하는 파이프라인은 한 번만 순회하는 용도로 쓰는 편이 안전합니다.
반복자 무효화
auto it = std::ranges::find(v, 4);
if (it != v.end()) {
it = v.erase(it); // erase 이후에는 반환된 반복자만 유효
}
vector::erase는 지운 위치와 그 뒤의 반복자를 모두 무효화합니다. find로 얻은 원래 반복자를 계속 쓰면 미정의 동작이므로 erase의 반환값을 씁니다.
표준 라이브러리 버전
Ranges는 컴파일러보다 표준 라이브러리 구현의 지원 시점이 더 중요합니다. C++20 <ranges>의 기본 기능은 GCC 10(libstdc++), Visual Studio 2019 16.10(MSVC STL)부터 쓸 수 있고, Clang의 libc++는 Clang 15~16 무렵에야 실험 플래그 없이 쓸 수 있게 됐습니다. 그래서 Linux GCC 빌드는 통과하는데 macOS나 Clang+libc++ CI에서만 <ranges> 관련 에러가 나는 경우가 흔합니다. C++23 기능은 더 늦어서 std::ranges::to는 GCC 14, views::enumerate는 GCC 13부터 들어왔습니다. 도입 전에 배포 대상 툴체인에서 기능 테스트 매크로를 확인합니다.
#include <version>
#if defined(__cpp_lib_ranges_to_container)
auto v = src | std::views::filter(pred) | std::ranges::to<std::vector>();
#else
auto view = src | std::views::filter(pred);
std::vector<int> v(view.begin(), view.end()); // common_range일 때
#endif
C++20 이전 표준에 묶인 프로젝트라면 표준 Ranges의 원형인 range-v3를 쓸 수 있습니다. 다만 표준으로 옮겨지면서 이름과 세부 동작이 달라진 부분이 있어, 나중에 표준 Ranges로 옮길 때 기계적 치환만으로는 끝나지 않습니다.
조합 예제
최근 에러 로그 N개
#include <algorithm>
#include <functional>
#include <ranges>
#include <string>
#include <vector>
struct LogEntry {
int level; // 0=debug, 1=info, 2=warning, 3=error
std::string message;
long long timestamp;
};
std::vector<LogEntry> recentErrors(const std::vector<LogEntry>& logs, std::size_t count) {
std::vector<LogEntry> result;
std::ranges::copy_if(logs, std::back_inserter(result),
[](int level) { return level >= 3; }, &LogEntry::level);
// 최신순: timestamp 내림차순
std::ranges::sort(result, std::ranges::greater{}, &LogEntry::timestamp);
if (result.size() > count) result.resize(count);
return result;
}
필터링에는 view 대신 copy_if와 프로젝션을 썼습니다. 결과를 어차피 벡터에 담아 정렬해야 하므로, view를 만들었다가 다시 순회해 복사하는 것보다 한 번에 복사하는 편이 단순합니다. 에러가 매우 많고 상위 몇 개만 필요하다면 전체 정렬 대신 std::ranges::partial_sort로 O(n log k)에 줄일 수 있습니다.
일부 구간만 정렬하기
std::vector<int> v = {9, 8, 7, 6, 5, 4, 3, 2, 1};
std::ranges::sort(std::ranges::subrange(v.begin() + 2, v.begin() + 7));
// 인덱스 2~6만 정렬: v == {9, 8, 3, 4, 5, 6, 7, 2, 1}
// views로도 같은 구간을 표현할 수 있음 (vector 위의 drop/take는 random_access 유지)
std::ranges::sort(v | std::views::drop(2) | std::views::take(5));
정렬 후 닫힌 구간 [lo, hi]의 원소 개수
template <std::ranges::random_access_range R>
auto countBetween(R& r, std::ranges::range_value_t<R> lo,
std::ranges::range_value_t<R> hi) {
std::ranges::sort(r); // 주의: 호출자의 범위를 정렬해 버림
auto first = std::ranges::lower_bound(r, lo);
auto last = std::ranges::upper_bound(r, hi); // hi 포함
return last - first;
}
upper_bound(hi)를 쓰면 hi와 같은 원소까지 포함하는 닫힌 구간이 됩니다. 반열린 구간 [lo, hi)가 필요하면 lower_bound(r, hi)를 씁니다. 이 함수는 인자를 정렬하는 부수 효과가 있으므로, 질의가 반복된다면 한 번 정렬해 둔 범위에 이진 탐색만 하는 형태로 나누는 편이 낫습니다.
같이 보면 좋은 글
- C++20 std::ranges 알고리즘: projection으로 중복 줄이기와 concept 기반 에러
- C++ 커스텀 Range 작성 | range 개념을 만족하는 타입 만들기 [#25-3]
- C++20 range adaptor: views::filter·transform 파이프라인의 지연 평가와 댕글링 함정
- C++ Ranges Views와 파이프라인 | 지연 연산으로 효율적으로 다루기 [#25-2]
- C++20 Modules
- C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
다음 글: Ranges Views와 파이프라인: 지연 연산으로 효율적으로 다루기 이전 글: 기존 프로젝트를 Module로 전환: 단계별 마이그레이션