Linking in C & C++

04 April 2024

Do link errors haunt your dreams? Why do we need header files? Here's an explainer for something that beginners often skip over.

Every C & C++ beginner's tutorial will step you through source and header files, .c and .h respectively for C, and... well take your pick for C++: .cpp and .hpp, .cxx and .hxx, or even .cc and .hh.

One of the build errors you'll encounter with these types of languages is the link error. Something like undefined reference to .... This tends to confound developers, who roughly understand this is due to some mistake with the compile options or build configuration.

That missing link (pardon the pun) is the linker. I'd say that's due to a common gap in these beginner tutorials, particularly in explaining the role of the linker. Understanding linking also explains the need for duplicating function and class signatures in both source and header files.

So if you've found yourself confused and frustrated over these errors, read on to master the linker once and for all.

Building an executable

The common understanding is that the build process for a compiled language goes something like this:

 A graph showing several .cpp files entering a compiler, and an executable coming out.

Which is true, but as explained above, for C & C++ it's missing the key step of linking. The reality for a simple executable is a bit more like this:

The compiler doesn't produce the executables itself: it relies on the linker as a secondary application to do so. Those .o files the compiler produces are called object files. I'll explain what those are shortly.

There are several types of objects files. You've probably come across at least the last one before:

These each serve slightly different purposes. The key difference is that the first two, object files and static libraries are build-time dependencies. i.e., once the executable has been built, they are no longer needed. You can remove them and the executable will still run.

Shared libraries are run-time dependencies of the executable. i.e., once the executable has been built and linked to these libraries, they must (or sometimes less strongly, should) be available for the executable to run correctly.

Git repository

To dig in to how this all works, I've set up a very simple toy C++ project.

You can find it in this repository. I'll link to specific commits as we go along, but for convenience, some files and diffs will be embedded into this article.

Objects

See commit 5ec3b5f2 Initial example (diff, tree)

All object files share the same purpose: they provide symbols. Generally, that includes:

A particularly useful tool for digging into symbols in object files is nm, which is provided by GNU binutils.

Here's the results of using it against a couple of object files generated from a very simple C++ source file.

main.cpp
1extern "C" {
2
3int square(int num) {
4 return num * num;
5}
6
7int operand = 5;
8
9}
10
11int main() {
12 return square(operand);
13}

The extern "C" block tells the compiler to use C's rules for linking code within that block. I'll explain what those differences are later on. You'll often see C or C++ headers with the following pattern:

#ifdef __cplusplus
extern "C" {
#endif

// function declarations and such

#ifdef __cplusplus
} // extern "C"
#endif

The extern "C" is conditionally wrapped around the declarations when using a C++ compiler, so C linking rules are always used. That's important if you're linking a C++ application to a C library.

Let's run nm against the generated object file:

main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
000000000000000f T main
0000000000000000 D operand
0000000000000000 T square

For each symbol, nm outputs

<address> <type> <symbol name>

address is the address in ??

type indicates both the purpose of the symbol (e.g. function, data), and which section it exists in the object.

symbol name is, unsurprisingly, the name of the symbol. Notice how for our functions operand and main, the symbol name is simply the name of the function.

So we've got an entry for the two functions in main.cpp of type T, and the variable with type d. But what exactly do the types mean?

Object layout and symbol types

The nm manual page explains the types of objects in

"D"
"d" The symbol is in the initialized data section.

...

"T"
"t" The symbol is in the text (code) section.

So T for functions because they live in the text section, and D for variables because they live in the data section.

As this suggests, an object file is split up into different sections for different types of symbols. Object formats vary by platform, GNU/Linux uses the Executable and Linking Format (ELF) originally from Unix System V1. Checking the object files with file shows:

$ file ./build/CMakeFiles/example.dir/main.cpp.o
./build/CMakeFiles/example.dir/main.cpp.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

