← 문제로

44. std::vector<bool> 특수화와 프록시 참조

난이도 중
내 리뷰 · C++
해설 · C++

해설 — std::vector 특수화와 프록시 참조

난이도: 중상

요약

std::vector<bool> 은 vector 의 부분 특수화로, 원소를 bool 배열이 아니라 비트 단위로 압축해 저장한다. 그 결과 operator[] 가 bool& 를 반환할 수 없어 프록시 객체 (std::vector<bool>::reference)를 반환하고, 이 때문에 다른 모든 vector<T> 와 다르게 동작한다. 표준 위원회도 실패한 설계로 인정하지만 ABI 호환 때문에 되돌리지 못했다.

핵심 개념

  • 부분 특수화: template<class Allocator> class vector<bool, Allocator> 가 별도로 정의되어 있다. 이름만 같을 뿐 일반 vector 와 인터페이스 계약이 다르다.
  • 비트 압축: 원소 1개당 1비트. 8개 bool 이 1바이트에 들어간다.
  • 프록시 참조: 비트에는 주소가 없다. C++ 의 가장 작은 주소 단위는 바이트이므로 개별 비트를 가리키는 bool* 이나 bool& 를 만들 수 없다. 그래서 (워드 포인터 + 비트 마스크)를 들고 operator bool() 과 operator=(bool) 을 제공하는 프록시 클래스를 반환한다.
  • 컨테이너 규약 위반: vector<bool> 은 표준의 Container/SequenceContainer 요구사항인 "&v[0] 로 연속 배열에 접근 가능", "operator[] 는 reference(=T&)를 반환"을 만족하지 않는다.

단계별 내부 동작

출력 A — sizeof(vb) vs sizeof(vc)

둘 다 컨테이너 객체 자체의 크기다. 힙 버퍼 크기가 아니다. libstdc++ 에서 vector<char> 는 포인터 3개(24바이트), vector<bool> 은 (워드 포인터 + 비트 오프셋) 쌍 2개 + 끝 포인터로 40바이트다. 즉 A: 40 24 (구현 의존). 원소 8개의 힙 사용량은 vector<bool> 이 1바이트 (할당 단위 때문에 실제로는 워드 하나), vector<char> 가 8바이트다. 문제 2번이 묻는 것은 이쪽이다.

printFirst — auto 추론의 갈림

auto first = c[0];
  • vector<char> 의 c[0] 은 const char& → auto 는 참조를 떼고 char. sizeof(first) == 1.
  • vector<bool> 의 const 버전 operator[] 는 bool 값을 반환(const 프록시가 아니라 값). 따라서 auto 는 bool, sizeof(first) == 1. 따라서 두 컨테이너 모두 first=0, sizeof(elem-proxy)=1 이 출력된다. printFirst 만 보면 차이가 없다.

printFirst 의 매개변수가 const Container& 라는 점이 결정적이다. vector<bool> 의 const operator[] 는 프록시가 아니라 bool 값을 반환하도록 정의되어 있기 때문이다. 비const 컨테이너였다면 auto 는 std::vector<bool>::reference 프록시가 되고 크기는 libstdc++ 기준 16바이트다. 차이는 바로 다음 함수에서 드러난다.

flipFirst — 프록시가 만드는 진짜 함정

auto x = c[0];   // 비const 컨테이너
x = true;
  • vector<char>: auto x 는 char 복사본. x = true 는 복사본만 바꾸므로 컨테이너는 그대로.
  • vector<bool>: auto x 는 reference 프록시 객체. 프록시는 원본 비트를 가리키므로 x = true 가 operator=(bool) 을 호출해 실제 컨테이너의 비트를 바꾼다.

따라서 B: 1 0 이 출력된다. 같은 제네릭 코드가 bool 에서만 원본을 수정하는, 최악의 종류의 비일관성이다.

주석 처리된 두 줄

bool& r = vb[0];   // 컴파일 오류
bool* p = &vb[0];  // 컴파일 오류
  • vb[0] 은 reference 프록시 rvalue 다. bool& 로 바인딩할 수 없다(프록시→bool 변환은 값을 만들 뿐이고, 비const 참조는 임시에 바인딩되지 않는다).
  • &vb[0] 은 프록시의 주소를 취하는 것이며, 프록시는 operator& 를 그렇게 정의하지 않는다. libstdc++ 에서는 _Bit_reference* 같은 임시 주소가 되거나 컴파일이 거부된다. 어느 쪽이든 bool* 로는 변환되지 않는다.
  • const bool& r = vb[0]; 는 컴파일된다. 프록시가 bool 로 변환되어 만들어진 임시에 수명 연장이 적용되기 때문이다. 원본과는 무관한 스냅샷이라는 점이 더 위험하다.

출력 C

