C++20 std::ranges 알고리즘: projection으로 중복 줄이기와 concept 기반 에러

이 글의 핵심

std::ranges::sort가 기존 std::sort보다 안전한 이유, projection이 실제로 없애주는 코드 중복, concept 기반 제약이 만들어내는 명확한 컴파일 에러 메시지를 정리합니다. find_if는 반복자를, search·equal_range 계열은 subrange를 돌려주는 반환 타입 차이와, 임시 컨테이너에 알고리즘을 쓸 때 돌아오는 std::ranges::dangling 같은 함정도 함께 다룹니다.

Range Algorithms란?

std::ranges 알고리즘은 C++20에서 <algorithm>, <numeric>에 있던 기존 알고리즘 대부분을 std::ranges 네임스페이스 아래 다시 구현한 것입니다. 겉보기엔 std::sort를 std::ranges::sort로 바꾼 정도로 보이지만, 실제로는 두 가지 근본적인 차이가 있습니다.

첫째, 기존 알고리즘은 begin()과 end() 반복자 두 개를 따로 받습니다. 이 인터페이스는 컴파일러가 “이 두 반복자가 같은 컨테이너를 가리키는지”를 전혀 검증할 수 없다는 구조적 약점을 안고 있습니다. v1.begin()과 v2.end()를 실수로 섞어 넘겨도 컴파일은 통과하고, 실행 중에 미정의 동작으로 이어집니다. std::ranges::sort(v)처럼 range 하나만 받으면 애초에 이런 실수를 저지를 문법적 여지가 없어집니다.

둘째, ranges 알고리즘은 C++20 concept(std::ranges::random_access_range, std::sortable 등)로 템플릿 파라미터를 제약합니다. 기존 템플릿 알고리즘은 조건에 맞지 않는 타입을 넘기면 인스턴스화 과정 어딘가에서 알아보기 힘든 에러 메시지 수십~수백 줄을 쏟아냈지만, concept 기반 제약은 “이 range가 요구 조건을 만족하지 않습니다”라는 형태로 실패 지점을 훨씬 명확하게 짚어줍니다.

#include <ranges>
#include <algorithm>

std::vector<int> v = {3, 1, 4, 1, 5};

// ranges 알고리즘
std::ranges::sort(v);

// 기존 알고리즘
std::sort(v.begin(), v.end());

두 호출은 결과적으로 같은 일을 하지만, 왼쪽은 “정렬 대상이 무엇인지”를 range 하나로 명시하고 오른쪽은 “어디부터 어디까지”를 반복자 쌍으로 명시합니다. 컨테이너를 리팩터링하면서 타입이 바뀌거나, 코드 복사 과정에서 반복자 소스가 뒤섞이는 실수는 실무에서 생각보다 자주 일어나는데, ranges API는 그 실수의 발생 지점 자체를 없앤다는 점에서 단순한 문법 설탕 이상의 의미가 있습니다.

기본 사용

#include <ranges>
#include <algorithm>

std::vector<int> v = {3, 1, 4, 1, 5};

// 정렬
std::ranges::sort(v);

// 검색
auto it = std::ranges::find(v, 4);

// 변환
std::ranges::transform(v, v.begin(), [](int x) { return x * 2; });

std::ranges::find와 std::ranges::transform도 마찬가지로 컨테이너 하나만 넘깁니다. 특히 transform처럼 입력 range와 출력 반복자를 동시에 받는 알고리즘에서는, 기존 방식이 std::transform(v.begin(), v.end(), v.begin(), func)처럼 같은 컨테이너를 두 번 언급해야 했던 반면 ranges 버전은 std::ranges::transform(v, v.begin(), func)로 입력 쪽 중복이 줄어듭니다. 이런 사소해 보이는 차이가 코드 리뷰에서 “이 두 반복자가 정말 같은 컨테이너를 가리키는가”를 매번 확인해야 하는 부담을 줄여줍니다.

실전 예시

예시 1: 투영 (Projection)

#include <ranges>
#include <algorithm>
#include <vector>
#include <string>

