function-like 宏如何保留调用位置信息?

解读

国内大厂面试中,这道题常被用来区分“只会写 macro_rules!”与“真正理解宏展开机制”的候选人。
面试官真正想听的是:

  1. 宏展开后,编译器诊断信息(panic、warn、error)如何定位回用户写的宏调用处,而不是指向宏定义内部;
  2. 你自己写的宏如果想生成包含文件、行号、列号的代码,应该显式使用编译器提供的内置宏
  3. 对于 proc-macro(派生/属性/函数式),还要知道 Span 这一核心概念,并能在 quote! 里正确“贴”回去。

答不到“Span”基本会被判定为“仅停留在声明宏层面”,在评级里会被降档。

知识点

  1. 内置宏
    file!() line!() column!() module_path!()宏调用处求值,展开后字面量直接嵌入,零成本。
  2. core::panic! 系列宏的秘密
    它们把上述四个内置宏拼成 PanicInfo,因此 panic 信息总能回到用户源码位置。
  3. std::panic::Location
    #[track_caller] 属性让函数拿到调用者位置,原理与内置宏相同,但适用于普通函数。
  4. proc-macro 的 Span
    proc_macro::TokenStream 中每个 TokenTree 都带 Span,代表它在用户源码中的坐标。
    • quote::quote_spanned!(span=> …) 可把生成代码的“报错坐标”钉回给定位置;
    • Span::call_site() 表示宏调用处
    • Span::mixed() 在 Rust 1.45 后用于更精细的列信息。
  5. syn::parse_macro_input!proc_macro2
    做派生宏时,先把 proc_macro::TokenStream 转成 proc_macro2::TokenStream,再取 Span,最后 quote_spanned! 贴回去,即可让 clippy/rustc 的提示精确到字段名。

答案

“function-like 宏”分两种场景:声明宏(macro_rules!)与过程宏(proc-macro)。

  1. 声明宏
    直接在宏体里插入 file!() line!() column!(),它们在调用点求值,因此生成代码里自带调用位置的字面量。例如:

    macro_rules! here {
        () => { concat!(file!(), ":", line!(), ":", column!()) };
    }
    

    调用 here!() 展开后即为 "src/main.rs:7:20",后续 panic!println! 均可使用。

  2. 过程宏(函数式)
    入口函数拿到的是 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 并回贴到生成节点,两者都能让编译器把错误坐标指向用户源码而非宏定义

拓展思考

  1. 混合场景
    在声明宏里再调用过程宏,位置信息会不会断?
    答:不会。声明宏展开后生成新的 TokenStream 传给过程宏,过程宏仍能得到正确的 Span::call_site()
  2. #[track_caller] 与 const 泛型
    在 const 上下文目前仍不能用 #[track_caller],此时若想在 const fn 里记录位置,只能把 file!() 等当常量字符串传进去,这是目前语言边界
  3. 错误消息美化
    自己写派生宏时,可额外生成 compile_error!(concat!(…)) 并手动指定 Span,让错误提示像官方宏一样带颜色、可点击跳转,在 CI 日志里极大提升排查效率