[Перевод] C++ с точки зрения Rust-разработчика: достоинства и недостатки

Я профессионально программирую на Rust и, признаться, немного этот язык продвигаю. Поэтому можете себе представить глубину моего расстройства, когда моя младшая сестрёнка, почти не умеющая программировать, обратилась ко мне и попросила научить её C++. Я попытался её отговорить, сказав, что однажды она будет сидеть за отладкой ошибок сегментирования, вооружившись Valgrind и вспоминать этот наш разговор, размышляя, а где же она свернула не туда. Но она оказалась ещё упрямее меня и настаивала: хочу выучить язык программирования, которым действительно пользуются люди.

Я не притрагивался к C++ с тех пор, как ещё в старших классах разрабатывал игры на Cocos2D-X, но решил, что сохранившихся у меня туманных воспоминаний о «правиле трёх» (или сколько там было? Пять? Ноль?) и прочих подобных материях будет более чем достаточно, чтобы решить такую задачу. Оказалось, что и мне требуется кое-что подучить, но я с удовольствием узнал, что существует большая аудитория, с которой можно поделиться этими знаниями. Почти любую концепцию из C++ легко понять, если объяснить её в ключе «о, эта как та штука из Rust».

Притом, что C++ местами несимпатичен, этот язык по-своему красив. Я и так это знал, но, когда взялся заново учиться C++, мне стало только яснее: если Rust в какой-то степени и превосходит C++ (допустим, вы верите, что это так), то лишь потому, что сам Rust стоял на плечах такого гиганта как C++.

Так что мы потратили пару недель, проштудировав серию руководств по OpenGL от ютубера под ником TheCherno (кстати, сама серия отличная). Две недели спустя нам удалось на экране единственный статичный голубой квадратик. Я уже стал опасаться, а не начнёт ли моя сестра сомневаться, стоило ли таким образом изучать разработку игр и пытаться изобразить что-нибудь на C++. Так что тогда я решил, что следует отбросить руководства господина Черно и взяться за разработку игры всерьёз.

Мы приступили к реализации фич геймплея и принялись выделять их в примитивный «игровой движок». С гордостью скажу, что теперь у нас есть полноценная игра, можно скачать её и попробовать в неё поиграть  (кодовое название SpaceBoom). Когда мы в целом рассортировали весь код на «элементы движка» и «элементы игровой логики», сестра решила «порулить» — реализовать большую часть игровой логики самостоятельно! Я же «подносил патроны», когда она просила реализовать ту или иную фичу на стороне игрового движка.

Достоинства C++

Свобода

Язык C++ очень либеральный. На нём можно написать код сейчас, а поправить потом. Может быть, не стоило браться за то, что вы сейчас делаете? Это зависит от вас, а не от какого-нибудь гнусного проверщика заимствований и не от пафосных проектировщиков языка, считающих, что им-то виднее, как правильно. Не стесняйтесь, разыменуйте этот указатель — ну что страшного может случиться? Программа аварийно завершится или вытворит что-то странное? Что ж, это видеоигра. Кроме того, вы уже приняли решение писать на C++, так что плохие идеи приходили вам в голову и раньше, и ничто вас не останавливало.

Я здесь немного паясничаю, но C++ в самом деле позволяет вам делать что вы захотите. Я в итоге написал этот ужасный шаблон для мемоизации типов, которые могут использоваться совместно, однако имеют RAII. В результате мне удалось учетверить кадровую частоту нашей игры, лишь минимально изменив тот код геймплея, который писала моя сестра. Не заостряю тут внимания на передаче чисел с плавающей точкой тем функциям, что ожидают целых чисел — правда, в конце концов накопились предупреждения компилятора, и нам пришлось править ошибки, из-за которых они возникали. А если на требовалась глобальная переменная, то мы сначала просто делали её статической, а уже потом переходили к вопросам. Я достаточно хорошо владею Rust и усвоил, как именно в Rust делаются те или иные вещи. Поэтому, несмотря ни на что, могу быстро набросать черновик. Но я не завидую той альтернативной версии меня, в которой я просвещал бы сестру, что такое once_cell и Arc>.

Выразительность

Объектная ориентация также очень приятна. Знаю, все вокруг ненавидят объектную ориентацию, но она крайне хороша при написании игр. Создаём класс GameObject, затем объекты GameObject, представляющие собой квадраты, жёстко вписанные в плиточную карту. Далее относим квадраты к подклассу  SquareObject, делаем объекты SquareObject. Если эти объекты движутся, то относим их к подклассу Character — и т.д. Затем всё это помещается в один гигантский массив vector>, после чего можно применять методы ->update() и ->render() с каждым из объектов. При таком подходе просто всякий раз вызывается нужная вещь, подход просто работает.

