字符串初识:String 与 &str 的差别

几乎所有从其他语言转来的人都在字符串上栽过第一个大跟头:为什么一个字符串要有两种写法?这篇解决的问题是 String 与 &str 各自到底是什么、什么时候用哪个、它们之间如何互相转换,以及 UTF-8 编码给字符串操作带来的约束。读完你再看到 fn foo(s: &str) 与 fn foo(s: String) 的区别时,能立刻判断哪个签名更适合当前场景。

一、两种字符串其实是两种东西

String 是堆上的、可变的、拥有所有权的字符串类型;&str 是对某段字符串数据的引用,固定长度,不拥有数据。&str 指向的东西可能来自字符串字面量(编译进二进制只读段),也可能是某个 String 的切片。把它们的关系画成表格会清楚很多。

维度 String &str
所有权 拥有数据 借用数据
存储 堆,可扩容 指向既有数据
可变性 可修改 固定,不可改
生命周期 动态管理 依附于数据源
典型来源 String::from、to_string 字面量、切片、解引用

一个常被引用的说法是:String 是管理者,&str 是管理员手里的一段视图。函数参数优先用 &str 而不是 String,因为调用方传字面量、传 String 的引用、传切片都兼容,接口最通用。这条经验在写库时尤其重要:参数类型决定了所有调用方必须付出什么代价,&str 参数意味着没有人被强迫分配内存,String 参数则逼着每个调用方先造一个堆上的副本。

二、创建与互相转换

创建 String 最常见的三条路径,以及从 String 拿到 &str 的方式,都用代码说话:

fn main() {
    let s1 = String::from("你好");
    let s2 = "世界".to_string();
    let s3 = String::new();            // 空字符串,之后 push
    let view: &str = &s1;              // String 借用为 &str
    let again = view.to_string();      // &str 转回 String
    println!("{}{}{}", s1, s2, s3);
}

&str 到 String 要付出一次内存分配与拷贝;String 到 &str 只是取引用,零成本。所以库的公共 API 收字符串参数时几乎一律写 &str,把分配的决定权留给调用方。修改 String 内容用 push_str、push、insert 等方法,它们可能触发扩容,但平摊成本仍然是常数级。需要频繁拼接的循环里,更稳妥的做法是预先用 with_capacity 估好容量,避免反复扩容与拷贝;容量给多了费内存,给少了触发扩容,写性能敏感代码时值得花一次测量来定。

三、UTF-8 是默认,也是约束

Rust 的字符串内容按 UTF-8 存储,这是不可妥协的契约。直接后果有两个:其一,s.len() 返回字节数而不是字符数;其二,不能用字节索引 s[3] 拿字符,因为 UTF-8 是变长编码,字节位置可能落在字符中间导致 panic,所以标准库干脆禁止了字节索引。

fn main() {
    let s = "你好,Rust";
    println!("字节数: {}", s.len());         // 13
    println!("字符数: {}", s.chars().count()); // 7
    for c in s.chars() {
        print!("{}", c); // 按字符迭代
    }
}

按字符迭代用 chars(),按字节用 as_bytes(),按字符与字节的混合单位用 char_indices()。三种迭代器的语义不同,混用是新手 bug 高发区,记住下表的分工。

方法 迭代单位 典型用途
chars() Unicode 标量值 显示、计数
as_bytes() 字节 二进制协议、哈希
char_indices() 字符与字节偏移 定位、切片
bytes() 字节 等价 as_bytes 迭代

一个真实场景的坑:想取「前三个字符」写 &s[..3] 会 panic,正确写法是 s.chars().take(3).collect::<String>()。凡是涉及按字符切片的操作,先想字节还是字符。做命令行工具按列对齐输出时这个坑尤其常见,因为终端宽度按字符算而字节长度按 UTF-8 算,二者对中文永远对不上。

四、拼接、格式化与切片

字符串拼接有几种形式,性能特征各不相同。format! 宏最灵活但每次都分配;+ 运算符会消耗左侧 String 的所有权,写法有点反直觉;push_str 适合在已有 String 上追加,分配最少。

fn main() {
    let mut buf = String::from("你好");
    buf.push_str(",世界");          // 就地追加
    let full = format!("{}!", buf); // 新分配
    let greeting = String::from("hi");
    let combined = greeting + "!";   // greeting 被 move,之后不可用
}

切片是 &str 最常见的获取方式:&s[0..3] 得到 &str,前提是边界落在字符边界上。字符串切片这一节先建立直觉,后文「切片」一章会把通用切片与生命周期标注接上。

五、什么时候用哪个

选型规则可以压成三句话:所有权归你这个模块、需要修改或生命周期长于调用栈,用 String;只是读、随用随还,用 &str;需要把多种来源的字符串收进一个容器长时间持有,用 Vec<String> 或按需 Arc<str>。初学者最常犯的错误是到处用 String 导致无谓的克隆,或者反过来把 &str 存进结构体、生命周期编译不过。

判断不准时的经验法则是:先写 &str,编译器报生命周期问题再考虑升级到 String。以官方标准库文档为准,Deref 机制让 String 在需要 &str 的位置自动解引用,这也是为什么多数方法调用在两种类型上看起来都能用。注意解引用只发生在参数传递与方法接收处,不会在泛型容器里自动发生,所以 Vec<&str> 与 Vec<String> 仍然是两个不兼容的类型。如果你在写大量文本处理的代码,正则表达式精进 里的示例展示了如何在高性能场景下用 &str 切片减少分配。

六、典型编译错误

字符串相关的新手错误集中在所有权与类型转换两处,下表先给个速查,详细机制在所有权章节展开。

错误信息 常见触发 方向
borrows of moved value String 被 move 后继续用 所有权
expected &str, found String 把 String 直接传给收 &str 的参数 加引用或解引用
byte index is not a char boundary 按字节切中文字符 改用 chars 迭代
mismatched types 拼接时混用 String 与 &str 明确转换

其中第三条是中文环境下的高频错误,报错信息会精确给出不合法的字节偏移,修起来不难,记住它代表「你按字节切到字符中间了」即可。想深入字符串内部机制,后续的切片与所有权章节会补齐拼图,到那时回头看这张表会更清楚每条错误对应的是哪一层规则。

延伸阅读

  • 下一节进入所有权与 Move 语义,String 是理解 move 最直观的例子,两章连读效果最好,多数关于字符串的疑惑会在那里解开。
  • 处理用户输入的复杂文本结构时,正则表达式精进 提供了从提取到验证的完整链路示例,里面的代码可以直接跑在本章的示例工程里。
  • 若要把字符串高效渲染进桌面界面,Tauri 桌面开发指南 讲了前后端文本传递的边界与编码处理。