Спецификаторы, квалификаторы и шаблоны

template
static inline thread_local constexpr const volatile T x = {};


Такое количество ключевых слов введет в ступор любого неподготовленного разработчика. Но на C++ Russia 2019 Piter Михаил Матросов (mmatrosov) разложил по полочкам квалификаторы и спецификаторы при объявлении переменных и функций.

Мы подготовили для вас текстовую версию доклада, чтобы вы могли в любой момент вернуться и изучить шпаргалки Михаила.


Из доклада вы узнаете:


  • как для переменных и функций сделать internal и external linkage;
  • почему inline для переменных обычно лучшем, чем extern;
  • особенности работы с шаблонами функций и переменных;
  • 8 способов объявить константу (ужас!);
  • какое светлое будущее обещает C++20.


Кстати, перед выступлением наш журналист Олег Чирухин (olegchir) и Павел Филонов из программного комитета C++ Russia взяли у Михаила интервью, где он поделился интересными историями работы в Align Technology, а также опытом работы над онлайн-курсами.

Далее — повествование от лица спикера.

Немного теории


Проведем небольшой теоретический экскурс, чтобы понять дальнейший материал доклада.

Посмотрим, как происходит сборка программы на C++:

noogozalzg0r4zzgrm3ymzsva0w.png

В исходные cpp-файлы включают заголовочные hpp-файлы. Во время сборки первым начинает работу препроцессор. Из исходных файлов он формирует единицы трансляции (translation units), в которые собраны все заголовочные файлы (headers), а за ними идет тело cpp-файла. Конечно, компилятор по умолчанию не сохраняет их в явном виде на жестком диске, а они лежат в оперативной памяти.

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

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

// Function declaration
int sqr(int x);

// Function definition
int sqr(int x) { return x * x; }

// Variable declarations
extern int n;
struct A { static int n; };

// Variable definitions
int n;
int A::n;


Перейдем к понятию linkage. Рассмотрим простенькую программу. В файле a.cpp содержится функция sqr ():

int sqr(int x) {
     return x * x;
}


А в файле b.cpp находится ее объявление и некоторая функция check ():

int sqr(int x);

bool check(int a, int b, int c) {
    return sqr(a) + sqr(b) == sqr(c);
}


Программа скомпилируется, потому что определение функции в a.cpp имеет external linkage. Поэтому когда компилятор создаст объектные файлы, в a.obj он положит определение функции sqr (), а в b.obj — объявление функции с пометкой, что в каком-то файле лежит определение этой функции sqr (), и компоновщик его найдет. Если же в объявление функции мы добавим ключевое слово static, то программа не соберется из-за ошибки линковки. Так как функция sqr () будет иметь internal linkage, то есть будет недоступна в других единицах трансляции, и компоновщик её не найдёт.

Кроме external linkage и internal linkage сущность может иметь статус no linkage. Така сущность доступна только в области видимости, в которой объявлена. Типичный пример — локальная переменная.

Теперь вспомним типы storage duration в C++:

  • automatic — память для объекта выделяется в тот момент, когда поток выполнения заходит в scope, в котором переменная объявлена, и освобождается, когда поток выходит из scope;
  • static — память выделяется, когда программа начинает работу, и освобождается, когда программа завершает работу;
  • thread — похоже на static storage duration, но применимо к потоку выполнения;
  • dynamic — выделение памяти контролируется с помощью вызовов new и delete.


Понятие storage duration применимо только к объектам, поскольку необходимо где-то в памяти хранить информацию. Все звучит достаточно просто, потому что задача сводится к выделению памяти. А вот момент, когда объект будет инициализирован, определить сложнее.

Storage duration и linkage контролируются рядом ключевых слов (storage class specifiers) — static, extern, thread_local и mutable. Mutable не имеет отношения к Storage duration и linkage, и об этом в докладе больше не будет, но он формально является storage class specifier.

На теоретическом экскурсе мы ответили на три вопроса:

  • Что? Объект.
  • Где? Linkage.
  • Когда? Storage duration.


