Введение

Описание и примеры

Эволюция термина inline

Первоначальное назначение ключевого слова inline состояло в том, чтобы служить индикатором для оптимизатора, что встроенная подстановка функции предпочтительнее вызова функции, то есть вместо выполнения команды CPU для передачи управления в тело функции, копия тела функции выполняется без генерирования вызова. Эта оптимизация (inline expansion) основана на идее, что выполнение вызова функции является относительно дорогостоящим: оно требует перехода к новой подпрограмме, передачи аргументов функции и копирования возвращаемых значений. Inline expansion подавляет вызов функции путем копирования инструкций функции непосредственно в тело вызывающего объекта.
Последствия такой оптимизации в программе может быть очень трудно предвидеть. В дополнение к уменьшению накладных расходов на вызов функций, inline expansion позволяет выполнять широкий спектр дополнительных оптимизаций, которые в противном случае было бы очень трудно выполнить между вызовами функций. Однако не забывайте, что inline expansion создает копию тела функции для каждого вызова. Как следствие, помимо очевидного увеличения размера программы, дублирование инструкций делает программу кэш-недружественной.
Inline expansion может значительно повысить производительность, но это не точно. Производительность должна измеряться, а не предполагаться.
Т.к. программисты редко могут хорошо управлять оптимизациями, с них эта обязанность была снята, и современные компиляторы сами решают, когда применить inline expansion, а когда - не нужно.

Сегодня ключевое слово inline имеет мало общего с inline expansion. Эта оптимизация является необязательной для компилятора, компиляторы могут свободно использовать встроенную замену для любой функции, которая не помечена inline, и могут генерировать вызовы функций для любой функции, помеченной inline.
Однако ключевое слово inline затрагивает так же вопросы линковки. Подробнее про это можно почитать здесь. Поведение компилятора в вопросе оптимизаций не изменяет правила, касающиеся нескольких определений. Сегодня inline - это про one definition rule, а не про inline expansion оптимизацию. И поскольку значение ключевого слова inline для функций стало означать "разрешено несколько определений", а не" предпочтительно встроенное", это значение было распространено на переменные.

One definition rule и как его нарушить?

Функции и переменные, объявленные inline, могут быть определены в программе несколько раз.
Это, по сути, исключение из правила одного определения. ODR говорит, что вы можете определить только один раз функции, переменные, классы, перечисление и т. д.
ODR должен выполняться не только на уровне единицы трансляции, но и на уровне программы.

Встроенные функции и переменные являются исключением из правила одного определения: они могут быть определены несколько раз в программе (несколько раз в программе, но только единожды в одной единице трансляции).

Давайте рассмотрим пару примеров: использование inline при объявлении и инициализации глобальных констант и использование inline при объявлении и инициализации статических полей класса.

Глобальные константы

Часто определенные символьные константы могут быть использованы в разных частях программы (а не только в одном месте). Это могут быть физические или математические константы, которые не изменяются (например, число Пи или число Авогадро), или специфические для приложения значения (например, коэффициенты трения или гравитации). Вместо того чтобы переопределять эти константы в каждом файле, который нуждается в них, лучше объявить их один раз в одном месте и использовать везде, где это необходимо. Тогда, если вам когда-либо понадобится изменить их, вам нужно будет изменить их только в одном месте.

Немного про линковку

В единицу трансляции включены файл реализации (.c/.cpp) и все его заголовочные файлы (.h/.hpp).
Если внутри единицы трансляции у объекта или функции есть внутреннее связывание, то этот символ виден компоновщику только внутри этой единицы трансляции. Если же у объекта или функции есть внешнее связывание, то компоновщик сможет видеть его при обработке других единиц трансляции. Использование ключевого слова static в глобальном пространстве имен дает символу внутреннее связывание. Ключевое слово extern дает внешнее связывание.
Компилятор по умолчанию дает символам следующие связывания:

  • Non-const глобальные переменные — внешнее связывание;

  • Const глобальные переменные — внутреннее связывание;

  • Функции — внешнее связывание.

Подробнее...

Глобальные константы как переменные с внутренним связыванием

