>
>
>
Путеводитель C++ программиста по неопре…

Андрей Карпов
Статей: 662

Дмитрий Свиридкин
Статей: 6

Путеводитель C++ программиста по неопределённому поведению: часть 6 из 11

Вашему вниманию предлагается шестая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.

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

Наверняка все С++ (а уж просто C тем более) программисты знакомы с семейством функций printf. Одной из удивительных особенностей этих функций является возможность принимать произвольное число аргументов. А также на printf можно писать полноценные программы! Исследованию и описанию этого безумия даже посвящены отдельные статьи.

Мы же остановимся только на произвольном числе аргументов. Но для начала я расскажу одну занимательную историю.

Какая-то замечательная библиотека предоставляла красивую функцию:

template <class HandlerFunc>
void ProcessBy(HandlerFunc&& fun) 
requires std::is_invocable_v<HandlerFunc, T1, T2, T3, T4, T5>;

И программист думал вызвать эту восхитительную функцию. В качестве HandlerFunc подсунуть лямбду, в которой ему было совершенно наплевать на передаваемые аргументы T1, T2, T3, T4, T5. Что же он мог сделать?

Вариант первый: честно перечислить пять аргументов с их типами. Как деды делали.

ProcessBy([](T1, T2, T3, T4, T5) { do_something(); });

Если имена типов короткие, почему бы и нет. Но все равно как-то слишком подробно. Неудобно. Да и добавится новый аргумент — придётся и тут править. Не очень современный C++ подход.

Вариант второй: воспользоваться функциями с произвольным числом аргументов.

ProcessBy([](...){ do_something(); });

Вау, красота! Компактно и здорово. До чего прогресс дошёл! И оно скомпилировалось. И даже работало. И так программист и оставил.

Но однажды замечательная библиотека обновилась, стала лучше и безопаснее. И начались странные, необъяснимые падения. SIGILL, SIGABRT, SIGSEGV. Все наши любимые друзья хлынули в проект.

Что произошло? Кто виноват? Что делать? Без опытного сыщика тут не обойтись...

Давайте разбираться.

В C можно определять собственные функции, принимающие сколь угодно много аргументов. И сделать это можно двумя способами:

1. Пустой список аргументов.

void foo() {
  printf("foo");
}

foo(1,2,4,5,6);

Казалось бы, функция foo не должна в принципе принимать аргументы. Но нет. В C функции, объявленные с пустым списком аргументов, на самом деле являются функциями с произвольным числом аргументов. Действительно ничего не принимающая функция объявляется так:

void foo(void);

В C++ это безобразие исправили.

2. Эллипсис и va_list.

#include <stdarg.h>

void sum(int count, /* Чтобы получить доступ к списку аргументов, 
                       нужен хотя бы один явный. */
         ...) {
  int result = 0;
  va_list args;
  va_start(args, count);
  for (int i = 0; i < count; ++i) // Причём функция не знает,
                                  // сколько аргументов передали.
  {
    result += va_arg(args, int); // Запрашиваем очередной аргумент.
                                 // Функция не знает, какой у него тип.
                                 // Указываем самостоятельно — int.
  }
  va_end(args);
  return result;
}

Если явного аргумента не будет, то получить доступ к списку остальных нельзя. Более того, мы уйдём в область implementation-defined поведения.

Также на этот явный аргумент, предшествующий вариативной части, налагаются ограничения:

  • он не может быть помечен спецификатором register. Но это мало кому надо;
  • он не может иметь "повышаемый" тип. Привет нашим любимым integer/float promotion. Использовать floatshortchar нельзя.

Нарушаем ограничения явного аргумента — получаем неопределённое поведение. Запрашиваем у va_arg повышаемый тип — снова неопределённое поведение. Передаём не тот тип, что запрашиваем... правильно, неопределённое поведение.

Невероятные возможности по отстрелу рук и ног себе и пользователям кода! Собственно, на этих возможностях и идёт игра при атаках на printf.

