字符串
约 845 字大约 3 分钟
2025-11-25
重要
严格来说 String 不在 std::collections 里,但它依旧是“字节的集合再加上一组面向文本的工具”,并且全程使用 UTF-8 编码
字符串是什么
先把名词对齐:核心语言只内建切片类型 str,通常以借用形态 &str 出现(字符串字面值也是 &'static str)。标准库另外提供了拥有所有权、可增长、可变的 String,底层同样是 UTF-8。无论是 String 还是 &str,都能放下任何合法的多语言文本,这也是后面所有行为的前提。
新建字符串
可以先造一个空壳,再填充内容,也可以直接从字面值起步。String::new() 返回一个空的可变字符串;"literal".to_string() 和 String::from("literal") 则直接把字面值拷贝进一个新的 String,两者效果相同,选自己觉得顺手的即可。
let mut s = String::new();
let s1 = "initial contents".to_string();
let s2 = String::from("initial contents");因为底层是 UTF-8,世界各地的问候语都可以无缝存进去:
let hello = String::from("こんにちは");
let greet = String::from("Здравствуйте");更新字符串
追加和拼接的接口和 Vec 很像。push_str(&str) 会把一段切片附加到末尾,不夺走参数的所有权;push(char) 追加单个字符:
let mut s = String::from("foo");
s.push_str("bar");
s.push('!');把多段字符串合并时有两条路线。+ 运算符底层调用 add(self, s: &str) -> String,它会拿走左侧 String 的所有权,右侧要求 &str(&String 会自动解引用),返回新的 String:
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 被移动,s2 仍可用如果要拼好几段,format! 更好读也不移动参数:
let s = format!("{s1}-{s2}-{s3}");为什么不能用索引
String 就是 Vec<u8> 的包装,而 UTF-8 中一个“字母”可能跨多个字节:拿 s[i] 时,是想要单个字节、Unicode 标量值,还是完整字形簇?答案并不唯一。更糟的是要定位字符边界必须从头扫描,做不到索引应有的 O(1)。与其返回让人误会的字节值,Rust 干脆禁止对 String 直接用索引。
切片与边界
如果确实要截取,必须用“字节范围 + 字符边界”两者同时满足。字节范围落在合法边界就能得到 &str,否则运行时 panic:
let hello = "Здравствуйте";
let head = &hello[0..4]; // OK,对应 "Зд"
// let bad = &hello[0..1]; // 非字符边界,运行时 panic遍历
操作时先挑清楚层级:要“字符”还是“原始字节”?
chars()迭代 Unicode 标量值for c in "Зд".chars() { println!("{c}"); }bytes()迭代底层字节for b in "Зд".bytes() { println!("{b}"); }- 如果要按“字形簇”处理(如天城文组合字符),标准库不直接提供,需要借助第三方 crate
小结
UTF-8 的灵活也带来复杂:同一“字母”可能是多字节,字符边界不等于字节边界。Rust 倾向把这些坑早早暴露出来,防止你无意中拿到错位的字节。写字符串代码时,先想清要操作的层级,再挑合适的 API:构造和追加用 push_str、push、format!,遍历用 chars 或 bytes,切片务必确认字符边界。这样既避免隐性错误,也能保持代码的可读性与性能。