The Pimpl Idiom: Out-of-Line Destructors, unique_ptr<Impl> and the Build-Time vs Runtime Trade-off

Key takeaways

How Pimpl hides implementation details to speed up builds and keep ABI stable, why the destructor must be defined out of line with unique_ptr<Impl>, the fast-Pimpl variant, and its runtime cost.

What is Pimpl Idiom?

Pointer to implementation: public class holds std::unique_ptr<Impl>; Impl is defined only in .cpp, hiding implementation details from header. The key mechanism making this possible is that class Impl; inside Widget is a mere forward declaration — an incomplete type — which is legal wherever the compiler doesn’t need to know Impl’s size or layout, such as declaring a pointer to it. std::unique_ptr<Impl> can hold a pointer to an incomplete type in the header, as long as the code that actually deletes it (the destructor) is compiled somewhere Impl is fully defined — which is exactly why Widget::~Widget() must be defined in the .cpp file rather than defaulted inline in the header, a subtlety covered in more depth under “Common Issues” below.

// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
    
private:
    class Impl;  // Forward declaration
    std::unique_ptr<Impl> pImpl;  // Implementation pointer
};
// widget.cpp
class Widget::Impl {
public:
    void doSomething() {
        // Actual implementation
    }
    
private:
    // Internal data
    int data;
    std::string name;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
void Widget::doSomething() {
    pImpl->doSomething();
}

Benefits

The “faster builds” benefit comes directly from how C++ compilation units work: any header a .cpp file #includes becomes part of that translation unit, so if widget.h itself included heavy dependencies (a database driver’s headers, platform SDK headers, a large template library), every single file across the codebase that includes widget.h would recompile whenever any of those transitive dependencies changed. By moving those includes into widget.cpp alongside the Impl definition, only widget.cpp itself depends on them — client code that just calls Widget’s public methods only ever sees the lightweight header, so changing the implementation of Impl (adding a private member, changing internal logic) doesn’t force a rebuild of every translation unit that uses Widget, only a relink.

// ✅ Reduced compilation dependencies
// widget.h - No header changes
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// widget.cpp - Only implementation changes
class Widget::Impl {
    // Internal changes don't affect header
    // No client recompilation needed
};

Key advantages:

  • Faster builds: Clients don’t include heavy private headers
  • ABI flexibility: Add private members without recompiling all users (within limits)
  • Implementation hiding: Hide platform-specific or proprietary code

Practical Examples

Example 1: Basic Pimpl

This example spells out the full Rule of Five explicitly, and it has to: once a class holds a unique_ptr to an incomplete type, every special member function that the compiler would otherwise generate automatically — copy constructor, copy assignment, move constructor, move assignment, and the destructor — needs a user-provided definition compiled where Impl is complete. Person::Person(Person&& other) noexcept = default; looks like it does nothing special, but even = default for the move operations must be written in the .cpp file rather than the header, because defaulting a special member function still requires the compiler to know how to destroy the old pImpl (for move assignment) or construct/inspect Impl — anything touching unique_ptr<Impl>’s destructor needs Impl’s complete definition in scope, regardless of whether the member function’s body is handwritten or defaulted.

// person.h
#include <memory>
#include <string>
class Person {
public:
    Person(const std::string& name, int age);
    ~Person();
    
    // Copy/move operators declaration
    Person(const Person& other);
    Person& operator=(const Person& other);
    Person(Person&& other) noexcept;
    Person& operator=(Person&& other) noexcept;
    
    std::string getName() const;
    int getAge() const;
    void setName(const std::string& name);
    void setAge(int age);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// person.cpp
class Person::Impl {
public:
    Impl(const std::string& name, int age) 
        : name(name), age(age) {}
    
    std::string name;
    int age;
};
Person::Person(const std::string& name, int age)
    : pImpl(std::make_unique<Impl>(name, age)) {}
Person::~Person() = default;
Person::Person(const Person& other)
    : pImpl(std::make_unique<Impl>(*other.pImpl)) {}
Person& Person::operator=(const Person& other) {
    if (this != &other) {
        *pImpl = *other.pImpl;
    }
    return *this;
}
Person::Person(Person&& other) noexcept = default;
Person& Person::operator=(Person&& other) noexcept = default;
std::string Person::getName() const {
    return pImpl->name;
}
int Person::getAge() const {
    return pImpl->age;
}
void Person::setName(const std::string& name) {
    pImpl->name = name;
}
void Person::setAge(int age) {
    pImpl->age = age;
}

Example 2: Library Interface

This is the case Pimpl was arguably invented for: a public library header that must never leak a third-party dependency’s own headers (here, <mysql/mysql.h>) into every client’s build. Deleting the copy constructor and copy assignment operator (= delete) rather than implementing them is the right call for a type like Database that represents a single stateful connection — copying it would raise an unanswerable question (does the copy share the same underlying connection, or open a new one?), so ruling copying out entirely at compile time is more honest than picking an arbitrary answer. Clients that do need to share a Database can still do so explicitly via a shared_ptr<Database>, which sidesteps the ambiguity by making the sharing visible at the call site.

// database.h
#include <memory>
#include <string>
#include <vector>
class Database {
public:
    Database(const std::string& connectionString);
    ~Database();
    
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
    
