跳转到内容

4.3 切片与胖指针:[T] 与 &[T]

上一节结尾,为了解决 &[i32; 4] 无法接收 [i32; 3] 的限制,我们提出了一个想法:把 [i32; 4] 里的 ; 4 擦掉,引入一个不带编译期长度的 [T]。

在正式写代码之前,先看清楚:在 Rust 中,去掉了 ; N 的 [T](比如填入 i32 后生成的类型实体 [i32])到底是一个怎样的类型?它描述的是什么样的数据?

在其他很多教程里,人们习惯把带引用的 &[T] 叫作「切片」;但在 Rust 的类型系统里,真正名叫切片类型(Slice Type)的,其实是不带 & 的那个 [T] 本体([-] 是类型构造器,填入具体类型 i32 后,[i32] 就是一个具体的类型实体)。

用我们在 3.1 建立的「物理表示 + 约束」视角来看,[i32] 这个类型实体有着非常鲜明的特征:

  1. 它描述的是「连续排布的同类型内存序列」这一纯粹形态: 只要内存里有一串紧挨着存放、中间没有任何空隙的 i32 元素——不管这串元素是住在栈帧上、堆内存里,还是只读数据段中;也不管它是某块完整的数据,还是从一大块数据中间截取出来的一小段——在物理形态上,它都是一段 [i32]。
  2. 它的元素布局完全确定,唯独「总长度」在类型里留白: 对照定长数组 [i32; 4],[i32] 的类型实体里清楚地记录着「每个元素是 4 字节的 i32、连续紧密排列」;但它唯独抹去了「一共有多少个元素」这项信息。

正是因为 [T] 把具体的长度数字从类型实体里抽离出去,只保留了「连续同类型序列」这一共性,它才能成为统领所有连续内存的抽象模型。

但问题也随之而来:既然 [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]));
}
在 Rust Playground 中运行

只要把开头 bad_sum 函数的注释取消,编译器立刻以 error[E0277] 拒绝编译:

编译器诊断摘要:取消 dst-sum.rs 中 bad_sum 函数的注释后
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]),都不能直接作为局部变量或函数参数放在栈上。

既然 [i32] 本体大小未知、没法直接放进栈槽位,怎么才能在函数参数或变量里使用它?

答案我们在第 1 章和 4.1 里已经见过:虽然目标数据 [i32] 有多长在编译期不知道,但指向它的「引用(指针)」在栈上占多大,编译期是固定的。

于是,我们用引用构造器 &(或 &mut)把 [i32] 包起来,合成出一个新的类型实体——&[i32](通常称为切片引用,日常也常简称为切片)。

注意这里的合成关系:Rust 并没有单独定义一个叫 &[T] 的独立构造器,它是引用构造器 & 与切片构造器 [T] 两个构造器的嵌套组合——先用 [-] 填入 i32 得到动态大小类型 [i32],再把 [i32] 作为参数填进 & 构造器里,合成出引用类型 &[i32]。

这样一来,编译器就允许参数 arr: &[i32] 放进栈帧了(如 dst-sum.rs 下半部分所示)。

但再往下想一步:光是套上一层普通的 8 字节内存地址,信息够用吗?

顺着 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());
}
在 Rust Playground 中运行

运行输出:

运行输出
r1 (&[i32; 4]): 8 bytes, first = 10
r2 (&[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. 第 1 个 8 字节(ptr):保存这段连续内存的起始地址(这里指向 a[1]);
    2. 第 2 个 8 字节(len):保存这段连续内存的元素个数(这里存的是 2)。
同一个 & 碰到 &[i32; 4] 时,栈上的 r1 只有 8 字节,长度 4 留在类型里;碰到长度被擦掉的 &[i32] 时,栈上的 r2 有 16 字节,第二个机器字记下运行时长度 2,覆盖 a 的 [1] 与 [2]。
同一个 &,瘦指针或胖指针左上方是 r1,类型 &[i32; 4],由 &a 得到。编译期类型记下 N = 4,运行时占 0 字节。栈上只有一个机器字 ptr,8 字节,箭头指向 a 的 [0]。左下方是 r2,类型 &[i32],由 &a[1..3] 得到。[i32] 擦掉了 ; N,所以指针在栈上占两个机器字:ptr 为 8 字节,指向 [1];len 为 2,再占 8 字节。右边是数组 a,类型 [i32; 4],连续四格为 10、20、30、40,共 16 字节,元素里没有长度字段。括号标出 r2 覆盖的两格 [1] 与 [2]。[0] 与 [3] 不在这次切片里。瘦指针 · 8 字节r1: &[i32; 4] = &a编译期类型实体&[i32; 4]N = 4 · 0 字节ptr · 8B栈a: [i32; 4]4 × i32 · 16 字节[0]10[1]20[2]30[3]40r2 覆盖 [1] 与 [2][0] 与 [3] 不在切片里胖指针 · 16 字节r2: &[i32] = &a[1..3][i32] 擦掉了 ; N长度记在这个指针里ptr · 8Blen: 2 · 8B

正因为 &[i32] 在运行时自带了 (ptr, len) 这两个机器字,回看刚才 dst-sum.rs 里的三行调用:

  • 传入 sum(&a4) 时,编译器自动构造出 (a4 的首地址, 4);
  • 传入 sum(&a3) 时,构造出 (a3 的首地址, 3);
  • 甚至当你用范围语法写下 sum(&a4[1..3]) 时,编译器只需把起始地址偏移到 a4[1]、把长度填成 2,就能零拷贝地切出数组中间的任意一段。

在截取数组、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 字节胖指针)。


刚才我们是从「擦掉数组 [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]));
}
在 Rust Playground 中运行

运行输出:

运行输出
from array: 100
from array[1..]:50
from Vec: 600
from 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] 本身是一个重要的类型实体,根据我们在 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);
}
在 Rust Playground 中运行

运行输出:

运行输出
len: 4, is_empty: false, contains 30: true
after reversing [1..4]: [40, 20, 30, 10]

很多初学者想自己去查 Rust 标准库文档(doc.rust-lang.org/std/),看看切片、数组或 Vec 有哪些好用的方法时,常常会犯迷糊:「我手里的变量类型明明是 &[i32]、或者是 [i32; 4] 和 Vec<i32>,为什么在文档里搜 &[T] 搜不到方法,反而要去看原生类型 slice(即 [T])的文档?」

掌握了前几课的「类型实体」与「点号自动解引用」,你现在可以像老手一样直接读懂 Rust 官方的 Primitive Type slice([T])文档 了。打开那页文档,你只需要盯住两个核心规律:

  1. 所有连续序列的通用方法,全都定义在 impl<T> [T] 之下: 不管你手里拿的是栈数组 [T; N]、堆数组 Vec<T>,还是切片引用 &[T] / &mut [T],当你用点号 . 调用方法时,编译器都会自动帮你转换(解引用)到 [T] 上去寻找方法。所以学会了 [T] 文档里的方法,就等于同时学会了数组和 Vec<T> 80% 的日常操作!

  2. 看方法签名里的第一个参数(&self vs &mut self),直接判断需要什么引用: 在官方 slice 文档的 Methods 列表里,你会看到这样的原始签名:

    • pub const fn len(&self) -> usize
    • pub const fn is_empty(&self) -> bool
    • pub fn contains(&self, x: &T) -> bool
    • pub 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 走过的完整推导链:

  1. 擦除 ; N 诞生动态大小类型(DST):把 [T; N] 的编译期长度抹掉得到 [T]。因为编译期算不出它占多少字节,它不能直接作为栈变量或参数持有(报 E0277);
  2. 两个构造器联合产出 16 字节胖指针:用引用构造器 &(或 &mut)包住 [T] 得到 &[T](或 &mut [T])。原本住在类型实体里的长度信息退回到运行时的指针里,使 & 从 8 字节瘦指针变成了 16 字节胖指针 (ptr, len);
  3. 一切连续内存的通用底座:&[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>。

继续:动态数组 Vec<T> →