精通rust宏系列教程-调试过程宏

Rust过程性宏是该语言最令人兴奋的特性之一。它们让你能够在编译时注入代码,但与单态泛型所使用的方法不同。使用非常特殊的包(crate),让你可以完全从头开始构建新代码。本文从简单示例开始,逐步分解,也会详细介绍相关依赖包。

构建过程派生宏

过程宏的运行原理非常简单:获取一段称为输入TokenStream的代码,将其转换为抽象语法树(ast),该树表示该代码段的编译器内部结构,从输入处获得的内容(使用syn::parse() 方法)构建一个新的TokenStream,并将其作为输出代码注入编译器。

  • 使用派生宏语法
rust 复制代码
#[derive()]

举例,支持输出的Debug派生宏,你应该很熟悉吧。

rust 复制代码
#[derive(Debug)]

从简单示例开始

假设你想创建了WhoAmI的派生宏,只是在派生宏代码中打印结构的名称,实际使用时代码:

rust 复制代码
#[derive(WhoAmI)]
struct Point {
    x: f64,
    y: f64
}

首先我们创建lib包项目,过程宏必须定义在独立的lib包中,在相同包中定义并调用会报错:can't use a procedural macro from the same crate that defines it

  • 创建lib 项目

    cargo new --lib whoami

  • 增加必要的依赖

proc-macro = true 表明时定义过程宏;可以通过 cargo add crate_name 方式增加,最终文件内容:

toml 复制代码
[lib]
proc-macro = true

[dependencies]
syn = { version = "1.0.82", features = ["full", "extra-traits"] }
quote = "1.0.10"
rust 复制代码
use proc_macro::TokenStream; // no need to import a specific crate for TokenStream
use syn::parse;

// 通过编译错误输出结构体名称
#[proc_macro_derive(WhoAmI)]
pub fn whatever_you_want(tokens: TokenStream) -> TokenStream {
    // 转化输入tokens为AST语法树
    let ast: syn::DeriveInput = syn::parse(tokens).unwrap();
	// ast.ident获取增加该派生宏的名称,如结构体名称
    panic!("My struct name is: <{}>", ast.ident.to_string());

    TokenStream::new()
}

由于不能使用常规的Rust宏在标准输出上打印出信息(如println!()),因此唯一的方法是panic输出消息,通过停止编译器并告诉编译器输出必要的信息。这不是很方便调试,也不容易完全理解过程宏的具体细节!

现在,我们创建可执行项目使用这个很棒的宏(不是很方便,因为它不会编译):

复制代码
cargo new demo

增加前面派生宏依赖:

toml 复制代码
[dependencies]
# 假设两个项目在相同目录,否则你需要修改path路径的内容
whoami = { path = "../whoami" }

修改main.rs代码:

rust 复制代码
// import our crate
use whoami::WhoAmI;

#[derive(WhoAmI)]
struct Point {
    x: f64,
    y: f64
}

fn main() {
    println!("Hello, world!");
}

cargo build 编译整个项目:

复制代码
error: proc-macro derive panicked
 --> src/main.rs:3:10
  |
3 | #[derive(WhoAmI)]
  |          ^^^^^^
  |
  = help: message: My struct name is: <Point>

可以看到编译器在过程宏中抛出我们定义的错误消息。

深入理解过程宏

至少可以这么说,前一种方法很笨拙,而且并没有让你理解如何真正利用过程性宏,因为暂时无法真正调试宏(尽管它将来可能会更改)。这就是proc-macro2存在的原因:你可以在单元测试中使用它的方法以及syn::parse2() 方法。然后你可以直接将生成的代码输出到stdout或将其保存为"*.Rs"文件以检查其内容。

让我们再通过一个稍复杂的过程宏示例来深入说明,该宏自动神奇地定义一个函数,用于计算Point结构中所有字段的总和。

创建二进制包:

复制代码
$ cargo new fields_sum

这个示例项目没有采用lib类型项目,主要希望底层让你理解每一步的过程,如果你跟着下面步骤读完,就很快能够独立编写自己实际可用的过程宏了。

增加依赖:

toml 复制代码
syn = { version = "1.0.82", features = ["full", "extra-traits"] }
quote = "1.0.10"
proc-macro2 = "1.0.32"

修改main.rs文件代码:

rust 复制代码
// necessary for the TokenStream::from_str() implementation
use std::str::FromStr;

use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::ItemStruct;

fn main() {
    // 以字符串方式给出结构体定义
    let s = "struct Point { x : u16 , y : u16 }";

    // 从字符串构建标识流
    let tokens = TokenStream::from_str(s).unwrap();

    // build the AST: 这里使用syn::parse2()方法
    // 这里通过方法解析,因此使用ItemStruct
    let ast: ItemStruct = syn::parse2(tokens).unwrap();

    // 获取结构体类型名称
    let struct_type = ast.ident.to_string();
    assert_eq!(struct_type, "Point");

    // 结构体字段数
    assert_eq!(ast.fields.len(), 2);

    // syn::Fields 实现了 Iterator trait, 因此可以迭代处理
    let mut iter = ast.fields.iter();

    // x
    let x_field = iter.next().unwrap();
    assert_eq!(x_field.ident.as_ref().unwrap(), "x");

    // y
    let y_field = iter.next().unwrap();
    assert_eq!(y_field.ident.as_ref().unwrap(), "y");

    // 下面是重要的部分: 使用 quote!() 宏生成新的代码,新的TokenStream

    // 首先构建函数名称: point_summation
    let function_name = format_ident!("{}_summation", struct_type.to_lowercase());

    // 如何未格式化,函数原型为:pub fn point_summation (pt : "Point")
    let argument_type = format_ident!("{}", struct_type);

    // 获取属性 x 和 y
    let x = format_ident!("{}", x_field.ident.as_ref().unwrap());
    let y = format_ident!("{}", y_field.ident.as_ref().unwrap());

    // quote!() 宏返回新的 TokenStream. 该TokenStream是过程宏返回给编译器的
    let summation_fn = quote! {
        pub fn #function_name(pt: &#argument_type) -> u16 {
            pt.#x + pt.#y
        }
    };

    // 输出Rust代码
    println!("{}", summation_fn);
}