    void connect();
    void disconnect();
    bool isConnected() const;
    
    void execute(const std::string& query);
    std::vector<std::string> query(const std::string& sql);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// database.cpp
#include <iostream>
// External library headers (not exposed to clients)
// #include <mysql/mysql.h>
class Database::Impl {
public:
    Impl(const std::string& connStr) 
        : connectionString(connStr), connected(false) {}
    
    void connect() {
        std::cout << "Connecting: " << connectionString << std::endl;
        connected = true;
    }
    
    void disconnect() {
        std::cout << "Disconnecting" << std::endl;
        connected = false;
    }
    
    bool isConnected() const {
        return connected;
    }
    
    void execute(const std::string& query) {
        std::cout << "Executing: " << query << std::endl;
    }
    
    std::vector<std::string> query(const std::string& sql) {
        std::cout << "Query: " << sql << std::endl;
        return {"result1", "result2"};
    }
    
private:
    std::string connectionString;
    bool connected;
    // MYSQL* connection;  // External library type
};
Database::Database(const std::string& connectionString)
    : pImpl(std::make_unique<Impl>(connectionString)) {}
Database::~Database() = default;
void Database::connect() {
    pImpl->connect();
}
void Database::disconnect() {
    pImpl->disconnect();
}
bool Database::isConnected() const {
    return pImpl->isConnected();
}
void Database::execute(const std::string& query) {
    pImpl->execute(query);
}
std::vector<std::string> Database::query(const std::string& sql) {
    return pImpl->query(sql);
}

Example 3: Platform-Specific Implementation

Here Pimpl solves a different problem than compile-time hiding — it’s used to get an entirely different Impl definition compiled per platform while presenting one unchanged public header. window_win32.cpp and window_x11.cpp each define Window::Impl with completely different internals (a HWND on Windows, a Display*/Window handle on X11), and only one of those two .cpp files is ever compiled into a given build, selected by the build system rather than the preprocessor alone. Any code that includes window.h and calls Window’s public API never needs to know which platform-specific implementation backs it, or even that a #ifdef branch exists at all — the platform-specific headers (<windows.h>, <X11/Xlib.h>) stay entirely contained inside their respective .cpp files.

// window.h
#include <memory>
#include <string>
class Window {
public:
    Window(const std::string& title, int width, int height);
    ~Window();
    
    void show();
    void hide();
    void setTitle(const std::string& title);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// window_win32.cpp (Windows)
#ifdef _WIN32
// #include <windows.h>
class Window::Impl {
public:
    Impl(const std::string& title, int width, int height) {
        // HWND hwnd = CreateWindow(...);
        std::cout << "Windows window created: " << title << std::endl;
    }
    
    void show() {
        // ShowWindow(hwnd, SW_SHOW);
        std::cout << "Windows window shown" << std::endl;
    }
    
    void hide() {
        std::cout << "Windows window hidden" << std::endl;
    }
    
    void setTitle(const std::string& title) {
        std::cout << "Windows title changed: " << title << std::endl;
    }
    
private:
    // HWND hwnd;
};
#endif
// window_x11.cpp (Linux)
#ifdef __linux__
// #include <X11/Xlib.h>
class Window::Impl {
public:
    Impl(const std::string& title, int width, int height) {
        std::cout << "X11 window created: " << title << std::endl;
    }
    
    void show() {
        std::cout << "X11 window shown" << std::endl;
    }
    
    void hide() {
        std::cout << "X11 window hidden" << std::endl;
    }
    
