随便讲讲 Rust 的 “宏”

最近在看《通过例子学 Rust》,真切感受到了 Rust 有多么难学设计有多么精巧。作为一门安全性和效率优先的编程语言,Rust 的语法个人感觉是半 C 半 Python,但结构上明显比二者都复杂。

在写第一个语句

1
println!("Hello, world!");

的时候,我很好奇为什么 println 后面要加一个感叹号 !。查阅后得知,这是一种 Rust

什么是宏?

在 Rust 中,宏(macros) 是一种元编程的工具,允许你在编译时对代码进行模式匹配、转换和生成。宏允许你编写一些特定的代码模板,在编译时根据这些模板生成具体的代码。这使得你可以编写更加灵活、通用和高效的代码。

其实说得简单点,这个宏和 C/C++ 中的宏定义是一个性质的,起到片段替换的一个作用。

Rust 中的宏有两种宏:声明式宏过程宏

声明式宏(declarative macros)

声明式宏也称为 macro_rules!,它允许你定义模式和替换规则,用于在代码中执行简单的文本替换。声明式宏类似于 C 语言中的宏替换,但更加强大和类型安全。声明式宏可以使用模式匹配、重复、递归等功能来定义宏,并且可以在宏中执行一些基本的代码转换。

声明式宏,顾名思义,是可以由用户自己声明的,声明时也可以调用其他已经声明好的宏。

比如我声明一个 say_hello 宏,让它去固定输出 Hello, World!

1
2
3
4
5
macro_rules! say_hello {
() => {
println!("Hello, World!");
};
}

然后我需要使用的时候,就这样子调用:

1
2
3
fn main() {
say_hello!();
}

执行结果就是这样的:

1
Hello, World!

对比:Rust 声明式宏 vs C 语言宏定义

  1. 模式匹配和重复

    Rust 的声明式宏允许使用模式匹配和重复来定义更复杂的模板。你可以使用 matchiffor 等语法来对输入的 token 流进行匹配和处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    macro_rules! print_range {
    ($start:expr, $end:expr) => {
    for i in $start..$end {
    println!("{}", i);
    }
    };
    }

    print_range!(1, 5);

    我可以直接在声明宏里面使用一个 for 循环,编译器预处理时候也知道这是个 for 循环。其实宏做到这样子,我个人感觉已经很接近函数了。

    C 语言的宏定义也支持简单的模式匹配和重复,但功能相对受限,只能进行简单的文本替换和展开

    1
    2
    3
    4
    5
    6
    #define PRINT_RANGE(start, end) \
    for (int i = start; i < end; i++) { \
    printf("%d\n", i); \
    }

    PRINT_RANGE(1, 5);

    预处理的时候,编译器并不知道这个宏定义里面的语句是一个 for 循环。它只知道预处理的时候等价替换就是的了。

    这样看来 Rust 的声明宏确实要更加灵活,我感觉已经可以成为半个函数了。

  2. 类型安全和错误检查:

    Rust 的声明式宏在编译时可以进行类型检查和错误检查,可以避免一些常见的错误。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    macro_rules! divide {
    ($numerator:expr, 0) => {
    panic!("division by zero");
    };
    ($numerator:expr, $denominator:expr) => {
    $numerator / $denominator
    };
    }

    let result = divide!(10, 0);

    C 语言的宏定义没有类型检查和错误检查,需要自己去写,像这样一个除数为 0 的简单情况,如果不做处理,可能会导致一些潜在的错误。

    1
    2
    3
    4
    #define DIVIDE(numerator, denominator) \
    (denominator == 0 ? (fprintf(stderr, "division by zero\n"), exit(EXIT_FAILURE), 0) : (numerator / denominator))

    int result = DIVIDE(10, 0);

    其实这个问题还是比较像第一条的,对于一些小的代码片段,也许我们不会太关注这个问题,但是一旦项目比较大,类型检查和错误检查就比较重要了。

  3. 代码生成和抽象:

    Rust 的声明式宏可以生成更复杂和灵活的代码,允许对输入进行更多的操作和转换。

    1
    2
    3
    4
    5
    6
    7
    macro_rules! vec_of_strings {
    ($($elem:expr),*) => {
    vec![$($elem.to_string()),*]
    };
    }

    let v = vec_of_strings!["hello", "world"];

    C 语言的宏定义主要用于文本替换,功能相对较弱,难以实现复杂的代码生成和抽象。

    1
    2
    3
    4
    #define VEC_OF_STRINGS(elem1, elem2) \
    {elem1, elem2}

    const char* v[] = VEC_OF_STRINGS("hello", "world");

其实这几条说到底也就是, Rust 的声明宏不像 C 的宏替换那样就是个简单的文本替换,它的功能更加丰富。

过程宏(procedural macros)

