Rust 的宏系统

Rust 的宏系统提供了一种在编译期生成代码的机制,用于减少重复代码、自动实现 trait、扩展语言语法等用途。宏不通过常规的函数调用方式运行,而是在编译过程中由编译器对源代码进行展开和替换,从而提高代码的可维护性和抽象能力。

Rust 中的宏主要分为两类:声明式宏和过程宏。

声明式宏使用 macro_rules! 定义,它通过匹配输入模式并生成代码,适用于结构较为固定的代码生成。例如:

macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}

这里定义了一个名为 add 的宏,接受两个表达式作为输入,并生成将这两个表达式相加的代码。在使用时,对于 add!(1, 2),宏会展开为 1 + 2

声明式宏是 hygienic(词法隔离的),这意味着它们在展开时不会意外地捕获或污染调用者作用域中的变量。编译器会自动处理变量作用域和名称冲突的问题,以避免宏展开时引入不可预期的副作用。

过程宏提供了比声明宏更强的代码生成能力,基于 Rust 的语法树(TokenStream)操作,可以分析和重写输入代码结构。过程宏需要定义在一个标注了 proc-macro 属性的独立 crate 中,并以编译器插件的方式参与编译过程。例如:

#[derive(Debug)]
struct User {
name: String,
age: u32,
}

这段代码使用了编译器提供的 Debug 派生宏,在编译时为 User 自动生成 impl Debug for User 的代码,从而支持调试输出。这种自动代码生成在大型项目中可以显著减少样板代码,提高开发效率。

与声明宏不同,过程宏是 unhygienic(不具备词法隔离的),宏生成的代码可能与调用者作用域中的名称产生冲突。因此,在编写过程宏时需要开发者自行处理作用域和标识符命名问题。

Rust 的宏系统在保证类型安全和语法完整性的前提下,为开发者提供了灵活的元编程工具。理解宏的基本机制,是深入掌握 Rust 高级抽象能力的基础。

过程宏的三种类型

Rust 的过程宏是一种基于语法树的代码生成机制,在编译期间运行,用于分析和扩展用户代码。与声明宏不同,过程宏允许开发者以结构化的方式解析输入代码、执行逻辑处理,并生成新的代码结构。过程宏只能定义在单独的 proc-macro crate 中,并通过特殊的属性进行注册。

过程宏分为三种类型:派生宏、属性宏和函数宏。它们在语法形式和适用范围上有所不同,但本质上都是通过处理 TokenStream 实现的。

派生宏

派生宏通过 #[derive(...)] 属性使用,主要用于自动为结构体或枚举生成 trait 的实现。这是最常见也是最容易使用的一类过程宏,常见于调试输出、克隆、序列化、反序列化等场景。

例如,下面这段代码使用了标准库提供的两个派生宏:

#[derive(Debug, Clone)]
struct User {
name: String,
age: u32,
}

Debug 会为 User 自动生成一个 impl std::fmt::Debug for User 实现,使得我们可以使用 println!("{:?}", user) 输出该结构体内容;Clone 则会生成克隆整个结构体的方法。通过派生宏,开发者可以避免大量重复性的样板代码。

除了标准库中的派生宏,用户也可以定义自己的派生宏。例如,假设有一个 #[derive(MyTrait)],它可以通过 #[proc_macro_derive(MyTrait)] 注册,并在编译期为结构体自动生成 impl MyTrait for ... 的代码。派生宏的输入是类型定义本身,输出是一个或多个 trait 实现代码块。

派生宏的输入语法结构相对固定,因此适合自动实现 trait,这也是许多库(如 serdesynthiserror)广泛使用派生宏的原因。

属性宏

属性宏使用自定义的属性标记来修饰函数、类型、模块等代码项。其语法形式通常为 #[name(...)]#[name],比派生宏更灵活,适用范围更广。属性宏可以分析和重写所修饰的代码块,对代码行为进行扩展或替换。

例如:

#[route(GET, "/")]
fn index() {
// ...
}

