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

в 16:06, , рубрики: c++, stl

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

Уже более десяти лет я профессионально занимаюсь 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 и посмотреть, как у меня выйдет :)

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

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

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

Не благодарите, 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.

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

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

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

Автор: Николй Шалакин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js