Pull to refresh

Ода хейта C++

Level of difficultyEasy
Reading time12 min
Views27K

Древнее зло

Язык С++ по-настоящему стар. Казалось бы это должно идти ему на пользу, как хорошему вину. Но этому мешает обратная совместимость. Хорошая идея, если бы она работала...

Давайте просто честно признаемся: ни один из стандартов не был обратно совместим. ВСЕГДА реальные проекты требовали миграции и адаптации не то что под новую версию стандарта, но даже под новую версию компилятора. Чем больше проект, тем больше усилий, и порой фатальных. Уверен, среди читающих найдется человек, у которого на работе все еще 98й стандарт С++.

В целом, идея не ломать старое - хорошая. Лучше уж поломать совсем немного, чем вообще все. Не спорю, здесь С++ держится хорошо, местами даже слишком. Но из-за боязни сломать старое в языке засели старые и просто неправильные решения.

Язык настолько сильно прошит старыми древними идеями, что порой удивляешься как это до сих пор существует в современном мире, где есть куча других более изящных решений в других языках. И ведь логично было бы поправить самые крупные косяки, но нет, все заплаточки аккуратно пришиваются сверху чтобы "не дай боже старая всем нужная библиотека не сломалась". Ну раз она старая и всем нужная, разве никто не проапгрейдит ее?

Все же хотелось бы чтобы язык признавал свои ошибки и ломал даже самые старые концепции в угоду более лучшего будущего.

#include

Моя самая "любимая" древность в языке - это инклюды. Честно говоря это похоже на какое-то издевательство. Я понимаю как к этой идее пришли несколько десятков лет назад. Но почему это существует до сих пор?

Есть же понятная парадигма - глобальное пространство имен. Весь код, все модули, неймспейсы и сущности помещаются в него. Никто в здравом уме не будет делать две разные сущности с одинаковым именем в одном пространстве имен, но в разных сорцах/библиотеках. Но С++ это позволяет. Зачем?

Все это заставляет жонглировать инклюдами и forward declaration'ами, из-за циклических зависимостей и времени компиляции. И даже если MSVS смог схавать каким-то образом твой код, то придет clang у унизит тебя. Попробуйте-ка сделать пару шаблонных классов, ссылающихся друг на друга, в двух разных хидерах! А если их станет больше двух - добро пожаловать в ад.

Недавно напоролся на кейс, где определение класса из хидера генерится с одной специализацией шаблона, а в .срр - с другой. Валится где-то внутри по порче памяти, т.к. специализации разные по размеру. Не тот язык брейнфаком назвали!

Время компиляции

Инклюды влияют на время компиляции. Просто вдумайтесь - одни и те же хидеры парсятся сотни раз. Зачем? Я понимаю, поведение может измениться от порядка включения, поэтому надо парсить. Но хоть кто-то в здравом уме будет это использовать? Кейс выше про специализации говорит что это напрямую вредная практика.

Да, есть способы как это оптимизировать - все те же forward'ы, precompiled header'ы, модули... Но камон, зачем поддерживать концепцию инклюдов, может их просто убрать и изменить стратегию компиляции? Решение буквально очевидно, но его нельзя сделать из-за обратной совместимости.

Ошибки

Выдавать абсолютную тарабарщину на элементарные вещи при ошибках - это просто классика. Тут я поругаю самих разработчиков, использующих С++ - ну вы то чего терпите? Как вообще мы столько лет уживаемся с этим фактом и еще не начали бунд!!1

Ты забыл указать конструктор, оператор или просто опечатался - лови тонны нечитаемой херни. Обязательно нужно все перечислить, не упустить ни одной детали, хотя в 99% случаев они не нужны и тебе нужна краткая выжимка.

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

Разработчиков IDE здесь тоже сложно похвалить, ведь буквально ничего нельзя сделать с этим... Эй, jetbrains, ловите идею: фолдить дефолтные специализации шаблонов в "...", чтобы по клику они разворачивались - они никому не нужны в 99% случаев. Включить подсветку синтаксиса, чтобы было удобно читать. А еще можно наделать шаблонов ошибок и пытаться эвристиками делать дедукцию реальных причин ошибки. Ну или совсем по-современному, отдать это ChatGPT для генерации краткой выжимки.

