【问题标题】:Does Iterator::collect allocate the same amount of memory as String::with_capacity?Iterator::collect 是否分配与 String::with_capacity 相同的内存量?
【发布时间】:2020-02-24 22:04:04
【问题描述】:

在 C++ 中,当连接一堆字符串(其中每个元素的大小大致已知)时,通常会预先分配内存以避免多次重新分配和移动:

std::vector<std::string> words;
constexpr size_t APPROX_SIZE = 20;

std::string phrase;
phrase.reserve((words.size() + 5) * APPROX_SIZE);  // <-- avoid multiple allocations
for (const auto &w : words)
  phrase.append(w);

同样,我在 Rust 中做了这个(这个块需要 unicode-segmentation crate)

fn reverse(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    for gc in input.graphemes(true /*extended*/).rev() {
        result.push_str(gc)
    }
    result
}

有人告诉我,惯用的表达方式是单一的表达方式

fn reverse(input: &str) -> String {
  input
      .graphemes(true /*extended*/)
      .rev()
      .collect::<Vec<&str>>()
      .concat()
}

虽然我真的很喜欢它并想使用它,但从内存分配的角度来看,前者分配的块会比后者少吗?

我用cargo rustc --release -- --emit asm -C "llvm-args=-x86-asm-syntax=intel"反汇编了它,但它没有穿插源代码,所以我很茫然。

【问题讨论】:

  • “单一表达式”形式应该是折叠而不是使用集合
  • Graphemes 的迭代器实现有 size_hint()String 在其FromIterator 实现中使用它来估计缓冲区大小,所以我认为不会有巨大的开销使用collect()
  • @DenysSéguret 你的意思是像.fold(String::with_capacity(input.len()), |result, gc| result + gc) 而不是.collect::&lt;Vec&lt;&amp;str&gt;&gt;().concat()
  • @DanilaKiver 感谢您对size_hint 发表评论;不知道。内存分配请求/调用的数量会像第一种方法一样吗?我认为对于每个字素簇,由于对应的Vec::push,都会有一个分配,然后是concat 的最终分配。我问的原因不是特定于这个玩具示例,我试图了解第二种方法的工作原理。知道它会在更大的项目中有所帮助。
  • @legends2k,在重新阅读size_hint()实现后,我意识到它使用1作为边界,并且代码根据提示保留空间也依赖于 lower 界限(StringVec),所以感觉实际上 will 使用这种特定类型(@ 987654340@).

标签: rust dynamic-memory-allocation


【解决方案1】:

您的原始代码很好,我不建议更改它。

原版分配一次:inside String::with_capacity

第二个版本分配至少两次:首先,它创建一个Vec&lt;&amp;str&gt; 并通过pushing &amp;strs 来增长它。然后,它计算所有&amp;strs 的总大小并创建一个具有正确大小的新String。 (此代码在the join_generic_copy method in str.rs 中。)这很糟糕有几个原因:

  1. 显然,它不必要地分配。
  2. 字素簇可以任意大,因此中间的Vec 无法预先调整大小 - 它只是从大小 1 开始并从那里增长。
  3. 对于典型的字符串,它分配的空间比存储最终结果实际所需的空间多,因为&amp;str 通常是 16 字节大小,而 UTF-8 字形簇通常是远不止于此。
  4. 在中间 Vec 上进行迭代以获取最终大小是浪费时间,您可以从原始 &amp;str 中获取它。

最重要的是,我什至不认为这个版本是惯用的,因为它 collects 到一个临时的 Vec 以便对其进行迭代,而不是仅仅 collecting 原始迭代器,如你有你的答案的早期版本。此版本修复了问题 #3,使 #4 无关紧要,但不能令人满意地解决问题 #2:

input.graphemes(true).rev().collect()

collectFromIterator 用于String,这将是try to use 来自Iterator 实现的Graphemessize_hint 的下限。但是,正如我之前提到的,扩展的字形簇可以任意长,因此下限不能大于 1。更糟糕的是,&amp;strs 可能为空,所以 FromIterator&lt;&amp;str&gt;String 不知道任何关于结果大小的内容(以字节为单位)。这段代码只是创建了一个空的String 并对其反复调用push_str

需要明确的是,这还不错! String 有一个增长策略,可以保证分期 O(1) 插入,所以如果你有大部分不需要经常重新分配的小字符串,或者你不相信分配成本是一个瓶颈,使用 @987654352如果您发现它更易读且更容易推理,这里的 @ 可能是合理的。

让我们回到你原来的代码。

let mut result = String::with_capacity(input.len());
for gc in input.graphemes(true).rev() {
    result.push_str(gc);
}

是惯用的collect 也是惯用的,但所有collect 所做的基本上都是上述内容,初始容量不太准确。由于collect 没有做你想做的事,所以自己编写代码并不习惯。

还有一个更简洁的迭代器版本,它仍然只进行一次分配。使用extend 方法,它是Extend&lt;&amp;str&gt; 的一部分,用于String

fn reverse(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    result.extend(input.graphemes(true).rev());
    result
}

我有一种模糊的感觉,extend 更好,但这两种方式都是编写相同代码的完全惯用方式。你不应该重写它以使用collect,除非你觉得它更好地表达了意图并且你不关心额外的分配。

相关

【讨论】:

  • 我是extend 版本的忠实粉丝,这就是我的选择。
  • 我认为我喜欢extend 的地方在于它同时位于动词和受影响的宾语前面。您不必了解整行即可看到它有什么副作用:它extends result。使用collectfold 前面input,所以你会看到首先迭代的是什么,然后是迭代的形式,“最重要”的位——分配和填充字符串——被“埋”在表达式的结尾。
  • 我会注意到,如果函数被多次使用,例如在循环中,提供一个带有&amp;mut String out 参数的版本可能是有益的。在某些情况下,这可能允许反复使用缓冲区。
猜你喜欢
  • 2020-05-21
  • 2021-12-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-08-19
  • 2020-04-18
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多