Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 9 – Templates and concepts

Templates in practice: build time and binary size

Measure where template-heavy C++ spends compile time, cut repeated instantiation with extern template, shrink object files and read long template errors.

  • Advanced
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict how object files grow with the number of instantiations, and why the linker hides it
  • Measure where template-heavy code spends compile time with -ftime-trace and -ftime-report
  • Remove repeated instantiations with extern template, and measure the effect
  • Move code that does not depend on the template parameters into a detail part compiled once
  • Read a long template error from the instantiation chain to the line in your own code

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict how object files grow with the number of instantiations, and why the linker hides it
  • Measure where template-heavy code spends compile time with -ftime-trace and -ftime-report
  • Remove repeated instantiations with extern template, and measure the effect
  • Move code that does not depend on the template parameters into a detail part compiled once
  • Read a long template error from the instantiation chain to the line in your own code

Before you start

On this page

Sort vectors of 40 types that differ only in their names, and the object file grows from 5,344 bytes for one type to 168,744 with Clang, although the source did not get any longer. Templates cost build time and binary size because each instantiation: The compiler writing a real function or class from a template for particular template arguments, such as larger<int> from larger, the first time a use needs it. Each instantiation is compiled as a function or class of its own. More in “C++ function templates and argument deduction”, each distinct set of template arguments, is compiled on its own, and again in every translation unit that uses it.

You will measure that cost with Clang’s -ftime-trace and GCC’s -ftime-report, cut repeated instantiations with extern template and see where it backfires, move code that ignores the template parameters into a part compiled once, and read a long template error in three steps. Every example ran with Apple clang 21 and GCC 16.

Every instantiation is compiled on its own

std::sort on vectors of 40 different types is 40 copies of the sorting code, each instantiated, optimised and emitted. many.cpp sorts a vector of Item<Tag> for COPIES different tags. The Item types are identical in every way except their names, which is enough to make each one a separate instantiation of everything that touches it:

many.cpp C++ · many.cpp
// One small template instantiated for COPIES different types: Item<0>, Item<1>, ... Item<COPIES - 1>.
#include <algorithm>
#include <compare>
#include <cstddef>
#include <utility>
#include <vector>

template <int Tag>
struct Item {
    int key;
    auto operator<=>(const Item&) const = default;
};

template <typename T>
std::size_t sort_and_count_unique(std::vector<T>& values) {
    std::sort(values.begin(), values.end());
    return static_cast<std::size_t>(std::unique(values.begin(), values.end()) - values.begin());
}

// Calls sort_and_count_unique once for each type Item<0> to Item<COPIES - 1>.
template <int... Tags>
std::size_t run_all(std::integer_sequence<int, Tags...>) {
    std::size_t total = 0;
    ((total += [] {
         std::vector<Item<Tags>> v{{3}, {1}, {3}, {2}};
         return sort_and_count_unique(v);
     }()),
     ...);
    return total;
}

int main() { return static_cast<int>(run_all(std::make_integer_sequence<int, COPIES>{})); }
1, 10 and 40 instantiations, both compilers, -O2 Python · sizes_session.py
"""Compiles many.cpp with 1, 10 and 40 instantiations on both compilers (-O2) and prints the size of each object file
and of its machine code (the __text section)."""

import os
import re
import subprocess

env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])

for compiler in ("clang++", "g++-16"):
    for copies in (1, 10, 40):
        subprocess.run([compiler, "-std=c++23", "-O2", f"-DCOPIES={copies}", "-c", "many.cpp", "-o", "many.o"],
                       check=True, env=env)
        sections = subprocess.run(["size", "-m", "many.o"], capture_output=True, text=True, check=True).stdout
        code = int(re.search(r"\(__TEXT, __text\): (\d+)", sections).group(1))
        print(f"{compiler:<8} {copies:>2} types: object file {os.path.getsize('many.o'):>7,} bytes, machine code {code:>7,} bytes")

Output

clang++   1 types: object file   5,344 bytes, machine code   3,644 bytes
clang++  10 types: object file  42,976 bytes, machine code  35,884 bytes
clang++  40 types: object file 168,744 bytes, machine code 143,564 bytes
g++-16    1 types: object file   3,336 bytes, machine code   1,720 bytes
g++-16   10 types: object file  24,456 bytes, machine code  17,000 bytes
g++-16   40 types: object file  94,864 bytes, machine code  67,880 bytes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sizes_session.py

The object file grows almost in proportion to the number of types, and the machine code with it:

Item types Apple clang 21 object file GCC 16 object file
1 5,344 bytes 3,336 bytes
10 42,976 bytes 24,456 bytes
40 168,744 bytes 94,864 bytes

Nothing in the source got bigger; only the list of types did. With Clang, 40 types made the object file about 32 times larger than one, close to 4,190 bytes for every extra type. Code size has a run-time cost as well, because a larger program uses more of the processor’s instruction cache.

Checkpoint

Check: One compiled copy per set of arguments

  1. Question 1 of 2 With Clang at -O2, one Item type gave an object file of 5,344 bytes and 40 types gave 168,744. About how many bytes does each extra type add?

    Type a number.

    A hint

    Divide the growth by the number of extra types.

    Show the answer: Question 1 of 2

    Answer: 4190 bytes (anything from 4090 to 4290 counts)

    (168,744 − 5,344) ÷ 39 is about 4,190 bytes a type, and 10 types fit the same line: 5,344 + 9 × 4,190 is about 43,000, against 42,976 measured. Each type adds its own copy of the sorting code.

  2. Question 2 of 2 Item<0> and Item<1> have the same members and differ only in their template argument. How many copies of the sorting code does a program that sorts both contain?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Two, because they are two different types and each gets its own instantiation

    An instantiation is identified by its template arguments. Item<0> and Item<1> are as different to the compiler as int and double, which is why 40 such types made the object file grow about 32 times.

Every file that uses an instantiation compiles it again

Templates live in headers, so every source file that uses Matrix<double> instantiates it again. Matrix<double> used in 300 source files is compiled 300 times before the linker throws 299 copies away. The linker accepts the duplicates because the compiler marks them:

Three files that use Matrix<double>

C header · matrix/matrix.h

// A matrix class template whose larger members are defined outside the class, as in most real headers.
#pragma once
#include <cstddef>
#include <stdexcept>
#include <vector>

template <typename T>
class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols) : rows_(rows), cols_(cols), data_(rows * cols) {}

    T& at(std::size_t r, std::size_t c) { return data_.at(r * cols_ + c); }
    const T& at(std::size_t r, std::size_t c) const { return data_.at(r * cols_ + c); }

    Matrix multiply(const Matrix& other) const;
    Matrix transpose() const;
    T trace() const;

private:
    std::size_t rows_, cols_;
    std::vector<T> data_;
};

template <typename T>
Matrix<T> Matrix<T>::multiply(const Matrix& other) const {
    if (cols_ != other.rows_) throw std::invalid_argument("sizes do not match");
    Matrix out(rows_, other.cols_);
    for (std::size_t i = 0; i < rows_; ++i)
        for (std::size_t k = 0; k < cols_; ++k)
            for (std::size_t j = 0; j < other.cols_; ++j) out.at(i, j) += at(i, k) * other.at(k, j);
    return out;
}