Строки

Строка в С++ не то чтобы строка: "abrakadabra" может быть и выглядит как строка, но в языке С++ это указатель на массив.

С одной стороны это хорошо, ведь это можно эффективно использовать как ключ с эффективным сравнением - нам не нужно проверять две строки посимвольно, достаточно сравнить указатель.

Однако код"abrakadabra" == "abrakadabra"вернет false, ведь это два разных массива, их адреса конечно же не равны. Или попробуем приплюсовать одну строку к другой: "abra" + "kadabra" Компилятор спросит вас, зачем ты пытаешься суммировать два указателя? И правда зачем...

Было бы не все так печально, если бы строки не нужны были так часто. Окей, нормальные строки конечно же есть в языке - std::string. Их относительно легко использовать, особенно если использовать литералы.

Но давайте вдумаемся в эту концепцию: для часто используемой строки нужно ненулевое действие чтобы ее создать, а для более редкой оптимизации - нулевое. То есть как бы наоборот получается, приходится постоянно прилагать усилия чтобы получить дефолтно ожидаемое поведение. Опять же, обратная совместимость с древними решениями, и мы продолжаем кушать кактус.

Стандартная библиотека

Продолжим про строки, в разрезе стандартной std::string. Какие операции можно делать со строками? Базовые конкатенацию, поиск и т.п. С этим нет проблем.

Но есть пачка стандартных и распространенных методов для строк, которые без труда можно использовать в других языках: split, starts/ends_with и подобные. Части из них в стандартной библиотеке нет. В целом, очень многого в стандартной библиотеке нет, даже из базового набора.

На мой взгляд стандартная библиотека на то и стандартная, что она должна покрывать максимум удобства, быть эталоном API. Но в С++ на API стандартной библиотеки не ругался только ленивый: то того нет, то этого. Реализация тоже вызывает вопросы, поэтому некоторые делают свои варианты (напр. EASTL).

Лично для меня еще существенным минусом является вынесение общих функций наружу, вместо использования их как функций‑членов класса. Мне, например, гораздо проще и очевиднее вызвать myVector.find_if() через точку, чем вызывать ее как внешнюю функцию с передачей итераторов внутрь:

  • когда ты нажимаешь точку (.) в IDE, у тебя вываливается список возможных вызовов у этого типа, его API. При наведении так же высвечивается комментарий, и выводится список параметров. Очень удобно, некий вид автодокументации. Однако, если функция внешняя - тебе просто нужно знать что она есть, никакой автодокументации не получается. Часто видел как пишут свои велосипеды просто по незнанию что они есть в стандарте

  • необходимость передачи .begin()/.end() в большинстве случаев. Это удлиняет и засоряет синтаксис: std::find_if(myVector.begin(), myVector.end(), [] (auto& x) { ... }) . Достаточно более краткой записи myVector.find_if([] (auto& x) { ... }) . Зачем каждый раз передавать в функцию этот дефолтный набор begin/end()?

Продолжая тему эталонности, наверняка вам приходилось смотреть исходники стандартной библиотеки? Как вам такой код?

Открывая буквально любую книгу по программированию, примерно на первой странице можно увидеть что-то вроде "не используй тарабарщину для имен переменных". Названия икеевской мебели звучат и то лучше, чем имена переменных внутри кода стандартной либы: _myIt, _myLast. Когда я вижу префикс "my", сразу вспоминаются MyClass, MyVariable из туториалов по программированию... Не такого уровня ожидаешь от стандартной библиотеки.

Внутри собраны буквально все плохие паттерны кода: полная мешанина из определения и реализации, макросы, хаки, friend'ы и конечно же нечитаемые имена переменных и функций.

Комитет стандарта

Все мы чего-то ждем от комитета, у каждого есть заветная фича из будущего стандарта. Каждый раз открывая статью на хабре про нововведения, жадно ищешь ее глазами... и разочаровываешься. Возвращаешься к началу, начинаешь вчитываться. И не понимаешь кому это нужно.

