← 기술 지식

기술 지식 / 프로그래밍 언어 / C++

C++ 저장 공간과 객체·반복자의 유효성

바이트 크기·정렬·객체 수명·별칭 규칙을 나누고 vector 재할당·erase·안정 핸들·네트워크 디코딩의 전제를 설명합니다.

언어·런타임 · 원리·구현·운영
기술 지식 트리

C++에서 메모리 주소가 읽을 수 있어 보인다는 사실과 그 주소를 특정 타입의 살아 있는 객체로 사용해도 된다는 사실은 다릅니다. 외부 바이트 디코딩, 컨테이너 재배치, 비동기 callback을 각각 크기·표현·수명·무효화 계약으로 나누어 확인해야 우연히 동작한 실행을 정의된 동작으로 오해하지 않습니다.

주소 접근과 객체 유효성의 구분

수신 버퍼의 네 바이트를 uint32_t 포인터로 캐스팅해 읽는 코드를 생각해 보겠습니다. 길이가 네 바이트 이상이라는 검사만으로 충분하지 않습니다. 주소 정렬·객체 수명·타입 접근 규칙·byte order가 맞아야 합니다. 운 좋게 한 CPU에서 값이 나왔다고 언어 계약이 유효한 것은 아닙니다.

vector에서 원소 주소를 보관한 경우도 비슷합니다. 주소가 여전히 매핑되어 있다고 그 주소의 객체가 계속 같은 논리 원소라고 보장할 수 없습니다. 저장 공간의 존재와 객체의 정체성·수명을 나누어야 합니다.

외부 바이트와 로컬 객체 표현의 형식 구분

조건 질문 크기 검사만으로 알 수 있나
정렬 이 타입 접근에 맞는 주소인가 아니오
수명 해당 타입 객체가 이 위치에서 살아 있는가 아니오
타입 접근 이 glvalue 타입으로 접근할 수 있는가 아니오
표현 endian·padding·필드 폭이 맞는가 아니오
값 의미 길이·enum·범위가 도메인에 맞는가 아니오

reinterpret_cast로 버퍼 주소를 T*로 바꿔도 그 위치에서 임의의 T 객체 수명이 시작되지는 않습니다. 일부 implicit-lifetime 타입과 memcpy 같은 메모리 연산의 적용 규칙은 표준 버전에 따라 정해지므로, memcpy만 하면 모든 타입이 생성된다고 일반화하지 않습니다. packed 구조체는 구현 기능으로 padding과 정렬의 일부만 바꿀 뿐, aliasing·객체 수명·외부 포인터의 유효성은 대신 보장하지 않습니다.

지정 필드의 바이트 단위 해석

8비트 바이트와 uint32_t를 제공하는 구현에서, big-endian 4바이트를 읽는 예제입니다. 필요한 헤더는 <cstdint>, <span>, <stdexcept>입니다.

std::uint32_t read_be32(std::span<const std::uint8_t> input) {
    if (input.size() < 4) throw std::runtime_error("truncated length");
    return (std::uint32_t(input[0]) << 24)
         | (std::uint32_t(input[1]) << 16)
         | (std::uint32_t(input[2]) << 8)
         |  std::uint32_t(input[3]);
}

각 바이트를 unsigned 32비트로 변환한 뒤 shift해 부호 있는 좁은 정수 승격의 함정을 피합니다. 결과를 얻은 뒤에도 최대 허용 길이와 남은 입력 바이트를 검사해야 합니다. 구조체 포인터를 복원하거나 외부 값을 로컬 포인터로 사용하는 방식은 피합니다.

trivially copyable 객체의 표현 복사에 memcpy·bit_cast를 사용할 수 있지만 크기·타입·유효 표현 등 조건이 있습니다. 특히 bit_cast는 크기가 같은 trivially copyable 타입이라는 전제를 요구하며 의미 있는 유효 값을 임의 입력에서 보장해 주는 범용 디코더가 아닙니다. enum·bool·포인터처럼 외부 비트를 그대로 신뢰하기 어려운 값은 명시적으로 검증합니다.

Vector 연산별 무효화 범위

