Как я вкатился в Rust (и зачем)

 Публичный пост
6 июля 2023  3199

Внимание

В посте будет код. На Rust. Вам может стать некомфортно. Вам может стать интересно. Я предупредил :)

И зачем?

Мы должны писать надёжный код, который не падает, не течет, не делает data race и не ломается, поскольку ошибки в
нашем коде критичнее некуда. Просто представьте, что Cloud API удалило не тот кластер. Поэтому я изучаю новый
инструмент, который позволит писать более безопасный код. Изучаю сам, чтоб не отвлекать инженеров от текущих задач

© менеджер во мне

А на самом деле зачем? :)

Однажды простого инженера Ваню повысили до лида, а потом ещё раз повысили, до менеджера. И стало Ване некогда писать код
и чинить продакшен, но инженер в нём окончательно ещё не умер. И стал Ваня искать, куда бы применить инженера, чтоб и
коллегам не мешать и интересно было. И нашёл Rust.

Процесс вкатывания

Картинка, отражающая реальность

ну вы поняли
ну вы поняли

Сначала новичку показывают Hello World и рассказывают про borrowing, и выглядит всё достаточно просто, ну примерно вот
так.

fn main() {
    let name = std::env::args().
        nth(1).
        unwrap_or_else(|| "%username%".to_owned());
    println!("Welcome to Rust, {name}!");
}

Потом, почти без предупреждения, это превращается во что-то такое.

impl<T> Stream for BroadcastStream<T>
    where T: 'static + Clone + Send
{
    type Item = Result<T, BroadcastStreamRecvError>;
    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
                 -> Poll<Option<Self::Item>> {
        let (result, rx) = ready!(self.inner.poll(cx));
        self.inner.set(make_future(rx));
        match result {
            Ok(item) => Poll::Ready(Some(Ok(item))),
            Err(RecvError::Closed) => Poll::Ready(None),
            Err(RecvError::Lagged(n)) => {
                Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(n))))
            }
        }
    }
}

А потом ты сам пишешь такой код и не видишь в этом ничего странного.

А секрет прост - простой советский три кита Rust.

Кит первый - ownership & borrowing

Если ты что-то взял - отдай

© объяснение принципов ownership

У любой переменной в Rust есть владелец. Владение заканчивается в момент передачи переменной другому владельцу.
Обращаться к тому, чем мы уже не владеем - нельзя.
Например, вот так - нельзя.

fn main() {
    let name = "name".to_owned(); // владелец name - текущая функция
    call_fn(name); // мы отдали владение name другой функции
    println!("my name = {name}"); // тут будет ошибка и оно не скомпилируется
}

fn call_fn(name: String) -> String {
    println!("call_fn name: {name}");
}

Но если нам нужно - мы можем отдать владение назад, и тогда будет снова можно.

fn main() {
    let name = "name".to_owned(); // владелец name - текущая функция
    let name = call_fn(name); // мы отдали владение name другой функции
    // и получили его обратно как результат
    println!("my name = {name}"); // тут будет все ок
}

fn call_fn(name: String) -> String {
    println!("call_fn name: {name}");
    name // это просто return без return
}

Или мы можем одолжить (borrow) - то есть передать не владение, а ссылку на владение. И тогда тоже будет можно.

fn main() {
    let name = "name".to_owned(); // владелец name - текущая функция
    call_fn(&name); // мы одолжили нашу name другой функции
    println!("my name = {name}"); // тут все хорошо
}

fn call_fn(name: &String) { // call_fn не владеет name
    println!("call_fn name: {name}");
}

Или мы можем передать копию - и тогда наша переменная останется при нас.

fn main() {
    let name = "name".to_owned(); // владелец name - текущая функция
    call_fn(name.clone()); // мы отдали владение копией
    println!("my name = {name}"); // тут будет всё ок
}

fn call_fn(name: String) { // call_fn владеет своей копией name
    println!("call_fn name: {name}");
}
  • Работать с переменными по ссылкам неудобно - многие функции требуют владения переменной.
  • Работать с копиями - удобно, но дорого с точки зрения памяти и производительности.
  • Передавать владение туда-сюда-обратно - удобно, но требует сильно много думать и планировать все свои движения.

