Understanding the inline specifier

What inline is not

The original idea behind inlining is replacing a function call with its body. It is an optimization trick to increase the performance by means of avoiding the calling overhead (such as passing arguments, creating a new stack frame etc). It would be quite logical to assume that the inline keyword forces the compiler to perform such a trick. In reality it's not the case.

Nowadays compilers often know better how to optimize code and don't necessarily need your input. Moreover compilers can inline a function which does not have the inline specifier. While you may confirm that inlining takes place in your particular case, it may not happen for the same code when compiled by a different compiler, using different compilation settings or on a different platform. In that sense the inline specifier is merely a hint that slightly increases the likelihood of a function being inlined.

The inline specifier does not guarantee inlining

Why do we still have this keyword? Why has it not been phased out like the auto and register storage class specifiers, which were also hints for optimization?

What inline really is

While inline may not guarantee actual inlining, it plays an important role in C++. Some code wouldn't even build without this keyword. But how can that be if we barely use inline explicitly and have no issues? This is an illusion as we rely on inline more often than we realize - just implicitly.

example.h
#pragma once

class Example
{
public:
    void foo()
    {
    }
};

There are 7 inline functions in the example above.

Let's count together:

  1. default constructor
  2. copy constructor
  3. copy assignment operator
  4. move constructor
  5. move assignment operator
  6. destructor
  7. foo()

The first six are implicitly defined and called special member functions. We are going to talk about those in another article. But what is important is that all of them are inline.

The foo() function is also inline even though we haven't used the inline keyword.

The importance of all of these cannot be underestimated, and to understand why it is so important, we can try making a non-inline stand-alone function outside of the Example class and see what happens.

example.h
#pragma once

class Example
{
public:
    void foo()
    {
    }
};

void nonInlineFunction()
{
}

As soon as such a header file is included in more than one translation unit the linker will scream out loud about nonInlineFunction() redefinition. The header guard is not going to help as it prevents multiple includes of the same header file in the same translation unit. The problem we are encountering is a totally different breed.

example.cpp
#include "example.h"
main.cpp
#include "example.h"

int main()
{
    return 0;
}
[build] [ 66%] Building CXX object CMakeFiles/main.dir/main.o
[build] [ 66%] Building CXX object CMakeFiles/main.dir/example.o
[build] [100%] Linking CXX executable main
[build] /usr/bin/ld: CMakeFiles/main.dir/example.o: in function `nonInlineFunction()':
[build] /home/denis/Documents/Cpp/example.cpp:12: multiple definition of `nonInlineFunction()'; CMakeFiles/main.dir/main.o:/home/denis/Documents/Cpp/main.cpp:12: first defined here
[build] collect2: error: ld returned 1 exit status

Each translation unit is compiled separately. The compiler is going to generate nonInlineFunction() from scratch for each translation unit which includes example.h as the definition is in that header file. Therefore we end up having multiple nonInlineFunction() versions for the linker to choose from. The linker is lost.

$ nm example.o
0000000000000000 T _Z17nonInlineFunctionv
$ nm main.o
000000000000000b T main
0000000000000000 T _Z17nonInlineFunctionv

We can see in the output above that each translation unit has the symbol for nonInlineFunction().

The way to fix this issue is by telling the linker that it's free to choose any of the definitions, as they are all identical. This is achieved using the inline keyword. It literally modifies the linkage behavior. Furthermore, despite having multiple definitions, the function is going to have the same memory address in every translation unit.

The whole concept is also applicable to variables and it's exactly why inline ones were introduced in C++17.

The inline specifier means "multiple definitions are possible"

The pitfall of inline

Let's have a look at another example:

sum.h
#pragma once

inline int sum(int a, int b);
sum.cpp
#include "sum.h"

inline int sum(int a, int b)
{
    return a + b;
}
main.cpp
#include "sum.h"

int main()
{
    return sum(1, 2);
}

Both main.cpp and sum.cpp will compile just fine, but if the function is actually inlined (which is highly likely), the linker will produce an "unresolved external symbol int sum(int, int)" error. This happens because when inlining occurs, the function's body replaces the call site, meaning no separate code or symbol for the function is generated.

$ nm sum.o
$ nm main.o
0000000000000000 T main
                 U _Z3sumii

We can see that sum.o has no symbols at all. main.o has an undefined symbol of the sum function which normally means that it has to be resolved by the linker. Of course it cannot be resolved.

Meanwhile if inlining doesn't take place everything will work without issues despite the inline specifier.

sum.cpp
#include "sum.h"

inline int sum(int a, int b)
{
    return a + b;
}

int (*foo())(int, int)
{
    return ∑
}

Defining a function which returns a pointer to the inline function was just enough to force the compiler not to inline that function (was tested on x86-64 gcc 14.2 and x86-64 clang 18.1.0). This makes the job done and the symbols being generated.

$ nm sum.o
0000000000000000 T _Z3foov
0000000000000000 W _Z3sumii
$ nm main.o
0000000000000000 T main
                 U _Z3sumii

Note that the sum symbol is marked with "W", indicating it is a weak symbol. In this context "weak" means that if another object file provides a strong definition of the same symbol, the linker will choose the strong one instead of this weak definition. If no strong definition is found, any of the weak definitions can be used. This is the result of using the inline keyword.

The linker completes the process, and the project builds successfully. However, this is not a proper solution by any means - it is merely a demonstration that you can sometimes get something to work, even when it's done entirely the wrong way.

Never ever split the declaration and definition of inline functions. Instead, always provide the complete definition, whether in a header file or a source file.

Worth mentioning

The inline keyword has broader applications beyond functions. As briefly mentioned, it can also be used with variables to modify linkage behavior in the same way it does for functions. Additionally, it can be applied to namespaces, though the result is not directly related to the focus of this article. Finally, the constexpr keyword implicitly implies inline, so all the points discussed above apply to constexpr as well.

Summary

While the inline keyword does not guarantee function inlining, it plays a crucial role in ensuring that multiple definitions across translation units are permitted. Its primary utility lies in avoiding linker errors rather than performance gains. Keep in mind that compilers today often handle optimizations better than manual hints, so focus on inline as a tool for managing linkage, not for performance enhancement.

Key points to remember about inline in C++:

  1. inline rarely causes actual inlining but instead ensures the application of inline properties
  2. It tells the linker that multiple definitions of the same function across translation units are permissible
  3. Never split definitions and declarations of inline functions to avoid linking errors