C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
이 글은 C++ auto와 decltype에 이어지는 내용입니다.
들어가며: 반복자 코드가 너무 길다
C++11 이전에는 컨테이너를 순회할 때마다 반복자 타입을 길게 적어야 했습니다. 범위 기반 for(range-based for)는 for (auto& x : container) 형태로 컨테이너 전체를 순회하는 문법으로, 시작과 끝을 컴파일러가 처리하므로 끝 조건을 잘못 쓰거나 반복자 타입을 틀리는 실수를 줄여 줍니다. C++17의 구조화된 바인딩(structured binding)은 auto [key, value]처럼 pair, tuple, 구조체를 여러 변수로 풀어 받는 문법인데, 둘을 함께 쓰면 map 순회가 한 줄로 읽기 쉬워집니다.
예전 방식은 다음과 같습니다.
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (std::vector<int>::iterator it = numbers.begin();
it != numbers.end(); ++it) {
std::cout << *it << "\n";
}
// map은 더 장황함
std::map<std::string, int> scores;
for (std::map<std::string, int>::iterator it = scores.begin();
it != scores.end(); ++it) {
std::cout << it->first << ": " << it->second << "\n";
}
범위 기반 for와 구조화된 바인딩으로 바꾸면 이렇게 됩니다.
// g++ -std=c++17 -o range_for range_for.cpp && ./range_for
#include <iostream>
#include <vector>
#include <map>
#include <string>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int num : numbers) {
std::cout << num << "\n";
}
std::map<std::string, int> scores = {{"alice", 90}, {"bob", 80}};
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << "\n";
}
return 0;
}
1부터 5까지 한 줄씩 출력된 뒤 alice: 90, bob: 80이 출력됩니다. for (int num : numbers)는 요소를 int로 복사하는데, int처럼 작은 타입은 문제가 없지만 큰 타입이라면 const auto&로 받는 것이 좋습니다.
범위 기반 for는 전체 구간을 처음부터 끝까지 순회할 때를 위한 문법입니다. 반복 중에 요소를 삭제하거나 위치를 건너뛰어야 한다면 반복자를 직접 다루는 일반 for문이나 erase-remove 관용구 같은 알고리즘을 써야 합니다.
범위 기반 for가 줄여 주는 실수
반복자 타입 실수
std::vector<int> vec = {1, 2, 3};
// const_iterator로 선언해 놓고 수정하려 함
for (std::vector<int>::const_iterator it = vec.begin();
it != vec.end(); ++it) {
*it = 10; // 컴파일 에러: const_iterator로는 수정 불가
}
// 범위 기반 for + auto&: 수정 의도가 선언에 드러남
for (auto& num : vec) {
num = 10;
}
map 순회 시 first/second 반복
std::map<std::string, int> config;
for (const auto& entry : config) {
if (entry.first == "timeout") {
int val = entry.second;
// ...
}
}
// 구조화된 바인딩: 이름이 의미를 설명함
for (const auto& [key, value] : config) {
if (key == "timeout") {
int val = value;
// ...
}
}
불필요한 복사
std::vector<std::string> logs(100000);
// 매 반복마다 std::string 복사 (긴 문자열이면 힙 할당까지)
for (std::string log : logs) {
process(log);
}
// const 참조: 복사 없음
for (const auto& log : logs) {
process(log);
}
기본 문법과 지원 컨테이너
기본 문법
for (int num : vec)는 “vec의 각 요소를 num에 복사해서 루프를 돈다”는 뜻입니다. 반복자를 직접 쓰지 않아도 되고, 선언부에는 요소를 어떻게 받을지만 적습니다.
std::vector<int> vec = {1, 2, 3, 4, 5};
for (int num : vec) {
std::cout << num << " ";
}
// 1 2 3 4 5
표준은 범위 기반 for를 대략 다음 코드와 같은 의미로 정의합니다(C++17 기준).
{
auto&& __range = vec; // 범위식을 한 번만 평가
auto __begin = __range.begin();
auto __end = __range.end(); // end도 루프 진입 전에 한 번만 계산
for (; __begin != __end; ++__begin) {
int num = *__begin;
std::cout << num << " ";
}
}
범위식과 begin/end가 루프 진입 전에 한 번만 평가된다는 점이 중요합니다. 루프 안에서 push_back이나 erase로 컨테이너를 바꾸면 미리 구해 둔 __begin과 __end가 무효화될 수 있으므로, 순회 중에는 컨테이너의 구조를 바꾸면 안 됩니다. __range가 auto&&로 선언되어 있다는 점은 뒤에서 볼 임시 객체 수명 규칙과도 연결됩니다.
컨테이너별 사용 예
// vector
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
for (const std::string& name : names) {
std::cout << name << "\n";
}
// 배열
int arr[] = {1, 2, 3, 4, 5};
for (int num : arr) {
std::cout << num << " ";
}
// set
std::set<int> numbers = {5, 2, 8, 1};
for (int num : numbers) {
std::cout << num << " "; // 1 2 5 8 (정렬됨)
}
// map
std::map<std::string, int> ages = {{"Alice", 25}, {"Bob", 30}};
for (const auto& pair : ages) {
std::cout << pair.first << ": " << pair.second << "\n";
}
문자열 요소는 const std::string&로 받아 복사를 피하고, const로 실수로 원본을 바꾸는 일을 막습니다. int 같은 기본 타입은 복사 비용이 참조와 거의 같아 값으로 받아도 됩니다. 배열은 컴파일러가 크기를 알고 있어서 그대로 순회할 수 있습니다. std::set은 정렬된 상태를 유지하므로 {5, 2, 8, 1}로 만들어도 1 2 5 8 순서로 순회하고, std::map도 키 기준으로 정렬되어 순회합니다. map의 요소 타입은 std::pair<const Key, Value>라서 pair.first가 키, pair.second가 값입니다.
초기화 리스트와 문자열 리터럴
// 중괄호 목록
for (int num : {1, 2, 3, 4, 5}) {
std::cout << num << " ";
}
// 문자열 리터럴
for (char c : "Hello") {
std::cout << static_cast<int>(c) << " ";
}
// 72 101 108 108 111 0 (마지막 널 문자 포함)
{1, 2, 3, 4, 5}는 std::initializer_list<int>로 추론되고, 루프가 끝날 때까지 유지됩니다. 문자열 리터럴 "Hello"는 const char[6] 타입의 배열이라 마지막 '\0'까지 6개 요소를 순회합니다. 널 문자를 빼려면 std::string이나 std::string_view로 감싸서 순회합니다.
map, tuple, 구조체를 순회하는 실행 예제
예제 1: 값, const 참조, 수정 가능 참조
// g++ -std=c++17 -o range_for_basic range_for_basic.cpp && ./range_for_basic
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> fruits = {"apple", "banana", "cherry"};
std::cout << "=== 값으로 순회 (복사) ===\n";
for (std::string fruit : fruits) {
std::cout << fruit << " ";
}
std::cout << "\n";
std::cout << "=== const 참조로 순회 ===\n";
for (const auto& fruit : fruits) {
std::cout << fruit << " ";
}
std::cout << "\n";
std::cout << "=== 수정 가능 참조 ===\n";
std::vector<int> nums = {1, 2, 3};
for (auto& n : nums) {
n *= 2;
}
for (int n : nums) {
std::cout << n << " "; // 2 4 6
}
std::cout << "\n";
return 0;
}
예제 2: map과 구조화된 바인딩
// g++ -std=c++17 -o range_for_map range_for_map.cpp && ./range_for_map
#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> inventory = {
{"sword", 3},
{"shield", 5},
{"potion", 12}
};
for (const auto& [item, count] : inventory) {
std::cout << item << ": " << count << "개\n";
}
// 키를 쓰지 않을 때 (C++26 이전에는 _도 일반 변수 이름)
for (const auto& [_, count] : inventory) {
std::cout << count << " ";
}
std::cout << "\n";
return 0;
}
map은 키 순으로 정렬되므로 potion, shield, sword 순서로 출력됩니다. C++26 이전에는 _가 특별한 의미 없는 일반 이름이라, 컴파일러에 따라 사용하지 않는 변수 경고가 나올 수 있습니다.
참조와 const: 무엇으로 받을까
복사 vs 참조
for (std::string name : names)처럼 값으로 받으면 매 반복마다 std::string이 복사됩니다. 읽기만 할 때는 const std::string&로, 수정이 필요할 때는 std::string&로 받습니다.
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
// 매 반복마다 복사
for (std::string name : names) {
std::cout << name << "\n";
}
// 참조: 복사 없음
for (const std::string& name : names) {
std::cout << name << "\n";
}
요소 타입이 클수록 차이가 커집니다. 아래처럼 int 1000개(보통 4000바이트)를 가진 구조체 10000개를 값으로 순회하면 루프 한 번에 약 40MB를 복사하는 셈이지만, 참조로 받으면 요소 하나당 주소만 다룹니다. 범위 기반 for를 쓸 때는 요소를 복사할 필요가 있는지 먼저 생각하고, 필요 없으면 const auto&나 auto&를 씁니다.
struct LargeObject {
int data[1000];
};
std::vector<LargeObject> objects(10000);
for (LargeObject obj : objects) {
// 요소마다 LargeObject 전체 복사
}
for (const LargeObject& obj : objects) {
// 복사 없음
}
수정 가능한 참조
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (int& num : numbers) {
num *= 2;
}
// numbers: {2, 4, 6, 8, 10}
for (const int& num : numbers) {
// num *= 2; // 컴파일 에러: const 참조
std::cout << num << " ";
}
auto와 함께 쓰기
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}};
for (auto pair : scores) { // pair<const string, int> 복사
std::cout << pair.first << "\n";
}
for (const auto& pair : scores) { // 복사 없음
std::cout << pair.first << "\n";
}
std::vector<int> vec = {1, 2, 3};
for (auto& num : vec) { // 수정 가능
num *= 2;
}
map을 순회할 때 const std::pair<std::string, int>&처럼 키의 const를 빼고 타입을 직접 적으면, 요소 타입(pair<const std::string, int>)과 달라서 매 반복마다 변환된 임시 pair가 만들어집니다. 참조로 받는다고 생각했는데 실제로는 복사가 일어나는 흔한 실수이므로, 타입을 직접 쓰기보다 const auto&를 쓰는 편이 안전합니다.
구조화된 바인딩 (C++17)
pair 분해
std::map<std::string, int> scores = {
{"Alice", 95},
{"Bob", 87},
{"Charlie", 92}
};
// 이전 방식
for (const auto& pair : scores) {
std::cout << pair.first << ": " << pair.second << "\n";
}
// C++17 구조화된 바인딩
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << "\n";
}
tuple 분해
std::vector<std::tuple<std::string, int, double>> students = {
{"Alice", 25, 95.5},
{"Bob", 23, 87.3},
{"Charlie", 24, 92.1}
};
for (const auto& [name, age, score] : students) {
std::cout << name << " (" << age << "): " << score << "\n";
}
구조체 분해
public 비정적 데이터 멤버만 가진 구조체는 멤버 선언 순서대로 분해됩니다.
struct Point {
int x;
int y;
};
std::vector<Point> points = {{1, 2}, {3, 4}, {5, 6}};
for (const auto& [x, y] : points) {
std::cout << "(" << x << ", " << y << ")\n";
}
배열 분해
int arr[][2] = {{1, 2}, {3, 4}, {5, 6}};
for (const auto& [a, b] : arr) {
std::cout << a << ", " << b << "\n";
}
범위 기반 for가 요구하는 것
범위식의 타입에 따라 __begin과 __end를 구하는 방법이 정해집니다. 배열이면 __range와 __range + 크기를 쓰고, 클래스 타입에 begin이나 end라는 이름의 멤버가 있으면 멤버 함수 __range.begin()/__range.end()를, 둘 다 아니면 인자 의존 조회(ADL)로 찾은 자유 함수 begin(__range)/end(__range)를 사용합니다. std::initializer_list는 begin()/end() 멤버를 제공합니다.
방법 1: begin/end 멤버 함수
class MyContainer {
std::vector<int> data;
public:
auto begin() { return data.begin(); }
auto end() { return data.end(); }
auto begin() const { return data.begin(); }
auto end() const { return data.end(); }
};
MyContainer c;
for (int x : c) { /* ... */ }
const 버전을 함께 두어야 const MyContainer&로 받은 객체도 순회할 수 있습니다.
방법 2: begin/end 자유 함수
struct CustomRange {
int* ptr;
size_t len;
};
// CustomRange와 같은 네임스페이스에 두어야 ADL로 찾힘
int* begin(CustomRange& r) { return r.ptr; }
int* end(CustomRange& r) { return r.ptr + r.len; }
int arr[] = {1, 2, 3};
CustomRange r{arr, 3};
for (int x : r) { /* ... */ }
반복자가 갖춰야 할 연산
| 연산 | 설명 |
|---|---|
*it | 현재 요소 역참조 |
++it | 다음 요소로 이동 (전위 증가) |
it != end | 끝 여부 비교 |
범위 기반 for 자체는 이 세 연산만 문법적으로 요구합니다. 다만 표준 알고리즘이나 ranges와 함께 쓰려면 반복자 범주 요구사항(타입 별칭, 후위 증가, 동등 비교 등)을 제대로 갖추는 편이 좋습니다. C++17부터는 begin()과 end()의 반환 타입이 달라도 되므로, 끝을 별도의 센티널 타입으로 표현할 수 있습니다.
직접 만든 타입에 begin/end 제공하기
정수 구간 Range
#include <iostream>
class Range {
int first_, last_;
public:
Range(int first, int last) : first_(first), last_(last) {}
class Iterator {
int current;
public:
explicit Iterator(int val) : current(val) {}
int operator*() const { return current; }
Iterator& operator++() { ++current; return *this; }
bool operator!=(const Iterator& other) const {
return current != other.current;
}
};
Iterator begin() const { return Iterator(first_); }
Iterator end() const { return Iterator(last_); }
};
int main() {
Range range(1, 6);
for (int num : range) {
std::cout << num << " "; // 1 2 3 4 5
}
}
데이터 멤버 이름을 end로 지으면 멤버 함수 end()와 이름이 충돌해 컴파일되지 않으므로 다른 이름을 씁니다. operator*가 값을 반환하므로 이 범위는 auto&로는 받을 수 없고, int나 auto, const auto&로 받아야 합니다.
고정 크기 컨테이너
#include <iostream>
template <typename T, size_t N>
class FixedArray {
T data[N];
public:
T* begin() { return data; }
T* end() { return data + N; }
const T* begin() const { return data; }
const T* end() const { return data + N; }
T& operator[](size_t i) { return data[i]; }
};
int main() {
FixedArray<int, 5> arr;
for (size_t i = 0; i < 5; ++i) arr[i] = static_cast<int>(i + 1);
for (int num : arr) {
std::cout << num << " "; // 1 2 3 4 5
}
}
연속 메모리에 저장된 컨테이너라면 포인터가 그대로 반복자 역할을 합니다.
자주 하는 실수
실수 1: 순회 중 컨테이너 수정
std::vector<int> vec = {1, 2, 3, 4, 5};
// 미정의 동작: erase가 미리 구해 둔 반복자를 무효화
for (int num : vec) {
if (num % 2 == 0) {
vec.erase(std::find(vec.begin(), vec.end(), num));
}
}
// erase-remove 관용구
vec.erase(std::remove_if(vec.begin(), vec.end(),
[](int n) { return n % 2 == 0; }),
vec.end());
// C++20: std::erase_if
std::erase_if(vec, [](int n) { return n % 2 == 0; });
push_back도 마찬가지입니다. 용량이 부족해 재할당이 일어나면 기존 요소의 반복자와 참조가 모두 무효화되므로, 순회 중인 벡터에 요소를 추가하는 코드도 미정의 동작이 될 수 있습니다.
실수 2: 문자열 리터럴의 널 문자 포함
for (char c : "Hello") {
std::cout << static_cast<int>(c) << " "; // 72 101 108 108 111 0
}
// 널 문자 제외
for (char c : std::string_view("Hello")) {
std::cout << c << " ";
}
실수 3: map의 키 수정 시도
std::map<std::string, int> m = {{"a", 1}};
for (auto& [key, value] : m) {
// key = "b"; // 컴파일 에러: map의 키는 const std::string
value = 2; // 값은 수정 가능
}
map은 키 순서로 정렬된 트리라서, 키를 바꾸면 정렬이 깨지므로 언어 차원에서 막혀 있습니다. 키를 바꾸려면 C++17의 extract()로 노드를 꺼내 키를 수정한 뒤 다시 insert()합니다.
순회 비용 줄이기
기본은 const auto&
// 큰 객체·문자열: 참조로
for (const auto& item : largeContainer) {
process(item);
}
// 작은 타입(int, char): 값으로 받아도 무방
for (int n : smallInts) {
sum += n;
}
범위식은 한 번만 평가된다
범위식과 begin()/end()는 루프 진입 시 한 번만 평가됩니다. getExpensiveRange()처럼 비용이 큰 함수를 범위식에 써도 반복마다 호출되지는 않습니다.
for (const auto& x : getExpensiveRange()) { // 한 번만 호출
// ...
}
결과 벡터는 reserve
범위 기반 for로 다른 벡터를 채운다면 reserve로 재할당을 줄입니다.
std::vector<int> result;
result.reserve(source.size());
for (const auto& x : source) {
result.push_back(transform(x));
}
루프 안의 문자열 할당
// 매 반복마다 새 문자열 생성
for (const auto& item : items) {
std::string msg = "Item: " + item;
}
// 루프 밖 버퍼 재사용: 용량이 충분하면 새 할당 없이 덮어씀
std::string msg;
for (const auto& item : items) {
msg.assign("Item: ");
msg += item;
}
msg = "Item: " + item;처럼 쓰면 operator+가 임시 문자열을 새로 만든 뒤 이동 대입하므로, 버퍼를 루프 밖에 두어도 할당이 줄지 않습니다. assign과 +=로 기존 버퍼에 직접 쓰면 이전 반복에서 확보한 용량을 재사용합니다.
C++20 ranges로 지연 연산
#include <ranges>
// filter와 transform이 요소 하나씩 지연 평가됨 (중간 컨테이너 없음)
for (int x : numbers | std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })) {
std::cout << x << " ";
}
인덱스, 역순, 필터, 조기 종료 순회 패턴
인덱스와 함께 순회
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
// 루프 밖 카운터
size_t index = 0;
for (const auto& name : names) {
std::cout << index << ": " << name << "\n";
++index;
}
// 전통적 for
for (size_t i = 0; i < names.size(); ++i) {
std::cout << i << ": " << names[i] << "\n";
}
// C++23: std::views::enumerate
for (const auto& [i, name] : std::views::enumerate(names)) {
std::cout << i << ": " << name << "\n";
}
루프 밖 카운터는 continue를 쓰면 증가가 빠질 수 있다는 점을 조심해야 합니다. 인덱스로 다른 컨테이너의 같은 위치에 접근해야 한다면 전통적인 인덱스 for가 오히려 읽기 쉬울 때도 있습니다.
역순 순회
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (auto it = numbers.rbegin(); it != numbers.rend(); ++it) {
std::cout << *it << " "; // 5 4 3 2 1
}
// C++20: std::views::reverse
for (int num : numbers | std::views::reverse) {
std::cout << num << " "; // 5 4 3 2 1
}
필터링과 변환
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 짝수만
for (int num : numbers) {
if (num % 2 == 0) std::cout << num << " "; // 2 4 6 8 10
}
for (int num : numbers | std::views::filter([](int x) { return x % 2 == 0; })) {
std::cout << num << " ";
}
// 제곱
for (int square : numbers | std::views::transform([](int x) { return x * x; })) {
std::cout << square << " "; // 1 4 9 ... 100
}
중첩 컨테이너
std::vector<std::vector<int>> matrix = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
for (const auto& row : matrix) { // 행은 참조로 받아야 복사가 없음
for (int val : row) {
std::cout << val << " ";
}
std::cout << "\n";
}
조기 종료와 건너뛰기
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for (int num : numbers) {
if (num > 5) {
std::cout << "Found: " << num << "\n"; // Found: 6
break;
}
}
for (int num : numbers) {
if (num % 2 == 0) continue;
std::cout << num << " "; // 1 3 5 7 9
}
값 하나를 찾는 목적이라면 std::find_if가 의도를 더 잘 드러냅니다.
실무 코드에서의 순회
설정 로깅
void logConfig(const std::map<std::string, std::string>& config) {
for (const auto& [key, value] : config) {
spdlog::info("config[{}] = {}", key, value);
}
}
JSON 배열 순회 (nlohmann/json)
void processUsers(const nlohmann::json& users) {
for (const auto& user : users) {
auto name = user.at("name").get<std::string>(); // 키가 없으면 예외
int age = user.at("age").get<int>();
// ...
}
}
const json에 operator[]로 없는 키를 조회하면 미정의 동작이므로, 입력을 신뢰할 수 없다면 at()을 씁니다.
검증 오류 수집
std::vector<std::string> collectErrors(const std::vector<Record>& records) {
std::vector<std::string> errors;
for (const auto& rec : records) {
if (auto err = validate(rec)) { // std::optional<std::string>
errors.push_back(*err);
}
}
return errors;
}
비동기 작업 분배
void processBatch(const std::vector<Task>& tasks) {
std::vector<std::future<Result>> futures;
futures.reserve(tasks.size());
for (const auto& task : tasks) {
futures.push_back(std::async(std::launch::async, process, task));
}
for (auto& f : futures) { // future는 복사 불가라 참조로
handleResult(f.get());
}
}
작업 수만큼 스레드가 생길 수 있으므로, 작업이 많다면 스레드 풀을 쓰는 편이 낫습니다.
순회하면서 map 요소 삭제
범위 기반 for 안에서는 erase를 할 수 없으므로, 반복자 루프에서 erase의 반환값으로 다음 위치를 받거나 C++20의 std::erase_if를 씁니다.
void removeExpired(std::map<std::string, CacheEntry>& cache) {
for (auto it = cache.begin(); it != cache.end(); ) {
if (it->second.isExpired())
it = cache.erase(it); // 다음 요소의 반복자 반환
else
++it;
}
// C++20: std::erase_if(cache, [](const auto& kv) { return kv.second.isExpired(); });
}
map 전체를 복사해 두고 복사본을 순회하며 원본에서 지우는 방법도 동작은 하지만, 요소 수만큼 복사 비용이 들어 큰 캐시에는 맞지 않습니다.
임시 객체와 vector 프록시
임시 객체의 수명
범위식 자체가 임시 객체이면 그 수명은 루프 전체로 연장됩니다. 범위 for가 내부적으로 auto&& __range = 범위식;으로 받아 두기 때문입니다. 그래서 아래 첫 번째 코드는 안전합니다.
// 안전: getVector()가 반환한 임시 vector는 루프가 끝날 때까지 살아 있음
for (const auto& item : getVector()) { /* ... */ }
// C++20까지 위험: 연장되는 건 마지막 결과(items()가 돌려준 참조)뿐,
// getConfig()가 만든 임시 Config는 __range 초기화 직후 소멸 → 댕글링
for (const auto& item : getConfig().items()) { /* ... */ }
// 중간 객체를 변수로 받으면 버전과 상관없이 안전
auto cfg = getConfig();
for (const auto& item : cfg.items()) { /* ... */ }
두 번째 패턴은 C++23(P2718)에서 범위 초기화식 안의 모든 임시가 루프 끝까지 연장되도록 바뀌어 안전해졌지만, C++17/20 컴파일러에서는 여전히 미정의 동작입니다. 디버그 빌드에서는 우연히 동작하다가 최적화 빌드에서만 쓰레기 값이 나오는 유형이라, 메서드 체인으로 범위를 만들 때는 중간 객체를 변수로 받는 습관이 안전합니다. items()가 참조가 아니라 컨테이너를 값으로 반환한다면 그 반환값 자체가 연장되므로 문제가 없습니다.
루프 안에서 얻은 참조를 컨테이너보다 오래 쓰는 것은 연장 규칙과 무관하게 위험합니다.
const std::string* p = nullptr;
{
std::vector<std::string> v = {"a"};
for (const auto& s : v) p = &s;
} // v 소멸
// *p → 미정의 동작
vector과 프록시 참조
std::vector<bool>은 비트 단위로 압축된 특수화라, 요소를 역참조하면 bool&가 아니라 프록시 객체(std::vector<bool>::reference)가 나옵니다. 그래서 흔한 습관대로 쓰면 컴파일이 안 됩니다.
std::vector<bool> flags(8);
// for (auto& f : flags) f = true; // 컴파일 에러: 임시 프록시를 비const 참조에 바인딩 불가
for (auto&& f : flags) f = true; // 프록시를 받아서 대입 → 원래 비트가 바뀜
for (bool f : flags) { /* 읽기 */ } // bool로 변환해 읽기
auto f로 받아도 복사본이 아니라 여전히 같은 비트를 가리키는 프록시라서 f = false가 원본을 바꾼다는 점도 주의해야 합니다. 템플릿 코드에서 T&를 가정하면 T = bool에서 깨지므로, 제네릭 코드에서는 auto&&로 받거나 std::vector<char>, std::deque<bool>을 쓰는 편이 안전합니다.
auto&&는 전달 참조라서 요소가 좌값이면 좌값 참조로, 프록시 같은 임시면 그 임시에 바인딩되므로 어느 쪽이든 컴파일됩니다. 요소 타입을 모르는 템플릿 코드에서는 이 성질이 유용하지만, 평범한 애플리케이션 코드에서 읽기만 한다면 의도가 더 분명한 const auto&가 낫습니다.