在编程中,字符串是处理文本数据的核心载体,几乎所有项目都会涉及字符串的创建、操作与转换。与其他编程语言(如Java、Python)不同,Rust 中的字符串设计更为严谨,它围绕"所有权""安全性"和"UTF-8 编码"构建了两种核心字符串类型,这既是 Rust 字符串的特色,也是初学者容易踩坑的关键点。
本文将从 Rust 字符串的核心设计理念入手,详细解析两种核心字符串类型(&str 与 String)的本质、差异及转换方式,再逐步讲解字符串的基础操作、高级用法,最后拓展常见陷阱与最佳实践,帮助你彻底掌握 Rust 字符串的使用技巧。
一、先搞懂核心:Rust 字符串的设计基石
Rust 字符串的两个核心设计原则,这是理解后续内容的前提:
- 默认 UTF-8 编码:Rust 中的字符串天然支持 UTF-8 编码,无需额外配置即可处理中文、日文、Emoji 等所有 Unicode 字符,这让 Rust 非常适合跨语言、跨平台的文本处理场景。
- 区分"不可变切片"与"可增长所有权" :Rust 没有单一的"字符串类型",而是提供了
&str(字符串切片)和String(可增长字符串)两种类型,分别对应"不可变、无所有权"和"可变、有所有权"的场景,这是由 Rust 的所有权机制决定的。
二、核心类型一:&str(字符串切片)------ 不可变的文本视图
2.1 什么是 &str
&str(读作"字符串切片")是 Rust 中最基础的字符串类型,它本质上是一个不可变的、对 UTF-8 编码文本的引用(视图),不拥有底层数据的所有权。
&str 的内存布局包含两部分(类似指针+长度的组合):
- 一个指针,指向底层文本数据在内存中的起始地址;
- 一个长度,表示字符串切片包含的字节数(注意:不是字符数,因为 UTF-8 字符可能占用 1-4 个字节)。
由于 &str 是引用类型,它不会复制底层数据,仅提供对已有数据的只读访问,因此它的创建和传递成本极低。
2.2 &str 的创建方式
&str 有多种创建方式,最常用的是直接使用字符串字面量(默认就是 &'static str 类型,数据存储在程序的只读数据段中,生命周期贯穿整个程序)。
示例 1:创建 &str 的几种方式
rust
fn main() {
// 1. 字符串字面量(最常用):默认类型为 &'static str
let hello: &str = "Hello, Rust!";
let chinese: &str = "你好,世界!";
let emoji: &str = "😀 Rust 真有趣";
println!("字符串字面量:{}", hello);
println!("中文字符串:{}", chinese);
println!("Emoji 字符串:{}", emoji);
// 2. 从 String 中获取字符串切片(&String 可以自动解引用为 &str,也可手动用 as_str())
let owned_string = String::from("我是 String 类型");
let slice1: &str = &owned_string; // 自动解引用
let slice2: &str = owned_string.as_str(); // 手动转换
println!("从 String 转换的切片1:{}", slice1);
println!("从 String 转换的切片2:{}", slice2);
// 3. 对已有 &str 进行切片(注意:必须按 UTF-8 字节边界切割,否则会 panic)
let full_str = "Rust 字符串";
// 前 4 个字节:"Rust"(每个英文字母占 1 个字节)
let partial_str = &full_str[0..4];
println!("部分切片:{}", partial_str);
}
运行结果:
字符串字面量:Hello, Rust!
中文字符串:你好,世界!
Emoji 字符串:😀 Rust 真有趣
从 String 转换的切片1:我是 String 类型
从 String 转换的切片2:我是 String 类型
部分切片:Rust
2.3 &str 的核心特性
-
不可变性 :
&str是只读的,无法修改其中的字符或字节,例如以下代码会编译报错:rustfn main() { let s = "hello"; s[0] = 'H'; // 错误:&str 是不可变的,无法修改 } -
无所有权 :
&str仅引用底层数据,不负责数据的释放,当&str超出作用域时,仅仅是释放 s 这个变量,而底层的字面量字符串不会被释放,只有程序结束时,底层的数据才会被释放。因此:如果你确实有场景需要使用大量不同的字符串(比如百万级、千万级),绝对不能使用字符串字面量,正确的做法是使用动态 String,并合理管理其生命周期。 -
轻量级 :由于仅存储指针和长度,
&str的大小固定(在 64 位系统上是 16 字节,32 位系统上是 8 字节),传递时无需复制大量数据,效率极高。
三、核心类型二:String(可增长字符串)------ 有所有权的可变文本
3.1 什么是 String
String 是 Rust 中用于表示可变的、有所有权的 UTF-8 编码字符串 ,它本质上是对底层 Vec<u8>(字节向量)的封装,存储在堆内存中,拥有底层数据的所有权。
与 &str 相比,String 的核心优势是可增长、可修改,可以动态添加、删除、替换字符,适合需要对文本进行修改操作的场景。
3.2 String 的创建方式
String 提供了多种灵活的创建方式,满足不同场景的需求。
示例 2:创建 String 的几种常用方式
rust
fn main() {
// 1. 使用 String::from() 从 &str 创建(最常用)
let s1 = String::from("Hello, String!");
let s2 = String::from("中文也支持");
println!("s1:{}", s1);
println!("s2:{}", s2);
// 2. 使用 String::new() 创建空字符串,再通过 push_str/push 填充
let mut s3 = String::new();
s3.push_str("我是通过 new() 创建的"); // 追加字符串切片
s3.push('!'); // 追加单个字符(注意:是单引号包裹的 char 类型)
println!("s3:{}", s3);
// 3. 使用 format! 宏拼接多个字符串(类似 C 语言的 sprintf,更安全)
let name = "Alice";
let age = 28;
let s4 = format!("姓名:{},年龄:{}", name, age);
println!("s4(格式化创建):{}", s4);
// 4. 预分配内存创建 String(优化性能,避免多次内存重分配)
let mut s5 = String::with_capacity(20); // 预分配 20 字节的堆内存
s5.push_str("预分配内存的字符串");
println!("s5:{},当前容量:{} 字节", s5, s5.capacity());
}
运行结果:
s1:Hello, String!
s2:中文也支持
s3:我是通过 new() 创建的!
s4(格式化创建):姓名:Alice,年龄:28
s5:预分配内存的字符串,当前容量:20 字节
3.3 String 的核心特性
- 可变性 :
String是可变的(需用mut关键字声明),支持添加、删除、修改等操作。 - 有所有权 :
String拥有堆上的字节数据,当String超出作用域时,会自动释放对应的堆内存,无需手动管理。 - 可增长性 :
String的底层是Vec<u8>,当添加数据超出当前容量时,会自动进行内存重分配,扩容策略与Vec一致(通常是翻倍扩容)。 - UTF-8 安全 :
String确保存储的是有效的 UTF-8 编码数据,所有修改操作都会验证 UTF-8 有效性,避免无效编码导致的问题。
四、核心对比:&str 与 String 的区别及转换
4.1 核心区别对照表
| 特性 | &str(字符串切片) | String(可增长字符串) |
|---|---|---|
| 所有权 | 无所有权(仅引用) | 有所有权(拥有堆上数据) |
| 可变性 | 不可变(只读) | 可变(需 mut 声明) |
| 内存位置 | 通常在只读数据段(字面量)或堆(引用 String) | 堆内存(底层是 Vec) |
| 内存布局 | 指针 + 长度(固定大小) | 指针 + 长度 + 容量(动态大小) |
| 创建成本 | 极低(无需分配堆内存) | 较高(需要分配堆内存) |
| 修改操作 | 不支持任何修改 | 支持添加、删除、替换等修改 |
| 使用场景 | 只读文本、函数参数、返回临时文本 | 可变文本、需要动态修改的场景 |
4.2 &str 与 String 的相互转换
在实际开发中,我们经常需要在 &str 和 String 之间转换,两者的转换方式简单且高效。
示例 3:&str 与 String 的相互转换
rust
fn main() {
// 1. &str 转 String(三种常用方式)
let str_slice = "我是 &str 类型";
// 方式1:String::from()
let s1 = String::from(str_slice);
// 方式2:to_string() 方法
let s2 = str_slice.to_string();
// 方式3:into() 方法(自动类型转换)
let s3: String = str_slice.into();
println!("&str 转 String:s1={},s2={},s3={}", s1, s2, s3);
// 2. String 转 &str(四种常用方式)
let mut string = String::from("我是 String 类型");
// 方式1:自动解引用(&String 会自动转为 &str)
let slice1: &str = &string;
// 方式2:as_str() 方法(推荐,语义更清晰)
let slice2 = string.as_str();
// 方式3:&string[..](全切片,等同于自动解引用)
let slice3 = &string[..];
// 方式4:deref() 方法(手动解引用,不常用)
let slice4 = (*string).deref();
println!("String 转 &str:slice1={},slice2={},slice3={},slice4={}", slice1, slice2, slice3, slice4);
// 注意:String 转 &str 后,&str 依赖于原 String 的生命周期,原 String 不可提前销毁
// 错误示例(编译报错):
// let slice = {
// let temp_string = String::from("临时字符串");
// temp_string.as_str(); // temp_string 在这里超出作用域被销毁,slice 悬空
// };
}
运行结果:
&str 转 String:s1=我是 &str 类型,s2=我是 &str 类型,s3=我是 &str 类型
String 转 &str:slice1=我是 String 类型,slice2=我是 String 类型,slice3=我是 String 类型,slice4=我是 String 类型
五、字符串的基础操作
无论是 &str 还是 String,都支持大量通用的基础操作,这些操作是处理文本的核心,下面通过示例详细讲解。
5.1 长度获取:len() 与 chars().count()
注意:len() 返回的是字节数 ,chars().count() 返回的是字符数(UTF-8 字符可能占用多个字节,两者结果可能不一致)。
rust
fn main() {
let s = "Rust 你好 😀";
// len():返回字节数
let byte_len = s.len();
// chars().count():返回字符数
let char_count = s.chars().count();
println!("字符串:{}", s);
println!("字节数:{}", byte_len); // Rust(4) + 空格(1) + 你(3) + 好(3) + 空格(1) + 😀(4) = 16
println!("字符数:{}", char_count); // R u s t 你 好 😀 = 8
}
运行结果:
字符串:Rust 你好 😀
字节数:16
字符数:8
5.2 字符串拼接:+ 运算符、push_str、format!
+运算符:右侧必须是&str类型,会获取左侧String的所有权,返回新的String。push_str:直接追加&str到现有String中,不创建新对象,性能更高。format!:支持多类型拼接,不获取任何参数的所有权,灵活且安全。
rust
fn main() {
// 1. + 运算符拼接(右侧必须是 &str)
let s1 = String::from("Hello");
let s2 = " World";
let s3 = s1 + s2; // s1 的所有权被转移,后续无法使用 s1
println!("s3(+ 运算符拼接):{}", s3);
// println!("s1:{}", s1); // 错误:s1 已失去所有权
// 2. push_str 追加(不转移所有权,性能更高)
let mut s4 = String::from("Hello");
let s5 = " Rust";
s4.push_str(s5);
println!("s4(push_str 追加):{}", s4);
println!("s5 仍可使用:{}", s5); // s5 未失去所有权
// 3. format! 宏拼接(最灵活,支持多类型)
let s6 = String::from("姓名");
let name = "Bob";
let age = 30;
let s7 = format!("{}:{},年龄:{}", s6, name, age);
println!("s7(format! 拼接):{}", s7);
println!("s6 仍可使用:{}", s6); // format! 不获取所有权
}
运行结果:
s3(+ 运算符拼接):Hello World
s4(push_str 追加):Hello Rust
s5 仍可使用: Rust
s7(format! 拼接):姓名:Bob,年龄:30
s6 仍可使用:姓名
5.3 字符串切片与字符访问
- 字符串切片:
&s[start..end],注意start和end是字节索引,必须落在 UTF-8 字符的边界上,否则会 panic。 - 字符访问:不能直接用
s[index]访问(因为 UTF-8 字符长度不固定),需使用chars()遍历或nth()获取指定索引的字符。
rust
fn main() {
let s = "Rust 你好 😀";
// 1. 合法切片(按字节边界切割)
let slice1 = &s[0..4]; // 切割前 4 字节(Rust)
let slice2 = &s[5..8]; // 切割第 5-7 字节(你)
println!("合法切片1:{}", slice1);
println!("合法切片2:{}", slice2);
// 2. 非法切片(会 panic,因为中文"你"占用 3 字节,索引 6 不在字符边界上)
// let slice3 = &s[5..6]; // 运行时 panic:invalid utf-8 sequence
// 3. 访问指定索引的字符(使用 chars().nth())
let ch1 = s.chars().nth(0); // 获取第 0 个字符(R)
let ch2 = s.chars().nth(5); // 获取第 5 个字符(你)
let ch3 = s.chars().nth(7); // 获取第 7 个字符(😀)
println!("第 0 个字符:{:?}", ch1);
println!("第 5 个字符:{:?}", ch2);
println!("第 7 个字符:{:?}", ch3);
}
运行结果:
合法切片1:Rust
合法切片2:你
第 0 个字符:Some('R')
第 5 个字符:Some('你')
第 7 个字符:Some('😀')
5.4 字符串替换、分割与查找
示例 4:字符串的替换、分割、查找操作
rust
fn main() {
let mut s = String::from("I love Rust, Rust is great!");
// 1. 字符串替换
// replace():替换所有匹配的子串,返回新 String
let s1 = s.replace("Rust", "Rust Lang");
// replacen():替换前 n 个匹配的子串,返回新 String
let s2 = s.replacen("Rust", "Rust Lang", 1);
println!("原字符串:{}", s);
println!("替换所有:{}", s1);
println!("替换前 1 个:{}", s2);
// 2. 字符串分割
let split_str = "apple,banana,orange,grape";
// split():按指定分隔符分割,返回迭代器
let mut split_iter = split_str.split(',');
println!("分割结果:");
for item in split_iter {
println!("{}", item);
}
// split_whitespace():按空白字符分割(空格、制表符、换行符等)
let whitespace_str = "hello world \t rust\n";
let whitespace_iter = whitespace_str.split_whitespace();
println!("按空白分割结果:");
for item in whitespace_iter {
println!("{}", item);
}
// 3. 字符串查找
let find_str = "Hello, Rust!";
// contains():判断是否包含指定子串
let has_rust = find_str.contains("Rust");
// find():查找子串首次出现的字节索引,返回 Option<usize>
let rust_index = find_str.find("Rust");
// rfind():查找子串最后一次出现的字节索引,返回 Option<usize>
let o_index = find_str.rfind("o");
println!("是否包含 Rust:{}", has_rust);
println!("Rust 首次出现的索引:{:?}", rust_index);
println!("o 最后一次出现的索引:{:?}", o_index);
}
运行结果:
原字符串:I love Rust, Rust is great!
替换所有:I love Rust Lang, Rust Lang is great!
替换前 1 个:I love Rust Lang, Rust is great!
分割结果:
apple
banana
orange
grape
按空白分割结果:
hello
world
rust
是否包含 Rust:true
Rust 首次出现的索引:Some(7)
o 最后一次出现的索引:Some(4)
六、字符串的高级操作
6.1 字符遍历:字节、字符、字形簇
由于 Rust 字符串是 UTF-8 编码,遍历方式有三种,分别对应不同的粒度:
- 字节遍历 :
bytes()方法,遍历字符串的每个字节,适用于底层字节处理。 - 字符遍历 :
chars()方法,遍历字符串的每个 Unicode 字符,适用于大多数文本处理场景。 - 字形簇遍历 :
graphemes(true)方法(需引入unicode-segmentationcrate),遍历视觉上的单个字符(部分字符由多个 Unicode 码点组成,如带声调的中文、组合 Emoji)。
示例 5:三种字符串遍历方式
rust
// 若使用 graphemes,需在 Cargo.toml 中添加:unicode-segmentation = "1.10"
use unicode_segmentation::UnicodeSegmentation;
fn main() {
let s = "Rust 你好 😀👨👩👧";
// 1. 字节遍历(bytes())
println!("字节遍历:");
for byte in s.bytes() {
print!("{} ", byte);
}
println!("\n");
// 2. 字符遍历(chars())
println!("字符遍历:");
for ch in s.chars() {
print!("{} ", ch);
}
println!("\n");
// 3. 字形簇遍历(graphemes(),视觉上的单个字符)
println!("字形簇遍历:");
for grapheme in s.graphemes(true) {
print!("{} ", grapheme);
}
println!("\n");
// 注意:😀 是单个字形簇,👨👩👧 也是单个字形簇(由多个 Unicode 码点组成)
let grapheme_count = s.graphemes(true).count();
println!("字形簇数量:{}", grapheme_count);
}
运行结果(节选):
字节遍历:
82 117 115 116 32 228 189 160 229 165 189 32 240 159 152 128 240 159 145 128 226 128 144 240 159 145 136 226 128 144 240 159 145 167
字符遍历:
R u s t 你 好 😀 👨 👩 👧
字形簇遍历:
R u s t 你 好 😀 👨👩👧
字形簇数量:10
6.2 字符串格式化:进阶用法
除了基础的 format! 宏,Rust 还支持通过 Display 和 Debug 特质自定义格式化输出,以及多种格式化参数(对齐、填充、精度等)。
示例 6:字符串格式化进阶
rust
use std::fmt;
// 自定义结构体
struct User {
name: String,
age: u32,
}
// 实现 Display 特质:自定义友好的格式化输出(用于 println!、format! 等)
impl fmt::Display for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "用户【{}】,年龄:{} 岁", self.name, self.age)
}
}
// 实现 Debug 特质:自定义调试格式输出(用于 {:?}、{:#?} 等)
impl fmt::Debug for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("User")
.field("name", &self.name)
.field("age", &self.age)
.finish()
}
}
fn main() {
let user = User {
name: String::from("Charlie"),
age: 35,
};
// 1. 使用 Display 格式化({})
println!("Display 格式化:{}", user);
// 2. 使用 Debug 格式化({:?} 简洁格式,{:#?} 美化格式)
println!("Debug 简洁格式化:{:?}", user);
println!("Debug 美化格式化:{:#?}", user);
// 3. 格式化参数:对齐、填充、精度
let num = 42;
let text = "Rust";
// 左对齐,宽度 10,填充 -
println!("左对齐:{:-<10}", text);
// 右对齐,宽度 10,填充 0
println!("右对齐:{:0>10}", num);
// 浮点数精度控制
let f = 3.1415926;
println!("浮点数保留 2 位小数:{:.2}", f);
}
运行结果:
Display 格式化:用户【Charlie】,年龄:35 岁
Debug 简洁格式化:User { name: "Charlie", age: 35 }
Debug 美化格式化:
User {
name: "Charlie",
age: 35,
}
左对齐:Rust------
右对齐:0000000042
浮点数保留 2 位小数:3.14
6.3 处理无效 UTF-8 数据
Rust 的 &str 和 String 都要求数据是有效的 UTF-8 编码,但在某些场景下(如读取二进制文件、网络数据),可能会遇到无效的 UTF-8 数据。此时可以使用 Vec<u8>(字节向量)或 OsString(操作系统字符串)来处理。
示例 7:处理无效 UTF-8 数据
rust
fn main() {
// 无效的 UTF-8 字节序列(0xff 不是合法的 UTF-8 字节)
let invalid_utf8 = vec![0xff, 0xfe, 0xfd];
// 1. 尝试将无效 UTF-8 转为 &str(会失败,返回 None)
let str_slice = std::str::from_utf8(&invalid_utf8);
match str_slice {
Ok(s) => println!("有效 UTF-8:{}", s),
Err(e) => println!("无效 UTF-8:{}", e),
}
// 2. 使用 Vec<u8> 存储无效 UTF-8 数据(不受 UTF-8 限制)
let mut byte_vec = Vec::from(invalid_utf8);
byte_vec.push(0x00); // 追加额外字节
println!("字节向量:{:?}", byte_vec);
// 3. 使用 OsString 处理操作系统相关的字符串(支持非 UTF-8 编码)
let os_str = std::ffi::OsString::from("操作系统字符串");
let os_str_from_bytes = std::ffi::OsString::from_vec(byte_vec);
println!("OsString:{:?}", os_str);
println!("从字节向量创建的 OsString:{:?}", os_str_from_bytes);
}
运行结果:
无效 UTF-8:invalid utf-8 sequence of 1 bytes from index 0
字节向量:[255, 254, 253, 0]
OsString:"操作系统字符串"
从字节向量创建的 OsString:"\xff\xfe\xfd\x00"
七、常见陷阱与最佳实践
7.1 常见陷阱
- 按字节索引访问字符 :直接使用
s[index]访问&str或String会编译报错,因为 UTF-8 字符长度不固定,必须使用chars().nth(index)。 - 切片超出 UTF-8 边界 :
&s[start..end]中start和end是字节索引,若落在字符中间,会导致运行时 panic。 - 忽略所有权转移 :使用
+运算符拼接字符串时,左侧String的所有权会被转移,后续无法再使用。 - 频繁创建 String 导致性能问题 :多次拼接字符串时,若使用
+运算符,会创建多个临时String,导致多次内存重分配,推荐使用String::with_capacity预分配内存或StringBuilder(第三方 crate)。
7.2 最佳实践
-
优先使用 &str 作为函数参数 :函数参数若只需只读访问字符串,应使用
&str而非String,这样既支持传入&str字面量,也支持传入&String(自动解引用),提高函数的通用性。rust// 推荐:使用 &str 作为参数 fn print_text(text: &str) { println!("{}", text); } fn main() { let str_slice = "字面量"; let string = String::from("String 类型"); print_text(str_slice); // 直接传入 &str print_text(&string); // 传入 &String(自动解引用为 &str) } -
需要修改时使用 String,且预分配内存 :当需要动态修改字符串时,使用
mut String,并通过String::with_capacity预分配足够的内存,避免多次扩容。 -
拼接多个字符串优先使用 format! :
format!宏灵活、安全,且不会转移所有权,适合多字符串拼接场景。 -
处理无效 UTF-8 用 Vec 或 OsString :避免使用
&str或String存储无效 UTF-8 数据,防止运行时 panic。 -
遍历视觉字符优先使用字形簇 :若需处理带声调的字符、组合 Emoji 等,使用
unicode-segmentationcrate 的graphemes方法,避免拆分视觉上的单个字符。
八、总结
Rust 中的字符串围绕"UTF-8 编码"和"所有权机制"构建,核心分为 &str 和 String 两种类型,总结如下:
- &str:不可变、无所有权、轻量级,是对 UTF-8 文本的引用,适合只读场景和函数参数。
- String:可变、有所有权、可增长,是堆上的 UTF-8 字符串,适合动态修改场景。
- 相互转换 :
&str转String可使用String::from()或to_string();String转&str可使用as_str()或自动解引用。 - 核心操作 :长度获取(
len()字节数、chars().count()字符数)、拼接(push_str、format!)、分割(split())、替换(replace())、查找(contains()、find())。 - 高级技巧 :三种遍历方式(字节、字符、字形簇)、自定义格式化(
Display/Debug)、处理无效 UTF-8 数据(Vec<u8>/OsString)。 - 最佳实践 :优先使用
&str作为函数参数、预分配String内存、避免按字节索引访问字符、优先使用format!拼接字符串。
掌握 Rust 字符串的核心特性和使用技巧,能帮助你规避常见陷阱,写出更安全、更高效的文本处理代码,也是深入 Rust 编程的重要基础。