MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Rust中的字符与字符串处理

2021-05-127.6k 阅读

Rust 中的字符类型

在 Rust 中,char 类型代表一个 Unicode 标量值。这意味着它可以表示世界上几乎所有的书面语言中的单个字符。一个 char 占用 4 个字节,与许多其他编程语言中简单的 1 字节字符类型不同。

字符字面量

定义一个字符变量非常简单,使用单引号包裹字符。例如:

let c: char = 'a';

这里我们显式地指定了变量 c 的类型为 char,不过 Rust 通常可以根据上下文推断出类型,所以下面的写法也是可行的:

let c = 'a';

Rust 中的字符不仅仅局限于 ASCII 字符,它可以表示任何有效的 Unicode 字符:

let heart_eyed_cat = '😻';
let euro_sign = '€';

字符的操作

虽然 char 类型本身的直接操作相对有限,但可以与其他类型和库结合使用来完成各种任务。例如,可以将 char 转换为其对应的 Unicode 码点值。每个 char 都实现了 Into<u32> 特征,这意味着可以将 char 转换为 u32,该 u32 值即为对应的 Unicode 码点:

let c = 'a';
let code_point: u32 = c.into();
println!("The code point of '{}' is {}", c, code_point);

输出结果为:The code point of 'a' is 97

还可以从 Unicode 码点值创建 charchar 类型提供了 from_u32 方法,该方法尝试从给定的 u32 值创建 char

let code_point = 97;
let c = char::from_u32(code_point);
if let Some(c) = c {
    println!("The character for code point {} is '{}'", code_point, c);
} else {
    println!("Invalid code point for creating a char");
}

在这里,from_u32 返回一个 Option<char>,因为并非所有的 u32 值都对应有效的 Unicode 标量值。如果值有效,则 OptionSome(char),否则为 None

Rust 中的字符串类型

Rust 中有两种主要的字符串类型:&strString

&str - 字符串切片

&str 是一个不可变的字符串切片,它是对存储在其他地方的 UTF - 8 编码字符串数据的引用。通常,字符串字面量的类型就是 &str。例如:

let s: &str = "Hello, world!";

字符串切片的长度在编译时不一定是已知的,并且它指向的字符串数据的生命周期由其所在的上下文决定。字符串切片在 Rust 中是非常高效的,因为它们只是一个指向字符串数据的指针和长度信息,不拥有数据本身。

&str 实现了许多有用的方法,例如 len 方法用于获取字符串的字节长度(注意,这不是字符数量):

let s = "Hello, world!";
println!("The length of '{}' is {} bytes", s, s.len());

输出结果为:The length of 'Hello, world!' is 13 bytes

contains 方法用于检查字符串切片是否包含另一个字符串切片:

let s = "Hello, world!";
let sub = "world";
println!("Does '{}' contain '{}'? {}", s, sub, s.contains(sub));

输出结果为:Does 'Hello, world!' contain 'world'? true

String - 可增长的字符串

String 类型是一个可增长、可变、拥有所有权的字符串类型。它在堆上分配内存,可以通过多种方式创建。

最常见的创建 String 的方式是使用 from 方法从字符串字面量转换:

let s: String = "Hello, world!".to_string();

也可以使用 String::new 创建一个空的 String,然后通过 pushpush_str 方法添加字符或字符串切片:

let mut s = String::new();
s.push('a');
s.push_str("bc");
println!("{}", s);

输出结果为:abc

String 可以通过 len 方法获取其字节长度,和 &str 类似:

let s = "Hello, world!".to_string();
println!("The length of '{}' is {} bytes", s, s.len());

输出结果为:The length of 'Hello, world!' is 13 bytes

字符串的编码与解码

Rust 中的字符串严格遵循 UTF - 8 编码。这带来了许多好处,比如可以高效地处理各种语言的文本,并且与现代网络和文件系统的标准编码相匹配。

UTF - 8 编码的优势

UTF - 8 是一种变长编码方案,它用 1 到 4 个字节表示一个 Unicode 码点。ASCII 字符(U+0000 - U+007F)用 1 个字节表示,这使得处理 ASCII 文本时和传统的 1 字节编码一样高效。对于其他 Unicode 字符,使用多个字节来表示。这种灵活性使得 UTF - 8 成为一种非常强大且广泛适用的编码方式。

