Как легко перейти с Java на Rust: Особенности и советы

Kate

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

d9230483cf4d70483a3342183644e02a.png

Кроме того, я создал три 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:🇮🇴:Error> {
let mut file = File::eek:pen(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:🇮🇴: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:🔒:Mutex, а не std::sync::Mutex, так как второй спроектирован для не асинхронного кода.

Вот пример того, как использовать Mutex для асинхронного кода:

use futures:🔒: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" мы можем использовать асинхронные функции внутри трейтов.

Какие мы имеем выводы?​

  1. При переходе с Java на Rust лучше начинать с простых типов вроде String, избегая сложных вроде &str. Это позволит быстрее написать рабочую версию кода.
  2. Использование структур и ассоциированных функций вместо классов соответствует идиоматичному Rust.
  3. Для обработки ошибок в Rust используется тип Result вместо исключений как в Java.
  4. Библиотеки вроде thiserror, log и async_trait упрощают написание idiomatic кода на Rust.

Что хочется сказать в заключении?​

Переход с Java на Rust может показаться непростым из-за отличий в подходах этих языков. Главное - начать с простых конструкций, затем постепенно переходить к идиоматичному коду.

 
Сверху