Skip to content
VLSI Mentor

C++ Part I · Module 24 · C++ Build and Project Architecture

Forward Declarations and Compile-Time Dependencies

C++ builds are slow for a structural reason rather than an accidental one. A header is copied into every translation unit that includes it, those headers include other headers, and the transitive closure is enormous — so a file that needs one pointer to one class can end up compiling most of the standard library. The lever that fixes it is the smallest syntax in the language: a line saying a class exists without saying what is in it. That one line is enough for a pointer, a reference, and a function signature, which covers a surprising share of real uses, and the chapter measures what it is worth both in text the compiler must read and in translation units a header change forces to recompile. The second measurement is the one that matters on a real project, because the cost of a dependency is not paid once at the start — it is paid every time somebody edits the header, by every file that transitively included it. Knowing which headers have a wide fan-out, and how to narrow it, is the difference between a build you can iterate against and one you wait for.

Intermediate18 min readC++Part IFundamentalsBuild

Part I · Module 24 · Chapter 24.4 · Forward Declarations and Compile-Time Dependencies

1. What an Unnecessary Include Costs

A class with a few standard-library members, and two consumers that each hold only a pointer to it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// heavy.h
#include <string>
#include <vector>
#include <map>
#include <memory>
#include <sstream>
#include <algorithm>
class Heavy { /* … members of those types … */ };

// uses_include.h — the usual approach
#include "heavy.h"
class UserA { public: void attach(Heavy* h); private: Heavy* h_ = nullptr; };

// uses_fwd.h — a pointer needs no more than this
class Heavy;
class UserB { public: void attach(Heavy* h); private: Heavy* h_ = nullptr; };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  include heavy.h :  87964 preprocessed lines   1311 includes, 980 distinct headers
  forward declare :     14 preprocessed lines      1 includes,   1 distinct headers

Eighty-eight thousand lines against fourteen. The including consumer opens 1,311 header files — 980 of them distinct — to compile a class whose only member is a pointer. The forward-declaring one opens one file.

Those line counts are deterministic and reproduce exactly: five runs gave 87,964 every time. The compile-time consequence is a median of 23×, range 12–62× over nine runs. The range is wide because this machine is a loaded workstation, and a single run of the experiment reported 52.5× — near the top of the range — which is why the figure is published with its range rather than alone.

One measurement note worth carrying forward, because it nearly produced a wrong published number. clang++ -H prints the include tree to standard error, and counting it through a pipe gave 1144, 1142, 1144, 1143 and 1143 on five consecutive runs. Captured to a file it is 1,311 every time. An earlier draft of this chapter was going to publish 1,148. If a count that should be deterministic is not, the instrument is wrong before the claim is.

2. What a Forward Declaration Is

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class Heavy;        // Heavy is a class type. That is all this says.

That is a declaration without a definition, and the type is then incomplete: the compiler knows the name denotes a class and knows nothing about its size, its members, or its bases. An incomplete type can be used wherever the compiler needs only the name.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class Heavy;

class OkPtr { Heavy* p; };                           // a pointer has a known size
class OkRef { Heavy& r; OkRef(Heavy& h) : r(h) {} }; // so does a reference
Heavy* factory();                                    // a declared return type
void take(Heavy&);                                   // a declared parameter
void takeByValue(Heavy);                             // even by value, IF never called here

All of those compile with only the forward declaration. The last one is the surprise and the rule is precise: declaring a function that takes or returns an incomplete type is fine; defining or calling it is not, because that is where the compiler must know how to construct, copy or destroy the object.

3. What You Cannot Do

Three things need the complete type, and each has a distinct diagnostic:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class Heavy;
class ByValue { Heavy h; };              // a member by value
class Derived : public Heavy { };        // a base class
int n = sizeof(Heavy);                   // sizeof
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cant.cpp:2:24: error: field has incomplete type 'Heavy'
    2 | class ByValue  { Heavy h;    };          // member by value: needs the complete type
      |                        ^
cant.cpp:1:7: note: forward declaration of 'Heavy'
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cant2.cpp:2:24: error: base class has incomplete type
    2 | class Derived : public Heavy { };        // inheritance: needs the complete type
      |                 ~~~~~~~^~~~~
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cant3.cpp:2:9: error: invalid application of 'sizeof' to an incomplete type 'Heavy'
    2 | int n = sizeof(Heavy);                   // sizeof: needs the complete type
      |         ^     ~~~~~~~

