阶段一 · 字符串与文本

String 与 &str 的本质

一句话总结

String 是拥有所有权,可增长的堆上字符串;&str 是指向一段 UTF-8 字节的只读"视图".
前者负责持有数据,后者负责借用数据--这正是 Rust 所有权模型在字符串上的投影.

从一个问题出发:为什么有两种字符串

初学者最常见的困惑是:明明都是字符串,为什么函数签名一会儿写 String,一会儿写 &str?换成其他语言,一个 string 类型就够用了.

答案藏在 Rust 的核心约束里:每一份堆上数据都必须有唯一的所有者,负责在恰当时机释放它.字符串内容长度不定,必须存在堆上,于是 Rust 把"持有这段数据"和"临时查看这段数据"拆成了两个类型.

String 像是买下并持有的一栋房子,拥有钥匙,能改建,最终负责拆除.
&str 则是一张参观通行证,可以进去看看,但既不能拆墙,也不需要在离开时负责拆房.

内存模型:三个字 vs 两个字

理解二者差异,最直接的方式是看它们在栈上的布局.假设有这样一段代码:

let owned: String = String::from("hello");
let slice: &str = &owned[..];

String 在栈上占据三个机器字:指向堆内存的指针,长度(len),容量(capacity).真正的字节 h e l l o 存放在堆上.

&str 是一个胖指针(fat pointer),在栈上占据两个机器字:指向某段 UTF-8 字节的指针,以及这段字节的长度.它自己不拥有任何堆内存,只是"借看"别处的数据.

下图展示了上面两个变量的内存关系:

栈 (stack)                          堆 (heap)
┌───────────────────────┐
│ owned: String │
│ ptr ───────────────┼────────► [ h | e | l | l | o ]
│ len = 5 │ ▲
│ cap = 5 │ │
├───────────────────────┤ │
│ slice: &str │ │
│ ptr ───────────────┼────────────┘
│ len = 5 │
└───────────────────────┘

capacity 的意义

String&str 多出的 capacity 字段,记录的是"已申请但未必用满"的堆空间.这让 String 能像动态数组那样追加内容而不必每次都重新分配--这也是它能"增长"的原因.&str 只读,自然不需要容量信息.

为什么几乎只见 &str,很少见 str

str 本身是一个类型,但几乎从不直接使用,原因是它属于动态大小类型(Dynamically Sized Type,DST).编译器在编译期无法确定一个 str 到底占多少字节,因此不能把它直接放在栈上,也不能作为普通变量或函数参数按值传递.

解决办法是给它套一层指针.&str 就是"指向 str 的引用",而引用的大小是编译期已知的(指针 + 长度),于是一切都成立了.

str 好比"一段长度未知的录音",没法把它整个塞进一个固定大小的盒子.
&str 则是"这段录音的起点位置 + 时长",用这两个已知信息就能精确定位它.

字符串字面量是什么类型

代码里直接写出的 "hello",类型是 &'static str.它指向的字节被编译器直接嵌入到程序的只读数据段,与程序同生共死,所以生命周期是 'static.

let s = "hello";          // s: &'static str
let n = s.len(); // 5,字节长度
// s.push('!'); // 编译错误:&str 是只读的,没有 push 方法

字面量不是 String

很多从其他语言转来的开发者会写 let mut s = "hello"; s += " world";,然后被编译器拦住.字面量是 &str,只读且大小固定.需要可变,可增长的字符串时,必须显式构造 String,例如 String::from("hello")"hello".to_string().

关键机制:解引用强制转换

一个常见的场景是把 String 传给接收 &str 参数的函数,却能通过编译:

fn greet(name: &str) {
println!("Hello, {name}!");
}

let owner = String::from("Alice");
greet(&owner); // 传入 &String,函数却要 &str,为何能编译?

这依赖一个叫解引用强制转换(Deref coercion) 的机制.String 实现了 Deref<Target = str>,编译器在类型不匹配时会自动把 &String 转成 &str.本质是:String 内部就持有一段 UTF-8 字节,取它的 &str 视图是零成本的--只是把堆指针和长度拷给一个胖指针,不复制任何字节.

函数参数的经验法则

定义函数参数时优先用 &str 而非 &String.&str 能同时接受字符串字面量,String 的借用,以及其他字符串切片,适用面更广;而 &String 只能接受 String 的借用,白白收窄了调用方的选择.

该用哪个:决策清单

场景 选择 理由
函数只读取字符串内容 &str 不需要所有权,接受面最广
结构体需要长期持有字符串 String 拥有数据,不受外部生命周期牵制
需要修改,拼接,增长内容 String 只有它可变且可增长
返回新构造的字符串 String 函数结束后数据需继续存活
返回输入字符串的一部分 &str 切片复用原数据,零拷贝

结构体字段慎用 &str

在结构体里放 &str 字段会强制引入生命周期标注(如 struct User<'a> { name: &'a str }),意味着这个结构体不能比它借用的字符串活得更久,使用起来处处受限.除非确有零拷贝的强需求,否则结构体字段一般直接用 String,让它自己拥有数据.

两者之间如何转换

// &str -> String:发生堆分配,把字节复制一份
let s1: String = "hello".to_string();
let s2: String = String::from("hello");
let s3: String = "hello".to_owned();

// String -> &str:零成本,只是借用
let owned = String::from("world");
let borrowed: &str = &owned; // 解引用强制转换
let borrowed2: &str = owned.as_str(); // 显式写法

方向不对称的代价

&str → String 必然伴随堆分配 + 字节复制,因为要创造一份新的,被拥有的数据;而 String → &str 只是生成一个指向已有数据的视图,零成本.在热点路径上频繁调用 to_string() 是常见的性能浪费点.

快速回顾

  • 所有权分工:String 拥有堆数据,&str 只借用,对应 Rust 的所有权与借用模型
  • 内存布局:String 是 ptr+len+cap 三个字,&str 是 ptr+len 两个字的胖指针
  • str 是 DST:大小编译期未知,必须通过 &str 这层引用使用
  • 字面量&'static str,只读,嵌入程序,随程序存活
  • 参数优先 &str,靠解引用强制转换自动兼容 &String
  • 下一篇将深入 UTF-8 编码,解释为什么 Rust 不让用 s[i] 取字符

动手练习

  1. first_word 函数:写一个 fn first_word(s: &str) -> &str,返回第一个空格前的子串.用字面量和 String 两种实参分别调用,验证两者都能传入.
  2. 结构体生命周期:尝试定义 struct User { name: &str }(不加生命周期),观察编译器报错信息,再改为带生命周期标注的版本和直接用 String 的版本,对比两者的使用差异.
  3. 栈上大小验证:用 std::mem::size_of::<String>()std::mem::size_of::<&str>() 打印两种类型在栈上的大小,验证"三个字 vs 两个字"的结论.