likes
comments
collection
share

编写强大的 Rust 宏——一个“Hello, World”程序性宏本章内容 设置程序性宏 通过解析令牌流获取结构体的名

作者站长头像
站长
· 阅读数 15

本章内容

  • 设置程序性宏
  • 通过解析令牌流获取结构体的名称
  • 生成硬编码的输出
  • 在生成的代码中使用变量
  • 使用 cargo expand 检查生成的代码
  • 在没有 synquote 帮助的情况下编写宏
  • 理解 Rust 的内部宏是如何特别的

现在我们进入本书的核心内容:程序性宏。正如我们之前解释的,程序性宏和声明式宏都是元编程的形式,允许你操作和扩展代码,但它们的实现方式有所不同。声明式宏提供了一种领域特定语言(DSL),通过匹配器和转录器的组合来生成代码。而程序性宏则处理更底层的信息。它们接收一个包含你要操作的代码所有细节的令牌流。

在我看来,声明式宏和程序性宏的区别——以及何时使用它们——有点类似于 SQL 和通用编程语言在查询数据库时的区别。SQL 强大、表达力丰富且用户友好,应该是查询的首选。然而,在某些复杂程度和某些任务的层面上,它会变得复杂且难以阅读和扩展。到那个时候,使用通用编程语言替代 SQL 可能是值得的。查询可能需要更多的努力和设置,但你会有更多的选项和能力。因此,这里的建议是从声明式宏开始。它们简单且强大,仅需要最小的设置,并且具有更好的 IDE 支持。如果你需要做一些声明式宏做不到的事情(例如,操作现有的结构体),你应该转向程序性宏。

3.1 程序性宏项目的基本设置

我们从一个简单的例子开始:一个宏,它向一个结构体或枚举中添加一个“Hello, World”打印方法(见图 3.1 了解项目设置)。向结构体添加新功能是派生宏的一个良好用例。另一方面,如果我们想要修改现有代码,就需要另寻他法,因为这些宏无法做到这一点。派生宏通过在代码中添加 #[derive] 注解来激活,将宏的名称放在方括号中。毫无疑问,你在想要向代码中添加 Debug (#[derive(Debug)]) 或 Clone (#[derive(Clone)]) 功能时,曾经遇到过这些注解。

编写强大的 Rust 宏——一个“Hello, World”程序性宏本章内容 设置程序性宏 通过解析令牌流获取结构体的名

创建一个程序性宏需要一些工作,因此请耐心等待我们完成设置。首先,我们需要一个名为 hello-world 的目录,在这个目录下创建另一个名为 hello-world-macro 的子目录。在 hello-world-macro 中,我们将放置宏项目,而在根目录中,我们将添加一个应用程序(见图 3.2)。

编写强大的 Rust 宏——一个“Hello, World”程序性宏本章内容 设置程序性宏 通过解析令牌流获取结构体的名

你可能已经习惯使用 cargo init 创建新的 Rust 项目。然而,对于我们的宏,我们需要的是一个库,而不是应用程序。开发者想要使用我们的宏时,会将我们的库作为依赖导入。因此,在项目的 hello-world-macro 子目录中运行 cargo init --lib

我们需要修改生成的 Cargo.toml 文件。最重要的更改是添加 lib 部分,并将 proc-macro 属性设置为 true。这告诉 Rust 这个库将暴露一个或多个程序性宏,并会为我们提供一些工具。此外,我们还希望添加 quotesyn 作为依赖项。虽然这两个依赖项不是严格必需的,但它们会使我们的工作更轻松。

清单 3.1 hello-world-macrolib 部分和依赖项

[package]
name = "hello-world-macro"
version = "0.1.0"
edition = "2021"

[dependencies]
quote = "1.0.33"
syn = "2.0.39"

[lib]
proc-macro = true

注意:我们不需要 Cargo 工作区来使其工作,虽然我们将在后面的章节中看到这种设置的示例。

在自动生成的 lib.rs 文件中,我们添加一个基本的实现,该实现尚未真正执行任何操作。我们将在下一节讨论代码。现在,让我们继续我们的设置。

清单 3.2 hello-world-macro 库的初始 lib.rs 文件

use quote::quote;
use proc_macro::TokenStream;