输出结果:

复制代码
pub fn point_summation (pt : &Point) -> u16 { pt.x + pt.y }

这里我们解释下 ItemStruct 和 DeriveInput 两者的区别:

  • syn::ItemStruct

    1. 它代表一个结构体定义。在 Rust 编译器的抽象语法树(AST)表示中,ItemStruct用于描述结构体相关的信息。这包括结构体的名称、字段、可见性修饰符等。它主要用于处理普通的结构体定义代码,比如在分析现有结构体的结构或者进行简单的代码转换操作时使用。
    2. 例如,对于结构体定义struct MyStruct { field1: i32, field2: String }syn::ItemStruct可以用来解析这个结构体的名称MyStruct,以及它的两个字段field1field2的类型。
  • syn::DeriveInput

    1. 这个类型主要用于派生宏(derive macros)的场景。当编写一个派生宏,例如#[derive(Debug)]这样的宏时,DeriveInput用来接收和处理要应用派生宏的目标结构体或枚举的信息。它包含了更多与派生宏相关的上下文信息,如目标类型(结构体或枚举)、属性(attrs)等。
    2. 比如,当用户在一个结构体上使用#[derive(MyTrait)]syn::DeriveInput会获取这个结构体的所有信息,包括结构体本身的定义以及这个#[derive(MyTrait)]属性的相关信息,以便派生宏可以根据这些信息生成对应的MyTrait实现代码。
  • 使用TokenStreams

前面的例子很简单,因为我们事先知道了结构体中的字段数量。如果我们事先不知道呢?我们可以使用quote!()的特殊构造来生成所有字段的总和:

rust 复制代码
use std::str::FromStr;

use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::ItemStruct;

fn main() {
    // struct sample
    let s = "struct Point { x : u16 , y : u16 }";

    // create a new token stream from our string
    let tokens = TokenStream::from_str(s).unwrap();
    let ast: ItemStruct = syn::parse2(tokens).unwrap();
    // save our struct type for future use
    let struct_type = ast.ident.to_string();

    // first, build our function name: point_summation
    let function_name = format_ident!("{}_summation", struct_type.to_lowercase());
    let argument_type = format_ident!("{}", struct_type);

    // syn::Fields is implementing the Iterator trait, so we can iterate through the fields
    let tokens = ast.fields.iter().map(
        |field| {
            let fx = &field.ident;
            quote!(pt.#fx)
        }
    );

    let summation_fn = quote! {
        pub fn #function_name(pt: &#argument_type) -> u16 {
            0 #(+ #tokens)*
        }
    };

    // output our function as Rust code
    println!("{}", summation_fn);
}

这里要解释应该是 0 #(+ #tokens)* :

首先初始化为 0,然后 #(+ #tokens)* 是一种类似模式匹配和展开的语法。#tokens 就是前面通过 map 操作生成的一系列用于获取 pt 各个字段的代码片段。#(... )* 的含义是,对于括号内的内容(这里就是 + #tokens),会根据 tokens 集合中的元素数量进行多次展开。也就是说,它会把前面生成的每一个获取字段的代码片段 #tokens 都用 + 运算符与前面的结果(初始为 0)进行连接,从而实现对 pt 的各个字段进行求和的效果(不过这里要注意,只是简单地用 + 连接可能并不适用于所有字段类型,比如如果字段类型不是数字类型,就需要进一步调整这个求和的逻辑)。

最后总结

本文主要介绍过程宏的构建过程,如何调试、理解相关依赖包。有了这些基础知识,有助于理解并构建自定义过程宏。

相关推荐
m0_4805026417 小时前
Rust 入门 注释和文档之 cargo doc (二十三)
开发语言·后端·rust
盒马盒马1 天前
Rust:变量、常量与数据类型
开发语言·rust
傻啦嘿哟1 天前
Rust爬虫实战:用reqwest+select打造高效网页抓取工具
开发语言·爬虫·rust
咸甜适中2 天前
rust语言 (1.88) egui (0.32.1) 学习笔记(逐行注释)(十四)垂直滚动条
笔记·学习·rust·egui
张志鹏PHP全栈2 天前
Rust第四天,Rust中常见编程概念
后端·rust
咸甜适中2 天前
rust语言 (1.88) egui (0.32.1) 学习笔记(逐行注释)(十五)网格布局
笔记·学习·rust·egui
susnm3 天前
最后的最后
rust·全栈
bruce541104 天前
深入理解 Rust Axum:两种依赖注入模式的实践与对比(二)
rust
该用户已不存在5 天前
这几款Rust工具,开发体验直线上升
前端·后端·rust
m0_480502647 天前
Rust 入门 生命周期-next2 (十九)
开发语言·后端·rust