理解 Rust 中的 String 和 &str

本文由 Joseph Mawa 于 2024 年 3 月 26 日更新,新增了有关 Rust 中字符串操作的信息,包括字符串切片和使用 containsstarts_with 以及 find 方法进行模式匹配。此外,还介绍了字符串转换的内容,包括将数据转换为字符串以及解析字符串。

file

理解 Rust 中的 String 和 &str

根据您的编程背景,Rust 中的多种字符串类型(例如 Stringstr)可能会令人感到困惑。在本文中,我们将详细讲解 Stringstr,更准确地说,是 String&String&str 之间的区别,并帮助您在使用这些类型时做出正确选择。

掌握本文中的概念将帮助您更高效地使用 Rust 中的字符串,同时也能更好地理解他人在代码中处理字符串的思路。

我们首先将探讨字符串类型的结构差异及其在可变性和内存位置方面的不同。然后,我们会讨论这些差异的实际意义,并说明在什么情况下选择哪种类型。最后,通过一些简单的代码示例,展示如何在 Rust 中使用不同的字符串类型。

如果您刚开始学习 Rust,这篇文章或许对您很有帮助。它将解释为什么在处理字符串时,您的代码有时无法通过编译。即使您已经熟悉 Rust,由于字符串在许多软件工程领域具有基础性的重要性,了解它们在您所使用的编程语言中的工作原理也是必要的。

让我们开始吧!

理解 Rust 字符串类型之间的差异

在本节中,我们将从语言层面探讨这些类型之间的差异及其意义。

一般来说,区别主要体现在 所有权(ownership)内存(memory) 上。需要注意的是,Rust 中的所有字符串类型始终保证是有效的 UTF-8 格式。

什么是 String?

String 是一种 拥有所有权(owned) 的类型,必须进行内存分配(allocation)。它具有动态大小,因此其大小在编译时未知,因为其内部数组的容量可以随时改变。其类型本质上是以下形式的结构体:

pub struct String {
    vec: Vec<u8>,
}

因为它包含一个 Vec(向量),所以我们知道它有一个指向内存块的指针、一个大小(size)和一个容量(capacity)。

  • 大小 表示字符串的长度。
  • 容量 表示字符串在需要重新分配内存之前可以达到的最大长度。
  • 指针 指向堆上的一个连续字符数组,该数组的大小为 capacity,实际使用长度为 size

String 非常灵活。我们可以用它随时创建新的动态字符串并对其进行修改。但这种灵活性是有代价的:每次创建 String 都需要分配新的内存。

什么是 &String?

&String 是对 String 的引用。这意味着它不是一个拥有所有权的类型,并且因为它只是一个指向实际 String 的指针,所以其大小在编译时是已知的。

关于 &String,需要注意以下几点:

  • 它不是拥有所有权的类型,因此我们可以在不分配内存的情况下传递 &String,只要所引用的 String 仍然在作用域内。
  • 一个有趣的特性是,Rust 编译器可以将 &String 自动 解引用(Deref)&str,这使得 API 的使用更加灵活。但这种转换是单向的,反过来却不行。例如:
fn main() {
    let s = "hello_world"; // &str 类型
    let mut mutable_string = String::from("hello"); // String 类型

    coerce_success(&mutable_string);
    coerce_fail(s);
}

fn coerce_success(data: &str) { // 编译通过,因为 &String 可自动转换为 &str
    println!("{}", data);
}

fn coerce_fail(data: &String) { // 编译错误:预期类型为 &String,但实际为 &str
    println!("{}", data);
}

什么是 &str?

&str 是一个指向字符串的只读引用。它的大小在编译时是已知的,因为它仅包含一个内存指针和一个大小(size)。&str 的内存可以位于堆、栈,或者直接位于程序的静态数据段中。

关于 &str 的几个要点:

  • 它不是一个拥有所有权的类型,而是对字符串切片的只读引用。
  • Rust 保证在 &str 作用域内,其指向的底层内存是不可变的,即使是 str 的拥有者也不能更改它。
  • &str 通常用于函数参数,当函数不需要修改或拥有字符串时,&str 是一个很好的选择。

使用场景:
&str 适合在需要字符串切片(视图)但不需要修改的情况下使用。不过需要注意,虽然 &str 指向的 str 的大小在编译时未知,但它的容量也不能动态更改。

了解以上类型的差异后,您可以更有信心地选择在特定场景中使用哪种字符串类型。