过程宏通常以函数的形式定义,并接收一个或多个输入 token 流,然后根据这些输入生成新的代码。过程宏可以在代码的语法树级别进行操作,因此可以实现更高级的代码转换和分析。

过程宏分为三种类型:

属性宏 (Attribute Macros)

属性宏允许你在代码上方使用类似于注解的语法来应用宏,比如

1
#[derive(Debug)]

就是一个常见的属性宏,用于自动为结构体或枚举类型实现 Debug trait。

函数宏(Function-like Macros)

函数宏类似于声明式宏,但完全以函数的形式定义,可以更灵活地处理输入 token 流并生成代码。

自定义派生宏 (Custom Derive Macros)

自定义派生宏允许编写用于自动实现 trait 或其他行为的宏,使得你可以为类型自动生成通用的实现代码。

刚刚提到的 print!() 就是一种过程宏。其实我也很好奇,几乎所有语言中,打印标准输出是被设计为一个函数的,而 Rust 为什么要采用宏这个设计方式。这个下文再聊。

为什么标准输出被定义为一个宏?

我们先来看一下 Rust 源码中对 print!() 宏的定义:

1
2
3
4
5
6
7
8
9
#[macro_export]
#[stable(feature = "rust1", since = "1.0.0")]
#[cfg_attr(not(test), rustc_diagnostic_item = "print_macro")]
#[allow_internal_unstable(print_internals)]
macro_rules! print {
($($arg:tt)*) => {{
$crate::io::_print($crate::format_args!($($arg)*));
}};
}

至于为什么 print 是个宏,Stack Overflow 上也有人提出了这个问题。我的理解是:

  1. 避免移动语义问题

    在 Rust 中,过程宏可以自动引用其参数,即使参数在调用宏的代码中已经被使用过,也不会产生移动语义的问题。这是因为过程宏在编译时操作代码的语法树,而不是在运行时处理值的拷贝和移动。

    对于普通的函数或方法,参数的所有权传递是显式的,一旦参数被使用,它就会被移动并在后续代码中不可再用。

    这实际上是一个 Rust 所有权的问题。在 Rust 中,当一个值被传递给一个函数或者移动到另一个变量时,它的所有权就会转移到接收它的函数或者变量上。这种所有权转移被称为移动(move)。一旦值的所有权被移动,原始变量将不能再继续使用它。

    也就是说,每个变量对于函数来说,只能用一次。当你调用一个函数并将一个拥有所有权的值传递给它时,函数可能会消费这个值并且不能再继续使用它。这是为了确保内存安全避免悬垂指针的问题而设计的。所有权的问题,以后我单写一篇文章讲。

    我们以一个简单的日志记录为例,我们先用过程宏去实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    macro_rules! log {
    ($level:expr, $($arg:tt)*) => {
    println!("{}: {}", $level, format_args!($($arg)*));
    };
    }

    fn main() {
    let message = "An error occurred".to_string();
    log!("ERROR", "Error message: {}", message);
    // 这里仍然可以继续使用 message
    println!("Continuing to use message: {}", message);
    }

    我们再用函数等价替换一下,看看会怎么样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    fn log(level: &str, message: String) {
    println!("{}: {}", level, message);
    }

    fn main() {
    let message = "An error occurred".to_string();
    log("ERROR", message.clone());
    // 尝试在此处继续使用 message
    println!("Continuing to use message: {}", message);
    }

    因为 log 函数会获取 message 的所有权,所以在函数调用之后,message 将被移动,导致后续的使用产生编译错误。而使用过程宏的情况下,由于参数的传递是基于宏的文本替换,并不会导致所有权的转移,因此后续对 message 的使用不会产生问题。

    很显然,几乎所有的场景下,标准输出都是需要重复输出一些变量内容的,那么你也只能使用过程宏,而不能用一次性的函数。

  2. 接受任意数量的参数

    过程宏可以接受任意数量的参数,这使得它们更加灵活和通用。你可以编写接受任意数量参数的宏来处理不同的场景。

    而普通函数或方法的参数数量是固定的,若用函数实现,要么需要为每种情况都编写不同的函数或方法,要么想尽办法把各种情况转换成统一参数数量和格式的形式。

  3. 编译前就进行格式验证

    过程宏可以在编译时验证格式字符串和参数的匹配,不匹配就不编译通过。C 语言中的 printf 函数在运行时才检查,运行的时候万一不匹配或者出现意料之外的结果,就很可能导致运行时出现 bug。

当然这些只是 Rust 宏的一个很基础的部分,还有很多东西需要学习。很多东西也只是我自己的一个理解,如果这篇文章有什么错误或者不足,欢迎友好指出。


随便讲讲 Rust 的 “宏”
https://gt610.codeberg.page/2024/03/28/rust-macro/
作者
GT610
发布于
2024年3月28日
许可协议