연산 재할당 여부 원소 포인터·참조·iterator
push_back 재할당 발생 기존 것 모두 무효화
push_back 재할당 없음 기존 원소는 유지, 옛 end는 무효화
중간 insert 재할당 없음 삽입 위치와 그 뒤는 무효화
erase 일반적으로 용량 유지 삭제 위치와 그 뒤는 무효화
reserve 기존 capacity 초과로 재할당 모두 무효화
재할당 뒤에는 새 저장소의 원소가 됩니다옛 vector 저장소: callback이 원소 주소 보관. 새 저장소에 원소 구성. 옛 원소·저장소 수명 종료capacity 경계 확장이전 완료옛 vector 저장소callback이 원소 주소 보관새 저장소에 원소 구성옛 원소·저장소 수명 종료
재할당 뒤에는 새 저장소의 원소가 됩니다 화살표는 원소 이전과 옛 저장소 종료입니다. 옛 주소를 보관한 callback은 reserve를 했다는 과거 사실만으로 새 원소를 사용할 수 없습니다.
그림을 글로 읽기
  • 옛 vector 저장소: callback이 원소 주소 보관
  • 새 저장소에 원소 구성
  • 옛 원소·저장소 수명 종료
  1. 옛 vector 저장소 → 새 저장소에 원소 구성: capacity 경계 확장
  2. 새 저장소에 원소 구성 → 옛 원소·저장소 수명 종료: 이전 완료

reserve는 정해진 크기 안에서 재할당을 줄일 뿐 영구 안정성을 만들지 않습니다. erase 뒤 같은 인덱스에는 다른 원소가 올 수 있습니다. 무효화된 iterator를 역참조해 값이 그럴듯한지 확인하는 방식 자체가 잘못된 검사입니다.

비동기 참조와 저장소 변경에 따른 객체 수명 설계

callback이 끝날 때까지 구조 변경을 금지하거나, 객체를 독립 할당해 안정적인 소유 참조를 전달하거나, ID·세대를 통해 현재 객체를 다시 찾는 방식을 선택할 수 있습니다. vector<unique_ptr<T>>에서는 vector가 이동해도 소유된 T의 주소는 유지될 수 있지만 해당 원소 삭제가 T를 파괴한다는 조건은 남습니다.

list·deque도 모든 iterator·참조가 모든 연산에서 유지되는 것은 아닙니다. 필요한 연산의 표준 계약을 확인해야 합니다. 객체 풀의 같은 주소 재사용도 옛 포인터를 영구 유효하게 만들지 않으며 이전 소멸과 새 수명 시작·별칭 사용 규칙을 지켜야 합니다.

정의된 동작과 진단용 실패의 구분

디코더는 잘린 입력·주소 offset·최댓값·허용 범위를 시험하고 알려진 바이트 벡터와 결과를 대조합니다. vector는 capacity를 의도적으로 넘기고 새 유효 iterator를 다시 얻어 결과를 검사합니다. 무효 참조 진단이 필요하면 별도 격리 테스트와 sanitizer·디버그 iterator 모드를 사용하고 결과를 정상 프로그램의 기대 동작으로 기록하지 않습니다.

진단 순서는 먼저 길이·offset·endian을 확인하고, 다음으로 vector의 capacity 경계를 넘는 연산 전후에 iterator를 새로 얻습니다. 예측 결과는 잘린 입력이 예외나 명시적 거절로 끝나고, 재할당 뒤 옛 포인터는 사용하지 않으며, erase 뒤에는 삭제 위치 이후의 논리 원소를 새 iterator로만 관찰하는 것입니다. sanitizer 통과는 이 순서를 보조할 뿐 수명 규칙을 대신 증명하지 않습니다.

sanitizer가 모든 aliasing·수명 UB를 찾지는 못합니다. scripts/verify-cpp-study.cpp를 Apple Clang 21.0.0, C++20, ASan·UBSan으로 실행해 offset이 있는 바이트 입력·잘린 입력 거절과 재할당 없는 append·erase 뒤 새 원소 조회를 확인했습니다. 무효화된 포인터를 일부러 역참조하지 않았으며, 이 통과를 모든 아키텍처·객체 수명 규칙의 증명으로 보지 않습니다.

후속·관련 개념

이 개념의 확장

함께 읽을 문서

전체 기술 지식
선택 연습 · 관련 질문 2개

질문은 이해를 확인하는 보조 자료입니다. 학습 경로와 문서의 범위는 질문 수와 독립적입니다.