И в C++, конечно же, эта прелесть осталась. И не просто осталась, но и значительно усилилась!

C — простой, маленький язык. В нём не так много типов: примитивы, указатели да пользовательские структуры.

В C++ есть ссылки. Есть объекты с интересными конструкторами и деструкторами. И вы уже наверняка догадались, что будет неопеределённое поведение, если засунуть ссылку или такой объект в качестве аргумента вариативной функции. Ещё больше возможностей для весёлой отладки!

Но C++ не был бы самим собой, если бы в нём эту проблему не "решили". Итак, у нас есть C++ style вариадики:

template <class... ArgT>
int avg(ArgT... arg) {
  // Доступно число аргументов.
  const size_t args_cnt = sizeof...(ArgT);
  // Доступны их типы.

  // Итерироваться по аргументам нельзя.
  // Нужно писать рекурсивные вызовы для обработки,
  // либо использовать fold expressions
  return (arg + ... + 0) / ((args_cnt == 0) ? 1 : args_cnt);
}

Не очень удобно, но намного лучше и безопаснее.

Ну что ж, теперь, когда все карты вскрыты, вернёмся к нашему детективу.

Убийца — C-вариадик!

ProcessBy([](...){ do_something(); });

Когда библиотека обновилась, в ней, незначительно на первый взгляд, поменялся один из типов T, которые передавались функцией ProcessBy в HandlerFunc. Но это изменение привело к неопределённому поведению.

А программисту же нужно было использовать C++ вариадик.

ProcessBy([](auto...){ do_something(); });

Всё. Всего одно слово auto, и никто бы не погиб. Удобно.

И, конечно, чтобы не было лишних копирований, надо дописать два амперсанда:

ProcessBy([](auto&&...){ do_something(); });

Вот теперь всё. Прекрасный способ принять и проигнорировать сколь угодно много аргументов. Ну, а тем программистом был когда-то я сам.

Полезные ссылки

Неработающий синтаксис и стандартная библиотека: operator [] ассоциативных контейнеров

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

В стандартной библиотеке C++ много неоднозначных решений. Одно из таких: для ассоциативного контейнера объединить операцию вставки и получения элемента.

operator [] для ассоциативных контейнеров пытается вызвать конструктор по умолчанию для элемента, если не находит переданный ключ.

С одной стороны, это удобно:

std::map<Word, int> counts;
for (Word c : text) {
  ++counts[word]; // ровно один поиск по ключу 
}

В иных языках придётся постараться, чтобы записать то же самое и не допустить повторного поиска. В Java:

// Поиск трижды!
map.put(key, map.containsKey(key) ? map.get(key) + 1 : 1);

// Поиск дважды!
map.put(key, map.getOrDefault(key, 0) + 1);

Оно, конечно, может быть, отоптимизируется JIT-компилятором... Но мы в C++ любим гарантии.

С другой стороны, вызов конструктора, если элемент не найден, может выйти боком:

struct S {
  int x;
  explicit S (int x) : x {x} {}
};

std::map<int, S> m { { 1, S{2} }}; // Ok
m[0] = S(5);   // Огромная трудночитаемая ошибка компиляции.
auto s = m[1]; // Опять огромная трудночитаемая ошибка компиляции.

Или другой сценарий:

struct Huge {
  Huge() { 
    data.reserve(4096);
  }
  std::vector<int> data;
};

std::map<int, Huge> m;
Huge h; 
.... /* заполняем h */
m[0] = std::move(h); // Бесполезный вызов default-конструктора,
                     // лишняя аллокация, 
                     // а потом перемещение.

Чтобы выпутаться из этой неприятности, у ассоциативных контейнеров к C++17 (20) наплодили целую гору методов insert_or_assigntry_emplace и insert с непонятным для непосвящённых возвращаемым значением pair<iterator, bool>.

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

С operator [], конечно же, проще, "понятнее" и короче. Но это же и ловушка для невнимательных. А если ещё и с мерзопакостными особенностями других объектов скрестить...

