第 11 章:集合类型——Vector、String、HashMap 的所有权内幕

作为经验丰富的 Java 开发者,我们对 ArrayListStringHashMap 的强大功能了如指掌。它们是 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 的安全思想。

  1. 通过索引&v[index] 这会返回一个元素的引用。但如果索引越界,程序会立即 panic!,这和 Java 中抛出 IndexOutOfBoundsException 类似。

    let third: &i32 = &v2[2];
    println!("The third element is {}", third);
    
    // let does_not_exist = &v2[100]; // 这行会 panic!
    
  2. 通过 .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_namefield_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 的泛型和接口,但又远不止于此。