课程概览 · 第 3 章

上一篇:字符串与切片:String、&str 与 UTF-8 边界

下一篇:表达式与控制流:让分支、循环和返回值清楚可审查

上一章的结论是"只读文本用 &str"。本章把同一思路推广到任意序列:函数应该声明自己需要的是"一段数据"还是"修改一段数据",而不是绑死调用方用 Vec 还是数组。切片就是承载这个契约的类型;边界与 UTF-8 是它真正的坑所在。

切片的读写接口、连续区间视图、下标边界检查与 UTF-8 字符串边界
图:切片 API 由读写权限、输入可信度和是否为 UTF-8 文本三项共同决定。

学习目标与默认选择

学完本章你能:

  • &[T] / &mut [T] / &str 写出不绑定具体容器的函数;
  • 在可信内部索引与用户可控范围之间选择 []get
  • 区分数值越界与 UTF-8 字符边界违反这两类 get 失败;
  • 解释切片为什么"借用而不分配"。

默认选择:

函数需要签名调用方付出什么
只读一段序列&[T]一次借用,函数返回后数据照常使用
修改一段序列&mut [T]借用期间独占,返回后照常使用
拿走序列并保存Vec<T> / T转移所有权(本章不默认)
只读文本&str&[T],且保证合法 UTF-8

切片接口:读、改、取前缀

一个程序演示三种切片接口:average 只读并对空输入返回 Nonezero_out 独占修改;first_two 对不足两个元素的输入返回 None 而不是 panic。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/// 平均值。空输入没有平均值,用 None 表达,不用 0.0 冒充。
fn average(values: &[f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
Some(values.iter().sum::<f64>() / values.len() as f64)
}

/// 把切片内所有元素清零。只修改借用的这段,不拥有整块数据。
fn zero_out(values: &mut [i32]) {
values.fill(0);
}

/// 取前两个字节;不足两个(包括单字节和空输入)返回 None。
fn first_two(input: &[u8]) -> Option<&[u8]> {
input.get(..2)
}

fn main() {
let values = vec![10.0, 20.0, 30.0, 40.0];

assert_eq!(average(&values), Some(25.0));
assert_eq!(average(&values[1..3]), Some(25.0));

// 空输入:显式失败路径。
assert_eq!(average(&[]), None);

// 可变切片作用在子段上,调用方仍拥有整块。
let mut ints = vec![10, 20, 30, 40];
zero_out(&mut ints[2..]);
assert_eq!(ints, vec![10, 20, 0, 0]);

// first_two 的三种边界。
let packet = [0x01, 0x02, 0x03];
assert_eq!(first_two(&packet), Some(&[0x01, 0x02][..]));
assert_eq!(first_two(&[0xFF]), None); // 单字节:不够两个
assert_eq!(first_two(&[]), None); // 空:不够两个
}

first_two(&[0xFF]) 返回 None 是本章的关键断言:get(..2) 在范围越界时给你一个可分支处理的 Option,而不是让程序崩溃。用户可控的长度/下标,一律走 get

边界:直接索引 vs get

a[start..end] 越界会 panic;a.get(range) 越界返回 None。两者的分工不是"谁更安全",而是错误的性质不同

  • 边界由代码内部逻辑保证(循环变量来自 len()、切分点来自 char_indices()):直接 []/范围即可,panic 意味着内部逻辑有 bug,崩溃是正确行为;
  • 边界来自外部输入(用户输入的长度、网络包里声明的字节数):必须 get,把"不合法"作为正常业务分支处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
let durations = [30, 45, 60, 90];

// 可信内部索引:范围由 len() 保证。
assert_eq!(durations[1..3], [45, 60]);

// 用户可控范围:永远 get。
// 假装两个下标都来自命令行参数:范围内返回 Some,越界返回 None。
let user_start = 1;
let user_end = 3;
match durations.get(user_start..user_end) {
Some(window) => assert_eq!(window, &[45, 60]),
None => panic!("in-range case must return Some"),
}
assert!(durations.get(3..10).is_none()); // end 越界:None,不 panic
assert!(durations.get(2..1).is_none()); // start > end:同样是 None
}

范围语法速查:

写法范围典型用途
a[start..end]start <= i < end最常见,长度是 end - start
a[start..]到结尾跳过前缀
a[..end]从开头取前缀
a.get(start..end)返回 Option外部输入或边界不可信时