std::map<std::string, std::string> options {
  {"max_value" , "1000"};
}
....
const auto ParseInt = [](std::string s) {
  std::istringstream iss(s);
  int val;
  iss >> val;
  return val;
};

// Перепутали! Нет такого поля!
const int value = ParseInt(options["min_value"]);

// value == 0. Все "ok". Счастливой отладки!
// operator[] вернул пустую строку.
// operator>> ничего не прочёл и записал ноль в результат.

Избежать неприятностей с operator[] для ассоциативных контейнеров можно, навесив const. И тогда вам этот оператор доступен не будет. И придётся использовать либо .at, бросающий исключения, либо всеми любимый:

if (auto it = m.find(key); it != m.end()) {
  // Делай что хочешь с *it, it->second.
}

Всё просто.

Неработающий синтаксис и стандартная библиотека: iostreams, или счастливой отладки!

Стандартная библиотека потоков ввода/вывода в C++ стара, неудобна и полна ужасов. Непосредственно с неопределённым поведением при её использовании столкнуться проблематично, но нервы потрепать можно. И веселье обычно начинается, когда ваш маленький изолированный и совершенно корректный код, использующий std::istream или std::ostream, становится частью чего-то большого.

Грабли первые. Состояние объекта i/ostream

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

Не нормально же то, что у этой же самой сущности есть дополнительное мутабельное состояние, ответственное за форматирование данных... Механизм манипуляторов. И он кошмарен.

std::cout << std::hex << 10; // 'a', ок
std::cout << 10; // опять 'a' ?!?!

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

auto state = std::cout.flags();
std::cout << std::hex << 10; // 'a'
std::cout.flags(state);
std::cout << 10; // 10, ок

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

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

Ну и, конечно, состояние форматирования — дополнительная возможность пострелять по ногам в многопоточной среде.

Грабли вторые. Глобальная локаль

Мало нам мутабельного состояния с флагами форматирования. Оно хотя бы привязано к конкретному экземпляру i/ostream. У нас ещё и конструирование новых экземпляров завязано на глобальную мутабельную переменную — текущую глобальную локаль.

Локали это, конечно, отдельная головная боль. И не только для C и C++, а вообще. Но это далеко за рамками этой книги.

Тут важно лишь то, что i/ostreams локалезависимые. И не только они, но и множество функций std::to_stringatofstrtol и прочие прекрасные функции преобразования чего-то к строкам и обратно.

А теперь фокус, демонстрирующий проблему, обнаруживаемую (а потом уныло исправляемую) на каком-то этапе жизни совершенно любой C++ библиотеки, берущейся парсить текстовые форматы данных:

int main(int argc, char **argv) {
  auto s = std::to_string(1.5f);
  std::istringstream iss1(s);
  float f1 = 0; iss1 >> f1;
  assert(fabs(f1 - 1.5) < 1e-6); // Ok

  std::locale::global(std::locale("de_DE.UTF8"));
  std::istringstream iss2(s);
  float f2 = 0; iss2 >> f2;
  assert(fabs(f2 - 1.5) < 1e-6); // Сюрприз! f2 == 1500000
}

Грабли третьи. Кодировка путей к файлам и fstream

UTF8 — это прекрасно. UTF8 — это хорошо. У вас код, скорее всего, в UTF8. Python строки по умолчанию гоняет в UTF8. Да все кому не лень гоняют в UTF8! 2024 год. Юникод!

Хотя, конечно, не так уж всё всегда и везде прекрасно. GCC уже 13 лет не могут исправить проблему с BOM-заголовком.

А что, если вашей C++ программе приходит UTF8-строка с путём к файлу, который надо открыть?

void fun(const std::string& filename) {
  std::ifstream file(filename);
}

И всё хорошо? И всё работает? И кириллица? И китайское письмо? Точно работает? А под Windows? И примерно в этот момент выясняется, что всё-таки не работает.