struct Person {
    std::string name;
    int age;
};

std::vector<Person> people = {
    {"Charlie", 35},
    {"Alice", 25},
    {"Bob", 30}
};

// 나이로 정렬 (투영)
std::ranges::sort(people, {}, &Person::age);

for (const auto& p : people) {
    std::cout << p.name << " (" << p.age << ")" << std::endl;
}
// Alice (25)
// Bob (30)
// Charlie (35)

std::ranges::sort(people, {}, &Person::age)에서 세 번째 인자 &Person::age가 투영(projection)입니다. 첫 번째 {}는 기본 비교자(std::less)를 그대로 쓰겠다는 뜻이고, 투영은 “비교하기 전에 각 원소에서 어떤 값을 꺼낼지”를 지정합니다. 기존 방식이라면 매번 [](const Person& a, const Person& b) { return a.age < b.age; } 같은 람다를 작성해야 했는데, 투영을 쓰면 “무엇을 비교할지”와 “어떻게 비교할지”가 분리되어 코드가 짧아지고 의도가 더 분명하게 드러납니다.

람다 비교자 방식의 약점은 같은 비교 로직이 여러 곳에 복사된다는 점입니다. 여러 화면에서 같은 Person 벡터를 나이순·이름순으로 정렬하면서 [](const auto& a, const auto& b){ return a.age < b.age; } 같은 람다를 복사해 쓰면, 리팩터링 중 한 곳에서 a.age만 a.name으로 바꾸고 b.age는 그대로 두는 식의 실수가 생겨도 타입만 맞으면 컴파일이 통과합니다. 리뷰에서도 람다 본문을 한 줄씩 비교하지 않으면 눈에 띄지 않습니다. &Person::age, &Person::name처럼 멤버 포인터를 투영으로 넘기면 비교 기준이 호출부의 인자 하나로 줄어들어, 양쪽 피연산자가 서로 다른 필드를 보는 실수가 구조적으로 불가능해집니다.

예시 2: 범위 반환

#include <ranges>
#include <algorithm>

std::vector<int> v = {1, 2, 3, 4, 5};

// find_if는 반복자 하나를 반환 (기존 std::find_if와 같음)
auto it = std::ranges::find_if(v, [](int x) { return x > 3; });
if (it != v.end()) {
    std::cout << "찾음: " << *it << std::endl;  // 4
}

// search는 찾은 구간을 subrange로 반환
std::vector<int> pattern = {3, 4};
auto [first, last] = std::ranges::search(v, pattern);
if (first != v.end()) {
    std::cout << "구간 시작: " << *first << std::endl;  // 3
}

std::ranges::find_if는 기존 알고리즘과 마찬가지로 반복자 하나를 반환합니다. 반면 search, find_end, equal_range처럼 결과가 “구간”인 알고리즘과 remove, unique처럼 “남은 꼬리 구간”을 알려 주는 알고리즘은 std::ranges::subrange를 반환합니다. subrange는 “시작 반복자”와 “끝 sentinel”의 쌍을 하나의 타입으로 묶은 것으로, 구조적 바인딩(auto [first, last] = ...)으로 바로 풀어 쓸 수 있습니다. 기존 std::search가 시작 반복자만 돌려줘 끝 위치를 따로 계산해야 했던 불편이 사라진 것입니다. remove의 경우도 기존에는 반환된 반복자로 erase(it, v.end())를 해야 했는데, ranges 버전은 지울 구간 자체를 돌려주므로 auto [b, e] = std::ranges::remove(v, 0); v.erase(b, e);처럼 쓸 수 있습니다. 여기서 중요한 개념이 sentinel(센티널)입니다. 기존 iterator 모델에서는 시작과 끝이 반드시 같은 타입이어야 했지만, ranges 모델에서는 끝을 나타내는 sentinel이 시작 반복자와 다른 타입이어도 됩니다. 예를 들어 “특정 값이 나오면 끝”이라는 조건부 sentinel이나, std::unreachable_sentinel처럼 끝이 존재하지 않는 무한 스트림을 표현하는 sentinel도 정의할 수 있습니다. 이 덕분에 표준 라이브러리가 무한 range나 입력 스트림 기반 range도 같은 알고리즘 인터페이스로 다룰 수 있게 됩니다.