Однако C++ не был бы C++, если бы все было так просто.

Internal и external linkage


Рассмотрим пример. В некотором заголовочном файле common.hpp объявили две константы:

const double thickness = 0.65;
const char* name = "tooth";


А в исходные файлы a.cpp и b.cpp включили этот hpp-файл:

// a.cpp
#include "common.hpp”

// b.cpp
#include "common.hpp”


Это не скомпилируется, потому что есть несколько определений одного и того же имени name. Однако компилятор не ругается на thickness. Почему?

Обратимся к C++ Reference:

Any of the following names declared at namespace scope have internal linkage:
  • non-volatile non-template non-inline const-qualified variables (including constexpr) that aren’t declared extern and aren’t previously declared to have external linkage;


Можно было бы подумать, что обе переменные const-qualified, поэтому имеют internal linkage, и их определения в единицах трансляции должны быть независимы. Однако name — это указатель, и ключевое слово const относится к объекту, на который он указывает. То есть он является указателем на константу, но не является константным указателем. Чтобы сделать его константным, нужно будет изменить запись:

const char* const name = "tooth";


Теперь name стал константным указателем на константу, получил internal linkage, и программа собирается без проблем.

Давайте изменим пример:

constexpr double thickness = 0.65;
const std::string name = "tooth";


Это скомпилируется, потому что name — константный символ, а спецификатор constexpr для объекта влечет за собой const, плюс linkage constexpr сущностей в явном виде описан в том же абзаце. Поэтому обе константы имеют internal linkage.

Any of the following names declared at namespace scope have internal linkage:
  • non-volatile non-template non-inline const-qualified variables (including constexpr) that aren’t declared extern and aren’t previously declared to have external linkage;


Перейдем к следующему примеру. В common.hpp оставим name и добавим функцию getName (), которая доступна из разных единиц трансляции:

const std::string name = "tooth";
const char* getName();


В a.cpp мы сравниваем адреса буферов, которые возвращают name.data () и getName ():

#include "common.hpp"
#include 

bool dumbCmp(const char* s1, const char* s2) {
    return s1 == s2;
}

int main() {
    std::cout << std::boolalpha
        << dumbCmp(name.data(), getName());
}


В b.cpp мы определим функцию getName ():

#include "common.hpp"

const char* getName() {
    return name.data();
}


Мы знаем, что name доступна в обеих единицах трансляции. Но одинаковая ли переменная в обоих случаях? Нет, программа напечатает false, потому что для каждой единицы трансляции создается отдельная копия name, а сравнение в dumbCmp () идет не по значению, а по адресу в памяти.

Чтобы программа выдала true, добавим к определению name спецификатор inline:

inline const std::string name = "tooth";


В этом случае во всей программе будет только один объект name, и эта переменная получит особенный external linkage. В каждой единице трансляции все еще будет своя копия переменной на этапе компиляции, но когда этот символ попадет в объектный файл, то он получит пометку, что это weak символ. И компоновщик при объединении объектных файлов в программу выберет из нескольких одинаковых символов только один. В стандарте нет понятия external weak linkage, поэтому формально переменная будет иметь external linkage. Однако если попросить утилиты типа nm или dumpbin показать информацию об этой переменной в объектном файле, то они выведут именно external weak linkage.

В другом примере в a.cpp и b.cpp включим заголовочный файл common.hpp, а в common.hpp запишем определение функции sqr ():

int sqr(int x) {
    return x * x;
}


Это не скомпилируется, потому что в каждой единице трансляции будет свое определение функции. Чтобы программа скомпилировалась, добавим спецификатор constexpr:

constexpr int sqr(int x) {
     return x * x;
}


Если функция constexpr-qualified, то она считается inline. А спецификатор inline для функций также влечет external weak linkage. В современном C++ inline в первую очередь означает, что компоновщик выберет только один экземпляр данной сущности.

Представим, что мы пишем какой-то main.cpp, где создаем класс Local и объявляем в нем функцию foo ():

