C++20 ranges::subrange: 반복자 쌍을 범위로 묶기와 std::span과의 차이
이 글의 핵심
subrange는 원본 컨테이너의 메모리를 그대로 가리키므로 새 할당이 없지만, 그만큼 원본이 먼저 사라지면 곧바로 댕글링이 됩니다. 이 글은 반복자 종류에 따라 size()가 제공되는지 달라지는 점, 타입 추론 문제, 범위를 통해 원소를 수정할 때의 규칙을 짚고 Ranges 라이브러리 안에서 subrange의 위치를 정리합니다.
subrange란?
전통적인 C++ 컨테이너는 begin()과 end()라는 한 쌍의 반복자로 순회할 범위를 나타내지만, 함수 사이에 그 범위를 전달하려면 반복자 두 개를 각각 인자로 넘겨야 해서 번거로웠습니다. C++20의 std::ranges::subrange는 임의의 반복자 쌍 [first, last)를 하나의 객체로 묶어, std::ranges의 알고리즘이나 범위 기반 for문에 그대로 넘길 수 있는 완전한 Range로 만들어줍니다. 아래 예제처럼 벡터 전체가 아니라 v.begin() + 1부터 v.begin() + 4까지의 일부 구간만 골라 subrange로 감싸면, 원본 벡터를 복사하지 않고도 그 부분 범위만 별도의 값처럼 다룰 수 있습니다.
#include <ranges>
std::vector<int> v = {1, 2, 3, 4, 5};
// 반복자 쌍 -> subrange
std::ranges::subrange sr{v.begin() + 1, v.begin() + 4};
for (int x : sr) {
std::cout << x << " "; // 2 3 4
}
subrange가 해결하는 문제는 단순히 “인자 두 개를 하나로” 줄이는 것 이상입니다. C++20 이전에는 알고리즘이 반복자 쌍을 받고 반환은 반복자 하나로 했기 때문에, “알고리즘의 결과를 다음 알고리즘에 범위로 넘긴다”는 조합이 번거로웠습니다. Ranges 라이브러리는 모든 것을 범위 하나로 주고받도록 바꿨고, subrange는 그 세계와 기존 반복자 기반 코드를 잇는 다리입니다. subrange 자체도 std::ranges::view 개념을 만족하는 뷰이므로, 복사가 싸고 파이프라인(|)에 그대로 들어갑니다.
기본 사용
subrange를 만드는 방법은 간단합니다. 시작과 끝 반복자 두 개를 중괄호 초기화로 넘기기만 하면 되고, 전체 컨테이너를 감쌀 수도(sr1) 일부 구간만 감쌀 수도(sr2) 있습니다. 반복자가 임의 접근(random access) 반복자라면 size() 멤버 함수로 두 반복자 사이의 거리를 O(1)에 계산해 요소 개수를 바로 얻을 수 있는데, 이는 뒤에서 다룰 리스트 같은 비연속 컨테이너의 반복자와 다른 점입니다.
subrange는 두 번째 템플릿 인자로 센티널(sentinel) 타입을 따로 받을 수 있습니다. 즉 끝을 나타내는 값이 시작 반복자와 같은 타입일 필요가 없습니다. 예를 들어 널 종료 문자열처럼 “끝이 어디인지는 조건으로만 알 수 있는” 범위를 std::ranges::subrange{p, std::unreachable_sentinel}처럼 표현하고, 조건을 만족하는 지점에서 알고리즘이 멈추게 할 수 있습니다. 또 subrange는 구조화된 바인딩을 지원해 auto [b, e] = sr;로 반복자 쌍을 다시 꺼낼 수 있어, 반복자를 받는 레거시 API에 넘기기도 쉽습니다.
#include <ranges>
#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5};
// 생성
std::ranges::subrange sr1{v.begin(), v.end()};
std::ranges::subrange sr2{v.begin() + 1, v.begin() + 4};
// 크기
std::cout << sr2.size() << std::endl; // 3
실전 예시
subrange가 실무에서 어떤 문제를 풀어주는지 네 가지 대표적인 사용 패턴으로 확인해 보겠습니다. 범위를 둘로 나누기, 알고리즘 결과를 다시 Range로 만들기, 뷰와 결합하기, 청크 단위로 순회하기입니다.
범위 분할
하나의 컨테이너를 논리적으로 두 부분으로 나눠 각각 독립적으로 다루고 싶을 때, subrange는 데이터를 복사하지 않고도 이를 표현할 수 있습니다. 아래 예제는 벡터의 중간 지점을 기준으로 first와 second라는 두 개의 subrange를 만드는데, 두 범위 모두 원본 벡터 v의 메모리를 그대로 가리키므로 새로운 메모리 할당 없이 “앞쪽 절반”과 “뒤쪽 절반”이라는 개념을 코드로 표현할 수 있습니다. 이런 분할 패턴은 병합 정렬처럼 범위를 재귀적으로 나눠 처리하는 알고리즘을 구현할 때 특히 유용합니다.
#include <ranges>
#include <algorithm>
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 중간 지점
auto mid = v.begin() + v.size() / 2;
// 두 부분으로 분할
std::ranges::subrange first{v.begin(), mid};
std::ranges::subrange second{mid, v.end()};
std::cout << "첫 번째: ";
for (int x : first) {
std::cout << x << " "; // 1 2 3 4 5
}
std::cout << "\n두 번째: ";
for (int x : second) {
std::cout << x << " "; // 6 7 8 9 10
}
알고리즘 결과
std::ranges::find_if 같은 검색 알고리즘은 조건을 만족하는 위치를 가리키는 반복자 하나만 반환합니다. 하지만 “그 위치부터 끝까지의 나머지 부분”을 이어서 처리하고 싶은 경우가 많은데, 이때 반환된 반복자와 v.end()를 묶어 subrange로 만들면 그 즉시 또 다른 Range 알고리즘에 넘길 수 있는 값이 됩니다. 아래 예제는 5보다 큰 첫 값을 찾은 뒤 그 지점부터 끝까지를 result라는 subrange로 감싸, 검색 결과를 다시 순회 가능한 범위로 자연스럽게 이어붙이는 패턴을 보여줍니다. 조건을 만족하는 값이 없으면 it이 v.end()가 되어 빈 subrange가 만들어지므로, 따로 예외 처리를 하지 않아도 뒤의 순회는 안전하게 아무것도 하지 않습니다.
#include <ranges>
#include <algorithm>
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// find_if는 반복자를 반환 — 끝까지의 부분 범위는 subrange로 감싼다
auto it = std::ranges::find_if(v, [](int x) { return x > 5; });
std::ranges::subrange result{it, v.end()};
std::cout << "찾은 값부터: ";
for (int x : result) {
std::cout << x << " "; // 6 7 8 9 10
}
일부 Ranges 알고리즘은 처음부터 subrange를 반환합니다. std::ranges::search는 찾은 구간을, std::ranges::equal_range는 같은 값의 구간을, C++20의 std::ranges::remove와 unique는 “지워야 할 꼬리” 구간을 subrange로 돌려줍니다. 특히 remove는 erase-remove 관용구를 다음처럼 바꿉니다.
auto [first, last] = std::ranges::remove(v, 3); // 3이 아닌 원소를 앞으로 모으고 꼬리 구간 반환
v.erase(first, last); // C++20: std::erase(v, 3) 한 줄로도 가능
반복자 기반 std::remove는 “새 끝” 반복자 하나만 돌려줬기 때문에 v.erase(it, v.end())에서 v.end()를 다시 써야 했는데, subrange를 반환하면 지울 구간이 한 번에 전달됩니다.
뷰 생성
subrange는 완전한 Range이기 때문에 std::views 어댑터와 파이프 연산자(|)로 자연스럽게 연결할 수 있습니다. 아래 예제는 벡터의 일부 구간을 subrange로 자른 뒤 std::views::transform을 붙여 각 요소를 2배로 만드는 지연 평가(lazy evaluation) 뷰를 만드는데, doubled를 실제로 순회하기 전까지는 어떤 연산도 수행되지 않고 for 루프가 각 요소에 접근하는 순간에만 변환이 일어납니다. 이렇게 슬라이스와 지연 변환을 조합하는 패턴은 원본 데이터를 복사하거나 즉시 계산하지 않고도 파이프라인을 구성할 수 있게 해줍니다.
#include <ranges>
std::vector<int> v = {1, 2, 3, 4, 5};
// subrange -> 뷰
std::ranges::subrange sr{v.begin() + 1, v.begin() + 4};
auto doubled = sr | std::views::transform([](int x) { return x * 2; });
for (int x : doubled) {
std::cout << x << " "; // 4 6 8
}
범위 순회
큰 데이터를 일정한 크기의 묶음(청크)으로 나누어 처리해야 할 때도 subrange가 유용합니다. 아래 예제는 반복문을 돌며 chunkSize만큼씩 앞으로 이동하는 시작·끝 반복자로 매번 새로운 subrange를 만들어, 마지막 청크처럼 정확히 나누어떨어지지 않는 경우까지 std::min으로 안전하게 처리합니다. 이런 청크 처리 패턴은 대용량 로그 파일을 일정 크기씩 나눠 병렬로 처리하거나, 네트워크 전송 시 데이터를 정해진 크기의 패킷으로 나눌 때 실무에서 흔히 쓰입니다. C++23을 쓸 수 있다면 v | std::views::chunk(3)이 이 루프를 그대로 대신하며, 마지막 짧은 청크 처리도 알아서 해 줍니다. 수동 루프에서는 i + chunkSize를 먼저 계산한 뒤 min을 취해야 하는데, 반대로 v.begin() + i + chunkSize를 먼저 만들면 끝을 넘는 반복자를 만드는 것만으로도 미정의 동작이므로 순서를 지켜야 합니다.
#include <ranges>
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 청크로 분할
constexpr size_t chunkSize = 3;
for (size_t i = 0; i < v.size(); i += chunkSize) {
auto end = std::min(i + chunkSize, v.size());
std::ranges::subrange chunk{v.begin() + i, v.begin() + end};
std::cout << "청크: ";
for (int x : chunk) {
std::cout << x << " ";
}
std::cout << std::endl;
}
// 청크: 1 2 3
// 청크: 4 5 6
// 청크: 7 8 9
// 청크: 10
subrange 속성
subrange는 컨테이너와 비슷한 인터페이스를 흉내 내는 여러 멤버 함수를 제공해, 알고리즘이나 반복문 없이도 범위의 상태를 바로 확인할 수 있게 해줍니다. begin()/end()로 반복자를 다시 꺼내거나, empty()로 범위가 비어 있는지 확인하거나, front()/back()으로 첫 요소와 마지막 요소에 바로 접근하는 식입니다. 다만 size()와 back()은 반복자의 종류에 따라 사용할 수 없는 경우가 있는데, 이 제약은 다음 절의 “자주 발생하는 문제”에서 자세히 다룹니다.
std::ranges::subrange sr{v.begin(), v.end()};
// 반복자
auto begin = sr.begin();
auto end = sr.end();
// 크기 (가능한 경우)
auto size = sr.size();
// 비어있는지
bool empty = sr.empty();
// 앞 요소
auto& front = sr.front();
// 뒤 요소 (양방향 범위)
auto& back = sr.back();
자주 발생하는 문제
subrange는 데이터를 소유하지 않고 원본을 참조만 하는 뷰(view)라는 성격 때문에, 편리한 만큼 몇 가지 함정도 함께 따라옵니다. 수명 관리, 반복자 종류에 따른 기능 제약, 타입 표기, 수정 가능 여부를 순서대로 살펴봅니다.
수명
subrange는 반복자만 담고 있을 뿐 가리키는 데이터를 직접 소유하지 않으므로, 원본 컨테이너보다 오래 살아남으면 안 됩니다. 아래 getSubrange()는 함수 내부의 지역 벡터 v에 대한 subrange를 반환하는데, 함수가 끝나는 순간 v가 소멸되어 반환된 subrange는 이미 사라진 메모리를 가리키는 댕글링 상태가 됩니다. 이런 실수를 막으려면 getSubrange(std::vector<int>& v)처럼 컨테이너를 매개변수로 받아 호출자가 소유한 컨테이너를 참조하도록 함수 시그니처를 바꿔야 합니다.
// ❌ 댕글링
auto getSubrange() {
std::vector<int> v = {1, 2, 3};
return std::ranges::subrange{v.begin(), v.end()};
// v 소멸
}
// ✅ 참조 명확화
auto getSubrange(std::vector<int>& v) {
return std::ranges::subrange{v.begin(), v.end()};
}
원래 예제는 반환 타입을 std::ranges::subrange로만 적었는데, 클래스 템플릿 인자 추론(CTAD)은 변수 선언에서만 동작하고 함수 반환 타입에는 쓸 수 없어 컴파일 오류가 납니다. 반환 타입은 auto로 두거나 std::ranges::subrange<std::vector<int>::iterator>처럼 명시해야 합니다.
댕글링은 지역 변수보다 재할당으로 더 자주 생깁니다. vector로 만든 subrange를 들고 있는 동안 v.push_back()이 용량을 넘기면 원소가 새 메모리로 옮겨져 subrange의 반복자가 모두 무효가 됩니다. 테스트 데이터가 작을 때는 용량이 남아 멀쩡하다가 운영 데이터에서만 터지는 전형적인 버그라, subrange는 “만들고 바로 쓰고 버리는” 짧은 수명으로 쓰는 것이 안전합니다.
크기
size()가 O(1)에 동작하려면 두 반복자 사이의 거리를 뺄셈 한 번으로 계산할 수 있어야 하는데, 이는 임의 접근 반복자에서만 가능합니다. std::list처럼 양방향 반복자만 제공하는 컨테이너의 subrange에서는 size() 멤버 함수 자체가 존재하지 않아 컴파일 에러가 발생합니다. 이런 경우에는 std::ranges::distance(sr)를 사용해야 하는데, 이 함수는 내부적으로 begin부터 end까지 반복자를 하나씩 증가시키며 세는 O(n) 연산이라는 점을 감안해서 성능이 중요한 코드에서는 호출 빈도를 신경 써야 합니다.
크기를 이미 알고 있다면 세 번째 인자로 넘겨 크기를 저장하는 subrange를 만들 수 있습니다. std::ranges::subrange sr{lst.begin(), lst.end(), lst.size()};로 만들면 subrange_kind::sized가 되어 size()가 O(1)로 제공됩니다. 이때 넘긴 크기가 실제 거리와 다르면 미정의 동작이므로, 컨테이너의 size()처럼 확실한 값만 넘겨야 합니다.
// 크기를 알 수 없는 범위
std::list<int> lst = {1, 2, 3};
std::ranges::subrange sr{lst.begin(), lst.end()};
// ❌ size() 없음 (양방향 반복자)
// auto size = sr.size(); // 에러
// ✅ distance
auto size = std::ranges::distance(sr);
타입 추론
std::ranges::subrange는 클래스 템플릿이라 정식으로는 std::ranges::subrange<std::vector<int>::iterator>처럼 반복자 타입을 템플릿 인자로 명시해야 하지만, 매번 이렇게 길게 쓰는 것은 비현실적입니다. C++17부터 도입된 클래스 템플릿 인자 추론(CTAD) 덕분에 생성자에 넘긴 반복자로부터 타입을 자동으로 추론할 수 있어, 실무에서는 거의 항상 auto sr = std::ranges::subrange{v.begin(), v.end()};처럼 auto와 함께 사용합니다. 함수 시그니처에 반복자 타입을 명시해야 하는 특수한 경우가 아니라면 굳이 템플릿 인자를 직접 적을 필요는 없습니다.
std::vector<int> v = {1, 2, 3};
// ✅ 명시적 타입
std::ranges::subrange<std::vector<int>::iterator> sr{v.begin(), v.end()};
// ✅ auto
auto sr = std::ranges::subrange{v.begin(), v.end()};
범위 수정
subrange는 읽기 전용 뷰가 아니라, 담고 있는 반복자가 가리키는 원본 데이터에 그대로 접근하는 통로입니다. 그래서 아래 예제처럼 for (int& x : sr)로 레퍼런스를 받아 값을 바꾸면, 그 변경은 subrange가 아니라 원본 벡터 v에 직접 반영됩니다. 이 성질은 “일부 구간만 골라서 수정하고 싶다”는 요구를 깔끔하게 해결해주지만, 반대로 원본을 건드리면 안 되는 상황이라면 const 반복자로 subrange를 만들거나 값 복사가 필요하다는 점을 분명히 인식하고 있어야 합니다.
std::vector<int> v = {1, 2, 3, 4, 5};
std::ranges::subrange sr{v.begin() + 1, v.begin() + 4};
// subrange 통해 수정 가능
for (int& x : sr) {
x *= 2;
}
// v: {1, 4, 6, 8, 5}
활용 패턴
지금까지 다룬 예시들을 하나로 압축하면, subrange가 실무에서 반복적으로 쓰이는 네 가지 패턴으로 정리됩니다. 범위 분할, 알고리즘 결과의 재활용, 뷰 파이프라인 구성, 청크 단위 반복 처리입니다. 새로운 코드에서 반복자 쌍을 다뤄야 하는 상황이 생기면 아래 목록에서 비슷한 패턴을 먼저 떠올려보는 것이 설계 시간을 줄이는 데 도움이 됩니다.
// 1. 범위 분할
std::ranges::subrange first{v.begin(), mid};
std::ranges::subrange second{mid, v.end()};
// 2. 알고리즘 결과
auto result = std::ranges::find_if(v, pred);
// 3. 뷰 생성
auto view = sr | std::views::transform(func);
// 4. 청크 처리
for (size_t i = 0; i < v.size(); i += chunkSize) {
std::ranges::subrange chunk{...};
}
C++20 Ranges 라이브러리와의 위치
C++20에서 <ranges>는 Range 개념(begin/end로 순회 가능한 타입)을 표준화하며, 알고리즘은 std::ranges:: 네임스페이스에 Range 버전으로 정리되었습니다. std::ranges::subrange는 그중에서도 “반복자 쌍 [first, last)를 하나의 Range로 포장한 어댑터” 역할을 합니다.
subrange도 뷰(view)의 일종입니다.views::take/filter같은 어댑터가 다른 범위를 감싸 지연 연산을 덧붙인다면,subrange는 연산 없이 이미 존재하는 시퀀스의 슬라이스만 표현한다는 점이 다릅니다. 그래서views::drop(n) | views::take(m)으로도 같은 구간을 만들 수 있지만, 이미 반복자를 들고 있다면subrange가 더 직접적입니다.subrange는 borrowed range입니다. 즉subrange객체가 사라져도 그 반복자는 원본을 가리키므로 유효합니다. 반면 Ranges 알고리즘에 임시vector같은 소유 범위를 넘기면(std::ranges::find(make_vector(), 3)), 반환된 반복자가 곧 댕글링이 될 것이므로 라이브러리는 반복자 대신std::ranges::dangling이라는 빈 타입을 반환합니다. 이 값을 역참조하려 하면 컴파일 오류가 나서 실수를 컴파일 타임에 잡아 줍니다. 임시 범위의 일부를 계속 쓰고 싶다면 먼저 지역 변수에 담아야 합니다.
#include <ranges>
#include <vector>
#include <algorithm>
void sort_tail(std::vector<int>& v, std::size_t n) {
if (n >= v.size()) return;
auto first = v.begin() + static_cast<std::ptrdiff_t>(v.size() - n);
std::ranges::subrange tail{first, v.end()};
std::ranges::sort(tail);
}
subrange와 std::span 비교
| 관점 | std::ranges::subrange | std::span (C++20) |
|---|---|---|
| 표현 | 반복자 쌍 [begin, end) | 연속 요소 블록 (data, size) 또는 배열 참조 |
| 컨테이너 | 임의 시퀀스(리스트 등) 슬라이스에도 적합 | 연속 메모리(contiguous_range)에 최적화 |
| 알고리즘 | Range 알고리즘과 조합 | 주로 인덱스/버퍼 처리, 저수준 API |
| 크기 | 반복자 종류에 따라 size()가 없을 수 있음 | size() 항상(고정/동적 span) |
문자열 파싱 버퍼, 고정 크기 윈도우, C API와의 경계에서는 span이 자연스럽으며, 중간부터 끝까지 잘라 sort/find 등에 넘길 때는 subrange가 자연스럽습니다. 둘 다 비소유(non-owning) 이므로 원본 수명은 동일하게 관리해야 합니다.
#include <span>
#include <vector>
void fill_span(std::span<int> s); // 연속 버퍼
void by_iterators(std::vector<int>& v) {
std::ranges::subrange part{v.begin() + 1, v.end()};
for (int x : part) { (void)x; }
}
실전 활용 보강
- 병렬 파이프라인: 큰
vector를 구간으로 나눠subrange단위로 워커에 넘기면, 데이터 복사 없이 부분 구간만 처리하게 할 수 있습니다(단, 동시 쓰기는 동기화 필요). - 파싱/토큰 버퍼: 원본 문자열은 그대로 두고
[it, end)만 넘겨 단계별 파서를 작성하면,string_view와 조합해 할당을 줄일 수 있습니다. - Range 파이프:
subrange를 만든 뒤views::transform/filter를 붙이면, 슬라이스 + 지연 변환을 한 흐름으로 표현할 수 있습니다.
성능 관점
- 복사 비용:
subrange객체 자체는 반복자 두 개(및 필요 시 크기/종류 정보) 수준이라 가볍습니다. 비용의 대부분은 참조하는 시퀀스를 순회하는 알고리즘 쪽입니다. - 캐시:
subrange가 연속 메모리를 가리키면span과 유사하게 캐시 친화적이지만, 리스트 등 비연속 컨테이너면 반복자 순회 비용은 그대로입니다. - 중간 객체: 파이프라인을 길게 이으면 컴파일러는 잘 최적화하지만, 디버그 빌드에서는 추상화 계층이 늘어날 수 있으므로, 핫 루프는 프로파일 후 단순 반복문과 비교하는 것이 좋습니다.
FAQ
Q1: 함수 매개변수로 subrange와 템플릿 range 중 무엇을 받아야 하나요?
A: 특정 컨테이너의 반복자 타입에 묶여도 된다면 std::ranges::subrange<It>를 받아도 되지만, 대부분은 template <std::ranges::input_range R> void f(R&& r)처럼 범위 개념으로 제약한 템플릿이 더 유연합니다. 호출자는 컨테이너, subrange, 뷰 파이프라인을 모두 그대로 넘길 수 있습니다. 비템플릿 함수가 필요하고 데이터가 연속 메모리라면 std::span이 좋은 대안입니다.
Q2: const 원소만 보는 subrange는 어떻게 만드나요?
A: const 반복자로 만들면 됩니다. std::ranges::subrange sr{v.cbegin() + 1, v.cend()};처럼 만들면 원소가 const int&로 노출되어 for (int& x : sr)는 컴파일되지 않습니다. const subrange로 선언하는 것만으로는 원소 수정이 막히지 않는다는 점에 주의하세요(반복자 자체의 const성과 원소의 const성은 별개입니다).
Q3: subrange와 views::counted는 어떻게 다른가요?
A: std::views::counted(it, n)은 시작 반복자와 개수로 범위를 만들고, 임의 접근 반복자면 내부적으로 subrange(연속이면 span)를 반환합니다. 끝 반복자 대신 개수를 알고 있을 때 편리합니다.
같이 보면 좋은 글
- C++20 ranges의 개념 계층: range 요건, input·forward·random_access_range, CPO 설계
- C++20 std::views: filter·transform·take 지연 평가와 ranges::to로 구체화하기
- C++ sregex_iterator·sregex_token_iterator: 모든 매치 순회와 문자열 분할
- C++20 Ranges | begin/end 반복 탈출하고 ranges 알고리즘 쓰기
- C++ Ranges Views와 파이프라인 | 지연 연산으로 효율적으로 다루기 [#25-2]
- C++ 커스텀 Range 작성 | range 개념을 만족하는 타입 만들기 [#25-3]