Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
template<class T>
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++:
В исходные 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 <iostream>
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:
Для dynamic storage duration не имеет смысла концепция linkage, потому что мы выделяем объект в куче самостоятельно. Для automatic storage duration применимо только no linkage, ведь память под объект выделяется только при попадании в scope, то есть на этапе выполнения программы. Поэтому автоматические и динамические объекты мы не будем больше рассматривать, и говорить будем только о статических и thread_local объектах.
Чтобы определить, какой storage duration у объекта, можно использовать блок-схему:
Если сущность имеет спецификатор thread_local, то у нее thread storage duration. Если это не так, то нужно посмотреть на scope. Если переменная глобальная, то у нее всегда static storage duration. Для локальной переменной или члена класса проверяем наличие спецификатора static. Если он есть, то переменная статическая, иначе — автоматическая.
Посмотрим, как эффекты, которые мы пронаблюдали, собираются вместе для разных видов сущностей:
Колонки в таблице соответствуют разным видам сущностей, а строки — свойствам, которые будут применяться к сущности. Свойства необходимо рассматривать по порядку, потому что те свойства, что ниже, имеет более высокий приоритет чем те, что выше.
Для примера рассмотрим глобальную переменную. Из таблицы мы можем понять:
- по умолчанию она имеет 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 в таблицу комбинаций свойств и сущностей:
Для не глобальных сущностей 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<class T> bool b = true;
template<class T> const bool cb = true;
template<class T> 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 и получил интересный ответ:
Стандарт не очень явно объясняет, какой будет linkage у инстанциации шаблонов. Поэтому мы тоже не будем настолько углубляться в эти детали. Если вы хотите убедиться, что используется один и тот же объект, то используйте inline, который не подведет. Например, стандартная константа
std::is_const_v
, как и другие стандартные константы, объявляется так:template<class T>
inline constexpr bool is_const_v = is_const<T>::value;
Использовать inline для шаблонов функций нет смысла. Компилятор проигнорирует такой inline, а инструмент для статического анализа подскажет, что он лишний. Если вы делаете явную специализацию этой функции (а специализация уже является не шаблоном, а именно функцией), то указание inline имеет смысл, иначе использование специализации в разных единицах трансляции привело бы к множественному определению.
Как уже говорилось ранее, у шаблонов в большинстве случаев неявная инстанциация, достаточно поставить угловые скобки. Есть не очень известный, но полезный механизм — объявление явной инстанциации (explicit instantiation declaration).
Пусть в
header.hpp
есть некоторый шаблон большой сложной функции:template<class T>
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;
}
Чтобы не путаться в дальнейшем, обратимся к блок-схеме, которая поможет понять, как объявить константу с инициализатором:
Она описывает ровно те примеры, что мы разобрали выше.
Загадочный пример из описания
Рассмотрим пример, который был в описании доклада:
template<class T>
static inline thread_local constexpr const volatile T x = {};
Попробуем его оптимизировать:
const
не нужен, потому что уже естьconstexpr
, поэтому убираем.- Мы знаем по таблице, что
static
перебиваетinline
, поэтому можем смело убиратьinline
.
В итоге у нас остается:
template<class T>
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
В 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 в таблицу:
Как жить с особенностями C++ и не сойти с ума
- Помещайте всё в анонимное пространство имен, если это возможно. Подумайте, сможете ли вы полностью отказаться от static для глобальных переменных в пользу анонимного пространства имен.
- Предпочитайте inline вместо extern.
- Предпочитайте constexpr вместо const.
- Старайтесь использовать переменные со static и thread storage duration только для констант. Иначе изменчивое глобальное состояние будет влиять на надёжность, дизайн и тестируемость.
В этом году на конференции С++ Russia 2020 Moscow выступят сам создатель языка С++ Бьярне Страуструп и председатель комитета по стандартизации С++ Герб Саттер! Еще больше знаменитых спикеров можно будет увидеть по билету-абонементу, который дает доступ ко всем 8 конференциям летнего сезона.