Rust: первые впечатления

Что привело меня к Rust?

На момент написания этой статьи у меня чуть более двадцати лет опыта в области ИТ и разработке.

И естественно, что за все это время мне как инженеру/разработчику довелось поработать с разными языками в разных средах и разных бизнес-контекстах.

В последнее время я более или менее регулярно использую следующие языки разработки: C, C++, Haskell, Elixir, Dart, Scala, Go, Kotlin и, конечно же, Rust.

В этот список не вошли интерпретируемые языки типа PHP, HTML/CSS, язык сценариев командной оболочки, Wolfram Language или JavaScript. Для меня они относятся, скорее, к категории инструментов для работы со сценариями.

В последние пять лет я участвовал в разработке нескольких сотен проектов (от небольшого инструмента интерфейса командной строки до правительственной системы контроля масштабного производства с использованием плагина Kubernetes или платформы BtoC). Работа велась в основном на языке Go.

Около полугода назад мое внимание привлек язык Rust. Этот интерес был обусловлен тремя главными причинами:

  • неудачей в поиске подходящей библиотеки на Go для одного из моих недавних проектов (обработка медицинских снимков);
  • моим разочарованием в одной из разработок с довольно «интенсивным использованием памяти» (базе данных в оперативной памяти) из-за неизбежного влияния системы сборки мусора на производительность;
  • постоянным желанием открывать и узнавать что-то новое, движущим всеми нами в этой работе.

Первые шаги в освоении Rust

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

Но не из-за его экзотического синтаксиса (те, кто привык к Python или Ocalm, не будут дезориентированы) и не из-за выразительности, делающей его неудобным для человеческого восприятия (по крайней мере, после использования языка Scala…). А из-за того, что делает его таким мощным: полное отсутствие встроенной среды выполнения и, как следствие, сборщика мусора.

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

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

Если язык Go осваивается за несколько дней в процессе использования, то для Rust этого будет недостаточно. Поэтому настоятельно рекомендуется прочитать хотя бы одну книгу, например «Язык программирования Rust».

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

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

Что делает его таким особенным?

Как уже было сказано, Rust  —  это аномалия в области разработки программного обеспечения. Это продвинутый и в высшей степени выразительный, но низкоуровневый язык.

Объяснения

Традиционно выделяют три категории языков программирования:

  • Так называемые «системные языки» или низкоуровневые языки, безоговорочным королем среди которых в течение почти 50 лет является C. Они не очень выразительны (т. е. здесь часто приходится изобретать велосипед или использовать библиотеки, уже созданные кем-то другим). Они не очень безопасны (стабильность и безопасность всецело зависят от уровня разработчика, которому предоставлена полная свобода), зато позволяют получить двоичный файл, очень близкий к «металлу» (т. е. процессору), и тем самым оптимально его использовать (если разработчик хороший).
  • Так называемые «продвинутые» или «выразительные» языки, предназначенные фактически для повышения продуктивности разработчиков с помощью различных парадигм: объектной, функционального программирования… Они позволяют ограничить риски, присущие первой категории (автоматическое управление памятью для избежания переполнения стека и других «висячих указателей»). Сюда относятся такие языки, как Java, Scala, Kotlin, Go, C#, Swift… Они явно уступают по производительности и эффективности (соотношению между производительностью и потреблением ресурсов). Но в мире, где мощность машин удваивается при постоянной цене каждые полтора года, никто и не считает это проблемой.
  • Интерпретируемые или «скриптовые» языки, которые не создают никакого скомпилированного кода, а код интерпретируется на лету во время выполнения программы. Благодаря им разработчик еще дальше продвигается по оси продуктивности, но происходит это в ущерб уровням производительности и эффективности, снижение которых часто бывает катастрофическим. Сюда относят, например, такие языки: Python, Ruby, PHP, JavaScript, R, Perl.

Rust заточен на то, чтобы сочетать в себе первую и вторую категории, и в некоторых аспектах это достигается.

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

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

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

Все эти возможности позволяют ему занять довольно высокое положение в эволюционной иерархии, делая его очень современным языком, но…

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

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

Компилятор достаточно мощный, чтобы выдавать полностью детерминированный код (включая управление памятью), избавляя таким образом от необходимости в среде выполнения и сборщике мусора во время выполнения.