UTF-8 边界:同一条规则的两面

第 2 章已经给出字符串切片的完整规则:切分点必须落在字符边界上,[] 违反时 panic、get 返回 None。这里只需要把那条规则放回切片语境,理解它和数值越界是同一机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let title = "修复 login 权限";

// 数值越界:get 表达为 None(外部输入走这条路径)。
assert!(title.get(..100).is_none());

// 字符边界违反:同样是 None。语言不可能给出半个字符。
// "修" 占 3 个字节,下标 2 落在它的中间。
assert_eq!(title.get(..2), None);
assert_eq!(title.get(..3), Some("修")); // 3 是合法边界

// 数组切片没有 UTF-8 约束:任何下标都是合法边界,只有数值越界。
let bytes = [0x01, 0x02, 0x03];
assert_eq!(bytes.get(..2), Some(&[0x01, 0x02][..]));
}

&str&[u8] 的差别只在最后那层检查:&[T] 检查数值边界,&str 在此之上还检查字符边界。按字符截断的 char_indices() 写法已在第 2 章展开,本章不再重复。

借用而不分配

&[T]&str 是胖指针(地址 + 长度),传递它们只是复制两个机器字,不复制元素。只有 to_vec()to_string()collect() 这类操作才会分配新的拥有者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn count_positive(values: &[i32]) -> usize {
// 借用迭代:零分配。
values.iter().filter(|&&v| v > 0).count()
}

fn main() {
let durations = vec![30, -5, 45, 0, 90];
assert_eq!(count_positive(&durations), 3);

// 需要独立拥有时才显式分配(to_vec),此时原数据不受影响。
let copy = durations.to_vec();
assert_eq!(copy, durations);

// &str 同理:切片操作返回的还是借用视图。
let title = String::from("release checklist");
let head: &str = &title[..7];
assert_eq!(head, "release");
// title 仍可使用;head 只是视图。
assert_eq!(title.len(), 17);
}

为什么可行:切片是胖指针

&[T] 概念上是(数据起始地址, 元素数量);&str 是(字节地址, 字节长度)。因此传切片是固定大小的复制,元素不搬家;长度随引用同行,编译器据此在索引处插入边界检查,并能在证明安全时优化掉。&str 额外携带"内容是合法 UTF-8"的保证,所以边界检查之外还有字符边界检查。get[] 的差异只是越界时的行为None vs panic),检查本身两者都做。

常见误区

  • 函数签名绑死容器:写 fn f(v: Vec<i32>)fn f(v: &Vec<i32>),调用方被迫分配/让接口无法接受数组;只要读,就写 &[i32]
  • getNone 当异常:用户可控范围下"越界"是正常输入的一种,走业务分支,不要 unwrap。
  • 用字节下标切中文文本&title[..2] 可能 panic 或 getNone;按字符截断先 char_indices()
  • 为了"安全"全部改用 get + unwrapvalues.get(i).unwrap() 比直接 values[i] 更难读;内部可信索引用 [],让 bug 崩溃得响亮。
  • 在切片上调用 to_vec() 只为了遍历:切片迭代零分配,复制了才需要 to_vec()

自测

  1. first_two(&[0xAB]) 返回什么,为什么不是 panic?–答方向:Noneget(..2) 把范围不足表达为 Option,让调用方分支处理。
  2. 什么情况下用 a[i..j],什么情况下必须 a.get(i..j)?–答方向:边界由内部逻辑保证用前者(panic 暴露 bug);边界来自外部输入用后者(越界是业务输入)。
  3. "修".get(..2)[0xE4, 0xBF, 0xAE].get(..2) 一个返回 None 一个返回 Some,差别在哪?–答方向:&str 除数值边界外还检查字符边界(2 落在"修"的 3 字节中间);&[u8] 只检查数值边界。
  4. 传递一个 100 万元素的切片,复制了多少数据?–答方向:两个机器字(指针 + 长度);元素不复制。
  5. average(&[]) 返回 Nonezero_out(&mut []) 照常返回,两个设计为什么都合理?–答方向:"没有平均值"是业务上有意义的结果缺失,用 Option;"清零零个元素"没有失败含义,正常完成。