ماکروها (Macros)
ما در طول این کتاب از ماکروهایی مانند println! استفاده کردهایم، اما هنوز به طور کامل بررسی نکردهایم که یک ماکرو چیست و چگونه کار میکند. اصطلاح ماکرو به مجموعهای از قابلیتها در Rust اشاره دارد: ماکروهای اعلانی (declarative) با macro_rules! و سه نوع ماکرو رویهای (procedural):
- ماکروهای سفارشی
#[derive]که کدی را که با ویژگیderiveبرای ساختارها (structs) و شمارشها (enums) اضافه میشود مشخص میکنند. - ماکروهای شبیه ویژگی (Attribute-like) که ویژگیهای سفارشی تعریف میکنند که میتوانند روی هر آیتمی استفاده شوند.
- ماکروهای شبیه تابع (Function-like) که مانند فراخوانی تابع به نظر میرسند اما روی توکنهایی که به عنوان آرگومان مشخص شدهاند عمل میکنند.
ما به نوبت درباره هر یک از اینها صحبت خواهیم کرد، اما ابتدا بیایید نگاهی بیندازیم که چرا اصلاً به ماکروها نیاز داریم وقتی قبلاً توابع را داریم.
تفاوت بین ماکروها و توابع
در اصل، ماکروها روشی برای نوشتن کدی هستند که کد دیگری را مینویسد، که به عنوان فرابرنامهنویسی (metaprogramming) شناخته میشود. در پیوست C، ما ویژگی derive را بررسی میکنیم که پیادهسازی ویژگیهای مختلف را برای شما تولید میکند. همچنین ما از ماکروهای println! و vec! در طول کتاب استفاده کردهایم. همه این ماکروها توسعه پیدا میکنند تا کدی بیشتر از کدی که به صورت دستی نوشتهاید تولید کنند.
برنامهنویسی فراداده (Metaprogramming) برای کاهش میزان کدی که باید بنویسید و نگهداری کنید مفید است، که این نیز یکی از وظایف توابع است. با این حال، ماکروها تواناییهای اضافیای دارند که توابع از آنها برخوردار نیستند.
یک امضای تابع باید تعداد و نوع پارامترهایی که تابع دارد را مشخص کند. از سوی دیگر، ماکروها میتوانند تعداد متغیری از پارامترها را بپذیرند: میتوانیم println!("hello") را با یک آرگومان یا println!("hello {}", name) را با دو آرگومان فراخوانی کنیم. همچنین، ماکروها قبل از اینکه کامپایلر معنی کد را تفسیر کند گسترش مییابند، بنابراین یک ماکرو میتواند، به عنوان مثال، یک ویژگی را روی یک نوع مشخص پیادهسازی کند. اما یک تابع نمیتواند، زیرا در زمان اجرا فراخوانی میشود و یک ویژگی باید در زمان کامپایل پیادهسازی شود.
عیب پیادهسازی یک ماکرو به جای یک تابع این است که تعریف ماکروها پیچیدهتر از تعریف توابع است زیرا شما در حال نوشتن کدی در Rust هستید که کد دیگری را در Rust مینویسد. به دلیل این واسطهگری، تعریف ماکروها به طور کلی سختتر از توابع خوانده میشود، فهمیده میشود و نگهداری میشود.
یکی دیگر از تفاوتهای مهم بین ماکروها و توابع این است که شما باید ماکروها را قبل از فراخوانی آنها در یک فایل تعریف کنید یا به دامنه بیاورید، برخلاف توابع که میتوانید آنها را در هر جایی تعریف کرده و در هر جایی فراخوانی کنید.
ماکروهای اعلانی با macro_rules! برای فرابرنامهنویسی عمومی
رایجترین شکل استفاده از ماکروها در Rust، ماکروهای اعلامی (declarative macro) هستند. این نوع ماکروها گاهی با عنوانهایی مانند “ماکروهای بر پایهی مثال”، “ماکروهای macro_rules!”، یا بهسادگی “ماکروها” شناخته میشوند. در هستهی خود، ماکروهای اعلامی به شما اجازه میدهند چیزی مشابه یک عبارت match در Rust بنویسید. همانطور که در فصل ۶ بیان شد، عبارات match ساختارهای کنترلیای هستند که یک عبارت را میگیرند، مقدار حاصل از آن را با الگوها مقایسه میکنند، و سپس کدی که با الگوی مطابق مرتبط است را اجرا میکنند. ماکروها نیز یک مقدار را با الگوهایی مقایسه میکنند که با کد خاصی مرتبطاند: در این حالت، مقدار، کد منبع Rust است که به ماکرو داده میشود؛ الگوها با ساختار این کد منبع مقایسه میشوند؛ و کدی که با هر الگو مرتبط است، در صورت تطابق، جایگزین کدی میشود که به ماکرو داده شده است. تمام این فرآیند در زمان کامپایل اتفاق میافتد.
برای تعریف یک ماکرو، از ساختار macro_rules! استفاده میکنید. بیایید بررسی کنیم چگونه از macro_rules! استفاده کنیم با نگاهی به نحوه تعریف ماکروی vec!. فصل ۸ پوشش داد که چگونه میتوانیم از ماکروی vec! برای ایجاد یک بردار جدید با مقادیر خاص استفاده کنیم. به عنوان مثال، ماکروی زیر یک بردار جدید حاوی سه عدد صحیح ایجاد میکند:
#![allow(unused)] fn main() { let v: Vec<u32> = vec![1, 2, 3]; }
ما همچنین میتوانیم از ماکروی vec! برای ساخت یک بردار شامل دو عدد صحیح یا یک بردار شامل پنج برش رشته استفاده کنیم. نمیتوانیم از یک تابع برای انجام همین کار استفاده کنیم زیرا نمیدانیم تعداد یا نوع مقادیر از پیش چیست.
فهرست 20-35 نسخهای کمی سادهشده از تعریف ماکروی vec! را نشان میدهد.
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
vec!نکته: تعریف واقعی ماکروی
vec!در کتابخانه استاندارد شامل کدی برای پیشاختصاص دادن مقدار مناسبی از حافظه بهصورت اولیه است. این کد یک بهینهسازی است که برای سادهتر شدن مثال، در اینجا گنجانده نشده است.
حاشیهنویسی #[macro_export] نشان میدهد که این ماکرو باید هر زمان که crateای که ماکرو در آن تعریف شده است به دامنه آورده شود، در دسترس قرار گیرد. بدون این حاشیهنویسی، ماکرو نمیتواند به دامنه آورده شود.
سپس تعریف ماکرو را با macro_rules! و نام ماکرویی که تعریف میکنیم بدون علامت تعجب شروع میکنیم. نام، که در اینجا vec است، با آکولادهایی دنبال میشود که بدنه تعریف ماکرو را مشخص میکنند.
ساختار بدنه vec! مشابه ساختار یک عبارت match است. در اینجا یک بازو با الگوی ( $( $x:expr ),* ) داریم، که با => و بلوک کدی که با این الگو مرتبط است دنبال میشود. اگر الگو تطابق یابد، بلوک کد مرتبط گسترش مییابد. با توجه به اینکه این تنها الگو در این ماکرو است، تنها یک روش معتبر برای تطابق وجود دارد؛ هر الگوی دیگری باعث خطا خواهد شد. ماکروهای پیچیدهتر ممکن است بیش از یک بازو داشته باشند.
سینتکس الگوهای معتبر در تعریف ماکروها با سینتکس الگوهایی که در فصل ۱۹ بررسی کردیم متفاوت است، زیرا الگوهای ماکرو در برابر ساختار کد راست مطابقت داده میشوند، نه در برابر مقادیر. بیایید قدمبهقدم بررسی کنیم که اجزای الگو در لیست ۲۰-۲۹ چه معنایی دارند؛ برای مشاهدهی کامل سینتکس الگوهای ماکرو، به مرجع رسمی Rust مراجعه کنید.
ابتدا از یک جفت پرانتز برای در بر گرفتن کل الگو استفاده میکنیم. از علامت دلار ($) برای تعریف یک متغیر در سیستم ماکرو استفاده میشود که کد راستی را که با الگو مطابقت دارد، در خود نگه میدارد. علامت دلار نشان میدهد که این یک متغیر ماکرو است و نه یک متغیر معمولی در راست. سپس یک جفت پرانتز میآید که مقادیری را که با الگو مطابقت دارند، در خود میگیرد تا در کد جایگزین مورد استفاده قرار گیرند. درون $()، عبارت $x:expr قرار دارد، که هر عبارت راست را مطابقت میدهد و به آن نام $x میدهد.
کامی که بعد از $() آمده است نشان میدهد که باید بین هر نمونه از کدی که با الگوی درون $() مطابقت دارد، یک کاراکتر ویرگول قرار داشته باشد. علامت * مشخص میکند که الگو صفر یا تعداد بیشتری از موردی را که قبل از * آمده، مطابقت میدهد.
وقتی این ماکرو را با vec![1, 2, 3]; فراخوانی میکنیم، الگوی $x سه بار با سه عبارت 1، 2 و 3 تطابق پیدا میکند.
حالا بیایید به الگویی که در بدنه کد مرتبط با این بازو وجود دارد نگاه کنیم: temp_vec.push() درون $()* برای هر بخشی که با $() در الگو تطابق دارد، صفر یا بیشتر بار بسته به اینکه الگو چند بار تطابق پیدا میکند، تولید میشود. $x با هر عبارتی که تطابق پیدا کند جایگزین میشود. وقتی این ماکرو را با vec![1, 2, 3]; فراخوانی میکنیم، کدی که جایگزین این فراخوانی ماکرو میشود به شکل زیر خواهد بود:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
ما یک ماکرو تعریف کردهایم که میتواند هر تعداد آرگومان از هر نوعی را بپذیرد و کدی برای ایجاد یک بردار که شامل عناصر مشخصشده است تولید کند.
برای یادگیری بیشتر در مورد نحوه نوشتن ماکروها، به مستندات آنلاین یا منابع دیگر مانند “The Little Book of Rust Macros” که توسط Daniel Keep آغاز و توسط Lukas Wirth ادامه داده شده است، مراجعه کنید.
ماکروهای رویهای (Procedural) برای تولید کد از ویژگیها (Attributes)
نوع دوم ماکروها، ماکروهای رویهای (procedural macros) هستند که رفتاری شبیه به توابع دارند (و در واقع نوعی رویه محسوب میشوند). ماکروهای رویهای قطعهای از کد را به عنوان ورودی دریافت میکنند، روی آن کد پردازش انجام میدهند و کدی را به عنوان خروجی تولید میکنند، در حالی که ماکروهای اعلامی (declarative) با الگوها مطابقت داده و کد را با کدی دیگر جایگزین میکنند. سه نوع از ماکروهای رویهای وجود دارد: derive سفارشی، ماکروهای شبیه-صفت (attribute-like)، و ماکروهای شبیه-تابع (function-like)، و همگی به شکلی مشابه عمل میکنند.
هنگام ایجاد ماکروهای رویهای، تعریف آنها باید در یک crate جداگانه قرار گیرد که نوع crate آن بهصورت ویژه مشخص شده باشد. این الزام به دلایل فنی پیچیدهای است که امیدواریم در آینده برطرف شوند. در لیست 20-36، نحوه تعریف یک ماکرو رویهای را نشان میدهیم که در آن some_attribute یک جایگزین برای نوع خاصی از ماکرو است.
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
تابعی که یک ماکروی رویهای را تعریف میکند، یک TokenStream را به عنوان ورودی میگیرد و یک TokenStream را به عنوان خروجی تولید میکند. نوع TokenStream توسط crate به نام proc_macro تعریف شده است که با Rust همراه است و نمایانگر یک توالی از توکنها است. این هسته ماکرو است: کد منبعی که ماکرو روی آن عمل میکند ورودی TokenStream را تشکیل میدهد و کدی که ماکرو تولید میکند خروجی TokenStream است. این تابع همچنین دارای یک ویژگی (attribute) متصل به خود است که مشخص میکند کدام نوع از ماکروی رویهای را ایجاد میکنیم. ما میتوانیم چندین نوع از ماکروهای رویهای را در یک crate داشته باشیم.
بیایید نگاهی به انواع مختلف ماکروهای رویهای بیندازیم. ابتدا با یک ماکرو derive سفارشی شروع میکنیم و سپس تفاوتهای جزئی که باعث تمایز شکلهای دیگر میشوند را توضیح خواهیم داد.
نحوه نوشتن یک ماکروی derive سفارشی
بیایید یک crate به نام hello_macro ایجاد کنیم که یک trait به نام HelloMacro را تعریف میکند با یک تابع مرتبط به نام hello_macro. بهجای آنکه کاربرانمان مجبور باشند trait مربوطه را برای هرکدام از نوعهایشان پیادهسازی کنند، ما یک ماکروی روندی فراهم خواهیم کرد تا کاربران بتوانند نوع خود را با #[derive(HelloMacro)] مشخص کنند و بهطور خودکار یک پیادهسازی پیشفرض از تابع hello_macro دریافت کنند. این پیادهسازی پیشفرض، عبارت Hello, Macro! My name is TypeName! را چاپ خواهد کرد، جایی که TypeName نام نوعی است که این trait روی آن تعریف شده است. به بیان دیگر، ما یک crate خواهیم نوشت که به برنامهنویس دیگری اجازه میدهد کدی شبیه لیست ۲۰-۳۷ را با استفاده از crate ما بنویسد.
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
این کد متن Hello, Macro! My name is Pancakes! را چاپ میکند وقتی کار ما تمام شود. اولین قدم این است که یک crate جدید از نوع کتابخانه بسازیم، به این صورت:
$ cargo new hello_macro --lib
سپس، در لیست ۲۰-۳۸، trait مربوط به HelloMacro و تابع مرتبط با آن را تعریف خواهیم کرد.
pub trait HelloMacro {
fn hello_macro();
}
derive macroما یک trait و تابع مربوط به آن داریم. در این مرحله، کاربر crate ما میتواند این trait را به صورت دستی پیادهسازی کند تا به عملکرد مورد نظر دست یابد، همانطور که در لیست ۲۰-۳۹ نشان داده شده است.
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
HelloMacro را انجام دهندبا این حال، آنها باید بلوک پیادهسازی را برای هر نوعی که میخواهند با hello_macro استفاده کنند بنویسند؛ ما میخواهیم آنها را از انجام این کار معاف کنیم.
علاوه بر این، ما هنوز نمیتوانیم برای تابع hello_macro یک پیادهسازی پیشفرض ارائه دهیم که نام نوعی که ویژگی روی آن پیادهسازی شده است را چاپ کند: Rust قابلیتهای بازتاب (reflection) ندارد، بنابراین نمیتواند نام نوع را در زمان اجرا جستجو کند. ما به یک ماکرو نیاز داریم تا کد را در زمان کامپایل تولید کند.
گام بعدی تعریف ماکروی رویهای است. در زمان نگارش این مطلب، ماکروهای رویهای باید در یک کرِیت جداگانه قرار داشته باشند. ممکن است این محدودیت در آینده برداشته شود. قرارداد ساختاردهی کرِیتها و کرِیتهای ماکرو به این صورت است: برای کرِیتی به نام foo، کرِیت ماکروی derive سفارشی با نام foo_derive شناخته میشود. بیایید یک کرِیت جدید با نام hello_macro_derive درون پروژه hello_macro خود ایجاد کنیم:
$ cargo new hello_macro_derive --lib
دو crate ما به شدت به هم مرتبط هستند، بنابراین ما crate ماکروی رویهای را درون دایرکتوری crate hello_macro ایجاد میکنیم. اگر تعریف ویژگی را در hello_macro تغییر دهیم، باید پیادهسازی ماکروی رویهای در hello_macro_derive را نیز تغییر دهیم. این دو crate باید به طور جداگانه منتشر شوند و برنامهنویسانی که از این جعبهها (crates) استفاده میکنند باید هر دو را به عنوان وابستگی اضافه کرده و آنها را به دامنه بیاورند. در عوض، میتوانستیم crate hello_macro از hello_macro_derive به عنوان یک وابستگی استفاده کند و کد ماکروی رویهای را دوباره صادر کند. با این حال، روشی که پروژه را ساختاربندی کردهایم، این امکان را فراهم میکند که برنامهنویسان از hello_macro حتی اگر عملکرد derive را نخواهند، استفاده کنند.
ما باید crate hello_macro_derive را به عنوان یک crate ماکروی رویهای اعلام کنیم. همچنین به عملکردهایی از جعبهها (crates)ی syn و quote نیاز خواهیم داشت، همانطور که به زودی خواهید دید، بنابراین باید آنها را به عنوان وابستگی اضافه کنیم. موارد زیر را به فایل Cargo.toml برای hello_macro_derive اضافه کنید:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
برای شروع تعریف ماکروی رویهای، کد موجود در لیست 20-40 را در فایل src/lib.rs کرِیت hello_macro_derive قرار دهید. توجه داشته باشید که این کد تا زمانی که یک تعریف برای تابع impl_hello_macro اضافه نکنیم، کامپایل نخواهد شد.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate.
let ast = syn::parse(input).unwrap();
// Build the trait implementation.
impl_hello_macro(&ast)
}
توجه کنید که کد را به دو تابع تقسیم کردهایم: hello_macro_derive که مسئول پردازش TokenStream است، و impl_hello_macro که مسئول تبدیل درخت نحوی است. این کار نوشتن یک ماکروی رویهای را آسانتر میکند. کد تابع بیرونی (hello_macro_derive در اینجا) تقریباً برای تمام جعبهها (crates)ی ماکروی رویهای که میبینید یا ایجاد میکنید یکسان خواهد بود. کدی که در بدنه تابع داخلی (impl_hello_macro در اینجا) مشخص میکنید بسته به هدف ماکروی رویهای شما متفاوت خواهد بود.
ما سه crate جدید معرفی کردهایم: proc_macro، [syn]، و [quote]. crate proc_macro همراه با Rust ارائه میشود، بنابراین نیازی به افزودن آن به وابستگیها در Cargo.toml نداریم. crate proc_macro API کامپایلر است که به ما اجازه میدهد کد Rust را از کد خود بخوانیم و دستکاری کنیم.
crate syn کد Rust را از یک رشته به یک ساختار دادهای تبدیل میکند که میتوانیم عملیات روی آن انجام دهیم. crate quote ساختارهای داده syn را دوباره به کد Rust تبدیل میکند. این جعبهها (crates) پردازش هر نوع کد Rust که بخواهیم مدیریت کنیم را بسیار سادهتر میکنند: نوشتن یک تجزیهکننده کامل برای کد Rust کار سادهای نیست.
تابع hello_macro_derive زمانی فراخوانی میشود که یک کاربر از کتابخانه ما ویژگی #[derive(HelloMacro)] را روی یک نوع مشخص کند. این امر به این دلیل ممکن است که ما تابع hello_macro_derive را با proc_macro_derive حاشیهنویسی کردهایم و نام HelloMacro را مشخص کردهایم، که با نام ویژگی ما مطابقت دارد؛ این روش معمولیای است که بیشتر ماکروهای رویهای دنبال میکنند.
تابع hello_macro_derive ابتدا input را از یک TokenStream به یک ساختار داده تبدیل میکند که سپس میتوانیم آن را تفسیر کرده و عملیاتهایی روی آن انجام دهیم. اینجاست که crate syn به کار میآید. تابع parse در syn یک TokenStream میگیرد و یک ساختار DeriveInput را که نمایانگر کد Rust تجزیهشده است، بازمیگرداند. لیست ۲۰-۳۳ بخشهای مرتبط از ساختار DeriveInput را نشان میدهد که هنگام تجزیه کد struct Pancakes; دریافت میکنیم:
DeriveInput {
// --snip--
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
DeriveInput که هنگام تجزیه کدی که ویژگی ماکرو در لیست 20-37 را دارد، دریافت میکنیمفیلدهای این struct نشان میدهند که کدی که در Rust تجزیه کردهایم یک ساختار واحد (unit struct) با ident (شناساگر، یعنی نام) به نام Pancakes است. فیلدهای بیشتری نیز در این struct وجود دارند که برای توصیف انواع مختلفی از کدهای Rust استفاده میشوند؛ برای اطلاعات بیشتر به مستندات syn دربارهی DeriveInput مراجعه کنید.
بهزودی تابع impl_hello_macro را تعریف خواهیم کرد؛ این تابع جایی است که کد جدید Rust را که میخواهیم به کد اضافه کنیم، تولید خواهیم کرد. اما پیش از آن، توجه داشته باشید که خروجی ماکروی derive ما نیز یک TokenStream است. این TokenStream بازگشتی به کدی که کاربران crate ما نوشتهاند اضافه میشود، بنابراین زمانی که crate خود را کامپایل میکنند، عملکرد اضافهای که ما در TokenStream تغییریافته فراهم کردهایم به کدشان افزوده خواهد شد.
ممکن است متوجه شده باشید که ما از unwrap استفاده میکنیم تا در صورتی که فراخوانی تابع syn::parse شکست بخورد، تابع hello_macro_derive به وحشت بیفتد (panic). لازم است ماکروی رویهای ما در صورت بروز خطا به وحشت بیفتد، زیرا توابع proc_macro_derive باید به جای Result یک TokenStream بازگردانند تا با API ماکروهای رویهای سازگار باشند. برای ساده کردن این مثال از unwrap استفاده کردهایم؛ در کد تولیدی، بهتر است پیامهای خطای خاصتری درباره مشکل ایجاد شده با استفاده از panic! یا expect ارائه دهید.
اکنون که کدی داریم که کد Rust حاشیهنویسیشده را از یک TokenStream به نمونهای از DeriveInput تبدیل میکند، بیایید کدی را تولید کنیم که trait مربوط به HelloMacro را برای نوع حاشیهنویسیشده پیادهسازی میکند، همانطور که در لیستینگ 20-42 نشان داده شده است.
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
HelloMacro با استفاده از کد Rust تجزیهشدهبا استفاده از ast.ident، یک نمونه از ساختار Ident دریافت میکنیم که شامل نام (identifier) نوعی است که با ماکرو حاشیهنویسی شده است. ساختار نشاندادهشده در لیستینگ 20-41 نشان میدهد که زمانی که تابع impl_hello_macro را بر روی کد موجود در لیستینگ 20-37 اجرا کنیم، فیلد ident که دریافت میکنیم، دارای مقدار "Pancakes" خواهد بود. بنابراین، متغیر name در لیستینگ 20-42 شامل یک نمونه از ساختار Ident خواهد بود که هنگام چاپ، رشته "Pancakes" را نشان میدهد؛ یعنی نام struct موجود در لیستینگ 20-37.
ماکروی quote! به ما اجازه میدهد کد Rust مورد نظر خود برای بازگرداندن را تعریف کنیم. کامپایلر به چیزی متفاوت از نتیجه مستقیم اجرای ماکروی quote! نیاز دارد، بنابراین باید آن را به یک TokenStream تبدیل کنیم. این کار را با فراخوانی متد into انجام میدهیم که این نمایش میانی را مصرف کرده و مقداری از نوع TokenStream مورد نیاز بازمیگرداند.
ماکروی quote! همچنین برخی قابلیتهای جالب الگوگذاری (templating) ارائه میدهد: میتوانیم #name را وارد کنیم و quote! آن را با مقدار موجود در متغیر name جایگزین میکند. حتی میتوانید تکرارهایی مشابه با نحوه کار ماکروهای معمولی انجام دهید. برای مقدمهای جامع به مستندات crate quote مراجعه کنید.
ما میخواهیم ماکروی رویهای ما یک پیادهسازی از ویژگی HelloMacro برای نوعی که کاربر حاشیهنویسی کرده است تولید کند، که میتوانیم با استفاده از #name به آن دسترسی پیدا کنیم. پیادهسازی ویژگی شامل یک تابع به نام hello_macro است که بدنه آن قابلیت مورد نظر ما، یعنی چاپ Hello, Macro! My name is و سپس نام نوع حاشیهنویسیشده، را ارائه میدهد.
ماکروی stringify! که در اینجا استفاده شده، یکی از ماکروهای توکار در Rust است. این ماکرو یک عبارت Rust، مانند 1 + 2 را میگیرد و در زمان کامپایل آن را به یک رشتهٔ متنی (string literal)، مانند "1 + 2" تبدیل میکند. این رفتار با ماکروهایی مانند format! یا println! متفاوت است؛ چرا که آنها ابتدا مقدار عبارت را ارزیابی میکنند و سپس نتیجه را به یک String تبدیل میکنند. از آنجا که امکان دارد ورودی #name یک عبارت باشد که باید به صورت متنی چاپ شود، از stringify! استفاده میکنیم. همچنین استفاده از stringify! باعث صرفهجویی در حافظه میشود زیرا #name را در زمان کامپایل به یک رشتهٔ متنی تبدیل میکند.
در این مرحله، اجرای دستور cargo build باید در هر دو crate یعنی hello_macro و hello_macro_derive با موفقیت کامل شود. حال بیایید این دو crate را به کدی که در لیستینگ 20-37 آمده متصل کنیم تا عملکرد ماکروی procedural را در عمل ببینیم! در دایرکتوری projects خود، یک پروژهٔ باینری جدید با دستور cargo new pancakes ایجاد کنید. سپس باید hello_macro و hello_macro_derive را به عنوان وابستگی در فایل Cargo.toml مربوط به crate پروژهٔ pancakes اضافه کنید. اگر قصد دارید نسخههای خود از hello_macro و hello_macro_derive را در crates.io منتشر کنید، آنها را بهصورت وابستگی معمولی اضافه کنید؛ در غیر این صورت، میتوانید آنها را بهصورت وابستگی مسیر (path) مانند نمونهٔ زیر مشخص کنید:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
کد موجود در لیستینگ 20-37 را در فایل src/main.rs قرار دهید و سپس دستور cargo run را اجرا کنید؛ باید خروجی زیر را مشاهده کنید:
Hello, Macro! My name is Pancakes!
پیادهسازی trait مربوط به HelloMacro توسط ماکروی procedural اضافه شده است، بدون اینکه crate مربوط به pancakes نیاز داشته باشد آن را خودش پیادهسازی کند؛ استفاده از #[derive(HelloMacro)] باعث شد پیادهسازی trait به کد اضافه شود.
اکنون، بیایید بررسی کنیم که سایر انواع ماکروهای procedural چه تفاوتی با ماکروهای سفارشی derive دارند.
ماکروهای شبیه به صفت (Attribute-Like Macros)
ماکروهای شبیه به صفت شباهت زیادی به ماکروهای سفارشی derive دارند، با این تفاوت که بهجای تولید کد برای صفت derive، به شما اجازه میدهند که صفتهای جدید ایجاد کنید. این نوع ماکروها انعطافپذیرتر نیز هستند: derive فقط برای structها و enumها کار میکند؛ در حالی که صفات میتوانند روی سایر آیتمها نیز اعمال شوند، مانند توابع. در ادامه، نمونهای از استفاده از یک ماکروی شبیه به صفت را مشاهده میکنید. فرض کنید صفتی به نام route دارید که برای علامتگذاری توابع در یک فریمورک برنامههای وب استفاده میشود:
#[route(GET, "/")]
fn index() {
این ویژگی #[route] توسط فریمورک به عنوان یک ماکروی رویهای تعریف میشود. امضای تابع تعریف ماکرو به این صورت خواهد بود:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
در اینجا، دو پارامتر از نوع TokenStream داریم. پارامتر اول برای محتوای ویژگی است: بخش GET, "/". پارامتر دوم برای بدنه آیتمی است که ویژگی به آن متصل شده است: در این مورد، fn index() {} و باقی بدنه تابع.
بهجز این تفاوت، ماکروهای شبیه به صفت دقیقاً به همان روشی کار میکنند که ماکروهای سفارشی derive کار میکنند: شما یک crate با نوع proc-macro ایجاد میکنید و تابعی را پیادهسازی میکنید که کدی را تولید میکند که میخواهید!
ماکروهای شبیه تابع (Function-Like Macros)
ماکروهای شبیه تابع، ماکروهایی هستند که شبیه به فراخوانی تابع به نظر میرسند. مشابه ماکروهای macro_rules!، این ماکروها نسبت به توابع انعطافپذیرتر هستند؛ برای مثال، میتوانند تعداد نامشخصی از آرگومانها را بپذیرند. با این حال، ماکروهای macro_rules! فقط میتوانند با استفاده از نحوی مشابه match که قبلاً در بخش [«ماکروهای اعلامی با macro_rules! برای فرا-برنامهنویسی عمومی»][decl] بحث شد، تعریف شوند. ماکروهای شبیه تابع یک پارامتر از نوع TokenStream میگیرند و تعریف آنها با استفاده از کد Rust، مانند دو نوع دیگر از ماکروهای procedural، آن TokenStream را پردازش میکند. برای مثال، یک ماکروی sql! را در نظر بگیرید که ممکن است به صورت زیر فراخوانی شود:
let sql = sql!(SELECT * FROM posts WHERE id=1);
این ماکرو عبارت SQL داخل خود را تجزیه کرده و بررسی میکند که از نظر نحوی درست باشد، که پردازش بسیار پیچیدهتری نسبت به آنچه یک ماکروی macro_rules! میتواند انجام دهد، دارد. ماکروی sql! به این صورت تعریف میشود:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
این تعریف مشابه امضای ماکروی سفارشی derive است: توکنهایی را که داخل پرانتز قرار دارند دریافت میکنیم و کدی را که میخواهیم تولید کنیم بازمیگردانیم.
خلاصه
ووف! حالا شما با برخی از ویژگیهای Rust آشنا شدهاید که احتمالاً زیاد از آنها استفاده نخواهید کرد، اما میدانید که در شرایط خاصی در دسترس هستند. ما چندین موضوع پیچیده را معرفی کردیم تا زمانی که در پیامهای خطا یا در کد دیگران با آنها روبهرو شدید، بتوانید این مفاهیم و نحو را تشخیص دهید. از این فصل بهعنوان یک مرجع استفاده کنید تا شما را بهسمت راهحلها راهنمایی کند.
در ادامه، همه چیزهایی که در طول کتاب بحث کردیم را در عمل پیادهسازی میکنیم و یک پروژه دیگر انجام خواهیم داد!