Modern technology gives us many things.

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

67

Уровень сложности Средний Время на прочтение 12 мин Количество просмотров 8.4K 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>.

Читать на TechLife:  Российский завод Hyundai планируют продать к 28 декабря

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

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

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

Спрашивается, как извращаться безопасно? Когда я в свое время полез на StackOverflow за правдой, ответ был суров но единственен: «используйте 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; }

Читать на TechLife:  Всё благодаря AMD и модерам. В Cyberpunk 2077 активировали генератор кадров от FSR 3, что повысило производительность RTX 3080 более чем втрое

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

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

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

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

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

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

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.

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

Читать на TechLife:  Два стула Авито

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

Что нам недодали в 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++.

Теги:

  • c++
  • stl

Хабы:

  • C++

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку Задонатить
Источник

Оставьте ответ

Ваш электронный адрес не будет опубликован.

©Купоно-Мания.ру