第 3 课:字符串与复合类型 ------ 数据怎么放
预计时间:75-90 分钟 | 难度:⭐⭐ | 前置:第 1-2 课(变量、类型、函数、控制流)
3.1 这节课解决什么问题
写程序 80% 的时间在处理"数据往哪放、怎么取"。Rust 对数据容器的设计分得很清楚,它不像 Python 那样"字符串就是字符串,列表就是列表",而是把两个问题拆开:
问题一:这块数据是"借来的视图"还是"自己拥有的完整数据"?
→ 字符串里就是 &str(借来的)vs String(拥有的)
→ 数组数据里就是 &[T](切片视图)vs Vec<T>(拥有的动态数组)
问题二:数据长度是编译期已知(栈上)还是运行期才变(堆上)?
→ 数组 [T; N] 定长在栈上
→ Vec/ String 变长在堆上,栈上只放"指针+长度+容量"
这种"拥有/借用"的区分是 Rust 独有的(其他语言大多模糊处理),它直接为第 4 课所有权铺路。所以本课的真正目标不是背 API,而是让你对每种容器先建立"它拥有什么、借用什么"的直觉。
💡 先给结论方便记忆:
&str和&[T]是"只读视图";String和Vec<T>是"可变的、自己持有数据的容器"。 只要在代码里看到类型前带&,立刻想"这只是借用,不是拥有"。
3.2 字符串:String vs &str
3.2.1 两种类型的根本区别
rust
fn main() {
// &str:字符串切片。字面量写进程序(只读区),"借"出来用
let greeting: &str = "你好,Rust";
// String:堆上的、自己拥有的、可增长的字符串
let mut s: String = String::from("你好");
s.push_str(",Rust"); // String 可以修改(要有 mut)
println!("{s}"); // 你好,Rust
// 二者转换:双向都很便宜(对 &str -> String 是拷贝数据到堆)
let s2: String = greeting.to_string(); // 或 String::from(greeting)
let view: &str = &s2; // String 借用成 &str(自动 deref 也可)
let view2: &str = s2.as_str(); // 等价写法
}
内存上看它俩差别(请把这张图记在脑子里,所有字符串问题都源于它):
String s &str view
┌─────────────┐ ┌──────────┐
│ ptr ────────┼───┐ │ ptr ─────┼────┐ ┌─ "你好,Rust..."(堆)
│ len: 9 │ │ │ len: 9 │ └───────▶│ 或(只读区)
│ cap: 9 │ ▼ └──────────┘ └─ "你好,Rust"(程序内嵌)
└─────────────┘ ┌──────────────┐
│ "你好,Rust" │ 堆上/程序内,数据本体
└──────────────┘
String 拥有自己那块内存(有容量 cap,可扩容)
&str 只是"指针+长度",不拥有数据,无法扩容
| 维度 | &str |
String |
|---|---|---|
| 是否拥有数据 | 否(借用/视图) | 是(堆上自有) |
| 是否可变长 | 否(不可 push) | 是(push_str 等) |
| 典型来源 | 字符串字面量、切片 | String::from、.to_string()、拼接 |
| 传参/返回值 | 想只读时用 | 想拥有、想修改时用 |
3.2.2 拼接与 format!(高频)
rust
fn main() {
let a = String::from("Hello");
let b = String::from("World");
// 方式一:+ ------ 语义是 a 被"吃掉"(add 会把 a move 进去),右边必须是 &str
let c = a + &b; // a 之后就不能再用了(被移动)!b 还能用(只借了 &b)
println!("{c}"); // HelloWorld
// 方式二:format! ------ 最常用,任何组合都行,不移动任何变量
let d = format!("{c}-{b}-{}", 2026);
println!("{d}"); // HelloWorld-World-2026
}
⚠️ 常见坑
String + String编译错误:
rust// let e = a + b; // ❌ error[E0369]: cannot add `String` to `String`原因:
+右侧只实现了&str(impl Add<&str> for String)。编译器会提示"右边的操作数可以借用一下(&b)"。凡是看到"cannot add",先想是不是该传引用。
3.2.3 为什么不能 s[0] 按下标取字符(UTF-8 关键课)
Rust 的 String 内部是 UTF-8 字节序列,下标索引是"按字节"的。但一个字符在 UTF-8 里占 1~4 字节,所以"第几个字符"和"第几个字节"对不上:
rust
fn main() {
let s = String::from("中文abc");
// len() 返回的是【字节数】!"中"占 3 字节,"文" 3 字节,a/b/c 各 1
println!("len = {}(字节)", s.len()); // 3+3+3 = 9
println!("chars count = {}", s.chars().count()); // 字符数 = 6
// 直接按下标/按字节切片可能 panic(切在字符中间)
// let ch = s[0]; // ❌ 编译期就拒绝:String 不支持按索引取字符
// let slice = &s[0..1]; // ❌ 运行期 panic:不是字符边界
// 正确取字符:用 chars()
let chars: Vec<char> = s.chars().collect();
println!("第一个字符: {}", chars[0]); // 中
println!("前两个字符: {}", s.chars().take(2).collect::<String>()); // 中文
// 需要按字节看?s.bytes() / s.as_bytes()
println!("bytes: {:?}", s.as_bytes()); // [228, 184, 173, ...]
}
一句话记忆:len() = 字节数,chars().count() = 字符数,遍历/取字符用 chars(),要下标式访问先 .chars().collect::<Vec<_>>() 或 .chars().nth(i)。
💡 后端对接时这点极重要:服务器往往按字节算长度、做截断(比如只允许 20 字节),你按"字符"算就对不上。课程第 14 课做 JSON 校验时还会遇到。
3.2.4 常用字符串 API 速查(每个都给最小示例)
rust
fn main() {
let s = " Hello, Rust 语言! ";
// 查询
println!("{}", s.trim()); // 去首尾空白
println!("{}", s.starts_with(" ")); // true
println!("{}", s.contains("Rust")); // true
println!("{}", "RUST".to_lowercase()); // rust
// 变换(注意:返回新值,原字符串不变------String 默认不可变只是约定,见 3.2.5)
let replaced = "a-b-c".replace("-", "_"); // a_b_c
println!("{replaced}");
// 按空白切词(写单词统计神器)
for word in s.split_whitespace() {
print!("[{word}] ");
}
println!();
// split / splitn / lines
let parts: Vec<&str> = "name=小灵;age=26".split(';').collect();
println!("{parts:?}"); // ["name=小灵", "age=26"]
// 查找:find 返回 Option<usize>(第 5 课才系统讲 Option,先会读即可)
if let Some(idx) = s.find("Rust") {
println!("Rust 首次出现于字节偏移 {idx}");
}
}
⚠️ 坑:
trim()只去 Unicode 空白 (含空格/制表/换行),不去自定义字符;要去掉某个固定前缀/后缀用trim_start_matches("xxx")/strip_prefix。
3.2.5 一个小实验:String 可变性 vs &str 的"修改"
rust
fn main() {
// 为什么 String 要 mut 才能 push?因为它要改自己堆上那块数据
let mut owned = String::from("A");
owned.push('B'); // ok,owned 可变
let view: &str = "AB";
// view.push('C'); // ❌ 借用视图没有 push 方法
// 想"改 &str 内容"?做不到,只能生成新字符串:
let new_view: String = view.replace("AB", "abc");
println!("{owned} {new_view}");
}
这条规则后面的推论很重要:函数若只读字符串,参数写 &str(谁都能传:字面量/String 的借用都行);函数若要存下/修改字符串,参数写 String 或 &mut String。 它是 3.1 那张"拥有 vs 借用"表在函数签名上的直接应用。
3.3 复合类型进阶:tuple / array / slice
第 2 课已见过 tuple 与 array 的基础,这里重点放在三者的对比 和真正的明星 slice(切片) 上。
| 类型 | 长度 | 元素 | 所在内存 | 典型写法 |
|---|---|---|---|---|
tuple |
固定 | 可不同类型 | 栈 | (1, "a", true) |
[T; N] array |
固定,N 编进类型 | 同类型 | 栈 | [1, 2, 3] |
&[T] slice |
不固定(视图) | 同类型 | 指向别人 | &arr[..]、&vec[..] |
Vec<T> |
可变 | 同类型 | 堆 | vec![1, 2, 3] |
3.3.1 切片 &[T]:一段数组的只读窗口
切片是对已有连续数据的借用视图 ,写出来一定是引用形式 &[T]:
rust
fn main() {
let arr = [1, 2, 3, 4, 5];
let whole: &[i32] = &arr[..]; // 整段切片
let head: &[i32] = &arr[..3]; // 前 3 个:[1, 2, 3]
let tail: &[i32] = &arr[2..]; // 从下标 2 到末尾:[3, 4, 5]
let mid: &[i32] = &arr[1..4]; // [2, 3, 4]
println!("{whole:?} {head:?} {tail:?} {mid:?}");
// 也可以切 Vec
let v = vec![10, 20, 30, 40];
let part: &[i32] = &v[1..3]; // [20, 30]
println!("{part:?}");
}
为什么函数要尽量收 &[T] 而不是 &Vec<T>? 因为切片能同时接受数组、Vec 的借用,通用性最强:
rust
fn sum(slice: &[i32]) -> i32 {
let mut total = 0;
for &n in slice { total += n; }
total
}
fn main() {
let arr = [1, 2, 3];
let vec = vec![4, 5, 6];
println!("{} {}", sum(&arr), sum(&vec)); // 两个都能传(&Vec<T> 自动转为 &[T])
}
💡 这是 Rust 生态约定俗成的函数签名风格:只读传
&[T]/&str,拥有或修改才传Vec/String。 后面实战篇封装的每个数据读取函数都会这么写。
3.3.2 切片越界:数组/切片下标访问的"安全阀"
rust
fn main() {
let arr = [1, 2, 3];
// arr[5] ------ 越界:运行期 panic(index out of bounds)
// let x = arr[5];
// 更稳的姿势:.get(i) 返回 Option,不 panic
println!("{:?}", arr.get(0)); // Some(1)
println!("{:?}", arr.get(5)); // None(而不是 panic!)
}
规则:确定不会越界 → 用 arr[i];不确定 → 用 arr.get(i) 拿 Option。 这在本课程后面处理用户输入下标、HTTP 分页参数时会反复用到。
3.4 Vec<T>:可变长数组(本课最重要容器)
Vec<T> 是"会自动扩容的数组",能存同类型数据任意多个,堆上存储:
rust
fn main() {
// 创建
let mut v: Vec<i32> = Vec::new(); // 空 Vec(类型要标/推断出来)
let mut v2 = vec![1, 2, 3]; // vec! 宏(最常用)
let v3 = vec![0; 5]; // 5 个 0
// 增删
v.push(10); v.push(20); v.push(30);
v2.pop(); // 弹掉末尾 3,返回 Option(Some(3))
v2.insert(1, 99); // 在下标 1 插入
v2.remove(0); // 移除下标 0,返回被删元素
// 查改
println!("{:?}", v.get(0)); // Some(&10)(引用!见下)
println!("{}", v[1]); // 直接下标:20
println!("len={} cap={}", v.len(), v.capacity()); // 3 / 4(容量≥长度)
// 遍历(三种取法对应"借用/借用改/拿走")
for x in &v { // 只读借用每个元素,x: &i32
print!("{x} ");
}
println!();
for x in &mut v2 { // 可变借用,可原地改
*x *= 2;
}
println!("{v2:?}"); // [198, 4, 6]
for x in v { // 拿走 v(v 之后不能再用)------ 所有权第 4 课细讲
print!("{x} ");
}
println!();
}
关键点三条:
v[i]返回元素本身,.get(i)返回Option<&T>引用 ------.get不 panic、不复制,读取大元素时也高效(后续读结构体时会看到&无处不在的原因);- 容量 capacity ≥ 长度 len :
push到容量不够时 Vec 会"搬家扩容"(重新分配更大的堆块并把数据拷过去),所以"大概知道多大"时可用Vec::with_capacity(n)提前留好,避免反复扩容(实战篇处理 SSE 大响应时会用); - 遍历三个
for版本本质不同(借用/借用改/拿走),这是所有权语义的预演,第 4 课回来看会豁然开朗。
3.4.1 小试:收集、过滤、求和(只用现有知识)
rust
fn main() {
// 收集 1..=100 里的偶数
let mut evens = Vec::new();
for n in 1..=100 {
if n % 2 == 0 {
evens.push(n);
}
}
println!("偶数个数: {}, 最后一个: {}", evens.len(), evens[evens.len() - 1]);
// 求平均(把 &[i32] 版本的 sum 复用在这里)
let avg = sum(&evens) as f64 / evens.len() as f64;
println!("前若干项均值: {avg:.2}");
}
fn sum(slice: &[i32]) -> i32 {
slice.iter().sum() // 预告:迭代器求和;等价写法见 3.3.1 的循环版
}
💡 注意
sum(&evens):这里把&Vec<i32>传给了要&[i32]的函数------&Vec自动 deref 成切片,所以 3.3.1 的通用签名在这里直接生效。
3.5 读报错专项:String 与 &str 的三类典型错误
选三种最高频的,学会看后你能自己解决大部分字符串报错:
① 把 String 当 &str 用 / 反之(类型不匹配)
rust
fn len_str(s: &str) -> usize { s.chars().count() }
fn main() {
let owned = String::from("hi");
// len_str(owned); // ❌ error[E0308]: mismatched types
// expected `&str`, found `String`
// 编译器通常还会附一句:help: consider borrowing here: `&owned`
len_str(&owned); // ✅ 传借用
}
② 想改 &str(没有所有权)
rust
fn main() {
let s = "hello";
// s.push('!'); // ❌ error[E0596]: cannot borrow `s` as mutable,
// as it is not declared as mutable
// 即使加了 mut:&str 也没有 push 方法,该错误会提示把类型改成 String
}
③ String 拼接右边必须是 &str
rust
fn main() {
let a = String::from("x");
let b = String::from("y");
// let c = a + b; // ❌ error[E0369]: cannot add `String` to `String`
// 修正:a + &b 或 format!("{a}{b}")
}
💡 统一的心法:看到报错里有
expected &str, found String(或反过来)时,先问自己------这个变量到底是"拥有者"还是"借用视图"? 判断准了,加个&或换个.to_string()就好。
3.6 常见坑清单(本课自检)
-
s.len()想当然当"字符个数"------它数的是字节;中文字符 1 个占 3 字节; -
&s[..n]切在字符中间直接 panic------想取前 n 个字符 用chars().take(n); -
Vec/数组越界下标直接 panic------边界不确定就用.get(i); -
String + String报错------右侧改&b; -
字符串 replace/trim 等方法返回新值 ,忘了接收就白干:
rustlet s = String::from("a-b"); s.replace("-", ""); // ❌ 结果被丢弃,编译器会警告 unused let s = s.replace("-", ""); // ✅ 或 let s2 = ... -
遍历 Vec 拿值却只需要读时,若写成
for x in v会把 v 整个移动走,后面再用 v 就报 "use of moved value"(第 4 课正式解释,现在只需记住:只读遍历写for x in &v)。
3.7 📝 动手练习
参考实现放 code/03-compound-strings/(写作时同步给出),每题独立完成再对照。
- 单词统计器 :写
fn count_words(text: &str) -> usize,用split_whitespace()实现;用一段含中文+英文的文本测试,并打印字符数(chars().count())与字节数(len())的差异并解释原因。 - 字符频率 Top3 (不动用 HashMap,用遍历+Vec):统计一个英文句子中每个字母出现次数,打印出现最多的 3 个(提示:
Vec<(char, u32)>,遍历时先查再改,或用元组临时排序思路)。此题做不出可先跳过,第 9 课 HashMap 后是送分题。 - 去重保留顺序 :写
fn dedup(items: &[i32]) -> Vec<i32>,返回去除连续重复项后的 Vec。 - 字符串清洗 :写
fn clean(text: &str) -> String:去首尾空白 → 把连续多个空格压成一个 → 全转小写。要求只用 2.3/3.2 学的 API(可拆多步链式调用)。 - 切片函数化 :把
sum(&[i32])改写为fn avg(slice: &[i32]) -> f64,并分别用数组、Vec 调用,验证&arr与&vec都能传。 - 读报错三连:把 3.5 的三种错误各写一遍,记录错误码(E0308 / E0596 / E0369)与编译器给出的修正提示原文,再修复。
- 字符偏移实验 :
let s = "é中文",分别打印s.len()、s.chars().count()、用chars().enumerate()打印每个字符的字节偏移(提示:需要手动累计字节数,可用c.len_utf8()获取单个字符占几个字节)。
验收门禁 :不查资料能说出------len() 与 chars().count() 的区别;String 与 &str 各在什么时候用;函数"只读时收 &str/&[T]"这条约定的理由;v[i] vs v.get(i) 的选择依据。
✅ 本节小结
- 核心心智 :
&str/&[T]是借用视图(不拥有、不能改);String/Vec<T>是拥有数据的可变容器(堆上,能扩容)------"有没有&"决定了一切; - 字符串 :UTF-8 字节序列,
len()数字节、chars().count()数字符;不能s[0];拼接用+ &s或format!; - slice :
&arr[..]/&vec[..]是通用只读窗口,函数收参首选&[T]; - Vec :
vec![]建、push/pop/insert/remove 增删、[i]与.get(i)两种访问、capacity 与 len 的关系、三种for遍历对应不同所有权; - 报错模式:E0308(类型不匹配)、E0596(不可变借用想改)、E0369(相加类型不对)三类字符串错误的修法。
下一课预告 :第 4 课《所有权系统:Rust 的灵魂》------本课程最重要的一课。为什么前面反复出现"拥有/借用/move/use of moved value"?栈与堆、三条所有权规则、Copy vs Clone、可变与不可变借用规则,一次讲清。这一课想明白,后面 5~21 课都会顺很多。