这个例子中,route 是一个自定义的属性宏,用于 Web 框架中,将函数 index 注册为处理 GET 方法和路径为 / 的 HTTP 请求。该宏可以读取属性内容 GET, "/",分析函数签名,并生成相应的注册逻辑。属性宏可以附加在函数、结构体、枚举、模块甚至外部项上。

实现属性宏时,需要使用 #[proc_macro_attribute] 注册,并实现一个接收两个 TokenStream 参数的函数:第一个参数是属性的内容(如 GET, "/"),第二个是被修饰的代码体。宏的任务是根据这两个输入返回新的代码。

由于属性宏可以改变输入代码的结构甚至含义,因此在框架开发和元编程中非常有用,例如路由注册、测试注入、状态管理等。

函数宏

函数宏的使用形式类似于普通函数调用,例如 my_macro!(...),但它的行为是在编译期展开。函数宏通常用于生成较复杂的结构化代码,或者定义领域特定语言(DSL)。

例如,yew 框架中定义了一个 HTML DSL,用于在 Rust 中构建 Web 前端:

html! {
<div>{ "Hello" }</div>
}

这里的 html! 是一个函数宏。它接收一段 HTML 形式的输入,解析后生成表示虚拟 DOM 的 Rust 代码。这种做法允许开发者在保持 Rust 类型安全的前提下,使用类 HTML 的语法构建组件。

实现函数宏时,使用 #[proc_macro] 进行注册。与属性宏不同,它只接收一个输入参数,即宏调用的参数内容。宏可以自由解析输入结构、处理语义,并生成任意合法的 Rust 代码作为输出。

函数宏的优势在于输入形式灵活、输出能力强,适合构建内部 DSL、代码模板系统等。不过,由于它不附着在特定代码项上,相比属性宏更容易失去上下文,因此解析和错误提示的难度也相对更高。

这三种过程宏形式各有侧重:派生宏用于 trait 实现,属性宏用于代码修饰和改写,函数宏用于结构化代码生成和 DSL 构造。理解它们的语法形式和适用场景,是掌握 Rust 宏系统的关键。

过程宏的开发流程和原理

过程宏依赖编译器插件机制,在编译阶段读取、分析并生成代码,本节将介绍过程宏的基本开发流程及其背后的运行原理。

开发流程

第一步,创建过程宏专用的 crate。

过程宏必须被定义在一个独立的 crate 中,并在其中启用特殊配置。

cargo new my_macro --lib

然后打开 my_macro/Cargo.toml,添加:

[lib]
proc-macro = true

这一步声明该 crate 是一个过程宏 crate,使其可以被 Rust 编译器识别并在编译阶段调用。

第二步,引入必要的依赖。

需要两个常用库:

  • syn:将 TokenStream 解析为结构化语法树
  • quote:用于生成新的 Rust 代码

Cargo.toml 中加入依赖:

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"

这些库是过程宏开发的标准工具,基本所有宏都离不开它们。

第三步,编写一个宏函数并注册。

src/lib.rs 中,定义一个宏函数,并使用属性将它注册给编译器:

use proc_macro::TokenStream;

#[proc_macro]
pub fn my_macro(_input: TokenStream) -> TokenStream {
"fn generated() { println!(\"Hello from macro!\"); }"
.parse()
.unwrap()
}

这里创建了一个最简单的函数宏,它会生成如下代码:

fn generated() {
println!("Hello from macro!");
}

虽然简单,但这个宏已经完整实现了输入处理、代码生成与输出。

第四步,在另一个 crate 中使用该宏。

过程宏只能在其他 crate 中使用,不能在定义它的 crate 自身中使用。新建一个二进制项目作为调用者:

cargo new macro_test

修改 macro_test/Cargo.toml,将过程宏作为依赖引入:

[dependencies]
my_macro = { path = "../my_macro" }

然后在 main.rs 中调用宏:

use my_macro::my_macro;

my_macro!();

fn main() {
generated();
}

运行结果为:

Hello from macro!

