大数跨境

切片与数组:[i32; 3]、[i32]、&[i32; 3]、&[i32] 到底有什么区别?(本篇免费)

切片与数组:[i32; 3]、[i32]、&[i32; 3]、&[i32] 到底有什么区别?(本篇免费) TonyBai
2026-11-10
1

大家好,我是Tony Bai。

【导读】

你写过 fn foo(s: [i32]) 吗?编译器会告诉你:大小在编译期未知。你又写过 fn foo(a: &[i32; 3]) 吗?它能跑,却只收长度为 3 的数组。[i32; 3]、[i32]、&[i32; 3]、&[i32],四个长得几乎一样的东西,是 Rust 初学者最容易搞混的语法细节之一。这篇文章用内存布局和函数传参的视角把它们彻底讲清楚,并和 C++、Go 做对比,最后给你一条可以直接抄走的结论。

【文章要点】

  • 数组的长度是类型的一部分,[i32; 3] 与 [i32; 4] 是两种不同的类型。
  • [i32] 是动态大小类型(DST),不能单独作为变量或参数,只能藏在指针后面。
  • &[i32; 3] 是瘦指针(8 字节),&[i32] 是胖指针(16 字节,地址加长度)。
  • 与 Go 的切片、C++20 的 std::span 相比,Rust 的 &[T] 在语义上最接近 span,但有借用检查器保驾护航。
  • 函数参数优先写 &[T] 或 &mut [T],只有需要严格限定长度时才用 &[T; N]。
  • &[T; N] 到 &[T] 靠的是编译器内置的非固定大小转换(Unsized Coercion),&Vec<T> 到 &[T] 靠的是 Deref 强制转换,二者机制不同。

引子:一道让新手卡住的“送分题”