    void setTitle(const std::string& title) {
        std::cout << "X11 title changed: " << title << std::endl;
    }
    
private:
    // Display* display;
    // Window window;
};
#endif
Window::Window(const std::string& title, int width, int height)
    : pImpl(std::make_unique<Impl>(title, width, height)) {}
Window::~Window() = default;
void Window::show() {
    pImpl->show();
}
void Window::hide() {
    pImpl->hide();
}
void Window::setTitle(const std::string& title) {
    pImpl->setTitle(title);
}

Example 4: ABI Stability

Without Pimpl, adding a new member function or private data member to MyClass would change the class’s binary layout (its size, its vtable if virtual functions are involved, member offsets), which breaks binary compatibility with any code compiled against the old header — a classic source of crashes when a shared library is upgraded without recompiling everything that links against it. Because MyClass here only ever exposes a fixed-size unique_ptr<Impl> in its own layout, the size and layout of MyClass itself never changes between v1 and v2, regardless of how much Impl grows internally — old binaries compiled against v1’s header can keep calling into a v2 shared library without recompiling, as long as the existing public functions’ signatures don’t change. This is precisely the guarantee libraries with a stable ABI promise (e.g., Qt’s d-pointer, which is this same pattern under a different name), and precisely what makes Pimpl worth its runtime cost for library authors even when it’s overkill for typical application code.

// library_v1.h (Version 1)
class MyClass {
public:
    MyClass();
    ~MyClass();
    void func1();
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// library_v2.h (Version 2 - Header unchanged!)
class MyClass {
public:
    MyClass();
    ~MyClass();
    void func1();
    void func2();  // New function added
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// library_v2.cpp
class MyClass::Impl {
public:
    void func1() {
        std::cout << "func1" << std::endl;
    }
    
    void func2() {  // New implementation
        std::cout << "func2" << std::endl;
    }
    
private:
    int newData;  // New member added (no ABI impact)
};

Fast Pimpl (Optimization)

The regular Pimpl idiom trades a heap allocation (and the resulting pointer indirection and potential cache miss on every access) for its compile-time decoupling benefits. “Fast Pimpl” tries to keep the decoupling while avoiding the allocation, by reserving inline storage for Impl directly inside Widget — the catch is that sizeof(Impl) still has to be known at the point implStorage is sized, which reintroduces a form of the coupling Pimpl was meant to remove (the header now has to reserve enough bytes, verified with a static_assert(sizeof(Impl) <= ImplSize) in the .cpp, or risk silent buffer overflow if Impl grows past the reserved size later).

The sketch below is intentionally incomplete and would not work as-is: pImpl() just reinterprets raw, uninitialized bytes as an Impl* without ever having constructed an Impl object in that storage. In a real implementation, the constructor must placement-new an Impl into implStorage (new (implStorage) Impl(...)), and the destructor must explicitly call pImpl()->~Impl() before the object goes out of scope — plain stack/member destruction won’t call Impl’s destructor for you, since as far as the compiler is concerned implStorage is just a char array with no nontrivial destructor of its own.

// Stack allocation for small objects
class Widget {
public:
    Widget();
    ~Widget();
    
private:
    static constexpr size_t ImplSize = 64;
    alignas(8) char implStorage[ImplSize];
    
