Использование HashSet для канонизации объектов в Rust

В качестве учебного упражнения я рассматриваю перенос cvs-fast -экспорт в 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<T>. Однако я не могу объяснить срок службы для этого контролеру заимствований.

Модель владения, к которой я стремлюсь, заключается в том, что промежуточная таблица владеет каноническими версиями данных и выдает ссылки. Ссылки должны быть действительными, пока существует промежуточная таблица. Мы должны иметь возможность добавлять новые вещи в таблицу интернирования, не делая недействительными старые ссылки. Я думаю, что корень моей проблемы в том, что я не понимаю, как написать интерфейс для этого контракта в соответствии с моделью владения Rust.

Решения, которые я вижу с моим ограниченным знанием Rust:

  1. Сделайте два прохода, постройте HashSet на первом проходе, затем заморозьте его и используйте ссылки на втором проходе. Это означает дополнительное временное хранилище (иногда существенное).
  2. Небезопасно

У кого-нибудь есть идея получше?


person Laurence    schedule 21.10.2016    source источник


Ответы (2)


Я несколько не согласен с @Shepmaster по поводу использования здесь unsafe.

Хотя сейчас это не вызывает проблем, если кто-то в будущем решит изменить использование HashSet, включив в него некоторую обрезку (например, оставить там только сотню авторов), тогда unsafe будет сильно укусить тебя.

В отсутствие серьезной причины производительности я бы просто использовал Rc<XXX>. Вы можете легко использовать псевдоним: type InternedXXX = Rc<XXX>;.

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);
}
person Matthieu M.    schedule 22.10.2016
comment
Не могли бы вы уточнить, кто может заменить HashSet? Я не думаю, что вы имеете в виду, что разработчик HashSet изменится (в любом случае, мы мало что можем с этим поделать). Если вы имеете в виду использование HashSet внутри интернера, то да. Вот почему я всегда выступаю за использование комментариев рядом с каждым unsafe для его документирования. Я думал об использовании Rc, но не понравилось взаимодействие с принудительным выделением. Например, вы не можете стажироваться &[u16]. - person Shepmaster; 22.10.2016
comment
@Shepmaster: обратите внимание, что я говорю изменить использование HashSet, имея в виду, что либо OP, либо какой-либо другой будущий участник может нарушить инвариант, согласно которому данные никогда не удаляются из набора. Что касается комментирования использования unsafe, это похвальная идея, однако можно и не подумать о том, чтобы заглянуть в метод intern при добавлении другого метода... и можно также неправильно понять или неправильно истолковать комментарии. В этом конкретном примере это, безусловно, достаточно просто, но я бы предпочел не культивировать здесь культуру простого использования unsafe. - person Matthieu M.; 22.10.2016
comment
Чтобы прояснить мой предыдущий комментарий для будущих читателей, мне не нравится это решение в этом случае, потому что оно требует лишних распределений. В частности, каждая строка, которую нужно интернировать, должна быть сначала выделена, затем проверена хэш-карта, а затем либо сохранена, либо освобождена. Общее количество выделений памяти за время существования программы одинаково, но дубликаты освобождаются намного раньше, поэтому максимальное использование памяти в любой момент времени ниже. Существует также некоторый объем накладных расходов времени выполнения для увеличения/уменьшения счетчика ссылок. Если эти ограничения в порядке, это идеальное решение! - person Shepmaster; 22.10.2016
comment
Я чуть не пропустил это сам. Я упустил смысл Rc в том, что вы можете вернуть T вместо &T. Я отметил этот ответ как правильный, хотя @Shepmaster более строго ответил на мой вопрос, поскольку вы правы в том, что это должно быть моим первым решением, и я должен использовать небезопасную версию только после профилирования. Приятно знать, что оба варианта существуют. - person Laurence; 22.10.2016

Ваш анализ правильный. Конечная проблема заключается в том, что при изменении 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());
}

Однако, может быть гораздо целесообразнее использовать обрешетку типа:

person Shepmaster    schedule 22.10.2016
comment
Я разрываюсь, какой из ваших ответов и ответов Матье поставить галочку. Я думаю, что вы более строго ответили на вопрос, который я задал, но Матье дал более идиоматическое решение проблемы ржавчины, хотя и с дополнительными накладными расходами и безопасностью. Оба ответа вносят ценный вклад, я хотел бы отметить оба. - person Laurence; 22.10.2016
comment
@Лоуренс, не беспокойся! Вы должны принять тот ответ, который лучше всего поможет вам. Голоса за вопросы и ответы, которые были полезны кому-то. Со временем люди могут проголосовать за непринятый ответ больше или меньше, чем за принятый. - person Shepmaster; 22.10.2016
comment
@Laurence Хотя я я бы сказал, что string_cache, вероятно, является лучшим ответом, чем любая из версий вашего собственного. - person Shepmaster; 22.10.2016