본문으로 건너뛰기
Previous
Next
C++ EBCO and [[no_unique_address]]

C++ EBCO and [[no_unique_address]]

C++ EBCO and [[no_unique_address]]

이 글의 핵심

EBCO and C++20 [[no_unique_address]] solving the problem of empty classes occupying memory. From std::tuple, std::unique_ptr implementation secrets, memory layout optimization to practical patterns.

When not to use this (first)

Most codebases do not need to optimize for empty-base compression. If your service is I/O bound, your win comes from batching, fewer round trips, and better query plans—not from shaving padding off a comparator object. I treat EBCO and [[no_unique_address]] as tools for the narrow band where object size actually shows up in profiles or wire formats: hot container nodes, high-volume handles, embedded budgets, or allocator/comparator storage that would otherwise sit in every list node. Outside that band, the complexity is rarely worth a few bytes.

Warning signs that I walk away from compression:

  • I am complicating a class design to save one byte on a cold path.
  • I need a stable layout across compilers for a binary protocol—then I use an explicit, boring struct contract, not a clever inheritance diamond.
  • I duplicated the same empty member type twice and expect both slots to vanish. That often fails; I verify on every toolchain instead of hoping.
  • My “empty” policy type is not empty in release (debug logging fields, hooks). The attribute does not override real data.

Complexity budget: I prefer clear data structures until profiling proves layout pressure. If the reader takes one thing from this page, I hope it is that compression is optional sharp tooling—not a default style choice.

This guide still walks through the mechanisms because when you do need them, you need the rules of engagement: address uniqueness, padding, EBCO, C++20’s attribute, and where the standard library historically leaned on the same ideas.

What this guide covers

  • When it pays off versus when to ignore it (above, and in the performance section).
  • Empty class rules: address uniqueness, sizeof, arrays, and padding.
  • EBCO and [[no_unique_address]]: layout rules, failure modes, duplicate empty members.
  • Policy design, stateless allocators, tag types, and high-level STL patterns (implementation-defined).
  • ABI, compiler differences, and worked tuple/compressed-pair style examples.

What it is: empty classes, EBCO, and [[no_unique_address]]

The one-byte rule and real containers

In C++, an empty class is a class type with no non-static data members (and typically no bases that add size). Even so, objects of such a type usually occupy at least one byte. The object model requires that distinct complete objects have distinct addresses, and that subobject layout remain well-defined for arrays, new[], and pointer arithmetic.

That single byte is rarely a problem in isolation. It becomes painful when you compose many small, stateless policy objects—allocators, comparators, mutex policies, or deleters—as data members: each empty member can force padding to satisfy alignment of surrounding members, inflating sizeof far beyond the “one byte” intuition.

Empty Base Class Optimization (EBCO, sometimes EBO) is the pattern by which implementations may lay out a derived object so that a non-polymorphic empty base subobject contributes zero size, overlapping the start of the derived object. C++20 adds [[no_unique_address]], which requests a similar effect for named data members of empty class type—without public inheritance.

Why sizeof(T) >= 1 for a complete empty object

The C++ abstract machine must support code like:

struct Empty {};

void f(Empty* p, Empty* q) {
  assert(p != q); // must be able to distinguish objects when they are distinct
}

For an array of Empty, each element must occupy storage so that pointers to distinct elements compare unequal when they should. That generally forces sizeof(Empty) == 1 (or larger if alignment requirements increase it).

Empty is not “free” as a member

When Empty is a member, it follows normal struct layout rules: alignment and padding apply.

struct Empty {};

struct Bad {
  int x;      // 4 bytes, alignof(int) typically 4
  Empty e;    // at least 1 byte, then padding to align the struct as a whole
};

// Commonly sizeof(Bad) == 8 on typical 32/64-bit platforms.
static_assert(sizeof(Bad) >= 5);

Here, the empty member’s one byte often causes three bytes of tail padding (to make the struct’s size a multiple of its alignment). This is the central problem EBCO/[[no_unique_address]] exist to mitigate.

“Empty enough” for optimization

A class is empty in the practical EBCO sense when it has no non-static data members and does not introduce hidden state that forces size: non-virtual empty types are the common case, while virtual functions imply a vptr—not empty for EBCO purposes. If any base subobject has non-zero size, the derived type will not behave like a pure empty type.


