Rust 入门 复合类型 字符串+切片(六)

从现在开始,我们正式踏入Rust大陆, 这边广袤而神秘的世界,在这个世界中,将接触到很多之前都没有听过的概念:

        1. 所有权,借用,声明周期

        2. 宏编程

        3. 模式匹配

类似的还有很多.不过不用怕,: 咱上面有人.有本书在,一切虚妄终将烟消云散.

本章主要介绍Rust的基础语法,数据类型,项目结构,学完本章,你将对Rust代码有一个清晰,完整的认识.

字符串

在其它语言中,字符串往往是送分题,因为实在是太简单了.例如"hello, world" 就是字符串章节的几乎全部内容了. 如果你带着同样的想法来学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
 

Bingo 果然报错了. 编译器提示 greet函数需要一个String 类型的字符串,却传入了一个&str类型的字符串,相信读者心中现在一定有几头马呼啸而过,怎么字符串也能整出这么多花活? 

在讲解字符串之前,先来看看什么是切片 ? 

切片(slice)

切片并不是Rust独有的概念 在Go语言中就非常流程,它允许你引用集合中部分连续的元素序列,而不是引用整个集合.

对于字符串而言,切片就是对String类型中某一部分的引用,它看起来像这样

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

let hello = &s[0..5];

let world = &s[6..11];

hello 没有引用整个String s , 而是引用了 s 的一部分内容, 通过[0..5]的方式来指定. 

这就是创建切片的语法, 使用方括号包括的一个序列:[开始索引.终止索引] ,其中开始索引是切片中第一个元素的索引位置, 而终止索引是最后一个元素后边的索引位置. 欢聚话说. 这是一个 右半开区间 (或称为左闭右开区间) -- 指的是在区间的左端点是包含在内的,而右端点是不包含在内的. 在切片数据接口内部会保存开始的位置和切片的长度, 其中长度是通过 终止索引-开始索引 的方式计算得来得. 

对于 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);

因为我们只取s字符串的前两个字节,但是本例中每个汉字占用三个字节,因此没有落在边界处,也就是连 太 字都取不完整,此时程序会直接崩溃推出,如果改成 &s[0..3], 则可以正常通过编译. 因此, 当你需要对字符串做切片索引操作时, 需要格外小心这一点

字符串切片的类型标识时&str,因此我们可以这样声明一个函数, 输入String 类型, 返回它的切片 : 

fn first_word(s:&String) -> &str.

有了切片就可以写出这样的代码: 

fn main() {
    let mut s = String::from("hello world");
    let word = first_word($s);
    
    s.clear(); // error ! 
    println!("the first word is :{} " ,word );
}

fn first_word(s:&String) -> &str {
    &s[..1]
}

编译器报错如下: 

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {}", word);
   |                                       ---- immutable borrow later used here
 

回忆以下借用的规则:  当我们已经有了可变借用时, 就无法在拥有不可变的借用.因为 clear 需要清空改变String , 因此他需要一个可变借用, ( 利用VSCode 可以看到该方法的声明 是 pub fn clear(&mut self) ,  参数是对自身的可变借用) ; 而之后的 println! 又使用了不可变借用,也就是在 s.clear();  处可变借用与不可变借用试图同时生效 ,因此编译无法通过. 

从上述代码可以看出, Rust 不仅让我们的api 更加容易使用, 而且也在编译期就消除了大量错误!

其他切片

因为切片是对集合的部分引用,因此不仅仅字符串有切片, 其他集合类型也有, 例如数组 

let a = [1,2,3,4,5];
let slice = &a[1..3];

assert_eq!(slice , &[2..3]);

该数组切片的类型是 &[i32] , 数组切片和字符串切片的工作方式是一样的, 例如持有一个引用指向原始数组的某个元素和长度. 

字符串字面量切片

之前提到过字符串字面量, 但是没有提到它的类型: 

let s = "Hello , world !";

实际上, s 的类型是&str, 因此你也可以这样声明

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

该切片指向了程序可执行文件中的某个点, 这也是为什么字符串字面量是不可变的.因为&str是一个不可变引用. 

什么是字符串?

