课程概览 · 第 3 章

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

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

上一章的结论是"只读文本用 &str"。本章把同一思路推广到任意序列:函数应该声明自己需要的是"一段数据"还是"修改一段数据",而不是绑死调用方用 Vec 还是数组。切片就是承载这个契约的类型;而构造切片用的 start..end 并非索引专用记号——范围(Range)本身是标准库里的结构体值,能绑定、传参、迭代和匹配,本章把它与切片一并讲透。边界与 UTF-8 是切片真正的坑,含端点范围与切片的配合则是范围最常见的越界来源。

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

学习目标与默认选择

学完本章你能:

  • 用 &[T] / &mut [T] / &str 写出不绑定具体容器的函数;
  • 在可信内部索引与用户可控范围之间选择 [] 或 get;
  • 区分数值越界与 UTF-8 字符边界违反这两类 get 失败;
  • 说出六种范围写法的类型与语义,知道哪些能当迭代器、哪些只能用于索引;
  • 解释 ops::Range 为什么不是 Copy,以及含端点范围索引切片为什么更容易越界;
  • 解释切片为什么"借用而不分配"。

默认选择:

函数需要签名调用方付出什么
只读一段序列&[T]一次借用,函数返回后数据照常使用
修改一段序列&mut [T]借用期间独占,返回后照常使用
拿走序列并保存Vec<T> / T转移所有权(本章不默认)
只读文本&str同 &[T],且保证合法 UTF-8
需要一段下标区间a..b / a..=b传递一个轻量结构体值,无所有权负担

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

一个程序演示三种切片接口:average 只读并对空输入返回 None;zero_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
18
19
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
// start > end 是非法范围:同样返回 None。
let (bad_start, bad_end) = (2, 1);
assert!(durations.get(bad_start..bad_end).is_none());
}

范围语法本身(六种形态、各自的类型与迭代能力、含端点的坑)在下一节展开;本节只需记住分工:内部可信边界用 [],外部输入用 get。

范围:切片语法背后的值

a[start..end] 中的 start..end 不是索引专用的记号,它构造的是标准库结构体 std::ops::Range { start, end }。既然是值,就能绑定变量、作为参数传递、存进结构体、在 match 里当模式;Range 与 RangeInclusive 还直接实现了 Iterator,能脱离任何容器独立迭代。六种形态一览:

写法类型语义可否迭代
a..bRangea <= i < b可
a..=bRangeInclusivea <= i <= b可
a..RangeFroma <= i,无限延伸可(无限)
..bRangeTo从头到 b(不含)否
..=bRangeToInclusive从头到 b(含)否
..RangeFull整段否

..b、..、..=b 没有起点,只能用于切片索引和模式匹配——对切片来说"从开头"由切片自身确定,作为独立迭代器却无从起步。a.. 有起点无终点,可以迭代但无限,直接进 for 会停不下来,要先接 take(n) 之类的终止条件。

范围是普通值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::ops::Range;

/// 统计某个下标区间内的任务数。
/// 范围做参数:调用方传字面量或变量都行。
fn count_in(tasks: &[&str], window: Range<usize>) -> usize {
tasks.get(window).map_or(0, |slice| slice.len())
}

fn main() {
let tasks = ["write", "review", "test", "ship"];
assert_eq!(count_in(&tasks, 1..3), 2);

// 范围可绑定变量;Range 是 Clone 不是 Copy,复用显式 clone。
let window = 2..4;
assert_eq!(count_in(&tasks, window.clone()), 2);
assert_eq!(&tasks[window], &["test", "ship"]);

// 越界范围交给 get:None 而不是 panic。
assert_eq!(count_in(&tasks, 3..99), 0);
}

count_in 的签名说明范围与别的值没有地位差别。两个细节值得记住:Range 的 start/end 是公有字段,可直接读写;RangeInclusive 的端点字段是私有的,用 .start()/.end() 读取。Range 只实现 Clone 不实现 Copy,所以 window 第二次使用前要显式 clone——原因在下一节。若需要可随意复制的"纯值"范围(库 API 把范围存进结构体、在多个容器上复用),1.96 新增的 std::range 模块提供了另一套 Copy 的范围类型,同样能索引切片、与 ops 版本可互相转换,只是不再直接充当迭代器(.iter() 产出迭代器)。

范围是迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn main() {
// Range 直接实现 Iterator:for 消费的是范围本身。
let mut sum = 0;
for i in 1..=4 {
sum += i; // 含端点:1+2+3+4
}
assert_eq!(sum, 10);
assert_eq!((1..4).sum::<i32>(), 6); // 1+2+3,不含端点

// 倒序与收集:范围本身就是迭代器适配器链的起点。
let countdown: Vec<i32> = (1..=3).rev().collect();
assert_eq!(countdown, vec![3, 2, 1]);

// 空范围合法:start == end 时不含任何值。
assert_eq!((5..5).count(), 0);
assert!([0, 1, 2][2..2].is_empty());

// 迭代会推进范围本身:这是 Range 不实现 Copy 的原因。
let mut rest = 5..8;
assert_eq!(rest.next(), Some(5));
assert_eq!(rest, 6..8); // 剩余区间已经变了
}