Конструктор std::fstream, ровно как и "сишные" fopen, особым умом не отличаются. Про ваш Юникод они ничего знать не знают. И что он от нативной кодировки системы внезапно может отличаться, не догадываются.

В итоге мы получаем, что почти каждая C++ программа сталкивается с багом под Windows: стоит в пути к файлу встретиться не-ASCII-символу, так сразу файл не найден.

Грабли четвёртые. binary mode

Бинарный режим чтения и записи файлов — ещё одна отдельная боль, от которой страдают на самых разных языках. Чтение бинарных данных из stdin, запись в stdout (которые по умолчанию открыты в текстовом режиме), теряющиеся или лишние добавляемые байты CR (\r) — всё как мы любим.

Но в C++ у нас есть дополнительные возможности для страданий.

Я довольно часто встречаю эту ошибку, и не только в студенческих работах:

std::ifstream file(name, std::ios::binary);
char x = 0;
file >> x; // считают, что будет чтение одного байта.

Но нет. operator>> для стандартных типов всегда пытается выполнить форматное чтение. И по умолчанию все пробельные символы будут пропущены. Более того, у нас в принципе нет возможности узнать, в каком режиме открыт поток! Нужно самостоятельно где-то сохранить информацию об этом.

Аналогично, но ошибка быстрее проявляется:

std::ifstream file(name, std::ios::binary);
int x = 0;
file >> x; // считают, что будет чтение sizeof(int) байт.

Также очень распространён особо мерзкий случай:

std::ifstream file(name, std::ios::binary);
std::string s;
file.read(reinterpter_cast<char*>(&s), sizeof(s)); // UB!

Неопытные программисты, тестирующие на коротких строках и успокаивающиеся на этом, могут столкнуться с тем, что код будет работать "так, как они и предполагали". Исключительно из-за особенности современной реализации строк и техники SSO (small string optimization): строка реализуется не просто как три поля (data, size, capacity), а, если она короткая, записывается прямо поверх этих полей.

Но, конечно же, это некорректно.

Грабли пятые. Ошибки чтения. Конец потока

У потоков ввода/вывода есть ещё одни флаги — состояние потока: были ли ошибки, достигли ли конца. И многие знают, что проверить успешность операции можно, засунув объект потока в условный оператор (или в иной другой контекст, где выполняется приведение к bool).

А те, кто не знает, используют проверку вида while (!iss.eof()) и однажды наступят на грабли вечного цикла. Такое произойдёт, когда файл ещё не закончился, но и читать его дальше нельзя. Например, если это файл на сетевом диске, и сеть отвалилась. Впрочем, это уже совсем другая история. Вернёмся к корректной проверке возможности чтения.

std::istringstream iss("\t aaaa \n bb  \t ccc dd e ");
std::string token;
int count = 0;
while (iss >> token) {
  ++count;
}
assert(count == 5); // OK

Тут будут прочитаны все пять токенов из строки. Ни больше, ни меньше.

Если будет ошибка:

std::istringstream iss("1 2 3 рг 5");
int token = 0;
int count = 0;
while (iss >> token) {
  ++count;
}
std::cout << token; // Выведет 0 ! 
assert(count == 3); // OK

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

А что, если бинарные данные почитать?

std::istringstream iss("12345");
std::array<char, 4> buf;
int read_count = 0;
while (iss.read(buf.data(), 4)) {
  read_count += iss.gcount();
}
assert(read_count == 5); // Упс, последний байт не учёлся.

А тут у нас EOF при чтении. А значит, ошибка. И всё равно, что один байт-то прочитался успешно.

Ну хорошо, в C есть прекрасные fread, которые сразу возвращают количество считанных байт, и получается красивый цикл. Может, что-то такое есть и у C++ потоков? Конечно есть!

Грабли шестые. readsome

std::istringstream iss("12345");
std::array<char, 4> buf;
int read_count = 0;
while (iss.readsome(buf.data(), 4) > 0) {
  read_count += iss.gcount();
}
assert(read_count == 5);

Вау, работает!

