作为经验丰富的 Java 开发者,我们对 ArrayList
、String
和 HashMap
的强大功能了如指掌。它们是 Java 集合框架的核心,也是我们日常编程的得力干将。Rust 同样提供了功能对等的集合类型,但它们的行为模式,尤其是内存管理方式,与 Java 有着天壤之别。
本章我们将探索 Rust 三大核心集合类型:Vec<T>
(向量)、String
(字符串)和 HashMap<K, V>
(哈希映射)。它们有一个共同点:其数据都存储在堆(Heap)上。这意味着,它们都必须严格遵守我们在前面章节学到的所有权规则。理解这些规则如何应用在集合类型上,是写出地道、安全 Rust 代码的关键。
Vec<T>
:可增长的数组(对比 ArrayList<E>
)
Vec<T>
,全称 Vector,是 Rust 版本的 ArrayList<E>
。它是一个可动态增长的、在内存中连续存放的列表。
创建与基本操作
// 创建一个空的 Vector
let mut v: Vec<i32> = Vec::new();
// 使用 vec! 宏创建并初始化 Vector
let mut v2 = vec![1, 2, 3];
// 添加元素
v2.push(4);
v2.push(5);
读取元素:所有权系统的第一次“干预”
读取 Vector 中的元素有两种方式,它们的区别深刻地体现了 Rust 的安全思想。
-
通过索引:
&v[index]
这会返回一个元素的引用。但如果索引越界,程序会立即panic!
,这和 Java 中抛出IndexOutOfBoundsException
类似。let third: &i32 = &v2[2]; println!("The third element is {}", third); // let does_not_exist = &v2[100]; // 这行会 panic!
-
通过
.get()
方法:v.get(index)
这不会直接返回引用,而是返回一个Option<&T>
。如果索引有效,它返回Some(&element)
;如果越界,它返回None
。这是一种更安全、更符合 Rust 风格的方式,它将可能的运行时错误转换成了编译期必须处理的Option
类型。match v2.get(2) { Some(third) => println!("The third element is {}", third), None => println!("There is no third element."), }
所有权内幕:借用规则如何保护你
现在,来看一个在 Java 中可能导致 ConcurrentModificationException
,但在 Rust 中被编译器直接禁止的经典场景。
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0]; // (1) 我们获取了第一个元素的不可变引用
v.push(6); // (2) 我们尝试向 Vector 中添加一个新元素
// println!("The first element is: {}", first); // (3) 编译错误!
这段代码无法通过编译!编译器会报错:cannot borrow 'v' as mutable because it is also borrowed as immutable
(无法在存在不可变借用的同时,进行可变借用)。
为什么?这背后的“内幕”是什么? Vector 为了保证内存的连续性,当 push
一个新元素而容量不足时,它可能需要在堆上重新申请一块更大的内存,然后将所有旧元素复制到新内存中,最后释放旧的内存块。
如果第 (2) 步发生了内存重分配,那么第 (1) 步中 first
变量所持有的引用,就会指向一块已经被释放的、无效的内存!它会瞬间变成一个悬垂引用。Rust 的借用检查器(borrow checker)预见了这种危险,并在编译期就严格禁止了这种“在持有元素引用的同时修改 Vector”的行为。
String
:被所有权“武装”的字符串
我们已经多次接触 String
,现在可以更深入地理解它。String
本质上就是一个被特殊封装过的 Vec<u8>
,并额外保证了其内容始终是有效的 UTF-8 编码。因此,Vec
上的所有权和借用规则,几乎完全适用于 String
。
所有权内幕:字符串拼接的“陷阱”
在 Java 中,我们常用 +
来拼接字符串。Rust 也可以,但其行为涉及到所有权的转移,对于初学者来说可能有些迷惑。
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
// s1 的所有权在这里被“移动”了,而 s2 只是被“借用”
let s3 = s1 + &s2;
// println!("{}", s1); // 编译错误!s1 的值已被移动
println!("{}", s3);
+
操作符背后实际上调用了一个类似这样的方法:fn add(self, s: &str) -> String
。
- 第一个参数是
self
,而不是&self
,所以它会获取s1
的所有权。 - 第二个参数是
&str
,所以它只是借用了s2
的内容。
这就是为什么 s1
在拼接后就失效了。这种方式不仅会转移所有权,还可能因为频繁的内存分配而效率低下。
更地道的方式:使用 format!
宏 format!
宏在功能上类似 Java 的 String.format
,它通过引用来拼接字符串,不会获取任何参数的所有权,是构建新字符串的首选方式。
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
// s1, s2, s3 在这里依然有效
HashMap<K, V>
:键值对的所有权(对比 HashMap<K, V>
)
HashMap
是 Rust 的哈希映射实现,与 Java 的 HashMap
用途一致。
所有权内幕:键和值的所有权转移
HashMap
的所有权行为非常直接:它会获得其键(Key)和值(Value)的所有权。
use std::collections::HashMap;
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");
let mut map = HashMap::new();
// field_name 和 field_value 的所有权被“移动”到了 map 中
map.insert(field_name, field_value);
// 下面这行会编译失败,因为它们的所有权已经不在这里了
// println!("{} : {}", field_name, field_value);
思维转变: 这与 Java 的行为有根本不同。在 Java 中,HashMap
存放的是对象的引用。对象本身存在于堆上,由 GC 管理,Map
和其他变量都可以持有对它的引用。而在 Rust 中,map
变量成为了 field_name
和 field_value
这两块 String
数据的新所有者。当 map
被销毁时,它负责释放这些键和值的内存。
对于实现了 Copy
trait 的类型(如 i32
),它们的值会被复制进 HashMap
,而不是移动。
访问数据
访问 HashMap
中的数据通常是通过 .get()
方法,它会返回一个 Option<&V>
,一个对值的不可变引用。
let team_name = String::from("Blue");
let score = map.get(&team_name); // get 方法接收一个键的引用
match score {
Some(s) => println!("Score is: {}", s),
None => println!("Team not found."),
}
本章小结
我们今天深入了 Rust 核心集合类型的“内幕”。虽然它们的功能与 Java 的同类相似,但所有权系统为它们注入了全新的行为模式:
- 所有集合都将其数据存储在堆上,并作为这些数据的所有者。
- 向集合中插入一个“有所有权”的值(如
String
),会导致该值的所有权被转移到集合中。 - Rust 的借用检查器在编译期就能防止对集合进行不安全的操作(如在持有元素引用的同时修改
Vec
),从而避免了悬垂指针和一部分类似ConcurrentModificationException
的问题。
这套机制,使得 Rust 的集合类型在提供强大功能的同时,也拥有了与生俱来的内存安全保证。
现在,我们已经掌握了如何使用 Rust 提供的泛型集合。那么,我们自己如何编写像 Vec<T>
这样可以适用于多种类型的通用代码呢?
在下一章 《特性(Traits)与泛型——Rust 式的“面向接口编程”》 中,我们将探索 Rust 实现代码复用和抽象的强大工具,它等同于 Java 的泛型和接口,但又远不止于此。