for i in 0..n 消费的就是 Range 这个迭代器:每次 next 推进 start,直到追上 end。也正因为 next 原地改写字段,被迭代到一半的范围是携带进度的状态值——若可随意复制,副本看起来是全新的 start..end,与原值的剩余区间不一致。标准库从 1.0 起就不给 Range 实现 Copy(RangeTo、RangeFull 等不充当迭代器的类型则是 Copy)。

一个高频误用:遍历元素本身时写 for i in 0..v.len() 再 v[i]——用下标绕路,还让每次访问都带边界检查。要值用 for x in &v;同时要下标用 .enumerate()。0..len() 只在确实需要下标区间(分块、滑窗)时才出现。

含端点范围的坑

对切片而言 a..=b 等价于 a..b+1:终点的合法性从 b <= len 收紧为 b < len。把 v.len() 当含端点终点,或用 len-1 硬凑"到结尾",都是高频越界来源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let v = [10, 20, 30];

// 正常:含端点,包含下标 2 的元素。
assert_eq!(&v[1..=2], &[20, 30]);

// end == len 的含端点范围:get 表达为 None,[] 则 panic。
assert!(v.get(0..=3).is_none()); // 要求下标 3 存在
assert!(v.get(3..=3).is_none()); // 同样越界

// 表达"到结尾"直接用半开范围,别拿 len-1 硬凑。
assert_eq!(&v[1..], &[20, 30]);
assert_eq!(&v[..v.len()], &v[..]); // 可以,但整段直接 .. 最直白
}

结论:含端点范围只用在"端点本身有意义"的地方——1..=9 的数位区间、'a'..='z' 的字母表;切片尾部的截取一律用半开范围,到结尾就是 1..,整段就是 ..。get 在这里同样把 panic 降级为 None。

contains 与范围模式

范围能直接回答"一个值落不落在区间内",match 分支里还能把区间当模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn band(score: u32) -> &'static str {
match score {
0..=59 => "fail",
60..=79 => "pass",
80..=100 => "good",
_ => panic!("score out of range"),
}
}

fn main() {
let score = 87;

// contains:语义就是"落在区间内",比手写两个比较直接。
assert!((60..=100).contains(&score));
assert!(!(0..60).contains(&score));

// 范围模式:分支里直接写区间,穷尽性由 _ 兜底。
assert_eq!(band(87), "good");
assert_eq!(band(42), "fail");

// 字符区间同理:判断是否常用汉字。
assert!(('一'..='龥').contains(&'中'));
}

(60..=100).contains(&x) 直接表达"落在区间内",比 60 <= x && x <= 100 少一层心算。范围模式是 match 的语法而非构造范围值:区间端点只能写字面量与常量路径,写变量名会被当作新绑定而报错;穷尽性仍由 _ 或覆盖全部输入的分支兜底。

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]。
  • 把 get 的 None 当异常:用户可控范围下"越界"是正常输入的一种,走业务分支,不要 unwrap。
  • 用字节下标切中文文本:&title[..2] 可能 panic 或 get 出 None;按字符截断先 char_indices()。
  • 为了"安全"全部改用 get + unwrap:values.get(i).unwrap() 比直接 values[i] 更难读;内部可信索引用 [],让 bug 崩溃得响亮。
  • 在切片上调用 to_vec() 只为了遍历:切片迭代零分配,复制了才需要 to_vec()。
  • 遍历元素却用下标循环:for i in 0..v.len() 加 v[i] 比直接 for x in &v 多一层下标与边界检查;要下标用 .enumerate()。
  • 用 ..=len-1 硬凑"到结尾":含端点范围要求终点元素存在,写对也绕;尾部截取用 1..,整段用 ..。
  • 把范围当 Copy 复用:ops::Range 是 Clone 不是 Copy,被迭代过的范围携带进度;需要值语义的可复制范围,用 1.96 的 std::range::Range。

自测

  1. first_two(&[0xAB]) 返回什么,为什么不是 panic?–答方向:None;get(..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(&[]) 返回 None 而 zero_out(&mut []) 照常返回,两个设计为什么都合理?–答方向:"没有平均值"是业务上有意义的结果缺失,用 Option;"清零零个元素"没有失败含义,正常完成。
  6. ..n 与 n.. 哪个能当迭代器,为什么?–答方向:n..(RangeFrom)能,从 n 无限延伸;..n(RangeTo)没有起点,只能用于索引与模式匹配。
  7. let v = [10, 20, 30];,v.get(2..=3) 返回什么,&v[2..=3] 会怎样?–答方向:None 与 panic;a..=b 对切片等价 a..b+1,含端点要求下标 3 的元素存在,而 len 只有 3。
  8. 为什么 ops::Range 不实现 Copy?–答方向:它兼作迭代器,next 推进 start,被迭代到一半的范围是携带进度的状态值,随意复制会产生剩余区间不一致的副本。