Кто хочет понять суть - реализуйте LinkedList на Rust.

Кит второй - Traits

Trait - это средство языка описать поведение (и ограничения) для типа.
Например, есть Send и Sync, на которые постоянно ругается компилятор при многопоточном программировании:

  • Чтоб отправить объект типа X в другой поток, он должен иметь trait Send
  • Чтоб поделиться объектом типа X с другим потоком, он должен иметь trait Sync

Как это работает?

  • Авторы библиотек для многопоточной обработки данных пишут требования: хочу Send и Sync на вход
  • Разные механизмы безопасной работы с данными (Mutex, например) говорят: я Send или я Sync
  • Программист вынужден использовать эти механизмы, иначе у него проект не компилируется
  • Data race не происходит

Можно ли это обойти?

Да, можно, но это надо прям специально хотеть. Не получится случайно передать в другой поток
недействительную ссылку.

Поначалу бесит, потом становится проще жить: если код скомпилировался, там data race происходить не будет.

Кит третий - async/await/Future

То, зачем 99% программистов используют этот язык - писать асинхронный код. Позволяет выжимать 100500 соединений на
одном ядре и делать blazing fast™.
Почти полностью состоит из бессонных ночей и ошибок компиляции из-за того,
что type X doesn't implement Send/Sync/Unpin и 'a should outlive 'static.

Писать асинхронный код так, чтоб не делать clone() всего - это действительно очень сложно.

Но, к счастью, почти всё уже написано за нас. Мы говорим асинхронный код - и подразумеваем tokio.
Так что, если упростить, то вот так можно написать echo сервер, который держит > 50 000 rps:

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::spawn;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let listener = TcpListener::bind(("localhost", 1234)).await?;
    loop {
        let (mut client, addr) = listener.accept().await?;
        println!("[+] Incoming connection from {addr}");
        spawn(async move {
            let mut data = Vec::with_capacity(256);
            loop {
                let x = client.read_u8().await?;
                data.push(x);
                if x == b'\n' || x == b'\r' || data.len() == 256 {
                    client.write_all(&data).await?;
                    break;
                }
            }
            Ok::<(), anyhow::Error>(())
        });
    }
}

Прогресс вкатывания

Сначала всё очень сложно. Потом я написал первый LinkedList.
Потом я написал свой currrrl, который быстрее актуальной сборки curl под мой
дистрибутив.
Быстрее аж за 20-50 ms, но быстрее же!
И поддерживает все опции, которые даёт copy as cURL в Dev Mode моего браузера.

А потом у меня появился повод вкатиться по-настоящему, мы начали проект, под стек которого идеально ложится именно
Rust - надо быстро, безопасно и чтоб сначала думали, а потом пушили в master.

Итоги

Инженер во мне жив и пишет код по ночам. Менеджер во мне позволяет команде делать сложные задачи и не забирает у них
работу.
Все довольны, вроде?

А на самом деле

Да правда неплохо всё, хотя я понял кое-что про себя. Но это уже совсем другая история.

А если я тоже хочу?

  • Learn Rust by Building Real Applications курс для тех, кто любит курсы
  • Rust for Rusteans - настольная книга для тех, кто любит настольные книги
  • Lets get rusty - канал на ютубе для тех, кто любит каналы на ютубе
  • Rust by Example - официальная дока для тех, кто (ну вы поняли)
  • // TODO: add more examples from comments

Outro

Кто первый напишет, что vas3k надо переписать на Rust - тот настоящий джедай!

63 комментария 👇

Рекомендую курс по Rust от Алексея Кладова

Автор когда-то поддержку Rust для IDE от JetBrains. Сейчас он делает rust-analyzer с которым все становится лучше.

Еще с изучением Rust мне очень помог Github Copilot и книжка Rust in Action 🙂

@r13v, класс, запинил :)

  Развернуть 1 комментарий

Vas3k надо переписать на haskell

  Развернуть 1 комментарий

@urtow, одобрямс

  Развернуть 1 комментарий

I fairly frequently get asked how to implement a linked list in Rust... I've decided to write this book to comprehensively answer the question

bruh

  Развернуть 1 комментарий

