hello, robot

예외를 던지는 언어에서 오면 감각이 갈리는 지점이 있어요. 잘못된 인덱스에 예외가 나는 언어와 달리 C++은 아무 일도 안 일어날 수도 있어요. 크래시할 수도, 조용히 틀린 값을 읽을 수도 있죠.

C++에서 나를 지켜주는 건 컴파일러뿐인데, 이 글은 컴파일러조차 못 잡는 자리만 모았어요. 문법상 전부 합법이라 경고도 안 뜨는 곳들이에요.

0에서 1을 빼면 184경이 돼요

크기를 담는 표준 타입은 부호 없는 정수예요. 컨테이너의 size()가 전부 이걸 돌려주죠. 음수가 없으니 0에서 1을 빼면 −1이 아니라 감싸 돌아 최대값이 돼요.

std::vector<double> empty;
empty.size() - 1      // 18446744073709551615   ← -1이 아니다

실전 증상은 빈 배열일 때만 죽는다예요.

for (size_t i = 0; i < v.size() - 1; ++i) { ... }   // 마지막 하나 전까지

원소가 있을 땐 멀쩡해요. 그러다 벡터가 비는 순간 루프가 184경 번 돌면서 범위 밖 접근으로 크래시해요. 센서 버퍼가 잠깐 비는 프레임에서만 터지니 재현이 어려워요.

규칙은 하나예요. 뺄셈 대신 덧셈을 써요. i + 1 < v.size()로 쓰면 언더플로가 원천적으로 없어요.

역방향 순회는 반드시 한 번 막혀요

정방향은 몸에 배어 있는데 역방향에서는 다들 한 번 걸려요.

// 무한 루프. 컴파일 경고도 안 뜬다
for (size_t i = v.size() - 1; i >= 0; --i) { ... }

부호 없는 타입이라 조건이 언제나 참이에요. 0일 때 감소하면 최대값으로 감싸 돌고, 그다음 184경 번째 원소를 읽으니 미정의 동작이죠. 조건 자체는 문법상 합법이라 컴파일러가 막을 근거가 없어요.

// 형태 1: 조건 안에서 후위 감소 (관용구)
for (size_t i = v.size(); i-- > 0;) { ... }
 
// 형태 2: 부호 있는 타입으로 바꿔 상식대로
for (int i = static_cast<int>(v.size()) - 1; i >= 0; --i) { ... }

형태 1에서 세미콜론 뒤 증감식이 비어 있는 게 핵심이에요. 감소를 조건식에서 이미 했거든요. 후위 감소는 비교에 감소 전 값을 쓰고 변수에는 감소 후 값을 남겨요.

참조를 들고 있는데 벡터가 이사를 가요

벡터는 숫자 둘을 들고 있어요. 실제로 든 개수와, 이사 없이 담을 수 있는 최대 개수요. 후자를 넘으려 하면 더 큰 메모리를 새로 잡아 원소를 전부 이사시키고 옛 메모리를 버려요.

그러면 이전에 얻은 포인터·참조·반복자가 전부 죽어요. 살아남는 건 인덱스뿐이에요. 그냥 숫자니까요.

auto& best = dets[i];      // 참조를 받아 둠
dets.push_back(...);       // 재할당이 일어나면
// best는 이제 해제된 메모리를 가리킨다

실전 증상은 값이 가끔 이상하다예요. 원소가 적을 땐 여유가 있어 재현이 안 되고, 검출이 많아지는 현장에서만 터져요.

규칙은 참조나 포인터 대신 인덱스로 들고 다니는 거예요. 개수를 미리 알면 용량을 확보해 이사 자체를 막을 수도 있고요.

여기서 헷갈리기 쉬운 게 있어요. 해시 맵은 정반대예요. 원소가 노드에 따로 있어서 내부 재구성이 일어나도 값 참조가 살아남아요. 벡터는 원소가 연속 배열에 있어 통째로 이사하니 죽는 거고요. “STL 컨테이너는 다 비슷하겠지”가 틀리는 지점이에요.

