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:
| Type | With stateless allocator | Without 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
| Scenario | Mechanism | Result |
|---|---|---|
| Empty type as base | EBO (automatic) | Zero bytes, standard C++98 |
| Empty type as data member (pre-C++20) | Inherit + EBO via compressed_pair | Zero 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
-
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. -
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.
-
Prefer
[[no_unique_address]]overcompressed_pairin C++20. The attribute is clearer and avoids the complexity of conditional inheritance hierarchies. -
Test
sizeofon all target compilers. EBO and[[no_unique_address]]have implementation-specific behavior. Astatic_asserton 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");
- 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
- cppreference, Empty Base Optimization: https://en.cppreference.com/w/cpp/language/ebo
- cppreference,
[[no_unique_address]]: https://en.cppreference.com/w/cpp/language/attributes/no_unique_address - Boost compressed_pair: https://www.boost.org/doc/libs/release/libs/utility/doc/html/compressed_pair.html
- Stack Overflow, “Why is EBO not working in MSVC?”: https://stackoverflow.com/questions/12701469