工程:把代码组织起来

Vec、HashMap 与字符串的三种形态

它在解决什么

前面几章已经零散用过 Vec 和 String,但没系统讲过。 这一篇收掉三件天天会撞到的事:选哪个容器、 entry API 为什么比 get + insert 好、 字符串的四种「字符」各是什么。

Vec<T>:默认选择

let mut v = vec![1, 2, 3];
v.push(4);
v.extend([5, 6]);

Vec::new() 不分配任何内存,容量是 0 —— 第一次 push 才会分配。

⇒ 知道大概要放多少个元素时用 Vec::with_capacity(n), 省掉中途几次重新分配(每次重新分配都要把老数据整个搬一遍)。

⚠️ 那条示例只断言 capacity() >= 1,不断言具体的增长倍数 —— 那是标准库的实现细节,换个版本可能就变了。而「空 Vec 容量是 0」是有保证的。

sort + dedup 是去重的标准组合

dedup 只删相邻的重复。 不先排序的话,[3, 1, 2, 3, 1] 一个元素都删不掉。

⇒ 只要去重不要排序,用 HashSet。

HashMap / BTreeMap / Vec<(K, V)>

三选一的判据只有一句:

你要什么 用哪个
最快的查找 HashMap
按键有序遍历 BTreeMap
保留插入顺序 别用 map —— 用 Vec<(K, V)>

BTreeMap 按键排序, 所以那条示例输出 a1b2c3 而不是插入顺序 c3a1b2。

🚨 HashMap 的遍历顺序不保证,而且 Rust 每次运行还会换哈希种子 (这是防哈希碰撞攻击的设计)。所以:

  • 要稳定输出就自己排;
  • 别把 HashMap 的遍历顺序写进测试断言 —— 那种测试会随机挂。

entry:一次查找搞定「有就改、没有就插」

*m.entry("a").or_insert(0) += 1;

对比 get + insert 的写法,它只查一次哈希表,而且不用处理 Option。

⚠️ 别忘了前面那个 * —— or_insert 返回的是 &mut i32,不解引用就是 E0368。

几个常用变体:

写法 什么时候用
or_insert(0) 默认值很便宜
or_insert_with(Vec::new) 默认值构造起来贵,没用上就别造
or_default() 默认值取类型的 Default
and_modify(|v| *v += 1).or_insert(1) 存在和不存在要做不同的事

字符串:四种「字符」

这是从别的语言过来最容易出错的地方,因为那些语言通常只有一种。

类型 是什么 大小
u8 一个字节 1
char 一个 Unicode 标量值 4 字节(不是 1)—— 实测
&str 一段借来的 UTF-8 文本 胖指针(地址 + 长度),16 字节
String 拥有的、可增长的 UTF-8 文本 地址 + 长度 + 容量,24 字节

len() 给的是字节数:

let s = String::from("héllo");
s.len()             // 6  ← 字节
s.chars().count()   // 5  ← 字符

换成纯 ASCII 之后两个数就一样了 —— 这正是这类 bug 只在遇到非 ASCII 时才暴露的原因。

⚠️ chars() 数的也不完全等于「用户看到的字符」: 组合字符(e + 重音符)、emoji 序列(👨‍👩‍👧)都会比你数的多。 真要按「用户感知的字符」处理,得上 unicode-segmentation 这类库。

String 和 &str 怎么互转

let s: String = "hi".to_string();      // 或 String::from("hi")
let r: &str = &s;                      // 借用,零成本
let r2: &str = s.as_str();             // 同上,写法更明确

⇒ 参数位置写 &str、存储位置写 String, 理由见切片那篇和 什么时候该 clone。

这一章到这里就完整了

篇 它回答的问题
模块、crate 与 workspace 代码怎么组织,谁能看见谁
测试与 cargo test 怎么让它不退化
本篇 那几个天天用的容器怎么选

下一步

最后一章讲的是怎么正当地绕开所有权那一章立下的规则 —— 从《智能指针与内部可变性》开始。

全部篇目见 Rust 教程首页。

本篇示例

下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。

`entry` API:一次查找搞定「有就改、没有就插」

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<&str, i32> = HashMap::new();
    *m.entry("a").or_insert(0) += 1;
    *m.entry("a").or_insert(0) += 1;
    let mut out: Vec<String> = m.iter().map(|(k, v)| format!("{k}={v}")).collect();
    out.sort();
    println!("{}", out.join(","));
}