Честно говоря, я даже не могу вспомнить что там в последних версиях было. Просто какая-то тарабарщина, малоприменимая в моей реальности. Набор фич, которые как будто заточены под какие-то точечные кейсы конкретных контор. Ощущение что у участников комитета нет цели сделать хорошо, а само участие в комитете - уже круто, и это является самой целью.

В итоге самое важное вылизывается годами, не дай боже накосячить и выпустить что-то кривое. Ой блин, погодите, а кривое все равно выходит. Сразу вспоминается какой-нибудь auto_ptr<>. Уверен найдутся люди которые его выкорчевывали из кода и бухтели "обратная совместимость обратная совместимость...".

А важные фичи двигаются все дальше и дальше. Старые легиси костыли никто трогать не хочет. В итоге весь язык буксует и стагнирует. Выглядит так что у комитета нет цели сделать хорошо, но есть долгий и тернистый путь.

Отсутствие очевидных фич

Когда ты варишься внутри одного лишь С++ все кажется нормальным. Если ты изо дня в день кушаешь кактус, но не пробовал тортика, то кактус кажется нормальным.

Мне повезло попробовать разные языки, особенно C#, в больших продакшн проектах. У меня наберется несколько простых, но супер-удобных фич из C#, которые можно было бы перенести в С++ вообще без проблем. Уверен у тех, кто пользовался и другими языками, тоже наберется несколько фич, которые легко встроятся в текущий С++ довольно просто.

Пара фич, из самого очевидного, что я хотел бы видеть в С++ из C#. Замечу, что это чисто синтаксический сахар, никоим образом не ухудшающий производительность:

  • properties, или члены класса, оборачивающие setter и getter в виде обычной переменной. Иногда гораздо удобнее оперировать со значением как с обычной переменной, например, для каких-нибудь векторов: object.position = object.position + other_object.position + delta;. При этом работать не со значением напрямую, а через setter/getter. В моем примере это важно, т.к. изменение позиции объекта как правило порождает перерасчет матриц трансформации. И выглядит это гораздо понятнее и более читаемо, чем вот такое: object.SetPosition(object.GetPosition() + other_object.GetPosition() + delta);

  • extension methods. Это возможность вне класса добавить какие-то методы API к нему. То есть у меня есть какой-то класс, допустим, в библиотеке. Я не могу поменять его код. Но мне хотелось бы внести в него какое-то новое API, чисто ради удобства. В C# вне класса можно написать статичный метод, который принимает this MyClassType, и в котором доступно его оригинальное API. На уровне синтаксиса такой метод считается членом класса и его можно вызвать через (.) точку. Если бы такая возможность была бы, то и проблем с API стандартной библиотеки не было бы, давно бы уже сделал все нужные экстеншены

Лямды

Спасибо что они есть, раньше ведь не было. Даже вспоминать страшно... Опять же, попробуем сравнить с C#. Допустим, нужно найти первый элемент коллекции, удовлетворяющий предикату-лямде. Посмотрим как это выглядит в C#:

collection.Find(obj => obj.myTag == tag);

лаконично. Давайте взглянем на это в С++