Пробовал ли ты чужой код поддерживать на расте? Неопытным глазом выглядит очень write-only

  Развернуть 1 комментарий

@Stenopolz, да, собственно, я понял, что вкатился, когда начал в чужой код вносить правки и находить там баги глазами.

  Развернуть 1 комментарий

@annmuor, мне сейчас код на расте кажется роднее и понятнее чем на пайтоне. Когда вижу пайтон хочется добавить ресалт, конвеерну обработку, энам...

  Развернуть 1 комментарий

Кстати, ты получил одобрение Rust™ Foundation на использование Rust™?

  Развернуть 1 комментарий

@fCCbp7RUf09pxYkr, они там откатились немножко :)

  Развернуть 1 комментарий

@annmuor, все ещё жду когда язык переименуют в awslang

  Развернуть 1 комментарий

Имхо rust сейчас язык #2 для data engineers.
Валидация схем? Pydantic v2.
Есть pandas, но на одой машине скучно? Давайте заменим его на Ray. У Ray dataset бекенд на polars.
А может без этого всего? datafusion.
Мне кажется я ждал когда Rust полетит последние 5 лет и сейчас вкатываться уже прям пора. ТС спасибо и лучи поддержки.

  Развернуть 1 комментарий

Rust уже не модно, переходите на Zig

  Развернуть 1 комментарий

@fCCbp7RUf09pxYkr, совршенно разные языки в своей цели. Цель Rust - сделать ошибки по памяти невероятными в safe rust, цель zig - писать код как будто ты пишешь на Си в 1989 :)

  Развернуть 1 комментарий

писать код как будто ты пишешь на Си в 1989

  Развернуть 1 комментарий

@annmuor,

как будто ты пишешь на Си в 1989

Дайте два

  Развернуть 1 комментарий

😱 Комментарий удален его автором...

  Развернуть 1 комментарий

Отличный выбор поразмять мозги. Но для работы и тех же требований понятно надо брать го.

  Развернуть 1 комментарий

@mdogx, го слишком шумный. Хотя раст тоже не идеал лаконичности.

  Развернуть 1 комментарий

@glader, что под шумностью имеется в виду? Вербозный синтаксис?

  Развернуть 1 комментарий

@krasovsky, явная проверка ошибок по всему стеку вызова.

  Развернуть 1 комментарий

@glader, чисто технически можно и не проверять, а просто в самом верху поставить defer с recovery().

  Развернуть 1 комментарий

@krasovsky, и бить за это нужно сразу и беспощадно, это антипаттерн.

  Развернуть 1 комментарий

@annmuor, конечно :) Но это подходит для быстрого прототипирования в пэт-проджектах, когда нужно проверить работоспособность кода в принципе.

  Развернуть 1 комментарий

@krasovsky, для быстрого прототипирования всё обмазывается в unwrap() :-)

  Развернуть 1 комментарий

@annmuor, антипаттерн — это навязываемый го не просто defensive programming, а параноидальный defensive programming без нормальных способов от него избавиться

  Развернуть 1 комментарий

@dmitriid, ну тогда Java это антипаттерн, ибо throws Exception

  Развернуть 1 комментарий

@annmuor, Exceptions — не антипаттерн.

  Развернуть 1 комментарий

😱 Комментарий удален его автором...

  Развернуть 1 комментарий

@dmitriid, С моей точки зрения, exception - худший из возможных способов обработки ошибок. Да, errno в Си - лучше.

  Развернуть 1 комментарий

@annmuor, где тут дизлайк поставить?

  Развернуть 1 комментарий

@annmuor, @dottedmag

Пишу в перерыве между Diablo IV (вполне рекомендую) и Marvellous Mrs. Maisel (горячо и настоятельно рекомендую, даже в русском переводе), поэтому на анализ, тщательную проработку аргументов и т.п. не расчитывайте :)

Любой разговор про обработку ошибок скатывается во флейм, понятное дело. Просто большинсто языков выбирает максимально отдаленную позицию на одном из спектров обработки ошибок, сидит там годами. А любители (и, хуже того, фанаты) этих языков уверены что именно так и надо, и никак больше.