#[proc_macro_derive(Hello)]
pub fn hello(_item: TokenStream) -> TokenStream {
    let add_hello_world = quote! {};
    add_hello_world.into()
}

库现在已经准备好。在外部的 hello-world 目录中,我们将使用 cargo init 设置一个 Rust 示例应用程序。这一次,我们将依赖于刚创建的宏库,使用相对路径添加依赖。依赖项的名称很重要,应与您要导入的包的名称匹配(路径和目录名称可以不同)。尝试将依赖项名称更改为 foo-bar 并运行 cargo run,你将得到类似 error: no matching package named foo-bar found 的错误。由于我们的应用程序位于宏目录的上一级项目根目录中,因此路径是 ./hello-world-macro。某些后续章节将有一个嵌套目录用于应用程序和库。在这种情况下,我们需要向上移动一级(路径 = ../填入你的宏名称)。

清单 3.3 外部 Rust 应用程序的 Cargo.toml 文件

[package]
name = "hello-world"
version = "0.1.0"
edition = "2021"

[dependencies]
hello-world-macro = { path = "./hello-world-macro" }

最后,修改默认的 main.rs 文件。现在你应该能够使用 cargo run 编译并运行你的应用程序。它目前不会打印任何内容(除了一个警告,指出 Example 从未构造)。

清单 3.4 外部应用程序的初始 main.rs 文件

#[macro_use]
extern crate hello_world_macro;

#[derive(Hello)]
struct Example;

fn main() {}

注意:如果你不喜欢这种设置或所有手动工作,你可以使用 cargo generategithub.com/cargo-gener…),它有一个生成宏设置的模板(github.com/waynr/proc-…)。为了学习目的,建议至少手动设置一次。但是一旦掌握了它,你可以使用代码库中的 util/create_setup.sh 脚本(github.com/VanOvermeir…),它可以自动设置本书中使用的各种项目样式。

3.2 分析过程宏的设置

现在让我们分析一下设置。包含 lib.rs 的嵌套目录是我们真正需要的唯一代码部分:

use quote::quote;
use proc_macro::TokenStream;

#[proc_macro_derive(Hello)]                 #1
pub fn hello(_item: TokenStream) -> TokenStream {
    let add_hello_world = quote! {};       #2
    add_hello_world.into()                #3
}

#1 声明这个函数是一个名为“Hello”的派生宏。

#2 使用 quote 宏生成一个新的(当前为空的)TokenStream

#3 使用 Into 特性将这个 TokenStream 转换为标准库中的 TokenStream,两者名称相同。

在依赖项的导入部分下方,我们有 #[proc_macro_derive(Hello)]。这是一个属性,作为元数据通知 Rust 需要执行某些操作。在我们的例子中,它告诉 Rust 这个函数是一个派生宏的入口点(见图 3.3)。这个注解需要在括号中指定一个名称——在我们的例子中是“Hello”——这个名称将在调用宏时使用:#[derive(Hello)]。另一方面,函数的名称不会被外部使用。所以,你可以随意选择一个名称。

编写强大的 Rust 宏——一个“Hello, World”程序性宏本章内容 设置程序性宏 通过解析令牌流获取结构体的名

高层概述

这里是 Rust 在编译应用程序时所做的高层概述(我对细节不是专家)。涉及两个步骤:词法分析和解析。词法分析(也称为标记化)是第一步,用来将你的代码从原始文本流转换成 token 流。例如,将表达式 1 + 11 传递给宏,结果流会是这样的(暂时忽略跨度和后缀):

TokenStream [Literal { kind: Integer, symbol: "1" }, Punct { ch: '+', spacing: Alone }, Literal { kind: Integer, symbol: "11" }]

我们的原始文本已经被转换成了三个 tokens(两个数字和一个 + 符号)。

解析将这些信息转换为抽象语法树(AST;抽象语法树的介绍),这是你程序中所有相关数据的树状表示。这棵 AST 使得 Rust 编译器(以及其他语言的编译器)更容易工作(即,创建计算机可以理解的可执行文件)。在之前的例子中,AST 可能将加号解释为根节点,将两个字面量(数字)作为其分支。同时,跨度用于链接回原始文本,这对于错误消息等功能非常有用。一旦 AST 构建完成,宏就可以被编译器处理。

虽然这些是有趣的细节,但你不需要了解这些过程的细节就能编写过程宏。最重要的是,Rust 给我们提供了经过解析的 token。作为回报,我们将以相同的格式返回信息。Rust 知道如何使用这些 tokens。毕竟,所有的“普通”代码也会被转换成 tokens,这意味着你可以轻松地将 tokens 转换成额外的应用代码。

你可以在 Rust 编译器开发指南《The Little Book of Rust Macros》Rust 参考 中找到更多信息。

这将我们引入到函数体部分,我们在其中调用了同名的 quote 宏。我们没有传递任何参数给 quote,这意味着我们生成了一个空的 TokenStream。因为 quote 实际上使用的是 proc_macro2TokenStream(这是对内置类型的封装),我们仍然需要使用 Into 特性将其转换为“正常”的 proc_macro TokenStreamadd_hello_world.into()。所以最终结果是一个生成什么都不做的宏。市场推广者会为这个版本的库宣传零运行时开销。但宏并不一定需要生成代码,所以这在 Rust 中是可以接受的。

现在看看 main.rs

#[macro_use]
extern crate hello_world_macro;      #1

#[derive(Hello)]                     #2
struct Example;                  #3

fn main() {}

#1 使 hello_world_macro 目录中的宏可以使用。

#2 为 Example 结构体添加了“Hello”派生宏。

#3 将宏应用到我们空的 Example 结构体上。

前两行告诉 Rust 我们将使用这个依赖中的宏,类似于我们如何导入声明式宏。(这次我们需要使用下划线来指定依赖。在我们的 Cargo.toml 中,我们使用了连字符。)这是导入过程宏的旧方式。现在,你应该用 use 一行代码来导入它们(例如,use hello_world_macro::Hello;)。在本章中,我们主要使用旧的方式,它仍然在很多 crate 中使用,因此仍值得了解。但在后续章节中,我们将切换到更新的(Rust 2018)风格。

接下来,#[derive(Hello)] 属性告诉 Rust 对 Example 结构体运行我们名为“Hello”的派生宏,将该结构体作为类型为 TokenStream 的输入。生成的(目前为空的)代码将被添加到我们的 main.rs 文件中。

之前我们提到外部应用程序并非绝对必要。它是一种便利——一个“消费者”,帮助我们验证代码是否编译。这很有用,因为 Rust 的编译器不会捕获我们库生成的代码中的错误。为了使这一点更具体,假设我们在添加派生宏函数的参数时打错了字,将 TokenStream 写成了 TokenStrea。如果我们运行 cargo check,Rust 会指出 TokenStrea 不存在。错误被阻止了!但是如果我们在 quote 宏调用中写入无效的 Rust 代码:

#[proc_macro_derive(Hello)]
pub fn hello(item: TokenStream) -> TokenStream {
    let add_hello_world = quote! {
        fn this should not work () {}
    };

    add_hello_world.into()
}

在我们的宏库中运行 cargo check 仍然会成功!但当我们在外部应用程序中检查时,我们会得到一个错误:

error: expected one of `(` or `<`, found `should`
 --> src/main.rs:4:10
  |
4 | #[derive(Hello)]
  |          ^^^^^ expected one of `(` or `<`
  |
  = note: this error originates in the derive macro `Hello`

显然,Rust 认为 fn this 意味着我们想创建一个名为 this 的函数。函数及其名称后面跟着的是括号(包含参数)或泛型(用 < > 包围)。而 should 这个词出现了。这里的教训是,cargo check 在你的过程宏库内部只会检查库代码中的错误,而不会检查生成的代码中的错误。

这很有意义,因为在库内部我们并不使用生成的代码。Rust 只关心我们声明 TokenStream 为函数的返回类型。只要我们返回任何 token 流,即使是无效的,它也会满足。即使它关心,编译器也无法知道生成的代码将在什么上下文中使用,这使得检查变得困难。因此,我们使用一个简单的应用程序和基本的使用示例来生成代码,强制编译器停止懒惰并进行一些工作。在后续章节中,我们将转向测试,以验证宏的正确工作。

3.3 生成输出

那实际生成一些代码怎么样?首先,将以下内容添加到 lib.rs 中:

// 之前的导入

#[proc_macro_derive(Hello)]
pub fn hello(_item: TokenStream) -> TokenStream {
    let add_hello_world = quote! {
        impl Example { #1
            fn hello_world(&self) {
                println!("Hello, World")
            }
        }
    };               

    add_hello_world.into()
}

#1 返回一个硬编码的实现块

我们仍然没有处理传入的 tokens。但是我们使用 quote 来生成新代码。目前,这段新代码是为我们在 main.rs 中添加的 Example 结构体生成的硬编码实现块。这意味着我们现在可以调用这个方法,运行以下代码。请记住,cargo run 的目标应该是应用程序,否则会出现 “bin target must be available for cargo run” 错误,因为库无法被执行。

// 宏导入

#[derive(Hello)]
struct Example;

fn main() {
    let e = Example {};
    e.hello_world(); #1
}

#1 执行时打印 "Hello, World"

唯一的问题是,我们的代码仅适用于名为 Example 的结构体。重命名结构体,Rust 会抱怨在这个作用域中找不到 Example 类型,因为它无法将实现块匹配到现有类型。解决方案是从传入的 tokens 中获取结构体的名称,并在我们生成的 impl 中使用它。在宏的术语中,无论是声明式宏还是过程宏,这个名称都是一个标识符。由于我们的 tokens 表示我们装饰的代码(一个结构体及其内容),我们需要最上层的标识符。一旦我们得到名称,我们可以将其与 quote 结合,生成更有趣的输出。

use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Hello)]
pub fn hello(item: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(item as DeriveInput);     #1
    let name = ast.ident;                  #2

    let add_hello_world = quote! {
        impl #name {           #3
            fn hello_world(&self) { #4
                println!("Hello, World")
            }
        }                              
    };

    add_hello_world.into()
}

#1 将传入的 TokenStream 解析为更友好的 AST。

#2 获取顶层标识符。在我们的例子中,这将是注解的 Example 结构体的名称。

#3 使用 quote 提供的特殊语法将其添加到我们的输出中。

#4 其他部分可以保持硬编码。

我们首先通过使用 syn crate 提供的 parse_macro_input 将输入 tokens 解析为 AST。syn 提供了许多工具来帮助你解析 Rust tokens,这个声明式宏是它工具箱中的一个工具。as DeriveInput 是一种定制的语法糖。在幕后,as 后面的参数用于确定将生成的类型。我们选择了 DeriveInput。顾名思义,这是一种你在编写派生宏时可能会得到的输入。因此,它基本上包含一个枚举或结构体。

编写强大的 Rust 宏——一个“Hello, World”程序性宏本章内容 设置程序性宏 通过解析令牌流获取结构体的名

一旦我们得到了 DeriveInput,我们可以通过获取顶层的 ident(标识符),并将其保存在一个名为 name 的变量中,来获取结构体的名称。如果我们想在输出中使用 name,我们需要一种特殊的语法来告诉 quote 这不是一个字面值,而是应该被替换为具有匹配名称的变量的内容。你可以通过在变量名之前加上一个井号(即 #name)来实现这一点,在我们的例子中,这将被替换为标识符 Example。正如你所见,使用 quote 生成输出是非常简单的(见图 3.4)。

再次运行代码,它应该会产生与之前相同的输出方法,即使你更改了结构体的名称。

3.4 实验我们的代码

现在让我们进行一些实验。我们的宏是否可以用于枚举?可以!将以下代码添加到 main.rs

// 之前的代码

#[derive(Hello)]
enum Pet {
    Cat,
}

fn main() {
    // 之前的代码

    let p = Pet::Cat;
    p.hello_world();
}

那么函数呢?不幸的是,这行不通,因为 impl 不能用于函数,而且 derive 也不允许用于函数。错误信息非常明确:derive 只能应用于结构体、枚举和联合体。

注意:尽管联合体是一个有效的派生目标,但在本书中没有涉及,主要是因为它们不如结构体和枚举普遍,几乎仅用于与 C 通过外部函数接口(FFI)的兼容性。

我们生成的代码会覆盖现有的实现块吗?幸运的是,不会。正如我们在前面的章节提到的,Rust 支持多个 impl 块。因此,下面的代码是有效的。

// 之前的代码

impl Example {
    fn another_function(&self) {
        println!("Something else");
    }
}

fn main() {
    let e = Example {};
    e.hello_world();
    e.another_function();
    // 其他代码
}

但其他事情可能仍会出错。例如,如果你定义了一个额外的 hello_world 函数,你将会得到错误 duplicate definitions for hello_world。你最好希望没有宏的用户会想要添加同名的函数!名称重叠是一个实际的风险——我们将在本书后续章节中讨论这个问题。

3.5 cargo expand

在我们前面的章节中,我们介绍了几种调试宏并查看它们展开内容的方法。但我们跳过了一个非常有用的工具:cargo expandgithub.com/dtolnay/car…),这个工具曾经不稳定,但现在可以与稳定版 Rust 一起使用。你可以通过 cargo install cargo-expand 安装它,然后在应用程序目录或 src 文件夹的根目录下运行 cargo expand。这将打印出宏展开后的应用程序代码。在清单 3.10 中,你可以看到所有宏,包括 println!,在我们运行 cargo expand 后都被展开了。(不过,format_args! 没有被展开。这是 Rust 的一个问题,github.com/dtolnay/car…,正在进行修复。)

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;       #1

// imports

struct Example;            #2

impl Example { #3
    fn hello_world(&self) {
        {
            ::std::io::_print( #4
                format_args!("Hello, World\n")
            );            
        }
    }
}               

// Pet 枚举及其展开后的代码

// main 函数

#1:正如我们之前看到的,前置导入包含了全局可用的函数、特性等。Rust 添加了注解和导入来使这些内容可用。例如,Clone 在没有导入的情况下也能工作。 #2:derive 注解已经消失。 #3:展开的 #[derive(Hello)] 代码。 #4:展开后的 println!

我们的 derive 注解已经消失,因为在宏展开完成后,它不再需要了。取而代之的是,宏生成的代码已被添加。

cargo expand 是一个用于视觉检查我们代码的有用工具,即使输出无效,它仍然可以运行,这使它在调试编译问题时很有用。假设我在 quote 宏调用中错拼了 self。我会得到一个编译错误和以下输出,显示函数参数有问题。如果错误仍不清楚,我可以将输出重定向到文件(cargo expand > somefile.rs),然后让我的 IDE 帮助我跟踪问题。或者,我可以临时替换 main.rs,通过 cargo check 得到指向不正确行的提示:

impl ExampleStruct {
    fn hello_world(sel: ()) {
        {
            ::std::io::_print(format_args!("Hello, World\n"));
        }
    }
}

你也可以在 hello-world-macro 库中使用 expand,但那只会显示你使用的宏——而不是你为他人创建的宏——如何展开,类似于 check 只会指出你代码中的错误,而不是生成的代码中的错误。因此,大多数情况下,你会希望在使用你库及其宏的应用程序中使用 expand 命令。

3.6 同样的宏——不使用 synquote

quotesyn 库非常有用,但在编写宏时并非严格必要。清单 3.11 是没有这些库的相同应用程序。为了获取名称,我们遍历输入流并取出第二个元素,即 nth(1)。该项是一个 TokenTree,包含结构体或枚举的名称。第一个元素,包含在 nth(0) 中,有类型(即结构体或枚举),在这种情况下不相关——所以我们跳过它。通过 ident_name 函数,我们获取树并返回名称标识符,如果找不到则抛出错误。

定义TokenTree 位于 TokenStream 和简单的标记之间。基本上,TokenStreamTokenTree 的序列,TokenTree 本身(递归地)由更多的树和/或标记组成。这就是为什么我们可以遍历流,选择一个元素,并保证 Rust 认为类型是 TokenTree。在声明式宏中,我们遇到的 tt 片段说明符也是 TokenTree

为了生成输出,我们使用 format 宏将名称变量注入到字符串中。对于从字符串到 TokenStream 的转换,我们可以使用 parse。我们期望这能工作,所以我们直接在解析返回的 Result 上使用 unwrap。这种方法看起来非常可行。我们去除了两个依赖库,代价是多写了一点代码。但即使在这样一个基本的示例中,我们也需要付出努力来获取标识符并输出新代码。对于更复杂的示例,复杂性和额外的工作负担只会增加。

use proc_macro::{TokenStream, TokenTree};