顾名思义, 字符串是由字符组成的连续集合. 但是在上一节中我们提到过, Rust 中的字符是 Unicode类型, 因此每个字符 占据4个字节内存空间, 但是在字符串中不一样, 字符串是UTF-8编码, 也就是字符串中的字符所占的字节数是变化的 (1-4), 这样有助于大幅降低字符串所占用的内存空间. 

Rust在语言级别,只有一种字符串类型: str  , 它通常是以引用类型出现 &str, 也就是上文提到的字符串切片. 虽然语言级别只有上述的 str 类型, 但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String类型. 

str 类型是硬编码进可执行文件,也无法被修改, 但是String 则是一个可增长,可改变且具有所有权的UTF-8编码字符串, 当Rust用户提到字符串时, 往往指的就是String类型和&str字符串切片类型, 这两个类型 都是 UTF-8编码 . 

除了String 类型的字符串, Rust的标准库还提供了其他类型的字符串, 例如 OsString , OsStr, CsString 和 CsStr等, 注意到这些名字都以String 或者 Str 结尾了么? 他们分别对应的时具有所有权和被借用的变量. 

String 与 &str 的转换 

在之前的代码中, 已经见到好几种从&str类型生成String 类型的操作: 

        String::from("hello,world")

        "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());
}

fn say_hello(s:&str){
    println!("{}",s);
}

实际上这种灵活用法, 是因为deref隐式强制转换, 具体我们会在 Deref 特征进行详细讲解.

字符串索引

在其他语言中, 使用索引的方式访问字符串的某个字符或者字串是很正常的行为, 但是在Rust中就会报错: 

let  s1 = String::from("hello");
let h = s1[0];

该代码会产生以下错误

3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

深入字符串内部

字符串的底层的数据存储格式实际上是[u8], 一个字节数组. 对于 let  hello = String::from("Hola");,这行代码来说. Hola的长度是 4 个字节, 因为 "Hola" 中的每个字母在 UTF-8 编码中仅占用1个字节, 但是对于下边的代码呢?

let hello = String::from("太棒了");

如果问你该字符串多长, 你可能会说3, 但是实际上是9个字节的长度, 因为大部分常用汉字在UTF-8中的长度是3个字节, 因此 这种情况下 对 hello 进行索引, 访问 &hello[0] 没有任何意义, 因为你取不到 太 这个字符, 而是取到了这个字符三个字节中的第一个自己个, 这是一个非常奇怪而且难以理解的返回值. 

字符串的不同表现形式 

现在看一下用梵文些的字符串  “नमस्ते” ,它底层的字节数组如下形式: 

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]

长度是18个字节, 这也是计算机最终存储该字符串的形式,如果从字符的形式去看.则是 

['न', 'म', 'स', '्', 'त', 'े']

但是这种形势下, 第四和六两个字符根本就不存在,没有任何意义, 接着再从字母串的形式去看 

["न", "म", "स्", "ते"]

所以,可以看出来Rust提供了不同的字符串展现方式,这样程序可以挑选自己下个要的方式取使用,而无需去管字符串从人类语言角度看长什么样.

还有一个原因导致了Rust不允许去索引字符串:   因为索引操作,我们总是期望它的性能表现是 O(1), 然而对于 String 类型来说. 无法保证这一点, 因为Rust 可能需要从 0开始去遍历字符串来定位合法的字符. 

字符串切片

前文提到过, 字符串切片是非常危险的操作.因为切片的索引是通过字节来进行. 但是字符串又是UTF-8编码, 因此你无法保证索引的字节刚好落在字符的边界上. 例如 

let hello = "太棒了";
let s = &hello[0..2];

运行上边的程序, 会直接造成崩溃

thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '中' (bytes 0..3) of `太棒了`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

这里提示的很清楚, 我们索引的字节落在了 太 字符的内部, 这种返回没有任何意义. 

因此在通过索引区间来访问字符串时, 需要格外的小心. 一个不注意,就会导致你的程序崩溃

操作字符串

由于 String 是可变字符串, 下面介绍Rust字符串的修改, 添加,删除等常用方法 ; 

追加(Push)

在字符串尾部可以使用 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);
}
插入(insert)

可以使用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);
}
替换(Replace)

如果想要把字符串中的某个字符串替换成其他的字符串,那可以使用 replace() 方法. 与替换有关的方法有三个. 