// main.cpp
void other();

struct Local {
    static void foo() {
        std::cout << "main ";
    }
};

int main() {
    Local::foo();
    other();
}


Но другой разработчик в other.cpp тоже независимо завел класс Local и функцию foo ():

// other.cpp
struct Local {
    static void foo() {
        std::cout << "main ";
    }
};

void other() {
    Local::foo();
}


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

main main


GCC считает, что Local в разных файлах — это один и тот же класс, в нём есть функция foo (). Компилятор знает, что определения этой функции в разных файлах обязаны быть одинаковыми. Поэтому он взял первое попавшееся — из main.cpp. Другой компилятор мог бы вывести что-то другое.

Эта проблема произошла из-за того, что класс Local имел external linkage. Чтобы исправить программу, положим классы в анонимное пространство имен (unnamed namespace):

namespace {
    struct Local {
        static void foo() {
            std::cout << "main ";
        }
    };
}


Все сущности, которые оказываются в анонимном пространстве имен, всегда имеют internal linkage, то есть ничего из translation unit не может просочиться наружу. Поэтому программа будет работать так, как мы ожидаем:

main other


Собираем в кучу


Посмотрим, какие существуют допустимые комбинации между storage duration и linkage:

xsecgaq52exykhrjuwuz2l-euqq.png

Для dynamic storage duration не имеет смысла концепция linkage, потому что мы выделяем объект в куче самостоятельно. Для automatic storage duration применимо только no linkage, ведь память под объект выделяется только при попадании в scope, то есть на этапе выполнения программы. Поэтому автоматические и динамические объекты мы не будем больше рассматривать, и говорить будем только о статических и thread_local объектах.

Чтобы определить, какой storage duration у объекта, можно использовать блок-схему:

d7v1cpq0gkkjr4hnamvneetp3tg.png

Если сущность имеет спецификатор thread_local, то у нее thread storage duration. Если это не так, то нужно посмотреть на scope. Если переменная глобальная, то у нее всегда static storage duration. Для локальной переменной или члена класса проверяем наличие спецификатора static. Если он есть, то переменная статическая, иначе — автоматическая.

Посмотрим, как эффекты, которые мы пронаблюдали, собираются вместе для разных видов сущностей:

ssavmxs4gn17t9qnayamyo_lf7g.png

Колонки в таблице соответствуют разным видам сущностей, а строки — свойствам, которые будут применяться к сущности. Свойства необходимо рассматривать по порядку, потому что те свойства, что ниже, имеет более высокий приоритет чем те, что выше.

Для примера рассмотрим глобальную переменную. Из таблицы мы можем понять:

  • по умолчанию она имеет external linkage;
  • если она объявлена constexpr, то она также будет const;
  • если она обозначена как const, то спецификатор влечет за собой internal linkage (но только если нет спецификаторов volatile и template);
  • если она inline, то она имеет external (weak) linkage;
  • если она static, то она имеет internal linkage, игнорируя предыдущие пункты;
  • если она лежит в анонимном пространстве имен, то она всегда имеет internal linkage.


Запись N/A в таблице означает, что ключевое слово из соответствующего свойства для данной сущности неприменимо. Например, inline неприменим к локальной переменной.

А под записью Required подразумевается, что эти сущности обязаны иметь ключевое слово из соответствующего свойства, чтобы вообще попасть в эту таблицу. Например, если у поля класса не будет спецификатора static, то оно вообще не попадёт в эту таблицу.

Спецификатор extern


В примере, где мы сравнивали буферы, мы использовали inline, чтобы программа вывела true. Однако это не единственный способ решения задачи.

До C++17 не было inline-переменных, и мы могли объявить переменную name как extern:

extern const std::string name;


Тогда бы переменная получила external linkage и превратилась в объявление (declaration). Но в этом случае необходимо где-то добавить явное определение для переменной name, и мы вставляем его в a.cpp:

const std::string name = "tooth";


Таким образом мы бы получили тот же результат выполнения программы.