На самом деле, нет. Идём на cppreference и читаем:

The behavior of this function is highly implementation-specific. For example, when used with std::ifstream, some library implementations fill the underlying filebuf with data as soon as the file is opened (and readsome() on such implementations reads data, potentially, but not necessarily, the entire file), while other implementations only read from file when an actual input operation is requested (and readsome() issued after file opening never extracts any characters).

В общем, не работает. Упражнение по замене istringstream на ifstream в примере выше предлагаю читателю проделать самостоятельно.

Неработающий синтаксис и стандартная библиотека: оператор запятая

Если вы начинали своё знакомство с программированием с языков Pascal или C#, то, наверное, знаете, что в них обращение к элементам двумерного массива (а также массивов большей размерности) осуществляется перечислением индексов через запятую внутри квадратных скобок:

double [,] array = new double[10, 10];
double x = array[1,1];

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

В C и C++ же на каждую размерность должны быть свои квадратные скобки:

double array[10][10];
double x = array[1][1];

Однако написать "неправильно" нам никто не запрещает и, более того, компилятор обязан это скомпилировать!

int array[5][5] = {};
std::cout << array[1, 4]; // oops!

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

Почему это вообще компилируется?

Все дело в операторе "запятая" (,). Она последовательно вычисляет оба своих аргумента и возвращает второй (правый).

int array[2][5] = {}
auto x = array[1, 4]; // Oops! Это array[4]. 
// Но для первой размерности максимальное значение = 1.
// Неопределённое поведение!

В C++20, на наше счастье, использование оператора запятая (,) при индексировании массивов пометили как deprecated, и теперь компиляторы сыпят предупреждениями (вы всегда можете их превратить в ошибки).

Кстати, облажаться с запятой можно не только при работе с массивами. Например, есть возможность опечататься при написании констант:

double A = 1,23; // Упс, A равно 23, а не 1.23.

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

Перегрузки оператора ,

Запятую можно перегрузить. И посеять ещё больше хаоса.

return f1(), f2(), f3();

Если (,) не перегружена, стандарт гарантирует, что функции будут вызваны последовательно. Если же тут вызывается перегруженная запятая, то до C++17 такой гарантии нет.

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

auto test() {
  return f1(), f2(), f3();
}

int main() {
  test();
  static_assert(!std::is_same_v<decltype(f3()), int>);
  static_assert(std::is_same_v<decltype(test()), int>); // ??!
  return 0;
}

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

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

template <class... F>
void invoke_all(F&&... f) {
  (static_cast<void>(f()), ...);
}

int main() {
  invoke_all([]{
    std::cout << "hello!\n";
  },
  []{
    std::cout << "World!\n";
  });
  return 0;
}

Зачем вообще может понадобиться перегружать запятую?

Может быть, для какого-нибудь DSL (domain-specific language).

Или вдруг вам всё-таки захочется сделать так, чтоб индексация через запятую работала.

struct Index { size_t idx; };

template <size_t N>
struct MultiIndex : std::array<Index, N> {};

template <size_t N, size_t M>
auto operator , (MultiIndex<N> i1, MultiIndex<M> i2) { .... }

template <size_t M>
auto operator , (Index i1, MultiIndex<M> i2) { .... }

template <size_t N>
auto operator , (MultiIndex<N> i1, Index i2) { .... }

auto operator , (Index i1, Index i2) { .... }

Index operator "" _i (unsigned long long x) {
  return Index { static_cast<size_t>(x) };
}

template <class T, size_t N, size_t M>
struct Array2D {
  T arr[N][M];

  T& operator [] (MultiIndex<2> idx) {
    return arr[idx[0].idx][idx[1].idx];
  }
};

int main() {
  Array2D<int, 5, 6> arr;

  arr[1_i, 2_i] = 5;
  std::cout << arr[1_i, 2_i]; // Ok
  std::cout << arr[1_i, 2_i, 3_i]; // Compilation error
}

Неработающий синтаксис и стандартная библиотека: function-try-block

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