Note ELF prefixes section names with a period, so in that context, you'll usually see them referred to as .text and .data. The sections are just two among many in an ELF object, but they contain most of the symbols the average developer is interested in. Some other notable ones include:

It probably won't surprise you that read-only data contains const global variables, i.e. those which exist for the lifetime of the program and are immutable. As a quick demo, I'll make operand const:

See commit edc20aec Make operand const (diff, tree)

You'll see that operand now has type r, for read-only data:

main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
000000000000000f T main
0000000000000000 T square
0000000000000000 r operand

Executables

Use nm on an executable and you'll see a lot more symbols which you likely won't recognise. Highlighted alongside these are our symbols from main.cpp.o:

executable symbols
$ nm --demangle ./build/example
0000000000004010 B __bss_start
                 w __cxa_finalize@GLIBC_2.2.5
0000000000004000 D __data_start
0000000000004000 W data_start
0000000000004008 D __dso_handle
0000000000003df0 d _DYNAMIC
0000000000004010 D _edata
0000000000004018 B _end
000000000000113c T _fini
0000000000003fe8 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
0000000000002008 r __GNU_EH_FRAME_HDR
0000000000001000 T _init
0000000000002000 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 U __libc_start_main@GLIBC_2.34
0000000000001128 T main
0000000000001119 T square
0000000000001020 T _start
0000000000004010 D __TMC_END__
0000000000002004 r operand

You don't have to worry about the other symbols, they're prefixed with underscores to indicate they're private, internal implementation details. Most of these are internal to the GNU Compiler Collection (GCC), the compiler suite with which this executable was compiled.

Definitions in header files

Let's imagine square and operand are common parts of our program which will be reused in other source files. A common pattern for C & C++ programs to share code is using header files.

So we simply copy the definitions into a header file, right? Let's give that a shot:

See commit 58e3d1a0 Move common code into a header file (diff, tree)

library.hpp
1#pragma once
2
3extern "C" {
4
5int square(int num) {
6 return num * num;
7}
8
9int operand = 5;
10
11} // extern "C"

After doing so, our executable still builds and executes successfully:

build log
$ cmake -B build
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/william/scratch/public/linking-by-example/build
$ cmake --build build
[1/2] Building CXX object CMakeFiles/example.dir/main.cpp.o
[2/2] Linking CXX executable example

And the symbols are identical to before:

executable symbols
$ nm --demangle ./build/example
0000000000004010 B __bss_start
                 w __cxa_finalize@GLIBC_2.2.5
0000000000004000 D __data_start
0000000000004000 W data_start
0000000000004008 D __dso_handle
0000000000003df0 d _DYNAMIC
0000000000004010 D _edata
0000000000004018 B _end
000000000000113c T _fini
0000000000003fe8 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
0000000000002008 r __GNU_EH_FRAME_HDR
0000000000001000 T _init
0000000000002000 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 U __libc_start_main@GLIBC_2.34
0000000000001128 T main
0000000000001119 T square
0000000000001020 T _start
0000000000004010 D __TMC_END__
0000000000002004 r operand

So job done, right? Not quite. Let's see what happens when we add another source file to our project.

The One Definition Rule

See commit 3eab681e Add another source file (diff, tree)

This file also wants to reuse square, so it includes the header file which defines the function.

library.cpp
1#include "library.hpp"
2
3int square_10() {
4 return square(10);
5}

Add this source file to the project and try compiling again, you'll hit a build error:

