Bỏ qua để đến nội dung

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.

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ấy async fn main của bạn và biến nó thành một fn main bì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ậy sqlx::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ết
▶ Tự chạy thử

Bạ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.

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 DeriveInput có 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à #name dán một biến vào.
  • proc-macro2 là lớp bọc quanh các kiểu proc_macro củ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.

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.)

Terminal window
mkdir procmacro-demo && cd procmacro-demo
cargo new --lib hello_macro
cargo new --lib hello_macro_derive
cargo 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:

hello_macro_derive/Cargo.toml
[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:

hello_macro/Cargo.toml
[dependencies]
hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }
shop/Cargo.toml
[dependencies]
hello_macro = { version = "0.1.0", path = "../hello_macro" }

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.

hello_macro/src/lib.rs
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:

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:

  1. parse_macro_input! biến token thành một DeriveInput. ast.ident là 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.
  2. quote! tạo khối impl. #name dán tên vào. stringify!(#name) biến tên thành đoạn chữ "Product".
  3. .into() chuyển proc_macro2::TokenStream của quote thành proc_macro::TokenStream củ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!
▶ Tự chạy thử

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::Enum hoặc Data::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ặc Fields::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 ... else xử 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 trong Vec, cách nhau bằng dấu phẩy. Ý tưởng giống $(...),* trong macro_rules!. Mỗi String trở 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: trong Fields::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.

shop/src/main.rs
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.
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
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:

Terminal window
cargo run -p shop

Kế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
Product #7: Teapot costs 1999 cents

Hã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.

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ào OrderStatus, 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ọi compile_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 macro compile_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.

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):

Terminal window
cargo install cargo-expand
cargo expand -p shop

Mộ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.

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 test
test lists_field_names_in_order ... ok

Như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.

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ử:

  1. 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.
  2. Một macro macro_rules! (xem Macro). Nó không cần thêm crate nào.
  3. 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() {
let ana = Customer { email: "[email protected]".to_string(), city: "Lisbon".to_string() };
println!("{} has fields {:?}", Customer::type_name(), Customer::field_names());
println!("{},{}", ana.email, ana.city);
}

Kết quả:

Customer has fields ["email", "city"]
▶ Tự chạy thử

Vớ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.

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 structs

Thay 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.

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.

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 fields

Việ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.

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.

  • Procedural macro là một hàm Rust chạy lúc biên dịch: TokenStream vào, TokenStream ra.
  • 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ằng cargo 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

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

  • 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, Error và 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 #var và lặp #(...),* hoạt động bên trong quote!.
  • 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.