Как-то я сказал своему коллеге, что в Rust имеются макросы, ему показалось, что это плохо. Раньше у меня была такая же реакция, но Rust показал мне, что макросы не обязательно плохи.


Где и как их уместно применять? Смотрите под катом.


Почему мы должны опасаться макросов


Макросы являются формой метапрограммирования: они являются кодом, который манипулирует кодом. Метапрограммирование получило плохую репутацию, потому что при их использовании нелегко уберечься от написания плохого кода. Примерами служат #define в C, который легко может взаимодействовать с кодом непредсказуемым образом, или eval в JavaScript, который увеличивают опасность инъекции кода.


О макросах в Rust


Многие их этих проблем могут быть решены при использовании необходимых средств, макросы же предоставляют некоторые такие средства:


  • генерирование избыточного/тривиального кода (boilerplate) вместо его ручного написания.
  • расширение языка перед тем, как будет добавлен новый синтаксис, закрытие пробелов в языке.
  • оптимизация производительности — ибо некоторые действия, которые ранее выполнялись во время исполнения, теперь исполняются на стадии компиляции -1-.

Для того чтобы достичь этих целей, Rust включает в себя два вида макросов -2-. Они известны под разными названиями (процедурные, декларативные, macro_rules, и т. д.), хотя я считаю, что данные имена несколько запутывают. К счастью, они не так важны, поэтому я буду называть их функциональные и атрибутные.


Причиной того, что имеется два типа макросов является то, что они хорошо подходят для решения разных задач:


  • функциональные: легко включить в код.
  • атрибутные: лучше подходят для генерации кода, который не вписывается в окружающий код.

Во всем остальном результаты их применения схожи: компилятор "стирает" макросы во время компиляции, заменяя их кодом, генерирующимся из макроса, и компилируя его с "обычным", не макро-кодом -3-. Реализация двух типов макросов сильно отличается, но мы не будет здесь глубоко в это вдаваться.


Почему функциональные макросы


Функциональный макрос может быть исполнен почти как функция. Данный вид макросов имеет ! в вызове:


let x = action();       // вызов функции
let y = action!();      // вызов макроса

Зачем использовать макросы, когда можно использовать функции? Нужно помнить, что функциональные макросы ничего не имеют общего с функциями — они похожи на функции, чтобы их, макросы, было легче использовать. Поэтому вопрос стоит не в том, лучше данный тип макросов чем функции или нет, а в том, нужна ли нам возможность менять исходный код.


Полезные утверждения


Начнем с рассмотрения assert!, который используется для проверки того, что некоторое условие выполняется, вызывая панику (panic), если это не так. Они проверяются во время выполнения, так что же нам дает здесь метапрограммирование? Давайте посмотрим на сообщение, которое печатается, когда assert! завершается неудачно:


fn main() {
    let mut vec = Vec::new();   // создать пустой массив
    vec.push(1);                // добавить элемент в конец массива
    assert!(vec.is_empty())     // массив не пуст - assert! завершается неудачно
    // печатается:
    // thread 'main' panicked at 'assertion failed: vec.is_empty()', src\main.rs:4
}

Данное сообщение содержит в себе условие, которое мы проверяем. Другими словами, макрос создает сообщение об ошибке, которое основано на исходном коде, мы же получаем содержательное сообщение об ошибке, не вставляя его в программу вручную.


Типо-безопасная работа с форматом строк


Во многих языках программирования поддерживается задание форматов вывода для строк -4-. Rust не является исключением и также поддерживает задание форматов строк посредством format!. Однако по-прежнему стоит вопрос: почему мы должны использовать метапрограммирование для решения проблемы? Давайте посмотрим на println! (он внутри использует format! для обработки переданной строки) -5-.


fn main() {
    // просто ввод
    println!("{} is {} in binary", 2, 10);
    // печатает: 2 is 10 in binary

    // вывод аргументов в числовой и двоичной форме
    println!("{0} is {0:b} in binary", 3)
    // печатает: 3 is 11 in binary
}