Как он это делает?

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

Управление памятью

Каким образом в Rust обеспечивается безопасность при работе с памятью без системы сборки мусора?

Все дело в инновационной концепции владения, в рамках которой с помощью следующих правил осуществляется управление памятью:

  • у значения (т. е. области памяти, в которой содержится простое целое число или более сложная структура) есть владелец (т. е. функция);
  • в каждый конкретный момент времени у значения только один владелец;
  • когда выполнение покидает область видимости владельца (т. е. выходит из функции), значение уничтожается (освобождается);
  • свойство передается из одной функции в другую (в этом случае исходная функция теряет доступ к значению);
  • функция «заимствует» доступ к значению у другой функции, которая при этом остается владельцем (это, например, как одолжить книгу почитать);
  • таких заимствований только для чтения может быть сколько угодно;
  • в каждый конкретный момент времени может быть только одно заимствование для чтения и записи;
  • наличие заимствования для чтения и записи исключает любое заимствование, доступное только для чтения;
  • заимствование должно ссылаться на активное значение (еще не уничтоженное, в этом случае нет указателя null/nill).

Этот набор правил позволяет компилятору статически:

  • определять, когда выделять и освобождать память, и таким образом писать код прямо в создаваемом двоичном файле;
  • гарантировать отсутствие висячих указателей;
  • гарантировать отсутствие «состояния гонок» (когда происходят одновременные записи данных в неопределенном порядке в один и тот же участок памяти);
  • гарантировать отсутствие переполнения памяти при том, что ссылки или заимствования будут умными указателями, согласно последнему правилу.

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

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

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

  • Традиционные системные языки типа Си позволяют безнаказанно нарушать эти правила, что оборачивается ужасными последствиями во время выполнения.
  • В традиционных продвинутых языках типа Java или Go вся работа, связанная с выделением и освобождением памяти, делается за вас. Поэтому с их ограничениями здесь никак не столкнуться.

Макросы

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

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

Например, эквивалент следующего кода Go на Rust невозможен:

fmt.Printf("Hello world, my name is %s I'm %d old", "bastien", 42)

Функция fmt.Printf принимает в качестве параметров переменное количество аргументов (исходная строка с заполнителями и значениями для использования).

В среде выполнения Go во время выполнения будет сгенерирован окончательный код в соответствии с количеством фактически имеющихся параметров. Но в Rust нет среды выполнения.

Детерминированное компилирование кода, эквивалентного fmt.Printf, привело бы к созданию бесконечного количества версий с числом параметров от 1 до бесконечности. А это, очевидно, невозможно.

Решить эту проблему помогает понятие «макрос».

Для обозначения макроса используется восклицательный знак, ставящийся после его названия:

println!("Hello world, my name is {}, I'm {} old", "bastien", 42);

Макрос «компилируется» дважды.

  • В первый раз будет сгенерирован код на Rust (это расширение макроса) в соответствии с количеством фактически используемых параметров (в данном случае 3, анализируя заодно тип значения, используемого в параметрах).
  • Во второй раз этот код на Rust будет скомпилирован в машинный код.

Просто и элегантно.

Каковы текущие результаты?

Отсутствие среды выполнения / сборщика мусора открывает интересные перспективы, позволяя использовать Rust для разработки операционных и/или встроенных систем.

  • В Microsoft объявили о постепенном задействовании его для замены языков C и C++, на которых построена их ОС.
  • Линус Торвальдс одобрил идею поэкспериментировать с использованием Rust в ядре Linux (а это дорогого стоит, учитывая сильные позиции Линуса в языках программирования).
  • В Amazon успешно использовали его для разработки своей системы микровиртуализации, лежащей в основе AWS Lambda.
  • В Google используют его для разработки Fucshia, возможной замены Android.

С другой стороны, есть также возможность использовать Rust на стороне фронтенда (в месте, где мы обычно встречаем JavaScript и другие языки) через WebAssembly.

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

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

Слабые стороны

Трудность освоения

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

Во Франции и в других странах уже несколько лет, как грибы после дождя, открываются школы (например, «Школа 42») и другие центры быстрого обучения (такие как Le Wagon). Благодаря этому у всех желающих учиться на программиста появляется такая возможность, и таким образом компенсируется нехватка имеющихся на рынке специалистов и дополняется поток выпускников более традиционных школ (ведь ИТ  —  это сфера, в которой спрос превышает предложение).

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

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

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

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

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