예시 3: 뷰와 알고리즘

#include <ranges>
#include <algorithm>

std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 뷰 생성
auto evens = numbers | std::views::filter([](int x) { return x % 2 == 0; });

// 뷰에 알고리즘 적용
auto max = std::ranges::max(evens);
std::cout << "최대: " << max << std::endl;  // 10

auto sum = std::ranges::fold_left(evens, 0, std::plus{});  // fold_left는 C++23
std::cout << "합: " << sum << std::endl;  // 30

std::ranges::fold_left는 C++20이 아니라 C++23에 추가된 알고리즘이라, -std=c++20으로 빌드하면 'fold_left' is not a member of 'std::ranges' 같은 오류가 납니다. C++20만 쓸 수 있다면 std::accumulate(evens.begin(), evens.end(), 0)을 쓰면 되는데, std::accumulate는 <numeric>에 있고 ranges 버전이 C++20에 포함되지 않았다는 점도 알아 두면 좋습니다. ranges 기능은 C++20과 C++23에 걸쳐 나뉘어 들어왔기 때문에(std::ranges::to, views::zip, views::enumerate도 C++23), 예제를 옮길 때 컴파일러와 표준 라이브러리 버전을 먼저 확인하는 것이 좋습니다.

std::views::filter가 만든 evens는 실제로 짝수만 담긴 새 컨테이너가 아니라, “원본을 순회하면서 조건에 맞는 원소만 골라내는 방법”을 담은 지연 평가(lazy evaluation) 객체입니다. std::ranges::max(evens)를 호출하는 순간에야 비로소 numbers를 순회하면서 짝수를 걸러내고 최댓값을 계산합니다. 이 특성 덕분에 중간 결과를 담을 임시 벡터를 만들지 않아도 되고, 필터·변환을 여러 단계로 파이프처럼 연결해도 각 단계마다 메모리 할당이 발생하지 않습니다. 다만 지연 평가라는 특성 자체가 함정이 되기도 하는데, filter에 전달한 람다가 캡처한 지역 변수의 수명이 뷰보다 먼저 끝나면(예: 람다가 참조로 캡처한 스택 변수가 스코프를 벗어난 뒤 뷰를 나중에 순회하는 경우) 댕글링 참조를 만들 수 있습니다. 뷰를 함수에서 반환하거나 나중에 순회할 목적으로 저장해 둘 때는 캡처 방식을 값으로 할지 참조로 할지 항상 의식적으로 결정해야 합니다.

예시 4: 조건부 처리

#include <ranges>
#include <algorithm>

std::vector<int> v = {1, 2, 3, 4, 5};

// all_of
bool allPositive = std::ranges::all_of(v, [](int x) { return x > 0; });

// any_of
bool hasEven = std::ranges::any_of(v, [](int x) { return x % 2 == 0; });

// none_of
bool noNegative = std::ranges::none_of(v, [](int x) { return x < 0; });

all_of, any_of, none_of는 컨테이너 전체를 조건으로 검사할 때 for 루프와 플래그 변수를 직접 작성하는 것보다 의도를 명확히 드러냅니다. 세 함수 모두 내부적으로 range를 순회하다 조건에 어긋나는(또는 부합하는) 첫 원소에서 곧바로 순회를 멈추는 단락 평가(short-circuit)를 수행하므로, 큰 컨테이너에서도 불필요한 순회를 하지 않습니다. 이 알고리즘들은 random_access_range처럼 강한 제약을 요구하지 않고 input_range만 만족하면 되므로, 벡터·리스트뿐 아니라 스트림 기반의 커스텀 range에도 그대로 적용할 수 있다는 점도 기존 반복자 기반 구현 대비 실용적인 장점입니다.

주요 알고리즘