build log
$ cmake -B build
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/william/scratch/public/linking-by-example/build
$ cmake --build build
[1/3] Building CXX object CMakeFiles/example.dir/main.cpp.o
[2/3] Building CXX object CMakeFiles/example.dir/library.cpp.o
[3/3] Linking CXX executable example
FAILED: example 
: && /usr/bin/c++   CMakeFiles/example.dir/library.cpp.o CMakeFiles/example.dir/main.cpp.o -o example   && :
/usr/bin/ld: CMakeFiles/example.dir/main.cpp.o: in function `square':
main.cpp:(.text+0x0): multiple definition of `square'; CMakeFiles/example.dir/library.cpp.o:library.cpp:(.text+0x0): first defined here
/usr/bin/ld: CMakeFiles/example.dir/main.cpp.o:(.data+0x0): multiple definition of `operand'; CMakeFiles/example.dir/library.cpp.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status
ninja: build stopped: subcommand failed.

Now it's complaining about multiple definition of both our function square and our variable operand. Notice the ld returned 1 exit status, indicating that this error is actually produced by ld, which is GCC's linker.

So, the linker isn't happy that these symbols are defined multiple times. This is the linker's default behaviour, because the linker doesn't know what to make of the ambiguity.

This is called the One Definition Rule (ODR) in C & C++2. C++'s rule is slightly different, because it has additional language features which affect linkage.

If we look into the object files you'll find that both main.cpp.o and library.cpp.o contain the symbols square and operand:

main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
000000000000000f T main
0000000000000000 D operand
0000000000000000 T square
library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 D operand
0000000000000000 T square
000000000000000f T square_10()

So the compiler has generated the objects for each source file correctly, but linker gives up when trying to join these two object files together into the executable.

While in this particular case, we know that both of these symbols were generated from the same code. In the case of square, we know the symbols are compatible. But things get a bit messier with operand, which at this point is once again a mutable global variable.

But what if we or some third-party code had defined its own symbols also called square and operand? The linker has no way to determine that these and our own versions are different and probably incompatible. So the linker gives up, and informs you of the multiple definitions, leaving you to diagnose the issue.

Remember, #include is an instruction to include the full text of another file at that line, also including any transient #includes in that file. So rather than just the source file' contents that is compiled, it's the source file's contents, plus any included files. This combination is generated by the preprocessor, the part of the compiler which handles all the # instructions in C & C++ code. The output of the preprocessor is called a translation unit, and is then passed on to the compiler proper to generate code.

If we visualise the preprocessor as its own separate stage, our project currently looks like this:

Those .i files are the translation units in between.

While you can explicitly ask the compiler to run just the preprocessing stage, preprocessing and compilation normally occurs in a single step.

The static specifier

So, each object has created its own copy of operand and square. How can we appease the linker?

An easy workaround with C linkage is adding the static keyword to the definitions.

See commit 29ebb7ab Workaround duplicate symbols with static (diff, tree)

library.hpp
1#pragma once
2
3extern "C" {
4
5static int square(int num) {
6 return num * num;
7}
8
9static int operand = 5;
10
11} // extern "C"

Now the executable is successfully compiled:

build log
$ cmake -B build
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/william/scratch/public/linking-by-example/build
$ cmake --build build
[1/3] Building CXX object CMakeFiles/example.dir/library.cpp.o
[2/3] Building CXX object CMakeFiles/example.dir/main.cpp.o
[3/3] Linking CXX executable example

Investigating the object files again, you'll find the symbols are still duplicated between library.cpp.o and main.cpp.o, but there's a subtle change in square and operand: the T and D are now respectively lowercased.

main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
000000000000000f T main
0000000000000000 t square
0000000000000000 d operand
library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 t square
000000000000000f T square_10()
0000000000000000 d operand

nm explains the distinction thus:

If lowercase, the symbol is usually local; if uppercase, the symbol is global (external).

A local/internal symbol in one object won't be linked across other objects. Conversely, a global/external symbol will be linked across objects.

Also, notice that square and operand are both duplicated in the final executable:

executable symbols
$ nm --demangle ./build/example
0000000000001147 T main
0000000000001119 t square
0000000000001138 t square
0000000000004010 d operand
0000000000004014 d operand

Thinking about our particular use case - a function and variable in a header - it's worth considering whether static gives you desirable behaviour.

square is compiled for each translation unit which includes library.hpp. That can inflate build times due to the redundant compilation. It's not a big deal with just these two symbols here, but imagine a mature codebase where lots of code is defined as static functions in headers. Similarly, the duplicate symbols can inflate the size of your executable.

Things are more complicated for operand, which is a mutable global. With a copy in each translation unit, if your program depends on mutating that global, duplicates can cause surprising behaviour.

The inline specifier

An alternative to static is the inline keyword, which can serve a similar purpose, but affects how everything is linked together at the end.

inline was originally intended for telling the compiler you want a function's code to be inserted wherever it is called, avoiding the overhead of a regular function call. In order to be inlined, a function's definition must be available in every translation unit that calls the function, so definitions are stored in headers. To prevent this causing duplicate symbol errors, a function defined with inline has different link rules.

See commit 93725c2f Make header symbols inline (diff, tree)

library.hpp
1#pragma once
2
3extern "C" {
4
5inline int square(int num) {
6 return num * num;
7}
8
9inline int operand = 5;
10
11} // extern "C"
main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
0000000000000000 T main
0000000000000000 u operand
0000000000000000 W square
library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 W square
0000000000000000 T square_10()
executable symbols
$ nm --demangle ./build/example
0000000000001138 T main
0000000000004010 u operand
0000000000001129 W square

There's only one copy of the symbols in the final executable, and square is now a global weak symbol, while operand is a global unique symbol.

Here are the explanations from nm:

"u" The symbol is a unique global symbol. This is a GNU extension to the standard set of ELF symbol bindings. For such a symbol the dynamic linker will make sure that in the entire process there is just one symbol with this name and type in use.

...

"W" "w" The symbol is a weak symbol that has not been specifically tagged as a weak object symbol. When a weak defined symbol is linked with a normal defined symbol, the normal defined symbol is used with no error. When a weak undefined symbol is linked and the symbol is not defined, the value of the symbol is determined in a system-specific manner without error. On some systems, uppercase indicates that a default value has been specified.

So a weak symbol is a kind of soft dependency, and linking won't fail if it's not present. Also, a symbol declared weak can be overridden by a strong symbol with the same name. Implicit here is that weak symbols don't collide with one another, and the linker will pick just one definition if there are several.

In our case, the weak symbols are capitalised, meaning a default definition has been provided. So the end result is the same for both symbols: the linker picks one definition of each, and there's only one copy in the final executable.

The general suggestion in C++ is that any functions defined in headers should be marked inline, to avoid running afoul of the One Definition Rule. C++ has a few more options, which may be more appropriate in certain contexts.

C++'s constexpr and consteval

Newer versions of C++ add a few extra keywords which avoid creating multiple definitions.

constexpr3, added in C++ 17, declares that a function or variable may be evaluated at compile-time.

consteval4, added in C++ 23, declares that a function or variable must be evaluated at compile-time.

Both of these imply inline, so you can use them in place of inline in headers. constexpr and consteval allow the compiler to eliminate some of your code by evaluating it at compile time, trading build time for runtime performance.

A recommendation in C++ is to constexpr everything that you can. Consider wisely what you consteval, because not everything needs to be evaluated at compile-time.

There are other cases where inline is implicit, including:

Sharing definitions with other source files

Another common method for following the One Definition Rule is by moving definitions into source files, and marking declarations with extern. This keyword indicates that a symbol has external linkage, so it's the opposite of static, and these specifiers are mutually exclusive. extern allows you to declare symbols which are defined in other translation units, leaving the linker to link references to those symbols later. Note that function declarations are implicitly extern.

Let's make square and operand extern, and move their definitions into library.cpp:

See commit 73d3221f Move definitions to source file (diff, tree)

library.hpp
1#pragma once
2
3extern "C" {
4
5int square(int num);
6
7extern int operand;
8
9} // extern "C"
library.cpp
1#include "library.hpp"
2
3extern "C" {
4
5int square(int num) {
6 return num * num;
7}
8
9int operand = 5;
10
11} // extern "C"
12
13int square_10() {
14 return square(10);
15}

Looking into the objects that are created, we can see a new type of symbol has appeared in main.cpp.o:

main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
0000000000000000 T main
                 U operand
                 U square

Both operand and square are undefined symbols. These are essentially placeholders for the linker to resolve during linking. Unlike a weak symbol as mentioned earlier, if an undefined symbol is not present during linking, the linker raises an error. E.g., an executable which refers to an unlinked undefined function isn't valid, because the code for that function isn't available.

Looking at library.cpp.o and the final executable, square and operand are in the regular data and text sections:

library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 D operand
0000000000000000 T square
000000000000000f T square_10()
executable symbols
$ nm --demangle ./build/example
0000000000001138 T main
0000000000004010 D operand
0000000000001119 T square

This shows that the linker resolved the references to square and operand in main.cpp.o to the definitions provided in library.cpp.o.

C++ specific stuff

Linkage

So, why are the link rules different between C and C++?

Because the rules for C didn't have to account for C++ features like namespaces, overloading, and templates.

Let's take our existing example and remove the enwrapping extern "C" so that the C++ link rules apply.

See commit b544fe3a Remove extern C (diff, tree)

library.hpp
1#pragma once
2
3int square(int num);
4
5extern int operand;
library.cpp
1#include "library.hpp"
2
3int square(int num) {
4 return num * num;
5}
6
7int operand = 5;
8
9int square_10() {
10 return square(10);
11}
library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 D operand
0000000000000000 T square(int)
000000000000000f T square_10()

Now the symbol names look a bit more like function declarations, because that information is encoded in the symbol name. I say encoded, because within the object file, C++ symbol names are mangled into a different form. Since I passed the --demangle option to nm, it has conveniently converted the names back into a human-readable form. Without that option, things look a bit different:

$ nm ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 D operand
0000000000000000 T _Z6squarei
000000000000000f T _Z9square_10v

You can still roughly make out the names, but add namespaces and classes into your files and mangled symbol names become a lot more unwieldy. You may also notice a pattern, e.g. the i suffix for square indicates an int parameter, and v at the end of square_10 indicates void since it takes no parameters. Mangling rules vary by compiler, so you may see a different pattern if you're not using GCC as I am.

If you're curious, here's an example with namespaces and operator overloading:

See commit 9e7dab9b C++ overloading example (diff, tree)

And here's the compile error you get, should you try overloading with C linkage rules:

See commit 39cfc50e C overloading attempt (diff, tree)

Templates

Commonly, C++ template definitions are stored in headers, and inline by default. Thinking about common templates like those provided by the standard library, e.g. std::vector<T>, there can be hundreds of instantiations with different types for T in a given program. Instantiation is when the templated code is actually generated on-demand during compilation, with the template arguments substituted.

Let's convert square to a template and see what happens:

See commit 624f9f11 Replace with a C++ template (diff, tree)

library.hpp
1#pragma once
2
3template <typename T>
4auto square(T num) {
5 return num * num;
6}
7
8extern int operand;
library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 D operand
0000000000000000 W auto square<int>(int)
0000000000000000 T square_10()
main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
0000000000000000 T main
                 U operand
0000000000000000 W auto square<long>(long)
executable symbols
$ nm --demangle ./build/example
0000000000001129 W auto square<int>(int)
000000000000114e W auto square<long>(long)
0000000000001119 T square_10()

Now you can see two instantiations of square in the final executable - one comes from library.cpp, which uses square<int>() in square_10(), the other comes from main.cpp, which uses square<long>() in main().

Template instantiation occurs in specific circumstances:

When a class template specialization is referenced in context that requires a complete object type, or when a function template specialization is referenced in context that requires a function definition to exist 5

If you analyse C++ build times with tools like ClangBuildAnalyzer, you'll often find a significant amount of time is spent instantiating templated code - particularly standard library classes, due to their popularity.

One workaround is using precompiled headers, which as the name suggests, are headers compiled before the rest of your code. The idea is to compile frequently used code once and reuse it, rather than generating the same code repeatedly while compiling different translation units.

When dealing with templated classes, you can use explicit template initialisation to force the compiler to generate some templated code. It looks like this:

template <typename T>
void Function(T t) {
  // ...
}

// Instantiate Function with template argument int
template void Function<>(int);

template <typename T>
class Class {
  // ...
};

// Instantiate Class and its methods with template argument int
template class Class<int>;

Try to not confuse the syntax with template specialisation, which generally looks like this:

template <typename T>
void Function(T t) {
  // ...
}

// Declare a specialised Function with template argument int
template <> void Function<>(int);

Oh the difference an empty pair of angled braces can make!

Alternatively, if you know the set of template parameters that you need up front, you can do similar to regular functions and classes: declare them in headers, and define them in source files. One extra step is needed: adding template instantiations in the source file to generate the required code.

In this case we know we need square instantiation with the template parameters int and long. So, let's move the definition for square into library.cpp, and leave a declaration in library.hpp.

See commit 343d4246 Explicit template instantiation (diff, tree)

library.hpp
1#pragma once
2
3template <typename T>
4extern T square(T num);
5
6extern int operand;
library.cpp
1#include "library.hpp"
2
3template <typename T>
4T square(T num) {
5 return num * num;
6}
7
8template int square<>(int);
9template long square<>(long);
10
11int operand = 5;
12
13int square_10() {
14 return square(10);
15}

And looking at the object files, things look similar to the previous example where we declared square in the header and defined it in the source file:

main.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/main.cpp.o
0000000000000000 T main
                 U operand
                 U long square<long>(long)
library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/library.cpp.o
0000000000000000 D operand
0000000000000000 W int square<int>(int)
0000000000000000 W long square<long>(long)
0000000000000000 T square_10()

Instantiations in multiple translation units

This all leads into my original reason for diving into this: a curious case where we hit linking issues relating to templates.

This particular situation involved a templated class which was implemented in a source file. We wanted to create a subclass from it, but have the subclass reside in a different library separate to the superclass.

What this boiled down to was needing multiple instantiations in different translation units: one for the parent class template, and one for the child class template.

The following is a rough sketch of the code.

See commit d6cb4e38 Templated subclass attempt (diff, tree)

library.hpp
1#pragma once
2
3template <typename T>
4class TemplatedClass
5{
6public:
7 virtual ~TemplatedClass() = default;
8
9 virtual int method();
10};
library.cpp
1#include "library.hpp"
2
3template <typename T>
4int TemplatedClass<T>::method() { return 42; }
other_library.hpp
1#pragma once
2
3#include "library.hpp"
4
5template <typename T>
6class ChildClass : public TemplatedClass<T>
7{
8public:
9 int method() override;
10};
other_library.cpp
1#include "other_library.hpp"
2
3template <typename T>
4int ChildClass<T>::method() { return 1; }
5
6template class ChildClass<int>;
7template class TemplatedClass<int>;
CMakeLists.txt
cmake_minimum_required(VERSION 3.27)

project(example
    LANGUAGES CXX
)

add_executable(example
    library.cpp
    other_library.cpp
    main.cpp
)

So we have a parent class defined in one source file, and a child class defined in its own source file. The source file for the child class also explicitly instantiates the template for both the child and parent class. Finally, I definitely added the new source file to the project.

Despite all that, we get a missing symbol error for the parent class when building:

build log
$ cmake -B build
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/william/scratch/public/linking-by-example/build
$ cmake --build build
[1/4] Building CXX object CMakeFiles/example.dir/library.cpp.o
[2/4] Building CXX object CMakeFiles/example.dir/other_library.cpp.o
[3/4] Building CXX object CMakeFiles/example.dir/main.cpp.o
[4/4] Linking CXX executable example
FAILED: example 
: && /usr/bin/c++   CMakeFiles/example.dir/library.cpp.o CMakeFiles/example.dir/other_library.cpp.o CMakeFiles/example.dir/main.cpp.o -o example   && :
/usr/bin/ld: CMakeFiles/example.dir/other_library.cpp.o:(.data.rel.ro._ZTV14TemplatedClassIiE[_ZTV14TemplatedClassIiE]+0x20): undefined reference to `TemplatedClass<int>::method()'
collect2: error: ld returned 1 exit status
ninja: build stopped: subcommand failed.

