跳转到内容

Rust 过程宏:自己动手写 #[derive]

每次你写下 #[derive(Debug)] 或 #[derive(Serialize)],编译器里都会运行一个小程序。它读一遍你的结构体,然后替你写好一个 impl 块。这个小程序就是过程宏(procedural macro),而且你也可以自己写。

可以把它想象成一位热心的办事员。你递过去一张表格(你的结构体),办事员把每一栏(字段)都看一遍,再把那些枯燥的文书工作填好还给你。在宏那一页,你认识了靠模式匹配工作的 macro_rules!。过程宏不一样:它就是普通的 Rust 函数,接收代码,再返回代码。这一页会一步一步带你做一个真正的过程宏,并给你看真实的运行结果。

过程宏一共有三种。它们内部的工作原理相同,只是在使用者眼里长得不一样:

类型 使用者写什么 著名的例子
derive 宏 写在结构体或枚举上的 #[derive(Serialize)] serde 的 Serialize、thiserror 的 Error、clap 的 Parser
属性宏 写在函数等项上的 #[route(GET, "/")] #[tokio::main]、actix-web 的 #[get("/")]
函数式宏 sql!(SELECT * FROM users) sqlx 的 query!、yew 的 html!
  • derive 宏只会在你的类型旁边添加代码。你的结构体会原封不动地保留下来。
  • 属性宏会替换它所标注的项。#[tokio::main] 接收你的 async fn main,把它改写成一个启动 tokio 运行时的普通 fn main。
  • 函数式宏看起来像 macro_rules! 的调用,但输入是什么意思,由一个 Rust 函数说了算。sqlx::query! 能在编译时检查你的 SQL,靠的就是这一点。

其实你已经在用其他 crate 提供的 derive 宏了。下面两个 derive 宏正在干实事:Serialize 负责写生成 JSON 的代码(见 JSON 与 Serde),Error 根据 #[error(...)] 里的文字写出 Display 的代码(见 anyhow 与 thiserror):

use serde::Serialize;
use thiserror::Error;
#[derive(Serialize)]
struct Order {
id: u32,
item: String,
quantity: u8,
}
#[derive(Debug, Error)]
enum ShopError {
#[error("商品“{0}”已售罄")]
OutOfStock(String),
}
fn main() {
let order = Order { id: 42, item: "茶壶".to_string(), quantity: 2 };
println!("{}", serde_json::to_string(&order).unwrap());
let err = ShopError::OutOfStock("茶壶".to_string());
println!("{err}");
}

输出:

{"id":42,"item":"茶壶","quantity":2}
商品“茶壶”已售罄
▶ 动手试一试

你一行 Display 实现、一行 JSON 代码都没写。这些代码是程序编译时由 derive 写出来的。

工作原理:输入 token,输出 token

Section titled “工作原理:输入 token,输出 token”

编译器首先把你的源代码切成一个个 token(词法单元):名字、标点、字面量、括号。过程宏就是一个函数,它以 TokenStream 的形式拿到这些 token,再返回一个新的 TokenStream。接着编译器会编译你返回的内容,就好像这些代码是你亲手敲进去的一样。

下面是三种函数的样子。每种都要用一个属性标出来,编译器才知道它是哪一种:

use proc_macro::TokenStream;
// #[derive(HelloMacro)]:拿到结构体或枚举,返回要“添加”的代码。
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream { /* ... */ }
// #[route(GET, "/")]:拿到属性自己的 token 和被标注的项,返回“替换”后的代码。
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }
// sql!(...):拿到括号里的全部内容,返回替换后的代码。
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }

原始的 TokenStream 只是一串 token,直接处理很费劲。所以几乎所有过程宏都会用到三个辅助 crate:

  • syn 把 token 解析成语法树。你拿到的不再是 token,而是一个 DeriveInput,里面有类型的名字、泛型和字段。
  • quote 把长得像 Rust 的代码变回 token。在 quote! { ... } 里照常写 Rust,用 #name 就能把一个变量的值贴进去。
  • proc-macro2 是对编译器 proc_macro 类型的一层包装,syn 和 quote 都在用它。它在编译器之外也能工作,所以写辅助函数和测试会更方便。

有一点非常重要:你的宏在编译时运行。Cargo 会先为你自己的电脑编译宏所在的 crate,然后编译器在构建使用这个宏的 crate 时运行它。等程序真正运行起来,宏早就不见了,留下的只有它写出来的代码。

