Rust 字符串

字符串是任何程序都离不开的数据类型,但 Rust 在这里给新手下了一个"马威"——它有两种字符串类型:String&str。理解它们的区别是写出地道 Rust 代码的关键,也会顺带巩固上一篇学的所有权和借用。这一篇我们彻底讲透。

1. 两种字符串:String 和 &str

这是新手最大的困惑来源,但理解了所有权之后并不难。Rust 把"堆上可变的字符串"和"只读的字符串切片"分成两个类型:

fn main() {
    // Rust 有两种字符串类型,这是新手最大的困惑点

    // 1. &str —— 字符串切片(只读,借用)
    //    "胖指针":指向某段 UTF-8 数据 + 长度
    let literal: &str = "我是字面量";     // 指向程序只读区
    let s = String::from("hello");
    let slice: &str = &s[..3];             // 指向 s 的前 3 字节

    // 2. String —— 堆上可变字符串(拥有所有权)
    //    可增长、可修改
    let mut owned: String = String::from("hi");
    owned.push_str(" world");              // 可变

    // 关键区别:
    //   &str  不可变,不拥有数据,轻量(就是指针+长度)
    //   String 可变,拥有数据,在堆上分配
    //   String 可以"降级"为 &str(免费,&s)
    //   &str   可以"升级"为 String(to_string())
}

一句话区分:

其他语言(如 Python、JS)只有一种字符串类型,而 Rust 分两种是为了"所有权清晰 + 零开销"——这是性能和安全的代价。

2. 创建 String

创建 String 有几种常用方式,最常用的是 Stringfrom 方法和 to_string 方法:

fn main() {
    // ===== 创建 String 的几种方式 =====

    // 1. 从字面量创建(最常见)
    let s1 = String::from("hello");

    // 2. to_string()(等价于 String::from)
    let s2 = "world".to_string();

    // 3. String::new() 空字符串,然后逐步填充
    let mut s3 = String::new();
    s3.push_str("逐步");
    s3.push_str("填充");

    // 4. 从字节构造(注意必须是合法 UTF-8)
    let bytes = vec![104, 105];       // 'h' 'i' 的字节
    let s4 = String::from_utf8(bytes).unwrap();

    // 5. 重复字符
    let dots = ".".repeat(3);         // "..."

    // 6. format! 宏拼接
    let name = "小明";
    let age = 20;
    let info = format!("{}今年{}岁", name, age);

    println!("{} {} {} {} {} {}", s1, s2, s3, s4, dots, info);
}

注意 String 必须是合法 UTF-8。如果从字节构造,字节必须是有效的 UTF-8 序列,否则会返回 Err。

3. 修改与拼接

String 是可变的,可以用 push_strpush+format! 等方式修改和拼接。但拼接时所有权规则要特别注意:

fn main() {
    let mut s = String::from("hello");

    // ===== 修改 String =====

    // push_str:追加字符串切片
    s.push_str(" world");

    // push:追加单个字符
    s.push('!');

    println!("{}", s);     // hello world!

    // + 运算符拼接(注意:左边必须是 String,右边是 &str)
    let s1 = String::from("Hello, ");
    let s2 = String::from("Rust!");
    let s3 = s1 + &s2;     // s1 的所有权被消耗,s2 借用
    // println!("{}", s1); // 编译错误!s1 已被 + 消耗
    println!("{}", s3);    // Hello, Rust!
    println!("{}", s2);    // OK,s2 还能用

    // 多个字符串拼接用 format! 更清晰
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");
    let s = format!("{}-{}-{}", s1, s2, s3);
    println!("{}", s);     // tic-tac-toe
    // format! 不消耗任何参数的所有权,s1/s2/s3 仍可用
}

几个易错点:

4. 切片与 UTF-8 编码(重要!)

这是 Rust 字符串最容易踩坑的地方。字符串切片按字节索引,不是按字符。因为 Rust 字符串底层是 UTF-8 编码,每个字符占 1~4 字节:

fn main() {
    let s = String::from("Hello, Rust!");
    let literal = "你好,世界";

    // ===== 切片 =====
    // 注意:Rust 字符串切片按【字节】索引,不是字符!
    // 因为底层是 UTF-8,一个中文占 3 字节

    let hello = &s[0..5];          // "Hello",ASCII 1 字节
    println!("{}", hello);

    // let bad = &literal[0..1];   // 运行时 panic!
    // 因为"你"占 3 字节,[0..1] 切到半个字符,非法

    // 中文必须按字符边界切:
    let first_char = &literal[0..3];    // "你",前 3 字节
    println!("{}", first_char);

    // ===== 长度 =====
    println!("字节数:{}", s.len());         // 12(字节)
    println!("字节数:{}", literal.len());    // 15(5 个中文 * 3 字节)

    // 字符数(注意不是 len):
    println!("字符数:{}", literal.chars().count());   // 5
}

