Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Статический анализ и ASOC: нулевая терпимость к ошибкам в проекте - 13.11

>
>
>
Что нам недодали в C++

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

29 Окт 2025
Автор:

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

Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи — Николай Шалакин.

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

Уже более десяти лет я профессионально занимаюсь 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

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

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, который и сейчас-то далеко не всем доступен. Но счастливчики наконец-то могут найти префикс у строки. И постфикс тоже:

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.

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

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 даже успели реализовать его в VS 2022 17.3. Он доступен при включении опции /std:c++latest компилятора. И даже название вышло хорошим. На мой вкус куда лучше, чем Result или Maybe.

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

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, куда без него. Так просто и заманчиво написать
uint32_t i = *reinterpret_cast<uint32_t*>(&f);

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

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

uint32_t i = *(uint32_t*)&f;

Ведь если разработчики Quake III не чурались, то почему нельзя нам? Но... это UB.

  • Трюк с union:
union {
  float f;
  uint32_t i;
} value32;

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

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

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

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

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

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

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

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

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 и брать какие-то заготовленные решения.

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

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

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

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

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

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. На этом способе и остановимся, т.к. он не особо экзотический — я его часто вижу в кодовых базах, — а значит, воспользоваться им не зазорно:

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

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

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:

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 у перечисления.
  • Перечисление не может иметь кастомных значений типа:
enum class Type
{
  A = 4,
  B = 12,
  C = 518,
  D
}

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

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

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

Early return

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

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, например, который по духу похож на то, чем мы здесь занимаемся: делается проверка некоторого условия, если надо, под капотом предпринимаются меры. Правда ассерту проще — ему не нужно ничего возвращать. Но, тем не менее, что-то похожее мы могли бы соорудить:

#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++ макросы.

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

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

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

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:

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 — утверждать ожидаемое, срабатывать при обратном, — то мы можем достаточно легко удовлетворить и ваш запрос. Нужно лишь инвертировать всю логику и назвать макрос сообразно новому поведению:

#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) ценой несохранения порядка элементов в векторе. И если вы готовы заплатить эту цену, вам определённо выгоднее использовать этот несложный трюк:

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 вот, например, он есть.

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

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++.

Последние статьи:

Опрос:

book gost

Дарим
электронную книгу
за подписку!

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form