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

Кроме того, я создал три open source библиотеки на Rust, которые публиковал на GitHub. Это позволило мне лучше изучить идиоматичный Rust, работу с асинхронностью и т. д.
В целом, после работы над этими проектами у меня как Java-разработчика накопился интересный опыт, которым хотелось бы поделиться для тех, кто только начинает изучать Rust, приходя из мира Java. Далее я привожу несколько полезных советов, которые помогут в переходе на Rust.
Не используйте &str при написании первой версии кода, лучше сразу String
В Rust есть два типа строк - &str и String. Первый представляет собой ссылку на строку, второй - владеющую строку. На первый взгляд кажется, что &str - это аналог String из Java, который лучше использовать по умолчанию.
Однако на практике проще начинать с String. Этот тип проще использовать, он ведет себя схожим с привычной строкой в Java. А &str требует следить за владением и ссылками.
Поэтому я рекомендую в первой версии кода использовать String, а затем оптимизировать узкие места с помощью &str.
fn main() { let name = String::from("Alice"); print_greeting(name); } fn print_greeting(name: String) { println!("Hello, {}!", name); }
Упрощение отладки с #[derive(Debug)]
В Java у нас есть инструменты для отладки, и в Rust тоже есть свои. Однако, чтобы облегчить процесс отладки, вы можете использовать атрибут #[derive(Debug)] на структурах, которые хотите анализировать. Это позволит вам автоматически генерировать реализацию метода fmt::Debug, который можно использовать для вывода состояния объекта при помощи функции println!.
#[derive(Debug)] struct Person { name: String, age: u32, } fn main() { let person = Person { name: String::from("Bob"), age: 30, }; println!("Person: {:?}", person); }
Не используйте ссылки в параметрах функций
В Rust нет сборки мусора, поэтому важно явно управлять памятью. Часто рекомендуют использовать ссылки вида &T для передачи данных в функции. Но на первых порах лучше этого избегать.
Вместо этого создавайте копии объектов с помощью метода clone():
#[derive(Clone)] struct MyData { field: i32 } fn process(data: MyData) { // работаем с data } fn main() { let data = MyData { field: 42 }; process(data.clone()); }
Это позволит избежать многих типичных ошибок. Потом, когда код заработает, можно оптимизировать узкие места, перейдя на ссылки.
Используйте структуры с функциями вместо классов
В Java принято создавать классы для организации кода, а Rust использует структуры (struct) для той же цели. Вместо того чтобы определять методы внутри структур, как это делается в классах, мы определяем функции и реализуем их для конкретных структур.
Вместо конструктора реализуют функцию new. Для асинхронного кода вернуть лучше Arc<MyClass>, а для синхронного - Rc<MyClass>. Это позволит имитировать сема��тику ссылок на объекты в Java.
use std::sync::Arc; struct MyClass { pub field: i32 } impl MyClass { fn new() -> Arc<MyClass> { let obj = MyClass { field: 42 }; Arc::new(obj) } } #[tokio::main] async fn main() { let obj1 = MyClass::new(); let obj2 = obj1.clone(); // объекты obj1 и obj2 указывают на одни и те же данные // благодаря Arc<T> }
Использование Result<T, E> вместо исключений
В Java ошибки обрабатываются с помощью исключений. В Rust исключений нет. Вместо них используется тип Result<T, E>, который представляет собой пару: значение T и ошибка E.
Вот пример функции в Java, которая может вернуть значение или ошибку:
public String readFile(String filename) throws IOException { File file = new File(filename); if (!file.exists()) { throw new IOException("File does not exist"); } FileInputStream inputStream = new FileInputStream(file); byte[] bytes = new byte[(int) file.length()]; inputStream.read(bytes); inputStream.close(); return new String(bytes); }
Эта функция может вернуть ошибку IOException, если файл не существует. Чтобы обработать эту ошибку, мы можем использовать try-catch.
В Rust мы можем написать функцию, используя тип Result<T, E>:
fn divide(a: i32, b: i32) -> Result<i32, String> { if b == 0 { Err("Division by zero".to_string()) } else { Ok(a / b) } }
Использование ? вместо unwrap() для эскалации ошибок
В Java, чтобы эскалировать ошибку по коду выше, мы можем использовать оператор throw или в определении функции просто указать тип исключения, который там может произойти. В Rust для этого используется оператор ?.
Вместо всеми любимого unwrap(), который может вызвать панику в случае ошибки, мы можем использовать оператор ?, чтобы просто передать ошибку выше. Это позволяет избежать паник и дает контроль над обработкой ошибок на более высоком уровне.
fn read_file(filename: &str) -> Result<(), std::io::Error> { let mut file = File::open(filename)?; let mut bytes = vec![]; file.read_to_end(&mut bytes)?; Ok(()) }
В этом примере, если файл не существует, функция вернет ошибку типа Error. Эта ошибка будет передана вызывающему коду.
Используйте библиотеку thiserror для объявления ошибок
В Java для объявления ошибок используется Exception. В Rust такого нет. Вместо этого ошибки объявляются с помощью типов.
Использование библиотеки thiserror делает объявление ошибок в Rust очень удобным и простым. С помощью этой библиотеки вы можете использовать макросы для объявления ошибок, которые автоматически генерируют код для их реализации.
use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("Connection problem error")] ConnectionError, #[error("Access deny error")] PermissionError, }
Использование библиотеки thiserror также позволяет вам автоматически конвертировать ошибки сторонней библиотеки в вашу ошибку.
#[derive(Error, Debug)] pub enum DataStoreError { #[error("data store disconnected")] Disconnect(#[from] std::io::Error), }
Используйте библиотеки log + env_logger вместо println!
В Rust я также рекомендую избегать непосредственного использования println! для логирования. Вместо этого я подключаю библиотеку log и использую макросы вроде info!, warn! или debug!.
А затем в main инициализировать env_logger, который позволяет легко настроить вывод логов в stdout или файл.
fn main() { env_logger::init(); log::debug!("Приложение запущено"); // ... }
Этот подход делает код намного чище и позволяет гибко управлять логированием приложения. На Java я часто использовал именно такой паттерн, поэтому мне было приятно найти похожее решение в экосистеме Rust.
Использование Mutex для асинхронного кода
В Java, когда вы хотите, чтобы переменная была доступна в нескольких потоках для чтения вы просто передаете ссылку. В Rust, аналогом этих структур является Arc. Arc позволяет безопасно передавать ссылку на переменную между потоками, но не позволяет изменять эту переменную.
В Java, когда вы хотите, чтобы переменная была доступна в нескольких потоках для модификации, вы используете AtomicReference или ConcurrentHashMap.
Если вы хотите в Rust, чтобы переменная была доступна в нескольких потоках и её можно было изменять, вам нужно использовать Mutex. Mutex позволяет получить эксклюзивную блокировку переменной, что позволяет другим потокам ждать, пока поток получивший доступ через блокировку не освободит ее.
��стати лучше сразу использовать futures::lock::Mutex, а не std::sync::Mutex, так как второй спроектирован для не асинхронного кода.
Вот пример того, как использовать Mutex для асинхронного кода:
use futures::lock::Mutex; fn main() { let mut counter = Mutex::new(0); let handle1 = spawn(async { let mut lock = counter.lock().await; *lock += 1; }); let handle2 = spawn(async { let mut lock = counter.lock().await; *lock += 1; }); handle1.join().unwrap(); handle2.join().unwrap(); println!("{}", counter.lock().unwrap()); // 2 }
В этом примере мы создаём переменную counter типа Mutex. Затем мы запускаем два асинхронных потока, которые увеличивают значение счетчика на единицу. В конце программы мы печатаем значение счетчика, которое должно быть равно 2.
Преодоление ограничений при использовании async функций в трейтах
Еще одним интересным аспектом перехода с Java на Rust является использование трейтов (traits). В Rust нельзя объявить асинхронные функции внутри трейтов напрямую. Однако существует библиотека под названием "async_trait", которая позволяет обойти это ограничение.
use async_trait::async_trait; #[async_trait] trait Worker { async fn do_work(&self); } struct MyWorker; #[async_trait] impl Worker for MyWorker { async fn do_work(&self) { println!("Working asynchronously"); } } #[tokio::main] async fn main() { let worker = MyWorker; worker.do_work().await; }
Благодаря библиотеке "async_trait" мы можем использовать асинхронные функции внутри трейтов.
Какие мы имеем выводы?
При переходе с Java на Rust лучше начинать с простых типов вроде String, избегая сложных вроде &str. Это позволит быстрее написать рабочую версию кода.
Использование структур и ассоциированных функций вместо классов соответствует идиоматичному Rust.
Для обработки ошибок в Rust используется тип Result вместо исключений как в Java.
Библиотеки вроде thiserror, log и async_trait упрощают написание idiomatic кода на Rust.
Что хочется сказать в заключении?
Переход с Java на Rust может показаться непростым из-за отличий в подходах этих языков. Главное - начать с простых конструкций, затем постепенно переходить к идиоматичному коду.