&str 的实际应用意义

理解上述概念将帮助您编写更符合 Rust 编译器要求的代码。而且,除了编译成功之外,还需要考虑 API 设计和性能方面的实际影响。

性能考虑

在性能方面,您需要注意,创建一个新的 String 总会触发内存分配。如果可以避免额外的内存分配,您应该尽量这样做,因为分配需要耗费相当多的时间,并对运行时性能产生重大影响。

嵌套循环示例
假设您的程序在嵌套循环中需要不断获取字符串的不同部分。如果每次都创建新的 String 子字符串,就需要为每个子字符串分配内存,并且会执行大量不必要的工作。而使用 &str 字符串切片(对基础 String 的只读视图)则可以避免这些问题。

同样的道理也适用于在应用中传递数据。如果您传递的是拥有所有权的 String 实例,而不是可变引用(&mut String)或只读视图(&str),将会触发更多的内存分配,并需要保留更多的内存。

因此,从性能的角度来看,了解何时发生分配以及如何避免分配非常重要。当可以重复使用字符串切片时,尽量使用它。

API 设计

在 API 设计方面,事情变得更加复杂。因为您的 API 会有特定目标,因此选择正确的字符串类型对于帮助 API 使用者达成目标至关重要。

例如,&String 可以自动转换为 &str,但反之却不可行。因此,如果您只需要字符串的只读视图,通常最好将参数类型定义为 &str

另外,如果函数需要修改传入的 String,传递 &str 就没有意义,因为它是不可变的。此时,您需要从 &str 创建一个新的 String 并返回它。为了避免这种额外的开销,在需要修改的场景下使用 &mut String 是更好的选择。

拥有的字符串(Owned Strings)

在某些情况下,您可能需要一个拥有所有权的字符串,例如将其发送到另一个线程,或创建一个需要拥有字符串的结构体。在这些情况下,您需要直接使用 String,因为 &String&str 都是借用类型。

无论在何种情况下,只要所有权和可变性很重要,在 String&str 之间进行选择就非常关键。在大多数情况下,如果使用的类型不正确,代码将无法编译。但是,如果您不清楚每种类型的属性,以及从一种类型转换到另一种类型时会发生什么,可能会导致设计出令人困惑的 API,从而触发更多不必要的内存分配。

String 和 &str 的代码示例

以下示例展示了一些前面提到的情况,并给出了处理每种情况的建议。

请记住,这些都是独立的、人为构造的示例。在实际代码中,应该根据具体需求选择合适的方式。不过,您可以将这些示例作为基本参考。

常量字符串

这是最简单的情况。如果您在应用中需要一个常量字符串,可以用以下方式实现:

const CONST_STRING: &'static str = "some constant string";

CONST_STRING 是只读的,并且会以静态形式存储在可执行文件中,在程序执行时加载到内存中。

字符串的修改

如果您有一个 String,并且希望在函数中对其进行修改,可以使用 &mut String 作为参数:

fn main() {
    let mut mutable_string = String::from("hello "); 
    do_some_mutation(&mut mutable_string);
    println!("{}", mutable_string); // hello add this to the end
}

fn do_some_mutation(input: &mut String) {
    input.push_str("add this to the end"); 
}

这种方式可以直接修改实际的 String,而不需要分配新的 String。不过,在某些情况下也可能发生内存分配,例如当 String 的初始容量不足以容纳新的内容时。

拥有所有权的字符串

在某些情况下,您需要一个拥有所有权的字符串,例如在函数中返回字符串,或将其传递给另一个线程(例如返回一个值、将其传递给另一个线程,或在运行时构建字符串):

fn main() {
    let s = "hello_world";
    println!("{}", do_something(s)); // HELLO_WORLD
}

fn do_something(input: &str) -> String {
    input.to_ascii_uppercase()
}

此外,如果需要传递具有所有权的字符串,我们必须传递 String

struct Owned {
    bla: String,
}

fn create_owned(other_bla: String) -> Owned {
    Owned { bla: other_bla }
}

由于 Owned 需要一个拥有所有权的字符串,如果我们传入的是 &String&str,则必须在函数内部将其转换为 String,这会触发一次分配。因此,在这种情况下最好直接传入一个 String

只读参数/切片

如果不需要修改字符串,可以将参数类型定义为 &str。这种方式对 String 也有效,因为 &String 可以被 Deref 强制转换为 &str

