精通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 的各个字段进行求和的效果(不过这里要注意,只是简单地用 + 连接可能并不适用于所有字段类型,比如如果字段类型不是数字类型,就需要进一步调整这个求和的逻辑)。

最后总结

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

相关推荐
SomeB1oody15 小时前
【Rust自学】4.1. 所有权:栈内存 vs. 堆内存
开发语言·后端·rust
SomeB1oody1 天前
【Rust自学】4.2. 所有权规则、内存与分配
开发语言·后端·rust
SomeB1oody1 天前
【Rust自学】4.5. 切片(Slice)
开发语言·后端·rust
编码浪子2 天前
构建一个rust生产应用读书笔记6-拒绝无效订阅者02
开发语言·后端·rust
baiyu332 天前
1小时放弃Rust(1): Hello-World
rust
baiyu332 天前
1小时放弃Rust(2): 两数之和
rust
Source.Liu2 天前
数据特性库 前言
rust·cad·num-traits
编码浪子2 天前
构建一个rust生产应用读书笔记7-确认邮件1
数据库·rust·php
SomeB1oody2 天前
【Rust自学】3.6. 控制流:循环
开发语言·后端·rust
Andrew_Ryan2 天前
深入了解 Rust 核心开发团队:这些人如何塑造了世界上最安全的编程语言
rust