namespace rng = std::ranges;

// 검색
rng::find(range, value)
rng::find_if(range, pred)
rng::count(range, value)
rng::count_if(range, pred)

// 정렬
rng::sort(range)
rng::stable_sort(range)
rng::partial_sort(range, middle)

// 변환
rng::transform(range, out, func)
rng::copy(range, out)
rng::fill(range, value)

// 조건
rng::all_of(range, pred)
rng::any_of(range, pred)
rng::none_of(range, pred)

// 집계
rng::min(range)
rng::max(range)
rng::minmax(range)

여기 나열된 알고리즘은 크게 검색·정렬·변환·조건·집계 다섯 범주로 묶입니다. 눈여겨볼 점은 stable_sort와 partial_sort처럼 성능 특성이 다른 정렬 알고리즘도 동일한 range 인자 방식을 그대로 따른다는 것입니다. sort는 비교 횟수가 적지만 안정성(같은 값의 상대 순서 유지)을 보장하지 않고, stable_sort는 안정성을 보장하는 대신 내부적으로 병합 정렬 계열 알고리즘을 사용해 추가 메모리를 요구할 수 있습니다. 어떤 정렬이 필요한지는 “동률 원소의 원래 순서가 결과에 영향을 주는가”를 기준으로 판단하면 됩니다. 예를 들어 여러 정렬 기준을 단계적으로 적용해야 하는 다단계 정렬(1차 부서, 2차 이름순 등)에서는 stable_sort가 필수입니다.

투영이 실제로 해결하는 문제

struct Person {
    std::string name;
    int age;
};

std::vector<Person> people = { /* ... */ };

// 투영으로 정렬
std::ranges::sort(people, {}, &Person::age);

// 투영으로 검색
auto it = std::ranges::find(people, "Alice", &Person::name);

// 투영으로 최대값
auto oldest = std::ranges::max(people, {}, &Person::age);

투영은 비교자에 넘기기 직전에 각 원소에 적용되며, 결과로 돌아오는 것은 투영된 값이 아니라 원래 원소라는 점이 핵심입니다. 그래서 std::ranges::max(people, {}, &Person::age)는 가장 큰 나이(int)가 아니라 가장 나이가 많은 Person을 반환합니다. 투영 함수는 비교가 일어날 때마다 호출되므로, 정렬처럼 비교가 여러 번 일어나는 알고리즘에 무거운 계산(문자열 소문자 변환 등)을 투영으로 넘기면 같은 원소에 대해 여러 번 계산됩니다. 이런 경우에는 키를 한 번만 계산해 별도로 저장한 뒤 정렬하는 편이 빠릅니다.

투영은 sort뿐 아니라 find, max, min, count_if 등 비교나 검색이 필요한 거의 모든 ranges 알고리즘이 공통으로 지원합니다. std::ranges::find(people, "Alice", &Person::name)을 보면, 기존 std::find_if로는 [](const Person& p) { return p.name == "Alice"; }라는 람다를 매번 새로 작성해야 했던 반면, 투영 방식은 “어떤 필드를 볼지”(&Person::name)와 “무엇과 비교할지”("Alice")를 인자로 분리해서 넘깁니다. 이 분리는 단순히 타이핑을 줄이는 것을 넘어, 여러 알고리즘 호출에서 같은 필드를 참조할 때 필드 이름을 일관되게 강제한다는 실질적인 이점이 있습니다. 멤버 포인터는 컴파일 타임에 타입이 고정되므로, &Person::age를 &Person::Age처럼 존재하지 않는 이름으로 잘못 쓰면 람다 본문 안에서 조용히 다른 의미로 해석될 여지 없이 그 자리에서 컴파일 에러가 납니다.

자주 발생하는 문제

투영 정렬·검색·max, subrange 부분 정렬, remove 반환 구간의 실행 결과와 list 정렬·filter 뷰 정렬·임시 컨테이너 find의 컴파일 오류

