syn

这里主要结合编写经验来总结 syn 的使用方式,少数内容是对 syn 文档的翻译和重新组织。

如果内容上有出入,请以 syn 文档为准。

整体介绍

文档:https://docs.rs/syn

syn 主要是一个解析库,用于把 Rust 标记流解析为 Rust 源代码的语法树。目前该库面向过程宏,但包含一些可能更通用的 API。

作者自己从以下几个方面介绍 syn,并给出了典型的代码。

  • 数据结构方面:syn 提供一个完整的、可以表示任何有效的 Rust 源代码的语法树。
    • syn::File 就是这棵语法树的根节点,这个类型表示一个完整的代码源文件;
    • 更常见的情况是使用其他入口,比如 syn::Itemsyn::Exprsyn::Type,它们都是枚举体,对应于 Rust 中的语法概念。
    • 几乎所有语法树节点的文档都给出了典型例子或者简明说明,非常易于理解和使用。
  • #[derive] 方面:为解析 derive 宏的标记流提供 syn::DeriveInput 类型。
  • 解析方面:
    • Parse trait 提供对 ParseStream 类型使用 parse 方法来将其解析成实现了此 trait 的基础(或自定义)类型;
    • syn 提供的每种语法树节点类型都可以单独解析和多项重组,由此轻松构建起全新的自定义语法;
    • 更深入的解析参考 syn::parse 模块文档。
  • 位置信息: syn 解析的每个标记都与一个 Span 相关联,该类型用于跟踪源代码中的标记的行和列信息,从而让过程宏指定在源代码位置上显示错误消息。
  • feature 控制:你只需要启用你所需要的功能,而不必开启不需要的功能。

解析

文档:https://docs.rs/syn/latest/syn/parse/index.html

parse_macro_input!

这个宏充当过程宏的解析入口:无论哪种过程宏,也无论转化成 syn 定义的语法树还是你自定义的语法树,几乎都从这个宏开始。

一个最基本的 derive 宏的完整例子是:

// Cargo.toml 中写入以下内容:
// [dependencies]
// syn = "1.0"
// quote = "1.0"
// 
// [lib]
// proc-macro = true

use proc_macro::TokenStream;

#[proc_macro_derive(MyMacro)]
pub fn my_macro(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = syn::parse_macro_input!(input as syn::DeriveInput);
    TokenStream::new()
}

当你使用 cargo expand --lib 命令,可以看到如下结果(点击右上角的取消隐藏看到完整内容):

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;

use proc_macro::TokenStream;
#[proc_macro_derive(MyMacro)]
pub fn my_macro(input: TokenStream) -> TokenStream {
    let input = match ::syn::parse_macro_input::parse::<syn::DeriveInput>(input) {
        ::syn::__private::Ok(data) => data,
        ::syn::__private::Err(err) => {
            return ::syn::__private::TokenStream::from(err.to_compile_error());
        }
    };
    TokenStream::new()
}

const _: () = {
   extern crate proc_macro;
   #[rustc_proc_macro_decls]
   #[allow(deprecated)]
   static _DECLS: &[proc_macro::bridge::client::ProcMacro] =
       &[proc_macro::bridge::client::ProcMacro::custom_derive(
           "MyMacro",
           &[],
           my_macro,
       )];
};

尽管以下内容不完全与 parse_macro_input! 有关,但是为了深入这段展开的代码,你需要确切地掌握以下内容,并且注意一些事情。

卫生性

涉及 prelude 的几行,以及 #[macro_use] extern crate std; 是每个 rs 文件都会自动添加的内容,它们用于导入最常用的 items,和 std 的所有公开的声明宏。由此,你可以直接使用 OptionResult(以及它们的成员)这些数据结构与 traits,而不必使用绝对路径或者手动 use。而 println! 这类常见的宏,以及你可能不会经常使用的宏(比如 compile_error!),也会被加载进来。