А я укушен Эрлангом. Где хочешь, "коды ошибок" возвращай, хочешь - исключения кидай, никто тебе слова не скажет. И, главное, в языке толпа инструментов для работы и с тем и с другим - вот тебе и паттерн матчинг, вот тебе и try/catch, вот тебе и механизмы восстановления от сбоев, вот тебе...

Go взял errno из C и возвел его в абсолют. То есть каждая функция на любом уровне исполнения должна не только что-то делать, но и обрабатывать ошибки. А любая необработанная ошибка вызывает полную остановку программы. Реально, курам насмех. Поэтому в итоге даже в Go появились костыли с defer и recover. Но нормальных сособов избавиться от необходимости тупо вручную проверять и возвращать ошибки на каждый чих, так и не появилось.

Rust изначально тоже пошел по этому пути. Там хотя бы Result пронизывает весь язык, что позволило авторам почти без потери лица ввести try!, потом успешно его задепрекейтить и ввести ?. Ну и catch_unwind конечно же. Потому что только идотам нужно, чтобы программа падала в случае непредвиденной ошибки. Эрланг сильно смеется, да.

Java вообще проблемный ребенок. У них реально все решения от силы наполовину. Отсюда и все проблемы с exception'ами в Java и неумирающий миф, что exception'ы это плохо, потому что Java, давайте лучше вручную писать boilerplate для обработки ошибок на каждой строчке даже если код, который сейчас пишется, вообще никак не должен заниматься обработкой ошибок.

Ну то есть реально:

  • Пишите нормальный код и функции, которые делают что-то одно, а не занимаются миллионом разнообразных вещей одновременно
  • Rust и Go: да, так и сделаем, ты должен писать код, обрабатывающий ошибки файловой системы, базы данных и сети, в коде, который делает бизнес-логику. Rust хоть смог убрать бойлерплейт своим ?-шорткатом

Exception'ы и let it crash вполне себе работают, надо только в язык добавлять инструменты для работы с ними. Ну или другими способами избавлять программистов от необходимости писать тонны ненужного бойлерплейта там, где это не нужно.

В общем, Rust - почти молодцы, Go все больше напоминает мне поделки Вирта: такая же принципиальная упертость в "чистоту концепций" без единой попытки что-то действительно улучшить.

ЗЫ. Более убог только Swift с его

  guard let name = getMeaningOfLife() else {
        return
  }

Но там есть исключения. Хотя насколько нормально они реализованы, я совсем хз.

  Развернуть 1 комментарий

@dmitriid, Exception ещё и дорогой, Питону понадобилось 10+ лет чтоб его оптимизировать.

  Развернуть 1 комментарий

@annmuor, Опять же зависит от языка и реализации. В Эрланге они достаточно дешевы... пока не попытаешься получить stacktrace :)

Тогда по невнимательности можно если не положить на лопатки всю систему, но очень сильно ее затормозить: http://erlang.org/pipermail/erlang-questions/2013-November/075928.html

  Развернуть 1 комментарий

@dmitriid, это все потому что в Rust нет do-notation, а do-notation нет потому что нет Monad, а Monad нет, потому что нет HKT. Было бы все это дерьмо, не пришлось бы делать костыли в виде .?. И асинхронность можно было бы не тащить в язык

  Развернуть 1 комментарий

call_fn(&name); // мы одолжили нашу name другой функции

Ооо, родимый perl

  Развернуть 1 комментарий

@glader, Perl это немного дедушка Rust. Rust частично вдохновлялся Ruby, ну а автор Ruby вдохновлялся Perl.

  Развернуть 1 комментарий

@glader, а что тут от Перла?

  Развернуть 1 комментарий

Правда ли, что преодолев начальный порог вхождения, дальше всё хорошо и писать на языке приятно?

  Развернуть 1 комментарий

@alexpyzhianov, и да и нет. Набив руку, ты перестаешь делать базовые ошибки и твой проект чаще компилируется с первого раза.
Но набив руку, ты можешь начать хотеть делать красиво, а красиво ( без копирования ) делать сложно, и проект опять не компилируется.

Но если позволять себе иногда писать некрасиво - то да, раст легко становится дефолтным языком для нового проекта.

  Развернуть 1 комментарий