Ранее я успел активно попользоваться Bevy, игровым движком на Rust (см. automated-testing-in-bevy) и много могу рассказать об этом движке. Но Bevy позиционируется как «золотой стандарт» легкого в использовании паттерна ECS (сущность-компонент-система), а наш подход к написанию игры на C++ просто получился интереснее и продуктивнее. Немного постыдно, что реальный игровой движок оказывается для разработчика менее удобен, чем какой-то код на C++, который мы с сестрой сварганили за пару недель. Подчеркну, в начале этого проекта ни она, ни я как следует не ориентировались в C++. Это сравнение не совсем верное, поскольку Bevy отчасти жертвует продуктивностью разработчика за серьёзное повышение производительности системы; к тому же, движок очень быстро улучшается. Тем не менее, это заслуга C++, что мы смогли так быстро сделать готовый продукт, работая в своё удовольствие.

Отладка

Мне очень нравится, как обстоят дела с отладкой в Visual studio и VSCode. Не знаю, почему так, но кажется, что ничего подобного (по уровню) для Rust не существует. Даже если это по какой-то причине возможно, мне никогда не приходилось к таким возможностям обращаться. Мои Rust-проекты я всегда выполняю в Cargo или Bazel, и это явно сложнее, чем иметь возможность взять и нажать кнопку в IDE, потом просто пощёлкать мышкой и расставить контрольные точки, и чего вам ещё захочется. За всё время работы с Rust я пользовался отладчиком, может быть, однажды, вся остальная отладка шла через printf.

Современный C++

В C++ сохранилось множество старинных возможностей, которыми, как правило, следует пренебрегать в пользу эквивалентных фич из «современного С++», если только нет явно причины поступить наоборот (char *, массивы, указатели, malloc/free, NULL, т.д.)

Примерно как на этой классической картинке:

c774a6dce1d0136898ccc901d56e2efb.png

В данном случае лестно отметить, что современный C++ на самом деле очень приятен. Особенно unique_ptr ,  shared_ptr и std::array. Научившись пользоваться этими сущностями, можно, как правило, уже не беспокоиться о безопасности памяти. Конечно, по ходу разработки игры нам, так или иначе, придётся эпизодически сталкиваться с ошибками сегментирования, но такие ошибки легко поддаются исправлению.

Оговорюсь, что отчасти нам удалось в этом преуспеть благодаря моему опыту работы с Rust. У нас был вектор объектов GameObject, и мы хотели, чтобы все ссылки были действительны в пространстве всех кадров, поэтому обернули их все в unique_ptr (так мы получили возможность прикреплять к массиву новые элементы, не рискуя инвалидировать ссылки). Совершенно очевидно, что при прикреплении новых элементов к массиву имеющиеся ссылки приходят в негодность — полагаю, вы знаете, как реализуются векторы. Но я вполне представляю себе новичка, который об этом не знает и может столкнуться с такой проблемой при программировании.

Недостатки C++

Непростая судьба языка C++ к настоящему времени возвела его в поразительный статус: это язык, иерархия изменений в котором прослеживается вплоть до 1973 года, когда он был изобретён. Причём, обратная совместимость нарушалась очень редко. Это настоящий технологический шедевр, и я не думаю, что было бы честно считать такую родословную недостатком C++. Да, из-за этого зачастую возникает путаница, но все альтернативы менее предпочтительны.

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

Сборка могла бы быть проще

Отсутствие стандартизированной сборочной системы реально напрягает. Сестра работает под Windows, а я — на макбуке. Поэтому в какой-то момент мы решили перейти с обычной Visual Studio на cmake. cmake — образчик хорошей инженерии и, может быть, я просто не умею пользоваться этим инструментом, но поверьте, что сочетать при работе cmake и Visual Studio совсем не так удобно, как пользоваться Cargo. В Visual Studio предусмотрен режим cmake, который работает нормально, но он явно выглядит второсортным по сравнению с реальной сборочной системой Visual Studio. Мы столкнулись с бесчисленными багами, что вынуждало нас то и дело закрывать и снова открывать инструмент (а в одном случае проблема решалась только через перезапуск компьютера).

Конечно же, C++ не виноват, что продукты Microsoft такие кривые, да и с cmake как-то можно перебиться. Но было бы здорово, если бы cmake хотя бы не усложняла лёгкие вещи, а разработчики пытались избегать проблем из разряда «ничего не знаю, у меня на машине работает». Есть и кое-что хорошее: в cmake предусмотрено отличное расширение для VSCode, работать с которым, по моему скромному опыту, весьма удобно.

Управлять пакетами не так просто

Поправка: мне уже рассказали о conan и vcpkg, которые, по-видимому, решают большинство проблем, затронутых в этом разделе. Однако, оставлю этот раздел для потомков.