在 Rust 中,所有的字符串字面量都是 UTF - 8 编码的,&strString 类型也都要求其内容是有效的 UTF - 8 编码。这意味着如果尝试创建一个无效的 UTF - 8 字符串,编译器会报错。例如:

// 这会导致编译错误,因为 \xFF 不是有效的 UTF - 8 字节序列
let s = "\xFF"; 

编码操作

当需要将字符串数据转换为其他编码格式时,Rust 有一些库可以提供帮助。例如,encoding 库可以将 UTF - 8 编码的字符串转换为其他编码,如 ISO - 8859 - 1。

首先,在 Cargo.toml 文件中添加依赖:

[dependencies]
encoding = "0.2"

然后可以进行编码转换:

use encoding::all::ISO_8859_1;
use encoding::Encode;

let s = "Hello, world!";
let encoded = s.encode(&ISO_8859_1).unwrap();
println!("Encoded string: {:?}", encoded);

这里将 UTF - 8 编码的字符串 s 转换为 ISO - 8859 - 1 编码,并打印出结果。

解码操作

同样,使用 encoding 库可以将其他编码格式的字符串解码为 UTF - 8。假设我们有一个 ISO - 8859 - 1 编码的字节数组,要将其解码为 UTF - 8 字符串:

use encoding::all::ISO_8859_1;
use encoding::Decode;

let encoded = b"Hello, world!";
let decoded = ISO_8859_1.decode(encoded).unwrap();
println!("Decoded string: {}", decoded);

这里将 ISO - 8859 - 1 编码的字节数组 encoded 解码为 UTF - 8 字符串 decoded

字符串的遍历

在 Rust 中,遍历字符串有几种不同的方式,具体取决于你是关心字节、字符还是单词等不同的粒度。

按字节遍历

因为 Rust 中的字符串是 UTF - 8 编码的,所以可以按字节遍历字符串。&strString 类型都实现了 IntoIterator 特征,当按字节遍历时,会逐个返回字符串的字节。例如:

let s = "Hello, 世界";
for byte in s.bytes() {
    println!("{}", byte);
}

这里的 bytes 方法返回一个迭代器,逐个返回字符串中的字节。输出结果会是每个字节的数值表示。

按字符遍历

如果要按字符遍历字符串,即获取每个 Unicode 标量值,可以使用 chars 方法。这个方法会根据 UTF - 8 编码将字符串解析为字符。例如:

let s = "Hello, 世界";
for char in s.chars() {
    println!("{}", char);
}

这样会逐个打印出字符串中的每个字符,无论是 ASCII 字符还是其他语言的字符。

按单词遍历

要按单词遍历字符串,可以使用 split_whitespace 方法。这个方法会根据空白字符(空格、制表符、换行符等)将字符串分割成单词。例如:

let s = "Hello world 你好 世界";
for word in s.split_whitespace() {
    println!("{}", word);
}

输出结果会是字符串中的每个单词。

字符串的拼接与格式化

在编程中,经常需要将多个字符串片段拼接在一起,或者按照特定的格式输出字符串。

字符串拼接

Rust 提供了几种方法来拼接字符串。一种简单的方式是使用 + 运算符。不过,由于 + 运算符的实现方式,其中一个操作数必须是 String,另一个可以是 &str。例如:

let s1 = String::from("Hello, ");
let s2 = "world!";
let s3 = s1 + s2;
println!("{}", s3);

这里 s1String 类型,s2&str 类型,通过 + 运算符将它们拼接在一起。注意,s1 在拼接后所有权被转移,不能再继续使用。

还可以使用 format! 宏来拼接多个字符串,这种方式更加灵活,并且不会转移任何字符串的所有权:

let s1 = "Hello, ";
let s2 = "world!";
let s3 = format!("{}{}", s1, s2);
println!("{}", s3);

format! 宏的工作方式类似于 println!,但它返回一个 String 而不是打印到控制台。

字符串格式化

format! 宏不仅可以用于拼接字符串,还可以进行格式化。它支持类似于 C 语言 printf 风格的格式化占位符。例如,要格式化整数和浮点数:

let num = 42;
let pi = 3.14159;
let s = format!("The number is {}, and pi is approximately {:.2}", num, pi);
println!("{}", s);

这里 {} 是通用占位符,{:.2} 表示将浮点数 pi 格式化为保留两位小数。

对于结构体和自定义类型,也可以实现 fmt::Display 特征来自定义格式化行为。例如:

struct Point {
    x: i32,
    y: i32,
}

impl std::fmt::Display for Point {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

let p = Point { x: 10, y: 20 };
let s = format!("The point is: {}", p);
println!("{}", s);

这里为 Point 结构体实现了 fmt::Display 特征,使得可以使用 format! 宏以自定义的格式输出 Point 实例。

字符串处理的性能考量

在处理字符串时,性能是一个重要的考量因素。由于 Rust 对内存安全和性能的关注,在字符串处理方面有一些值得注意的点。

内存分配与释放

String 类型在堆上分配内存,每次增长字符串(例如通过 pushpush_str 方法)时,可能需要重新分配内存。这是因为字符串的大小在编译时是未知的,并且需要足够的连续内存空间来存储新的内容。

为了减少不必要的内存重新分配,可以预先分配足够的空间。例如,使用 with_capacity 方法创建 String

let mut s = String::with_capacity(100);
s.push_str("Hello, ");
s.push_str("world!");

这里预先分配了 100 字节的空间,这样在添加 "Hello, world!" 时就不太可能需要重新分配内存,从而提高性能。

操作的时间复杂度

不同的字符串操作具有不同的时间复杂度。例如,push 方法在字符串末尾添加一个字符的时间复杂度是 O(1),因为它只是简单地将字符添加到现有内存块的末尾(假设没有内存重新分配)。

split 方法将字符串分割成多个子字符串,其时间复杂度取决于字符串的长度和分割的次数。如果频繁进行分割操作,并且对性能要求较高,可以考虑使用更高效的数据结构或算法,例如 aho - corasick 库进行多模式匹配和分割,它在处理大量文本和复杂模式时具有更好的性能。

字符串与其他类型的转换

在实际编程中,经常需要在字符串和其他数据类型之间进行转换。

字符串与数字的转换

将字符串转换为数字是常见的操作。Rust 提供了 parse 方法来实现这一功能。例如,将字符串转换为 i32

let s = "42";
let num: i32 = s.parse().expect("Failed to parse number");
println!("The number is: {}", num);

这里使用 parse 方法尝试将字符串 s 转换为 i32。如果转换失败,expect 方法会导致程序崩溃并打印错误信息。

将数字转换为字符串可以使用 to_string 方法:

let num = 42;
let s = num.to_string();
println!("The string is: {}", s);

字符串与字节数组的转换

将字符串转换为字节数组可以使用 as_bytes 方法,它返回一个 &[u8] 切片,包含字符串的 UTF - 8 编码字节:

let s = "Hello, world!";
let bytes = s.as_bytes();
println!("{:?}", bytes);

将字节数组转换为字符串需要确保字节数组是有效的 UTF - 8 编码。可以使用 String::from_utf8 方法,它返回一个 Result<String, FromUtf8Error>,因为转换可能失败:

let bytes = b"Hello, world!";
let s = String::from_utf8(Vec::from(bytes)).unwrap();
println!("{}", s);

这里先将字节数组转换为 Vec<u8>,然后使用 from_utf8 方法尝试将其转换为字符串。如果字节数组不是有效的 UTF - 8 编码,unwrap 会导致程序崩溃,更好的做法是处理 Result 中的错误情况。

字符串处理中的错误处理

在字符串处理过程中,可能会遇到各种错误,如无效的 UTF - 8 编码、转换失败等。Rust 提供了强大的错误处理机制来应对这些情况。

无效 UTF - 8 编码的处理

当尝试从无效的 UTF - 8 字节序列创建字符串时,Rust 会报错。例如,String::from_utf8 方法在遇到无效编码时会返回 Err

let invalid_bytes = vec![0xFF];
let result = String::from_utf8(invalid_bytes);
match result {
    Ok(s) => println!("The string is: {}", s),
    Err(e) => println!("Error: {}", e),
}

这里 from_utf8 返回一个 Err,因为 0xFF 不是有效的 UTF - 8 字节。通过 match 语句可以处理这个错误并给出相应的提示。

转换错误的处理

在字符串与其他类型的转换中,如字符串转换为数字,也可能出现错误。parse 方法返回一个 Result,可以通过 match? 操作符来处理错误:

let s = "abc";
let result: Result<i32, _> = s.parse();
match result {
    Ok(num) => println!("The number is: {}", num),
    Err(e) => println!("Error: {}", e),
}

这里 s 不能转换为 i32parse 返回 Err,通过 match 语句可以处理这个错误。使用 ? 操作符可以更简洁地处理错误,例如在函数中:

fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse()?
}