Looking into the object for the new source file, you'll find the root cause:

other_library.cpp.o symbols
$ nm --demangle ./build/CMakeFiles/example.dir/other_library.cpp.o
                 U operator delete(void*, unsigned long)
0000000000000000 W ChildClass<int>::method()
0000000000000000 W ChildClass<int>::~ChildClass()
0000000000000000 W ChildClass<int>::~ChildClass()
0000000000000000 W ChildClass<int>::~ChildClass()
0000000000000000 n ChildClass<int>::~ChildClass()
                 U TemplatedClass<int>::method()
0000000000000000 W TemplatedClass<int>::~TemplatedClass()
0000000000000000 W TemplatedClass<int>::~TemplatedClass()
0000000000000000 W TemplatedClass<int>::~TemplatedClass()
0000000000000000 n TemplatedClass<int>::~TemplatedClass()
0000000000000000 V typeinfo for ChildClass<int>
0000000000000000 V typeinfo for TemplatedClass<int>
0000000000000000 V typeinfo name for ChildClass<int>
0000000000000000 V typeinfo name for TemplatedClass<int>
0000000000000000 V vtable for ChildClass<int>
0000000000000000 V vtable for TemplatedClass<int>
                 U vtable for __cxxabiv1::__class_type_info
                 U vtable for __cxxabiv1::__si_class_type_info

