【问题标题】:Using a HashSet to canonicalize objects in Rust使用 HashSet 规范化 Rust 中的对象
【发布时间】:2020-03-12 02:47:07
【问题描述】:

作为一项教育练习,我正在考虑将 cvs-fast-export 移植到 Rust。

它的基本操作方式是将多个CVS主文件解析成一个中间形式,然后对中间形式进行分析,目的是将其转化为git快速导出流。

解析时要做的一件事是将中间形式的公共部分转换为规范表示。一个鼓舞人心的例子是提交作者。一个 CVS 存储库可能有数十万个单独的文件提交,但可能少于一千个作者。因此,当您从文件中解析作者时,在解析输入作者的位置时使用了一个实习表,它会给您一个指向规范版本的指针,如果以前没有见过它,则会创建一个新版本。 (我也听说过这叫做雾化或实习)。然后这个指针被存储在中间对象上。

我第一次尝试在 Rust 中做类似的事情是尝试使用 HashSet 作为实习表。注意这里使用的是 CVS 版本号而不是作者,这只是一个数字序列,例如 1.2.3.4,表示为 Vec

use std::collections::HashSet;
use std::hash::Hash;

#[derive(PartialEq, Eq, Debug, Hash, Clone)]
struct CvsNumber(Vec<u16>);

fn intern<T:Eq + Hash + Clone>(set: &mut HashSet<T>, item: T) -> &T {
    let dupe = item.clone();
    if !set.contains(&item) {
        set.insert(item);
    }
    set.get(&dupe).unwrap()
}

fn main() {
    let mut set: HashSet<CvsNumber> = HashSet::new();
    let c1 = CvsNumber(vec![1, 2]);
    let c2 = intern(&mut set, c1);
    let c3 = CvsNumber(vec![1, 2]);
    let c4 = intern(&mut set, c3);
}

error[E0499]: cannot borrow 'set' as mutable more than once at a time 失败。这很公平,HashSet 不保证如果您在获得参考后添加更多项目,对其键的参考将有效。 C 版本小心地保证这一点。为了得到这个保证,我认为HashSet 应该超过Box&lt;T&gt;。但是我无法向借阅检查器解释这个的生命周期。

我在这里使用的所有权模型是实习表拥有数据的规范版本,并分发引用。只要实习表存在,引用就应该是有效的。我们应该能够在不使旧引用无效的情况下向实习表添加新内容。我认为我的问题的根源在于我很困惑如何以与 Rust 所有权模型一致的方式编写该合约的接口。

我用我有限的 Rust 知识看到的解决方案是:

  1. 执行两次,在第一次通过时构建HashSet,然后将其冻结并在第二次通过时使用引用。这意味着额外的临时存储空间(有时很大)。
  2. 不安全

有人有更好的主意吗?

【问题讨论】:

    标签: rust hashset canonicalization


    【解决方案1】:

    我有点不同意@Shepmaster 在这里使用unsafe

    虽然现在不会引起问题,但如果将来有人决定更改 HashSet 的使用以包括一些修剪(例如,只保留一百个作者) ),那么unsafe会狠狠地咬你。

    如果没有强大的性能原因,我会简单地使用Rc&lt;XXX&gt;。你可以很容易地给它起别名:type InternedXXX = Rc&lt;XXX&gt;;

    use std::collections::HashSet;
    use std::hash::Hash;
    use std::rc::Rc;
    
    #[derive(PartialEq, Eq, Debug, Hash, Clone)]
    struct CvsNumber(Rc<Vec<u16>>);
    
    fn intern<T:Eq + Hash + Clone>(set: &mut HashSet<T>, item: T) -> T {
        if !set.contains(&item) {
            let dupe = item.clone();
            set.insert(dupe);
            item
        } else {
            set.get(&item).unwrap().clone()
        }
    }
    
    fn main() {
        let mut set: HashSet<CvsNumber> = HashSet::new();
        let c1 = CvsNumber(Rc::new(vec![1, 2]));
        let c2 = intern(&mut set, c1);
        let c3 = CvsNumber(Rc::new(vec![1, 2]));
        let c4 = intern(&mut set, c3);
    }
    

    【讨论】:

    • 你能澄清一下可能会改变HashSet吗?我不认为您的意思是 HashSet 的实现者会改变(无论如何,我们对此无能为力)。如果你的意思是 use 在内部内部HashSet,那么是的。这就是为什么我总是提倡在每个 unsafe 旁边使用 cmets 来记录它。我考虑过使用Rc,但不喜欢与强制分配的交互。例如,你不能实习 &amp;[u16]
    • @Shepmaster:注意我说的是改变HashSet的使用,我的意思是OP或其他一些未来的贡献者可能会破坏数据的不变性从未从集合中移除。至于评论unsafe 的使用,这是一个值得称道的想法,但是在添加另一种方法时可能不会考虑查看intern 方法......而且人们也可能会误解或误解 cmets。在这个具体的示例中,它当然很简单,但我宁愿避免培养“只在此处使用unsafe”的文化。
    • 为了向未来的读者澄清我之前的评论,我不喜欢 this 案例中的这种解决方案,因为它需要 extraneous allocations。具体来说,必须首先分配每个想要实习的字符串,然后检查哈希图,然后存储或解除分配。在程序的整个生命周期内分配的总数是相同,但重复的被释放得更早,因此在任何给定时间的最大内存使用量较低。还有一些运行时开销来增加/减少引用计数。如果这些限制没问题,这是一个完美的解决方案!
    • 我差点错过了自己写这篇文章。我错过了Rc 的要点是您可以返回T 而不是&amp;T。我已将此答案标记为正确,即使@Shepmaster 已经更严格地回答了我的问题,因为您是对的,这应该是我的第一个解决方案,并且我应该只在分析后选择不安全的版本。很高兴知道这两个选项都存在。
    【解决方案2】:

    您的分析是正确的。最终的问题是在修改HashSet 时,编译器不能保证突变不会影响现有的分配。实际上,一般情况下,它们可能会影响它们,除非您添加另一个间接层,正如您已确定的那样。

    这是unsafe 有用的地方的一个典型例子。您,程序员,可以断言代码只会以特定方式使用,并且这种特定方式将允许变量通过任何突变保持稳定。您可以使用类型系统和模块可见性来帮助强制执行这些条件。

    注意String 已经引入了堆分配。只要您在分配后不更改String,就不需要额外的Box

    这样的开始看起来不错:

    use std::{cell::RefCell, collections::HashSet, mem};
    
    struct EasyInterner(RefCell<HashSet<String>>);
    
    impl EasyInterner {
        fn new() -> Self {
            EasyInterner(RefCell::new(HashSet::new()))
        }
    
        fn intern<'a>(&'a self, s: &str) -> &'a str {
            let mut set = self.0.borrow_mut();
    
            if !set.contains(s) {
                set.insert(s.into());
            }
    
            let interned = set.get(s).expect("Impossible missing string");
    
            // TODO: Document the pre- and post-conditions that the code must
            // uphold to make this unsafe code valid instead of copying this
            // from Stack Overflow without reading it
            unsafe { mem::transmute(interned.as_str()) }
        }
    }
    
    fn main() {
        let i = EasyInterner::new();
    
        let a = i.intern("hello");
        let b = i.intern("world");
        let c = i.intern("hello");
    
        // Still strings
        assert_eq!(a, "hello");
        assert_eq!(a, c);
        assert_eq!(b, "world");
    
        // But with the same address
        assert_eq!(a.as_ptr(), c.as_ptr());
        assert!(a.as_ptr() != b.as_ptr());
    
        // This shouldn't compile; a cannot outlive the interner
        // let x = {
        //     let i = EasyInterner::new();
        //     let a = i.intern("hello");
        //     a
        // };
    
        let the_pointer;
        let i = {
            let i = EasyInterner::new();
            {
                // Introduce a scope to contstrain the borrow of `i` for `s`
                let s = i.intern("inner");
                the_pointer = s.as_ptr();
            }
            i // moving i to a new location
              // All outstanding borrows are invalidated
        };
    
        // but the data is still allocated
        let s = i.intern("inner");
        assert_eq!(the_pointer, s.as_ptr());
    }
    

    但是,使用类似以下的板条箱可能更方便:

    【讨论】:

    • 不知道你和马蒂厄的答案中的哪一个要打勾。我认为您已经更严格地回答了我提出的问题,但 Matthieu 给出了更惯用的防锈解决方案,尽管有额外的开销和安全性。这两个答案都做出了宝贵的贡献,我希望我能同时勾选。
    • @Laurence 别担心!你应该接受对最有帮助的答案。赞成票是针对对某人有用的问题和答案。随着时间的推移,人们对不被接受的答案的支持可能会多于或少于接受的答案。
    • @Laurence 虽然我说 string_cache 可能是比滚动你自己的任何一个版本更好的答案。
    猜你喜欢
    • 2019-07-26
    • 1970-01-01
    • 1970-01-01
    • 2015-09-20
    • 2021-03-19
    • 2019-10-03
    • 2011-10-24
    • 2017-05-02
    • 2017-05-21
    相关资源
    最近更新 更多