그림: 실제 실행 결과 — ranges::max는 최대 나이가 아니라 해당 Person을 돌려주고, subrange로 인덱스 2~6만 정렬된다. std::list 정렬은 random_access_range 위반으로, filter 뷰 정렬은 매칭되는 sort가 없다는 오류로, 임시 vector의 find 결과는 std::ranges::dangling이라 역참조 오류로 막힌다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · -std=c++20)

문제 1: 반복자 vs 범위

std::vector<int> v = {1, 2, 3, 4, 5};

// ❌ 기존 방식
std::sort(v.begin(), v.end());

// ✅ ranges
std::ranges::sort(v);

// 더 간결하고 안전

이 문제는 코드 길이보다 “실수를 저지를 수 있는 표면적”의 문제입니다. 기존 방식에서 v.begin()과 v.end()를 각각 다른 변수 이름으로 옮겨 적다 보면, 리팩터링 과정에서 한쪽만 다른 컨테이너를 가리키게 바뀌는 사고가 생길 수 있습니다. 예를 들어 std::sort(container_a.begin(), container_b.end())처럼 서로 다른 컨테이너의 반복자를 섞어 넘기면 미정의 동작인데, 컴파일러는 이를 잡아 주지 못하고 실행 결과도 즉시 크래시가 아니라 정렬이 미묘하게 깨지거나 엉뚱한 메모리를 건드리는 형태로 나타날 수 있어 원인을 찾기 어렵습니다. ranges::sort(container_a)는 컨테이너 하나만 받으므로 이런 조합을 만들 방법 자체가 없습니다. 또 std::list처럼 random_access_range를 만족하지 않는 컨테이너를 넘기면 std::sort도 컴파일에 실패하긴 하지만 알고리즘 내부 깊은 곳의 긴 템플릿 에러로 나타나는 반면, ranges::sort는 호출 지점에서 어떤 concept이 만족되지 않았는지를 알려 줍니다.

문제 2: 투영 타입

struct Person {
    std::string name;
    int age;
};

std::vector<Person> people = { /* ... */ };

// ✅ 멤버 포인터
std::ranges::sort(people, {}, &Person::age);

// ✅ 람다
std::ranges::sort(people, {}, [](const Person& p) { return p.age; });

투영 인자는 멤버 포인터와 호출 가능한 객체(람다, 함수 포인터, 함수 객체) 모두를 받을 수 있습니다. 단순히 멤버 하나를 그대로 꺼내는 경우라면 멤버 포인터가 더 짧고 오타 위험도 적지만, 멤버 두 개를 조합한 값(예: p.first_name + p.last_name)을 기준으로 삼아야 한다면 람다가 필요합니다. 두 방식을 섞어 쓸 때 주의할 점은 투영 함수가 반환하는 타입입니다. 비교자의 기본값(std::less)은 반환 타입에 < 연산자가 정의되어 있어야 하므로, 커스텀 타입을 그대로 반환하는 투영을 쓸 때는 그 타입에 비교 연산자가 구현되어 있는지 먼저 확인해야 컴파일 에러의 원인을 헤매지 않습니다.

문제 3: 뷰 수정

std::vector<int> v = {1, 2, 3, 4, 5};

auto view = v | std::views::filter([](int x) { return x > 2; });

// ❌ filter 뷰는 random access가 아니라서 정렬 불가 (원소 값 수정 자체는 가능)
// std::ranges::sort(view);  // 에러

