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.
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