聊一聊 Rust 的宏编程

楔子

本次来聊一聊 Rust 的宏,但在讲如何使用宏、如何构建宏之前,我们要先搞清楚为什么会出现宏。

这要从设计非常独特的 Lisp 语言讲起,在 Lisp 的世界里,有句名言:代码即数据,数据即代码(Code is data, data is code)。如果你有 Lisp 相关的开发经验,或者听说过任何一种 Lisp 方言,你可能知道,和普通编程语言不同的是,Lisp 的语言直接把 AST(抽象语法树)暴露给开发者,开发者写的每一行代码,其实就是在描述这段代码的 AST。

从语法树的角度看,编程语言其实也没有什么了不起的,它操作和执行的数据结构不过就是这样的一棵棵树,就跟我们开发者平日里编程操作的各种数据结构一样。如果一门编程语言把它在解析过程中产生的语法树暴露给开发者,允许开发者对语法树进行裁剪和嫁接这样移花接木的处理,那么这门语言就具备了元编程的能力。

语言对这样处理的限制越少,元编程的能力就越强,当然作为一枚硬币的反面,语言就会过度灵活,无法无天,甚至反噬语言本身;反之,语言对开发者操作语法树的限制越多,元编程能力就越弱,语言虽然丧失了灵活性,但是更加规矩。

Lisp 语言,作为元编程能力的天花板,毫无保留地把语法树像数据一样敞露给开发者,让开发者不光在编译期,甚至在运行期,都可以随意改变代码的行为,这也是 Lisp 代码即数据,数据即代码思路的直接体现。

在《黑客与画家》一书里,PG 引用了"格林斯潘第十定律":

任何 C 或 Fortran 程序复杂到一定程度之后,都会包含一个临时开发的、只有一半功能的、不完全符合规格的、到处都是 bug 的、运行速度很慢的 Common Lisp 实现。

虽然这是 Lisp 拥趸对其他语言的极尽嘲讽,不过也说明了一个不争的事实:一门设计再精妙、提供再丰富生态的语言,在实际的使用场景中,都不可避免地需要具备某种用代码生成代码的能力,来大大减轻开发者不断撰写结构和模式相同的重复脚手架代码的需求。

幸运的是,Rust 这门语言提供了足够强大的宏编程能力,让我们在需要的时候,可以通过撰写宏来避免重复的脚手架代码。同时 Rust 对宏的使用还有足够的限制,在保证灵活性的前提下,防止我们过度使用让代码失控。

Rust 对宏编程有哪些支持?

在这之前我们已经见过各种各样的宏,比如创建 Vec 的 vec! 宏、为数据结构添加各种 trait 支持的 #[derive(Debug, Default, ...)]、条件编译时使用的 #[cfg(test)] 宏等等。

其实 Rust 中的宏就两大类:对代码模板做简单替换的声明宏(declarative macro)、可以深度定制和生成代码的过程宏(procedural macro)。

声明宏

首先是声明宏(declarative macro),像 vec![]、println!、以及 info!,它们都是声明宏。声明宏可以用 macro_rules! 来描述:

macro_rules! mul {
    ($a:expr, $b:expr) => {
        $a * $b
    };
}

fn main() {
    println!("The result is {}",  mul!(2, 3));  // The result is 6
    println!("The result is {}",  mul!(22, 3));  // The result is 66
}

可以看到它主要做的就是通过简单的接口,把不断重复的逻辑包装起来(当然我们这里的逻辑比较简单),然后在调用的地方展开而已,不涉及语法树的操作。

如果你用过 C/C++,那么 Rust 的声明宏和 C/C++ 里面的宏类似,承载同样的目的。只不过 Rust 的声明宏更加安全,你无法在需要出现标识符的地方出现表达式,也无法让宏内部定义的变量污染外部的世界。

过程宏

除了做简单替换的声明宏,Rust 还支持允许我们深度操作和改写 Rust 代码语法树的过程宏(procedural macro),更加灵活,更为强大。

Rust 的过程宏分为三种:

  • 函数宏(function-like macro):看起来像函数的宏,但在编译期进行处理。
  • 属性宏(attribute macro):可以在其他代码块上添加属性,为代码块提供更多功能。
  • 派生宏(derive macro):为 derive 属性添加新的功能,这是我们平时使用最多的宏,比如 #[derive(Debug)] 为数据结构提供 Debug trait 的实现、#[derive(Serialize, Deserialize)]为数据结构提供 serde 相关 trait 的实现。

关于宏的具体语法,一会再说。

什么情况可以用宏

前面说了,宏的主要作用是避免我们创建大量结构相同的脚手架代码,那么我们在什么情况下可以使用宏呢?

首先说声明宏。如果重复性的代码无法用函数来封装,那么声明宏就是一个好的选择,比如 Rust 早期版本中的try!,它是 ? 操作符的前身。再比如 futures 库的ready! 宏:

#[macro_export]
macro_rules! ready {
    ($e:expr $(,)?) => {
        match $e {
            $crate::task::Poll::Ready(t) => t,
            $crate::task::Poll::Pending => return $crate::task::Poll::Pending,
        }
    };
}

这样的结构,因为涉及提早 return,无法用函数封装,所以用声明宏就很简洁。

过程宏里,先说最复杂的派生宏,因为派生宏会在特定的场景使用,所以如果你有需要可以使用。比如一个数据结构,我们希望它能提供 Debug trait 的能力,但为自己定义的每个数据结构 impl Debug for T 太过繁琐,而且代码所做的操作又都是一样的,这时候就可以考虑使用派生宏来简化这个操作。

一般来说,如果你定义的 trait 别人实现起来有固定的模式可循,那么可以考虑为其构建派生宏。serde 在 Rust 的世界里这么流行、这么好用,很大程度上也是因为基本上你的数据结构只需要添加 #[derive(Serialize, Deserialize)],就可以轻松序列化成 JSON、YAML 等好多种类型(或者从这些类型中反序列化)。

函数宏和属性宏并没有特定的使用场景,sqlx 用函数宏来处理 SQL query、tokio 使用属性宏 #[tokio::main] 来引入 runtime。它们可以帮助目标代码的实现逻辑变得更加简单,但一般除非特别必要,否则并不推荐写。

声明宏应该怎么写?

下面来看看声明宏的具体语法。

macro_rules! mul {
    () => {
        1
    };
    ($a:expr, $b:expr) => {
        $a * $b
    };
}

fn main() {
    let result = mul!();
    println!("The result is {}", result);  // The result is 1
    let result = mul!(2, 3);
    println!("The result is {}", result);  // The result is 6

}

macro_rules! 定义了一个名为 mul 的宏,宏使用模式匹配,所以你可以提供多个匹配条件以及匹配后对应执行的代码块。上面代码中,我们写了两个用于匹配的 rules。

第一个 () => {1}; 很好理解,如果没有传入任何参数,那么会执行该 rules 的代码块。第二个 rules 接收两个参数,如果调用宏时传递了两个参数,那么就会执行该分支,然后它的语法需要解释一下。

$a:expr$b:expr 用于匹配表达式,并将匹配后的表达式命名为 $a$b。然后在代码块中会对宏进行展开,并将 $a$b 替换为对应的表达式。我们举个稍微复杂点的例子:

macro_rules! create_array {
    () => {{
        let arr: [u8; 0] = [];
        arr
    }};
    ($a:expr, $b:expr, $c:expr) => {{
        let vec = vec![$a, $b, $c];
        vec
    }};
}

fn main() {
    let result = create_array!();
    println!("The result is {:?}", result);  // The result is []
    let result = create_array!(1, 2, 3);
    println!("The result is {:?}", result);  // The result is [1, 2, 3]
}

如果调用宏时不指定参数,那么返回长度为 0 的静态数组;指定 3 个参数时,返回长度为 3 的动态数组,当然这个例子本身没啥意义。如果是函数,那么参数要定义成 (a: T, b: T, c: T) 的形式,在代码块内部直接使用 a、b、c 即可,就像使用普通变量一样。但在宏里面,参数要定义成上述代码中的形式,然后在内部通过 $a$b$c 的形式使用。

然后还需要注意的是,宏本质上就是对代码的替换,所以上面的代码在编译时会被替换为如下:

fn main() {
    let result = create_array!();
    // 会被替换为如下
    let result = {
        let arr: [u8; 0] = [];
        arr
    };
    
    let result = create_array!(1, 2, 3);
    // 会被替换为如下
    let result = {
        let vec = vec![1, 2, 3];
        vec
    };
}

因此我们在定义宏的时候,代码块使用了两个大括号,这是必须的。如果只是返回一个普通的表达式,那么一个大括号就够了,但如果包含了 let 等语句,就必须再嵌套一个大括号。因为宏在被调用的地方会直接展开,直接替换为大括号里面的内容,那么结果可能会导致当前作用域的变量被污染。但如果大括号里面还有大括号,那么展开的时候,代码块就会被限定在一个单独的作用域中,不会污染外部变量。

补充:外层的大括号换成小括号也是可以的,每个分支之间必须用分号分隔。

然后还需要注意:替换后 $a$b$c 就都不存在了,它们不是变量,仅仅是个占位符。所以我们不能这么做:

macro_rules! div {
    ($a:expr, $b:expr) => {
        if $b == 0 {
            Err("除数不可以为 0")
        } else {
            Ok($a / $b)
        }
    };
}

fn main() {
    let res = div!(3, 0);
}

如果是函数,那么类似的逻辑是完全正确的,如果 b 为 0,返回 Err,否则返回 Ok。但这是宏,宏在编译时参数会被替换为具体的表达式。所以上面的调用就等价于:

fn main() {
    let res = div!(3, 0);
    // 等价于
    let res = if 0 == 0 {
        Err("除数不可以为 0")
    } else {
        Ok(3 / 0)
    };
}

宏的参数和函数的参数有着本质的不同,函数的参数是货真价实的变量,而宏的参数只是一个占位符,在调用时会被替换为具体的表达式。然后再看上面的例子,虽然替换之后 0 == 0 一定成立,else 分支永远不会执行,但 Rust 依旧会检查每个分支的合法性,从而避免这种潜在的运行时错误,因此这里发现了除零,于是会报错。

$v 后面如果跟 expr,表示 $v 要接收一个表达式,但除了 expr 之外还可以是别的。

  • ident:标识符,比如结构体名称、函数名称、变量名、类型名等等
  • ty:类型名,虽然 ident 也可以表示类型名,但它的匹配范围仅限制于 i32、String 这种单个标识符,不能匹配像 Vec<i32>、以及带生命周期这种更复杂的类型
  • expr:表达式

当然还有其它的,但这些最常用,我们举个例子。

macro_rules! some_macro1 {
    ($var:ident) => {
        println!("{:?}", $var)
    };
}

macro_rules! some_macro2 {
    ($var:ident) => {
        // 此时的 $var 必须是含有 name 和 age 两个字段的结构体名称
        $var{name: "satori", age: 17}
    };
}

fn main() {
    let x = 666;
    // 展开后等价 println!("{:?}", x)
    some_macro1!(x);  // 666

    #[derive(Debug)]
    struct Girl {
        name: &'static str,
        age: u8
    }
    println!("{:?}", some_macro2!(Girl));  // Girl { name: "satori", age: 17 }
}

不难理解,如果 $var 后面还是 expr,那么这两个调用就是不合法的,因为 expr 要求的是编译期间可以计算的表达式。如果想传递变量(函数、结构体名称啥的都是变量),那么需要将 expr 换成 ident。

因此我们便看到了宏的强大之处,比如 some_macro2 里面的 $var,我们并不知道它是啥,但依旧可以对它做任意的操作。而我们对 $var 进行了结构体实例化操作,并指定了 name 和 age 两个字段,所以我们在调用时只需要传递合法的结构体即可。

macro_rules! some_macro {
    ($var:ident, $type:ident) => {{
        let $var: $type = 123;
        $var
    }};
}

fn main() {
    // 等价于 let abc: i32 = 123;,然后打印 abc
    println!("{:?}", some_macro!(abc, i32));  // 123
    println!("{:?}", some_macro![abc, i32]);  // 123
    println!("{:?}", some_macro!{abc, i32});  // 123
    // 另外调用宏的时候,()、[]、{} 都可以,比如 vec!(1, 2, 3) 也是合法的
}

因为类型比较简单,所以可以用 ident,但如果类型带泛型以及生命周期,那么就需要用 ty,ty 可以表示所有的类型。

所以声明宏构建起来很容易,只要遵循它的基本语法,你可以很快把一个函数或者一些重复的语句片段转换成声明宏。然后我们再回想一下 vec! 宏,它里面可以接收任意数量个参数,而我们目前定义的宏的参数都是固定的。那么怎么让它不固定呢?想必你已经猜到了,就是正则的重复匹配。

Rust 的宏允许通过 $(...)* 或者 $(...),* 这样的模式来指定宏可以接收任意数量的参数,比如 $($el:expr),* 表示可以接收任意个表达式,表达式的名称叫做 el。另外通配符除了 * 之外还有 + 和 ?,分别表示任意次、至少一次、零次或一次。

macro_rules! print_values {
    ($($value:expr),*) => {
        // 此时 value 不再是一个表达式,而是一系列表达式
        // 所以需要将代码放在 $()* 里面,表示对每个表达式单独处理
        $(
            println!("{:?}", $value);
        )*
    }
}

fn main() {
    print_values!(1, 2u8, "你好", vec![1, 2, 3]);
    /*
    1
    2
    "你好"
    [1, 2, 3]
     */
}

并不复杂,我们来看看 vec! 这个宏是怎么干的。

当宏返回一个表达式时,可以使用小括号、中括号、大括号,如果出现了 let 等变量定义语句,那么需要在里面再嵌套一个大括号。然后我们看匹配的 rules,结尾多了一个 $(,)?,表示在元素的结尾可以出现一个额外的逗号。而我们前面的代码中没有,所以最后一个元素的后面不能出现逗号。

过程宏应该怎么写?

未完待续

posted @ 2023-11-23 16:55  古明地盆  阅读(449)  评论(0编辑  收藏  举报