"Дикий запад" c крейтами. Доументация на любителя. Придется почти всегда читать чужой код. Потом плюнуть и написать свой "блэкджек" быстрее выйдет.
Бесит, что нет наследования. Только композиция. И вот что легко делается на ООП десериалицзации JSON с вложенной структурой, тут придется напрячься. Но Serde хорош.
Автор макросов достоин отдельного места в котле в аду.
Но есть плюсы. Если компилится, то считай победа. Посреди ночи не надо будет просыпаться потому что память утекла под нагрузкой и сервис прилег насмерть. gRPC работает огонь просто. Процессинг жрет мало ресурсов. Красота.

  Развернуть 1 комментарий

@gritmax, отсутствие наследования - рай.

  Развернуть 1 комментарий

Поначалу бесит, потом становится проще жить: если код скомпилировался, там data race происходить не будет.

А можно раскрыть мысль? Пока субъективно выглядит как то что я могу добавить к чему угодно нужный трейт, а затем передавать это "что-то" куда угодно. При этом сам объект может быть небезопасным для использования из разных тредов. Т.е. нужно просто не добавлять трейт пока не реализуешь какие-то функции для работы из разных потоков. А если у тебя в них будет ошибка, то получим всё тот же race condition.

Есть ошибка в моих рассуждениях?

  Развернуть 1 комментарий

@grbit, можешь, но не можешь.
Ты должен явно указать, что ты добавляешь этот трейт, причём это можно сделать только если unsafe rust разрешен в проекте ( Send и Sync - unsafe трейты )
Когда ты это делаешь - unsafe impl Send for X{} - ты говоришь компилятору, что ты берешь на себя ответственность за все действия X.
Ты не можешь сделать этого случайно, ты явно декларируешь, что ты подумал и подготовился.

В проектах, где unsafe запрещен, ты так сделать не можешь.
А в конечных проектах ( не библиотеках ) он чаще всего запрещен.

  Развернуть 1 комментарий

@annmuor, спасибо за пояснения.

Я понимал что случайно я этого сделать не могу.
Но теперь я не понимаю как же вообще многопоточно программировать тогда если добавлять трейт Send/Sync считается unsafe...

  Развернуть 1 комментарий

@grbit, Используя типы, которые уже сделаны разработчиками языка и библиотек и хорошо проверены: Arc/Mutex/RWLock/Atomic*

  Развернуть 1 комментарий

@annmuor, нет, ну мьютекс я положим передать могу, а что с тем же линкд лист? Что если мне по ссылке к хэш таблице доступ нужен?

Не поймите неправильно, мне оч нравится идея таких трейтов, классная штука. Я просто не согласен с тем что на выходе у нас не будет data race. Всё равно можно по глупости случайно допустить кучу ошибок со структурами данных кмк

  Развернуть 1 комментарий

@grbit, не, ты не понял идею. Все эти типы - это контейнеры.
Для однопоточного приложения допустим у тебя T = HashMap<String, String>.
Для многопоточного - Arc<Mutex<HashMap<String,String>>>

Для thread-unsafe типа только комбинация этих контейнеров даст тебе необходимые Send + Sync. Ты можешь выбрать другой Arc или другой Mutex ( или написать свой ), но ты не можешь просто сделать unsafe impl Send for HashMap<String,String>{}.

Пример с LinkedList сложен, тебе придётся очень много кода писать, поскольку LinkedList - это самореференсная структура данных в Rust, тебе придётся использовать Pin/Unpin внутри и вообще это задачка на хорошо так погрузиться в механизмы языка.

Но в общем виде - ты пишешь тип, оборачиваешь его в нужные тебе контейнеры для получения нужной тебе комбинации Send + Sync ( Mutex не везде нужен, кстати ) и язык не дает тебе совершить гонку.

  Развернуть 1 комментарий

@annmuor, контейнеры кртуо) Этого и не хватало для понимания, спасибо большое!
Я б статью добавил бы пример контейнера с хэш мапой.

А то что linked list сложный эт канешн печально. На Go пишется на изи

  Развернуть 1 комментарий

@grbit, такова цена языка без GC.

  Развернуть 1 комментарий

