基础篇 (11讲)
字符串,最频繁、太常见:
如 Web 开发、解析器等,主要就是处理字符串。
Rust 里字符串:
String, &String,
str, &str, &'static str
[u8], &[u8], &[u8; N], Vec
as_str(), as_bytes()
OsStr, OsString
Path, PathBuf
CStr, CString
Rust 字符串的复杂性:
C 的字符串叫 char *,是统一的抽象,简洁,代价是丢失了很多额外的信息。
抽象是用来解决现实问题建模的工具。
Rust 把字符串在各种场景下的使用给模型化、抽象化了。比 C 的 char *,多了建模的过程,模型里多了很多额外的信息。
fn main() {
let s1: &'static str = "I am a superman.";
let s2: String = s1.to_string();
let s3: &String = &s2;
let s4: &str = &s2[..];
let s5: &str = &s2[..6];
}
4 种不同类型的字符串表示。在内存中的结构图。
s1:"I am a superman." (双引号括起来部分)字符串的字面量,存在静态数据区。s1 是指向静态数据区中字符串的切片引用,形式是 &'static str,这是静态数据区中的字符串的表示方法。
s2:执行 s1.to_string(),Rust 将静态数据区中的字符串字面量拷贝了一份到堆中,通过指向,s2 具有堆内存字符串的所有权,String 代表具有所有权的字符串。
s3 是对 s2 的不可变引用,类型为 &String。
s4 是对 s2 的切片引用,类型是 &str。切片是一块连续内存的某种视图,提取目标对象的全部或一部分。s4 取的目标对象字符串的全部。
s5 是对 s2 的另一个切片引用,类型是 &str。与 s4 不同,s5 是 s2 的部分视图。是 "I am a" 这一部分。
详细解释:
&str 既可引用堆中的字符串,也可引用静态数据区的字符串(&'static str 是 &str 的一种特殊形式)。内存本来就是一个线性空间,一个指针(引用是指针的一种)可指向线性空间中的任何地址。
&str 可转换为 String。
let s: String = "I am a superman.".to_string();
let a_slice: &str = &s[..];
let another_String: String = a_slice.to_string();
切片(slice),是一段连续内存的一个视图(view),用 [T] 表示,T 为元素类型。视图可以是连续内存的全部或一部分。切片通过切片的引用来访问。
let s = String::from("abcdefg");
let s1 = &s[..]; // s1 内容是 "abcdefg"
let s2 = &s[0..4]; // s2 内容是 "abcd"
let s3 = &s[2..5]; // s3 内容是 "cde"
s 是堆内存中所有权型字符串类型。
s1 作为 s 的一个切片引用,也指向堆内存中那个字符串的头部,表示 s 的完整内容。
s2 与 s1 指向的堆内存地址是相同的,但内容不同,s2 是 "abcd", s1 是 "abcdefg"。
s3 是 s 的中间位置的一段切片引用,内容是 "cde"。s3 指向的地址与 s、s1、s2 不同。
字符串切片引用,可以转换成所有权型字符串:
let s: &str = "I am a superman.";
let s1: String = String::from(s); // 使用 String 的from构造器
let s2: String = s.to_string(); // 使用 to_string() 方法
let s3: String = s.to_owned(); // 使用 to_owned() 方法
不直接与字符串相关,但具有类比性。
Vec
let a_vec: Vec = vec![1,2,3,4,5,6,7,8];
// a_slice 是 [1,2,3,4,5]
let a_slice: &[u8] = &a_vec[0..5];
// 用 .to_vec() 方法将切片转换成Vec
let another_vec = a_slice.to_vec();
// 或者用 .to_owned() 方法
let another_vec = a_slice.to_owned();
整理出一个对比表格。
String类型上 as_str() 方法,返回 &str 类型。等价于 &a_string[..],包含完整字符串内容的切片。
let s = String::from("foo");
assert_eq!("foo", s.as_str());
String 类型上 as_bytes()方法,返回 &[u8] 类型。
let s = String::from("hello");
assert_eq!(&[104, 101, 108, 108, 111], s.as_bytes());
上面两个示例,显示这两个方法的不同之处。
猜想 &str 也可以转成 &[u8] ,查询标准库文档发现,用的正是同名方法。
let bytes = "bors".as_bytes();
assert_eq!(b"bors", bytes);
Vec 上 as_slice() 函数,与 String 上 as_str() 对应,把完整内容转换成切片引用 &[T],等价于 &a_vec[..]。
let a_vec = vec![1, 2, 3, 5, 8];
assert_eq!(&[1, 2, 3, 5, 8], a_vec.as_slice());
Rust 中 &String 与 &str 其实是不同的。这种细节的区分,会造成一些不方便,还比较常见:
fn foo(s: &String) {
}
fn main() {
let s = String::from("I am a superman.");
foo(&s);
let s1 = "I am a superman.";
foo(s1);
}
上例中,函数的参数类型定义成 &String。函数调用时,函数只接受 &String 类型的参数传入。一个字符串字面量变量,想传进 foo 函数中,不行。
error[E0308]: mismatched types
--> src/main.rs:8:7
|
8 | foo(s1); // error on this line
| --- ^^ expected `&String`, found `&str`
| |
| arguments to this function are incorrect
|
= note: expected reference `&String`
found reference `&str`
Rust 严格的一面。可以通过一些精妙的机制,实现一定程度上的灵活性:
fn foo(s: &str) { // 只需要把这里的参数改为 &str 类型
}
fn main() {
let s = String::from("I am a superman.");
foo(&s);
let s1 = "I am a superman.";
foo(s1);
}
把 foo 参数的类型由 &String 改为 &str,编译通过了。为什么?
Rust 中对 String 做引用操作时,若想把 &String 直接转换到 &str 类型,在代码中明确指定目标类型就可以了。
let s = String::from("I am a superman.");
let s1 = &s;
let s2: &str = &s;
于是 foo() 函数中,只定义一种参数,就可以接收两种入参类型:&String 和 &str。这让函数的调用更符合直觉,使用更方便了。
具体是怎么做到的呢?知识点:Deref。
同理,可以作用在 String 上,也可以作用在 Vec
示例:一个函数可以接受 &Vec
fn foo(s: &[u32]) {
}
fn main() {
let v: Vec = vec![1,2,3,4,5];
foo(&v);
let a_slice = v.as_slice();
foo(a_slice);
}
as_bytes() 方法将字符串转换成 &[u8]。
相反的操作,把 &[u8] 转换成字符串。
Rust 的字符串实际是 UTF-8 序列,转换的过程与 UTF-8 编码相关。
注意 :from_utf8 系列函数,返回 Result。Rust 的严谨性要求对这种转换不成功的情况做严肃的自定义处理。其他语言,对于这种转换不成功的情况往往用一种内置的策略做处理,而无法自定义。
&str 类型 chars() 函数,可把字符串转换为一个迭代器(迭代器是一种通用的抽象,用来按顺序安全迭代)。通过这个迭代器,就可以取出 char。用法:
fn main() {
let s = String::from("中国你好");
let char_vec: Vec = s.chars().collect();
println!("{:?}", char_vec);
for ch in s.chars() {
println!("{:?}", ch);
}
}
输出:
['中', '国', '你', '好']
'中'
'国'
'你'
'好'
Path、PathBuf、OsStr、OsString、CStr、CString。是具体场景下的字符串,比 String 或 &str,包含了更多的特定场景的信息。
1、Path 类型,要处理跨平台的目录分隔符(Unix 是 /,Windows 是\),以及一些其他信息。而 PathBuf 与 Path 的区别就对应于 String 与 str 的区别。
2、OsStr 的存在:各个操作系统平台上的原生字符串定义是不同的。
开发平台相关的应用,往往需要处理这种类型转换的细节,有了 OsStr 类型。而 OsString 与 OsStr 的关系对应于 String 与 str 的关系。
3、CStr 是 C 语言风格的字符串,字符串以 0 这个字节作结束符,在字符串中不能包含 0。Rust 要无缝集成 C 的能力。所以这些类型出现在 Rust 中就很合理了。而 CString 与 CStr 的关系就对应于 String 与 str 的关系。
这些平台细节的处理相当繁琐和专业,Rust 把已处理好这些细节的类型提供给我们,我们直接使用就好了。
理解了这一点,还觉得 C 语言中唯一的 char * 是更好的设计吗?
需要理解 Rust 中为什么存在这些类型,这些类型之间的关系。
str 的 parse() 方法非常强大,可从字符串转换到任意 Rust 类型,只要这个类型实现了 FromStr 这个 Trait(Trait 是 Rust 中一个极其重要的概念)即可。
把字符串解析成 Rust 类型(有不成功的可能),返回 Result 。
fn main() {
let a = "10".parse::();
let aa: u32 = "10".parse().unwrap(); // 这种写法也很常见
println!("{:?}", a);
let a = "10".parse::();
println!("{:?}", a);
let a = "4.2".parse::();
println!("{:?}", a);
let a = "true".parse::();
println!("{:?}", a);
let a = "a".parse::();
println!("{:?}", a);
let a = "192.168.1.100".parse::();
println!("{:?}", a);
}
可以看看哪些标准库类型已实现了 FromStr trait。
parse() 函数相当于 Rust 语言内置的统一的解析器接口,若自己实现的类型要与字符串互相转换,要考虑实现这个接口 --> Rust 地道风格。
对于更复杂和更通用的与字符串转换的场景,更倾向于序列化和反序列化的方案。Rust 生态中有标准的方案——serde(序列化框架),支持各种数据格式协议,功能非常强大、统一。
相比 C 中的字符串,Rust 把字符串按场景划分成了不同的类型,每种类型都包含有不同的额外信息。通过将研究目标(字符串)按场景类型化,在代码中加入了更多的信息,让编译器做更多的校验和推导的事情,确保程序的正确性,并尽可能做性能优化。
新的概念,如迭代器、Trait 等,
主要任务:
字符串在 Rust 代码中使用广泛,贯穿整个课程。一定多加练习,牢牢掌握字符串相关类型在不同场景下的转换以及一些常用的方法。
chars 函数是定义在 str 上的,为什么 String 类型能直接调用 str 上定义的方法?实际上 str 上的所有方法,String 都能调用,请问这是为什么呢?
答:
1.String类型为struct类型,实现了Deref特征。
2.当String类型调用chars方法时,编译器会检查String类型是否实现了chars方法,检查项包括self,&self,&mut self
3.如果都没有实现chars方法,编译器则调用deref方法解引用(智能指针),得到str,此时编译器才会调用chars方法,也就是可以调用str实现的所有方法
在 Rust 中,String 是一个可变的字符串类型,是用结构体定义的,而且实现了 Deref trait。str 是一个不可变的字符串切片类型。当调用一个 str 上的方法时,实际上就是通过 Deref 的自动转换机制(解引用),将 String 转换为对应的 str 切片,从而可以调用 str 上的方法。
什么时候用to_owned(),什么时候用to_string()呢?
答: to_owned() 是更通用的,目的就是在只能拿到引用的情况下获得所有权(通过克隆一份资源)。to_string() 就只是转成字符串而已,这两个用法重叠,但是不完全相同。