Чорна магія метапрограммирования: як працюють макроси в Rust 1.15

минулій статті ми познайомилися з однією з найцікавіших можливостей мови 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 вже дорослий
Система складання компілятора і стандартної бібліотеки 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, "Привіт".to_owned());
}

тобто, визначивши атрибут
#[derive(new)]
ми попросили компілятор самостійно вивести… а що саме? Звідки компілятор зрозуміє, який саме метод ми очікуємо отримати? Давайте розбиратися.
Для початку заглянемо в вихідний код бібліотеки, на щастя він не такий великий:
Багато буків (75 рядків)
#![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.узагальнення.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.узагальнення.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, я буду вважати своє завдання виконаним повністю :)
Матеріал підготовлений спільно з Дариною Щетиніною.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.