Linking in C & C++
04 April 2024Do 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:
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:
- object files
- on Unix-y platforms:
.o
- on Windows; sometimes
.obj
- on Unix-y platforms:
- static libraries
- on Unix-y platforms:
.a
for Archive - on Windows:
.lib
- on Unix-y platforms:
- shared libraries
- on Unix-y platforms:
.so
for Shared Object - on Windows:
.dll
for Dynamic Link Library
- on Unix-y platforms:
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:
- functions
- data (variables, constants)
- classes (which are a combination of both data and functions)
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
1 extern "C" 2
3 int 4 return num * num;
5
6
7 int operand = 5;
8
9 }
10
11 int 12 return ;
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:
extern "C" } // extern "C"
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:
.strtab
: string tables, constant strings that are embedded in the program.rodata
: read-only data. When symbols in this section are loaded into a process' memory, that memory section is typically set to non-writable..init
and.fini
: instructions used during a process' static init and destruction respectively..ctors
and.dtors
: pointers to C++ constructor and destructor functions respectively.
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 2
3 extern "C" 4
5 int 6 return num * num;
7
8
9 int 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 2
3 int 4 return ;
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 #include
s 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 2
3 extern "C" 4
5 static int 6 return num * num;
7
8
9 static 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 2
3 extern "C" 4
5 inline int 6 return num * num;
7
8
9 inline 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.
constexpr
3, added in C++ 17, declares that a function or variable
may be evaluated at compile-time.
consteval
4, 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:
- member function definitions that are inside a class definition
- template function definitions and template variables
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 2
3 extern "C" 4
5 int ;
6
7 extern int operand;
8
9 } // extern "C"
library.cpp
1 2
3 extern "C" 4
5 int 6 return num * num;
7
8
9 int operand = 5;
10
11 } // extern "C"
12
13 int 14 return ;
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 2
3 int ;
4
5 extern int operand;
library.cpp
1 2
3 int 4 return num * num;
5
6
7 int operand = 5;
8
9 int 10 return ;
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 2
3
4 auto 5 return num * num;
6
7
8 extern 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:
void
// Instantiate Function with template argument int
;
;
// Instantiate Class and its methods with template argument int
;
Try to not confuse the syntax with template specialisation, which generally looks like this:
void
// Declare a specialised Function with template argument int
void ;
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 2
3
4 extern T ;
5
6 extern int operand;
library.cpp
1 2
3
4 T 5 return num * num;
6
7
8 ;
9 ;
10
11 int operand = 5;
12
13 int 14 return ;
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 2
3
4 5 6 public:
7 virtual ;
8
9 virtual int ;
10 ;
library.cpp
1 2
3
4 int
other_library.hpp
1 2
3 4
5
6 7 8 public:
9 int ;
10 ;
other_library.cpp
1 2
3
4 int
5
6 ;
7 ;
CMakeLists.txt
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 #include
ing 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 2
3
4 int
other_library.cpp
1 2
3 4
5
6 int
7
8 ;
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.