C++ EBCO와 [[no_unique_address]] | "빈 베이스 최적화" 완벽 가이드
이 글의 핵심
빈 클래스가 메모리를 차지하는 문제를 해결하는 EBCO와 C++20 [[no_unique_address]]. std::tuple, std::unique_ptr 구현 비밀, 메모리 레이아웃 최적화, 실전 패턴까지.
들어가며: “빈 클래스인데 왜 1바이트를 차지하나요?"
"std::unique_ptr<T, Deleter>가 왜 8바이트가 아니라 16바이트죠?”
C++에서 빈 클래스(Empty Class—멤버 변수가 없는 클래스)도 최소 1바이트를 차지합니다. 이는 각 객체가 고유한 주소를 가져야 하기 때문입니다. 하지만 이 규칙 때문에 상태 없는 함수 객체(Stateless Functor)나 커스텀 삭제자(Custom Deleter)를 멤버로 가질 때 불필요한 메모리를 낭비하게 됩니다.
EBCO(Empty Base Class Optimization—빈 베이스 클래스 최적화)는 빈 클래스를 베이스로 상속받으면 크기가 0이 되는 컴파일러 최적화입니다. C++20의 [[no_unique_address]] 속성은 멤버 변수에도 이 최적화를 적용할 수 있게 해줍니다.
이 글에서 다루는 것:
- 빈 클래스 규칙: 왜 1바이트를 차지하는가
- EBCO 원리: 베이스 클래스로 상속 시 크기 0
- [[no_unique_address]]: C++20 멤버 변수 최적화
- 실전 활용: std::tuple, std::unique_ptr, 압축 쌍(compressed pair)
- 문제 시나리오: 메모리 레이아웃 최적화가 필요한 경우
- 완전한 예제: 커스텀 스마트 포인터, 압축 쌍 구현
- 일반적인 에러: EBCO 실패 케이스, ABI 호환성
- 프로덕션 패턴: 표준 라이브러리 구현 기법
실무에서 겪은 문제
실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.
문제 상황과 해결
대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.
실전 경험:
- 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
- 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
- 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다
이 글이 여러분의 시행착오를 줄여주길 바랍니다.
목차
- 문제 시나리오: 빈 클래스가 메모리를 낭비할 때
- 빈 클래스 규칙과 EBCO
- EBCO 동작 원리
- C++20 [[no_unique_address]]
- 완전한 실전 예제
- 자주 발생하는 에러와 해결법
- 표준 라이브러리 구현 사례
- 프로덕션 패턴
- 정리
1. 문제 시나리오: 빈 클래스가 메모리를 낭비할 때
시나리오 1: “unique_ptr에 커스텀 삭제자를 넣었더니 크기가 2배가 됐어요”
"std::unique_ptr<int>는 8바이트인데, 커스텀 삭제자를 넣으니 16바이트가 됐어요."
"삭제자는 빈 클래스인데 왜 메모리를 차지하나요?"
원인: std::unique_ptr<T, Deleter>는 내부적으로 T* ptr과 Deleter del을 멤버로 가집니다. Deleter가 빈 클래스여도 최소 1바이트를 차지하고, 정렬(alignment) 때문에 8바이트로 패딩됩니다.
// ❌ 나이브한 구현
template <typename T, typename Deleter = std::default_delete<T>>
struct NaiveUniquePtr {
T* ptr; // 8바이트
Deleter deleter; // 빈 클래스여도 1바이트 + 패딩 → 8바이트
// 총 16바이트
};
struct EmptyDeleter {
void operator()(int* p) const { delete p; }
};
static_assert(sizeof(NaiveUniquePtr<int, EmptyDeleter>) == 16);
시나리오 2: “std::tuple에 빈 타입이 섞여 있는데 크기가 커요”
"std::tuple<int, EmptyTag, double>이 24바이트예요."
"EmptyTag는 상태가 없는데 왜 공간을 차지하나요?"
원인: 각 멤버가 고유한 주소를 가져야 하므로, 빈 클래스도 최소 1바이트 + 정렬 패딩이 필요합니다.
struct EmptyTag {};
// ❌ 나이브한 tuple 구현
template <typename... Ts>
struct NaiveTuple;
template <typename T1, typename T2, typename T3>
struct NaiveTuple<T1, T2, T3> {
T1 first; // 4바이트 (int)
T2 second; // 1바이트 (EmptyTag) + 3바이트 패딩
T3 third; // 8바이트 (double)
// 총 16바이트 (정렬 때문에 24바이트가 될 수도)
};
시나리오 3: “할당자를 멤버로 가진 컨테이너가 너무 커요”
"std::vector<int, MyAllocator>를 수천 개 만드는데 메모리가 부족해요."
"MyAllocator는 상태가 없는 빈 클래스인데 왜 공간을 차지하나요?"
원인: 컨테이너는 할당자를 멤버로 저장합니다. 빈 할당자여도 1바이트 + 패딩이 필요합니다.
시나리오 4: “압축 쌍(compressed pair)을 직접 구현하고 싶어요”
"Boost의 compressed_pair처럼 빈 타입일 때 크기를 줄이고 싶어요."
"std::pair는 항상 두 멤버를 다 저장해서 비효율적이에요."
원인: std::pair는 EBCO를 적용하지 않습니다. 빈 타입이어도 크기가 줄어들지 않습니다.
시나리오 5: “표준 라이브러리 구현을 이해하고 싶어요”
"std::tuple, std::unique_ptr, std::shared_ptr 내부 구현이 궁금해요."
"어떻게 크기를 최소화하는지 알고 싶어요."
원인: 표준 라이브러리는 EBCO와 [[no_unique_address]]를 적극 활용해 메모리를 최적화합니다.
2. 빈 클래스 규칙과 EBCO
빈 클래스도 1바이트를 차지하는 이유
C++ 표준에서 모든 객체는 고유한 주소를 가져야 합니다. 빈 클래스여도 배열의 각 원소가 서로 다른 주소를 가지려면 최소 1바이트가 필요합니다.
struct Empty {};
int main() {
Empty e1, e2;
std::cout << sizeof(Empty) << '\n'; // 1
std::cout << &e1 << " vs " << &e2 << '\n'; // 서로 다른 주소
Empty arr[10];
std::cout << &arr[0] << " vs " << &arr[1] << '\n'; // 1바이트씩 떨어짐
}
출력:
1
0x7ffc1234 vs 0x7ffc1235
0x7ffc1240 vs 0x7ffc1241
멤버로 가질 때의 문제
struct Empty {};
struct Container {
int value; // 4바이트
Empty empty; // 1바이트 + 3바이트 패딩 (정렬 때문)
// 총 8바이트
};
static_assert(sizeof(Container) == 8);
문제: Empty는 상태가 없는데 4바이트(패딩 포함)를 낭비합니다.
EBCO: 베이스 클래스로 상속 시 크기 0
EBCO(Empty Base Class Optimization)는 빈 베이스 클래스의 크기를 0으로 만드는 컴파일러 최적화입니다.
struct Empty {};
struct Optimized : Empty {
int value; // 4바이트
// Empty는 크기 0 → 총 4바이트
};
static_assert(sizeof(Optimized) == 4);
핵심: 베이스 클래스는 배열의 원소가 아니므로 고유 주소 규칙이 완화됩니다. 컴파일러는 빈 베이스를 파생 클래스의 시작 주소와 같게 배치해 공간을 절약합니다.
EBCO 메모리 레이아웃
멤버로 가질 때 (Container):
┌──────────────────────────────┐
│ value (4바이트) │ Empty (1바이트) │ padding (3바이트) │
└──────────────────────────────┘
총 8바이트
베이스로 상속 시 (Optimized):
┌──────────────────────────────┐
│ Empty (0바이트) │ value (4바이트) │
└──────────────────────────────┘
총 4바이트
EBCO가 적용되는 조건
- 베이스 클래스여야 함 (멤버 변수는 안 됨)
- 빈 클래스여야 함 (비정적 멤버 변수가 없음)
- 가상 함수가 없어야 함 (vtable 포인터가 있으면 빈 클래스가 아님)
- 첫 번째 멤버와 타입이 달라야 함 (같은 타입이면 주소가 겹칠 수 있음)
3. EBCO 동작 원리
단일 상속에서의 EBCO
struct EmptyBase {};
struct Derived : EmptyBase {
int x;
};
int main() {
Derived d;
std::cout << "sizeof(Derived): " << sizeof(Derived) << '\n'; // 4
std::cout << "Address of base: " << static_cast<EmptyBase*>(&d) << '\n';
std::cout << "Address of derived: " << &d << '\n';
// 베이스와 파생 클래스의 주소가 같음
}
출력:
sizeof(Derived): 4
Address of base: 0x7ffc1234
Address of derived: 0x7ffc1234
핵심: 빈 베이스 클래스는 파생 클래스의 시작 주소와 동일한 주소를 가지므로 추가 공간이 필요 없습니다.
다중 상속에서의 EBCO
struct Empty1 {};
struct Empty2 {};
struct MultiDerived : Empty1, Empty2 {
int x;
};
static_assert(sizeof(MultiDerived) == 4); // 두 빈 베이스 모두 0바이트
주의: 같은 타입을 여러 번 상속하면 모호성(ambiguity) 문제가 발생합니다.
struct Empty {};
// ❌ 컴파일 에러: 같은 베이스를 두 번 상속
struct Bad : Empty, Empty { // error: duplicate base type
int x;
};
EBCO 실패 케이스 1: 첫 번째 멤버와 타입이 같을 때
struct Empty {};
struct Container : Empty {
Empty first; // ❌ 베이스와 타입이 같음 → EBCO 실패
int second;
};
static_assert(sizeof(Container) == 8); // Empty가 1바이트 + 패딩
이유: first와 베이스 Empty가 같은 주소를 가지면 서로 다른 객체인데 주소가 같아지는 문제가 발생합니다. 컴파일러는 이를 방지하기 위해 EBCO를 적용하지 않습니다.
EBCO 실패 케이스 2: 가상 함수가 있을 때
struct VirtualBase {
virtual ~VirtualBase() = default;
};
struct Derived : VirtualBase {
int x;
};
static_assert(sizeof(Derived) == 16); // vtable 포인터 8바이트 + int 4바이트 + 패딩
이유: 가상 함수가 있으면 vtable 포인터(8바이트)가 필요하므로 빈 클래스가 아닙니다.
4. C++20 [[no_unique_address]]
멤버 변수에도 EBCO 적용하기
C++20 이전에는 베이스 클래스로만 EBCO를 적용할 수 있었습니다. 하지만 [[no_unique_address]] 속성을 사용하면 멤버 변수에도 EBCO를 적용할 수 있습니다.
struct Empty {};
struct WithAttribute {
[[no_unique_address]] Empty empty;
int value;
};
static_assert(sizeof(WithAttribute) == 4); // Empty가 0바이트
베이스 상속 vs [[no_unique_address]]
struct Empty {};
// 방법 1: EBCO (베이스 상속)
struct WithEBCO : Empty {
int value;
};
// 방법 2: [[no_unique_address]] (C++20)
struct WithAttribute {
[[no_unique_address]] Empty empty;
int value;
};
static_assert(sizeof(WithEBCO) == 4);
static_assert(sizeof(WithAttribute) == 4);
장점:
- 멤버 접근이 명확:
obj.empty로 접근 (베이스는static_cast<Empty&>(obj)필요) - private 상속 불필요: 멤버로 선언하면 됨
- 다중 상속 복잡도 회피: 같은 타입을 여러 개 가질 수 있음
[[no_unique_address]] 여러 개 사용
struct Empty1 {};
struct Empty2 {};
struct Multi {
[[no_unique_address]] Empty1 e1;
[[no_unique_address]] Empty2 e2;
int value;
};
static_assert(sizeof(Multi) == 4); // 두 빈 멤버 모두 0바이트
같은 타입 여러 개
struct Empty {};
struct SameType {
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2;
int value;
};
// 컴파일러마다 다름: GCC/Clang은 8바이트, MSVC는 4바이트
// e1과 e2가 같은 주소를 가질 수 없으므로 하나는 1바이트 차지
주의: 같은 타입을 여러 개 가지면 하나는 1바이트를 차지할 수 있습니다. 표준은 이를 구현 정의(implementation-defined)로 남겨둡니다.
5. 완전한 실전 예제
예제 1: 압축 쌍 (Compressed Pair) 구현
목표: std::pair처럼 두 값을 저장하되, 빈 타입일 때 크기를 줄입니다.
#include <iostream>
#include <type_traits>
// C++20: [[no_unique_address]] 사용
template <typename T1, typename T2>
struct CompressedPair {
[[no_unique_address]] T1 first;
[[no_unique_address]] T2 second;
CompressedPair() = default;
CompressedPair(const T1& f, const T2& s) : first(f), second(s) {}
T1& getFirst() { return first; }
const T1& getFirst() const { return first; }
T2& getSecond() { return second; }
const T2& getSecond() const { return second; }
};
// 테스트
struct Empty {};
int main() {
// 일반 std::pair
std::pair<int, Empty> p1;
std::cout << "std::pair<int, Empty>: " << sizeof(p1) << " bytes\n"; // 8
// 압축 쌍
CompressedPair<int, Empty> p2;
std::cout << "CompressedPair<int, Empty>: " << sizeof(p2) << " bytes\n"; // 4
// 두 빈 타입
CompressedPair<Empty, Empty> p3;
std::cout << "CompressedPair<Empty, Empty>: " << sizeof(p3) << " bytes\n"; // 1 (최소)
// 일반 타입
CompressedPair<int, double> p4(42, 3.14);
std::cout << "CompressedPair<int, double>: " << sizeof(p4) << " bytes\n"; // 16
std::cout << "first: " << p4.getFirst() << ", second: " << p4.getSecond() << '\n';
return 0;
}
예제 2: EBCO 기반 압축 쌍 (C++17 호환)
C++20 이전에는 베이스 상속으로 EBCO를 구현합니다.
#include <type_traits>
#include <iostream>
// 빈 타입인지 확인
template <typename T>
constexpr bool is_empty_v = std::is_empty_v<T> && !std::is_final_v<T>;
// 빈 타입이면 베이스로 상속, 아니면 멤버로 저장
template <typename T, int Index, bool = is_empty_v<T>>
struct CompressedElement {
T value;
CompressedElement() = default;
CompressedElement(const T& v) : value(v) {}
T& get() { return value; }
const T& get() const { return value; }
};
// 빈 타입 특수화: 베이스로 상속
template <typename T, int Index>
struct CompressedElement<T, Index, true> : T {
CompressedElement() = default;
CompressedElement(const T& v) : T(v) {}
T& get() { return *this; }
const T& get() const { return *this; }
};
// 압축 쌍
template <typename T1, typename T2>
struct CompressedPair :
private CompressedElement<T1, 0>,
private CompressedElement<T2, 1> {
using First = CompressedElement<T1, 0>;
using Second = CompressedElement<T2, 1>;
CompressedPair() = default;
CompressedPair(const T1& f, const T2& s) : First(f), Second(s) {}
T1& first() { return First::get(); }
const T1& first() const { return First::get(); }
T2& second() { return Second::get(); }
const T2& second() const { return Second::get(); }
};
// 테스트
struct Empty {};
int main() {
CompressedPair<int, Empty> p1(42, Empty{});
std::cout << "CompressedPair<int, Empty>: " << sizeof(p1) << " bytes\n"; // 4
std::cout << "first: " << p1.first() << '\n';
CompressedPair<Empty, double> p2(Empty{}, 3.14);
std::cout << "CompressedPair<Empty, double>: " << sizeof(p2) << " bytes\n"; // 8
std::cout << "second: " << p2.second() << '\n';
CompressedPair<Empty, Empty> p3;
std::cout << "CompressedPair<Empty, Empty>: " << sizeof(p3) << " bytes\n"; // 1
return 0;
}
코드 상세 설명:
- CompressedElement: 빈 타입이면 베이스로 상속 (EBCO 적용), 아니면 멤버로 저장
- Index 템플릿 인자: 같은 타입을 두 번 상속할 때 서로 다른 베이스로 만들기 위함
- is_final_v 체크:
final클래스는 상속할 수 없으므로 멤버로 저장
예제 3: 커스텀 unique_ptr (EBCO 적용)
#include <iostream>
#include <type_traits>
template <typename T, typename Deleter = std::default_delete<T>>
class CompressedUniquePtr : private Deleter {
T* ptr_;
public:
CompressedUniquePtr(T* p = nullptr) : Deleter(), ptr_(p) {}
~CompressedUniquePtr() {
if (ptr_) {
Deleter::operator()(ptr_);
}
}
CompressedUniquePtr(const CompressedUniquePtr&) = delete;
CompressedUniquePtr& operator=(const CompressedUniquePtr&) = delete;
CompressedUniquePtr(CompressedUniquePtr&& other) noexcept
: Deleter(std::move(other.getDeleter())), ptr_(other.release()) {}
CompressedUniquePtr& operator=(CompressedUniquePtr&& other) noexcept {
if (this != &other) {
reset(other.release());
getDeleter() = std::move(other.getDeleter());
}
return *this;
}
T* get() const { return ptr_; }
T* release() { T* p = ptr_; ptr_ = nullptr; return p; }
void reset(T* p = nullptr) {
if (ptr_) Deleter::operator()(ptr_);
ptr_ = p;
}
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
Deleter& getDeleter() { return *this; }
const Deleter& getDeleter() const { return *this; }
};
// 테스트
struct EmptyDeleter {
void operator()(int* p) const {
std::cout << "EmptyDeleter called\n";
delete p;
}
};
struct StatefulDeleter {
int log_level = 0;
void operator()(int* p) const {
std::cout << "StatefulDeleter (level " << log_level << ") called\n";
delete p;
}
};
int main() {
// 빈 삭제자: 8바이트 (포인터만)
CompressedUniquePtr<int, EmptyDeleter> p1(new int(42));
std::cout << "CompressedUniquePtr<int, EmptyDeleter>: "
<< sizeof(p1) << " bytes\n"; // 8
// 상태 있는 삭제자: 16바이트 (포인터 + 삭제자)
CompressedUniquePtr<int, StatefulDeleter> p2(new int(99));
std::cout << "CompressedUniquePtr<int, StatefulDeleter>: "
<< sizeof(p2) << " bytes\n"; // 16
return 0;
}
출력:
CompressedUniquePtr<int, EmptyDeleter>: 8 bytes
CompressedUniquePtr<int, StatefulDeleter>: 16 bytes
EmptyDeleter called
StatefulDeleter (level 0) called
예제 4: 할당자를 가진 벡터 (EBCO 적용)
#include <vector>
#include <memory>
#include <iostream>
// 상태 없는 할당자
template <typename T>
struct EmptyAllocator {
using value_type = T;
T* allocate(size_t n) { return static_cast<T*>(::operator new(n * sizeof(T))); }
void deallocate(T* p, size_t) { ::operator delete(p); }
template <typename U>
struct rebind { using other = EmptyAllocator<U>; };
};
template <typename T, typename Allocator>
class CompressedVector : private Allocator {
T* data_;
size_t size_;
size_t capacity_;
public:
CompressedVector() : Allocator(), data_(nullptr), size_(0), capacity_(0) {}
~CompressedVector() {
if (data_) {
for (size_t i = 0; i < size_; ++i) {
data_[i].~T();
}
Allocator::deallocate(data_, capacity_);
}
}
void push_back(const T& value) {
if (size_ == capacity_) {
size_t new_cap = capacity_ == 0 ? 1 : capacity_ * 2;
T* new_data = Allocator::allocate(new_cap);
for (size_t i = 0; i < size_; ++i) {
new (&new_data[i]) T(std::move(data_[i]));
data_[i].~T();
}
if (data_) Allocator::deallocate(data_, capacity_);
data_ = new_data;
capacity_ = new_cap;
}
new (&data_[size_]) T(value);
++size_;
}
size_t size() const { return size_; }
T& operator { return data_[i]; }
};
int main() {
CompressedVector<int, EmptyAllocator<int>> vec;
std::cout << "CompressedVector size: " << sizeof(vec) << " bytes\n"; // 24
// data_ (8) + size_ (8) + capacity_ (8) + EmptyAllocator (0)
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
std::cout << "Elements: " << vec[0] << ", " << vec[1] << ", " << vec[2] << '\n';
return 0;
}
예제 5: 함수 객체 저장 (EBCO)
#include <iostream>
#include <functional>
// 상태 없는 함수 객체
struct Multiplier {
int operator()(int x, int y) const { return x * y; }
};
// EBCO 적용 컨테이너
template <typename Func>
struct Calculator : private Func {
int compute(int a, int b) {
return Func::operator()(a, b);
}
};
int main() {
Calculator<Multiplier> calc;
std::cout << "Calculator size: " << sizeof(calc) << " bytes\n"; // 1
std::cout << "Result: " << calc.compute(3, 4) << '\n'; // 12
return 0;
}
6. 자주 발생하는 에러와 해결법
에러 1: 같은 타입을 두 번 상속
증상: error: duplicate base type
struct Empty {};
// ❌ 컴파일 에러
struct Bad : Empty, Empty {
int x;
};
해결: 태그 타입(tag type)으로 구분합니다.
template <int N>
struct EmptyTag {};
struct Good : EmptyTag<0>, EmptyTag<1> {
int x;
};
static_assert(sizeof(Good) == 4);
에러 2: final 클래스 상속 시도
증상: error: cannot derive from 'final' base
struct FinalEmpty final {};
// ❌ 컴파일 에러
struct Bad : FinalEmpty {
int x;
};
해결: final 클래스는 [[no_unique_address]] 로 멤버로 저장하거나, final을 제거합니다.
struct FinalEmpty final {};
struct Good {
[[no_unique_address]] FinalEmpty empty;
int x;
};
static_assert(sizeof(Good) == 4); // C++20
에러 3: 가상 함수가 있는 베이스
증상: EBCO가 적용되지 않아 크기가 줄지 않음.
struct VirtualBase {
virtual void foo() {}
};
struct Derived : VirtualBase {
int x;
};
static_assert(sizeof(Derived) == 16); // vtable 포인터 때문에
해결: 빈 클래스에는 가상 함수를 넣지 않습니다. 다형성이 필요하면 CRTP(Curiously Recurring Template Pattern)나 std::variant를 고려합니다.
에러 4: 첫 번째 멤버와 베이스 타입이 같음
증상: EBCO가 적용되지 않음.
struct Empty {};
struct Bad : Empty {
Empty first; // ❌ 베이스와 타입이 같음
int second;
};
static_assert(sizeof(Bad) == 8); // EBCO 실패
해결: 첫 번째 멤버를 다른 타입으로 바꾸거나, [[no_unique_address]] 사용.
struct Good : Empty {
int first; // ✅ 타입이 다름
Empty second; // 이건 멤버로 1바이트
};
static_assert(sizeof(Good) == 8);
// 또는 C++20
struct Better {
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2; // 같은 타입이지만 구현마다 다름
int value;
};
에러 5: 정렬 요구사항 무시
증상: 빈 클래스에 alignas가 있으면 EBCO가 제한됨.
struct alignas(16) AlignedEmpty {};
struct Container : AlignedEmpty {
int x;
};
static_assert(sizeof(Container) == 16); // 정렬 때문에 16바이트
해결: 빈 베이스에 과도한 정렬 요구사항을 넣지 않습니다.
에러 6: ABI 호환성 문제
증상: 라이브러리를 C++17로 빌드하고 C++20으로 재빌드하면 크기가 달라져 ABI가 깨짐.
struct Empty {};
// C++17: 멤버로 저장 → 8바이트
struct Data {
Empty e;
int x;
};
// C++20: [[no_unique_address]] 추가 → 4바이트
struct Data {
[[no_unique_address]] Empty e;
int x;
};
해결: ABI 안정성이 중요하면 [[no_unique_address]]를 조심스럽게 도입하거나, 버전별로 별도 네임스페이스를 사용합니다.
에러 7: 배열에서 EBCO 미적용
증상: 빈 클래스 배열은 각 원소가 1바이트씩 차지.
struct Empty {};
struct Container : Empty {
Empty arr[10]; // ❌ 배열은 EBCO 적용 안 됨 → 10바이트
};
static_assert(sizeof(Container) >= 10);
이유: 배열의 각 원소는 고유한 주소를 가져야 하므로 EBCO가 적용되지 않습니다.
7. 표준 라이브러리 구현 사례
std::unique_ptr 내부 구조
// 표준 라이브러리 유사 구현 (단순화)
template <typename T, typename Deleter>
class unique_ptr : private Deleter { // EBCO 적용
T* ptr_;
public:
unique_ptr(T* p = nullptr) : Deleter(), ptr_(p) {}
~unique_ptr() {
if (ptr_) Deleter::operator()(ptr_);
}
// 이동 생성자/대입 연산자 생략
T* get() const { return ptr_; }
Deleter& get_deleter() { return *this; }
};
// 빈 삭제자: 8바이트 (포인터만)
struct EmptyDeleter {
void operator()(int* p) const { delete p; }
};
static_assert(sizeof(unique_ptr<int, EmptyDeleter>) == 8);
// 상태 있는 삭제자: 16바이트
struct StatefulDeleter {
int log_level;
void operator()(int* p) const { delete p; }
};
static_assert(sizeof(unique_ptr<int, StatefulDeleter>) == 16);
std::tuple 내부 구조 (재귀 상속)
// 표준 라이브러리 유사 구현
template <typename... Ts>
struct Tuple;
template <>
struct Tuple<> {}; // 빈 tuple
template <typename T, typename... Rest>
struct Tuple<T, Rest...> : Tuple<Rest...> { // 재귀 상속
T value;
Tuple() = default;
Tuple(const T& v, const Rest&... rest)
: Tuple<Rest...>(rest...), value(v) {}
T& get() { return value; }
};
// 테스트
struct Empty {};
int main() {
Tuple<int, Empty, double> t(42, Empty{}, 3.14);
std::cout << "Tuple size: " << sizeof(t) << " bytes\n"; // 16
// int (4) + padding (4) + double (8) + Empty (0, EBCO)
Tuple<Empty, Empty, int> t2;
std::cout << "Tuple size: " << sizeof(t2) << " bytes\n"; // 4
// 두 Empty 모두 EBCO
return 0;
}
std::shared_ptr 제어 블록
// shared_ptr 제어 블록 (단순화)
template <typename T, typename Deleter, typename Allocator>
struct ControlBlock : private Deleter, private Allocator { // EBCO
T* ptr;
std::atomic<int> ref_count;
std::atomic<int> weak_count;
ControlBlock(T* p, Deleter d, Allocator a)
: Deleter(std::move(d)), Allocator(std::move(a)),
ptr(p), ref_count(1), weak_count(1) {}
void release() {
if (--ref_count == 0) {
Deleter::operator()(ptr);
if (--weak_count == 0) {
Allocator::deallocate(this, 1);
}
}
}
};
// 빈 삭제자·할당자: 24바이트
// ptr (8) + ref_count (4) + weak_count (4) + padding (8)
// Deleter (0) + Allocator (0)
std::function 내부 (Small Buffer Optimization + EBCO)
// std::function 유사 구현 (단순화)
template <typename Signature>
class Function;
template <typename R, typename... Args>
class Function<R(Args...)> {
static constexpr size_t BUFFER_SIZE = 16;
alignas(void*) char buffer_[BUFFER_SIZE];
R (*invoke_)(char*, Args...);
void (*destroy_)(char*);
public:
template <typename F>
Function(F f) {
// 작은 함수 객체는 buffer_에 직접 저장 (EBCO 활용)
if constexpr (sizeof(F) <= BUFFER_SIZE && std::is_empty_v<F>) {
new (buffer_) F(std::move(f));
invoke_ = -> R {
return (*reinterpret_cast<F*>(buf))(std::forward<Args>(args)...);
};
destroy_ = {
reinterpret_cast<F*>(buf)->~F();
};
} else {
// 큰 함수 객체는 힙 할당
// (생략)
}
}
R operator()(Args... args) {
return invoke_(buffer_, std::forward<Args>(args)...);
}
~Function() {
if (destroy_) destroy_(buffer_);
}
};
8. 프로덕션 패턴
패턴 1: 압축 쌍으로 메모리 절약
// std::unique_ptr, std::tuple 등에서 사용
template <typename T1, typename T2>
using CompressedPair = /* EBCO 또는 [[no_unique_address]] 구현 */;
// 사용 예: 포인터 + 삭제자
template <typename T, typename Deleter>
class SmartPtr {
CompressedPair<T*, Deleter> data_;
public:
T* get() const { return data_.first(); }
Deleter& get_deleter() { return data_.second(); }
};
패턴 2: 정책 기반 설계 (Policy-Based Design)
// 빈 정책 클래스를 베이스로 상속
template <typename LockPolicy, typename LogPolicy>
class ThreadSafeContainer : private LockPolicy, private LogPolicy {
std::vector<int> data_;
public:
void add(int value) {
typename LockPolicy::Guard lock(LockPolicy::getMutex());
LogPolicy::log("Adding value");
data_.push_back(value);
}
};
// 빈 정책
struct NoLock {
struct Guard { Guard(std::mutex&) {} };
static std::mutex& getMutex() { static std::mutex m; return m; }
};
struct NoLog {
static void log(const char*) {}
};
// 크기: vector (24바이트) + NoLock (0) + NoLog (0) = 24바이트
ThreadSafeContainer<NoLock, NoLog> container;
패턴 3: 타입 태그 (Type Tag)
// 빈 타입으로 오버로딩 선택
struct InputIteratorTag {};
struct RandomAccessIteratorTag {};
template <typename Iter>
struct IteratorTraits : RandomAccessIteratorTag {
// EBCO로 태그 크기 0
};
template <typename Iter>
void advanceImpl(Iter& it, int n, InputIteratorTag) {
// O(n) 구현
for (int i = 0; i < n; ++i) ++it;
}
template <typename Iter>
void advanceImpl(Iter& it, int n, RandomAccessIteratorTag) {
// O(1) 구현
it += n;
}
template <typename Iter>
void advance(Iter& it, int n) {
advanceImpl(it, n, IteratorTraits<Iter>{});
}
패턴 4: 할당자 전파 (Allocator Propagation)
// 컨테이너가 할당자를 베이스로 상속
template <typename T, typename Allocator = std::allocator<T>>
class Vector : private Allocator {
T* data_;
size_t size_;
size_t capacity_;
public:
// 할당자 접근
Allocator& get_allocator() { return *this; }
// 할당 시 베이스의 allocate 호출
void reserve(size_t n) {
T* new_data = Allocator::allocate(n);
// ...
}
};
// 빈 할당자: 24바이트 (data_ + size_ + capacity_)
패턴 5: 상태 기반 최적화 선택
// 상태 유무에 따라 구현 선택
template <typename T, typename Deleter>
class SmartPtr {
using Storage = std::conditional_t<
std::is_empty_v<Deleter> && !std::is_final_v<Deleter>,
CompressedPair<T*, Deleter>, // EBCO 적용
std::pair<T*, Deleter> // 일반 저장
>;
Storage data_;
};
패턴 6: 다중 정책 조합
// 여러 빈 정책을 베이스로 상속
template <typename ErrorPolicy, typename LogPolicy, typename ThreadPolicy>
class Service : private ErrorPolicy, private LogPolicy, private ThreadPolicy {
std::string data_;
public:
void process() {
ThreadPolicy::lock();
LogPolicy::log("Processing");
if (ErrorPolicy::shouldThrow()) {
throw std::runtime_error("Error");
}
ThreadPolicy::unlock();
}
};
// 모든 정책이 빈 클래스면 data_만 차지
패턴 7: 컴파일 타임 플래그
// 빈 타입으로 컴파일 타임 플래그 전달
struct DebugMode {};
struct ReleaseMode {};
template <typename Mode>
class Logger : private Mode {
public:
void log(const char* msg) {
if constexpr (std::is_same_v<Mode, DebugMode>) {
std::cout << "[DEBUG] " << msg << '\n';
}
// ReleaseMode면 아무것도 안 함
}
};
// DebugMode는 크기 0
Logger<DebugMode> logger;
패턴 8: 반복자 어댑터
// 빈 함수 객체를 베이스로 상속
template <typename Iter, typename Func>
class TransformIterator : private Func {
Iter iter_;
public:
TransformIterator(Iter it, Func f) : Func(std::move(f)), iter_(it) {}
auto operator*() const {
return Func::operator()(*iter_);
}
TransformIterator& operator++() {
++iter_;
return *this;
}
};
// 빈 함수 객체: sizeof(TransformIterator) == sizeof(Iter)
9. 정리
| 주제 | 요약 |
|---|---|
| 빈 클래스 규칙 | 모든 객체는 고유 주소 필요 → 최소 1바이트 |
| EBCO | 빈 베이스 클래스는 크기 0으로 최적화 |
| [[no_unique_address]] | C++20 멤버 변수에도 EBCO 적용 |
| 압축 쌍 | 빈 타입일 때 크기 절약 (std::unique_ptr, std::tuple) |
| 문제 시나리오 | 커스텀 삭제자, 할당자, 정책 클래스 |
| 일반적 에러 | 같은 타입 중복 상속, final 클래스, 가상 함수 |
| 프로덕션 | 표준 라이브러리, 정책 기반 설계, 타입 태그 |
EBCO와 [[no_unique_address]]는 메모리 레이아웃 최적화의 핵심 기법입니다. 표준 라이브러리 구현을 이해하고, 커스텀 컨테이너·스마트 포인터를 만들 때 필수적입니다.
구현 체크리스트
EBCO와 [[no_unique_address]] 적용 시 확인할 항목:
- 빈 타입(상태 없는 함수 객체, 할당자, 정책)을 멤버로 가지는지
- C++20 이상이면 [[no_unique_address]] 사용 고려
- C++17 이하면 private 베이스 상속으로 EBCO 적용
- 같은 타입을 여러 번 상속할 때 태그 타입으로 구분
- final 클래스는 상속할 수 없으므로 [[no_unique_address]] 사용
- 가상 함수가 있으면 빈 클래스가 아님 (vtable 포인터)
- ABI 호환성이 중요하면 [[no_unique_address]] 도입 신중히
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 메모리 정렬과 패딩 | alignas·alignof·구조체 크기 최적화
- C++ 데이터 지향 설계 | AoS vs SoA·캐시 최적화
- C++ PIMPL 패턴 | 컴파일 의존성 제거와 ABI 안정성
- C++ CRTP 패턴 | 정적 다형성과 컴파일 타임 인터페이스
이 글에서 다루는 키워드 (관련 검색어)
EBCO, Empty Base Class Optimization, [[no_unique_address]], 빈 베이스 최적화, 압축 쌍, 메모리 레이아웃 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 표준 라이브러리 구현, 제네릭 라이브러리 개발, 임베디드 시스템(메모리 제약), 고성능 컨테이너 제작 시 사용합니다. 특히 커스텀 할당자, 정책 기반 설계, 타입 태그를 사용할 때 EBCO로 메모리를 절약할 수 있습니다.
Q. 선행으로 읽으면 좋은 글은?
A. C++ 메모리 정렬과 패딩, C++ 상속과 다형성, C++ 템플릿 기초를 먼저 읽으면 이해에 도움이 됩니다.
Q. 더 깊이 공부하려면?
A. cppreference - Empty base optimization, cppreference - [[no_unique_address]], Boost의 compressed_pair 구현, LLVM libc++ 소스 코드를 참고하세요.
한 줄 요약: 빈 클래스를 베이스로 상속하거나 [[no_unique_address]]를 쓰면 메모리를 절약할 수 있습니다. 다음으로 커스텀 알로케이터·pmr를 읽어보면 좋습니다.
다음 글: [고성능 C++ #39-2] 현대적 메모리 관리: 커스텀 알로케이터(Memory Pool) 제작과 std::pmr 활용
이전 글: [고성능 C++ #39-1] 캐시 효율적인 코드: 데이터 지향 설계 가이드
추가 학습 자료
온라인 리소스
- cppreference - Empty base optimization
- cppreference - [[no_unique_address]]
- Compiler Explorer - 메모리 레이아웃 확인
표준 라이브러리 소스 코드
- LLVM libc++
<memory>- unique_ptr 구현 - GCC libstdc++
<tuple>- tuple 구현 - MSVC STL - compressed_pair 구현
관련 제안서
심화 주제
EBCO와 ABI 호환성
// 라이브러리 v1.0 (C++17)
struct Data {
Empty e;
int x;
};
// sizeof(Data) == 8
// 라이브러리 v2.0 (C++20, [[no_unique_address]] 추가)
struct Data {
[[no_unique_address]] Empty e;
int x;
};
// sizeof(Data) == 4 → ABI 깨짐!
해결:
- 버전별로 별도 네임스페이스 사용
- ABI 안정성이 중요하면 [[no_unique_address]] 도입 신중히
- PIMPL 패턴으로 내부 구현 숨기기
EBCO와 표준 레이아웃 (Standard Layout)
struct Empty {};
// Standard Layout 유지
struct StandardLayout : Empty {
int x;
int y;
};
static_assert(std::is_standard_layout_v<StandardLayout>);
주의: 빈 베이스가 있어도 첫 번째 멤버와 타입이 다르면 Standard Layout을 유지합니다.
EBCO와 constexpr
struct Empty {
constexpr void foo() const {}
};
struct Container : Empty {
int x;
constexpr Container(int v) : x(v) {}
constexpr int compute() const {
Empty::foo();
return x * 2;
}
};
constexpr Container c(21);
static_assert(c.compute() == 42);
static_assert(sizeof(c) == 4);
벤치마크 예제
메모리 사용량 비교
#include <iostream>
#include <memory>
struct EmptyDeleter {
void operator()(int* p) const { delete p; }
};
struct StatefulDeleter {
int log_level = 0;
void operator()(int* p) const { delete p; }
};
int main() {
std::cout << "std::unique_ptr<int>: "
<< sizeof(std::unique_ptr<int>) << " bytes\n"; // 8
std::cout << "std::unique_ptr<int, EmptyDeleter>: "
<< sizeof(std::unique_ptr<int, EmptyDeleter>) << " bytes\n"; // 8 (EBCO)
std::cout << "std::unique_ptr<int, StatefulDeleter>: "
<< sizeof(std::unique_ptr<int, StatefulDeleter>) << " bytes\n"; // 16
// 배열 크기 비교
constexpr size_t N = 1000000;
std::cout << "\n1백만 개 배열 메모리:\n";
std::cout << "EmptyDeleter: " << N * 8 / 1024 / 1024 << " MB\n"; // 7.6 MB
std::cout << "StatefulDeleter: " << N * 16 / 1024 / 1024 << " MB\n"; // 15.2 MB
return 0;
}
캐시 효율 비교
#include <vector>
#include <chrono>
#include <iostream>
struct Empty {};
// EBCO 적용
struct Optimized : Empty {
int data[15]; // 60바이트 + Empty (0) = 60바이트
};
// EBCO 미적용
struct Unoptimized {
Empty e;
int data[15]; // Empty (1 + 3 패딩) + 60바이트 = 64바이트
};
template <typename T>
void benchmark(const char* name) {
constexpr size_t N = 10000000;
std::vector<T> vec(N);
auto start = std::chrono::high_resolution_clock::now();
long long sum = 0;
for (const auto& item : vec) {
sum += item.data[0];
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << name << ": " << ms << "ms (sum: " << sum << ")\n";
}
int main() {
std::cout << "Optimized size: " << sizeof(Optimized) << " bytes\n";
std::cout << "Unoptimized size: " << sizeof(Unoptimized) << " bytes\n\n";
benchmark<Optimized>("Optimized (EBCO)");
benchmark<Unoptimized>("Unoptimized");
return 0;
}
예상 결과: Optimized가 캐시 라인당 더 많은 객체를 담아 캐시 효율이 좋습니다.
고급 활용: std::tuple 재귀 구현
완전한 tuple 구현 (EBCO 활용)
#include <iostream>
#include <utility>
// 재귀 종료
template <typename... Ts>
struct TupleImpl;
template <>
struct TupleImpl<> {};
// 재귀 케이스: 첫 번째 타입을 베이스로 상속
template <typename T, typename... Rest>
struct TupleImpl<T, Rest...> : TupleImpl<Rest...> {
T value;
TupleImpl() = default;
template <typename U, typename... Args>
TupleImpl(U&& v, Args&&... args)
: TupleImpl<Rest...>(std::forward<Args>(args)...),
value(std::forward<U>(v)) {}
T& get() { return value; }
const T& get() const { return value; }
TupleImpl<Rest...>& getTail() { return *this; }
const TupleImpl<Rest...>& getTail() const { return *this; }
};
// 편의 래퍼
template <typename... Ts>
class Tuple : public TupleImpl<Ts...> {
public:
using TupleImpl<Ts...>::TupleImpl;
};
// get<N> 헬퍼
template <size_t N, typename T, typename... Rest>
struct TupleGetter {
using Type = typename TupleGetter<N - 1, Rest...>::Type;
static Type& get(TupleImpl<T, Rest...>& t) {
return TupleGetter<N - 1, Rest...>::get(t.getTail());
}
};
template <typename T, typename... Rest>
struct TupleGetter<0, T, Rest...> {
using Type = T;
static Type& get(TupleImpl<T, Rest...>& t) {
return t.get();
}
};
template <size_t N, typename... Ts>
auto& get(Tuple<Ts...>& t) {
return TupleGetter<N, Ts...>::get(t);
}
// 테스트
struct Empty {};
int main() {
Tuple<int, Empty, double> t(42, Empty{}, 3.14);
std::cout << "Tuple size: " << sizeof(t) << " bytes\n"; // 16
std::cout << "get<0>: " << get<0>(t) << '\n'; // 42
std::cout << "get<2>: " << get<2>(t) << '\n'; // 3.14
// 빈 타입만
Tuple<Empty, Empty, Empty> t2;
std::cout << "Tuple<Empty, Empty, Empty> size: " << sizeof(t2) << " bytes\n"; // 1
return 0;
}
코드 상세 설명:
- 재귀 상속:
TupleImpl<int, Empty, double>는TupleImpl<Empty, double>를 상속 - EBCO 적용: 빈 타입인
Empty는 베이스 클래스로 들어가 크기 0 - get
: 재귀적으로 N번째 베이스를 찾아 값을 반환
컴파일러별 차이
GCC/Clang vs MSVC
struct Empty {};
struct Test {
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2;
int x;
};
// GCC/Clang: sizeof(Test) == 8 (e1과 e2가 같은 주소)
// MSVC: sizeof(Test) == 4 (e2가 1바이트 차지)
이유: 표준은 [[no_unique_address]]의 정확한 동작을 명시하지 않습니다. 컴파일러마다 구현이 다를 수 있습니다.
이식성 확보
// 컴파일 타임 검증
static_assert(sizeof(Test) <= 8, "Size too large");
// 또는 조건부 컴파일
#ifdef _MSC_VER
// MSVC 전용 최적화
#else
// GCC/Clang 전용 최적화
#endif
실전 사례: 표준 라이브러리 스타일 구현
압축 쌍 최종 버전 (프로덕션 수준)
#include <type_traits>
#include <utility>
// 빈 타입이고 final이 아닌지 확인
template <typename T>
constexpr bool can_use_ebco_v = std::is_empty_v<T> && !std::is_final_v<T>;
// 압축 원소: 빈 타입이면 베이스로, 아니면 멤버로
template <typename T, int Index, bool UseEBCO = can_use_ebco_v<T>>
struct CompressedElement {
T value_;
constexpr CompressedElement() = default;
template <typename U>
constexpr explicit CompressedElement(U&& v)
: value_(std::forward<U>(v)) {}
constexpr T& get() noexcept { return value_; }
constexpr const T& get() const noexcept { return value_; }
};
// EBCO 특수화
template <typename T, int Index>
struct CompressedElement<T, Index, true> : private T {
constexpr CompressedElement() = default;
template <typename U>
constexpr explicit CompressedElement(U&& v)
: T(std::forward<U>(v)) {}
constexpr T& get() noexcept { return *this; }
constexpr const T& get() const noexcept { return *this; }
};
// 압축 쌍
template <typename T1, typename T2>
class CompressedPair :
private CompressedElement<T1, 0>,
private CompressedElement<T2, 1> {
using Base1 = CompressedElement<T1, 0>;
using Base2 = CompressedElement<T2, 1>;
public:
constexpr CompressedPair() = default;
template <typename U1, typename U2>
constexpr CompressedPair(U1&& f, U2&& s)
: Base1(std::forward<U1>(f)), Base2(std::forward<U2>(s)) {}
constexpr T1& first() noexcept { return Base1::get(); }
constexpr const T1& first() const noexcept { return Base1::get(); }
constexpr T2& second() noexcept { return Base2::get(); }
constexpr const T2& second() const noexcept { return Base2::get(); }
};
// 테스트
struct Empty {};
struct Stateful { int x; };
int main() {
// 모두 빈 타입
CompressedPair<Empty, Empty> p1;
std::cout << "CompressedPair<Empty, Empty>: " << sizeof(p1) << " bytes\n"; // 1
// 하나만 빈 타입
CompressedPair<int, Empty> p2(42, Empty{});
std::cout << "CompressedPair<int, Empty>: " << sizeof(p2) << " bytes\n"; // 4
std::cout << "first: " << p2.first() << '\n';
// 둘 다 상태 있음
CompressedPair<int, double> p3(42, 3.14);
std::cout << "CompressedPair<int, double>: " << sizeof(p3) << " bytes\n"; // 16
// constexpr 지원
constexpr CompressedPair<int, Empty> p4(99, Empty{});
static_assert(p4.first() == 99);
return 0;
}
마치며
EBCO와 [[no_unique_address]] 는 C++ 메모리 최적화의 숨은 영웅입니다. 표준 라이브러리가 어떻게 효율적으로 구현되는지 이해하고, 직접 제네릭 라이브러리를 만들 때 이 기법을 활용하면 메모리 사용량을 크게 줄일 수 있습니다.
특히 임베디드 시스템, 게임 엔진, 고성능 서버처럼 메모리가 중요한 환경에서 이 최적화는 필수적입니다. 수백만 개의 객체를 다룰 때 객체당 4~8바이트 절약은 수십 MB의 메모리 절약으로 이어집니다.
다음 글에서는 커스텀 알로케이터와 std::pmr로 할당 성능을 최적화하는 방법을 다룹니다.
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |