为什么 HashMap::get_mut 对生命周期的要求比 HashMap::get 更严格?

4
我有一个名为struct Foo<'a>的结构体,它是对&'a str引用的封装。我想将Foo作为键填充到HashMap中。以下是代码片段(在Playground中打开):
use std::collections::HashMap;

#[derive(PartialEq, Eq, Hash)]
struct Foo<'a> {
    txt: &'a str,
}

fn main() {
    let a = "hello".to_string();
    let a2 = Foo { txt: &a };
    let b = "hello".to_string();
    let b2 = Foo { txt: &b };

    let mut hm = HashMap::<Foo, u32>::new();

    hm.insert(a2, 42);
    println!("=== {:?}", hm.get(&b2));     // prints Some(42)
    println!("=== {:?}", hm.get_mut(&b2)); // prints Some(42)

    {
        let c = "hello".to_string();
        let c2 = Foo { txt: &c };
        println!("=== {:?}", hm.get(&c2));         // prints Some(42)
        // println!("=== {:?}", hm.get_mut(&c2));  // does not compile. Why?
        // hm.insert(c2, 101);                     // does not compile, but I understand why.
    }
}

这段代码可以编译和运行得很好,但是如果我取消最后两行的注释,编译器就会抱怨c2中借用的值不够长久。对于最后一个(insert),这是完全可以理解的:我不能将c2移动到比从c中借用的数据更长寿的HashMap中。然而,我不明白为什么倒数第二行(get_mut)也有同样的问题:在这种情况下,借用的数据应该只在调用get_mut时需要,它没有被移动到HashMap中。更令人惊讶的是,上面的get正常工作(正如我所期望的那样),而且当涉及k参数时,getget_mut具有相同的签名。经过深入挖掘,我用普通引用(而不是嵌入引用的结构体)重现了这个问题。
use std::collections::HashMap;

fn main() {
    let a = 42;
    let b = 42;

    let mut hm = HashMap::<&u32,u32>::new();

    hm.insert(&a, 13);
    println!("=== {:?}", hm.get(&&b));     // prints Some(13)
    println!("=== {:?}", hm.get_mut(&&b)); // prints Some(13)

    {
        let c = 42;
        println!("=== {:?}", hm.get(&&c));        // prints Some(13)
        //println!("=== {:?}", hm.get_mut(&&c));  // does not compile. Why?
    } 
}

(在 playground 中打开)

再次取消注释最后一行会导致编译器抱怨(与上面相同的消息)。

然而,我发现了一个有趣的解决方法:将最后一行中的&&c替换为&c可以解决问题——实际上,在所有调用getget_mut时都可以用&代替&&。我想这与&T实现Borrow<T>有关。

我不明确地理解,在这个解决方法中,是什么使得编译器按照我的意愿执行。我无法直接将其应用于我的原始代码,因为我不使用引用作为键,而是嵌入引用的对象,所以我无法用&代替&&...


我相信这个问题已经被 为什么只有可变引用才关心生命周期链接? 回答过了。如果您不同意,请编辑您的问题以解释它与现有答案的区别在哪里。否则,我们可以将其标记为已回答。 - Shepmaster
此外,编译器如何仅使用函数签名就知道 get_mut 不会将参数存储在 HashMap 中呢? - Shepmaster
我不完全确定我的问题是否与您所提到的问题相同。主要的区别在于,在我的情况下,只涉及对“Foo”(借用类型)的不可变引用。 - Pierre-Antoine
没错,这里涉及到 &self&mut self 的区别。由于有了生命周期省略,该方法为 get_mut<'a, Q: ?Sized>(&'a mut self, k: &Q) -> Option<&'a mut V> — 生命周期是相关联的。 - Shepmaster
1
如果你的问题中包含“方差”这个词,那么你可能会遇到麻烦。对于潜在的回答者,以下是我发现有启发性的一些事实:&TT 中是变体的,&mut TT 中是不变的,而 HashMap<K, V>K 中是变体的。祝你好运! - trent
显示剩余2条评论
1个回答

7
问题出在不可变引用在其(引用的)类型上是协变的,而可变引用在其类型上是不变的。
了解这个概念的好方法是阅读Nomicon
HashMap: getget_mut 的区别
进一步缩小范围,这是一个简单的代码示例来复现问题:
#![allow(unused_variables)]
use std::collections::HashMap;

fn main() {
    let mut hm = HashMap::<&u32, u32>::new();    // --+ 'a
    let c = 42;                                    // |  --+ 'b
                                                   // |    |
    HashMap::<&u32, u32>::get(&mut hm, &&c);       // |    |
    // HashMap::<&u32, u32>::get_mut(&mut hm, &&c);// |    |
}                                                  // +    +

不可变情况

考虑 HashMap::get 的签名:

fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
    where K: Borrow<Q>, Q: Hash + Eq

在这种情况下,&Q&&'b u32,而get的接收方是&Self
不可变引用的多样性意味着可以使用&HashMap<&'a u32, u32>代替需要&HashMap<'b u32, u32>的情况。
由于这个规则,编译器会考虑原始调用:
HashMap::<&'a u32, u32>::get(&hm, &&'b c);

等同于:

HashMap::<&'b u32, u32>::get(&hm, &&'b c);

编译器仅从接口推断方法实现不会引入泄漏,仅从接口推断:编译成功。

可变情况

考虑HashMap::get_mut的签名:
fn get_mut<Q: ?Sized>(&mut self, k: &Q) -> Option<&mut V>
    where K: Borrow<Q>, Q: Hash + Eq

同样的情况下,&Q 也是 &&'b u32,但是 get_mut 的接收器是 &mut Self
可变引用的不变性意味着 &mut HashMap<&'a u32, u32> 不能用于期望 &mut HashMap<&'b u32, u32> 的情况。
由于这个规则,编译器会抛出错误,因为它仅仅分析了接口:
HashMap::<&'a 32, u32>::get_mut(&mut hm, &&'b c);

编译器无法排除 get_mut 存储生命周期为 'b 的键值对的可能性。但这样的键值对不能超出 hm HashMap 的生命周期范围,否则编译会失败。

现在是正确的。非常感谢@trentcl的纠正! - attdona
我仍然不明白为什么将 &&c 替换为 &c 就可以工作... 我理解它在类型上是如何工作的(K=&u32 实现了 Borrow<Q>,其中 Q=u32);但我不明白为什么编译器突然对那种情况下的生命周期感到满意... - Pierre-Antoine
[&'a u32] 实现了 Borrow<u32>,适用于任何生命周期 'a。同时,[&'a u32] 通过自反实现,也实现了 Borrow<&'a u32>。但是,即使 'a: 'b&'a u32 不会 实现 Borrow<&'b u32>。这是因为特质(trait),包括 Borrow 在内,始终对其参数具有不变性。 - trent
1
@Pierre-Antoine 这也意味着,如果你愿意创建一个新的类型结构来放入你的 HashMap 中,你可以自己编写缺失的通用 impl,并使(几乎)原始示例编译通过:playground。所以你的直觉是正确的:这个限制过于保守。 - trent
@trentcl 真遗憾,我有个FooKey的想法,但由于某些原因无法使其工作。我将从您的代码重新开始,谢谢。请注意,为了遵守HashMap的契约,FooKey必须一致地实现Hash和Eq,以与Foo保持一致... - Pierre-Antoine
显示剩余2条评论

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