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