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

Комментарии (21)


  1. Ritan
    25.08.2023 15:53
    +4

    Это позволит имитировать семантику ссылок на объекты в Java.

    Имхо, конечно, но это очень плохой совет. Мало того что объекты за Rc/Arc нельзя мутировать без Cell/RefCell, так ещё и попытки натянуть сборку мусора на язык без оной плохо кончаются


    1. igumnov Автор
      25.08.2023 15:53
      +2

      Там в статье говориться про Mutex и про то, что это решение временное, что бы быстро написать рабочий код. И тут сборку мусора никто не натягивает. В Rust ее просто нет )


  1. GospodinKolhoznik
    25.08.2023 15:53
    +3

    Используйте структуры с функциями вместо классов

    Весьма возможно , что этот совет хорош не только лишь для раста, но и для джавы.


    1. igumnov Автор
      25.08.2023 15:53
      +2

      Таки статья дает советы как делать в Rust, а не в Java )


    1. placidity_master
      25.08.2023 15:53

      в Rust есть классы ?


      1. igumnov Автор
        25.08.2023 15:53

        нет


  1. vlad4kr7
    25.08.2023 15:53

    используйте для конвертации ошибок:

    .map_err(|e| e.to_string())?;

    можно начать с простого типа Result<_, String>

    Кстати - return ; не нужен:

    fn divide(a: i32, b: i32) -> Result<i32, String> {
        if b == 0 {
            Err("Division by zero".to_string())
        } else {
            Ok(a / b) // return ; не нужен
        }
    }

    А также, очень часто используемая конструкция:

    lazy_static! {  pub static ref CACHE: Arc<RwLock<HashMap<...>>> 


    1. igumnov Автор
      25.08.2023 15:53
      +1

      Убрал из статьи return - я тоже когда на Scala пишу его не использую ну и на Rust тоже. Просто по привычке для Java кодеров его оставил. В целом согласен для красоты надо убрать пусть ощущают новации в синтаксическом сахаре.


    1. igumnov Автор
      25.08.2023 15:53

      lazy_static! { pub static ref CACHE: Arc<RwLock<HashMap<...>>>

      На счет этого я добавлю тоже в статью уже завтра.
      Но я использую такой вариант:
      use once_cell::sync::Lazy;
      static HANDLERS: Lazy<Mutex<HashMap<String, i32>>> = Lazy::new(|| { Mutex::new(HashMap::new())});
      для обьявления глобально асинхроного синглтона
      а потом
      let mut handlers = HANDLERS.lock().await;
      handlers.insert(disk.to_string(), handler);


    1. andy128k
      25.08.2023 15:53

      Всё же лучше использовать более "всеядный" `Result<T, Box<dyn Error>>` или Error из крейта anyhow. Он больше похож на джавовский RuntimeException.

      Строка может быть преобразована в `Box<dyn std::error::Error>` и поэтому можно писать `Err("Division by zero".into())`.


  1. kolebynov
    25.08.2023 15:53
    +3

    Вместо конструктора реализуют функцию new. Для асинхронного кода вернуть лучше Arc<MyClass>, а для синхронного - Rc<MyClass>

    А может все-таки лучше возврашать обычный инстанс структуры, чтобы вызывающий код сам решал, нужен Rc, Arc или вообще ничего.


    1. igumnov Автор
      25.08.2023 15:53
      +1

      Да, когда ты опытный Rust-кодер, то это верное решение. А когда тебе неопытному быстро надо написать компилирующийся код, то лучше так. А потом убрать лишние Arc<....>
      Когда код рабочий это легко изменить. Исправил в объявлении и реализации new и компилятор подсказал где надо в вызовах new это подправить.


      1. vlad4kr7
        25.08.2023 15:53

         А когда тебе неопытному быстро надо написать компилирующийся код,  ...

        поэтому и "lazy_static!"

        кстати:

        let _ = HANDLERS.lock().await.insert(disk.to_string(), handler);  

        лок освободит в той же строке


        1. igumnov Автор
          25.08.2023 15:53
          -1

          Не спорю, но чем цепочка вызовов функций длиннее, тем тяжелей человеку читать код. Хотя она у вас не сильно длинная, но в целом идею вы поняли)


          1. vlad4kr7
            25.08.2023 15:53

            Eсли не использовать async await, то будет .unwrap(), и все получится длиннее, и не так красиво. Кстати .unwrap() лучше избегать с самого начала.

            let mut handlers = HANDLERS.lock().await; // лок на создается для переменной handlers

            в случае, если функция больше чем в одну строку, то лок на handlers используется или до конца блока { }, или до конца функции.

            И это эксклюзивный лок, а не rwlock.


  1. Kelbon
    25.08.2023 15:53
    -3

    Ооо, с такими советами раст быстро загнётся, одобряю!


  1. gochaorg
    25.08.2023 15:53
    +3

    не плохо бы сказать что Mutex в Rust не являются реентерабельными, в Java же по умолчаю synchronized/Lock/etc... на оборот


  1. Homyakin
    25.08.2023 15:53

    В Java ошибки обрабатываются с помощью исключений. В Rust исключений нет. Вместо них используется тип Result<T, E>

    На самом деле в Java никто не запрещает вместо исключений использовать return-based подход к ошибкам. Например, с помощью Either из vavr.io, или можно самим написать класс-обёртку для возвращаемых значений.


  1. Helltraitor
    25.08.2023 15:53
    +10

    В Rust есть два типа строк - &str и String. Первый представляет собой ссылку на строку, второй - владеющую строку.

    Если это туториал, то, думаю, стоит упомянуть, что &'_ str представляет собой по сути структуру с указателем на начало и длиной строки где-то в памяти (будь то стэк или куча).
    А вот String содержит Vec<u8> и не только владеет строкой, но и всегда держит ее в куче.

    Однако на практике проще начинать с String. Этот тип проще использовать, он ведет себя схожим с привычной строкой в Java. А &str требует следить за владением и ссылками

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

    В данном примере можно было бы спокойно использовать примитивный тип str.

    Но есть решение лучше. Задайте себе вопрос, что нам необходимо сделать с типом, или без каких его характеристик мы это сделать не сможем?

    Единственное, что нам необходимо - нечто, что можно отобразить. За эту характеристику отвечает трейт Display. Вот как измениться функция:

    fn print_greeting<T: Display>(name: T) {
        // Сейчас можно писать так
        println!("Hello, {name}!");
    }
    
    fn main() {
        let name = "John".to_string();
    
        print_greeting(name.as_str());  // Или &name - это &str
        print_greeting("Sam");          // это тоже &str
        print_greeting(name);           // Это просто String
    
        // Обычно потом от строк отходят в пользу более сложных структур
        // В вашем варианте придется везде писать .to_string()
        // Здесь же достаточно, чтобы структура реализовала Display
        print_greeting(person);         // или &person
    }

    Второй совет тоже плохой:

    Вы упоминули Debug, но не остальные трейты. Даже Clone забыли, без которого не получится скопировать сложные структуры (о Copy и других даже не заикаюсь).

    Третий совет еще хуже чем первый:

    Когда вы используете владеемые типы вы ЯВНО управляете памятью, потому что вместе с выходом из функции начнутся ненужные удаления объектов. Идея простая - смотрите на то, как вы используете объекты. Ничего страшного в лайфтаймах нет, а с одним аргуметом нет никакого смысла избегать их (где запутаться хоть можно?).

    Четвертый совет мимо кассы:

    Если читатель не захочет использовать структуры, откуда он возьмет классы? В Rust их нет.

    Нормальный совет мог бы звучать так: используйте композицию объектов - аналог наследования, и создавайте собственные трейты - аналог интерфейсов. Сразу отмечу, что второй более гибкий и простой на практике.

    Вместо конструктора реализуют функцию new.

    На самом деле это только соглашение, вполне можно использовать условный create. Самым близким аналогом будет трейт Default.

    use std::sync::Arc;
    
    // Классов в Rust нет
    struct Struct {
        pub field: i32
    }
    
    impl Default for Struct {
        fn default() -> Self {
            Self { field: 42 }
        }
    }
    
    fn main() {
        let data: Arc<Struct> = Arc::default();
        println!("Field equals to '{}'", data.field);
    }

    Пятый совет тоже неудачный:

    Поскольку Result - тип с дженериками, разные Result плохо между собой уживаются и требуется постоянно маппить ошибку. Используйте крейтыanyhow и thiserror для данных целей (смотрите документацию - оно того стоит).

    // Добавьте крейт anyhow к существующему проекту:
    // cargo add anyhow --features backtrace
    //   По умолчанию без бэктрейса
    
    // Скрываем наш Result
    use anyhow::Result;
    
    // Используем derive macro Error из thiserror
    // При желании, имя можно поменять
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    #[error("Unable to divide by zero denominator")]
    pub struct DivisionByZeroError;
    
    fn divide(num: i32, den: i32) -> Result<i32> {
        // Для деления с проверкой лучше ее и использовать
        num.checked_div(den).ok_or(DivisionByZeroError.into())
    }
    
    // Наиболее желательная сигнатура, anyhow Error можно использовать здесь
    fn main() -> Result<()> {
        println!("{}", divide(1, 0)?);
    
        Ok(())
    }
        Blocking waiting for file lock on package cache
       Compiling playground-rust v0.1.0 (...)
        Finished dev [unoptimized + debuginfo] target(s) in 1.26s
         Running `target\debug\playground-rust.exe`
    Error: Unable to divide by zero denominator
    
    // Если включена фича backtrace в anyhow
    Stack backtrace:

    В качестве типов для ошибок лучше всего использовать что-то, что реализует трейт Error, иначе придется маппить ошибки. Два эти крейта делают все за вас.

    Использование ? вместо unwrap() для эскалации ошибок

    Для чего - для чего unwrap используется? Для "эскалации" ошибки? Мы точно про один Rust говорим?

    И опять же, можно переписать на нормальный код с anyhow.

    Далее идут два нормальных совета, но есть примечания:

    В Java для объявления ошибок используется Exception. В Rust такого нет.

    " Да ладно?! " (c) Якубович

    А зачем тогда нужен трейт std::error::Error, который используется в thiserror и anyhow, и который, к тому же, можно вернуть в виде Box<dyn std::error::Error> из функции main (тем самым позволяя пробросить ошибку откуда угодно на самый верх)?

    В Java, когда вы хотите, чтобы переменная была доступна в нескольких потоках для чтения вы просто передаете ссылку. 

    // Не показывай этот код друзьям
    struct SomeStruct {
        pub thread_unsafe: String
    }
    
    unsafe impl Send for SomeStruct {}
    unsafe impl Sync for SomeStruct {}
    
    // SomeStruct можно использовать без Arc

    Отличие Java от Rust здесь в том, что Rust говорит все типы нельзя безопасно передавать Send или использовать из разных потоков Sync, если не будет сказано обратное. Оба эти трейта - маркеры, поэтому их не стоит реализовывать не убедившись, что интерфейс типа не является потокобезопасным.

    Я не так хорошо знаком с Java, но думаю там все +\- также: можно передать объект в другой поток и получить одновременный доступ к одному элементу

    Кстати лучше сразу использовать futures::lock::Mutex, а не std::sync::Mutex, так как второй спроектирован для не асинхронного кода

    Не пробовал, вместо этого советую сразу async_std - там есть многие асинхронные реализации совместимые с синхронным std (по крайней мере, когда я писал ВКР, проблем не ощутил).

    Используйте RwLock, когда необходимо получить множественное чтение (одним "мутиксом" сыт не будешь).

    Еще одним интересным аспектом перехода с Java на Rust является использование трейтов (traits). В Rust нельзя объявить асинхронные функции внутри трейтов напрямую.

    Можно. Для справки сейчас ведется работа над стабилизацией асинхронных трейтов. А данный макрос просто делает то, что можно уже сейчас с использованием сопли на конце: ... -> Pin<Box<dyn core::future::Future<Output = ()> + Send + '_>> {...}

    Собственно, async - это сахар, который не стабилизирован для трейтов.

    Что там с выводами?

    1. При переходе с Java на Rust лучше ориентироваться на то, что ДЕЛАЕТ тип, для чего он НУЖЕН в данной функции и отталкиваться от его функциональность. Именно ЭТО позволит быстрее написать рабочую версию кода.

    2. При разработке ориентируетесь на трейты, а структуры воспринимайте как конкретизацию их набора.

    3. Для обработки ошибок действительно используется Result, но ничего не запрещает заткнуть все Option (они тоже поддерживают ?). Лучше всего использовать Result из anyhow и приправить щепоткой thiserror.

    4. Все крейты упрощают разработку, равно как и для другого языка и его пакетов. Что более важно, ориентируйтесь на МАКРОСЫ. Пишите их, используйте их. И будет вам быстрая разработка.

    По поводу пункта 4. Лучшим примерами будут: serde (и связанные библиотеки serde_yaml), thiserror, clap (3 версия, питоновский click). И наверное есть много других - эти первые на ум пришли.

    Раз уж я так много написал, думаю, имею право высказаться насчет статьи.

    Это просто ужас. Надеюсь, ПЕРВЫЙ блин коммом. А дальнейшие статьи будут значительно лучше

    P.S. Комментарий писался с перерывами суммарно около 2 часов. Я хотел уточнить некоторые вещи, но решил упростить комментарий и сконцентрироваться на самой статье. Спасибо за внимание


    1. igumnov Автор
      25.08.2023 15:53
      +2

      Зачетная критика. Снимаю шляпу. Вы явно Rust-гуру.


  1. Question_man
    25.08.2023 15:53

    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);
    }

    Похоже, что данный код свалится в компайл-тайме из-за неизвестного размера массива.