В прошлой статье мы познакомились с одной из самых интересных возможностей языка Rust — процедурными макросами.
Как и обещал, сегодня я расскажу о том, как писать такие макросы самостоятельно и в чем их принципиальное отличие от печально известных макросов препроцессора в C/C++.
Но сначала пройдемся по релизу 1.15 и поговорим о других новшествах, поскольку для многих они оказались не менее востребованы.
Что можно почитать?
Язык Rust развивается очень интенсивно. Издатели, натурально, не успевают и не берутся выпускать книги, поскольку они устаревают еще до того, как на страницах высохнет краска.
Поэтому большая часть актуальной документации представлена в электронном виде. Традиционным источником информации является Книга, в которой можно найти большинство ответов на вопросы новичков. Для совсем частых вопросов предусмотрен раздел FAQ.
Тем, кто уже имеет опыт программирования на других языках, и вообще достаточно взрослый, чтобы разбираться самостоятельно, подойдет другая книга. Предполагается, что она л��чше подает материал и должна прийти на смену первой книге. А тем, кому нравится учиться на примерах, подойдет Rust by Example.
Людям, знакомым с C++, может быть интересна книга, а точнее porting guide, старающаяся подать материал в сравнении с C++ и делающая акцент на различиях языков и на том, какие проблемы Rust решает лучше.
Если вас интересует история развития языка и взгляд с той стороны баррикад, крайне рекомендую блоги Aaron Turon и Niko Matsakis. Ребята пишут очень живым языком и рассказывают о текущих проблемах языка и о том, как предполагается их решать. Зачастую из этих блогов узнаешь куда больше актуальной информации, чем из других источников.
Наконец, если вы не боитесь драконов и темных углов, то можете взглянуть на Растономикон. Только предупреждаю, после прочтения этой книги вы уже не сможете смотреть на Rust прежним образом. Впрочем, я отвлекся…
Новое в Rust 1.15
С момента выпуска 1.14 прошло около 6 недель. За это время в новый релиз успели войти 1443 патча (неслабо, правда?) исправляющие баги и добавляющие новые возможности. А буквально на днях появился и хотфикс 1.15.1, с небольшими, но важными исправлениями.
За подробностями можно обратиться к странице анонса или к детальному описанию изменений (changelog). Здесь же мы сконцентрируемся на наиболее заметных изменениях.
Cargo уже взрослый
Cистема сборки компилятора и стандартной библиотеки Rust была переписана на сам Rust с использованием Cargo — стандартного пакетного менеджера и системы сборки, принятой в экосистеме Rust.
С этого момента Cargo является системой сборки по умолчанию. Это был долгий процесс, но он наконец-то принес свои плоды. Авторы утверждают, что новая система сборки используется с декабря прошлого года в master ветке репозитория и пока все идет хорошо.
Теперь файл с названием build.rs, лежащий на одном уровне с Cargo.toml будет интерпретироваться как билд скрипт.
Уже даже завели уже вмержили pull request на удаление всех makefile; интеграция запланирована на релиз 1.17.
Все это готовит почву к прямому использованию пакетов из crates.io для сборки компилятора, как и в любом другом проекте. А еще это неплохая демонстрация возможностей Cargo.
Новые архитектуры
У раста появилась поддержка уровня Tier 3 для архитектур i686-unknown-openbsd, MSP430, и ARMv5TE. Недавно стало известно, что в релизе LLVM 4.0 появляется поддержка архитектуры микроконтроллеров AVR. Разработчики Rust в курсе этого и уже готовятся почти все сделали для интеграции новой версии LLVM и новой архитектуры.
Более того, уже есть проекты использования Rust в embedded окружении. Разработчики компилятора опрашивают сообщество для выяснения потребностей этой пока малочисленной, но важной группы пользователей.
Быстрее! Выше! Сильнее!
Компилятор стал быстрее. А недавно еще и объявили о том, что система инкрементальной компиляции перешла в фазу бета-тестирования. На моих проектах время компиляции после незначительных изменений уменьшилось с ~20 до ~4 секунд, хотя окончательная линковка все еще занимает приличное время. Пока инкрементальная компиляция работает только в ночных сборках и сильно зависит от характера зависимостей, но прогресс радует.
Алгоритм slice::sort() был переписан и стал намного, намного, намного быстрее. Теперь это гибридная сортировка, реализованная под влиянием Timsort. Раньше использовалась обычная сортировка слиянием.
В C++ мы можем определить перекрывающую специализацию шаблона для некоторого типа, но пока не можем наложить ограничения на то, какие типы вообще могут использоваться для специализации этого шаблона. Работы в этом направлении ведутся, но пока все очень сложно.
Стабильный Rust всегда умел задавать ограничения типажей, но с недавних пор появилась возможность доопределить, а точнее перекрыть обобщенную реализацию более конкретной, если она задает более строгие ограничения. Это позволяет оптимизировать код для частных случаев, не нарушая при этом обобщенный интерфейс.
В частности, в релизе 1.15 была добавлена специализированная реализация метода extend() для Vec<T>, где T: Copy, которая использует простое линейное копирование регионов памяти, что привело к значительному ускорению.
Помимо этого были ускорены реализации методов chars().count(), chars().last(), и char_indices().last().
Поддержка IDE
Этого пока нет в стабильном Rust, но тем не менее новость слишком значительная, чтобы о ней умолчать. Дело в том, что недавно разработчики Rust Language Server объявили о выходе альфа-версии своего детища.
Language Server Protocol это стандартный протокол, который позволяет редакторам и средам разработки общаться на одном языке с компиляторами. Он абстрагирует такие операции, как автодополнение ввода, переход к определению, рефакторинг, работу с буферами и т.д.
Это означает, что любой редактор или IDE, которые поддерживают LSP автоматически получают поддержку всех LSP-совместимых языков.
Уже сейчас можно попробовать базовые возможности на совместимых редакторах, только авторы настоятельно советуют осторожно относиться к своим данным, ибо код еще довольно сырой.
Макросы в Rust
Вернемся к нашим баранам.
С самого начала программисты хотели писать поменьше, а получать побольше. В разное время под этим понимали разные вещи, но условно можно выделить два метода сокращения ко��а:
- Выделение логически законченных частей кода для многократного использования
- Выделение несамостоятельных фрагментов кода, ничего не значащих вне своего контекста
Первый принцип больше соответствует традиционной декомпозиции программ: разделению кода на функции, методы, классы и т. п.
К второму можно отнести макросы, инклуды и прочий препроцессинг. В языке Rust для этого предусмотрено три механизма:
- Обычные макросы
- Процедурные макросы
- Плагины компилятора
Обычные макросы (в документации macro by example) используются, когда хочется избежать повторения однообразного кода, но выделять его в функцию нерационально, либо невозможно. Макросы vec! или println! являются примерами таких макросов. Задаются декларативным образом. Работают по принципу сопоставления и подстановки по образцу. Реализация основана на базе работы 1986-го года, из которой они получили свое полное название.
Процедурные макросы являются первой попыткой стабилизации интерфейса плагинов компилятора. В отличие от обычных декларативных макросов, процедурные макросы представляют собой фрагмент кода на Rust, который выполняется в процессе компиляции программы и результатом работы которого является набор токенов. Эти токены компилятор будет интерпретировать как результат подстановки макроса.
На данный момент компилятором предусмотрено только использование процедурных макросов для поддержки пользовательских атрибутов derive. В будущем количество сценариев будет расширяться.
Плагины компилятора являются самым мощным, но сложным и нестабильным (в смысле API) средством, которое доступно только в ночных сборках компилятора. В документации приведен пример плагина поддержки римских цифр в качестве числовых литералов.
Пример макроса
Поскольку макросы не ограничены лексическим контекстом функции, они могут генерировать определения и для более высокоуровневых сущностей. Например, макросом можно определить целый impl блок, или метод вместе с именем, списком параметров и типом возвращаемого значения.
Макро-вставки возможны практически во всех местах иерархии модуля:
- внутри выражений
- в
traitиimplблоках - в телах функций и методов
- в теле модуля
Макросы довольно часто применяются в библиотеках, когда приходится определять однотипные конструкции, например серию impl для стандартных типов данных.
Например, в стандартной бибилотеке Rust макросы используются для компактного объявления реализации типажа PartialEq для всевозможных сочетаний срезов, массивов и векторов:
macro_rules! __impl_slice_eq1 {
($Lhs: ty, $Rhs: ty) => {
__impl_slice_eq1! { $Lhs, $Rhs, Sized }
};
($Lhs: ty, $Rhs: ty, $Bound: ident) => {
#[stable(feature = "rust1", since = "1.0.0")]
impl<'a, 'b, A: $Bound, B> PartialEq<$Rhs> for $Lhs where A: PartialEq<B> {
#[inline]
fn eq(&self, other: &$Rhs) -> bool { self[..] == other[..] }
#[inline]
fn ne(&self, other: &$Rhs) -> bool { self[..] != other[..] }
}
}
}
__impl_slice_eq1! { Vec<A>, Vec<B> }
__impl_slice_eq1! { Vec<A>, &'b [B] }
__impl_slice_eq1! { Vec<A>, &'b mut [B] }
__impl_slice_eq1! { Cow<'a, [A]>, &'b [B], Clone }
__impl_slice_eq1! { Cow<'a, [A]>, &'b mut [B], Clone }
__impl_slice_eq1! { Cow<'a, [A]>, Vec<B>, Clone }Мы же рассмотрим более показательный пример. А именно, реализацию макроса vec!, который выполняет роль конструктора для Vec:
macro_rules! vec {
// Задание через значение и длину: vec![0; 32]
( $elem:expr; $n:expr ) => ( $crate::vec::from_elem($elem, $n) );
// Задание перечислением элементов: vec![1, 2, 3]
( $($x:expr),* ) => ( <[_]>::into_vec(box [$($x),*]) );
// Рекурсивная обработка финальной запятой: vec![1, 2, 3, ]
( $($x:expr,)* ) => ( vec![$($x),*] )
}Макрос работает подобно конструкции match, но на этапе компиляции. Входом для него является фрагмент синтаксического дерева программы. Каждая ветвь состоит из шаблона сопоставления и выражения подстановки, разделенных с помощью =>.
Шаблон сопоставления напоминает регулярные выражения с возможными квантификаторами * и +. Кроме метапеременных через двоеточие указываются еще предполагаемые типы (designator). Например, тип expr соответствует выражению, ident — любому идентификатору, а ty — идентификатору типа. Подробнее про синтаксис макросов написано в руководстве по макросам и в документации, а в porting guide можно найти актуальный разбор макроса vec! с описанием каждой ветви.
Указание типов метапеременных позволяет более точно определить область применимости макроса, а также отловить возможные ошибки.
Встретив в коде использование макроса, компилятор выберет ту ветвь, которая подходит для данного случая и заменит в дереве конструкцию макроса на соответствующее выражение подстановки. Если в теле макроса подходящей конструкции не нашлось, компилятор сгенерирует осмысленное сообщение об ошибке.
Чистота и порядок
Макрос в Rust должен быть написан так, чтобы генерировать лексически корректный код. Это означает, что не всякий набор символов может быть валидным макросом. Это позволяет избежать многих проблем, связанных с использованием препроцессора в C/C++.
#define SQUARE(a) a*a
int x = SQUARE(my_list.pop_front());
int y = SQUARE(x++);В безобидном с виду фрагменте кода мы вместо одного элемента вытащили два, вычислили не тот результат, какой ожидали, а последней строкой еще и спровоцировали неопределенное поведение. Три серьезных ошибки на две строки кода — это как-то многовато.
Конечно, пример синтетический, но мы все прекрасно знаем, как постоянная смена требований и людей в команде могут запутать даже хороший некогда код.
Корень зла лежит в том, что препроцессор C/C++ орудует на уровне текста, а компилятору приходится разбирать уже испорченную препроцессором программу.
Напротив, макросы в Rust разбираются и применяются самим компилятором и работают на уровне синтаксического дерева программы. Поэтому описанные выше проблемы не могут возникнуть в принципе.
Макросы в Rust:
- не затеняют переменные
- не нарушают порядка разбора условий
- не дают скрытых побочных эффектов
- не приводят к неопределенному поведению
Такие макросы называются гигиеничными. Одним из следствий является то, что макрос не может объявить переменную, видимую за его пределами.
Зато в пределах макроса можно заводить переменные, которые гарантировано не пересекутся с переменными выше по коду. Например, описанный выше макрос vec! можно переписать с использованием промежуточной переменной. Для простоты рассмотрим только основную ветвь:
macro_rules! vec {
( $($x:expr),* ) => {
{
// Объявляем переменную-аккумулятор
let mut result = Vec::new();
// На каждое выражение из $x подставляем свою строку
$(result.push($x);)*
// Возвращаем result как результат применения макроса
result
}
};
}Таким образом, код
let vector = vec![1, 2, 3];после подстановки макроса будет преобразован в
let vector = {
let mut result = Vec::new();
result.push(1);
result.push(2);
result.push(3);
result
};Процедурные макросы
Когда возможностей обычных макросов недостаточно, в бой идут процедурные.
Как уже было сказано выше, процедурные макросы так называются, потому что вместо простой подстановки они могут вернуть совершенно произвольный набор токенов, являющийся результатом выполнения некоторой процедуры, а точнее функции. Эту функцию мы и будем изучать.
В качестве подопытного кролика возьмем реализацию автоматически выводимого конструктора #[derive(new)] из соответствующей библиотеки.
С точки зрения пользователя использование будет выглядеть так:
#[macro_use]
extern crate derive_new;
#[derive(new)]
struct Bar {
x: i32,
y: String,
}
fn main() {
let _ = Bar::new(42, "Hello".to_owned());
}То есть, определив атрибут #[derive(new)] мы попросили компилятор самостоятельно вывести… а что именно? Откуда компилятор поймет, какой именно метод мы ожидаем получить? Давайте разбираться.
Для начала заглянем в исходный код библиотеки, к счастью он не такой большой:
#![crate_type = "proc-macro"]
extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;
use proc_macro::TokenStream;
#[proc_macro_derive(new)]
pub fn derive(input: TokenStream) -> TokenStream {
let input: String = input.to_string();
let ast = syn::parse_macro_input(&input).expect("Couldn't parse item");
let result = new_for_struct(ast);
result.to_string().parse().expect("couldn't parse string to tokens")
}
fn new_for_struct(ast: syn::MacroInput) -> quote::Tokens {
let name = &ast.ident;
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
let doc_comment = format!("Constructs a new `{}`.", name);
match ast.body {
syn::Body::Struct(syn::VariantData::Struct(ref fields)) => {
let args = fields.iter().map(|f| {
let f_name = &f.ident;
let ty = &f.ty;
quote!(#f_name: #ty)
});
let inits = fields.iter().map(|f| {
let f_name = &f.ident;
quote!(#f_name: #f_name)
});
quote! {
impl #impl_generics #name #ty_generics #where_clause {
#[doc = #doc_comment]
pub fn new(#(args),*) -> Self {
#name { #(inits),* }
}
}
}
},
syn::Body::Struct(syn::VariantData::Unit) => {
quote! {
impl #impl_generics #name #ty_generics #where_clause {
#[doc = #doc_comment]
pub fn new() -> Self {
#name
}
}
}
},
syn::Body::Struct(syn::VariantData::Tuple(ref fields)) => {
let (args, inits): (Vec<_>, Vec<_>) = fields.iter().enumerate().map(|(i, f)| {
let f_name = syn::Ident::new(format!("value{}", i));
let ty = &f.ty;
(quote!(#f_name: #ty), f_name)
}).unzip();
quote! {
impl #impl_generics #name #ty_generics #where_clause {
#[doc = #doc_comment]
pub fn new(#(args),*) -> Self {
#name(#(inits),*)
}
}
}
},
_ => panic!("#[derive(new)] can only be used with structs"),
}
}А теперь разберем его по косточкам и попытаемся понять, что он делает.
#![crate_type = "proc-macro"]
extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;
use proc_macro::TokenStream;Во первы́х строках библиотеки задается тип специальный единицы трансляции proc-macro, который говорит, что это будет не абы-что, а плагин к компилятору. Затем подключаются необходимые библиотеки proc_macro и syn со всем инструментарием. Первая задает основные типы, вторая — предоставляет средства парсинга Rust кода в абстрактное синтаксическое дерево (AST). В свою очередь, библиотека quote предоставляет очень важный макрос quote! который мы увидим в действии чуть позже.
Наконец, импортируется необходимый тип TokenStream, поскольку он фигурирует в прототипе функции.
Далее следует собственно функция, выступающая в роли точки входа в процедурный макрос:
#[proc_macro_derive(new)]
pub fn derive(input: TokenStream) -> TokenStream {
let input: String = input.to_string();
let ast = syn::parse_macro_input(&input).expect("Couldn't parse item");
let result = new_for_struct(ast);
result.to_string().parse().expect("couldn't parse string to tokens")
}Обратите внимание на атрибут proc_macro_derive(new), который говорит компилятору, что эта функция отвечает за #[derive(new)].
На вход она получает набор токенов из компилятора, составляющих тело макроса. На выходе компилятор ожидает получить другой набор токенов, являющихся результатом пр��менения макроса. Таким образом, функция derive() работает как своеобразный фильтр.
Тело функции весьма нехитрое. Сначала мы преобразуем входной набор токенов в строку, а затем разбираем строку как абстрактное синтаксическое дерево. Самое интересное происходит внутри вызова функции new_for_struct(), который принимает AST на вход, а отдает процитированные токены (об этом позже). Наконец, полученные токены преобразуются обратно в строку (не спрашивайте меня, почему так), парсятся в TokenStream и отдаются уже в качестве результата работы макроса компилятору.
Если честно, я тоже не понимаю, зачем тасовать данные туда-сюда через строки и почему нельзя было сразу сделать вменяемый интерфейс, ну да ладно. Возможно, в будущем ситуация изменится.
Давайте разберемся в том, что делает функция new_for_struct(). Но сначала посмотрим на те структуры, для которых нам может потребоваться сгенерировать конструкторы.
Итак, на вход нам могут подать:
// Обычная структура
#[derive(new)]
struct Normal {
x: i32,
y: String,
}
// Вариант tuple struct
#[derive(new)]
struct Tuple(i32, i32, i32);
// Структура-пустышка
#[derive(new)]
struct Empty;Понятное дело, что синтаксические деревья у всех трех вариантов будут различными. И это нужно учитывать при генерировании метода new(). Собственно, все что делает new_for_struct(), — это смотрит на переданное AST дерево, определяет, с каким вариантом она имеет дело данный момент и генерирует нужную подстановку. А если ей на вход передали незнамо что — она начинает паниковать.
fn new_for_struct(ast: syn::MacroInput) -> quote::Tokens {
let name = &ast.ident;
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
let doc_comment = format!("Constructs a new `{}`.", name);
match ast.body {
syn::Body::Struct(syn::VariantData::Struct(ref fields)) => { /* обычная структура */ },
syn::Body::Struct(syn::VariantData::Unit) => { /* единичный тип */ },
syn::Body::Struct(syn::VariantData::Tuple(ref fields)) => { /* tuple struct */ }
_ => panic!("#[derive(new)] can only be used with structs"),
}
}Давайте посмотрим на код, генерирующий подстановку для обычной структуры. Здесь код дробить уже неудобно, поэтому я вставлю комментарии прямо в текст:
// Для каждого поля в структуре генерируем список пар имя:тип
// которые позже используем в списке параметров конструктора
let args = fields.iter().map(|f| {
let f_name = &f.ident;
let ty = &f.ty;
quote!(#f_name: #ty)
});
// Генерируем тело конструктора, пары имя:имя
let inits = fields.iter().map(|f| {
let f_name = &f.ident;
quote!(#f_name: #f_name)
});
// Наконец, собираем все воедино и цитируем весь блок целиком:
quote! {
impl #impl_generics #name #ty_generics #where_clause {
#[doc = #doc_comment]
pub fn new(#(args),*) -> Self {
#name { #(inits),* }
}
}
}Вся хитрость здесь заключена в макросе quote! который позволяет цитировать фрагменты кода, подставляя вместо себя набор соответствующих токенов. Обратите внимание на метапеременные, начинающиеся с решетки. Они унаследованы из лексического контекста, в котором находится цитата.
Если все еще не понятно «как оно работает», взгляните на результат применения процедурного макроса к описанной выше структуре Normal.
Сама структура еще раз:
#[derive(new)]
struct Normal {
x: i32,
y: String,
}Результат применения процедурного макроса:
/// Constructs a new `Normal`.
impl Normal {
pub fn new(x: i32, y: String) -> Self {
Normal { x: x, y: y }
}
}Внезапно, все становится на свои места. Оказывается, мы только что собственноручно сгенерировали impl блок для структуры, добавили в него ассоциированную функцию-конструктор new() с документацией (!), двумя параметрами x и y соответствующих типов и с реализацией, которая возвращает нашу структуру, последовательно инициализируя ее поля значениями из своих параметров.
Поскольку Rust может понять из контекста, чему соответствуют x и y до и после двоеточия, все компилируется успешно.
В качестве упражнения, оставшиеся две ветви предлагаю разобрать самостоятельно.
Заключение
Потенциал процедурных макросов только предстоит выявить. Обозначенные в прошлой статье примеры — только вершина айсберга и самый прямолинейный вариант использования. Есть гораздо более интересные проекты, как например проект сборщика мусора, реализованного целиком лексическими средствами языка Rust.
Надеюсь, что статья оказалась вам полезной. А если после ее прочтения вы еще и захотели поиграться с языком Rust, я буду считать свою задачу выполненной полностью :)
Материал подготовлен совместно с Дарьей Щетининой.
