Rust | 02. 包管理、集合(String、Vector、HashMap)、泛型

  • 代码组织需要包括哪些细节可以暴露,哪些细节可以私有,作用域内哪些名称有效等等;Rust的代码组织(也可以叫模块系统)如下:
    • Package(包),是Cargo的特性,让你构建、测试、共享crate
    • Crate(单元包),一个模块树,它可以产生一个library或可执行文件
    • Module(模块)、use,让你控制代码的组织、作用域、私有路径
    • Path(路径),是为Struct、function、module等项命名的方式
  • Crate有两种类型,即binary或library;Crate root是指源代码文件,Rust编译器会从这里开始,组成你Crate的根Module;一个Package包含了一个Cargo.toml文件(用于描述了如何构建这些Crates),一个package只能包含0-1个library crate,以及任意数量的binary crates,另外,一个library至少包含一个crate(library或binary)

  • 通常而言,src/main.rs是binary crate的crate root,且crate名与package名相同;而在library crate中,则会生成一个叫做src/lib.rs的文件,其是该library crate的crate root,此时crate的名字也与package名相同;一个package中也可以同时这两个文件,此时该package中包含两个crate,一个binary一个library,都与package名相同;一个package可以有多个binary crate,文件放在src/bin目录下,每个文件是一个单独的binary crate

  • Crate的作用:将相关功能组合到一个作用域内,便于在项目间共享,防止冲突;例如rand crate,就可以使用它的名字访问其相关功能

  • 定义Module可以控制作用域和私有性:在一个crate内,将代码进行分组,增强可读性,易于复用,可以使用public、private关键字控制项目(item)的私有性;使用mod关键字建立module,其可以嵌套,类似于如下的实例

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    mod front_of_house{
        mod hosting{
            fn add_to_waiting(){ }
            fn seat_at_table(){ }
        }
    
        mod serving{
            fn take_order(){ }
            fn serve_order(){ }
            fn take_payment(){ }
        }
    }
    
  • 路径用于在rust中找到某个模块,有两种形式:

    • 绝对路径,从crate root开始,使用crate名或字面值"crate"
    • 相对路径,从当前模块开始,使用self、super或当前模块标识符
  • 路径至少由一个标识符组成,标识符之间用::分隔,例如

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    // src/lib.rs
    
    mod front_of_house{
        // 需要声明公开,否则无法外部访问
        pub mod hosting{
            pub fn add_to_waiting(){ }
        }
    }
    
    pub fn eat_at_restaurant(){
        // 使用绝对路径,从顶部crate起始,逐级向下(使用crate字面量)
        crate::front_of_house::hosting::add_to_waiting();
    
        // 相对路径,因为和顶级模块在同一级,可从模块名开始调用
        front_of_house::hosting::add_to_waiting();
    }
    
  • PS:定义的位置和使用的位置始终一起移动时,可以选择使用相对路径;但为了避免错误,有经验的程序员会直接写成绝对路径

  • 模块不仅仅可以组织代码,还可以定义私有边界;Rust中的所有条目(函数、方法、struct、模块、常量)都是私有的;父级模块无法访问子模块中的私有条目,而在子模块中可以使用所有祖先模块中的条目

  • 使用pub关键字可以使模块对外暴露;根级模块可以相互调用,无论共有还是私有

  • super关键字用于访问父级模块中的内容,相当于文件系统中的两个点..

    1
    2
    3
    4
    5
    6
    7
    8
    
    fn serve_order(){ }
    mod back_of_house{
        fn fix_incorrect_order(){
            cook_order();
            super::serve_order(); //相对路径
        }
        fn cook_order(){ }
    }
    
  • 当将pub关键字放在struct前,struct就是公共的,但字段如果没有加pub修饰时,字段仍旧是私有的;即是说,struct的字段需要单独设置pub来变成共有

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    mod back_of_house {
        pub struct Breakfast {
            pub toast: String,
            seasonal_fruit: String,
        }
    
        impl Breakfast {
            pub fn summer(toast: &str) -> Breakfast {
                Breakfast{
                    toast: String::from(toast),
                    seasonal_fruit: String::from("peaches"),
                }
            }
        }
    }
    
    pub fn eat_at_restaurant(){
        let mut meal = back_of_house::Breakfast::summer("Rye"); // 使用关联函数初始化
        meal.toast = String::from("Wheat"); //pub公共字段可以修改
        println!("I'd like {} toast please", meal.toast);
        meal.seasonal_fruit = String::from("Blueberries"); // 这一句会报错,因为season_fruit是私有的
    }
    
  • 使用pub关键字在enum前,可以把枚举类型变为公共类型,需要注意的是,当枚举类型为公共时,其变体默认也为公共类型(只有公共的才有用)

  • 可以使用use关键字将路径导入到当前作用域内(类似Linux的符号链接),但仍旧遵循私有原则

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    mod front_of_house {
        pub mod hosting {
            pub fn add_to_waitlist(){ }
        }
    }
    
    use crate::front_of_house::hosting; // 使用绝对路径
    use front_of_house::hosting; // use也可以使用相对路径
    
    pub fn eat_at_restaurant(){
        hosting::add_to_waitlist();
        hosting::add_to_waitlist();
        hosting::add_to_waitlist();
    }
    
  • 在上面的案例中,有经验的程序员在引入一个函数时,是引入到函数的上一级,而不是直接到函数名(crate::front_of_house::hosting::add_to_waitlist),这是想要强调,该函数是在hosting模块下的,如果直接引入函数名,则会与本地定义函数混淆、无法区分

  • 但对于引入struct、enum类型时,可以直接指定完整路径到其本身;但对于同名条目,还是需要指定到父级,否则会混淆

    1
    2
    3
    4
    5
    
    use std::collections::HashMap;
    fn main(){
        let mut map = HushMap::new();
        map.insert(1, 2);
    }
    
    1
    2
    3
    4
    5
    
    use std::fmt;
    use std::io;
    fn f1() -> fmt::Result { }
    fn f2() -> io::Result{ }
    fn main() { }
    
  • as关键字可以为引入的路径指定一个本地的别名

    1
    2
    3
    4
    5
    
    use std::fmt::Result;
    use std::io::Result as IoResult;
    fn f1() -> Result { }
    fn f2() -> IoResult { }
    fn main() { }
    
  • 使用use将路径(名称)导入到作用域后,该名称在此作用域内是私有的。例如下面的代码,函数eat_at_restaurant()对于外部代码而言是公开的;但名称hosting却对外面是默认私有的,需要在use语句前使用pub关键字,将其公开

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    mod front_of_house {
        pub mod hosting {
            pub fn add_to_waitlist(){ }
        }
    }
    
    pub use crate::front_of_house::hosting;
    
    pub fn eat_at_restaurant(){
        hosting::add_to_waitlist();
        hosting::add_to_waitlist();
        hosting::add_to_waitlist();
    }
    
  • 小结一下,pub use的作用就是重导出,其不仅仅将条目引入当前作用域,也使该条目可以被外部代码引入到他们的作用域内

  • 可以使用嵌套语句,来在同一行内同时引入相同包/模块下的多个条目,其格式为:

    use 路径相同部分::{路径, 不同, 部分}
    1
    2
    3
    4
    
    use std::cmp::Ordering;
    use std::io;
    
    use std::{cmp::Ordering, io};
    
    1
    2
    3
    4
    
    use std::io;
    use std::io::Write;
    
    use std::io::{self, Write};
    
  • 可以使用通配符*,可以将路径中所有公共条目都引入到当前作用域内;需要注意的是,通配符需要谨慎使用,其仅仅在测试阶段(将所有需要测试代码引入到tests模块)或预导入模块(prelude)

    1
    
    use std::collections::*;
    
  • Cargo.toml文件中添加外部包的名字和版本号,cargo build时就会自动从https://crates.io/下载到本地环境,随后便可使用use将特定条目引入到作用域

  • 可以在\Users\user\.cargo\config目录下写配置文件来更换清华源

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    [source.crates-io]
    registry "https://github.com/rust-lang/crates.io-index"
    
    replace-with 'tuna'
    [source.tuna]
    registry "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
    
    [net]
    git-fetch-with-cli true
    
  • Rust的标准库std也被当做是外部包,需要使用use引入特定条目到当前作用域,但因其本身写入到rust语言本体之中,不需要修改Cargo.toml文件来包含std

  • 定义模块时,模块名后可以直接写分号;,而不是代码块,此时Rust会从与模块同名的问价你中加载内容(即实现了将模块拆分到不同的文件内)

    1
    2
    3
    4
    5
    6
    7
    8
    
    src 
    ├─ lib.rs
       mod front_of_house{
           pub mod hosting;{
               pub fn add_to_waitlist(){ }
           }
       }
    └─ main.rs
    
    1
    2
    3
    4
    5
    6
    7
    8
    
    src
    ├─ front_of_house.rs
       pub mod hosting;{
           pub fn add_to_waitlist(){ }
       }
    ├─ lib.rs
       mod front_of_house;
    └─ main.rs
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    src
    ├─ lib.rs
       mod front_of_house;
    ├─ main.rs
    ├─ front_of_house.rs
       mod hosting;
    └─ front_of_house
        └─ hosting.rs
            pub fn add_to_waitlist(){ }
    
  • 可以使用Vector类型存储类型相同的多个值,写作Vec<T>,其由标准库提供

    1
    2
    3
    4
    
    fn main(){
        let v: Vec<i32> = Vec::new();
        // 因为初始化时为空,编译器无法推断类型,需要手动标注i32类型
    }
    
    1
    2
    3
    4
    
    fn main(){
        let v = vec![1, 2, 3];
        // 使用宏vec!直接初始化,此时不需要显式指定类型
    }
    
  • 使用push()函数向其中添加元素

    1
    2
    3
    4
    5
    
    fn main(){
        let v = Vec::new();
        v.push(1); // 由于向其中添加了值,此时编译器可以推断类型
        v.push(2);
    }
    
  • 使用索引或get()方法获得其中的元素,索引从0开始,超出索引范围后会引发程序崩溃,而get方法返回Option枚举类型,需要手动处理

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    fn main(){
        let v = vec![1,2,3,4,5];
        let third: &i32 = &v[2];
        println!("The third element is {}", third);
    
        match v.get(2) {
            Some(third) => println!("The third element is {}", third),
            None => println!("There is no third elemrnt"),
        }
    }
    
  • 所有权和借用规则要求,不能在同一作用域内同时拥有可变和不可变引用,该规则仍旧适合vector;因为Vector的其元素成集合统一存储在heap上,因而不能只考虑一个元素是否可变,而需要关注整个vector是否可变

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    fn main(){
        let mut v = vec![1,2,3,4,5];
        let first = &v[0];
        v.push(6); // 因为前一行定义为不可变引用,这一行会报错
        println!("The first element is {}", first);
    
        // 需要修改时,需要界定为可变引用
        for i in &mut v{
            *i += 50;
        }
    
        for i in v{
            println!("{}", i);
        }
    }
    
  • 在Vector中只能存放相同类型的数据,当有需求存储不同类型数据时,可以基于枚举的如下特性,利用附加数据的枚举类型来完成

    • 枚举的变体可以附加不同类型的数据

    • 枚举变体的定义在同一个枚举类型下

    • PS:在使用该方法定义Vector时,需要知道详尽的数据类型,不能存在类型未知的情况;Trait对象对于解决该问题有帮助

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      enum SpreadsheetCell {
          Int(i32),
          Float(f64),
          Text(String),
      }
      
      fn main(){
          let row = vec![
              SpreadsheetCell::Int(3),
              SpreadsheetCell::Text(String::from("blue")),
              SpreadsheetCell::Float(3.5),
          ]
      }
      
  • 字符串是Byte的集合,能够提供一些方法将byte解析为文本的类型,在Rust中,通常意义上所说的字符串是指String类型或&str类型;此外,标准库中也还提供了许多其他的字符串类型,如OsString、OsStr、CString、CStr等

  • 据说Rust的字符串很劝退,原因可能在于字符串本身的复杂性、Rust倾向于暴露可能的错误、Rust使用utf-8编码

  • 在Rust的核心语法层面,只有一个字符串类型,是字符串切片(str或&str),其是对储存在其他地方、utf-8编码的字符串的引用

  • 而字符串String是来自标准库,是可增长、可修改、可拥有的类型;由于其本身就是Byte的集合,因而许多Vec<T>的操作也都可用于String,可以使用String::new()创建字符串,也可以直接使用to_string()方法(该方法可用于实现了Display trait的类型,包含字符串字面值)或String::from()函数创建初始化一个字符串

    1
    2
    3
    4
    5
    6
    
    fn main(){
        let data = "initial contents ⭐";
        let s1 = data.to_string();
    
        let s2 = String::from("initial contents");
    }
    
  • String的大小是可以增减的,其内容也可以更改

    • 使用push_str()方法可以向字符串尾部添加内容,该方法的参数是字符串切片(因而可以直接填写字面值),因为是传入引用,是借用字符串,不获取其所有权,因而修改后的字符串仍旧可以正常使用

    • 使用push()方法,可以把单个字符添加到字符串尾部

      1
      2
      3
      4
      5
      6
      
      fn main(){
          let mut s = String::from("foo");
          s.push_str("bar");
          s.push('x');
          println!("{}", s);
      }
      
    • 使用+运算符直接拼接字符串,其相当于使用了类似签名fn add(self, s:&str) -> String {...}这样的方法(实际上是一个泛型,会更复杂),因而需要加号前为字符串,加号后为字符串切片,在相加后前面的变量所有权移交到add内部,离开后会失效,而后面的变量则可以继续保留所有权而可以继续使用

      1
      2
      3
      4
      5
      6
      7
      
      fn main(){
          let s1 = String::from("Hello, ");
          let s2 = String::from("World!");
      
          let s3 = s1 + &s2;
          println!("{}", s3);
      }
      

      PS:在该例子中,加号后面使用&s2是字符串引用类型,并不是上面要求的字符串切片,这是因为Rust存在一个解引用强制转换(deref coercion)的机制

    • 也可以使用format!宏来完成字符串的拼接,其更灵活也不会取得任何参数的所有权

      1
      2
      3
      4
      5
      6
      7
      8
      
      fn main(){
          let s1 = String::from("tic");
          let s2 = String::from("tac");
          let s3 = String::from("toe");
      
          let s = format!("{}-{}-{}", s1, s2, s3);
          println!("{}", s);
      }
      
  • 按照索引语法访问String的某部分时会报错,即是说Rust字符串不支持索引语法访问;实际上,String是对Vec<u8>的包装,可以使用len()方法返回其字节长度

    1
    2
    3
    4
    5
    6
    7
    
    fn main(){
        let len1 = String::from("abcdefg").len();
        let len2 = String::from("æɑðɜɣʝʪ").len();
    
        println!("{}, {}", len1, len2);
        // 输出为7, 14
    }
    

    之所以输出24,是因为里面的每个字母占用了2字节;这里所谓的字母称之为Unicode标量值,即是说,rust中不是所有的字节索引都能对应到一个有效的Unicode标量值,因而Rust在开发早期就为了杜绝此类错误,直接拒绝了使用索引值访问

  • Rust有三种看待字符串的方式:字节Bytes、标量值Scalar values和字形簇Grapheme cluster;其中字形簇才是最接近我们所理解的“字母”;可以使用bytes()chars()方法获得其字节和标量值的迭代器,用于遍历字符串;对于字形簇的获取,其很复杂,标准库为提供

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    fn main(){
        let s = "æɑðɜɣʝʪ";
    
        for b in s.bytes(){
            println!("{}", b);
        }
    
        for b in s.chars(){
            println!("{}", b);
        }
    }
    
  • 此外,Rust不允许对String进行索引,还因为每次索引操作都消耗一个常量时间O(l),而String无法保证这个时间,因为要确认这个时间需要遍历整个字符串来计算有多少个合法字符

  • 可以使用[]和一个范围来创建字符串的切片(切割字符串),但是需要谨慎使用,如果切割时跨越了字符边界,程序就会panic

    1
    2
    3
    4
    5
    
    fn main(){
        let s = "æɑðɜɣʝʪ";
        let s1 = &s[0..4]; //可以切割出两个字符
        let s2 = &s[0..3]; //编译不会报错,运行会报错,说索引3不是一个字符的边界
    }
    
  • 小结,Rust选择将正确处理String作为程序员的默认行为,要求程序员必须在处理utf-8数据之前投入更多精力,从而防止在开发后期处理涉及非ASCII字符时的错误

  • 哈希表(HashMap)是以键值对的形式存储数据的一种方式,相当于python或C#中的字典