假设你要写一个函数,打印一串整数。如果你从 Go 或 Python 转来,手感大概是这样的:

   
fn print_all(s: [i32]) {          // 编译失败    println!("{:?}", s);}

编译器会毫不客气地报错:error[E0277]: the size for values of type [i32] cannot be known at compilation time。

你改成 [i32; 3],能跑了,但传一个 4 个元素的数组又报错。你再改成 &[i32; 3],还是一样。最后,你在别人的代码里看到 &[i32],什么都能传,于是照抄,却不知道为什么。

这是 Rust 新手遇到的第一道“暗礁”,很多写了一两年的开发者也只是“知道这么写”,说不清背后的原理。这篇文章要解决的问题是:这四种写法到底差在哪里,函数参数应该怎么选?

这个问题值得花时间弄懂,因为Rust的所有权、借用、Vec、String、迭代器等,几乎都建立在“切片”这个概念之上。这里一知半解,后面每个知识点都会隐隐作痛。

我会先从内存布局讲起,再对比 C++ 和 Go,然后逐一分析四种函数形参,最后给你一张可以直接用的决策表。

四个长得很像的类型

假设要表达 1, 2, 3 这串整数,Rust 里有四种相关的类型。先看结论:

写法
名称
大小是否编译期已知
64 位系统下的大小
[i32; 3]
定长数组
是
12 字节
[i32]
切片(DST)
否
不存在固定大小
&[i32; 3]
数组引用
是
8 字节
&[i32]
切片引用
是
16 字节

下面逐个拆解。

  • 定长数组 [i32; 3]

它就是栈上连续的 3 个 i32,没有任何额外信息。关键在于:长度是类型的一部分。[i32; 3] 与 [i32; 4] 是两种不同的类型,关系就像 i32 和 String 一样,不能互相替代。

  • 切片 [i32]

这是新手最大的坑。它的类型名里没有长度,意味着编译器无法知道它占多少字节。Rust 把这类类型称为动态大小类型(DST,Dynamically Sized Type)。而 Rust 要求局部变量和函数参数的大小必须在编译期确定,所以 let s: [i32] = ... 和 fn foo(s: [i32]) 都是非法的。DST 只能“躲在指针后面”使用。

  • 数组引用 &[i32; 3]

这是一个普通的指针,只存首地址,占 8 字节。长度不用存,因为类型签名已经把“3”锁死了。

  • 切片引用 &[i32]

这就是平时大家口中的“切片”。它是一个胖指针(Fat Pointer),由两部分组成:数据首地址(8 字节)和元素个数(8 字节)。长度信息从类型里搬到了运行时的值里。

用代码验证一下:

   
use std::mem::{size_of, size_of_val}; fn main() {    let arr = [1, 2, 3];    println!("{}", size_of::<[i32; 3]>());   // 12    println!("{}", size_of::<&[i32; 3]>());  // 8    println!("{}", size_of::<&[i32]>());     // 16    println!("{}", size_of_val(&arr[..]));   // 12:切片指向的数据大小}

内存布局如下图:

记住一个关键对比:瘦指针靠类型记住长度,胖指针靠值记住长度。这个差别决定了后面所有的行为。

横向对比:C++ 和 Go 是怎么做的

理解 Rust 的设计取舍,最好的办法是看看别人怎么处理同一个问题。

C++:长度会丢失。

C++ 里的原生数组 int arr[3] 传给函数时会“退化”为指针,长度信息当场丢失:

   
void foo(int* p, size_t n);        // 必须额外传长度void bar(int p[3]);                // 看似有长度,实际仍是 int* 

bar 里的 sizeof(p) 得到的是指针大小,不是 12。这是无数越界 bug 的源头。C++20 引入的 std::span<int> 才算补上了这个缺口,它同样是“指针加长度”,在语义上与 Rust 的 &[i32] 最接近。

Go:切片是一等公民。

Go 把 [3]int(数组,值类型,传参会整体拷贝)和 []int(切片)都做进了语言里,且切片是 24 字节的描述符:指针、长度、容量。Go 的切片天然能“长大”,代价是多个切片可能共享同一块底层内存,写入互相影响,需要程序员自己小心。

Rust:把“视图”和“所有者”拆开。

&[i32] 只有指针和长度,没有容量,因为它只是一个借用的视图,既不拥有数据,也不负责扩容。扩容是 Vec<i32> 的事。


C++
Go
Rust
定长数组传参
退化为指针,丢失长度
整体拷贝
按值拷贝,或传引用
通用序列视图
std::span
(C++20)
[]int
(24 字节)
&[i32]
(16 字节)
能否扩容
视图不能
切片可以(append)
视图不能,Vec 可以
安全保证
无
无借用检查
借用检查器保证无悬垂、无别名写

所以你会发现:Rust 并不是“没有切片”,而是把切片拆成了更精确的两层:[T] 是数据,&[T] 是借用它的视图。

函数形参的四种写法

回到开头的问题。假设函数要读取一串整数,我们逐个分析。

写法 1:按值传数组 fn foo(arr: [i32; 3])

   
fn print_by_value(arr: [i32; 3]) { println!("{:?}", arr); } let a = [1, 2, 3];print_by_value(a);   // i32 是 Copy,整个数组被逐字节复制 

问题有两个:只接受长度恰好为 3 的数组,且数组越大,栈上拷贝越贵。

写法 2:传数组引用 fn foo(arr: &[i32; 3])

   
fn print_array_ref(arr: &[i32; 3]) { println!("{:?}", arr); } print_array_ref(&a);   // 只传 8 字节地址,无拷贝 

没有拷贝了,但依然死板:你无法为它扩容,无法用它打印 [1, 2, 3, 4]。

写法 3:按值传切片 fn foo(s: [i32])

直接编译失败,原因前面说过:参数入栈需要确定大小,而 [i32] 是 DST。

写法 4:传切片引用 fn foo(s: &[i32])

这是标准库和绝大多数第三方库的做法:

   
fn print_slice(s: &[i32]) {    println!("len: {}, data: {:?}", s.len(), s);} let arr3 = [1, 2, 3];let arr5 = [1, 2, 3, 4, 5];let v = vec![10, 20, 30]; print_slice(&arr3);        // 数组引用print_slice(&arr5);        // 不同长度也行print_slice(&arr5[1..4]);  // 子区间 [2, 3, 4]print_slice(&v);           // 堆上的 Vec 也行 

一个签名通吃四种输入。如果需要修改元素,用 &mut [i32]:它允许改写元素,但不能增删,因为“视图”没有扩容的能力。

为什么这些类型能自动转换?

你可能会问:我传的明明是 &[i32; 3] 或 &Vec<i32>,为什么能被 &[i32] 接收?这里其实有两套不同的机制,很多教程把它们混为一谈。

数组到切片:Unsized Coercion

编译器知道数组长度是 3,于是把 8 字节的瘦指针升级成 16 字节的胖指针,补上长度 3。这是编译器内置的转换,不经过 Deref。

Vec 到切片:Deref Coercion

Vec<T> 实现了 Deref<Target = [T]>,编译器在需要 &[T] 的地方,会自动对 &Vec<T> 解引用一次。String 到 &str、Box<T> 到 &T 也是同一套机制。

两者的共同点是:转换发生在函数调用处,由编译器自动完成,零额外成本。

两个进阶技巧

1. 反向转换:从切片得到数组

有时你手里是 &[i32],却需要一个定长数组,比如解析二进制协议头:

   
let s: &[i32] = &[1, 2, 3, 4, 5];let head: [i32; 3] = s[..3].try_into().unwrap();      // 拷贝出数组let head_ref: &[i32; 3] = s[..3].try_into().unwrap(); // 或者得到数组引用 

转换会在运行时检查长度,不匹配就返回 Err,因此用的是 try_into 而不是 into。

2. 既要定长,又要通用:const 泛型

如果你确实需要“任意固定长度”的数组参数,不要为每个长度写一遍函数,用 const 泛型:

   
fn sum<const N: usize>(arr: &[i32; N]) -> i32 {    arr.iter().sum()}

这样 N 在编译期被推导出来,长度仍然是类型的一部分,编译器可以据此做更激进的优化。

小结

我们回顾一下这篇文章的主线:Rust 把“序列”拆成了数据本身([T; N]、[T])和对数据的借用(&[T; N]、&[T]),其中 [T] 因为大小未知,只能通过胖指针 &[T] 使用。

最后给你一张可以直接用的决策表:

你的需求
形参写法
说明
只读任意长度序列
&[T]
默认选择,兼容数组、Vec、子区间
就地修改元素
&mut [T]
可改元素,不能增删
必须恰好 N 个元素
&[T; N]
 或 const 泛型
如矩阵运算、固定协议头
小型定长数据,需要所有权
[T; N]
如坐标 [f32; 2],仅限很小的数组
需要增删元素
&mut Vec<T>
视图做不到,需要所有者

一句话口诀:参数想通用,就写 &[T];想严格,才写 &[T; N]。

最后留两个思考题,欢迎在评论区讨论:

  1. 为什么 &mut [i32] 不能 push,而 &mut Vec<i32> 可以?
  2. 如果函数参数写成 impl AsRef<[i32]>,和 &[i32] 相比有什么取舍?

如果本文对你有所帮助,请帮忙点赞、推荐和转发!


还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。

【声明】内容源于网络
0
0
TonyBai
Tony Bai的技术世界 (tonybai.com)。 不满足于“会用”,我们追求“精通”。 专注Go语言底层原理、高质量工程实践与云原生架构,探索Go与AI等前沿结合。 欢迎对技术有追求的Gopher同行,关注我,与Go一同进化。
内容 1253
粉丝 0
TonyBai Tony Bai的技术世界 (tonybai.com)。 不满足于“会用”,我们追求“精通”。 专注Go语言底层原理、高质量工程实践与云原生架构,探索Go与AI等前沿结合。 欢迎对技术有追求的Gopher同行,关注我,与Go一同进化。
总阅读13.1k
粉丝0
内容1.3k