第十章 通用集合

Rust标准库中有很多非常有用的集合类的数据结构。大部分其他标量类型或复合类型只能含有一个值,但是集合类型的数据结构可以包含多个值,且可以自动扩展或缩小,这些集合类的数据保存在堆内存上,这就意味着在编译器可以不用知道数据量的大小,在程序运行时可以进行扩展或缩小。每种集合都有不同的能力和所需消耗的代价,这就要求你需要在开发阶段需要知道到底哪种集合适合你的场景。之后,我们会讨论最常用的三种集合:

向量(vector):允许你一个接一个的保存数据。

字符串(string):字符的集合。

哈希map(hash map):允许你将值和一个键关联起来,它是map的特殊实现。

更多关于Rust集合的文档参见这篇文档

10.1 向量

向量是Vec<T>类型的数据,是Rust标准库体统的动态可增长的数组。它存储在堆内存中,长度可以在运行时改变,数据类型可以在初始化时根据T的值进行设置。

关键特性:

  1. 同质存储:只能存储相同类型(T)的元素。
  2. 连续内存:元素在内存中时连续紧挨着存放的,因此访问速度快。
  3. 使用场景:替代了固定长度的数组,用于存储未知数量或数量会变化的数据(例如:文件行、购物车的商品)

10.1.1 创建

rust 复制代码
fn main() {

    //方式一:创建一个空的Vec,通常需要显示的注明要保存数据的类型(因为编译器无法推断)

    let mut v:Vec<i32> = Vec::new();


    //方式二:使用语法糖宏vec!(最常用,编译器会自行推断它的类型)

    let v = vec![1,2,3];


    //方式三:创建指定容量的Vec(性能优化,避免频繁扩容)

    let mut v:Vec<f32> = Vec::with_capacity(10);

}

示例中展示了三种方式创建新的向量,分别是:

使用向量new函数创建,必须指定向量类型;

使用语法糖宏来创建向量,数据类型自动推断。

使用指定容量的函数with_capcity进行创建,同样必须指定类型,而且还必须指定容量,这样会优化性能,避免了频繁扩容。

10.1.2 元素的添加与删除

rust 复制代码
fn main() {

    let mut v = vec![1,2,3];

    //添加元素

    v.push(5);

    println!("添加元素5后的向量是:{v:?}");

    //删除并返回最后一个元素

    let last = v.pop();

    println!("删除的元素是:{}",last.unwrap());

    println!("删除后的向量内容是:{v:?}");

}

添加和删除元素分别是push和pop。

10.1.3 访问向量中的某个元素

rust 复制代码
fn main() {

    let v = vec![10, 20, 30];

    //方法一: 使用索引值访问;如果索引值越界,程序会panic,并退出运行。

    let second = v[1];

    println!("使用索引值访问的元素值是:{second}");


    //方式二:安全访问(使用.get(index);返回Option(&T),越界时返回None,程序不会崩溃

    match v.get(2) {

        Some(value) => println!("使用get方法返回的数值是:{value}"),

        None => println!("索引越界!"),

    }


    //索引越界,程序崩溃并退出

    let second = v[10];

    println!("使用索引值访问的元素值是:{second}");

}

访问元素有两种方式,分别是使用索引值直接访问;另一种是使用get方法进行访问。推荐使用get方法进行访问,这种方法更加安全。

10.1.4 遍历

rust 复制代码
fn main() {

    let v = vec![100, 200, 300];


    //不可变遍历(只读)

    for i in &v {

        println!("{i}");

    }


    //可变遍历(可以修改元素中的值)

    let mut v = vec![1, 2, 3];

    for i in &mut v {

        *i += 50; //注意:需要使用解引用操作符*来修改值

    }

    println!("修改后");

    for i in &v {

        println!("{i}");

    }

}

变量有两种,一种是可变借用,另一种是不可变借用,具体使用哪个根据用户的场景来决定。可变遍历中需要使用解引用操作符来操作它的值("*")。

10.1.5 使用枚举类型来保存多种类型的值

rust 复制代码
fn main() {

    #[derive(Debug)]

    enum SpreadsheetCell {

        Int(i32),

        Float(f64),

        Text(String),

    }

    let row = vec![

        SpreadsheetCell::Int(3),

        SpreadsheetCell::Text("曹操".into()),

        SpreadsheetCell::Float(3.14),

    ];

    for item in &row {

        println!("{item:?}");

    }

}

虽然向量类型要求类型相同,但是可以利用Enum类型的变体形式来存储多类型数据。

10.1.6 删除向量或向量中的值