过程宏必须放在一个单独的 crate 里,而且这个 crate 要有特殊的 crate 类型。这种 crate 只能导出宏,不能导出普通函数或 trait。所以常见的做法是把两个 crate 并排放,就像 serde 和 serde_derive 那样:一个放 trait,一个放 derive。我们再加上第三个 crate:一个叫 shop 的小程序,用来使用前两个 crate。(想复习 crate 和工作空间?请看 Cargo 与 crate。)

Terminal window
mkdir procmacro-demo && cd procmacro-demo
cargo new --lib hello_macro
cargo new --lib hello_macro_derive
cargo new shop

完成后的文件结构是这样的:

procmacro-demo/
├── Cargo.toml (工作空间)
├── hello_macro/
│ ├── Cargo.toml
│ └── src/lib.rs (trait,以及重新导出的 derive)
├── hello_macro_derive/
│ ├── Cargo.toml (proc-macro = true)
│ └── src/lib.rs (宏本身)
└── shop/
├── Cargo.toml
└── src/main.rs (使用 #[derive(...)] 的程序)

最外层的 Cargo.toml 把三个 crate 组成一个工作空间:

[workspace]
members = ["hello_macro", "hello_macro_derive", "shop"]
resolver = "2"

只有宏所在的 crate 需要一个特殊设置:proc-macro = true。它还依赖 syn、quote 和 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"

另外两个 crate 都很普通。hello_macro 依赖 derive 所在的 crate,shop 只需要 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" }

先从很小的开始。我们希望任何加了 #[derive(HelloMacro)] 的类型都能得到一个 hello_macro() 函数,用来打印类型的名字。首先是 trait,写在 hello_macro/src/lib.rs 里。最后一行重新导出了 derive 宏,这样使用者只需要依赖这一个 crate。serde 也正是这么做的。

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];
}
// 重新导出 derive 宏,这样使用者只需要依赖这一个 crate。
pub use hello_macro_derive::{Describe, HelloMacro};

(Describe 留给第 2 步用。)接下来是宏本身,写在 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 和 Fields:第 2 步用
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 1. 把 token 解析成可以查看的语法树。
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
// 2. 用 quote! 拼出新代码。
let generated = quote! {
impl hello_macro::HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
// 3. 把新的 token 交还给编译器。
generated.into()
}

三个步骤,几乎每个 derive 宏里都能看到:

  1. parse_macro_input! 把 token 变成一个 DeriveInput。ast.ident 是类型的名字,比如 Product。如果解析失败,这个宏会替你返回一个像样的编译错误。
  2. quote! 拼出 impl 块。#name 把名字贴进去。stringify!(#name) 把名字变成文本 "Product"。
  3. .into() 把 quote 的 proc_macro2::TokenStream 转换成编译器的 proc_macro::TokenStream。

注意这里写的是完整路径 hello_macro::HelloMacro。生成的代码会被贴进使用者的文件里,所以不能指望使用者写了哪些 use。

当有人写下 #[derive(HelloMacro)] struct Product 时,编译器添加的代码和下面这个手写版本效果完全一样。这段代码不涉及宏 crate,所以能在 Playground 里运行(为了和示例项目的输出保持一致,字符串保留英文):

trait HelloMacro {
fn hello_macro();
}
struct Product;
// #[derive(HelloMacro)] 替你写的就是这段:
impl HelloMacro for Product {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", "Product");
}
}
fn main() {
Product::hello_macro();
}

输出:

Hello, Macro! My name is Product!
▶ 动手试一试

手写一次没什么问题。可要是给 40 个类型都写一遍,derive 的价值就体现出来了。

第 2 步:用 #[derive(Describe)] 读取字段

Section titled “第 2 步:用 #[derive(Describe)] 读取字段”

HelloMacro 只用到了类型的名字。真正的 derive,比如 serde 的,会去看字段。我们来做一个 Describe:它列出结构体的字段名,拿来做 CSV 表头很方便。syn 通过 ast.data 把字段交给你:

  • Data::Struct、Data::Enum 或 Data::Union:表示这是哪一种类型。
  • 如果是结构体,字段是 Fields::Named(struct Product { id: u32 })、Fields::Unnamed(struct Meters(f64))或 Fields::Unit(struct Marker;)之一。

把下面的代码加到 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),*]
}
}
})
}