Есть много причин того, что format! реализован как макрос -6-, я же хочу подчеркнуть то, что он может разделить строку на части во время компиляции, проанализировать ее и проверить, является ли обработка переданных аргументов типо-безопасной. Мы можем изменить наш код и получить ошибку компиляции:


fn main() {
    println!("{} is {} in binary", 2/*, 10*/);
    // Ошибка компиляции: ожидались два аргумента, был передан один

    println!("{0} is {0:b} in binary", "3")
    // Ошибка компиляции: не реализовано представление строк в двоичном виде
}

Во многих других языках данные ошибки проявились бы во время выполнения, однако в Rust мы можем использовать макросы для того, чтобы выполнять данную проверку на этапе компиляции и генерировать производительный код для обработки формата строки без проверок времени выполнения -7-.


Легкое логирование


В этом примере немного вдадимся в экосистему языка. Rust имеет пакет log, который используется как главный фронтенд логирования. Как и другие решения для логирования, он предоставляет разные уровни логирования, но, в отличие от других решений, данные уровни представляются макросами, а не функциями.


Логирование показывает мощность метапрограммирования в том, как оно использует макросы file! и line!; Данные макросы дают возможность установить точное место расположения вызова функции логирования в исходном коде. Давайте посмотрим на пример. Так как log является фронтендом, добавим бэкенд, пакет flexi_logger.


#[macro_use] extern crate log;
extern crate flexi_logger;

use flexi_logger::{Logger, LogSpecification, LevelFilter};

fn main() {
    // Установим `trace` в качестве минимального уровня логирования
    let log_config = LogSpecification::default(LevelFilter::Trace).build();

    Logger::with(log_config)
        .format(flexi_logger::opt_format)   // Specify how we want the logs formatted
        .start()
        .unwrap();

    // Логирование готово к использованию. Используем его для отладки алгоритма
    info!("Fired up and ready!");

    complex_algorithm()
}

fn complex_algorithm() {
    debug!("Running complex algorithm.");
    for x in 0..3 {
        let y = x * 2;
        trace!("Step {} gives result {}", x, y)
    }
}

Эта программа напечатает:


[2018-01-25 14:48:42.416680 +01:00] INFO [src\main.rs:16] Fired up and ready!
[2018-01-25 14:48:42.418680 +01:00] DEBUG [src\main.rs:22] Running complex
algorithm.
[2018-01-25 14:48:42.418680 +01:00] TRACE [src\main.rs:25] Step 0 gives
result 0
[2018-01-25 14:48:42.418680 +01:00] TRACE [src\main.rs:25] Step 1 gives
result 2
[2018-01-25 14:48:42.418680 +01:00] TRACE [src\main.rs:25] Step 2 gives
result 4

Как вы видите, наши логи содержат имена файлов и номера строк.


  • мы получаем данную информацию без накладных расходов времени выполнения на получение этих данных.
  • информация корректна и полезна.

В первом случае компилятор вставляет в исполняемые файлы необходимую информацию, которую мы можем при необходимости напечатать. Если бы мы не решали данную проблемы во время компиляции, то нам пришлось мы исследовать стек во время выполнения, что чревато ошибками и снижает производительность.


Если мы заменим логирующие макросы на функции, то по-прежнему можем вызывать file! и line!:


fn info(input: String) {    // Надуманный вариант info!
    Log::log(
        logger(),
        RecordBuilder::new()
            .args(input)
            .file(Some(file!()))
            .line(Some(line!()))
            .build()
    )
}

А данный код вывел бы следующее:


[2018-01-25 14:48:42.416680 +01:00] INFO [src\loggers\info.rs:7] Fired up and ready!

Имя файла и номер строки бесполезны, ибо указывают на то, где была вызвана логирующая функция. Другими словами, первый пример работал как раз потому, что мы использовали макросы, которые были заменены генерируемым кодом, помещая file! и line! напрямую в исходный код, предоставляя нам необходимую информацию (имя файла и номер строки теперь в исполняемом файле) -8-.