这样,一个最简单的过程宏使用就完成了。

需要注意的是,过程宏生成的代码在编译后会直接嵌入到最终二进制中。对于商业软件,如果生成的代码包含敏感逻辑(如加密算法、许可证校验等),建议使用 Virbox Protector 对二进制进行加固,防止反编译和代码篡改。它能有效保护生成的代码逻辑不被逆向分析,尤其适合与 Rust 的元编程能力结合使用。

实现一个自动生成 Builder 的宏

本节通过一个小项目了解整个过程。

我们希望用户这样写:

#[derive(Builder)]
struct Command {
executable: String,
args: Vec<String>,
}

然后自动生成:

impl Command {
pub fn builder() -> CommandBuilder {
CommandBuilder { executable: None, args: None }
}
} pub struct CommandBuilder {
executable: Option<String>,
args: Option<Vec<String>>,
} impl CommandBuilder {
pub fn executable(mut self, val: String) -> Self {
self.executable = Some(val);
self
} pub fn args(mut self, val: Vec<String>) -> Self {
self.args = Some(val);
self
} pub fn build(self) -> Result<Command, &'static str> {
Ok(Command {
executable: self.executable.ok_or("missing field")?,
args: self.args.ok_or("missing field")?,
})
}
}

第一步,创建一个新的 crate builder_derive,设置为 proc-macro = true

在项目根目录中使用命令新建宏 crate:

cargo new builder_derive --lib

Cargo.toml 中添加以下配置:

[lib]
proc-macro = true

只有设置了 proc-macro = true,才能定义如 #[proc_macro_derive(...)] 的宏。

第二步,引入 synquote 依赖。

修改 Cargo.toml,添加如下依赖:

[dependencies]
proc-macro2 = "1.0"
syn = { version = "2.0", features = ["full"] }
quote = "1.0"

proc-macro2proc_macro 的稳定包装器,syn 用于解析 Rust 代码为 AST,启用 syn"full" 特性是为了支持完整的结构体解析,quote 用于生成 Rust 代码。

第三步,使用 syn 解析输入 struct 的字段信息。

lib.rs 中定义一个宏入口:

extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput}; #[proc_macro_derive(Builder)]
pub fn builder_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput); // 后续的生成逻辑
TokenStream::new()
}

此时已经把 TokenStream 转换为 DeriveInput,它代表一个 struct, enumunion 的定义。

倘若用户写了:

#[derive(Builder)]
struct Command {
executable: String,
args: Vec<String>,
}

我们可以从 input.data 中提取字段列表,类似下面的提取字段:

let fields = match &input.data {
syn::Data::Struct(data) => &data.fields,
_ => panic!("Builder can only be derived for structs"),
}; let field_names: Vec<_> = fields.iter().filter_map(|f| f.ident.as_ref()).collect();

第四步,使用 quote 构建 builder struct 和方法实现。

我们要生成:

  • 一个 CommandBuilder struct,其中字段为 Option<T>
  • 每个字段的 setter() 方法。
  • 一个 build() 方法,构造原始 struct。

示例代码:

let struct_name = &input.ident;
let builder_name = syn::Ident::new(
&format!("{}Builder", struct_name.to_string()),
struct_name.span(),
); let builder_fields = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! { #name: std::option::Option<#ty> }
}); let builder_setters = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! {
pub fn #name(mut self, val: #ty) -> Self {
self.#name = Some(val);
self
}
}
}); let build_checks = fields.iter().map(|f| {
let name = &f.ident;
let err_msg = format!("{} is missing", name.as_ref().unwrap());
quote! {
#name: self.#name.ok_or(#err_msg)?
}
}); let expanded = quote! {
pub struct #builder_name {
#( #builder_fields, )*
} impl #builder_name {
#( #builder_setters )* pub fn build(self) -> std::result::Result<#struct_name, &'static str> {
Ok(#struct_name {
#( #build_checks, )*
})
}
} impl #struct_name {
pub fn builder() -> #builder_name {
#builder_name {
#( #field_names: None, )*
}
}
}
};

