Ребята, свершилось! После долгих шести недель ожидания наконец вышла версия Rust 1.15 с блекджеком и процедурными макросами.
По моему нескромному мнению, это самый значительный релиз, после эпического 1.0. Среди множества вкусных вещей в этом релизе были стабилизированы процедурные макросы, взрывающие мозг своим могуществом, удобством и безопасностью.
А что же это дает простым смертным? Практически бесплатную [де]сериализацию, удобный интерфейс к БД, интуитивный веб фреймворк, выводимые конструкторы и много чего еще.
Да, если вы все еще не добрались до этого языка, то сейчас самое время попробовать, тем более, что теперь установить компилятор и окружение можно одной командой:
curl https://sh.rustup.rs -sSf | sh
Впрочем, обо всем по порядку.
Немного истории
Долгое время автоматически выводить можно было только стандартные типажи, такие как Eq, PartialEq, Ord, PartialOrd, Debug, Copy, Clone. Теперь это возможно и для пользовательских типов.
Вместо ручной реализации достаточно написать #[derive(имя_типажа)]
, а остальное компилятор сделает за нас:
#[derive(Eq, Debug)]
struct Point {
x: i32,
y: i32,
}
Программистам, работавшим с Haskell, все это должно быть очень знакомо (включая названия), да и применяется оно примерно в тех же случаях. Компилятор, обнаружив атрибут derive
, пройдется по списку типажей и реализует для них стандартный набор методов в меру своего понимания.
Например, для типажа Eq
будет реализован метод fn eq(&self, other: &Point) -> bool
путем последовательного сравнения полей структуры. Таким образом, структуры будут считаться равными, если равны их поля.
Конечно, в тех случаях, когда желаемое поведение отличается от поведения по умолчанию, программист может определить реализацию типажа собственноручно, например так:
use std::fmt;
impl fmt::Debug for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "My cool point with x: {} and y: {}", self.x, self.y)
}
}
Как бы там ни было, автоматический вывод типажей заметно упрощает кодирование и делает текст программы более читаемым и лаконичным.
Процедурные макросы
Компилятор, даже такой умный как Rust, не может вывести все методы самостоятельно. Однако, в некоторых случаях хочется иметь возможность подсказать компилятору, как следует выводить те или иные методы и для нестандартных структур, а дальше предоставить ему полную свободу действий. Такой подход может применяться в довольно сложных механизмах, избавляя программиста от необходимости писать код руками.
Процедурные макросы позволяют добавить в язык элемент метапрограммирования и тем самым существенно упростить рутинные операции, такие как сериализация или обработка запросов.
Ну хорошо, скажете вы, это все чудесно, а где же примеры?
Сериализация
Часто возникает задача передачи данных в другой процесс, отправки их по сети или же записи на диск. Хорошо если структура данных простая и легко может быть представлена в виде последовательности байтов.
А если нет? Если данные представляют собой набор сложных структур со строками произвольной длины, массивами, хеш таблицами и B-деревьями? Так или иначе, данные придется сериализовать.
Конечно, в истории Computer Science эта задача возникала неоднократно и ответ обычно кроется в библиотеках сериализации, навроде Google Protobuf.
Традиционно программист строит метаописание данных и протоколов на специальном декларативном языке, а затем все это дело компилируется в код, который уже используется в бизнес-логике.
В этом смысле Rust не является исключением, и библиотека для сериализации действительно есть. Вот только никакого метаописания писать не нужно. Все реализуется средствами самого языка и механизма процедурных макросов:
// Подключаем библиотеку и макро-определения
#[macro_use]
extern crate serde_derive;
// Подключаем поддержку JSON
extern crate serde_json;
// Наша структура
#[derive(Serialize, Deserialize, Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
// Создаем экземпляр структуры
let point = Point { x: 1, y: 2 };
// Конвертируем экземпляр в строку JSON
let serialized = serde_json::to_string(&point).unwrap();
// На печать будет выведено: serialized = {"x":1,"y":2}
println!("serialized = {}", serialized);
// Конвертируем строку JSON обратно в экземпляр Point
let deserialized: Point = serde_json::from_str(&serialized).unwrap();
// Ожидаемо, результат будет: deserialized = Point { x: 1, y: 2 }
println!("deserialized = {:?}", deserialized);
}
Помимо JSON библиотека Serde поддерживает еще массу форматов: URL, XML, Redis, YAML, MessagePack, Pickle и другие. Из коробки поддерживается сериализация и десериализация всех контейнеров из стандартной библиотеки Rust.
Похоже на магию, только это не магия. Все работает благодаря своеобразной интроспекции на этапе компиляции. А значит все ошибки будут своевременно выловлены и исправлены.
Чтение конфигурации
Кстати о десериализации. Выше мы увидели, как можно взять JSON строку и получить из нее структуру с заполненными полями. Тот же подход можно применить и для чтения файлов конфигурации.
Достаточно создать файл в одном из поддерживаемых форматов и просто десериализовать его в структуру конфигурации вместо унылого парсинга и разбора параметров по одному.
Работа с БД
Разумеется, одной сериализацией дело не ограничивается. Например, библиотека Diesel предоставляет удобный интерфейс к базам данных, который тоже стал возможен благодаря процедурным макросам и автоматическому выводу методов в Rust:
// ...
#[derive(Queryable)]
pub struct Post {
pub id: i32,
pub title: String,
pub body: String,
pub published: bool,
}
// ...
fn main() {
let connection = establish_connection();
let results = posts.filter(published.eq(true))
.limit(5)
.load::<Post>(&connection)
.expect("Error loading posts");
println!("Displaying {} posts", results.len());
for post in results {
println!("{}", post.title);
println!("----------\n");
println!("{}", post.body);
}
}
Полный пример можно найти на сайте библиотеки.
А что там с вебом?
Может быть, мы хотим обработать пользовательский запрос? И снова возможности языка позволяют писать интуитивный код, который «просто работает».
Ниже приведен пример кода с использованием фреймворка Rocket который реализует простейший счетчик:
struct HitCount(AtomicUsize);
#[get("/")]
fn index(hit_count: State<HitCount>) -> &'static str {
hit_count.0.fetch_add(1, Ordering::Relaxed);
"Your visit has been recorded!"
}
#[get("/count")]
fn count(hit_count: State<HitCount>) -> String {
hit_count.0.load(Ordering::Relaxed).to_string()
}
fn main() {
rocket::ignite()
.mount("/", routes![index, count])
.manage(HitCount(AtomicUsize::new(0)))
.launch()
}
Или, может быть, надо обработать данные из формы?
#[derive(FromForm)]
struct Task {
complete: bool,
description: String,
}
#[post("/todo", data = "<task>")]
fn new(task: Form<Task>) -> String { ... }
Выводы
В общем и целом становится понятно, что механизмы метапрограммирования в Rust работают очень неплохо. А если вспомнить, что сам язык является безопасным в отношении памяти и позволяет писать безопасный многопоточный код, свободный от состояния гонок, то все становится совсем хорошо.
Очень радует, что теперь эти возможности доступны и в стабильной версии языка, ведь многие жаловались, что ночные сборки приходилось использовать только из-за Serde и Diesel. Теперь такой проблемы нет.
В следующей статье я расскажу о том, как же все-таки писать эти макросы и что еще можно делать с их помощью.
Комментарии (27)
apro
10.02.2017 20:44+7Было бы неплохо добавить в статью и описание того как эти процедурные макросы работают,
а не только как их использовать.Halt
10.02.2017 20:53+5В следующей статье я как раз планирую это описать.
Vanger13
11.02.2017 14:53Ждем-с :)
Забавно что про сам термин «Procedural macro» в вики написано одно предложение, а все остальные ссылки связаны с Rust'ом (например http://sfackler.github.io/syntax-ext-talk/#/). Неужели там все так просто что нечего и читать про эти макросы?Halt
11.02.2017 15:03Макросы такого вида появились сильно раньше, чем Rust. Почитайте, например, эту статью.
Vanger13
12.02.2017 01:11Спасибо, ознакомился :) Про макросы знаю мало ибо не пользовался особо.
Однако остался вопрос — как связаны понятия Procedural и hygienic macro? Я догадываюсь что в Rust макросы поддерживают обе парадигмы… но как из одного следует другое (и следует ли) я не понимаю. Мне не хватает каких-то базовых знаний на которые можно опираться. Как имплементируются системы с макросами, почему и какие бывают альтернативы.
Кстати, не знаете ли про Scala — как называется тип макросов который используется сейчас (compile time reflection) и который придет на смену (scala-meta)? Если я правильно понял, то сейчас там как раз тупая лексическая замена, а будет что-то похожее на Scheme\Rust — с разбором AST. Прав ли я?Halt
12.02.2017 08:51+2Процедурными их назвали в Rust чтобы подчеркнуть, что код в точке подстановки макроса генерируется процедурным образом и замена может быть сколь угодно сложной: фактически, процедура работает с куском AST и может делать с ним что угодно.
Обычные макросы в Rust работают по принципу Сишных #define: «подставь то-то вместо того-то», с поправкой на гигиеничность и механизм сопоставления с образцом, конечно.
Про Scala, к сожалению, ничего сказать не могу, но могу предположить, что scala-meta — это как раз аналог обычных макросов Rust, тогда как compile time reflection — аналог процедурных.senia
12.02.2017 09:42+1Про Scala, к сожалению, ничего сказать не могу, но могу предположить, что scala-meta — это как раз аналог обычных макросов Rust, тогда как compile time reflection — аналог процедурных.
О как же вы заблуждаетесь!
Макросы в scala обладают всей информацией, которой обладает компилятор (ну почти).
Если процедурные макросы в Rust оперируют набором токенов, то макросы в scala работают с AST.
Например ниже вы мне отвечали про сериализацию полей с типами из сторонних библиотек. В scala библиотеки сериализации могут сгенерировать тайпкласс (аналог trait в Rust) для класса и всех его полей рекурсивно потому, что обладают полной информацией о типе, включая полную информацию о типе его полей. В упомянутой ссылке на библиотеке вообще тайпклассы генерируются в месте вызова методов и не надо засорять определение типа информацией о маршалинге в json (это не его ответственность).
meta — некоторое упрощение макросов, но принцип все еще тот же.Halt
12.02.2017 10:03Спасибо за информацию, теперь я знаю о Scala чуть больше, а не только то, что «оно есть» :)
pftbest
10.02.2017 21:03+5А еще serde умеет десериализовывать переменные окружения прямо в структуру, очень удобно.
senia
11.02.2017 08:39+1Я правильно понимаю, что если в моей структуре есть поле типа, определенного в библиотеке, и в библиотеке не озаботились совместимостью с данным типажом сериализации, то ни какой сериализации для своей структуры я не получу?
Halt
11.02.2017 09:07+4С учетом coherence rule действительно нельзя просто взять и сериализовать внешний тип, который этим не озаботился сам. Но есть способ преодолеть это для своей библиотеки с помощью введения производного типа.
Мне пришлось делать это для BitVec и в принципе, особых неудобств это не доставляет, поскольку deref coercions дают возможность использовать производный тип вместо основного.
Вообще, предоставлять средства сериализации для библиотечных типов это такой же хороший тон, как определять Debug и Display. Я думаю, в будущем это будет повсеместно.
P.S.: Этот момент языка все еще является активно обсуждаемым, так что в будущем могут появиться и другие варианты решения.senia
11.02.2017 20:42+2Надеюсь они придут к какому-то более удобному способу.
Я с этой проблемой столкнулся при написании генераторов для QuickCheck. Добавление оберток помогает, но выглядит грязно.
Вообще, предоставлять средства сериализации для библиотечных типов это такой же хороший тон, как определять Debug и Display. Я думаю, в будущем это будет повсеместно.
Это не выход. В той же scala, где генерация typeclass макросами используется давно и продуктивно, есть несколько различных библиотек для маршалинга в json. Думаю и в Rust появятся разные библиотеки для сходных задач — у всех свои фломастеры. К которой предоставлять имплементации? Ко всем?Halt
12.02.2017 09:14+5Тут надо понимать, что проблема далеко не тривиальна и ограничения возникают неспроста.
Rust всегда ставил стабильность и безопасность выше простого удобства и выразительности. Конечно, со временем и они подтягиваются, но совершенно точно можно сказать, что их не позиционируют как абсолютный приоритет.
Если совсем на пальцах, то orphan rule делает невозможной ситуацию, когда в один прекрасный момент, после минорного обновления библиотеки где-то в недрах дерева зависимостей, код просто перестает компилироваться.
Такое может возникнуть, если есть две библиотеки A и B, каждая из которых реализует у себяimpl Trait for Struct
и все это импортируется в библиотеку C, которая уже используется где-то у вас в коде.
До тех пор, пока единственной точкой реализации типажа была библиотека A, все было прекрасно. Но потом разработчики библиотеки B сделали минорное изменение и добавили, например, поддержку сериализации структур (потому что их давно об этом просили).
По иерархии semver это незначительное изменение, которое не влияет на стабильность, посему оно к вам спокойно приехало при очередном обновлении. И тут внезапно оказалось, что при сборке библиотеки C компилятор оказывается перед выбором, какую же из реализаций взять? Ведь с точки зрения компилятора они совершенно различны.
Разработчики Rust называют эту проблему «hashtable problem», потому что это еще лучше иллюстрирует проблему. Представьте, что каждая из библиотек A и B предоставила свою реализацию функции хеширования для некоего типа T. А потом заполненная хеш таблица из одной библиотеки передается в другую. Думаю не надо объяснять, к чему это может привести.
Брать реализацию наугад, как это делает C++ в случае Weak символов — очень опасно и провоцирует сложновыявляемые проблемы. Поэтому на данный момент Rust явным образом запрещает такое поведение.
Ставить тут Scala в пример не совсем корректно, поскольку основа у языков и способ диспетчеризации очень отличаются. В этом смысле Rust торит совершенно новый путь и пока не ясно, куда он приведет.
Советую почитать недавний пост Aaron Turon-а на эту тему, он многое проясняет.senia
12.02.2017 09:51Сравнение со scala тут как раз очень подходит, так как в scala есть эти же проблемы. И не скажу, что они идеально решены.
Но некоторые все-таки решены. Например добавление реализации тайпкласса в зависимости не приведет к проблемам с компиляцией, так как любую реализацию тайпкласса, кроме тех, что написаны прямо вместе с типом, необходимо явно импортировать.
Но проблему с хеш-таблицами это не решает, конечно, ибо при разных использованиях хеш-таблицы могут быть импортированы разные реализации. Решения для этого тоже есть, но они довольно нетривиальны.Halt
12.02.2017 10:14Может оказаться так, что определения вынесли в прелюдию для обеих библиотек, а потом неявно реэкспортировали в третьей. Так что импорты сами по себе хоть и защищают от случайной коллизии, но все равно не дают 100% гарантии. Разумеется, так делать не надо, но…
С моей точки зрения, проблема именно в том, что существует ненулевая вероятность огрести проблемы при неудачном стечении обстоятельств из-за кода, которым вы не управляете.
Мне кажется, что наличие этого правила на данном этапе развития языка как раз отражает философию «stability without stagnation», потому что в противном случае, люди стали бы опасаться обновлять библиотеки, что в конечном итоге стало бы причиной стагнации.
xomachine
11.02.2017 14:42+2Ну надо же, я как раз недавно писал библиотеку для сериализации/десериализации с похожим принципом действия для Nim. Если кому интересно, вот она + краткий мануал.
Пусть обилием поддерживаемых форматов она не может похвастаться, но принцип действия, если я правильно всё понял, тот же. Структура, которую нужно сериализовать передается на вход макросу, а он генерирует соответствующие функции для сериализации/десериализации.
dom1n1k
11.02.2017 19:31-7И как, сильно быстрее такой сервис, чем пхп, го или нода?
Halt
11.02.2017 20:49+9Смотря что вы под этим понимаете.
Если говорить про сам язык, то да, быстрее. Rust не использует сборщик мусора, код напрямую компилируется в нативные инструкции, а не выполняется в виртуальной машине. Наконец, сама система типов позволяет поддерживать такие инварианты и делать такие оптимизации, которые в традиционных языках делать крайне сложно.
Если говорить про готовую экосистему для веб-разработки прямо сейчас, то за меня лучше ответит сайт http://www.arewewebyet.org/.
Отдельные компоненты системы тестировались и демонстрировали весьма приличные показатели, но разумеется это только пол дела.
Могу посоветовать посмотреть планы сообщества на 2017й год, где хорошо обозначены приоритеты развития.
0xd34df00d
12.02.2017 10:38То есть, это как Template Haskell, только Rust?
Halt
12.02.2017 12:01Я сам не разбирался с Template Haskell, но по описанию похоже.
Вообще, Rust проектировался под большим влиянием Haskell, ML и других функциональных языков, так что ничего удивительно, если какие-то концепции были заимствованы.
Единственное что стоит отметить: в отличие от Haskell, в Rust еще нет типов высших порядков, но исследования в этом направлении ведутся.
Shablonarium
12.02.2017 21:11+1Коллеги, может кто-то здесь подскажет как сбилдить XI Editor, который вот этот https://github.com/google/xi-editor
Antervis
13.02.2017 06:36В теории процедурные макросы выглядят очень мощно. Но очень уж многословно для простого случая
Halt
13.02.2017 06:48А в чем многословность? То что надо токены разбирать в AST? Ну по крайней мере это делается один раз в библиотеке.
Все же, написать#[derive(Eq, PartialEq, Ord, ParialOrd, Hash, Clone, Debug, Serialize, Deserialize)]
— это одна строка, а те же методы, реализованные вручную — сотня-две.
greabock
мда… оказали вы услугу rocket.rs, разместив ссылку на Хабре. В пятницу. Вечером.
snuk182
Пусть привыкают. Фреймворк очень годный.