const CONST_STRING: &'static str = "some constant string";

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

    print_something(&mutable_string);
    print_something(s);
    print_something(CONST_STRING);
}

fn print_something(something: &str) {
    println!("{}", something);
}

正如您所看到的,我们可以将 &String&'static str&str 作为输入值传递给 print_something 方法。

在结构体中使用 Rust 字符串类型

我们可以在结构体中使用 String&str。关键的区别是,如果结构体需要拥有数据的所有权,则需要使用 String;如果使用 &str,则必须使用 Rust 的生命周期(lifetime),并确保结构体的生命周期不会超过被借用的字符串,否则代码将无法编译。

示例:无效的代码

以下代码不会工作:

struct Owned {
    bla: String,
}

struct Borrowed<'a> {
    bla: &'a str,
}

fn create_something() -> Borrowed { // 错误:没有可以借用的内容
    let o = Owned {
        bla: String::from("bla"),
    };

    let b = Borrowed { bla: &o.bla };
    b
}

在这段代码中,Rust 编译器会产生一个错误,提示 Borrowed 包含了一个被借用的值,而其来源 o 在函数结束时超出作用域。因此,当我们返回 Borrowed 时,其引用的值已经超出了作用域,导致编译失败。

修复方法:传递被借用的值

我们可以通过传递被借用的值来修复上述问题:

struct Owned {
    bla: String,
}

struct Borrowed<'a> {
    bla: &'a str,
}

fn main() {
    let o = Owned {
        bla: String::from("bla"),
    };

    let b = create_something(&o.bla);
}

fn create_something(other_bla: &str) -> Borrowed {
    let b = Borrowed { bla: other_bla };
    b
}

在这种方法中,当我们返回 Borrowed 时,所引用的值仍然在作用域内,因此代码可以正常工作。

Rust 中的字符串操作

Rust 提供了许多内置方法来处理字符串操作。本节将探讨其中的一些。

Rust 中的字符串切片

可以使用字符串切片来引用字符串中的某个字符子集。可以通过方括号中的整数范围 [起始索引..结束索引] 来创建字符串切片。切片将引用从起始索引到(但不包括)结束索引的字符:

let hello_world = String::from("hello world");

let ello = &hello_world[1..5]; // ello
let orld = &hello_world[7..11]; // orld

如果想从索引 0 开始引用字符,可以省略起始索引:

let hello = &hello_world[..5];

同样,如果想引用到最后一个字符,可以省略结束索引:

let orld = &hello_world[7..];

如果同时省略起始索引和结束索引,则可以切片整个字符串:

let hello_world_ref = &hello_world[..];

注意:Rust 将字符串存储为 UTF-8 编码的字节序列。因此,上述示例在字符串仅包含单字节字符时有效。如果字符串中包含多字节字符,则必须在字符边界处进行切片,否则 Rust 会在切片多字节字符的中间时抛出错误。

例如,字符串 "hello ❤️" 中的 ❤️ 表情符号是由 6 个字节编码的。这些字节的索引范围是 6 到 11。如果 起始索引结束索引 的值介于 6 和 12 之间,代码会因为试图在多字节字符中间切片而崩溃:

let hello_love = String::from("hello ❤️");

let heart = &hello_love[6..12]; // 正常
let broken_heart = &hello_love[6..8]; // 错误

使用 contains 方法进行模式匹配

contains 方法可用于检查字符串切片是否包含另一个字符串或子字符串。它返回一个布尔值。

如果传递的模式作为字符串切片的子集匹配,则 contains 方法返回 true;否则返回 false。可以传递的模式包括 &strcharchar 的切片。它也可以是函数或闭包,用于确定字符是否匹配:

let test_string = "hello ❤️";

assert!(test_string.contains("hell"));
assert!(test_string.contains("ell ❤️")); // 该断言会失败

使用 starts_with 方法进行模式匹配

在 Rust 中,可以使用 starts_with 方法判断字符串切片是否以另一个字符串或子字符串开头。如果字符串以指定模式开头,则返回 true,否则返回 false

let my_string: &str = "Hello world!";

assert!(my_string.starts_with("Hell"));
assert!(my_string.starts_with("hell")); // 该断言会失败

注意starts_with 是区分大小写的。因此,上述示例中,"Hell" 和 "hell" 会被视为不同的前缀。

使用 find 方法进行模式匹配

find 方法用于查找字符串切片中模式的首次出现位置。它接收一个模式作为参数,并返回匹配模式的第一个字符的字节索引:

let my_string = "Hello ❤️";

assert_eq!(my_string.find("❤️"), Some(6));
assert_eq!(my_string.find("k"), None);
assert_eq!(my_string.find("Hell"), Some(0));
assert_eq!(my_string.find("hell"), Some(0));

如果字符串切片中没有找到匹配的模式,find 方法返回 None注意find 方法区分大小写。例如,Hello ❤️ 不会匹配 hello ❤️

使用 rfind 方法进行模式匹配

rfind 方法与上述 find 方法类似,不同之处在于 rfind 用于查找模式在字符串中的最后一次出现位置。find 方法返回模式首次匹配的第一个字符的字节索引,而 rfind 方法返回模式最后一次匹配的第一个字符的字节索引:

let my_string = "Hello lovely";

assert_eq!(my_string.rfind("❤️"), None);
assert_eq!(my_string.rfind("el"), Some(9));
assert_eq!(my_string.rfind("hell"), None);

例如,假设需要在字符串 "Hello lovely" 中查找子字符串 "el"。在该字符串中,子字符串 "el" 出现了两次。find 方法会返回第一个 "e" 的字节索引,而 rfind 方法会返回第二个 "e" 的字节索引。如果没有找到匹配模式,rfind 方法返回 None

Rust 中的字符串转换

Rust 中的字符串转换

在 Rust 中,你可以使用 to_string 方法将任何实现了 ToString 特征的类型转换为 String

let my_bool: bool = true;
let my_int: i32 = 23;
let my_float: f32 = 3.14;
let my_char: char = 'a';

println!("{}", my_bool.to_string());
println!("{}", my_int.to_string());
println!("{}", my_float.to_string());
println!("{}", my_char.to_string());

任何实现了 Display 特征的类型也会自动实现 ToString 特征。因此,你不需要直接实现 ToString 特征,而是可以实现 Display 特征。Rust 文档中列出了所有实现了 Display 特征的内置类型。

Rust 中的两种字符串类型转换

Rust 主要有两种字符串类型。有时,你可能需要将一种字符串类型转换为另一种。如前所述,你可以使用 String::from 方法将字符串切片转换为 String

let my_string = String::from("Hello world");

反之,你可以使用 as_str 方法将 String 转换为字符串切片。as_str 方法会借用底层数据而不复制:

let my_string = String::from("Hello world.");
let my_string_slice = my_string.as_str();

println!("{}", my_string);
println!("{}", my_string_slice);

Rust 中的字符串解析

你可能需要将字符串转换为数字类型或任何实现了 FromStr 特征的其他类型。parse 方法非常适合用于此操作:

let my_string = "10";

let parsed_string: u32 = my_string.parse().unwrap();
println!("{parsed_string}");

由于你可以将字符串解析为任何实现了 FromStr 特征的类型,因此你必须显式指定类型,如上面的示例所示,以防遇到类型推导问题。Rust 文档中列出了所有实现了 FromStr 特征的内置类型。如需了解更多关于 Rust 特征的信息,请查看《Rust 特征:深入解析》。

除了使用类型注解外,你还可以使用 Rust 的“涡轮鱼”语法,如下所示:

let my_string = "10";

let parsed_string = my_string.parse::<u32>().unwrap();
println!("{parsed_string}");

如果你使用 parse 方法将字符串转换为数字类型,请确保字符串包含有效的字符。你解析的字符串可能包含无效字符,如单位、前后空格以及区域特定的格式(例如千位分隔符)。

如果字符串格式不正确,可以使用内置方法,如 trimtrim_starttrim_matchesreplace,去除无效字符,然后再将字符串解析为数字,如下面的示例所示:

let price = " 200,000 ";
println!("{}", price.trim().replace(",", "").parse::<u32>().unwrap());

同样,你解析的数字可能超出了目标数字类型的范围。要留意可能由溢出引起的错误。

如果无法将字符串解析为指定类型,parse 方法会返回错误。确保正确处理错误。

使用 regex 库进行复杂的字符串解析

对于更复杂的解析需求,你也可以使用 regex 库。它具有内置标准 Rust 模块可能没有的功能:

use regex::Regex;

fn main() {
    let price = "$ 1,500";
    let regx = Regex::new(r"[^0-9.]").unwrap();

    println!("{}", regx.replace_all(price, ""));
}

参考链接

Comments

No comments yet. Why don’t you start the discussion?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注