Rust中避免重复字符串的教程

455 阅读15分钟

基于实际事件。

比方说,你有一个博客。该博客有一堆帖子。每个帖子都有一个标题和一组标签。这些帖子的元数据都包含在一个目录下的TOML文件中。(如果你使用Zola,你就很接近了。)现在你需要生成一个CSV文件,显示博客文章及其标签的矩阵。对于Rust来说,这似乎是一个很好的工作。

在这篇文章中,我们将:

  • 探索我们如何解决这个(相当简单的)问题
  • 研究Rust的类型如何告诉我们很多关于内存使用的信息
  • 使用一些好的和不好的方法来优化我们的程序

继续前进!

程序行为

我们在posts 目录下有一堆TOML文件。这里有一些例子文件。

# devops-for-developers.toml
title = "DevOps for (Skeptical) Developers"
tags = ["dev", "devops"]

# rust-devops.toml
title = "Rust with DevOps"
tags = ["devops", "rust"]

我们想创建一个CSV文件,看起来像这样。

Title,dev,devops,rust,streaming
DevOps for (Skeptical) Developers,true,true,false,false
Rust with DevOps,false,true,true,false
Serverless Rust using WASM and Cloudflare,false,true,true,false
Streaming UTF-8 in Haskell and Rust,false,false,true,true

为了实现这一点,我们需要:

  • 遍历posts 目录中的文件
  • 加载并解析每个TOML文件
  • 收集所有帖子中存在的所有标签的集合
  • 收集解析后的帖子信息
  • 从这些信息中创建一个CSV文件

不算太坏,对吗?

设置

你应该确保你已经安装了Rust工具。然后你可以用cargo new tagcsv 创建一个新的空项目。

稍后,我们要玩一些不稳定的语言特性,所以让我们选择进入编译器的夜间版本。要做到这一点,创建一个包含rust-toolchain 的文件。

nightly-2020-08-29

然后在你的Cargo.toml 文件中添加以下依赖项。

[dependencies]
csv = "1.1.3"
serde = "1.0.115"
serde_derive = "1.0.115"
toml = "0.5.6"

好了,现在我们终于可以编写一些代码了!

第一个版本

我们将使用toml crate来解析我们的元数据文件。toml 是建立在serde 之上的,我们可以方便地使用serde_derive ,为代表该元数据的struct 自动派生出一个Deserialize 实现。因此,我们将用以下方式开始我们的程序。

use serde_derive::Deserialize;
use std::collections::HashSet;

#[derive(Deserialize)]
struct Post {
    title: String,
    tags: HashSet<String>,
}

接下来,我们将定义我们的main 函数来加载数据。

fn main() -> Result<(), std::io::Error> {
    // Collect all tags across all of the posts
    let mut all_tags: HashSet<String> = HashSet::new();
    // And collect the individual posts
    let mut posts: Vec<Post> = Vec::new();

    // Read in the files in the posts directory
    let dir = std::fs::read_dir("posts")?;
    for entry in dir {
        // Error handling
        let entry = entry?;
        // Read the file contents as a String
        let contents = std::fs::read_to_string(entry.path())?;
        // Parse the contents with the toml crate
        let post: Post = toml::from_str(&contents)?;
        // Add all of the tags to the all_tags set
        for tag in &post.tags {
            all_tags.insert(tag.clone());
        }
        // Update the Vec of posts
        posts.push(post);
    }
    // Generate the CSV output
    gen_csv(&all_tags, &posts)?;
    Ok(())
}

最后,让我们定义我们的gen_csv 函数来获取标签集和帖子的Vec ,并生成输出文件。

fn gen_csv(all_tags: &HashSet<String>, posts: &[Post]) -> Result<(), std::io::Error> {
    // Open the file for output
    let mut writer = csv::Writer::from_path("tag-matrix.csv")?;

    // Generate the header, with the word "Title" and then all of the tags
    let mut header = vec!["Title"];
    for tag in all_tags.iter() {
        header.push(tag);
    }
    writer.write_record(header)?;

    // Print out a separate row for each post
    for post in posts {
        // Create a record with the post title...
        let mut record = vec![post.title.as_str()];
        for tag in all_tags {
            // and then a true or false for each tag name
            let field = if post.tags.contains(tag) {
                "true"
            } else {
                "false"
            };
            record.push(field);
        }
        writer.write_record(record)?;
    }
    writer.flush()?;
    Ok(())
}

题外话:如果能将标签集按字母顺序排列就更好了,你可以将所有的标签收集到一个Vec ,然后进行排序。我以前有这个功能,但在上面的代码中删除了它,以减少对例子的附带噪音。如果你觉得好玩,可以试着把它加回来。

总之,这个程序完全按照我们的要求工作,并产生一个CSV文件。很完美,对吗?

让类型引导你

我喜欢类型驱动的编程。我喜欢这样的想法,即通过观察类型可以了解到很多关于程序的行为。而在Rust中,类型往往可以告诉你程序的内存使用情况。我想集中讨论两行,然后用第三行来证明一个观点。考虑一下。

tags: HashSet<String>,

let mut all_tags: HashSet<String> = HashSet::new();

首先,我喜欢这样一个事实,即类型告诉我们如此多的预期行为。标签是一个集合:顺序不重要,而且没有重复的。这很有意义。我们不希望在所有标签的集合中列举两次 "devops"。而且,"dev "和 "rust "在本质上并没有什么 "第一 "或 "第二"。而且我们知道,标签是任意的文本数据。很好。

但我真正喜欢的是,它告诉我们内存的使用情况。每个帖子都有自己的每个标签的副本。all_tags 集也是如此。我怎么知道这些?很简单:因为这正是String 的意思。根本就不存在数据共享的可能性。如果我们有200个标记为 "dev "的帖子,我们将在内存中有201个 "dev "字符串的副本(200个帖子,1个all_tags )。

现在我们已经在类型中看到了它,我们也可以在实现中看到它的证据。

all_tags.insert(tag.clone());

当我第一次写它的时候,.clone() ,这让我很困扰。而这也是让我去看类型的原因,这让我更加困扰。

实际上,这没什么可担心的。即使有1000个帖子,平均5个标签,每个标签平均20个字节,这也只会多占用10万个字节的内存。因此,优化这一点并不是对我们时间的良好利用。我们最好做一些其他的事情。

但我想找点乐子。如果你在读这篇文章,我想你也想继续这个旅程。向前走!

Rc

这并不是我尝试的第一个解决方案。但它是第一个容易成功的方案。所以我们就从这里开始。

我们首先要改变的是我们的类型。只要我们有HashSet<String> ,我们就知道,我们会有额外的数据副本。这似乎是Rc 的一个很好的用例。Rc 使用引用计数来让多个值分享另一个值的所有权。听起来正是我们想要的。

我的方法是使用编译器错误驱动的开发,我鼓励你用你自己的代码副本来玩。首先,让我们use Rc

use std::rc::Rc;

接下来,让我们改变我们对Post 的定义,使用一个Rc<String> ,而不是String

#[derive(Deserialize)]
struct Post {
    title: String,
    tags: HashSet<Rc<String>>,
}

编译器不太喜欢这样。我们不能为一个Rc<String> 推导出Deserialize 。因此,让我们为反序列化做一个RawPost 结构,然后用Rc<String> 专门用于保存数据的Post 。 换句话说:

#[derive(Deserialize)]
struct RawPost {
    title: String,
    tags: HashSet<String>,
}

struct Post {
    title: String,
    tags: HashSet<Rc<String>>,
}

然后,在解析toml ,我们将解析成一个RawPost 类型。

let post: RawPost = toml::from_str(&contents)?;

如果你跟得上,这时你只会有一条错误信息,关于posts.push(post); ,在PostRawPost 之间有不匹配。但在我们解决这个问题之前,让我们在上面再做一个类型改变。我想让all_tags 包含Rc<String>

let mut all_tags: HashSet<Rc<String>> = HashSet::new();

好的,现在我们得到了一些关于Rc<String>String 之间不匹配的错误信息。这就是我们必须要小心的地方。最简单的做法是简单地将我们的Strings包裹在一个Rc 中,最后得到大量的String 的拷贝。让我们先错误地实现下一步,看看我在说什么。

在我们重写代码的这一点上,我们已经有了一个RawPost ,我们需要:

  • 将其标签添加到all_tags
  • 在此基础上创建一个新的PostRawPost
  • Post 添加到posts Vec

这里是简单而浪费的实现。

let raw_post: RawPost = toml::from_str(&contents)?;

let mut post_tags: HashSet<Rc<String>> = HashSet::new();

for tag in raw_post.tags {
    let tag = Rc::new(tag);
    all_tags.insert(tag.clone());
    post_tags.insert(tag);
}

let post = Post {
    title: raw_post.title,
    tags: post_tags,
};
posts.push(post);

这里的问题是,我们总是保留来自RawPost 的原始String 。如果该标签已经存在于all_tags 集中,我们最终不会使用同一个副本。

HashSets上有一个不稳定的方法可以帮助我们解决这个问题。get_or_insert 将尝试把一个值插入到一个HashSet 。如果该值已经存在,它将放弃新的值并返回对原始值的引用。如果该值不存在,该值将被添加到HashSet ,我们将得到一个对它的引用。改变我们的代码来使用它是非常容易的。