第五步,将生成的代码转为 TokenStream 并返回。

quote! 生成的 TokenStream2 转换为 TokenStream,返回即可:

TokenStream::from(expanded)

最终的 builder_derive 宏代码如下:

#[proc_macro_derive(Builder)]
pub fn builder_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput); // ... TokenStream::from(expanded)
}

至此,便已经构建了一个最小可用的过程宏,能自动生成构建器方法!

可以在另一个 crate 中使用这个宏,如下:

[dependencies]
builder_derive = { path = "../builder_derive" }

使用:

use builder_derive::Builder;

#[derive(Builder)]
struct Command {
executable: String,
args: Vec<String>,
}

常见问题和调试技巧

虽然 procedural macros 功能强大,但也可能带来调试困难、报错不清晰、行为意外等问题。

编译错误信息不清晰,难以定位问题

过程宏执行时是在编译阶段运行的,一旦发生 panic 或语法错误,编译器报错信息通常指向宏调用处,而不是宏实现的代码。这给调试带来困难。

此时可以在宏中主动添加 panic!()assert!() 来捕捉非法输入或未处理的结构,有助于定位错误逻辑。

match &input.data {
syn::Data::Struct(data) => data,
_ => panic!("Only structs are supported"),
}

也可以使用 compile_error! 生成用户可见的编译器错误,这比 panic 更友好,能在使用宏的源代码中生成清晰提示:

return quote! {
compile_error!("Builder can only be used with named structs");
}
.into();

看不到宏展开结果,不确定宏是否按预期生成代码

过程宏生成的代码是在编译器内部展开的,不直接可见,所以很难确认宏是否生成了正确的结构和方法。

cargo expand 可以查看宏展开的结果,安装和使用方式如下:

cargo install cargo-expand
cargo expand

这会打印宏调用后实际生成的完整 Rust 代码,便于对比、审查、调试。

结语

宏系统为 Rust 带来了强大的元编程能力,既可简化代码,也可构建类型安全的抽象,提升开发效率。尽管过程宏开发存在一定的复杂性,但通过实践逐步掌握这些工具,将使我们能更高效地构建灵活、健壮的 Rust 项目。

理解它、掌握它,就能真正驾驭 Rust 语言最强大的代码生成引擎,构建更加灵活的程序。

