Rust:选择宏还是函数?

我们在进行 Rust 开发的时候,时常困惑,何时使用宏封装简化代码,何时使用函数呢?那下面本文就会对 宏的使用场景进行剖析,让大家一篇明白,什么情况下才会用宏,首先说结论:

宏与函数不是相互替代的关系,而是相互补充,分别有各自擅长的情景,合理运用才能写出优秀的 Rust 代码

那我们下面就来看看用宏的使用场景吧。

宏的分类

  1. 声明宏(Declarative Macros,macro_rules!
  2. 过程宏(Procedural Macros)
    • 自定义派生宏(Custom Derive Macros)
    • 属性宏(Attribute Macros)
    • 函数宏(Function-like Macros)

在 Rust 中,函数 都是用于代码复用和抽象的重要工具。函数 主要用于封装逻辑,处理已知数量和类型的参数,提供类型安全和可读性。而则用于在编译时生成代码,处理函数无法胜任的任务,如接受任意数量和类型的参数、代码生成、元编程等。


具体情景分析

1. 声明宏(macro_rules!

1.1 情景:处理可变数量和类型的参数

问题描述:

  • 函数在定义时必须指定参数的数量和类型,无法直接接受可变数量和类型的参数。
  • 需要一个机制来处理类似于 println! 这样的功能,可以接受任意数量和类型的参数。

宏的解决方案:

  • 声明宏可以使用模式匹配来接受任意数量和类型的参数。
  • 使用重复模式($()*)和元变量($var)来捕获参数列表。

示例代码:

rust 复制代码
// 定义一个可变参数的宏
macro_rules! my_println {
    ($($arg:tt)*) => {
        println!($($arg)*);
    };
}

fn main() {
    my_println!("Hello, world!");
    my_println!("Number: {}", 42);
    my_println!("Multiple values: {}, {}, {}", 1, 2, 3);
}

函数的局限性:

  • 函数无法定义接受任意数量和类型参数的签名。
  • 即使使用可变参数(变长参数列表),在 Rust 中也不直接支持,需要借助特定的特性,如 format_args!

宏和函数的协调:

  • 宏用于处理参数的收集和展开,然后调用底层的函数(如 println! 最终调用 std::io::stdout().write_fmt())。
  • 函数负责具体的逻辑执行,宏负责参数解析和代码生成。

1.2 情景:简化重复代码模式

问题描述:

  • 在代码中存在大量的重复模式,需要生成类似的代码结构,如测试用例、字段访问器等。
  • 手动编写这些代码容易出错,且维护成本高。

宏的解决方案:

  • 声明宏可以根据模式匹配,生成重复的代码结构。
  • 使用宏可以自动生成代码,减少手动编写的工作量。

示例代码:

rust 复制代码
// 定义一个宏,为结构体生成 getter 方法
macro_rules! generate_getters {
    ($struct_name:ident, $($field:ident),*) => {
        impl $struct_name {
            $(
                pub fn $field(&self) -> &str {
                    &self.$field
                }
            )*
        }
    };
}

struct Person {
    name: String,
    email: String,
}

generate_getters!(Person, name, email);

fn main() {
    let person = Person {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    };
    println!("Name: {}", person.name());
    println!("Email: {}", person.email());
}

函数的局限性:

  • 函数无法在定义时根据输入生成多个函数,需要手动为每个字段编写 getter 方法。
  • 无法在编译时自动生成代码,缺乏元编程能力。

宏和函数的协调:

  • 宏用于代码生成,创建具体的函数实现。
  • 函数作为最终的可调用实体,被宏生成出来。

1.3 情景:实现小型的嵌入式 DSL

问题描述:

  • 需要在代码中使用更自然或领域特定的语法,提高可读性和表达能力。
  • 希望直接在代码中嵌入类似其他语言的语法结构,如 HTML、SQL 等。

宏的解决方案:

  • 声明宏可以匹配特定的语法结构,生成对应的 Rust 代码。
  • 通过模式匹配和递归调用,构建嵌入式 DSL。

示例代码:

rust 复制代码
// 一个简单的 HTML DSL 宏
macro_rules! html {
    // 匹配带有内容的标签
    ($tag:ident { $($inner:tt)* }) => {
        format!("<{tag}>{content}</{tag}>", tag=stringify!($tag), content=html!($($inner)*))
    };
    // 匹配文本节点
    ($text:expr) => {
        $text.to_string()
    };
    // 匹配多个子节点
    ($($inner:tt)*) => {
        vec![$(html!($inner)),*].join("")
    };
}

fn main() {
    let page = html! {
        html {
            head {
                title { "My Page" }
            }
            body {
                h1 { "Welcome!" }
                p { "This is a simple HTML page." }
            }
        }
    };
    println!("{}", page);
}

函数的局限性:

  • 函数无法接受和解析自定义的语法结构,参数必须是合法的 Rust 表达式。
  • 无法通过函数调用实现类似的嵌套语法,代码会变得冗长且不直观。

宏和函数的协调:

  • 宏负责解析自定义的语法结构,将其转换为 Rust 代码。
  • 函数用于执行具体的逻辑,如 format!、字符串拼接等。

2. 过程宏

过程宏是一种更强大的宏,能够操作 Rust 的抽象语法树(AST),用于更复杂的代码生成和转换。主要分为三类:

  1. 自定义派生宏(Custom Derive Macros)
  2. 属性宏(Attribute Macros)
  3. 函数宏(Function-like Macros)

2.1 自定义派生宏

情景:为类型自动实现特征

问题描述:

  • 需要为多个类型自动实现某个特征(trait),避免手动编写重复的代码。
  • 自动生成实现代码,同时可能需要根据类型的属性进行定制。

宏的解决方案:

  • 自定义派生宏可以在编译时解析类型的定义,生成特定的代码实现。
  • 常见的例子包括 serde 的序列化和反序列化、CloneDebug 等特征的自动实现。

示例代码:

rust 复制代码
// 引入所需的宏支持
use serde::{Serialize, Deserialize};

// 使用自定义派生宏自动实现 Serialize 和 Deserialize
#[derive(Serialize, Deserialize)]
struct Person {
    name: String,
    age: u8,
}

fn main() {
    let person = Person {
        name: "Alice".to_string(),
        age: 30,
    };

    // 序列化为 JSON 字符串
    let json = serde_json::to_string(&person).unwrap();
    println!("Serialized: {}", json);

    // 反序列化回结构体
    let deserialized: Person = serde_json::from_str(&json).unwrap();
    println!("Deserialized: {} is {} years old.", deserialized.name, deserialized.age);
}

函数的局限性:

  • 函数无法在类型定义的基础上自动生成特征的实现。
  • 无法根据类型的字段和属性,生成对应的代码。

宏和函数的协调:

  • 自定义派生宏生成对应的特征实现代码。
  • 函数提供特征的默认实现或具体逻辑。

2.2 属性宏

情景:修改函数或类型的行为

问题描述:

  • 需要在编译时修改函数或类型的行为,例如自动添加日志、检测性能、注入代码等。
  • 希望通过注解的方式,简化代码的修改和维护。

宏的解决方案:

  • 属性宏可以附加在函数、类型或模块上,修改或生成新的代码。
  • 可以在编译时注入额外的逻辑,提高代码的灵活性。

示例代码:

rust 复制代码
// 定义一个简单的属性宏,用于在函数执行前后打印日志
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn log_execution(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(item as syn::ItemFn);
    let fn_name = &input.sig.ident;
    let block = &input.block;

    let expanded = quote::quote! {
        fn #fn_name() {
            println!("Entering function {}", stringify!(#fn_name));
            #block
            println!("Exiting function {}", stringify!(#fn_name));
        }
    };

    TokenStream::from(expanded)
}

// 使用属性宏
#[log_execution]
fn my_function() {
    println!("Function body");
}

fn main() {
    my_function();
}

函数的局限性:

  • 函数无法在自身定义外部修改其行为,必须手动添加日志代码。
  • 难以避免在多个函数中重复添加相同的逻辑。

宏和函数的协调:

  • 属性宏用于在编译时修改函数的定义,注入额外的代码。
  • 函数保持原有的业务逻辑,宏负责增强或修改其行为。

2.3 函数宏(Function-like Macros)

情景:创建自定义的语法或代码生成

问题描述:

  • 需要接受特定的输入格式,生成对应的代码,如初始化配置、生成路由等。
  • 希望在代码中使用类似函数调用的方式,传入自定义的参数格式。

宏的解决方案:

  • 函数宏可以接受 TokenStream 作为输入,解析并生成新的代码。
  • 适用于需要复杂解析和代码生成的场景。

示例代码:

rust 复制代码
// 定义一个函数宏,将字符串转换为大写
use proc_macro::TokenStream;

#[proc_macro]
pub fn make_uppercase(input: TokenStream) -> TokenStream {
    let s = input.to_string();
    let uppercased = s.to_uppercase();
    let output = quote::quote! {
        #uppercased
    };
    TokenStream::from(output)
}

// 使用函数宏
fn main() {
    let s = make_uppercase!("hello, world!");
    println!("{}", s); // 输出:HELLO, WORLD!
}

函数的局限性:

  • 函数无法在编译时解析字符串字面量并修改其内容。
  • 运行时修改字符串需要额外的开销,无法用于编译时常量。

宏和函数的协调:

  • 函数宏在编译时生成所需的代码或数据。

  • 函数在运行时使用这些代码或数据,完成具体的逻辑。

如何选择呢?

在实际开发中,应根据具体需求选择合适的工具:

  • 优先使用函数:当可以使用函数解决问题时,函数应作为首选,保证代码的可读性和维护性。
  • 合理使用宏:在函数无法满足需求的情况下,使用宏来解决问题,但应注意宏的复杂性和可能带来的调试困难。

函数不擅长但宏擅长的情景

  • 接受可变数量和类型的参数
  • 在编译时生成代码,避免重复
  • 创建嵌入式 DSL,处理自定义语法
  • 自动实现特征,为类型生成代码
  • 在编译时修改代码结构或行为

宏不擅长但函数擅长的情景

  • 处理复杂的业务逻辑:函数更适合编写复杂的算法和逻辑,宏在这方面不直观。
  • 类型安全和错误检查:函数有明确的类型签名,编译器可以进行类型检查,宏的类型安全性较弱。
  • 可读性和维护性:函数的代码结构清晰,宏可能因为展开后代码复杂而降低可读性。
  • 调试和测试:函数更容易进行单元测试和调试,宏的错误信息可能不直观。

根据上面的参考原则,基本可以对代码是选择用宏还是选择用函数有一个清醒的判断。Pomelo_刘金。转载请注明原文链接。感谢!相互结合使用,相信你也可以写出优秀的 Rust 代码。

相关推荐
liyinuo20172 小时前
嵌入式(单片机方向)面试题总结
嵌入式硬件·设计模式·面试·设计规范
代码中の快捷键3 小时前
java开发面试有2年经验
java·开发语言·面试
bufanjun0016 小时前
JUC并发工具---ThreadLocal
java·jvm·面试·并发·并发基础
SomeB1oody15 小时前
【Rust自学】4.1. 所有权:栈内存 vs. 堆内存
开发语言·后端·rust
Zhu_S W17 小时前
Java web的发展历史
面试·职场和发展
power-辰南1 天前
大厂 Java 架构师面试题全解析
java·前端·面试
重生之Java开发工程师1 天前
ArrayList与LinkedList、Vector的区别
java·数据结构·算法·面试
啥都想学的又啥都不会的研究生1 天前
高性能MySQL-查询性能优化
数据库·笔记·学习·mysql·面试·性能优化
m0_748256141 天前
前端图表与数据可视化 - 2024 年实战与面试重点
前端·信息可视化·面试
SomeB1oody1 天前
【Rust自学】4.2. 所有权规则、内存与分配
开发语言·后端·rust