The pimpl idiom

The pimpl idiom moves the private members of a class into a struct that is defined in the source file, and the header keeps one pointer to it. The name is short for pointer to implementation, and the effect is that the header no longer depends on the private types.

The cost is one allocation and one indirection per object. The gain is a header that changes rarely: a new private member edits the source file only, and the programs that include the header do not need to be recompiled. This lesson builds one, measures it and meets the destructor rule that makes it work.

The idiom is also called the compilation firewall, and a large project measures it in build time: a change to one private member rebuilds the source file and relinks, instead of rebuilding every file that included the header.

The header of a pimpl class is small, and that is the point: a reader sees the interface and the one pointer, and the implementation can be replaced without touching the declaration.

The header with one pointer

The header declares struct impl; without defining it and holds a std::unique_ptr<impl>. The source file defines the struct and the methods, so the private data is invisible to every file that includes the header.

The program sets 40 and reads 41: the implementation adds the number of calls, and the header knows nothing about either field. The two translation units are compiled separately and linked.

The class is move-only because the unique_ptr is, which is the rule of lesson 091. A copy needs its own implementation, defined in the source file where the type is complete.

widget.h

#pragma once #include <memory> class widget { public: widget(); ~widget(); void set(int value); int get() const; private: struct impl; std::unique_ptr<impl> pimpl_; };

widget.cpp

#include "widget.h" struct widget::impl { int value = 0; int calls = 0; }; widget::widget() : pimpl_{std::make_unique<impl>()} {} widget::~widget() = default; void widget::set(int value) { pimpl_->value = value; ++pimpl_->calls; } int widget::get() const { return pimpl_->value + pimpl_->calls; }

main.cpp

#include <iostream> #include "widget.h" int main() { widget w; w.set(40); std::cout << w.get() << '\n'; }

g++ -std=c++23 -Wall -Wextra main.cpp widget.cpp -o pimpl
./pimpl

41

What the class costs

The object in the header is one pointer plus the alignment, so sizeof(widget) is 8 on this build. The implementation behind it is 12 bytes, three ints, and it lives in a separate allocation.

The program prints 8 and 12 from sizeof(widget) and sizeof(impl). The indirection is the price: every member function reads the pointer before it reads a field, and the allocation happens once per object.

For a class that is created a few times and called often, the cost is invisible. For a million small objects the allocation and the cache misses are the reason not to use pimpl, and the reason a container type keeps its data in the header.

#include <iostream> #include <memory> class widget { public: widget() : pimpl_{std::make_unique<impl>()} {} ~widget(); int size() const; private: struct impl; std::unique_ptr<impl> pimpl_; }; struct widget::impl { int a = 1; int b = 2; int c = 3; }; widget::~widget() = default; int widget::size() const { return sizeof(impl); } int main() { widget w; std::cout << sizeof(widget) << ' ' << w.size() << '\n'; }

g++ -std=c++23 -Wall -Wextra size.cpp -o pimpl_size
./pimpl_size

8 12

Move without a copy

The pointer makes the class cheap to move and impossible to copy by default. widget(widget&&) = default moves the unique_ptr in one pointer assignment and leaves the source empty.

The program moves a widget holding 7 into a second one and prints the value and whether the source is empty: 7 and 1. The moved-from object is valid and does nothing until it is assigned again.

A copy constructor, when the class needs one, is written in the source file and calls std::make_unique<impl>(*other.pimpl_). That is the deep copy of lesson 031, a line that would not compile in the header because impl is incomplete there.

#include <iostream> #include <memory> #include <utility> class widget { public: explicit widget(int value) : pimpl_{std::make_unique<impl>(value)} {} widget(widget&&) noexcept = default; widget& operator=(widget&&) noexcept = default; ~widget() = default; int value() const { return pimpl_->value; } bool empty() const { return pimpl_ == nullptr; } private: struct impl { explicit impl(int start) : value(start) {} int value; }; std::unique_ptr<impl> pimpl_; }; int main() { widget first{7}; widget second = std::move(first); std::cout << second.value() << ' ' << first.empty() << '\n'; }

g++ -std=c++23 -Wall -Wextra move.cpp -o pimpl_move
./pimpl_move

7 1

The destructor that needs the type

The destructor of a unique_ptr needs the complete type of its object, and a destructor defined in the header makes the compiler generate that code in every file that includes the header. In those files impl is still incomplete.

The header below defines ~widget() = default and main.cpp includes it, while widget.cpp defines impl. The compiler reports that sizeof is applied to an incomplete type, and the instantiation trace starts at the destructor in the header.

