Pull to refresh
136.64
JUG Ru Group
Конференции для Senior-разработчиков

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

Reading time15 min
Views24K
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 << "other ";
    }
};

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 = {};


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

  1. const не нужен, потому что уже есть constexpr, поэтому убираем.
  2. Мы знаем по таблице, что 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 конференциям летнего сезона.
Tags:
Hubs:
Total votes 31: ↑29 and ↓2+27
Comments9

Articles

Information

Website
jugru.org
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
Алексей Федоров