// Стандартный способ
void f() {
  try {
    may_throw();
  } catch (...) {
    handle_error(); 
  }
}

// Альтернативный синтаксис 
void f() try {
  may_throw();
} catch (...) {
  handle_error();
}

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

struct ThrowInCtor {
  ThrowInCtor() {
    throw std::runtime_error("err1");
  }
};


struct TryStruct1 {
  TryStruct1() try {

  } catch (const std::exception& e) {
    // Будет поймано исключение из конструктора `c`.
    std::cout << e.what() << "\n";
  }
  ThrowInCtor c;
};

struct TryStruct2 {
  TryStruct2() {
    try {

    } catch (const std::exception& e) {
      // Исключение не будет поймано,
      // поскольку тело конструктора
      // исполняется после инициализации полей.
      std::cout << e.what() << "\n";
    }
  }
  ThrowInCtor c;
};

На примере с try-block для конструктора мы сталкиваемся с, на первый взгляд, странной неожиданностью: несмотря на блок catch, исключение вылетает в код, вызывающий конструктор, — и код выше печатает:

err1
something wrong
something wrong

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

Потому можно иногда встретить такие страшные нагромождения:

struct S {
  S(....) try :
      a(....),
      b(....) {
    try {
      init();
    } catch (const std::exception& e) {
      log(e);
      try_repair();
    }    
  } catch (const std::exeption& e) {
    // Не получилось починить или
    // неисправимая ошибка в полях.
    log(e);
    // implicit rethrow
  }

  A a;
  B b;
};

Ну хорошо. А как насчёт деструкторов? Ведь из деструкторов крайне нежелательно выкидывать исключения, и возможность красиво и просто поставить catch, который бы гарантированно перехватил всё, весьма недурна.

struct DctorThrowTry {
  ~DctorThrowTry() try {
    throw std::runtime_error("err");
  } catch (const std::exception& e) {
    std::cout << e.what() << "\n";
  }
};

Выглядит неплохо. Но у нас C++, так что это не работает!

Кто-то очень доброжелательный решил, что в случае с деструкторами поведение по умолчанию должно быть таким же, как и с конструкторами. То есть catch-блок деструктора неявно прокидывает исключение дальше. И привет всем возможным проблемам с исключениями из деструкторов, в том числе нарушению неявного noexcept(true).

Однако, в отличие от конструкторов, для деструкторов добавили возможность подавить неявное пробрасывание пойманного исключения. Для этого нужно всего лишь... добавить return!

struct DctorThrowTry {
  ~DctorThrowTry() try {
     throw std::runtime_error("err");
  } catch (const std::exception& e) {
    std::cout << e.what() << "\n";
    return; // Исключение не будет перевыброшено!
  }
};

Удивительно, но из-за этого в C++ есть случай, в котором return последней командой в void-функции меняет её поведение.

Также нужно добавить, что в catch блоке деструкторов и конструкторов нельзя обращаться к нестатическим полям и методам класса — будет неопределённое поведение. По понятным причинам. В момент входа в catch блок они все уже мертвы.

struct S {
  A a;
  B b;

  S() try {
    ....
  } catch (...) {
     do_something(a); // UB!
  }

  ~S() try {
    ....
  } catch (...) {
    do_something(b); // UB!
    return;
  } 
};

// Но при этом

bool fun(T1 a, T2 b) try {
  ....
  return true;
} catch (...) {
  // Важно: этот блок не ловит исключения,
  // возникающие при инициализации a и b.
  do_something(a); // Ok!
  return false;
}

Итого

  • Для обычных функций и main с помощью альтернативного синтаксиса можно удобно и красиво перехватывать все исключения, которые могли бы вылететь. И поведение по умолчанию — именно перехват. Дальше не летит.
  • Для конструкторов можно ловить исключения из конструкторов полей, обрабатывать их (печатать в лог), но подавить нельзя. Либо кидаете своё, новое исключение, либо пойманное неявно будет проброшено дальше.
  • Для деструкторов также будет неявный проброс, но его можно подавить, добавив return.

