빈 클래스가 1바이트를 먹는 이유: EBCO와 C++20 [[no_unique_address]]
이 글의 핵심
표준 라이브러리는 std::tuple과 unique_ptr 내부에서 빈 타입이 공간을 차지하지 않도록 빈 베이스 최적화를 활용합니다. 첫 번째 멤버와 타입이 같거나 가상 함수가 있을 때 EBCO가 적용되지 않는 경우, 베이스 상속과 [[no_unique_address]]의 차이, C++17 호환 압축 쌍 구현까지 따라가며 메모리 레이아웃을 직접 확인합니다.
빈 클래스가 1바이트를 차지하는 문제
”직접 만든 스마트 포인터가 왜 8바이트가 아니라 16바이트죠?”
C++에서 빈 클래스(멤버 변수가 없는 클래스)도 최소 1바이트를 차지합니다. 서로 다른 객체는 서로 다른 주소를 가져야 하기 때문입니다. 이 규칙 때문에 상태 없는 함수 객체나 커스텀 삭제자를 그냥 멤버로 넣으면, 정렬 패딩까지 붙어 불필요한 메모리를 쓰게 됩니다.
EBCO(Empty Base Class Optimization, 빈 베이스 클래스 최적화)는 빈 클래스를 베이스로 상속하면 그 베이스가 공간을 차지하지 않도록 배치하는 최적화입니다. C++20의 [[no_unique_address]] 속성은 멤버 변수에도 비슷한 효과를 낼 수 있게 해 줍니다.
이 글에서는 빈 클래스가 1바이트를 차지하는 이유부터 EBCO가 적용되는 조건과 실패하는 경우, [[no_unique_address]]와의 차이, 표준 라이브러리의 unique_ptr·tuple이 이 기법을 쓰는 방식, 그리고 압축 쌍(compressed pair)을 직접 구현하는 방법까지 차례로 살펴봅니다.
빈 타입 때문에 크기가 커지는 다섯 가지 상황
스마트 포인터에 삭제자 멤버를 넣었더니 크기가 2배가 됨
"raw 포인터는 8바이트인데, 직접 만든 스마트 포인터에 삭제자를 넣으니 16바이트가 됐습니다."
"삭제자는 빈 클래스인데 왜 메모리를 차지하나요?"
원인: 포인터 T* ptr과 삭제자 Deleter del을 나란히 멤버로 두면, Deleter가 빈 클래스여도 최소 1바이트를 차지하고 정렬 때문에 8바이트까지 패딩됩니다. 표준 std::unique_ptr은 빈 삭제자에 대해 이 문제를 EBCO로 피하기 때문에 보통 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);
빈 타입이 섞인 std::tuple이 예상보다 큼
"int, EmptyTag, double을 담는 구조체가 16바이트예요. int와 double만 있어도 16바이트인데 EmptyTag 자리는 왜 필요하죠?"
"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바이트 (순서가 <EmptyTag, double, int>라면 24바이트)
};
할당자를 멤버로 가진 컨테이너가 너무 큼
"std::vector<int, MyAllocator>를 수천 개 만드는데 메모리가 부족해요."
"MyAllocator는 상태가 없는 빈 클래스인데 왜 공간을 차지하나요?"
원인: 컨테이너는 할당자를 멤버로 저장합니다. 빈 할당자여도 1바이트 + 패딩이 필요합니다.
압축 쌍(compressed pair)을 직접 구현하고 싶을 때
"Boost의 compressed_pair처럼 빈 타입일 때 크기를 줄이고 싶습니다."
"std::pair는 항상 두 멤버를 다 저장해서 비효율적입니다."
원인: std::pair는 EBCO를 적용하지 않습니다. 빈 타입이어도 크기가 줄어들지 않습니다.
표준 라이브러리 구현을 읽고 싶을 때
"std::tuple, std::unique_ptr, std::shared_ptr 내부 구현이 궁금해요."
"어떻게 크기를 최소화하는지 알고 싶습니다."
배경: 표준 라이브러리 구현은 EBCO와 [[no_unique_address]]를 적극 활용해 빈 타입이 공간을 차지하지 않게 만듭니다. 이 기법을 알면 구현 코드를 읽기가 훨씬 쉬워집니다.
고유 주소 규칙과 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);
핵심: 표준은 베이스 클래스 하위 객체가 크기 0을 가질 수 있도록 허용합니다. 그래서 컴파일러는 빈 베이스를 파생 클래스의 첫 멤버와 같은 주소에 겹쳐 배치해 공간을 절약합니다. 다만 같은 타입의 서로 다른 두 객체가 같은 주소를 갖는 것은 여전히 금지되므로, 뒤에서 볼 것처럼 이 규칙에 걸리면 최적화가 적용되지 않습니다.
EBCO 메모리 레이아웃
멤버로 가질 때 (Container):
┌──────────────────────────────┐
│ value (4바이트) │ Empty (1바이트) │ padding (3바이트) │
└──────────────────────────────┘
총 8바이트
베이스로 상속 시 (Optimized):
┌──────────────────────────────┐
│ Empty (0바이트) │ value (4바이트) │
└──────────────────────────────┘
총 4바이트
EBCO가 적용되는 조건
- 베이스 클래스여야 합니다(C++17까지 멤버 변수에는 적용되지 않음).
- 빈 클래스여야 합니다(비정적 멤버 변수가 없음).
- 가상 함수나 가상 베이스가 없어야 합니다(vtable 포인터가 생기면 빈 클래스가 아님).
- 첫 번째 멤버와 타입이 달라야 합니다(같은 타입의 두 객체가 같은 주소에 놓일 수 없음).
단일·다중 상속에서 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바이트
GCC와 Clang(Itanium ABI)에서는 위 static_assert가 통과하지만, MSVC는 기본적으로 첫 번째 빈 베이스에만 최적화를 적용해 sizeof(MultiDerived)가 8이 됩니다. MSVC에서 모든 빈 베이스를 압축하려면 클래스에 __declspec(empty_bases)를 붙여야 합니다.
주의: 같은 타입을 직접 베이스로 두 번 지정하는 것은 문법 오류입니다.
struct Empty {};
// ❌ 컴파일 에러: 같은 베이스를 두 번 상속
struct Bad : Empty, Empty { // error: duplicate base type
int x;
};
EBCO가 실패하는 경우: 첫 번째 멤버와 타입이 같을 때
struct Empty {};
struct Container : Empty {
Empty first; // ❌ 베이스와 타입이 같음 → EBCO 실패
int second;
};
static_assert(sizeof(Container) == 8); // Empty가 1바이트 + 패딩
이유: first와 베이스 Empty가 같은 주소를 가지면 서로 다른 객체인데 주소가 같아지는 문제가 발생합니다. 컴파일러는 이를 방지하기 위해 EBCO를 적용하지 않습니다.
EBCO가 실패하는 경우: 가상 함수가 있을 때
struct VirtualBase {
virtual ~VirtualBase() = default;
};
struct Derived : VirtualBase {
int x;
};
static_assert(sizeof(Derived) == 16); // vtable 포인터 8바이트 + int 4바이트 + 패딩
이유: 가상 함수가 있으면 vtable 포인터(8바이트)가 필요하므로 빈 클래스가 아닙니다.
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바이트
이 절의 sizeof 값과 static_assert는 GCC와 Clang 기준입니다. MSVC는 표준 [[no_unique_address]]를 레이아웃에 반영하지 않으므로 같은 코드에서 크기가 줄지 않습니다(뒤의 컴파일러 차이 절 참고).
베이스 상속 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);
[[no_unique_address]]의 장점은 세 가지입니다. 멤버 이름(obj.empty)으로 바로 접근할 수 있어 베이스로 캐스팅할 필요가 없고, private 상속으로 인터페이스를 숨기는 수고가 없으며, final 클래스처럼 상속할 수 없는 타입에도 쓸 수 있습니다.
[[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;
};
// e1과 e2는 같은 타입이라 같은 주소에 놓일 수 없음 → 서로 다른 오프셋에 배치
// GCC/Clang: 빈 멤버가 int와 겹칠 수 있어 보통 4바이트
// MSVC: 표준 [[no_unique_address]]를 레이아웃에 반영하지 않아 8바이트
주의: 같은 타입의 빈 멤버를 여러 개 두면 서로 다른 주소를 가져야 하므로, 크기가 0으로 모두 사라지지는 않습니다. [[no_unique_address]]는 겹쳐도 된다는 허락일 뿐 실제로 겹칠지는 구현이 정하므로, 크기에 의존하는 코드라면 static_assert로 확인해야 합니다.
압축 쌍·unique_ptr·할당자 벡터 직접 구현하기
[[no_unique_address]]로 만든 압축 쌍
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"; // 2 (같은 타입이라 주소가 달라야 함, GCC/Clang)
// 일반 타입
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;
}
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"; // 2
return 0;
}
코드의 요점은 세 가지입니다.
CompressedElement는 빈 타입이면 그 타입을 상속하고(EBCO 적용), 아니면 멤버로 저장합니다.Index템플릿 인자는T1과T2가 같은 타입일 때도 두 베이스를 서로 다른 타입으로 만들기 위해 있습니다. 다만 둘 다 결국Empty를 상속하므로,CompressedPair<Empty, Empty>에서는 두Empty하위 객체가 서로 다른 주소를 가져야 해서 크기가 2가 됩니다.is_final_v검사는final클래스를 상속할 수 없기 때문에 넣었습니다. 이 경우엔 멤버로 저장합니다.
EBCO를 적용한 커스텀 unique_ptr
#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
할당자를 가진 벡터
#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[](size_t i) { 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;
}
빈 함수 객체 저장
#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;
}
상속 구조·final·정렬·ABI에서 생기는 에러
같은 타입을 두 번 상속함
증상: 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);
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
가상 함수가 있는 베이스
증상: EBCO가 적용되지 않아 크기가 줄지 않음.
struct VirtualBase {
virtual void foo() {}
};
struct Derived : VirtualBase {
int x;
};
static_assert(sizeof(Derived) == 16); // vtable 포인터 때문에
해결: 빈 클래스에는 가상 함수를 넣지 않습니다. 다형성이 필요하면 CRTP(Curiously Recurring Template Pattern)나 std::variant를 고려합니다.
첫 번째 멤버와 베이스 타입이 같음
증상: 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;
};
정렬 요구사항을 무시함
증상: 빈 클래스에 alignas가 있으면, 베이스 자체는 여전히 공간을 차지하지 않더라도 파생 클래스 전체의 정렬 요구가 커져 크기가 늘어납니다.
struct alignas(16) AlignedEmpty {};
struct Container : AlignedEmpty {
int x;
};
static_assert(sizeof(Container) == 16); // 정렬 때문에 16바이트
해결: 빈 베이스에 과도한 정렬 요구사항을 넣지 않습니다.
[[no_unique_address]] 도입으로 ABI가 바뀜
증상: 기존 구조체의 멤버에 [[no_unique_address]]를 추가하면 크기와 오프셋이 바뀌어, 이전 버전으로 빌드된 코드와 ABI가 맞지 않게 됩니다.
struct Empty {};
// 이전 버전: 멤버로 저장 → 8바이트
struct Data {
Empty e;
int x;
};
// 새 버전: [[no_unique_address]] 추가 → 4바이트 (GCC/Clang)
struct Data {
[[no_unique_address]] Empty e;
int x;
};
해결: ABI 안정성이 중요한 공개 타입에는 [[no_unique_address]]를 신중하게 도입하고, 레이아웃을 바꿔야 한다면 인라인 네임스페이스로 버전을 나눕니다. 컴파일러마다 속성 반영 여부가 달라, 같은 헤더라도 GCC와 MSVC 사이에서는 레이아웃이 다를 수 있다는 점도 기억해야 합니다.
배열 원소에는 EBCO가 적용되지 않음
증상: 빈 클래스 배열은 각 원소가 1바이트씩 차지.
struct Empty {};
struct Container : Empty {
Empty arr[10]; // ❌ 배열은 EBCO 적용 안 됨 → 10바이트
};
static_assert(sizeof(Container) >= 10);
이유: 배열의 각 원소는 고유한 주소를 가져야 하므로 EBCO가 적용되지 않습니다.
unique_ptr·tuple·shared_ptr·function의 내부 구조
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"; // 24 (x86-64 GCC/Clang)
// value가 멤버라 Empty도 1바이트 + 패딩을 차지함
Tuple<Empty, Empty, int> t2;
std::cout << "Tuple size: " << sizeof(t2) << " bytes\n"; // 12
// 재귀 상속만으로는 원소가 압축되지 않음
return 0;
}
이 단순한 버전은 재귀 상속을 쓰지만 각 원소를 T value 멤버로 저장하므로, 빈 타입도 공간을 차지합니다. 상속되는 것은 나머지 원소를 담은 Tuple<Rest...>이지 원소 타입 자체가 아니기 때문입니다. 실제 libstdc++와 libc++는 각 원소를 앞의 CompressedElement 같은 보조 클래스(libstdc++의 _Head_base)로 감싸, 원소가 빈 타입이면 그 타입을 상속하거나 [[no_unique_address]]로 저장해 공간을 없앱니다. 그래서 sizeof(std::tuple<int, Empty, double>)은 보통 16입니다.
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);
}
}
}
};
// 빈 삭제자·할당자: 16바이트
// ptr (8) + ref_count (4) + weak_count (4)
// Deleter (0) + Allocator (0)
std::function 내부 (Small Buffer Optimization)
std::function은 EBCO가 아니라 다른 방식으로 할당을 줄입니다. 빈 람다나 작은 함수 객체는 힙에 할당하지 않고 객체 안의 고정 버퍼에 직접 저장하는데, 이를 Small Buffer Optimization이라고 합니다. 타입이 지워지기 때문에 빈 함수 객체라도 std::function 자체의 크기는 줄어들지 않습니다.
// 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_에 직접 저장 (SBO)
if constexpr (sizeof(F) <= BUFFER_SIZE && std::is_empty_v<F>) {
new (buffer_) F(std::move(f));
invoke_ = [](char* buf, Args... args) -> R {
return (*reinterpret_cast<F*>(buf))(std::forward<Args>(args)...);
};
destroy_ = [](char* buf) {
reinterpret_cast<F*>(buf)->~F();
};
} else {
// 큰 함수 객체는 힙 할당
// (생략)
}
}
R operator()(Args... args) {
return invoke_(buffer_, std::forward<Args>(args)...);
}
~Function() {
if (destroy_) destroy_(buffer_);
}
};
정책 기반 설계와 타입 태그 패턴
압축 쌍으로 메모리 절약
// 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(); }
};
정책 기반 설계 (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;
타입 태그 (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>{});
}
할당자 전파 (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_)
상태 유무에 따라 저장 방식 선택
// 상태 유무에 따라 구현 선택
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_;
};
여러 빈 정책을 동시에 상속
// 여러 빈 정책을 베이스로 상속
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_만 차지
빈 타입으로 컴파일 타임 플래그 전달
// 빈 타입으로 컴파일 타임 플래그 전달
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;
빈 함수 객체를 상속하는 반복자 어댑터
// 빈 함수 객체를 베이스로 상속
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)
ABI·표준 레이아웃·constexpr과의 관계
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 (정수 나눗셈, 약 7.6MiB)
std::cout << "StatefulDeleter: " << N * 16 / 1024 / 1024 << " MB\n"; // 15 (약 15.3MiB)
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;
}
이 예에서 줄어드는 것은 객체당 4바이트, 전체 메모리의 약 6%입니다. 다만 60바이트 객체는 64바이트 캐시 라인 경계에 걸치는 경우가 생기고, 이 루프는 객체마다 첫 원소만 읽기 때문에 실행 시간 차이는 거의 없거나 환경에 따라 뒤집힐 수도 있습니다. 크기 최적화가 속도로 이어지는지는 실제 접근 패턴으로 측정해 봐야 합니다.
std::tuple을 재귀 상속으로 구현하기
#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"; // 24 (x86-64 GCC/Clang)
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"; // 3
return 0;
}
TupleImpl<int, Empty, double>는TupleImpl<Empty, double>를 상속하고, 이것이 다시TupleImpl<double>를 상속하는 식으로 재귀합니다.get<N>은getTail()로 한 단계씩 베이스를 따라가 N번째 원소를 반환합니다.- 이 구현은 원소를
T value멤버로 저장하므로 빈 타입도 1바이트씩 차지합니다. 앞 절에서 설명했듯이 원소를 압축하려면T value대신 앞의CompressedElement같은 보조 클래스를 상속하게 바꿔야 합니다. C++20이라면[[no_unique_address]] T value;로도 대부분의 빈 원소를 없앨 수 있습니다.
GCC/Clang과 MSVC의 차이
GCC/Clang vs MSVC
struct Empty {};
struct Test {
[[no_unique_address]] Empty e1;
[[no_unique_address]] Empty e2;
int x;
};
// GCC/Clang: sizeof(Test) == 4 (e1과 e2는 다른 오프셋, 둘 다 x와 겹침)
// MSVC: sizeof(Test) == 8 (표준 속성을 레이아웃에 반영하지 않음)
// MSVC에서는 [[msvc::no_unique_address]]를 써야 압축됨
이유: 표준은 [[no_unique_address]]가 붙은 멤버가 다른 멤버와 겹쳐도 된다고 허락할 뿐, 실제로 겹치게 배치할지는 구현에 맡깁니다. MSVC는 기존 ABI를 깨지 않기 위해 표준 속성을 무시하고 별도의 [[msvc::no_unique_address]]를 제공합니다.
이식성 확보
// 컴파일러별로 속성을 분기하는 매크로
#if defined(_MSC_VER) && !defined(__clang__)
# define NO_UNIQUE_ADDRESS [[msvc::no_unique_address]]
#else
# define NO_UNIQUE_ADDRESS [[no_unique_address]]
#endif
struct Portable {
NO_UNIQUE_ADDRESS Empty e;
int x;
};
// 기대한 크기인지 빌드 단계에서 검증
static_assert(sizeof(Portable) == sizeof(int), "Empty member was not compressed");
표준 라이브러리 수준의 압축 쌍 최종 버전
#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"; // 2 (두 Empty의 주소가 달라야 함)
// 하나만 빈 타입
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++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: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this
- C++ 시리즈 전체 보기
자주 묻는 질문 (FAQ)
Q. MSVC에서 [[no_unique_address]]를 붙였는데 크기가 줄지 않는 이유는 무엇인가요?
MSVC는 기존 ABI와의 호환성 때문에 표준 [[no_unique_address]] 속성을 받아들이기만 하고 레이아웃에는 반영하지 않으며, 대신 [[msvc::no_unique_address]]라는 별도 속성을 제공합니다. 또 다중 상속에서 빈 베이스를 모두 압축하려면 __declspec(empty_bases)가 필요한 경우가 있습니다. 여러 컴파일러를 지원하는 라이브러리라면 매크로로 속성을 분기하고, 본문처럼 sizeof를 static_assert로 검사해 컴파일러별 차이를 빌드 단계에서 확인하는 것이 안전합니다.
Q. 빈 deleter를 unique_ptr에 넣으면 크기가 줄어든다는 말이 맞나요?
deleter가 상태 없는 빈 타입이라면 표준 라이브러리 구현이 EBCO나 압축 쌍으로 deleter 공간을 없애 unique_ptr가 일반 포인터 크기를 유지하는 경우가 많습니다. 반대로 함수 포인터나 캡처가 있는 람다처럼 상태를 가진 deleter를 넣으면 그만큼 크기가 커지므로, 크기가 중요하다면 sizeof를 static_assert로 확인하는 것이 안전합니다.
빈 클래스를 베이스로 상속하거나 [[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 구현