yuqi-zheng

Empty Base Optimization (EBO) and [[no_unique_address]]: Zero-Cost Abstraction in Practice


Every complete object in C++ occupies at least 1 byte of storage, even if its type has no non-static data members. This rule exists so that distinct objects always have distinct addresses. But when an empty class is used as a base, the compiler is allowed to optimize that base subobject down to zero bytes — this is the Empty Base Optimization (EBO), and it is one of the oldest zero-cost-abstraction mechanisms in the language.

C++20 extended the same idea to data members via the [[no_unique_address]] attribute. Together, these two features eliminate the storage overhead of stateless policy classes, allocators, and other empty types that pervade the standard library.


The one-byte minimum

The standard requires:

The size of any object or member subobject is at least 1.

struct Empty {};
static_assert(sizeof(Empty) >= 1); // always true

The reason is address identity. In this array, each element must have a unique address:

Empty arr[10];
static_assert(&arr[0] != &arr[1]); // guaranteed by the language

If Empty were zero-sized, all elements would share the same address, breaking pointer arithmetic and comparison.


EBO: zero-byte base subobjects

Base class subobjects are not subject to the 1-byte minimum. When an empty class is inherited, the compiler may reduce the base subobject to zero size:

struct Empty {};

struct Derived : Empty {
    int i;
};

static_assert(sizeof(Derived) == sizeof(int)); // EBO: Empty costs nothing

Without EBO, sizeof(Derived) would be sizeof(int) + 1 plus padding — typically 8 bytes on a 64-bit platform instead of 4. The base class subobject simply does not exist in the object representation.

This is not a compiler nicety — for standard layout types, the standard requires EBO (more on this below).


When EBO cannot apply

EBO is prohibited when an empty base class is also the type (or a base of the type) of the first non-static data member. The reason is the same address-uniqueness rule that motivates the 1-byte minimum: two subobjects of the same most-derived type must have different addresses.

Same type as first member

struct Empty {};

struct NoEbo : Empty {
    Empty c;   // first member has the same type as the base
    int i;
};

// EBO cannot apply: base occupies 1 byte, member c occupies 1 byte,
// plus padding for int alignment
static_assert(sizeof(NoEbo) == 2 * sizeof(int)); // typically 8

The base subobject and the member c are both of type Empty. If EBO were applied, both would occupy zero bytes and share the same address — violating the rule that same-type subobjects must be distinct.

Base of the first member’s type

struct Derived1 : Empty { int i; }; // EBO applies here

struct NoEbo2 : Empty {
    Derived1 c; // Derived1 derives from Empty
    int i;
};

// EBO cannot apply: the Empty base must occupy at least 1 byte
// to avoid sharing an address with the Empty subobject inside c
static_assert(sizeof(NoEbo2) == 3 * sizeof(int)); // typically 12

The Empty base of NoEbo2 and the Empty base inside Derived1 c would collide in address if EBO were applied to NoEbo2.

Reordering as a workaround

If you move the empty-typed member to a non-first position, EBO can apply to the base:

struct EboWorkaround : Empty {
    int i;
    Empty c;   // not the first member — EBO can apply to the base
};

static_assert(sizeof(EboWorkaround) == sizeof(int) + 1);
// With padding: typically 8 bytes (same as NoEbo in practice,
// but the base itself is zero-sized)

The base subobject is now zero bytes, but c still occupies 1 byte. The overall size may be the same due to padding, but the layout is semantically different.


Standard layout types

Since C++11, a standard layout type must satisfy EBO. This is not an optimization — it is a correctness requirement. Standard layout guarantees that a pointer to the object, when reinterpret_cast to a pointer to its first non-static data member, points to the same address:

struct S { int x; };
static_assert(reinterpret_cast<char*>(&s) == reinterpret_cast<char*>(&s.x));

For this guarantee to hold when the type has empty bases, those bases must not occupy any storage before the first data member. This is why the standard layout rules include:

  • All non-static data members must be declared in the same class (not spread across base and derived).
  • No base class may have the same type as the first non-static data member.

