Как Rust меняет мышление разработчика

в 13:00, , рубрики: memory safety, Rust, ruvds_перевод, traits, безопасность доступа к памяти, утиная типизация
Я страшно ненавижу C++. Обычно я люблю программирование, но каждый проект, с которым я имел дело на C++, ощущался как монотонная рутина. В январе 2023 года я пошёл по пути изучения Rust, поэтому теперь могу сказать, что знаю язык системного программирования, который действительно люблю использовать.

Первый стабильный релиз Rust появился в 2015 году, и каждый год, начиная с 2016, он признаётся в Stack Overflow’s Annual Developer Survey самым любимым языком (в 2023 году эта категория называется «обожаемый»). Почему же разработчики, ощутившие вкус Rust, не могут отказаться от его использования? Похоже, в мире прогремевших наследников C/C++ репутация растёт только у Rust. Как же этот язык, появившийся на сцене меньше десятка лет назад, стал настолько популярным?

Как Rust меняет мышление разработчика - 1

Ржавый красный краб Феррис по версии Midjourney

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

Я начну историю с разговора о том, что легко полюбить — со среды Rust, управления пакетами и документации. Затем я расскажу о системе типов и типажах (trait). Далее я поведаю о тех возможностях тестирования и test driven development, которые становятся возможными благодаря Rust. Наконец, мы обсудим самую запутанную и сбивающую с толку часть — одержимость Rust тем, кто какой переменной владеет.

▍ Экосистема Rust

В большинстве регулярно используемых мной языков есть управление пакетами и версиями. В наши дни очень полезны системы наподобие npm, pip и NuGet, но они не всегда были такими, и всё ещё далеки от идеала. В большинстве языков само управление установленной версией языка становится мучением.

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

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

Ещё один важнейший аспект экосистемы Rust — это документация. Я изучал язык исключительно по официальной документации и у меня никогда не было потребности искать туториалы в других местах. В «книге» и в Rust By Example было раскрыто всё необходимое мне. На самом деле, когда бы я ни приходил на Stack Overflow с вопросом, самые полезные ответы обычно заключались в указании на подходящий раздел или в официальной документации, или в одном из этих двух источников.

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

▍ Кря-кря! Утиная типизация с типажами

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

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

Как Rust меняет мышление разработчика - 2

Слон, притворяющийся уткой (Midjourney)

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

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

trait Swim {
    fn swim(&self);
}

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

fn cross_the_pond(animal: impl Swim) {
    animal.swim();
}

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

struct Duck {
    name: String,
}

impl Swim for Duck {
    fn swim(&self) {
        println!("{} paddles furiously...", self.name);
    }
}

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

struct Elephant {
    name: String,
}

impl Swim for Elephant {
    fn swim(&self) {
        println!("{} is actually just walking on the bottom...", self.name);
    }
}

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

fn main() {
    let duck = Duck { name: String::from("Sir Quacks-a-lot") };
    let ellie = Elephant { name: String::from("Ellie BigEndian") };

    println!("Crossing the pond...");

    cross_the_pond(duck);
    cross_the_pond(ellie);
}

При этом получается следующий вывод:

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

Можете поэкспериментировать с этим кодом в Rust Playground здесь.

Кроме того, в стандартной библиотеке Rust есть очень полезные типы наподобие Option и Result, позволяющие обрабатывать случаи, в которых значение может существовать, а может и не существовать. Благодаря сопоставлению с образцом (pattern matching) Rust при помощи этих типов можно писать сжатый и читаемый код обработки ошибок. В этой статье мы не будем рассматривать их или оператор match, но если вы только осваиваете Rust, с ними стоит ознакомиться. Вместо этого давайте поговорим о подходе Rust к тестированию.

▍ Тестирование кода в коде

У разработчиков обычно есть устоявшиеся мнения о структуре папок и формате именования файлов. Все согласятся, что мы стремимся поддерживать максимальную чистоту в папках, но люди обычно расходятся в том, что же это на самом деле значит. Важная причина споров заключается в том, куда помещать тесты. Нужно ли создавать для них отдельную папку? Должна ли структура папки с тестами копировать структуру папки с исходниками? Добавлять ли к файлам тестов префикс «test_», чтобы они были сгруппированы вместе, или добавлять суффикс "_test", чтобы тесты были рядом с тем, что они тестируют?

Как Rust меняет мышление разработчика - 3

Запутанная структура усложняет поиск. Но какой должна быть аккуратная структура? (Midjourney)

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

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

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

// Публичная функция, получающая два integer и возвращающая их удвоенную сумму
pub fn add_and_double(x: i32, y: i32) -> i32 {
    2 * _add(x, y)
}

// Приватная вспомогательная функция, складывающая два integer
fn _add(x: i32, y: i32) -> i32 {
    x + y
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add_and_double() {
        assert_eq!(add_and_double(2, 3), 10);
        assert_eq!(add_and_double(0, 0), 0);
    }

    #[test]
    fn test_add() {
        assert_eq!(_add(2, 3), 5);
        assert_eq!(_add(0, 0), 0);
    }
}

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

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

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

▍ Позаимствовал и теперь не отдаёт

Для меня самой сложной частью изучения Rust стало понимание концепции владения (ownership), времени жизни (lifetime), заимствования (borrowing), перемещения (moving) и копирования (copying).

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

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

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

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

fn main() {
    let original_owner = String::from("Something");
    let new_owner = original_owner;
    
    println!("{}", original_owner);
}

Меня это очень сбивало с толку. Давайте взглянем на сообщение об ошибке:

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. Хотя я никогда не пользовался и не буду пользоваться языком Lisp профессионально, этот опыт привёл к глубокому прозрению относительно функционального программирования на уровне, которого я не ощущал ранее. Завершил доклад я следующими цитатами:

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

и

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

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

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

Автор:
ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js