String 与 &str:为什么有两个
「一个字符串而已,凭什么搞出两个类型?」这是卷 II 的最后一堵小墙,而它其实就是「拥有 vs 借看」的一次具体化。看清 String 拥有数据、&str 只是一段视图,你就再也不会纠结参数该写哪个了 —— 顺带,你还会理解 Vec 和切片、以及 Rust 里所有「拥有型 / 借用型」成对出现的类型。
两个类型,两种身份
它们的差别,一句话就是所有权:
String—— 拥有者。它在堆上分配了一块缓冲区,装着那串字符,并对这块内存负责(负责扩容、负责在 drop 时释放)。它可增可减、可变长。它是「一本你自己的书」。&str—— 借看者。它自己不拥有任何字符,只是指向别人那串字符里的一段。它记两个数:从哪开始(指针)、多长(长度)。它是「一张写着『第 3 到第 8 页』的借条」。
所以 String::from("hi") 给你一个拥有者,而字符串字面量 "hi" 的类型是 &'static str —— 一段指向「编进二进制里的那串字符」的视图(上一章讲过它为什么是 'static)。
下面这台图把一个 String 摊开:左边是它拥有的堆缓冲区,右边是各种切片 &s[a..b] —— 注意每个切片不拷贝任何字符,它只是记下「起点指针 + 长度」,然后指回原来那块内存的一段。
切片 = 指针 + 长度,这叫「胖指针」
玩过 demo 你就看到了:&s[0..5] 没有复制 "hello" 这几个字,它只是造了一个小小的两件套 ——「指向堆缓冲区第 0 字节的指针」+「长度 5」。这种「带着长度的指针」叫胖指针(普通指针只有地址,胖指针多带一个长度)。正因为切片不拷贝,它极其便宜:不管你切的是 5 个字节还是 5 万个字节,切片本身都只是那两个机器字。
这也顺带解释了一个「拥有 vs 借看」在内存上的对应:
String(拥有) | &str(借看) | |
|---|---|---|
| 栈上存 | 指针 + 长度 + 容量(三件套) | 指针 + 长度(两件套 / 胖指针) |
| 堆上有没有它的数据 | 有,它拥有 | 没有,它只是指向别人的 |
| 能不能改 | 能(push_str 等) | 不能(它是借看) |
| drop 时 | 释放堆缓冲区 | 什么都不做(不是它的) |
String 比 &str 多一个「容量」,是因为拥有者要管扩容 —— 它得知道当前缓冲区还能塞多少、什么时候要搬家。借看者不管这些,所以更瘦。
那到底该用哪个?一张决策
这是最实用的部分。记住一个原则:拥有才用 String,借看就用 &str。展开成几条你会天天用到的经验:
- 函数参数「只读地看一段字」→ 写
&str。这是最重要的一条。因为&str最通用:字面量能传,String也能传(Rust 会自动把&String转成&str,这叫解引用强制转换)。写成&str的函数,两种字符串都能喂。 - 函数参数需要「拿走 / 长期保存」这段字 → 写
String。比如你要把它存进一个结构体字段、或塞进某个集合,那你需要拥有权,写String。 - 结构体字段 → 通常存
String(拥有)。存&str会逼你给结构体加生命周期参数(上一章那个坑)。拥有一份,省心。 - 返回值 → 看你返回的是新造的还是借来的。新拼出来的字符串返回
String;从参数里切一段返回,返回&str(并可能要标生命周期)。
// 地道:接收 &str(最通用),只是读
fn greet(name: &str) {
println!("你好,{}", name);
}
fn main() {
let owned = String::from("Ferris");
greet(&owned); // &String 自动变 &str,能传
greet("字面量"); // &str 本身,也能传
}
这个模式,Rust 里到处都是
「拥有型 vs 借用型」不是字符串的特例,它是 Rust 类型系统的一个贯穿模式。你把 String / &str 想通了,下面这些一眼就懂:
| 拥有型 | 借用型(切片 / 视图) | 装的是 |
|---|---|---|
String | &str | 字符串 |
Vec<T> | &[T] | 一串同类型的值 |
Box<T> | &T | 单个堆上的值 |
PathBuf | &Path | 文件路径 |
规律完全一致:左边那个拥有堆上的数据、可变长、负责释放;右边那个是一段只读视图、胖指针、不拥有。Vec<T> 之于 &[T],和 String 之于 &str,是同一个故事。所以函数想「只读地遍历一串数」,参数就写 &[T],道理和写 &str 一模一样。学一个,会一片。
Rust 的字符串是 UTF-8 编码,一个「字」可能占多个字节(英文字母 1 字节,多数汉字 3 字节,emoji 4 字节)。切片 &s[a..b] 的下标是字节下标,如果 a 或 b 落在了某个多字节字符的中间,程序会 panic(而不是给你半个乱码字符)。这是 Rust 的一个刻意选择:宁可当场 panic,也不给你一段无效的 UTF-8。要按「字符」而不是「字节」处理,用 .chars()。demo 里用的是纯 ASCII,所以字节和字符一一对应,没踩到这个坑 —— 但真实世界处理中文时,记着这条。
回流
Java:早年的 String.substring() 曾经就是「共享底层字符数组 + 记录 offset 和 count」—— 一个不拷贝的视图,和 &str 一模一样的思路。后来因为会导致大数组无法回收(一个小 substring 拖着整个大数组不放)改成了拷贝。看到区别了吗?Java 有 GC,所以「视图拖着大对象不被回收」是个真问题;Rust 有所有权和生命周期,编译器精确知道视图借了谁、活多久,反而能放心地用零拷贝视图。同一个把戏,在有 GC 的地方是隐患,在 Rust 里是安全的默认。
Go:Go 的切片 []T 就是「指针 + 长度 + 容量」的胖指针,和 Rust 的 Vec / &[T] 几乎同源。区别是 Go 不区分「拥有」和「借看」—— 切片可以随便共享底层数组,于是也就有了「两个切片共享底层数组、一个改了另一个莫名其妙也变」的经典坑。Rust 用 String/&str、Vec/&[T] 的区分,把这个坑用类型堵上了。
这一章的一句话
String 拥有堆上的字符、负责释放;&str 只是「指针 + 长度」的一段视图,不拷贝、不拥有。参数只读地看一段就写 &str,要拿走或保存就写 String —— 这个「拥有型 / 借用型」的成对模式,在 Vec/&[T] 等处处复现。
到这里,卷 II —— 整门语言的地基 —— 打完了。移动、drop、借用、生命周期、拥有 vs 借看,这五块砖,是你之后读任何 Rust 代码时脑子里都在运转的东西。如果你走到这里没有被劝退,那道最难的坎,你已经迈过去了。
从下一卷起,画风一转:我们从「编译器拦着你不能干什么」,切换到「类型系统能帮你干什么」—— Rust 的第二样超能力,用类型让一整类 bug 从一开始就无法被写出来。