for tag in raw_post.tags {
    let tag = Rc::new(tag);
    let tag = all_tags.get_or_insert(tag);
    post_tags.insert(tag.clone());
}

我们最终还是要调用.clone() ,但现在它是一个Rc 的克隆,这是一个廉价的整数增量。不需要额外的内存分配!由于这种方法不稳定,我们还必须通过在你的源文件的顶部添加这个来启用这个功能。

#![feature(hash_set_entry)]

而且只需要再做一个改动。gen_csv 的签名是希望有一个&HashSet<String> 。如果你把它改成&HashSet<Rc<String>> ,代码就能正确编译和运行。耶!

如果你对上面的所有编辑感到迷惑,这里是当前版本的main.rs

争论

我已经告诉过你,原始的HashSet<String> 版本的代码在大多数情况下可能是足够好的。我还会告诉你,如果你真的被那笔开销所困扰,HashSet<Rc<String>> 版本几乎肯定是正确的。所以我们也许应该到此为止,以一个漂亮、安全的音符结束这篇博文。

但是让我们大胆一点,疯狂一点。实际上我不太喜欢这个版本的代码,有两个原因;

  1. Rc 在这里感觉很脏。Rc 对于有值的奇怪的生命期情况很好。但在我们的例子中,我们知道拥有所有标签的all_tags 集,将永远比Posts里面的标签的使用时间长。所以引用计数感觉是一种不必要的开销,并且掩盖了情况。
  2. 正如之前所展示的,使用Rc<String> 版本很容易出乱子。你可以意外地绕过所有节省内存的好处,使用一个新的String ,而不是克隆一个现有的引用。

我真正想做的是让all_tags 成为一个HashSet<String> ,并拥有标签本身。然后,在Post 里面,我想保留对这些标签的引用。不幸的是,这并不完全可行。你能预见到为什么吗?如果不能,别担心,我也没看出来,直到借贷检查器告诉我我错了好几次。让我们一起体验这种快乐。而我们将再次用编译器驱动的开发来做这件事。

我准备做的第一件事是删除use std::rc::Rc; 语句。这导致了我们的第一个错误:Rc 不在Post 的范围内。我们想在这个结构中保留一个&str 。但在结构中保留引用时,我们必须明确规定寿命。所以我们的代码最后变成了:

struct Post<'a> {
    title: String,
    tags: HashSet<&'a str>,
}

下一个错误是关于main 中的all_tags 的定义。这很简单:只需去掉Rc :

let mut all_tags: HashSet<String> = HashSet::new();

这就很简单了!类似地,post_tags 被定义为HashSet<Rc<String>> 。在这种情况下,我们想用&strs 来代替,所以:

let mut post_tags: HashSet<&str> = HashSet::new();

我们不再需要在for 循环中使用Rc::new ,或者克隆Rc 。因此我们的循环简化为:

for tag in raw_post.tags {
    let tag = all_tags.get_or_insert(tag);
    post_tags.insert(tag);
}

而且(误导性的),我们只剩下一个错误信息:gen_csv 的签名仍然使用Rc 。我们将用新的签名摆脱它。

