Сравнение rust и c

Обновлено: 04.07.2024

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

C ++ - это объектно-ориентированный язык программирования, который можно использовать для разработки игр, операционных систем, веб-браузеров, облачных / распределенных систем и т. д. Он особенно популярен для разработки игр из-за его высокой производительности, сильной абстракции и доступности библиотек и инструментов.

В этом руководстве мы сравним Rust и C ++ в контексте разработки игр. Мы рассмотрим сходства и различия между Rust и C, а также плюсы и минусы использования каждого языка программирования для разработки игр. Мы также познакомим вас с некоторыми инструментами для разработки игр на Rust и C ++.

Почему C ++ популярен для разработки игр

C ++ уже довольно давно используется в индустрии разработки игр. Многие разработчики игр предпочитают дополнять его другими языками семейства C или даже языками ассемблера.

C ++ делает ставку на высокую производительность и сильную абстракцию. Разработчики также выбирают C ++ из-за его функции наследования и других функций, предоставляемых его объектно-ориентированной моделью. Любой, кто какое-то время работает в игровой индустрии, может подтвердить широкую доступность инструментов для создания игр на C ++. Для разработчиков, которым нужно уложиться в срок или которые являются новичками в игровой индустрии, C ++ всегда подходит из-за широкой доступности инструментов и ресурсов.

Игровые движки для разработчиков на C ++

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

Blender

Blender - это бесплатное программное обеспечение с открытым исходным кодом (FOSS) для 3D-производства. Он полностью построен на C ++ и предлагает поддержку OpenAL 3D-звука и сценариев Python. Учитывая, что это кроссплатформенный блендер, он поддерживает большинство основных операционных систем. Разработка игр - это не все, для чего подходит Blender; вы также можете создавать короткометражные фильмы и другие кинематографические элементы.

Unity

Unity - это кроссплатформенный игровой движок, который позволяет создавать игры в 2D, 3D и виртуальной реальности. Хотя Unity в основном создавался как эксклюзивный игровой движок для MAC OS X, с тех пор Unity был принят во многих кинематографических, инженерных и строительных приложениях.

Panda3D

Некоторые игровые движки требуют использовать внешние библиотеки для обнаружения столкновений, I / O, аудио, и т.д. Panda3D предлагает все это и многое другое в одном пакете. Этот игровой движок, написанный на C ++, позволяет писать игры на Python, хотя есть обходной путь для написания игр на C ++.

Godot

Godot - это кроссплатформенный игровой движок с открытым исходным кодом, наполненный инструментами, которые позволяют вам сосредоточиться на разработке игры. Этот игровой движок построен на C ++ и довольно популярен для разработки игр на C ++ из-за поддерживаемой гибкости.

Инструменты C ++ для разработки игр

В отличие от Rust, большинство игровых движков на C ++ содержат все инструменты, необходимые для разработки игр.

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

Rust в разработке игр: мы уже играем?

Чтобы оценить, что предлагает Rust и почему это полезный язык для разработки игр, давайте немного прокатимся на поезде истории, чтобы понять, почему он вообще был создан.

Rust начинался как побочный проект одного из сотрудников Mozilla, Грейдона Хоара, который устранил большинство уязвимостей в C ++. Со временем пользователи Mozilla разочаровались в утечках памяти и других уязвимостях C ++, который является основным языком его веб-браузера Firefox.

Вот почему Грейдон Хоар предлагает Rust, язык, над которым он работал с 2006 года. Только в 2010 году Mozilla начала поддерживать Rust после того, как он продемонстрировал большие успехи в обеспечении безопасности памяти.

Зачем использовать Rust для разработки игр?

Зачем кому-то нужно использовать новый язык для разработки игр вместо семейства C, которое существует уже много лет? Это потому, что безопасные для памяти языки, такие как Rust, устраняют многие ошибки, с которыми ваши пользователи могут столкнуться при использовании вашего продукта. Безопасные для памяти языки не позволяют запускать код с утечками памяти. Для обеспечения безопасности памяти Rust использует модель, ориентированную на данные. Он обрабатывает игровые элементы как данные, а не как объекты, как в объектно-ориентированном программировании.

Объектно-ориентированное и ориентированное на данные программирование

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

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

Чтобы понять обратную сторону инкапсуляции / наследования, связанной с ООП при разработке игр, давайте посмотрим на этот быстрый пример из заключительного выступления Кэтрин Уэст на RustConf 2018:

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

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

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

Поскольку в Rust используется подход, ориентированный на данные, игровые элементы обрабатываются как данные. Rust использует паттерн Entity Component System (ECS) при разработке игр, где объект состоит из различных компонентов, прикрепленных к нему, компонент состоит из блоков данных (данные для разработки игры), а система управляет логикой приложения. Например, если мы хотим воспроизвести тот же пример из C ++ в Rust, мы будем использовать подход ECS с сущностями и компонентами как структурами.

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

Игровые движки для разработчиков на Rust