rust 复制代码
fn main() {

    {

        let v = vec![1,3,5];

    }

    println!("{}",v[0]);

}

执行这段程序,编译器会报错,错误信息如下所示:

rust 复制代码
  --> src\main.rs:10:19

   |

10 |     println!("{}",v[0]);

   |                   ^

   |

help: the binding `v` is available in a different scope in the same function

  --> src\main.rs:8:13

   |

 8 |         let v = vec![1,3,5];

   |             ^

当向量离开了自己的作用域,它会删除掉自己所用的元素,和自己本身。

10.1.7 所用权与借用检查

在一个作用域内不能同时存在可变借用和不可变借用。

rust 复制代码
fn main() {

    let mut v = vec![1,2,3];

    let first = &v[0];      //获取一个不可变借用

    v.push(4);              //❌️使用可变引用来修改向量的值,可能会导致向量从新分配

    println!("{first}");

}

编译器报错:

rust 复制代码
  --> src\main.rs:9:5

   |

 8 |     let first = &v[0];

   |                  - immutable borrow occurs here

 9 |     v.push(4);

   |     ^^^^^^^^^ mutable borrow occurs here

10 |     println!("{first}");

   |  

但是如果先是使用可变借用,在使用不可变借用,系统不会报错

rust 复制代码
fn main() {

    let mut v = vec![1, 2, 3];

    v.push(4); //使用可变引用来修改向量的值,可能会导致向量从新分配

    let first = &v[0]; //获取一个不可变借用

    println!("{first}");

}

最佳实践

  1. 访问数据:优先使用.get()方法而不是[index],除非索引值绝对安全。
  2. 内存预分配:如果你可以预估数据量,使用Vec::with_capacity(n)可以显著减少内存分配,提高性能
  3. 迭代优于使用索引:尽量使用for element in &vec进行遍历,而不是通过索引ve[index]访问,这样会更安全。
  4. 注意作用域:向量离开作用域后,所有元素会被自动清理(Drop),无需手动释放内存。

10.1.8 总结对比表

|------------|---------------------------------------------|-------------------------------------------|
| 特性 | Vector ( Vec<T> ) | 普通数组 ( [T; N] ) |
| 长度​ | 动态可变 | 编译时固定 |
| 内存​ | 堆 (Heap) | 栈 (Stack) |
| 存储​ | 必须同类型 | 必须同类型 |
| 性能​ | 稍慢(需间接访问) | 极快(直接访问) |

10.2 字符串

10.2.1 核心概念:两种字符串类型

Rust 的字符串设计非常严谨,这也是新手最容易"劝退"的地方。你需要明确区分两种核心类型:

|------------|------------|--------------|-------------|----------------------|
| 类型 | 名称 | 内存位置 | 可变性 | 来源 |
| String​ | 字符串对象 | 堆 (Heap) | 可增长、可变 | 标准库提供,Vec<u8>的封装 |
| &str​ | 字符串切片 | 栈或二进制程序 | 不可变引用 | 核心语言,通常是 &String的视图 |

深度理解:

  1. String:就像你拥有一本可写的笔记本(可以修改,添加内容,也可以删除内容)。
  2. &str:就像借阅图书馆的一本书(只能看,不能改,且需要保证来源有效)。

10.2.2 Rust字符串的特点

Rust字符串主要有三点区别于其他语言:

  1. 使用UTF-8编码:Rust强制所有的字符串都使用UTF-8编码。这意味着每个字符的长度是不一致的,有可能1、2、3个字节。
  2. 索引访问的禁止:不能使用下标索引来获取字符"[0]",因为每个字符不一定正好对应着该字符的索引,可能取到的不是一个合法的字符。
  3. 所有权系统的接入:字符串拼接操作会涉及到所有权的转移,这与其他语言的"无感复制"截然不同。

10.2.3 创建字符串

rust 复制代码
fn main() {

    //1.创建空的字符串

    let mut s = String::new();

    s.push_str("Hello, world!");

    println!("{s}");


    //2.从字面量创建

    let s1 = "曹操".to_string();

    let s2 = String::from("孙权");

    let s3:String = "刘备".into();

    println!("{s1}-{s2}-{s3}");

    //3.新建并赋值,包含多种字符

    let  hello = String::from("你好,主人,��");

    println!("{hello}");

}

创建字符串有多种方式,可以创建空字符串,然后添加字符串。亦可以直接创建字符串切片,然后转化为字符串。也可以在创建的同时进行赋值操作。

10.2.3 更新字符串

