学 Rust 有段时间的人都会有这种感觉:基本类型看一遍就会,但到了复合类型这一层,才真正开始体会这门语言为什么“劝退”又迷人。社区里经常把数组(array)、元组(tuple)、切片(slice)、字符串(String/&str)这四种最常见的复合类型戏称为“四大军阀”。这个比喻虽然有点中二,但确实传神:它们各有严格的领地,谁也替代不了谁,但你在真实项目里离不开它们,还得学会在它们之间来回调度。这篇文章就是我对着这四个类型反复折腾后的完整记录,涉及内存布局、常用写法、边界条件,还有实际排查过程中踩过的坑。无论你是刚学 Rust 的新手,还是已经写了一阵子想系统地补一补课,都能从里面拿到一点能直接抄走的经验。比如数组到底什么时候该用,元组和结构体的边界在哪里,被“字符串不能按下标访问”气得摔键盘时该怎么办。下面按四个部分逐个拆开讲。
1. 四大军阀全景:先搞清四种复合类型的分工
1.1 为什么 Rust 需要这么多复合类型
Rust 的基本类型只有数字、布尔、字符这些,单人作战能力很强,但遇到稍微真实一点的需求——比如要表示一场考试的多个成绩、一个点的二维坐标、一段文本的一部分——单靠基本类型就没法组织了。复合类型就是把若干个值组合成一个整体,让数据有了“形状”。
严格说起来,Rust 的复合类型还包括结构体、枚举、联合体这些,但数、元、切、串是四个基础得不能再基础的家伙。数组和元组是“值语义”的固定容器,切片和字符串则天然带着借用和引用的色彩。把它们吃得够透,后面再碰结构体枚举才不费劲,否则你会经常在类型匹配和所有权的泥潭里打转。
为什么叫“军阀”?因为这几个类型各有地盘,谁也不完全听谁的。数组长度固定,元组允许异构,切片只看不拥有,字符串则自带 UTF-8 编码约束。你想把数组当元组用,不行;想直接用下标去改字符串,也不行;想用一个数组类型去接任意长度的数据,更是门都没有。它们之间可以转换,但必须按规则来。
1.2 四种类型对比与选型思路
先把四种类型放在一张表里对比,选型的时候一眼就能看出方向:
| 类型 | 元素类型 | 长度 | 内存位置 | 所有权 |
|---|---|---|---|---|
数组[T; N] | 必须相同 | 编译期固定 | 栈上 | 拥有数据 |
元组(T1, T2, ...) | 可以不同 | 编译期固定 | 栈上 | 拥有数据 |
切片&[T] | 必须相同 | 运行时可变 | 不固定(借用他人) | 不拥有,只借用 |
字符串String/&str | 字节/字符 | 运行时可变 | 堆上/String 栈上 + 堆上 | String拥有,&str借用 |
数组和元组的共同点是长度在编译期就定死了,存在栈上,访问高效;不同点是数组要求所有元素类型一致,元组允许你把(i32, f64, bool)这种混搭数据塞进一个变量。切片可以理解为“一段连续内存的窗口”,你拿着这个窗口去读别人拥有的数据,自己不用管释放。字符串则比较特别,它本质上是字节序列,但 Rust 保证这些字节是合法的 UTF-8 编码,String是可变、拥有所有权的版本,&str是不可变、借用的版本。
我在选型的时候一般这么判断:如果长度固定且元素同构,用数组;如果长度固定但想塞不同种类的数据,用元组;如果数据在其他地方,自己只想读其中一段,用切片;如果是要构造、修改、拼接文本,用String,只是读取文本参数时优先写&str。
2. 数组:定长的同构数据容器
2.1 数组的定义、初始化与内存位置
Rust 里数组的语法很直观,常见写法有三种:
// 显式标注类型:[T; N],N 是数组长度,属于类型的一部分 let arr1: [i32; 3] = [1, 2, 3]; // 用同一个值填充整个数组 let arr2 = [42; 5]; // 等价于 [42, 42, 42, 42, 42] // 类型推断写法 let arr3 = [1, 2, 3, 4, 5];这里有个非常容易被忽略的点:数组长度 N 是类型的一部分。也就是说,[i32; 3]和[i32; 4]是两种完全不同的类型,编译器不会让你用一个[i32; 3]的值直接赋值给一个[i32; 4]的变量。这个特性在有些场合很爽,比如你把长度写死在类型层面,编译器能帮你做一堆静态检查,但代价是函数参数一旦想接收“任意长度”的数组,就必须引入泛型,不然就写死长度。
内存位置上,数组直接保存在栈上,没有堆分配,没有额外元数据,访问非常快。这也是它和Vec(动态数组)最大的区别:Vec把数据放在堆上,可以动态扩容,但多了一次间接访问;数组则是把数据平铺在栈上,固定不变。
初始化时如果是复制类型,可以用[值; 长度]的简写;如果是非复制类型,比如[String; 2],这种简写就不行,因为这里会尝试把同一个String拷贝 N 次,而String是不能拷贝的。这时候得老老实实写[String::from("a"), String::from("b")],或者逐个赋值。新手在这里报错会看到the trait bound String: Copy is not satisfied,其实就是在告诉你:别想复制String。
2.2 数组访问、越界保护与迭代
数组访问可以用下标,也可以拿引用,标准得很:
let arr = [10, 20, 30, 40, 50]; let first = arr[0]; let last = arr[4]; // 若访问越界,直接 panic // let error = arr[5];越界访问在 C 语言里是未定义行为,可能读到隔壁内存,可能在某个夜深人静的时候崩溃。Rust 的选择是每次索引都会做边界检查,越界就立刻 panic,把问题暴露在开发阶段。比如上面的arr[5]会触发index out of bounds: the len is 5 but the index is 5。这个行为我一开始觉得有点“多管闲事”,但被测到线上问题之后就知道了:panic 再难看,也比内存被修改成不知道什么样子强得多。
如果不想让程序直接 panic,可以用.get()方法,它返回Option<&T>:
if let Some(value) = arr.get(3) { println!("{}", value); } else { println!("没有这个下标"); }遍历数组有几种姿势,区别在于拿的是引用还是值:
let arr = [1, 2, 3]; // 只读遍历,拿到 &i32 for item in arr.iter() { println!("{}", item); } // 需要修改,用 iter_mut let mut arr2 = [1, 2, 3]; for item in arr2.iter_mut() { *item *= 2; } // 想同时拿到下标和值,用 enumerate for (index, value) in arr.iter().enumerate() { println!("arr[{}] = {}", index, value); }要注意 Rust 2021 版本的IntoIterator行为变化:直接写for item in arr在旧版本里拿到的是&i32,但新版本会消费数组并产生所有权。如果你不想把数组移动走,还是老老实实用.iter()最保险。
2.3 数组和切片、动态数组的转换
数组虽然固定长度,但它和切片、Vec之间的关系非常亲密。
切片是对数组的一段连续区域的借用:
let arr = [1, 2, 3, 4, 5]; let slice = &arr[1..3]; // 类型是 &[i32],内容是 [2, 3]几乎任何地方把数组传给函数时,你都会直接传切片:
fn sum(nums: &[i32]) -> i32 { nums.iter().sum() } let arr = [1, 2, 3, 4, 5]; println!("{}", sum(&arr)); // 自动从 &[i32; 5] 转成 &[i32]而动态数组Vec<T>可以理解为“堆上的可变长度数组”。如果需求是不断往里面添加元素、删除元素,就别用固定数组了,直接用Vec:
let mut v = Vec::new(); v.push(1); v.push(2); // Vec 转切片 let s: &[i32] = &v; // Vec 的数组式索引 let first = v[0];有个经验值得分享:写函数签名时,尽量接收&[T]而不是&Vec<T>,更不要接收&[T; N](除非你确定长度不变)。原因很简单,&[i32]能同时接收数组切片、Vec切片和String相关的切片数据,调用方不用为了这一个函数去改变自己的数据结构。这段经验在代码评审里我反复提,每次改完都清爽很多。
3. 元组:把不同类型打包在一起的神器
3.1 元组语法、解构与索引访问
元组最大的特点就是异构:一个元组里可以混着i32、f64、&str,把它们当一个小包裹用。定义和解构都很直观:
let tuple: (i32, f64, &str) = (42, 3.14, "hello"); // 解构:一次性取出多个字段 let (a, b, c) = tuple; println!("{} {} {}", a, b, c); // 也可以按下标访问,下标从 0 开始 let x = tuple.0; let y = tuple.1; let z = tuple.2;解构这个能力特别实用,比 Java 里那种一个类只能靠 getter 拿字段的方式直接得多。比如函数返回一个二维坐标,你直接用let (x, y) = get_point();就把两个值拆出来了。
需要注意的是,元组下标访问不像数组那样是tuple[0],而是tuple.0。这个差异刚接触的时候很容易写错,编译器会提示你“不能对元组使用索引”。
长度为 1 的元组写法有个经典坑。普通括号表达式(1)其实就是整数 1,要表示一个只有 1 个元素的元组,必须写成(1,),多一个逗号:
let single = (1,); // 类型是 (i32,) let not_tuple = (1); // 类型是 i32你可能会觉得这语法很怪,但它其实是为了和表达式里的括号区分开。要是没有这个逗号,编译器根本分不清你是要写元组还是写普通的运算表达式。
3.2 用元组做函数多返回值,告别“输出参数”
在很多语言里,一个函数要返回多个值,要么定义一个临时类,要么用输出参数。Rust 里最简单的做法就是返回元组:
fn min_max(nums: &[i32]) -> (i32, i32) { let min = nums.iter().min().copied().unwrap_or(0); let max = nums.iter().max().copied().unwrap_or(0); (min, max) } let arr = [3, 1, 4, 1, 5, 9, 2, 6]; let (lo, hi) = min_max(&arr); println!("min={} max={}", lo, hi);看到没,一次调用拿到两个结果,还不用声明一堆引用参数。这个模式在写解析器、统计工具的时候尤其常见,返回(结果, 错误信息)都很自然。
元组也能和match模式配合,做分支判断非常舒服:
fn describe(point: (i32, i32)) -> &'static str { match point { (0, 0) => "原点", (x, 0) => "在 x 轴上", (0, y) => "在 y 轴上", (x, y) if x == y => "在对角线上", (_, _) => "其他位置", } }这里(x, 0)这种模式其实就是元组解构的加强版,顺带把命中条件也写成了结构化的形式。日常代码里看到这种写法,说明作者对解构已经比较熟练了。
3.3 单元类型 () 与分支结构里的小应用
元组家族里还有个特殊成员:(),叫“单元类型”,它只有一个值,就是()。你写的每个main函数,返回值其实就是这个类型;println!宏的返回值也是这个类型;很多表达式如果没有实际值,也会给它一个()。
()在日常生活中最常用的地方之一是表示“没有有效数据但仍然要走一遍逻辑”。比如使用Option或Result时,你可能会写Result<(), Error>,意思是“操作不返回数据,但可能失败”:
fn write_file() -> Result<(), String> { // 模拟写文件失败 Err("磁盘写满".to_string()) }调用方只需要关心有没有错误,不需要关心成功时带回什么值。这种写法在读配置、发请求、写日志的场景里非常多见。
元组还能用来做小型的数据聚合,比如临时把几个字段捆在一起传给另一个函数。如果你发现自己为了两三个字段专门定义一个结构体,但结构体只在一个函数内部使用,那用元组暂时顶一下会更轻量。不过一旦这个“打包数据”要在多个函数之间来回传,并且字段有明确含义的时候,还是优先用结构体,代码可读性会好很多。元组适合“一次性交易”,结构体适合“长期关系”,这个感受写代码多了自然就有。
4. 切片:借用连续区域的视图
4.1 切片的内存结构:指针加长度
切片这个概念刚开始学 Rust 的时候特别容易懵。它不是一个拥有数据的类型,更像是“借过来的一段连续内存视图”。最常见的切片类型是&[T],读作“T 的切片引用”。
为什么切片必须带引用?因为真正的切片类型[T]是动态大小类型(DST),它没有固定长度,编译器不知道它占多少内存,所以不能直接作为变量或函数参数。但只要加上一个引用,&[T]就变成了一个“胖指针”,里面存两份信息:一个指向数据起始位置的指针,一个长度。这个结构你可以理解为“指针 + 长度”的二元组,所以&[T]的大小是 16 字节(64 位系统上),普通引用&T只有 8 字节。
这种设计带来的安全性非常直观:切片知道自己的长度,遍历或者索引的时候能做边界检查,不会出现 C 语言里“指针指到哪就算哪”的情况。而创建切片的方式也几乎不用做额外运算,就是在一个连续的序列上切一个区间:
let arr = [1, 2, 3, 4, 5]; let whole: &[i32] = &arr[..]; let part: &[i32] = &arr[2..4]; // 包含索引 2、34.2 从数组、Vec 和字符串切出子集
切片最大的优势是“万物皆可切”:只要底层是连续内存,就能借用出一段来。数组、Vec、String都能被切成切片。
// 从数组切 let arr = [10, 20, 30, 40, 50]; let slice_from_arr = &arr[1..3]; // 从 Vec 切 let v = vec![1, 2, 3, 4, 5]; let slice_from_vec = &v[..3]; // 从 String 切 let s = String::from("hello world"); let hello = &s[0..5];注意字符串切片有个特殊的地方:它要求切割位置必须在字符边界上。比如:
let s = "中国"; let broken = &s[0..2]; // panic!因为 '中' 占 3 个字节,索引 2 落在字符中间这种情况不是 Rust 故意刁难你,而是 UTF-8 变长编码决定的。“中”在 UTF-8 里是e4 b8 ad三个字节,如果你从第 0 个字节切到第 2 个字节,得到的是半个字符,这不符合“字符串必须合法”的约束,所以编译器/运行时直接拒绝。
实际开发中,如果要从字符串里取子串,更安全的方式是先按字符边界找到位置,比如用char_indices()或find():
let s = String::from("hello 世界"); if let Some(pos) = s.find("世界") { let sub = &s[pos..]; println!("{}", sub); // 输出:世界 }find()返回的字节索引一定在字符边界上,所以直接用不会 panic。
4.3 为什么函数参数推荐写 &[T] 而不是 &Vec
这个建议我在很多地方提过,再展开说一下,因为真的很有用。如果你写:
fn print_all(v: &Vec<i32>) { for x in v { println!("{}", x); } }那么这个函数只能接收Vec<i32>的引用。可如果你的数据其实在数组里,或者你只是想传一段子区间,调用方就得先造一个Vec,麻烦不说还白白多了拷贝。改成:
fn print_all(v: &[i32]) { for x in v { println!("{}", x); } }调用方就可以自由传入:
let arr = [1, 2, 3, 4, 5]; let v = vec![6, 7, 8]; print_all(&arr); // &[i32; 5] 自动转 &[i32] print_all(&v); // &Vec<i32> 自动转 &[i32] print_all(&v[1..3]); // 传子区间这种写法在标准库和其他 Rust 开源项目里非常普遍,几乎是约定俗成。同理,字符串参数长时间用&str而不是&String,因为&String也能自动解引用成&str,但反过来不行。你写&str,调用方手里的String、字面量、Cow<str>都能传进来,灵活度完全不一样。
5. 字符串:String 与 &str 的相爱相杀
5.1 两种字符串类型的内存和特点
字符串是“四大军阀”里最让人头疼的一位,核心原因在于 Rust 把字符串分成了两种常见形态:String和&str。
&str是字符串切片,通常指向一个静态字符串字面量,或者借用String的一部分。它没有所有权,只负责读,而且长度固定,不能扩容。String是拥有所有权的可变字符串,数据存放在堆上,可以动态增长。
从内存模型看,String内部类似于一个Vec<u8>,有三个字段:指向堆内存的指针、当前长度、当前容量。它会对这个堆缓冲区做管理,扩容时可能重新分配内存。&str则简单得多,只是一个“指针 + 长度”的胖指针,不持有缓冲区,只用来看数据。
这两个类型之间的转换非常频繁:
// &str 转 String let s1 = String::from("hello"); let s2 = "world".to_string(); // String 转 &str let s3 = String::from("rust"); let s3_ref: &str = &s3; // 隐式借用转换 let s3_ref2: &str = s3.as_str(); // 显式调用新手最容易犯的错是把String和&str当成完全两个世界的东西,其实它们就是“拥有数据的可变版本”和“借用数据的只读版本”的关系。写函数参数时,只读场景一律用&str;需要构造、拼接、修改时,才在函数内部生成或接收String。
5.2 字符串的增删改查与拼接
字符串的增删改查基本都是String的方法,因为String才能修改,&str只能配合查找、切片这些只读操作。
常用操作:
let mut s = String::from("hello"); s.push('!'); // 追加单个字符:hello! s.push_str(" world"); // 追加字符串切片:hello! world s.insert_str(0, "say "); // 在指定字节位置插入:say hello! world s.replace_range(0..3, ""); // 替换一段范围,需要字符边界拼接字符串最常见的方式是+和format!:
let s1 = String::from("Hello, "); let s2 = String::from("world!"); // 注意:s1 在这里被 move 进去了,后面不能再使用 s1 let s3 = s1 + &s2; // 如果你想让 s1 仍然可用,用 format! let s4 = format!("{}{}", s1, s2);为什么s1 + &s2会 move 掉s1?因为String的+运算符定义是fn add(self, other: &str) -> String,左侧的self是按值传入的,所以你等于把s1的所有权交出去了。右侧传&s2是因为这里发生了 deref coercion,&String会被自动转成&str。如果你直接写s1 + s2,编译器会报错,因为s2是String,不是&str。
如果你担心 move 的语义太绕,那就无脑用format!,它在可读性和复杂度上更友好,代价只是多一次格式化开销,通常可以忽略不计。
查找和判断包含的操作很常用,也很直白:
let s = String::from("hello rust world"); println!("{}", s.contains("rust")); // true println!("{}", s.starts_with("hello")); // true println!("{}", s.ends_with("world")); // true // 拆分 let parts: Vec<&str> = s.split(' ').collect(); println!("{:?}", parts); // ["hello", "rust", "world"]字符串转数字也是一个高频需求,直接用parse:
let num: i32 = "42".parse().expect("不是合法数字"); let float: f64 = "3.14".parse().unwrap_or(0.0);parse的返回值是Result,需要显式标注目标类型,失败的时候会返回Err。实际项目中我一般不用unwrap,而是用match或?运算符做错误向上传播。
5.3 UTF-8 编码三个常见坑位
字符串相关的坑,十个里有八个都出在编码上。第一坑就是上面说过的“字符串不能按下标访问”。有人写 Python 写顺手了,到了 Rust 里写s[0],编译器直接报错:the typestrcannot be indexed by{integer}。你要取第一个字符,正确方式是用chars():
let s = "hello"; let first_char = s.chars().next(); // Some('h')第二坑是len()返回的是字节数,不是字符数。英文串没区别,一到中文就露馅:
let s = "中国"; println!("{}", s.len()); // 6,不是 2 println!("{}", s.chars().count()); // 2你要是拿len()去限制输入长度,很可能把一堆字节数远超预期但实际没几个字的输入放进来。统计用户可见的“字数”请用chars().count()。
第三坑是修剪和查找时对边界的把握。比如你要截取字符串前 N 个字符,不能直接&s[0..n],因为 n 可能落在字符中间。正确做法是用char_indices()找到第 N 个字符的字节位置,再切片:
fn take_first_n_chars(s: &str, n: usize) -> &str { if let Some((byte_idx, _)) = s.char_indices().nth(n) { &s[..byte_idx] } else { s } } let text = "你好,世界"; println!("{}", take_first_n_chars(text, 3)); // 你好,这个函数在写日志截断、标题省略号处理的时候非常实用。顺便提醒一句:你在“十六进制模式下搜索字符串”之类的问题里看到的诡异字节错乱,多半也是没处理好 UTF-8 边界造成的。
6. 常见问题排查与实战心法
6.1 五个高频编译和运行错误
我把这几年带新人和自己踩坑遇到的报错整理成了一个小表,方便你对照。
| 错误现象 | 常见原因 | 正确姿势 |
|---|---|---|
index out of boundspanic | 数组或切片索引越界 | 先检查长度,或改用.get() |
the type str cannot be indexed | 尝试用下标访问字符串 | 用s.chars()、s.find()等 |
expected tuple of 3 elements | 解构元组时变量数量和元组长度不匹配 | 核对元组的元素数量,或改成_ |
| 传参数报类型不匹配 | 函数参数写&Vec<T>但传入数组 | 推荐改参数为&[T] |
cannot borrow as mutable | 同时存在不可变和可变借用 | 缩小可变借用作用域,或先结束不可变借用 |
第一个错误其实很经典。数组越界在 Rust 里不是“可能崩溃”,而是“必定 panic”,因为边界检查就是语言机制的一部分。比如:
let arr = [1, 2, 3]; let x = arr[3]; // panic更好的设计是不确定下标是否安全时,用.get()返回Option<&T>,让调用方决定如何处理。
第二个错误常见于你把String或str当成字符数组用了。Rust 明确不提供按字节索引字符串的能力,因为 UTF-8 变长编码导致下标和字符不是一一对应。真想遍历字符,就用chars();真想按字节遍历,就用bytes(),但要知道字节和字符不是一回事。
第四个错误我已经在上面详细说了,这里再给个最小示例:
fn print_vec(v: &Vec<i32>) {} // 不推荐 let arr = [1, 2, 3]; // print_vec(&arr); // 类型不匹配,因为 &[i32; 3] 不是 &Vec<i32>把函数参数改成&[i32]后,数组、Vec、切片的引用都能直接传进来。
第五个借用冲突是 Rust 所有权模型里最容易让人抓狂的:
let mut s = String::from("hello"); let r1 = &s; // r1 还在作用域里,不能再创建可变借用 // let r2 = &mut s; // E0502 println!("{}", r1);解决办法通常是缩小不可变借用的作用域,或者让两个借用不在同一个生命周期里。这段规则看起来繁琐,但它就是 Rust 防数据竞争的底牌,早点适应比抱怨有用。
6.2 性能与可读性上的几条实操原则
写代码不只是为了通过编译,还要让后面维护你的人少掉头发。我在实际项目里总结出几条和这四个类型相关的小原则。
原则一:函数参数能只读就绝不拿所有权。一个字面量字符串传进来,你用&str接收,调用方可以继续用;你非用String接收,调用方得先克隆一份或者被迫交所有权。这个差别在性能敏感路径上特别明显。当然,如果你的函数本就要替代调用方持有数据,那接收String理所应当。
原则二:能用切片就用切片,不要无脑克隆。很多人拿到Vec后想做只读操作,直接.clone()一份,代价可能是整块堆内存复制。正确的打开方式是&v[..]或者直接传&v让它自动转切片。
原则三:字符串拼接少用+连环操作。你写a + &b + &c + &d时,每一步都可能触发一次新的内存分配,性能和可读性都不理想。要拼多个片段,用format!或者把片段收集到Vec<String>之后再join。
原则四:数组初始化和类型标注要明确。团队协作时,[0; 100]这种写法不是不行,但最好在变量名或注释里说明长度含义,不然[0; 100]到底是一百个零,还是坐标里的 100 个点,只能靠猜。代码是给人读的,类型系统只是帮你兜底。
6.3 练手建议:一个覆盖四大类型的综合任务
如果只想用一个练习把数、元、切、串都过一遍,我给你推荐个组合任务:写一个函数,输入是多个字符串切片,要求按字符长度排序,并把排名前三的字符串合并成一个用逗号分隔的String返回。
思路大概是这样:
fn top_three_joined(input: &[&str]) -> String { let mut items: Vec<&str> = input.to_vec(); items.sort_by_key(|s| s.chars().count()); let top: Vec<&str> = items.iter().rev().take(3).copied().collect(); top.join(", ") } fn main() { let data: [&str; 5] = ["abc", "de", "中文测试", "f", "ghijk"]; let result = top_three_joined(&data); println!("{}", result); }这个练习里,data是数组,函数参数是切片,中间排序用到了Vec动态数组,排序里用chars().count()处理字符串,最终用join合成String。一个例子把这几个类型串在一起用,心里的很多疑问反而解开了。
最后一个实际建议
写这篇文章的时候我又把《Rust 程序设计语言》里复合类型那章翻了一遍,每次带新人都会让他们先做上面那个综合任务。看似简单,其实把数组、切片、元组、字符串全用了一遍,连所有权和借用也顺带复习了。要我说,“四大军阀”不是用来背的,是用来在代码里来回折腾的。一开始不习惯是正常的,我当初也被字符串编码和借用冲突折腾得够呛,现在回头看,正是这些边界条件让 Rust 代码在运行期少了很多莫名其妙的崩溃。写代码的时候多问一句“我这里到底需要所有权还是借用”,时间久了,你会发现自己写出来的接口自然就顺了。