Несмотря на то, что Rust является относительно новым языком в сфере разработки игр, уже существует солидный выбор игровых движков, созданных на Rust. Давайте посмотрим на некоторые из лучших ящиков игрового движка Rust и кратко рассмотрим, как их использовать при разработке игр.

Amethyst

Amethyst ориентирован на данные, быстр и прост в настройке. Он имеет массивно-параллельную архитектуру, использует модель ECS и позволяет быстро создавать прототипы с помощью файлов RON.

Amethyst позволяет разработчикам, которые плохо знакомы с разработкой игр, сразу же взяться за дело. Движок игры предлагает примеры, которые помогут вам легко познакомиться. Чтобы запустить любой из примеров, выполните команду ниже в интерфейсе командной строки по вашему выбору:

Caper

Caper поддерживает другие системы, включая аудио, рендеринг, ввод и обнаружение столкновений. Это не кроссплатформенный игровой движок и поддерживает только ОС Linux. Как и Amethyst, Caper предоставляет несколько примеров, которые помогут вам сориентироваться в игровом движке. Вы можете протестировать эти примеры, выполнив приведенную ниже команду в интерфейсе командной строки.

Chariot

Chariot - это повторная реализация игры Age of Empires, выпущенной Microsoft, в которой используется движок Genie Engine. Chariot - это игровой движок с открытым исходным кодом, который можно портировать на любую желаемую платформу. Цель этого игрового движка - создавать игры, подобные вышеупомянутому.

Console

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

Oxygengine

Oxygengine - это движок веб-игр, написанный на Rust с помощью web-sys. Это игровой движок HTML5 и WebAssembly, основанный на крейте Specs, используемом для его среды ECS.

Другие примечательные игровые движки, написанные в Русте включают bevy , coffee , corange , doryen , dotrix , muoxi , rusty_engine , turbine , и многое другое.

Инструменты Rust для разработки игр

Как мы упоминали ранее, инструменты играют важную роль в разработке игр. В этом разделе, как и в случае с C ++, мы подробно рассмотрим некоторые инструменты Rust для разработки игр.

2D рендеринг

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

3D рендеринг

Хотя 2D-рендеринг предлагает двухмерные фотореалистичные изображения, как вы, наверное, догадались, 3D-рендеринг делает вашу игровую среду еще более реалистичной с трехмерными изображениями. Ниже приведены некоторые из наиболее полезных инструментов 3D-рендеринга для разработчиков игр на Rust:

Искусственный интеллект (ИИ)

Библиотеки AI позволяют использовать алгоритмы для реализации прогнозируемого поведения в вашей игре. Например, есть библиотеки AI с готовыми шахматными алгоритмами, которые вы можете использовать для создания такой игры в Rust. Яркие примеры библиотек Rust AI для разработки игр:

Библиотеки анимации

Большинство игр требуют движения. Библиотеки анимации в Rust позволяют управлять изображениями так, как будто они движутся. Поскольку большинство библиотек Rust создается членами сообщества, а Rust - относительно новый язык на сцене, Pareen - единственный широко используемый ящик для анимации для разработки игр на Rust на момент написания.

Pareen позволяет создавать анимацию, параметризованную по времени, без необходимости передавать временные переменные. Это полезно для создания плавных переходов между несколькими игровыми состояниями.

Аудио оболочки

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

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

Для игр, в которых используются планшеты и другие устройства ввода, вам понадобится ящик для работы с контроллерами в устройствах ввода. В Rust есть две библиотеки ввода:

Сетевые инструменты

Игры становятся намного веселее, когда вы играете в них с друзьями. Экосистема Rust включает ряд сетевых инструментов, которые помогают наладить сотрудничество между разработчиками и упростить многопользовательские функции в играх на Rust, в том числе:

Библиотеки обнаружения столкновений

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

Полезные библиотеки обнаружения столкновений для разработчиков игр на Rust:

Библиотеки пользовательского интерфейса

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

Вот некоторые библиотеки пользовательского интерфейса для разработки игр на Rust:

Движки VR

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

Ниже приведены некоторые из лучших движков виртуальной реальности, которые Rust может предложить:

Где Rust терпит неудачу

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

Чтобы узнать больше о проблемах, которые ставит объектно-ориентированное программирование при разработке игр, ознакомьтесь с заключительным докладом Кэтрин Уэст с RustConf 2018.

C ++ против Rust: что лучше всего подходит для вашего проекта по разработке игр?

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

Доступность инструментов и поддержка также являются главными соображениями для разработчиков игр. Поэтому, если вы создаете игру, в которой безопасность памяти является приоритетом, Rust, вероятно, будет вашим лучшим выбором. На Discord и в других местах есть поддержка сообщества и каналы связи. Вы можете быть в курсе последних событий и отслеживать готовность Rust к разработке игр, посетив Are We Game Again?

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

Заключение

В этом руководстве мы изучили основы разработки игр на C ++ и языке программирования Rust. Мы сравнили опыт разработчиков с использованием Rust и C ++ для создания игр; перечислены наиболее полезные и широко используемые инструменты для реализации анимации, звуков, обнаружения столкновений, многопользовательских функций и многого другого; и определили несколько простых параметров для определения того, какой язык лучше всего подходит для вашего проекта разработки игр

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

