Как известно, 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"),
};
Кажется, что создатели раста захотели хаскель в продакшне, но так, чтобы, типа, никто и не догадался. Идея ок.