Rust四大复合类型详解:数组、元组、切片、字符串实战指南
2026/9/24 20:41:35 网站建设 项目流程

学 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 元组语法、解构与索引访问

元组最大的特点就是异构:一个元组里可以混着i32f64&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!宏的返回值也是这个类型;很多表达式如果没有实际值,也会给它一个()

()在日常生活中最常用的地方之一是表示“没有有效数据但仍然要走一遍逻辑”。比如使用OptionResult时,你可能会写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、3

4.2 从数组、Vec 和字符串切出子集

切片最大的优势是“万物皆可切”:只要底层是连续内存,就能借用出一段来。数组、VecString都能被切成切片。

// 从数组切 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,编译器会报错,因为s2String,不是&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>,让调用方决定如何处理。

第二个错误常见于你把Stringstr当成字符数组用了。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 代码在运行期少了很多莫名其妙的崩溃。写代码的时候多问一句“我这里到底需要所有权还是借用”,时间久了,你会发现自己写出来的接口自然就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询