collection.Find(..

Ах блин ну чего это я

std::find_if(myVec.begin(), myVec.end(), [&tag](const GameObject& obj) { return obj.myTag == tag; });

Пока начало прочитаешь уже забудешь зачем сюда пришел. Ладно, ладно, итераторы похейтили уже, посмотрим на саму лямду.

Хорошим тоном считается не использовать короткие [&] и [=], мало ли че там "захватится". Но по факту почти везде нужна ссылка... Поэтому перечисляем весь скоуп захвата. Хорошо что в примере одна переменная, в реальности как правило там целая пачка имен.

Далее, аргумент функции и обязательно указать тип. Да, можно auto воткнуть, но тогда IDE не понимает что там за тип предполагается и автодополнение отсутствует. Пальцы отсохнут писать по памяти.

Потом фигурные скобки, потом return, потом точка с запятой, закрывающая фигурная скобка... В общем, С++ довольно многословный.

В языке такое сплошь и рядом, пример с лямдами на поверхности. Приходится много писать чтобы выразить что-то простое, о чем разработчики на других языках не задумываются.

Смарт-поинтеры

Указатели это же базовая вещь языка? В каких-то языках есть GC, в каких-то вшиты в язык shared/weak умные указатели. Но в С++ это пришито сбоку и реализовано как отдельные классы. Они не включены в сам синтаксис, из-за чего возникают проблемы.

Например, вот вы используете std::make_shared<> для более эффективной аллокации, чтобы счетчик был перед объектом для лучшей кеш-утилизации. А теперь попробуй взять шареный указатель на this из конструктора. Не получится, счетчика то еще нет, он прокинется в объект только после конструктора.

А если в конструкторе нужно создать некую древовидную структуру родитель-ребенок, где дети хранят слабую ссылку на родителя? Например, при копировании объекта. В геймдеве это супер-частая тема, когда нужно скопировать гейм-обжект со всеми его потрохами.

Приходится городить фабрики и другие костыли, чтобы обойти эти сайд-эффекты. Это порождает вторичные сайд-эффекты, например IDE не понимает что вызов std::make_shared<> - это вызов конструктора по факту, и не может показать тебе список аргументов.

А ведь эти проблемы можно было бы решить, если бы умные указатели работали на уровне языка?

SFINAE

Думаю многие С++ программисты не особо даже знают что это такое, но это очень маленькое игольное ушко, через которое протащена добрая часть функционала языка.

Итак, SFINAE - это "substitution failure is not an error ". По-русски - ошибка при подстановке типа шаблона не является ошибкой. То есть компилятор пытается специализировать шаблон каким-то типом, в процессе получает ошибку компиляции, и такой "окей, значит так и должно быть, ошибку игнорим и идем дальше".

"И как же через это можно сделать добрую часть функционала?" - спросят некоторые из вас. А я о чем! Как блин через это можно было пропихнуть столько важных вещей?

Возьмем какую-нибудь относительно простую задачу. Например, понять есть ли в классе метод hello(), чтобы вызвать его, если он есть. Не в рантайме через интерфейс, а статически при компиляции вставить вызов метода если он определен.

Попробуем предположить как это можно было бы сделать. Сразу напрашивается решение, спросить у компилятора есть ли у типа нужный нам метод, и если есть, вызвать функцию. А само условие вырезать. Если метода у типа нет, то вырезать условие совсем. В коде могло бы выглядеть как-то так:

if (typeid(MyClass).has_method(hello()))
    myClass.hello();

Просто же правда? На деле, конечно, все сложнее, но суть вроде бы понятна - мы оперируем данными, которыми обладает компилятор, на уровне синтаксического дерева.

Хехе, давайте посмотрим как это работает в реальном С++:

template<class T, class = std::void_t<>>
struct has_hello: std::false_type { };

template<class T>
struct has_hello<T, std::void_t<decltype(&T::hello)>>: std::true_type { };

...

if constexpr (has_hello<MyClass>::value))
    myClass.hello();

WTF??? Скажу честно, когда я это увидел в первый раз, я не мог понять вообще ничего.

Часть с if'ом еще более-менее понятна и лаконична. Даже удобно что if обозначен constexpr , явно обозначающий статичность условия. Не очень понятно откуда взялся ::value, но в целом это еще нормальная часть.

Ну а вот выше это вот магия как бы всех устраивает? Нет никаких вопросов, выглядит как понятный код?

Да, тут применяется SFINAE. Если у класса нет метода hello(), то подстановка его в шаблон в void_t<decltype(&T::hello)> дает ошибку, т.к.decltype(&T::hello) дает ошибку, т.к. типа указателя на несуществующую функцию не существует. В итоге компилятор молча отбрасывает специализацию has_hello, которая наследуется от std::true_type, а вот другая специализация, наследующаяся от std::false_type компилируется без проблем и has_hello<T>::value является константой false.

Ну вот насколько нужно быть упоротым чтобы это на серьезно использовать? Всё мета-программирование С++ сделано через SFINAE. При этом, уверен, некоторые type trait классы все-таки реализованы на уровне компилятора.