新出现的几个要点:

  • 真正的工作放在一个返回 syn::Result 的普通辅助函数里。这样就能像其他错误处理代码一样使用 ? 和 return Err(...)。最上面的宏函数只负责把错误变成编译错误。
  • let ... else 当场处理“不是结构体”的情况,让正常流程的代码保持平整、好读。
  • #(#field_names),* 是 quote 的重复语法。它把 Vec 里的每个字符串都贴进去,用逗号隔开,和 macro_rules! 里的 $(...),* 是一个思路。每个 String 都会变成一个字符串字面量,所以这里会变成 &["id", "name", "price_cents"]。
  • 这里的 field.ident.as_ref().unwrap() 是安全的:在 Fields::Named 里,每个字段都有名字。

现在在 shop/src/main.rs 里同时使用这两个 derive。注意 use hello_macro::{Describe, HelloMacro} 同时引入了 trait 和 derive 宏。它们名字相同,但处在不同的命名空间里,serde::Serialize 也正是这样工作的。

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());
// 一个 CSV 文件:表头来自 derive,数据行来自真实的值。
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);
}

在工作空间的文件夹里运行它:

Terminal window
cargo run -p shop

输出(全新构建时):

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

Compiling、Finished、Running 这几行是 Cargo 打印的,总是英文。留意一下顺序:Cargo 在 shop 之前先构建了 syn 和 hello_macro_derive,因为编译器在编译 shop 时需要运行你的宏。如果你给 Customer 加一个字段,下次构建时 CSV 表头会自动更新。

如果有人把 #[derive(Describe)] 用在枚举上,会怎么样?因为 expand_describe 返回的是 syn::Error,使用者会看到一条和普通编译错误一模一样的报错。(这一页的错误示例都放在 shop/src/bin/broken.rs 里试过,所以报错里会出现这个路径。)

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 {
| ^^^^^^^^^^^

这条信息就是我们在宏里写的那句话(“Describe 只能用于结构体”)。这个报错好在两点:

  • syn::Error::new_spanned(name, ...) 记住了名字在使用者文件里的位置(它的 span)。所以 ^^^^^ 指向的是 OrderStatus,而不是你宏里的某一行。
  • to_compile_error() 把错误变成一个 compile_error!("...") 调用。然后编译器会像报告其他错误一样报告它。在普通代码里,你也可以用这个 compile_error! 宏。

元组结构体会得到另一条信息(“Describe 需要具名字段”),同样指向正确的位置:

error: Describe needs named fields, like `struct Point { x: i32 }`
--> shop/src/bin/broken.rs:4:8
|
4 | struct Meters(f64);
| ^^^^^^

写错误信息时,要站在使用宏的人的角度:说清楚哪里不对,以及怎样写才行。

宏有时让人觉得像魔法,而魔法很难调试。cargo-expand 能让你看到所有宏运行之后的代码。安装一次就行(更多好用的工具见 Rust 工具与环境搭建):

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

对我们的项目,它打印出的部分内容如下:

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"]
}
}

这就是你的 impl 块,字段名已经填好了,stringify!(#name) 也已经变成了 "Product"。当某个 derive 的行为和你预想的不一样时,先运行 cargo expand 看看。它也会展开 println! 等其他宏,所以要找你自己的那些 impl 块。

宏所在的 crate 只能导出宏,所以很难测试自己。简单的办法是:在一个使用这个宏的 crate 里写普通的测试。把下面的代码放进 hello_macro/tests/describe.rs,然后运行 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

那么,怎么测试用在枚举上时会给出友好的报错呢?普通测试做不到,因为测试文件本身就编译不过。这正是 trybuild crate 的用途。你把一些“应该编译失败”的小文件放进一个文件夹,trybuild 会逐个编译它们,再把编译器的报错和事先保存好的 .stderr 文件做比较:

#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}

大多数热门的宏 crate,包括 serde 和 thiserror,都是这样测试它们的错误信息的。

过程宏很强大,但也有代价。动手之前,先诚实地问问自己:

  • 编译时间。 syn 是个大 crate。每个用到你的宏的项目,第一次构建时都必须编译它。(写 derive 用 syn 的默认 feature 就够了。只有要解析函数体这类内容时,才需要 features = ["full"],而那会更慢。)
  • 复杂度。 你多了一个 crate、一堆“写代码的代码”,报错也更难追踪。
  • 工具支持。 编辑器和 rust-analyzer 对普通代码的理解,比对宏生成的代码要好。

推荐按这个顺序尝试:

  1. 函数、带默认方法的 trait,或者泛型。大多数“重复代码”的问题到这一步就解决了。
  2. macro_rules! 宏(见宏)。它不需要额外的 crate。
  3. 过程宏:当代码取决于类型的结构(它的名字、字段或变体),很多类型都需要它,而且别人也会用的时候。serde、thiserror 和 clap 用 derive 就是这个原因。

作为对比,下面是用 macro_rules! 实现的 Describe。它能用,也不需要额外的 crate,但结构体必须写在宏调用的里面。一旦你还想加文档注释、pub 字段或其他 derive,写起来很快就会变得别扭。

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!("{} 的字段:{:?}", Customer::type_name(), Customer::field_names());
println!("{},{}", ana.email, ana.city);
}

