빈 클래스가 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가 적용되는 조건

  1. 베이스 클래스여야 합니다(C++17까지 멤버 변수에는 적용되지 않음).
  2. 빈 클래스여야 합니다(비정적 멤버 변수가 없음).
  3. 가상 함수나 가상 베이스가 없어야 합니다(vtable 포인터가 생기면 빈 클래스가 아님).
  4. 첫 번째 멤버와 타입이 달라야 합니다(같은 타입의 두 객체가 같은 주소에 놓일 수 없음).

단일·다중 상속에서 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]] 도입 신중히

같이 보면 좋은 글


자주 묻는 질문 (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] 캐시 효율적인 코드: 데이터 지향 설계 가이드


추가 학습 자료

온라인 리소스

표준 라이브러리 소스 코드

  • LLVM libc++ <memory> - unique_ptr 구현
  • GCC libstdc++ <tuple> - tuple 구현
  • MSVC STL - compressed_pair 구현

관련 제안서