切片与胖指针 ------ 告别 C 的"指针 + 长度"手动模式
一、C 的数组:退化为指针,丢失长度
1.1 数组参数退化为指针
C 中数组作为函数参数时自动退化为指针,长度信息丢失:
c
// 这三个声明完全等价
void process(int* arr);
void process(int arr[]); // 这里的 [] 只是个语法糖
void process(int arr[10]); // ← 10 被编译器忽略了!
// 等价于:
void process(int* arr); // 全部退化为 int*
这意味着函数无法从参数中知道数组有多大:
c
void process(int arr[]) {
// arr 的类型是 int*,没有长度信息
// 我不知道 arr 指向 1 个 int 还是 100 个 int
}
void caller() {
int arr[10];
process(arr); // 传的是 int*,长度 10 留在了 caller 的栈帧里
}
1.2 补救方案:手动传递长度
C 的补救是额外传一个长度参数------这是约定,不是类型系统的强制:
c
// 方案 A:传递指针 + 长度(最常见的 C 模式)
void process(int* arr, size_t len); // len 是调用者的保证
// 方案 B:在数组中用哨兵值标记结尾
int process_until_sentinel(int* arr, int sentinel);
// 方案 C:返回的数组自带长度(结构体包装)
struct Array {
int* data;
size_t len;
};
void process_arr(struct Array a);
方案 A 的问题 :arr 和 len 是两个独立的参数,可能不一致:
c
void caller() {
int arr[5];
process(arr, 10); // ❌ 编译器不报错!arr 只有 5 个元素
// 但 process 以为有 10 个------越界写入
}
void process(int* arr, size_t len) {
for (size_t i = 0; i <= len; i++) { // off-by-one
arr[i] = 0; // ❌ 越界
}
}
1.3 越界访问没有保护
c
int arr[5] = {1, 2, 3, 4, 5};
arr[10] = 42; // ❌ 编译通过,运行时越界写入
// 可能覆盖其他变量、可能 segfault、可能一切正常------未定义行为
C 的 arr[i] 没有边界检查。 每一次数组访问都是潜在的缓冲区溢出漏洞。
1.4 C 字符串的问题
c
// C 字符串依赖 NULL 结尾------需要遍历到 \0 才能知道长度
const char* s = "hello";
size_t len = strlen(s); // O(n)------必须遍历找到 \0
// 如果忘记 NULL 结尾------strlen 会继续读直到遇到随机内存中的 \0
char buf[5] = {'h', 'e', 'l', 'l', 'o'};
// buf 没有 NULL 结尾!
size_t len = strlen(buf); // ❌ 缓冲区溢出读------未定义行为
// 字符串操作中的常见 bug:
void copy_name(char* dest, const char* src, size_t dest_size) {
strcpy(dest, src); // ❌ 如果 src 比 dest_size 长------溢出
// strncpy(dest, src, dest_size); // 稍微安全,但不 NULL 结尾如果超长
// snprintf(dest, dest_size, "%s", src); // 推荐的做法
}
C 字符串是 C 中最多安全漏洞的来源之一。 NULL 结尾、手动管理长度、缓冲区溢出------所有问题都源于"长度不在类型中"。
1.5 C 中"手动胖指针"模式
有些 C 程序员会自己包装"指针 + 长度"的结构体:
c
// C 中的"手动胖指针"
struct ByteSlice {
uint8_t* data;
size_t len;
};
void print_slice(struct ByteSlice s) {
for (size_t i = 0; i < s.len; i++) {
printf("%02x ", s.data[i]);
}
}
// 但这只是结构体------不是语言内置的概念
// 每个库各自定义自己的 Slice 结构体
// 不能和原生数组互操作
C 的手动胖指针问题是:
-
不是语言内置------每个库自己定义
-
不能和原生数组无缝互操作
-
没有编译器检查------可以构造
ByteSlice { data: NULL, len: 100 } -
没有边界检查------
s.data[i]仍然可能越界
二、Rust 的切片:带长度的胖指针
2.1 切片是什么
切片(slice)是一个"胖指针"------包含两个东西:指向数据的指针 + 长度。
rust
// 切片类型:&[T]
// 在 64 位系统上是 16 字节(8 字节指针 + 8 字节长度)
let arr = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr; // 对整个数组的切片
// 也可以切一部分
let slice: &[i32] = &arr[1..4]; // [2, 3, 4]
// 切片的长度是已知的
println!("长度: {}", slice.len()); // 3
// 访问元素
println!("第一个: {}", slice[0]); // 2
// println!("{}", slice[100]); // ❌ panic: index out of bounds
yaml
切片的内存布局(64 位系统):
栈上(16 字节):
┌──────────────────────┐
│ ptr: 0x7ffe12345600 │ ← 指向数据的指针(8 字节)
├──────────────────────┤
│ len: 3 │ ← 元素个数(8 字节)
└──────────────────────┘
│
▼
堆/栈上的数据:
┌────┬────┬────┬────┬────┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└────┴────┴────┴────┴────┘
^^^^ 只取这 3 个
2.2 切片可以从多种来源创建
rust
// 从数组引用
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let s: &[i32] = &arr; // 全数组切片
let s: &[i32] = &arr[1..4]; // 部分切片
// 从 Vec
let v: Vec<i32> = vec![1, 2, 3, 4, 5];
let s: &[i32] = &v; // Vec 自动 Deref 为 &[i32]
let s: &[i32] = &v[2..]; // 从索引 2 到末尾
// 从字符串
let s: &str = "hello"; // 字符串切片(&str 也是一种切片)
let s: &str = &"hello world"[0..5]; // "hello"
切片是所有连续数据类型的统一"视图":
scss
Vec<i32> ──── deref ──→ &[i32]
[i32; 5] ──── & ──────→ &[i32]
[i32; 100] ── & ──────→ &[i32]
│
统一的读写接口:
.len() .is_empty() .iter() [i]
2.3 切片是胖指针,不拥有数据
rust
// 切片只是"视图"------不拥有数据
fn get_slice() -> &[i32] {
let arr = [1, 2, 3, 4, 5];
&arr[1..3] // ❌ 编译错误!arr 是局部变量,离开函数被销毁
} // 返回的切片会成为悬垂指针
// 正确的做法:把拥有权交给调用者,或者从参数借
fn get_slice<'a>(data: &'a [i32]) -> &'a [i32] {
&data[1..3] // ✅ 返回的切片和输入的生命周期相同
}
2.4 切片的安全保证
rust
// 1. 边界检查------越界 panic 而不是内存破坏
let s = &arr[1..4];
// s[100]; // ❌ panic: index out of bounds
// 2. 永远不可能是 NULL
// &[T] 是一个有效的胖指针------不能是 NULL
// 如果需要"可能没有数据",用 Option<&[T]>
// 3. 长度是类型的一部分------不可能"忘记传递长度"
fn process(s: &[i32]) {
for i in 0..s.len() { // ✅ s.len() 是确定的
println!("{}", s[i]);
}
}
// 调用者不需要额外传长度------长度在切片里
三、&str ------ 字符串切片
3.1 不是 C 字符串
rust
// &str 是一个胖指针:指向 UTF-8 字节的指针 + 字节长度
let s: &str = "hello";
println!("长度: {}", s.len()); // 5(字节数,不是字符数)
println!("是否为空: {}", s.is_empty());
// &str 不是 NULL 结尾的!
// "hello" 在内存中是 5 个字节 [0x68, 0x65, 0x6C, 0x6C, 0x6F]
// 没有 \0 结尾
// 不需要 strlen()------长度在切片里
// s.len() 是 O(1)------不是 O(n)
3.2 UTF-8 保证
rust
// &str 保证是有效的 UTF-8
// 无法构造一个非法 UTF-8 的 &str
let s = "hello"; // ASCII(兼容 UTF-8)
let s = "你好"; // ✅ 中文 UTF-8(6 字节,2 个字符)
let s = "😀"; // ✅ emoji UTF-8(4 字节,1 个字符)
// 按字符(Unicode 标量值)迭代
for c in "你好".chars() {
println!("{}", c); // '你' '好'
}
// 按字节迭代
for b in "你好".bytes() {
println!("{}", b); // 228 189 160 229 165 189
}
3.3 C 字符串 vs &str
| 维度 | C 字符串(char*) |
Rust &str |
|---|---|---|
| 终止符 | \0(NULL 结尾) |
无(长度在胖指针中) |
| 获取长度 | strlen(s) --- O(n) |
s.len() --- O(1) |
| 编码 | 通常 ASCII/扩展 ASCII | 总是有效 UTF-8 |
| 越界访问 | 无保护(缓冲区溢出漏洞) | 有保护(panic) |
| 空指针 | 可以为 NULL | 不能为 NULL |
| 子串操作 | 需要手动管理偏移 | &s[0..5] --- 类型安全 |
c
// C 字符串的 O(n) strlen
const char* s = "hello world";
size_t len = strlen(s); // 必须遍历到 \0 才知道长度
// 如果 s 没有 \0 结尾------strlen 继续读直到遇到 \0 或 crash
rust
// Rust &str 的 O(1) len
let s: &str = "hello world";
let len = s.len(); // 直接从胖指针读取------O(1)
四、胖指针与普通指针
4.1 什么是胖指针
胖指针(fat pointer)是"带额外元数据的指针"。Rust 中有两种胖指针:
rust
// 1. 切片引用(ptr + len)
let s: &[i32] = &[1, 2, 3]; // 16 字节(64 位系统)
let s: &str = "hello"; // 16 字节
// 2. Trait 对象(ptr + vtable)
let t: &dyn std::fmt::Debug = &42; // 16 字节(ptr + vtable_ptr)
// 普通引用
let r: &i32 = &42; // 8 字节(只是一个指针)
rust
内存中的大小(64 位系统):
&i32 = 8 字节(普通指针)
&[i32] = 16 字节(指针 + 长度)
&dyn Debug = 16 字节(指针 + vtable 指针)
Box<dyn Debug> = 16 字节(堆上的指针 + vtable)
4.2 切片胖指针 vs 普通指针
rust
use std::mem::size_of;
// 普通引用------一个机器字
assert_eq!(size_of::<&i32>(), 8); // 普通指针
// 切片引用------两个机器字(胖指针)
assert_eq!(size_of::<&[i32]>(), 16); // 指针 + 长度
assert_eq!(size_of::<&str>(), 16); // 指针 + 长度
// Option 优化
assert_eq!(size_of::<Option<&[i32]>>(), 16);
// Option<&[i32]> 也是 16 字节------和 &[i32] 一样大!
// None 用合法的非空胖指针中不可能的值来表示
// 普通指针的 Option 也有优化
assert_eq!(size_of::<Option<&i32>>(), 8); // = &i32
// None = NULL 指针
4.3 胖指针在 Rust 中的无处不在
rust
// 函数参数------不需要额外传长度
fn process(data: &[u8]) {
// data 携带了指针和长度
}
// 返回值------同样携带长度
fn read_file(path: &str) -> Vec<u8> {
// ...
}
// 如果需要返回一个"视图",返回 &[u8]
// 子切片------自动调整指针和长度
fn first_half(data: &[u8]) -> &[u8] {
&data[..data.len() / 2]
// 自动调整 ptr 和 len
}
五、切片操作详解
5.1 切片的基本方法
rust
let s = &[1, 2, 3, 4, 5];
s.len(); // 5------元素个数
s.is_empty(); // false------是否为空
s.first(); // Some(&1)------第一个元素
s.last(); // Some(&5)------最后一个元素
s.get(2); // Some(&3)------安全的索引
s.get(100); // None------越界不会 panic
s[2]; // 3------直接索引(越界 panic)
// 子切片
&s[1..4]; // [2, 3, 4]
&s[..3]; // [1, 2, 3]
&s[2..]; // [3, 4, 5]
&s[..]; // [1, 2, 3, 4, 5]------整个切片
// 分割
let (left, right) = s.split_at(2); // ([1, 2], [3, 4, 5])
5.2 可变切片 &mut [T]
rust
let mut arr = [1, 2, 3, 4, 5];
let s: &mut [i32] = &mut arr;
s[0] = 100; // ✅ 通过可变切片修改
s.reverse(); // ✅ 反转:[5, 4, 3, 2, 1]
s.sort(); // ✅ 排序:[1, 2, 3, 4, 5]
s.copy_within(0..2, 3); // ✅ 内存内复制
5.3 切片的迭代
rust
let s = &[1, 2, 3, 4, 5];
// 按元素迭代
for x in s {
println!("{}", x); // x: &i32
}
// 按元素迭代(可变)
for x in &mut s.clone() {
*x *= 2;
}
// 迭代器适配器
let sum: i32 = s.iter().sum(); // 15
let max = s.iter().max(); // Some(&5)
let contains = s.contains(&3); // true
let windows = s.windows(3); // [1,2,3], [2,3,4], [3,4,5]
六、"胖指针"的 C 对应
6.1 C 中的"手动胖指针"
c
// C 程序员手动模拟切片的常见方式
struct Slice {
int* data;
size_t len;
};
void print_slice(struct Slice s) {
for (size_t i = 0; i < s.len; i++) {
printf("%d ", s.data[i]);
}
}
问题在于:
c
// 不是语言内置------每个库各自定义
// 我的 Slice、你的 ArrayView、他的 ByteRange
// 没有边界保证
struct Slice bad = { NULL, 100 }; // ✅ C 允许!dangling 访问
// 不能和原生数组无缝互操作
int arr[10];
struct Slice s = { arr, 10 }; // ✅ 需要手动构造
// 但 &arr[..5] 这种语法不存在
// 运行时没有检查
s.data[100] = 42; // ❌ 不会 panic,直接覆盖内存
6.2 C 中的"字符串切片"
c
// C 中"字符串切片"需要手动管理
const char* text = "hello world";
// 想要一个"指向"hello"的切片?------需要自己跟踪起点和长度
struct StrSlice {
const char* start;
size_t len;
};
struct StrSlice s = { text, 5 }; // "hello"
// 但 s 不保证是完整的 UTF-8 序列
// 不保证点到的是合法字符串
// 不保证数据没有被释放
6.3 C vs Rust 切片对比
| 维度 | C(手写 Slice) | Rust(&[T]) |
|---|---|---|
| 语言内置 | 否(各库自定义) | 是(&[T] 是原生类型) |
| 长度获取 | 从结构体字段读 | .len() --- O(1) |
| 边界检查 | 无(需手动检查) | 有(越界 panic) |
| 空指针 | 可以构造 {NULL, 100} |
不能为 NULL |
| 子切片 | 需要手动调整指针+长度 | &s[1..4] |
| 与数组互操作 | 手动构造 | &arr 自动成为 &[T] |
| 与 Vec 互操作 | 不能 | &vec 自动成为 &[T] |
| 作为函数参数 | void f(struct Slice s) |
fn f(s: &[T]) |
| 传递长度 | 手动 + 易错 | 自动 + 安全 |
6.4 同一个功能:C vs Rust
c
// C ------ 计算数组前 n 个元素的和
// 需要手动传递指针和长度
int sum_array(const int* data, size_t len) {
int total = 0;
for (size_t i = 0; i < len; i++) {
total += data[i]; // 如果 i >= len------越界
}
return total;
}
int caller() {
int arr[] = {1, 2, 3, 4, 5};
int s1 = sum_array(arr, 5); // ✅ 正确
int s2 = sum_array(arr, 100); // ❌ 编译通过!越界
return s1 + s2;
}
rust
// Rust ------ 计算切片中元素的和
fn sum_array(data: &[i32]) -> i32 {
let mut total = 0;
for i in 0..data.len() { // ✅ data.len() 在切片中
total += data[i]; // 如果 i 越界------panic,不是内存破坏
}
total
// 或者更简单:data.iter().sum()
}
fn caller() {
let arr = [1, 2, 3, 4, 5];
let s1 = sum_array(&arr); // ✅
// let s2 = sum_array(&arr[..100]); // ❌ 编译时不会报错
// 但运行时 panic(slice 索引越界)
// 不会静默越界
}
七、与 C 程序员的对话
"传个数组还要传 length,C 不也这么干?"
C 程序员:"C 中传数组时传指针+长度是标准做法,有什么问题吗?"
Rust :"问题是 '指针 + 长度' 在 C 中是两个独立的东西------类型系统不认为它们有关联。你可以传长度为 100 但指针只指向 5 个元素------编译器不报错。Rust 的切片把指针和长度捆绑为一个类型------它们永远不会分家。而且切片的len()是直接读胖指针里的值------O(1),不像 C 的strlen需要 O(n)。"
c
// C ------ 指针和长度是分离的
void process(int* data, size_t len); // 各传各的
process(arr, 100); // 传错了编译器不报错
rust
// Rust ------ 指针和长度在一起
fn process(data: &[i32]); // 一个参数包含两者
process(&arr); // ✅ 长度自动从 arr 推导
// process(&arr[..100]); // ❌ 如果超过实际长度,运行时 panic
"strlen 不是一直用得好好的吗?"
C 程序员 :"C 的字符串用
\0结尾,strlen遍历到\0就知道长度了------有什么问题?"
Rust :"两个问题。第一:strlen是 O(n)------字符串越长,获取长度越慢。对长度为 1GB 的字符串调用 strlen 就是灾难。第二:如果没有\0结尾------strlen会继续读直到在随机内存中碰到一个\0------要么读到错误数据,要么 segfault。Rust 的&str长度是胖指针中的字段------O(1),而且不依赖任何哨兵值。"
c
// C ------ 依赖哨兵值
const char* s = get_string();
size_t len = strlen(s); // O(n)------必须遍历到 \0
// 如果 s 没有 \0 结尾------未定义行为
rust
// Rust ------ 长度在胖指针中
let s: &str = get_string();
let len = s.len(); // O(1)------直接从胖指针读
// 不需要遍历,不需要哨兵值
// 总是准确的
"那我用 struct { int* data; size_t len; } 不是一样吗?"
C 程序员:"C 中我用 struct 把指针和长度包在一起,不就是 Rust 的切片吗?"
Rust :"语法上类似,但 Rust 的切片是语言原生的 。你的 struct 没有边界检查------s.data[i]越界了不会告诉你。你的 struct 不能和Vec或原生数组直接用&互操作。你的 struct 不能直接用&s[1..4]取子切片。这些不是语法糖------是编译器检查和类型系统的集成。你的 struct 是结构体,切片是语言特性。"
八、小结
8.1 C 的问题
c
数组退化为指针 → 丢失长度信息
→ 必须手动传递 size_t
→ 指针和长度可能不一致
→ 越界访问无保护
字符串 NULL 结尾
→ strlen 是 O(n)
→ 缺少 \0 导致缓冲区溢出读
→ 长度不在类型中
"手动胖指针"模式
→ 每个库自己定义
→ 没有边界检查
→ 没有语言集成
8.2 Rust 的解决
| C 的问题 | Rust 的解决 |
|---|---|
| 数组退化为指针,丢失长度 | &[T] 是胖指针(ptr + len) |
| 指针和长度分离 | 指针和长度在同一个类型中 |
| 越界无保护 | 越界 panic(不是 UB) |
| strlen 是 O(n) | s.len() 是 O(1) |
| NULL 结尾 | &str 自带长度,不需要哨兵 |
| 手动取子串 | &s[1..4] --- 子切片 |
| 各库各自定义 Slice | &[T] 是语言原生类型 |
8.3 胖指针是 Rust 的关键创新
ini
普通指针:&i32 = ptr(8 字节)------只知道地址
胖指针: &[i32] = ptr + len(16 字节)------知道地址和长度
&str = ptr + len(16 字节)------知道地址、长度、UTF-8 保证
&dyn T = ptr + vtable(16 字节)------知道地址和类型行为
C 中:指针就是地址------只有 8 字节(64 位)
Rust 中:指针可以携带元数据------最多 16 字节
一句话总结 :C 的数组退化为指针时,"长度"被遗弃在了调用者的栈上。你必须手动传递它,而且很容易传错。Rust 的切片是胖指针------指针和长度永远在一起 ,一起被传递、一起被检查、一起被销毁。越界时 panic 而不是内存破坏,获取长度是 O(1) 而不是 O(n)------这些都是胖指针带来的根本改进。对于 C 字符串,
&str把 NULL 结尾换成了显式长度 + UTF-8 保证------不再有哨兵依赖,不再有缓冲区溢出读。