template <typename T>
Matrix<T> Matrix<T>::transpose() const {
    Matrix out(cols_, rows_);
    for (std::size_t i = 0; i < rows_; ++i)
        for (std::size_t j = 0; j < cols_; ++j) out.at(j, i) = at(i, j);
    return out;
}

template <typename T>
T Matrix<T>::trace() const {
    T sum{};
    for (std::size_t i = 0; i < rows_ && i < cols_; ++i) sum += at(i, i);
    return sum;
}

#ifdef USE_EXTERN
// Every file that includes this header: do not instantiate Matrix<double> here; matrix.cpp does.
extern template class Matrix<double>;
#endif

C++ (a.cpp) · matrix/a.cpp

// One of three source files that use Matrix<double>.
#include "matrix.h"

double work_a(double x) {
    Matrix<double> m(3, 3);
    for (std::size_t i = 0; i < 3; ++i) m.at(i, i) = x + static_cast<double>(i);
    return m.multiply(m.transpose()).trace();
}

C++ (main.cpp) · matrix/main.cpp

#include <cstdio>

double work_a(double), work_b(double), work_c(double);

int main() { std::printf("%.1f %.1f %.1f\n", work_a(1), work_b(2), work_c(3)); }
Three copies in the object files, one in the program Python · duplicates_session.py
from session import run

# Three object files, each with its own copy of Matrix<double>::multiply, then the program the linker makes of them.
# nm -m shows each symbol's section and kind; c++filt turns the mangled names back into C++. On macOS, c++filt is
# started through a small launcher that may print a note about its cache on standard error; 2>/dev/null keeps that
# note out of the names.
run("""
cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && for f in a.o b.o c.o; do echo "$f:"; nm -m $f | c++filt 2>/dev/null | grep "Matrix<double>::multiply"; done
cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && clang++ main.cpp a.o b.o c.o -o program && ./program && nm -m program | c++filt 2>/dev/null | grep "Matrix<double>::multiply"
""")

Output

$ cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && for f in a.o b.o c.o; do echo "$f:"; nm -m $f | c++filt 2>/dev/null | grep "Matrix<double>::multiply"; done
a.o:
00000000000001b8 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const
b.o:
00000000000001b8 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const
c.o:
00000000000001b8 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const
$ cd matrix && clang++ -std=c++23 -c a.cpp b.cpp c.cpp && clang++ main.cpp a.o b.o c.o -o program && ./program && nm -m program | c++filt 2>/dev/null | grep "Matrix<double>::multiply"
14.0 29.0 50.0
0000000100000700 (__TEXT,__text) weak external Matrix<double>::multiply(Matrix<double> const&) const

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duplicates_session.py

a.o, b.o and c.o each hold a complete Matrix<double>::multiply, marked weak external: a weak definition: A definition that the linker may meet in several object files and keep once, without a duplicate-symbol error. Compilers emit template instantiations and inline functions this way; nm -m shows them as weak external., which the linker: The last stage of a build: it joins the object files of a program and the libraries it uses into one executable, matching every symbol a file uses with exactly one definition. More in “How C++ compiles: preprocess, compile and link” may meet several times and keep once. Step through the build:

Interactive · step by step Three object files, three copies of multiply, one program Step through the build of the matrix program with Clang. The left column is what each source file compiled to; the right one is the program the linker makes of them.
Three object files, three copies of multiply, one program, step 5 of 5. Showing a.o, from a.cpp (multiply: weak external), b.o, from b.cpp (multiply: weak external), c.o, from c.cpp (multiply: weak external), The linker (keeps 1 of 3 copies), program (one multiply: 14.0 29.0 50.0). In focus: program.a.o, from a.cppmultiply: weak externalb.o, from b.cppmultiply: weak externalc.o, from c.cppmultiply: weak externalThe linkerkeeps 1 of 3 copiesprogramone multiply: 14.0 29.0 50.0

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The first file. a.cpp includes matrix.h and calls multiply on a Matrix<double>, so compiling it instantiates the whole member function: a.o defines Matrix<double>::multiply.
  2. The second file, again. b.cpp does the same work from the same header, compiled on its own, without knowing about a.cpp: b.o holds a second, identical multiply.
  3. And a third time. c.o gets the third copy. nm -m marks all three as weak external: a definition the linker may meet in several object files and keep once.
  4. The linker keeps one. The linker meets three definitions of one symbol. For an ordinary function that would be a duplicate-symbol error; for weak definitions it keeps one and drops the others.
  5. One copy in the program. The program holds a single Matrix<double>::multiply and printed 14.0 29.0 50.0. The compiler did the work three times, and the linker hid two of the results.

The one-definition rule: The rule that a program holds exactly one definition of each ordinary function it uses. An inline function may be defined in every translation unit that uses it, as long as all the definitions are identical. More in “How C++ compiles: preprocess, compile and link” allows a template’s definition in every translation unit, as the lesson on headers explained, and weak definitions are how the compiler and linker make that work. They are also why the work is invisible: the compiler did it three times, and the linker hid two of the results.

Checkpoint

Check: The same instantiation in every file

  1. Question 1 of 2 a.o, b.o and c.o each define Matrix<double>::multiply. How many copies does the linked program contain?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 1 copies

    The copies are weak definitions, so the linker keeps one and drops the others. nm -m found one multiply in the program, at 0x100000700.

  2. Question 2 of 2 The program holds one multiply. What happened to the work of compiling the other two copies?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The compiler did it for b.cpp and c.cpp, and the linker threw the results away

    Weak definitions make duplicate instantiations legal and invisible: the program is right, and the cost shows only in build time and in the size of the object files.

Measure where the compile time goes before changing anything

The cost is measured, not guessed: Clang’s -ftime-trace writes a timeline of the compile, and GCC’s -ftime-report prints a table of where time went. Both split a compile the same way, into the front end (of a compiler): The part of a compiler that reads the source: it preprocesses and parses the code, checks it and instantiates templates. The back end then optimises the result and generates machine code., which parses the code and instantiates templates, and the back end (of a compiler): The part of a compiler that optimises the code the front end produced and generates machine code for it, function by function, so its work grows with every instantiated function, most of all with optimisation on., which optimises and generates machine code.

Clang: -ftime-trace

-ftime-trace makes Clang write a JSON file in the Chrome tracing format, with one event for each piece of work longer than 500 microseconds (-ftime-trace-granularity changes that), next to totals per kind of work. Viewers for that format show it as a timeline; for numbers, a short script is enough:

trace_summary.py Python · trace_summary.py
"""Summarises a Clang -ftime-trace file: the whole compile, front end and back end, the time spent instantiating
templates, and the three templates whose instantiations took longest. A template's time includes the instantiations
it caused in turn, so these numbers overlap and do not add up."""

import collections
import json
import sys

events = json.load(open(sys.argv[1]))["traceEvents"]
total = {e["name"][6:]: e["dur"] / 1e6 for e in events if e.get("name", "").startswith("Total ")}
whole = total["ExecuteCompiler"]
print(f"compile {whole:.1f} s: front end {total['Frontend']:.1f} s, back end {total['Backend']:.1f} s")
instantiation = total.get("InstantiateFunction", 0) + total.get("InstantiateClass", 0)
print(f"template instantiation: {instantiation:.1f} s, {100 * instantiation / whole:.0f} % of the compile")
by_template = collections.Counter()
for e in events:
    if e.get("name") in ("InstantiateFunction", "InstantiateClass") and e.get("ph") == "X":
        by_template[e.get("args", {}).get("detail", "").split("<")[0]] += e["dur"] / 1e6