了解这一点很重要,因为过程宏不是卫生的: 时刻警醒自己,不要轻易假设使用者的句法上下文,以免让意外的重名标识符污染代码,尽可能在 生成的标记中 使用 绝对路径、或者添加醒目的标识前后缀 —— 使用 ::std::result::Result 而不是 Result,对生成的 内部函数名或内部变量名 使用 __internal_foo 而不是 foo

宏展开

parse_macro_input! 被展开成以下 match 语句:

let input = match ::syn::parse_macro_input::parse::<syn::DeriveInput>(input) {
    ::syn::__private::Ok(data) => data,
    ::syn::__private::Err(err) => {
        return ::syn::__private::TokenStream::from(err.to_compile_error());
    }
};

它是个声明宏,所做的事情很简单,解析成功则取出数据,解析失败则直接返回错误。

它有两种语法:

  • ($tokenstream:ident as $ty:ty) 或者 ($tokenstream:ident):即把实现 Parse trait 的类型放到宏的语法内,或者放到模式语法上
let input = syn::parse_macro_input!(input as syn::DeriveInput);
let input: syn::DeriveInput = syn::parse_macro_input!(input); // 或者在 binding 时指明类型
  • ($tokenstream:ident with $parser:path):使用具有 Parser trait 的类型,这个 trait 专门为函数而设计, 函数签名满足 FnOnce(ParseStream) -> Result<T> 即可。

此外,你肯定会好奇这里的 syn::__private 模块,它没什么特别的,里面是 std、quote、proc_macro2、proc_macro 和 syn 其他模块的 reexport,这印证了第一点的建议:在宏中使用绝对路径。

你还需要观察到以下三点:

  • ::syn::parse_macro_input::parse 这个内部函数与 syn::parse 函数几乎别无二致,都把 proc_macro::TokenStream 转化成 实现了 Parse trait 的类型。但它们的唯一的区别在于, ::syn::parse_macro_input::parse 增加支持解析 AttributeArgs,此类型用在解析属性宏参数上。
  • Err(err) 中的 errsyn::parse::Error 类型,也是 syn::Error 类型。因为这两个 Error 类型都是从 syn::error 私有模块的重导出。 syn::Resultsyn::parse::Result 也是同一类型,因为它们是类型别名 + 重导出。
  • 这个宏使用的另一个限制条件:必须用在返回值为 proc_macro::TokenStream 类型的函数中。

const _ 技巧

最后的 const _ 部分是过程宏库自动添加的,它用于初始化过程宏。

这是一种不起眼的语法,你很少真正手写它,但它却是宏非常常用的技巧。它有正式的名称 —— unnamed const,具有很好的性质:可以重复定义。

无论是过程宏还是声明宏,你都会感受到这种写法给你提供便利:在只能定义 item 的地方,它给你一个编译期求值的局部作用域,在其中你可以给某个类型实现 trait,而且可以定义不影响源代码的临时 const 或 type alias 或数据结构。

proc-macro-workshop 的最后一个案例 bitfield 中,你势必会使用这种技巧。比如这样或者这样

Parse trait

Parse 是 syn 里对外用途最广泛的 trait (之一,另一个对外被广泛使用的 trait 是 quote::ToTokens)。

它很简单,因为只有一个方法:

pub trait Parse: Sized {
    fn parse(input: ParseStream<'_>) -> Result<Self>;
}

syn 根模块中(syn::*)的大多数结构体和枚举体都实现了此 trait,其功能就是把语法标记的缓冲流解析成语法树。

一个典型的使用方法如下:

// src: https://docs.rs/syn/latest/syn/parse/index.html#example
use syn::{
   braced,
   parse::{Parse, ParseStream},
   punctuated::Punctuated,
   token, Field, Ident, Result, Token,
};
struct ItemStruct {
    struct_token: Token![struct],
    ident: Ident,
    brace_token: token::Brace,
    fields: Punctuated<Field, Token![,]>,
}

impl Parse for ItemStruct {
    fn parse(input: ParseStream) -> Result<Self> {
        let content;
        Ok(ItemStruct {
            struct_token: input.parse()?,
            ident: input.parse()?,
            brace_token: braced!(content in input),
            fields: content.parse_terminated(Field::parse_named)?,
        })
    }
}

这是 syn::ItemStruct 实现 Parse 的一个简要版本,你只需要使用 input.parse()? 就能解析一个语法标记。这得益于 Rust 的泛型和 syn 给你写好的基础解析类型及其 Parse 实现。

这段样例代码不难看懂,但里面的细节得琢磨琢磨。

Token!braced!

这里涉及两个宏:Token!braced!,其中 braced!bracketed!parenthesized! 用法一致。

使用 cargo expand 展开得到:

struct ItemStruct {
    struct_token: ::syn::token::Struct,
    ident: Ident,
    brace_token: token::Brace,
    fields: Punctuated<Field, ::syn::token::Comma>,
}

impl Parse for ItemStruct {
    fn parse(input: ParseStream) -> Result<Self> {
        let content;
        Ok(ItemStruct {
            struct_token: input.parse()?,
            ident: input.parse()?,
            brace_token: match ::syn::group::parse_braces(&input) {
                ::syn::__private::Ok(braces) => {
                    content = braces.content;
                    braces.token
                }
                ::syn::__private::Err(error) => {
                    return ::syn::__private::Err(error);
                }
            },
            fields: content.parse_terminated(Field::parse_named)?,
        })
    }
}
  • syn::Token! 声明宏做的事情非常简单,把标点或者关键字直接替换成 syn::token 模块下对应的解析类型: 把 Token![struct] 替换成 ::syn::token::Struct,把 Token![,] 替换成 ::syn::token::Comma。任何需要写出 syn::token::xx 的地方,都可以使用这个宏,而无需记住其类型名称。
    作者为了展示这个功能“特别”的宏,特意使用了大写,以及方括号 []
  • syn::braced! 声明宏与 syn::parse_macro_input! 类似,被展开成 match 语句来处理 Result。但它需要一个预先声明的 content 变量,当 content 经过 braced! 处理之后,为 ParseStream 类型,用于解析花括号 {} 内部的标记(如这里的 fields)。
    bracketed!parenthesized! 也是一样的用法,区别在于后两者用在解析方括号 [] 和圆括号 () 上。

接下来,把目光放到主角 ParseStream 类型上。

ParseBufferParseStream

ParseStreamParseBuffer 的共享引用的类型别名:

type ParseStream<'a> = &'a ParseBuffer<'a>;

我们总是在 syn 的解析函数的函数签名中把 ParseStream 作为函数参数,而在解析函数内部,调用其方法时,应该参考 ParseBuffer 的文档。

此外,ParseStream 是共享引用,它实现了 Copy trait:任何使用它的地方,都是使用引用的复制品,而不具有移动语义。

ParseBuffer 表示缓冲标记流的游标位置,其背后的核心类型是 Cursor (后面会谈到这个类型)。

你无法在 syn 之外的代码构造 ParseBuffer,只通过三条公开的解析入口接触到此类型:

  1. parse_macro_input! 用于解析过程宏的输入;
  2. syn::parse* 函数用于中途解析语法;
  3. Parser trait 用于抽象所有解析函数。

它的文档非常友好,这里只概括地浏览一遍:

位置方法名返回值类型1说明
推进parse(&self)Result<T>成功解析当前游标下的语法标记之后,把游标位置推进到下一个标记
推进call(&self, f)Result<T>与 parse 方法类似,但使用一个解析函数 f
不变peek(&self, t)bool判断下一个标记是否是 t
不变peek2(&self, t)bool判断下下一个标记是否是 t
不变peek3(&self, t)bool判断下下下一个标记是否是 t
不变lookahead1(&self)Lookahead1可调用 .peek() 判断下一个标记是否是一组标记中的某一个,
如果不是,可调用 .error() 方法来返回解析错误
不变fork(&self)Self不建议使用;其实质是将 Self 复制(游标的复制成本很低),但需要注意解析成本;
此方法可搭配 Speculative trait
不变is_empty(&self)bool判断解析流是否还有待解析的标记
不变span(&self)Span如果解析流导了末尾(无待解析的标记),返回 Span::call_site() (过程宏在源码的位置);
否则返回当前游标下(即下一个标记)的 Span
不变error(&self, message)syn::Error在当前游标位置上返回错误信息
不变cursor(&self)Cursor底层的解析 API;复制当前游标位置,对其返回的游标做任何操作都不会影响解析流的游标
结束parse_terminated(&self, f)Result<Punctuated<T, P>>从当前游标解析直到解析流的末尾,解析成 0 次或多次 T,每个 T 的分隔符为 P,
且最后的分隔符可以出现也可以不出现
推进step(&self, f)Result<R>强大但底层的解析 API,但在 syn 之外很少使用(因为你很少直接使用 Cursor):
f 类似于 fn(Cursor) -> Result<(R, Cursor)> 形式即可;解析成功将自动推进游标
1

T 和 P 都为实现了 Parse 的类型,即 T: ParseP: ParseR 可为任意类型。

你可以观察到,这些方法都只有 &self,而没有 &mut self,而推进游标理应需要独占引用。这是因为 ParseBuffer 采用了“内部可变性”设计,其背后使用了 Cell

Parser trait

Parser 是 syn 的解析函数抽象,功能是把标记流转化成语法树节点。

对 syn 的使用者来说,它可以用在 parse_macro_input! 上,比如文档给的例子

但除此之外,你很少直接使用它的方法,也很少关注哪些函数实现了 Parser trait。比如 Parse trait 的唯一方法就实现了 Parser trait,但你 不必写 ItemStruct::parse.parse2(token_stream)

虽然 Parser 就是对 TokenStream -> T 的抽象(TokenStream 来自于过程宏的函数输入参数),而以下实现

impl<F, T> Parser for F
  where F: FnOnce(ParseStream<'_>) -> Result<T> 

把这种数据类型的变换转化成 ParseStream -> T,从而 syn 的大部分解析方式都只针对和基于 ParseStream (即 ParseBuffer)。

以至于,即使以下函数实现了 Parser trait,你也几乎只在给某类型实现 Parse trait 时使用它们2

  • syn::punctuated::Punctuated::parse_terminated
  • syn::punctuated::Punctuated::parse_separated_nonempty
  • syn::Attribute::parse_outer
  • syn::Attribute::parse_inner
  • syn::Field::parse_named
  • syn::Field::parse_unnamed
  • syn::Block::parse_within
2

比如上述例子content.parse_terminated()


你可能会发现 syn 的文档中有直接使用 Parser trait 方法的例子

那的确是一种写法,但缺点也显而易见:Parser 所定义的方法需要消耗 TokenStream 的所有权。

这意味着每次解析一部分标记,你需要复制一次标记流(复制 TokenStream 的成本比较高,所以才会有 ParseBuffer —— 或者说 syn::buffer)。

总而言之,ParseParser 更面向使用者,它们代表不同的抽象。

syn::parse* 函数

基于 ParseStream 并不是 syn 唯一的解析途径。syn 提供以下解析函数(重点在函数参数上):

函数说明
fn parse<T: Parse>(tokens: TokenStream) -> Result<T>proc_macro::TokenStream 中直接解析成 T
fn parse2<T: Parse>(tokens: TokenStream) -> Result<T>proc_macro2::TokenStream 中直接解析成 T
fn parse_str<T: Parse>(s: &str) -> Result<T>从字符串中解析成类型 T
fn parse_file(content: &str) -> Result<File>从字符串中解析成类型 File

它们背后都涉及 Parser trait,可以看做 Parser trait 的用户级接口。 parseparse2 的区别在于 TokenStream 的来源库不同,通常我们使用 2 后缀区分来自 proc_macro2 的内容,尤其是以下写法:

use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;

parseparse_macro_input! 的区别已经在宏展开部分说过了,后者可以解析属性宏的参数 AttributeArgs

use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn my_attribute(args: TokenStream, input: TokenStream) -> TokenStream {
    let args = syn::parse_macro_input!(args as syn::AttributeArgs);
    /* ... */
}

parse_quote!parse_quote_spanned!

回顾一下 quote::quote! 的功能:

  • 它是一个声明宏,其展开的结果是 proc_macro2::TokenStream 类型;
  • 它有自己的插值语法 #( #val ),*,其中 , 是可选的任意分隔符,且 val 满足以下一个条件即可:
    • val 实现了 Iterator trait,且 Iterator::Item 实现了 ToTokens trait
    • val 是 VecsliceBTreeSet,且其元素实现了 ToTokens trait

quote::quote_spanned!quote! 增加了 span=> 语法,即给生成的标记附带自定义的 Span

parse_quote!quote! 的不同之处在于,parse_quote! 的展开结果为 TT 满足以下一种情况:

  • T 实现了 Parse trait
  • TAttributePunctuated<T, P>Vec<Stmt>

它的名字听起来有点奇怪,但你看它的实现,就非常容易理解什么叫做 parse + quote!

// src: https://docs.rs/syn/latest/src/syn/parse_quote.rs.html#70-74
macro_rules! parse_quote {
    ($($tt:tt)*) => {
        $crate::parse_quote::parse($crate::__private::quote::quote!($($tt)*))
    };
}

// $crate::parse_quote::parse 函数定义如下
// Not public API.
#[doc(hidden)]
pub fn parse<T: ParseQuote>(token_stream: proc_macro2::TokenStream) -> T {
    let parser = T::parse;
    match parser.parse2(token_stream) {
        Ok(t) => t,
        Err(err) => panic!("{}", err),
    }
}

// Not public API.
#[doc(hidden)]
pub trait ParseQuote: Sized {
    fn parse(input: ParseStream) -> Result<Self>;
}

impl<T: Parse> ParseQuote for T {
    fn parse(input: ParseStream) -> Result<Self> {
        <T as Parse>::parse(input)
    }
}

impl ParseQuote for Attribute { /* 省略 */}
impl<T: Parse, P: Parse> ParseQuote for Punctuated<T, P> { /* 省略 */ }
impl ParseQuote for Vec<Stmt> { /* 省略 */ }

理解了 parse_quote!quote! 之间的区别,那么就能理解 parse_quote_spanned!quote_spanned! 的区别:它们只在返回类型上不同。

但,这有什么意义呢?

大部分情况下你只需关注和使用 quote!,因为它的目的是生成语法标记,也是生成 proc_macro::TokenStream 的最常见方式。(忽略 proc_macro::TokenStreamproc_macro2::TokenStream 之间的差异,它们之间的转化只需要 from-into

有些情况下,当你真正需要构造 syn 的某种类型时,尽管那种类型的字段都是公开的,但你很少直接用结构体语法构造它们。

假设你想构造闭包表达式 || a + b,它的直接类型是 ExprClosure,你的函数签名需要 Expr,它们之间转化只需要 from-into,但你如何得到 ExprClosure

它有 9 个字段,而且 PathPunctuated 类型手动构造起来有些繁琐。

有了 parse_quote!,你只需要 let d: syn::Expr = parse_quote! { || a + b }; 一行语句即可。

它完整的展现如下(点击右上角的取消隐藏看到完整内容):

Expr::Closure(
   ExprClosure {
       attrs: [],
       asyncness: None,
       movability: None,
       capture: None,
       or1_token: Or,
       inputs: [],
       or2_token: Or,
       output: Default,
       body: Binary(
           ExprBinary {
               attrs: [],
               left: Path(
                   ExprPath {
                       attrs: [],
                       qself: None,
                       path: Path {
                           leading_colon: None,
                           segments: [
                               PathSegment {
                                   ident: Ident {
                                       sym: a,
                                   },
                                   arguments: None,
                               },
                           ],
                       },
                   },
               ),
               op: Add(
                   Add,
               ),
               right: Path(
                   ExprPath {
                       attrs: [],
                       qself: None,
                       path: Path {
                           leading_colon: None,
                           segments: [
                               PathSegment {
                                   ident: Ident {
                                       sym: b,
                                   },
                                   arguments: None,
                               },
                           ],
                       },
                   },
               ),
           },
       ),
   },
)

在我没有真正搞清楚这几个宏的区别之前,我写了这个例子 struct_new,为了做同样一件事,它有两个版本:完全不使用 parse_quote! 和尽可能使用 parse_quote!

然而,真正去构建具体的解析类型是必要的吗?

你的函数可能只需要(或者返回) proc_macro2::TokenStream 类型就好了,不必把这些标记转来转去: parse_quote! 只不过是把语法用 quote! 转成 proc_macro2::TokenStream,然后用 Parser 再转成你要的类型。

请相信 quote! 足够聪明,产生你想要的语法标记。别忘了它的特点 “quasi-quoting”。

buffer

syn::buffer 模块只有两个结构体:

  • TokenBuffer 高效多次遍历标记流的缓冲标记流,只有三个公有方法:
    • fn new(stream: proc_macro::TokenStream) -> Self 构造缓冲
    • fn new2(stream: proc_macro2::TokenStream) -> Self 构造缓冲
    • fn begin(&self) -> Cursor<'_> 从缓冲的第一个标记位置上产生游标,我们就是利用这个游标遍历缓冲标记流
  • Cursor 高效复制的游标,是不可变数据(缓冲标记流)的共享引用,其背后是裸指针。
    • 它的亮点是实现了 Copy trait,,这意味着你可以隐式复制来驻足在这个游标上。
    • empty 方法创建空游标之外,其余所有方法都是 fn xx(self) -> .. 形式。且大部分方法是 fn xx(self) -> Option<(.., Self)> 形式,这意味着每次调用其方法,都是把这个游标消耗掉,然后得到下一个标记的游标。
    • 两个游标可以比较相等:当它们在同一个标记流中位置相同,且 Span 相同时,两个游标相等。
    • 它属于低层级 API,其大部分方法与 proc_macro2 的数据结构有关,所以你想使用它,得掌握 TokenTree

通常,你很少关注到这两个类型,但它们在 syn 中作为底层数据结构被使用。

一个很好的案例是 proc-macro-workshop 的 Seq,你可以参考我的解答

visit | visit_mut | fold

这三个模块都是在遍历语法树,且默认以递归形式遍历,它们之间的区别与所有权概念对应:

// 以共享引用方式遍历语法树节点
pub trait Visit<'ast> {
    fn visit_expr_binary(&mut self, node: &'ast ExprBinary) {
        visit_expr_binary(self, node);
    }

    /* ... */
}

// 以独占引用方式遍历语法树节点
pub trait VisitMut {
    fn visit_expr_binary_mut(&mut self, node: &mut ExprBinary) {
        visit_expr_binary_mut(self, node);
    }

    /* ... */
}

// 以所有权方式遍历语法树节点
pub trait Fold {
    fn fold_expr_binary(&mut self, node: ExprBinary) -> ExprBinary {
        fold_expr_binary(self, node)
    }

    /* ... */
}

visit 的方式可以在遍历语法树时,把某种节点类型的引用都提取出来; visit_mut 可以在遍历时修改节点类型;而 fold 可以遍历时消耗某种节点类型来生成这种节点类型。

它们的文档都有最简代码样例,很好理解。其他案例可参考