rust 复制代码
fn main() {

    let mut s = String::from("你好,");

    // 追加字符串切片(不会夺取所有权)

    s.push_str("主人");

    // 追加单个字符,注意要使用单引号,单引号表示为单个字符。

    s.push('!');

    println!("{s}");


    // 使用"+"运算符连接字符串(注意有所有权的转移)

    let s1 = String::from("你好,");

    let s2 = String::from("曹操!");

    let s3 = s1 + &s2; //s1的所有权被转移到s3了,s2只是借用,所有权没有转移

    //println!("{s1}"); //系统报错

    println!("{s2}"); //正常显示

    println!("{s3}"); //正常显示

}

上面的示例演示了字符串的添加和字符串的连接,添加字符串使用函数push_str,添加单个字符使用push,注意参数要使用单引号。字符串的连接使用符号"+",它的签名函数如下所示:

rust 复制代码
fn add(self,s:&str) -> String 

这个签名函数会拿走第一个参数的所有权,并借用第二个参数。这非常高效,避免了深拷贝。对一个复杂的多个字符串的拼接,推荐使用farmat!宏(它不会夺取任何参数的所有权):

rust 复制代码
fn main() {

    let s1 = "曹操";

    let s2 = "孙权";

    let s3 = "刘备";

    let s = format!("{s1}-{s2}-{s3}");

    println!("{s}");

}

10.2.4 核心难点

索引和UTF-8的处理是Rust字符串最独特的部分,也是安全性的体现。

  • 为什么禁止索引?

Rust是字符串的字节的集合(Vec<u8>)。由于UTF-8是可变长编码:

英文字符是1个字节

中文支付"你"占三个字节

表情符号��占4个字节

如果尝试let c=&s[0],Rust无法保证返回的是一个完整的Unicode标量值(可能取到的是中文字符的1/3),因此编译器直接禁止此操作。

  • 正确的遍历与切片

你不能按索引随机访问,但是可以按字节范文进行切片,或者按字符进行遍历。

rust 复制代码
fn main() {

    let s = String::from("你好English!");


    //1.按字符进行遍历(推荐)

    for c in s.chars() {

        println!("{c}");

    }

    //2.按字节遍历(用于底层协议处理)

    for b in s.bytes() {

        println!("{b}");

    }

    //3.字符串切片(必须严格在字符边界上!)

    let slice = &s[0..6];

    println!("{slice}");

    //4.字符串切片(没有字符边界上!系统报错)

    // let slice = &s[0..5];

    println!("{slice}");

    //5.安全获取(返回Option类型,系统不会崩溃)

    if let Some(safe_slice) = s.get(0..5) {

        println!("安全切片:{safe_slice}");

    } else {

        println!("切片的位置有误!");

    }

}

中文处理法则:

如果你需要处理"字",永远使用.chars()函数。

如果你需要处理字节(如网络传输),使用.bytes()函数。

经理避免手动切片,除非你非常清楚字符的编辑位置。

  • 总结与最佳实践

类型选择:

  1. 需要修改、拼接、构建字符串使用String。
  2. 函数参数、只读的字符串使用&str。

拼接选择:

  1. 两个字符串拼接使用操作符"+"。
  2. 多个字符串拼接使用format!宏。

访问选择:

永远不要使用s[i]下标索引的访问方式。

  1. 遍历使用for c in s.chars()。
  2. 获取子串有限使用s.get(...)或&s[...]

10.3 哈希映射(HashMap)

10.3.1 定义

HashMap<K,V>是一种键值对(Key-Value)映射的集合类型。它通过哈希函数决定键值的保存位置,因此查找效率高(平均O(1)的时间复杂度)。

10.3.2 为什么要用HashMap

使用场景:当你通过一个键来查找数据,而不是通过索引下标时。

示例:记录游戏队伍的得分时。键是队伍的名称(String类型),值是份数(i32类型)。

10.3.3 创建

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    //插入键值对

    scores.insert(String::from("Blue"), 10);

    scores.insert(String::from("Yellow"), 50);

    println!("{scores:?}");

}

关键特性:

同质类型:所有的键必须是同一类型,所有的值必须是同一类型。

堆分配:数据存储在堆(Heap)内存上。

10.3.4 查询与空值处理

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert("Bule".to_string(), 10);

    let team_name = String::from("Blue");

    let score = scores.get(&team_name).copied().unwrap_or(0);

    println!("{score}");

}

安全机制:Rust强制你处理键不存在的情况。get函数返回Option<&V>,你需要通过copied()和unwrap_or(default)来安全的获取值。