These rules exist precisely to avoid the address collisions that would break the reinterpret_cast guarantee if EBO were not required.


[[no_unique_address]] (C++20)

EBO only works through inheritance. If an empty type is a data member, it always costs at least 1 byte regardless of the optimization level. C++20 closes this gap with the [[no_unique_address]] attribute:

struct Empty {};

struct WithAttribute {
    int i;
    [[no_unique_address]] Empty e;
};

static_assert(sizeof(WithAttribute) == sizeof(int)); // e occupies 0 bytes

The attribute tells the compiler that this member does not need a unique address — it may share its address with other members. This is the data-member analogue of EBO.

Multiple empty members

When multiple [[no_unique_address]] members of the same type appear, they can share an address:

struct Empty {};

struct Multi {
    int i;
    [[no_unique_address]] Empty e1;
    [[no_unique_address]] Empty e2;
};

// GCC/Clang: sizeof(Multi) == sizeof(int) — e1 and e2 share an address
// MSVC:     sizeof(Multi) == sizeof(int) + 1 — MSVC does not merge same-type members

The standard permits but does not require this merging, which leads to implementation divergence (see the MSVC section below).

Different empty types

Members of different empty types can always share an address, because the address-uniqueness rule only applies to subobjects of the same type:

struct Empty1 {};
struct Empty2 {};

struct TwoTypes {
    int i;
    [[no_unique_address]] Empty1 e1;
    [[no_unique_address]] Empty2 e2;
};

static_assert(sizeof(TwoTypes) == sizeof(int)); // both optimized away

compressed_pair: EBO in action

Before [[no_unique_address]], the standard mechanism for combining an empty type with a non-empty type was boost::compressed_pair. It uses conditional inheritance to apply EBO when one or both types are empty:

Simplified implementation

template<typename T1, typename T2, bool = std::is_empty_v<T1>>
struct compressed_pair;

// Both types empty: inherit from both
template<typename T1, typename T2>
struct compressed_pair<T1, T2, true> : T1, T2 {
    // T1 and T2 each cost 0 bytes via EBO
};

// Only T1 is empty: inherit from T1, store T2 as member
template<typename T1, typename T2>
struct compressed_pair<T1, T2, false> {
    T2 second_;
    // T1 inherited via EBO costs 0 bytes
};

// Neither empty: store both as members
template<typename T1, typename T2>
struct compressed_pair<T1, T2, false> {
    T1 first_;
    T2 second_;
};

A real implementation is more complex (handling same-type pairs, reference types, etc.), but the core idea is simple: inherit from empty types to get EBO, store non-empty types as members.

Before and after

struct StatelessAlloc {};

// Naive: pair stores both as members
struct NaiveVector {
    int* begin_;
    int* end_;
    int* capacity_;
    StatelessAlloc alloc_; // 1 byte + 7 bytes padding
};
static_assert(sizeof(NaiveVector) == 32); // 3 pointers + 1 byte + padding

// Optimized: compressed_pair applies EBO to the allocator
struct OptimizedVector : private StatelessAlloc {
    int* begin_;
    int* end_;
    int* capacity_;
    // StatelessAlloc costs 0 bytes via EBO
};
static_assert(sizeof(OptimizedVector) == 24); // 3 pointers only

That is an 8-byte saving per vector instance. For containers of containers (std::vector<std::vector<int>>), this compounds.


STL containers and allocator overhead

The standard library uses EBO pervasively for allocator-aware containers. The allocator is stored as a base of an internal struct that holds the pointer members, so that a stateless allocator adds zero overhead:

TypeWith stateless allocatorWithout EBO
std::vector<int>24 bytes (3 pointers)32 bytes (3 pointers + 1 byte + padding)
std::shared_ptr<int>16 bytes (2 pointers)24 bytes (2 pointers + 1 byte + padding)
std::function<void()>32 bytes (vtable + captures)40 bytes (+ allocator overhead)

These numbers are for 64-bit Linux with GCC. The “without EBO” column shows what the sizes would be if the allocator were stored as a plain data member.

std::function detail

