切片和范围:为函数设计稳定、低耦合的输入
课程概览 · 第 3 章
上一章的结论是"只读文本用 &str"。本章把同一思路推广到任意序列:函数应该声明自己需要的是"一段数据"还是"修改一段数据",而不是绑死调用方用 Vec 还是数组。切片就是承载这个契约的类型;而构造切片用的 start..end 并非索引专用记号——范围(Range)本身是标准库里的结构体值,能绑定、传参、迭代和匹配,本章把它与切片一并讲透。边界与 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 | |
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 | |
范围语法本身(六种形态、各自的类型与迭代能力、含端点的坑)在下一节展开;本节只需记住分工:内部可信边界用 [],外部输入用 get。
范围:切片语法背后的值
a[start..end] 中的 start..end 不是索引专用的记号,它构造的是标准库结构体 std::ops::Range { start, end }。既然是值,就能绑定变量、作为参数传递、存进结构体、在 match 里当模式;Range 与 RangeInclusive 还直接实现了 Iterator,能脱离任何容器独立迭代。六种形态一览:
| 写法 | 类型 | 语义 | 可否迭代 |
|---|---|---|---|
a..b | Range | a <= i < b | 可 |
a..=b | RangeInclusive | a <= i <= b | 可 |
a.. | RangeFrom | a <= i,无限延伸 | 可(无限) |
..b | RangeTo | 从头到 b(不含) | 否 |
..=b | RangeToInclusive | 从头到 b(含) | 否 |
.. | RangeFull | 整段 | 否 |
..b、..、..=b 没有起点,只能用于切片索引和模式匹配——对切片来说"从开头"由切片自身确定,作为独立迭代器却无从起步。a.. 有起点无终点,可以迭代但无限,直接进 for 会停不下来,要先接 take(n) 之类的终止条件。
范围是普通值
1 | |
count_in 的签名说明范围与别的值没有地位差别。两个细节值得记住:Range 的 start/end 是公有字段,可直接读写;RangeInclusive 的端点字段是私有的,用 .start()/.end() 读取。Range 只实现 Clone 不实现 Copy,所以 window 第二次使用前要显式 clone——原因在下一节。若需要可随意复制的"纯值"范围(库 API 把范围存进结构体、在多个容器上复用),1.96 新增的 std::range 模块提供了另一套 Copy 的范围类型,同样能索引切片、与 ops 版本可互相转换,只是不再直接充当迭代器(.iter() 产出迭代器)。
范围是迭代器
1 | |
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 | |
结论:含端点范围只用在"端点本身有意义"的地方——1..=9 的数位区间、'a'..='z' 的字母表;切片尾部的截取一律用半开范围,到结尾就是 1..,整段就是 ..。get 在这里同样把 panic 降级为 None。
contains 与范围模式
范围能直接回答"一个值落不落在区间内",match 分支里还能把区间当模式:
1 | |
(60..=100).contains(&x) 直接表达"落在区间内",比 60 <= x && x <= 100 少一层心算。范围模式是 match 的语法而非构造范围值:区间端点只能写字面量与常量路径,写变量名会被当作新绑定而报错;穷尽性仍由 _ 或覆盖全部输入的分支兜底。
UTF-8 边界:同一条规则的两面
第 2 章已经给出字符串切片的完整规则:切分点必须落在字符边界上,[] 违反时 panic、get 返回 None。这里只需要把那条规则放回切片语境,理解它和数值越界是同一机制:
1 | |
&str 与 &[u8] 的差别只在最后那层检查:&[T] 检查数值边界,&str 在此之上还检查字符边界。按字符截断的 char_indices() 写法已在第 2 章展开,本章不再重复。
借用而不分配
&[T] 和 &str 是胖指针(地址 + 长度),传递它们只是复制两个机器字,不复制元素。只有 to_vec()、to_string()、collect() 这类操作才会分配新的拥有者:
1 | |
为什么可行:切片是胖指针
&[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。
自测
first_two(&[0xAB])返回什么,为什么不是 panic?–答方向:None;get(..2)把范围不足表达为Option,让调用方分支处理。- 什么情况下用
a[i..j],什么情况下必须a.get(i..j)?–答方向:边界由内部逻辑保证用前者(panic 暴露 bug);边界来自外部输入用后者(越界是业务输入)。 "修".get(..2)与[0xE4, 0xBF, 0xAE].get(..2)一个返回None一个返回Some,差别在哪?–答方向:&str除数值边界外还检查字符边界(2 落在"修"的 3 字节中间);&[u8]只检查数值边界。- 传递一个 100 万元素的切片,复制了多少数据?–答方向:两个机器字(指针 + 长度);元素不复制。
average(&[])返回None而zero_out(&mut [])照常返回,两个设计为什么都合理?–答方向:"没有平均值"是业务上有意义的结果缺失,用Option;"清零零个元素"没有失败含义,正常完成。..n与n..哪个能当迭代器,为什么?–答方向:n..(RangeFrom)能,从n无限延伸;..n(RangeTo)没有起点,只能用于索引与模式匹配。let v = [10, 20, 30];,v.get(2..=3)返回什么,&v[2..=3]会怎样?–答方向:None与 panic;a..=b对切片等价a..b+1,含端点要求下标 3 的元素存在,而 len 只有 3。- 为什么
ops::Range不实现Copy?–答方向:它兼作迭代器,next推进start,被迭代到一半的范围是携带进度的状态值,随意复制会产生剩余区间不一致的副本。