Почему атрибутные макросы


Rust включает в себя концепт атрибутов, который нужен для пометки кода. Например, функция тестирования выглядит так:


#[test]     // <- аттрибут
fn my_test() {
    assert!(1 > 0)
}

Запуск cargo test запустит данную функцию. Атрибутивные макросы позволяют вам создавать новые атрибуты, который подобны "родным" атрибутам, но имеют другие эффекты. На текущий момент существует важное ограничение: в компиляторе из ветки stable работают только макросы использующие атрибут derive, в то время как пользовательские атрибуты работают в ночных сборках. Рассмотрим разницу ниже.


Рассматривая преимущества, даваемые атрибутивными макросами, целесообразно сравнить код, который может манипулировать исходным кодом, с тем, который не может это делать.


Получение избыточного кода (boilerplate)


Атрибут derive используется в Rust для генерации реализации типажей. Давайте посмотрим на PartialEq.


#[derive(PartialEq, Eq)]
struct Data {
    content: u8
}

fn main() {
    let data = Data { content: 2 };
    assert!(data == Data { content: 2 })
}

Здесь мы создаем структуру, экземпляры кодой хотим проверять на равенство ( использовать ==), поэтому мы получаем реализацию PartialEq -9-. Мы могли бы реализовать PartialEq самостоятельно, но наша реализация была бы тривиальной, ибо мы хотим только проверять объекты на равенство:


impl PartialEq for Data {
    fn eq(&self, other: &Data) -> bool {
        self.content == other.content
    }
}

Данный код также генерирует нам компилятор, так что использование макроса экономит нам время, однако, что важнее, избавляет нас от необходимости поддержки проверяющего на равенство кода в актуальном состоянии. Если мы добавим поле в структуру, нам нужно изменить проверку в нашей ручной реализации PartialEq, иначе (например, если мы забудем изменить код проверки) проверка разных объектов может пройти успешно.


Избавление от бремени поддержки является большим преимуществом, который предоставляет нам атрибутный макрос. Мы написали код структуры в одном месте и автоматически получили реализацию функции проверки и гарантии времени компиляции того, что код проверки соответствует текущему определению структуры. Ярким примером сказанного является пакет serde, используемый для сериализации данных, и без макросов нам необходимо было бы использовать строки для указания serde на названия полей структур, поддерживая эти строки в актуальном состоянии относительно определения структуры -10-.


Derive с преимуществами


derive является одной из многих возможностей генерации кода атрибутивными макросами, а не только реализации типажей. На данный момент это доступно в ночных сборках, что, надеюсь, будет стабилизировано в текущем году.


Наиболее выдающимся случаем использования на текущий момент является Rocket — библиотека для написания веб-серверов. Создание REST-endpoint'ов требует добавления атрибута к функции, так что теперь функция содержит всю необходимую информацию для обработки запроса.


#[post("/user", data = "<new_user>")]
fn new_user(admin: AdminUser, new_user: Form<User>) -> T {
    //...
}

Если вы работали с веб-библиотеками в других языка (например, Flask или Spring), то данный стиль для вас, вероятно, не нов. Я не буду здесь сравнивать эти библиотеки, отмечу лишь, что вы можете писать подобный код и в Rust, пользуясь его преимуществами (высокая производительность получаемого нативного кода и т. д.) -11-.


Недостатки