fn gen_csv(all_tags: &HashSet<String>, posts: &[Post]) -> Result<(), std::io::Error> {

我们会得到一个关于&str&String 不太一致的错误信息(令人困惑)。

error[E0277]: the trait bound `&str: std::borrow::Borrow<std::string::String>` is not satisfied
  --> src\main.rs:67:38
   |
67 |             let field = if post.tags.contains(tag) {
   |                                      ^^^^^^^^ the trait `std::borrow::Borrow<std::string::String>` is not implemented for `&str`

但这可以通过as_str 方法明确地要求一个&str 来解决。

let field = if post.tags.contains(tag.as_str()) {

你可能认为我们已经完成了。但这正是 "误导 "想法发挥作用的地方。

借款检查员赢了

如果你一直在关注,你现在应该在你的屏幕上看到一个错误信息,看起来像:

error[E0499]: cannot borrow `all_tags` as mutable more than once at a time
  --> src\main.rs:35:23
   |
35 |             let tag = all_tags.get_or_insert(tag);
   |                       ^^^^^^^^ mutable borrow starts here in previous iteration of loop

error[E0502]: cannot borrow `all_tags` as immutable because it is also borrowed as mutable
  --> src\main.rs:46:13
   |
35 |             let tag = all_tags.get_or_insert(tag);
   |                       -------- mutable borrow occurs here
...
46 |     gen_csv(&all_tags, &posts)?;
   |             ^^^^^^^^^  ------ mutable borrow later used here
   |             |
   |             immutable borrow occurs here

确信借款检查器在这里过于谨慎了。为什么对all_tags 的可变借用来向集合中插入一个标签会与对集合中的标签的不可变借用相冲突?(如果你已经看到了我的错误,请随意嘲笑我的天真。)我可以理解为什么我违反了借用检查规则。具体来说:你不能让一个易变的引用和任何其他引用同时存在。但我不明白这如何能阻止我的代码发生分离故障。

经过一番思考,我明白了。我意识到,我的脑子里有一个不变量,但在我的类型中没有出现。因此,借贷检查器完全有理由说我的代码是不安全的。我意识到的是,我一直隐含地假设我对all_tags 集的突变永远不会删除该集中的任何现有值。我可以看一下我的代码,发现情况确实如此。然而,借贷检查器并不玩这些类型的游戏。它处理的是类型和事实。而事实上,我的代码并没有被证明是正确的。

所以现在真的是时候放弃了,接受Rc,甚至只是接受String和浪费的内存。我们都完成了。请不要再继续读下去了。

是时候变得不安全了

好吧,我说谎了。我们要在这里采取最后一步。我不打算告诉你这是个好主意。我不打算告诉你这段代码一般来说是安全的。我要告诉你的是,它在我的测试中是有效的,而且我拒绝把它提交到我正在进行的项目的主分支中。

我们有两个问题:

  • 我们有一个未声明的不变因素,即我们永远不会从我们的all_tags 中删除标签。HashSet
  • 我们需要一个对HashSet 的可变引用来插入,而这使得我们的标签不能采用不可变的引用

让我们来解决这个问题。我们将定义一个新的struct ,称为AppendSet ,它只提供插入新标签的能力,而不是删除旧标签。

struct AppendSet<T> {
    inner: HashSet<T>,
}

我们将提供三个方法:

  • 一个静态方法new, 无聊
  • 一个get_or_insert ,它的行为和HashSet's一样,但只需要一个不可变的引用,而不是一个可变的引用
  • 一个inner 方法,返回内部HashSet 的引用,这样我们就可以重新使用它的Iterator 接口。

第一个和最后一个真的很容易。get_or_insert 是一个有点复杂的问题,现在我们先把它存起来。

impl<T> AppendSet<T> {
    fn new() -> Self {
        AppendSet {
            inner: HashSet::new(),
        }
    }

    fn get_or_insert(&self, t: T) -> &T
    where
        T: Eq + std::hash::Hash,
    {
        unimplemented!()
    }

    fn inner(&self) -> &HashSet<T> {
        &self.inner
    }
}

接下来,我们将重新定义all_tags 为:

let all_tags: AppendSet<String> = AppendSet::new();

注意,我们在这里不再有mut 这个关键字。我们不需要改变这个东西......算是吧。我们将通过get_or_insert 与它互动,这至少说明它不会变异。我们唯一要做的改变是在对gen_csv 的调用中,在这里我们要使用inner() 方法。

gen_csv(all_tags.inner(), &posts)?;

也许令人惊讶的是,我们的代码现在可以编译了。现在只剩下一件事要做:实现那个get_or_insert 方法。而这正是肮脏发生的地方。

fn get_or_insert(&self, t: T) -> &T
where
    T: Eq + std::hash::Hash,
{
    let const_ptr = self as *const Self;
    let mut_ptr = const_ptr as *mut Self;
    let this = unsafe { &mut *mut_ptr };
    this.inner.get_or_insert(t)
}

这就对了,unsafe 宝贝!

这段代码绝对有效。我也相当肯定它一般不会工作。我们很可能违反了HashSet's 接口的不变性。作为一个简单的例子,我们现在有能力改变一个HashSet 的内容,同时有一个正在进行的迭代循环通过它。我还没有调查过HashSet 的内部情况,但如果发现这破坏了一些不变性,我一点也不惊讶。

注意为了解决其中一个问题:如果我们修改AppendSet 上的inner 方法,以消耗self ,并返回一个HashSet ,会怎么样? 这肯定会帮助我们避免意外地违反不变性。但它也不会被编译。AppendSet 本身就被Post 的值不可改变地借用了,因此我们不能移动它。

那么,这段代码有用吗?它似乎是的。AppendSet 一般来说对类似的问题会起作用吗?我不知道。在未来的标准库版本中,如果对HashSet 的实现进行修改,这段代码是否还能继续工作?我也不知道。换句话说:不要使用这段代码。但写起来确实很有趣。

结论

这当然是一次有趣的旅行。在需要不安全的理想解决方案中结束,这有点令人失望。但Rc ,是一个非常好的中间地带。而且,即使是 "坏 "的版本也不是那么糟糕。

理论上更好的答案是使用一个专门为这个用例设计的数据结构。我没有做任何调查,看看这种东西是否已经存在了。如果你有什么建议,请告诉我!