Skip to content
C++
Idiom
since C++11
Intermediate

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++11

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

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
};
cpp
// 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:

cpp
// 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:

cpp
// 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

cpp
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 recompilation

Best Practices

  • Declare all five special members in the header, even as = delete or = default. The compiler needs declarations to resolve copy/move expressions at call sites. Put any body requiring Impl completeness in the .cpp.
  • Use std::make_unique (C++14) in the constructor initializer list rather than new. If multiple subexpressions in an initializer list can throw, new can leak; make_unique eliminates the window.
  • Keep Impl a 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 abstract Engine interface is the right tool.
  • Do not add getImpl() accessors for testing. Even friend class WidgetTest in 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 const propagation. const Widget::method() makes impl_ (the pointer) const, not *impl_. Fields of Impl are freely mutable through a const Widget. If you rely on const for thread safety, enforce it explicitly with mutable std::shared_mutex (C++17) inside Impl.

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:

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