C++ Interface Design and PIMPL: Cut Compile Dependencies

Introduction: when the header changes, the world rebuilds

If implementation details (data members, heavy includes) live in a public header, every translation unit that includes it is affected. Adding/removing private members can change object layout and break ABI. PIMPL (pointer to implementation) moves the real class into an Impl type defined only in .cpp files. The public class holds something like std::unique_ptr and forward-declares Impl in the header—like showing only the building façade while machinery stays out of sight. Including implementation headers only from .cpp cuts compile dependencies and makes binary compatibility easier.

The underlying reason is that C++ has no separation between a class’s interface and its layout. To create a Widget on the stack or as a member, the compiler must know sizeof(Widget), which depends on every data member — including private ones — so the private section is part of what every client compiles against. Private members are hidden from access, not from the compiler. Change one, and every translation unit that includes the header has stale assumptions about offsets and size; the build system rebuilds them all, and already-compiled binaries that are not rebuilt read the wrong offsets. PIMPL breaks that link by making the only data member a pointer, whose size never changes.


Scenarios where PIMPL helps

Shipping a library: add a private cache

You ship Document and later add an internal std::unordered_map. Every user of document.h rebuilds; mixing old and new .so can crash due to ABI mismatch. Fix: hide state in DocumentImpl; document.h stays stable so many clients avoid rebuilds and ABI stays coherent.

Build time explosion

Widget.h is included by 200+ .cpp files. Adding #include <boost/json.hpp> to the header pulls a huge parse cost into every TU. Fix: include Boost.JSON only in widget.cpp; widget.h stays light.

Plugin ABI drift

Host and plugins build at different times. Adding a virtual in the middle of a base interface shifts vtable slots and crashes old plugins. Fix: stable abstract interface + PIMPL in concrete wrappers; add features via versioned APIs or new virtuals at the end.

Platform-specific backends

FileWatcher uses ReadDirectoryChangesW on Windows and inotify on Linux. Platform headers in a shared .h break cross-platform builds. Fix: FileWatcherImpl in .cpp behind #ifdef.

Circular includes

A.h includes B and B includes A; forward declarations are not enough if A stores B by value. Fix: std::unique_ptr (or PIMPL) in A.h with forward declaration; include B.h in A.cpp.

The ABI scenarios are the ones with the worst symptoms, because nothing fails at build time. A client compiled against the old document.h allocates, say, 48 bytes for a Document; the new library’s code, which believes a Document is 104 bytes, writes the new cache member past the end of that allocation. The result is heap corruption that shows up as a crash somewhere unrelated, often only in release builds. The vtable case is similar: an old plugin calls “slot 3” expecting render(), but in the new host slot 3 is the newly inserted virtual function. If you have ever seen a crash that disappears after a full clean rebuild of every component, a layout mismatch like this is a likely cause.


PIMPL pattern

Hide implementation behind a pointer

  • The public class holds a pointer to Impl (typically std::unique_ptr). Impl is forward-declared in the header and defined in .cpp.
  • TUs that include the public header do not see Impl’s size or members, so changes to Impl do not force recompilation of unrelated TUs.
flowchart TB
    subgraph public["Public header (widget.h)"]
        W[Widget]
        P[pImpl_]
        W --> P
    end
    subgraph impl["Implementation (.cpp only)"]
        I[WidgetImpl]
        D[data, cache, ...]
        I --> D
    end
    P -.->|pointer only| I

Because std::unique_ptr needs a complete type in the destructor’s TU, declare ~Widget() in the header and define it in .cpp after WidgetImpl is complete. Construct with std::make_unique() and delegate doSomething() to pImpl_.

// widget.h — public API
#pragma once
#include <memory>
struct WidgetImpl;  // must match the definition's class-key (struct) — MSVC mangles class/struct differently
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
private:
    std::unique_ptr<WidgetImpl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include <vector>
struct WidgetImpl {
    std::vector<int> data;
};
Widget::Widget() : pImpl_(std::make_unique<WidgetImpl>()) {}
Widget::~Widget() = default;
void Widget::doSomething() {
    // use pImpl_->data
}

std::shared_ptr can avoid out-of-line destructor requirements at the cost of control blocks and atomic refcounting; unique_ptr is the default for exclusive ownership.

The out-of-line destructor is the single most common PIMPL error. If ~Widget() is not declared, the compiler generates it implicitly — inline, in every file that destroys a Widget — and there WidgetImpl is incomplete. std::unique_ptr’s deleter contains a static_assert against deleting incomplete types, so the build fails with a message like invalid application of 'sizeof' to incomplete type 'WidgetImpl' (GCC) or can't delete an incomplete type (MSVC), pointing into the <memory> header rather than your code. Declaring the destructor in the header and writing Widget::~Widget() = default; in the .cpp, after the WidgetImpl definition, moves the instantiation to the one place where the type is complete. shared_ptr does not have this problem because it captures the deleter when the pointer is created, which is also why it costs more.

