Lesson 11 / 12
Managing Compile Times
Forward declaration, trimming unnecessary includes, the pimpl idiom, opaque types, explicit instantiation and extern template, and how the cost gained at compile time moves to run time per object.
Published Updated
In this lesson
- Explains how many translation units a header is made visible to by forward declaration and by trimming unnecessary includes.
- Hides private members behind a forward declaration with the pimpl idiom.
- Explains why the pimpl idiom requires the destructor and the move operations to be defined in the .cpp file.
- Shows with an allocation count how the cost gained at compile time moves to run time.
Contents
The previous lesson showed that the profile finds the hot spot and that where the measurement starts changes the result. But there is one more cost that no counter can catch: the compile cost paid before the program runs. When a header file changes, every translation unit that includes that header, directly or indirectly, is rebuilt. The more units a header is visible to, the more expensive a change is. This lesson examines ways to reduce that visibility and measures where one of the ways, the pimpl idiom, makes the compile cost it gained be paid at run time. The answer is the kind run. A distinction from M11/K05: there, the state of a moved object was not the measure; here too we do not look at the state of a moved object, we only count whether the move makes an allocation. Compile time is not printed; the dependency is described as “how many units see this header”, and the number measured is the allocation per object.
Reducing the Visibility of a Header
The first tool is the forward declaration. If a header uses another type only as a
pointer or a reference, it does not need the full definition of that type; writing
class Window; is enough. Including the header that holds the full definition with
#include makes this header rebuild too every time that header changes. A forward
declaration cuts this chain: the full definition is included only in the .cpp file that
really uses it.
The second tool is deleting unnecessary #include lines. A line added to a header is a cost
paid by all the units that include that header; an unused line makes them pay the same cost.
The third tool is specific to templates: if a frequently used template specialization is
instantiated again in every unit, explicit instantiation and the extern template
declaration gather it into one unit. extern template class Box<int>; is written in the
header, and the instantiation is done with template class Box<int>; in a single .cpp
file; the other units link to the definition in that unit instead of regenerating the
non-inline members. There is a small example of this tool below; its effect does not show in
the run output, so it is not measured.
Why a Forward Declaration Lightens a Header
The work of a forward declaration shows in an example. A drawing function takes the
Window type as a parameter. The full definition of the type is not needed for the
declaration of the function: an incomplete type can be a parameter in a function
declaration, whether as a reference or by value. The full definition is needed only in the
definition of the function and at the place where the function is called. For this
reason the drawing header writes only class Window; and does not include window.hpp at
all; window.hpp is visible only inside drawing.cpp.
A unit that includes drawing.hpp does not see the inside of Window; even if the members
of Window change, that unit is not rebuilt. A unit includes the full definition itself only
if it creates a Window object or calls one of its members.
// drawing.hpp — Window is visible only through a forward declaration #pragma once class Window; int draw(const Window& w); int draw_copy(Window w);
// drawing.cpp — the full definition is included only here
#include "drawing.hpp"
#include "window.hpp"
int draw(const Window& w) { return w.width(); }
int draw_copy(Window w) { return w.width(); }
The two functions return the same width, but the way the parameter is passed is different.
draw takes a reference and does not build a new Window. draw_copy takes a value: a copy
is built during the call, and the copy of a class with the pimpl idiom makes a heap
allocation. The header shows both functions with the same forward declaration; the cost
depends on the form of the parameter, not on the header.
Explicit Instantiation and extern template
For templates a forward declaration is not enough: the body of the template has to sit in the header, because the compiler substitutes the type at every place of use. If a template is used with the same type in many units, each unit generates the same instantiation inside itself and the linker picks one of the copies. The running program is correct, but this generation is written into the compile time of every unit.
extern template cuts this repetition. If extern template class Box<int>; is written in
the header, the units do not instantiate the non-inline members of Box<int> inside
themselves; the definition waits in another unit. Members defined in the class body are
implicitly inline and the declaration does not cover them: the compiler may still generate
them to inline them. The gain grows in members whose bodies are defined outside; because the
members of the Box below are defined in the class body, the example shows the syntax, not
the gain. Explicit instantiation is done with template class Box<int>; in a single
.cpp file, and the single copy of the definition is generated there. The effect shows in
compile time; it does not show in the output. The program below only verifies that the pair
of declarations compiles and links.
// box.hpp — the template body is in the header, the int specialization comes from outside
#pragma once
template <typename T>
class Box {
public:
explicit Box(T value) : value_(value) {}
T get() const { return value_; }
private:
T value_;
};
extern template class Box<int>;
// box.cpp — the single definition of the Box<int> instantiation #include "box.hpp" template class Box<int>;
The Pimpl Idiom
The fourth tool also takes the private members of a class out of the header. The private
fields of a class are visible in the header, because the compiler has to know the size of the
object. If the type of a private field changes, every unit that includes the class is
rebuilt. The pimpl idiom moves these fields into an inner structure named Impl and
keeps in the header only its forward declaration and a std::unique_ptr<Impl>. At this point
Impl is an opaque type: the unit that sees the header knows it exists but not what is
inside.
The header below carries only the forward declaration and the pointer. Notice that the
destructor and the move operations are only declared here; their definitions are in the
.cpp file.
// window.hpp — only a forward declaration and a unique_ptr
#pragma once
#include <memory>
class Window {
public:
Window();
~Window();
Window(Window&&) noexcept;
Window& operator=(Window&&) noexcept;
Window(const Window&);
Window& operator=(const Window&);
int width() const;
void set_width(int value);
static int object_count();
static int alloc_count();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
When a std::unique_ptr<Impl> is destroyed it calls the destructor of Impl; for this it
has to see the full definition of Impl. Since Impl stands in the header only with a
forward declaration, leaving the destructor implicit in the header or writing = default
makes the compiler try to generate the destructor while Impl is an incomplete type, and
the build fails. For this reason the destructor and the move operations are only declared,
and defined with = default in the .cpp file where Impl is defined.
// window.cpp — the Window implementation, Impl is defined here
#include "window.hpp"
namespace {
int objects = 0;
int allocs = 0;
} // namespace
struct Window::Impl {
int width = 0;
};
Window::Window() : impl_(std::make_unique<Impl>()) {
++objects;
++allocs;
}
Window::~Window() = default;
Window::Window(Window&&) noexcept = default;
Window& Window::operator=(Window&&) noexcept = default;
Window::Window(const Window& other) : impl_(std::make_unique<Impl>(*other.impl_)) {
++allocs;
}
Window& Window::operator=(const Window& other) {
if (this != &other) *impl_ = *other.impl_;
return *this;
}
int Window::width() const { return impl_->width; }
void Window::set_width(int value) { impl_->width = value; }
int Window::object_count() { return objects; }
int Window::alloc_count() { return allocs; }
If a field is added to Impl, or the type of width changes, only window.cpp is rebuilt.
window.hpp stays as it is, and no unit that includes it is rebuilt. Being able to change the
implementation without changing the header is the compile cost the pimpl idiom gains.
Why the Destructor Is in the .cpp File
The most common mistake in this design is leaving the destructor in the header. The rule is
simple: when a std::unique_ptr<T> is destroyed, delete is called, and delete needs the
full definition of T. If the destructor of Window is not written in the header or is
written as = default, the compiler tries to generate it in every unit that destroys a
Window object. In those units Impl is still an incomplete type and the delete is
invalid. The fix is to only declare the destructor in the header and define it with
= default in the .cpp file where Impl is fully visible. The same reason applies to the
move assignment: the assignment has to destroy the old unique_ptr. The move constructor is
also defined in the .cpp file, because if construction is cut halfway, the code that
destroys the constructed members is generated in it too.
We do not quote the error message here and we do not run it; it varies from compiler to compiler. What matters is that the error appears in the user unit: not where the destructor is written, but where it is called. This makes the error harder to find.
The difference shows when compared with std::shared_ptr. A shared_ptr captures its
deleter when the object is constructed; the destructor can be written in the header even
for an incomplete type, because the deleter is formed in the unit where the construction is
done, while the type is fully visible. In a unique_ptr the deleter is part of the type and
is generated at the place where it is destroyed. A shared_ptr is thereby freed from the
destructor-in-the-header rule, but in return it carries a control block: std::make_shared
builds it together with the object in a single allocation, while a separately constructed
shared_ptr pays a second allocation for it. A unique_ptr with the default deleter is, in
common implementations, the size of a single pointer. In this lesson unique_ptr was chosen
and the allocation count is read through it.
How Many Units See This Header
Compile cost is measured by how many translation units a header is visible to. We cannot read
this from the output; we read it from the setup of the project, so we give the number as a
counting example, not as a measurement. Consider a hypothetical project with five
translation units; all the units include window.hpp. When the type of a field inside Impl
changes, which units are rebuilt?
| Layout | Units that include window.hpp |
Units rebuilt when Impl changes |
|---|---|---|
| Private fields in the header | 5 | 5 |
Pimpl idiom, Impl in the .cpp |
5 | 1 |
The numbers in the table come from the setup of this hypothetical project. The only unit
rebuilt in the second row is window.cpp, which defines Impl. By the same logic, if
drawing.hpp included window.hpp, every unit that includes drawing.hpp would be tied to
window.hpp as well; the forward declaration cuts this indirect connection. Indirect
includes quietly grow this number: a header also brings in everything that the headers
including it include.
For this reason two things are checked before adding an #include to a header. Is the added
type really needed in the header with its full definition, or is a forward declaration
enough? And how many headers include this header, and how many units do they in turn
include? The length of the chain comes from the answers to these questions; the compiler does
not do this count for us and warn us, because the program compiles correctly and only more
work is done.
Measurement: Allocation per Object
// window_measurement.cpp — the allocation per object of the pimpl idiom
#include <iostream>
#include <utility>
#include "box.hpp"
#include "drawing.hpp"
#include "window.hpp"
int main() {
Window a;
Window b;
Window c;
a.set_width(640);
b.set_width(480);
std::cout << "pimpl idiom run objects=" << Window::object_count()
<< " allocs=" << Window::alloc_count() << "\n";
int before = Window::alloc_count();
Window moved(std::move(a));
int move_allocs = Window::alloc_count() - before;
before = Window::alloc_count();
Window copied(b);
int copy_allocs = Window::alloc_count() - before;
std::cout << "pimpl object run move=" << move_allocs
<< " copy=" << copy_allocs << "\n";
std::cout << "width moved=" << moved.width()
<< " copy=" << copied.width() << "\n";
int start = Window::alloc_count();
int w1 = draw(b);
int by_reference = Window::alloc_count() - start;
start = Window::alloc_count();
int w2 = draw_copy(b);
int by_value = Window::alloc_count() - start;
std::cout << "forward declaration run reference=" << by_reference
<< " value=" << by_value << " width=" << w1 + w2 << "\n";
Box<int> box(7);
std::cout << "explicit instantiation run box=" << box.get() << "\n";
return 0;
}
pimpl idiom run objects=3 allocs=3 pimpl object run move=0 copy=1 width moved=640 copy=480 forward declaration run reference=0 value=1 width=960 explicit instantiation run box=7
CP24. The first line shows that each of the three Window objects allocated one Impl
at construction: objects=3, allocs=3. The pimpl idiom makes you pay the cost of reducing
the visibility of the header as a heap allocation on every object; the inner structure is no
longer inside the object but in a separate block of memory.
CP25. The third line confirms that the two objects carry the right values: the moved
object got the width 640 and the copy got 480. The state of the moved-from a was not
looked at; this lesson measures not its state but whether the move makes an allocation.
CP26. The fourth line shows that a Window shown through a forward declaration made no
allocation when passed by reference (reference=0) and made one allocation when passed by
value (value=1). The width total 960 confirms that both calls read the value 480. The
header carried both functions with the same forward declaration; the cost came not from the
header but from the form of the call.
CP27. The fifth line prints box=7. The extern template declaration and the
template class definition were compiled and linked together; the output does not show that
there is a single instantiation, it only shows that the pair of declarations is valid. The
effect on compile time cannot be read from this output.
CP28. Nothing in the output verifies the five against one in the table; it is a counting
example that comes from the setup of the project. Its correctness depends on the assumption:
that all five units include window.hpp and that only one defines Impl. In a real project
indirect includes change this number.
CP29. The move=0 in the second line depends on the move constructor being defined with
= default in the .cpp file: while Impl is fully defined, the move only takes over the
unique_ptr. If the same constructor were defined with = default in the header, a problem
could appear because Impl is incomplete. For this reason the measurement is meaningful only
in a layout that applies the rule.
The Bounding Measurement
The mechanism this lesson measured is moving the cost gained at compile time to run time per
object. The second line shows when this cost is paid again. Moving an object with the pimpl
idiom makes no new allocation: move=0, because the move only takes over the pointer of the
unique_ptr and Impl stays in place. Copying the same object duplicates Impl and
makes one allocation: copy=1.
That is, the compile cost gained turns into a run cost at every place an object is produced:
producing and copying pay an allocation, moving does not. The forward declaration shows the
same limit: reference=0 and value=1. The call that passes by reference does not pay the
cost of the pimpl idiom; the call that passes by value is the one that pays. The limit is
this: if the class is copied a hundred thousand times in a hot loop, the time gained in
compiling corresponds to a hundred thousand allocations at run time. For this reason the
pimpl idiom is justified for classes whose objects are produced rarely, whose header is
visible to many units, and whose implementation changes often.
Summary
- The mechanism this lesson measured moves the cost to the kind run: the pimpl idiom reduces the visibility of the header and in return every object has to pay a heap allocation.
- When a header changes, every unit that includes it is rebuilt; forward declaration,
deleting unnecessary
#includelines andextern templateshorten this chain. - The pimpl idiom hides the private members behind
Impl; the implementation can change without the header changing. The destructor and the move operations are defined in the.cppfile whileImplis fully defined. pimpl idiommeasuredobjects=3 allocs=3on three objects: each object pays one allocation.- A forward declaration lightens the header; with
reference=0andvalue=1the cost depends on the form of the call.extern templategathers the template instantiation into one unit. - Moving made
move=0allocations and copying madecopy=1: the compile cost gained is paid again at run time in copying.
Next Step
This lesson showed reducing the visibility of a header, but we still described the effect of the reduction by guessing. The next lesson is the close of the course: with the question of how many units a changing header rebuilds, we will evaluate together all the mechanisms we measured in this course.
To keep your progress and take notes, Log in
My notes
Log in to take notes.