Какими свойствами обладает extern?

  • Применим только к глобальным функциям и переменным.
  • Несовместим со static.
  • Не имеет смысла с constexpr и с inline.
  • Значение не видно в точке объявления (обычно недостаток).


У extern есть недостаток: необходимо вручную делать определение, то есть явно выбрать единицу трансляции, в которой переменная будет определена. Однако теоретически extern позволяет оптимизировать время сборки. Поскольку мы делаем определение вручную, то extern дает возможность избежать дополнительной нагрузки на компоновщик, поскольку символы не будут попадать в каждый объектный файл, как это происходит с inline.

Но это довольно специфический момент, и обычно вместо extern лучше использовать inline.

Добавим extern в таблицу комбинаций свойств и сущностей:

6hfngmfpezpyszi0c_0ssbbnrde.png

Для не глобальных сущностей extern неприменим. Для глобальных же функций данный спецификатор излишен, потому что любое объявление глобальной функции по умолчанию является extern. Но для глобальных переменных спецификатор будет работать, и для переменной он будет указывать external linkage и превращать ее в объявление переменной.

Practice time


От теории перейдем к практике. Рассмотрим такой класс:

struct A
{
    double x1;
    static double x2;
    static const double x3;
    static inline const double x4 = 4.0;
    static constexpr double x5 = 5.0;
};


Посмотрим на таблицу. Нас интересует колонка member variable. Какие выводы мы можем сделать?

  • x1 имеет automatic storage duration и не может иметь linkage;
  • x2, x3, x4 и x5 имеют static storage duration;
  • x2 и x3 имеют external linkage. Причем x2 и x3 являются объявлениями.
  • x4 и x5 имеют external (weak) linkage, поскольку они inline (constexpr влечет за собой inline для членов класса). Мы можем указать инициализацию прямо в теле класса. И компоновщик позаботится о том, чтобы определения не конфликтовали в разных единицах трансляции.


А что такое static constexpr? Мы знаем, что переменная с constexpr используется только на этапе компиляции, а static это про storage duration, который имеет смысл только на этапе выполнения. Может, вообще нет никакого storage duration, если она доступна только во время компиляции?

Не совсем. constexpr и static находятся в разных мирах. constexpr действителен только при компиляции, и после этого процесса от constexpr не остается и следа (ну, точнее, от него останется const или inline, в соответствии с таблицей свойств). Но когда программа начинает выполняться, те же самые переменные, которые использовались на этапе компиляции, начинают существовать уже на этапе выполнения. К ним становится применим спецификатор static, потому что только на стадии выполнения у них есть storage duration.

Стоит вспомнить еще одну «парочку» ключевых слов — const и volatile. const означает, что мы не можем из программы менять наш объект. volatile разрешает менять и читать объект кому-то другому извне программы. const volatile переменную мы менять не можем, но ее может изменить кто-то другой. Кроме того, в практически любом контексте, где используется const, можно применить volatile.

Шаблоны


Функции, классы и переменные могут быть шаблонами. Однако важно понимать, что не бывает шаблонных сущностей (template entity), а есть только шаблоны сущностей (entity template). Сравним функцию и шаблон:

  • шаблон нельзя вызвать, как функцию;
  • у шаблона нельзя взять адрес, в отличие от функции;
  • шаблон нельзя перегрузить.


Если же мы выполним инстанциацию шаблона, то из него получается сущность. Поэтому в контексте доклада удобно думать, что спецификаторы применяются к инстанцированной сущности, а не к шаблону, хотя на самом деле это не так. У самого шаблона тоже есть linkage, но на практике это почти не используется. Вместо этого далее мы будем говорить про linkage инстанциации шаблона.

У шаблона есть неявные инстанциации. Но компоновщик сам позаботится о них в разных модулях трансляции. Их linkage не так важен и даже не всегда понятен.

Перейдем к примеру. Заведем три шаблона переменных в заголовочном файле:

template bool b = true;
template const bool cb = true;
template inline const bool icb = true;


