[Перевод] Rust: за пределами синтаксиса. Обретение просветления в неожиданных местах

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

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

Снимок экрана 2024-05-31 в 11.53.52.png

Снимок экрана 2024–05–31 в 11.53.52.png

Данная статья это перевод с английского с некоторыми адаптациями. Перевод сделан НЕшколой для инженеров Inzhenerka.Tech совместно с автором курса-тренажера по Rust. Больше материала в нашем сообществе

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

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

Экосистема Rust

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

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

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

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

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

Кря-кря! Утиная типизация с трейтом

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

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

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

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

trait Swim {
    fn swim(&self);
}

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

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

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

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

Но утка — это не единственное, что умеет плавать. Давайте определим структуру 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 также предлагает несколько действительно полезных типов, таких как Option и Result, которые позволяют обрабатывать случаи, когда значение может присутствовать или отсутствовать. С помощью сопоставления с образцом в Rust вы можете писать лаконичный и читаемый код для обработки ошибок, используя эти типы. Мы не будем вдаваться в подробности об этих типах или операторе match в этой статье, но они стоит изучить, если вы только начинаете работать с Rust. Вместо этого, давайте обсудим подход Rust к тестированию.

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

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

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

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

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


// A public function that takes two integers and returns double their sum
pub fn add_and_double(x: i32, y: i32) -> i32 {
    2 * _add(x, y)
}
// A private helper function that adds two integers
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 было понимание концепций владения, времён жизни, заимствования, перемещения и копирования.

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

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

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

Когда вы возвращаете значение из функции или присваиваете его новой переменной, другая переменная должна взять на себя владение этим значением. Это называется перемещением, и после перемещения исходная переменная больше не может использовать это значение. Это означает, что простой код, подобный следующему, который не был бы проблемой в большинстве других высокоуровневых языков, не скомпилируется в 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 делает меня лучшим программистом, даже когда я пишу код на других языках.

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

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

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

Данная статья это перевод с английского с некоторыми адаптациями. Перевод сделан НЕшколой для инженеров Inzhenerka.Tech совместно с автором курса-тренажера по Rust. Больше материала в нашем сообществе

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