blog.dopana

Back

Rust 的字符串处理机制常常让初学者感到困惑。为什么 Rust 同时存在 String&str?为什么不能通过 s[0] 像数组那样直接下标索引?理解 Rust 字符串的底层架构,是掌握这门语言内存安全性与国际化 UTF-8 正确性的关键。

像给 10 岁孩子解释:活页笔记本 vs 透明书签#

把处理文本的两种方式想象为两种文具:

  1. String (可自由增删页面的活页笔记本): 放在你书桌上的实体笔记本(堆内存 Heap)。你可以随时往里面写新句子、擦除段落、添加空白页(mut),或者把整本笔记本转赠给他人(所有权转移)。
  2. &str (透明书签标尺): 盖在图书馆印刷书或他人笔记本某一行文字上方的透明塑料书签。你可以直接阅读底下的内容,而无需复印纸张或拥有这本书。
graph TD
    subgraph HeapMemory ["堆内存分配"]
        StringBuf["String 缓冲区 (堆上的 UTF-8 字节流)"]
    end
    subgraph StackVariables ["栈上的局部变量"]
        StringObj["String 结构体<br/>[ ptr | len | capacity ]"] -->|拥有堆缓冲区数据| StringBuf
        StrSlice["&str 字符串切片<br/>[ ptr | len ]"] -->|借用视角只读访问| StringBuf
        LiteralStr["静态字符串 static str<br/>[ ptr | len ]"] -->|指向二进制段只读字符串| ReadOnlyData["只读数据段 (.rodata)"]
    end

String&str 的核心区别#

属性String&str (字符串切片)
所有权拥有底层的堆内存缓冲区借用字符串数据的只读视图
可变性可变,通过 mut 自由伸缩容量不变,固定长度,只读视图
内存分布数据存储在堆上(栈存 ptr, len, cap)栈上的胖指针(ptr + len)
常见创建方式String::new(), String::from("..."), s.to_string()"hello" (字面量), &s[0..4], &s

字符串的创建与更新#

[!NOTE] s1 + &s2 中的 + 运算符调用了 fn add(self, s: &str) -> String。它获取 s1 的所有权,把 s2 的内容复制追加到末尾,并返回新的缓冲区。

为什么 Rust 禁止通过整数下标直接索引 (s[0])?#

在很多编程语言中,s[0] 会返回首个字符。但在 Rust 中,这会导致编译报错:

let s = String::from("hello");
// let h = s[0]; // ❌ 编译报错: the type `String` cannot be indexed by `{integer}`
rust

UTF-8 编码原理#

Rust 中的字符串始终是合法的 UTF-8 字节序列。在 UTF-8 中,一个 Unicode 字符占用的字节长度为 1 到 4 字节不等:

graph LR
    subgraph English ["英文: 'Hello' (每个字符 1 字节)"]
        H["'H' [0x48]"] --- E["'e' [0x65]"] --- L1["'l' [0x6C]"] --- L2["'l' [0x6C]"] --- O["'o' [0x6F]"]
    end
    subgraph Chinese ["中文: '你好' (每个字符 3 字节)"]
        C1["'你' [0xE4, 0xBD, 0xA0]"] --- C2["'好' [0xE5, 0xA5, 0xBD]"]
    end
    subgraph Emoji ["Emoji: '🦀' (4 字节)"]
        Crab["'🦀' [0xF0, 0x9F, 0xA6, 0x80]"]
    end

如果允许 s[0],获取中文字符 '你' 的首个字节只会得到不完整的 0xE4,破坏程序逻辑。此外,查找第 NN 个字符需要从头扫描多字节字符(O(N)O(N) 复杂度),这违背了 Rust 零开销抽象(Zero-cost Abstraction)的设计哲学。

遍历字符串:字节 vs 标量字符#

进行字符串检查时,必须显式指定遍历粒度:

字符串切片的安全性注意事项#

可以通过区间范围对字符串进行切片,但区间的起止下标必须精确落在 UTF-8 字符的合法边界上:

src/main.rs
fn main() {
    let hello = "Здравствуйте";
    
    // 西里尔字符每个占用 2 字节
    let s = &hello[0..4]; // 提取前 2 个字符(4 字节) -> "Зд"
    println!("{s}");

    // ❌ 如果从字符内部切分,运行时将直接触发 Panic:
    // let invalid = &hello[0..1]; // byte index 1 is not a char boundary
}
rust

[!WARNING] 处理多语言国际化文本时,尽量避免直接使用字节范围切片,推荐使用 .chars().take(n) 等迭代器方法以确保安全。

函数传参的最佳实践:优先接收 &str#

编写只需只读访问字符串的函数时,始终优先将参数类型声明为 &str,而不是 &String

src/main.rs
// 最佳实践:得益于 Deref 隐式转换,可同时接收 &String 和 &str
fn print_length(text: &str) {
    println!("长度: {} 字节", text.len());
}

fn main() {
    let owned = String::from("Rustacean");
    let literal = "Rustacean";

    print_length(&owned);   // 自动将 &String 转换为 &str
    print_length(literal);  // 直接传递 &str
}
rust

总结#

  • String 是堆上分配、拥有所有权且可伸缩的 UTF-8 缓冲区。
  • &str 是只读借用 UTF-8 数据的不可变切片(视图)。
  • 禁止整数下标索引 s[0],因为 UTF-8 字符具有 1 到 4 字节的变长特性。
  • 使用 .chars() 遍历字符,使用 .bytes() 遍历原始字节。
  • 函数参数优先使用 &str,兼具最高灵活性与零额外内存开销。

参考资料#