for name, seconds in by_template.most_common(3):
    print(f"  {seconds:5.2f} s  {name}")
20 instantiations, without and with optimisation Python · trace_session.py
from session import run

# Twenty instantiations, without and with optimisation; -ftime-trace writes the JSON file named after the =.
run("""
clang++ -std=c++23 -O0 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O0.json
python3 trace_summary.py many-O0.json
clang++ -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O2.json
python3 trace_summary.py many-O2.json
""")

Output

$ clang++ -std=c++23 -O0 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O0.json
$ python3 trace_summary.py many-O0.json
compile 9.4 s: front end 6.2 s, back end 3.1 s
template instantiation: 5.0 s, 54 % of the compile
   4.30 s  std::vector
   3.13 s  run_all
   1.54 s  sort_and_count_unique
$ clang++ -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-trace=many-O2.json
$ python3 trace_summary.py many-O2.json
compile 19.4 s: front end 7.9 s, back end 11.4 s
template instantiation: 4.7 s, 24 % of the compile
   3.32 s  run_all
   2.53 s  sort_and_count_unique
   2.36 s  std::vector

This output changes from run to run: compile times differ from one run to the next, and so can the order of templates whose times are close

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace_session.py

Without optimisation the front end takes most of the time, and instantiating templates is a large share of it. With -O2 the back end takes the larger part, because every instantiated function is now optimised on its own, while the time spent instantiating stays about the same. Fewer instantiations therefore speed up release builds as well as debug builds. The list of templates shows where the instantiation time went: building std::vector and the sorting code for each Item type. A template’s time includes the instantiations it caused, so the three numbers overlap.

JSON Viewer & Tree Editor Open a -ftime-trace file to browse its traceEvents and the Total entries the script reads.

GCC: -ftime-report

GCC has no -ftime-trace. Its -ftime-report prints a table of timings to standard error when the compile ends, one row per part of the compiler; time_report.py keeps four rows:

GCC's own account of the same compile Python · report_session.py
from session import run

# GCC has no -ftime-trace; -ftime-report prints a table of timings to standard error when the compile ends.
run("""
g++-16 -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-report 2>&1 | python3 time_report.py
""")

Output

$ g++-16 -std=c++23 -O2 -DCOPIES=20 -c many.cpp -o many.o -ftime-report 2>&1 | python3 time_report.py
phase parsing             0.87 s    8 %
phase lang. deferred      1.71 s   15 %
phase opt and generate    8.61 s   77 %
template instantiation    1.52 s   14 %

This output changes from run to run: compile times differ from one run to the next

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 report_session.py

The picture matches Clang’s with -O2: optimising and generating code (“phase opt and generate”) takes most of the compile, and template instantiation is a small slice of the total. Step through the parts of one compile, and how the two reports name them:

Interactive · stage by stage Where one compile of many.cpp spends its time Step through the parts of a compile in order. Each stage says what the compiler does there and how the time reports name it.
Where one compile of many.cpp spends its time, step 6 of 6. Showing Parse the source (the larger part at -O0), Instantiate templates (similar at -O0 and -O2), Optimise each function (the larger part at -O2), Generate machine code, The time reports (times per template overlap). In focus: The time reports, Optimise each function.Parse the sourcethe larger part at -O0Instantiate templatessimilar at -O0 and -O2Optimise each functionthe larger part at -O2Generate machine codethe back endThe time reportstimes per template overlap

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Reading the code. The front end reads many.cpp and every header it includes, and checks the code. In -ftime-trace this is part of Total Frontend.
  2. Making the instantiations. Still in the front end, the compiler makes every instantiation the code needs: std::vector of each Item type, std::sort and its helpers, run_all, twenty times over. The trace records them as InstantiateClass and InstantiateFunction.
  3. Without optimisation. At -O0 the back end has little to do, so the front end takes most of the compile, and instantiating templates is a large share of it.
  4. With -O2. At -O2 every instantiated function is optimised on its own, so the back end takes the larger part. Fewer instantiations therefore also speed up release builds.
  5. Totals that overlap. trace_summary.py also ranks templates by instantiation time. A template's time includes the instantiations it caused, so the three times overlap and do not add up.
  6. GCC's own account. GCC has no -ftime-trace; -ftime-report prints a table per phase. At -O2, phase opt and generate takes most of the compile and template instantiation a small slice, the same picture as Clang.

Measure a build before changing it, and measure it again afterwards: in a real project, the slowest files are often the ones that include one particular header.

Checkpoint

Check: Measuring where compile time goes

  1. Question 1 of 2 With -O2, which part of the Clang compile of many.cpp took the larger share of the time?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The back end, which optimises and generates code for every instantiated function

    Instantiation took about the same time at both levels, but at -O2 each instantiation is also optimised. That is why fewer instantiations speed up release builds as well as debug builds.

  2. Question 2 of 2 The three template times that trace_summary.py prints add up to more than its total for template instantiation. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A template's time includes the instantiations it caused, so the times overlap

    Use the per-template list to rank where instantiation time goes, and the totals to see how big that time is; never add the per-template numbers up.

extern template compiles a common instantiation once

C++11 added a way to say “this instantiation is compiled elsewhere” (paper N1987). It has two halves:

  • An explicit instantiation definition in one source file, template class Matrix<double>;, compiles every member of Matrix<double> there: the explicit instantiation: A line such as template int largest(const std::vector<int>&); in the file that holds a template's body. That file then writes the code of the named instantiation, so other files can link to it without seeing the body. More in “C++ header files and the one-definition rule” of the headers lesson, for a whole class.
  • An explicit instantiation declaration: A line such as extern template class Matrix<double>; that tells a translation unit not to instantiate the non-inline members of that specialisation itself, because an explicit instantiation definition in another file of the program provides them. in the header, extern template class Matrix<double>;, tells every other file not to instantiate those members itself but to call the ones the definition provides.
Without extern template three files compile Matrix<double>::multiply and the linker keeps one; with it, only matrix.cpp does.Without extern templateWith extern templatea.cpp, b.cpp and c.cpp eachinstantiate and compileMatrix<double>::multiplymatrix.h: extern templateclass Matrix<double>;a.cpp, b.cpp, c.cpp only call itmatrix.cpp: template classMatrix<double>; compilesevery member oncesame program, less compiling

Who compiles Matrix<double>, without and with extern template

Text description of the diagram

The diagram has two panels.

Without extern template: a.cpp, b.cpp and c.cpp each instantiate and compile Matrix<double>::multiply. The linker meets three weak copies of it and keeps one.

With extern template: matrix.h contains the explicit instantiation declaration extern template class Matrix<double>;, so a.cpp, b.cpp and c.cpp only call the member functions. matrix.cpp contains the explicit instantiation definition template class Matrix<double>;, which compiles every member once. The linker uses that one copy in matrix.o.

The arrow between the panels says that the program is the same, with less compiling.

matrix.h above has the declaration behind #ifdef USE_EXTERN, and matrix.cpp holds the definition:

matrix/matrix.cpp C++ · matrix/matrix.cpp
// The one file that instantiates Matrix<double>: an explicit instantiation definition.
#include "matrix.h"

