ماکروها (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 آشنا شدهاید که احتمالاً زیاد از آنها استفاده نخواهید کرد، اما میدانید که در شرایط خاصی در دسترس هستند. ما چندین موضوع پیچیده را معرفی کردیم تا زمانی که در پیامهای خطا یا در کد دیگران با آنها روبهرو شدید، بتوانید این مفاهیم و نحو را تشخیص دهید. از این فصل بهعنوان یک مرجع استفاده کنید تا شما را بهسمت راهحلها راهنمایی کند.
در ادامه، همه چیزهایی که در طول کتاب بحث کردیم را در عمل پیادهسازی میکنیم و یک پروژه دیگر انجام خواهیم داد!