Как известно, html-разработчики скучают по многопоточке. Читая на днях шикарную книгу Mara Bos oб "атомиках и локах", наткнулся на упоминание одного интересного языка программирования: rust lang. Согласно интернетам, learning curve раста стремится к бесконечности, но для многопоточного программирования выбор кажется нормуль. Тут вопрос - можно и, главное, удобно ли юзать раст для бизнес-логики (ну то есть для продакшна).
Краткое содержание: макросы, компилятор-враг, компилятор-друг, unsafe и miri, многопоточка, options, iters, match.
Будем честны, фичи тестировались на учебном проекте, но как бывает со всеми пет прожектами (учебными проектами), переписывались они десятки раз.
Макросы
Проектирование какой-никакой бизнес-логики обычно делается с небольшим заделом на прекрасное будущее. Добавление новых фичей происходит, если сильно утрировать, путем наследования, копипасты, расширением глобальной хеш-таблицы или "добавь вот тут еще" парочку if-elif конструкций. В целом, тут как бы про баланс между переиспользованием и копипастой. Eсли вам нужно от чего-то отнаследоваться, наверно, лучше заюзать емкую dsl конструкцию. Функциональный смысл такой же, но из-за локальности и несвязности выглядит лучше. Например, макрос tree!
let expr = tree![ | BracketLeft, Expr, BracketRight, Term | Number, Term | Never ]; let term = tree![ | Operator, Expr ];
На выходе два дерева, которые матчат, например, строку инпута 5+5+5 или (2+2)*2. Движок чекает первый столбец макроса, и если матч - продолжает по выбранному ряду. Составные токены (например, Expr или Term) раскрываются в еще одно дерево. Чуть сложнее использование макроса:
// grammar for javascript object literals let map = HashMap::from([ (Expr, tree![ | CurlyBracketLeft, Object, CurlyBracketRight | SquareBracketLeft, Array, SquareBracketRight | OtherExpr ]), (Object, tree![ | Variable, Value | String, Value | SquareBracketLeft, Variable, SquareBracketRight, Value ]), (Value, tree![ | Colon, Expr, Term | Never ]), (Term, tree![ | Comma, Object ]), ]);
Код макросов кажется запутанным, но потом привыкаешь :). Тут матчимся на vertical bar и наполняем дерево токенами ряд за рядом.
macro_rules! tree { ($(| $($x:ident),+ $(,)?)+) => { { let mut tt = TokenTree::new(); $( let v = vec![$(Token::$x,)*]; tt.add_right(v[0]); for x in v.into_iter().skip(1) { tt.add_left(x, Token::Never); } )* tt } } }
Еще один пример compile time макроса
macro_rules! _reverse_vec { ([$first:ident $($rest:ident)*] $($acc:ident)*) => { _reverse_vec!([$($rest)*] $first $($acc)*) }; ([] $($x:ident)*) => { vec![$(Token::$x,)*] } }
Добрый компилятор, злой компилятор
При использовании entry апи для хеш-таблиц компилятор поначалу любит поругаться, особенно на js разработчиков, которые юзают обьекты (они же мапы) почти везде:
// javascript code for if-elif construction const x = ({ plus: func, minus: func2, }[operator] || defFunc)(operand, operand2);
Entry апи принимает лямбду на модификацию объекта:
map.entry("key") .and_modify(|e| { *e += 1 }) .or_insert(42);
В лямбду выше хочется запихнуть побольше кода, как в питоне или джаваскрипте, но в расте компилятор скорее всего будет не доволен и очень сильно порежет диапазон возможных действий, так как аргумент передается по мутабельной ссылке. На самом деле, компилятор помогает, ну или хочет помочь. Сообщения об ошибках очень хороши. Немного времени, и вы уже будете скучать по компилятору раста в других языках программирования. В интернетах кто-то сказал, что pair programming в расте бесплатно - так и есть. Affine types (система типов) позволяет (скорее заставляет, но это тонкости) взглянуть на структуру кода немного под другим углом, пока, честно говоря, не ясно, это хорошо или плохо, но что-то новое это верное направление.
Rc & Arc
На всякий случай, стандартная библиотека раста имеет шикарный набор примитивов для работы в многопоточной среде.
Наш тестовый проектик парсит файлы джаваскрипта, в принципе, используя только следующую структурку:
type Choice = Option<Word>; #[derive(Default, PartialEq, PartialOrd, Debug, Clone)] struct Word(Token, Rc<Choice>, Rc<Choice>);
Логика, в двух словах, - мы двигаемся влево (выбираем второй элемент структурки Word), если токен инпута совпадает с ожидаемым токеном, в противном - двигаемся вправо (третий элемент структурки Word). Тип Rc (reference сounted) здесь нужен для хранения нашей структурки Word сразу в нескольких местах (например, для итерации по дереву и матчинга в хеш-таблице).
Скорее всего, мы захотим парсить файлики параллельно. В расте есть прекрасная библиотека Rayon, которая как бы создана для этих целей. То есть у нас есть список файлов, и вместо input.iter() мы пишем par_iter() -> профит (почти).
use rayon::prelude::*; fn par(input: Vec<PathBuf>) { let tt = token_tree(); input .par_iter() .map(|path| { let str = fs::read_to_string(path).expect("Unable to read file"); let data = str.bytes().peekable(); let v = tokens(data).into_iter().rev().collect(); parse(v, &tt) }) .collect() }
Не забываем заменить тип Rc на Arc (Atomically Reference Counted) в структурке выше, и система работает в многопоточной среде. Профит.
unsafe и miri
Юзать умные указатели (rc, arc) для иммутабельных структурок, наверно, оверхед, иногда обычных указателей достаточно. В расте чтение указателя - unsafe операция, и лучше, честно говоря, держаться подальше от unsafe блоков. В интернетах существует замечательная книга "Learn Rust With Entirely Too Many Linked Lists" которая поможет сделать жизнь с unsafe немного проще. В итоге, мы заменяем Arc на обычные указатели, не теряя возможности парсить файлики параллельно:
use std::ptr::NonNull; use std::marker::PhantomData; pub struct Tree<T> { /* omitted */ } unsafe impl<T: Send> Send for Tree<T> {} unsafe impl<T: Sync> Sync for Tree<T> {} type Link<T> = Option<NonNull<Node<T>>>; struct Node<T> { elem: T, left: Link<T>, right: Link<T>, } #[derive(Default)] pub struct Cursor<'a, T> { current: Link<T>, _foo: PhantomData<&'a T>, } impl<'a, T> Cursor<'a, T> { pub fn get(&self) -> Option<&'a T> { unsafe { self.current.map(|node| &(*node.as_ptr()).elem) } } pub fn left(&mut self) { unsafe { if let Some(node) = self.current.take() { self.current = (*node.as_ptr()).left; } } } pub fn right(&mut self) { unsafe { if let Some(node) = self.current.take() { self.current = (*node.as_ptr()).right; } } } }
Тут структурка Tree (представляет дерево грамматик) шарится между потоками, а Cursor (наш указатель) не шарится. Другими словами, Cursor создается в скоупе потока исполнения, а вот дерево одно на все потоки и должно быть иммутабельным. PhantomData - это так называемый маркер, который интуитивно показывает, каким типом данных владеет данная структурка (полезно при работе с указателями). Вроде работает, но unsafe код как бы не проверяется компилятором, а мы уже решили, что компилятор друг. Тут на помощь приходит проект Miri (An experimental interpreter for Rust's mid-level intermediate representation). Чуваки как бы пытаются проверить unsafe код, и вроде как у них что-то получается. Miri говорит, все ок с unsafe кодом, но ругается на библиотеку Rayon (параллельные вычисления). Автор в интернетах говорит, что все ок, но уговорить Miri не знает как. Что ж, оставим данное на совести автора библиотеки и перейдем к итераторам.
iters, options, match
Итераторы пришли из хаскеля, если даже не из хаскеля - они все равно молодцы, очень хороший апи. Например, вспомните в питоне, пожалуйста, без консоли позицию индекса при enumerate(list)? В расте, по-моему, ответ очевиден:
for (x,i) in v.iter().zip(0..) { println!("val {x} - index {i}"); }
Или тип Option - который неявно импортирован в скоуп каждого файла, - просто прекрасен. В следующих раз, когда вы захотите -1 поставить как отсутствие зн��чения, вспомните, плиз, об Option. Ну и напоследок match:
let token = match ch as char { '0'..='9' => Token::Number, 'A'..='Z' | 'a'..='z' | '_' => Token::Variable, '*' | '+' | '-' | '/' | '&' | '|' | '%' | '^' => Token::Operator, _ => panic!("no match"), };
Кажется, что создатели раста захотели хаскель в продакшне, но так, чтобы, типа, никто и не догадался. Идея ок.