1. 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");
    dbg!(new_string_replace);
}

2. 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);
    dbg!(new_string_replacen);
}

3. 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");
    dbg!( string_replace_range);
}
删除(Delete)

与字符串删除相关的方法有 4个 ,分别是 pop() , remove() , truncate() , clear(). 这四个方法仅适用于 String 类型. 

1. 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);
}

2. 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);
}

3. truncate  -- 删除字符串中从指定位置开始到结尾的全部字节 

        该方法是直接操作原来的字符串,无返回值. 该方法 truncate() 方法是按照字节来处理字符串的. 如果参数所给的位置不是合法的字符边界, 则会发生错误.

fn main() {
    let mut string_truncate = String::from("测试truncate");
    string_truncate.truncate(3);
    dbg!(string_truncate);
}

4. clear -- 清空字符串

        该方法是直接操作原来的字符串,调用后,删除字符串中的所有字符, 相当于truncate() 方法参数为0的时候. 

fn main() {
    let mut string_clear = String::from("string clear");
    string_clear.clear();
    dbg!(string_clear);
}

连接(Concatenate)

1. 使用 + 或者 +=  连接字符串. 

使用 + 或者 += 连接字符串, 要求右边的参数必须为字符串的切片引用 (Slice) 类型. 其实当调用+的操作符时, 相当于调用了std::string 标准库中的 add() 方法. 这里add()方法 的第二个参数是一个引用的类型, 因此我们在使用 + 时, 必须传递切片引用类型, 不能直接传递String 类型, + 是返回一个新的字符串, 所以变量声明可以不需要mut 关键字修饰 

fn main() {
    let string_append = String::from("hello ");
    let string_rust = String::from("rust");
    // & string_rust 会自动解引用为&str
    let result = string_append + &string_rust;
    let mut result = result + "!" ; // 'result + "!"' 中的'result' 是不可变的
    result += "!!!";
    println!("连接字符串 + -> {} ", result );
}

add()方法的定义: 

fn add(self,s:&str) -> String 

该方法涉及到更复杂的特征功能, 因此我们这里简单说明下: 

fn main(){
    let s1 = String::from("Hello,");
    let s2 = String::from("world!");
    // 在下句中, s1的所有权被转移走了. 因此后边不能在使用s1
    let s3 = s1 + &s2;
    assert_eq!(s3,"hello,world!");
    // 下面额语句如果去掉注释,就会报错
    // println!("{}",s1);
    
}

self 是String 类型的字符串 s1, 该函数说明, 只能将 &str 类型的字符串切片添加到String 类型的s1上, 然后返回一个新的String 类型, 所以 let s3 = s1 + &s2; 就很好解释了, 将String 类型的s1 与 &str类型的 s2 进行想加, 最后得到 String 类型的s3. 

以此类推, 以下代码也是合法的. 

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

// String = String + &str + &str + &str + &str
let s = s1 + "-" + &s2 + "-" + &s3;

String + &str 返回一个 String ,然后在继续跟一个 &str 进行 + 操作.,  返回一个String 类型. 不断循环, 最终生成一个s ,也是 String 类型 

s1 这个变量通过 add()方法后, 所有权被转移到 add() 方法里面, add() 方法调用后就被释放了, 同时 s1 也被释放了, 在使用s1 就会发生错误. 这里涉及到所有权转移的相关 知识 . 

2. 使用format!  连接字符串 

        format! 这种方式适用于 String  和 &str . format!  的用法与 print! 的用法类似. 详见 格式化输出.

fn main() {
    let s1 = "hello";
    let s2 =  String::from("rust");    
    let s = format!("{} {} !",s1,s2);
    println!("{}",s);
}

字符串转义

我们可以通过转义的方式 \ 输出 ASCII 和 Unicode 字符

fn main() {
    // 通过 \ +字符的十六进制表示, 转义输出一个字符
    let byte_escape = "I'm writing \x52\x75\x73\x74!";
    println!("What are you doing \x3f (\\x3F means ? ) {}",byte_escape);
    
    // \u 可以输出一个 unicode 字符 
    let unicode_codepoint = "\u{2110}";
    let character_name = "\"DOUBLE-STRUCK CAPITAL R\"";
    
    println!( "Unicode character {} (U+211D) is called {}",
        unicode_codepoint, character_name );
    
    // 换行了也会保持之前的字符串格式
    // 使用\忽略换行符
    let  long_string = "String literals can span multiple lines. The linebreak and indentation here -> \ <- can bi escaped too ! ";
    println!("{}",long_string );
    
}