Включаем hpp-файл в два cpp-файла. Далее инстанцируем переменные: b, cb и icb. В каждой единице трансляции мы берем адрес у этих инстанциаций и выводим. Компилятор clang выдал:

0x6030c0 0x401ae4 0x401ae5 // first translation unit
0x6030c0 0x401ae4 0x401ae5 // second translation unit


Мы видим одни и те же адреса. Значит, программа работала с одними и теми же объектами. Скомпилируем программу с помощью gcc и посмотрим результат:

0x6015b0 0x400ef5 0x400ef4 // first translation unit
0x6015b0 0x400ef6 0x400ef4 // second translation unit


Для const bool cb внезапно различаются адреса. Я даже задал вопрос на stackoverflow и получил интересный ответ:

buskkzqhklnd_wz5rwlrbsmpwgm.png

Стандарт не очень явно объясняет, какой будет linkage у инстанциации шаблонов. Поэтому мы тоже не будем настолько углубляться в эти детали. Если вы хотите убедиться, что используется один и тот же объект, то используйте inline, который не подведет. Например, стандартная константа std::is_const_v, как и другие стандартные константы, объявляется так:

template
inline constexpr bool is_const_v = is_const::value;


Использовать inline для шаблонов функций нет смысла. Компилятор проигнорирует такой inline, а инструмент для статического анализа подскажет, что он лишний. Если вы делаете явную специализацию этой функции (а специализация уже является не шаблоном, а именно функцией), то указание inline имеет смысл, иначе использование специализации в разных единицах трансляции привело бы к множественному определению.

Как уже говорилось ранее, у шаблонов в большинстве случаев неявная инстанциация, достаточно поставить угловые скобки. Есть не очень известный, но полезный механизм — объявление явной инстанциации (explicit instantiation declaration).

Пусть в header.hpp есть некоторый шаблон большой сложной функции:

template
int complicatedTemplateFunction(const T& x) {
    // Some complicated stuff
}


Мы можем написать extern template и указать сущность с конкретным типом:

extern template int complicatedTemplateFunction(const std::string& x);


Компилятор будет воспринимать это как объявление явной инстанциации. Если в какой-либо единице трансляции он встретит специализацию функции для этого типа, он не сделает неявную инстанциацию, а просто оставит пометку компоновщику, чтобы тот искал явную инстанициацию в других единицах трансляции. А это значительно быстрее.

Поскольку у нас есть объявление явной инстанциации, куда-то нужно будет поместить её определение:

template int complicatedTemplateFunction(const std::string& x);


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

Долгий путь к const


Константы до C++17 могли быть объявлены в заголовочном файле кучей разных способов:

#define n 42


Тут вроде бы уже все знают, что так делать не стоит.

const int n = 42;


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

extern const int n;


Неплохо, но значение, которое мы инициализируем, пропадает, и нужно искать в программе его определение, что неудобно.

inline int n() {
    return 42;
}


Так тоже можно, но не получится взять адрес, потому что будет возвращаться rvalue. Да и ещё скобки нужно будет писать при использовании.

enum {
     n = 42
};


Весьма неплохой подход, но работает только для целочисленных типов.

Начиная с C++17 мы можем использовать inline, который будет работать для любого типа. В заголовочном файле это будет выглядеть так:

inline constexpr int n1 = 1; // Default choice
inline const std::string s2 = "2"; // If not a literal type


На этапе компиляции второй вариант использовать не получится, но в остальном будет все то же самое, что и при constexpr.

Если мы объявляем константу в cpp-файле, то она должна быть доступна только в текущей единице трансляции:

constexpr int n3 = 3; // Default choice; implicitly static
const std::string s4 = "4"; // If not a literal type; implicitly static


Убираем inline, иначе объявление константы может интерферировать с другой единицей трансляции. Кстати, в module interface unit в C++20 можно использовать тот же синтаксис.

Если константа — член класса, то она объявляется как static:

struct A {
    static constexpr int n = 5; // Default choice; implicitly inline
    static inline const std::string s = "6"; // If not a literal type
};


Если к константе нельзя применить constexpr, то придется вручную прописать inline, потому что для поля класса его компилятор не подставит, в отличие от функций.

Если же константа — локальная переменная, то синтаксис похож на объявление глобальной переменной, но со static:

void f() {
    static constexpr int n = 7; // Default choice
    static const std::string s = "8"; // If not a literal type
}


Целых 8 вариантов. Но все не так сложно, как кажется. Асимметрия между constexpr и const наблюдается только в случае, когда константа — член класса.

Когда в светлом будущем, допустим, останутся только модули и не будет заголовочных файлов, останутся только эти варианты:

// module.ixx
constexpr int n3 = 3;

// Anywhere
struct A {
    static constexpr int n = 5;
};

void f() {
    static constexpr int n = 7;
}


Чтобы не путаться в дальнейшем, обратимся к блок-схеме, которая поможет понять, как объявить константу с инициализатором:

l-jvfnyp1pytuwoegjmjmcqrfso.png

Она описывает ровно те примеры, что мы разобрали выше.

Загадочный пример из описания


Рассмотрим пример, который был в описании доклада:

template
static inline thread_local constexpr const volatile T x = {};

Попробуем его оптимизировать:

  1. const не нужен, потому что уже есть constexpr, поэтому убираем.
  2. Мы знаем по таблице, что static перебивает inline, поэтому можем смело убирать inline.


В итоге у нас остается:

template
static thread_local constexpr volatile T x = {};


static для глобальной переменной даёт internal linkage. thread_local говорит о том, что будет thread storage duration. Поэтому x — это constexpr volatile шаблон переменной с thread storage duration и internal linkage (constexpr volatile variable template with thread storage duration and internal linkage).

Изменения в C++20


6foucvpa7qy1mk4sh5iecn_ktne.png

В C++ 20 добавляется еще один вид linkage — module linkage. external linkage становится module linkage, потому что это linkage внутри модуля, а все, что выходит за пределы модуля, становится external linkage.

В C++20 появляется спецификатор для функции consteval. Это как constexpr, но если constexpr функция может работать как на этапе компиляции, так и на этапе выполнения, то consteval доступен только на этапе компиляции.

Для удобства можно считать, что consteval функция недоступна на этапе компоновки и выполнения, не генерирует символа в объектном файле и является своеобразным функциональным макросом. На самом деле в стандарте вообще нет таких понятий, как «время компиляции» и «время выполнения». Есть только «наблюдаемый эффект выполнения программы». Однако формулировка consteval дана таким образом, чтобы реальные компиляторы имели возможность реализовать ожидаемое поведение.

Для переменных в C++ добавили спецификатор constinit. Если constinit переменную попытаться инициализировать чем-то, что неизвестно на этапе компиляции, то компилятор выдаст ошибку. Забавно, что constinit не означает, что переменная является const. Он значит только то, что переменная должна быть инициализирована в момент компиляции, а во время выполнения ее можно изменять.

Добавим consteval и constinit в таблицу:

ybh7iyfi4i2datindbqagdmv-cm.png

Как жить с особенностями C++ и не сойти с ума


  • Помещайте всё в анонимное пространство имен, если это возможно. Подумайте, сможете ли вы полностью отказаться от static для глобальных переменных в пользу анонимного пространства имен.
  • Предпочитайте inline вместо extern.
  • Предпочитайте constexpr вместо const.
  • Старайтесь использовать переменные со static и thread storage duration только для констант. Иначе изменчивое глобальное состояние будет влиять на надёжность, дизайн и тестируемость.


В этом году на конференции С++ Russia 2020 Moscow выступят сам создатель языка С++ Бьярне Страуструп и председатель комитета по стандартизации С++ Герб Саттер! Еще больше знаменитых спикеров можно будет увидеть по билету-абонементу, который дает доступ ко всем 8 конференциям летнего сезона.

© Habrahabr.ru