﻿# Что нам недодали в C\+\+

Что ещё мог бы уметь C\+\+ — и как разработчики сами добавляют недостающие фичи? Автор рассуждает об идеях, которых не хватает языку, и приводит любопытные примеры\. Приглашаем к прочтению\!

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


> Мы опубликовали и перевели эту статью с разрешения правообладателя\\\. Автор статьи — \[Николай Шалакин\]\(https://habr\.com/en/users/AskePit/\)\\\.

## Фичи, которых нет

Уже более десяти лет я профессионально занимаюсь C\+\+ разработкой\. Я вошёл в профессию в 2013 году, когда комитет по стандартизации языка C\+\+ раскочегарился и встал на рельсы трёхлетних релизов обновленных стандартов языка\. Уже был выпущен C\+\+11, в котором была введена куча самых заманчивых новшеств, существенно освеживших язык\. Однако далеко не каждому была доступна роскошь использовать все эти нововведения в рабочем коде, и приходилось сидеть на унылом C\+\+03, облизываясь на новый стандарт\.

Вместе с тем, несмотря на всё разнообразие новых фич, внедряющихся в язык, я от проекта к проекту наблюдал и поныне наблюдаю одну и ту же повторяющуюся картину: helper\-файлы, helper\-контейнеры, в которых зачастую реализуются одни и те же вещи, восполняющие то, чего нет в STL\. Я не говорю о каких\-то узкоспециализированных специфических структурах и алгоритмах — скорее о вещах, без которых не получается комфортно разрабатывать программный продукт на C\+\+\. И я вижу, как разные компании на различных проектах сооружают одни и те же самопальные решения, просто потому что они естественны и на них есть спрос\. А предложение отсутствует, по крайней мере в STL\.

В статье я хотел собрать самые яркие примеры того, что видел и использовал в разработке\. Но в процессе сбора всех отсутствующих из коробки в C\+\+ фич внезапно для себя обнаружил, что часть из них уже покрыта новыми стандартами языка — полностью или частично\. Поэтому данная статья — скорее некая рефлексия и книга жалоб о том, чего не было очень долго, но оно в итоге пришло в язык; и о том, что всё ещё отсутствует в стандарте\. Материал не претендует ни на что, скорее просто предлагает поболтать о повседневном C\+\+\.

**DISCLAIMER**: в статье я могу взаимозаменять \(а может быть и уже успел взаимозаменить\) понятия C\+\+, STL, язык, стандарт языка и т\.п\., так как в контексте статьи это не так важно, и речь будет идти "обо всем об этом"\.

## Чего не было очень долго

### std::string::starts\_with, std::string::ends\_with

Фантомная боль каждого второго плюсовика\. Этих вещей ждали так долго, а они так долго не приходили к нам\. Ставь лайк, если видел что\-то похожее в закромах кодовой базы своего рабочего проекта:

```cpp
inline bool starts_with(const std::string &s1, const std::string &s2)
{
  return s2.size() <= s1.size() && s1.compare(0, s2.size(), s2) == 0;
}
```

Эти методы ввели в язык лишь в C\+\+20, который и сейчас\-то далеко не всем доступен\. Но счастливчики наконец\-то могут найти префикс у строки\. И постфикс тоже:

```cpp
std::string s("c++20");

bool res1 = s.starts_with("c++"); // true
bool res2 = s.starts_with("c#");  // false
bool res3 = s.ends_with("20");    // true
bool res4 = s.ends_with("27");    // false
```

### std::optional

"Этот класс давно есть в языке, дед, иди пей таблетки," — скажете вы и будете частично правы, ведь `std::optional` с нами с 17 стандарта, и все к нему прикипели как к родному\. Но тут скорее моя личная боль, когда я в самые первые годы работы сидел на проекте с ограничением в стандарт C\+\+03 и использовал самописный `optional`, созданный моим коллегой\.

Чтение кода, реализующего этот самописный `optional`, было для меня захватывающим процессом\. Я тогда был ещё джуном, и на меня это сумело произвести впечатление\. Да, там всё было достаточно просто и прямолинейно, но эмоций приносило столько, будто я читаю исходники STL\.

Я рад, что теперь могу писать смело и без стеснения вот так практически на любом проекте:

```cpp
std::optional<Result> getResult();

const auto res = getResult();
if (res) {
  std::cout << *res << std::endl;
} else {
  std::cout << "No result!" << std::endl;
}
```

### std::expected

Если вы знакомы с Rust, вы знаете, что у класса `Option<T>` есть близкий соратник — `Result<T, E>`\. Они очень тесно связаны и каждый имеет пачку методов, преобразующих одно в другое\.

Если с `Option<T>` всё понятно — это аналог `optional<T>` в C\+\+, то с `Result<T, E>` стоит пояснить\. Это что\-то типа `optional<T>`, но отсутствие результата трактуется как ошибка типа `E`\. Т\.е\. объект класса `Result<T, E>` может находиться в двух состояниях:

* Состояние `Ok`\. Тогда объект хранит в себе валидное значение типа `T`\.
* Состояние `Error`\. Тогда объект хранит в себе ошибку типа `E`\.

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

Для C\+\+ программиста такой класс может показаться чем\-то диковинным, но в Rust он имеет большое значение, поскольку в языке нет исключений, и обработка нештатных ситуаций происходит только через возврат кодов ошибок\. В 99% случаев это делается через возврат результата в виде объекта `Result<T, E>`\.

С другой стороны, я за время работы с C\+\+ принимал участие только в проектах, где исключения были под запретом по тем или иным причинам, а в таком прочтении C\+\+ становится аналогичен Rust в плане работы с ошибками в программе\.

Именно поэтому, единожды увидев `Result<T, E>` в Rust, я не смог его развидеть и завидовал Rust'у за то, что в нем `Result<T, E>` есть, а в C\+\+ его нет\. И да, я написал аналог `Result<T, E>` для C\+\+\. У класса было сомнительное название `Maybe<T, E>`, которое могло бы ввести Haskel\-программистов в заблуждение \(в Haskell Maybe — это аналог `optional`\)\.

А буквально недавно я обнаружил, что комитет по стандартизации языка C\+\+ утвердил класс `std::expected<T, E>` в 23 стандарте, и MSVC даже [успели реализовать](https://learn.microsoft.com/en-us/cpp/overview/visual-cpp-language-conformance?view=msvc-170) его в VS 2022 17\.3\. Он доступен при включении опции `/std:c++latest` компилятора\. И даже название вышло хорошим\. На мой вкус куда лучше, чем `Result` или `Maybe`\.

Оценить класс в действии предлагаю кодом, который парсит человекочитаемый шахматный адрес в координаты, удобные для использования внутри шахматного движка\. Например, `a3` должен стать координатами `[2; 0]`:

```cpp
struct ChessPosition
{
  int row; // stored as [0; 7], represents [1; 8]
  int col; // stored as [0; 7], represents [a; h]
};

enum class ParseError
{
  InvalidAddressLength,
  InvalidRow,
  InvalidColumn
};

auto parseChessPosition(std::string_view address) -> 
                    std::expected<ChessPosition, ParseError>
{
  if (address.size() != 2) {
    return std::unexpected(ParseError::InvalidAddressLength);
  }

  int col = address[0] - 'a';
  int row = address[1] - '1';

  if (col < 0 || col > 7) {
    return std::unexpected(ParseError::InvalidColumn);
  }

  if (row < 0 || row > 7) {
    return std::unexpected(ParseError::InvalidRow);
  }

  return ChessPosition{ row, col };
}

...

auto res1 = parseChessPosition("e2");  // [1; 4]
auto res2 = parseChessPosition("e4");  // [3; 4]
auto res3 = parseChessPosition("g9");  // InvalidRow
auto res4 = parseChessPosition("x3");  // InvalidColumn
auto res5 = parseChessPosition("e25"); // InvalidAddressLength
```

### std::bit\_cast

Это то, обо что я эпизодически спотыкался\. Уж не знаю почему, но у меня периодически возникала необходимость делать странные вещи вроде получения битового представления `float`\-числа\. Конечно же, в джуновские времена я не боялся UB и пользовался тем, что просто работает, по крайней мере, здесь и сейчас\. Итак, что у нас есть из небезопасного битового представления одного типа в другой:

* `reinterpret_cast`, куда без него\. Так просто и заманчиво написать

```cpp
uint32_t i = *reinterpret_cast<uint32_t*>(&f);
```

и не заботиться ни о чем\. Но это UB\.

Назад к корням — c\-style cast\. Всё то же самое, что с `reinterpret_cast`, только ещё проще в написании:

```cpp
uint32_t i = *(uint32_t*)&f;
```

Ведь если [разработчики Quake III не чурались](https://en.wikipedia.org/wiki/Fast_inverse_square_root), то почему нельзя нам? Но\.\.\. это UB\.

* Трюк с `union`:

```cpp
union {
  float f;
  uint32_t i;
} value32;
```



Сам по себе такой код — не UB, но беда в том, что чтение из `union`\-поля, в которое вы перед этим ничего не писали, — это _тоже_ UB\.

Тем не менее, я наблюдал все эти подходы в разных типах извращений:

* Попытка узнать знак `float`\-числа через прочтение его старшего бита\.
* Превращение указателя в число и обратно — привет, embedded\. Видел экзотический случай, когда адрес превращали в ID\.
* Математические извращения с экспонентой или мантиссой `float`\.

Да кому и зачем нужна мантисса, спросите вы? А я отвечу: вот мой древний [GitHub\-проект](https://github.com/AskePit/IEEE754Converter), где я по фану сделал маленький IEEE 754 конвертер, в котором можно играться с битовым представлением 32\-битных чисел с плавающей точкой\. Я его делал очень давно в самообразовательных целях, к тому же очень хотелось украсть оформление стандартного калькулятора Windows7 и посмотреть, как у меня выйдет :\)

![1303_what_we_miss_in_cpp_ru/image2.png](https://import.viva64.com/docx/blog/1303_what_we_miss_in_cpp_ru/image2.png)

В общем, битовые извращения то тут, то там кому\-то да становятся необходимы\.

Спрашивается, как извращаться безопасно? Когда я в своё время полез на Stack Overflow за правдой, ответ был суров, но однозначен: "Используйте `memcpy`"\. Где\-то там же я своровал небольшой сниппет, чтобы использовать `memcpy` было удобно:

```cpp
template <class OUT, class IN>
inline OUT bit_cast(IN const& in)
{
  static_assert(sizeof(OUT) == sizeof(IN), 
                "source and dest must be same size");
  static_assert(std::is_trivially_copyable<OUT>::value,
                "destination type must be trivially copyable.");
  static_assert(std::is_trivially_copyable<IN>::value,
                "source type must be trivially copyable");
  
  OUT out;
  memcpy(&out, &in, sizeof(out));
  return out;
}
```

В C\+\+20 появился `std::bit_cast`, выполняющий ту же задачу, но при этом являющийся `constexpr` благодаря возможностям, которые стандарт обязал реализовать компиляторам\.

Теперь мы можем прикоснуться к прекрасному и сделать его не только прекрасным, но и корректным с точки зрения спецификации языка:

```cpp
float q_rsqrt(float number)
{
  long i;
  float x2, y;
  const float threehalfs = 1.5F;

  x2 = number * 0.5F;
  y = number;
  i = std::bit_cast<long>(y);          // evil floating point bit level hacking
  i = 0x5f3759df - (i >> 1);           // what the fuck?
  y = std::bit_cast<float>(i);
  y = y * (threehalfs - (x2 * y * y));    // 1st iteration
  //y = y * (threehalfs - (x2 * y * y));  // 2nd iteration, this can be removed

  return y;
}
```

Не благодарите, id Software\.

## Чего нет и может быть не будет

### Математика float\-чисел

Все мы знаем, что нельзя просто так взять и проверить на равенство два `float`\-числа\. `1.0` и `0.999999999` не будут равны друг другу, даже если по вашим меркам они вполне себе равны\. Стандартных методов адекватного решения этой проблемы в языке нет — ты должен сам ручками сравнить модуль разницы чисел с эпсилоном\.

Другая вещь, которую иногда хочется иметь под руками — возможность округлить число до какого\-то количества знаков после запятой\. У нас в распоряжении есть `floor`, `ceil`, `round`, но все они не про то, все они округляют до целого\. Поэтому приходится идти на Stack Overflow и брать какие\-то заготовленные решения\.

В итоге ваша кодовая база обрастает примерно такими хелперами:

```cpp
template<class T>
bool almostEqual(T x, T y)
{
  return std::abs(x - y) < std::numeric_limits<T>::epsilon();
}

template<class T>
bool nearToZero(T x)
{
  return std::abs(x) < std::numeric_limits<T>::epsilon();
}

template<class T>
T roundTo(T x, uint8_t digitsAfterPoint)
{
  const uint32_t delim = std::pow(10, digitsAfterPoint);
  return std::round(x * delim) / delim;
}
```

Что тут ещё можно сказать? Не критично, но грустно\.

### EnumArray

Представим, у вас есть перечисление:

```cpp
enum class Unit
{
  Grams,
  Meters,
  Liters,
  Items
};
```

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

```cpp
std::unordered_map<Unit, const char*> unitNames {
  { Unit::Grams, "g" },
  { Unit::Meters, "m" },
  { Unit::Liters, "l" },
  { Unit::Items, "pcs" },
};
```

Что мы можем подметить про этот кусок кода:

* `std::unordered_map` — не самый тривиальный контейнер\. И не самый оптимальный по части представления в памяти\.
* Подобного рода словари\-конфиги могут встречаться в проекте ну очень часто\. В подавляющем большинстве случаев они будут малого размера, ведь среднестатистическое количество элементов в перечислении редко превышает несколько десятков, а чаще всего и вовсе исчисляется штуками\. Хэш\-таблица, если мы используем `std::unordered_map`, или дерево, если используем `std::map`, начинают выглядеть как оверкилл\.
* Перечисление по своей сути — число\. Очень заманчиво представить его как числовой индекс\.

Последний факт может быстро привести нас к идее, что тут можно было бы сделать такой контейнер, который интерфейсно бы представлял из себя словарь, но под капотом у него лежал бы `std::array`\. Индексы такого массива — это элементы нашего перечисления, данные массива — значения "мапы"\.

Остаётся лишь довести до ума, как массиву дать понять, какой он должен быть длины\. Т\.е\. как посчитать количество элементов в перечислении\. Самый простой дедовский метод — добавить в конец перечисления служебный элемент `Count`\. На этом способе и остановимся, т\.к\. он не особо экзотический — я его часто вижу в кодовых базах, — а значит, воспользоваться им не зазорно:

```cpp
enum class Unit
{
  Grams,
  Meters,
  Liters,
  Items,
  
  Count
};
```

Дальнейшая реализация прокси\-контейнера достаточно проста:

```cpp
template<typename Enum, typename T>
class EnumArray
{
public:
  EnumArray(std::initializer_list<std::pair<Enum, T>>&& values);

  T& operator[](Enum key);
  const T& operator[](Enum key) const;

private:
  static constexpr size_t N = std::to_underlying(Enum::Count);
  std::array<T, N> data;
};
```

Конструктор с `std::initializer_list` нужен, чтобы можно было сформировать наш конфиг точно так же, как мы формировали в своё время `std::unordered_map`:

```cpp
EnumArray<Unit, const char*> unitNames {
  { Unit::Grams, "g" },
  { Unit::Meters, "m" },
  { Unit::Liters, "l" },
  { Unit::Items, "pcs" },
};
std::cout << unitNames[Unit::Items] << std::endl; // выведет "psc"
```

Красота\!

В чём выражается красота:

* Мы используем все прелести `std::array` и `std::unordered_map` одновременно\. Удобство интерфейса словаря \+ быстрота и примитивность массива \(в хорошем смысле\) под капотом\.
* Сache\-friendly — данные лежат в памяти последовательно, совершенно не в пример `std::unordered_map` и `std::map`\.
* Размер массива известен на этапе компиляции, а если доводить контейнер до ума, практически все его методы можно легко сделать `constexpr`\.

Какие этот подход имеет ограничения:

* Обязательный `Count` у перечисления\.
* Перечисление не может иметь кастомных значений типа:

```cpp
enum class Type
{
  A = 4,
  B = 12,
  C = 518,
  D
}
```

Только дефолтный порядок с нуля\.

* В массиве выделена память под все элементы перечисления сразу\. Если вы заполнили `EnumArray` не всеми значениями, остальные будут содержать в себе `default-constructed`\-объекты\.
* А это, кстати, ещё одно ограничение — тип `T` должен быть `default-constructed`\.

Я обычно с такими ограничениями ок, поэтому пользуюсь этим контейнером без каких\-то особых проблем\.

### Early return

Давайте посмотрим на типичную функцию с некоторым количеством проверок на пограничные состояния:

```cpp
std::string applySpell(Spell* spell)
{
  if (!spell)
  {
    return "No spell";
  }

  if (!spell->isValid())
  {
    return "Invalid spell";
  }

  if (this->isImmuneToSpell(spell))
  {
    return "Immune to spell";
  }

  if (this->appliedSpells.constains(spell))
  {
    return "Spell already applied";
  }

  appliedSpells.append(spell);
  applyEffects(spell->getEffects());
  return "Spell applied";
}
```

Согласны? Узнали? Несчастные три строчки внизу — реальная работа метода\. Остальное — проверки, можно ли совершить эту работу\. Немного раздражает\. Особенно, если вы приверженец Allman style, и каждая ваша фигурная скобочка умеет выстраивать личные границы\.

Хотелось бы лаконичнее, без бойлерплейта\. Есть же у C\+\+ `assert`, например, который по духу похож на то, чем мы здесь занимаемся: делается проверка некоторого условия, если надо, под капотом предпринимаются меры\. Правда ассерту проще — ему не нужно ничего возвращать\. Но, тем не менее, что\-то похожее мы могли бы соорудить:

```cpp
#define early_return(cond, ret)      \
  do {                             \
    if (static_cast<bool>(cond)) \
    {                            \
      return ret;              \
    }                            \
  } while (0)

#define early_return_void(cond)      \
  do {                             \
    if (static_cast<bool>(cond)) \
    {                            \
      return;                  \
    }                            \
  } while (0)
```

FFFUUU, макросы\! Бьёрн Страуструп не любит макросы\. Если он напишет мне в личку и попросит извиниться, я его пойму и извинюсь, я тоже не люблю C\+\+ макросы\.

![1303_what_we_miss_in_cpp_ru/image3.png](https://import.viva64.com/docx/blog/1303_what_we_miss_in_cpp_ru/image3.png)

Но да, в предлагаемом коде макросы, даже два\. На самом деле, мы можем сократить их до одного, если задействуем `variadic macro`:

```cpp
#define early_return(cond, ...)      \
  do {                             \
    if (static_cast<bool>(cond)) \
    {                            \
      return __VA_ARGS__;      \
    }                            \
  } while (0)
```

Макрос остался один, но это всё ещё макрос\. И нет, чуда, скорее всего, не произойдёт — его нельзя переделать в немакрос — как только мы попытаемся утащить его в функцию, мы потеряем возможность влиять на control flow нашей текущей функции\. Жаль, но реальность такова\. Зато посмотрите, как мы можем переписать наш пример:

```cpp
std::string applySpell(Spell* spell)
{
  early_return(!spell, "No spell");
  early_return(!spell->isValid(), "Invalid spell");
  early_return(this->isImmuneToSpell(spell), "Immune to spell");
  early_return(this->appliedSpells.constains(spell), "Spell already applied");

  appliedSpells.append(spell);
  applyEffects(spell->getEffects());
  return "Spell applied";
}
```

Это будет работать и в случае, если бы функция возвращала `void`:

```cpp
void applySpell(Spell* spell)
{
  early_return(!spell);
  early_return(!spell->isValid());
  early_return(this->isImmuneToSpell(spell));
  early_return(this->appliedSpells.constains(spell));

  appliedSpells.append(spell);
  applyEffects(spell->getEffects());
}
```

Стало короче, и я считаю, что в целом — лучше\. Если бы стандарт поддерживал эту фичу, она могла бы быть уже не макросом, а полноценной языковой конструкцией\. Хотя, ради забавы скажу, что плюсовый `assert` — это\-таки тоже макрос :\)

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

```cpp
#define ensure_or_return(cond, ...)   \
  do {                              \
    if (!static_cast<bool>(cond)) \
    {                             \
      return __VA_ARGS__;       \
    }                             \
  } while (0)

void applySpell(Spell* spell)
{
  ensure_or_return(spell);
  ensure_or_return(spell->isValid());
  ensure_or_return(!this->isImmuneToSpell(spell));
  ensure_or_return(!this->appliedSpells.constains(spell));

  appliedSpells.append(spell);
  applyEffects(spell->getEffects());
}
```

Нейминг, скорее всего, неудачный, но вы уловили идею\. А я был бы рад видеть в C\+\+ любую из конструкций\.

### Unordered erase

Полагаю, самая часто используемая коллекция в C\+\+ — это `vector`\. И мы хорошо помним, что вектор хорош всем, кроме вставки и удаления в произвольном месте коллекции\. Это занимает `O(n)` времени, поэтому мне каждый раз грустно что\-то удалять из середины вектора, поскольку ему придётся перелопачивать половину своего контента, чтобы сместиться немного влево\.

Есть идиоматичный приём, который может превратить `O(n)` в `O(1)` ценой несохранения порядка элементов в векторе\. И если вы готовы заплатить эту цену, вам определённо выгоднее использовать этот несложный трюк:

```cpp
std::vector<int> v {
  17, -2, 1084, 1, 17, 40, -11
};

// удаляем число 1 из вектора
std::swap(v[3], v.back()); 
v.pop_back();

// получаем [17, -2, 1084, -11, 17, 40]
```

Что мы сделали? Мы сначала обменяли последний элемент вектора с помеченным на удаление, а затем просто выкинули хвостовой элемент\. Обе операции очень дешевы\. Просто, красиво\.

Почему интерфейс вектора не располагает такой простой альтернативой обычному методу `erase` — непонятно\. В Rust вот, например, [он есть](https://doc.rust-lang.org/std/vec/struct.Vec.html#method.swap_remove)\.

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

```cpp
template<typename T>
void unorderedErase(std::vector<T>& v, int index)
{
  std::swap(v[index], v.back());
  v.pop_back();
}
```

## Итоги

Половину статьи C\+\+ переиграл и уничтожил ещё в процессе её написания, потому что современные стандарты C\+\+20 и C\+\+23 покрыли добрую половину хотелок, описанных в этой жалобной книге\. В остальном же список пожеланий у пользователей языка все равно никогда не иссякнет, потому что сколько людей, столько хотелок, и все их в стандартную библиотеку или в сам язык не упихнёшь\.

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