输出:

Customer 的字段:["email", "city"]
▶ 动手试一试

如果只是给自己程序里的一两个类型用,这种写法(或者干脆手写 impl)往往是更好的选择。derive 对使用者更友好:他们照常写结构体,只需多加一行。

忘了写 proc-macro = true。 宏 crate 的 Cargo.toml 里如果没有 [lib] 这一节,这个 crate 就只是个普通库,编译器会拒绝编译:

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`

用 panic! 或 unwrap() 处理使用者的错误。 这样“也能用”,但报错很含糊,指向的是整个 #[derive],而不是出问题的地方:

error: proc-macro derive panicked
--> shop/src/bin/broken.rs:3:10
|
3 | #[derive(Describe)]
| ^^^^^^^^
|
= help: message: Describe can only be derived for structs

应该像第 2 步那样,用 new_spanned 返回一个 syn::Error。

生成的代码里用了短路径。 如果你写的是 impl Describe for #name,而不是 impl hello_macro::Describe for #name,那只有使用者碰巧导入了 Describe 时才能编译。有人写 #[derive(hello_macro::Describe)] 时就会得到这个报错:

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`

在 quote! 里一定要用完整路径:hello_macro::Describe、::std::fmt::Display、::core::option::Option。

这些练习要修改真实的项目,所以没有 Try it 按钮。请在自己的电脑上搭好本页的项目,然后运行 cargo run -p shop。

给 Describe trait 和 derive 都加上第三个方法 fn field_count() -> usize。Product::field_count() 应该返回 3。提示:你已经有 field_names 这个 vector 了。在 quote! 之前把 field_names.len() 存进一个变量,再用 # 把它贴进去。

查看答案

在 hello_macro/src/lib.rs 里,给 trait 加上这个方法:

pub trait Describe {
fn type_name() -> &'static str;
fn field_names() -> &'static [&'static str];
fn field_count() -> usize;
}

在 expand_describe 里数出字段个数,并把这个方法加到生成的 impl 里:

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

然后在 main 里写 println!("{} has {} fields", Product::type_name(), Product::field_count());,会打印:

Product has 3 fields

计数发生在你的宏里,也就是编译时。cargo expand 会显示结果只是 fn field_count() -> usize { 3usize },一个常量,运行时没有任何额外的工作。

试着把 #[derive(Describe)] 用在一个泛型结构体上,比如 struct Page<T> { items: Vec<T>, number: u32 }。它会报 error[E0107]: missing generics for struct Page,因为生成的代码写的是 impl hello_macro::Describe for Page,漏掉了 <T>。修改这个 derive,让泛型结构体也能用。提示:ast.generics.split_for_impl() 会给你三个部分,把它们贴进 impl 那一行。impl<T> 长什么样,可以回顾泛型。

查看答案

在 expand_describe 里拆分泛型,并在 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),*]
}
}
})

现在在 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 是 <T>(带上所有约束),ty_generics 是 <T>,where_clause 是 where ... 那部分(如果有的话)。每个正经的 derive 都会这样做,所以记住 split_for_impl。

  • 过程宏是一个在编译时运行的 Rust 函数:输入 TokenStream,输出 TokenStream。
  • 过程宏有三种:derive 宏(#[derive(Serialize)])、属性宏(#[tokio::main])和函数式宏(sql!(...))。
  • 它们放在单独的、设置了 proc-macro = true 的 crate 里。通常由一个主 crate 把它们和 trait 一起重新导出。
  • syn 负责解析输入(DeriveInput、Data、Fields),quote 负责写出输出(#name、#(...),*)。
  • 用 syn::Error::new_spanned(...).to_compile_error() 报告使用者的错误,用 cargo expand 检查生成结果,用 trybuild 测试报错。
  • 先试试函数、trait 或 macro_rules!。当很多类型都需要根据自身结构生成代码时,过程宏才值得写。

小测验

1. 过程宏的代码在什么时候运行?

2. #[tokio::main] 属于哪一种过程宏?

3. syn 和 quote 分别做什么?

4. 你的 derive 应该拒绝枚举。最好的做法是什么?

得分: 0 / 4