@grbit, двусвязный список самостоятельно реализовывать обычно не нужно. А уже готовые встроены в язык, интрузивные наверняка есть в десятке видов в крейтах. Ну и при желании можно свой написать при помощи unsafe, но лучше не надо — будет так же больно, как на C++. Только хуже, потому что на C++ все привычные к тому что программа взрывается, а на Rust если библиотека допускает взрыв при любом поведении пользователя — это баг библиотеки. И в Rust про это надо заморачиваться сильно больше, чем в C++, потому что в C++ про такие высокие материи просто сил не хватает задуматься.

  Развернуть 1 комментарий

еще из интересного, если начали присматриваться в сторону раста

  Развернуть 1 комментарий

Интересно а где сейчас можно именно применять раст? Вакансии для него крайне редки, писать на нём пет-проджекты не кажется оправданным, оверхед, обычно есть инструменты которые позволяют сделать твою идею намного проще. Реально ли сейчас где-то найти позицию джуна на расте за еду?)

  Развернуть 1 комментарий

@Crypto_mate, имхо – импосибуру.
Идея же в том что вы пишите крутые приложения БЕЗ БАГОВ, а джун отличается спосоьностью стрелять в ногу не смотря ни на что)

Но я Go-шник, а не Rust-овщик, так что жди более информированного коментатора)

  Развернуть 1 комментарий

@Crypto_mate, не уверен за джуна, Rust требует очень много подготовительных знаний, чтоб на нём писать. Про порог входа не шутка же. То есть в идеале в Rust заходить, когда ты уже поел программирования и тебе не нужно думать о базовых паттернах, типах данных и всяком таком.

Если мы говорим про переходящих сеньёров - да, таких вакансий я вижу по 5 штук в день, типа "мы пишем на Расте, нам нужен раст сеньер или сеньер на C++/Go/Java, на Раст переучим". Но перешедший сеньёр джуном и не будет, в общем-то :)

На рынке есть две ветви, где Раст востребован:

  • Крипта, web3.0, NFT и вот это всё ( потому, что безопасно и мамкины хакеры тебе буффер оверфлоу не сделают )
  • Телекоммуникации и всё такое ( потому что безопасно и очень быстро, 100500 тыщ миллионов коннектов в секунду )

Весь MAANG нанимает Rust'оводов именно за второе, поэтому джуна там не ждут, но переучить переучат.

А пет проекты - на самом деле отличный вариант. Я вот тут сел и написал за ~30 минут резолвер на стотыщ RPS чтоб проверять кросс-валидность всех зон которые мы используем.
Вот такие мелкие проекты отлично учат писать код, их можно делать в рамках работы, они затаскиваются в одно лицо и приносят всем пользу.

  Развернуть 1 комментарий
Dmitriy Rozhkov Distinguished Notion API Consultant 6 августа 2023

Я думаю, что четвертый кит — умные указатели. С одной стороны тебе придется с ними разобраться разбираясь с владением. С другой — понять эти коробочки отдельно помогает понять что вообще происходит.

  Развернуть 1 комментарий

Можно ли это обойти?

а зачем это обходить? выглядит как разумный набор правил

  Развернуть 1 комментарий

@shrddr, иногда надо, когда сам пишешь такие библиотеки, например.
Но чаще не надо.

  Развернуть 1 комментарий

Пример про borrow без перехода на &str не полон, всё же!

  Развернуть 1 комментарий

@ldvsoft, не хотелось грузить магией as_ref / into

  Развернуть 1 комментарий

@annmuor, Ну всё же канонически строчки передают в двух вариантах: String и &str ;)

  Развернуть 1 комментарий

@ldvsoft, да, но чтоб объяснить, как из &String получается &str надо прям постараться.

  Развернуть 1 комментарий
Evgeniy Maruschenko Data Engineer/Software Engineer 12 октября 2023

Там где-то потерялись между китами алгебраические типы и паттерн метчинг.

А в целом - да, если разобрался с кодом - чужой читать легче. Начал с контрибьюта в datafusion, вообще проблем с чтением чужого кода нет.

  Развернуть 1 комментарий

😎

Автор поста открыл его для большого интернета, но комментирование и движухи доступны только участникам Клуба

Что вообще здесь происходит?


Войти  или  Вступить в Клуб