C++ 범위 기반 for문 no begin function 에러: 내부 변환과 커스텀 컨테이너 begin·end
이 글의 핵심
범위 기반 for문은 컴파일러가 begin()과 end()를 호출하는 일반 for문으로 바꿔 주는 문법이라, 이 변환을 알면 대부분의 에러가 풀립니다. auto로 받아 수정이 반영되지 않는 문제, 순회 중 push_back으로 반복자가 무효화되는 문제, 임시 객체를 순회할 때 생기는 댕글링을 예제로 보고, 복사를 줄이는 const auto& 사용 기준도 정리합니다.
들어가며: “범위 기반 for문에서 에러가 나요”
C++11의 범위 기반 for문(Range-based for loop)은 컨테이너 순회를 간결하게 만들지만, 잘못 사용하면 컴파일 에러나 크래시가 발생합니다.
// ❌ 에러 코드
for (auto x : myCustomType) { // begin()/end() 없음
std::cout << x << '\n';
}
// error: no matching function for call to 'begin(MyCustomType&)'
이 글에서 다루는 것:
- 범위 기반 for문 에러 8가지
- begin()/end() 요구사항
- 값 vs 참조 캡처
- 반복자 무효화 주의
- 임시 객체 수명
범위 기반 for문 동작 원리
내부 변환
// 범위 기반 for
for (auto x : vec) {
std::cout << x << '\n';
}
// 컴파일러가 변환 (개념적)
{
auto&& __range = vec;
auto __begin = std::begin(__range);
auto __end = std::end(__range);
for (; __begin != __end; ++__begin) {
auto x = *__begin;
std::cout << x << '\n';
}
}
요구사항: begin()과 end() 함수 필요.
이 변환 코드 한 덩어리가 이 글에서 다루는 거의 모든 에러의 원인을 설명합니다. 세 줄만 눈여겨보면 됩니다.
auto&& __range = vec;— 콜론 오른쪽의 식(범위 식)은 딱 한 번 평가되어 전달 참조에 묶입니다. 범위 식이 임시 객체라면 이 참조가 수명을 루프 끝까지 늘려 줍니다. 반대로 범위 식 내부에서 만들어진 다른 임시 객체는 이 연장을 받지 못합니다(에러 5에서 다룹니다).auto __begin = ...; auto __end = ...;—end는 루프 시작 전에 한 번만 계산해서 캐싱합니다. 그래서 루프 안에서 컨테이너 크기를 바꾸면 캐싱된__end가 더 이상 진짜 끝을 가리키지 않습니다.auto x = *__begin;— 루프 변수 선언부에 쓴auto,auto&,const auto&가 그대로 여기로 들어갑니다. 복사가 일어나는지, 원본을 수정할 수 있는지가 전부 이 한 줄에서 결정됩니다.
begin/end를 찾는 규칙도 알아 두면 에러 메시지가 읽힙니다. 컴파일러는 ① 배열이면 포인터 연산으로, ② 타입에 begin/end 멤버가 있으면 멤버로, ③ 둘 다 아니면 ADL(인자 종속 탐색)로 찾은 비멤버 begin(x)/end(x)로 시도합니다. 셋 다 실패해야 “no matching function for call to ‘begin’” 에러가 납니다. 참고로 C++17부터는 __begin과 __end의 타입이 달라도 되도록 규칙이 완화되어, 끝을 “센티넬” 타입으로 표현하는 범위도 범위 기반 for에 쓸 수 있습니다.
자주 나오는 에러 8가지
에러 1: no begin function
// ❌ begin()/end() 없음
struct MyRange {
int data[5] = {1, 2, 3, 4, 5};
};
MyRange range;
for (auto x : range) { // begin()/end() 없음
std::cout << x << '\n';
}
// error: no matching function for call to 'begin(MyRange&)'
해결: begin()/end() 정의.
// ✅ 멤버 함수
struct MyRange {
int data[5] = {1, 2, 3, 4, 5};
int* begin() { return data; }
int* end() { return data + 5; }
};
// ✅ 또는 비멤버 함수
int* begin(MyRange& r) { return r.data; }
int* end(MyRange& r) { return r.data + 5; }
멤버와 비멤버 중 어느 쪽을 쓸지는 타입을 수정할 수 있느냐로 정하면 됩니다. 내 코드라면 멤버가 가장 단순하고, 서드파티 라이브러리 타입처럼 손댈 수 없는 경우에만 비멤버를 씁니다. 비멤버 함수는 그 타입과 같은 네임스페이스에 선언해야 ADL로 찾아집니다. 전역이나 다른 네임스페이스에 두면 “선언했는데도 no begin function이 난다”는 혼란스러운 상황이 생깁니다. 또 위 예제처럼 MyRange&만 받는 버전만 만들면 const MyRange를 순회할 때 다시 같은 에러가 나므로, 읽기 전용 순회가 필요하면 const int* begin() const 버전도 함께 제공해야 합니다.
에러 2: 값 복사 (수정 안 됨)
// ❌ 복사 (원본 수정 안 됨)
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto x : vec) { // 값 복사
x = 99; // 복사본 수정
}
// vec은 여전히 {1, 2, 3, 4, 5}
// ✅ 참조로 수정
for (auto& x : vec) { // 참조
x = 99;
}
// vec은 이제 {99, 99, 99, 99, 99}
이 실수는 컴파일 에러도 경고도 없어서 가장 늦게 발견됩니다. 변환 코드의 auto x = *__begin;을 떠올리면 당연한 결과입니다. 루프 변수는 원소의 사본이고, 사본을 바꿔도 원본은 그대로입니다. 저도 처음에 구조체 벡터를 돌면서 obj.visited = true를 설정했는데 다음 단계에서 전부 false로 나와 한참 헤맸던 적이 있는데, 원인은 & 한 글자였습니다. 값을 바꾸려는 루프라면 선언부에 &가 있는지부터 확인하세요.
에러 3: const 불일치
// ❌ const 컨테이너를 비const 참조 타입으로 명시
void print(const std::vector<int>& vec) {
for (int& x : vec) { // 원소 타입은 const int
std::cout << x << '\n';
}
}
// error: binding reference of type 'int&' to 'const int' discards qualifiers
// ✅ const 참조
void print(const std::vector<int>& vec) {
for (const auto& x : vec) { // const auto&
std::cout << x << '\n';
}
}
흔히 오해하는 부분인데, for (auto& x : vec)는 const 컨테이너에서도 컴파일됩니다. auto&의 auto가 const int로 추론되어 결국 const int&가 되기 때문입니다. 에러가 나는 것은 위처럼 int&를 직접 적었을 때, 또는 auto&로 받은 뒤 루프 안에서 x = 0처럼 값을 대입하려 할 때(“assignment of read-only reference ‘x’“)입니다. 그래도 읽기 전용 루프에 const auto&를 명시하는 습관이 좋은 이유는, 나중에 함수 시그니처에서 const가 빠져도 루프가 실수로 원본을 수정하지 못하게 막아 주고, 읽는 사람에게 의도를 드러내기 때문입니다.
에러 4: 반복 중 수정 (무효화)
// ❌ 반복 중 push_back
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto x : vec) {
vec.push_back(x * 2); // ❌ 재할당 → 반복자 무효화 → 크래시
}
// ✅ 크기를 미리 저장
std::vector<int> vec = {1, 2, 3, 4, 5};
size_t size = vec.size();
for (size_t i = 0; i < size; ++i) {
vec.push_back(vec[i] * 2);
}
이 문제는 재할당이 일어날 때만 위험한 것이 아닙니다. reserve()로 용량을 충분히 잡아 두어 재할당이 없더라도, 루프 시작 시 캐싱된 __end는 원래 끝을 가리키므로 추가된 원소는 순회되지 않고, vector::push_back은 재할당이 없을 때에도 end() 반복자를 무효화하므로 비교 자체가 미정의 동작입니다. 증상도 일정하지 않습니다. 작은 벡터에서는 멀쩡히 돌다가, 데이터가 커져 재할당이 일어나는 순간 쓰레기 값이 찍히거나 segfault가 나기 때문에 테스트에서 놓치기 쉽습니다. 디버그 빌드의 MSVC는 “vector iterators incompatible”/“cannot increment value-initialized vector iterator” 같은 assertion으로, libstdc++는 -D_GLIBCXX_DEBUG를 켜면 비교적 명확한 메시지로 잡아 줍니다.
원소를 삭제하는 경우도 같습니다. 순회 중 erase가 필요하면 범위 기반 for를 포기하고 std::erase_if(C++20)나 erase-remove 관용구를 쓰는 편이 안전합니다. 추가할 원소를 별도 벡터에 모아 두었다가 루프가 끝난 뒤 한 번에 insert하는 방법도 흔히 씁니다.
에러 5: 임시 객체 순회 (범위 식 안의 임시 객체)
// ✅ C++11부터 안전: 범위 식 자체가 임시 객체면 __range가 수명을 연장
for (auto x : getVector()) { // 임시 vector
std::cout << x << '\n';
}
// ❌ C++20까지 위험: 임시 객체의 "멤버"를 참조로 순회
struct Config {
std::vector<int> items_;
const std::vector<int>& items() const { return items_; }
};
Config getConfig();
for (auto x : getConfig().items()) { // getConfig() 임시 객체는 __range 초기화 직후 소멸
std::cout << x << '\n'; // ❌ 댕글링 참조 순회 (미정의 동작)
}
// ✅ 임시 객체를 이름 있는 변수에 저장
auto cfg = getConfig();
for (auto x : cfg.items()) {
std::cout << x << '\n';
}
이 부분은 잘못 알려진 경우가 많아 정확히 짚고 넘어가겠습니다. getVector()처럼 범위 식 전체가 임시 객체인 경우는 C++11부터 안전합니다. 변환 코드의 auto&& __range = getVector();가 임시 객체의 수명을 루프 끝까지 연장해 주기 때문입니다. 문제는 getConfig().items()처럼 범위 식 안에서 만들어진 중간 임시 객체입니다. 수명 연장은 __range에 직접 묶이는 최종 결과에만 적용되므로, getConfig()가 만든 Config는 __range 초기화가 끝나는 세미콜론에서 소멸하고 __range는 이미 사라진 items_를 가리키게 됩니다. getMap()["key"], std::optional을 반환하는 함수에 .value()를 붙인 경우도 같은 함정에 빠집니다.
C++23(P2718)에서 범위 기반 for의 범위 식 안에 있는 임시 객체도 루프 끝까지 살아 있도록 규칙이 바뀌었지만, 컴파일러 지원 여부와 -std 설정에 따라 달라지므로 여러 표준을 오가는 코드라면 임시 객체를 변수에 저장하는 방식이 가장 확실합니다. 이 버그는 컴파일러 경고 없이 조용히 틀린 값을 내므로 AddressSanitizer(-fsanitize=address)의 “heap-use-after-free” 보고로 처음 발견하는 경우가 많습니다.
에러 6: 포인터 컨테이너
// ❌ 포인터 복사
std::vector<std::unique_ptr<int>> vec;
vec.push_back(std::make_unique<int>(42));
for (auto ptr : vec) { // ❌ unique_ptr 복사 불가
std::cout << *ptr << '\n';
}
// error: use of deleted function 'std::unique_ptr<int>::unique_ptr(const std::unique_ptr<int>&)'
// ✅ const 참조
for (const auto& ptr : vec) { // 참조
std::cout << *ptr << '\n';
}
에러 7: map 순회 실수
// ❌ 값 복사 (느림)
std::map<std::string, int> scores;
for (auto pair : scores) { // pair 복사
std::cout << pair.first << ": " << pair.second << '\n';
}
// ✅ const 참조
for (const auto& pair : scores) { // 참조
std::cout << pair.first << ": " << pair.second << '\n';
}
// ✅ 구조화 바인딩 (C++17)
for (const auto& [key, value] : scores) {
std::cout << key << ": " << value << '\n';
}
map 순회에는 한 단계 더 숨은 함정이 있습니다. map의 원소 타입은 std::pair<std::string, int>가 아니라 std::pair<const std::string, int>입니다. 그래서 for (const std::pair<std::string, int>& p : scores)처럼 타입을 직접 적으면, 타입이 달라 참조로 바인딩되지 못하고 원소마다 임시 pair가 복사 생성된 뒤 그 임시에 const 참조가 묶입니다. 컴파일도 되고 결과도 맞기 때문에 알아채기 어렵지만, 매 반복마다 문자열이 복사됩니다. 타입을 직접 적지 말고 const auto&나 구조화 바인딩을 쓰는 것이 이 실수를 피하는 가장 쉬운 방법입니다.
에러 8: 배열 크기 추론 실수
// ❌ 포인터로 decay
void print(int arr[]) { // int* 로 decay
for (auto x : arr) { // ❌ 포인터는 범위 기반 for 불가
std::cout << x << '\n';
}
}
// error: 'begin' was not declared in this scope
// ✅ 템플릿으로 크기 유지
template <size_t N>
void print(const int (&arr)[N]) { // 배열 참조
for (auto x : arr) { // OK
std::cout << x << '\n';
}
}
// ✅ 또는 std::array
void print(const std::array<int, 5>& arr) {
for (auto x : arr) {
std::cout << x << '\n';
}
}
함수 매개변수에 int arr[]나 int arr[5]라고 적어도 실제 타입은 int*입니다. 포인터에는 원소 개수 정보가 없으니 컴파일러가 end를 만들 방법이 없고, 그래서 에러가 납니다. 템플릿 배열 참조, std::array, 또는 C++20의 std::span<const int>로 받으면 크기 정보가 함께 전달됩니다. 특히 std::span은 크기가 다른 배열과 vector를 모두 받을 수 있어 함수 인터페이스로 가장 무난합니다.
커스텀 타입 지원
begin()/end() 정의
// 커스텀 컨테이너
class MyContainer {
std::vector<int> data_;
public:
MyContainer() : data_{1, 2, 3, 4, 5} {}
// 멤버 함수
auto begin() { return data_.begin(); }
auto end() { return data_.end(); }
// const 버전
auto begin() const { return data_.begin(); }
auto end() const { return data_.end(); }
};
// 사용
MyContainer container;
for (auto x : container) { // OK
std::cout << x << '\n';
}
내부 컨테이너를 감싸는 클래스라면 이렇게 내부 반복자를 그대로 넘기는 방식이 가장 적은 코드로 끝납니다. const 버전을 반드시 함께 두어야 하는데, 없으면 const MyContainer&로 받은 함수 안에서 순회할 때 “passing ‘const MyContainer’ as ‘this’ argument discards qualifiers” 에러가 납니다. 위 코드에서 const 버전의 auto는 data_가 const이므로 const_iterator로 추론됩니다.
내부 컨테이너 없이 값을 계산해서 내놓는 범위(예: 0부터 N까지의 정수)를 만들려면 반복자 타입을 직접 작성해야 합니다. 범위 기반 for가 요구하는 것은 생각보다 적어서 operator*, 전위 operator++, operator!= 세 개면 충분합니다. 다만 같은 타입을 std::find나 Ranges 알고리즘에도 넘기려면 iterator_category, value_type 같은 반복자 특성까지 갖춰야 하므로, 처음부터 std::views::iota 같은 표준 뷰로 해결되는지 먼저 확인하는 편이 낫습니다.
성능 최적화
불필요한 복사 제거
// ❌ 복사 (느림)
struct BigObject {
std::array<int, 1000> data;
};
std::vector<BigObject> vec;
for (auto obj : vec) { // 4KB 복사
// ...
}
// ✅ const 참조
for (const auto& obj : vec) { // 복사 없음
// ...
}
복사 비용은 타입에 따라 다르다
| 원소 타입 | auto (복사) | const auto& |
|---|---|---|
int, double, 포인터 | 레지스터 복사 수준, 차이 거의 없음 | 차이 거의 없음 |
std::string, 작은 구조체 | 원소마다 복사 생성자 호출, 긴 문자열이면 힙 할당 | 복사 없음 |
std::array<int, 1000> 같은 큰 값 | 원소마다 4KB 복사 | 복사 없음 |
unique_ptr 등 이동 전용 타입 | 컴파일 에러 | 정상 |
범위 기반 for 자체는 일반 반복자 for문과 같은 코드로 변환되므로 오버헤드가 없습니다. 성능 차이는 오직 루프 변수를 어떻게 선언했는지에서 생깁니다. int처럼 작은 타입은 복사가 사실상 공짜이고 최적화된 빌드에서는 두 방식이 같은 기계어가 되기도 합니다. 반면 std::string이나 큰 구조체는 원소마다 복사 생성자가 돌고 힙 할당까지 일어날 수 있어, 프로파일러에서 operator new/memcpy가 루프 안에서 튀어나오는 형태로 나타납니다. 그래서 “기본은 const auto&, 작은 값을 일부러 복사해 쓰고 싶을 때만 auto”라는 규칙이 가장 무난합니다. clang-tidy의 performance-for-range-copy 검사를 켜 두면 이런 불필요한 복사를 자동으로 찾아 줍니다.
같이 보면 좋은 글
- C++ Iterator 카테고리 이해하기
- C++ 반복자 무효화 | iterator invalidation 해결
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++ STL 알고리즘 | for_each·transform 가이드
- C++ 시리즈 전체 보기
자주 묻는 질문 (FAQ)
Q. vector을 for (auto& b : v)로 순회하면 왜 컴파일 에러가 나나요?
A. std::vector<bool>은 원소를 비트 단위로 압축해 저장하기 때문에, 반복자를 역참조하면 bool&가 아니라 비트를 대신 가리키는 프록시 객체가 임시값으로 반환됩니다. 임시값은 non-const 좌측값 참조(auto&)에 바인딩할 수 없어서 컴파일 에러가 납니다. 원소를 수정하려면 for (auto&& b : v)를, 읽기만 한다면 for (bool b : v)를 쓰면 되고, 제네릭 코드에서는 이런 컨테이너까지 고려해 auto&&를 쓰는 경우가 많습니다.