Вебинар: Использование статических анализаторов кода при разработке безопасного ПО - 19.12
Вашему вниманию предлагается девятая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.
C++ — восхитительный язык. В нём столько идиом, концепций, и каждая со своей замечательной, иногда невыговариваемой аббревиатурой! А самое замечательное в них, что они иногда конфликтуют. И от их конфликта страдать придётся разработчику. А иногда они вступают в симбиоз, и страдать приходится ещё больше.
В C++ есть конструкторы, деструкторы и приходящая с ними концепция RAII: захватывай и инициализируй ресурс в конструкторе, очищай и отпускай в деструкторе. И будет тебе счастье.
Ну что ж, давайте попробуем!
Сделаем какой-нибудь простенький класс, выполняющую буферизированную запись:
struct Writer {
public:
static const size_t BufferLimit = 10;
// Захватываем устройство, в которое будет писать.
Writer(std::string& dev) : device_(dev) {
buffer_.reserve(BufferLimit);
}
// В деструкторе отпускаем, записывая всё,
// что набуферизировали.
~Writer() {
Flush();
}
void Dump(int x) {
if (buffer_.size() == BufferLimit){
Flush();
}
buffer_.push_back(x);
}
private:
void Flush() {
for (auto x : buffer_) {
device_.append(std::to_string(x));
}
buffer_.clear();
}
std::string& device_;
std::vector<int> buffer_;
};
И попробуем им красиво воспользоваться:
const auto text = []{
std::string out;
Writer writer(out);
writer.Dump(1);
writer.Dump(2);
writer.Dump(3);
return out;
}();
std::cout << text;
Работает! Печатает 123. Всё, как мы и ожидали. Как похорошел язык!
Ага. Только работает оно исключительно потому, что нам повезло. Тут, начиная с C++17, гарантированные NRVO (named return value optimization) и copy elision. А программа написана вообще-то с очень злобной ошибкой. И если мы возьмём, например, MSVC, который частенько забывает полностью соответствовать последним стандартам, то результат внезапно будет иной. А именно — программа ничего не печатает.
Если мы чуть-чуть модифицируем программу:
int x = 0; std::cin >> x;
const auto text = [x]{
if (x < 1000) {
std::string out;
Writer writer(out);
writer.Dump(1);
writer.Dump(2);
writer.Dump(3);
return out;
} else {
return std::string("hello\n");
}
}();
std::cout << text;
То под Clang все ещё работает, а под GCC — нет.
И самое замечательное во всём этом безобразии, что никакое это не неопределённое поведение!
Помните, мы обсуждали неработающее перемещение? И выясняли, что в C++ нет деструктивного перемещения. А оно всё-таки есть. Иногда, когда одновременно срабатывают оптимизация возвращаемого значения и удаление лишних вызовов конструкторов копий/перемещений.
Программы выше все неправильные. Они предполагают, что деструктор Writer будет вызван до возврата значения из функции, чего никак быть не может. Деструкторы объектов вызываются всегда после возврата из функции, иначе эти самые значения просто бы умирали, и вызывающий код всегда бы получал мёртвый объект.
Но как же тогда оно иногда работает и скрывает такую печальную ошибку? А вот как:
const auto text = []{
std::string out;
Writer writer(out); // (2) Адреса out и text одинаковые.
// По сути, это один и тот же объект
writer.Dump(1);
writer.Dump(2);
writer.Dump(3);
return out; // (1) Это единственная точка возврата
// из функции. NRVO позволяет в качестве
// адреса временной переменной out
// подложить адрес переменной,
// в которую мы запишем результат — text.
}(); // (3) Деструктор Writer пишет напрямую в text.
Без всех хитроумных оптимизаций происходит следующее:
const auto text = []{
std::string out; // (0) Строка пуста
Writer writer(out); // (1) Адреса out и text разные.
// Это разные объекты.
writer.Dump(1);
writer.Dump(2);
writer.Dump(3); // (2) Записи не происходило,
// буфер не заполнился.
return out; // (3) Возвращаем копию out — пустую строку.
}(); // (3) Деструктор Writer пишет в out,
// она умирает и не достаётся никому, text пуст.
Никакого неопределённого поведения тут, повторяю, нет. Просто всякий деструктор/конструктор с побочными эффектами как бы "сломан" из-за разрешеённых и описанных в стандарте (и даже иногда гарантированных) оптимизаций.
Ну а в каком-нибудь Rust нам такую ерунду написать просто не дадут. Такие дела.
Исправляется проблема либо вытаскиванием Flush наружу и его явным вызовом, либо добавлением ещё одной вложенной области видимости:
const auto text = []{
std::string out;
{
Writer writer(out);
writer.Dump(1);
writer.Dump(2);
writer.Dump(3);
} // Деструктор Writer вызывается здесь.
return out;
}();
std::cout << text;
Не забудьте только оставить комментарий, чтобы ваши коллеги случайно не удалили такие "лишние" скобочки. И проверьте, что ваш автоформаттер кода также их не удаляет.
Самая крутая ошибка с самыми жуткими последствиями. null вообще называют ошибкой на миллиард долларов. От них страдает куча кода на самых разных языках программирования. Но если в условной Java при обращении по null-ссылке вы получите исключение с вполне предсказуемыми последствиями (ну упало и упало), то в великом и ужасном C++, а также в C, за вами придёт неопределённое поведение. И оно будет действительно неопределённым!
Но для начала, конечно, надо отметить, что — после всех обсуждений туманных формулировок стандарта — до июля 2024 года было некоторое соглашение, что всё-таки не сама по себе конструкция *p, где p — нулевой указатель, вызывает неопределённое поведение, а lvalue-to-rvalue преобразование. Ну или менее формально, кратко и не совсем правильно: пока нет чтения или записи значения по этому самому нулевому адресу — всё нормально.
Так, совершенно законно вы могли вызвать статические методы класса через nullptr:
struct S {
static void foo() {};
};
S *p = nullptr;
p->foo();
А также можно было писать вот такую ерунду:
S* p = nullptr;
*p;
Причём эту ерунду можно было писать только в C++. В C это безобразие всё-таки запретили (см. 6.5.3.2, сноска 104). И в C применять оператор разыменования к невалидным и нулевым указателями нельзя нигде. А у C++ свой, особый путь. И эти странные примеры собирались в constexpr контексте (напоминаю, в нём запрещено UB, и компилятор проверяет).
Но вот уж совсем недавно было принято решение всё-таки это безобразие пресечь. И все примеры выше теперь содержат неопределённое поведение.
Но никто всё ещё не запрещает разыменовывать nullptr в невычисляемом контексте (внутри decltype):
#define LVALUE(T) (*static_cast<T*>(nullptr))
struct S {
int foo() { return 1; };
};
using val_t = decltype(LVALUE(S).foo());
Но несмотря на то, что так делать можно, совершенно не значит, что так делать нужно. Потому что последствия от разыменования nullptr там, где это делать нельзя, могут быть печальными. Лезвие тонкое, острое, можно легко оступиться и что-нибудь взорвать.
Если разыменовать nullptr, может быть исполнен код, который никак не вызывался:
#include <cstdlib>
typedef int (*Function)();
static Function Do = nullptr;
static int EraseAll() {
return system("rm -rf /");
}
void NeverCalled() {
Do = EraseAll;
}
int main() {
return Do();
}
Компилятор обнаруживает разыменование nullptr (вызов функции Do). Это — неопределённое поведение. Такого быть не может. Компилятор обнаруживает, что есть одно место, где этому указателю присваивается ненулевое значение. И, раз нуля быть не может, значит именно это значение он и использует. Как результат — исполняется код функции, которую мы не вызывали.
Или вот, совершенно дурная программа:
void run(int* ptr) {
int x = *ptr;
if (!ptr) {
printf("Null!\n");
return;
}
*ptr = x;
}
int main() {
int x = 0;
scanf("%d", &x);
run(x == 0 ? nullptr : &x);
}
Из-за разыменования указателя ptr проверка на nullptr после разыменования может быть удалена. Воспроизводится, например, при сборке с помощью GCC 14.2 (-O1 -std=c++17). Output:
Null!
Вы, конечно же, наверняка никогда не напишете такой странный код. Но что, если разыменование указателя будет спрятано за вызовом функции?
void run(int* ptr) {
try_do_something(ptr); // Если функция разыменует указатель,
// и оптимизатор это увидит, проверка ниже
// может быть удалена.
if (!ptr) {
printf("Null!\n");
return;
}
*ptr = x;
}
Такая ситуация уже куда ближе к реальности.
В стандартной библиотеке C, например, есть функции, от которых можно было бы, по неопытности, ожидать проверки на nullptr, но они этого не делают.
strlen, strcmp, другие строковые функции, а в C++ ещё конструктор std::string(const char*) — их вызов с nullptr в качестве аргумента ведёт к неопределённому поведению (и удалению нижерасположенных проверок, если вам не повезёт).
Ещё есть особо мерзкие в этом смысле memcpy и memmove, которые, несмотря на принимаемые в аргументах размеры буферов, всё равно приводят к неопределённому поведению, если передать в них nullptr и нулевой размер! И точно так же это может проявиться в удалении ваших проверок.
int main(int argc, char **argv) {
char *string = NULL;
int length = 0;
if (argc > 1) {
string = argv[1];
length = strlen(string);
if (length >= LENGTH) exit(1);
}
char buffer[LENGTH];
memcpy(buffer, string, length); // При передаче nullptr
// length будет нулевым,
// но это не спасает от UB.
buffer[length] = 0;
if (string == NULL) {
printf("String is null, so cancel the launch.\n");
} else {
printf("String is not null, so launch the missiles!\n");
}
}
На одних и тех же входных данных (вернее, их отсутствии) этот код завершается с разными результатами в зависимости от компилятора и уровня оптимизаций.
Если вы недостаточно напуганы, то вот ещё замечательная история о том, как весело и задорно падала функция вида:
void refresh(int* frameCount)
{
if (frameCount != nullptr) {
++(*frameCount); // Прямо вот тут грохалась из-за
// разыменования nullptr.
}
....
}
Просто потому, что где-то совершенно в не связанном с ней классе написали:
class refarray {
public:
refarray(int length)
{
m_array = new int*[length];
for (int i = 0; i < length; i++) {
m_array[i] = nullptr;
}
}
int& operator[](int i)
{
// Разыменование указателя без проверки на null.
return *m_array[i];
}
private:
int** m_array;
};
И вызвали функцию так:
refresh(&(some_refarray[0]));
А деятельный компилятор, зная, что ссылки нулевыми не бывают, заинлайнил и удалил проверку. Здорово, не правда ли?
Возможно, вы думаете, что ситуации разыменования указателя до проверки — это, скорее, теоретические опасения, чем практическая беда. Команда PVS-Studio вынуждена расстроить — это одна из самых частых ошибок. На момент написания книги команда обнаружила в процессе проверки различных открытых проектов уже 1822 таких случая. Они бережно собраны в "коллекции ошибок", где с ними можно познакомиться и философски поразмышлять о бытие нулевых указателей.
Не забывайте проверять на nullptr, иначе оно взорвётся.
Проблемы с использованием объектов до окончания их полной инициализации наигрываются во многих языках программирования. Сомнительный дизайн с разрывом объявления, конструирования и инициализации можно воплотить в жизнь чуть ли ни где угодно. Но обычно для этого всё-таки надо приложить некоторые усилия. А в C и C++ можно вляпаться в это незаметно, случайно и очень долго не подозревать о случившемся.
В C и C++ мы можем разделять код программы по разным, независимым единицам трансляции (в разные .c/.cpp файлы). Они могут компилироваться параллельно. Скорость сборки повышается. И всё было бы хорошо.
Но как только в одном "модуле" появляется глобальная переменная, используемая в другом модуле, начинаются проблемы. И проблемы не только от того, что глобальные переменные в принципе признак не самого удачного дизайна. Проблема в том, что связи между модулями нет (заголовочные файлы ничего не связывают), и после объединения модулей код с инициализацией глобальной переменной может оказаться после кода с использованием.
Стандарты C и С++ гарантируют, что глобальные переменные будут сконструированы в порядке их объявления внутри единицы трансляции. А вот в какой последовательности они будут конструироваться, находясь в разных единицах трансляции, не определено. Вместе с этим не определено и поведение программы.
// module.h
extern int global_value;
// module.cpp
#include "module.h"
int init_func() {
return 5 * 5;
}
int global_value = init_func();
// main.cpp
#include "module.h"
#include <iostream>
static int use_global = global_value * 5;
int main() {
std::cout << use_global;
}
Результат будет зависеть от того, в каком порядке будут обработаны main.cpp и module.cpp.
До C++11 в следующем простеньком примере было неопределённое поведение как раз из-за возможности неправильного порядка инициализации статических объектов:
#include <iostream>
struct Init {
Init() {
std::cout << "Init!\n";
}
} init; // До C++11 не было гарантии,
// что std::cout сконструирован к этому моменту.
int main() {
return 0;
}
Бороться с неправильным порядком инициализации можно, например, организовав доступ к глобальной переменной через вызов функции.
// module.h
int global_variable();
// module.cpp
int global_variable() {
static int glob_var = init_func();
return glob_var;
}
В таком случае при первом же доступе инициализация гарантировано произойдёт.
Помимо неопределённого поведения из-за неправильного порядка инициализации, наиграть можно проблемы и с порядком деинициализации!
Стандарт C++ гарантирует, что деструкторы объектов всегда вызываются в порядке, обратном порядку завершения работы конструкторов.
#include <iostream>
#include <string>
const std::string& static_name() {
static const std::string name = "Hello! Hello! long long string!";
return name;
}
struct TestStatic {
TestStatic() {
std::cout << "ctor: " << "ok" << "\n";
}
~TestStatic() {
std::cout << "dctor: " << static_name() << "\n";
}
} test;
int main() {
std::cout << static_name() << "\n";
}
Сначала отрабатывает конструктор TestStatic. Затем main, вызвав static_name, конструирует строку. По завершении программы сначала уничтожается строка, а затем деструктор TestStatic обращается к уже уничтоженной строке.
Чтобы избежать подобного, можно либо в конструкторе TestStatic вызвать функцию static_name — тогда конструктор строки завершится до завершения конструктора TestStatic, и порядок уничтожения объектов будет другим.
Либо (и так иногда делают) в принципе предотвратить уничтожение статической строки: создать её в куче.
const std::string& static_name() {
static const std::string* name
= new std::string("Hello! Hello! long long string!");
return *name;
}
Но тогда вы соглашаетесь на утечку памяти. Конечно, никакой утечки на самом деле не будет — статический объект умрёт при завершении работы программы. И память всё равно будет освобождена. Однако утилиты, используемые для обнаружения утечек, обязательно укажут на ваш статический объект в куче, и вам придётся их отфильтровывать, чтобы не мешали искать настоящие утечки.
Для ускорения процесса сборки хорошей практикой в C++ является уменьшение количества подключаемых заголовков. Подключать стараются только то, что действительно используется. Если размер структур в конкретном файле не важен (например, используются только ссылки и указатели), то можно подключить отдельный маленький заголовок с предобъявлениями (например, iosfwd вместо iostream). Есть линтеры (cpplint, например), которые могут подсказывать, какие заголовочные файлы у вас совсем не используются. Все неиспользуемое — в мусор!
Если следовать подобным советам и подходам, исходники после препроцессинга получаются меньше. Меньше неиспользуемых символов. Повторяющихся символов тоже меньше — меньше работы для линкера. Красота. Все только выигрывают... Вроде бы.
На самом деле, есть подводные камни, об которые легко разбиться. И они связаны с порядком инициализации статических объектов (спасибо Egor Suvorov за концепцию примера).
Допустим, вы пишете библиотеку логгирования. Её интерфейс скромен:
// logger.h
#include <string_view>
void log(std::string_view message);
В интерфейсе используется только минимально необходимый заголовок.
В первой реализации вы решили логгировать в stdout с помощью стандартной библиотеки потоков ввода/вывода:
// logger.cpp
#include "logger.h"
#include <iostream>
void log(std::string_view message) {
std::cout << "INFO: " << message << std::endl;
}
Вы отладили свой логгер и выдали его чуть более широкому кругу пользователей. И один из них, любящий, например, создавать плагины с саморегистрирующимися фабриками, не ожидая никакого подвоха, воспользовался вашим логгером в своём любимом деле:
// main.cpp
#include "logger.h"
struct StaticFactory {
StaticFactory() {
log("factory created");
}
} factory;
int main() {
log("start main");
return 0;
}
Он, располагая компилятором GCC version 10.3.0 (Ubuntu 10.3.0-1ubuntu1), собрал приложение командой:
g++ -std=c++17 -o test main.cpp logger.cpp
Запустил, и оно сразу же упало с ошибкой сегментации. Тогда озадаченный пользователь отключил вашу библиотеку, вернулся к использованию проверенного временем iostream и написал вам баг-репорт, в котором почему-то привёл только исходник, а команду компиляции не приложил.
Вы пытаетесь воспроизвести падение на том же сборочном тулчейне и используете строку компиляции:
g++ -std=c++17 -o test2 logger.cpp main.cpp
Запускаете. И — о, чудо! — ничего не падает. Закрываем баг-репорт?
В этом примере очень злобная ошибка с нарушением порядка инициализации статических объектов. C++11 гарантирует, что объекты std::cin, std::cout, std::cerr и их "широкие" аналоги будут инициализированы до любого статического объекта, объявленного в вашем файле, только если заголовок <iostream> подключён перед объявлением ваших объектов. Достигается это в глубинах <iostream> созданием статического объекта std::ios_base::Init. До C++11 гарантий не было. Тёмные времена.
В своей заботе о минимизации зависимостей и размере обработанных препроцессором исходников (или просто последовав совету линтера), вы не включили iostream в интерфейсный заголовок библиотеки, но использовали его в реализации. Пользователь, не знающий об этом, получает проблемы. Не самое удачное решение.
Объекты стандартных потоков не единственная возможность для подобных ошибок. Любая библиотека, использующая глобальные статические объекты, не позаботившаяся об их инициализации до любых действий пользователя — потенциальный источник проблем. Если вы — автор библиотеки, внимательнее относитесь к проектированию её интерфейса. В C++ он не ограничивается только сигнатурами функций и описанием классов.
C++ славен тем, что почти все его конструкции невероятно сильно зависят от контекста, и, просто взглянув на случайный участок кода, крайне сложно быть уверенным в понимании того, что же он делает. Перегруженные операторы, контекстно-зависимые значения ключевых слов, ADL, auto, auto, auto!
Одно из самых перегруженных значениями ключевых слов в C++ — static:
В C++23 будут ещё и static-перегрузки для operator()! Это будет что-то новое, восхитительное и прекрасное.
Главное, не путать со static-модификатором при перегрузке других операторов вне класса, ведь это уже модификатор видимости! И, если написать в разных единицах трансляции что-нибудь вот такое:
/// TU1.cpp
static Monoid operator + (Monoid a, Monoid b) {
return {
a.value + b.value
};
}
Monoid sum(Monoid a, Monoid b) {
return a + b;
}
/// TU2.cpp
static Monoid operator + (Monoid a, Monoid b) {
return {
a.value * b.value
};
}
Monoid mult(Monoid a, Monoid b) {
return a + b;
}
/// main.cpp
int main(int argc, char **argv) {
auto v1 = sum({5}, {6}).value;
auto v2 = mult({5}, {6}).value;
std::cout << v1 << " " << v2 << "\n";
}
То оно даже будет работать ожидаемым образом, ведь никакой проблемы нет — определения локальны в единицах трансляции.
В C++17 дополнительными значениями обросло ещё и ключевое слово inline.
Когда-то оно было лишь подсказкой компилятору, что тело функции надо "встраивать" вместо вызова, то есть не делать относительно дорогой call с сохранением точки возврата, регистров, ещё чего-то, а прямо вместо вызова воткнуть инструкции... Подсказка эта, правда, не всегда работает. По разным причинам. Но в основном потому, что программисты писали и пишут её налево и направо даже туда, куда это делать не стоит, чтобы не раздувать чрезмерно получаемый код. Но это не наша история. Наша история о другом.
В современном C++ inline используется чаще всего только для того, чтобы поместить определение функции в заголовочный файл. В C это тоже работает, но совсем не так — вместо ошибки multiple definition, к которой приводит помещение не-inline функций в заголовочный файл, и которую мы хотели избежать, мы вовсе получили undefined reference.
В C inline-определения из заголовка нужно сопрячь модификатором static. И, возможно, получить code bloating, потому что получите копию функции в каждой единице трансляции, и все они будут считаться разными, если линковщик окажется недостаточно умным.
Либо всё-таки предоставить одно не-inline определение где-нибудь, например, вот таким мерзким трюком:
// square.h
#ifdef DEFINE_STUB
#define INLINE
#else
#define INLINE inline
#endif
INLINE int square(int num) {
return num * num;
}
// square.c
#define DEFINE_STUB
#include "square.h"
// main.c
#include "square.h"
int main() {
return square(5);
}
Или же упомянуть где-нибудь объявление этой функции со спецификатором extern (или даже без него может работать):
// square.h
inline int square(int num) {
return num * num;
}
// square.c
#include "square.h"
extern int square(int num);
// main.c
#include "square.h"
int main() {
return square(5);
}
Либо использовать GCC и собирать "сишный" код всегда с включёнными оптимизациями. Только release cборки! Я таких разработчиков тоже видел. Но работает это решение не всегда:
// square.h
inline int square(int num) {
return num * num;
}
inline int cube(int num) {
return num * num * num;
}
// main.c
#include "square.h"
#include <stdlib.h>
typedef int (*fn) (int);
int main() {
fn f;
if (rand() % 2) {
f = square;
} else {
f = cube;
}
// Адреса inline-функции неизвестны ->
// undefined reference
return f(5);
}
Но вернёмся к C++. Помимо функций, в заголовках иногда очень хочется определять ещё и переменные. В приличных проектах, конечно, в основном константы. Но разработка сложна, туманна и полна ужасов, а также нестандартных креативных решений, которые пришлось принять здесь и сейчас. Поэтому встречаются не только константы.
К сожалению, в C++ до 17 стандарта просто так взять и поместить в заголовочный файл определение какой-то константы было не всегда возможно. А если и возможно, то с интересными спецэффектами.
// my_class.hpp
struct MyClass {
static const int max_limit = 5000;
};
// main.cpp
#include "my_class.hpp"
#include <algorithm>
int main() {
int limit = MyClass::max_limit; // OK
return std::min(5, MyClass::max_limit); // Compilation error!
// std::min хочет принять ссылку,
// но линкер не знает адрес этой константы!
}
Можно написать:
// my_class.hpp
struct MyClass {
static constexpr int max_limit = 5000;
};
И оно заработает.
Но constexpr возможен не всегда, и тогда всё-таки придётся взять и отнести определение в отдельную единицу трансляции...
Пришёл C++17, и нашим мучениям настал конец! Теперь можно написать inline у переменной, и компилятор это съест, сгенерирует подобающую аннотацию для символа в объектном файле, чтобы линковщик более не кричал на multiple definition. Пусть берет любое — мы гарантируем, что все определения одинаковые, а иначе — undefined behavior.
// my_class.hpp
#include <unordered_map>
#include <string>
struct MyClass {
static const inline
std::unordered_map<std::string, int> supported_types_versions =
{
{"int", 5},
{"string", 10}
};
};
inline const
std::unordered_map<std::string, int> another_useful_map = {
{"int", 5},
{"string", 6}
};
void test();
// my_class.cpp
#include "my_class.hpp"
#include <iostream>
void test() {
std::cout << another_useful_map.size() << "\n";
}
// main.cpp
#include "my_class.hpp"
#include <algorithm>
#include <iostream>
int main() {
std::cout << MyClass::supported_types_versions.size() << "\n";
test();
}
Всё прекрасно работает — никаких multiple definitions и никаких undefined references! Невероятно похорошел C++ при 17-м стандарте!
Внимательный читатель уже должен был почувствовать и даже заметить подвох.
Вот перед вами блок кода:
DEFINE_NAMESPACE(details)
{
class Impl { .... };
static int process(Impl);
static inline const
std::vector<std::string> type_list = { .... };
};
Может ли что-то пойти не так?
Конечно же может! Это же C++!
DEFINE_NAMESPACE(name) может быть опредёлен как:
#define DEFINE_NAMESPACE(name) namespace name
А может быть как:
#define DEFINE_NAMESPACE(name) struct name
Что?! Да! Что, если из благих побуждений, чтобы спрятать доступ к перегрузке функции process от вездесущего ADL, однажды сумрачному гению автора библиотеки пришло в голову именно такое решение, которое включается и выключается всего одним макросом!
В таких случаях вообще-то type_list — это разные вещи.
В случае namespace — это static inline глобальная переменная. inline тут как бы бесполезен, потому что static модифицирует видимость глобальной переменной (linkage). В каждой единице трансляции, где такой заголовок окажется подключённым, будет своя копия переменной type_list.
В случае же class или struct этот static inline — поле, ассоциированное с классом, и оно будет одно на всех.
Ну ладно, какая разница! Они же константы и объявлены одинаково! Никто ничего не заметит на практике... Разумеется.
А теперь мы вспоминаем, что иногда нам нужны не константы. Например, если мы опять-таки делаем эту избитую систему с автоматической регистрацией плагинов при загрузке библиотек или иную систему авторегистрации типов.
Вот так всё работает. Красиво и ожидаемо.
// plugin_storage.h
#include <vector>
#include <string>
using PluginName = std::string;
struct PluginStorage {
static inline std::vector<PluginName> registered_plugins;
};
// plugin.cpp
#include "plugin_storage.h"
namespace {
struct Registrator {
Registrator() {
PluginStorage::registered_plugins.push_back("plugin");
}
} static registrator_;
}
// main.cpp
#include "plugin_storage.h"
#include <iostream>
int main() {
// Печатает ровно один элемент.
for (auto&& p : PluginStorage::registered_plugins) {
std::cout << p << "\n";
}
}
Но меняем struct PluginStorage на namespace PluginStorage — всё компилируется, но уже не работает. Переменная PluginStorage своя в каждой единице трансляции, поэтому в main мы видим пустой список. Нужно удалить static перед inline, и мы получим желаемое поведение снова.
Изменяемые глобальные статические переменные — это сложно везде. В Rust, например, обращение к ним обязательно требует unsafe. C++ ничего не требует. Вам нужно самим помнить о множественных синтаксических ритуалах, которые нужно произвести:
И ещё не забыть про многопоточный доступ.
С++17 породил static inline переменные. Они удобные, но только когда неизменяемые. Хотя и не беспроблемные. Средства просмотра изменений на ревью могут не показывать весь файл, а лишь часть с добавлением. Если видите static inline, не забудьте посмотреть, в каком он контексте. Если это проигнорировать, в лучшем случае ваши исполняемые файлы будут тяжёлыми, в худшем — можно уйти во многие часы безнадёжной отладки после какого-нибудь минималистичного изменения: кто-то объявление переменной с глобальным состоянием в заголовок вынес или наоборот внёс, логически же ничего не поменялось...
Изменяемые статики — страшное зло. С ними не только у рядовых разработчиков проблемы. Например, для Clang более года висел баг, связанный с порядком инициализации статиков внутри одной единицы трансляции из-за неправильной сортировки static глобальных переменных и static inline полей классов.
Вызвать функцию, которая не должна вызываться, испортить стек, сломать проверенную временем стороннюю библиотеку, довести до безумия программиста, пытающегося найти проблему под отладчиком — всё может ODR violation!
Вполне естественное и понятное правило, действующее во многих языках программирования: у одной и той же сущности должно быть не больше одного определения. Возьмём для примера функции. Их реализации могут отличаться. Наличие двух и более определений функции приводит к проблеме: а какое же использовать?
В некоторых языках неопределённости нет. Например, в Python каждое следующее определение перекрывает предыдущее:
# hello.py
def hello():
print("hello world")
hello() # hello world
def hello():
print("Hello ODR!")
hello() # Hello ODR!
В иных языках множественные определения просто приводят к ошибке компиляции.
fun x y = x + y
gun x y = x - y
fun x y = x * y
main = print $ "Hello, world!" ++ (show $ fun 5 6)
-- Multiple declarations of 'fun'
-- Declared at: 1124215805/source.hs:3:1
-- 1124215805/source.hs:7:1
С и С++ не исключения — в них переопределения функций, классов, шаблонов тоже диагностируются и выливаются в ошибку компиляции.
int fun() {
return 5;
}
int fun() { // CE: redefinition
return 6;
}
И вроде бы всё хорошо. Ожидаемое, отличное решение. Но есть нюансы.
Для статического анализа, конечно, очень удобно, если весь ваш код живёт в одном единственном файле. Но на практике обычно код разделяют на отдельные "модули", занимающиеся своей обособленной логикой. И вполне встречается ситуация, в которой два разных модуля содержат одноименные типы или функции. И это не должно вызывать проблем, должно работать из коробки... Но не в C и C++.
Знакомые с Python, наверное, знают, что в нём каждый отдельный файл — модуль — отдельное пространство имён. Имена классов и функции из разных файлов никак не интерферируют, до тех пор, пока не будут импортированы.
В C никогда модулей не было и, скорее всего, не будет. Вместо них — раздельная компиляция, работающая на возможности оставлять сущности объявленными (например, в "подключаемых" заголовочных файлах), но не определёнными (определение помещают в отдельную единицу трансляции, компилируемую независимо). Окончательная сборка и разрешение всех неопределённых имён откладываются до этапа линковки.
Никаких пространств имён также нет, и определение двух функций с одним и тем же именем в разных единицах трансляции нарушает ODR и... почти наверняка не будет отловлено на этапе компиляции. Возможно, если вам повезёт, и вы не забыли настроить опции линковки, проблема будет выявлена на следующем этапе. А если же вам не повезёт, вы попадёте в цепкие лапы неопределённого поведения.
Наибольшую неприятность доставляет то, что проблема не ограничивается сборкой лишь вашего кода. Ведь вы можете случайно использовать какое-то имя, встречающееся в сторонней библиотеке! И тогда можно сломать эту библиотеку как в своём проекте, так и в чужом, если ваш код будет использоваться в качестве зависимости. Причём достаточно случайно угадать лишь имя функции: в С нет перегрузок функции и определение функции с тем же именем, но с другими аргументами — ODR violation.
Из-за всех этих проблем в стандартах C и С++ даже указаны ограничения на имена, которые вы можете использовать в своём коде, чтобы случайно не сломать стандартную библиотеку!
Что же делать?
В мире чистого C с этим борются комплексом методов:
1. Ручной имплементацией механизма пространств имён. Каждой функции и структуре в проекте дописывают префиксом имя проекта.
2. Настраивают видимость символов:
3. Пишут скрипты для линкера, если предыдущий пункт пропустил в итоговый бинарь что-то лишнее.
Всё это, возможно, спасёт при интеграции с другими библиотеками. Но от переопределения ваших функций и структур внутри вашего же проекта почти не помогает.
В C++ ситуация немного лучше.
Во-первых, есть перегрузки функций: типы аргументов участвуют в формировании имён, используемых при линковке, так что всего лишь угадать имя недостаточно, чтобы встрять в неприятности, нужно ещё угадать аргументы (но не тип возвращаемого значения!)
Во-вторых, есть пространства имён, и вручную прописывать префиксы к каждой объявляемой функции не нужно.
В-третьих, есть анонимные пространства имён, позволяющие делать невидимым за пределами единицы трансляции всё, что определено внутри него.
// A.cpp
namespace {
struct S {
S() {
std::cout << "Hello A!\n";
}
};
}
void fun_A() {
S{};
}
// B.cpp
namespace {
struct S {
S() {
std::cout << "Hello B!\n";
}
};
}
void fun_B() {
S{};
}
Структуры S находятся в разных анонимных пространствах имён, проблем с нарушением ODR не возникает.
У меня в проекте долгое время существовали два определения вспомогательной приватной структуры префиксного дерева, но не были помещены в анонимное пространство имён. Всё прекрасно работало до тех пор, пока однажды не поменяли порядок компиляции файлов. И сразу SEGFAULT — в объявлениях были разные типы полей, и при тестировании происходило настоящее безумие. Хорошо, что это обнаружилось раньше, чем упало на боевом стенде.
Наконец, в C++, начиная с 20 стандарта, появились модули. Приватные, явно неэкспортируемые имена внутри одного модуля не интерферируют с именами из других модулей. Но для экспортируемых имён все проблемы сохраняются: объявлять пространство имён и следить за пересечениями надо самостоятельно.
Вместе с возможностями чуть реже нарушать ODR, в C++, конечно же, есть дополнительные возможности для неявного нарушения ODR — шаблоны.
Шаблоны инстанциируются в каждой единице трансляции, и при использовании одних и тех же параметров должны раскрываться в один и тот же код, чтобы не нарушить ODR.
В C++ мы можем определять функции, принадлежащие к какому угодно пространству имён, в любой единице трансляции. А шаблоны компилируются в два прохода с привлечением ADL (argument dependent lookup). И горе вам, если один из проходов вытянет разные функции!
struct A {};
struct B{};
struct D : B {};
// demo_1.cpp
bool operator<(A, B) { std::cout << "demo_1\n"; return true; }
void demo_1() {
A a; D d;
std::less<void> comparator;
comparator(a, d); // Шаблонный оператор ()
// ищет подходящее определение для <.
}
// demo_2.cpp
bool operator<(A, D) { std::cout << "demo_2\n"; return true; }
void demo_2() {
A a; D d;
std::less<void> comparator;
comparator(a, d);
}
int main() {
demo_1();
demo_2();
return 0;
}
В этом примере (спасибо LDVSOFT) разный порядок компиляции даёт разные результаты:
Занятно, что из-за специфики и трудности реализации двухэтапной компиляции шаблонов разные компиляторы будут давать разный результат, если поместить этот пример в одну единицу трансляции! И о проблеме никто не сообщит!
Для упрощения анализа печать строк заменена на печать чисел 1 и 2.
GCC:
demo_1():
mov esi, 1
mov edi, OFFSET FLAT:_ZSt4cout
jmp std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
demo_2():
mov esi, 1
mov edi, OFFSET FLAT:_ZSt4cout
jmp std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
MSVC:
void demo_1(void) PROC ; demo_1, COMDAT
push 2
mov ecx,
OFFSET
std::basic_ostream<char,std::char_traits<char> > std::cout
; std::cout
call std::basic_ostream<char,std::char_traits<char> > &
std::basic_ostream<char,std::char_traits<char> >::operator<<(int)
; std::basic_ostream<char,std::char_traits<char> >::operator<<
ret 0
void demo_1(void) ENDP
void demo_2(void) PROC ; demo_2, COMDAT
push 2
mov ecx,
OFFSET
std::basic_ostream<char,std::char_traits<char> > std::cout
; std::cout
call std::basic_ostream<char,std::char_traits<char> > &
std::basic_ostream<char,std::char_traits<char> >::operator<<(int)
; std::basic_ostream<char,std::char_traits<char> >::operator<<
ret 0
Код, собранный GCC, печатает 11, MSVC — 22.
Страшно? Не бойтесь! Если в этом примере operator < действительно предполагался приватным, то заворачивание его в анонимное пространство имён решило бы проблему. Внутри std::less<void>::operator() оператор < не был бы найден, и вы бы получили ошибку компиляции (она бы вам не понравилась). Пришлось бы использовать сравнение явно, и тут уже всё определено.
Используйте модули или помещайте приватные кишки в анонимные пространства имён, и будет вам счастье. Наверное.
ODR violation почти всегда ходит вместе с проблемами обновлений и слома ABI.
Вы обновили библиотеку, и теперь ваш код зависит от её более новой версии. Убедитесь, что другой код, зависящий от вашего, также использует новую версию этой библиотеки. Или хотя бы бинарно совместимую. Иначе — ODR violation, слом стека, нарушение конвенции вызова... ну, вы в курсе.
Слом ABI, потенциальное нарушение ODR — одни из самых острых причин, почему миграция на новые версии стандарта, компиляторов и библиотек в C++ мире занимает многие годы. Нужно всё пересобрать, всё перетестить, убедиться, что никто не привнёс неправильных имён.
Как это ни парадоксально, но возможность нарушить ODR иногда оказывается полезной. Связанное с ним неопределённое поведение является в каком-то смысле определённым и контролируемым: какое из определений будет использоваться, задаётся порядком, на который можно влиять. GCC, например, поддерживает __attribute__((weak)) для пометки функций, которые ожидаемо будут замещаться альтернативными определениями (с более эффективной реализацией, без отладочных инструкций, например). Или же техника symbol hooking, использующая LD_PRELOAD, чтобы заменить определённые функции из динамических библиотек для отладки с инструментированным аллокатором или же для перехвата вызовов и сбора статистики.
Эта тема тесно связана с ODR violation.
В C и C++ невероятно много идентификаторов, использовать которые для своих переменных и типов запрещено под страхом неопределённого поведения.
Некоторые имена запрещены самими стандартами C и C++, некоторые — стандартами POSIX, некоторые — платформоспецифическими библиотеками. В последнем случае вам обычно ничего не грозит, пока библиотека не подключена.
Так, в глобальной области видимости нельзя использовать имена функций из библиотеки C. Ни в C, ни в C++! Иначе вы можете столкнуться не только с ODR violation, но ещё и с удивительным поведением компиляторов, умеющих оптимизировать распространённые конструкции.
Так, если определить свой собственный memset:
void *memset (void *destination, int c, unsigned long n) {
for (unsigned long i = 0; i < n; ++i) {
((char*)(destination))[i] = c;
}
return destination;
}
Заботливый оптимизирующий компилятор может запросто превратить его в:
void *memset (void* destination, int c, unsigned long n) {
return memset(destination, c, n);
}
В C++, благодаря включённому по умолчанию декорированию имён, рекурсии не будет — вызовется стандартный memset вместо нашего.
Однако декорирование не спасает, если объявлять не функции, а глобальные переменные:
#include <iostream>
int read;
int main(){
std::ios_base::sync_with_stdio(false);
std::cin >> read;
}
При сборке такого примера со статически влинкованной стандартной библиотекой C программа упадёт (SIGSEGV), так как вместо адреса стандартной функции read будет подставлен адрес глобальной переменной read. Аналогичный пример с использованием имени write предлагается читателю воплотить самостоятельно в качестве упражнения.
Запретных имён много. Например, всё, что начинается с is*, to* или _* запрещено в глобальном пространстве. _[A-Z]* запрещены вообще везде. POSIX резервирует имена, заканчивающиеся на _t. И ещё много всего неожиданного.
Вы можете расширить пространства имён std или POSIX. Несмотря на то, что такая программа успешно компилируется и исполняется, модификация этих пространств имён может привести к неопределённому поведению программы, если иное не указано стандартом.
Содержимое пространства имён std определяется исключительно комитетом стандартизации, и стандарт запрещает добавлять в него:
Стандарт разрешает добавлять следующие специализации шаблонов, определённых в пространстве имён std, если они зависят хотя бы от одного определённого в программе типа (program-defined type):
Однако специализации шаблонов, лежащих внутри классов или шаблонов классов, запрещены.
В отличие от пространства имён std, какая-либо модификация пространства имён POSIX полностью запрещена.
Если вы пользуетесь запрещёнными именами, то сегодня может всё работать, но не завтра.
Чтобы не жить в страхе, во многих случаях достаточно использовать static или анонимные пространства имён. Или просто не использовать C и C++.
Автор — Дмитрий Свиридкин
Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.
Редактор — Андрей Карпов
Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.
0