String拥有一段可增长的UTF-8文本,str表示有效UTF-8字节组成的字符串切片,实际代码最常见的是借用形式&str。
len()和范围索引都按字节计算。把字节数当字符数,或在UTF-8码点中间切片,会得到错误结果或触发panic。
只读参数优先接收&str,需要持有或增长文本时才使用String;按语义明确选择字节、Unicode标量值或字素簇。
是什么,为什么存在
Rust的核心字符串类型是String和str。String拥有一段堆上的、可增长的UTF-8字节序列;str是动态大小的 字符串切片(string slice) 类型,表示一段有效的UTF-8字节。因为裸str的大小要到运行时才能确定,所以通常通过&str、Box<str>或Arc<str>等指针使用它。
最常见的组合是拥有型String与借用型&str。String负责分配、增长和释放缓冲区,&str只描述其中一段连续文本,不负责释放。这样的 借用(borrowing) 让函数读取调用方的文本,而不用先复制一份。
字符串字面量的类型也是&str,但它通常借用程序二进制中的静态数据。由String取得的&str则可能指向堆缓冲区。存储位置不是字符串类型本身的定义,所有权与UTF-8不变量才是关键。
你会在函数参数、解析器、日志消息、协议字段和用户输入中遇到这些类型。选择哪一个取决于API是否需要保留输入、是否要修改长度,以及输入是否保证是UTF-8,而不是取决于文本看起来像不像普通ASCII。
| 形式 | 是否拥有字节 | 能否增长 | 常见用途 |
|---|---|---|---|
String | 是 | 是 | 返回新文本、存入结构体、逐步构建内容 |
&str | 否 | 否 | 只读参数、字符串字面量、借用子串 |
&mut str | 否 | 否 | 在不改变字节长度的前提下就地修改 |
Box<str> | 是 | 否 | 拥有固定长度文本,不再需要额外容量 |
String与str都保证内容是有效UTF-8。文件名、C字符串和任意网络载荷未必满足这个条件,应分别考虑Path/OsStr、CStr或字节切片。强行做有损转换会改变数据,不能只为了让类型匹配而使用。
工作原理
所有权与借用视图
String管理缓冲区的指针、字节长度和容量。把一个String赋给另一个变量时,默认发生 移动语义(move semantics) :缓冲区所有权转移,旧变量不再可用。只有显式调用clone()才会复制其中的字节。
从String借用&str不会复制文本。借用保存数据地址与字节长度,可以覆盖整个字符串,也可以只覆盖一个合法子区间。共享借用还在使用时,Rust不允许可能使缓冲区重分配的可变操作,因此引用不会在push_str()之后悄悄悬空。
这张图描述的是概念关系,不是可依赖的外部ABI。String拥有堆缓冲区,&str只是指向有效UTF-8区间的视图。借用的生命周期保证视图不会比它引用的数据活得更久。
UTF-8与三种计数
UTF-8使用一到四个字节编码一个Unicode标量值。str::len()返回字节数,chars()迭代 Unicode标量值(Unicode scalar value) ,而用户眼中的一个字符可能是由多个标量值组成的 字素簇(grapheme cluster) 。这三种单位不能互换。
例如,é可以是单个标量值U+00E9,也可以由e与组合重音U+0301组成。两段文本看起来相同,字节和标量值数量却可能不同。Rust标准库不自动做Unicode规范化,也不提供完整的字素簇分段。
范围切片使用字节偏移,并要求起点与终点都位于UTF-8字符边界。&text[a..b]在边界不合法时会panic;text.get(a..b)会返回None。char_indices()同时给出合法字节偏移和对应标量值,适合把字符扫描结果交给后续切片操作。
分配与修改
String::new()创建空字符串,第一次增长前通常不需要分配。String::with_capacity(n)预留至少n个字节,适合已经知道大致输出大小的构建过程。容量是实现为后续增长保留的空间,不属于字符串内容,len()永远只报告当前有效字节。
push()追加一个char,push_str()追加一个&str。insert()、remove()、truncate()和replace_range()接收字节位置,其中涉及的位置必须是字符边界。安全API会维护UTF-8不变量,但不保证每个运行时位置都有效。
String + &str会移动左侧String,然后把右侧内容追加进去。它适合明确消费左值的一次拼接;如果之后还要使用所有输入,format!()或显式构建新的String更清楚。循环构建内容时,复用一个缓冲区通常也更容易表达所有权。
示例
借用输入,返回拥有型结果
这个函数只在调用期间读取姓名,所以参数使用&str。返回值需要在函数结束后继续存在,因此返回新建的String。
fn greeting(name: &str) -> String {
let mut message = String::with_capacity("Hello, ".len() + name.len());
message.push_str("Hello, ");
message.push_str(name);
message
}
fn main() {
let owned = String::from("Ferris");
let first = greeting(&owned);
let second = greeting("Rust");
println!("{first}");
println!("{second}");
println!("still owned: {owned}");
}Hello, Ferris
Hello, Rust
still owned: Ferrisgreeting(&owned)通过解引用强制转换把&String当作&str使用,字符串字面量则直接是&str。两次调用都只借用输入,所以最后仍能读取owned。预分配按字节计算,正好对应最终要追加的两段UTF-8文本。
如果函数要把姓名存进返回的结构体,接收String或impl Into<String>可能更合适。参数类型应表达所有权契约,而不是一律追求最泛化的签名。
查看UTF-8边界
这个例子同时打印字节数、标量值数量和每个标量值的起始字节偏移。两个get()调用只差一个起点,却分别成功和失败。
fn main() {
let text = "Aé中👋";
println!("bytes: {}", text.len());
println!("scalars: {}", text.chars().count());
for (byte_offset, scalar) in text.char_indices() {
println!("{byte_offset}: {scalar}");
}
println!("1..3: {:?}", text.get(1..3));
println!("2..3: {:?}", text.get(2..3));
let decomposed = "e\u{301}";
println!(
"decomposed bytes/scalars: {}/{}",
decomposed.len(),
decomposed.chars().count()
);
}bytes: 10
scalars: 4
0: A
1: é
3: 中
6: 👋
1..3: Some("é")
2..3: None
decomposed bytes/scalars: 3/2é占据字节区间1..3,所以该范围能产生&str。偏移2落在它的编码内部,get(2..3)因此返回None。组合形式e\u{301}有两个标量值和三个字节,但界面可能把它显示为一个字素簇。
如果业务要求“最多20个用户可见字符”,chars().take(20)仍不一定正确,因为它按标量值截断。此时需要明确采用Unicode字素簇分段规则,并决定是否先做规范化。
预估容量并在合法边界修改
路径构建器先计算所有部分和分隔符的总字节数,再复用一个缓冲区。后半段使用find()返回的匹配起点进行替换;成功匹配&str得到的位置一定落在字符边界上。
fn join_path(parts: &[&str]) -> String {
let separators = parts.len().saturating_sub(1);
let byte_len = parts.iter().map(|part| part.len()).sum::<usize>() + separators;
let mut path = String::with_capacity(byte_len);
for (index, part) in parts.iter().enumerate() {
if index > 0 {
path.push('/');
}
path.push_str(part);
}
path
}
fn main() {
let path = join_path(&["用户", "42", "settings"]);
println!("{path}");
println!("bytes: {}", path.len());
let mut status = String::from("状态: ready");
let start = status.find("ready").expect("marker is present");
status.replace_range(start.., "done");
println!("{status}");
}用户/42/settings
bytes: 18
状态: done容量计算使用len()是正确的,因为分配器关心字节,不关心显示字符数。saturating_sub(1)让空切片的分隔符数量保持为零。若构建器的输入规模无法预估,直接从String::new()开始也正确,只是增长时可能需要重新分配。
这里的replace_range(start.., "done")改变了字节长度,因此只能作用于String,不能作用于&mut str。替换完成后,String仍然保存有效UTF-8。
从输入中返回借用字段
解析器不需要改写字段,也不需要让结果脱离原始记录,所以它直接返回指向输入的两个&str。split_once()与trim()都能产生借用视图,不会为字段创建新的String。
fn parse_record(line: &str) -> Option<(&str, &str)> {
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
if key.is_empty() {
return None;
}
Some((key, value))
}
fn main() {
for line in ["color = blue", " retries=3 ", " = missing"] {
match parse_record(line) {
Some((key, value)) => println!("{key} -> {value}"),
None => println!("invalid: {line:?}"),
}
}
}color -> blue
retries -> 3
invalid: " = missing"返回类型中的两个引用由生命周期省略规则关联到唯一的输入引用。调用方持有字段期间,不能销毁或以冲突方式修改原始line。若字段要进入比输入活得更久的配置对象,就应在该所有权边界调用to_owned()。
这个实现只把缺少分隔符或空键视为无效,允许空值。真实解析器应在签名和测试中明确这些规则;Option只能表示成功或失败,需要错误原因时应改用Result。
陷阱
把字节数当作字符数
修复: 先写清业务单位。协议长度、容量和磁盘大小通常按字节;码点处理可用chars();光标移动、删除“一个字符”和长度限制通常需要字素簇分段。不要把一种计数改名后冒充另一种。
用任意范围切片
修复: 如果偏移来自不可信输入,使用get()处理None。如果范围来自文本扫描,保留char_indices()、find()或匹配API给出的字节边界。按标量值或字素簇截断时,应先迭代目标单位,再转换成字节范围。
为只读API强制取得所有权
修复: 普通只读参数使用&str。函数需要把文本存起来、交给其他所有者或返回独立值时再接收或创建String。impl AsRef<str>会增加泛型表面积,只有确实需要接受多种包装类型时才使用。
借用期间增长原字符串
修复: 调整操作顺序,让共享借用在修改前结束;或者先保存拥有型结果,再修改原字符串。不要用不安全指针绕过错误。编译器阻止的是可能悬空的视图,不是无意义的语法偏好。
把小写转换当作完整文本比较
修复: 先定义比较契约。ASCII协议字段可使用eq_ignore_ascii_case();面向自然语言的搜索、排序和去重需要选定规范化、大小写折叠与区域规则。标准库不会替你决定这些产品语义。
深入理解String的存储
UTF-8不变量
String可以理解为带有UTF-8有效性不变量的字节向量。安全构造函数会验证外部字节,安全修改方法只接受不会破坏编码的输入,所以String::as_str()可以直接给出&str。这个不变量使后续API无需在每次读取时重新验证整个缓冲区。
String::from_utf8(Vec<u8>)在字节无效时返回错误,并把原字节保留在错误值中供调用方处理。from_utf8_lossy()会用替换字符处理无效序列,返回Cow<str>;名称中的lossy是数据契约,不是显示选项。只有调用方已经证明字节有效时,才可能考虑不安全的免检查构造。
安全Rust依赖这个不变量。通过不安全代码制造无效String,再调用假定UTF-8有效的方法,会破坏这些方法的前置条件。省下一次验证之前,需要在同一边界提供可审查的有效性证明。
描述符与布局边界
从行为上看,String记录数据位置、当前字节长度和可用容量,文本字节位于其拥有的缓冲区中。移动String通常只转移缓冲区的所有权信息,不逐字节复制内容;规范保证的是移动后的所有权行为,而不是某个稳定的C ABI字段顺序。
&str是指向动态大小str的胖指针,携带数据地址与字节长度。它没有容量,因为借用不能增长所指区间。切出子串会产生新的地址与长度组合,但仍借用原来的存储。
不要把调试时看到的地址或size_of结果写成跨目标承诺。指针宽度、布局优化和ABI属于目标与实现边界。需要跨FFI传递字符串时,应使用双方明确约定的表示与生命周期,而不是直接暴露String。
容量不是长度
容量表示缓冲区无需重新分配即可容纳的字节数上限。reserve(additional)保证为当前长度之外的至少additional个字节留出空间,实际容量可以更大。with_capacity()同样只承诺下界,因此测试不应断言精确增长倍率。
预分配适合输出大小有可靠上界或容易求和的路径。猜一个很大的容量可能长期保留闲置内存,频繁调用shrink_to_fit()也可能让之后的增长重新分配。没有测量时,只描述分配行为,不声称固定的速度倍数。
try_reserve()让无法满足的容量请求通过Result风格的错误返回,而不是把分配失败只留给默认处理路径。处理外部长度字段时,还要先检查求和和乘法是否溢出;容量API不能修复上游的整数错误。
空字符串也是正常输入
空String的长度与容量可以都是零,空&str仍是有效切片。许多边界错误来自默认输入至少含一个字符,例如直接调用chars().next().unwrap(),而不是来自字符串类型本身。
API应明确空文本是有效值、缺失值还是错误。若业务需要区分“没有字段”和“字段存在但为空”,应使用Option<&str>或领域类型表达,不要用空字符串同时承担两种状态。
深入理解Unicode边界与成本
字节、标量值与字素簇
字节是存储和I/O单位。Rust的char表示Unicode标量值,不表示UTF-16码元,也不保证对应一个屏幕字符。字素簇由Unicode文本分段规则定义,更接近用户在光标移动或退格时感知的单位。
即使按字素簇计数,也无法直接推出终端列宽或排版宽度。组合标记、全角字符、emoji序列、字体和渲染环境都会影响显示。文本框长度限制、数据库字段上限和终端对齐应分别定义,不应共用一个含糊的character_count。
规范化解决的是规范等价序列的表示问题,分段解决的是边界问题,大小写折叠解决的是不区分大小写的匹配问题。它们是不同操作,执行顺序也可能影响结果。业务若需要其中任何一种,应把规则、Unicode版本和测试样例写进契约。
索引成本
len()读取已保存的字节长度,是常数时间操作。bytes()和chars()从头到尾迭代时与输入字节数线性相关;chars().count()必须扫描编码,不能从len()推出。查找第n个标量值也要从某个已知边界开始解码。
因此,在0..text.chars().count()循环里反复调用text.chars().nth(i)会重复从头扫描,长输入下可能呈二次增长。单次遍历可直接使用chars();同时需要字节位置时用char_indices();需要频繁随机访问时,应先选择并构建符合业务单位的索引结构。
字节搜索不一定是错误。查找ASCII分隔符、验证协议前缀或计算缓冲区大小时,按字节通常正合适。算法单位必须与业务语义一致,而且只能在合法边界上重新解释为str。
深入理解API边界
何时返回借用
返回&str表示结果是输入或其他现有存储的一部分,因此返回生命周期必须与来源相连。strip_prefix()、split_once()和trim()都能返回借用视图,因为它们不需要创造新字节。这样的API没有结果分配,但调用方不能让结果活得比来源更久。
大小写转换、替换或格式化通常会创造新字节,所以自然返回String。试图返回对函数内部临时String的引用不会延长临时值寿命,生命周期标注也无法做到这一点。函数创造并交付文本时,就应返回拥有型结果。
结构体字段使用String最直接,因为结构体独立拥有数据。借用字段&'a str适合明确的视图类型,但它把结构体寿命绑定到外部存储。Cow<'a, str>可以表达“未修改时借用,修改后拥有”,代价是API多了一个需要调用方理解的状态。
常见转换的成本与失败方式并不相同:
| 转换 | 结果 | 分配或检查 |
|---|---|---|
String::from(text) | String | 复制text的UTF-8字节并分配 |
owned.as_str() | &str | 借用,不分配 |
String::from_utf8(bytes) | Result<String, FromUtf8Error> | 复用向量缓冲区并验证UTF-8 |
str::from_utf8(bytes) | Result<&str, Utf8Error> | 借用字节并验证UTF-8 |
String::from_utf8_lossy(bytes) | Cow<str> | 有效时可借用,无效时为替换内容分配 |
把转换放在数据进入系统的边界,内部代码就能依赖String/str的UTF-8不变量。若内部仍需要原始字节与解码文本两种形式,应分别命名并保存,不要来回做有损转换。
UTF-8之外
操作系统路径不保证可转成str。路径逻辑应保留Path/OsStr,只有在显示或协议边界明确要求Unicode时才调用to_str()或带有明确损失语义的转换。文件名无法解码不等于文件不存在。
C字符串以空字节终止,并且内部不能含有空字节;它的编码也不自动等于UTF-8。与C交互时,CString/CStr解决终止符和指针表示问题,to_str()才负责验证UTF-8。裸指针的有效性与生命周期仍属于FFI安全契约。
任意二进制载荷使用Vec<u8>或&[u8]。只有在边界验证成功后才转换成String或&str。这样,“解码失败”会保持为可处理的输入错误,而不会被隐藏成替换字符或更晚出现的字符串异常。
4个问题 · 1道输出预测题 · 1道找错题