Inline Functions and Variables

When developing software, you'll frequently need to encapsulate logic into reusable pieces. You have two fundamental approaches:

  1. Write the code directly where it's needed (in-place implementation).
  2. Extract it into a separate function or set of functions.

Creating dedicated functions offers significant advantages:

  • Improves code organization and readability.
  • Enables code reuse across your program.
  • Simplifies maintenance by centralizing logic.

However, function calls introduce runtime overhead. Each time a function executes, the program must:

  • Save the current execution location to return to later.
  • Store CPU register states for restoration.
  • Allocate and initialize function parameters.
  • Jump to the function's code location.
  • Jump back to the call site upon completion.
  • Handle return value transfer.

This preparatory and cleanup work around executing a task is called overhead.

For substantial functions performing complex operations, this overhead is negligible compared to the function's actual work. But for small utility functions, the overhead can exceed the time spent executing the function's logic. When such functions are called frequently, this cumulative overhead can noticeably impact performance.

Consider this temperature conversion utility:

#include <iostream>

double celsiusToFahrenheit(double celsius)
{
    return (celsius * 9.0 / 5.0) + 32.0;
}

int main()
{
    std::cout << "Today: " << celsiusToFahrenheit(25.0) << "°F\n";
    std::cout << "Tomorrow: " << celsiusToFahrenheit(28.5) << "°F\n";
    return 0;
}

Each call to celsiusToFahrenheit() incurs overhead: saving state, initializing celsius, jumping to the function, returning, and transferring the result. For such a simple calculation, this overhead is disproportionate to the actual arithmetic being performed.

Inline Expansion

The compiler has a powerful optimization technique to eliminate this overhead: inline expansion replaces function calls with the actual code from the function's body.

If the compiler expanded our celsiusToFahrenheit() calls, the result would look like:

#include <iostream>

int main()
{
    std::cout << "Today: " << ((25.0 * 9.0 / 5.0) + 32.0) << "°F\n";
    std::cout << "Tomorrow: " << ((28.5 * 9.0 / 5.0) + 32.0) << "°F\n";
    return 0;
}

The function calls vanish, replaced by the calculation itself with parameters substituted directly. This eliminates call overhead while maintaining the same computational result.

Performance Characteristics of Inline Expansion

Removing function call overhead is just one benefit. Inline expansion enables additional optimizations. Since ((25.0 * 9.0 / 5.0) + 32.0) becomes a constant expression, the compiler can evaluate it at compile time, further improving runtime performance.

However, inline expansion has tradeoffs. If the function body contains more instructions than a function call requires, each expansion increases executable size. Larger executables can perform worse due to reduced CPU cache efficiency.

Whether a function benefits from inlining depends on multiple factors: function call cost, function size, call frequency, and available optimizations. Inline expansion might improve performance, degrade it, or have no measurable effect.

Inline expansion works best for simple, short functions (typically a few statements) called frequently, especially inside loops.

When the Compiler Performs Inline Expansion

Functions fall into two categories regarding inline expansion:

  • May be expanded: Most functions fall here. Calls can be expanded when beneficial.
  • Cannot be expanded: Functions whose definitions the compiler cannot see.

For functions in the "may" category, modern compilers analyze each function and call site individually, deciding whether expansion would be beneficial. The compiler might expand none, some, or all calls to a given function.

Modern optimizing compilers automatically determine when inline expansion is appropriate.

The primary case where expansion is impossible: functions defined in different translation units. Without seeing the definition, the compiler cannot know what code to substitute for the call.

The inline Keyword: Historical Usage

Early compilers lacked sophisticated heuristics for beneficial inline expansion. C++ introduced the inline keyword as a hint suggesting a function would likely benefit from being expanded inline.

Functions declared with inline are called inline functions:

#include <iostream>

inline double celsiusToFahrenheit(double celsius)
{
    return (celsius * 9.0 / 5.0) + 32.0;
}

int main()
{
    std::cout << "Today: " << celsiusToFahrenheit(25.0) << "°F\n";
    std::cout << "Tomorrow: " << celsiusToFahrenheit(28.5) << "°F\n";
    return 0;
}

In modern C++, the inline keyword no longer serves this purpose for several reasons:

  • Using inline to request expansion is premature optimization that can backfire.
  • The inline keyword is merely a hint; compilers freely ignore it.
  • Compilers routinely inline functions without the keyword as part of standard optimizations.
  • The keyword operates at the wrong granularity: you mark entire function definitions, but expansion decisions happen per call site.

Modern optimizing compilers excel at determining which specific function calls should be inlined—typically better than human programmers.

Do not use the inline keyword to request inline expansion for your functions.

The inline Keyword: Modern Usage

In earlier chapters, we explained that defining functions with external linkage in header files causes problems. When headers are included into multiple source files, each receives a copy of the function definition. After compilation, the linker encounters multiple definitions of the same function, violating the one-definition rule (ODR).

