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)를 씁니다. 이 함수는 인자를 정렬하는 부수 효과가 있으므로, 질의가 반복된다면 한 번 정렬해 둔 범위에 이진 탐색만 하는 형태로 나누는 편이 낫습니다.


같이 보면 좋은 글

다음 글: Ranges Views와 파이프라인: 지연 연산으로 효율적으로 다루기 이전 글: 기존 프로젝트를 Module로 전환: 단계별 마이그레이션