当然,在某些情况下, 可能你会希望保持字符串的鸳鸯, 不要转移: 

fn main() {
    println!("{}", "hello \\x52\\x75\\x73\\x74");
    let raw_str = r"Escapes don't work here: \x3F \u{{211D}";
    println!("{}",raw_str);

    // 如果字符串包含双引号, 可以在开头和结尾加 # 
    let quotes = r#"And then I said: "There is no escape!""#;
    println!("{}",qutoes);
    
    // 如果字符串中包含 # 号, 可以在开头和结尾加多个# 号, 最多加255个,只需要保证与字符串中连续#号的个数不超过开头和结尾的#号的个数即可
    let longer_delimiter = r###"A string with "# in it. And even "##!"###;
    println!("{}",loger_delimiter);
    
}

操作UTF-8字符串

前文提到了几种使用UTF-8字符串的方式, 下面来一一说明. 

字符

如果你想要以Unicode 字符的方式遍历字符串, 最好的办法是使用chars方法, 例如

for c in "太棒了".chars() {
    println!("{}",c);
}
字节

这种方式是返回字符串的底层字节数组表现形式

for b in "太棒了".bytes(){
    println!("{}",b);
}
获取字串

想要准确的从UTF-8 字符串中获取子串是较为复杂的事情,例如想要从 halla太棒了नमस्ते 这种边长的字符串中取出某一个字串,使用标准库你是做不到的,  你需要在creates.io 上搜索 utf8 来寻找想要的功能.  

可以考虑尝试下这个库: utf8_slice 

字符串深度剖析

那么问题来了, 为啥String  可变, 而字符串字面值 str 却不可以? 

就字符串字面值来说, 我们在编译时就知道其内容, 最终字面值文本被直接硬编码进可执行文件中, 这使得字符串字面值快速且高效, 这主要得益于字符串字面值的不可变性. 不幸的时,我们不能为了获得这种性能, 而把每一个在编译时大小位置的文本都放进内存中 (你也做不到! ) , 因为有的字符串时在程序运行的过程中动态生成的. 

对于 String 类型, 为了支持一个可变, 可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容, 这些都是在程序运行时完成的;

        首先向操作系统请求内存来存放 String 对象. 

        在使用完成后, 将内存释放, 归还给操作系统 

其中第一部分由 String::from 完成, 它创建了一个全新的 String .

重点来了, 到了第二部分, 就是百家齐放的环境, 在有垃圾回收 GC 的语言中, GC来负责标记并清除这些不在使用的内存对象, 这个过程都是自动完成, 无需开发者关心, 非常简单好用. 但是在无GC的语言中, 需要开发者手动去释放这些内存对象, 就像创建对象需要通过编写代码来完成一行, 未能正确释放对象 造成的后果简直不可估量. 

对于Rust 而言, 安全和性能时写道骨子里的核心特性, 如果使用GC,那么会牺牲性能; 如果使用手动管理内存,那么会牺牲安全性, 这该怎么办? 为此,Rust的开发者想出了一个无比惊艳的办法: 变量在离开作用域后,就自动释放其占用的内存: 

{
    let s = String::from("hello"); // 从此处起, s是有效的
    
    // 使用 s 
    
} //  此作用域已结束, s不在有效,内存被释放

 与其他系统编程语言的  free 函数相同, Rust 也提供了一个释放内存的函数,  drop ,但是不同的是, 其他语言要手动调用 free来释放每一个变量占用的内存. 而Rust则在变量离开作用域是, 自动调用drop函数: 上面代码中, Rust 在结尾的 } 处自动调用 drop. 

其实,在C++中, 也有这种概念: Resource  Acquisition Is initalization (RAII). 如果你使用过RAII模式的话应该对 Rust的 drop 函数并不陌生.

这个模式对编写Rust代码的方式有着深远的影响, 在后边章节我么那会进行更深入的介绍. 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值