The unifying rule is whether the compiler needs to know the type's size or layout:

needs the complete typedoes not
a member by valuea member by pointer or reference
a base classa pointer or reference to it anywhere
sizeof or alignofa declared function signature
new Heavy or a local Heavy h;a Heavy* returned from a factory
dereferencing or calling a memberpassing a Heavy* through
delete p on a Heavy*holding a Heavy* you do not delete
a container of Heavy by valuea container of Heavy*

The delete row is the dangerous one. Deleting a pointer to an incomplete type is undefined behaviour, not an error — the destructor cannot be found, so it is not called, and on this toolchain the compiler issues a warning rather than refusing. A std::unique_ptr<Heavy> member avoids this, because the standard requires the type to be complete at the point the deleter is instantiated, which is what forces §5's explicit destructor.

Note also what a forward declaration cannot do at all: you cannot forward declare a nested type (class Outer::Inner; is ill-formed), a type alias, or an enumeration without its underlying type — though enum class Resp : int; is a legal opaque declaration precisely because the underlying type fixes the size.

4. The Include That Is Actually Required

Standard-library types have a dedicated solution that is better than forward declaring them yourself:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <iosfwd>          // declares std::istream, std::ostream, std::stringstream …
void writeRow(std::ostream& os, Cycle c, Addr a, Resp r);

<iosfwd> exists for exactly this: the stream classes are templates with default arguments, so hand-writing their declarations is both tedious and fragile. Chapter 23.5 §6's advice to take std::ostream& combines with this to give a header that mentions streams and costs almost nothing.

Do not forward declare standard-library types yourself. namespace std { class string; } is ill-formed — adding declarations to namespace std is undefined — and it is wrong anyway, because std::string is a typedef for a template specialisation rather than a class.

For your own types in namespaces, the declaration must nest correctly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
namespace vm { namespace bus { class Axi; } }   // or: namespace vm::bus { class Axi; }

A frequently useful pattern is a project-level forward-declaration header, so consumers write one include instead of several declarations:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// vm/fwd.h
#pragma once
#ifndef VM_FWD_H
#define VM_FWD_H
namespace vm {
class Bus;
class Report;
enum class Resp : int;        // legal: the underlying type is fixed
}
#endif

5. Pimpl, and What It Actually Costs

A forward declaration cannot help with a member by value — §3's first row — and a class's private data must be in the header, as Chapter 24.2 §7 established. The pointer-to-implementation idiom moves that data behind a pointer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// pimpl.h
#include <memory>
class HeavyImpl;                       // defined only in the .cpp

class HeavyPimpl {
public:
    HeavyPimpl();
    ~HeavyPimpl();                     // MUST be declared here and defined in the .cpp
    void add(int v);
private:
    std::unique_ptr<HeavyImpl> p_;
};
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  pimpl consumer  : 33165 preprocessed lines

Thirty-three thousand lines, against 87,964 for the include and 14 for the forward declaration. So pimpl recovers about 62% of the cost and is nowhere near free, because the header still includes <memory>. That is the honest accounting, and it is why pimpl is not the default answer: a plain forward declaration is three orders of magnitude cheaper when it applies.

Three details are not optional:

The destructor must be declared in the header and defined in the source file. If you let the compiler generate it, it is generated where HeavyImpl is incomplete, and unique_ptr's deleter requires a complete type — which is a confusing error inside <memory>, or in the worst case §3's silent non-destruction.

Copying needs a decision. The default copy constructor would copy the unique_ptr, which is deleted, so the class is move-only unless you write a copy that clones. Chapter 14.2 §4 is the ownership argument.

You pay an indirection on every access, and the implementation is a separate heap allocation. For a type instantiated in a hot loop that is the wrong trade; for a compilation firewall around a large component it is the right one.

6. Measuring Your Own Fan-Out

The build system already knows the answer. With -MMD -MP the compiler writes a dependency file per object:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
reader.o: reader.cpp config.h
config.h:

The first line is the real dependency. The second is what -MP adds: a phony target for each header, so that deleting a header does not break the build with "no rule to make target". Chapter 24.8 §2 is the full treatment.

