Procedural macro trong Rust: tự viết #[derive]
Mỗi lần bạn viết #[derive(Debug)] hay #[derive(Serialize)], một chương trình nhỏ chạy bên trong trình biên dịch. Nó nhìn vào struct của bạn và viết một khối impl giúp bạn. Chương trình nhỏ đó là một procedural macro (macro thủ tục), và bạn có thể tự viết một cái.
Hãy hình dung một nhân viên văn phòng tận tình. Bạn đưa một tờ đơn (struct của bạn), người đó đọc từng trường, rồi trả lại cho bạn đống giấy tờ nhàm chán đã điền sẵn. Ở trang Macro, bạn đã gặp macro_rules!, loại macro hoạt động bằng cách so khớp mẫu. Procedural macro thì khác: chúng là những hàm Rust bình thường, nhận code vào và trả code ra. Trên trang này, bạn sẽ xây một procedural macro thật, từng bước một, và xem kết quả thật.
Ba loại procedural macro
Phần tiêu đề “Ba loại procedural macro”Có ba loại. Bên trong chúng hoạt động giống nhau, nhưng với người dùng thì trông khác nhau:
| Loại | Người dùng viết gì | Ví dụ nổi tiếng |
|---|---|---|
| Macro derive | #[derive(Serialize)] trên một struct hoặc enum |
Serialize của serde, Error của thiserror, Parser của clap |
| Macro attribute | #[route(GET, "/")] trên một hàm hoặc item |
#[tokio::main], #[get("/")] của actix-web |
| Macro dạng hàm | sql!(SELECT * FROM users) |
query! của sqlx, html! của yew |
- Macro derive chỉ thêm code bên cạnh kiểu của bạn. Struct của bạn giữ nguyên như lúc bạn viết.
- Macro attribute thay thế item mà nó gắn vào.
#[tokio::main]lấyasync fn maincủa bạn và biến nó thành mộtfn mainbình thường khởi động runtime tokio. - Macro dạng hàm trông giống một lời gọi
macro_rules!, nhưng một hàm Rust quyết định đầu vào có nghĩa là gì. Nhờ vậysqlx::query!có thể kiểm tra câu SQL của bạn ngay lúc biên dịch.
Bạn vốn đã dùng macro derive từ các crate khác. Đây là hai cái đang làm việc thật. Serialize viết code JSON (xem JSON với Serde), còn Error viết code Display từ đoạn chữ trong #[error(...)] (xem anyhow và thiserror):
use serde::Serialize;use thiserror::Error;
#[derive(Serialize)]struct Order { id: u32, item: String, quantity: u8,}
#[derive(Debug, Error)]enum ShopError { #[error("mặt hàng {0} đã hết")] OutOfStock(String),}
fn main() { let order = Order { id: 42, item: "Teapot".to_string(), quantity: 2 }; println!("{}", serde_json::to_string(&order).unwrap());
let err = ShopError::OutOfStock("Teapot".to_string()); println!("{err}");}Kết quả:
{"id":42,"item":"Teapot","quantity":2}mặt hàng Teapot đã hếtBạn chưa hề viết một impl Display hay dòng code JSON nào. Các derive đã viết chúng trong lúc chương trình được biên dịch.
Cách chúng hoạt động: token vào, token ra
Phần tiêu đề “Cách chúng hoạt động: token vào, token ra”Trước tiên, trình biên dịch cắt code nguồn của bạn thành các token: tên, dấu câu, literal, dấu ngoặc. Một procedural macro là một hàm nhận các token đó dưới dạng TokenStream và trả về một TokenStream mới. Sau đó trình biên dịch biên dịch bất cứ thứ gì bạn trả về, như thể bạn tự gõ nó.
Đây là ba dạng hàm. Bạn đánh dấu mỗi hàm bằng một attribute để trình biên dịch biết nó thuộc loại nào:
use proc_macro::TokenStream;
// #[derive(HelloMacro)]: nhận struct hoặc enum, trả về code để THÊM vào.#[proc_macro_derive(HelloMacro)]pub fn hello_macro_derive(input: TokenStream) -> TokenStream { /* ... */ }
// #[route(GET, "/")]: nhận token của chính attribute và item, trả về bản THAY THẾ.#[proc_macro_attribute]pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }
// sql!(...): nhận mọi thứ trong dấu ngoặc, trả về bản thay thế.#[proc_macro]pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }Một TokenStream thô chỉ là một danh sách token, rất khó làm việc. Vì vậy gần như mọi procedural macro đều dùng ba crate hỗ trợ:
- syn phân tích token thành một cây cú pháp. Thay vì token, bạn nhận được một
DeriveInputcó tên kiểu, các generic và các trường của nó. - quote biến code trông giống Rust trở lại thành token. Bên trong
quote! { ... }bạn viết Rust bình thường, và#namedán một biến vào. - proc-macro2 là lớp bọc quanh các kiểu
proc_macrocủa trình biên dịch, được syn và quote dùng. Nó cũng chạy được bên ngoài trình biên dịch, nên việc viết hàm phụ trợ và kiểm thử dễ hơn.
Một ý quan trọng: macro của bạn chạy lúc biên dịch. Cargo biên dịch crate macro trước, cho chính máy tính của bạn, và trình biên dịch chạy nó trong lúc build crate dùng nó. Khi chương trình cuối cùng chạy, macro đã biến mất từ lâu. Chỉ còn lại code mà nó đã viết.
Chuẩn bị dự án
Phần tiêu đề “Chuẩn bị dự án”Procedural macro phải nằm trong một crate riêng với một kiểu crate đặc biệt. Crate đó chỉ được xuất (export) macro, không xuất hàm hay trait bình thường. Vì vậy cách bố trí thường gặp là hai crate cạnh nhau, giống serde và serde_derive: một cho trait, một cho derive. Ta sẽ thêm crate thứ ba, một chương trình nhỏ tên shop dùng hai crate kia. (Cần ôn lại về crate và workspace? Xem Cargo và crate.)
mkdir procmacro-demo && cd procmacro-democargo new --lib hello_macrocargo new --lib hello_macro_derivecargo new shopĐây là cây thư mục hoàn chỉnh:
procmacro-demo/├── Cargo.toml (the workspace)├── hello_macro/│ ├── Cargo.toml│ └── src/lib.rs (the traits, and a re-export of the derives)├── hello_macro_derive/│ ├── Cargo.toml (proc-macro = true)│ └── src/lib.rs (the macros themselves)└── shop/ ├── Cargo.toml └── src/main.rs (a program that uses #[derive(...)])Ở đây: Cargo.toml gốc là workspace; hello_macro chứa các trait và xuất lại (re-export) các derive; hello_macro_derive chứa chính các macro; shop là chương trình dùng #[derive(...)].
Cargo.toml ở cấp trên cùng gộp ba crate vào một workspace:
[workspace]members = ["hello_macro", "hello_macro_derive", "shop"]resolver = "2"Crate macro là crate duy nhất có điểm đặc biệt: proc-macro = true. Nó cũng phụ thuộc vào syn, quote và proc-macro2:
[package]name = "hello_macro_derive"version = "0.1.0"edition = "2024"
[lib]proc-macro = true
[dependencies]proc-macro2 = "1"quote = "1"syn = "2"Hai crate còn lại là crate bình thường. hello_macro phụ thuộc vào crate derive, và shop chỉ cần hello_macro:
[dependencies]hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }[dependencies]hello_macro = { version = "0.1.0", path = "../hello_macro" }Bước 1: #[derive(HelloMacro)]
Phần tiêu đề “Bước 1: #[derive(HelloMacro)]”Hãy bắt đầu thật nhỏ. Ta muốn mọi kiểu có #[derive(HelloMacro)] đều nhận được một hàm hello_macro() in ra tên của kiểu. Đầu tiên là trait, trong hello_macro/src/lib.rs. Dòng cuối xuất lại các macro derive, để người dùng chỉ cần một crate này. Serde làm đúng như vậy.
pub trait HelloMacro { fn hello_macro();}
pub trait Describe { fn type_name() -> &'static str; fn field_names() -> &'static [&'static str];}
// Xuất lại các macro derive, để người dùng chỉ cần một crate này.pub use hello_macro_derive::{Describe, HelloMacro};(Describe dành cho bước 2.) Giờ tới chính macro, trong hello_macro_derive/src/lib.rs:
use proc_macro::TokenStream;use quote::quote;use syn::{parse_macro_input, Data, DeriveInput, Fields}; // Data và Fields: dùng ở bước 2
#[proc_macro_derive(HelloMacro)]pub fn hello_macro_derive(input: TokenStream) -> TokenStream { // 1. Phân tích token thành một cây cú pháp mà ta có thể xem xét. let ast = parse_macro_input!(input as DeriveInput); let name = &ast.ident;
// 2. Tạo code mới bằng quote!. let generated = quote! { impl hello_macro::HelloMacro for #name { fn hello_macro() { println!("Hello, Macro! My name is {}!", stringify!(#name)); } } };
// 3. Trả token mới lại cho trình biên dịch. generated.into()}Ba bước, và bạn sẽ thấy chúng trong hầu hết mọi macro derive:
parse_macro_input!biến token thành mộtDeriveInput.ast.identlà tên kiểu, ví dụProduct. Nếu phân tích thất bại, macro này tự trả về một lỗi biên dịch đàng hoàng cho bạn.quote!tạo khốiimpl.#namedán tên vào.stringify!(#name)biến tên thành đoạn chữ"Product"..into()chuyểnproc_macro2::TokenStreamcủa quote thànhproc_macro::TokenStreamcủa trình biên dịch.
Hãy để ý đường dẫn đầy đủ hello_macro::HelloMacro. Code được sinh ra sẽ được dán vào file của người dùng, nên nó không thể dựa vào các dòng use của người dùng.
Khi ai đó viết #[derive(HelloMacro)] struct Product, trình biên dịch thêm code hoạt động giống hệt phiên bản viết tay dưới đây. Phiên bản này chạy được trên Playground, vì không có crate macro nào dính vào. Chuỗi được giữ bằng tiếng Anh để khớp với kết quả của dự án thật ở bên dưới:
trait HelloMacro { fn hello_macro();}
struct Product;
// Đây là những gì #[derive(HelloMacro)] viết giúp bạn:impl HelloMacro for Product { fn hello_macro() { println!("Hello, Macro! My name is {}!", "Product"); }}
fn main() { Product::hello_macro();}Kết quả:
Hello, Macro! My name is Product!Viết tay một lần thì không sao. Viết cho 40 kiểu mới là lúc một derive bắt đầu đáng công.
Bước 2: #[derive(Describe)] đọc các trường
Phần tiêu đề “Bước 2: #[derive(Describe)] đọc các trường”HelloMacro chỉ dùng tên của kiểu. Các derive thật, như của serde, nhìn vào các trường. Hãy xây Describe: nó liệt kê tên các trường của một struct, rất tiện để làm dòng tiêu đề CSV. syn đưa cho bạn các trường thông qua ast.data:
Data::Struct,Data::EnumhoặcData::Union: đây là loại kiểu nào.- Với một struct:
Fields::Named(struct Product { id: u32 }),Fields::Unnamed(struct Meters(f64)) hoặcFields::Unit(struct Marker;).
Thêm đoạn này bên dưới macro đầu tiên trong hello_macro_derive/src/lib.rs:
#[proc_macro_derive(Describe)]pub fn describe_derive(input: TokenStream) -> TokenStream { let ast = parse_macro_input!(input as DeriveInput); expand_describe(&ast) .unwrap_or_else(|err| err.to_compile_error()) .into()}
fn expand_describe(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> { let name = &ast.ident;
let Data::Struct(data) = &ast.data else { return Err(syn::Error::new_spanned( name, "Describe can only be derived for structs", )); };
let field_names: Vec<String> = match &data.fields { Fields::Named(fields) => fields .named .iter() .map(|field| field.ident.as_ref().unwrap().to_string()) .collect(), Fields::Unit => Vec::new(), Fields::Unnamed(_) => { return Err(syn::Error::new_spanned( name, "Describe needs named fields, like `struct Point { x: i32 }`", )); } };
Ok(quote! { impl hello_macro::Describe for #name { fn type_name() -> &'static str { stringify!(#name) }
fn field_names() -> &'static [&'static str] { &[#(#field_names),*] } } })}Những phần mới:
- Phần việc thật nằm trong một hàm phụ trợ bình thường trả về
syn::Result. Nhờ vậy bạn dùng được?vàreturn Err(...)như trong mọi code xử lý lỗi khác. Hàm macro ở trên cùng chỉ biến lỗi thành lỗi biên dịch. let ... elsexử lý ngay trường hợp “không phải struct” và giữ cho nhánh chính phẳng, dễ đọc.#(#field_names),*là cú pháp lặp (repetition) của quote. Nó dán mọi chuỗi trongVec, cách nhau bằng dấu phẩy. Ý tưởng giống$(...),*trongmacro_rules!. MỗiStringtrở thành một string literal, nên đoạn này biến thành&["id", "name", "price_cents"].field.ident.as_ref().unwrap()an toàn ở đây: trongFields::Named, trường nào cũng có tên.
Giờ dùng cả hai derive trong shop/src/main.rs. Hãy để ý rằng use hello_macro::{Describe, HelloMacro} đưa vào cả trait lẫn macro derive. Chúng trùng tên nhưng nằm ở các namespace khác nhau, và đó chính xác là cách serde::Serialize hoạt động.
use hello_macro::{Describe, HelloMacro};
#[derive(HelloMacro, Describe)]struct Product { id: u32, name: String, price_cents: u64,}
#[derive(HelloMacro, Describe)]struct Customer { email: String, city: String,}
fn main() { Product::hello_macro(); Customer::hello_macro();
println!("{} has fields {:?}", Product::type_name(), Product::field_names());
// Một file CSV: dòng tiêu đề lấy từ derive, các dòng dữ liệu lấy từ giá trị thật. println!("{}", Customer::field_names().join(",")); println!("{},{}", ana.email, ana.city);
let teapot = Product { id: 7, name: "Teapot".to_string(), price_cents: 1999 }; println!("{} #{}: {} costs {} cents", Product::type_name(), teapot.id, teapot.name, teapot.price_cents);}Chạy nó từ thư mục workspace:
cargo run -p shopKết quả (khi build lần đầu):
Compiling proc-macro2 v1.0.107 Compiling unicode-ident v1.0.27 Compiling quote v1.0.47 Compiling syn v2.0.119 Compiling hello_macro_derive v0.1.0 (/home/you/procmacro-demo/hello_macro_derive) Compiling hello_macro v0.1.0 (/home/you/procmacro-demo/hello_macro) Compiling shop v0.1.0 (/home/you/procmacro-demo/shop) Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s Running `target/debug/shop`Hello, Macro! My name is Product!Hello, Macro! My name is Customer!Product has fields ["id", "name", "price_cents"]email,city[email protected],LisbonProduct #7: Teapot costs 1999 centsHãy nhìn thứ tự: Cargo build syn và hello_macro_derive trước shop, vì trình biên dịch cần chạy macro của bạn trong lúc biên dịch shop. Nếu bạn thêm một trường vào Customer, dòng tiêu đề CSV sẽ tự cập nhật ở lần build sau.
Thông báo lỗi tốt
Phần tiêu đề “Thông báo lỗi tốt”Chuyện gì xảy ra nếu ai đó đặt #[derive(Describe)] lên một enum? Vì expand_describe trả về một syn::Error, người dùng nhận được một lỗi biên dịch trông hoàn toàn bình thường. (Chúng tôi đặt các ví dụ lỗi của trang này trong shop/src/bin/broken.rs, nên đường dẫn đó xuất hiện trong thông báo.)
use hello_macro::Describe;
#[derive(Describe)]enum OrderStatus { Pending, Shipped,}error: Describe can only be derived for structs --> shop/src/bin/broken.rs:4:6 |4 | enum OrderStatus { | ^^^^^^^^^^^Thông báo này (bằng tiếng Anh, vì đó là chuỗi trong macro của ta) nói rằng Describe chỉ derive được cho struct. Có hai điều làm cho lỗi này tốt:
syn::Error::new_spanned(name, ...)nhớ tên đó nằm ở đâu trong file của người dùng (gọi là span của nó). Vì vậy^^^^^chỉ vàoOrderStatus, chứ không phải một dòng nào đó bên trong macro của bạn.to_compile_error()biến lỗi thành một lời gọicompile_error!("..."). Sau đó trình biên dịch báo nó như mọi lỗi khác. Bạn cũng có thể dùng chính macrocompile_error!trong code bình thường.
Một tuple struct nhận thông báo còn lại, cũng chỉ đúng chỗ:
error: Describe needs named fields, like `struct Point { x: i32 }` --> shop/src/bin/broken.rs:4:8 |4 | struct Meters(f64); | ^^^^^^Hãy viết thông báo cho người dùng macro. Nói rõ cái gì sai và cái gì sẽ chạy được.
Xem code được sinh ra bằng cargo expand
Phần tiêu đề “Xem code được sinh ra bằng cargo expand”Macro có thể giống như phép thuật, và phép thuật thì khó gỡ lỗi. cargo-expand cho bạn xem code sau khi mọi macro đã chạy. Cài nó một lần (xem Công cụ và cài đặt Rust để biết thêm nhiều công cụ tiện dụng khác):
cargo install cargo-expandcargo expand -p shopMột phần những gì nó in ra cho dự án của ta:
impl hello_macro::Describe for Product { fn type_name() -> &'static str { "Product" } fn field_names() -> &'static [&'static str] { &["id", "name", "price_cents"] }}impl hello_macro::Describe for Customer { fn type_name() -> &'static str { "Customer" } fn field_names() -> &'static [&'static str] { &["email", "city"] }}Khối impl của bạn đây, với tên các trường đã được điền vào, và stringify!(#name) đã thành "Product". Khi một derive không làm điều bạn mong đợi, hãy chạy cargo expand trước tiên. Nó cũng mở rộng println! và các macro khác, nên hãy tìm các khối impl của riêng bạn.
Kiểm thử macro
Phần tiêu đề “Kiểm thử macro”Một crate macro khó tự kiểm thử chính nó, vì nó chỉ xuất được macro. Giải pháp đơn giản là viết kiểm thử bình thường trong một crate dùng macro đó. Đặt đoạn này vào hello_macro/tests/describe.rs và chạy cargo test -p hello_macro:
use hello_macro::Describe;
#[allow(dead_code)]#[derive(Describe)]struct Product { id: u32, name: String,}
#[test]fn lists_field_names_in_order() { assert_eq!(Product::type_name(), "Product"); assert_eq!(Product::field_names(), ["id", "name"]);}running 1 testtest lists_field_names_in_order ... okNhưng làm sao kiểm thử rằng trường hợp enum cho ra một lỗi tốt? Một test bình thường không làm được, vì file test sẽ không biên dịch được. Đó là việc của crate trybuild. Bạn đặt các file nhỏ “phải thất bại” vào một thư mục, và trybuild biên dịch từng file rồi so sánh lỗi của trình biên dịch với một file .stderr đã lưu:
#[test]fn ui() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs");}Phần lớn các crate macro phổ biến, kể cả serde và thiserror, kiểm thử thông báo lỗi của chúng theo cách này.
Khi nào nên tự viết một cái?
Phần tiêu đề “Khi nào nên tự viết một cái?”Procedural macro rất mạnh, nhưng chúng có cái giá của nó. Hãy thành thật với bản thân trước khi viết:
- Thời gian biên dịch. syn là một crate lớn. Lần build đầu tiên của mọi dự án dùng macro của bạn đều phải biên dịch nó. (Các feature mặc định của syn đã đủ cho derive. Bạn chỉ cần
features = ["full"]để phân tích những thứ như thân hàm, và điều đó làm nó chậm hơn.) - Độ phức tạp. Giờ bạn có thêm một crate, có code viết ra code, và có những lỗi khó lần theo hơn.
- Công cụ. Trình soạn thảo và rust-analyzer hiểu code bình thường tốt hơn code sinh ra từ macro.
Một thứ tự nên thử:
- Một hàm, một trait có phương thức mặc định, hoặc generic. Phần lớn vấn đề “code lặp đi lặp lại” dừng ở đây.
- Một macro
macro_rules!(xem Macro). Nó không cần thêm crate nào. - Một procedural macro, khi code phụ thuộc vào cấu trúc của một kiểu (tên, trường hay biến thể của nó), nhiều kiểu cần nó, và người khác sẽ dùng nó. Đó là lý do serde, thiserror và clap dùng derive.
Để so sánh, đây là Describe làm bằng macro_rules!. Nó chạy được và không cần thêm crate, nhưng struct phải được viết bên trong lời gọi macro. Cách này nhanh chóng trở nên vướng víu, ví dụ khi bạn muốn thêm doc comment, trường pub hay các derive khác. Chuỗi in ra được giữ bằng tiếng Anh để khớp với dự án ở trên:
trait Describe { fn type_name() -> &'static str; fn field_names() -> &'static [&'static str];}
macro_rules! describe_struct { (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => { struct $name { $($field: $ty),* }
impl Describe for $name { fn type_name() -> &'static str { stringify!($name) } fn field_names() -> &'static [&'static str] { &[$(stringify!($field)),*] } } };}
describe_struct!(struct Customer { email: String, city: String });
fn main() { println!("{} has fields {:?}", Customer::type_name(), Customer::field_names()); println!("{},{}", ana.email, ana.city);}Kết quả:
Customer has fields ["email", "city"][email protected],LisbonVới một hai kiểu trong chương trình của riêng bạn, cách này (hoặc đơn giản là viết impl bằng tay) thường là lựa chọn tốt hơn. Một derive dễ chịu hơn cho người dùng: họ giữ struct bình thường của mình và chỉ thêm một dòng.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Quên proc-macro = true. Không có phần [lib] trong Cargo.toml của crate macro, crate đó là một thư viện bình thường, và trình biên dịch từ chối:
error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type --> hello_macro_derive/src/lib.rs:5:1 |5 | #[proc_macro_derive(HelloMacro)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0432]: unresolved import `proc_macro`Trình biên dịch nói rằng attribute #[proc_macro_derive] chỉ dùng được trong crate có kiểu proc-macro.
Dùng panic! hoặc unwrap() cho lỗi của người dùng. Nó “chạy được”, nhưng lỗi thì mơ hồ và chỉ vào toàn bộ #[derive], chứ không chỉ vào vấn đề:
error: proc-macro derive panicked --> shop/src/bin/broken.rs:3:10 |3 | #[derive(Describe)] | ^^^^^^^^ | = help: message: Describe can only be derived for structsThay vào đó, hãy trả về một syn::Error với new_spanned, như ở bước 2.
Đường dẫn ngắn trong code được sinh ra. Nếu bạn viết impl Describe for #name thay vì impl hello_macro::Describe for #name, nó chỉ chạy được khi người dùng tình cờ đã import Describe. Ai viết #[derive(hello_macro::Describe)] sẽ nhận được lỗi này:
error[E0405]: cannot find trait `Describe` in this scope --> shop/src/bin/broken.rs:1:10 |1 | #[derive(hello_macro::Describe)] | ^^^^^^^^^^^^^^^^^^^^^ not found in this scope | = note: this error originates in the derive macro `hello_macro::Describe`Lỗi này nghĩa là không tìm thấy trait Describe trong phạm vi hiện tại. Hãy luôn dùng đường dẫn đầy đủ trong quote!: hello_macro::Describe, ::std::fmt::Display, ::core::option::Option.
Luyện tập
Phần tiêu đề “Luyện tập”Các bài tập này thay đổi dự án thật, nên không có nút Try it. Hãy build dự án trên trang này trên máy tính của bạn và chạy cargo run -p shop.
1. Đếm số trường
Phần tiêu đề “1. Đếm số trường”Thêm một phương thức thứ ba, fn field_count() -> usize, vào trait Describe và vào derive. Product::field_count() phải trả về 3. Gợi ý: bạn đã có vector field_names. Lưu field_names.len() vào một biến trước quote!, rồi dán nó vào bằng #.
Xem lời giải
Trong hello_macro/src/lib.rs, thêm phương thức vào trait:
pub trait Describe { fn type_name() -> &'static str; fn field_names() -> &'static [&'static str]; fn field_count() -> usize;}Trong expand_describe, đếm số trường và thêm phương thức vào impl được sinh ra:
let count = field_names.len();
Ok(quote! { impl hello_macro::Describe for #name { fn type_name() -> &'static str { stringify!(#name) }
fn field_names() -> &'static [&'static str] { &[#(#field_names),*] }
fn field_count() -> usize { #count } } })Sau đó println!("{} has {} fields", Product::type_name(), Product::field_count()); trong main in ra:
Product has 3 fieldsViệc đếm diễn ra trong macro của bạn, lúc biên dịch. cargo expand cho thấy kết quả chỉ là fn field_count() -> usize { 3usize }, một hằng số, không còn việc gì phải làm lúc chạy.
2. Hỗ trợ struct generic
Phần tiêu đề “2. Hỗ trợ struct generic”Thử #[derive(Describe)] trên một struct generic như struct Page<T> { items: Vec<T>, number: u32 }. Nó thất bại với error[E0107]: missing generics for struct Page, vì code được sinh ra viết impl hello_macro::Describe for Page và quên mất <T>. Hãy sửa derive để struct generic chạy được. Gợi ý: ast.generics.split_for_impl() cho bạn ba mảnh để dán vào dòng impl. Xem Generic để nhớ lại impl<T> trông như thế nào.
Xem lời giải
Trong expand_describe, tách generic ra và dùng cả ba mảnh trong dòng impl:
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
Ok(quote! { impl #impl_generics hello_macro::Describe for #name #ty_generics #where_clause { fn type_name() -> &'static str { stringify!(#name) }
fn field_names() -> &'static [&'static str] { &[#(#field_names),*] } } })Giờ đoạn này chạy được trong shop:
#[derive(Describe)]struct Page<T> { items: Vec<T>, number: u32,}
fn main() { let page = Page { items: vec!["a", "b"], number: 1 }; println!("{} {} {:?}", page.items.len(), page.number, Page::<String>::field_names());}2 1 ["items", "number"]impl_generics là <T> (kèm mọi ràng buộc), ty_generics là <T>, và where_clause là phần where ..., nếu có. Mọi derive nghiêm túc đều làm điều này, nên hãy nhớ split_for_impl.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Procedural macro là một hàm Rust chạy lúc biên dịch:
TokenStreamvào,TokenStreamra. - Có ba loại: derive (
#[derive(Serialize)]), attribute (#[tokio::main]) và dạng hàm (sql!(...)). - Chúng nằm trong một crate riêng có
proc-macro = true. Một crate chính thường xuất lại chúng bên cạnh các trait. - syn phân tích đầu vào (
DeriveInput,Data,Fields), còn quote viết đầu ra (#name,#(...),*). - Báo lỗi của người dùng bằng
syn::Error::new_spanned(...).to_compile_error(), kiểm tra kết quả bằngcargo expand, và kiểm thử lỗi bằng trybuild. - Hãy thử hàm, trait hoặc
macro_rules!trước. Procedural macro đáng công khi nhiều kiểu cần code dựa trên cấu trúc của chúng.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Code của một procedural macro chạy khi nào?
2. #[tokio::main] là loại procedural macro nào?
3. syn và quote làm gì?
4. Derive của bạn cần từ chối enum. Cách tốt nhất là gì?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- Procedural Macros — The Rust Book (tiếng Anh): hướng dẫn chính thức từng bước cho một derive
HelloMacro, cùng macro dạng attribute và dạng hàm. - Procedural Macros — The Rust Reference (tiếng Anh): quy tắc chính xác cho ba loại macro, kiểu crate, span và hygiene.
- Tài liệu syn (tiếng Anh):
DeriveInput,Data,Fields,Errorvà các hàm phân tích dùng trên trang này. - Tài liệu quote (tiếng Anh): cách chèn biến
#varvà lặp#(...),*hoạt động bên trongquote!. - proc-macro-workshop (tiếng Anh): các dự án thực hành miễn phí của David Tolnay (một derive builder, một derive debug và hơn nữa), có test hướng dẫn bạn từng bước.