The Pimpl idiom (short for "pointer to implementation") hides a class's private members behind an opaque pointer so that the header file exposes only what callers need. The payoff is faster builds, fewer recompiles when internal details change, and a stable binary interface across library versions. This lesson covers the classic raw-pointer form, the modern std::unique_ptr form, the subtle destructor rule that causes a common error, and the trade-offs that decide when Pimpl is worth the indirection.
A C++ class declared in a header file leaks more than its public API. Anything inside the class body, including private member variables, private helper functions, and any types they mention, ends up in every translation unit that includes the header. Adding a new private field forces every file that includes the header to recompile.
For a small project, that's invisible. For a shared library where the header sits inside the SDK and gets included by hundreds of files across many services, the cost is real. Worse, if the library is shipped as a .so or .dll and you add a private member, the layout of the class changes, breaking binary compatibility with every caller that was compiled against the old version.
Pimpl breaks this coupling. The public class holds a pointer to a forward-declared implementation class. The implementation class is defined entirely inside the .cpp file. Callers see nothing about the internals, so changes to those internals don't trigger a rebuild and don't shift the public class's size or layout.
The PaymentProcessor used in an e-commerce checkout flow is the running example. It validates cards, talks to a third-party authorizer, holds connection state, and caches results. Most of that is internal. Every service that takes a checkout request includes its header, and that header has caused long incremental rebuilds whenever the team adds a new field.
The class has six private members, and the header drags in four other headers just so the compiler knows the size of PaymentProcessor. Every service that uses PaymentProcessor now also has to recompile when any of those internal headers changes.
Including a header with N private members of types defined in other headers transitively pulls in all of those headers. Touching any of them invalidates the precompiled output for every translation unit downstream.
The simplest version of Pimpl uses a raw pointer and manual cleanup. It's worth seeing once because it shows the mechanism plainly, even though modern code should use std::unique_ptr instead.
The public header forward-declares the implementation type and holds a pointer to it. No mention of the internal types, no transitive includes.
The .cpp file defines Impl fully and forwards every public method to it. This is where the heavy includes live now, not in the header.
A small main() that exercises this looks the same as if Pimpl wasn't there at all. The caller doesn't notice.
The two things that make this Pimpl work are the forward declaration of Impl inside the class body and the destructor defined in the .cpp file. The forward declaration lets the header compile without knowing what Impl contains. The out-of-line destructor lives where Impl is complete, so delete impl_; knows the full type.
Every PaymentProcessor now requires one heap allocation in the constructor and one heap deallocation in the destructor. Member access goes through one extra pointer dereference. For a class that lives for the duration of a request or longer, this is invisible. For something allocated in a tight inner loop, it's a real overhead.
The win from Pimpl shows up in the compilation dependency graph. Before Pimpl, every translation unit that included payment_processor.h transitively pulled in card_validator.h, authorizer_client.h, and fraud_rules.h. Touching any of those forced a recompile of every downstream .cpp.
After Pimpl, the public header only forward-declares Impl and includes <string>. The three internal headers move into payment_processor.cpp, which is the only translation unit that needs them.
Now changing fraud_rules.h only recompiles payment_processor.cpp, which then relinks. The three downstream files stay untouched. On a codebase with hundreds of files, this cuts incremental build times sharply when internal headers churn.
The same idea explains why Pimpl helps with binary compatibility. The public header describes a class with exactly two private members: the forward-declared inner class and a pointer to it. Adding a new field to Impl doesn't change the size or layout of PaymentProcessor, so callers compiled against the old library still see a class that looks identical in memory. Without Pimpl, adding a private member would resize the class and break every binary that wasn't recompiled.
Pimpl trades compile-time decoupling for runtime indirection. Every method call goes through one extra pointer hop, and every instance carries one heap allocation. On modern hardware that's nanoseconds, but a class instantiated millions of times per second feels it.
std::unique_ptrHand-rolled new and delete is fine for an academic example, but modern C++ uses std::unique_ptr<Impl> instead. The smart pointer handles cleanup automatically and integrates with move semantics so the public class can be made movable without writing special members by hand.
The header looks almost the same as before, with one important detail.
The .cpp file defines Impl and provides out-of-line definitions for every special member function the header declared, including the destructor and the move operations. The destructor body can be = default;, but it has to live in the .cpp file.
A usage example shows the public surface is identical to the raw-pointer version.
The class is now movable without any manual resource tracking. std::unique_ptr already knows how to be moved: it swaps the pointer with the source and leaves the source holding nullptr. The destructor of PaymentProcessor (defined in the .cpp file) sees a fully-complete Impl, so unique_ptr's destructor can correctly call delete on it.
std::make_unique<Impl> is still one heap allocation per object. The unique_ptr itself is the same size as a raw pointer on most implementations, so there's no extra memory overhead per instance compared to the raw-pointer version.
The detail that causes a common error the first time is which special member functions need to live in the .cpp file when using std::unique_ptr<Impl> with a forward-declared Impl. The short answer is: all of them that the compiler would otherwise generate inline in the header.
std::unique_ptr<T> has a destructor that calls delete on its owned pointer. The C++ standard says delete on a pointer to an incomplete type is undefined behavior unless the type has a trivial destructor. The compiler emits a warning (-Wdelete-incomplete) but the language doesn't reject the code outright, so the bug can slip through.
When class Impl; is in the header and ~PaymentProcessor() is not declared, the compiler synthesizes the destructor on demand wherever it needs one, which usually means inside the header at the point of use. At that point, Impl is incomplete, so the generated destructor of unique_ptr<Impl> invokes delete on an incomplete type.
Same story for the move operations. The compiler-generated move constructor and move assignment operator both have to destroy the old unique_ptr<Impl> value at some point, which means calling its destructor, which calls delete on Impl.
The rule, written as a checklist for the header:
If unique_ptr<Impl> is a member, you must... | Where to define it |
|---|---|
Declare ~PaymentProcessor(); | In the header |
Define ~PaymentProcessor() = default; | In the .cpp file |
| Declare move constructor / move assignment if needed | In the header |
Define them (as = default; or by hand) | In the .cpp file |
| Delete or define copy operations explicitly | Copy ops are deleted by default for unique_ptr members |
Forgetting any of these triggers the error in the translation unit that first uses PaymentProcessor, which can be confusing because the header itself compiles fine. The error message from g++ is roughly error: invalid application of 'sizeof' to incomplete type 'PaymentProcessor::Impl', which is the standard library's way of saying "tried to instantiate default_delete<Impl> and couldn't".
Trying to compile main.cpp that uses this class triggers an error like:
Fix:
Then in the .cpp file:
The defaulted destructor in the .cpp file sees the complete Impl and can therefore synthesize the call to unique_ptr<Impl>'s destructor correctly. The fix is mechanical, but the diagnostic is famously cryptic, so the checklist is worth keeping nearby.
A Pimpl'd class has a tiny memory footprint at the public level. The object itself holds exactly one pointer (plus whatever the public class adds directly, which is usually nothing). All of the real state lives on the heap, reachable only through that pointer.
The stack slot for the PaymentProcessor is exactly the size of one pointer, eight bytes on a 64-bit system. The actual state lives in a separately-allocated chunk on the heap. Every method call resolves impl_->something, which is one extra pointer dereference compared to a direct access on a non-Pimpl class.
That indirection has two consequences. First, the data isn't co-located with the object; an array of PaymentProcessor values iterates over pointers contiguously but the actual state lives in scattered heap blocks. CPU prefetching can't help as much. Second, calls to public methods that forward to Impl can't be inlined across the translation-unit boundary unless the compiler has whole-program optimization on, because the implementation lives in another .cpp file.
Both costs are real but small. A Pimpl call adds something on the order of a few nanoseconds compared to a direct call. For a class instantiated once at startup and used to serve thousands of requests, that's a rounding error. For a value type used inside a tight numerical loop, it's a disaster.
| Aspect | Non-Pimpl class | Pimpl class |
|---|---|---|
| Public object size | Sum of all members | One pointer |
| Heap allocations per construction | Depends on members | One extra for Impl |
| Method call cost | One indirect call (or inlined) | One pointer hop, then call |
| Inlining across TU | Possible with LTO | Same, but Impl methods can be inlined inside the .cpp |
| Cache behavior | Members co-located with object | Members on heap, one extra dereference |
| Header recompile fanout | Every member's header is transitively included | Only <memory> and primitive includes |
The heap allocation is the most expensive single piece of Pimpl. make_unique calls into the allocator, which is typically a handful of cached fast-path operations but can cause page faults in cold cases. Allocating a million Pimpl'd objects in a loop is expensive; allocating a few dozen long-lived ones is not.
Pimpl is a tool for managing dependencies and binary compatibility. It's not a default. The decision usually comes down to where the class sits in the system.
Pimpl is a strong fit when:
Pimpl is a poor fit when:
Point, a Money, or a small struct of two or three primitives. The heap allocation dwarfs the type's actual cost.The abstract-interface approach is worth a brief comparison. Implementation details can also be hidden by declaring an abstract base class and providing a factory function that returns a pointer to the abstract base.
The two approaches solve the same compile-time-decoupling problem but with different trade-offs.
| Aspect | Pimpl | Abstract base + factory |
|---|---|---|
| Public type is a value | Yes, normal class | No, callers hold a pointer/reference |
| Method calls | One pointer hop, then a direct call | One pointer hop, then a virtual call |
| Multiple implementations | Awkward | Natural |
| Stack-allocatable | Yes | No, callers hold a pointer |
| Inheritable by callers | Yes | The interface is, the impl isn't |
If callers want to hold the class by value, store it as a member, or copy/move it like a normal type, Pimpl is the right fit. If multiple swappable implementations are required (a real implementation and a test double, for example) or polymorphism is the actual goal, the abstract-interface route is cleaner.
A single-file version that compiles and runs without the multi-file layout. The Impl class is defined inside the same file, but the structure mirrors what would be split across .h and .cpp. This isn't how Pimpl would ship in production (the whole point is the file split), but it shows the mechanism end-to-end.
The flow exercises every part of the idiom. Construction allocates the Impl on the heap. Each charge call forwards through the unique_ptr. The move construction transfers ownership in O(1) by swapping the pointer, and the original is left holding nullptr (which is harmless as long as nobody calls a method on it after the move). The destructor (defined after the full Impl) cleans up via unique_ptr's destructor.
10 quizzes