Empty Base Class Optimization (EBCO)

What EBCO is

EBCO allows an implementation, for an appropriate class layout, to allocate a base class subobject of size zero within a most-derived object, typically by placing the empty base at the same address as the derived object (or another subobject), as permitted by the object model rules for empty bases.

Before (member):

struct Empty {};

struct WithMember {
  int x;
  Empty e;
};

After (base):

struct Empty {};

struct WithBase : Empty {
  int x;
};

On typical implementations, sizeof(WithBase) == sizeof(int) when Empty is truly empty and the hierarchy is eligible. The empty base does not add a distinct “field” at offset 4; it shares the starting address with WithBase.

When EBCO “applies” in practice

EBCO is a family of layout decisions made by the compiler within the platform ABI rules. You should expect it when the base class is empty, you are not in an ABI situation that requires a non-zero offset for a base, and you are not relying on pointer-to-member or other features that become more subtle with complicated multiple inheritance. Empty bases still often collapse in MI, but not always the same way across compilers.

Why inheritance is a trade-off

EBCO via inheritance is powerful, but it has design costs: base classes are part of your type’s public story unless you use private inheritance; multiple inheritance can complicate conversions and name lookup. Overuse of inheritance for “storage compression” can obscure intent. In C++20, [[no_unique_address]] sometimes reads more clearly as “I am storing a component” than an extra base—especially for named allocators and comparators, where a member name documents intent better than a private base hook. The classic private inheritance path remains excellent when you already think in terms of “implemented in terms of” policy storage.


Rules and limitations

Things that commonly break “emptiness”

EBCO applies to types that are layout-empty in practice. Watch for virtual functions, virtual inheritance complications, types like std::polymorphic_allocator that carry state, and the simple mistake of believing [[no_unique_address]] removes sizeof when the type is not actually empty.

Multiple empty bases

With multiple inheritance, two distinct empty base types can often both be layout-empty in the same object if the ABI allows overlapping of empty bases—compiler/ABI dependent. Do not assume two empty bases both have zero size in every MI pattern; duplicating the same empty type as multiple distinct base classes is especially constrained.

Arrays and standard layout

EBCO does not let you form an array of zero-size elements. Do not confuse “zero-size base subobject inside a derived object” with “sizeof is zero.” The complete type Derived still has a positive size if it has members.

final affects inheritance, not emptiness. Friendship does not change object size. The hard constraints are layout, ABI, and type identity.


[[no_unique_address]] (C++20)

What the attribute requests

[[no_unique_address]] marks a non-static data member such that, if the member’s type is empty in the standard’s sense for this feature, the implementation may give that member no unique address—i.e., it may occupy no space similarly to an empty base.

struct Empty {};

struct Holder {
  int x;
  [[no_unique_address]] Empty e;
};

On typical implementations, sizeof(Holder) == sizeof(int) when eligible.

Ordering and padding

Members are laid out in declaration order. A large-alignment member before a small one can still create padding; the attribute does not reorder members. I still permute real structs and measure when padding is suspicious—especially if I can place an empty member where it absorbs padding between earlier fields.

Multiple [[no_unique_address]] empty members

If you have two members of the same empty class type, the compiler cannot necessarily overlap their addresses in ways that would violate unique-address rules for members—you may not get the compression you expect for duplicates of the same type. Different empty types are easier to reason about:

struct E1 {};
struct E2 {};

struct Ok {
  int x;
  [[no_unique_address]] E1 a;
  [[no_unique_address]] E2 b;
};

[[maybe_unused]] only silences warnings; it does not change layout.


EBCO vs [[no_unique_address]]

ConcernEBCO (empty base)[[no_unique_address]] member
Language supportPre-C++20C++20
Models “is-a”Yes (public/protected/private inheritance)No; it is a member
API surfaceBase classes visible in overload resolutionMember name is explicit
Multiple “same type” emptiesOften awkward; duplicate base types are specialProblematic; may not compress duplicates as hoped
Popular in stdlib patternsExtremely common historicallyAvailable in C++20-era designs
MSVC prior supportEmpty bases worked; attribute support is C++20C++20

Use private inheritance when a base hook is the natural shape of the design; use [[no_unique_address]] when a named member (m_alloc, comp_) clarifies the API. On pre-C++20 codebases, a macro gated on __cpp_no_unique_address is a reasonable compromise if size tests justify it.