template class Matrix<double>;
The matrix program built eight ways Python · extern_session.py
"""Builds the matrix program twice with each compiler and optimisation level: as it is, and with
"extern template class Matrix<double>;" in the header plus matrix.cpp. Prints the bytes of a.o + b.o + c.o, the bytes
of matrix.o, how many of a.o, b.o and c.o define Matrix<double>::multiply, and what the program printed."""

import os
import subprocess

env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
os.chdir("matrix")


def build(compiler: str, level: str, use_extern: bool) -> str:
    flags = [compiler, "-std=c++23", level] + (["-DUSE_EXTERN"] if use_extern else [])
    users = ["a.o", "b.o", "c.o"]
    for obj in users:
        subprocess.run([*flags, "-c", obj[:-2] + ".cpp", "-o", obj], check=True, env=env)
    objects = list(users)
    if use_extern:
        subprocess.run([*flags, "-c", "matrix.cpp", "-o", "matrix.o"], check=True, env=env)
        objects.append("matrix.o")
    subprocess.run([*flags, "main.cpp", *objects, "-o", "program"], check=True, env=env)
    printed = subprocess.run(["./program"], capture_output=True, text=True, check=True).stdout.strip()
    defining = 0
    for obj in users:
        symbols = subprocess.run(["nm", "-C", "--defined-only", obj], capture_output=True, text=True).stdout
        defining += any("Matrix<double>::multiply" in line for line in symbols.splitlines())
    users_bytes = sum(os.path.getsize(o) for o in users)
    extra = f" + matrix.o {os.path.getsize('matrix.o'):>6,}" if use_extern else " " * 18
    return f"a+b+c {users_bytes:>6,} bytes{extra}; multiply in {defining} of 3; printed {printed}"


for compiler in ("clang++", "g++-16"):
    for level in ("-O0", "-O2"):
        print(f"{compiler:<7} {level} plain:  {build(compiler, level, False)}")
        print(f"{compiler:<7} {level} extern: {build(compiler, level, True)}")

Output

clang++ -O0 plain:  a+b+c 59,352 bytes                  ; multiply in 3 of 3; printed 14.0 29.0 50.0
clang++ -O0 extern: a+b+c 19,104 bytes + matrix.o 19,312; multiply in 0 of 3; printed 14.0 29.0 50.0
clang++ -O2 plain:  a+b+c 16,104 bytes                  ; multiply in 3 of 3; printed 14.0 29.0 50.0
clang++ -O2 extern: a+b+c  4,152 bytes + matrix.o  5,824; multiply in 0 of 3; printed 14.0 29.0 50.0
g++-16  -O0 plain:  a+b+c 37,008 bytes                  ; multiply in 3 of 3; printed 14.0 29.0 50.0
g++-16  -O0 extern: a+b+c  9,384 bytes + matrix.o 12,016; multiply in 0 of 3; printed 14.0 29.0 50.0
g++-16  -O2 plain:  a+b+c  5,064 bytes                  ; multiply in 0 of 3; printed 14.0 29.0 50.0
g++-16  -O2 extern: a+b+c  5,688 bytes + matrix.o  5,016; multiply in 0 of 3; printed 14.0 29.0 50.0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extern_session.py

Without optimisation the effect is large. With Clang the three files that use the matrix shrink from 59,352 to 19,104 bytes, and with GCC from 37,008 to 9,384, while matrix.o holds the single copy; none of the three defines multiply any more. With -O2 and Clang, 16,104 bytes become 4,152. With -O2 and GCC the result goes the other way, from 5,064 to 5,688 bytes plus matrix.o: GCC had inlined multiply into each caller, so there was no separate copy to remove, and the declaration stopped that inlining. Every build printed the same numbers. Step through the eight builds:

Interactive · step by step The matrix program built eight ways, without and with extern template Step through one compiler and optimisation level at a time. The left box is the plain build, the right one adds extern template and matrix.cpp.
The matrix program built eight ways, without and with extern template, step 6 of 6. Showing Clang -O0, plain (59,352 B, multiply in 3), Clang -O0, extern (19,104 B + matrix.o), Clang -O2, plain (16,104 B, multiply in 3), Clang -O2, extern (4,152 B + matrix.o), GCC 16 -O0, plain (37,008 B, multiply in 3), GCC 16 -O0, extern (9,384 B + matrix.o), GCC 16 -O2, plain (5,064 B, multiply in 0), GCC 16 -O2, extern (5,688 B + matrix.o).Clang -O0, plain59,352 B, multiply in 3Clang -O0, extern19,104 B + matrix.oClang -O2, plain16,104 B, multiply in 3Clang -O2, extern4,152 B + matrix.oGCC 16 -O0, plain37,008 B, multiply in 3GCC 16 -O0, extern9,384 B + matrix.oGCC 16 -O2, plain5,064 B, multiply in 0GCC 16 -O2, extern5,688 B + matrix.o

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Clang without optimisation. Plain, a.o, b.o and c.o take 59,352 bytes and each defines multiply. With extern template they take 19,104, none defines it, and matrix.o, 19,312 bytes, holds the one copy.
  2. Clang with -O2. Optimised code is smaller, and the saving stays: 16,104 bytes become 4,152, plus 5,824 for matrix.o, and again no user file defines multiply.
  3. GCC without optimisation. GCC 16 tells the same story at -O0: 37,008 bytes shrink to 9,384, and matrix.o, 12,016 bytes, compiles every member once.
  4. GCC with -O2 had no copy. In the plain -O2 build of GCC 16, no user file defines multiply at all: GCC inlined it into each caller, so 5,064 bytes hold no separate copy to remove.
  5. And extern made it larger. With extern template the three files may no longer instantiate multiply to inline it; they call the copy in matrix.o instead, and grow to 5,688 bytes, plus 5,016 for matrix.o.
  6. The same program every time. All eight builds printed 14.0 29.0 50.0. extern template saves the most where nothing is inlined, and can cost size and speed where the compiler would have inlined: measure release builds too.

Three rules come with extern template:

  1. Inline members are still instantiated. Members defined inside the class body are inline, and the standard lets the compiler instantiate them anyway so that it can inline them; only the non-inline members, such as multiply, defined outside the class without inline, are left to the definition. That also means extern template can make hot code slower, as GCC’s -O2 build shows: measure release builds too.
  2. The definition must exist. Every specialisation declared extern and used must be explicitly instantiated in some file of the program; otherwise the program is ill-formed, in practice with “undefined symbols” from the linker.
  3. Only for your own types in the standard library. A program may explicitly instantiate a standard library class template only when the arguments name a type the program defines: extern template class std::vector<Order>; is allowed, std::vector<double> is not.

extern template pays off for a few specialisations, used in many files, with large members: Matrix<double>, a Stats<int>, a parser for one character type. It is pointless for small inline helpers and harmful where inlining matters.

Checkpoint

Check: extern template

  1. Question 1 of 2 Matrix<double>::at is defined inside the class body. Does extern template class Matrix<double>; stop a.cpp from instantiating it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, because members defined in the class are inline, and the compiler may still instantiate them to inline them

    extern template removes the instantiation of non-inline members. That is why it pays off for large members defined outside the class, and why small inline helpers gain nothing from it.

  2. Question 2 of 2 Which of these may a program write?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: extern template class std::vector<Order>;, where Order is a type the program defines

    Explicit instantiation of the standard library's own templates is allowed only for the program's own types. For its own templates, such as Matrix<double>, a program decides freely.

