Что нам недодали в 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
с нами аж с 11 стандарта, и все к нему прикипели как к родному. Но тут скорее моя личная боль, когда я в самые первые годы работы сидел на проекте с ограничением в стандарт C++03 и использовал самописный optional
, созданный моим коллегой. Самое ироничное, что реализован он был уже в те времена, когда параллельно существовал std::optional
, но на нашем проекте он был нам не доступен.
Чтение кода, реализующего этот самописный optional
было для меня захватывающим чтивом. Я тогда был еще джуном, и на меня это сумело произвести впечатление. Да, там все было достаточно просто и прямолинейно, но эмоций было столько, будто я читаю исходники STL.
Я рад, что теперь могу писать смело и без стеснения вот так практически на любом проекте:
std::optional getResult();
const auto res = getResult();
if (res) {
std::cout << res << std::endl;
} else {
std::cout << "No result!" << std::endl;
}
std: expected
Если вы знакомы с Rust, вы знаете, что у класса Option
есть близкий соратник — класс Result
. Они очень тесно связаны и каждый имеет пачку методов, преобразующих одно в другое.
Если с Option
все понятно — это аналог optional
в C++ — то с Result
стоит пояснить. Это что-то типа optional
, но отсутствие результата трактуется как ошибка типа E
. Т.е. объект класса Result
может находиться в двух состояниях:
Состояние Ok. Тогда объект хранит в себе валидное значение типа
T
Состояние Error. Тогда объект хранит в себе ошибку типа
E
Мы всегда можем спросить объект, в каком из двух состояний он находится и попытаться взять у него валидное значение либо спросить, какая у него ошибка.
Для C++ программиста такой класс может показаться чем-то диковинным, но в Rust он имеет большое значение, поскольку в языке нет исключений, и обработка нештатных ситуаций происходит исключительно через возврат кодов ошибок и в 99% случаев это делается через возврат результата в виде объекта Result
.
С другой стороны, я за время работы с C++ принимал участие только в проектах, где исключения были под запретом по тем или иным причинам, а в таком прочтении C++ становится аналогичен Rust в плане работы с ошибками в программе.
Именно поэтому, единожды увидев Result
в Rust, я не смог его развидеть и завидовал Rust’у за то, что в нем Result
есть, а в C++ его нет. И да, я написал аналог Result
для C++. У класса было сомнительное название Maybe
, которое могло бы ввести Haskel-программистов в заблуждение (в Haskell Maybe
— это аналог optional
)
А буквально недавно я обнаружил, что комитет по стандартизации языка C++ утвердил класс std::expected
в 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
{
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
(&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 и посмотреть, как у меня выйдет :)
В общем, битовые извращения то тут, то там кому-то да становятся необходимы.
Спрашивается, как извращаться безопасно? Когда я в свое время полез на StackOverflow за правдой, ответ был суров, но единственен: «используйте memcpy
». Где-то там же я своровал небольшой сниппет, чтобы использовать memcpy
удобно:
template
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::value, "destination type must be trivially copyable.");
static_assert(std::is_trivially_copyable::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(y); // evil floating point bit level hacking
i = 0x5f3759df - (i >> 1); // what the fuck?
y = std::bit_cast(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
, но все они не про то, все они округляют до целого. Поэтому приходится идти на StackOverflow и брать какие-то заготовленные решения.
В итоге ваша кодовая база обрастает примерно такими хелперами:
template
bool almostEqual(T x, T y)
{
return std::abs(x - y) < std::numeric_limits::epsilon();
}
template
bool nearToZero(T x)
{
return std::abs(x) < std::numeric_limits::epsilon();
}
template
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 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
class EnumArray
{
public:
EnumArray(std::initializer_list>&& values);
T& operator[](Enum key);
const T& operator[](Enum key) const;
private:
static constexpr size_t N = std::to_underlying(Enum::Count);
std::array data;
};
Конструктор с std::initializer_list
нужен, чтобы можно было сформировать наш конфиг точно так же как мы формировали в свое время std::unordered_map
:
EnumArray 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(cond)) \
{ \
return ret; \
} \
} while (0)
#define early_return_void(cond) \
do { \
if (static_cast(cond)) \
{ \
return; \
} \
} while (0)
FFFUUU, макросы! Бьёрн Страуструп не любит макросы. Если он напишет мне в личку и попросит извиниться, я его пойму и извинюсь, я тоже не люблю C++ макросы.
Но да, в предлагаемом коде макросы, даже два. На самом деле мы можем сократить их до одного, если задействуем variadic macro:
#define early_return(cond, ...) \
do { \
if (static_cast(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(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 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
void unorderedErase(std::vector& v, int index)
{
std::swap(v[index], v.back());
v.pop_back();
}
Итоги
Половину статьи C++ переиграл и уничтожил еще в процессе ее написания, потому что современные стандарты C++20 и C++23 покрыли добрую половину хотелок, описанных в этой жалобной книге. В остальном же список пожеланий у пользователей языка все равно никогда не иссякнет, потому что сколько людей, столько хотелок, и все их в стандартную библиотеку или в сам язык не упихнешь.
Я постарался упомянуть только те моменты, которые на мой взгляд менее всего пахнут вкусовщиной, и были бы достойны вхождения в стандарт языка, по крайней мере в моей работе они востребованы +/- каждодневно. Вы справедливо можете иметь другое мнение на мой список, а я в свою очередь с удовольствием бы почитал в комментариях вашу боль и ваши недополученные фичи, чтобы увидеть, как пользователи языка хотели бы видеть будущее C++.