Как собрать информацию из нескольких расширений процедурных макросов и вывести результат все сразу в Rust?
Я пытаюсь использовать процедурные макросы для сбора информации о типе, включая не только его определение, но и его реализации. Я могу применять разные атрибуты к определению и к impl, но затем мне нужно вывести некоторый код на основе собранной информации из обоих мест.
Пример структуры:
#[derive(MyDerive)]
pub struct MyType { }
#[myattr]
impl MyType {
// ...
}
mycrate::output!()
Атрибуты будут собирать информацию, а финальный макрос развернет соответствующий код. Это обходной путь, чтобы не заставлять пользователей помещать эти типы в встроенный модуль.
Поскольку процедурные макросы-атрибуты нестабильны как внутренние атрибуты, приведенная выше структура кажется хорошим обходным путем.
Как мне обмениваться данными между разными вызовами процедурных макросов?
Я пытался записывать в статическую переменную в одном макросе и читать из нее в другом, и тест прошел успешно:
static DATA: RwLock<bool> = RwLock::new(false);
#[proc_macro_attribute]
pub fn write_macro(_args: TokenStream, input: TokenStream) -> TokenStream {
*DATA.write().unwrap() = true;
input
}
#[proc_macro_attribute]
pub fn read_macro(_args: TokenStream, input: TokenStream) -> TokenStream {
if *DATA.read().unwrap() {
input
} else {
panic!("LoL")
}
}
Если я разворачиваю read_macro после write_macro, паника не происходит, поэтому, кажется, это работает. Но гарантированно ли это?
Если нет, есть ли какой-либо способ достичь вышеописанного шаблона сбора информации из нескольких расширений процедурных макросов и вывода результата все сразу?
В настоящее время, хотя использование статических переменных для обмена данными между различными вызовами макросов процедур может работать в тестах, этот подход не гарантирует надежную работу из-за инкрементальной компиляции, проблем с потокобезопасностью и отсутствия официальной поддержки обмена состоянием между макросами процедур. Компилятор Rust не гарантирует порядок выполнения, размещение по потокам или даже выполнение отдельных вызовов макросов, что делает статические переменные ненадежным подходом для межмакросной коммуникации.
Содержание
- Как работают статические переменные в макросах процедур
- Проблемы инкрементальной компиляции
- Потокобезопасность и порядок выполнения
- Альтернативные подходы
- Лучшие практики и рекомендации
- Работа с различными типами макросов
Как работают статические переменные в макросах процедур
Статические переменные технически могут использоваться в макросах процедур, но они имеют значительные ограничения и риски. Приведенный вами пример демонстрирует, что статические переменные могут работать в простых тестовых случаях, но это не делает их надежным решением для производственного кода.
static DATA: RwLock<bool> = RwLock::new(false);
#[proc_macro_attribute]
pub fn write_macro(_args: TokenStream, input: TokenStream) -> TokenStream {
*DATA.write().unwrap() = true;
input
}
#[proc_macro_attribute]
pub fn read_macro(_args: TokenStream, input: TokenStream) -> TokenStream {
if *DATA.read().unwrap() {
input
} else {
panic!("LoL")
}
}
Проблема в том, что ничто не мешает вам использовать статические переменные в макросах процедур [источник], но такое поведение не гарантировано. Ваш тест работает потому, что:
- Оба макроса вызываются в рамках одной сессии компиляции
- Они выполняются в одном пространстве процессов
- Статическая переменная сохраняется между вызовами
Однако этот подход нарушает несколько ключевых принципов проектирования макросов процедур и relies на детали реализации, а не на стабильные гарантии.
Проблемы инкрементальной компиляции
Наибольший риск при использовании статических переменных в макросах процедур связан с инкрементальной компиляцией. Компилятор Rust может кэшировать расширения макросов и повторно использовать их в последующих компиляциях без повторного выполнения кода макроса.
Согласно результатам исследований:
- не гарантируется, что все вызовы вашего макроса процедур будут фактически выполнены из-за инкрементальной компиляции [источник]
- Компилятор Rust не обещает не кэшировать вызовы макросов и выполнять каждое расширение макроса [источник]
- Порядок выполнения макросов не гарантирован, и макросы могут не выполняться вовсе в конкретной компиляции, если можно использовать кэшированное выполнение из предыдущей компиляции [источник]
Это означает, что в реальном проекте с включенной инкрементальной компиляцией (которая включена по умолчанию в Cargo), ваш подход со статическими переменными может непредсказуемо выходить из строя в зависимости от:
- Какие файлы изменились с последней компиляции
- Порядка обработки файлов
- Решит ли компилятор повторно использовать кэшированные расширения макросов
Потокобезопасность и порядок выполнения
Другая критическая проблема - потокобезопасность. Макросы процедур могут выполняться в разных потоках, что может привести к состоянию гонки или другим проблемам синхронизации со статическими переменными.
Ключевые выводы из исследований:
- В идеале мы бы использовали CrossThread, который создает поток для каждого вызова, чтобы предотвратить (и не рекомендовать) использование макросами процедур TLS для состояния между вызовами [источник]
- Нет гарантии, в каком порядке будут выполняться макросы [источник]
- Чтобы сделать потокобезопасным (и избежать использования unsafe), вы можете использовать AtomicUsize [источник]
Даже если вы используете потокобезопасные примитивы, такие как RwLock или `AtomicUsize, фундаментальная проблема остается: компилятор Rust не гарантирует, что все вызовы макросов будут выполняться в рамках одной сессии компиляции.
RFC для макросов процедур упоминает, что “MacroContext - это объект, помещаемый в потоковое локальное хранилище при расширении макроса” [источник], что указывает на то, что макросы действительно могут выполняться в разных потоках или контекстах.
Альтернативные подходы
Хотя статические переменные ненадежны, существуют несколько альтернативных подходов для достижения цели сбора информации из нескольких расширений макросов процедур:
1. Обмен состоянием на основе файлов
Один из надежных подходов - использование временных файлов для обмена состоянием между вызовами макросов:
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
static STATE_FILE: Mutex<Option<PathBuf>> = Mutex::new(None);
#[proc_macro_attribute]
pub fn collect_info(_args: TokenStream, input: TokenStream) -> TokenStream {
let state_file = STATE_FILE.lock().unwrap();
if let Some(path) = &*state_file {
// Чтение существующего состояния из файла
let existing = fs::read_to_string(path).unwrap_or_default();
// Добавление новой информации
let updated = format!("{}\n{}", existing, input.to_string());
fs::write(path, updated).unwrap();
}
input
}
Этот подход работает, потому что ввод-вывод в файл гарантированно происходит и не затрагивается кэшированием макросов.
2. Переменные окружения
Вы также можете использовать переменные окружения для передачи информации:
#[proc_macro_attribute]
pub fn collect_info(_args: TokenStream, input: TokenStream) -> TokenStream {
let existing = std::env::var("MY_MACRO_STATE").unwrap_or_default();
let updated = format!("{}\n{}", existing, input.to_string());
std::env::set_var("MY_MACRO_STATE", updated);
input
}
3. Специализированные крейты для координации макросов
Существуют крейты, специально разработанные для обработки координации между вызовами макросов:
-
Macro Magic: “Помимо прочего, шаблоны, представленные в Macro Magic, могут использоваться для реализации безопасной и эффективной координации и коммуникации между вызовами макросов в одном файле, а даже в разных файлах и разных крейтах” [источник]
-
Macro State: “предназначен для построения и использования информации о состоянии в нескольких вызовах макроса” [источник]
Эти крейты предоставляют более надежные и надежные решения для межмакросной коммуникации.
4. Подход на основе модулей
Вместо того чтобы пытаться обмениваться данными между макросами, вы можете перестроить подход для работы внутри модуля:
#[derive(MyDerive)]
pub struct MyType { }
#[collect_impls]
impl MyType {
// ...
}
// Вместо mycrate::output!(), вывод генерируется макросом derive
Лучшие практики и рекомендации
На основе результатов исследований и лучших практик для макросов процедур, вот несколько рекомендаций:
1. Избегайте статических переменных для межмакросной коммуникации
Вы не должны использовать статические переменные в вашем крейте макросов для обмена информацией. Это будет работать, прямо сейчас, но компилятор Rust не обещает не кэшировать вызовы макросов [источник].
2. Используйте детерминированные подходы
Используйте подходы, которые гарантированно работают независимо от деталей реализации компилятора:
- Ввод-вывод в файлы
- Переменные окружения
- Специализированные крейты для координации
- Организация на основе модулей
3. Учитывайте экосистему макросов
Если вы создаете связанные макросы, подумайте о том, как они будут работать вместе:
- Определите четкие интерфейсы между макросами
- Документируйте ожидаемые шаблоны использования
- Предоставьте примеры и руководства по миграции
4. Тестируйте с инкрементальной компиляцией
При тестировании макросов специально тестируйте сценарии, использующие инкрементальную компиляцию:
- Вносите небольшие изменения в файлы и проверяйте поведение макросов
- Тестируйте разные порядки компиляции
- Убедитесь, что кэшированные расширения макросов не нарушают вашу функциональность
Работа с различными типами макросов
Ваш случай использования включает разные типы макросов (макросы вывода и атрибуты), что добавляет сложности к проблеме коммуникации. Вот несколько стратегий:
1. Разделение крейтов макросов
Рассмотрите возможность разделения ваших макросов на отдельные крейты для снижения сложности:
// mytype-derive
#[derive(MyDerive)]
pub struct MyType { }
// mytype-impls
#[collect_impls]
impl MyType { }
// mytype-output
mytype_output::generate!()
2. Единая система макросов
Создайте единую систему макросов, которая обрабатывает как определение, так и реализацию:
#[my_macro]
pub struct MyType { }
#[my_macro_impl]
impl MyType { }
// Это обрабатывается одной и той же системой макросов внутренне
3. Шаблон построителя
Используйте шаблон построителя, где макросы работают вместе в предсказуемой последовательности:
#[my_macro::define]
pub struct MyType { }
#[my_macro::impl]
impl MyType { }
#[my_macro::generate]
fn main() {
// Сгенерированный код здесь
}
Источники
- r/rust на Reddit: Какой хороший шаблон для обмена состоянием между макросами процедур?
- Возможно ли хранить состояние внутри макросов процедур Rust? - Stack Overflow
- rust - Порядок выполнения макроса процедур - Stack Overflow
- r/rust на Reddit: Статическая модификация из контекста макроса процедур. Возможно ли это?
- Выполнение вызовов макроса процедур в отдельных потоках. · Issue #56058 · rust-lang/rust
- mm_example_crate — Помощник макроса процедур Rust // Lib.rs
- rust - Существует ли последовательный контекст компиляции внутри функции proc_macro_attribute? - Stack Overflow
- Структурирование, тестирование и отладка крейтов макросов процедур - Ferrous Systems
- 1566-proc-macros - Книга RFC Rust
- Макросы процедур - Справочник Rust
Заключение
Хотя использование статических переменных для обмена данными между вызовами макросов процедур может работать в простых тестах, этот подход фундаментально ненадежен и должен избегаться в производственном коде. Ключевые выводы:
- Статические переменные не гарантированно работают из-за инкрементальной компиляции, проблем с потокобезопасностью и отсутствия официальной поддержки
- Инкрементальная компиляция может вызывать кэширование вызовов макросов и их повторное использование без повторного выполнения, что нарушает коммуникацию через статические переменные
- Проблемы потокобезопасности существуют, так как макросы могут выполняться в разных потоках или контекстах
- Альтернативные подходы, такие как ввод-вывод в файлы, переменные окружения или специализированные крейты (Macro Magic, Macro State), предоставляют более надежные решения
- Перестройте дизайн ваших макросов, чтобы избежать необходимости в межмакросной коммуникации, когда это возможно
Для вашего конкретного случая использования сбора информации из нескольких расширений макросов и вывода результатов все сразу, рассмотрите использование обмена состоянием на основе файлов, специализированных крейтов для координации или перестройку ваших макросов для работы в более детерминированном шаблоне. Эти подходы обеспечат надежность и поддерживаемость, необходимые для производственного кода.