在其他语言中,字符串通常都会比较简单,例如 “hello, world” 就是字符串章节的几乎全部内容了。
但是Rust中的字符串与其他语言有所不同,若带着其他语言的习惯来学习Rust字符串,将会波折不断。
所以最好先忘记脑中已有的关于字符串的知识,从头开始学习Rust字符串。
首先来看段逻辑简单的代码:
fn main() {let my_name = "Pascal";greet(my_name);
}fn greet(name: String) {println!("Hello, {}!", name);
}
greet 函数接受一个字符串类型的 name 参数,然后打印到终端控制台中,非常好理解,那么这段代码能不能通过编译呢?
error[E0308]: mismatched types--> src/main.rs:3:11|
3 | greet(my_name);| ^^^^^^^| || expected struct `std::string::String`, found `&str`| help: try using a conversion method: `my_name.to_string()`error: aborting due to previous error
编译后发现报错了。编译器提示 greet 函数需要一个 String 类型的字符串,却传入了一个 &str 类型的字符串。思考分析这个错误,它表明的信息是:我们写下的 let my_name = “Pascal”; 语句,my_name并不是 String 类型,而是 “&str” 类型。
相信如果按照其他编程语言的思路,此时就像遇到了无法理解的现象,我们需要抛开这种成见,从头开始学习Rust字符串的原理。
在学习字符串之前,先来看看什么是切片。
(一)Slice(切片)
如果你有其他编程语言的经验更好理解切片。
切片并不是 Rust 独有的概念,在 Go 语言中就非常流行。Rust切片它允许我们引用集合中部分连续的元素序列,而不是引用整个集合。即“从一个大蛋糕中切出一小块来”。
我们以数组为例,从a数组中引用“ [2, 3] ”,语句是这么写的:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
println!("{:?}", slice); // Rust 使用 {:?} 来打印数组切片
//执行结果:[2, 3]
请先不要纠结切片的语法,接下来我们会详细说明。
而对于 Rust 字符串的切片而言,从原理上讲即 “对 String 类型中某一部分的引用”,语句是这么写的:
let s = String::from("hello world");let hello = &s[0..5];
let world = &s[6..11];
hello 没有引用整个 String s,而是引用了 s 的一部分内容,通过 [0…5] 的方式来指定。
这就是创建切片的语法,使用方括号包括的一个序列:[开始索引…终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,也就是这是一个 右半开区间 [a, b) 。
在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引 - 开始索引 的方式计算得来的。
对于 let world = &s[6…11]; 来说,world 是一个切片,该切片的指针指向 s 的第 7 个字节(索引从 0 开始, 6 是第 7 个字节),且该切片的长度是 5 个字节。
在使用 Rust 的 …range 序列语法 时,如果我们想从索引 0 开始,可以使用如下的方式,这两个写法是等效的:
let s = String::from("hello");let slice = &s[0..2];
let slice = &s[..2];
同样的,如果我们的的切片想要包含 String 的最后一个字节,则可以这样使用:
let s = String::from("hello");let len = s.len();let slice = &s[4..len];
let slice = &s[4..];
我们也可以截取完整的 String 切片:
let s = String::from("hello");let len = s.len();let slice = &s[0..len];
let slice = &s[..];
在对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界。
例如中文在 UTF-8 中占用三个字节,下面的代码就会崩溃:
let s = "中国人";let a = &s[0..2];println!("{}",a); //索引是一个右半开区间 [a, b),[0..2]显然拿不到完整的字符
注意:slice 是一种引用,所以它没有所有权。
这点并不难理解,结合上面我们已经提到的例子,slice 它允许我们引用集合中部分连续的元素序列,而不是引用整个集合。
而引用并不涉及 所有权转移,所以它并没有所有权。
(二)字符串Slice:&str
现在我们已经了解了Rust中的切片。接下来我们要引出Rust中很重要的一个概念:在Rust中,字符串切片的类型标识就是 &str
,而我们说的“字符串字面量”类型标识也是 &str
。
现在,我们就能回过头再审视一下我们最初写下的这段报错的代码:
fn main() {let my_name = "Pascal";greet(my_name);
}fn greet(name: String) {println!("Hello, {}!", name);
}
编译器提示 greet 函数需要一个 String 类型的字符串,却传入了一个 &str 类型的字符串。
即:my_name的本质上其实是字符串字面量(&str类型),他并不是一个 动态字符串(String类型)。
也就是说,下面两行代码是写法不同但本质相同的代码:
let my_name = "Pascal";
let my_name: &str = "Pascal";
也许你会有一个问题,既然 my_name 是一个字符串切片引用,它引用的来源是谁呢,是从谁身上切的?
在 Rust 中,字符串字面量(如我们的
"Pascal"
)是在编译时由编译器处理的。当我们在代码中写入一个字符串字面量时,编译器会将其视为一个静态分配的字符串。这些字符串字面量通常存储在程序的只读数据段(在内存中)。当你执行
let my_name = "Pascal";
时,my_name
是一个对字符串字面量的引用。这个引用是一个字符串切片(&str
),它指向存储在只读内存段中的 “Pascal” 字符串。字符串切片
&str
是一个引用,它不拥有数据,只是指向数据。
切片指向了程序可执行文件中的某个点,这也是为什么字符串字面量是不可变的,因为 &str
是一个引用,引用是不能改变它的数据的。
根据这些内容,我们小小修改一下代码,就可以执行成功了:
fn main() {let my_name = "Pascal";greet(my_name);
}fn greet(name: &str) {println!("Hello, {}!", name);
}
//Hello, Pascal!
所有权、引用、借用、slice 这些概念都是为了让 Rust 程序在编译时确保了内存安全。
(三)Rust字符串的定义
顾名思义,字符串是由字符组成的连续集合,但是在上一节中我们提到过,Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间。
Rust 在语言级别上,只有一种字符串类型: str
。
而它通常是以引用类型出现 &str
,也就是上文提到的字符串切片。虽然语言级别只有上述的 str
类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String
类型,即动态字符串。
str
类型是硬编码进可执行文件,也无法被修改,但是 String
则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串(动态字符串),当 Rust 用户提到字符串时,往往指的就是 Strin
类型和 &str
字符串切片类型,这两个类型都是 UTF-8 编码。
除了 String 类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString,OsStr,CsString 和CsStr 等,注意到这些名字都以 String 或者 Str 结尾了吗?
OsString
拥有字符串数据,而OsStr
是其对应的借用类型。CsString
拥有字符串数据,而CsStr
是其对应的借用类型。
(四)动态字符串:String
(1)String 与 &str 的转换
在之前的代码中,已经见到好几种从 &str
类型生成 String
类型的操作:
let s = String::from("hello,world")
let s = "hello,world".to_string()
那么如何将 String
类型转为 &str
类型呢?答案很简单,取引用即可:
fn main() {let s = String::from("hello,world!");say_hello(&s);say_hello(&s[..]);say_hello(s.as_str()); //s.as_str() 创建了一个s字符串的引用
}fn say_hello(s: &str) {println!("{}",s);
}//结果:
//hello,world!
//hello,world!
//hello,world!
(2)字符串索引
在其它语言中,使用索引的方式访问字符串的某个字符或者子串是很正常的行为,但是在 Rust 中就会报错:
let s1 = String::from("hello");
let h = s1[0];
//报错:`String` cannot be indexed by `{integer}`
这是因为 Rust 强调内存安全和数据完整性。在 Rust 中,字符串是以 UTF-8 编码存储的,这意味着一个字符可能由多个字节组成。直接通过字节索引访问可能会导致字符串在逻辑上被错误地分割,从而破坏字符的完整性。
而要在 Rust 中安全地访问字符串中的字符或子串,我们可以使用以下几种方法,简单了解一下:
- 使用 chars() 方法:这个方法返回一个迭代器,迭代器中的每个元素都是字符串中的一个 Unicode 字符。你可以通过迭代器来访问每个字符。
let s = "hello";
for c in s.chars() {println!("{}", c);
}
- 使用 char_indices() 方法:这个方法返回一个迭代器,迭代器中的元素是 (byte_index, char) 形式的元组,其中 byte_index 是字符在字符串中的字节索引。
let s = "hello";
for (i, c) in s.char_indices() {println!("{}: {}", i, c);
}
- 使用 get() 方法:这个方法允许你通过字节索引来安全地访问字符串中的字符。如果索引有效,它会返回 Some(&char),否则返回 None。
let s = "hello";
if let Some(c) = s.get(0) {println!("{}", c);
} else {println!("Index out of bounds");
}
- 使用切片语法:如果你需要访问子串,可以使用切片语法 &s[start…end]。这里的 start 和 end 是字节索引,但 Rust 会确保切片操作不会在字符边界上切割。
let s = "hello";
let slice = &s[0..2]; // "he"
println!("{}", slice);
- 使用 split_off() 方法:如果你需要从字符串中分离出一部分,可以使用 split_off() 方法,它会返回一个新的 String,包含从指定索引开始的所有字符。
let mut s = String::from("hello");
let part = s.split_off(2); // s 现在是 "he", part 是 "llo"
println!("s: {}, part: {}", s, part);
(3)字符串操作
由于 String
是可变字符串,下面介绍 Rust String字符串的修改,添加,删除等常用方法。
(a)追加
在字符串尾部可以使用 push()
方法追加字符 char,也可以使用 push_str()
方法追加字符串字面量。这两个方法都是在原有的字符串上追加,并不会返回新的字符串。
由于字符串追加操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut 关键字修饰。
fn main() {let mut s = String::from("Hello ");s.push_str("rust");println!("追加字符串 push_str() -> {}", s);s.push('!');println!("追加字符 push() -> {}", s);
}//运行结果:
//追加字符串 push_str() -> Hello rust
//追加字符 push() -> Hello rust!
(b)插入
可以使用 insert()
方法插入单个字符 char
,也可以使用 insert_str()
方法插入字符串字面量。与 push()
方法不同,这俩方法都需要传入两个参数,第一个参数是字符(串)插入位置的索引,第二个参数是要插入的字符(串)。
索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut 关键字修饰。
fn main() {let mut s = String::from("Hello rust!");s.insert(5, ',');println!("插入字符 insert() -> {}", s);s.insert_str(6, " I like");println!("插入字符串 insert_str() -> {}", s);
}//运行结果:
//插入字符 insert() -> Hello, rust!
//插入字符串 insert_str() -> Hello, I like rust!
(c)替换
如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace()
方法。与替换有关的方法有三个。
- replace
- replacen
- replace_range
replace 方法可用于
String
和&str
类型。
replace()
方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。
fn main() {let string_replace = String::from("I like rust. Learning rust is my favorite!");let new_string_replace = string_replace.replace("rust", "RUST");println!("{}",new_string_replace);
}//运行结果:
//I like RUST. Learning RUST is my favorite!
replacen 方法可用于
String
和&str
类型。
replacen()
方法接收三个参数,前两个参数与replace()
方法一样,第三个参数则表示替换的个数。该方法是返回一个新的字符串,而不是操作原来的字符串。
fn main() {let string_replace = "I like rust. Learning rust is my favorite!";let new_string_replacen = string_replace.replacen("rust", "RUST", 1);println!("{}",new_string_replace);
}//运行结果:
//I like RUST. Learning rust is my favorite!
replace_range 方法仅适用于
String
类型。
replace_range
接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。该方法需要使用 mut 关键字修饰。
fn main() {let mut string_replace_range = String::from("I like rust!");string_replace_range.replace_range(7..8, "R");println!("{}",new_string_replace);
}
//运行结果:
//I like Rust!
(d)删除
与字符串删除相关的方法有 4 个,他们分别是 pop()
,remove()
,truncate()
,clear()
。这四个方法仅适用于 String 类型。
pop
—— 删除并返回字符串的最后一个字符该方法是直接操作原来的字符串。但是存在返回值,其返回值是一个
Option
(枚举) 类型,如果字符串为空,则返回None
。
fn main() {let mut string_pop = String::from("rust pop 中文!");let p1 = string_pop.pop();let p2 = string_pop.pop();dbg!(p1);dbg!(p2);dbg!(string_pop);
}/*
p1 = Some('!',
)
p2 = Some('文',
)
string_pop = "rust pop 中"
*/
PS:dbg!()
宏是一个非常有用的调试工具。它允许你在运行时打印变量的值,而不需要添加任何额外的打印语句。
remove
—— 删除并返回字符串中指定位置的字符该方法是直接操作原来的字符串。但是存在返回值,其返回值是删除位置的字符串,只接收一个参数,表示该字符起始索引位置。
remove
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
fn main() {let mut string_remove = String::from("测试remove方法");println!("string_remove 占 {} 个字节",std::mem::size_of_val(string_remove.as_str()));// 删除第一个汉字string_remove.remove(0);// 下面代码会发生错误// string_remove.remove(1);// 直接删除第二个汉字// string_remove.remove(3);dbg!(string_remove);
}//string_remove 占 18 个字节
//string_remove = "试remove方法"
truncate
—— 删除字符串中从指定位置开始到结尾的全部字符该方法是直接操作原来的字符串。无返回值。该方法
truncate()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
fn main() {let mut string_truncate = String::from("测试truncate");string_truncate.truncate(3);dbg!(string_truncate);
}//string_truncate = "测"
clear
—— 清空字符串该方法是直接操作原来的字符串。调用后,删除字符串中的所有字符,相当于
truncate()
方法参数为 0 的时候。
fn main() {let mut string_clear = String::from("string clear");string_clear.clear();dbg!(string_clear);
}//string_clear = ""
(e)连接
使用
+
或者+=
连接字符串
使用 +
或者 +=
操作符连接字符串时,要求操作符右边的参数必须为字符串的切片引用(Slice)类型。
其实当调用 +
的操作符时,相当于调用了 std::string
标准库中的 add() 方法,这里 add()
方法的第二个参数是一个引用的类型。因此我们在使用 +
时, 必须传递切片引用类型,不能直接传递 String
类型。
+
是返回一个新的字符串,所以变量声明可以不需要 mut
关键字修饰。
fn main() {let string_append = String::from("hello ");let string_rust = String::from("rust");let result = string_append + &string_rust; // &string_rust会自动解引用为&strlet result = result + "!"; // `result + "!"` 中的 `result` 是不可变的println!("连接字符串 + -> {}", result);
}//连接字符串 + -> hello rust!
使用
format!
连接字符串
format!
这种方式适用于 String
和 &str
。format!
的用法与 print!
的用法类似。
fn main() {let s1 = "hello";let s2 = String::from("rust");let s = format!("{} {}!", s1, s2);println!("{}", s);
}//hello rust!
(五)String与&str总结
在 Rust 中,String
和 &str
都是处理字符串数据的类型,但它们之间的关系和用途有所不同。以下是它们之间的关系和区别:
- 所有权与借用:
String
拥有其数据,它是一个动态分配在堆上的可变字符串类型。这意味着String
可以增长和缩小,并且可以被修改。&str
是一个字符串切片,它是一个对字符串数据的不可变引用。它不拥有数据,而是借用了其他字符串数据的一部分或全部。
- 内存管理:
String
需要管理其在堆上的内存,因此它需要负责分配和释放内存。这使得String
可以动态地改变其大小。&str
只是一个引用,它不需要管理内存,因为它指向的数据可能来自String
、字符串字面量或其他数据源。
- 可变性:
String
是可变的,你可以更改其内容,如添加、删除或修改字符。&str
是不可变的,你不能更改它指向的数据。如果你需要修改数据,你必须在String
上进行操作。
- 使用场景:
- 当你需要一个可以修改的字符串时,使用
String
。 - 当你需要读取或处理字符串数据,但不需要修改它时,使用
&str
。
- 当你需要一个可以修改的字符串时,使用
- 转换:
- 你可以从
String
创建一个&str
引用,只需通过引用String
实例即可,如&my_string
。 - 你也可以从字符串字面量创建一个
&str
引用,因为字符串字面量本身就是&str
类型。 - 从
&str
创建String
可以通过to_string()
方法或String::from()
函数,这涉及到从不可变引用到可变数据的转换,可能需要复制数据。
- 你可以从
- 生命周期:
String
拥有其数据,因此它不受生命周期的限制。&str
是一个引用,它依赖于数据的生命周期。如果&str
引用的数据超出了作用域,那么&str
引用将不再有效。
总的来说,String
和 &str
在 Rust 中提供了灵活的字符串处理机制,允许你根据需要选择最合适的类型。String
提供了可变性和动态内存管理,而 &str
提供了对字符串数据的不可变引用,这两者在不同的场景下都非常有用。