Policy-based design, allocators, and tags

Stateless policies as empty types

Policy-based design factors behavior into template parameters that are classes with only static or empty state: hash policy with no data, a threading policy without a real mutex, an empty release-only logging policy. If Compare in a FlatSet is an empty functor, a member [[no_unique_address]] Compare comp_ may add no size—if Compare holds user state, compression correctly disappears.

Stateless allocators in nodes

Node-based containers historically store allocator instances because stateful allocators must be carried. For stateless std::allocator and many custom empty allocators, a full data member can bloat every node without EBCO/compression. Library implementations often use base class compression and/or [[no_unique_address]]. Correctness still depends on propagation rules and traits; optimization only changes storage size when legal.

template <class T, class Alloc = std::allocator<T>>
struct Node {
  T value;
  Node* next;
  [[no_unique_address]] Alloc alloc; // often 0 size if empty
};

Tag types

Tag types are empty structs for overload selection (from_file_t). Tags passed as function parameters are not stored; EBCO matters when you store a tag in an object. The mental link is that tags and policies are the same species of type—empty classes—that benefit from compression in layout.


Standard library and memory layout (high level)

You must not depend on sizeof(std::container<...>) across standard library versions as a guarantee, but implementations aim for compression where possible: std::unique_ptr<T, Deleter> often compresses an empty default deleter; std::tuple often uses recursive inheritance or compressed storage to limit padding for empty element types. std::function is a different mechanism (type erasure, vtable, buffer)—not an “empty functor compresses like a pointer” story.

A simple sizeof progression

struct E1 {};
struct E2 {};

struct A {
  int x;
  E1 e1;
};

struct B : E1 {
  int x;
};

struct C {
  int x;
  [[no_unique_address]] E1 e1;
};

struct D {
  int x;
  [[no_unique_address]] E1 e1a;
  [[no_unique_address]] E2 e2b;
};

On many platforms, sizeof(B) == sizeof(int) and sizeof(C) == sizeof(int) when eligible. sizeof(A) is commonly 8 due to padding. offsetof is only legal for standard-layout types; template-heavy storage may be non-standard-layout even when it looks “simple.”

Inspecting layout: Clang’s -fdump-record-layouts and MSVC’s /d1reportAllClassLayout are educational; I do not turn them on in production builds by default.


I actually looked at the disassembly to verify

Textbooks and static_assert(sizeof(T) == …) are useful, but once I was arguing with myself whether a particular [[no_unique_address]] member was truly elided. I compiled the translation unit to assembly (-S with Clang or GCC, or the compiler listing MSVC can emit) and stepped through the optimized build. The generated code for member access only touched the live data—there was no extra load for a storage slot that did not exist. That matched what the record-layout dump had claimed.

Disassembly is not a substitute for the standard: it is one toolchain, one flag set, one day. It did, however, end a debate in my own head and convinced me to trust the measurement path—compiler dumps plus assembly—when a few bytes actually matter. When they do not, I do not do this; life is too short to -S every struct.


ABI and compiler behavior

The Application Binary Interface governs how separately compiled TUs share object layout. You can rely on language rules for program correctness; you cannot portably hardcode sizeof for standard library types or exotic templates across compilers. Dynamic libraries and ODR still apply: empty types do not grant an exemption. Upgrading the compiler or standard library can change implementation layout—design exported ABI with that in mind (pimpl, stable C API).

All three major compilers implement EBCO for simple cases on their ABIs. Divergence shows up in multiple inheritance corners, duplicate [[no_unique_address]] members of the same type, and warning strictness. If I static_assert layout for my type, I only do it on toolchains I test in CI and accept the maintenance cost. Feature tests such as __cpp_no_unique_address help when I conditionally compile a member.

#if defined(__cpp_no_unique_address)
  #define PKGLOG_NO_UNIQUE_ADDRESS [[no_unique_address]]
#else
  #define PKGLOG_NO_UNIQUE_ADDRESS
#endif

Document in code review or comments why a struct’s size contract matters when you rely on compression—future maintainers are not mind readers.


Real examples

Tuple sketch (inheritance chain)

A common teaching pattern is a recursive tuple front with an empty tail:

struct TupleLeaf {};

template <std::size_t I, class T>
struct TupleElem : TupleLeaf {
  T value;
};

template <class Indices, class... Ts>
struct TupleImpl;

template <std::size_t... Is, class... Ts>
struct TupleImpl<std::index_sequence<Is...>, Ts...>
  : TupleElem<Is, Ts>... {
};

Distinct index parameters create distinct base types, which matters for compressing multiple elements of the same type without the duplicate-base trap. A real std::tuple is not this small—references, assignment, and SFINAE matter—but the sketch shows why implementers juggle base chains.

compressed_pair

boost::compressed_pair popularized storing (first, second) with EBCO when one or both types are empty. A C++20 style:

template <class F, class S>
struct CompressedPair {
  [[no_unique_address]] F first;
  [[no_unique_address]] S second;
};

If F and S are the same empty type, you may not get the ideal layout—mirroring the duplicate member problem. A classic EBCO alternative inherits one side from the empty type and holds the other as a member, with care for ABI and ambiguity.

TinyVector with private allocator base

template <class T, class Alloc = std::allocator<T>>
class TinyVector : private Alloc {
public:
  explicit TinyVector(Alloc a = {}) : Alloc(std::move(a)) {}

  Alloc& allocator() { return *this; }
  const Alloc& allocator() const { return *this; }

private:
  T* ptr_{nullptr};
  std::size_t size_{0};
  std::size_t cap_{0};
};

TinyVector<int> with the default allocator often adds no separate storage for the allocator when Alloc is empty. private inheritance keeps the base relationship out of the public API.


Performance: cache, padding, and when size wins

EBCO/[[no_unique_address]] can shrink hot container nodes, cut cache footprint, and help pointer-chasing workloads when padding was the real enemy. It does not improve asymptotic complexity, and it can interact badly with false sharing if you pack poorly—profile with real traffic. If you need this class of win, you already know: microbenchmarks on toy loops lie; use workload metrics and cache miss counters where they matter.


When I got this wrong

I once slapped [[no_unique_address]] on a member and assumed the object shrank, but the type had picked up a vptr in a refactor. The sizeof did not move; I had wasted a code review on noise. The fix was to notice virtuals, not to sprinkle more attributes.

I have also been burned by two [[no_unique_address]] members of the same empty class type, expecting both to vanish. The layout stayed fat until I split the types (distinct tag structs) or changed to a base class design. I should have read the standard’s mental model of unique addresses before naming two identical empty members.

Padding sometimes increased after I reordered “optimized” members because alignment of the next field moved. I now reorder with dumps and sizeof, not intuition.

I have not had good luck using std::function or std::variant “layout” as a stand-in for EBCO education—they are different beasts; when the symptom looks like “weird sizeof,” the cause is often type erasure, not an empty policy.

Weird overload resolution after a public EBCO base was my fault: I had let the base participate in the type’s story more than I intended. Private inheritance and narrower using declarations fixed the API without giving up the size win.


Summary

  1. Most programs do not need this; use it when measured size or a clear design win (e.g. named allocator in a handle) justifies the complexity.
  2. Empty complete objects usually have sizeof >= 1 for address uniqueness.
  3. EBCO can make empty base subobjects contribute zero size; [[no_unique_address]] does the analogous job for members (C++20).
  4. Policy types and stateless allocators are the everyday motivation when you do care.
  5. ABI and library layout are not portable contracts—test, document, and measure.


FAQ (from front matter, expanded briefly)

Is EBCO applied to all standard types?
No. Standard library types have implementation-defined aspects; only document what your types guarantee.

When should I use [[no_unique_address]]?
When you store an object of empty class type and want the implementation to be allowed to eliminate its storage—most useful for allocators, comparators, and policy objects in hot storage.

Does EBCO always improve performance?
Not automatically. Smaller size often helps, but layout affects caching and concurrency in subtle ways—profile.

Does unique_ptr shrink with an empty deleter?
Often yes on major implementations, but it is not a language guarantee you should hardcode as an ABI promise for all toolchains.


Further reading

  • cppreference: [no_unique_address] (C++ attribute)
  • Itanium C++ ABI documentation (for Linux toolchains): base layout rules and empty base offsets
  • Your standard library’s <memory> and <tuple> sources (educational; not a stability contract)