The class-key comment in the header is not pedantry: forward-declaring class WidgetImpl and defining struct WidgetImpl is legal C++, but MSVC encodes the class-key into mangled names and warns with C4099; in some cases this leads to unresolved-symbol link errors that only appear on Windows.


Copy, move, destructor

Rule of Five and PIMPL

  • Destructor: define where Impl is complete so unique_ptr can delete it—usually = default in .cpp.
  • Copy: default copy shallow-copies the pointer—wrong. Implement deep copy in .cpp by cloning *other.pImpl_ into a new Impl.
  • Move: often = default with noexcept so containers prefer move on reallocation.
// widget.h
class Widget {
    // ...
    Widget(const Widget& other);
    Widget& operator=(const Widget& other);
    Widget(Widget&&) noexcept;             // declared here,
    Widget& operator=(Widget&&) noexcept;  // defaulted in widget.cpp
};
// widget.cpp (after the WidgetImpl definition)
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
Widget::Widget(const Widget& other)
    : pImpl_(std::make_unique<WidgetImpl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
    if (this != &other) {
        *pImpl_ = *other.pImpl_;
    }
    return *this;
}

The move operations are defaulted in the .cpp for the same reason as the destructor: move assignment must destroy the WidgetImpl the target currently owns, which needs the complete type, so = default directly in the header fails to compile with the same incomplete-type error. The copy constructor is a genuine deep copy: WidgetImpl’s own copy constructor duplicates the vector. The copy assignment reuses the existing Impl instead of allocating a new one, which is efficient, but it has a hole: if *this was moved from, pImpl_ is null and *pImpl_ dereferences null. Either document moved-from Widgets as usable only for destruction and assignment-from-scratch, or allocate when pImpl_ is empty. Copy-and-swap (Widget tmp(other); std::swap(pImpl_, tmp.pImpl_);) handles both cases and gives the strong exception guarantee at the cost of an allocation per assignment.


ABI and public headers

Why ABI matters

ABI covers layout, calling conventions, and name mangling across compiled boundaries. Changing public class layout breaks consumers who were built against an older .so.

flowchart LR
    subgraph stable[PIMPL — stable ABI]
        A1[Widget] --> A2[one pointer]
        A2 --> A3[Impl can evolve]
    end
    subgraph broken[Exposed members — fragile ABI]
        B1[Widget] --> B2[inline data]
        B2 --> B3[layout change ⇒ crash]
    end

PIMPL keeps the public object size fixed (typically one pointer). You can still break ABI by adding virtual functions in the wrong place or changing existing public data members—follow a versioning policy.

PIMPL stabilizes layout, but ABI has other parts that it does not protect. Changing a function’s parameter types, const-ness or return type changes its mangled name, so old clients fail to load with an undefined-symbol error — a loud failure, which is the better kind. Changing an inline function in the header or a default argument is silent: old clients keep the old inline code compiled into their own binaries. Standard library types in the interface bring the standard library’s ABI with them — GCC’s switch to a new std::string layout in version 5 (the _GLIBCXX_USE_CXX11_ABI macro) broke exactly the libraries that passed std::string across their boundaries. And on Windows, a DLL built with one MSVC runtime that passes a std::vector to a client using a different runtime or debug/release setting can crash when the client frees memory allocated by the DLL. PIMPL helps with all of these only insofar as it keeps such types out of the public header.


Complete examples

Plugin-style C API

// plugin_interface.h — stable ABI
#pragma once
#include <memory>
#include <string>
#include <cstdint>
extern "C" {
struct PluginAPI {
    uint32_t version;
    void* (*create)(const char* config);
    void (*destroy)(void* handle);
    int (*process)(void* handle, const void* input, void* output);
};
}

A C struct of function pointers is the most portable plugin boundary available: C has a de facto stable ABI on every major platform, so a host compiled with MSVC can load a plugin built with MinGW or a different MSVC version, which is not true of C++ classes. The version field is placed first so that a host can check it before touching any other member, and new function pointers are only ever appended, so an older plugin’s struct is a valid prefix of the newer layout. destroy exists because memory must be freed by the module that allocated it — the host calling delete on a handle created inside the plugin is exactly the cross-runtime problem described above. (The <memory> and <string> includes are not needed for this C header itself.)

Host wrapper with PIMPL