厌倦样板代码?用 Rust 过程宏自动生成!的更多相关文章

  1. java如何在eclipse编译时自动生成代码

    用eclipse写java代码,自动编译时,如何能够触发一个动作,这个动作是生成本项目的代码,并且编译完成后,自动生成的代码也编译好了, java编辑器中就可以做到对新生成的代码的自动提示? 不生成代 ...

  2. 代码自动生成工具MyGeneration之一(程序员必备工具)

    代码自动生成工具MyGeneration之一(程序员必备工具) 转 分类: C#2008-08-06 18:12 16064人阅读 评论(12) 收藏 举报 工具数据库相关数据库stringbrows ...

  3. 代码自动生成工具MyGeneration之一

    前段时间用C#做网站,用到了大量数据库相关的东西.网站采用3层结构,即数据访问层(Data Access Layer),业务逻辑层(Business Logic Layer),页面表现层().做了一段 ...

  4. 修改AssemblyInfo.cs自动生成版本号

    一. 版本号自动生成方法 1.把 AssemblyInfo.cs文件中的[assembly:AssemblyVersion("1.0.0.0")]改成[assembly:Assem ...

  5. .net 程序集自动生成版本号

    一. 版本号自动生成方法 只需把 AssemblyInfo.cs文件中的 [assembly:AssemblyVersion("1.0.0.0")]改成 [assembly:Ass ...

  6. JDBC学习笔记(6)——获取自动生成的主键值&处理Blob&数据库事务处理

    获取数据库自动生成的主键 [孤立的技术是没有价值的],我们这里只是为了了解具体的实现步骤:我们在插入数据的时候,经常会需要获取我们插入的这一行数据对应的主键值. 具体的代码实现: /** * 获取数据 ...

  7. IT轮子系列(二)——mvc API 说明文档的自动生成——Swagger的使用(一)

    这篇文章主要介绍如何使用Swashbuckle插件在VS 2013中自动生成MVC API项目的说明文档.为了更好说明的swagger生成,我们从新建一个空API项目开始. 第一步.新建mvc api ...

  8. [转载].net程序集自动生成版本号

    原文:http://hi.baidu.com/bcbgrand/item/a74a7ba71c3b0ea928ce9dce .net程序版本号的格式是4个十进制数字 比如 2.5.729.2 依次是 ...

  9. 如何使用ThinkPHP5 ,自动生成目录?

    具体步骤: A.在build.php中按照实际需求修改定义模块的内容: B.修改Public/index.php,在代码中加入: // 读取自动生成定义文件 $build = include '/.. ...

  10. Mybatis自动生成插件对数据库类型为text的处理

    2019独角兽企业重金招聘Python工程师标准>>> 如果数据库中的字段为text或者blob这种大文本类型,在使用MybatisGenerator工具自动生成代码的时候会将其进行 ...

随机推荐

  1. 进程间通信-POSIX 信号量

    POSIX 信号量 POSIX 信号量是一种 POSIX 标准中定义的进程间同步和互斥的方法.它允许进程之间通过信号量来实现临界区的互斥访问,从而避免竞争条件和死锁等问题. 信号量的P.V操作: P ...

  2. 【SQL周周练】:利用行车轨迹分析犯罪分子作案地点

    大家好,我是"蒋点数分",多年以来一直从事数据分析工作.从今天开始,与大家持续分享关于数据分析的学习内容. 本文是第 7 篇,也是[SQL 周周练]系列的第 6 篇.该系列是挑选或 ...

  3. Boost库简单介绍

    c++ boost库官网 https://www.boost.org/ 官网最新版文档说明 https://www.boost.org/doc/libs/1_70_0/ Boost库是一个可移植.提供 ...

  4. Seata源码—9.Seata XA模式的事务处理

    大纲 1.Seata XA分布式事务案例及AT与XA的区别 2.Seata XA分布式事务案例的各模块运行流程 3.Seata使用Spring Boot自动装配简化复杂配置 4.全局事务注解扫描组件的 ...

  5. 使用Spring AOP 和自定义注解统一API返回值格式

    摘要:统一接口返回值格式后,可以提高项目组前后端的产出比,降低沟通成本.因此,在借鉴前人处理方法的基础上,通过分析资料,探索建立了一套使用Spring AOP和自定义注解无侵入式地统一返回数据格式的方 ...

  6. 如何优雅的关闭channel?

    一.channel使用存在的不方便地方 1.在不改变channel自身状态的情况下,无法获知一个channnel是否关闭. 2.关闭一个已经关闭的channel,会导致panic.因此,如果关闭cha ...

  7. K&R 语法 vs. ANSI C 语法

    由于项目中使用了Bison,看到有个奇怪的C语言的语法,查了一下居然是要兼容早期的C标准 Bison 是什么? Bison 是一个 语法分析器生成器(parser generator),它用于根据 上 ...

  8. HarmonyOS运动开发:如何选择并上传运动记录

    ##鸿蒙核心技术##运动开发##Core File Kit(文件基础服务) 前言 在运动类应用中,能够快速导入和分析其他应用的运动记录是一个极具吸引力的功能.这不仅为用户提供便利,还能增强应用的实用性 ...

  9. AI接口实现:简单实现Viper配置管理

    简介 前面实现的一个简易suno-api.是使用cookie来获取suno-token发起请求的.当时并没有通过配置的方式来获取cookie,而是直接在代码中写死了cookie的值,这种做法并不好,所 ...

  10. springboot中mybatis报错

    反正有关于mybatis报错的,问题肯定就是mybatis这几个文件之中. 要么就是Mapper类少注解,要么就是mybatis配置文件中的namespace java.lang.IllegalArg ...