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

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

  1. При переходе с Java на Rust лучше начинать с простых типов вроде String, избегая сложных вроде &str. Это позволит быстрее написать рабочую версию кода.

  2. Использование структур и ассоциированных функций вместо классов соответствует идиоматичному Rust.

  3. Для обработки ошибок в Rust используется тип Result вместо исключений как в Java.

  4. Библиотеки вроде thiserror, log и async_trait упрощают написание idiomatic кода на Rust.

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

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