4.5 字符串:str、&str 与 String
在日常写代码时,字符串是使用频率最高的数据类型之一。但在初学 Rust 时,几乎每个从 Java、Python 或 Go 转过来的开发者都会被下面三个问题卡住:
- 为什么别的语言里只有一个
String,Rust 里却同时冒出String、&String、str、&str四种写法? - 为什么直接写双引号字面量
"hello",它的类型不是String,而是&str? - 为什么连最基础的按下标取第一个字符
s[0],编译器都直接报错拒绝?
现在你手里已经有了前两节推导出的连续内存三成员——动态大小类型 [T]、16 字节胖指针 &[T] 与 24 字节动态数组 Vec<T>。把这三样东西搬过来,Rust 字符串的设计细节就能直接对上。
一张表看清对应关系:加了 UTF-8 约束的字节序列
Section titled “一张表看清对应关系:加了 UTF-8 约束的字节序列”上一节我们拆解了 Vec<T> 的 24 字节控制头(ptr、len、cap)。而在底层内存布局上,String 内部直接包着一个字节动态数组 Vec<u8>。区别在于,String 的类型实体里多了一条安全约束:内部的字节序列在任何时刻都必须是合法的 UTF-8 编码。
顺着这个事实,把前两节学过的 [u8]、&[u8]、Vec<u8> 全部加上「合法 UTF-8」这条约束,Rust 字符串家族的四个类型就一一对应上了:
| 通用字节序列 | 加上「合法 UTF-8」约束后的字符串类型 | 栈上物理表示 | 本质角色 |
|---|---|---|---|
[u8] |
str |
编译期大小未知(DST,无法直接上栈) | 字符串切片本体:内存中任意一段连续的合法 UTF-8 字节序列 |
&[u8] |
&str |
16 字节胖指针 (ptr, len) |
字符串切片引用(日常简称字符串切片):零分配的只读借用视图 |
Vec<u8> |
String |
24 字节控制头 (ptr, len, cap) |
拥有所有权的堆字符串:负责释放堆内存,支持动态追加与扩容 |
&Vec<u8> |
&String |
8 字节瘦指针 ptr |
指向栈上 String 控制头的普通只读引用 |
把这张表拆开来看:
- 不带
&的str,完全对应上一节不带&的[u8]。它描述的是内存中一段连续的 UTF-8 文本字节;因为编译期不知道一段文本有多长,所以str是一个动态大小类型(DST),永远不能直接写let s: str = ...;。 - 平时无处不在的
&str,是用引用构造器&包住str合成出的 16 字节胖指针(ptr, len)——完全对应上一节的&[u8]。
为什么字面量 "hello" 的类型是 &str?
Section titled “为什么字面量 "hello" 的类型是 &str?”当你在代码里写下 let literal = "hello rust"; 时,"hello rust" 这 10 个字节在编译期就已经直接固化在可执行文件的只读数据段(Read-Only Data Segment)里了。
程序运行时不需要去堆上申请内存,只需要在栈上放一个 16 字节的胖指针 &str(ptr 指向只读数据段里的起始地址,len 为 10),就能零成本地使用这段文本。因为这块内存从程序启动一直存活到程序退出,所以字符串字面量的完整类型是带静态生命周期的 &'static str。
运行下面这段代码,核对它们的物理大小与通用视图能力:
源文件:string-and-str.rs
use std::mem::size_of;
fn print_greeting(text: &str) { println!("greeting ({} bytes): {text}", text.len());}
fn main() { let owned: String = String::from("hello world"); let borrowed_full: &String = &owned; let slice_part: &str = &owned[0..5]; let literal: &str = "hello rust";
println!("String: {} bytes", size_of::<String>()); println!("&String: {} bytes", size_of::<&String>()); println!("&str: {} bytes", size_of::<&str>());
print_greeting(borrowed_full); print_greeting(slice_part); print_greeting(literal);}运行输出:
运行输出String: 24 bytes&String: 8 bytes&str: 16 bytesgreeting (11 bytes): hello worldgreeting (5 bytes): hellogreeting (10 bytes): hello rustprint_greeting(text: &str) 接收的是一个 16 字节的胖指针。它不在乎那段 UTF-8 字节到底是住在堆上(来自 String 的全文或局部切片 &owned[0..5]),还是住在只读数据段里(来自字面量 "hello rust")。而且得益于上一节提过的解引用强制转换(Deref Coercion),当你传入 &String(borrowed_full)时,编译器会自动从 String 控制头里取出堆地址和长度,把它转换成 &str 胖指针。
UTF-8 变长编码:为什么 Rust 禁止写 s[0]?
Section titled “UTF-8 变长编码:为什么 Rust 禁止写 s[0]?”在其他许多语言里,取字符串的第 0 个字符是一件习以为常的事(比如 Python 的 s[0])。可当你在 Rust 里写下 let first = s[0]; 时,编译器会直接拒绝。
运行下面这段包含中英文混合字符的代码:
源文件:utf8-indexing.rs
// Uncomment the marked line to trigger error[E0277].fn main() { let s = String::from("Rust语言"); println!("byte length (.len()): {}", s.len()); println!("char count (.chars()): {}", s.chars().count());
// let first = s[0]; let prefix: &str = &s[0..4]; let suffix: &str = &s[4..10]; println!("slices: {prefix} | {suffix}");
print!("chars: "); for c in s.chars() { print!("[{c}] "); } println!();}运行输出:
运行输出byte length (.len()): 10char count (.chars()): 6slices: Rust | 语言chars: [R] [u] [s] [t] [语] [言]取消第 7 行 // let first = s[0]; 的注释,编译器立刻以 error[E0277] 拒绝编译:
error[E0277]: the type `str` cannot be indexed by `{integer}` --> src/bin/utf8-indexing.rs:7:19 |7 | let first = s[0]; | ^ string indices are ranges of `usize` | = help: the trait `SliceIndex<str>` is not implemented for `{integer}`1. .len() 返回的是字节数,不是字符数
Section titled “1. .len() 返回的是字节数,不是字符数”先看上面代码的输出:"Rust语言" 明明只有 6 个字符(4 个英文字母 + 2 个汉字),为什么 s.len() 返回的却是 10?
因为 UTF-8 是一种变长编码:
- 一个英文 ASCII 字符(如
'R'、'u'、's'、't')在 UTF-8 中占 1 个字节(4 个字母共占 4 字节); - 一个常用中文字符(如
'语'、'言')在 UTF-8 中占 3 个字节(2 个汉字共占 6 字节);如果是 Emoji 表情(如'🦀'),则占 4 个字节。 - 既然
str和String的底层是 UTF-8 字节序列,胖指针和控制头里的len记录的自然是总字节数(4 + 6 = 10字节)。
2. 为什么不能用 s[i] 直接下标取值?
Section titled “2. 为什么不能用 s[i] 直接下标取值?”明白了 UTF-8 是变长编码,就能看清语言设计者为什么封禁了 s[i]:
- 含义歧义:如果允许写
s[4],你想要的到底是第 4 个字节(只是汉字'语'的前三分之一个残缺字节),还是第 4 个人类字符('语')? - 拒绝隐藏的性能成本:在内存中,所有下标操作
arr[i]给人的直觉都是常数时间O(1)(直接用首地址 + i × 元素大小一步算出位置)。但由于 UTF-8 每个字符占 1 到 4 字节不等,要想找到「第i个字符」,CPU 必须从第 0 个字节开始逐个解码扫描(时间复杂度是O(N))。Rust 绝不允许一个看起来像O(1)的s[i]语法在背后偷偷做O(N)的全量遍历。
因此,Rust 要求你在代码里显式表明意图:
- 按人类字符(Unicode 标量值
char)遍历或计数,显式调用s.chars(); - 按原始字节(
u8)访问,显式调用s.as_bytes()。
3. 范围切片 &s[a..b] 的边界检查与线上防崩
Section titled “3. 范围切片 &s[a..b] 的边界检查与线上防崩”虽然不能写单个整数 s[0],但范围切片 &s[start..end] 是允许的,因为它的语义很明确:直接从第 start 个字节切到第 end 个字节(左闭右开),耗时是 O(1)。
但这里有一条必须遵守的规则:start 和 end 字节偏移量,必须刚好落在 UTF-8 字符的边界上。
如果一个汉字 '语' 占第 0..3 共 3 个字节,你却写 &s[0..1] 只切出它的前 1 个字节,会发生什么?看下面这段代码:
utf8-boundary.rs
// Uncomment the marked line to trigger a runtime panic.fn main() { let s = String::from("语言"); let valid_slice = &s[0..3]; println!("first char slice: {valid_slice}");
// Safe slicing in production: returns Option<&str> instead of panicking. println!("safe get 0..3: {:?}", s.get(0..3)); println!("safe get 0..1: {:?}", s.get(0..1));
// let bad_slice = &s[0..1];}运行输出:
运行输出first char slice: 语safe get 0..3: Some("语")safe get 0..1: None一旦取消最后一行 // let bad_slice = &s[0..1]; 的注释并运行,程序会在运行时当场触发 panic 终止:
thread 'main' panicked at src/bin/utf8-boundary.rs:11:22:byte index 1 is not a char boundary; it is inside '语' (bytes 0..3) of `语言`为什么必须当场报错终止?因为 str 这个类型实体的核心安全约束,就是「内部字节在任何时刻都必须是合法的 UTF-8」。如果允许 &s[0..1] 切出半个汉字并构造出 &str,就会制造出一个破坏了 UTF-8 约束的非法 &str。
代码审查(Code Review)防崩警示:在线上服务中,审查你自己的代码或 AI 生成的代码时,一旦看到对外部输入的字符串直接写硬编码的 &s[0..10],必须立刻拦截——只要用户输入的第 10 个字节正好卡在某个中文字符或 Emoji 中间,线程就会当场崩溃。在不确定字节边界或长度时,始终改用 s.get(0..10):边界合法时它返回 Some(&str),切在字符中间或越界时它安全地返回 None。
工程选型与签名裁决:什么时候用 &str,什么时候用 String?
Section titled “工程选型与签名裁决:什么时候用 &str,什么时候用 String?”在实际项目中设计接口签名、定义结构体,或者审查 AI 写出的代码时,对照下面四条工程判据来做裁决:
| 工程场景 | 选型判据 | 底层原因 |
|---|---|---|
| 1. 函数参数(只读) | 优先写 fn process(s: &str)(不写 &String) |
16 字节胖指针 &str 既能直接接收字面量 "abc" 和子串切片,也能通过解引用强制转换零成本接收 &String,通用性最广。 |
| 2. 函数参数(需持有所有权) | 写 fn take(s: String) |
当函数内部必须把这段字符串存进结构体或交给新线程时,直接按值要求 String。把「是否需要克隆分配」的决定权交给调用方:调用方若不再使用原 String,可直接移动进来。 |
| 3. 函数返回值 | 内部新生成的文本返回 String;截取自输入参数的子串返回 &str |
函数内部动态拼出的文本住在当前栈帧管理的堆上,必须返回 String 把释放责任移交给调用方;如果只是在传入的 &str 里切出一段子串,直接返回胖指针 &str 即可。 |
| 4. 结构体(Struct)字段 | 默认用 String |
结构体通常需要独立存活并到处移动,让它自己拥有堆上文本的所有权(String)最稳妥。除非在写零拷贝解析器,否则不要轻易在结构体里放 &'a str,那会让整个结构体带上生命周期参数的约束。 |
把字符串放回我们的类型构造器与切片模型里,所有的设计都严丝合缝:
str与String就是加上了 UTF-8 约束的[u8]与Vec<u8>:str是编译期大小未知的动态大小类型(DST);&str是 16 字节胖指针(ptr, len);String是拥有堆内存所有权的 24 字节控制头(ptr, len, cap);- UTF-8 变长编码决定了访问与安全边界:
.len()返回的是字节数而非字符数;为了避免语义歧义与隐藏的O(N)遍历开销,Rust 禁止整数下标s[i](报E0277),而范围切片在不确定边界时应优先使用s.get(a..b)防止运行时panic; - 签名选型遵循所有权需求:只读参数一律写
&str,结构体字段与需要交出所有权的返回值使用String。
在 utf8-boundary.rs 中,let s = String::from("语言");(两个汉字,每个汉字在 UTF-8 中占 3 个字节,共 6 个字节)。
- 如果想用范围切片取出第二个汉字
"言",应该写&s[?..?]? - 在审查一段处理用户输入的代码时,如果看到
let preview = &user_input[0..20];,这行代码有什么隐患?应该改成什么?
核对答案:
- 应该写
&s[3..6](或简写为&s[3..])。因为第一个汉字'语'占据了字节索引0, 1, 2(即0..3),第二个汉字'言'从字节索引3开始,占据3, 4, 5(即3..6)。 - 隐患:如果
user_input总字节数不足 20,或者第 20 个字节恰好落在某个中文字符或 Emoji 的多字节中间,程序会在运行时直接触发panic崩溃。应改为user_input.get(0..20)安全获取Option<&str>。
实战演练:审查并重写日志提取函数
Section titled “实战演练:审查并重写日志提取函数”学完引用(&T / &mut T)、数组([T; N])、切片(&[T])、动态数组(Vec<T>)和字符串(&str / String),我们用一道综合任务检验连续序列与字符串的工程裁决力。
业务需求:
写一个函数 extract_errors,从一组日志行中找出所有以 "[ERROR] "(共 8 个 ASCII 字节)开头的日志,去掉前面的 "[ERROR] " 前缀后,把剩余的错误消息收集起来返回给调用方;调用方在调用完函数后,还要继续使用原来的日志列表。
某位新手(或 AI)写出了下面这段问题百出的初版代码:
// 初版问题代码(请勿在生产环境模仿)fn extract_errors(logs: &Vec<String>) -> Vec<String> { let mut result: Vec<String> = Vec::new(); for i in 0..logs.len() { let line: String = logs[i]; // 问题 A if &line[0..8] == "[ERROR] " { // 问题 B result.push(line[8..].to_string()); } } result}请独立完成以下三项任务:
- 找出编译错误与运行时崩溃隐患:
- 问题 A(第 5 行
let line: String = logs[i];):编译器会直接拒绝编译这一行。请说出它会报什么错误码,以及为什么不能这样写? - 问题 B(第 6 行
if &line[0..8] == "[ERROR] "):即使把第 5 行改成借用let line = &logs[i];让代码编译通过,第 6 行在线上遇到什么样的日志文本时会当场触发panic崩溃(说出两种情况)?
- 问题 A(第 5 行
- 优化函数签名(零拷贝设计):
- 初版签名写成了
fn extract_errors(logs: &Vec<String>) -> Vec<String>,每次提取子串都要调用.to_string()在堆上重新分配一块内存。 - 既然错误消息本身就是原日志字符串的一部分(子串),如果要求函数内部不拷贝任何字符串内容(只返回指向原日志子串的胖指针),参数类型和返回值类型分别应该改成什么?
- 初版签名写成了
- 写出修正后的实现:用
s.get(0..8)(或s.strip_prefix("[ERROR] "))写出安全、无堆拷贝的extract_errors函数。
点击核对参考答案与完整代码
- 错误与崩溃诊断:
- 问题 A:报
error[E0507]: cannot move out of index of Vec<String>。因为logs是只读引用&Vec<String>,且String未实现Copy,直接写logs[i]会试图把Vec内部第i个槽位的String所有权移走,破坏Vec连续有效区间的约束。应改为借用let line = &logs[i];(或直接for line in logs)。 - 问题 B:
&line[0..8]会在以下两种情况下当场panic:①某行日志总长度不足 8 个字节(如空行""或"ok"),触发越界panic;②某行日志开头包含中文字符或 Emoji(例如"警告:连接超时",每个汉字占 3 字节,第 8 个字节正好卡在第三个汉字中间),触发非 UTF-8 字符边界panic。
- 问题 A:报
- 签名优化:
- 参数应将
&Vec<String>拓宽为切片&[String](如果输入源本身是&str列表则可用&[&str]); - 返回值不需要重新分配
String,直接返回存放字符串切片胖指针的动态数组Vec<&str>,里面的每个&str(16 字节)直接借用自输入logs中的堆缓冲区。
- 参数应将
- 安全、零字符串拷贝的参考实现:
fn extract_errors(logs: &[String]) -> Vec<&str> {let mut result: Vec<&str> = Vec::new();for line in logs {// 使用 get(0..8) 安全检查前缀,既防越界又防切碎多字节字符if line.get(0..8) == Some("[ERROR] ") {if let Some(msg) = line.get(8..) {result.push(msg);}}// 注:日常工程中也可直接用标准库提供的 line.strip_prefix("[ERROR] ")}result}在 Rust Playground 中运行
前五节我们拆解的 [T; N]、&[T]、Vec<T> 和 String 都按顺序存放元素,通过整数下标或字节范围来访问。下一节我们看标准库里另一个高频容器 HashMap<K, V>:当数据不再按位置顺序排列、而是按键(Key)查找值(Value)时,所有权与借用规则怎样约束它的接口设计。