Полезные ссылки

Неработающий синтаксис и стандартная библиотека: типы "нулевого" размера

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

struct MyTag {};

Конечно же, мы можем не только объявлять пустые структуры, но и создавать объекты этих типов.

struct Tag {};

Tag func(Tag t1) {
  Tag t2;
  return Tag{};
}

Возможности, несомненно, полезные и широко используемые:

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

А давайте сыграем в игру? Я буду показывать вам разные определения структур, а вы постараетесь угадать их размеры в байтах (sizeof). Начинаем?

struct StdAllocator {};

struct Vector1 {
  int* data;
  int* size_end;
  int* capacity_end;
  StdAllocator alloc;
};

struct Vector2 {
  StdAllocator alloc;
  int* data;
  int* size_end;
  int* capacity_end;
};

struct Vector3 : StdAllocator {
  int* data;
  int* size_end;
  int* capacity_end;
};

Угадали?

Vector1 и Vector2 имеют размеры 4*sizeof(int*). Но как же так?! Откуда берутся 3*sizeof(int*), совершенно очевидно. Но четвёртый-то откуда?!

Все очень просто: в C++ не бывает структур нулевого размера. И потому размер пустой структуры sizeof(StdAllocator) == 1.

Но sizeof(int*) != 1. По крайней мере на x86. А это ещё проще: выравнивание и паддинг. Vector1 дополняется байтами конце, чтобы его размер был кратен выравниванию первого поля. А в Vector2 дополняется байтами между alloc и data, чтобы смещение до data было кратным его выравниванию. Всё очень просто и очевидно! Если же вам, как и многим другим людям, которые не задаются подобными вопросами каждый день, не очевидно наличие паддинга в той или иной структуре, то советую использовать флаг компилятора -Wpadded для GCC/Clang.

Хорошо, мы разобрались с Vector1 и Vector2. А что там с Vector3? Тоже 4*sizeof(int*)? Ведь мы же знаем, что подобъект базового класса должен быть где-то размещён, а его размер, как мы выяснили, не нулевой... А вот и нет! Размер Vector3 равен 3*sizeof(int*)! Но как же так?! А это называется EBO (empty base optimization).

Интересный zero-cost! Для сравнения можно глянуть на аналогичные пустые структуры в Rust. Там их размер может быть равен нулю.

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

struct StdAllocator {};
struct StdComparator {};

struct Map1 {
  StdAllocator alloc;
  StdComparator comp;
};

struct Map2 {
  StdAllocator alloc;
  [[no_unique_address]] StdComparator comp;
};

struct Map3 {
  [[no_unique_address]] StdAllocator alloc;
  [[no_unique_address]] StdComparator comp;
};

struct MapImpl1 : Map1 {
  int x;
};

struct MapImpl2 : Map2 {
  int x;
};

struct MapImpl3 : Map3 {
  int x;
};

Чему равны размеры Map1Map2Map3?

Ну, тут все просто:

  • очевидно, что sizeof(Map1) == 2, ведь она состоит из двух пустых структур, каждая из которых имеет размер 1;
  • благодаря атрибуту [[no_unique_address]] из стандарта C++20 (Clang поддерживает с C++11), Map2 и Map3 должны иметь размер 1. В Map3 оба поля разделяют общий адрес. В Map2 то же самое. Да и меньше, чем 1, не бывает.

Хорошо. А что же теперь с наследующими структурами?

Все по 2*sizeof(int)? А вот и нет: у MapImpl3 работает EBO!

Ну ладно. В этом есть какая-то логика и закономерность. Это ещё можно принять. Хотя... На самом деле, вы были правы! Ведь если у вас компилятор MSVC, то [[no_unique_address]] просто не работает. И не будет работать. Потому что MSVC долгое время просто игнорировал незнакомые ему атрибуты. И если поддержать [[no_unique_address]], то сломается бинарная совместимость. Используйте [[msvc::no_unique_address]]! EBO, правда, пока не работает.