Code that ignores the template parameters can be compiled once

Libraries split a template into the part users call and the part they should not touch, the second usually in a namespace called detail. The split pays off when the detail part does not depend on the template parameters: then it can be an ordinary function, declared in the header and defined once in a source file. This is called hoisting: Moving the code of a template that does not depend on its template parameters into an ordinary function, or a base class that is not a template, so that it is compiled once instead of once per instantiation.. Buffer<T, N> reports a full buffer with a message that uses neither T nor N except as numbers:

The error path inside the template, and hoisted into detail

C header (buffer_inline.h) · hoist/buffer_inline.h

#pragma once
#include <array>
#include <cstddef>
#include <stdexcept>
#include <string>

// Every member is a template member: report_full is compiled again for each Buffer<T, N>.
template <typename T, std::size_t N>
class Buffer {
public:
    void push(const T& value) {
        if (used_ == N) report_full(used_, N);
        items_[used_++] = value;
    }
    std::size_t size() const { return used_; }

private:
    [[noreturn]] static void report_full(std::size_t used, std::size_t capacity) {
        std::string message = "buffer full: ";
        message += std::to_string(used);
        message += " of ";
        message += std::to_string(capacity);
        message += " slots used; raise the capacity or drain the buffer first";
        throw std::length_error(message);
    }
    std::array<T, N> items_{};
    std::size_t used_ = 0;
};

C header (buffer_hoisted.h) · hoist/buffer_hoisted.h

#pragma once
#include <array>
#include <cstddef>

namespace detail {
// Does not depend on T or N, so it is an ordinary function, compiled once in buffer.cpp.
[[noreturn]] void report_full(std::size_t used, std::size_t capacity);
}

template <typename T, std::size_t N>
class Buffer {
public:
    void push(const T& value) {
        if (used_ == N) detail::report_full(used_, N);
        items_[used_++] = value;
    }
    std::size_t size() const { return used_; }

private:
    std::array<T, N> items_{};
    std::size_t used_ = 0;
};

C++ (buffer.cpp) · hoist/buffer.cpp

#include "buffer_hoisted.h"
#include <stdexcept>
#include <string>

namespace detail {
void report_full(std::size_t used, std::size_t capacity) {
    std::string message = "buffer full: ";
    message += std::to_string(used);
    message += " of ";
    message += std::to_string(capacity);
    message += " slots used; raise the capacity or drain the buffer first";
    throw std::length_error(message);
}
}

C++ (use.cpp) · hoist/use.cpp

#include <cstddef>
#include <utility>
#include BUFFER_HEADER

// Twenty different buffers: five element types times four capacities.
template <typename T, std::size_t N>
std::size_t fill() {
    Buffer<T, N> b;
    for (std::size_t i = 0; i < N; ++i) b.push(T{});
    return b.size();
}

template <typename T>
std::size_t fill_all() { return fill<T, 4>() + fill<T, 8>() + fill<T, 16>() + fill<T, 32>(); }

std::size_t use_all() {
    return fill_all<char>() + fill_all<short>() + fill_all<int>() + fill_all<long>() + fill_all<double>();
}
Twenty Buffer types, with and without hoisting Python · hoist_session.py
"""Compiles use.cpp, which makes twenty different Buffer<T, N>, once against each header, and buffer.cpp once.
Prints the size of each object file and how many copies of report_full each one defines."""

import os
import subprocess

env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
os.chdir("hoist")


def copies(obj: str) -> int:
    symbols = subprocess.run(["nm", "-C", "--defined-only", obj], capture_output=True, text=True).stdout
    return sum("report_full" in line for line in symbols.splitlines())


for compiler in ("clang++", "g++-16"):
    for level in ("-O0", "-O2"):
        flags = [compiler, "-std=c++23", level, "-c"]
        subprocess.run([*flags, '-DBUFFER_HEADER="buffer_inline.h"', "use.cpp", "-o", "inline.o"], check=True, env=env)
        subprocess.run([*flags, '-DBUFFER_HEADER="buffer_hoisted.h"', "use.cpp", "-o", "hoisted.o"], check=True, env=env)
        subprocess.run([*flags, "buffer.cpp", "-o", "buffer.o"], check=True, env=env)
        print(f"{compiler:<7} {level}: in the template {os.path.getsize('inline.o'):>6,} bytes ({copies('inline.o')} copies);"
              f" hoisted {os.path.getsize('hoisted.o'):>6,} bytes + buffer.o {os.path.getsize('buffer.o'):>6,} once")

Output

clang++ -O0: in the template 39,744 bytes (20 copies); hoisted 20,112 bytes + buffer.o  6,080 once
clang++ -O2: in the template  6,904 bytes (2 copies); hoisted    944 bytes + buffer.o  4,312 once
g++-16  -O0: in the template 55,392 bytes (20 copies); hoisted 23,904 bytes + buffer.o 17,088 once
g++-16  -O2: in the template 19,728 bytes (20 copies); hoisted  1,032 bytes + buffer.o  5,024 once

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hoist_session.py

Which members can move? Ask each one whether its code depends on T or N:

Interactive · stage by stage Which members of Buffer can be compiled once Step through the members of Buffer<T, N>. For each one, the question is whether its code depends on T or N; the last stage is what moving one member changed.
Which members of Buffer can be compiled once, step 5 of 5. Showing push(const T& value) (depends on T and N: stays), size() const (tiny and inline: stays), report_full(used, capacity) (depends on neither: hoist it), detail::report_full (compiled once, in buffer.cpp), The object files (20 copies became 1). In focus: The object files.push(const T& value)depends on T and N: stayssize() consttiny and inline: staysreport_full(used, capacity)depends on neither: hoist itdetail::report_fullcompiled once, in buffer.cppThe object files20 copies became 1

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. push needs T. push copies a T into std::array<T, N>, so its code differs for each element type and each capacity. It has to stay a template member, instantiated for every Buffer<T, N>.
  2. size is tiny. size() returns a std::size_t member. It is one line, defined in the class, so it is inlined wherever it is called; moving it would gain nothing.
  3. report_full uses only numbers. report_full builds a message from two numbers, how many slots are used and the capacity, and throws std::length_error. Neither T nor N appears in its code: push passes N in as a plain number.
  4. Hoisted into detail. buffer_hoisted.h declares detail::report_full(std::size_t, std::size_t), and buffer.cpp defines it once. push now calls it with used_ and N.
  5. Twenty copies become one. use.cpp makes twenty Buffer types. With the message built in the template, Clang -O0 gave use.o 20 copies of report_full in 39,744 bytes; hoisted, use.o took 20,112 bytes and buffer.o, 6,080, the one copy.

use.cpp creates twenty different buffers, five element types times four capacities. With the message built inside the template, the object file defines up to twenty copies of report_full, one per Buffer<T, N>. Hoisted into detail::report_full, it is compiled once in buffer.o, and use.o shrinks from 39,744 to 20,112 bytes with Clang without optimisation, and from 19,728 to 1,032 bytes with GCC at -O2. The same idea scales: a container template can keep its memory management in a non-template base class that works with sizes in bytes, and only the typed interface on top is instantiated per type.

