C++ Interface Design and PIMPL: Cut Compile Dependencies and Keep ABI Stability [#38-3]
이 글의 핵심
Hide implementation behind a pointer, stabilize your public header, and ship libraries without breaking callers.
Introduction: when the header changes, the world rebuilds
“I only changed a private field—why did everything recompile?”
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<Impl> 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.
This article covers:
- PIMPL mechanics and when to use them
- Special members (destructor, copy, move)
- ABI and what stays stable in public headers
- Scenarios, full interface examples, errors, versioning, production patterns
Table of contents
- Scenarios
- PIMPL pattern
- Copy, move, destructor
- ABI and public headers
- Complete interface examples
- Common errors
- Versioning
- Production patterns
- Summary
1. Scenarios where PIMPL helps
1) 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.
2) 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.
3) 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.
4) 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.
5) 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<B> (or PIMPL) in A.h with forward declaration; include B.h in A.cpp.
2. PIMPL pattern
Hide implementation behind a pointer
- The public class holds a pointer to Impl (typically
std::unique_ptr<WidgetImpl>). 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<WidgetImpl> 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<WidgetImpl>() and delegate doSomething() to pImpl_.
// widget.h — public API
#pragma once
#include <memory>
class WidgetImpl;
class Widget {
public:
Widget();
~Widget();
void doSomething();
private:
std::unique_ptr<WidgetImpl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include "widget_impl.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.
3. Copy, move, destructor
Rule of Five and PIMPL
- Destructor: define where Impl is complete so
unique_ptrcan delete it—usually= defaultin .cpp. - Copy: default copy shallow-copies the pointer—wrong. Implement deep copy in .cpp by cloning
*other.pImpl_into a new Impl. - Move: often
= defaultwithnoexceptso containers prefer move on reallocation.
// widget.h
class Widget {
// ...
Widget(const Widget& other);
Widget& operator=(const Widget& other);
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
};
// widget.cpp
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;
}
4. 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.
5. 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);
};
}
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;
PluginHost& operator=(PluginHost&&) noexcept = default;
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.
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.
6. Common errors
sizeofon incomplete type: defining~Widget() = defaultinline in the header while Impl is incomplete—move~Widget()to .cpp.- Self-assignment in copy-assign without
if (this != &other)when reusing Impl storage. - Using moved-from object after
std::move—treat as invalid unless reset. - Exposing STL containers in public ABI—prefer PIMPL or stable opaque handles.
- Inserting virtuals in the middle of a polymorphic interface—append new virtuals at the end for compatibility.
- Publishing
widget_impl.hto clients—defeats compile isolation. - Non-
noexceptmove—can force copy instd::vectorreallocation.
7. 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.
8. Production patterns
- Factory + PIMPL for plugin-like creation.
- Lazy PIMPL: construct Impl on first use.
unique_ptrby default;shared_ptronly if shared ownership is real.extern "C"exports for maximum cross-toolchain stability.- Measure indirection cost on hot paths before micro-optimizing away PIMPL.
9. Summary
| Topic | Takeaway |
|---|---|
| PIMPL | Hide implementation; stabilize public header & size |
| Special members | Destructor/copy/move defined with full Impl visible |
| ABI | Public surface + vtable plan + versioning discipline |
| Errors | Incomplete 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/Boost-style), plugin SDKs, and large codebases where header churn dominates build time—often 5–10 hot headers refactored to PIMPL yield large CI savings.
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.
Related links
- PIMPL and Bridge #19-3
- Design patterns #20-2
- ABI compatibility #55-4
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
Keywords (SEO)
PIMPL, C++ ABI, binary compatibility, opaque pointer, unique_ptr, library design, compile time