#[proc_macro_derive(Hello)]
pub fn hello_alt(item: TokenStream) -> TokenStream {
    fn ident_name(item: TokenTree) -> String {
        match item {
            TokenTree::Ident(i) => i.to_string(), #1
            _ => panic!("no ident")
        }
    }                 
    let name = ident_name(item.into_iter().nth(1).unwrap());  #2

    format!("impl {} {{ fn hello_world(&self) \
    {{ println!("Hello, World\") }} }} ", name
        ).parse()
        .unwrap()              #3
}
#1 如果我们有一个包含标识符的 `TokenTree`,我们将其作为字符串返回。
#2 作为先前函数的输入,我们提供 `TokenStream` 的第二个元素,即名称。
#3 我们使用 `format!` 将获取的名称添加到 `impl` 块的字符串表示中。然后我们解析将其转换为 `TokenStream`。这只有在我们犯错误时才会失败,此时 `unwrap` 触发恐慌是可以接受的。

尽管如此,编译速度是你可能想要放弃使用 `syn` 的一个理由。虽然它是一个非常强大的库,但它也很大并且编译速度较慢。因此,如果我们的示例是一个真实的宏,并且我们只需要结构体/枚举的名称,我们的简单示例会更快编译。许多库试图提供 `syn` 的轻量级替代品,例如 venial([https://github.com/PoignardAzur/venial](https://github.com/PoignardAzur/venial))。

清单 3.12 显示了使用该库的宏。不要忘记在依赖项中添加 `venial = "0.5.0"`。代码看起来与之前非常相似。我们使用 `parse_declaration`,它返回一个 `Declaration` 枚举。通过模式匹配,我们从该枚举中检索名称。

```rust
use quote::quote;
use proc_macro::TokenStream;
use venial::{parse_declaration, Declaration, Struct, Enum};

#[proc_macro_derive(Hello)]
pub fn hello(item: TokenStream) -> TokenStream {
    let declaration = parse_declaration(item.into()).unwrap();

    let name = match declaration { #1
        Declaration::Struct(Struct { name, .. }) => name,
        Declaration::Enum(Enum { name, .. }) => name,
        _ => panic!("only implemented for struct and enum")
    };        

    let add_hello_world = quote! {
        impl #name {
            fn hello_world(&self) {
                println!("Hello, World")
            }
        }
    };

    add_hello_world.into()
}
#1 如果我们接收到一个枚举或结构体,则检索名称;在其他情况下抛出恐慌。

即使在这个简单的示例中,使用 cargo build --timings 测量的构建时间从我的机器上的 3.1 秒降至 1.8 秒。然而,在本书中,我们将使用 syn,因为它非常知名、广泛使用且功能强大。此外,一旦你熟悉了它如何处理 TokenStream 解析,切换到轻量级的替代品应该不会太难:许多解析概念始终是相同的。

3.7 现实世界中的应用

我们将把进一步的库探索留到后续章节,目前仅限于一些观察。首先,Rocket 的开发者(rocket.rs/)实际上很乐意教你宏可以通过两种方式导入(我们在本章中描述的方式):

//! 并通过 `#[macro_use]` 在 crate 根目录中导入所有宏、属性和派生:
//!
//! ```rust
//! #[macro_use] extern crate rocket;
//! # #[get("/")] fn hello() { }
//! # fn main() { rocket::build().mount("/", routes![hello]); }
//! ```
//!
//! 或者,选择性地从顶层范围导入:
//!
//! ```rust
//! # extern crate rocket;
//!
//! use rocket::{get, routes};
//! # #[get("/")] fn hello() { }
//! # fn main() { rocket::build().mount("/", routes![hello]); }
//! ```

其次,你可能还会好奇标准库是如何解析和输出宏的,因为它不能使用像 synquote 这样的外部库。相反,标准库使用类似概念和名称的内置功能。例如,Rust 的抽象语法树 rustc_ast 用于解析输入。输出代码是通过 rustc_expand 完成的。rustc_span 包含像 IdentSpan 这样的工具。它在使用 synquote 时既熟悉又陌生,但并不打算供外部使用。

最后,由于过程宏必须放在库中,在 crate 根目录(否则你会得到标记为 #[proc_macro_derive] 的函数必须当前存在于 crate 根目录中的错误),lib.rs 是探索其他人过程宏代码的一个很好的起点。你将看到他们有哪些宏,并在需要时进行深入研究。

转载自:https://juejin.cn/post/7406139000266162239
评论
请登录