There's an undefined reference to TemplatedClass<int>::method(), which is what the linker is complaining about.

It's a bit confusing that we have a hard dependency on a method that is never called, but after all, it is possible for a method override to call the parent's version of the method. But we've explicitly instantiated TemplateClass<int>, why hasn't method() also been instantiated? Well, it's defined in library.cpp, but our explicit instantiation is in other_library.cpp. The definition is inaccessible!

But how do we make the definition available without defining it in the header?

Well, how about adding #include "library.cpp" in other_library.cpp? That's pretty much the only practical solution.

I'm convinced I've seen this pattern once before, possibly in a standard library implementation. Typically, a different file extension is used to indicate it's an template implementation file, e.g. .tpp or .txx. Semantically, this feels better than #includeing a .cpp file in another.

Let's apply this here, so let's rename library.hpp to library.tpp. We can also remove the explicit instantiation of the parent class, because that's implicit in the instantiation of the child class.

See commit 8757c737 Resolve undefined parent class method (diff, tree)

library.tpp
1#include "library.hpp"
2
3template <typename T>
4int TemplatedClass<T>::method() { return 42; }
other_library.cpp
1#include "other_library.hpp"
2
3#include "library.tpp"
4
5template <typename T>
6int ChildClass<T>::method() { return 1; }
7
8template class ChildClass<int>;

And our link issue is resolved:

build log
$ cmake -B build
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/william/scratch/public/linking-by-example/build
$ cmake --build build
[1/3] Building CXX object CMakeFiles/example.dir/other_library.cpp.o
[2/3] Building CXX object CMakeFiles/example.dir/main.cpp.o
[3/3] Linking CXX executable example

Is this rare pattern worth introducing for a probably negligible impact on build times? Probably not. But should you encounter this situation in the future, now you know what to do! Hopefully you learned a thing or two along the way.

What surprises me is that there's no compiler warning when an explicit instantiation can't generate all the code required for said instantiation. Personally I feel that's the intent behind an explicit instantiation, so the compiler should raise if it's unable to follow that instruction. It could also helpfully diagnose which definitions are missing. For the record, I've built these examples with GCC 13. Maybe things will improve in later versions.

References