Сложность чтения

Я отношу Rust к категории выразительных языков и считаю его в высшей степени выразительным.

Это его качество позволяет Rust лаконично и элегантно решать непростые и даже очень сложные задачи.

Но это не делает его легким для чтения языком.

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

Разработчик тратит в среднем 90 % рабочего времени на чтение кода (своего или чужого) и только 10 % на его написание.

Поэтому скорость чтения и понимания является решающим фактором, определяющим общую продуктивность. С ростом важности проекта (сопровождающимся увеличением строк кода и количества участников) влияние этого фактора становится только больше.

Go выгодно выделяется в этом отношении: он настолько прост (и эта простота порой оборачивается нехваткой лаконичности или элегантности), что новичок в проекте очень быстро осваивается и так же быстро работает.

Что же касается Rust, интеллектуальная механика понимания множественной вложенности связанных в одной строке кода функций другая. Ее подход (функциональное программирование) заимствуется из математического формализма.

Чтение кода на Rust всегда будет сопряжено с большими усилиями, чем на Go. Вопрос в том, стоит ли оно того.

Мощь системы управления пакетами

Система управления пакетами и зависимостями на Rust, состоящая из Cargo и Crate, очень мощная.

Казалось бы, относить ее к слабым сторонам странно, но этому есть объяснение.

Здесь компании, которой желательно иметь частный реестр (хранилище крейтов), требуется более тяжелая инфраструктура, чем на Go (довольствующимся простым репозиторием исходного кода (например, Git), часто уже развернутым в компании).

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

Недостаточная зрелость конкурентного программирования

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

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

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

Но возможность выбора и свобода выбора никуда ведь не девались: можно, например, выбрать свой механизм управления потоками, будь то «поток ОС» или «зеленый поток».

Медленная компиляция

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

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

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

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

Лучше использовать Rust на быстром компьютере.

Сильные стороны

Производительность

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

Производительность, которая обычно неразрывно связана с эффективностью, в любом случае ценится высоко. Будем откровенны: до сих пор нет ничего, что позволяло бы выполняться быстрее, чем Rust (за исключением разве что работы с кодом в ассемблере, хотя эта форма пытки и была отменена несколько лет назад).

Единственная альтернатива Rust по достигнутым им уровням производительности  —  это язык C, который уступает ему и по уровню безопасности, и по уровню выразительности.

И имеет перед ним всего два преимущества: его история (количество доступных библиотек) и количество специалистов на рынке.

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

Безопасность

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

Когда код наконец компилируется, у нас есть гарантия, что он будет выполняться правильно и будет сделано то, чего мы от него требовали (хотя не факт, что это обязательно будет то, чего мы хотели).

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

Межплатформенная переносимость

На момент написания статьи Rust имеет возможность компилироваться в 84 различные целевые платформы (сочетания архитектуры процессора и операционной системы).

То есть он превосходит по межплатформенной переносимости Go (непозволительная для остальных роскошь) с его 37 поддерживаемыми целями, хотя в Rust не все целевые платформы имеют одинаковый уровень поддержки.

Мощь системы управления пакетами

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

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

Лично я не встречал ничего подобного в других языках.

Документация

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

Это стало возможным в основном благодаря мощи его системы автоматической генерации документации (через cargo).

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

Заключение

Несмотря на свой юный возраст (около десяти лет), Rust стал важным языком в среде разработки программного обеспечения.

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

  • Буду ли я продолжать его использовать? Конечно, да.
  • Заменю ли я Go на Rust? Конечно, нет.

Эти две технологии неплохо дополняют друг друга.

Даже отличный разработчик на Rust никогда не достигнет уровня продуктивности хорошего разработчика на Go.

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

А вот для критически важных, функционально сложных проектов, где производительность измеряется в наносекундах (или даже пикосекундах), верное решение  —  Rust, при условии что вы принимаете затраты.

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

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

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Bastien Vigneron: Rust, first impressions

Предыдущая статьяКак посещать сайты, не заходя в браузер
Следующая статьяАвтоматизация создания стикеров с помощью веб-скрейпинга и обработки изображений в Python