The fix is the first section's rule: declare the destructor in the header and define it in the source file, after impl is complete. The same applies to the move operations and the copy constructor, which also destroy or duplicate the pointer.

widget.h

#pragma once #include <memory> class widget { public: widget(); ~widget() = default; private: struct impl; std::unique_ptr<impl> pimpl_; };

widget.cpp

#include "widget.h" struct widget::impl { int value = 0; }; widget::widget() : pimpl_{std::make_unique<impl>()} {}

main.cpp

#include "widget.h" int main() { widget w; }

g++ -std=c++23 -Wall -Wextra main.cpp widget.cpp -o inline_dtor

In file included from /usr/include/c++/15.2.0/memory:80,
from widget.h:3,
from main.cpp:1:
/usr/include/c++/15.2.0/bits/unique_ptr.h: In instantiation of 'constexpr void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = widget::impl]':
/usr/include/c++/15.2.0/bits/unique_ptr.h:399:17: required from 'constexpr std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = widget::impl; _Dp = std::default_delete<widget::impl>]'
399 | get_deleter()(std::move(__ptr));
| ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~
widget.h:9:5: required from here
9 | ~widget() = default;
| ^
/usr/include/c++/15.2.0/bits/unique_ptr.h:91:23: error: invalid application of 'sizeof' to incomplete type 'widget::impl'
91 | static_assert(sizeof(_Tp)>0,
| ^~~~~~~~~~~

Exercises

  1. Add a copy constructor to the header and the source of the first widget and check that a copy changes one field only.
  2. Move the impl of the size program into the source file and rebuild it as two translation units.
  3. Change the implementation of widget to use a std::string and explain why main.cpp does not need to be recompiled.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does the pimpl idiom hide?

    Answer

    The private data behind a pointer that is defined in the source file

    The header carries one pointer, so the private types and fields are not needed to compile the users.

  2. Why must the destructor of a pimpl class be defined in the source file?

    Answer

    Because the unique_ptr needs the complete type when it deletes the object

    An inline destructor would instantiate the delete where impl is still incomplete.

  3. What is the main cost of pimpl?

    Answer

    One allocation per object and one indirection per member access

    The object is a pointer, and the data lives in a separate heap block.

  4. What is the benefit of pimpl for a project's build?

    Answer

    A change to the private members does not recompile the files that include the header

    The header's contents do not change when the implementation does, which is the compilation firewall.

  5. What does this program print? #include <iostream> #include <memory> class widget { public: widget() : pimpl_(std::make_unique<impl>()) {} void set(int value) { pimpl_->value = value; ++pimpl_->calls; } int get() const { return pimpl_->value + pimpl_->calls; } ~widget(); private: struct impl { int value = 0; int calls = 0; }; std::unique_ptr<impl> pimpl_; }; widget::~widget() = default; int main() { widget w; w.set(40); std::cout << w.get(); }

    Answer

    41

    The value is 40 and the implementation adds the one call, so get returns 41.

  6. What does this program print? #include <iostream> #include <memory> class widget { public: widget() : pimpl_(std::make_unique<impl>()) {} int size() const { return sizeof(impl); } ~widget(); private: struct impl { int a = 1; int b = 2; int c = 3; }; std::unique_ptr<impl> pimpl_; }; widget::~widget() = default; int main() { widget w; std::cout << sizeof(widget) << ' ' << w.size(); }

    Answer

    8 12

    The widget is one pointer and the impl is three ints.

  7. What does this program print? #include <iostream> #include <memory> #include <utility> class widget { public: explicit widget(int value) : pimpl_(std::make_unique<impl>(value)) {} widget(widget&&) noexcept = default; int value() const { return pimpl_->value; } bool empty() const { return pimpl_ == nullptr; } private: struct impl { explicit impl(int v) : value(v) {} int value; }; std::unique_ptr<impl> pimpl_; }; int main() { widget first{7}; widget second = std::move(first); std::cout << second.value() << ' ' << first.empty(); }

    Answer

    7 1

    The pointer moves to the second widget and the first is left empty.

  8. What does the compiler say about an inline destructor with an incomplete impl?

    Answer

    That sizeof is applied to an incomplete type

    The default_delete instantiates a sizeof check, and impl is not complete in the header.

  9. Is a pimpl class copyable by default?

    Answer

    No, the unique_ptr deletes the copy; a copy has to be written

    The ownership of the implementation is exclusive, so a deep copy is an explicit decision.

  10. What happens to the implementation when a pimpl object is moved?

    Answer

    The pointer moves and the implementation stays at the same address

    The move is a pointer transfer, so the heap block and its address do not change.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.

Article author: Arthur Isaev

Banner image