Один из способов сделать это:

  1. Создайте заголовочный файл для хранения этих констант.

  2. Внутри этого заголовочного файла определите пространство имен.

  3. Добавьте все ваши константы в пространство имен (убедитесь, что они constexpr).

  4. #include вашего заголовочного файла везде, где это нужно.

Например:

// constants.h
#ifndef CONSTANTS_H
#define CONSTANTS_H
 
// define your own namespace to hold constants
namespace constants
{
    // constants have internal linkage by default
    constexpr double pi { 3.14159 };
    constexpr double avogadro { 6.0221413e23 };
    constexpr double my_gravity { 9.2 }; // m/s^2 -- gravity is light on this planet
    // ... other related constants
}
#endif

И используйте ваши константы:

// main.cpp
#include "constants.h" // include a copy of each constant in this file
 
#include <iostream>
 
int main()
{
    std::cout << "Enter a radius: ";
    int radius{};
    std::cin >> radius;
 
    std::cout << "The circumference is: " << 2.0 * radius * constants::pi << '\n';
 
    return 0;
}

Когда этот заголовок включается (#include) в файл. cpp, каждая из переменных, определенных в заголовке, будет скопирована в этот cpp-файл в момент включения. Вы можете использовать эти константы в любом месте cpp-файла.
Поскольку эти глобальные константы имеют внутреннее связывание, каждый файл .cpp получает независимую версию глобальной константы. В большинстве случаев, компилятор соптимизирует их и подставит конкретные значения в местах использования.

Глобальные константы с внешним связыванием

Вышеприведенный метод имеет несколько потенциальных недостатков.Это легко использовать, но каждый раз, когда мы включаем заголвочный файл с константами в файл с кодом, каждая из этих переменных копируется в файл с кодом. Поэтому, если constants.h включается в 20 различных файлов кода, каждая из этих переменных дублируется 20 раз. Header guard не предотвратит это, так как она предотвращает только включение заголовка более одного раза в один cpp-файл, а не в несколько разных файлов с кодом. Это создает две проблемы:

  1. Изменение одной константы потребует перекомпиляции каждого файла, используеющего константы, что делает компиляцию долгой для крупных проектов.

  2. Если константы имеют большой размер и не могут быть оптимизированы, это приведёт к нежелательному расходу памяти.

Один из способов избежать этих проблем - обеспечить этим константами внешнее связывание, тогда мы сможем иметь единственную переменную (инициализированную один раз), которая является общей для всех единиц трансляции. Давайте определим константы в файле .cpp (чтобы гарантировать, что определения существуют только в одном месте) и запишем объявления в заголовочном файле (которые будут включены в другие cpp-файлы):

// constants.cpp
#include "constants.h"
 
namespace constants
{
    // actual global variables
    extern const double pi { 3.14159 };
    extern const double avogadro { 6.0221413e23 };
    extern const double my_gravity { 9.2 }; // m/s^2 -- gravity is light on this planet
}
// constants.h
#ifndef CONSTANTS_H
#define CONSTANTS_H
 
namespace constants
{
    // since the actual variables are inside a namespace, the forward declarations need to be inside a namespace as well
    extern const double pi;
    extern const double avogadro;
    extern const double my_gravity;
}
 
#endif

Можем использовать их:

// main.cpp
#include "constants.h" // include all the forward declarations
 
#include <iostream>
 
int main()
{
    std::cout << "Enter a radius: ";
    int radius{};
    std::cin >> radius;
 
    std::cout << "The circumference is: " << 2.0 * radius * constants::pi << '\n';
 
    return 0;
}

Теперь константы будут создаваться только один раз (в constants.cpp), а не один раз каждый раз при включении constants.h, и все использования будут просто ссылаться на версию в constants.cpp. Любые внесенные изменения в constants.cpp потребуют только перекомпиляции constants.cpp.
Однако у этого метода есть несколько недостатков. Во-первых, эти константы теперь могут считаться константами времени компиляции только в файле, в котором они фактически определены (constants.cpp), а не где-либо еще. Это означает, что вне constants.cpp они не могут быть использованы нигде, где требуется постоянная времени компиляции. Во-вторых, оптимизировать их использование компилятору сложнее.
Учитывая вышеперечисленные недостатки, хочется определять константы в заголовочном файле.

Глобальные константы как inline переменные

C++17 ввел новую концепцию под названием inline variables. В C++ термин inline эволюционировал и стал означать “допускается несколько определений". Таким образом, встроенная переменная - это та переменная, которая может быть определена в нескольких файлах без нарушения ODR. Встроенные глобальные переменные по умолчанию имеют внешнее связывание.
Встроенные переменные имеют два основных ограничения, которые необходимо соблюдать:

  1. Все определения встроенной переменной должны быть идентичны (в противном случае, это приведёт к неопределенному поведению).

  2. Определение встроенной переменной должно присутствовать в любом файле, использующем переменную.

Компоновщик объединит все встроенные определения в одно определение переменной. Это позволяет нам определять переменные в заголовочном файле и обрабатывать их так, как если бы где-то в файле .cpp было только одно определение.
Перепишем наш пример следующим образом:

// constants.h
#ifndef CONSTANTS_H
#define CONSTANTS_H
 
// define your own namespace to hold constants
namespace constants
{
    inline constexpr double pi { 3.14159 }; // note: now inline constexpr
    inline constexpr double avogadro { 6.0221413e23 };
    inline constexpr double my_gravity { 9.2 }; // m/s^2 -- gravity is light on this planet
    // ... other related constants
}
#endif
// main.cpp
#include "constants.h"
 
#include <iostream>
 
int main()
{
    std::cout << "Enter a radius: ";
    int radius{};
    std::cin >> radius;
 
    std::cout << "The circumference is: " << 2.0 * radius * constants::pi << '\n';
 
    return 0;
}

Мы можем включать constants.h в любое количество cpp-файлов, но эти переменные будут созданы только один раз и совместно использоваться во всех файлах с кодом.

Инициализация статических полей класса

Рассмотрим класс со статическим полем. В C++14 вам нужно сначала объявить его в классе:

// my_header.h
#pragma once
#include <string>

struct SomeClass
{
    static std::string myStaticString;
};

А затем определить его в отдельном блоке компиляции:

//my_src.cpp
#include #my_header.h"
std::string SomeClass::myStaticString{"This is annoying"};

В C++14 определение переменной в классе приведет к ошибке во время компиляции. Однако в C++17 можно так:

// my_header.h
#pragma once

struct SomeClass
{
    static inline std::string myStaticString{"This is cool"};
};

Определение вне класса также возможно:

// my_header.h
#pragma once

struct SomeClass
{
    static std::string myStaticString;
};

inline std::string SomeClass::myStaticString{"This is cool"};

Замечания

Ещё раз подчеркну: все определения встроенной функции или переменной во всей программе должны быть идентичны. Нарушение этого правила приведёт к неопределённому поведению.

Статическая переменная-член (но не переменная пространства имен), объявленная constexpr, неявно является встроенной переменной.

Пример:

// my_header.h
#pragma once

constexpr int generateRandomInt()
{
    // calculate some random value at compile time
}

struct SomeClass
{
    static constexpr int myRandomInt = generateRandomInt();
};

Но почему же мы не сталкиваемся с проблемами при определении функций в заголовочных файлах классов?

На самом деле для функций всё сказанное выше тоже актуально, но компилятор облегчает нам задачу, самостоятельно расставляя слово inline в нужных местах. А если точнее:

  • Функция, определенная полностью внутри определения класса/структуры/объединения, неявно является встроенной функцией.

  • Функция, объявленная constexpr, неявно является встроенной функцией.

  • Удаленная функция неявно является встроенной функцией: ее определение (=delete) может отображаться в нескольких единицах трансляции.

Ссылки

При подготовке статьи, кроме материалов, на которые я ссылался в тексте, использовались:

Данная статья была подготовлена экспертом OTUS - Анатолием Махаевым специально для студентов курса C++ Developer. Professional.

В преддверии старта курса, приглашаем всех желающих записаться на бесплатный демоурок по теме: "Полезные инструменты в разработке на С++".