vb[3] = true; 는 프록시의 대입 연산자를 호출해 정상 동작한다. C: 1.

출력 D — 프록시의 수명

auto ref = vb[3];   // 프록시가 vb 의 내부 버퍼(워드 주소 + 비트 마스크)를 가리킨다
vb = big;           // 5000 원소 대입 → 용량 초과 → 재할당 + 옛 버퍼 해제
std::printf("D: %d\n", (int)ref);

프록시는 원본 버퍼의 워드 주소를 들고 있을 뿐 소유권이 없다. 대입으로 용량을 초과하면 새 버퍼를 할당하고 옛 버퍼를 해제하므로 ref 는 댕글링이 된다. D 는 정의되지 않은 동작이며, ASAN(-fsanitize=address)을 켜면 heap-use-after-free ... in std::_Bit_reference::operator bool() 로 정확히 잡힌다.

여기서 반드시 구분해야 할 두 가지가 있다.

  • 형식적 무효화: 표준은 operator=, resize, push_back 등이 기존 참조·반복자·포인터를 무효화한다고 규정한다. 즉 재할당이 실제로 일어나지 않아도 표준상으로는 이미 무효다.
  • 실제 재할당 여부: 만약 대입 대상의 원소 수가 기존 용량 안에 들어가면(예: 8원소 벡터에 8원소 벡터를 대입) libstdc++ 는 버퍼를 재사용한다. 이때 ref 는 살아 있는 메모리를 읽고 새 값(0)을 결정론적으로 반환하며 ASAN 도 아무것도 보고하지 않는다.

"UB 인데 아무 일도 안 일어나는" 이 경우가 실무에서 가장 위험하다. 테스트에서는 멀쩡히 통과하다가, 컨테이너가 커져 재할당이 발생하는 순간 프로덕션에서 터진다. 반복자 무효화 규칙이 프록시에도 그대로 적용된다는 점을 놓치기 쉽다.

흔한 오해·함정

  • "vector<bool> 은 그냥 메모리를 아끼는 vector 다" — 아니다. 타입 계약 자체가 다르다.
  • auto 와 궁합이 최악 — 위 flipFirst 처럼 값 복사를 의도한 auto 가 참조처럼 동작한다. 방어책은 auto x = static_cast<bool>(c[0]); 또는 bool x = c[0]; 처럼 타입을 명시하는 것이다.
  • 제네릭 코드가 조용히 깨진다 — template<class T> void f(std::vector<T>& v) { T* p = v.data(); } 는 T = bool 에서 컴파일되지 않는다. libstdc++ 에는 data() 가 존재하되 void 를 반환 하도록 의도적으로 무력화되어 있어(mismatched types 'auto*' and 'void'), 어떤 방법으로도 bool* 을 얻을 수 없다. C API 나 memcpy, write() 에 버퍼를 넘기는 코드가 전부 막힌다.
  • 스레드 안전성 — 서로 다른 인덱스의 원소를 다른 스레드가 동시에 쓰는 것은 일반 컨테이너에서는 안전하지만, vector<bool> 은 같은 워드의 다른 비트를 쓰면 read-modify-write 가 겹쳐 데이터 레이스가 된다. 게임서버에서 플래그 배열을 병렬로 갱신할 때 실제로 터지는 함정이다.
  • std::bitset 과의 차이 — bitset 은 크기가 컴파일 타임 상수이고 프록시를 쓴다는 사실이 타입 이름에 드러나 있어 오해가 없다.

대안

  • 크기가 컴파일 타임에 정해지면 std::bitset<N>.
  • 동적 크기 + 비트 압축이 필요하면 boost::dynamic_bitset 또는 직접 만든 비트셋.
  • 압축이 필요 없고 컨테이너 규약이 중요하면 std::vector<char>, std::vector<std::uint8_t>, 또는 std::deque<bool>(특수화 없음).
  • C++23 부터는 std::vector<bool> 대신 명시적 비트 컨테이너를 쓰라는 권고가 더 분명해졌다.

면접 포인트

  1. vector<bool> 이 왜 "표준 라이브러리의 대표적 설계 실패"로 불리는가. 최적화를 타입 이름에 숨긴 것이 왜 문제인가(zero-overhead 원칙과 최소 놀람 원칙의 충돌).
  2. 프록시 참조(proxy reference)의 일반적 패턴과 위험 — vector<bool>, bitset::reference, 그리고 표현식 템플릿 라이브러리에서 auto 와 결합될 때 생기는 댕글링.
  3. 게임서버에서 대량 불리언 플래그(퀘스트 완료, AoI 가시성)를 다룰 때 비트 압축의 이득 (캐시 효율, 메모리)과 비트 단위 동시 쓰기의 데이터 레이스 위험을 어떻게 저울질할 것인가.