std::views::filter가 만든 뷰는 원본 컨테이너의 원소를 가리키는 얇은 래퍼일 뿐, 별도의 저장 공간을 갖지 않습니다. 흔히 “뷰는 읽기 전용”이라고 설명하지만 정확하지는 않습니다. for (int& x : view) x *= 10;처럼 뷰를 통해 원본 원소의 값을 바꾸는 것은 가능합니다(단, filter의 조건을 더 이상 만족하지 않게 바꾸면 이후 그 뷰를 순회하는 동작은 보장되지 않습니다). 막히는 것은 sort처럼 임의 접근이 필요한 알고리즘으로, filter 뷰는 다음 원소를 찾으려면 조건을 검사하며 앞으로 나아가야 하므로 양방향 반복자까지만 제공하고 random_access_range를 만족하지 않습니다. filter 뷰 자체를 정렬하려는 시도가 막히는 또 다른 이유는 필터링된 결과의 순서를 바꾸는 것이 “원본 컨테이너의 어느 위치를 옮길지”와 직접 연결되지 않아 의미가 모호해지기 때문입니다(필터를 통과하지 못한 원소들 사이에 필터를 통과한 원소를 끼워 넣는 것이 불가능합니다). 실제로 정렬이 필요하면 원본을 직접 정렬하거나, 뷰의 결과를 std::vector<int> output(view.begin(), view.end())처럼 별도 컨테이너로 복사한 뒤 그 컨테이너를 정렬해야 합니다. 이 제약을 모르고 뷰에 바로 변경 알고리즘을 적용하려다 concept 불일치로 인한 긴 컴파일 에러를 마주치는 경우가 많은데, 에러 메시지에 viewable_range나 permutable 같은 concept 이름이 보이면 “이 뷰가 그 자리에서 재배열 가능한 저장 공간을 갖고 있는가”를 먼저 의심하면 됩니다.

문제 4: 범위 반환

// 알고리즘마다 반환 타입이 다름
auto it = std::ranges::find_if(v, pred);          // 반복자
auto [lo, hi] = std::ranges::equal_range(v, 3);   // subrange (정렬된 v에서 3의 구간)
auto [b, e] = std::ranges::remove(v, 0);          // subrange (지울 꼬리 구간)
v.erase(b, e);

// ❌ 임시 컨테이너에 알고리즘을 쓰면 반복자 대신 std::ranges::dangling이 반환됨
// auto bad = std::ranges::find(std::vector{1, 2, 3}, 2);
// *bad;  // 컴파일 에러: dangling은 역참조할 수 없음

ranges 알고리즘의 반환 타입은 “결과가 위치 하나인가, 구간인가”에 따라 나뉩니다. find, find_if, max_element처럼 위치 하나를 찾는 알고리즘은 반복자를, search, equal_range, remove, unique처럼 구간을 알려 주는 알고리즘은 subrange를 반환하므로, 기존 코드를 옮길 때는 알고리즘별 반환 타입을 확인해야 합니다. sort처럼 원래 void를 반환하던 알고리즘도 ranges 버전에서는 끝 반복자를 반환하도록 바뀌었습니다.

더 중요한 안전장치가 std::ranges::dangling입니다. 임시 std::vector처럼 호출이 끝나면 사라지는 rvalue 컨테이너를 알고리즘에 넘기면, 반환된 반복자가 이미 파괴된 메모리를 가리키게 됩니다. 기존 std::find(tmp.begin(), tmp.end(), x) 방식에서는 이 실수가 조용한 댕글링 반복자가 되었지만, ranges 알고리즘은 이런 경우 반복자 대신 아무 연산도 할 수 없는 dangling 타입을 반환해 역참조하는 순간 컴파일 에러를 냅니다. std::string_view나 std::span처럼 원소를 소유하지 않는 range(borrowed range)는 임시 객체여도 반복자가 유효하므로 정상적인 반복자를 돌려받습니다.

문제 5: 반복자 쌍을 직접 subrange로 묶을 때

알고리즘이 돌려준 subrange를 받기만 하는 것이 아니라, 들고 있는 반복자 쌍을 직접 std::ranges::subrange로 묶어 range 알고리즘에 넘길 수도 있습니다. 컨테이너의 일부 구간만 정렬하거나, find_if가 돌려준 위치부터 끝까지를 다음 알고리즘에 넘길 때 씁니다.

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만 정렬

auto it = std::ranges::find_if(v, [](int x) { return x > 5; });
std::ranges::subrange rest{it, v.end()};  // 못 찾으면 빈 범위가 되어 이후 순회는 아무것도 안 함

