Как стать автором
Обновить

Черная магия метапрограммирования: как работают макросы в Rust 1.15

Время на прочтение13 мин
Количество просмотров17K

В прошлой статье мы познакомились с одной из самых интересных возможностей языка 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)] мы попросили компилятор самостоятельно вывести… а что именно? Откуда компилятор поймет, какой именно метод мы ожидаем получить? Давайте разбираться.


Для начала заглянем в исходный код библиотеки, к счастью он не такой большой:


Много буков (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.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, я буду считать свою задачу выполненной полностью :)


Материал подготовлен совместно с Дарьей Щетининой.

Теги:
Хабы:
Всего голосов 52: ↑51 и ↓1+50
Комментарии76

Публикации

Истории

Работа

Rust разработчик
6 вакансий

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань