写 Rust 也写了几年,每次碰到刚入坑的朋友,最先卡住他们的往往不是生命周期,而是字符串。平时写惯了 Python、Java,下意识会觉得"字符串就是一种类型",到了 Rust 这里突然冒出 String 和 &str 两种,底层又是 UTF-8,稍不留神还会 panic。这篇文章就把 Rust 的字符串和切片这两个话题串起来讲清楚,包括它们的内存模型、日常操作、边界坑,以及我在实际项目里的取舍习惯。如果你正准备学 Rust,或者已经在写 Rust 但经常被编译器教育,这篇文章应该能帮你省下不少踩坑时间。
1. 先搞清楚:Rust 的字符串到底有几种
1.1 三种常见的字符串类型
Rust 里最常见的字符串类型大致可以分为三类:String、&str和&mut str。虽然 Rust 标准库里还有Cow<str>、Box<str>这类变体,但日常开发里遇到最多的就是前两者。
- String:可变、拥有所有权的 UTF-8 字符串,类似 C++ 的
std::string或者 Java 的String,区别在于 Rust 的 String 是可变的。 - &str:字符串切片,是对一段 UTF-8 字节序列的借用,通常由"字符串字面量"或从 String 中切出来得到,全称是
str的引用。 - &mut str:可变的字符串切片,使用频率极低,一般只有在需要原地修改原始字符串的一部分时才会用到。
字符串字面量本身有个很特殊的类型:&'static str。比如let greeting = "hello";这里的greeting就是&'static str,生命周期是'static,因为它直接编译进二进制文件的只读区,程序运行期间始终有效。很多初学者会困惑:为什么我用了一个字符串,它的类型却带个引用符号?其实"hello"本质上是一段编译期写入可执行文件的数据,变量只是对这段数据的引用,不需要在堆上再分配一块内存。
1.2 为什么 Rust 要区分两种字符串
把字符串拆成两种,背后是所有权和可变性这两个核心设计理念。
先说可变性。String 可以在原地扩容、修改,比如push_str、insert_str。&str 默认不可变,不能通过它修改字符串内容。这是一个很自然的设计:只读借用不允许修改底层数据,避免多个视图同时写入造成数据竞争。
再说所有权。String 拥有底层缓冲区,离开作用域会自动释放堆内存。&str 只是借用的视图,生命周期结束后不会释放内存,它指向的那块内存由原来的拥有者负责释放。借用检查器会追踪 &str 的生命周期,确保它不会比原字符串活得更久。
然后是性能。&str 是零拷贝的"视图",多个切片可以共享同一块堆内存,不产生复制成本。比如从一个很长的 JSON 文本中切出一个字段名,你不需要把字段名单独复制一份新字符串,直接用 &str 指向原文本的某一段即可。这个特性在处理大文本、解析协议时非常有价值。
我自己习惯用一个类比来理解:String 是房子的主人,&str 是拿着钥匙来参观的租客。租客可以看房子,但不能随意改动房屋结构;主人则可以装修、扩建。Rust 的所有权系统确保所有参观者在房子拆迁之前必须离开,谁也不能逗留。
2. 从内存布局看 String 和 &str
2.1 String 是三段式的胖指针
String 在内存里其实是一个结构体,包含三个字段:
ptr:指向堆上 UTF-8 字节序列的起始地址len:当前字节长度capacity:已分配的容量,即在不重新分配的前提下最多能存多少字节
在 64 位平台上,String 本身占 24 字节(8 字节 ptr + 8 字节 len + 8 字节 capacity),而真正的字符数据存在堆上。capacity - len就是未来的扩容空间。当 String 追加内容超过容量时,会重新分配一块更大的堆内存,把旧数据拷过去,然后更新指针和容量。这个扩容过程有一定开销,所以如果需要反复追加大量内容,可以先with_capacity预分配空间。
有一点必须强调:String 复制很贵,借用很便宜。因为复制 String 意味着深拷贝整段堆内存,而借用 &str 只是复制 16 字节的胖指针。哪个更划算,一目了然。这也是为什么 Rust 代码里到处是借用而不是复制。
2.2 &str 是两段式的胖指针
&str 由ptr和len两个字段组成,64 位平台上占 16 字节。它没有 capacity,因为它只是借用,不负责管理内存的分配和释放。
这里有个容易忽略的细节:&str 的 len 表示 UTF-8 字节数,不是字符个数。比如"你好"这个字符串,字符只有 2 个,但len()返回 6,因为每个汉字在 UTF-8 编码里占 3 字节。很多新手在这里栽过跟头,用硬编码的下标去切片,结果遇到非 ASCII 文本直接 panic。
下面用一个表格对比一下:
| 维度 | String | &str |
|---|---|---|
| 可变性 | 可变 | 不可变(&mut str 除外) |
| 是否拥有所有权 | 拥有堆缓冲区 | 借用视图 |
| 组成部分 | ptr + len + capacity | ptr + len |
| 栈上大小 | 24 字节(64 位平台) | 16 字节(64 位平台) |
| 常见来源 | 运行时构建、拼接、读取输入 | 字面量、字符串切片 |
| 典型场景 | 需要修改的文本、容器 key | 函数参数、只读文本处理 |
2.3 切片,本质就是"指哪看哪"的零拷贝视图
再说切片(slice)。切片本质上是对一段连续内存的借用,数组有切片、Vec 有切片、字符串也有切片。核心语法如下:
let arr = [1, 2, 3, 4, 5]; let slice = &arr[1..3]; // 类型是 &[i32],内容是 [2, 3] let v = vec![1, 2, 3, 4, 5]; let slice = &v[..2]; // 内容是 [1, 2]注意切片类型是&[T]或者&mut [T],它不是拥有所有权的容器。初学者常把Vec<T>和&[T]混为一谈,其实前者是可变长的堆数组,后者只是不可变的借用视图。如果一个函数只需要读取数据,传&[T]就足够了,没必要把整个 Vec 传进去。调用方既可以把 Vec 的切片传给它,也可以把数组的切片传给它,灵活性高很多。
切片在实际项目中非常常用。解析网络报文时,可以把一个大的字节缓冲区切成一个个字段区域来读取;做图像处理时,可以把像素矩阵按行切片后分发给多个线程。Rust 的切片在运行时带长度信息,不会像 C 语言裸指针那样出现"不知道边界在哪里"的问题。
3. 字符串切片:最大的坑在 UTF-8 边界
3.1 为什么不能用 str[i]
到了字符串切片这里,情况变得特殊。很多人第一次写let c = s[0]会被编译器直接拒绝,提示字符串不能按索引访问。在这里 Rust 的态度非常坚决。
原因是 Rust 字符串以 UTF-8 存储,一个字符可能占 1 到 4 个字节。如果直接按字节索引,无法保证拿到的下标恰好落在字符边界上。比如"你好"的字节内容是[228, 189, 160, 229, 165, 189],你取s[0]会得到 228,但这个字节单独拿出来没有任何字符意义,它只是"你"的其中一个字节。为了彻底避免生产出这种无效数据,Rust 索性禁止用整数索引字符串。
相比之下,C 语言里字符串就是一个 char 数组,想怎么下标访问都行,但开发者必须自己保证边界,否则轻则乱码,重则越界崩溃。Rust 选择把错误挡在编译期和 panic 信息里,代价是新手多记一个概念,收益是整个文本处理过程更稳定。
3.2 边界 panic 是怎么触发的
字符串切片有两种写法:
let sub = &s[起始字节..结束字节]; let sub = &s[..结束字节]; let sub = &s[起始字节..];只要切片落在非字符边界,或者越界,运行时会直接 panic,典型的报错信息是:
byte index 1 is not a char boundary; it is inside '你' (bytes 0..3)这个报错在写中文处理、表情符号处理时特别常见。比如s = "你好,世界",如果你想取前两个字符"你好",正确切片边界是&s[..6],而不是&s[..2]。因为"你"占 3 字节、"好"占 3 字节,前两个字符一共 6 字节,下标 2 落在"你"的第二个字节上,自然 panic。
更隐蔽的是 emoji。"ha😂ha"里的 "😂" 占 4 字节,按字符数遍历后试图用下标切片,很容易把字节数算错。我见过不少线上工具处理用户昵称时突然 panic,查到最后都是某个 emoji 在作怪。所以处理用户输入时,最好先用字符边界函数做截断,而不是用字节下标硬切。
3.3 安全切到字符边界:char_indices 与手动查找
想要按字符位置安全切片,最实用的办法是用char_indices。
let s = "你好,世界"; let positions: Vec<(usize, char)> = s.char_indices().collect(); if let Some((byte_idx, _)) = positions.get(2) { let first_two_chars = &s[..*byte_idx]; println!("{}", first_two_chars); // 输出 "你好" }char_indices返回每个字符的起始字节偏移和字符本身。想切前 N 个字符时,先找到第 N 个字符的起始字节,再用它作为切片边界。这个思路很通用,也容易封装成工具函数。
还有一个常见写法是s.chars().take(n).collect::<String>(),同样能取前 N 个字符,但会生成一份新的堆字符串。如果只是临时查看子串,用切片方式更快;如果目标是独立的所有权字符串,collect 更方便。两种各有场景,关键是心里明白它们一个零拷贝一个必须分配。
我在处理文本时习惯先问自己一句:这一步要按字符做,还是按字节做?比如取行前缀、计算显示宽度,通常要按字符;做协议解析、文件 IO 写入,按字节反而更直接。混淆这两者,是很多文本 bug 的根源。
4. 日常操作:拼接、查找、遍历与转换
4.1 拼接和追加的正确姿势
String 支持多种拼接方式,各有适用场景。
let mut s = String::from("hello"); s.push_str(", world"); // 追加 &str,效率最高 s.push('!'); // 追加单个字符 let s1 = String::from("hello"); let s2 = String::from("world"); let s3 = s1 + " " + &s2; // 注意:s1 的所有权被 move,s2 可以继续用 let name = "rust"; let greeting = format!("hello {name}, welcome"); // 最灵活+运算符的签名是fn add(self, rhs: &str) -> String,左侧 String 会被消耗掉。我第一次看到这个签名时愣了一下,后来才意识到这是有意设计:直接把左侧字符串的缓冲区拿来扩容,右侧内容追加进去,避免一次额外的堆分配。代价是左侧变量后续不能再使用,这在语义上很清晰。
format!适合把多个变量拼成复杂字符串,底层本质也是扩容追加,但写起来清晰得多。需要留意的点是,在性能敏感的循环里频繁使用format!会不断产生新字符串,建议改用write!写入缓冲区,或者用多个push_str手动拼接。
4.2 查找、分割、替换与 trim
字符串查找最常用的是find、rfind、contains。
let s = "hello rust"; if let Some(pos) = s.find("rust") { println!("found at {pos}"); } let contains = s.contains("rust");分割文本用split、split_whitespace、lines。
let s = "a,b,c"; for part in s.split(',') { println!("{part}"); } let text = "word1 word2\nword3 word4"; for w in text.split_whitespace() { println!("{w}"); } let lines = "line1\nline2"; for l in lines.lines() { println!("{l}"); }split返回的迭代器是懒加载的,不会一次性生成整份 Vec,只有迭代到某个元素时才去定位边界。如果你确实需要收集结果,可以.collect::<Vec<_>>(),此时元素类型是&str,生命周期和原字符串绑定。如果想过滤掉空字符串,用s.split(',').filter(|x| !x.is_empty()),在解析 CSV 时几乎必用。
替换方面,replace是全量替换:
let s = "a-b-c"; let replaced = s.replace("-", "+");trim()系列用于去掉首尾空白字符,包括空格、tab、换行。注意trim()只去开头结尾的空白,不会动字符串内部的空白。如果只想去掉首尾的特定字符,可以用trim_matches,比如s.trim_matches(|c| c == '/')。
4.3 遍历:chars、bytes 与 char_indices
字符串遍历有三种常用姿势,搞混了很容易出错:
let s = "你好 Rust"; for c in s.chars() { println!("char: {c}"); } for b in s.bytes() { println!("byte: {b:02x}"); } for (idx, c) in s.char_indices() { println!("byte offset: {idx}, char: {c}"); }chars()按字符解码,bytes()直接给出 u8,char_indices()同时给字节偏移和字符。如果只是判断某个字符存不存在,用contains更省事。如果要统计字符个数,用s.chars().count(),而不是s.len()。这个差别在中英文混合文本里非常明显,也是作业和面试题里经常埋坑的地方。
4.4 类型互转:String、&str 与数字
实际开发中经常要在 String 和 &str 之间切换,记住几个常规操作:
let s = String::from("hello"); // String 转 &str let slice: &str = &s; let slice2: &str = s.as_str(); // &str 转 String let s2 = slice.to_string(); let s3 = String::from(slice);&s依赖 Deref 自动解引用,在函数传参时很常见。to_string()与String::from()等价,底层都会分配新堆内存并拷贝内容。
除了字符串之间的互转,字符串和数字互转也是高频需求:
let num = "42".parse::<i32>().unwrap(); let s = 42.to_string();parse需要明确目标类型,常见做法是"42".parse().expect("invalid number")靠变量类型推断。解析时要注意parse对空格、正负号、前导零的处理方式和业务预期可能不完全一致,比如"+42"能解析成功,但"42 "会失败。
在函数签名这块,我的建议是:只读取字符串内容的参数尽量用 &str,需要拥有并修改的才用 String。这样调用方既可以传 String 也可以传 &str,灵活性最大。如果参数写成 String,调用方只有一个 &str 时就得先 to_string,凭空多一次分配。
5. 实战中高频问题与排查方法
5.1 字节边界 panic 的完整排查思路
这是 Rust 字符串切片最典型的报错:byte index is not a char boundary。触发场景通常是"按字符位置截断"但代码里用了字节下标。
let s = "你好"; let sub = &s[..1]; // panic排查时可以按三步走:第一步看报错信息里提示的字符范围,比如inside '你' (bytes 0..3),基本能定位是哪个字符;第二步检查代码里是否存在硬编码的字节下标,或从len()、position等地方拿到的值是字节偏移而不是字符索引;第三步改成用char_indices获取边界,或直接用s.chars().take(n).collect::<String>()。
如果项目里频繁做截断,我建议封装一个工具函数,统一按字符数截断,返回 &str。这样所有调用方都不用关心字节细节。
5.2 字符串比较与 HashMap 的 key 选择
Rust 的String和&str都实现了PartialEq,直接比较即可:
assert_eq!(String::from("a"), "a");这里比较的是字节内容,不是指针地址。这和 C 语言里字符串常量比较指针的行为完全不同,反而更接近直觉。如果两个字符串内容相同但内存地址不同,Rust 判断它们是相等的,这在日常业务里基本是期望行为。
另一个相关场景是作为 HashMap 的 key。选择HashMap<String, ...>还是HashMap<&str, ...>,主要看 key 的生命周期。如果 key 来自外部输入并且需要独立保存,用 String;如果 key 是从一个生命周期足够长的结构体里借用来的,用 &str 更省内存。但要留个心眼:如果用 &str 做 key,这个借用关系会让 HashMap 和原字符串之间产生隐性耦合,原字符串一变,整个 map 的含义就变了。所以除非你非常确定原字符串不变,否则新项目建议默认用 String 做 key。
5.3 函数参数写 String 还是 &str
这个问题几乎每场代码评审都会被问到。最粗暴但实用的准则:
- 参数只需要读取内容:用
&str - 参数需要修改原字符串内容:用
&mut String(很少见) - 参数需要拥有这个字符串并保存:用
String
我见过一个反面案例:一个日志函数接收String,每次调用都先to_string()再传进去,结果日志量一大,堆分配数量直接拖慢了服务。改成&str后,调用方有的传字面量、有的传&s,零分配,改动很小收益很大。
也有适合传 String 的场景:函数内部要把字符串存进一个结构体再返回出去,此时接收String更合适,因为&str的生命周期会很难管理。这种情况下直接转移所有权,反而是最简单、最高效的做法。
5.4 FFI 场景下的 CString 注意事项
和 C 语言互操作时,Rust 字符串要转换成以\0结尾的字节序列,标准库提供了CString。
use std::ffi::CString; let c_string = CString::new("hello").expect("contains null byte"); let ptr = c_string.as_ptr();CString::new不接受内部包含\0的字符串,因为 C 字符串遇到\0就结束了,内部有\0会导致语义混乱,所以它返回 Result。在使用 CString 时,我要特别提醒一点:用完指针后,必须确保 c_string 本身还活着,不能让它提前 drop,否则指针就悬垂了。Rust 编译器不会自动帮你校验裸指针的生命周期,这属于 unsafe 代码的自我修养。
5.5 split 结果的 &str 生命周期陷阱
split返回的迭代器元素是&str,它借用自原字符串。很多人试图在函数里把 split 结果保存为 Vec 并返回,这时候生命周期就开始纠缠了。
fn parse(s: &str) -> Vec<&str> { s.split(',').collect() }这个函数可以编译,因为返回的 &str 和参数 s 共享生命周期。但如果你想返回一个独立于原字符串的列表,就必须转成Vec<String>,否则原字符串被 drop 后,那些 &str 就成了悬垂引用。
我在实际项目里看到一个典型错误:解析完文本后,直接把 split 结果存进一个全局结构体,原字符串已经从函数返回、被释放了,结果程序某个分支里出现乱码。排查了很久才发现是生命周期的问题。建议所有涉及 split 后再保存的场景,先想清楚返回值会不会逃逸出原字符串的生命周期。
6. 我后来才想明白的几个操作习惯
6.1 优先用 &str 做参数,必要时再转 String
这是我改掉最多代码风格的一次。早期写库函数时,参数一律 String 起步,因为直觉上"字符串类型传进来最省事"。结果调用方到处都是to_string(),不仅难看,还凭空多了一堆堆分配。后来统一改成&str,调用方直接&s或者传字面量,灵活度立刻上来了。借用检查器还帮我揪出了几个原本会悄悄修改源字符串的问题,这就是设计带来的额外收益。
6.2 切片和生命周期是天然的伙伴
切片的零拷贝特性让它在高性能场景占尽优势,前提是你得想清楚"这个切片是从哪块内存切出来的"。数组切片的生命周期通常很好推断,字符串切片因为涉及 UTF-8 字节边界,反而容易出问题。我建议在项目里定一个规范:凡是按字符处理的字符串操作,必须显式用 char_indices 或 chars(),不写硬编码字节下标。这样注释都省了,代码即文档。
6.3 一个顺手的小工具:按行读文本时拿行号
最后分享一个小工具函数。处理配置文件时经常要"按行读、还要知道行号",直接用lines()拿不到行号,我一般这样封装:
fn enumerate_lines(text: &str) -> impl Iterator<Item = (usize, &str)> { text.lines().enumerate().map(|(idx, line)| (idx + 1, line)) }返回的迭代器每一项是(行号, 行内容),行号从 1 开始,错误提示里可以直接写出"第 3 行有问题"。这个实现很短,但用起来特别顺手。类似的思路也用在字节切片遍历上,比如把一个大数组按块切分处理时,用arr.chunks(16)一次处理一批字节,性能很好且边界清晰。
写到这里,核心其实就三句话:String 是拥有数据的可变字符串,&str 是零拷贝的借用视图,切片时永远记住 UTF-8 的字节边界。真把这三条刻进脑子里,Rust 字符串相关的坑就少了一大半。我自己在项目里踩过的所有字符串问题,最后追根溯源都能落到这三个点上。现在设计新的文本处理模块,我都会先定义清楚每个函数的参数到底是 String 还是 &str,再开始写实现。这个习惯帮我省了很多重构时间。