Язык Rust позиционирует себя, как язык системного программирования, поэтому основным его vis-à-vis следует называть C/C++ . Сравнивать же молодой и мультипарадигмальный Rust , который поддерживает множество современных конструкций программирования (таких, как итераторы, RAII и др.) с «голым» C я считаю не правильно. Поэтому в данной статье речь пойдет об сравнении с C++ .

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

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

Данная статья достаточно субъективная. Даже сравнение производительности программ, которые делают одинаковые вещи, но написаны на разных языках, подвержены авторскому подходу. Поэтому я не претендую на объективные замеры, и, чтобы не навязывать свои результаты, предлагаю всем ознакомится с кодом программ и с подходом к замеру производительности. Код выложен на github. Буду рад любой критике и замечаниям. Начнем.

Что плохого и хорошего в Rust

+ Разработчики Rust поставляют свой компилятор уже с «батарейками внутри» тут есть: компилятор, менеджер пакетов (он же сборщик проектов, он же отвечает за запуск тестов), генератор документации и отладчик gdb . Исходный код на Rust может включать в себя сразу тесты и документацию, и чтобы собрать все это не требуется дополнительных программ или библиотек.

+Компилятор строг к тексту программы, который подается ему на вход: в его выводе можно увидеть какой код не используется, какие переменные можно изменить на константный тип, и даже предупреждения, связанные со стилем программирования. Часто для ошибок компиляции приведены варианты ее устранения, а ошибки при инстанциировании обобщенного кода (шаблонов) лаконичны и понятны (привет ошибкам с шаблонами STL в C++ ).

+ При присваивании или передачи аргументов по умолчанию работает семантика перемещения (аналог std::move , но не совсем). Если функция принимает ссылку на объект необходимо явно взять адрес (символ & , как в C++ ).

+ Все строки — это юникод в кодировке UTF-8 . Да, для подсчета количества символов нужно О(N) операций, но зато никакого зоопарка с кодировками.

+ Есть поддержка итераторов и замыканий (лямбда функций). Благодаря этому можно писать однострочные конструкции, которые выполняют множество операций с сложной логикой (то, чем славится Python ).

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

- Нужно писать программы так, чтобы компилятор (точнее borrow checker) поверил, что вы не делаете ничего плохого с памятью (или с ресурсами в целом). Часто это работает как надо, но иногда приходится писать код в несколько хитрой форме только для того, что бы удовлетворить условиям borrow checker'а.

- В Rust нет классов, но есть типажи, на основе которых можно построить объектно ориентированную программу. Но скопировать реализацию с полиморфными классами в C++ в язык Rust напрямую не получиться.

- Rust достаточно молод. Мало полезного материала в сети, на StackOverflow. Мало хороших библиотек. Например, нет библиотек для построения GUI, нет портов wxWidgets , Qt .

Алгоритмические задачи на Rust

Бинарный поиск

Нужно для для каждого значения из вектора B найти его позицию в векторе A. По сути нужно применить n раз бинарный поиск, где n — кол-во элементов в B (массив A предварительно отсортирован). Поэтому тут я приведу функцию бинарного поиска.

  1. Тип возвращаемого значения указывается в конце объявления функции
  2. Если переменная изменяемая, то нужно указывать модификатор mut
  3. Rust не переводит типы неявно, даже числовые. Поэтому нужно писать явный перевод типа l = i as i32 + 1

Сортировка слиянием

Формально задача звучит так: для заданного массива подсчитать кол-во перестановок, необходимых для сортировки массива. По факту нам требуется реализовать сортировку слиянием, подсчитывая по ходу длину перестановок элементов.

Давайте рассмотрим код чтения массива с stdin

  1. У классов нет конструкторов, но можно делать статические методы-фабрики, которые возвращают объекты классов, как String::new() выше.
  2. Функции, которые могут ничего не вернуть, возвращают объект Option , который содержит None или результат корректного завершения функции. Метод unwrap позволяет получить результат или вызывает panic! , если вернулся None .
  3. Метод String::parse парсит строку в тип возвращаемого значения, т.е. происходит вывод типа по возвращаемому значению.
  4. Rust поддерживает итераторы (как генераторы в Python). Связка split_whitespace().enumerate() генерирует итератор, который лениво читает следующий токен и инкрементирует счетчик.

Приведу сначала неправильную сигнатуру вызова функции _merge , которая сливает in place два отсортированных подмассива.


Данная конструкция не взлетит в Rust без unsafe кода, т.к. тут мы передаем два изменяемых подмассива, которые располагаются в исходном массиве. Система типов в Rust не позволяет иметь две изменяемых переменных на один объект (мы знаем, что подмассивы не пересекаются по памяти, но компилятор — нет). Вместо этого надо использовать такую сигнатуру:

Кодирование Хаффмана

Заведем класс Node:

Метод для посещения нод дерева сверху вниз и для составления таблицы кодирования.

  1. Рекурсивно вызывает ветки бинарного дерева, если их указатель не пустой &Some(ref leaf) .
  2. Конструкция match похожа на switch в C . match должен обработать все варианты, поэтому тут присутсвует _ =>< >.
  3. Помните про семантику перемещения по-умолчанию? Поэтому нам нужно писать prefix.clone() , чтобы в каждую ветвь дерева передалась своя строка.

Декодирование Хаффмана

  1. Тут match используется для сравнения структуры переменной p . &mut Node if c == '0' означает «если p это изменяемая ссылка на объект Node у, которого поле left указывает на существующий node и при этом символ c равен '0'».
  2. В Rust нет исключений, поэтому panic!(". ") раскрутит стек и остановит программу (или поток).

Расстояние Левенштейна

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

  1. str1: &str — это срез строки. Легковесный объект, который указывает на строку в памяти, аналог std::string_view C++17.
  2. let ind = |i, j| i * m + j; — такой конструкцией определяется лямбда функция.

Замеры производительности Rust vs C++

В конце, как обещал, прикладываю таблицу сравнения времени работы программ, описанных выше. Запуск производился на современной рабочей станции Intel Core i7-4770, 16GB DDR3, SSD, Linux Mint 18.1 64-bit. Использовались компиляторы:


  1. Измерялось полное время работы программы, в которое включено чтение данных, полезные действия и вывод в /dev/null
  2. В * (core) измерялось только время работы алгоритмической части (без ввода/вывода)
  3. Делалось 10 прогонов каждой задачи на каждом наборе данных, далее результаты усреднялись
  4. Все скрипты, производящие компиляцию, подготовку тестовых данных и замеры производительности, представлены в репозитории. К ним есть описание.
  5. По моему мнению, на ряде задач C++ проигрывает из-за библиотеки потокового чтения/записи iostream . Но эту гипотезу еще предстоит проверить.И да, в коде есть std::sync_with_stdio(false)
  6. По моему мнению, Rust сильно проигрывает в тесте Huffman encoding по причине медленных хешей в HashMap
  7. На полном времени выполнения задач Rust показал, что по производительности он не уступает C++ . В каждом языке есть свои особенность реализации стандартной библиотеки, которые сказывают на скорости работы задач.
  8. На замерах только алгоритмической части, Rust проигрывает порядка 10%, но, думаю ситуация бы исправилась, если будем использовать хэши побыстрее в первой задаче.

Заключение

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

Спасибо, что дочитали до конца!

Обновление от 11.12.2017

  1. Исправил функцию binary_search . Теперь она возвращает Option
  2. Задача Binary_search теперь принимает на вход отсортированный массив A, поэтому во время работы теперь не входит сортировка A
  3. Добавил -DNDEBUG при компиляции C++ . Посыпаю голову пеплом..
  4. Исправлена грубая ошибка в задаче Huffman_decoding , из-за который программа на C++ ДВАЖДЫ декодировала строку. Прошу прощения за это.
  5. Добавлен замер времени выполнения только алгоритмической части, без учета операций ввода-вывода и загрузки программы.

Теперь ситуация стала более объективной. Но в целом, оба языка держаться в поле ±10% на данных задачах.

Спойлер: C++ не быстрее и не медленнее и вообще смысл не в этом. Эта статья является продолжением славных традиций развенчания мифов крупных российских компаний о языке Rust. Предыдущая была "Go быстрее Rust, Mail.Ru Group сделала замеры".

Недавно я пытался заманить коллегу, сишника из соседнего отдела, на Тёмную сторону Rust. Но мой разговор с коллегой не задался. Потому что, цитата:

В 2019 году я был на конференции C++ CoreHard, слушал доклад Антона antoshkka Полухина о незаменимом C++. По словам Антона, Rust еще молодой, не очень быстрый и вообще не такой безопасный.

Антон Полухин является представителем России в ISO на международных заседаниях рабочей группы по стандартизации C++, автором нескольких принятых предложений к стандарту языка C++. Антон действительно крутой и авторитетный человек в вопросах по C++. Но доклад содержит несколько серьёзных фактических ошибок в отношении Rust. Давайте их разберём.

Речь идет об этом докладе с 13:00 по 22:35.

Для примера сравнения ассемблерного выхлопа Антон взял функцию возведения в квадрат(link:godbolt):

Получаем одинаковый ассемблерный выхлоп. Отлично! У нас есть базовая линия. Пока что C++ и Rust выдает одно и то же.

В самом деле, ассемблерный листинг арифметического умножения в обоих случаях выглядит одинаковым, но это только до поры до времени. Дело в том, что с точки зрения семантики языков, код делает разные вещи. Этот код определяет функции возведения числа в квадрат, но в случае Rust область определения [-2147483648, 2147483647], а в случае C++ это [-46340, 46340]. Как такое может быть? Магия?

В Rust такая ситуация с неопределенным поведением в арифметике невозможна в принципе.