编译通过 · 输出 "a=2\n"

对照:忘了前面那个 `*`
use std::collections::HashMap;

fn main() {
    let mut m: HashMap<&str, i32> = HashMap::new();
    m.entry("a").or_insert(0) += 1;
    m.entry("a").or_insert(0) += 1;
    let mut out: Vec<String> = m.iter().map(|(k, v)| format!("{k}={v}")).collect();
    out.sort();
    println!("{}", out.join(","));
}

编译不过:error[E0368]、error[E0368]

`or_insert` 返回的是 `&mut i32`,所以要 `*` 解引用才能加(对照项 `E0368`)。⚠️ 注意最后那个 `out.sort()` 不是装饰:**`HashMap` 的遍历顺序不保证**,而且 Rust 每次运行还会换种子。要稳定输出就得自己排。

要顺序就用 `BTreeMap`

use std::collections::BTreeMap;

fn main() {
    let mut m = BTreeMap::new();
    m.insert("c", 3);
    m.insert("a", 1);
    m.insert("b", 2);
    let out: Vec<String> = m.iter().map(|(k, v)| format!("{k}{v}")).collect();
    println!("{}", out.join(""));
}

编译通过 · 输出 "a1b2c3\n"

对照:换成 `Vec<(&str, i32)>`(保留插入顺序)
fn main() {
    let m = vec![("c", 3), ("a", 1), ("b", 2)];
    let out: Vec<String> = m.iter().map(|(k, v)| format!("{k}{v}")).collect();
    println!("{}", out.join(""));
}

编译通过,输出:"c3a1b2\n"

`BTreeMap` 按**键排序**遍历(所以是 `a1b2c3` 而不是插入顺序 `c3a1b2`)。⇒ 三选一的判据:要最快查找用 `HashMap`,要有序遍历用 `BTreeMap`,要保留插入顺序就别用 map —— 用 `Vec<(K, V)>`。

`len()` 给的是**字节数**,不是字符数

fn main() {
    let s = String::from("héllo");
    println!("{} {} {}", s.len(), s.chars().count(), s.bytes().count());
}

编译通过 · 输出 "6 5 6\n"

对照:把 `é` 换成普通的 `e`
fn main() {
    let s = String::from("hello");
    println!("{} {} {}", s.len(), s.chars().count(), s.bytes().count());
}

编译通过,输出:"5 5 5\n"

`é` 在 UTF-8 下占 2 个字节,所以 `len()` 是 6 而 `chars().count()` 是 5。对照项换成纯 ASCII 之后三个数就一样了 —— **这正是这类 bug 只在遇到非 ASCII 时才暴露的原因**。⚠️ `chars()` 数的是 Unicode 标量值,也不完全等于「用户看到的字符」(组合字符、emoji 序列会更复杂)。

`dedup` 只删**相邻**的重复

fn main() {
    let mut v = vec![3, 1, 2, 3, 1];
    v.sort();
    v.dedup();
    println!("{v:?}");
}

编译通过 · 输出 "[1, 2, 3]\n"

对照:去掉 `v.sort()`
fn main() {
    let mut v = vec![3, 1, 2, 3, 1];
    v.dedup();
    println!("{v:?}");
}

编译通过,输出:"[3, 1, 2, 3, 1]\n"

对照项**一个元素都没删掉**(输出还是原样),因为那五个数里没有两个相邻的相同。⇒ `sort` + `dedup` 是去重的标准组合;只要去重不要排序,用 `HashSet`。

`Vec::new()` 不分配内存,`with_capacity` 才分配

fn main() {
    let mut v: Vec<i32> = Vec::new();
    let before = v.capacity();
    v.push(1);
    println!("{} {}", before, v.capacity() >= 1);
}

编译通过 · 输出 "0 true\n"

对照:改成 `Vec::with_capacity(10)`
fn main() {
    let mut v: Vec<i32> = Vec::with_capacity(10);
    let before = v.capacity();
    v.push(1);
    println!("{} {}", before, v.capacity() >= 1);
}

编译通过,输出:"10 true\n"

⚠️ 这条只断言 `capacity() >= 1`,**不断言具体的增长倍数** —— 那是标准库的实现细节,不同版本可能改。空 `Vec` 容量是 0(不分配)这一点倒是有保证的。⇒ 知道大概要放多少个元素时用 `with_capacity`,省掉中途几次重新分配。