In modern C++, inline means "multiple definitions are allowed." An inline function may be defined in multiple translation units without violating the ODR.

Inline functions must satisfy two requirements:

  • The compiler must see the complete inline function definition in each translation unit using it. Forward declarations alone are insufficient, though they can precede the definition.
  • All definitions of an inline function with external linkage must be identical; otherwise, undefined behavior results.

The compiler needs the full definition of an inline function wherever it's used, and all definitions must match exactly.

The linker consolidates all inline function definitions for an identifier into a single definition, satisfying the ODR.

Here's an example:

main.cpp:

#include <iostream>

double calculateArea(double radius);

inline double radiusMultiplier() { return 3.5; }

int main()
{
    std::cout << radiusMultiplier() << '\n';
    std::cout << calculateArea(10.0) << '\n';

    return 0;
}

geometry.cpp:

inline double radiusMultiplier() { return 3.5; }

double calculateArea(double radius)
{
    return radiusMultiplier() * radius * radius;
}

Both files define radiusMultiplier()—but because it's marked inline, this is acceptable. The linker deduplicates them. Removing inline from both would cause an ODR violation.

The historical and modern uses of inline are deeply connected. Historically, marking a function inline requested expansion. For expansion to work across translation units, the compiler needed the full definition in each unit—which would violate the ODR. Making inline functions ODR-exempt solved this problem. Today, we use inline primarily for ODR-exemption, letting the compiler handle expansion automatically.

Inline functions are typically defined in header files for inclusion into any source file needing them, ensuring all definitions remain identical.

radius_config.h:

#pragma once

inline double radiusMultiplier() { return 3.5; }

main.cpp:

#include "radius_config.h"
#include <iostream>

double calculateArea(double radius);

int main()
{
    std::cout << radiusMultiplier() << '\n';
    std::cout << calculateArea(10.0) << '\n';

    return 0;
}

geometry.cpp:

#include "radius_config.h"

double calculateArea(double radius)
{
    return radiusMultiplier() * radius * radius;
}

This approach enables header-only libraries: one or more header files implementing complete functionality without separate source files. Header-only libraries are popular because they're easy to integrate—simply include the header and use its features without linking additional files.

The following are implicitly inline:

  • Functions defined inside class, struct, or union definitions.
  • Constexpr and consteval functions.
  • Functions instantiated from templates.

Generally, avoid marking functions or variables inline unless defining them in header files (and they're not already implicitly inline).

Avoid using inline unless you have a specific reason, such as defining functions or variables in headers.

Why Not Make Everything Inline in Headers?

Primarily because it significantly increases compilation time.

When a header containing inline functions is included, that function definition compiles as part of that translation unit. An inline function included in six translation units compiles six times before the linker deduplicates definitions. Conversely, a function defined in a source file compiles once, regardless of how many translation units include its forward declaration.

Additionally, when a function defined in a source file changes, only that file requires recompilation. When an inline function in a header changes, every file including that header (directly or indirectly) needs recompilation. In large projects, this cascades into extensive rebuilds.

Inline Variables (C++17)

In the previous example, radiusMultiplier() returns a constant. Implementing it as a constant variable would be more straightforward. However, before C++17, doing so created problems.

C++17 introduces inline variables: variables allowed to be defined in multiple files. Inline variables work like inline functions with identical requirements—the compiler must see identical full definitions wherever the variable is used.

The following are implicitly inline:

  • Static constexpr data members of classes.

Unlike constexpr functions, constexpr variables are not implicitly inline (except those noted above).

We'll explore practical inline variable usage in the next lesson on sharing global constants across files.

Summary

Inline expansion: A compiler optimization that replaces function calls with the function's actual code, eliminating call overhead. Modern compilers automatically perform inline expansion when beneficial.

The inline keyword (historical): Originally used to hint that a function should be inlined. Modern compilers ignore this hint and make their own inlining decisions based on sophisticated analysis.

The inline keyword (modern): Means "multiple definitions are allowed." Allows functions and variables to be defined in multiple translation units without violating the one-definition rule (ODR).

Header-only libraries: Libraries implemented entirely in header files using inline functions. Easy to integrate but increase compilation time since every including file must compile the function definitions.

Implicitly inline: Functions defined inside classes, constexpr/consteval functions, and template instantiations are automatically inline without the keyword.

Inline variables (C++17): Variables that can be defined in multiple files, similar to inline functions. Useful for sharing global constants across translation units.

Best practices: Don't use inline to request performance optimization—let the compiler decide. Only use inline when you need to define functions or variables in headers to make them available across multiple translation units.

The modern purpose of inline is about linkage and the ODR, not performance. It enables organizing code in headers while avoiding linker errors, with the compiler handling actual inlining decisions automatically as part of its optimization process.