Давайте послушаем, что об этом думает Антон (13:58):

Неопределенное поведение заключается в том что у нас тут знаковое число, и компилятор C++ считает что переполнения знаковых чисел не должно происходить в программе. Это неопределенное поведение. За счет этого компилятор C++ делает множество хитрых оптимизаций. В компиляторе Rust'а это задокументированное поведение, но от этого вам легче не станет. Ассемблерный код у вас получается тот же самый. В Rust'е это задокументированное поведение, и при умножении двух больших положительных чисел вы получите отрицательное число, что скорее всего не то, что вы ожидали. При этом за счет того, что они документируют это поведение, Rust теряет возможность делать многие оптимизации. Они у них прям где-то на сайте написаны.

Я бы почитал, какие оптимизации не умеет Rust, особенно с учётом того, что в основе Rust лежит LLVM — тот же самый бэкенд, что и у Clang. Соответственно, Rust «бесплатно» получил и разделяет с C++ большую часть независящих от языка трансформаций кода и оптимизаций. И хотя в представленном примере мы и получили одинаковый ассемблер, на самом деле, это случайность. Хитрые оптимизации и наличие неопределённого поведения при переполнении знакового в языке C++ могут приводить к веселью и порой порождают такие статьи. Рассмотрим эту статью подробнее.

Дан код функции, вычисляющей полиномиальный хеш от строки с переполнением int'a:

На некоторых строках, в частности, на строке «bye», и только на сервере (что интересно, на своем компьютере все было в порядке) функция возвращала отрицательное число. Но как же так, ведь в случае, если число отрицательное, к нему прибавится MAX_INT и оно должно стать положительным.

Как подсказывает PVS-Studio, неопределенное поведение действительно не определено. Если посчитать 27752 в 3 степени, можно понять, почему хэш от двух букв считается нормально, а от трех уже с какими-то странными результатами.

Аналогичный код на Rust будет вести себя корректно(link:playground):

Выполнение этого кода отличается в Debug и Release по понятным причинам, а для унификации поведения можно воспользоваться семейством функций: wrapping*, saturating*, overflowing* и checked*.

Как видите, документированное поведение и отсутствие неопределённого поведения при переполнении знакового действительно делают жизнь легче.

Вычисление квадрата числа — это отличный пример того, как можно выстрелить себе в ногу с помощью C++ в трех строчках кода. Зато быстро и с оптимизациями. Если от обращения к неинициализированной памяти ещё можно откреститься вдумчивым взглядом, то проблема с арифметикой в том, что беда может прийти совершенно внезапно и на «голом» арифметическом коде, где ломаться на первый взгляд нечему.

В качестве примера приводится следующий код(link:godbolt):

Здесь мы наблюдаем бесконечную рекурсию. Опять-таки код компилируется в одинаковый ассемблерный выхлоп, то есть NOP для функции bar как в C++, так и в Rust. Но это баг LLVM.

Если вывести LLVM IR кода с бесконечной рекурсией, то мы увидим(link:godbolt):

ret i32 undef — и есть ошибка, сгенерированная LLVM.

В самом LLVM бага живет с 2006 года. И это важный вопрос, ведь необходимо иметь возможность пометить бесконечные цикл или рекурсию так, чтобы LLVM не мог оптимизировать это в ноль. К счастью, есть прогресс. В LLVM 6 добавили интринсик llvm.sideeffect, а в 2019 году в rustc был добавлен флаг -Z insert-sideeffect , который добавляет llvm.sideeffect в бесконечные циклы и рекурсии. И бесконечная рекурсия становится действительно бесконечной(link:godbolt). Надеюсь, что в скором времени этот флаг перейдет и в stable rustc по умолчанию.

UPD(16 апреля 2021):
Этот флаг вошёл в стабильную версию компилятора версии 1.47, то есть 8 октября 2020. Начиная с этой версии смысла рассматривать Миф №2 нет, так как компилятор раста начинает вставлять действительно бесконечную рекурсию.

В C++ бесконечная рекурсия и цикл без побочных эффектов считаются неопределённым поведением, так что от этой баги LLVM страдают только Rust и C.

Итак, после того, как мы разобрались с ошибкой LLVM, давайте перейдем к главному заявлению: "его безопасность заключается только в анализе времени жизни объектов". Это заявление ложно, так как безопасное подмножество Rust защищает от ошибок, связанных с многопоточностью, гонками данных и выстрелами по памяти.

Посмотрим более сложные функции. Что с ними делает Rust. Поправили нашу функцию bar и теперь она вызывает функцию foo . Мы видим, что Rust сгенерировал две лишних инструкции: одна инструкция сохраняет что-то в стек, другая инструкция в конце вытаскивает со стека. В C++ этого нету. Rust два раза потрогал память. Как-то уже не очень.

И C++, и Rust сгенерировали одинаковый выхлоп ассемблера, оба добавили push rbx для выравнивания стека. Q.E.D.