关键警告:

这个设计听起来反人类,但它换来了O(1) 切片、零开销存储。Rust 选择"UTF-8 + 字节索引"是为了性能。

5. 遍历与常用方法

Rust 字符串提供了丰富的方法,但要按字符操作时必须显式用 chars() 迭代器:

fn main() {
    let s = "你好Rust";

    // ===== 遍历字符 =====
    // .chars() 按 Unicode 标量值遍历
    for c in s.chars() {
        print!("[{}] ", c);    // [你] [好] [R] [u] [s] [t]
    }
    println!();

    // .bytes() 按字节遍历(通常不需要)
    for b in s.bytes() {
        print!("{} ", b);
    }
    println!();

    // .char_indices() 同时拿字符索引和字符
    for (i, c) in s.chars().enumerate() {
        println!("第 {} 个字符: {}", i, c);
    }

    // ===== 常用方法 =====
    let text = "Hello World";
    println!("大写: {}", text.to_uppercase());   // HELLO WORLD
    println!("小写: {}", text.to_lowercase());   // hello world
    println!("是否为空: {}", text.is_empty());    // false
    println!("包含 World: {}", text.contains("World"));   // true
    println!("以 Hello 开头: {}", text.starts_with("Hello"));  // true
    println!("替换: {}", text.replace("World", "Rust"));    // Hello Rust
}

几个要点:

6. String 和 &str 的转换

两种类型经常需要互转。规则:&str 转 String 要分配堆(有开销),String 转 &str 几乎免费(借用):

fn main() {
    // ===== String 和 &str 的相互转换 =====

    // &str -> String(三种等价方式)
    let s1: String = "hello".to_string();
    let s2: String = String::from("hello");
    let s3: String = "hello".to_owned();

    // String -> &str(免费,自动)
    let owned = String::from("hi");
    let borrowed: &str = &owned;        // 自动 deref
    // 函数参数接收 &str 时,可以直接传 String
    show(&owned);                        // 自动转 &str

    // 函数参数推荐接收 &str(更通用)
    // 因为 String 和 &str 都能传进去
    fn show(s: &str) {
        println!("{}", s);
    }

    // 拥有 vs 借用的选择:
    // - 函数只是"读"字符串 -> 用 &str 参数
    // - 函数需要"长期持有"字符串 -> 用 String 参数(转移所有权)
    // - 函数需要修改 -> 用 &mut String 参数
    // - 返回字符串 -> 通常返回 String(拥有)
}

一个非常重要的最佳实践:函数参数优先用 &str 而不是 String。因为 &str 既能接收 String(自动 deref),也能接收字面量,最通用。而 String 参数会强制调用者转移所有权,不灵活。

7. 为什么有两种类型?

这是设计上的深思熟虑,不是历史包袱:

// 为什么 Rust 要分两种字符串类型?

// 1. 性能:&str 是"胖指针"(指针+长度),复制成本极低
//    传 &str 比传 String 快得多(不需要堆分配)

// 2. 灵活性:&str 可以指向任何 UTF-8 数据:
//    - 字符串字面量(程序只读区)
//    - String 的一段
//    - 文件读进来的字节
//    统一接口,不关心数据来源

// 3. 所有权清晰:String 明确"我拥有这块堆内存",
//    &str 明确"我借用别人的"。编译器据此保证安全。

// 实际经验:
// - 结构体字段、长期持有 -> String
// - 函数参数 -> &str(最通用)
// - 局部变量、临时拼接 -> String
// - 函数返回 -> String(避免生命周期问题)
fn greet(name: &str) -> String {       // 借进 &str,返回 String
    format!("你好, {}!", name)
}

记一个简单规则:

8. 字符串与所有权综合

字符串是把所有权和借用最具体地展现出来的场景。回顾几个典型情况:

9. 实战:解析字符串

Rust 字符串处理常用方法还包括 split、parse 等。处理命令行输入、配置文件时很常用:

下一篇讲结构体时我们会综合运用这些。

小结

这一篇你彻底搞懂了 Rust 两种字符串:String(堆、可变、拥有)和 &str(切片、只读、借用)。关键要点:切片按字节(中文要小心)、函数参数优先 &str、len 返回字节数、chars().count() 返回字符数。字符串是把所有权和借用最具体地展现的场景,理解了它,你对 Rust 的核心理念就有了实战感。

下一篇我们看结构体——Rust 用 struct + impl 替代了传统面向对象的"类",是组织数据的核心工具。

← 上一篇 Rust 借用与引用

下一篇 Rust 结构体

✈️💬