function-like 宏如何保留调用位置信息?
解读
国内大厂面试中,这道题常被用来区分“只会写 macro_rules!”与“真正理解宏展开机制”的候选人。
面试官真正想听的是:
- 宏展开后,编译器诊断信息(panic、warn、error)如何定位回用户写的宏调用处,而不是指向宏定义内部;
- 你自己写的宏如果想生成包含文件、行号、列号的代码,应该显式使用编译器提供的内置宏;
- 对于 proc-macro(派生/属性/函数式),还要知道
Span这一核心概念,并能在quote!里正确“贴”回去。
答不到“Span”基本会被判定为“仅停留在声明宏层面”,在评级里会被降档。
知识点
- 内置宏
file!()line!()column!()module_path!()在宏调用处求值,展开后字面量直接嵌入,零成本。 core::panic!系列宏的秘密
它们把上述四个内置宏拼成PanicInfo,因此 panic 信息总能回到用户源码位置。std::panic::Location
#[track_caller]属性让函数拿到调用者位置,原理与内置宏相同,但适用于普通函数。- proc-macro 的 Span
proc_macro::TokenStream中每个TokenTree都带Span,代表它在用户源码中的坐标。quote::quote_spanned!(span=> …)可把生成代码的“报错坐标”钉回给定位置;Span::call_site()表示宏调用处;Span::mixed()在 Rust 1.45 后用于更精细的列信息。
syn::parse_macro_input!与proc_macro2
做派生宏时,先把proc_macro::TokenStream转成proc_macro2::TokenStream,再取Span,最后quote_spanned!贴回去,即可让clippy/rustc的提示精确到字段名。
答案
“function-like 宏”分两种场景:声明宏(macro_rules!)与过程宏(proc-macro)。
-
声明宏
直接在宏体里插入file!()line!()column!(),它们在调用点求值,因此生成代码里自带调用位置的字面量。例如:macro_rules! here { () => { concat!(file!(), ":", line!(), ":", column!()) }; }调用
here!()展开后即为"src/main.rs:7:20",后续panic!、println!均可使用。 -
过程宏(函数式)
入口函数拿到的是proc_macro::TokenStream,每个 token 都带Span。
步骤:
a. 用syn::parse_macro_input!(input as syn::Expr)解析;
b. 取调用位置let span = input.span();;
c. 生成代码时用quote::quote_spanned!(span=> …),把生成节点的Span设成与原 token 一致;
d. 若需要显式文件行号,可在生成代码里再插入file!()等,但诊断信息已能精确定位到宏调用处。
一句话总结:声明宏靠内置宏在调用点求值,过程宏靠保留 Token 的 Span 并回贴到生成节点,两者都能让编译器把错误坐标指向用户源码而非宏定义。
拓展思考
- 混合场景
在声明宏里再调用过程宏,位置信息会不会断?
答:不会。声明宏展开后生成新的TokenStream传给过程宏,过程宏仍能得到正确的Span::call_site()。 - #[track_caller] 与 const 泛型
在 const 上下文目前仍不能用#[track_caller],此时若想在const fn里记录位置,只能把file!()等当常量字符串传进去,这是目前语言边界。 - 错误消息美化
自己写派生宏时,可额外生成compile_error!(concat!(…))并手动指定Span,让错误提示像官方宏一样带颜色、可点击跳转,在 CI 日志里极大提升排查效率。