Самое интересное заключается в том, что именно C++ нуждается в деоптимизации кода путём добавления аргумента -ftrapv , чтобы ловить неопределенное поведение при переполнении знаковых. Выше я уже показал, что Rust будет вести себя корректно даже без флага -C overflow-checks=on , так что можете сравнить сами(link:godbolt) стоимость корректного кода на C++, либо почитайте статью на эту тему. К тому же -ftrapv в gcc сломан с 2008 года.

На протяжении всего доклада Антон выбирает примеры, написанные на Rust'е, которые компилируются в чуть больший ассемблер. Не только примеры выше, которые "трогают" память, но и пример на 17:30(link:godbolt):

Складывается впечатление, что весь этот анализ ассемблерного выхлопа был нужен лишь для того, чтобы сказать, что больше асма → медленнее язык.

В 2019 на конференции CppCon был интересный доклад There Are No Zero-cost Abstractions от Chandler Carruth. Вот он там на 17:30 сильно страдает из-за того, что std::unique_ptr стоит дороже сырых указателей (link:godbolt). И чтобы хоть как-то приблизиться к ассемблерному выхлопу кода на сырых указателях ему приходится добавлять noexcept , rvalue ссылки, и использовать std::move . А на Rust всё будет работать без дополнительных усилий. Давайте сравним два кода и ассемблер. В примере на Rust мне пришлось дополнительно извратиться с extern "Rust" и unsafe , чтобы компилятор не заинлайнил вызовы (link:godbolt):

При меньших трудозатратах Rust генерирует меньше ассемблера. И не нужны подсказки компилятору в виде noexcept , rvalue ссылок и std::move . В сравнениях языков нужны нормальные бенчмарки. Нельзя вытащить понравившийся пример, и утвержать, что один язык медленнее другого.

В декабре 2019 Rust превосходил по производительности C++ согласно результатам Benchmarks Game. С тех пор C++ немного укрепил свои позиции. Но на таких синтетических бенчмарках языки будут раз за разом обходить друг друга. Я бы не отказался посмотреть нормальные бенчмарки.

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

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

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

К счастью, у языка Rust есть пакетный менеджер cargo, который позволяет один раз сгенерировать объявления и поделиться ими со всем миром. Как вы понимаете, люди делятся не только сырыми объявлениями, но и безопасными и идиоматичными обёртками. На 2020 год в реестре пакетов crates.io находится около 40 000 крейтов.

Ну а само использование сишной библиотеки занимает буквально одну строчку в вашем конфиге:

Всю работу по компиляции и линковке с учетом версий зависимостей cargo выполнит автоматически. Пример с flate2 примечателен тем, что в начале своего существования этот крейт использовал сишную библиотеку miniz, написанную на C, но со временем сообщество переписало сишный код на Rust. И flate2 стал работать быстрее.

Внутри блока unsafe отключаются все проверки Rust'а, он там ничего не проверяет, и целиком полагается на то, что вы в этом месте написали все правильно.

Данный пункт является продолжением темы про интеграцию сишных библиотек в Rust'овый код.

Увы, мнение об отключении всех проверок в unsafe — это типичное заблуждение, потому что в документации к языку Rust сказано, что unsafe позволяет:

  1. Разыменовывать сырой указатель;
  2. Вызывать и объявлять unsafe функции;
  3. Читать или измененять статическую изменяемую переменную;
  4. Реализовывать и объявлять unsafe типаж;
  5. Получать доступ к полям union .

Ни о каких отключениях всех проверок Rust здесь и речи не идет. Если у вас ошибка с lifetime-ами, то просто добавление unsafe не поможет коду скомпилироваться. Внутри этого блока компилятор продолжает проверять код на соответствие системы типов, отслеживать время жизни переменных, корректность на потокобезопасность и многое-многое другое. Подробнее можно прочитать в статье You can’t "turn off the borrow checker" in Rust.

К unsafe не стоит относиться как "я делаю, что хочу". Это указание компилятору, что вы берете на себя ответственность за вполне конкретный набор инвариантов, которые компилятор самостоятельно проверить не может. Например, разыменование сырого указателя. Это мы с вами знаем, что сишный malloc возвращает NULL или указатель на аллоцированный кусок неинициализированной памяти, а компилятор Rust об этой семантике ничего не знает. Поэтому для работы с сырым указателем, который вернул, к примеру, malloc , вы должны сказать компилятору: "я знаю, что делаю; я проверил, там не нулл, память правильно выравнена для этого типа данных". Вы берете на себя ответственность за этот указатель в блоке unsafe .

Из десяти ошибок за последний месяц которые я встречал и которые возникали в C++ программах три были вызваны тем, что с сишным методом неправильно работают, где-то забыли освободить память где-то не тот аргумент передали, где-то не проверили на null и передали нулевой указатель. Огромное количество проблем именно в использовании сишного кода. И в этом месте Rust вам никак не поможет. Получается как-то не очень хорошо. Вроде бы у Rust с безопасностью намного лучше, но только мы начинаем использовать сторонние библиотеки, нужно иметь такую же бдительность как в C++.