Given those files, the fan-out of every header is a short script, and so is the reverse question:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# which TUs depend on a given header?
grep -l 'vm/types\.h' build/*.d

# which headers does one TU pull in, and how many?
sed -n 's/^\.* //p' trace.txt | sort -u | wc -l     # from clang++ -H, captured to a FILE

Two cautions from §1 apply to both. Capture -H to a file, not through a pipe. And when you compare "before" and "after" numbers for a dependency change, compare the deterministic ones — preprocessed lines, distinct headers, translation units recompiled — rather than wall-clock times, which on a loaded machine move more than the change you are measuring.

7. The Order to Try Things In

Given a header that is too expensive, work down this list; the cost of each step rises and the applicability falls.

One: delete includes it does not use. Chapter 24.2 §6's duty, and often the largest single win because the unused include may be the heavy one.

Two: replace an include with a forward declaration wherever only a pointer, reference or signature is involved. Measured: 87,964 lines to 14.

Three: use <iosfwd> for stream types, and a project vm/fwd.h for your own.

Four: move members from by-value to by-pointer where the design allows it. This is a design change, not a mechanical one.

Five: pimpl the component. Measured: 33,165 lines, so about 62% of the cost recovered, at the price of an allocation, an indirection and three pieces of required boilerplate.

Six: split the header. A single vm/all.h that every consumer includes guarantees maximum fan-out; several narrow headers let each consumer depend on only what it uses.

Two tools worth knowing about and not relying on: precompiled headers help with large stable third-party includes and do nothing for your own churning headers; C++20 modules are the real fix for all of this, and whether they are usable depends on your toolchain and build system — Chapter 21.8 §1 is the pattern for checking rather than assuming.

8. The Table

the questionthe answermeasured
what does an unnecessary include cost87,964 preprocessed lines, 980 distinct headers§1
what does the forward declaration cost14 lines, 1 header§1
in compile time23× (12–62×), median of 9§1
is that figure stablethe line counts yes (5/5); the time no§1
can I forward declare for a pointeryes§2
for a member by valueno — field has incomplete type§3
for a base classno — base class has incomplete type§3
for sizeofno — invalid application of 'sizeof'§3
for delete pit compiles and is undefined behaviour§3
for std::ostream&use <iosfwd>§4
what does pimpl recover87,964 → 33,165 lines, about 62%§5
which header is most expensive to editthe one with the widest fan-out, not the largest§5 callout
what is the fan-out heretypes.h 4, bus.h 3, report.h 2§5 callout

9. What to Actually Do

  • Forward declare instead of including wherever only a pointer, reference or declared signature is involved. Measured: 87,964 preprocessed lines become 14.
  • Delete unused includes first. It is the cheapest step and often the biggest.
  • Use <iosfwd> for stream types, and never hand-declare anything in namespace std.
  • Keep a project fwd.h so consumers write one include rather than several declarations.
  • Declare pimpl's destructor in the header and define it in the source file, and decide explicitly about copying.
  • Reach for pimpl as a compilation firewall, not a default. It recovers 62%, not 100%, and costs an allocation and an indirection.
  • Measure the fan-out from the .d files, and manage the widely-included headers rather than the large ones.
  • Prefer several narrow headers to one convenience header.
  • Capture clang++ -H to a file, never through a pipe — through a pipe the count drifts.
  • Compare deterministic quantities — preprocessed lines, distinct headers, translation units rebuilt — not wall-clock build times on a loaded machine.

10. Mistakes Worth Making Once

Including a header for a type you only hold a pointer to. The measurement is the argument.

namespace std { class string; }. Ill-formed, and wrong about what std::string is.

delete p where p points at an incomplete type. It compiles. The destructor does not run.

Letting the compiler generate pimpl's destructor. An error from inside <memory>, or silent non-destruction.

Assuming pimpl is free. 33,165 lines — <memory> is not cheap.

Forward declaring a nested class. class Outer::Inner; is ill-formed.

One all.h that every consumer includes. Maximum fan-out by construction.

Judging a header by its size. A nine-line types.h was the most expensive file in the project to edit.

Counting -H output through a pipe. Five runs, five different answers.

Timing a dependency change on a loaded machine. The noise exceeds the effect.

11. Interview Relevance

The frozen curriculum marks this chapter High, and the question is usually "when can you forward declare instead of including?". The crisp answer is: wherever the compiler does not need the type's size or layout — pointers, references, and declared-but-not-defined signatures — and not for a member by value, a base class, sizeof, or anything that constructs or destroys the object.

The follow-up worth being ready for is "why does it matter?", and the measurement is a much better answer than "it speeds up the build": a consumer holding one pointer cost 87,964 preprocessed lines across 980 headers with the include and 14 lines with the declaration.

The strongest thing you can add is the fan-out framing — that a header's cost is its size multiplied by how many translation units recompile when it changes, so a nine-line types.h that everything includes is more expensive to edit than a 400-line implementation file. That is the observation that distinguishes someone who has managed a build from someone who has read about one. And if delete on an incomplete type comes up, knowing it is undefined behaviour rather than an error is the detail interviewers remember.

12. What You Should Be Able to Say Now

  • That a forward declaration makes the type incomplete, and that this suffices wherever the compiler needs only the name.
  • The three things that need the complete type — a member by value, a base class, sizeof — and their exact diagnostics.
  • That delete on an incomplete type compiles and is undefined behaviour, and that unique_ptr is what forces you to get it right.
  • That declaring a function taking an incomplete type by value is legal; defining or calling it is not.
  • That <iosfwd> is the right tool for stream types and that declaring anything in namespace std is ill-formed.
  • The measurement: 87,964 preprocessed lines / 980 headers against 14 / 1, with compile time 23× (12–62×).
  • That pimpl recovers to 33,165 lines — about 62% — and requires a declared destructor, a copy decision, and an indirection.
  • That a dependency's cost is size × fan-out, that fan-out is measured from the .d files, and that a nine-line header can be the most expensive file in a project.
  • That -H must be captured to a file, and that deterministic quantities beat wall-clock times on a loaded machine.

13. Exercises

Exercise 1 — the measurement. Build §1's two consumers and compare clang++ -E | wc -l for each. Then capture -H to a file and count the distinct headers. Then count it through a pipe five times and observe the drift.

Exercise 2 — the limits. Write all seven rows of §3's left column and collect the diagnostics. Then write delete p on an incomplete type and record what the compiler says and what the program does.

Exercise 3 — pimpl. Convert a class with three standard-library members to pimpl. Measure the consumer's preprocessed size before and after. Then delete the explicitly declared destructor and read the error.

Exercise 4 — fan-out. Add -MMD -MP to a project, build it, and compute every header's fan-out from the .d files. Then verify one of them with make -n, stamping the header into the future so the whole-second timestamp problem does not mislead you.

Exercise 5 — the graph. Find the header in a real project with the largest size × fan-out product and propose one change that removes an edge.

14. Summary

  • A forward declaration makes a type incomplete, which is enough for a pointer, a reference, and a declared signature — and not enough for a member by value, a base class, or sizeof.
  • Measured, for a consumer holding one pointer: 87,964 preprocessed lines across 1,311 includes and 980 distinct headers, against 14 lines from 1 file. The line counts are deterministic (5/5 identical); compile time is 23× with a range of 12–62×.
  • delete on an incomplete type compiles and is undefined behaviour. The destructor is not called.
  • Use <iosfwd> for stream types, a project fwd.h for your own, and never declare anything in namespace std.
  • Pimpl recovers about 62% — 87,964 to 33,165 lines — because the header still needs <memory>. It requires a destructor declared in the header, an explicit decision about copying, and an indirection per access. It is a firewall, not a default.
  • A dependency's cost is size × fan-out, paid on every rebuild by every transitive consumer. Measured in a four-file project, touching the nine-line vm/types.h recompiles 4 of 4 translation units while touching a 400-line source file recompiles 1 — so the cheap-looking file is the expensive one.
  • Measure fan-out two ways. make -n and the .d files agreed on 4, 3 and 2; the first attempt reported 2, 2 and 1 because GNU Make 3.81 compares whole seconds and the touch landed inside the build's own second. The self-inconsistency is what revealed the broken instrument.
  • Capture -H to a file, not a pipe — through a pipe the count drifted across five runs — and compare deterministic quantities rather than wall-clock times on a loaded machine.

Chapter 24.5 goes to the other side of the seam and asks what the linker is actually matching, which turns out to explain both the errors in Chapter 24.2 and why extern "C" exists at all.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.