这里 parse 返回的 Result 中的错误直接通过 ? 操作符传播出去,如果没有错误,则返回解析后的 i32

字符串处理的高级技巧

除了基本的字符串操作,Rust 还提供了一些高级技巧来更高效、灵活地处理字符串。

正则表达式

正则表达式是处理字符串模式匹配和替换的强大工具。Rust 中有 regex 库可以用于正则表达式操作。首先在 Cargo.toml 文件中添加依赖:

[dependencies]
regex = "1.5"

然后可以使用正则表达式进行匹配:

use regex::Regex;

let re = Regex::new(r"\d+").unwrap();
let s = "I have 42 apples and 10 oranges";
for cap in re.captures_iter(s) {
    println!("Found number: {}", cap[0]);
}

这里创建了一个正则表达式 \d+,用于匹配一个或多个数字。captures_iter 方法返回一个迭代器,逐个返回匹配的结果。

字符串替换

regex 库也可以用于字符串替换。例如,将字符串中的所有数字替换为 "number":

use regex::Regex;

let re = Regex::new(r"\d+").unwrap();
let s = "I have 42 apples and 10 oranges";
let replaced = re.replace_all(s, "number");
println!("{}", replaced);

这里 replace_all 方法将字符串 s 中所有匹配正则表达式的部分替换为 "number"。

字符串搜索与索引

在字符串中搜索特定的子字符串并获取其索引也是常见的操作。除了使用 contains 方法判断是否包含子字符串外,还可以使用 find 方法获取子字符串第一次出现的索引:

let s = "Hello, world!";
let sub = "world";
if let Some(index) = s.find(sub) {
    println!("'{}' found at index {}", sub, index);
} else {
    println!("'{}' not found", sub);
}

这里 find 方法返回一个 Option<usize>,如果找到子字符串则返回其索引,否则返回 None

多线程环境下的字符串处理

在多线程编程中,字符串处理需要特别小心,因为字符串类型(如 String)的所有权和可变性可能会导致数据竞争。

SyncSend 特征

String 类型实现了 Send 特征,这意味着它可以安全地在不同线程之间传递。而 &str 类型既实现了 Send 也实现了 Sync 特征,这意味着它可以在线程之间共享只读。

例如,以下代码展示了如何将 String 传递到另一个线程:

use std::thread;

let s = String::from("Hello from main");
let handle = thread::spawn(move || {
    println!("{} from thread", s);
});
handle.join().unwrap();

这里通过 move 关键字将 String 的所有权转移到新线程中。

线程安全的字符串处理

如果需要在多个线程中同时访问和修改字符串,可以使用 Mutex<String>RwLock<String>Mutex 提供独占访问,而 RwLock 提供读 - 写锁,允许多个线程同时读,但只允许一个线程写。

以下是使用 Mutex<String> 的示例:

use std::sync::{Arc, Mutex};
use std::thread;

let s = Arc::new(Mutex::new(String::from("Hello")));
let handles = (0..10).map(|_| {
    let s = s.clone();
    thread::spawn(move || {
        let mut s = s.lock().unwrap();
        s.push_str(" world");
    })
}).collect::<Vec<_>>();

for handle in handles {
    handle.join().unwrap();
}

println!("{}", s.lock().unwrap());

这里使用 Arc(原子引用计数)来在多个线程之间共享 Mutex<String>,每个线程获取锁并修改字符串,最后打印出修改后的字符串。

通过以上内容,我们全面深入地了解了 Rust 中字符与字符串处理的各个方面,从基础类型到高级技巧,以及在多线程环境下的处理方式。希望这些知识能帮助开发者在 Rust 编程中更好地处理字符串相关的任务。