C++20 이전의 **Empty Base Optimization (EBO)**는 빈 기반 클래스가 파생 클래스 데이터 멤버와 메모리 주소를 공유할 수 있도록 허용하여 효과적으로 제로 저장소를 소비했습니다. 그러나 데이터 멤버는 엄격하게 고유한 주소와 비제로 크기를 가져야 했으며, 이로 인해 std::map과 같은 컨테이너의 상태 없는 할당자는 노드 크기를 부풀리거나 불안정한 비공식 상속에 의존해야 했습니다. [[no_unique_address]] 속성은 비정적 데이터 멤버가 그 유형이 비어 있을 경우 제로 바이트를 차지하도록 명시적으로 허용하여 상태 없는 할당자 저장소를 위해 상속보다 구성을 허용하며 STL 컨테이너에서 최적의 메모리 밀도를 유지합니다.
C++98 할당자 모델은 주로 상태 없는 펑터를 사용했으며, EBO를 통한 상속이 표준 컨테이너에서 저장소 오버헤드를 피하는 표준 기술이었습니다. C++11이 범위 할당자 및 복잡한 할당자 전파 특성을 도입함에 따라 상태 있는 할당자로부터 상속하는 복잡성이 증가하여 변형 간의 전환 시 정의되지 않은 동작이나 레이아웃 비효율성의 위험이 증가했습니다. C++20은 [[no_unique_address]] 속성을 표준화하여 취약한 상속 계층 구조 없이 제로 오버헤드 구성을 위한 1급 언어 지원을 제공합니다.
C++ 객체 모델은 완전한 객체와 잠재적으로 겹치는 하위 객체가 독특한 비제로 크기와 고유한 주소를 가져야 한다고 명시합니다. 동일 클래스의 두 데이터 멤버가 그 유형이 비어 있더라도 메모리 위치를 공유하는 것을 방지합니다. std::list 또는 std::map과 같은 노드 기반 컨테이너의 경우 각 노드는 일반적으로 할당자 인스턴스를 저장하며, 최적화 없이는 상태 없는 할당자가 최소 하나의 바이트를 추가하여(정렬을 위해 반올림) 수백만 개의 작은 노드의 메모리 소비를 크게 증가시킵니다. 전통적인 우회 방법은 비공식 상속을 사용하여 클래스 계층 구조를 복잡하게 만들고 할당자를 상태 있는 대안으로 쉽게 교체하는 것을 방해했습니다.
[[no_unique_address]] 속성은 데이터 멤버가 고유한 주소를 필요로 하지 않음을 컴파일러에 신호를 주며, 멤버의 유형이 비어 있는 트리비얼 복사 가능 클래스일 경우 다른 하위 객체와 동일한 메모리 위치에 배치될 수 있도록 합니다. 이를 통해 컨테이너 구현자는 상태 없는 유형에 대해 제로 저장 비용을 보장하면서 할당자를 직접 구성원으로 선언할 수 있으며, 컴파일러는 자동으로 패딩 및 레이아웃을 조정합니다. 이 속성은 엄격한 별칭 규칙과 객체 생명 주기 의미를 보존하며, 주석이 달린 멤버에 대해 주소 고유성 제약만 완화합니다.
#include <iostream> #include <memory> #include <cstdint> // 상태 없는 할당자 예 template <typename T> struct EmptyAllocator { using value_type = T; EmptyAllocator() = default; template <typename U> EmptyAllocator(const EmptyAllocator<U>&) {} T* allocate(std::size_t n) { return std::allocator<T>().allocate(n); } void deallocate(T* p, std::size_t n) { std::allocator<T>().deallocate(p, n); } // 빈 유형 bool operator==(const EmptyAllocator&) const = default; }; // [[no_unique_address]]가 있는 노드 template <typename T, typename Alloc = EmptyAllocator<T>> struct NodeOptimized { [[no_unique_address]] Alloc allocator; // Alloc이 비어있을 경우 제로 바이트 T value; NodeOptimized* next; explicit NodeOptimized(const T& val) : value(val), next(nullptr) {} }; // 최적화 없는 노드 (비교용) template <typename T, typename Alloc = EmptyAllocator<T>> struct NodeNaive { Alloc allocator; // 항상 1+ 바이트 T value; NodeNaive* next; explicit NodeNaive(const T& val) : value(val), next(nullptr) {} }; int main() { std::cout << "최적화된 노드 크기: " << sizeof(NodeOptimized<int>) << " 바이트 "; std::cout << "단순 노드 크기: " << sizeof(NodeNaive<int>) << " 바이트 "; // 일반적인 구현에서 최적화된 것은 16 바이트 (8+4+4 또는 유사) // 반면에 단순 노드는 24 바이트 (1을 8로 패딩 + 8 + 4 + 패딩) return 0; }
저지연 거래 인프라 프로젝트에서 팀은 각 노드가 제한 주문을 나타내는 사용자 정의 침습적 레드-블랙 트리를 구현해야 했습니다. 시스템은 시장 시간 동안 고정 크기 블록을 위한 스택 할당자 및 백테스팅 시나리오를 위한 std::allocator와 같은 플러그 가능 메모리 전략을 요구했습니다.
초기 구현은 할당자로부터 비공식 상속을 사용하여 Empty Base Optimization을 활용했으며, 표준 할당자가 비용이 제로 바이트일 것이라고 가정했습니다.
// 초기 접근법: 상속 기반 EBO template <typename T, typename Alloc> class OrderNode : private Alloc { // 불편: Alloc은 기반입니다 T data; OrderNode* left; OrderNode* right; Color color; public: // 문제: Alloc에 'left' 또는 'color'라는 이름의 메서드가 있는 경우 모호성 // 문제: 상태가 있을 경우 Alloc을 구성원으로 쉽게 저장할 수 없음 };
이 접근법은 부서지기 쉬운 것으로 판명되었습니다. 위험 관리 팀이 메모리 사용 카운터를 추적하는 상태 있는 감사 할당자를 요구했을 때, 구성원 변수를 변경하는 것은 정렬 때문에 즉시 노드당 8바이트의 팽창을 초래하여 전체 메모리 사용량을 40% 증가시키고 캐시 성능을 저하했습니다.
대안 솔루션 A: std::variant로 타입 지워진 저장소.
팀은 상태 있는 경우 할당자에 대한 포인터를 저장하거나 상태 없는 경우 아무것도 저장하는 것을 고려했습니다 std::variant 또는 수동 타입 지우기를 사용합니다.
장점: 상태 있는 할당자 및 상태 없는 할당자에 대한 통합 인터페이스, 템플릿 폭발 없이.
단점: 상태 있는 할당자를 위한 간접 비용, 그리고 변형 자체는 판별자 저장소를 위해 최소 1 바이트(정렬 추가)가 필요하므로 상태 없는 할당자를 위한 제로 오버헤드 요구를 충족하지 못했습니다.
대안 솔루션 B: 고유 클래스에 대한 템플릿 특수화.
그들은 std::is_empty_v<Alloc>에 따라 전체 OrderNode 클래스를 특수화하여 비어 있을 경우 상속하고 상태 있을 경우 구성을 평가했습니다.
장점: 비어 있는 경우 제로 오버헤드를 보장합니다.
단점: 두 개의 특수화 간의 코드 중복, 두 배의 컴파일 시간 및 새로운 노드 필드를 추가할 때 유지 관리 악몽, 변경이 두 템플릿 분기 모두에서 반영되어야 했습니다.
선택된 솔루션 및 결과:
팀은 C++20로 마이그레이션하고 할당자 구성원에 [[no_unique_address]]를 적용했습니다.
template <typename T, typename Alloc> struct OrderNode { [[no_unique_address]] Alloc alloc; // 비어 있을 경우 제로 비용 T data; OrderNode* left; OrderNode* right; // ... 나머지 구현 };
이 설계는 상속의 필요성을 제거하면서 생산 스택 할당자에 대해 제로 바이트의 오버헤드를 유지했습니다. 감사 할당자(상태 있음)를 대체하면 구성원이 자동으로 카운터를 수용하기 위해 확장되며 코드 변경이 필요하지 않았습니다. 벤치 마크는 캐시 결핍률이 15% 감소했다고 보고하였으며, 상위 클래스 계층에서 보다 나은 컴파일러 최적화 덕분에 코드베이스가 상당히 유지 관리 가능해졌습니다.
동일한 빈 클래스 유형의 두 [[no_unique_address]] 데이터 멤버가 동일한 메모리 주소를 차지할 수 있습니까?
아니요, 그럴 수 없습니다. [[no_unique_address]]가 다른 하위 객체와 관련하여 고유 주소 요구 사항을 없애지만, C++는 여전히 동일한 유형의 완전한 객체가 고유한 주소를 가져야 한다고 요구합니다. 비어 있는 클래스 유형의 주석이 달린 두 멤버 m1 및 m2가 있는 경우 컴파일러는 별도의 스토리지를 할당해야 합니다(일반적으로 정렬에 따라 각각 1 바이트). 이 속성은 단지 서로 다른 유형의 멤버나 기본 클래스 하위 객체와의 겹침을 허용합니다.
[[no_unique_address]]가 offsetof 및 표준 레이아웃 유형과 어떻게 상호작용합니까?
상호작용은 미묘하고 잠재적으로 위험합니다. 클래스에 [[no_unique_address]] 멤버가 포함된 경우 여전히 표준 레이아웃일 수 있지만, 그러한 멤버에 대해 offsetof를 호출하면 멤버가 비어 있고 다른 하위 객체와 겹치는 경우 구현 정의된 결과가 발생합니다. 게다가 표준 레이아웃 규칙은 비정적 데이터 멤버가 선언 순서대로 서로 다른 바이트를 차지한다고 가정하므로, 비어 있는 멤버를 다음 멤버와 겹치면 일부 레거시 코드가 만드는 엄격한 순서 가설을 기술적으로 위반합니다. 개발자는 [[no_unique_address]] 멤버에 대해 offsetof에 기반한 포인터 산술을 피하고 대신 std::addressof를 사용하는 것이 좋습니다.
기본 클래스에 대해 [[no_unique_address]]가 불필요한 이유와 그것이 상속에 비해 피할 수 있는 위험은 무엇입니까?
기본 클래스는 속성이 없이도 본질적으로 Empty Base Optimization을 받을 수 있는데, 비어 있는 기본 하위 객체가 파생 클래스의 첫 번째 비정적 데이터 멤버의 주소를 공유할 수 있습니다. [[no_unique_address]]는 이 기능을 데이터 멤버에 부여하기 위해 존재하며, 구성을 가능하게 합니다. 데이터 멤버를 사용하면 불안정한 상속의 이름 숨김 및 다중 상속 모호성의 함정을 피할 수 있습니다. 예를 들어, 컨테이너가 중첩된 pointer typedef를 정의하는 할당자로부터 상속했고, 컨테이너도 고유한 pointer 유형을 정의한다면, 한정되지 않은 조회는 기본 클래스 멤버로 해결되어 모호한 컴파일 오류를 초래할 수 있습니다. [[no_unique_address]]가 있는 데이터 멤버는 레이아웃 효율성을 유지하면서 이러한 범위 오염을 제거합니다.