Находить просветление в неожиданных местах

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

Первый стабильный выпуск Rust вышел в 2015 году, и каждый год, начиная с 2016 года, он признавался самым любимым языком в Ежегодном опросе разработчиков Stack Overflow (теперь в 2023 году он называется Восхитительно). Почему разработчики, почувствовав вкус Rust, не могут перестать его использовать? В мире разрекламированных преемников C/C++ похоже, что Rust находится на вершине. Как получилось, что язык, вышедший на главную сцену лишь в последнее десятилетие, стал таким популярным?

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

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

Экосистема ржавчины

Большинство языков, которые я регулярно использую, связаны с управлением пакетами и версиями. Такие системы, как npm, pip и NuGet, в наши дни очень удобны в использовании, но они не всегда были такими и все еще далеки от совершенства. Управление установленной версией самого языка по-прежнему является проблемой для большинства языков.

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

Cargo сочетает в себе функциональность управления пакетами и инструмента сборки и представляет все лучшие характеристики управления пакетами. Это просто и не мешает.

Другой важный аспект экосистемы Rust — это документация. Я изучил язык полностью из официальной документации и никогда не чувствовал необходимости искать где-то еще учебник. Между книгой и Rust By Пример было рассказано все, что мне нужно было знать. Фактически, всякий раз, когда я сталкивался с проблемой переполнения стека, наиболее полезными ответами обычно были те, которые указывали на правильный раздел либо официальной документации, либо одного из этих двух источников.

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

Кря кря! Утиное печатание с чертами

На самом деле в Rust были классы на заре его существования, но они просуществовали немногим более 6 месяцев. Они были заменены гораздо более простой структурой данных struct. Вы определяете типы, объявляя struct, который представляет собой не более чем набор связанных полей, в которых могут храниться данные. Rust позволяет добавлять реализации типов, которые представляют собой наборы функций, которые могут выполнять операции с этим типом или связанные с ним.

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

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

Давайте посмотрим на пример. Вот черта для плавания. Любой тип, реализующий черту Swim , умеет плавать.

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

Давайте создадим несколько типов, которые можно будет передать в функцию cross_the_pond. Мы можем создать тип с именем Duck , определив struct и реализовав для него признак Swim .

Но утка — не единственное существо, которое умеет плавать. Давайте определим Elephant struct и реализуем для него черту Swim.

Наша main функция может создавать экземпляры уток и слонов и объединять их воедино.

Это дает следующий результат:

Crossing the pond...
Sir Quacks-a-lot paddles furiously...
Ellie BigEndian is actually just walking on the bottom...

Вы можете поиграть с этим кодом на Rust Playground здесь.

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

Тестируйте код в коде

Разработчики, как правило, имеют твердое мнение о структуре папок и соглашениях об именах файлов. Все согласны с тем, что мы хотим, чтобы наши папки были как можно более чистыми, но люди склонны расходиться во мнениях относительно того, что это на самом деле означает. Большой спорный вопрос — где проводить тесты. Нужна ли отдельная папка для тестов? Отражает ли структура тестовой папки исходную папку? Смешиваете ли вы тесты с исходным кодом? Вы добавляете к своим тестовым файлам префикс `test_`, чтобы тесты были сгруппированы вместе, или вы добавляете к ним суффикс `_test`, чтобы тесты соответствовали коду, который они тестируют?

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

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

Давайте посмотрим на пример. Модуль ниже предоставляет одну глупую функцию, которая складывает два числа и возвращает двойную сумму. Он использует частную вспомогательную функцию и некоторые тесты.

Атрибут #[cfg(test)] сообщает компилятору, что при запуске тестов нужно компилировать только модуль тестов, а в производственной сборке тесты удаляются.

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

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

Взяли его взаймы, так и не отдали, а потом переехали?

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

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

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

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

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

Я нашел это очень запутанным. Давайте посмотрим на сообщение об ошибке:

error[E0382]: borrow of moved value: `original_owner`
 --> src/main.rs:6:20
  |
3 |     let original_owner = String::from("Something");
  |         -------------- move occurs because `original_owner` has type `String`, which does not implement the `Copy` trait
4 |     let new_owner = original_owner;
  |                     -------------- value moved here
5 |
6 |     println!("{}", original_owner);
  |                    ^^^^^^^^^^^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let new_owner = original_owner.clone();
  |                                   ++++++++

For more information about this error, try `rustc --explain E0382`.

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

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

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

Заключительные мысли

В 2019 году я выступил с докладом под названием «Не только синтаксис» о своем опыте изучения Racket, языка семейства Lisp. Хотя я никогда не хотел и никогда не хотел бы использовать язык Лисп профессионально, этот опыт привел меня к глубоким прозрениям в отношении функционального программирования на уровне, с которым я никогда раньше не сталкивался. Я завершил этот разговор следующими цитатами:

Язык, который не влияет на ваше представление о программировании, не стоит знать. — Алан Перлис

и

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

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

Я обнаружил, что видят в нем почти 85% разработчиков, использовавших Rust, и когда на мой почтовый ящик приходит электронное письмо об опросе Stack Overflow 2024 года, и в опросе меня спрашивают, хочу ли я продолжать использовать Rust в следующем году, я отвечаю: Я обязательно отвечу «Да».