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으로 잘못 고쳐 정렬 기준이 슬쩍 바뀌는 버그가 생긴 적이 있습니다. 컴파일은 당연히 통과했고, 코드 리뷰에서도 람다 본문을 한 줄씩 세밀하게 비교하지 않으면 눈에 띄지 않는 실수였습니다. &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처럼 존재하지 않는 이름으로 잘못 쓰면 람다 본문 안에서 조용히 다른 의미로 해석될 여지 없이 그 자리에서 컴파일 에러가 납니다.

자주 발생하는 문제

문제 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)로 정리하고 나니, 애초에 두 개의 반복자를 따로 들고 다닐 필요가 없어 같은 유형의 실수가 재발할 여지 자체가 사라졌습니다. 컨테이너 타입이 random_access_range 개념을 만족하지 않으면 ranges::sort 호출 자체가 컴파일되지 않으므로, “이 컨테이너에 정렬을 적용해도 되는가”라는 질문에 대한 답을 런타임이 아니라 컴파일 타임에 받는다는 차이도 큽니다.

문제 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)는 임시 객체여도 반복자가 유효하므로 정상적인 반복자를 돌려받습니다.

알고리즘 조합

#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

Q1: Range Algorithms는 정확히 무엇인가요?

A: C++20에서 <algorithm>의 기존 알고리즘들을 std::ranges 네임스페이스 아래 다시 구현한 것입니다. 반복자 쌍 대신 range 하나를 받고, concept 기반으로 인자 타입을 제약하며, search·equal_range·remove처럼 구간을 알려 주는 알고리즘은 subrange를 반환합니다.

Q2: 기존 알고리즘과의 실질적인 차이는?

A:

  • 반복자 쌍이 아닌 range를 직접 전달해 컨테이너 불일치 실수를 컴파일 타임에 방지
  • projection 인자로 비교·검색 기준을 별도 함수 없이 지정
  • 반복자와 끝을 다른 타입(sentinel)으로 둘 수 있어 무한 range나 조건부 끝을 가진 range도 지원
  • 임시 컨테이너에 쓰면 std::ranges::dangling을 반환해 댕글링 반복자를 컴파일 시점에 차단
  • concept 제약으로 잘못된 타입 사용 시 더 명확한 컴파일 에러 메시지 제공

Q3: 투영은 언제 써야 하나요?

A: 구조체나 클래스의 특정 멤버(또는 계산된 값) 기준으로 비교·검색·정렬해야 할 때 씁니다. 매번 같은 멤버를 꺼내는 람다를 반복해서 작성하는 대신, 멤버 포인터 하나로 대체해 코드 중복과 필드 이름 오타로 인한 버그를 줄일 수 있습니다.

Q4: 뷰(view)는 수정할 수 없나요?

A: 뷰를 통해 원본 원소의 값을 바꾸는 것은 가능하지만, 뷰는 별도의 저장 공간이 없는 얇은 래퍼라서 할 수 있는 연산이 뷰의 반복자 종류에 따라 제한됩니다. 특히 filter 뷰는 조건을 만족하지 않는 원소를 건너뛰는 방식이라 임의 접근이 불가능하므로 sort 같은 알고리즘을 쓸 수 없고, 원소를 조건에 맞지 않게 바꾸는 수정도 피해야 합니다. 재배열이 필요하면 원본을 정렬하거나 뷰 결과를 별도 컨테이너로 복사해야 합니다.

Q5: 성능은 기존 알고리즘 대비 어떤가요?

A: 대부분의 구현체에서 ranges 알고리즘과 기존 알고리즘은 같은 하부 로직을 공유하므로 실행 속도 차이는 거의 없습니다. ranges의 이점은 속도가 아니라 컴파일 타임 안전성과, 뷰 파이프라인을 통한 중간 컨테이너 할당 감소에 있습니다.

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

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

같이 보면 좋은 글