zero-size array

Язык C (не С++), начиная с версии стандарта 99, позволяет использовать следующую любопытную конструкцию:

struct ImageHeader{
  int h;
  int w;
};

struct Image {
  struct ImageHeader header;
  char data[];
};

Поле data в структуре Image имеет нулевой размер. Это FAM (flexible array member). Очень удобная штука, чтобы получать доступ к массиву статически не известной длины, размещённому сразу после некоторого заголовка в бинарном буфере. Длина массива обычно указывается в самом заголовке. FAM может быть только последним полем в структуре.

Стандарт C++ такие фичи не разрешает. Но ведь есть GCC с его нестандартными включёнными по умолчанию расширениями.

Что будет, если сделать так?

struct S {
  char data[];
};

Чему будет равен размер структуры S?

В стандартном C пустые структуры в принципе запрещены. И поведение программы с ними не определено. GCC определяет их размер нулевым при компиляции C-программ. А при компиляции C++ размер, как мы выяснили ранее, единичный. Дело пахнет страшными багами и ночными кошмарами при неосторожном проектировании C++ библиотек с "сишным" интерфейсом или использованием C-библиотек в C++!

Но вернёмся всё-таки к нашей структуре с FAM. Поле в ней есть. Стандартный C опять-таки требует, чтобы было ещё хотя бы одно поле ненулевой длины перед FAM. GNU C же охотно сделает нам структуру нулевого размера.

А теперь посмотрим на GCC C++:

struct S1 {
  char data[];
};

struct S2 {};

static_assert(sizeof(S1) != sizeof(S2));
static_assert(sizeof(S1) == 0);

И вот уже внезапно у нас в C++ структуры нулевого размера. Только C++ не стандартный. Каким образом такие структуры будет взаимодействовать с EBO, нужно читать в спецификации к GCC.

tag dispatching

Мы видели, что неаккуратное использование пустых структур приводит к увеличению размера других, не пустых структур. А может ещё есть какие-то подводные камни? Например, при использовании пустых структур-тегов для выбора перегрузки?

Есть ли разница между:

struct Mul {};
struct Add {};

int op(Mul, int x, int y) {
  return x * y;
} 

int op(Add, int x, int y) {
  return x + y;
}

и

int mul(int x, int y) {
  return x * y;
} 

int add(int x, int y) {
  return x + y;
}

в плане генерируемого кода?

Краткий ответ: да. Есть разница. Зависит от конкретной имплементации. Стандарт не гарантирует оптимизацию пустых аргументов. От перемены позиций тегов может меняться бинарный интерфейс. Поиграться с наиболее заметными изменениями можно на примере MSVC.

Послесловие про оптимизацию размеров структур

Простой перестановкой полей можно уменьшать размер структур. Например, на классической 32-битной архитектуре размер этой структуры из-за выравнивания равен 16 байтам:

struct A {
  int  x;
  char foo_x;
  int  y;
  char foo_y;
};

А вот этой уже — 12 байт:

struct A {
  int  x;
  int  y;
  char foo_x;
  char foo_y;
};

Подобные оптимизации не нужны, если объекты создаются штучно, и не всегда возможны, если структуры отображают некую внешнюю структуру данных.

Однако если объекты создаются миллионами, то можно заметно оптимизировать потребление памяти простейшим рефакторингом. Единственная проблема — не всегда сразу видно, какие структуры можно оптимизировать, а какие нет. Тем более на разных архитектурах разные размеры данных и правила выравнивания. Жизнь облегчают анализаторы кода. Например, в PVS-Studio для этой задачи есть диагностика V802.

От структуры никак не избавиться, рекомендую обратить внимание на всё более популярную технику: преобразование массива структур (AoS, Array-of-Structs) в структуру из массивов (SoA, Struct-of-Arrays).

Полезные ссылки

Автор — Дмитрий Свиридкин

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на 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 активности.