Макросы не идеальны, рассмотрим их некоторые недостатки:


  • увеличенное время компиляции, так как тратится время на получение кода из макроса и компиляцию данного кода.
  • Макросы могут привести к увеличению размера машинного кода, ибо легко впасть в копипаст при их использовании, при котором маленькая строка может развернуться в большой блок кода. Раньше это было проблемой пакета clap, о котором автор написал хорошую заметку с описанием проблемы и то, как посадил код на диету.
  • отладка становится сложнее, ибо нужно отлаживать сгенерированный код. К счастью, имеются инструменты, которые могут вам помочь. Читаемость и информативность сообщений об ошибке при использовании макросов зависит не от компилятора, а от авторов макроса. Опять же, имеются необходимые инструменты (например, compiler_error! и пакеты подобные syn).
  • перегрузка DSL (немного субъективный пункт). Например, format! принимает строку, написанную на мини-языке, который является не Rust'ом, а DSL. Хотя DSL является мощным инструментом, его использование легко может ввести в затруднение, если разработчик задумает создать свой собственный встроенный язык. Если надумаете писать DSL, помните, что большие возможности подразумевают большую ответственность, и то, что вы можете сделать DSL, не подразумевает необходимости делать это.

Выводы


Макросы являются мощным инструментом, который может помочь в разработке. Надеюсь, смог внушить вам мысль, что макросы в Rust являются положительным явлением и имеют случаи, когда их применение уместно.


-1-: Не путайте с возможностью const fn.
-2-: Известны как Макросы 1.1.
-3-: Замена макроса сгенерированным кодом называется расширением макроса.
-4-: Например, printf в C, String.Format в C#, фооматирование строк в Python.
-5-: format! занимается форматированием строки, которая может использоваться макросами println! и другими.
-6-: varargs использует format!. Данная возможность (varargs) входит в противоречие с решением на запрет перегрузки функций, поэтому использование макроса очень уместно — не нужно добавлять поддержку в ядро языка.
-7-: Scala имеет хорошую реализацию интерполяции строк, которая делает проверки на стадии компиляции. Не знаю, будет ли добавлена интерполяция строк в Rust, хотя мы уже видели подобные примеры: try! развился из макроса во встроенную в язык возможность, так что подобное возможно при целесообразности.
-8-: У Rust есть проблема — паникующие методы (например, unwrap и expect) выдают бесполезные сообщения об ошибке, потому что не имеют доступа к информации о вызывающем коде.
-9-: PartialEq — типаж, используемый для проверки объектов на равенство, мы также используем Eq для корректности. Документация PartialEq объясняет, почему в Rust имеется подобное деление.
-10-: Проблема может быть решена рефлексией, которая не поддерживается в Rust, ибо противоречит дизайну языка, так как уменьшает производительность времени выполнения, ибо требует соответствующий runtime.
-11-: Sergio Benitez, автор Rocket, сделал связанное с этим хорошее выступление.