std::function typically stores a function pointer or a small buffer for captured state, plus a vtable pointer for type erasure. The allocator (when stateless) is stored via EBO in the internal base class, contributing zero bytes to the total size.

std::shared_ptr detail

std::shared_ptr holds two pointers: the managed object pointer and the control block pointer. The allocator for the control block is stored inside the control block itself (which is already heap-allocated), so the shared_ptr object on the stack does not carry allocator overhead regardless of EBO. However, the control block uses EBO internally for the allocator and deleter.


MSVC: the _Empty_base hack

Historically, MSVC did not implement EBO in a conforming way. A well-known issue is that MSVC may fail to apply EBO even when the standard requires it for standard layout types.

The workaround

MSVC’s standard library uses an internal _Empty_base wrapper:

// MSVC internal workaround (simplified)
struct _Empty_base {};

template<typename T>
struct _Wrap_base : _Empty_base {
    T value;
};

// Instead of: struct Foo : SomeEmptyType { int x; };
// MSVC uses:  struct Foo : _Wrap_base<int> { /* ... */ };

By inserting _Empty_base as an intermediate base, MSVC’s standard library avoids the cases where EBO would fail on that compiler. This is an implementation detail, but it explains why sizeof results may differ between MSVC and GCC/Clang.

[[no_unique_address]] on MSVC

As of Visual Studio 2022 (MSVC v19.3+), [[no_unique_address]] is supported but with a known deviation: when multiple [[no_unique_address]] members have the same type, MSVC gives each one a distinct address (costing 1 byte each), whereas GCC and Clang allow them to share an address:

struct Empty {};

struct Test {
    [[no_unique_address]] Empty e1;
    [[no_unique_address]] Empty e2;
    int i;
};

// GCC/Clang: sizeof(Test) == 4
// MSVC:      sizeof(Test) == 8 (each Empty gets its own byte + padding)

This is a conformance issue — the standard permits but does not require address sharing, so MSVC’s behavior is legal but less optimal. A workaround is to use different empty types for each member.


EBO vs [[no_unique_address]]: when to use which

ScenarioMechanismResult
Empty type as baseEBO (automatic)Zero bytes, standard C++98
Empty type as data member (pre-C++20)Inherit + EBO via compressed_pairZero bytes, requires wrapper
Empty type as data member (C++20+)[[no_unique_address]]Zero bytes, no wrapper needed
Multiple same-type empty members (C++20+)[[no_unique_address]]0 bytes (GCC/Clang) or 1 byte each (MSVC)
Multiple different-type empty members (C++20+)[[no_unique_address]]Zero bytes, all compilers

Rule of thumb: if you control the type and can use inheritance, EBO works everywhere. If you need a data member, use [[no_unique_address]] (C++20). If you need pre-C++20 compatibility with data members, use compressed_pair or the inheritance trick.


Practical guidelines

  1. Policy classes should be empty. If a policy class (custom allocator, comparator, trait) has no state, make sure it has no non-static data members. Then EBO or [[no_unique_address]] will eliminate its storage cost.

  2. Avoid making the first member the same type as the base. If you must inherit from an empty type and also store it as a member, declare the member after all non-empty members.

  3. Prefer [[no_unique_address]] over compressed_pair in C++20. The attribute is clearer and avoids the complexity of conditional inheritance hierarchies.

  4. Test sizeof on all target compilers. EBO and [[no_unique_address]] have implementation-specific behavior. A static_assert on expected sizes is a portable way to catch regressions:

struct MyContainer : private MyAllocator { // empty allocator
    int* data_;
    size_t size_;
    size_t capacity_;
};
static_assert(sizeof(MyContainer) == 3 * sizeof(void*),
              "EBO should eliminate allocator overhead");
  1. Use std::is_empty_v<T> to guard EBO-dependent code. This trait detects whether a type is empty (has no non-static data members and no virtual functions), allowing compile-time dispatch:
template<typename Alloc>
class container : std::conditional_t<std::is_empty_v<Alloc>, Alloc, detail::alloc_holder<Alloc>> {
    // ...
};

References