注意:HashMap在初始化的时候一定要定义为mut,否则它是只读的,不能保存新值。

10.3.5 遍历

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    //插入键值对

    scores.insert(String::from("Blue"), 10);

    scores.insert(String::from("Yellow"), 50);

    println!("{scores:?}");

    //遍历

    for (key, value) in &scores {

        println!("{key}:{value}");

    }

}

无序性:遍历的顺序是不确定的(取决于哈希函数)。

上面的示例遍历时取值是只读借用,因此不影响原有的哈希函数的所有权。

10.3.6 所有权规则

HashMap堆数据的所有权处理是容易出错的地方:

|---------------|----------------------------|
| 数据类型 | 插入 HashMap 后的行为 |
| i32(实现 Copy) | 值会被复制进 HashMap,原变量仍可用 |
| String(拥有所有权) | 值会被移动 (Move),原变量失效 |

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let field_name = String::from("魏国");

    let field_value = String::from("曹操");

    let mut map = HashMap::new();

    map.insert(field_name, field_value);

    //println!("{field_name}:{field_value}");

    println!("{map:?}");

}

如果想要保留所有权,可以插入引用符号"&",但必须处理生命周期问题。亦可以使用clone()函数,复制原有的数据,原有的数据所有权不变。

10.3.7 更新策略

场景一:键存在则不更新(仅插入新键)

使用entry().or_insert()模式:

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    //插入键值对

    scores.insert(String::from("Blue"), 10);

    scores.insert(String::from("Yellow"), 50);

    // 如果 Yellow 键不存在,则插入 88;否则不做任何操作

    scores.entry(String::from("Yellow")).or_insert(88);

    println!("{scores:?}");

}

程序运行结果显示:新值没有加进去。

rust 复制代码
{"Blue": 10, "Yellow": 50}

场景二:基于旧值更新

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert("Blue".to_string(), 10);

    scores.insert("Blue".to_string(), 20);

    scores.insert("Blue".to_string(), 30);

    println!("{scores:?}");

}

使用同一个键名,就可以更新键值。也可以使用entry()函数来对键值进行更新。

rust 复制代码
fn main() {

    use std::collections::HashMap;

    let text = "hello world wenderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {

        //or_insert(0)返回一个值的可变引用(&mut V)

        let count = map.entry(word).or_insert(0);

        //必须使用解引用(*)才可以修改值。

        *count += 1;

    }

    println!("{map:?}");

}

上面的代码展示了使用返回值的引用来对保存的值进行修改。

10.3.8 性能提升

  • 默认的哈希算法:Rust默认使用了SipHash,虽然这个算法有点慢,但是可以有效的防止哈希碰撞攻击(HashDoS)。
  • 更换算法:如果你认为这个算法慢不适合你的项目,你可以指定别的hasher(Hash算法因子)。

10.3.9 常见疑惑与解答

  • 为什么HashMap没有在Prelude中自动导入?

因为和Vec和String相比,它的使用频率稍低,且标准库对其支持也比较少(例如没有内置的构造宏)。

  • 一个键可以对应多个值吗?

不可以。一个键只能对应一个值(但是多个键可以对应一个值)。如果需要一对多,可以使用Vec作为值类型(例如:HashMap<String,Vec<i32>>)。

  • 如何安全的更新值?

利用Entry的API(entry().and_modify()或entry().or_insert())是官方推荐的做法,它比手动检查contains_key更加高效且安全。

相关推荐
小王师傅661 小时前
【Java结构化梳理】泛型-初步了解-下
java·开发语言
新新学长搞科研1 小时前
【高质量能源会议推荐】第十一届能源与环境研究进展国际学术会议(ICAEER 2026)
人工智能·物联网·算法·机器学习·能源·环境·新能源
悟空聊架构1 小时前
GStack的26种专家角色,真正实现一人成军!
后端
counting money1 小时前
Spring框架基础(依赖注入-半注解形式)
java·后端·spring
CN-Dust1 小时前
【C++】for循环例题专题
java·c++·算法
SmartRadio1 小时前
ESP32-S3 双模式切换实现:兼顾手机_路由器连接与WiFi长距离通信 (采用Arduino代码框架)
开发语言·智能手机·esp32·长距离wifi
Code_Artist1 小时前
一天之内我让 AI 用 Netty 造了一个最小可用的 MVC 框架:体验一下造轮子的快感😅!
后端·netty·ai编程
也许明天y2 小时前
LangChain4j + Spring Boot 多智能体协调架构原理深度解析
spring boot·后端·agent