Комментарии (22)


  1. aikixd
    08.03.2018 15:03

    Макросы могут привести к увеличению размера машинного кода, ибо легко впасть в копипаст при их использовании, при котором маленькая строка может развернуться в большой блок кода.

    Мне кажется это не проблема метапрограммирования как подхода, а следствие недостатка инструментов. Попробовал макросы, понимаешь насколько это круто и начинаешь пытаться впихнуть невпихуемое. Хотя просто недостает механизма полноценной генерации кода.


  1. smer44
    09.03.2018 03:04

    улыбнуло, сама необходимость использовать макросы в супер-пупер навороченном языке высокого уровня говорит очень не в пользу этого языка.


    1. google_fan
      09.03.2018 03:31

      Не развернете вашу мысль? Мне кажется что есть задачи которые удобнее делать на этапе компиляции, а не выполнения и для таких задач раст предлагает макросы.


      1. varanio
        09.03.2018 06:50

        Вроде бы Страуструп писал однажды. что макросы — это костыли, которые пытаются сгладить проблемы в языке.


        1. Gorthauer87
          09.03.2018 09:02
          +8

          Сравните как устроены макросы в Си и Rust. В первом случае на уровне лексера используется автоподстановка, а в случае второго это полноценная модификация AST (не считая аттрибутных, там как раз в стиле си все).
          В результате чего макросы гигиенические и не несут опасности для окружающего кода.
          А для любителей более сложной кодогенерации есть возможность в build.rs прикрутить что угодно.


      1. smer44
        09.03.2018 07:26

        varanio исчерпывающе сказал про

        • генерирование избыточного/тривиального кода (boilerplate) вместо его ручного написания.
        • расширение языка перед тем, как будет добавлен новый синтаксис, закрытие пробелов в языке.

        То есть:
        Если вам в языке что-то надо писать «автоматически», повторять много раз похожий код (нарушая принцип не повторяй себя), или вы видите что часто попадается задача для которой надо «расширить» синтаксис языка, этому языку нужны костыли.
        Не костылями будут функции профайлинга, вроде file! line!, принтауты которые используются для тестов, любая функция которая нужна при разработке но отпадает при релизе, их можно переложить на нормальное окружение для теста или дебага.
        Ещё макросы часто используются для написания разных вариантов кода для разных платтформ, Раст этим страдает?
        При этом при наличии полноценного метаязыка, может даже с полной рефлексией действий компилятора фичи Раста не нужны и язык можно разделить на две компоненты —
        язык написания кода и язык управления компилятором. Заново изобретаем С++ с улучшенным механизмом макросов.


        1. smer44
          09.03.2018 07:32

          и кстати

          если разработчик задумает создать свой свой собственный встроенный язык.
          то он былдокодер


        1. nexmean
          09.03.2018 09:36
          +3

          1) Скажите, а на каком таком языке вы пишете, что у вас нет никакой необходимости в бойлерплейт коде?
          2) Чем, например, декораторы выгодно отличаются от макросов? А прочая компайл-тайм рефлексия?


        1. mayorovp
          09.03.2018 10:47

          Эдак можно любую фичу языка критиковать.


          Например, вот этот аргумент можно использовать для того чтобы доказать что подпрограммы — тоже плохо:


          Если вам в языке что-то надо писать «автоматически», повторять много раз похожий код (нарушая принцип не повторяй себя), или вы видите что часто попадается задача для которой надо «расширить» синтаксис языка, этому языку нужны костыли.


    1. ozkriff
      09.03.2018 11:13
      +3

      Меня в макросах больше смущает что они по своей сути полностью прозрачны для всего инструментария: хороший макрос может создать новый уровень абстракции над кодом для человека, но IDE и что подобное никогда не научится им пользоваться нормально. В лучшем случае будет видеть только конечный развернутый код.


      Если только специально не захардкодить знание, как для format! и других стандартных макросов делают, но это не расширяемый подход, конечно.


      1. Gorthauer87
        09.03.2018 12:01

        Ну кстати, я тут обратил внимание, что всякая фигня, которая через build.rs генерится, превращается во вполне обычные rust модули и вполне себе ide friendly.
        В теории это должно работать и с кодогенерацией для процедурных макросов.
        А вот в случае с dsl, уже так не получится.


  1. azymohliad
    09.03.2018 06:08

    Есть много причин того, что format! реализован как макрос -6-, я же хочу подчеркнуть то, что он может разделить строку на части во время компиляции, проанализировать ее и проверить, является ли обработка переданных аргументов типо-безопасной

    Во многих других языках данные ошибки проявились бы во время выполнения

    Интересно, а как Си выполняет подобные проверки во время компиляции в printf() и подобных, если там это не макросы?


    1. azymohliad
      09.03.2018 06:53
      +1

      Уже нашел в приведенной ссылке на вики:


      Some compilers, like the GNU Compiler Collection, will statically check the format strings of printf-like functions and warn about problems (when using the flags -Wall or -Wformat). GCC will also warn about user-defined printf-style functions if the non-standard "format" attribute is applied to the function.

      То есть, это чисто фича GCC.


      1. khim
        09.03.2018 09:28

        Угу. И она никак не дружит с register_printf_function

        А как с этим в rust? Можно язык format! расширять?


        1. nexmean
          09.03.2018 10:42
          +3

          В каком смысле раширять? Вообще говоря в Rust есть 2 типажа(класса типов) отвечающих за расширение format!: Display и Debug. Реализация этих трейтов для типа T как раз отвечает за отображение этого типа с помощью макроса format!. О таком расширении вы спрашиваете?


          1. khim
            09.03.2018 10:59

            В каком смысле раширять?
            Хороший вопрос. Например в C register_printf_function может добавить возмость печать число римскими числами. То есть не только распечатать свой тип (тут вы правы — типажи решают проблему), но и распечатать тип, для которого уже есть «канонический» способ печати как-то по другому. Ну и получить несколько аргументов и их перечатать совместно (хотя это уже блажь: glibc так умеет, но на практике я этого ни разу не использовал).


            1. newpavlov
              09.03.2018 11:07

              Можно писать обобщённые «типы-обёртки», которые, например, будут брать тип способный конвертироваться в число и реализовывать Display выводящий римские цифры, применение которых будет выглядеть как println!("{}", RomanNum(my_value)).


              1. khim
                09.03.2018 16:22

                Это не очень хорошо, так как не позволяет управлять этим процессом локализатору.

                Впрочем 99% потребностей то, что есть у rust покрывает, борьба за последний 1% — это уже другая история.

                Просто забавно, что после всех десятилетий эволюции самый гибкий вывод текста в стандартной библиотеке — у старого доброго C. Rust пока что чуть позади, а про C++ и говорить не стоит: даже поменять местами два аргумента — проблема.

                P.S. А, кстати, как с этим у rust? Такое как делать умеет:

                printf(_("Directory \"%s\" does not contain file \"%s\"!"), d, f);

                Чтобы при запуске в русской локали оно вывело:

                Файл "попа.txt" отсутствует в каталоге "/ай-ай-ай"!


                Python и старый добрый C так умеют, а вот C++ — увы…


                1. newpavlov
                  09.03.2018 18:39

                  К сожалению (или к счастью), Rust так тоже не умеет. Но, думаю, вполне возможно написать локализующие аналоги println!, format! и других используя процедурные макросы, которые на данный момент доступны только на Nightly. (есть ещё вариант использовать proc-macro-hack на стабильной ветке, но у него есть существенные ограничения)


                  1. khim
                    09.03.2018 18:55

                    Понятно. Может быть когда на rust начнут GUI делать — прикрутят.

                    Но тут, конечно, вступает в противоречие желание статически всё проверять с одной стороны и локализовать без перекомпиляции — с другой.

                    Как это грамотно разрулить — я сходу не представляю… В C оно сделано ка и всё в C: если грамотно использовать — то работает быстро и хорошо, а если вы где-то ошиблись… ну дык эта, это ж C, шлем и наколенники в комплекте не идут…


                    1. newpavlov
                      09.03.2018 19:19

                      >Может быть когда на rust начнут GUI делать — прикрутят.

                      Как часть стандартной библиотеки сильно вряд ли. Скорее это будет частью крейта, например, такого как fluent.

                      >Но тут, конечно, вступает в противоречие желание статически всё проверять с одной стороны и локализовать без перекомпиляции — с другой.

                      Грамотно написанный макрос будет во время компиляции проверять корректность подтянутых из файлов локализационных строк и переданных аргументов, а так же сообщать об ошибке используя compile_error! с человеко читаемой ошибкой, так что, когда у разработчиков дойдут руки до этого, мы вполне можем получить локализацию на Расте защищённую от выстрелов в ногу, при этом не просто на уровне подстановок, а с «умным» поведением ожидаемым от современных локализационных фреймворков.


          1. newpavlov
            09.03.2018 11:04

            Вообще-то не два, а целых 9. Дополняя ответ на изначальный вопрос: кастомные расширения на данный момент создавать нельзя и добавлять их в среднесрочной перспективе не планируется. Реализует ли тип необходимый для форматирования трейт строго проверяется на этапе компиляции.