Rust 的数据类型里,标量类型是那些单个值,比如i32、bool、f64,而真正的“重头戏”其实是复合类型:数组、元组、切片、字符串。这四样东西在 Rust 社区里被人戏称为“四大军阀”,原因是各占一方、语法上各有势力范围,但实际写代码时又谁都绕不开谁。这篇文章就用“数、元、切、串”四个字做线索,把四者的内存思想、初始化方式、高频操作和常见坑一次讲透,适合刚学完 Rust 基础语法、准备写真实逻辑的读者,也适合回来查漏补缺的老手。
我跟很多初学者聊过,发现大家的卡点通常不是let、不是循环,而是遇到String、&str、&[i32]、[i32; 5]、(i32, String)这种“类型扎堆”的代码时当场懵掉。所以这篇文章不搞虚的,直接从设计思路讲到实战代码,最后一个综合示例覆盖全部四种类型,你可以直接复制到本地跑。
1. 先看整体:四大复合类型的设计思路
1.1 标量 vs 复合:内存里谁是谁的地盘
Rust 把类型分成了两大类:标量类型描述“单个值”,复合类型描述“一堆值怎么组合”。标量类型是整数、浮点数、布尔、字符这几位,它们各自占一块固定大小内存,互不掺和。而复合类型是“把多个值组织在一起”的容器,数组、元组、切片、字符串就是最常见的四种组织方式。
这四种类型的地盘意识非常强。数组和元组是“自己拥有数据”的类型:数组要求所有元素类型相同,长度在编译期确定,数据直接存在栈上;元组允许元素类型不同,但长度同样固定。切片则是“借来的地盘”,它本身不拥有数据,只是对一段连续内存的借用视图,所以它不关心数据是在栈上还是堆上。字符串String是唯一一个默认在堆上分配、可以动态增长的“重装备”,它能装下运行时才确定的文本内容。
这种差异不是 Rust 故意找麻烦,而是它在内存安全上的核心设计:谁拥有数据,谁负责释放;借用的人只能按规则访问。你写arr[0]读数组成员,不产生所有权转移,因为i32是Copy类型;但如果你写fn take(s: String) { }然后把s传进去,s的所有权就交出去了,外面不能再使用。四种复合类型正好把这套“拥有 / 借用 / 移动”的规则全部演示了一遍。
1.2 为什么动态数组 Vec 不在“四大”名单里
很多新手会问:动态数组Vec这么常用,为什么不把它算进去?因为“四大”是按 Rust 官方The Rust Programming Language的章节划分来的,那本书把Vec归进了标准集合类型(Collection),和HashMap、HashSet放一起,强调它是“运行时可变长的堆上序列”。而数组、元组、切片、字符串这四者恰好覆盖了“定长+变长”“拥有+借用”“栈+堆”的几个维度,是官方在复合类型章节里一次性给出的基础武器。
不过你也可以把Vec理解成“数组的升级版”。数组[T; N]的长度N写死在类型里,编进编译期;Vec<T>的长度存在堆上,可以随时push和pop。实际开发里,如果数据量在运行时才知道,或者需要频繁增删,直接上Vec;如果数据量固定,比如一个 RGB 颜色值(三个u8)、一个 4x4 矩阵,用数组更好,既省内存又省一次堆分配。后面综合示例里我会同时用数组和Vec,你看完就能明白这两种选择的分界点在哪。
2. 数组:定长、同质、栈上的一条街
2.1 数组的四种初始化写法与内存布局
数组的类型长这样:[T; N],T是元素类型,N是长度,两者都写死在类型里。比如:
let empty: [i32; 0] = []; let one = [7; 3]; // 等价于 [7, 7, 7] let arr = [1, 2, 3, 4, 5]; // 推断为 [i32; 5] let mut buf = [0u8; 1024]; // 一个 1KB 的字节缓冲区初始化写法基本就是四种:空数组、同一个值重复N次([x; N])、显式写出所有元素、以及带类型后缀的简化写法。[0u8; 1024]在写网络协议解析、缓冲区处理这种代码时特别常见,因为它会一次性在栈上开出连续 1024 字节,不涉及堆分配,速度极快。
连续内存是这个类型最大的特点。数组的所有元素按顺序紧挨着存放,所以arr[0]的内存地址加上i * size_of::<T>()就是arr[i]的地址。CPU 缓存对连续内存的访问极其友好,这也是数组在性能敏感场景中仍然不可替代的原因。这里有个细节:数组在栈上的总大小就是N * size_of::<T>(),所以[u8; 1_000_000]会直接生成一个接近 1MB 的巨大栈变量,如果递归函数里这么干,很快会栈溢出。要存大块数据,应该用Vec或Box。
2.2 数组常用操作:遍历、查找、排序,以及越界风险
数组的基本操作是遍历、取长度、查找和修改。下面的代码展示了最常见的用法:
let mut scores = [88, 92, 75, 100, 67]; println!("len = {}", scores.len()); for s in &scores { println!("score = {}", s); } scores[0] = 95; // 需要 scores 是 mut println!("first = {}", scores[0]); let max = scores.iter().max(); let min = scores.iter().min(); println!("max = {:?}, min = {:?}", max, min); if scores.contains(&100) { println!("有满分"); }注意两点:第一,for s in &scores是借用迭代,如果直接写for s in scores,在 Rust 2021 edition 里数组会被按值迭代,对i32这样的Copy类型没问题,但如果数组元素是String,元素会被移动走,数组之后就不能用了。第二,scores[0] = 95要求scores声明为mut,否则编译报错。
数组排序是另一个高频需求。数组本身没有直接实现sort,但方法查找会自动解引用到切片[T]:
let mut nums = [3, 1, 4, 1, 5, 9, 2, 6]; nums.sort(); println!("sorted = {:?}", nums);nums.sort()这种写法能通过编译,是因为数组会自动以切片的方式参与方法调用。如果你希望更明确,也可以写成let slice: &mut [i32] = &mut nums; slice.sort();。这种“数组转切片”是 Rust 的老传统,切片负责操作逻辑,数组负责内存存储,两者配合非常自然。
关于越界,别抱侥幸心理。let x = arr[100];这种代码在编译期不一定报错(数组长度可能是个动态索引),但运行时会直接 panic,提示index out of bounds。这不像 C 语言那样给你一个“合法”的非法内存访问,Rust 宁可立刻崩溃也不让你踩到野内存。所以写数组索引前,养成先判断idx < arr.len()的习惯。如果你确实想“越界了就给个默认值”,用arr.get(idx).copied().unwrap_or(0)。
3. 元组:不同类型的临时抱团
3.1 元组定义、解构与访问:一行搞定多个值
元组和数组最大的区别是:元组允许每个位置放不同类型的值,但长度固定。它的类型写法是(T1, T2, T3),元素个数就是类型里的个数:
let tup: (i32, f64, &str) = (42, 3.14, "hello"); let (x, y, z) = tup; // 解构 println!("x = {}, y = {}, z = {}", x, y, z); println!("tup.0 = {}, tup.1 = {}", tup.0, tup.1);解构是元组最爽的地方。如果只需要其中一部分值,可以用下划线占位:let (a, _, _) = tup;。点号下标访问(tup.0、tup.1)适合当你不想把整个元组拆开、只想拿某个字段时用。这两种方式各有场景:解构适合“一次性把值全取出来”,点号访问适合“在表达式里临时取一个字段”。
元组还有一个特殊成员:空元组(),它表示“什么都没有”,却是一种真实存在的类型。Rust 里的函数如果没有返回值,其实返回的就是()。很多新手看到别人写-> ()会疑惑,其实等价于没有返回类型。在泛型代码里经常会看到Result<T, ()>这种写法,意思是“操作成功给T,失败没有具体错误信息,只给个空值”。
3.2 元组实战:多返回值、坐标与临时包裹
元组最经典的用途是函数返回多个值。比如写一个交换两个数并返回新旧值的函数:
fn swap_with_log(a: i32, b: i32) -> (i32, i32, String) { let log = format!("swap {} and {}", a, b); (b, a, log) } let (new_a, new_b, log) = swap_with_log(1, 2); println!("{} -> new_a={}, new_b={}", log, new_a, new_b);如果不支持元组,你得定义结构体,或者用可变引用来“带出”多个结果,代码会啰嗦不少。元组的另一个好处是它天生具名性弱:结构体字段有名字,元组的位置没有名字,所以它适合那些“临时凑一起、不需要长期语义”的场景,比如返回(x, y)坐标、返回(code, message)状态码,或者把一个键和一个值打包放进Vec:
let mut list: Vec<(String, i32)> = Vec::new(); list.push(("apple".to_string(), 3)); list.push(("banana".to_string(), 1)); for (name, count) in &list { println!("{}: {}", name, count); }遍历&list时拿到的是&(String, i32),在for循环里写成(name, count)会自动解构,且name和count分别是&String和&i32。这个模式在算法题、数据处理脚本里极其常见。
不过元组也不是越多越好。如果你的元组长度到了四五项,或者某个字段反复被用但你还得靠位置去猜它是啥,就应该上结构体了。我自己判断标准很简单:这个组合会被多个函数传来传去,就定义struct;只在一个函数内部临时用一下,就用元组。
4. 切片:借用一段连续数据的“视图”
4.1 切片是什么:数组和字符串的共享视角
切片(slice)是我们熟悉的&[T],它不拥有数据,而是对一段连续内存的借用视图。你可以把数组的一部分“借”出来给另一个变量用,数据本身没有移动、没有复制:
let arr = [10, 20, 30, 40, 50]; let part = &arr[1..4]; // 包含索引 1、2、3,类型是 &[i32] println!("part = {:?}", part);关键点在于,切片是一个“胖指针”:它由两部分组成,一个指向连续数据的起始地址,一个记录长度。它比裸指针多保存了长度信息,所以访问part[0]时 Rust 能帮你做越界检查。切片和数组的关系,语法上有点像把一条街租下一段门面,你只对这段门面有使用权,房子是谁的、整条街多长,都不归你管。
切片最常见的来源有三个:对数组取区间&arr[..]、对String取子串&s[..n]、把整个String借用成&str类型。切片的类型和数组、字符串互操作非常顺畅,写函数时优先接收&[T]而不是具体的数组类型,这样既不是定长数组[T; N],也不用关心它是从Vec还是从数组来的:
fn sum_slice(nums: &[i32]) -> i32 { nums.iter().sum() } let arr = [1, 2, 3]; let vec = vec![4, 5, 6]; println!("{}", sum_slice(&arr)); println!("{}", sum_slice(&vec));这个模式在 Rust 标准库里到处都是:几乎所有对连续序列的处理都定义在[T]切片类型上,而不是定义在Vec或数组上。
4.2 字符串切片与字节边界:最常见的 panic 来源
字符串切片是初学者最容易翻车的地方。先明确:Rust 里的&str本质就是字符串切片,它与字节切片&[u8]类似,但额外保证内容是合法 UTF-8。而对String做切片时,如果你用&s[start..end]的写法,start和end指的是字节位置,不是字符位置:
let s = "hello"; let sub = &s[0..2]; // "he"上面的例子没问题,因为英文一字节一字符。但一旦出现中文就危险了:
let s = "你好世界"; let sub = &s[0..2]; // panic! byte index 2 is not a char boundary"你好世界"中每个汉字占 3 个字节。字节索引0..2取到了“你”的前两个字节,这既不是一个完整的字符,也不是合法的 UTF-8 序列,Rust 直接拒绝,运行时 panic。想按字符安全遍历,用s.chars();想按字节遍历,用s.bytes();想取前 N 个字符,可以用s.chars().take(n).collect::<String>():
let s = "你好世界"; let first_two = s.chars().take(2).collect::<String>(); println!("{}", first_two); // 你好对比 Python 的切片是按“字符”来的,Rust 因为更贴近底层内存语义,强制让你意识到 UTF-8 编码不是定长发。这不是缺点,是设计取向:Rust 不想偷偷隐藏“取到半个字符”的这种错误。
校验边界也很简单:s.is_char_boundary(idx)可以判断某个字节索引是否落在字符边界上。切割字符串前先判断,可以避免很多 panic。
5. 字符串:堆上动态的“重装备”
5.1 String 与 &str:拥有者和借阅者的恋爱关系
String和&str是 Rust 字符串话题里绕不开的一对。简单说,String是拥有数据所有权的、可增长的“拥有者”,它内部是一块堆上的字节缓冲区,容量可以动态扩张;&str是“借阅者”,它只借用一段已有的字符串数据,没有独立内存,用完就走。
创建字符串的常规方式:
let s1 = String::from("hello"); let s2 = "world".to_string(); let mut s3 = String::new(); s3.push_str("hello");把String转成&str很便宜,通过借用就行:
let s = String::from("hello"); let s_ref: &str = &s; // 或者 &s[..]反过来把&str变成String就会发生一次堆分配和数据复制,因为要独立拥有一份数据:
let s_ref = "hello"; let owned = s_ref.to_string();为什么 Rust 要整两个类型?因为“可变、变长、独立拥有”和“只读、借用、轻量”是两种完全不同的使用场景。函数内部只需要读字符串内容时,接收&str最灵活,既可以传String的借用,也可以传字符串字面量;如果函数需要往字符串里追加内容,那必须接收&mut String或者干脆直接持有String所有权。理解了这一层,你就不会纠结“为什么老是要加个&”。
String内部实际上就是一个Vec<u8>的包装,所以它天然能变长、能扩容。如果你需要处理任意二进制数据,不一定是 UTF-8,那就直接用Vec<u8>,别硬往String里塞。
5.2 字符串高频操作:拼接、转数字、包含判断、排序
字符串拼接有三种常见姿势:
push_str:往可变String末尾追加一个&strpush:往末尾追加一个字符format!:把多个值格式化成新String,最适合拼接多个变量
let mut s = String::from("hello"); s.push(' '); s.push_str("world"); let num = 42; let msg = format!("{} is {}", s, num); println!("{}", msg); // hello world is 42我基本不推荐用+拼接,因为+的签名是fn add(self, other: &str) -> String,会导致你频繁考虑借用和所有权,读起来也不直观。format!最稳妥。
字符串和数字互转是高频率需求。数字转字符串用to_string()或format!("{}", n)。字符串转数字必须用parse,并显式指定目标类型,因为 Rust 猜不到你要转成i32还是f64:
let s = "42"; let n: i32 = s.parse().unwrap(); let f: f64 = "3.14".parse().unwrap(); let bad: Result<i32, _> = "hello".parse(); println!("{:?}", bad); // Err(ParseIntError { kind: InvalidDigit })parse返回Result,实战中一定要处理错误,unwrap()只适合示例和快速验证。更稳妥的写法是match或?操作符。
判断字符串是否包含子串,用contains:
let url = "https://example.com/hey"; if url.contains("example") { println!("yes"); }字符串排序则要绕一下,因为String本身不能直接sort。先把字符收集成Vec<char>,排序后再收集回String:
let s = "rust"; let mut chars: Vec<char> = s.chars().collect(); chars.sort(); let sorted: String = chars.into_iter().collect(); println!("{}", sorted); // rstu如果你做的是英文单词排序、字符串数组排序,那直接用words.sort()就可以了,字符串切片上已经实现了按字典序的Ord。
还有一个容易踩的坑:s.len()返回的是字节数,不是字符数。对一个包含中文的字符串,s.len()往往比你预期大 3 倍。想统计字符数,用s.chars().count()。
5.3 实战小任务:解析 "100,200,300" 并求和
把前面学的东西串一下。需求:输入一个字符串"100,200,300",解析出逗号分隔的数字并求和。
fn sum_csv(data: &str) -> i32 { data.split(',') .map(|item| item.trim().parse::<i32>().unwrap()) .sum() } fn main() { let data = "100,200,300"; let result = sum_csv(data); println!("sum = {}", result); }data.split(',')返回一个迭代器,迭代的元素是&str切片类型;trim()去掉可能的空格;parse::<i32>()把&str转成数字;sum()累计求和。四行代码做完,全是复合类型的功劳。
如果要保证健壮性,unwrap()应该换成错误处理,比如遇到"abc"就跳过错值并记录日志。这里先不展开,重要的是看懂:字符串split产生切片,切片被parse消费,数字再通过迭代器汇总,整个过程没有任何一处显式管理内存,全交给了类型系统。
6. 四大军阀联合作战:综合示例与踩坑排查
6.1 综合实战:解析 "apple:3,banana:1,cherry:2" 并按数量排序
现在用一个更完整的例子把数组、元组、切片、字符串全用上。数据仍然是 CSV 风格,每项是名字:数量,我们要解析出来,按数量从小到大排序,再输出。
fn main() { let data = "apple:3,banana:1,cherry:2"; // 用 Vec 动态数组保存解析结果,元素是 (名字切片, 数量) 元组 let mut items: Vec<(&str, i32)> = Vec::new(); for part in data.split(',') { // part 是 &str 切片 let mut kv = part.split(':'); let name = kv.next().unwrap(); let count: i32 = kv.next().unwrap().parse().unwrap(); items.push((name, count)); } // 按数量排序 items.sort_by_key(|item| item.1); for (name, count) in items { println!("{}: {}", name, count); } }这段代码虽然短,但信息密度很高:data.split(',')返回的是字符串切片迭代器;part.split(':')同理;kv.next()逐个取出&str;parse()转数字;items是一个Vec,元素是(&str, i32)元组;排序用的是元组的第二个字段;最后for (name, count) in items又用了解构。
运行结果应该是banana: 1、cherry: 2、apple: 3。你可以试着把items的类型从Vec<(&str, i32)>换成let fixed: [(&str, i32); 3],用固定长度数组存这三个值,感受一下数组和Vec的使用区别。
6.2 常见问题速查表:编译错误和运行 panic 的解法
我把日常遇到最多的六大类报错整理成一张表,方便你出了问题直接按图索骥:
| 报错现象 | 发生场景 | 原因与解法 |
|---|---|---|
index out of bounds | 数组或切片用越界索引 | 索引超出长度,运行时 panic。先检查idx < arr.len(),或改用arr.get(idx) |
mismatched types | 元组或数组类型不匹配 | 比如let t: (i32, i32) = (1, 2.0);。统一元素类型,或改元组字段类型 |
string indices are bytes | 尝试写s[0] | String不能直接用整数索引。用s.chars()、s.bytes()或s.get(..) |
byte index ... is not a char boundary | 对 UTF-8 字符串切片 | &s[0..2]可能切到半个字符。用s.is_char_boundary(idx)判断,或按chars()处理 |
cannot parse string to number | parse失败 | 输入不是合法数字。用match或?处理Result,别一上来就unwrap() |
cannot borrow ... as mutable | 数组/字符串可变借用失败 | 变量没声明mut。改成let mut arr,或者重新设计变量所有权 |
temporary value dropped while borrowed | 临时String转成切片后使用 | 比如let s = &String::from("x");。让String活得比借用更长 |
这里最值得强调的是第三种和第四种。很多从 Python、JavaScript 转过来的同学,天然认为s[0]能得到第一个字符,但在 Rust 里这是编译错误。在 Python 里元组和切片按“可读性好”设计,Rust 则按“内存安全明确”设计,两种思维没有优劣,只是你要记住 Rust 的字符串是字节序列,不是字符数组。
6.3 我自己的记忆方法和下一步练习
我个人学下来,四样东西各用一句话记忆:
- 数组:同类型、定长、栈上,一组连续房间。
- 元组:不同类、定长、可拆,一组临时组合包。
- 切片:不拥有、借一段,数组和字符串的共享视图。
- 字符串:拥有数据、堆上动态、UTF-8 编码的文本容器。
配套的小练习,建议你写一个命令行工具:接收一个字符串,统计每个字符出现的次数。这里你会用到chars()(字符串转字符迭代器)、Vec<char>或数组、元组(char, usize)、以及sort排序。这个练习做完,四者关系基本就通了。
我在实际写代码时还有一个习惯:遇到类型不确定,先在代码里写let x: () = 变量;让编译器报错,从错误信息里看它到底是什么类型。比如把一个char错误地当成&str用,编译器会告诉你这俩不匹配,比翻文档快得多。Rust 的类型系统其实是最好的老师,你只要愿意读报错信息,很多卡住半天的问题,编译器的提示已经写到七八分了。