4.3 切片与胖指针:[T] 与 &[T]
上一节结尾,为了解决 &[i32; 4] 无法接收 [i32; 3] 的限制,我们提出了一个想法:把 [i32; 4] 里的 ; 4 擦掉,引入一个不带编译期长度的 [T]。
在正式写代码之前,先看清楚:在 Rust 中,去掉了 ; N 的 [T](比如填入 i32 后生成的类型实体 [i32])到底是一个怎样的类型?它描述的是什么样的数据?
什么是切片类型 [T]?
Section titled “什么是切片类型 [T]?”在其他很多教程里,人们习惯把带引用的 &[T] 叫作「切片」;但在 Rust 的类型系统里,真正名叫切片类型(Slice Type)的,其实是不带 & 的那个 [T] 本体([-] 是类型构造器,填入具体类型 i32 后,[i32] 就是一个具体的类型实体)。
用我们在 3.1 建立的「物理表示 + 约束」视角来看,[i32] 这个类型实体有着非常鲜明的特征:
- 它描述的是「连续排布的同类型内存序列」这一纯粹形态:
只要内存里有一串紧挨着存放、中间没有任何空隙的
i32元素——不管这串元素是住在栈帧上、堆内存里,还是只读数据段中;也不管它是某块完整的数据,还是从一大块数据中间截取出来的一小段——在物理形态上,它都是一段[i32]。 - 它的元素布局完全确定,唯独「总长度」在类型里留白:
对照定长数组
[i32; 4],[i32]的类型实体里清楚地记录着「每个元素是 4 字节的i32、连续紧密排列」;但它唯独抹去了「一共有多少个元素」这项信息。
正是因为 [T] 把具体的长度数字从类型实体里抽离出去,只保留了「连续同类型序列」这一共性,它才能成为统领所有连续内存的抽象模型。
但问题也随之而来:既然 [i32] 这个类型实体故意隐去了长度信息,当我们像对待普通类型一样,直接用它来声明函数参数或变量时,会发生什么?
为什么 [i32] 不能直接上栈?
Section titled “为什么 [i32] 不能直接上栈?”我们把上一节结尾猜想的 fn bad_sum(arr: [i32]) -> i32 放进编译器里试一试:
dst-sum.rs
// Uncomment the marked function to trigger error[E0277]./*fn bad_sum(arr: [i32]) -> i32 { 0}*/
fn sum(arr: &[i32]) -> i32 { let mut total = 0; for &x in arr { total += x; } total}
fn main() { let a4: [i32; 4] = [10, 20, 30, 40]; let a3: [i32; 3] = [10, 20, 30];
println!("sum of a4: {}", sum(&a4)); println!("sum of a3: {}", sum(&a3)); println!("sum of a4[1..3]: {}", sum(&a4[1..3]));}只要把开头 bad_sum 函数的注释取消,编译器立刻以 error[E0277] 拒绝编译:
error[E0277]: the size for values of type `[i32]` cannot be known at compilation time --> src/bin/dst-sum.rs:2:17 |2 | fn bad_sum(arr: [i32]) -> i32 { | ^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `[i32]`报错信息把原因说得清清楚楚:[i32] 的大小在编译期无法确定!
回到第 1 章的栈与堆模型想一想:
- 函数调用时,必须在栈上为每个参数和局部变量开辟栈帧槽位,而每个栈槽位占多少字节,必须在编译期完全算死;
- 当你写
arr: [i32; 4]时,编译器知道它占4 × 4 = 16字节;可一旦你擦掉了; 4写成arr: [i32],调用方传进来的可能是 3 个i32(12 字节),也可能是 1000 个i32(4000 字节)——编译器在生成bad_sum的栈帧代码时,到底该给arr留几个字节?根本算不出来!
在 Rust 中,像 [i32] 这样因擦除了编译期尺寸信息、导致编译期算不出字节大小的类型实体,统称为动态大小类型(Dynamically Sized Type,简称 DST)(对应报错里的 the trait Sized is not implemented)。
因为栈槽位必须定长,所以 Rust 规定:任何动态大小类型(如 [T]),都不能直接作为局部变量或函数参数放在栈上。
& 与 [T] 联合使用
Section titled “& 与 [T] 联合使用”既然 [i32] 本体大小未知、没法直接放进栈槽位,怎么才能在函数参数或变量里使用它?
答案我们在第 1 章和 4.1 里已经见过:虽然目标数据 [i32] 有多长在编译期不知道,但指向它的「引用(指针)」在栈上占多大,编译期是固定的。
于是,我们用引用构造器 &(或 &mut)把 [i32] 包起来,合成出一个新的类型实体——&[i32](通常称为切片引用,日常也常简称为切片)。
注意这里的合成关系:Rust 并没有单独定义一个叫 &[T] 的独立构造器,它是引用构造器 & 与切片构造器 [T] 两个构造器的嵌套组合——先用 [-] 填入 i32 得到动态大小类型 [i32],再把 [i32] 作为参数填进 & 构造器里,合成出引用类型 &[i32]。
这样一来,编译器就允许参数 arr: &[i32] 放进栈帧了(如 dst-sum.rs 下半部分所示)。
但再往下想一步:光是套上一层普通的 8 字节内存地址,信息够用吗?
16 字节胖指针
Section titled “16 字节胖指针”顺着 3.1 的信息转移视角,一个信息缺口马上就显现出来了:
在 sum(arr: &[i32]) 函数内部,我们要写 for &x in arr 遍历求和。既然参数类型 &[i32] 的类型实体里已经把编译期长度 ; N 擦掉了,而栈上的数组 a4、a3 在运行时内存里也只是纯粹的 i32 字节(没有存任何对象头或长度字段)——假如 &[i32] 和普通引用一样只存一个 8 字节的内存首地址,运行时的 sum 函数只知道从哪个地址开始读,它怎么知道这次传进来的 arr 到底有 4 个元素、3 个元素,还是只有 2 个元素?
怎么补上这个缺口?编译期类型实体里省掉的长度信息,必须放回运行时的引用变量里。
运行下面这段代码,直接测量指向同一个数组 a 的 &[i32; 4] 与 &[i32] 在栈上的字节大小:
fat-pointer-size.rs
use std::mem::size_of;
fn main() { let a: [i32; 4] = [10, 20, 30, 40]; let r1: &[i32; 4] = &a; let r2: &[i32] = &a[1..3];
println!("r1 (&[i32; 4]): {} bytes, first = {}", size_of::<&[i32; 4]>(), r1[0]); println!("r2 (&[i32]): {} bytes, len = {}", size_of::<&[i32]>(), r2.len());}运行输出:
运行输出r1 (&[i32; 4]): 8 bytes, first = 10r2 (&[i32]): 16 bytes, len = 2看打印出来的数字——8 bytes 与 16 bytes 的对比:
- 指向定长类型的引用
&[i32; 4](8 字节瘦指针 Thin Pointer): 因为长度4已经记录在编译期类型实体&[i32; 4]里了,运行时的引用变量r1只需要存 1 个机器字(8 字节首地址ptr); - 指向动态大小类型的引用
&[i32](16 字节胖指针 Fat Pointer): 因为[i32]擦掉了; N,编译期不知道目标有多长,所以引用变量r2在栈上扩充为 2 个机器字(16 字节):- 第 1 个 8 字节(
ptr):保存这段连续内存的起始地址(这里指向a[1]); - 第 2 个 8 字节(
len):保存这段连续内存的元素个数(这里存的是2)。
- 第 1 个 8 字节(
正因为 &[i32] 在运行时自带了 (ptr, len) 这两个机器字,回看刚才 dst-sum.rs 里的三行调用:
- 传入
sum(&a4)时,编译器自动构造出(a4 的首地址, 4); - 传入
sum(&a3)时,构造出(a3 的首地址, 3); - 甚至当你用范围语法写下
sum(&a4[1..3])时,编译器只需把起始地址偏移到a4[1]、把长度填成2,就能零拷贝地切出数组中间的任意一段。
切片的范围语法(Range)
Section titled “切片的范围语法(Range)”在截取数组、Vec<T> 或字符串的子切片时,方括号里的范围表达式(Range)遵循统一的「左闭右开」规则(以长度为 4 的数组 a 为例,有效下标为 0, 1, 2, 3):
| 写法 | 含义 | 切出的元素下标 | 胖指针的 (ptr, len) |
|---|---|---|---|
&a[1..3] |
左闭右开:从下标 1 开始,到下标 3 之前结束(不含 3) |
a[1], a[2] |
(&a[1], 3 - 1 = 2) |
&a[..3] |
省略开头:默认从下标 0 开始(等价于 &a[0..3]) |
a[0], a[1], a[2] |
(&a[0], 3) |
&a[1..] |
省略结尾:从下标 1 一直切到末尾(等价于 &a[1..4]) |
a[1], a[2], a[3] |
(&a[1], 3) |
&a[..] |
头尾全省略:切取全部元素(等价于 &a[0..4]) |
a[0] .. a[3] |
(&a[0], 4) |
&a[1..=3] |
全闭区间(加 = 号):包含结尾下标 3 |
a[1], a[2], a[3] |
(&a[1], 3) |
为什么默认设计成「左闭右开 start..end」?因为这样算出来的切片长度永远等于 end - start(例如 1..3 的长度就是 3 - 1 = 2),而且把相邻两段 &a[0..2] 和 &a[2..4] 拼在一起时,边界数字 2 刚好无缝衔接,不需要手写容易出错的 + 1 或 - 1。
从这里也能看出一个关于类型构造器的结论:同一个类型构造器 &,根据填入参数 T 的实际情况(编译期定长还是动态大小),它合成出的类型实体,连运行时的物理内存表示都不同(8 字节瘦指针 vs 16 字节胖指针)。
连续内存的通用视图
Section titled “连续内存的通用视图”刚才我们是从「擦掉数组 [T; N] 的长度参数」推导出 [T] 与 &[T] 的。但不要因此以为「切片只是为栈上数组服务的附属品」。
一旦 [T] 独立出来,它的适用范围就超出了定长数组 [T; N]。
因为 &[T] 的 16 字节胖指针里只存一个「起始地址 ptr」和一个「长度 len」,它不在乎那段连续的元素到底是放在栈帧上、堆内存里,还是可执行文件的静态数据段里。
看下面这段代码:
源文件:slice-universal.rs
fn sum(slice: &[i32]) -> i32 { let mut total = 0; for &x in slice { total += x; } total}
fn main() { let stack_arr: [i32; 4] = [10, 20, 30, 40]; let heap_vec: Vec<i32> = vec![100, 200, 300];
println!("from array: {}", sum(&stack_arr)); println!("from array[1..]:{}", sum(&stack_arr[1..3])); println!("from Vec: {}", sum(&heap_vec)); println!("from Vec[..2]: {}", sum(&heap_vec[..2]));}运行输出:
运行输出from array: 100from array[1..]:50from Vec: 600from Vec[..2]: 300在这段代码中:
- 只写了一个
fn sum(slice: &[i32]) -> i32; - 它既能接收栈上的定长数组
&stack_arr及其局部片段&stack_arr[1..3]; - 又能接收堆上的动态数组
Vec<i32>(&heap_vec)及其局部片段&heap_vec[..2]。
在 Rust 工程实践中,只要一个函数只需要读取或原地修改一段连续的元素序列,参数就应该写成切片 &[T](或 &mut [T]),而不要写成 &[T; N] 或 &Vec<T>。 [T] 统一了所有连续内存的借用视图。
[T] 的方法与标准库文档
Section titled “[T] 的方法与标准库文档”既然 [T] 本身是一个重要的类型实体,根据我们在 3.1 建立的「类型实体 = 物理表示 + 行为与安全约束」,Rust 标准库在 [T] 这个类型实体上直接挂载了极其丰富的行为契约(方法)。
先跑一下这段代码,体验几个最常用的切片方法:
源文件:slice-methods.rs
fn main() { let mut nums: [i32; 4] = [40, 10, 30, 20];
let s: &[i32] = &nums; println!("len: {}, is_empty: {}, contains 30: {}", s.len(), s.is_empty(), s.contains(&30));
let m: &mut [i32] = &mut nums[1..4]; m.reverse(); println!("after reversing [1..4]: {:?}", nums);}运行输出:
运行输出len: 4, is_empty: false, contains 30: trueafter reversing [1..4]: [40, 20, 30, 10]如何阅读 [T] 的原始文档?
Section titled “如何阅读 [T] 的原始文档?”很多初学者想自己去查 Rust 标准库文档(doc.rust-lang.org/std/),看看切片、数组或 Vec 有哪些好用的方法时,常常会犯迷糊:「我手里的变量类型明明是 &[i32]、或者是 [i32; 4] 和 Vec<i32>,为什么在文档里搜 &[T] 搜不到方法,反而要去看原生类型 slice(即 [T])的文档?」
掌握了前几课的「类型实体」与「点号自动解引用」,你现在可以像老手一样直接读懂 Rust 官方的 Primitive Type slice([T])文档 了。打开那页文档,你只需要盯住两个核心规律:
-
所有连续序列的通用方法,全都定义在
impl<T> [T]之下: 不管你手里拿的是栈数组[T; N]、堆数组Vec<T>,还是切片引用&[T]/&mut [T],当你用点号.调用方法时,编译器都会自动帮你转换(解引用)到[T]上去寻找方法。所以学会了[T]文档里的方法,就等于同时学会了数组和Vec<T>80% 的日常操作! -
看方法签名里的第一个参数(
&selfvs&mut self),直接判断需要什么引用: 在官方slice文档的 Methods 列表里,你会看到这样的原始签名:pub const fn len(&self) -> usizepub const fn is_empty(&self) -> boolpub fn contains(&self, x: &T) -> boolpub fn reverse(&mut self)pub fn sort(&mut self)
虽然我们下一章(
5.2)才会正式展开结构体方法的语法,但凭你在4.1学过的知识,现在一眼就能看懂:- 凡是括号里写着
&self的(如len、is_empty、contains),说明它只需要只读引用&[T]就能调用(而且len()只是直接读出 16 字节胖指针里的第 2 个机器字,没有任何遍历开销); - 凡是括号里写着
&mut self的(如reverse原地反转、sort原地排序),说明它需要可变引用&mut [T],直接在原内存位置上修改元素顺序,不会产生任何额外的内存分配!
养成去标准库查阅 [T] 原始文档的习惯,你会发现诸如二分查找(binary_search)、按窗口遍历(windows)、按块切分(chunks)等大量利器,全都在那里整整齐齐地等着你。
回看我们在 4.2 和 4.3 走过的完整推导链:
- 擦除
; N诞生动态大小类型(DST):把[T; N]的编译期长度抹掉得到[T]。因为编译期算不出它占多少字节,它不能直接作为栈变量或参数持有(报E0277); - 两个构造器联合产出 16 字节胖指针:用引用构造器
&(或&mut)包住[T]得到&[T](或&mut [T])。原本住在类型实体里的长度信息退回到运行时的指针里,使&从 8 字节瘦指针变成了 16 字节胖指针(ptr, len); - 一切连续内存的通用底座:
&[T]摆脱了「只为栈数组服务」的局限,无论是栈数组[T; N]还是堆Vec<T>(乃至它们的任意局部区间),都能统一借用为&[T],并共享定义在[T]类型实体上的全部标准库方法。
在 slice-methods.rs 中,我们写了 let m: &mut [i32] = &mut nums[1..4]; m.reverse();。为什么 m.reverse() 只把后三个元素 [10, 30, 20] 反转成了 [20, 30, 10],而原数组的第 0 个元素 40 纹丝不动?
核对答案:因为 &mut nums[1..4] 构造出的 16 字节胖指针 m 中,起始地址 ptr 直接指向 nums[1](即 10 所在的槽位),长度 len 为 3。当 m.reverse() 执行原地反转时,它只看得到从 ptr 开始的这 3 个元素,因此只在这 3 个槽位内部交换数据,原数组的 nums[0] 完全不在 m 的视野范围内。
掌握了 [T] 与 16 字节胖指针 &[T],下一节我们来看看连续内存家族里的第三位核心成员——拥有堆上可伸缩内存的动态数组 Vec<T>。