Smaller habits help as well: include only the headers a file needs, declare types instead of including their headers where a pointer or a reference is enough, and keep rarely used templates out of widely included headers. C++20 modules, covered in a later module, and precompiled headers attack the same cost from the other side, by parsing headers once.

Key idea

Every instantiation is compiled on its own, so the code a template does not need to repeat, the parts that ignore its parameters, belongs in ordinary functions compiled once.

Checkpoint

Check: Hoisting code out of templates

  1. Question 1 of 2 Why can detail::report_full be compiled once instead of once per Buffer<T, N>?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its code uses neither T nor N, only two numbers passed in as arguments

    Hoisting moves the part of a template that does not depend on its parameters into a function compiled once. The typed interface stays a template; the shared work does not.

  2. Question 2 of 2 A program uses Buffer<T, N> with 3 element types and 5 capacities, every combination. With the message built inside the template, up to how many copies of report_full can its object file define?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 15 copies

    Each Buffer<T, N> is a separate class, and report_full is a member of each: 3 × 5 = 15 instantiations, as 5 × 4 = 20 gave 20 copies in use.o. Hoisted into detail, there is one.

A long template error needs three facts

A template error is long because the compiler shows the instantiation backtrace: The notes under a template error that list the instantiations which led to it, from the failing line inside a template back to the line of your own code that asked for it. -ftemplate-backtrace-limit sets how many are shown. that led to it. Here a function copies a vector whose elements hold a std::unique_ptr, which cannot be copied:

copy_owners.cpp C++ · copy_owners.cpp
// A snapshot of orders, copied into a new vector. Order holds a std::unique_ptr, which cannot be copied.
#include <memory>
#include <string>
#include <vector>

struct Order {
    std::string id;
    std::unique_ptr<std::string> note;
};

std::vector<Order> snapshot(const std::vector<Order>& orders) {
    return orders;   // copies the vector, and with it every Order
}

int main() {
    std::vector<Order> orders;
    orders.push_back({"A-1", nullptr});
    return static_cast<int>(snapshot(orders).size());
}
The size of the error, and the three lines that matter Python · errors_session.py
from session import run

# Size of each error, then only the lines that name this file, and the cause.
run("""
clang++ -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp"
clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -o "call to implicitly-deleted copy constructor of '[A-Za-z]*'"
g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp" | cut -c1-150
""")

Output

$ clang++ -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
30 lines, 1 error
$ clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp"
copy_owners.cpp:12:12: note: in instantiation of member function 'std::vector<Order>::vector' requested here
$ clang++ -std=c++23 -c copy_owners.cpp 2>&1 | grep -o "call to implicitly-deleted copy constructor of '[A-Za-z]*'"
call to implicitly-deleted copy constructor of 'Order'
$ g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
106 lines, 3 errors
$ g++-16 -std=c++23 -c copy_owners.cpp 2>&1 | grep -E "^copy_owners.cpp" | cut -c1-150
copy_owners.cpp:12:12:   required from here
copy_owners.cpp:6:8: note: 'Order::Order(const Order&)' is implicitly deleted because the default definition would be ill-formed:
copy_owners.cpp:6:8: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = std::__cxx11:
copy_owners.cpp:6:8: note: use '-fdiagnostics-all-candidates' to display considered candidates

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 errors_session.py

Clang printed 30 lines and GCC 16 printed 106, but three facts are enough to fix it:

  1. What failed. The first error: line says it: constructing an Order inside the library’s copy code, __construct_at in libc++ and construct_at in libstdc++.
  2. Where in your code. Among the notes, find the first line that names your own file: copy_owners.cpp:12:12, the return orders; that copies the vector. Clang marks it “requested here” and GCC “required from here”.
  3. Why. Clang’s last note says “call to implicitly-deleted copy constructor of ‘Order’”. GCC goes one step further, to line 6, the struct: its copy constructor is deleted because the std::unique_ptr member cannot be copied.

Step through the same three facts in the order you read them:

Interactive · stage by stage Reading a long template error in three steps Step through the error of copy_owners.cpp, from the first line to the cause. Three facts are enough; the rest of the lines lead from one to the next.
Reading a long template error in three steps, step 5 of 5. Showing 1. What failed (constructing an Order), 2. Where in your code (copy_owners.cpp:12:12), 3. Why (Order cannot be copied), The fix (ids, a move or a deep copy). In focus: The fix.1. What failedconstructing an Order2. Where in your codecopy_owners.cpp:12:123. WhyOrder cannot be copiedThe fixids, a move or a deep copy

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A long message. Copying a std::vector<Order> fails because Order holds a std::unique_ptr. Clang printed 30 lines with one error and GCC 16 106 lines with three, mostly frames inside the library.
  2. What failed. The first error: line says what failed: constructing an Order inside the library's copy code, __construct_at in libc++ and construct_at in libstdc++. It names library code, not yours.
  3. Where in your code. Among the notes, the first line that names your own file is copy_owners.cpp:12:12, the return orders; that copies the vector. Clang marks it requested here, GCC required from here.
  4. Why. Clang's last note says call to implicitly-deleted copy constructor of 'Order'. GCC goes one step further, to line 6: the copy constructor is deleted because the std::unique_ptr member cannot be copied.
  5. Decide the fix. The error says what is impossible; the fix is a choice: return the ids instead of the orders, move the vector when the caller no longer needs it, or give Order a copy constructor that copies the note.

The fix belongs to the design, not the error: return the ids instead of the orders, move the vector when the caller no longer needs it, or give Order a copy constructor that copies the note. A struct with a move-only type: A type whose objects can be moved but not copied, because its copy constructor is deleted, such as std::unique_ptr. Its objects cannot come out of a std::initializer_list, whose elements can only be copied. More in “Brace initialisation and initializer_list traps” member is move-only itself. Two options shorten such errors: -ftemplate-backtrace-limit=n sets how many instantiation notes appear (10 by default in both compilers), and on GCC 16 -fno-diagnostics-show-nesting prints the flat layout of earlier releases instead of the new tree.

GCC 16 explains failed type traits

A constraint written with a standard trait used to fail with only “evaluated to false”. GCC 16 now explains why the trait is false:

trait_constraint.cpp C++ · trait_constraint.cpp
// A callback that must accept an int, given a lambda that takes a string.
#include <string>
#include <type_traits>

template <typename F>
    requires std::is_invocable_v<F, int>
void call_with_int(F f) { f(1); }

int main() {
    call_with_int([](const std::string& s) { return s.size(); });
}
Why is the lambda not invocable with an int? Python · trait_session.py
from session import run

run("""
clang++ -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "note: because"
g++-16 -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "is not invocable|no known conversion"
""")

Output

$ clang++ -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "note: because"
trait_constraint.cpp:6:14: note: because 'std::is_invocable_v<(lambda at trait_constraint.cpp:10:19), int>' evaluated to false
$ g++-16 -std=c++23 -c trait_constraint.cpp 2>&1 | grep -E "is not invocable|no known conversion"
          • 'main()::<lambda(const std::string&)>' is not invocable by 'int', because
                • no known conversion for argument 1 from 'int' to 'const std::string&' {aka 'const std::__cxx11::basic_string<char>&'}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trait_session.py