    class Impl;
    Impl* pImpl() {
        return reinterpret_cast<Impl*>(implStorage);
    }
    // Widget() must placement-new an Impl into implStorage:
    //   new (implStorage) Impl(...);
    // ~Widget() must explicitly call the destructor before implStorage
    // goes out of scope:
    //   pImpl()->~Impl();
};

Small Buffer Optimization: For tiny Impl objects, inline storage avoids heap allocation. Advanced technique—measure first, and be aware it reintroduces some of the size coupling Pimpl exists to remove.


Common Issues

Issue 1: Missing destructor definition

This is the single most common Pimpl compile error, and the reason is subtle: unique_ptr<Impl>’s deleter needs to call delete on the Impl*, and delete on an incomplete type is undefined behavior that most compilers reject outright at the point the deletion code is generated. Defaulting ~Widget() inline in the header generates that deletion code right there in the header, at a point where Impl is still just a forward declaration — hence the error. Declaring the destructor in the header but defining it (even as = default) in the .cpp file defers generating that code until Impl’s full definition is in scope in that translation unit, which is why this exact pattern (declare in header, define — even trivially — in the .cpp) shows up in every Pimpl example in this guide.

// ❌ Defining destructor in header
class Widget {
public:
    ~Widget() = default;  // Error: Impl incomplete type
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// ✅ Define in cpp file
// widget.h
class Widget {
public:
    ~Widget();
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// widget.cpp
Widget::~Widget() = default;

Issue 2: Copy operators

unique_ptr is deliberately non-copyable — copying it would leave two owners pointing at the same object with no defined rule for who deletes it — so a class that holds one as a member has its implicitly-generated copy constructor and copy assignment operator deleted by the compiler automatically. There’s no way around writing them by hand if you want Widget to remain copyable: each one has to allocate a new Impl and copy the pointee’s contents into it (std::make_unique<Impl>(*other.pImpl)), not just copy the pointer, otherwise two Widgets would end up sharing one Impl and double-free it when both are destroyed.

// ❌ Default copy (shallow copy)
class Widget {
public:
    // Compiler-generated copy can't copy unique_ptr
    
private:
    std::unique_ptr<Impl> pImpl;
};
// ✅ Explicit copy implementation
Widget::Widget(const Widget& other)
    : pImpl(std::make_unique<Impl>(*other.pImpl)) {}
Widget& Widget::operator=(const Widget& other) {
    if (this != &other) {
        *pImpl = *other.pImpl;
    }
    return *this;
}

Issue 3: Performance overhead

Every call through pImpl-> costs a pointer dereference the compiler can’t optimize away, since Impl’s definition (and therefore whether the call is even inlinable) is invisible from the header — even a one-line function like doSomething() forwarding to pImpl->doSomething() can’t be inlined at the caller’s call site the way a normal member function could be, because the compiler processing the caller’s translation unit has never seen Impl’s definition. For a class with a handful of trivial getters and one genuinely complex subsystem, it’s worth keeping the trivial parts as ordinary inlinable members and reserving pImpl only for the parts that actually benefit from the hiding — applying Pimpl uniformly to every member “for consistency” pays the indirection cost everywhere for a benefit that only some of those members need.

// ❌ Indirect call overhead
void Widget::doSomething() {
    pImpl->doSomething();  // Indirect call
}
// ✅ Keep inlinable functions direct
class Widget {
public:
    int getValue() const { return value; }  // Inline
    
private:
    int value;  // Simple data direct
    class Impl;
    std::unique_ptr<Impl> pImpl;  // Only complex implementation
};

Issue 4: Forward declaration constraints

Impl getImpl() const; fails to compile for the same underlying reason the destructor does: returning Impl by value requires the compiler to know Impl’s size (to lay out the return value) and how to copy/move-construct it — none of which is knowable from a forward declaration alone. A pointer or reference, by contrast, has a fixed size regardless of what it points to, so const Impl*/const Impl& compiles fine even with Impl still incomplete at that point; the caller just can’t do anything with the returned pointer beyond passing it around opaquely, unless they also happen to have Impl’s full definition in scope (which, by design, only widget.cpp does).

// ❌ Using Impl type directly
class Widget {
public:
    Impl getImpl() const;  // Error: incomplete type
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// ✅ Use pointer/reference only
class Widget {
public:
    const Impl* getImpl() const;  // OK
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

Pimpl vs Other Patterns

These three patterns look structurally similar — each wraps an owned pointer behind a public interface — but they solve different problems, and confusing them leads to over-engineered designs. Pimpl’s Impl is a compilation firewall with exactly one implementation per Widget, swapped only across build configurations (like the platform-specific example above), not at runtime. Bridge deliberately makes the abstraction/implementation split part of the public design so that multiple implementations can be substituted, often at runtime, and the abstraction itself may be further subclassed independently of the implementation hierarchy. Strategy is narrower still — it’s specifically about swapping an algorithm (comparison logic, a scheduling policy) while the rest of the object’s state and behavior stays fixed. If you only need to hide compilation details with a single implementation, Bridge’s extra abstraction layer is unneeded complexity; if you genuinely need multiple swappable implementations, Pimpl’s single fixed Impl won’t get you there.

// Pimpl: Hide implementation
class Widget {
    class Impl;
    std::unique_ptr<Impl> pImpl;
};
// Bridge: Separate abstraction and implementation
class Widget {
    std::unique_ptr<WidgetImpl> impl;  // Interface
};
// Strategy: Algorithm replacement
class Widget {
    std::unique_ptr<Strategy> strategy;
};

FAQ

Q1: When to use Pimpl?

A:

  • Reduce compilation dependencies
  • Need ABI stability
  • Hide implementation details

Q2: Performance impact?

A:

  • Indirect call overhead
  • Memory allocation cost
  • Possible cache misses

Q3: unique_ptr vs shared_ptr?

A:

  • unique_ptr: General case (recommended)
  • shared_ptr: When sharing needed

Q4: How to handle copying?

A: Need explicit implementation. *pImpl = *other.pImpl

Q5: When to avoid?

A:

  • Simple classes
  • Performance-critical code
  • Header-only libraries

Q6: Pimpl learning resources?

A:

  • “Effective Modern C++”
  • “API Design for C++”
  • cppreference.com