예외가 안 났으니 맞았겠지가 성립하지 않아요

범위 검사를 하는 접근자는 예외를 던지지만, 대괄호 접근은 아무 에러 없이 통과할 수도 있어요. 그게 더 무섭죠.

항상 예외를 던지는 언어에서 오면 “예외가 안 났으니 인덱스가 맞았겠지”라고 읽게 되는데, C++에서 그 추론은 성립하지 않아요. 외부에서 온 인덱스면 검사하는 쪽을, 내가 만든 루프 인덱스면 대괄호를 써요.

빈 컨테이너에서는 첫 원소·마지막 원소 접근이 전부 미정의 동작이에요. 최댓값을 찾는 알고리즘도 마찬가지고요. 빈 범위에서는 마지막 다음을 가리키는 자리표시자를 돌려주는데, 그건 역참조하면 안 되거든요.

int max_index(const std::vector<double>& xs) {
    if (xs.empty()) return -1;               // 빈 검사 먼저
    size_t best = 0;
    for (size_t i = 1; i < xs.size(); ++i)   // 0은 이미 후보
        if (xs[i] > xs[best]) best = i;
    return static_cast<int>(best);
}

반환 타입이 부호 있는 정수인 이유가 있어요. “없음”을 −1로 표현하려면 음수가 표현되는 타입이어야 하거든요. 부호 없는 타입으로 반환하면 −1이 184경이 되어 호출자의 비교가 거짓이 돼요.

반복문 한 줄에 세 가지가 갈려요

for (const auto& t : temps) { ... }

const auto&를 쓰는 이유를 짚고 갈게요. 값으로 받으면 매 반복마다 원소를 복사해요. double 하나면 무해하지만 큰 메시지 구조체라면 조용한 성능 버그가 되죠.

표기복사하나원본을 고치나
for (auto t : v)한다복사본만 (원본 안 변함)
for (auto& t : v)안 함고친다
for (const auto& t : v)안 함못 고침 — 기본값으로

두 번째 줄이 특히 함정이에요. 전 원소에 1을 더하려고 값으로 받아 더하면 사본만 바뀌고 원본은 그대로인데 에러가 안 나요.

손이 멈추는 자리 모음

의도맞는 코드흔한 오답오답의 증상
합계 누적double s = 0;double s;초기화 안 한 값이 쓰레기 → 합계가 매번 다름
전 원소 +1for (auto& x : q)for (auto x : q)복사본만 바뀜 (에러 없음)
큰 원소 읽기for (const auto& d : dets)for (auto d : dets)매 반복 전체 복사
마지막 전까지i + 1 < v.size()i < v.size() - 1빈 벡터에서 폭주
역방향for (size_t i = v.size(); i-- > 0;)i >= 0; --i무한 루프

지역 변수 초기화가 첫 줄에 있는 게 우연이 아니에요. C++은 지역 변수를 0으로 보장하지 않아요. 그 자리에 우연히 있던 값이 읽히는데, 디버그 빌드에서는 그 자리가 0인 경우가 많아 멀쩡해 보이다가 최적화를 켜면 드러나요. 그 이야기는 C++ 도구와 디버깅 — 경고, sanitizer, gdb에 있어요.

이 함정들의 공통점은 에러가 아니라 이상한 동작으로 나타난다는 거예요. 그래서 도구가 필요해요. 경고 옵션이 미사용 변수와 부호 비교를 잡아 주고, 실행 중 검사 도구가 범위 초과와 해제 후 사용을 잡아 줘요.

컨테이너를 다룰 때 원소 소유가 어디에 있는지는 C++ 소유권과 수명 — 스마트 포인터에, 재할당 비용을 주기 안에서 없애는 이야기는 C++ 성능과 메모리 — 숨은 힙 할당이 만드는 최악 지연에 있어요.