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

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

![1163_book_pt_6_ru/image1.png](https://import.viva64.com/docx/blog/1163_book_pt_6_ru/image1.png)

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

Наверняка все С\+\+ \(а уж просто C тем более\) программисты знакомы с семейством функций _printf_\. Одной из удивительных особенностей этих функций является возможность принимать произвольное число аргументов\. А также на _printf_ можно писать [полноценные программы](https://github.com/carlini/printf-tac-toe)\! Исследованию и описанию этого безумия даже посвящены отдельные [статьи](https://www.usenix.org/system/files/conference/usenixsecurity15/sec15-paper-carlini.pdf)\.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Казалось бы, функция _foo_ не должна в принципе принимать аргументы\. [Но нет](https://godbolt.org/z/vY7fvdo9h)\. В C функции, объявленные с пустым списком аргументов, на самом деле являются функциями с произвольным числом аргументов\. Действительно ничего не принимающая функция объявляется так:

```cpp
void foo(void);
```

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

2\. Эллипсис и _va\_list_\.

```cpp
#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\. Использовать _float_, _short_, _char_ нельзя\.

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

Невероятные возможности по отстрелу рук и ног себе и пользователям кода\! Собственно, на этих возможностях и идёт игра при [атаках на _printf_](https://pvs-studio.ru/ru/blog/posts/cpp/0129/)\.

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

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

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

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

```cpp
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\-вариадик\!

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

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

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

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

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

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

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

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

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

1. Михаил Зинин\. [C\+\+: сеанс спонтанной археологии и почему не стоит использовать вариативные функции в стиле C](https://habr.com/ru/articles/430064/)\.
1. Cppreference\. [va\_list](https://en.cppreference.com/w/c/variadic/va_list)\.
1. LiveOverflow\. [A simple Format String exploit example \- bin 0x11](https://youtu.be/0WvrSfcdq1I?si=lhp4H535MGqS-bbm)\.

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

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

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

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

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

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

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

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

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

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

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

```cpp
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]; // Опять огромная трудночитаемая ошибка компиляции.
```

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

```cpp
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\_assign_, _try\_emplace_ и _insert_ с непонятным для непосвящённых возвращаемым значением _pair<iterator, bool\>_\.

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

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

```cpp
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_, бросающий исключения, либо всеми любимый:

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

Всё просто\.

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

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

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

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

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

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

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

```cpp
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\_string_, _atof_, _strtol_ и прочие прекрасные функции преобразования чего\-то к строкам и обратно\.

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

```cpp
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 лет [не могут](https://pvs-studio.ru/ru/blog/posts/cpp/1141/) исправить проблему с BOM\-заголовком\.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Неопытные программисты, тестирующие на коротких строках и успокаивающиеся на этом, могут столкнуться с тем, что код будет работать "так, как они и предполагали"\. Исключительно из\-за особенности современной реализации строк и техники [SSO \(_small string optimization_\)](https://pvs-studio.ru/ru/blog/terms/6658/): строка реализуется не просто как три поля \(data, size, capacity\), а, если она короткая, записывается прямо поверх этих полей\.

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

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

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

А те, кто не знает, используют проверку вида _while \(\!iss\.eof\(\)\)_ и однажды наступят на [грабли вечного цикла](https://pvs-studio.ru/ru/docs/warnings/v663/)\. Такое произойдёт, когда файл ещё не закончился, но и читать его дальше нельзя\. Например, если это файл на сетевом диске, и сеть отвалилась\. Впрочем, это уже совсем другая история\. Вернёмся к корректной проверке возможности чтения\.

```cpp
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
```

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

Если будет [ошибка](https://godbolt.org/z/PG317rx8o):

```cpp
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
```

Ну тоже логично\. А на токене, на котором произошла ошибка, результат зануляется\. Если сильно надо, можно настроить [выбрасывание исключений](https://en.cppreference.com/w/cpp/io/basic_ios/exceptions)\.

А что, если бинарные данные [почитать](https://godbolt.org/z/4dMEabM59)?

```cpp
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

```cpp
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);
```

Вау, [работает\!](https://godbolt.org/z/4KbGrdbcW)

На самом деле, нет\. Идём на cppreference и [читаем](https://en.cppreference.com/w/cpp/io/basic_istream/readsome):


> 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\#, то, наверное, знаете, что в них обращение к элементам двумерного массива \(а также массивов большей размерности\) осуществляется перечислением индексов через запятую внутри квадратных скобок:

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

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

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

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

Однако написать "неправильно" нам никто не запрещает и, более того, компилятор обязан это [скомпилировать](https://godbolt.org/z/G4zhYdnd1)\!

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

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

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

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

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

В C\+\+20, на наше счастье, использование оператора запятая \(_,_\) при индексировании массивов пометили как deprecated, и теперь компиляторы [сыпят предупреждениями](https://godbolt.org/z/976Gsad1o) \(вы всегда можете их превратить в ошибки\)\.

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

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

Есть и другие [вариации опечаток с запятой](https://pvs-studio.ru/ru/blog/examples/v521/)\. На этом можно было бы и закончить, если бы не один нюанс\.

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

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

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

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

В случае встроенной запятой гарантируется, что тип результата совпадает с последним аргументом в цепочке\. Если же оператор перегружен, тип может быть [каким угодно](https://godbolt.org/z/T8oGvMEaT)\.

```cpp
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](https://ru.wikipedia.org/wiki/SFINAE)\.

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

```cpp
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\)\.

Или вдруг вам всё\-таки захочется сделать так, чтоб индексация через запятую [работала](https://godbolt.org/z/doET59dY8)\.

```cpp
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\+\+ существует альтернативный синтаксис для определения тела функции, позволяющий навесить на него целиком перехват и обработку исключений\.

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

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

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

```cpp
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;
};
```

На [примере](https://godbolt.org/z/z6hfP83Mz) с _try\-block_ для конструктора мы сталкиваемся с, на первый взгляд, странной неожиданностью: несмотря на блок _catch_, исключение вылетает в код, вызывающий конструктор, — и код выше печатает:

```cpp
err1
something wrong
something wrong
```

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

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

```cpp
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_, который бы гарантированно перехватил всё, весьма недурна\.

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

Выглядит неплохо\. Но у нас C\+\+, так что это [не работает](https://godbolt.org/z/EEjE96W3s)\!

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

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

```cpp
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_ блок они все уже мертвы\.

```cpp
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;
}
```

**Итого**

1. Для обычных функций и _main_ с помощью альтернативного синтаксиса можно удобно и красиво перехватывать все исключения, которые могли бы вылететь\. И поведение по умолчанию — именно перехват\. [Дальше не летит\.](https://godbolt.org/z/1PhMd6TTn)
1. Для конструкторов можно ловить исключения из конструкторов полей, обрабатывать их \(печатать в лог\), но подавить нельзя\. Либо кидаете своё, новое исключение, либо пойманное неявно будет проброшено дальше\.
1. Для деструкторов также будет неявный проброс, но его можно подавить, добавив _return_\.

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

1. Сppreference\. [Function try block](https://en.cppreference.com/w/cpp/language/try#Function_try_block)\. 
1. Marius Bancila\. [Little\-known C\+\+: function\-try\-block](https://mariusbancila.ro/blog/2019/03/13/little-known-cpp-function-try-block/)\.

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

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

```cpp
struct MyTag {};
```

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

```cpp
struct Tag {};

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

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

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

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

```cpp
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;
};
```

[Угадали?](https://godbolt.org/z/h1v3WdohG)

_Vector1_ и _Vector2_ имеют размеры _4\*sizeof\(int\*\)_\. Но как же так?\! Откуда берутся _3\*sizeof\(int\*\)_, совершенно очевидно\. Но четвёртый\-то откуда?\!

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

Но _sizeof\(int\*\) \!\= 1_\. По крайней мере на x86\. А это ещё проще: выравнивание и паддинг\. _Vector1_ дополняется байтами конце, чтобы его размер был кратен выравниванию первого поля\. А в _Vector2_ дополняется байтами между _alloc_ и _data_, чтобы смещение до _data_ было кратным его выравниванию\. Всё очень просто и очевидно\! Если же вам, как и многим другим людям, которые не задаются подобными вопросами каждый день, не очевидно наличие паддинга в той или иной структуре, то советую [использовать](https://godbolt.org/z/z91qP9Wev) флаг компилятора _\-Wpadded_ для GCC/Clang\.

Хорошо, мы разобрались с _Vector1_ и _Vector2_\. А что там с _Vector3_? Тоже _4\*sizeof\(int\*\)_? Ведь мы же знаем, что подобъект базового класса должен быть где\-то размещён, а его размер, как мы выяснили, не нулевой\.\.\. А вот и нет\! Размер _Vector3_ равен _3\*sizeof\(int\*\)_\! Но как же так?\! А это называется EBO [\(empty base optimization\)](https://en.cppreference.com/w/cpp/language/ebo)\.

Интересный zero\-cost\! Для сравнения можно глянуть на аналогичные пустые структуры в Rust\. Там их размер [может быть](https://godbolt.org/z/r9YTKrbb3) равен нулю\.

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

```cpp
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;
};
```

Чему равны размеры _Map1_, _Map2_, _Map3_?

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

* очевидно, что _sizeof\(Map1\) \=\= 2_, ведь она состоит из двух пустых структур, каждая из которых имеет размер 1;
* благодаря атрибуту _\[\[no\_unique\_address\]\]_ из стандарта C\+\+20 \(Clang поддерживает с C\+\+11\), _Map2_ и _Map3_ должны иметь размер 1\. В _Map3_ оба поля разделяют общий адрес\. В _Map2_ то же самое\. Да и меньше, чем 1, не бывает\.

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

Все по _2\*sizeof\(int\)_? А вот и нет: у _MapImpl3_ [работает EBO](https://godbolt.org/z/78WeWzWYs)\!

Ну ладно\. В этом есть какая\-то логика и закономерность\. Это ещё можно принять\. Хотя\.\.\. На самом деле, вы были правы\! Ведь если у вас компилятор MSVC, то _\[\[no\_unique\_address\]\]_ просто [не работает](https://godbolt.org/z/6364qzYe6)\. И не будет работать\. Потому что MSVC долгое время просто игнорировал незнакомые ему атрибуты\. И если поддержать _\[\[no\_unique\_address\]\]_, то сломается бинарная совместимость\. Используйте _\[\[msvc::no\_unique\_address\]\]_\! EBO, правда, пока [не работает](https://godbolt.org/z/GTjrsbPPK)\.

#### zero\-size array

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

```cpp
struct ImageHeader{
  int h;
  int w;
};

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

Поле _data_ в структуре _Image_ имеет [нулевой размер](https://godbolt.org/z/7e8nPrE19)\. Это FAM \(flexible array member\)\. Очень удобная штука, чтобы получать доступ к массиву статически не известной длины, размещённому сразу после некоторого заголовка в бинарном буфере\. Длина массива обычно указывается в самом заголовке\. FAM может быть только последним полем в структуре\.

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

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

```cpp
struct S {
  char data[];
};
```

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

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

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

А теперь [посмотрим](https://godbolt.org/z/fxo9zPWh3) на GCC C\+\+:

```cpp
struct S1 {
  char data[];
};

struct S2 {};

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

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

#### tag dispatching

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

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

```cpp
struct Mul {};
struct Add {};

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

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

и

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

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

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

Краткий ответ: да\. Есть разница\. Зависит от конкретной имплементации\. Стандарт не гарантирует оптимизацию пустых аргументов\. От перемены позиций тегов может меняться бинарный интерфейс\. Поиграться с наиболее заметными изменениями можно на примере [MSVC](https://godbolt.org/z/E68ojMb8f)\.

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

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

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

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

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

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

Однако если объекты создаются миллионами, то можно заметно оптимизировать потребление памяти простейшим рефакторингом\. Единственная проблема — не всегда сразу видно, какие структуры можно оптимизировать, а какие нет\. Тем более на разных архитектурах разные размеры данных и правила выравнивания\. Жизнь облегчают анализаторы кода\. Например, в PVS\-Studio для этой задачи есть диагностика [V802](https://pvs-studio.ru/ru/docs/warnings/v802/)\.

От структуры никак не избавиться, рекомендую обратить внимание на всё более популярную [технику](https://zig.news/kristoff/struct-of-arrays-soa-in-zig-easy-in-userland-40m0): преобразование массива структур \(AoS, Array\-of\-Structs\) в структуру из массивов \(SoA, Struct\-of\-Arrays\)\.

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

1. Daniel Griffing\. [MSVC C\+\+20 and the /std:c\+\+20 Switch](https://devblogs.microsoft.com/cppblog/msvc-cpp20-and-the-std-cpp20-switch/)\.
1. Cppreference\. [C\+\+ attribute: no\_unique\_address](https://en.cppreference.com/w/cpp/language/attributes/no_unique_address)\. 
1. Cppreference\. [Empty base optimization](https://en.cppreference.com/w/cpp/language/ebo)\.
1. Loris Cro\. [Struct of Arrays \(SoA\) in Zig](https://zig.news/kristoff/struct-of-arrays-soa-in-zig-easy-in-userland-40m0)\.

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

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

## Все части

1. [Часть 1](https://pvs-studio.ru/ru/blog/posts/cpp/1129/): предисловие, что такое неопределённое поведение и как оно проявляется, сужающие преобразования и неявное приведение типов\.
1. [Часть 2](https://pvs-studio.ru/ru/blog/posts/cpp/1136/): переполнение целых знаковых чисел, числа с плавающей точкой, integer promotion, _char_ и знаковое расширение\.
1. [Часть 3](https://pvs-studio.ru/ru/blog/posts/cpp/1149/): висячие ссылки, _string\_view_, синтаксический сахар с ложкой дёгтя \(range\-based for\), self\-reference, _std::vector_ и инвалидация ссылок\.
1. [Часть 4](https://pvs-studio.ru/ru/blog/posts/cpp/1156/): списки захвата лямбда\-функций, кортежи, внезапная мутабельность, неявные ссылки, use\-after\-move, lifetime extension\.
1. [Часть 5](https://pvs-studio.ru/ru/blog/posts/cpp/1160/): Most Vexing Parse, неконстантные константы, семантика перемещения, _std::enable\_if\_t_ против _std::void\_t_, забытый _return_\.
1. [Часть 6](https://pvs-studio.ru/ru/blog/posts/cpp/1163/): эллипсис и функции, _operator \[\]_, _iostreams_ \(счастливой отладки\!\), оператор запятая, function\-try\-block, типы "нулевого" размера\.
1. [Часть 7](https://pvs-studio.ru/ru/blog/posts/cpp/1174/): NULL\-терминированные строки, _std::shared\_ptr_, \(не\)явное приведение типов, как передать стандартную функцию и ничего не сломать\.
1. [Часть 8](https://pvs-studio.ru/ru/blog/posts/cpp/1178/): бесконечные циклы и проблема остановки, рекурсия, ложный _noexcept_, переполнение буфера\.
1. [Часть 9](https://pvs-studio.ru/ru/blog/posts/cpp/1182/): \(N\)RVO vs RAII, разыменование нулевых указателей, static initialization order fiasco, static inline, нарушение ODR, зарезервированные имена\.
1. [Часть 10](https://pvs-studio.ru/ru/blog/posts/cpp/1193/): тривиальные типы и ABI, неинициализированные переменные, С\+\+20 unbounded ranges, невиртуальные виртуальные функции, VLA\.
1. [Часть 11](https://pvs-studio.ru/ru/blog/posts/cpp/1199/): невалидные указатели, placement new для массивов, data race, повторный захват mutex, сигнало\(не\)безопасность, как сделать всё правильно и уйти в deadlock\.
1. [Часть 12](https://pvs-studio.ru/ru/blog/posts/cpp/1211/): _std::vector::reserve_ и _std::vector::resize_, невыровненные ссылки, время жизни и смерти, статический анализ и UB, заключение\.