String 字符串深度
本教程共 78 篇 · 第 37 篇 · 更新于 2026-08-08 · 约 11 分钟阅读
本节目标:分清
String和&str两种字符串类型,理解为何不能按下标取字符,并掌握增删改连与遍历字符串的正确姿势。
别的语言里字符串往往是送分题,Rust 里却是高频踩坑点。先来看一段看着没毛病的代码:
fn main() {
let my_name = "Pascal";
greet(my_name);
}
fn greet(name: String) {
println!("Hello, {name}!");
}
编译却报错:期待 String,却传了 &str。怎么字符串还能分出两种类型?这正是本章要解开的结。
1-1 两种字符串类型
Rust 在语言层面只有一种字符串:str,而且它几乎总以引用的形式 &str 出现,叫”字符串切片”。标准库里最常用的另一种是 String:
&str:字符串切片,通常指向某段已存在的、不可变的 UTF-8 文本;String:在堆上、可增长、可修改、拥有所有权的 UTF-8 字符串。
两者都是 UTF-8 编码。说的”字符串”,大多时候就是指这两个家伙。还有 OsString、CString 等变种,名字带 String 的有所有权,带 Str 的被借用,先不用管。
Note字符串字面量
"hello"的类型其实是&str,不是String。它直接硬编码进可执行文件,所以不可变——因为&str是个不可变引用。这也是为什么上面的greet收String却接不住字面量。
1-2 两者之间怎么转
从 &str 造 String 有两种常见写法:
let s = String::from("hello, world");
let s = "hello, world".to_string();
反过来,把 String 当 &str 用,取它的引用即可:
fn say_hello(s: &str) {
println!("{s}");
}
fn main() {
let s = String::from("hello, world!");
say_hello(&s); // &String 自动转成 &str
say_hello(&s[..]); // 切片也行
say_hello(s.as_str()); // 显式拿切片
}
Tip写函数时,参数尽量用
&str而不是String。这样String和字面量都能传进去,调用方更省心。等学到 trait 你会更理解这背后的Deref强制转换。
1-3 为什么不能按下标取字符
很多语言里 s[0] 取第一个字符天经地义,Rust 里却直接报错:
let s1 = String::from("hello");
let h = s1[0]; // 报错:String 不能按整数索引
根子在 UTF-8。字符串底层是一串 u8 字节。英文 “hello” 占 5 字节,但 "中国人" 占 9 字节——因为每个常用汉字在 UTF-8 里占 3 字节。若允许 s[0],你拿到的只是第一个字节 228,根本拼不出完整的”中”字,这个返回值毫无意义。
把”中国人”拆开看更直观:
let s = "中国人";
for (i, b) in s.bytes().enumerate() {
println!("字节 {i} = {b}");
}
// 字节 0 = 228,字节 1 = 184,字节 2 = 173 -> 这三个合起来才是「中」
// 字节 3 = 229,字节 4 = 155,字节 5 = 189 -> 这是「国」
// 字节 6..=8 才是「人」
你看,第一个”字符”其实是 3 个字节拼的。所谓”第 0 个字符”在字节层面根本不是一个整数索引能表达的东西。
另一个原因是性能预期:大家都默认索引是 O(1) 常数时间,但 UTF-8 下 Rust 可能得从 0 遍历才能定位一个合法字符,保证不了 O(1)。所以干脆不让你用下标索引字符串。
1-4 切片落在字符边界才安全
字符串切片用 [开始..结束],这是个左闭右开区间:
let s = String::from("hello world");
let hello = &s[0..5]; // "hello"
let world = &s[6..11]; // "world"
从 0 开始可省:&s[..2];到末尾可省:&s[4..];取全部:&s[..]。
Warning切片按字节切,必须落在字符边界上。对
"中国人"写&s[0..2]会直接 panic,因为前 2 字节只切到”中”字的一半。汉字占 3 字节,要切就&s[0..3]。凡是对含中文等非 ASCII 字符串做字节切片,务必当心。
1-5 改:追加、插入、替换、删除
String 是可变的,前提是用 mut。追加用 push(加单个 char)和 push_str(加字符串):
let mut s = String::from("Hello ");
s.push_str("rust");
s.push('!');
println!("{s}"); // Hello rust!
插入用 insert(索引, 字符) 和 insert_str(索引, 字符串),索引从 0 起,越界就报错。替换有三个:replace 替换全部并返回新串、replacen 限次数替换、replace_range 直接在原串上改指定区间。删除有 pop(删末尾字符,返回 Option)、remove(索引)、truncate(到某位置)、clear(清空)。
let mut s = String::from("I like rust!");
s.replace_range(7..8, "R");
println!("{s}"); // I like Rust!
1-6 连:+ 与 format!
用 + 连接时,右边必须是切片引用,而且左边的 String 会被”消费”掉(所有权转移),之后不能再使用它:
let s1 = String::from("hello,");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 被移动,s3 是新的 String
// println!("{s1}"); // 报错,s1 已失效
多个串连起来就是 s1 + "-" + &s2 + "-" + &s3,每次 String + &str 都返回新 String,一路串下去。
更推荐 format!,它不夺取所有权,用法和 println! 一样:
let s1 = "hello";
let s2 = String::from("rust");
let s = format!("{s1} {s2}!");
println!("{s}"); // hello rust!
Tip多段拼字符串,优先
format!。既不会吃掉你的变量,格式也最灵活,可读性也好。
1-7 转义与原始字符串
普通字符串用 \ 转义:\n 换行、\t 制表、\u{211D} 输出 Unicode 字符。如果你不想转义,用原始字符串 r"...",里面的 \ 原样保留;字符串里含双引号,用 r#"..."#;含 # 号就多加 #:
let raw = r#"他说:"这里没有转义!\x3F"#;
println!("{raw}");
1-8 正确遍历 UTF-8 字符串
想要按”字符”遍历,用 chars 方法,它会把每个 Unicode 字符正确拆开:
for c in "中国人".chars() {
println!("{c}");
}
// 中
// 国
// 人
想要底层字节,用 bytes:
for b in "中国人".bytes() {
println!("{b}");
}
Note取”第几个字符”或”某段子串”,标准库没有直接好用的 API,因为变长 UTF-8 下这很复杂。真需要按字符位置截取,去 crates.io 找
utf8_slice这类库。
1-9 为什么 String 可变而字面量不行
字面量在编译期内容就定了,文本被写死进可执行文件,所以天生不可变、访问极快。但程序运行时动态产生的文本,长度编译期未知,没法塞进这种固定区域。
String 为此在堆上申请一块运行期才知道大小的内存来装内容。String::from 负责申请,变量离开作用域时 Rust 自动调用 drop 把内存还回去——既有动态增长的灵活,又不用你手动 free,也不靠垃圾回收拖慢性能。这套”作用域结束自动释放”的机制,正是 Rust 内存安全的核心之一。
1-10 函数里该用 String 还是 &str
写函数时,字符串参数用 &str 还是 String 是个高频问题。经验法则:如果函数只是”读”字符串、不打算拥有它,参数就用 &str。这样调用方既能传 &String(靠 Deref 自动转成 &str),也能传字符串字面量 &'static str,最灵活。
只有在函数需要”接管”这段内容、或者要修改并长久保存它时,才用 String。返回值时同理:返回 String 表示”我新建了一段属于你的内容”;若要返回”借用的视图”,则要带上生命周期标注 &'a str。
fn greet(name: &str) -> String {
let mut s = String::from("你好,");
s.push_str(name); // 这里需要拥有,所以用 String
s
}
Tip记住
String实现了Deref<Target=str>,所以凡是接收&str的地方,几乎都能直接喂给它。优先用&str当参数,能少写很多.clone()。
1-11 String 的内存与小技巧
String 在内存里是三部分:一个指向堆上字节的指针、当前长度 len、已分配容量 capacity。正因为它把数据放在堆上,才能随时增长。&str 则只是”别人内存上的一段视图”,自己不拥有数据,所以轻量、不可变。
创建 String 常用 String::from("...") 或 "...".to_string(),两者等价。trim()、replace()、to_uppercase() 这类方法都会返回”新的 String”,不会改动原来的——因为 &str 内容不可变,只能产出新的。
比较两个字符串内容用 == 即可,Rust 会逐字节比较,哪怕一个是 String 一个是 &str(靠 Deref 对齐)。不要拿指针去比,那比的是”是不是同一块内存”,而不是”内容一不一样”。
Note需要”原地改大小写”时记得接住返回值:
let s = s.to_uppercase();,原s不会被修改,因为to_uppercase返回的是全新分配。
1-12 常用查询、查找与切割
除了增删改连,日常还常要”查”和”切”。这几个方法值得先混个脸熟:
let s = "hello, rust";
println!("{}", s.contains("rust")); // true:是否包含子串
println!("{}", s.starts_with("hello")); // true:是否以...开头
println!("{}", s.ends_with("rust")); // true:是否以...结尾
println!("{:?}", s.find("rust")); // Some(7):子串起始字节位置
split 按分隔符切成迭代器,配合 for 或 collect 用:
let s = "a,b,c";
for part in s.split(',') {
println!("{part}"); // 依次 a / b / c
}
let nums: Vec<&str> = "1-2-3".split('-').collect();
assert_eq!(nums, vec!["1", "2", "3"]);
处理多行文本用 lines,去除首尾空白用 trim(还有 trim_start / trim_end)。注意这些方法大多返回”新的视图或新串”,不会改动原 &str/String。
Note想拿到”第几个字符”的位置,用
char_indices拿到(字节偏移, 字符)的配对;想按字符序号遍历,用chars().enumerate()。它们帮你绕开”字节 vs 字符”的坑。
1-13 小结
- 语言层只有
str,常用的是String(有所有权、可变)和&str(切片、不可变); - 字面量是
&str;String::from/to_string造String,取引用得到&str; - 字符串不能按下标索引,因为 UTF-8 变长;切片要落在字符边界,中文易踩坑;
- 改串用
push/insert/replace/remove等,记得mut; - 拼接用
format!最稳,不夺所有权; - 遍历字符用
chars,遍历字节用bytes。
下一章看 HashMap,用键值对存数据。