Сколько же времени тратится впустую из-за того, что в cmake не предусмотрен менеджер пакетов. Именно по этой причине возникала масса проблем из разряда «а у меня на машине работает». Кажется, что специалистам по C++ просто нравится устанавливать пакеты глобально, а затем добиваться, чтобы сборочная система сама как-то их нашла и связала. Но вот как мы поступим: я попытаюсь сделать у меня на макбуке какую-то операцию, под которую у меня определённо есть библиотека, а затем моя сестра попробует повторить это у себя — и код не соберётся. Я решил эту проблему окольным путём: просто собирал все элементы из исходного кода, насколько это возможно. Работа по сборке библиотечных зависимостей из исходного кода, по-видимому, должна строиться так: добавляете репозиторий как субмодуль git, настраиваете add_subdirectory и include_directories в cmake, затем добавляете библиотеку в target_link_libraries. Конечно, не всегда всё так просто, как описано здесь. Некоторые проекты, например, glew, не предназначены для использования в качестве субмодулей git, поэтому только и остаётся, что впихнуть прямо в репозиторий простыни их кода. Кроме того, мне почему-то не было очевидно, какую именно библиотеку использовать в target_link_libraries (я потратил массу времени на glew, прежде, чем осознал, что его нужно связывать с glew_s, а не с glew). Уже не говорю о том, что в большинстве библиотек, которыми мы пользовались, нашлась папка для cmake, а в этой папке — файл readme примерно следующего содержания: «вот, какой-то чел добавил это через пул-реквест, и я, честно, не знаю, как это работает, но если хочешь — можешь попробовать». А если ваши зависимости увязаны с другими зависимостями — я по-прежнему не вполне понимаю, как это работает. В целом, работать так гораздо менее удобно, чем просто запустить cargo add $CRATE_NAME.

Также требовалось немного потрудиться, чтобы найти документацию — может быть, потому, что она не генерируется автоматически.

Сообщения об ошибках

Сообщения об ошибках в C++ (при использовании Clang и Visual Studio) также отличаются шероховатостью по сравнению с аналогами из Rust. Предположу, что именно из-за сообщений об ошибках моя сестра была так не склонна учить C++, если не касаться этой ситуации со сборочной системой.

Вот что происходит, если изменить сигнатуру функции в заголовке, а в файле .cpp этого не сделать — и наоборот.

FAILED: OpenGL/CMakeFiles/SpaceBoom.dir/src/Renderer.cpp.o

/usr/bin/clang++ -DGLEW_STATIC -I~/coding/SpaceBoom/OpenGL/vendor/openal-soft/include -I~/coding/SpaceBoom/OpenGL/src -I~/coding/SpaceBoom/OpenGL/vendor/glfw/include -I~/coding/SpaceBoom/OpenGL/vendor/glew/include -I~/coding/SpaceBoom/OpenGL/vendor/glm -I~/coding/SpaceBoom/OpenGL/vendor/soloud/include -I~/coding/SpaceBoom/OpenGL/vendor/xxHash/cmake_unofficial/.. -I~/coding/SpaceBoom/OpenGL/vendor/openal-soft/include/AL -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.5.sdk/System/Library/Frameworks -g -std=c++20 -arch arm64 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.5.sdk -mmacosx-version-min=14.2 -pedantic -Wall -Wextra -Wcast-qual -Wdisabled-optimization -Winit-self -Wmissing-include-dirs -Wswitch-default -Wno-unused -Wno-cast-qual -Wno-unused-parameter -MD -MT OpenGL/CMakeFiles/SpaceBoom.dir/src/Renderer.cpp.o -MF OpenGL/CMakeFiles/SpaceBoom.dir/src/Renderer.cpp.o.d -o OpenGL/CMakeFiles/SpaceBoom.dir/src/Renderer.cpp.o -c ~/coding/SpaceBoom/OpenGL/src/Renderer.cpp

~/coding/SpaceBoom/OpenGL/src/Renderer.cpp:18:30: error: out-of-line definition of 'ResPath' does not match any declaration in 'Renderer'

const std::string& Renderer::ResPath(bool idk) {

Сестра постоянно попадала в подобные ситуации, а искать каждую ошибку в man не так здорово, как может показаться. Как только успеешь разобраться, что значат все термины, начинаешь действовать чётко и прямолинейно, но поставьте себя на место моей сестры, которая едва представляет разницу между «объявлением» и «определением». Нигде даже прямо не указано, что определение находится в Renderer.cpp, а объявления лежат в Renderer.h. Да, я знаю, что в силу самого устройства C++ невозможно обеспечить качественные сообщения об ошибках на все случаи, но кажется, что именно этот казус и другие «простые» случаи довольно легко довести до ума.

Habrahabr.ru прочитано 1085 раз