По статистике Microsoft, 70% уязвимостей связаны с нарушениями безопасности доступа к памяти и с другими классами ошибок, которые Rust предотвращает ещё на этапе компиляции. Это ошибки, которые физически невозможно совершить в безопасном подмножестве Rust.

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

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

На практике делегация unsafe наверх выглядит вот так:

slice::get_unchecked — это стандартная unsafe функция, которая получает элемент по индексу без проверок индекса на выход за границы. Так как в нашей функции get_elem_by_index мы тоже не проверяем индекс, а передаем его как есть, то наша функция потенциально опасна. И любое обращение к такой функции требует явного указания unsafe (link:playground):

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

Тем не менее, с помощью этой unsafe функции мы можем построить безопасную версию(link:playground):

И эта безопасная версия никогда не выстрелит по памяти, какие бы аргументы вы туда не передали. Если что, я не призываю вас писать подобный код на Rust (есть функция slice::get ), я показываю, как можно перейти из unsafe подмножества Rust в безопасное подмножество с сохранением гарантий безопасности. На месте нашей unchecked_get_elem_by_index могла быть аналогичная функция, написанная на C.

Благодаря межъязыковой LTO вызов сишной функции может быть абсолютно бесплатен:

Я выложил проект с флагами компилятора на гитхаб. Результирующий выхлоп ассемблера аналогичен коду, написанному на чистом C(link:godbolt), но имеет гарантии кода, написанного на Rust.

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

В 2018 году доказали, что система типов Rust, механизмы заимствования, владения, времён жизни и многопоточности корректны. Так же было доказано, что если мы используем семантически правильный код из библиотек внутри unsafe и смешаем это с синтаксически правильным safe кодом, мы получим семантически правильный код, который не позволяет стрелять по памяти или делать гонки данных.

Из этого следует, что если вы подключаете и используете крейт(библиотеку), которая содержит unsafe, но предоставляет правильные безопасные обертки, то ваш код от этого не станет небезопасным.

В качестве практического применения своей модели авторы доказали корректность некоторых примитивов стандартной библиотеки, включая Mutex, RwLock, thread::spawn. А они используют сишные функции. Таким образом, в Rust невозможно случайно расшарить переменную между потоков без примитивов синхронизации; а, используя Mutex из стандартной библиотеки, доступ к переменной всегда будет корректен, несмотря на то, что их реализация опирается на сишные функции. Круто? Круто.

Объективно обсуждать относительные преимущества того или иного языка сложно, особенно если вам сильно нравится один язык и не нравится другой. Весьма часто новый апологет очередного "новоявленного языка-убийцы C++" делает громкие заявления, не разобравшись толком с C++, за что ожидаемо получает по рукам.

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

Большое спасибо Дмитрию Кашицыну и Алексею Кладову за ревью статьи.

в rustc был добавлен. И бесконечная рекурсия становится действительно бесконечной(link:godbolt). Надеюсь, что в скором времени этот флаг перейдет и в stable rustc по умолчанию.

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

Все C++ программы были собраны при помощи gcc-4.7.2 в режиме c++11, используя online compiler. Программы на Rust были собраны последней версией Rust (nightly, 0.11-pre), используя rust playpen.

Я знаю, что C++14 (и далее) будет залатывать слабые места языка, а также добавлять новые возможности. Размышления на тему того, как обратная совместимость мешает C++ достичь звёзд (и мешает ли), выходят за рамки данной статьи, однако мне будет интересно почитать Ваше экспертное мнение в комментариях. Также приветствуется любая информация о D.

Проверка типов шаблона

Шаблоны в Rust проверяются на корректность до их инстанцирования, поэтому есть чёткое разделение между ошибками в самом шаблоне (которых быть не должно, если Вы используете чужой/библиотечный шаблон) и в месте инстанцирования, где всё, что от Вас требуется — это удовлетворить требования к типу, описанные в шаблоне:

Этот код не собирается по очевидной причине:

demo:5:5: 5:9 error: failed to find an implementation of trait Sortable for int
demo:5 sort(&mut [1,2,3]);
Обращение к удалённой памяти

Существует целый класс проблем с С++, выражающихся в неопределённом поведении и падениях, которые возникают из-за попытки использовать уже удалённую память.
Пример:

