Pimpl Idiom
"The Pointer to Implementation idiom: ABI stability, compilation firewalls, and complete encapsulation using unique_ptr — with full Rule of 5 mechanics."
Pimpl Idiomsince C++11A class design technique that moves all private state into a separately-defined struct accessed through a std::unique_ptr, creating a compilation firewall that hides implementation headers from callers and fixes sizeof(T) to one pointer width for binary-stable ABI.
Overview
The pattern predates C++11 — the raw-pointer form appeared in C++98-era codebases — but std::unique_ptr (C++11) made it safe and idiomatic by eliminating manual deletion. Pimpl is the simplest instance of the Bridge design pattern: the public header owns the interface, while all state and implementation dependencies live behind a pointer to a forward-declared Impl struct defined exclusively in the .cpp file.
Two engineering goals drive adoption:
Compilation firewall. Without Pimpl, #include "widget.h" forces every including translation unit to parse every header that Widget's private members require — <mutex>, <vector>, internal subsystem headers. With Pimpl, the public header needs only <memory> plus your own forward declaration. In codebases where a header is included from hundreds of translation units, this routinely cuts clean-build times by 30–50%.
ABI stability. sizeof(Widget) is always sizeof(std::unique_ptr<Widget::Impl>) — 8 bytes on 64-bit platforms — regardless of how Impl's members change. Shared library consumers can upgrade to a new library version without recompiling their own code, because the binary layout of any object containing Widget never changes. Without Pimpl, adding a single int to Widget's private section invalidates every downstream binary.
The overhead is real but bounded: one heap allocation per object and one pointer indirection per member access. For objects not constructed in tight loops, this is negligible.
Syntax
The canonical C++11/C++14 structure. All five special members are declared in the header; bodies that require Impl to be complete are defined in the .cpp.
// widget.h — callers only see <memory> and this file
#pragma once
#include <memory>
#include <string>
class Widget {
public:
explicit Widget(std::string name);
~Widget(); // defined in .cpp — Impl must be complete
Widget(Widget&&) noexcept; // C++11 — defined in .cpp
Widget& operator=(Widget&&) noexcept; // C++11 — defined in .cpp
Widget(const Widget&);
Widget& operator=(const Widget&);
void process();
[[nodiscard]] std::string name() const; // [[nodiscard]]: C++17
private:
struct Impl; // forward declaration only — Impl is incomplete here
std::unique_ptr<Impl> impl_; // std::unique_ptr: C++11
};// widget.cpp — heavy headers confined to this translation unit
#include "widget.h"
#include <mutex>
#include <vector>
#include "database_connection.h" // never leaks into widget.h callers
struct Widget::Impl {
std::string name;
std::vector<int> cache;
std::mutex mtx; // non-copyable, non-movable — fine inside Impl
DatabaseConnection db;
explicit Impl(std::string n) : name(std::move(n)) {}
};
// Impl is complete here — unique_ptr's deleter can call delete safely
Widget::~Widget() = default;
Widget::Widget(std::string name)
: impl_(std::make_unique<Impl>(std::move(name))) {} // make_unique: C++14
// C++11 alternative: impl_(new Impl(std::move(name)))
// Move defaults work here: Impl is complete, unique_ptr's move is well-defined
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
Widget::Widget(const Widget& other)
: impl_(std::make_unique<Impl>(*other.impl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other)
impl_ = std::make_unique<Impl>(*other.impl_);
return *this;
}
void Widget::process() {
std::lock_guard<std::mutex> lock(impl_->mtx); // lock_guard: C++11
// std::lock_guard lock(impl_->mtx); // CTAD: C++17
impl_->db.execute(impl_->cache);
}
std::string Widget::name() const { return impl_->name; }Examples
Move-only resource handle
Resources that cannot be duplicated should explicitly delete copy operations:
// file_handle.h — C++11
#pragma once
#include <memory>
class FileHandle {
public:
explicit FileHandle(const char* path, const char* mode);
~FileHandle();
FileHandle(FileHandle&&) noexcept;
FileHandle& operator=(FileHandle&&) noexcept;
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
void write(const void* data, std::size_t len);
void flush();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};Move operations declared = default in the header still require explicit = default definitions in the .cpp where Impl is complete — see Common Pitfalls.
Shared implementation with std::shared_ptr
When copies should share underlying state, std::shared_ptr (C++11) provides type-erased deletion: its deleter is captured in the control block at construction time, so Impl need not be complete when the destructor is generated. The compiler-generated destructor in the header is safe:
// config.h — C++11
#pragma once
#include <memory>
#include <string>
class Config {
public:
explicit Config(const std::string& path);
// No user-defined destructor needed — shared_ptr handles deletion safely
// Compiler-generated copy/move work: shared copies share the same Impl
std::string get(const std::string& key) const;
void set(std::string key, std::string value);
private:
struct Impl;
std::shared_ptr<Impl> impl_; // shared_ptr: C++11; costs atomic refcount on copy
};Prefer unique_ptr unless reference-counted sharing is a deliberate design choice — shared_ptr adds per-copy atomic operations and a separate control block allocation.
ABI stability across library versions
Without Pimpl:
libwidget.so v1.0 sizeof(Widget) == 48
consumer binary links v1.0
libwidget.so v2.0 sizeof(Widget) == 64 (added two members)
consumer binary links v2.0 at runtime → stack/heap layout mismatch → crash
With Pimpl:
libwidget.so v1.0 sizeof(Widget) == 8 (one pointer)
libwidget.so v2.0 sizeof(Widget) == 8 (still one pointer; Impl grew internally)
consumer binary links v2.0 at runtime → works without recompilationBest Practices
- Declare all five special members in the header, even as
= deleteor= default. The compiler needs declarations to resolve copy/move expressions at call sites. Put any body requiringImplcompleteness in the.cpp. - Use
std::make_unique(C++14) in the constructor initializer list rather thannew. If multiple subexpressions in an initializer list can throw,newcan leak;make_uniqueeliminates the window. - Keep
Impla plain struct. No virtual functions, no inheritance. Pimpl hides data, not behavior. If you need virtual dispatch inside the implementation, the full Bridge pattern with an abstractEngineinterface is the right tool. - Do not add
getImpl()accessors for testing. Evenfriend class WidgetTestin the header couples test code to the declaration. Test through the public interface, or use a separate internal header that callers outside the library never include. - Audit
constpropagation.const Widget::method()makesimpl_(the pointer) const, not*impl_. Fields ofImplare freely mutable through aconst Widget. If you rely onconstfor thread safety, enforce it explicitly withmutable std::shared_mutex(C++17) insideImpl.
Common Pitfalls
Destructor defined in the header. std::unique_ptr<Impl> must call delete Impl* when the unique_ptr is destroyed. If Impl is incomplete at the point the destructor is instantiated — which it always is in the header — this is undefined behavior. Modern libstdc++ and libc++ emit a static_assert that catches this, but older toolchains silently generate broken code. Always define Widget::~Widget() = default; in the .cpp.
Move special members = default in the header. Widget(Widget&&) noexcept = default; instantiates unique_ptr's move constructor. The move constructor of unique_ptr<Impl> requires Impl to be a complete type for the deleter. Putting = default in the header compiles only if Impl is complete there — which it isn't. Move the definition to the .cpp:
// widget.cpp — correct
Widget::Widget(Widget&&) noexcept = default; // Impl complete here
Widget& Widget::operator=(Widget&&) noexcept = default;Self-assignment in copy assignment. The guard if (this != &other) is mandatory. Without it, impl_ = std::make_unique<Impl>(*other.impl_) first allocates a new Impl by dereferencing other.impl_ — which is impl_ when this == &other — then destroys the old impl_, invalidating the source object mid-copy.
mutable and lazy initialization confusion. A const method that lazily populates a field inside Impl will compile without mutable on the field, because the pointer impl_ is const but *impl_ is not. This means mutable is unnecessary inside Impl — which can be surprising if you're used to adding mutable for caches. The flip side is that const Widget offers weaker thread-safety guarantees than it appears.
Incomplete Type and the Destructor
std::unique_ptr<Impl> uses std::default_delete<Impl> as its deleter. default_delete::operator() calls delete p, which requires sizeof(Impl) > 0 — i.e. Impl must be a complete type. The destructor of unique_ptr<Impl> is therefore also the point where Impl must be complete.
When the compiler generates Widget's destructor in the header, Impl is still an incomplete forward declaration. This triggers a hard error on any conforming toolchain:
error: invalid application of 'sizeof' to an incomplete type 'Widget::Impl'
static_assert(sizeof(_Tp) > 0, ...)Modern libstdc++ and libc++ emit this static_assert explicitly. Older toolchains used to silently generate broken code that called delete on an incomplete pointer — undefined behavior that could skip running Impl's destructor.
The fix is a user-declared destructor in the header, defined in the .cpp:
// widget.h — declare only; definition must be in the .cpp
class Widget {
public:
~Widget(); // declared here — NOT = default, NOT defined here
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// widget.cpp — Impl is complete here, so unique_ptr's deleter is valid
Widget::~Widget() = default; // the = default body is emitted hereThe declaration in the header tells the compiler "do not synthesize the destructor body here." The out-of-line = default in the .cpp tells the compiler to synthesize the body at a point where Impl is fully defined. The code generated is identical to a fully empty destructor body — there is no runtime cost.
The same incomplete-type constraint applies to the move constructor and move assignment operator. unique_ptr's move constructor resets the source pointer to nullptr, which again requires the deleter to be callable. Putting = default on these in the header causes the same error as an inline destructor.
// widget.h — declare, do not define
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
// widget.cpp — define after Impl is complete
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;Move and Copy Semantics
Move-only (most common)
A pimpl class whose Impl contains non-copyable members (like std::mutex or file handles) naturally becomes move-only. Declare the moves and explicitly delete the copies:
// widget.h
class Widget {
public:
explicit Widget(std::string name);
~Widget();
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// widget.cpp — Impl complete, moves defaulted here
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;After a move, the source object's impl_ is nullptr. Any call to a method that dereferences impl_ on a moved-from Widget is undefined behavior. Document this or add a null check.
Copyable with deep copy
To support copies, implement the copy constructor and copy assignment in the .cpp by constructing a new Impl from the existing one. This requires Impl to be copyable (all its members must support copy construction):
// widget.cpp
Widget::Widget(const Widget& other)
: impl_(std::make_unique<Impl>(*other.impl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other)
impl_ = std::make_unique<Impl>(*other.impl_);
return *this;
}The self-assignment guard if (this != &other) is essential: without it, make_unique<Impl>(*other.impl_) reads from the same impl_ that the assignment is about to destroy.
Why you cannot = default copies in the header
unique_ptr's copy constructor is explicitly deleted. A = default copy constructor on Widget would call unique_ptr's (deleted) copy constructor, producing a compile error. The deep-copy implementation must be written by hand in the .cpp.
Fast Pimpl (In-Place Storage)
The standard pimpl idiom carries two performance costs: a heap allocation per object construction and a pointer indirection (potential cache miss) on every member access. For objects created in tight loops or with strict latency requirements, these costs can matter.
Fast Pimpl eliminates the heap allocation by embedding a raw byte buffer of the right size and alignment directly inside the public class, then constructing Impl into that buffer with placement new:
// widget.h — C++17
#pragma once
#include <cstddef> // std::byte
#include <new> // std::destroy_at, std::launder
class Widget {
public:
Widget();
~Widget();
// copies are deleted — storage cannot be bitwise-copied
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
void process();
private:
struct Impl;
// storage sized and aligned to hold Impl — size must be kept in sync with the .cpp
alignas(std::max_align_t) std::byte storage_[64];
Impl* get_impl() noexcept;
};// widget.cpp
#include "widget.h"
#include <memory> // std::destroy_at
struct Widget::Impl {
int a{0};
int b{0};
// ... other fields
};
Widget::Impl* Widget::get_impl() noexcept {
// std::launder required when the underlying storage type differs from the object type
return std::launder(reinterpret_cast<Impl*>(&storage_));
}
Widget::Widget() {
static_assert(sizeof(Impl) <= sizeof(storage_),
"storage_ is too small for Impl — increase the buffer size in the header");
static_assert(alignof(Impl) <= alignof(decltype(storage_)),
"storage_ alignment is insufficient for Impl");
new (&storage_) Impl{};
}
Widget::~Widget() {
std::destroy_at(get_impl()); // calls Impl's destructor without freeing memory
}
void Widget::process() {
get_impl()->a += 1;
}The static_assert on sizeof and alignof catches mismatches at compile time in the .cpp where Impl is complete. If Impl grows beyond storage_, the build fails with a clear message rather than silently corrupting adjacent memory.
Fast Pimpl trade-offs
| Property | Standard Pimpl | Fast Pimpl |
|---|---|---|
| Heap allocation | One per object | None |
| Cache locality | Poor (pointer to heap) | Good (Impl inside Widget) |
sizeof(Widget) | Pointer size (8 bytes) | Fixed buffer size (manual) |
| ABI stability | Complete — Impl can grow freely | Broken if Impl size/alignment changes |
| Copy/move | Move via pointer swap | Complex — must manually copy fields |
| Code complexity | Low | High |
The critical trade-off is ABI stability: fast pimpl fixes sizeof(Widget) to the declared buffer size, which is visible in the header. If Impl's size or alignment ever increases beyond the declared buffer, the header must change — which breaks the ABI guarantee that standard pimpl provides. Fast pimpl is appropriate for internal types where ABI stability is not a concern, or when profiling has confirmed that heap allocation is a genuine bottleneck.
ABI Stability: What Changes Are Safe
With standard pimpl in place, the public header exposes exactly one pointer (std::unique_ptr<Impl> at 8 bytes on 64-bit platforms). The following changes to Impl are safe — they alter only the .cpp and leave the binary interface of any caller untouched:
- Add, remove, or reorder data members inside
Impl - Change the types of members inside
Impl - Add or remove private helper methods inside
Impl - Add new non-virtual public methods to
Widget(new symbols, no layout change) - Add new static data members to
Widget - Change the implementation of any existing method
The following changes to the public header break the ABI even with pimpl:
- Add a virtual function to
Widget— inserts a vtable pointer, changingsizeof(Widget)and the vtable layout; any caller that hard-coded the vtable slot order crashes - Change the order of existing virtual functions in
Widget - Add a non-static data member to
Widgetitself (not toImpl) — changessizeof(Widget) - Change or remove any existing public function signature — callers linked against the old symbol name will fail to resolve the new mangled name
The distinction is that pimpl stabilizes sizeof(Widget) and the layout of Widget's own fields; it does not protect against changes to the vtable or to the public function signatures. Qt's d-pointer (their name for pimpl) and KDE's library policy codify exactly this boundary: anything in the private class (Impl / FooPrivate) is freely changeable; anything declared in the public header that affects object layout or symbol names requires a major version bump.
Practical checklist before shipping a library update without recompilation:
- Did
sizeof(Widget)change? (Would if you added a member to the class itself, or added a first virtual function) - Did any public function signature change? (Return type, parameter types, cv-qualifiers, or
noexcept-ness) - Did the vtable order change? (Any addition/removal/reordering of virtual functions)
If the answer to all three is no, and all new state is inside Impl, binary consumers can safely upgrade without recompiling.
PImpl with std::shared_ptr and Copy-on-Write
std::shared_ptr enables reference-counted sharing of Impl between copies. Unlike unique_ptr, shared_ptr captures the deleter in its control block at construction time, using type erasure. This means Impl does not need to be a complete type at the point where Widget's destructor, copy constructor, or copy assignment operator is generated — the compiler-synthesized bodies are safe to leave in the header.
// C++11
// shared_widget.h — no user-declared destructor required
#pragma once
#include <memory>
#include <string>
class SharedWidget {
public:
explicit SharedWidget(std::string name);
// Compiler-generated destructor is safe: shared_ptr's deleter is type-erased
// Compiler-generated copies share the same Impl (ref-count incremented)
std::string name() const;
void rename(std::string new_name); // must COW before mutating
private:
struct Impl;
std::shared_ptr<Impl> impl_; // shared_ptr: C++11
};// C++11
// shared_widget.cpp
#include "shared_widget.h"
struct SharedWidget::Impl {
std::string name;
explicit Impl(std::string n) : name(std::move(n)) {}
};
SharedWidget::SharedWidget(std::string name)
: impl_(std::make_shared<Impl>(std::move(name))) {} // make_shared: C++11
std::string SharedWidget::name() const { return impl_->name; }
void SharedWidget::rename(std::string new_name) {
// Copy-on-write: if another SharedWidget shares this Impl, fork it first
if (!impl_.unique()) // unique(): C++11
impl_ = std::make_shared<Impl>(*impl_);
impl_->name = std::move(new_name);
}The impl_.unique() check in rename implements copy-on-write (COW): when the reference count is greater than one, a new Impl is allocated from the current state before the mutation, leaving all other SharedWidget instances untouched. impl_.unique() is deprecated in C++17 and removed in C++20; prefer impl_.use_count() == 1. Every copy operation is O(1) (atomic increment only), and the full heap allocation is deferred until a write actually occurs. The trade-off versus unique_ptr pimpl is the cost of an atomic increment and decrement on every copy and destruction, plus an extra control-block allocation unless std::make_shared is used.
PImpl with Virtual Functions for Full ABI Isolation
Combining PImpl with an abstract Impl interface provides the deepest available ABI isolation: callers see neither the public class's data layout nor any vtable slot order. In the standard pimpl form, the public Widget class itself may still have virtual functions whose vtable order is part of the ABI. Replacing the concrete Impl struct with an abstract interface breaks this link entirely — the .so exposes only a factory function and the public class with its one pointer member.
// C++11
// engine.h — only this header is shipped to consumers
#pragma once
#include <memory>
#include <string>
class Engine {
public:
// Abstract Impl: callers never see virtual function table details
struct IImpl {
virtual ~IImpl() = default;
virtual void run() = 0;
virtual std::string status() const = 0;
};
explicit Engine(std::unique_ptr<IImpl> impl);
~Engine();
Engine(Engine&&) noexcept;
Engine& operator=(Engine&&) noexcept;
Engine(const Engine&) = delete;
Engine& operator=(const Engine&) = delete;
void run();
std::string status() const;
// Factory: returns a concrete Engine; concrete type stays inside the .so
static Engine create(const std::string& config_path);
private:
std::unique_ptr<IImpl> impl_; // C++11
};// C++11
// engine.cpp — concrete type never appears in the installed header
#include "engine.h"
#include "internal_subsystem.h"
struct ConcreteEngine : Engine::IImpl {
InternalSubsystem subsystem;
explicit ConcreteEngine(const std::string& path) : subsystem(path) {}
void run() override { subsystem.execute(); }
std::string status() const override { return subsystem.describe(); }
};
Engine::Engine(std::unique_ptr<IImpl> impl) : impl_(std::move(impl)) {}
Engine::~Engine() = default;
Engine::Engine(Engine&&) noexcept = default;
Engine& Engine::operator=(Engine&&) noexcept = default;
void Engine::run() { impl_->run(); }
std::string Engine::status() const { return impl_->status(); }
Engine Engine::create(const std::string& config_path) {
return Engine(std::make_unique<ConcreteEngine>(config_path));
}The virtual call through IImpl adds one additional indirection compared to plain pimpl, but this cost is usually irrelevant outside hot loops. The key gain is that the vtable of ConcreteEngine is entirely internal to the .so: adding, removing, or reordering its virtual overrides does not change any ABI-visible symbol. This pattern is widely used in plugin systems and shared libraries that must remain link-compatible across major feature additions — the public header is frozen, and all extensibility happens inside the implementation translation unit.
See Also
- Rule of Five — copy/move/destructor interdependencies that Pimpl triggers
- unique_ptr — ownership semantics and the incomplete-type constraint on the deleter
- Bridge Pattern — the design pattern Pimpl instantiates at the simplest level
- Modules — C++20's structural answer to compilation firewalls