subrange는 원소를 복사하지 않고 반복자만 담는 뷰라서 몇 가지 규칙이 따라옵니다. 첫째, 원본보다 오래 살면 안 되고, 지역 변수보다 더 자주 문제가 되는 것은 재할당입니다. vector로 만든 subrange를 들고 있는 동안 push_back이 용량을 넘기면 반복자가 모두 무효가 되는데, 테스트 데이터가 작을 때는 용량이 남아 멀쩡하다가 운영 데이터에서만 터집니다. 둘째, size()는 반복자 사이 거리를 O(1)에 구할 수 있는 임의 접근 반복자에서만 제공되므로 std::list로 만든 subrange에는 size()가 없습니다. 크기를 이미 안다면 std::ranges::subrange{lst.begin(), lst.end(), lst.size()}처럼 세 번째 인자로 넘겨 크기를 저장하는 subrange를 만들 수 있고(넘긴 값이 실제 거리와 다르면 미정의 동작), 아니면 O(n)인 std::ranges::distance를 씁니다. 셋째, 클래스 템플릿 인자 추론은 변수 선언에서만 동작하므로 함수 반환 타입을 std::ranges::subrange로만 적으면 컴파일되지 않습니다. 반환 타입은 auto로 두거나 std::ranges::subrange<std::vector<int>::iterator>처럼 명시합니다. 마지막으로 원소를 읽기만 하게 하려면 const subrange로 선언하는 것으로는 부족하고 v.cbegin(), v.cend() 같은 const 반복자로 만들어야 합니다.

연속 메모리 버퍼를 함수에 넘기는 용도라면 std::span이 더 맞습니다. span은 (포인터, 크기)로 표현되어 항상 size()와 인덱스 접근을 제공하고 비템플릿 함수의 매개변수로 쓰기 좋으며, subrange는 리스트처럼 비연속인 컨테이너의 구간까지 표현할 수 있다는 점이 다릅니다.

알고리즘 조합

#include <ranges>
#include <algorithm>

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 필터 -> 변환 -> 정렬
auto result = v
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * x; });

std::vector<int> output(result.begin(), result.end());
std::ranges::sort(output, std::greater{});

for (int x : output) {
    std::cout << x << " ";  // 100 64 36 16 4
}

파이프 연산자(|)로 이어진 filter와 transform은 각각 독립된 임시 컨테이너를 만들지 않고, 최종적으로 output에 복사되는 시점에야 원본 v를 한 번만 순회하면서 필터링과 변환을 동시에 적용합니다. 기존 방식으로 같은 결과를 얻으려면 std::copy_if로 짝수만 걸러 임시 벡터를 만들고, 그 벡터에 다시 std::transform을 적용하는 두 단계가 필요했을 텐데, 뷰 파이프라인은 이 중간 단계의 메모리 할당을 없애줍니다. 다만 ranges::sort(output, std::greater{})처럼 정렬은 뷰가 아니라 실체화된 컨테이너에 적용해야 한다는 점은 앞선 “문제 3”의 제약과 그대로 이어집니다. 성능 면에서는 ranges 알고리즘 자체가 기존 알고리즘보다 더 빠르거나 느리지 않습니다(둘 다 같은 하부 구현을 공유하는 경우가 대부분입니다). 이득은 실행 속도가 아니라 “잘못된 조합을 컴파일 타임에 걸러내는 안전성”과 “중간 컨테이너를 줄이는 조합 가능성”에서 나온다는 점을 기억해두면 언제 ranges를 선택해야 할지 판단하기 쉬워집니다.

참고 자료

FAQ

std::ranges::sort(v)는 되는데 std::ranges::sort(myList)는 긴 에러가 납니다.

std::list는 양방향 반복자만 제공해 random_access_range를 만족하지 않기 때문입니다. concept 덕분에 에러 메시지에 “constraints not satisfied”와 함께 어떤 concept이 실패했는지 나오므로 그 부분을 먼저 찾아 읽으면 됩니다. std::list는 멤버 함수 myList.sort()로 정렬하며, 노드를 다시 연결하는 방식이라 원소를 복사하지 않습니다.