В Rust такого рода проблемы невозможны, так как не существует команд удаления памяти. Память на стеке живёт, пока она в области видимости, и Rust не допускает, чтобы ссылки на неё пережили эту область (смотрите пример про потерявшийся указатель). Если же память выделена в куче — то указатель на неё (
На выходе:

Неинициированные переменные

Выдаёт мне 0 на выходе, хотя на самом деле здесь, конечно, неопределённый результат. А вот то же самое на Rust (прямой не-идиоматичный перевод):

Не собирается, ошибка:

Более идиоматичный (и работающий) вариант этой функции выглядел бы так:

Неявный конструктор копирования

Собирается, однако падает при выполнении:

*** glibc detected *** demo: double free or corruption (fasttop): 0x0000000000601010 ***

То же самое на Rust:

Собирается и выполняется без ошибки. Копирования не происходит, ибо объект не реализует trait Copy .
Rust ничего за Вашей спиной делать не будет. Хотите автоматическую реализацию Eq или Clone ? Просто добавьте свойство deriving к Вашей структуре:

Перекрытие области памяти

Функция явно не ожидает, что ей передадут ccылки на один и тот же объект. Чтобы убедить компилятор, что ссылки уникальные, в С99 придумали restrict, однако он служит лишь подсказкой оптимизатору и не гарантирует Вам отсутствия перекрытий: программа будет собираться и исполняться как и раньше.

Попробуем сделать то же самое на Rust:

Выдаёт нам следующее ругательство:

demo:7:24: 7:25 error: cannot borrow `x` as immutable because it is also borrowed as mutable
demo:7 swap_from(&mut x, &x);
^
demo:7:20: 7:21 note: previous borrow of `x` occurs here; the mutable borrow prevents subsequent moves, borrows, or modification of `x` until the borrow ends
demo:7 swap_from(&mut x, &x);
^
demo:7:26: 7:26 note: previous borrow ends here
demo:7 swap_from(&mut x, &x);

Как видим, компилятор не позволяет нам ссылаться на одну и ту же переменную через " &mut " и " & " одновременно, тем самым гарантируя, что изменяемую переменную никто другой не сможет прочитать или изменить, пока действительна &mut ссылка. Эти гарантии обсчитываются в процессе сборки и не замедляют выполнение самой программы. Более того, этот код собирается так, как если бы мы на C99 использовали restrict указатели (Rust предоставляет LLVM информацию об уникальности ссылок), что развязывает руки оптимизатору.

Испорченный итератор

Код собирается без ошибок, однако при запуске падает:

Попробуем перевести на Rust:

Компилятор не позволяет нам это запустить, вежливо указав, что изменять вектор в процессе его обхода нельзя:

demo:7:13: 7:14 error: cannot borrow `v` as mutable because it is also borrowed as immutable
demo:7 v.push(5-*x);
^
demo:5:14: 5:15 note: previous borrow of `v` occurs here; the immutable borrow prevents subsequent moves or mutable borrows of `v` until the borrow ends
demo:5 for x in v.iter() ^
demo:10:2: 10:2 note: previous borrow ends here
demo:5 for x in v.iter() demo:6 if *x < 5 demo:7 v.push(5-*x);
demo:8 >
demo:9 >
demo:10 >
Опасный Switch

Выдаёт нам "2". В Rust жы Вы обязаны перечислить все варианты при сопоставлении с образцом. Кроме того, код автоматически не прыгает на следующий вариант, если не встретит break . Правильная реализация на Rust будет выглядеть так:

Случайная точка с запятой

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

Многопоточность

Порождает несколько ресурсов вместо одного:

done
resource: 0x7f229c0008c0
resource: 0x7f22840008c0
resource: 0x7f228c0008c0
resource: 0x7f22940008c0
resource: 0x7f227c0008c0

Это типичная проблема синхронизации потоков, которая возникает при одновременном изменении объекта несколькими потоками. Попробуем написать то же на Rust:

Получаем ругательство, ибо нельзя вот так просто взять и мутировать общий для потоков объект.

demo:20:23: 20:26 error: cannot borrow immutable captured outer variable in a proc `res` as mutable
demo:20 let ptr = res.acquire();

Вот так может выглядеть причёсанный код, который удовлетворяет компилятор:

Он использует примитивы синхронизации Arc (Atomically Reference Counted - для доступа к тому же объекту разными потоками) и RWLock (для блокировки совместного изменения). На выходе получаем:

resource: 0x7ff4b0010378
resource: 0x7ff4b0010378
resource: 0x7ff4b0010378
resource: 0x7ff4b0010378
resource: 0x7ff4b0010378

Понятное дело, что на С++ тоже можно написать правильно. И на ассемблере можно. Rust просто не даёт Вам выстрелить себе в ногу, оберегая от собственных ошибок. Как правило, если программа собирается, значит она работает. Лучше потерять полчаса на приведение кода в приемлимый для компилятора вид, чем потом месяцами отлаживать ошибки синхронизации (стоимость исправления дефекта).

Немного про небезопасный код

Rust позволяет Вам играть с голыми указателями сколько угодно, но только внутри блока unsafe<> . Это тот случай, когда Вы говорите компилятору "Не мешай! Я знаю, что делаю.". К примеру, все "чужие" функции (из написанной на С библиотеки, с которой вы сливаетесь) автоматически маркируются как опасные. Философия языка в том, чтобы маленькие куски небезопасного кода были изолированы от основной части (нормального кода) безопасными интерфейсами. Так, например, небезопасные участки можно обнаружить в реализациях классов Cell и Mutex . Изоляция опасного кода позволяет не только значительно сузить область поиска неожиданно возникшей проблемы, но и хорошенько покрыть его тестами (мы дружим с TDD!).

Читайте также: