如何在Rust中遍历Hashmap,打印键/值并删除值?

80

在任何语言中这应该是一个微不足道的任务。但在Rust中却无法正常工作。

use std::collections::HashMap;

fn do_it(map: &mut HashMap<String, String>) {
    for (key, value) in map {
        println!("{} / {}", key, value);
        map.remove(key);
    }
}

fn main() {}

这是编译器的错误:

error[E0382]: use of moved value: `*map`
 --> src/main.rs:6:9
  |
4 |     for (key, value) in map {
  |                         --- value moved here
5 |         println!("{} / {}", key, value);
6 |         map.remove(key);
  |         ^^^ value used here after move
  |
  = note: move occurs because `map` has type `&mut std::collections::HashMap<std::string::String, std::string::String>`, which does not implement the `Copy` trait

为什么它要移动一个引用?根据文档,我认为移动/借用不适用于引用。

3个回答

53
至少有两个原因导致这种操作是被禁止的:
  1. 你需要同时拥有两个可变引用指向map——一个在for循环中使用的迭代器所持有的,另一个在调用map.remove时由变量map所持有。
  2. 当尝试修改map时,你会拥有对键和值的引用,如果允许以任何方式修改map,则这些引用可能会失效,从而导致内存不安全。
Rust的一个核心原则是“别名XOR可变性”。你可以有多个对值的不可变引用,或者你可以有一个对它的可变引用。

我没想到移动/借用也适用于引用。

每种类型都遵循Rust的移动规则和可变别名规则。如果有哪部分文档说不是这样,请告诉我们,以便我们进行修正。

为什么它要尝试移动一个引用?

这包含两个部分:
  1. 你只能拥有一个可变引用,因此可变引用不实现Copy trait。
  2. for循环通过值来获取要迭代的值
当你调用for (k, v) in map {}时,map的所有权被转移到for循环,并且现在已经消失。
我会对地图进行不可变借用(&*map),并在其上进行迭代。最后,我会清空整个地图:
fn do_it(map: &mut HashMap<String, String>) {
    for (key, value) in &*map {
        println!("{} / {}", key, value);
    }
    map.clear();
}

移除所有以字母"A"开头的键值对

我会使用 HashMap::retain 方法:

fn do_it(map: &mut HashMap<String, String>) {
    map.retain(|key, value| {
        println!("{} / {}", key, value);

        !key.starts_with("a")
    })
}

这确保了当地图实际被修改时,keyvalue不再存在,因此它们原本可能存在的任何借用现在都已消失。

1
我可以通过 for (key, value) in map {}; for (key, value) in map {} 获得相同的错误,而且我认为这个答案没有解释清楚。 - Josh Lee
1
一种思考方法是,如果你在循环内部调用map.clear()会发生什么?keyvalue是引用,它们将不再引用任何东西。从借用检查的角度来看,clearremove都使用&mut self,它们是相同的。 - loganfsmyth
2
这让我遇到了一个更奇怪的问题,但我怀疑方法调用语法掩盖了这个问题。https://play.rust-lang.org/?gist=ecf6d9bdbe8e1ad99e5fb3c35c402d1c&version=stable - Josh Lee
1
@JoshLee 这个有点不明显!你要找的神奇关键词应该是“reborrowing”,它是可变引用的特殊属性。 - Shepmaster
2
@Shepmaster,现在我知道为什么https://doc.rust-lang.org/std/iter/index.html#for-loops-and-intoiterator会写`IntoIterator::into_iter(values)`而不是`values.into_iter()`。 - Josh Lee
显示剩余4条评论

51

在任何语言中,这都应该是一项微不足道的任务。

Rust 防止您在迭代过程中更改映射表。在大多数语言中,这是允许的,但通常行为未定义,删除项目可能会干扰迭代,从而影响其正确性。

为什么它要移动引用?

HashMap 实现了 IntoIterator,因此您的循环等效于:

for (key, value) in map.into_iter() {
    println!("{} / {}", key, value);
    map.remove(key);
}

如果您查看into_iter的定义,您会发现它使用self而不是&self&mut self。您的变量map是一个可变引用,而IntoIterator是为&mut HashMap实现的-into_iter中的self&mut HashMap,而不是HashMap。可变引用不能被复制(因为同时只能存在一个对任何数据的可变引用),因此这个可变引用被移动了。

API是有意这样构建的,以便在循环结构时无法执行任何危险操作。一旦循环完成,结构的所有权就被放弃,您可以再次使用它。

一个解决方案是在Vec中跟踪要删除的项,然后将它们删除:

fn do_it(map: &mut HashMap<String, String>) {
    let mut to_remove = Vec::new();
    for (key, value) in &*map {
        if key.starts_with("A") {
            to_remove.push(key.to_owned());
        }
    }
    for key in to_remove.iter() {
        map.remove(key);
    }
}

你也可以使用迭代器将地图过滤成新的地图。可能像这样:

fn do_it(map: &mut HashMap<String, String>) {
    *map = map.into_iter().filter_map(|(key, value)| {
        if key.starts_with("A") {
            None
        } else {
            Some((key.to_owned(), value.to_owned()))
        }
    }).collect();
}

但我刚刚看到Shepmaster的编辑 - 我忘记了retain,这更好。它更加简洁,不像我所做的那样进行不必要的复制。


9
经常情况下,行为表现并不明确定义,而移除该项可能会干扰迭代并损害其正确性。说得非常好。由此引起的错误非常奇怪,在 C++ 中曾花费数十小时追踪过它们,特别是在嵌套循环内部。谢谢。 - don bright
1
rustc 1.58.1,没有 into_iter(),for (key, value) in map_abc {} 也可以工作。 - Charlie 木匠

7

Rust实际上支持多种解决这个问题的方法,尽管我自己一开始也觉得有点困惑,并且每次需要更复杂的哈希图处理时都是如此。

  • 如果你要在迭代过程中同时移除元素,请使用.drain().drain()的优点是它获取/拥有而不是借用值。
  • 如果您只想有条件地删除其中一些元素,请使用.drain_filter()
  • 如果您需要改变每个元素但只想删除其中一些元素,则可以在.drain_filter()的闭包参数中改变它们,但这将在更改之后检查删除。
  • 如果您需要在更改之前检查删除,请使用变量存储检查结果,然后在结尾处返回该变量。一个略微慢但可能更清晰的替代方法是在一个for循环中给它们赋值,然后在另一个for循环或映射中.drain_filter()它们。
  • 如果您不在函数参数中借用哈希图并在函数结束时允许哈希图下降,则可以初始化新哈希图(如果需要)。这完全删除了哈希图,显然。显然,您可能希望保留哈希图,以便不需要反复初始化它。
  • 您也可以调用.clear()删除所有元素,在迭代完它们并打印后使用。

谢谢您的回复,但是很遗憾,.drain_filter() 方法目前仍处于夜间实验阶段,因此我们无法有条件地删除项目。 - rsalmei
在撰写本文时,.drain_filter() 仍处于实验阶段,但 .drain() 不是。还有一个 .retain() 可以用于过滤,但它不会返回已删除的值。 - Deven T. Corzine

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