Почему просто не использовать информацию из компилятора? Всего то нужно стандартизировать API, и добавить пачку методов, которые реализуются на уровне языка, наподобие typeid(), sizeof(). Сразу же получаем статичную рефлексию. Если поверх этого принять еще и метаклассы - вообще сказка будет.

Вместо этого постоянные хаки и хождения вокруг да около. Все это приправлено соусом как круто делать эти извращения. Но по факту это как операция на сердце хомячка, сделанная через его задницу.

Ты не платишь за то, чего не используешь

Если действительно разобраться, то это не совсем правда. Я не спорю, используя С++ ты довольно приближен к железу и накладные ресурсы довольно маленькие. Но они есть, от этого никуда не деться. В итоге разработчик вводится в некое заблуждение этой красивой фразой.

Рассмотрим пример - dynamic_cast<> и RTTI (runtime type information). Это отключаемая фича в С++, но по-дефолту она включена и многими используется. Многими программистами С++ она воспринимается как бесплатная, однако при включении RTTI постоянно происходит скрытая работа в рантайме.

Можно найти кучу примеров, где нужно просто знать о накладных ресурсах. Банальное добавление элемента в вектор ведь тоже несет некие затраты? Тем более если происходит реаллокация, а она может быть уууух какая...

На мой взгляд фраза "ты не платишь за то, чего не используешь" не совсем соответствует действительности. Скорее подходит "косты в С++ довольно низкие".

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

Более того, я думаю что некая "вариативность" в выборе инструментов позволит разработчику самому балансировать между удобством и производительностью. Было бы круто иметь выбор между зубодробительными оптимизированными инструментами, с ужасным синтаксисом, и менее оптимизированными, но более удобными.

IDE

Немного не честно, но все же еще относительно недавно (пару-тройку лет назад) типичные IDE для C++ выглядели как сарай в сравнении с другими IDE для других языков. До смешного, даже одна и та же IDE MSVS совсем разный зверь для C++ и C#.

Сейчас есть jetbrains, плагины для MSVS (отчасти тоже от jetbrains), с которыми можно жить относительно комфортно. Однако до сих пор, сравнивая работу в IDE для С++ и других языков, C++ больше про страдание.

Голый MSVS 2022 без плагинов по функционалу примерно такой же, как и MSVS 6.0 от 1998 года. Да, я утрирую, но все же в той старой версии было практически все, что есть и сегодня. Единственная мажорная фича, что приходит в голову - это подсветка ошибок в коде без компиляции. Основной функционал работы я получаю из плагинов - адекватный быстрый поиск, переименование, инклюды и т.п.

Для mac os еще недавно вообще был только xCode. Кто пользовался, тот поймет. Если MSVS не дотягивает до других IDE других языков, то xCode - это буквально чуть выше плинтуса. Он тормозит, не умеет вообще ничего, а на больших проектах отваливается и подсветка синтаксиса, и автодополнение. Справедливости ради, тулзы профилирования в xCode - одни из лучших.

Почему-то у С++ постоянно такой флер... страдания, что ли. Вот прям все должно быть сложно, со скрипом. То же самое касается и IDE. Они тяжелые, взгревают твой ноутбук что аж картошку можно жарить, при этом все равно тупят и плохо умеют в рефакторинг кода.

Считается что это из-за сложности языка. Действительно сложно увязать кучу инклюдов, макросов и специализаций шаблонов. Но есть ощущение, опять же, отпилив старый заскорузлый функционал языка, можно получить профит и в удобстве IDE.

Послесловие

Фух, ну вроде бы отлегло... Наверняка кучу всего еще не вспомнил, но думаю общий посыл понятен - в языке куча нерешенных проблем, которые приносят боль каждый день. Надеюсь это откликнется у кого-то в сердце и он тоже сможет "излить душу".

Я думаю стоит всегда задумываться о том, "как мы к такому пришли". Уверен, причин масса. Но хотелось бы так же задумываться о том "как это сделать лучше". Хотелось бы немного больше смелости от любимого языка

Tags:
Hubs:
+89
Comments563

Articles