如何在过程宏生成的代码中创建卫生的标识符?

9

当编写一个声明式的(macro_rules!)宏时,我们自动获得了宏卫生。在这个例子中,我在宏中声明了一个名为f的变量,并传入一个标识符f,它会成为一个局部变量:

macro_rules! decl_example {
    ($tname:ident, $mname:ident, ($($fstr:tt),*)) => {
        impl std::fmt::Display for $tname {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                let Self { $mname } = self;
                write!(f, $($fstr),*)
            }
        }
    }
}

struct Foo {
    f: String,
}

decl_example!(Foo, f, ("I am a Foo: {}", f));

fn main() {
    let f = Foo {
        f: "with a member named `f`".into(),
    };
    println!("{}", f);
}

这段代码编译通过,但如果你查看部分展开后的代码,你会发现存在一个明显的冲突:

impl std::fmt::Display for Foo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let Self { f } = self;
        write!(f, "I am a Foo: {}", f)
    }
}

我正在将这个声明宏写成一个过程宏,但不知道如何避免用户提供的标识符与由我的宏创建的标识符之间可能存在的名称冲突。就我所看到的,生成的代码没有卫生概念,只是一个字符串: src/main.rs
use my_derive::MyDerive;

#[derive(MyDerive)]
#[my_derive(f)]
struct Foo {
    f: String,
}

fn main() {
    let f = Foo {
        f: "with a member named `f`".into(),
    };
    println!("{}", f);
}

Cargo.toml

[package]
name = "example"
version = "0.1.0"
edition = "2018"

[dependencies]
my_derive = { path = "my_derive" }

my_derive/src/lib.rs

extern crate proc_macro;

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

#[proc_macro_derive(MyDerive, attributes(my_derive))]
pub fn my_macro(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let name = input.ident;

    let attr = input.attrs.into_iter().filter(|a| a.path.is_ident("my_derive")).next().expect("No name passed");
    let meta = attr.parse_meta().expect("Unknown attribute format");
    let meta = match meta {
        Meta::List(ml) => ml,
        _ => panic!("Invalid attribute format"),
    };
    let meta = meta.nested.first().expect("Must have one path");
    let meta = match meta {
        NestedMeta::Meta(Meta::Path(p)) => p,
        _ => panic!("Invalid nested attribute format"),
    };
    let field_name = meta.get_ident().expect("Not an ident");

    let expanded = quote! {
        impl std::fmt::Display for #name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                let Self { #field_name } = self;
                write!(f, "I am a Foo: {}", #field_name)
            }
        }
    };

    TokenStream::from(expanded)
}

my_derive/Cargo.toml

[package]
name = "my_derive"
version = "0.1.0"
edition = "2018"

[lib]
proc-macro = true

[dependencies]
syn = "1.0.13"
quote = "1.0.2"
proc-macro2 = "1.0.7"

在Rust 1.40中,这会产生编译器错误:
error[E0599]: no method named `write_fmt` found for type `&std::string::String` in the current scope
 --> src/main.rs:3:10
  |
3 | #[derive(MyDerive)]
  |          ^^^^^^^^ method not found in `&std::string::String`
  |
  = help: items from traits can only be used if the trait is in scope
  = note: this error originates in a macro outside of the current crate (in Nightly builds, run with -Z external-macro-backtrace for more info)
help: the following trait is implemented but not in scope; perhaps add a `use` for it:
  |
1 | use std::fmt::Write;
  |

有哪些技术可以将我的标识符与我不能控制的标识符隔离开来?


1
显而易见的想法(不知道是否可行):编写一个过程宏,生成一个声明性的过程,然后调用它? - trent
这个实用工具的Lisp术语是gensym,显然至少有一个crate支持它。然而,这个实现与French's的回答完全相同。 - user4815162342
2个回答

8
摘要: 目前在稳定版的Rust中,还不能使用卫生标识符(hygienic identifiers)来进行过程宏(proc macros)。你最好使用一个特别丑陋的名称,例如__your_crate_your_name
您正在使用quote!创建标识符(特别是f)。这确实很方便,但它只是编译器提供的实际过程宏API周围的助手。因此,让我们查看该API以了解如何创建标识符!最终,我们需要TokenStream,因为这是我们的过程宏返回的内容。我们如何构建这样的标记流?
我们可以从字符串中解析它,例如"let f = 3;" .parse ::<TokenStream>()。但这基本上是一个早期的解决方案,现在不鼓励使用。无论如何,以这种方式创建的所有标识符都会以非卫生方式运行,因此这不会解决您的问题。
第二种方法(`quote!`在幕后使用的方法)是手动创建一个 `TokenStream`,通过创建一堆 {{link1:TokenTree}} 来实现。一种 `TokenTree` 的类型是 {{link2:Ident}}(标识符)。我们可以通过 `new` 创建一个 `Ident`:
fn new(string: &str, span: Span) -> Ident

string 参数很好理解,但是 span 参数才是有趣的部分!一个 Span 存储了源代码中某个位置的信息,通常用于错误报告(例如为了让 rustc 指出拼写错误的变量名)。但是在 Rust 编译器中,spans 承载的不仅仅是位置信息:还有卫生的类型!我们可以看到 Span 的两个构造函数:

  • fn call_site() -> Span: 创建一个带有调用点卫生的span。这就是所谓的“不卫生”,相当于“复制粘贴”。如果两个标识符具有相同的字符串,它们将冲突或遮蔽彼此。

  • fn def_site() -> Span: 这就是您想要的。技术上称为定义点卫生,这就是所谓的“卫生”。您定义的标识符和用户的标识符存在于不同的宇宙中,永远不会发生冲突。正如文档中所示,此方法仍不稳定,因此只能在夜间编译器上使用。遗憾!

没有真正伟大的解决办法。显而易见的方法是使用一个非常丑陋的名称,如__your_crate_some_variable。为了让你更容易一些,可以在quote!中创建该标识符并重复使用它(稍微好一些的解决方案在这里):

let ugly_name = quote! { __your_crate_some_variable };
quote! {
    let #ugly_name = 3;
    println!("{}", #ugly_name);
}

有时候,您甚至可以搜索用户的所有标识符,以避免与您的标识符冲突,然后通过算法选择一个不会冲突的标识符。这实际上就是{{link1:我们为auto_impl}所做的事情,使用了一个非常丑陋的备用名称。主要是为了避免在生成的文档中出现非常丑陋的名称。
除此之外,恐怕您无能为力。

你提到通过解析字符串来构建结果TokenStream现在已经不被鼓励使用了。你能否链接到一份文件,概述首选技术(可以在评论中或编辑你的答案中)?我读过的几篇文章要么使用.parse(),要么根本没有解释如何生成TokenStream。 - Mutant Bob

5
你可以通过 UUID 来实现此功能:
fn generate_unique_ident(prefix: &str) -> Ident {
    let uuid = uuid::Uuid::new_v4();
    let ident = format!("{}_{}", prefix, uuid).replace('-', "_");

    Ident::new(&ident, Span::call_site())
}

1
有什么阻止用户传入与我生成的标识符(不)幸匹配的标识符吗? - Shepmaster
4
概率的法则,我猜。 - Boiethios
6
这是一个极不可能发生的事件,因为v4 UUID由128个随机位组成。使用正确种子的伪随机数生成器,这就相当于问你的git存储库是否会因为两个不幸哈希到相同的SHA1而被破坏。 - user4815162342
2
这会不会有时打破增量重新编译?编译中随机事件的想法让我有点害怕。 - Lou Garczynski
1
@LouisGarczynski 你说得对,我没有考虑到这一点。也许你可以对一些信息进行哈希处理,比如文件名、行号、列号等等... 这样在这个方面应该会更好。 - Boiethios

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接