Apple clang 21 reports that std::is_invocable_v<…, int> “evaluated to false” and stops there. GCC 16 adds the reason: the lambda “is not invocable by ‘int’, because” there is “no known conversion for argument 1 from ‘int’ to ‘const std::string&’”. Its release notes describe the change for traits such as is_constructible_v and is_invocable_v. When an error on one compiler is unclear, building the same file with the other is often the quickest explanation.

Checkpoint

Check: Reading a long template error

  1. Question 1 of 2 In the error of copy_owners.cpp, which line tells you where in your own code the problem starts?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first line that names your own file, copy_owners.cpp:12:12

    A template error is a chain from your code into the library. The frame to look for is the first one in a file you own, because that is the line you can change.

  2. Question 2 of 2 What is the cause of the copy_owners.cpp error, as the last notes give it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Order's copy constructor is implicitly deleted, because its std::unique_ptr member cannot be copied

    A struct with a move-only member is move-only itself. Copying a vector of them is impossible, so the fix is a choice: return something else, move the vector, or write a copy constructor that copies what the pointer owns.

Practise: cut the build

slowbuild is a small project: stats.h defines a Stats<T> class template, four source files each use Stats<double> and Stats<int>, and main.cpp prints their reports. check.py builds every .cpp file in the folder as a debug build, runs the program and reports what it measures:

The project and its sample check

C header · slowbuild/stats.h

// A small statistics class template, used with double and int by four source files.
#pragma once
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <stdexcept>
#include <string>
#include <vector>

template <typename T>
class Stats {
public:
    void add(T value) { values_.push_back(value); }
    std::size_t count() const { return values_.size(); }

    double mean() const;
    double stddev() const;
    T percentile(double p) const;
    std::string summary(const std::string& name) const;

private:
    std::vector<T> values_;
};

template <typename T>
double Stats<T>::mean() const {
    if (values_.empty()) throw std::domain_error("mean of no values");
    double sum = 0;
    for (T v : values_) sum += static_cast<double>(v);
    return sum / static_cast<double>(values_.size());
}

template <typename T>
double Stats<T>::stddev() const {
    double m = mean();
    double squares = 0;
    for (T v : values_) squares += (static_cast<double>(v) - m) * (static_cast<double>(v) - m);
    return std::sqrt(squares / static_cast<double>(values_.size()));
}

template <typename T>
T Stats<T>::percentile(double p) const {
    if (values_.empty()) throw std::domain_error("percentile of no values");
    if (p < 0 || p > 100) throw std::out_of_range("percentile outside 0 to 100");
    std::vector<T> sorted = values_;
    std::sort(sorted.begin(), sorted.end());
    auto rank = static_cast<std::size_t>(std::ceil(p / 100.0 * static_cast<double>(sorted.size())));
    return sorted[rank == 0 ? 0 : rank - 1];
}

template <typename T>
std::string Stats<T>::summary(const std::string& name) const {
    std::string out = name + ": " + std::to_string(count()) + " values, mean " + std::to_string(mean());
    out += ", standard deviation " + std::to_string(stddev());
    out += ", median " + std::to_string(percentile(50)) + ", 90th percentile " + std::to_string(percentile(90));
    return out;
}

C++ (latency.cpp) · slowbuild/latency.cpp

#include "stats.h"
std::string latency_report() {
    Stats<double> ms;
    for (double v : {12.5, 8.0, 15.25, 9.75, 30.0, 11.0}) ms.add(v);
    Stats<int> hops;
    for (int v : {3, 4, 3, 5, 4}) hops.add(v);
    return ms.summary("latency in ms") + "\n" + hops.summary("network hops");
}

C++ (main.cpp) · slowbuild/main.cpp

// Prints the four reports, one line per Stats object.
#include <cstdio>
#include <string>

std::string latency_report();
std::string sizes_report();
std::string retries_report();
std::string scores_report();

int main() {
    for (const std::string& report : {latency_report(), sizes_report(), retries_report(), scores_report()})
        std::puts(report.c_str());
}

Python · slowbuild/check.py

"""The exercise's sample check. Builds every .cpp file of this folder with Clang without optimisation, as a debug
build does, links and runs the program, and reports what the exercise measures: how many object files compile each
member of Stats<double> and Stats<int>, and the total size of the object files."""

import glob
import os
import subprocess

env = dict(os.environ)
env["PATH"] = os.pathsep.join([env.get("PATH", ""), "/opt/homebrew/bin", "/usr/local/bin"])
EXPECTED_LINES = 8
GOAL_BYTES = 200_000

sources = sorted(glob.glob("*.cpp"))
objects = []
for src in sources:
    obj = src[:-4] + ".o"
    subprocess.run(["clang++", "-std=c++23", "-O0", "-c", src, "-o", obj], check=True, env=env)
    objects.append(obj)
subprocess.run(["clang++", *objects, "-o", "report"], check=True, env=env)
output = subprocess.run(["./report"], capture_output=True, text=True, check=True).stdout
print(f"{len(sources)} source files built; the program printed {len(output.splitlines())} of {EXPECTED_LINES} lines")

compiled_in = {}
for obj in objects:
    symbols = subprocess.run(["nm", "-C", "--defined-only", obj], capture_output=True, text=True, env=env).stdout
    for t in ("double", "int"):
        for member in ("mean", "stddev", "percentile", "summary"):
            name = f"Stats<{t}>::{member}"
            if any(name + "(" in line for line in symbols.splitlines()):
                compiled_in.setdefault(name, []).append(obj)
for name, where in compiled_in.items():
    print(f"{name}: compiled in {len(where)} object file{'' if len(where) == 1 else 's'} (goal: 1)")
total = sum(os.path.getsize(o) for o in objects)
print(f"object files: {total:,} bytes in total (goal: under {GOAL_BYTES:,})")
The sample check on the project as given Python · exercise_session.py
from session import run

# The sample check, run on the project as given.
run("""
cd slowbuild && python3 check.py
""")

Output

$ cd slowbuild && python3 check.py
5 source files built; the program printed 8 of 8 lines
Stats<double>::mean: compiled in 4 object files (goal: 1)
Stats<double>::stddev: compiled in 4 object files (goal: 1)
Stats<double>::percentile: compiled in 4 object files (goal: 1)
Stats<double>::summary: compiled in 4 object files (goal: 1)
Stats<int>::mean: compiled in 4 object files (goal: 1)
Stats<int>::stddev: compiled in 4 object files (goal: 1)
Stats<int>::percentile: compiled in 4 object files (goal: 1)
Stats<int>::summary: compiled in 4 object files (goal: 1)
object files: 350,344 bytes in total (goal: under 200,000)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exercise_session.py

Every member of both specialisations is compiled in four object files, and the objects add up to 350,344 bytes. Make the check report one object file for every member and a total under 200,000 bytes, while the program still prints its eight lines. Two changes do it: extern template declarations at the end of stats.h for Stats<double> and Stats<int>, and a new stats.cpp with the two explicit instantiation definitions (the check builds every .cpp file, so it picks the new one up). Then change -O0 to -O2 in check.py and see whether the saving holds, and add a file that uses Stats<float>: which object file compiles that one?

Then test what you can predict before building: how sizes grow, what the linker keeps, which part of a compile grows with optimisation, what extern template covers, and where an error starts. The session begins with whichever of these ideas has cost you most answers, and explains every choice against the measurements above:

Practice that adapts to you

Practise this lesson

9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 Item<0> to Item<39> are identical except for their names. Why is the object file for 40 of them about 32 times larger than for one?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: Each Item type is a distinct type, so std::sort and the rest are instantiated and compiled again for each one

    Templates are instantiated per set of template arguments, and two types with different names are different arguments even when their contents are the same. Every instantiation is compiled and optimised on its own.

  2. Question 2 of 9 a.o, b.o and c.o each define Matrix<double>::multiply. Why does linking them not fail with a duplicate symbol?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: The compiler marks template instantiations as weak definitions, and the linker keeps one of them

    nm showed each copy as a weak external definition. The one-definition rule allows a template's definition in every translation unit, as long as they are identical, and the linker merges them into one copy in the program.

  3. Question 3 of 9 Which pieces of work does Clang's -ftime-trace record as events by default?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: Those that take longer than 500 microseconds, besides totals for each kind of work

    -ftime-trace writes a JSON file in the Chrome tracing format. Its events and totals are what a viewer shows as a timeline, and what trace_summary.py turns into numbers.

  4. Question 4 of 9 A teammate argues that fewer instantiations only help debug builds, since instantiation is a smaller share of an -O2 compile. What do the traces say?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Fewer instantiations help -O2 too, because every instantiated function is also optimised and compiled to code in the back end

    Look at both the share and what follows from it. A template instantiated once less is parsed, checked, optimised and turned into machine code once less.

  5. Question 5 of 9 Which two lines together make Matrix<double> compiled in one place only?

    Choose every answer that is right.

    Show the answer: Question 5 of 9

    Answer:

    • extern template class Matrix<double>; in matrix.h
    • template class Matrix<double>; in exactly one .cpp file

    The explicit instantiation declaration stops every file that includes the header from instantiating the non-inline members, and the explicit instantiation definition compiles them once. template <> declares an explicit specialisation, a different definition, which is something else entirely.

  6. Question 6 of 9 With GCC at -O2, adding extern template made a.o, b.o and c.o larger. Why?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: GCC had inlined multiply into each caller, and extern template stopped it from instantiating the body to inline

    Without the declaration there was no separate copy of multiply in any of the three files: GCC had inlined it. With the declaration, the non-inline member may not be instantiated there, so each file calls the copy in matrix.o. extern template saves the most in unoptimised builds; measure release builds too.

  7. Question 7 of 9 Which member of a class template is the best candidate for hoisting into an ordinary function in a detail namespace?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: An error-reporting function that uses the size and capacity only as numbers

    Code that does not depend on T or N can be compiled once and shared by every instantiation. The other three use T itself, so they have to be instantiated for each element type.

  8. Question 8 of 9 Put the three steps for reading a long template error in order.

    Give each item its position, from 1 (first).

    Show the answer: Question 8 of 9

    Answer:

    1. Read the first error line for what failed
    2. Find the first line that names your own file, for where
    3. Read the last notes for why, such as a deleted copy constructor

    The first error says which operation failed deep in the library, the first frame in your own code says which of your lines triggered it, and the notes at the end usually name the cause, here the implicitly deleted copy constructor of Order.

  9. Question 9 of 9 call_with_int requires std::is_invocable_v<F, int>, and it is called with a lambda that takes a const std::string&. Which compiler said why the trait is false?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: GCC 16, which added that there is no known conversion from int to const std::string&

    When an error on one compiler is unclear, building the same file with the other is often the quickest explanation.

Interview questions

Fresher · What does extern template class Matrix<double>; tell the compiler?

It is an explicit instantiation declaration: it says that Matrix<double> is instantiated in some other translation unit, so this one must not instantiate its members itself and should call the definitions found elsewhere. The other half is an explicit instantiation definition, template class Matrix<double>;, in exactly one source file, which compiles every member there. Together they replace many identical instantiations, one per file that uses the type, with one. A good answer adds the limits: members defined inside the class are inline and may still be instantiated for inlining, a declared specialisation that nobody defines ends in an undefined-symbol link error, and the saving should be measured, since it is largest in unoptimised builds and can even reverse when the compiler would have inlined the code.

Follow-ups usually ask what the declaration covers and what it does not. Decide these two:

Checkpoint

Interview round: two follow-up questions

  1. Question 1 of 2 What is the difference between template class Matrix<double>; and template <> class Matrix<double> { … };?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first compiles the members of the primary template for double; the second defines a different class for double

    extern template and template class come as a pair, about the one definition the template already has. template <> is about giving one argument list a different definition.

  2. Question 2 of 2 matrix.h declares extern template class Matrix<double>; and matrix.cpp defines it. A new source file uses Matrix<float>. Which object file compiles the members of Matrix<float>?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The new file's own object file, as an ordinary implicit instantiation

    extern template is per specialisation. Each argument list that should be compiled once needs its own pair of an extern declaration and an explicit instantiation definition.

Two harder questions take build cost into a real service: a fourteen-minute build to cut, and an extern template change that broke a test build and slowed a benchmark:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Senior to staff

    A service takes 14 minutes to build. Clang's -ftime-trace shows that in 300 source files about 40 % of the front-end time goes to instantiating one JSON library's templates. Plan the fix: what you measure, what you change first, and how you prove it helped.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

Key takeaways

  • Each distinct instantiation is compiled separately: 40 types that differ only in their names made the object file about 32 times larger with Clang, about 4,190 bytes for every extra type.
  • Every file that uses an instantiation compiles it again, and weak definitions let the linker keep one copy, so three files compiled three copies of multiply that became one in the program.
  • Measure before changing anything: Clang’s -ftime-trace splits a compile into front end, back end and template instantiation, GCC’s -ftime-report prints its phases, and with -O2 optimising each instantiation takes most of the time.
  • extern template in the header plus one explicit instantiation in a source file compiles a common specialisation once, as 59,352 bytes became 19,104 in the matrix example, but inline members are still instantiated, the definition must exist, and GCC’s -O2 build got larger.
  • Code that does not depend on the template parameters belongs in ordinary functions in a detail namespace, compiled once: twenty copies of an error path became one.
  • A long template error is read in three steps, the first error: line for what failed, the first line in your own file for where and the last notes for why, and GCC 16 also explains failed standard traits.

Terms in this lesson

back end (of a compiler)
The part of a compiler that optimises the code the front end produced and generates machine code for it, function by function, so its work grows with every instantiated function, most of all with optimisation on.
explicit instantiation declaration
A line such as extern template class Matrix<double>; that tells a translation unit not to instantiate the non-inline members of that specialisation itself, because an explicit instantiation definition in another file of the program provides them.
front end (of a compiler)
The part of a compiler that reads the source: it preprocesses and parses the code, checks it and instantiates templates. The back end then optimises the result and generates machine code.
hoisting
Moving the code of a template that does not depend on its template parameters into an ordinary function, or a base class that is not a template, so that it is compiled once instead of once per instantiation.
instantiation backtrace
The notes under a template error that list the instantiations which led to it, from the failing line inside a template back to the line of your own code that asked for it. -ftemplate-backtrace-limit sets how many are shown.
weak definition
A definition that the linker may meet in several object files and keep once, without a duplicate-symbol error. Compilers emit template instantiations and inline functions this way; nm -m shows them as weak external.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.