ماکروها (Macros)
ما در طول این کتاب از ماکروهایی مانند println!
استفاده کردهایم، اما هنوز به طور کامل بررسی نکردهایم که یک ماکرو چیست و چگونه کار میکند. اصطلاح ماکرو به مجموعهای از قابلیتها در Rust اشاره دارد: ماکروهای اعلانی (declarative) با macro_rules!
و سه نوع ماکرو رویهای (procedural):
- ماکروهای سفارشی
#[derive]
که کدی را که با ویژگیderive
برای ساختارها (structs) و شمارشها (enums) اضافه میشود مشخص میکنند. - ماکروهای شبیه ویژگی (Attribute-like) که ویژگیهای سفارشی تعریف میکنند که میتوانند روی هر آیتمی استفاده شوند.
- ماکروهای شبیه تابع (Function-like) که مانند فراخوانی تابع به نظر میرسند اما روی توکنهایی که به عنوان آرگومان مشخص شدهاند عمل میکنند.
ما به نوبت درباره هر یک از اینها صحبت خواهیم کرد، اما ابتدا بیایید نگاهی بیندازیم که چرا اصلاً به ماکروها نیاز داریم وقتی قبلاً توابع را داریم.
تفاوت بین ماکروها و توابع
در اصل، ماکروها روشی برای نوشتن کدی هستند که کد دیگری را مینویسد، که به عنوان فرابرنامهنویسی (metaprogramming) شناخته میشود. در پیوست C، ما ویژگی derive
را بررسی میکنیم که پیادهسازی ویژگیهای مختلف را برای شما تولید میکند. همچنین ما از ماکروهای println!
و vec!
در طول کتاب استفاده کردهایم. همه این ماکروها توسعه پیدا میکنند تا کدی بیشتر از کدی که به صورت دستی نوشتهاید تولید کنند.
فرابرنامهنویسی برای کاهش مقدار کدی که باید بنویسید و نگهداری کنید مفید است، که یکی از نقشهای توابع نیز هست. با این حال، ماکروها تواناییهای اضافی دارند که توابع ندارند.
یک امضای تابع باید تعداد و نوع پارامترهایی که تابع دارد را مشخص کند. از سوی دیگر، ماکروها میتوانند تعداد متغیری از پارامترها را بپذیرند: میتوانیم println!("hello")
را با یک آرگومان یا println!("hello {}", name)
را با دو آرگومان فراخوانی کنیم. همچنین، ماکروها قبل از اینکه کامپایلر معنی کد را تفسیر کند گسترش مییابند، بنابراین یک ماکرو میتواند، به عنوان مثال، یک ویژگی را روی یک نوع مشخص پیادهسازی کند. اما یک تابع نمیتواند، زیرا در زمان اجرا فراخوانی میشود و یک ویژگی باید در زمان کامپایل پیادهسازی شود.
عیب پیادهسازی یک ماکرو به جای یک تابع این است که تعریف ماکروها پیچیدهتر از تعریف توابع است زیرا شما در حال نوشتن کدی در Rust هستید که کد دیگری را در Rust مینویسد. به دلیل این واسطهگری، تعریف ماکروها به طور کلی سختتر از توابع خوانده میشود، فهمیده میشود و نگهداری میشود.
یکی دیگر از تفاوتهای مهم بین ماکروها و توابع این است که شما باید ماکروها را قبل از فراخوانی آنها در یک فایل تعریف کنید یا به دامنه بیاورید، برخلاف توابع که میتوانید آنها را در هر جایی تعریف کرده و در هر جایی فراخوانی کنید.
ماکروهای اعلانی با macro_rules!
برای فرابرنامهنویسی عمومی
پرکاربردترین شکل ماکروها در Rust، ماکروهای اعلانی هستند. به این ماکروها گاهی اوقات “ماکروهای با مثال”، “ماکروهای 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!
برای ساخت یک بردار شامل دو عدد صحیح یا یک بردار شامل پنج برش رشته استفاده کنیم. نمیتوانیم از یک تابع برای انجام همین کار استفاده کنیم زیرا نمیدانیم تعداد یا نوع مقادیر از پیش چیست.
لیست ۲۰-۲۹ یک تعریف کمی سادهشده از ماکروی 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 و نه مقادیر تطابق داده میشوند. بیایید مرور کنیم که قسمتهای الگوی لیست ۲۰-۲۹ چه معنایی دارند؛ برای مشاهده کامل سینتکس الگوهای ماکرو، به مستندات مرجع Rust مراجعه کنید.
ابتدا، مجموعهای از پرانتزها را برای شامل کردن کل الگو استفاده میکنیم. از علامت دلار ($
) برای اعلام یک متغیر در سیستم ماکرو استفاده میکنیم که کد Rust تطابقیافته با الگو را در خود جای میدهد. علامت دلار مشخص میکند که این یک متغیر ماکرو است، نه یک متغیر معمولی Rust. سپس مجموعهای از پرانتزها میآیند که مقادیری را که با الگو درون پرانتزها تطابق دارند، برای استفاده در کد جایگزین ثبت میکنند. درون $()
، $x:expr
قرار دارد که با هر عبارت Rust تطابق دارد و به آن عبارت نام $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 macro) است که بیشتر شبیه به یک تابع عمل میکند (و نوعی رویه است). ماکروهای رویهای کدی را به عنوان ورودی میپذیرند، روی آن کد عمل میکنند و به جای تطابق با الگوها و جایگزین کردن کد با کدی دیگر مانند ماکروهای اعلانی، کدی را به عنوان خروجی تولید میکنند. سه نوع ماکروی رویهای شامل derive
سفارشی، شبیه ویژگی (attribute-like) و شبیه تابع (function-like) هستند و همه به شیوهای مشابه عمل میکنند.
هنگام ایجاد ماکروهای رویهای، تعاریف باید در یک crate مجزا با نوع crate خاص خود قرار گیرند. این به دلایل فنی پیچیدهای است که امیدواریم در آینده برطرف شود. در لیست ۲۰-۳۰، نحوه تعریف یک ماکروی رویهای را نشان میدهیم که در آن 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
ایجاد کنیم که یک ویژگی به نام HelloMacro
را با یک تابع وابسته به نام hello_macro
تعریف کند. به جای اینکه کاربران ما ویژگی HelloMacro
را برای هر یک از انواع خود پیادهسازی کنند، ما یک ماکروی رویهای فراهم میکنیم تا کاربران بتوانند نوع خود را با #[derive(HelloMacro)]
حاشیهنویسی کنند و یک پیادهسازی پیشفرض برای تابع hello_macro
دریافت کنند. پیادهسازی پیشفرض متن Hello, Macro! My name is TypeName!
را چاپ میکند که در آن TypeName
نام نوعی است که این ویژگی روی آن تعریف شده است. به عبارت دیگر، ما 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
سپس، ویژگی HelloMacro
و تابع وابسته به آن را تعریف میکنیم:
pub trait HelloMacro {
fn hello_macro();
}
ما اکنون یک ویژگی و تابع وابسته به آن داریم. در این مرحله، کاربر crate ما میتواند ویژگی را پیادهسازی کند تا به عملکرد مورد نظر برسد، به این صورت:
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();
}
با این حال، آنها باید بلوک پیادهسازی را برای هر نوعی که میخواهند با hello_macro
استفاده کنند بنویسند؛ ما میخواهیم آنها را از انجام این کار بینیاز کنیم.
علاوه بر این، ما هنوز نمیتوانیم برای تابع hello_macro
یک پیادهسازی پیشفرض ارائه دهیم که نام نوعی که ویژگی روی آن پیادهسازی شده است را چاپ کند: Rust قابلیتهای بازتاب (reflection) ندارد، بنابراین نمیتواند نام نوع را در زمان اجرا جستجو کند. ما به یک ماکرو نیاز داریم تا کد را در زمان کامپایل تولید کند.
مرحله بعدی این است که ماکروی رویهای را تعریف کنیم. در زمان نگارش این متن، ماکروهای رویهای باید در یک crate جداگانه قرار گیرند. این محدودیت ممکن است در آینده برداشته شود. روش استاندارد برای ساختاردهی جعبهها (crates) و جعبهها (crates)ی ماکرو به این صورت است: برای یک crate به نام foo
، یک ماکروی رویهای سفارشی derive
به نام foo_derive
نامگذاری میشود. بیایید یک crate جدید به نام 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"
برای شروع تعریف ماکروی رویهای، کد لیست ۲۰-۳۲ را در فایل src/lib.rs برای crate 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
که هنگام تجزیه کدی که ویژگی ماکرو در لیست ۲۰-۳۱ را دارد، دریافت میکنیمفیلدهای این ساختار نشان میدهند که کد Rust که تجزیه کردهایم یک ساختار واحد (unit struct) با شناسه (ident
) به نام Pancakes
است. این ساختار فیلدهای بیشتری برای توصیف انواع مختلف کد Rust دارد؛ برای اطلاعات بیشتر به مستندات syn
برای DeriveInput
مراجعه کنید.
به زودی تابع impl_hello_macro
را تعریف خواهیم کرد، جایی که کد جدیدی که میخواهیم اضافه کنیم را تولید خواهیم کرد. اما قبل از این کار، توجه داشته باشید که خروجی ماکروی 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
تبدیل میکند، بیایید کدی که ویژگی HelloMacro
را روی نوع حاشیهنویسیشده پیادهسازی میکند، تولید کنیم، همانطور که در لیست ۲۰-۳۴ نشان داده شده است.
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 gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
HelloMacro
با استفاده از کد Rust تجزیهشدهما با استفاده از ast.ident
یک نمونه از ساختار Ident
که شامل نام (شناسه) نوع حاشیهنویسیشده است، دریافت میکنیم. ساختار موجود در لیست ۲۰-۳۳ نشان میدهد که وقتی تابع impl_hello_macro
را روی کد لیست ۲۰-۳۱ اجرا میکنیم، فیلد ident
با مقدار "Pancakes"
پر خواهد شد. بنابراین، متغیر name
در لیست ۲۰-۳۴ یک نمونه از ساختار Ident
را شامل میشود که وقتی چاپ میشود، رشته "Pancakes"
، یعنی نام ساختار در لیست ۲۰-۳۱، خواهد بود.
ماکروی 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
، را گرفته و در زمان کامپایل آن را به یک رشته ثابت، مانند "1 + 2"
، تبدیل میکند. این با ماکروهایی مانند format!
یا println!
که عبارت را ارزیابی کرده و سپس نتیجه را به یک String
تبدیل میکنند، متفاوت است. احتمال دارد ورودی #name
یک عبارتی برای چاپ باشد، بنابراین از stringify!
استفاده میکنیم. استفاده از stringify!
همچنین با تبدیل #name
به یک رشته ثابت در زمان کامپایل، یک تخصیص را صرفهجویی میکند.
در این مرحله، دستور cargo build
باید با موفقیت در هر دو crate hello_macro
و hello_macro_derive
اجرا شود. بیایید این جعبهها (crates) را به کد موجود در لیست ۲۰-۳۱ متصل کنیم تا ماکروی رویهای را در عمل ببینیم! یک پروژه باینری جدید در دایرکتوری projects خود با استفاده از دستور cargo new pancakes
ایجاد کنید. باید hello_macro
و hello_macro_derive
را به عنوان وابستگی در فایل Cargo.toml crate pancakes
اضافه کنیم. اگر نسخههای خود از hello_macro
و hello_macro_derive
را در crates.io منتشر میکنید، آنها به عنوان وابستگیهای معمولی خواهند بود؛ در غیر این صورت، میتوانید آنها را به صورت وابستگیهای path
به شکل زیر مشخص کنید:
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
کد موجود در لیست ۲۰-۳۱ را در فایل src/main.rs قرار دهید و دستور cargo run
را اجرا کنید: باید عبارت Hello, Macro! My name is Pancakes!
را چاپ کند. پیادهسازی ویژگی HelloMacro
که از ماکروی رویهای آمده بود، بدون نیاز به پیادهسازی آن توسط crate pancakes
اضافه شد؛ ویژگی #[derive(HelloMacro)]
پیادهسازی ویژگی را اضافه کرد.
در ادامه، بیایید بررسی کنیم که انواع دیگر ماکروهای رویهای چه تفاوتی با ماکروهای سفارشی derive
دارند.
ماکروهای شبیه ویژگی (Attribute-like macros)
ماکروهای شبیه ویژگی مشابه ماکروهای سفارشی derive
هستند، اما به جای تولید کد برای ویژگی derive
، به شما امکان میدهند ویژگیهای جدید ایجاد کنید. آنها همچنین انعطافپذیرتر هستند: derive
فقط برای ساختارها (structs) و شمارشها (enums) کار میکند؛ اما ویژگیها میتوانند به آیتمهای دیگر نیز اعمال شوند، مانند توابع. در اینجا یک مثال از استفاده از یک ماکروی شبیه ویژگی آورده شده است: فرض کنید یک ویژگی به نام 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
ایجاد میکنید و تابعی را پیادهسازی میکنید که کدی را که میخواهید تولید میکند!
ماکروهای شبیه تابع
ماکروهای شبیه تابع، ماکروهایی را تعریف میکنند که شبیه به فراخوانی توابع به نظر میرسند. مشابه با ماکروهای macro_rules!
، این ماکروها انعطافپذیرتر از توابع هستند؛ برای مثال، میتوانند تعداد نامشخصی از آرگومانها را بپذیرند. با این حال، ماکروهای macro_rules!
فقط میتوانند با استفاده از سینتکس شبیه به match
که در بخش “ماکروهای اعلانی با macro_rules!
برای فرابرنامهنویسی عمومی” بحث شد تعریف شوند. ماکروهای شبیه تابع یک پارامتر TokenStream
میگیرند و تعریف آنها این TokenStream
را با استفاده از کد Rust مانند دو نوع دیگر ماکروهای رویهای دستکاری میکند. مثالی از یک ماکروی شبیه تابع، ماکروی sql!
است که ممکن است به این صورت فراخوانی شود:
let sql = sql!(SELECT * FROM posts WHERE id=1);
این ماکرو عبارت SQL داخل خود را تجزیه کرده و بررسی میکند که از نظر نحوی درست باشد، که پردازش بسیار پیچیدهتری نسبت به آنچه یک ماکروی macro_rules!
میتواند انجام دهد، دارد. ماکروی sql!
به این صورت تعریف میشود:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
این تعریف مشابه امضای ماکروی سفارشی derive
است: ما توکنهایی که داخل پرانتزها قرار دارند را دریافت میکنیم و کدی را که میخواهیم تولید کنیم بازمیگردانیم.
خلاصه
وای! اکنون شما برخی از ویژگیهای Rust را در ابزار خود دارید که احتمالاً به ندرت از آنها استفاده میکنید، اما میدانید که در شرایط خاصی در دسترس هستند. ما موضوعات پیچیده متعددی را معرفی کردیم تا زمانی که با پیشنهادات پیامهای خطا یا کدهای دیگران مواجه شدید، بتوانید این مفاهیم و سینتکس را بشناسید. از این فصل به عنوان مرجعی برای یافتن راهحلها استفاده کنید.
در ادامه، همه چیزهایی که در طول کتاب بحث کردیم را در عمل پیادهسازی میکنیم و یک پروژه دیگر انجام خواهیم داد!