#pragma once
#include <memory>
#include <string>
class PluginImpl;
class PluginHost {
public:
    explicit PluginHost(const std::string& plugin_path);
    ~PluginHost();
    PluginHost(const PluginHost&) = delete;
    PluginHost& operator=(const PluginHost&) = delete;
    PluginHost(PluginHost&&) noexcept;             // = default in .cpp,
    PluginHost& operator=(PluginHost&&) noexcept;  // where PluginImpl is complete
    int process(const void* input, void* output);
private:
    std::unique_ptr<PluginImpl> pImpl_;
};

Implementation loads dlopen/ PluginAPI, holds instance pointer, and calls process through the vtable-like function pointers.

Here PIMPL hides the platform details: PluginImpl holds the dlopen/LoadLibrary handle, the PluginAPI* and the plugin’s instance handle, and its destructor calls api->destroy(handle) before dlclose. That order matters — unloading the library first leaves destroy pointing at unmapped code, and the crash happens on shutdown, which makes it easy to miss in testing. Copying is deleted because two hosts sharing one plugin instance would both destroy it.

Document class (ABI-friendly public API)

The full Document / DocumentImpl listing keeps an std::unordered_map cache in the .cpp—only the stable interface ships in headers.

FileWatcher (platform PIMPL)

Public header stays neutral; FileWatcherImpl contains HANDLE vs inotify fds behind #ifdef.


Common errors

  1. sizeof on incomplete type: defining ~Widget() = default (or the move operations) inline in the header while Impl is incomplete—move them to .cpp.
  2. Self-assignment in copy-assign without if (this != &other) when reusing Impl storage.
  3. Using moved-from object after std::move—treat as invalid unless reset.
  4. Exposing STL containers in public ABI—prefer PIMPL or stable opaque handles.
  5. Inserting virtuals in the middle of a polymorphic interface—append new virtuals at the end for compatibility.
  6. Publishing widget_impl.h to clients—defeats compile isolation.
  7. Non-noexcept move—can force copy in std::vector reallocation.

Versioning

  • MAJOR: ABI breaks (layout, incompatible vtables).
  • MINOR: additive changes hidden in Impl or new trailing virtuals.
  • PATCH: implementation-only fixes. Use semantic version fields in C APIs, symbol versioning on Linux, and ABI checker tools in CI.

On Linux, the major version usually lives in the shared library’s SONAME (libdoc.so.2), so programs built against version 1 keep loading libdoc.so.1 and are never handed an incompatible library. Tools such as abi-compliance-checker or libabigail’s abidiff compare two builds of a library and report layout, vtable and symbol changes, which catches accidental ABI breaks in review rather than in the field. The versioning rules only help if something checks them automatically; “we will be careful” does not survive the first urgent release.


Production patterns

  • Factory + PIMPL for plugin-like creation.
  • Lazy PIMPL: construct Impl on first use.
  • unique_ptr by default; shared_ptr only if shared ownership is real.
  • extern “C” exports for maximum cross-toolchain stability.
  • Measure indirection cost on hot paths before micro-optimizing away PIMPL.

The costs are concrete: one heap allocation per object, one extra pointer dereference per member access (often a cache miss when many objects are processed in a loop), and no inlining of member functions across the .cpp boundary unless link-time optimization is enabled. For a handful of long-lived objects — a database connection, a window, a document — this is negligible. For millions of small value objects such as vectors or colors it can dominate, which is why PIMPL is used for “service” classes and avoided for small value types. A “fast PIMPL” variant stores the Impl in an aligned byte buffer inside the object to avoid the allocation, but it hard-codes the Impl size in the header and so gives back most of the ABI benefit.


Summary

TopicTakeaway
PIMPLHide implementation; stabilize public header & size
Special membersDestructor/copy/move defined with full Impl visible
ABIPublic surface + vtable plan + versioning discipline
ErrorsIncomplete destructor type, bad copy, virtual order

Series #38 moves from clean interfaces → composition → PIMPL/ABI as a foundation for maintainable large codebases.

When PIMPL is essential

Libraries (Qt uses it pervasively, as “d-pointers”, precisely to keep binary compatibility across minor releases), plugin SDKs, and large codebases where header churn dominates build time. In the last case, the gains are concentrated: a few widely included headers that change often usually account for most unnecessary rebuilds, so it pays to find them (build-time tracing such as Clang’s -ftime-trace helps) before converting classes wholesale.

Checklist

  • Hot include graph?
  • Private members change often?
  • Heavy third-party includes in header?
  • Need stable .so for out-of-tree users?
    Skip PIMPL for tiny header-only types, templates (different trade-offs), or proven nanosecond hot paths—profile first.

FAQ

When is this useful? Shipping shared libraries, plugins, and any API where compile time and ABI stability matter. What to read next? Series index, then cache / data-oriented design #39-1. Previous: Polymorphism & variant #38-2
Next: Data-oriented design #39-1