Rust | 03. 泛型、Trait、生命周期、自动化测试

  • 在出现重复代码时,可以使用函数将其抽象出来,提高代码的效率和可读性
  • 泛型时具体类型或其他属性的抽象代替,也是提高代码复用能力的关键方法之一;简单理解,其就是一种模板或占位符,代替某种数据类型,编译器在编译的时候,需要将其解析为具体的类型
  • 在该例中,fn largest<T>(list: &[T])->T{ ... },函数传入一个T类型的列表,在经过运算之后返回一个T,这个T可以是任意类型
  • 通常,泛型的声明是在使用前的<>中,通常使用一个字母来命名(驼峰法Type),其不仅仅可以用在函数中,也可以用于结构体或枚举的定义中,其可以同时存在多个(但太多了会不宜阅读,代码应该进一步分解为更小的单元)
  • 在给结构体或枚举类型实现方法时,如果方法是针对泛型的,应在impl关键字后加上泛型标注,如impl<T> Point<T> { fn x(&self)->&T {&self.x} },如果说只是针对某一具体的类型来实现,则只需要在结构体名称后标注impl Point<i32> {fn x1(&self)->i32 {&self.x}}
  • struct里的泛型类型参数可以和方法的泛型参数不同,例如
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    struct Point<T, U> {
      x: T,
      y: U,
    }
    impl<T, U> Point<T, U> {
      fn mixup<V, W>(other: Point<V, W>) -> Point<T, W> {
        Point{ x: self.x, y: other.y }
      }
    }
    fn main() {
      let p1 = Point { x: 5, y: 4 };
      let p2 = Point { x: "Hello", y: 'c'};
      let p3 = p1.mixup(p2);
      println!("p3.x={}, p3.y={}", p3.x, p3.y);
    }
    
  • Rust在编译阶段,会将泛型替换为具体的类型,这个过程称为单态化(monomorphization),使用泛型代码和使用具体类型时,代码运行速度是一样的
  • Trait是抽象定义共享行为的一种策略,其告诉编译器,某种类型具有哪些可以与其他类型共享的功能,类似于其他语言中的接口(interface),其把方法签名放在一起,来定义实现某种目的所必须的一组行为

  • 其使用trait关键字进行定义,其可以有多个方法,每个方法签名占一行(定义时只用方法的签名,不需要具体实现),以;结尾,但其要求实现该trait的类型,必须提供具体的方法实现impl trait for class {...}

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    pub trair Summary {
      fn summarize(&self) -> String;
      fn summarize2(&self) -> String {
        String::from("{}, (Read more...)", self.summarize()) //默认实现
      }
    }
    
    pub struct NewsArticle {
      pub headline: String,
      pub location: String,
      pub author: String,
      pub content: String,
    }
    impl Summary for NewsArticle {
      fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
      }
    }
    
    pub struct Tweet {
      pub username: String,
      pub content: String,
      pub reply: bool,
      pub retweet: bool,
    }
    impl Summary for Tweet {
      fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
      }
    }
    
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    use demo::Summary; //这里需要将pub trait引入作用域
    use demo::Tweet;
    
    fn main() {
      let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("as you probably know, people"),
        reply: false,
        retweet: false,
      }
      println!("1 new tweet: {}", tweet.summarize())
    }
    
  • 可以在某个类型上实现某个trait的前提条件是,这个类型或这个trait是在本地crate中定义的,即无法为外部类型来实现外部的trait。该限制是程序一致性的一部分,确保了其他人的代码不能破坏自己的代码(反之亦然);如果没有这个规则,两个crate可以为同一类型实现同一个trait,此时rust就不知道该使用哪个具体的实现了(孤儿原则)

  • 默认实现:针对不同的类型但行为类似的情景,也可以直接提供一个默认的操作,其直接定义在trait中,这样当某个具体类型实现该trait时,可以直接使用默认行为就可以,当需要有自定义需求时,再重写覆盖掉默认行为即可

  • 在默认实现中,可以调用Trait其他的方法,哪怕其没有默认实现也可以,用户在使用该trait时,只需要重写用到的方法即可;需要注意的是,无法在重写实现里,调用默认的实现

  • 在标注类型的时候,可以将其指定为实现特定Trait的类型,这样函数就可以大胆调用Trait要求实现的方法了,有几种语法来实现

    • impl trait语法,例如pub fn notify(item: impl Summary) {...}表示该函数的参数类型必须实现Summary Trait,其可以理解为下一种方法的语法糖
    • Trait bound(约束)语法,其可用于更复杂的情况,例如pub fn notify<T: Summary>(item: T){ ... }
    • 可以使用+来表示指定多种Trait,例如pub fn notify(item: impl Summary+Display) {...}pub fn notify<T: Summary+Display>(item: T){ ... }
    • 也可以使用where子句来整理代码,让多个Trait在函数签名里看起来不那么乱
    1
    2
    3
    4
    5
    6
    7
    
    pub fn notify<T: Summary+Display, U: Clone+Debug>(a:T, b:U) -> String { ... }
    pub fn notify2<T, U>(a: T, b: U) -> String {
    where
      T: Summary + Display,
      U: Clone + Debug,
    { ... }
    }
    
    • Trait在标注类型时,不仅仅可以用于指定参数,也可以用于指定返回值的类型,如pub fn notify(s: &str) -> impl Summary { ... },此时需要注意的是,impl trait只能返回确定的同一种类型,返回可能不同类型的代码会报错,例如不可以if 类型1 else 类型2,哪怕这两种都实现了该Trait
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    // PatialOrd在prelude中
    fn largest<T: PatialOrd + Copy>(list: &[T]) -> T {
      let mut largest = list[0];
      for &item in list.iter() {
        if item > largest {
          largest = item;
        }
      }
      largest
    }
    fn main() {
      let number_list = vec![34, 50, 25, 100, 65];
      let result = largest(&number_list);
      println!("The largest number is {}", result);
    
      let char_list = vec!['y', 'm', 'a', 'q'];
      let result = largest(&char_list);
      println!("The largest char is {}", result);
    }
    
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    fn largest<T: PatialOrd + Clone>(list: &[T]) -> T {
      let mut largest = list[0].clone();
      for item in list.iter() {
        if item > &largest {
          largest = item.clone();
        }
      }
      largest
    }
    fn largest2<T: PatialOrd + Clone>(list: &[T]) -> &T {
      let mut largest = &list[0];
      for item in list.iter() {
        if item > &largest {
          largest = item;
        }
      }
      largest
    }
    fn main() {
      let str_list = vec![String::from("hello"), String::from("world")];
      let result = largest(&str_list);
      println!("The largest number is {}", result);
    }
    
  • 在使用泛型类型参数的impl块上使用trait bound,就可以有条件的为实现了特定trait的类型来实现方法(如下例子);这样可以为满足Trait Bound的所有类型实都现Trait叫做覆盖实现(blanket implementations),例如标准库中所有实现Display的类型,都会有to_string方法

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    use std::fmt::Display;
    struct Pair<T> { x: T, y: T }
    impl<T> Pair<T> {
      fn new(x: T, y: T) -> Self {
        Self { x, y }
      }
    }
    // 只有实现Display和PartialOrd的类型T,才可以拥有以下方法
    impl <T: Display + PartialOrd> Pair<T> {
      fn cmp_display(&self) {
        if self.x >= self.y {
          println!("The largest number is {}", self.x);
        } else {
          println!("The largest number is {}", self.y);
        }
      }
    }
    
  • Rust的每个引用都有自己的生命周期,其就是引用保持有效的作用域。大多数时候,生命周期是隐式的、可被推断的,但当引用的生命周期可能以不同的方式相互关联时,就需要手动标注生命周期。生命周期存在的主要目的,就是避免悬垂引用(dangling reference)。在Rust内部,会有一个借用检查器,检查每个借用的生命周期长短,从而检查其是否合法。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    fn main() {
      {
        let r; 
        {
          let x = 5;
          r = &x;
        }
        // 此时代码会报错,因为x在离开作用域之后,&x指向的地址内存被释放
        println!("r: {}", r);
      }
    }
    
  • 在rust中,可以使用生命周期的标注,来描述多个引用的生命周期的关系。标注并不会改变引用的生命周期长度,因而单个生命周期的标注本身是没有意义的;当指定了泛型生命周期参数时,函数就可以接受带任何生命周期的引用

  • 其标注语法以'开头,通常全是小写且很短,多为'a,标注在引用符&后面,并使用空格将标注和引用类型分开,例如&i32是一个引用,&'a i32是带有显式生命周期的引用,&'a mut i32是带有显示生命周期的可变引用

  • 当需要函数返回一个引用时,就需要标注生命周期,并且标注的返回类型的生命周期参数,需要于其中一个参数的生命周期相匹配。

  • 因为返回的引用只能指向参数,如果是指向函数内创建的值,会直接报错,因为其离开作用域的时候一定会被清空,要返回内部创建值,只能直接返回值来移交所有权到外部,不能只返回引用。因此,返回的引用必定与参数相关(至少要与一个参数的生命周期相关)。

  • 而返回值作为一个引用,其有效性就依赖于参数的有效性,rust的借用检查器在检查返回值的有效性时,就需要知道返回值和参数之间的依赖关系,因此,其强制要求标注参数与返回值生命周期的关系。而指定生命周期参数的方式依赖于函数所作的事情。

  • 假设有一个返回引用类型的longest()函数,在下面的例子,就需要确保返回值result的生命周期不能长于string1或者string2,否则不能保证result的有效性。可以使用泛型生命周期参数要求rust检查返回值与参数的生命周期关系。

    1
    2
    3
    4
    5
    6
    7
    
    fn main() {
      let string1 = String::from("abcd");
      let string2 = "xyz";
      let result = longest(string1.as_str(), string2);
      println!("The longest string is {}", result); 
      //理想情况下的调用,在result存活期间,string1和string2可以保持有效
    }
    
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    fn main() {
      let string1 = String::from("abcd");
      let result; // result定义在外部,其生命周期长于string2
      {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
      }
      println!("The longest string is {}", result); 
      // 如果string2被返回,此时result会因string2生命周期较短而失效
    }
    
  • 泛型生命周期参数,需要声明在函数名和参数列表之间的<'a>里,当参数带有该声明周期标注后,其并不改变该参数的生命周期,但却告诉借用检查器,该参数的声明周期应不短于'a,即'a所指代的生命周期应是所有带该标注参数中最短的那一个的生命周期

  • 在上面的例子中,可以使用如下函数前面来进行生命周期的标注。在下面的代码中,rust就会强行要求函数在执行时,检查参数x、y以及返回值的生命周期长短,'a指代参数x和y的生命周期中较短的那一个,要求返回值的生命周期也必须为'a,避免了因x或y失效,而result作为x或y的引用,理应失效,但仍旧存在的情形

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    // 以下方法没有标注声明周期,编译会不通过
    // 因为返回值是x或者y的一种(都是引用),借用检查器需要保证返回值的有效性
    // 如果返回值的生命周期更长,x或y如果提前失效了,那么返回值会没有意义
    fn longest(x: &str, y: &str) -> &str {
      if x.len() > y.len() { x } else { y }
    }
    
    fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
      if x.len() > y.len() { x } else { y }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // 此时生命周期'a指代为string2的生命周期,result与其拥有相同的生命周期,代码合法
    fn main() {
      let string1 = String::from("abcd");
      {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result); // 此时result有效
      }
    }
    
  • Struct里除了自持有的类型外,也可以包含引用,但需要在每个引用上添加生命周期标注
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    struct ImportantExcerpt<'a> {
      part: &'a str, // 该标注确保了part的生命周期,应该长于实例的生命周期
    }
    fn main() {
      let novel String::from("Call me Ishmael. Some years age ...");
      let first_sentence = novel.split('.')
        .next()
        .expect("Could not found a '.'");
      let i = ImportantExcerpt {
        part: first_sentence
      }
    }
    
  • 每个引用都有生命周期,程序员需要为使用生命周期的函数或struct指定生命周期参数。在rust的早期版本(pre 1.0)中,总是要求程序员显式地标注所有的生命周期,但随着代码量的增加,rust开发者发现在某些情况下,程序员总是在一遍又一遍地编写着同样的生命周期标注,而且这些生命周期标注还都是可预测的(存在明确的模式)。于是,rust团队就将这些模式写入了编译器代码,就使得借用解释器在这些情况下可以自动地对生命周期进行推导,而无需程序员手动标注。
  • 目前,这样的固定模式会越来越多地被添加进来,这些在rust引用分析中所编入的模式称为生命周期省略规则。这些规则是一些由编译器来考虑的特殊情况,无需开发者来遵守。当代码符合这些情况时,就无需显式地标注生命周期。另外,省略规则并不会提供完整的推断,如果应用规则后,引用的生命周期仍然模糊不清,编译就会报错,此时需要程序员手动添加生命周期标注,表明引用间的相互关系。
  • 如果生命周期若出现在函数/方法的参数中,其称为输入生命周期,若出现在函数/方法的返回值中,则称为输出生命周期。如下是编译器使用的3条规则(适用于fn定义或impl块):
    • 规则1:每个引用类型的参数都有自己的生命周期(应用于输入生命周期,即1个参数会有1个生命周期,2个参数会有两个不同的生命周期)
    • 规则2:如果只有1个输入生命周期参数,那么该生命周期被赋给所有的输出生命周期参数
    • 规则3:如果有多个输入生命周期参数,且其中一个是&self&mut self(方法),那么self的生命周期会被赋给所有输出生命周期参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    fn first_word(s: &str) -> &str {
      let bytes = s.as_bytes();
      for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
          return &s[0..i];
        }
      }
      &s[..]
    }
    
  • 上述代码并不会报错,因为其只有1个参数,规则1会给其标注1个生命周期,规则2会将该生命周期赋给返回值,这样所有的引用都有明确的标注了;但对于有2个参数的函数,就需要手动标注了,因为编译器无法推断
  • 'static是一个特殊的生命周期,代表整个程序的持续时间,例如,对于所有的字符串字面值都拥有let s:&'static str = "I have a static lifetime."

  • 在为一个引用指定静态生命周期前,一定要三思是否真的需要引用在程序整个生命周期内都存活,大部分时候,需要生命周期指定的错误都是为了由于悬垂引用或生命周期不匹配,解决问题会更重要

  • 最后看一个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    use std::fmt::Display;
    
    fn longest_with_an_announcement<'a, T> 
      (x: &'a str, y: &'a str, ann: T) -> &'a str
      where T: Display
    {
      println!("Announcement! {}", ann);
      if x.len() > y.len() { x } else { y }
    }
    
  • 在rust中,一个测试就是一个函数,其用于验证非测试代码的功能是否和预期一致;测试函数体通常执行3个操作:准备数据/状态、运行被测试的代码、断言结果是否正确。此外,测试函数需要使用test属性(attribute)进行标注。Attribute是一段rust代码的元数据,在函数前加上#[test]就可以把函数变成测试函数。
  • 使用cargo test命令运行所有测试函数,此时rust会构建一个test runner的可执行文件,其来运行标注了test的函数,并报告其是否运行成功。当使用cargo create name -lib创建library项目时,会生成一个tests module,里面自动会有一个test函数。也可以手动添加任意数量的test module或函数。
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    #[cfg(test)]
    mod tests {
      #[test]
      fn it_works() {
        assert_eq!(2+2, 4);
      }
      #[test]
      fn another() {
        panic!("Make this test fail")
      }
    }
    
  • 当运行测试时,每一个测试运行在一个新线程上,当测试函数发生panic时,主线程会看见某个测试线程挂掉了,并将其标记为失败并在最后返回、打印
  • 来自标准库的宏assert!(result)可以用来确定某个状态是否为true,结果为true时测试通过,否则调用panic!(),测试失败;此外,还有assert_eq!()assert_ne!()来判断两个参数是否相等或不等,其本质上就是使用了==!=运算符,只是当断言失败时,该宏可以使用debug格式自动打印参数(要求参数实现了PartialEqDebug这两个Traits,所有基本类型和标准库里的大部分类型都已经实现了)

  • 这三个宏也都可以添加可选的自定义消息,可以作为第2个或第3个参数传入函数中,并且该自定义消息参数会被传递给format!()宏,因而可以使用{}占位符填充消息

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    #[derive(Debug)] //注解,一种格式化的方式
    struct Rectangle {
        width: u32,
        length: u32,
    }
    impl Rectangle {
      pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.length > other.length && self.width > other.width
      }
    }
    pub fn add_two(a: i32) -> i32 { a+2 }
    
    #[cfg(test)]
    mod tests {
      use super::*;
    
      #[test]
      fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };
        assert!(larger.can_hold(&smaller));
      }
    
      #[test]
      fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };
        assert!(!smaller.can_hold(&larger));
      }
    
      #[test]
      fn it_adds_two() {
        assert_eq!(4, add_two(2), "2 was not added to original number, the result is {}", add_two(2)); // rust中运行结果和期待值的顺序无所谓
      }
    }
    
  • 可以使用should_panic属性来检查是否发生了恐慌,确保程序可以按照预期处理了发生的错误。标注了should_panic的函数,如果发生了恐慌,则测试通过,否则测试失败。注意该属性写在函数声明前,test属性后

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    pub struct Guess {
      value: u32,
    }
    impl Guess {
      pub fn new(value: u32) -> Guess {
        if value < 1 || value > 100 {
          panic!("Guess value must be between 1 and 100, got {}.", value)
        }
        Guess { value }
      }
    }
    #[cfg(test)]
    mod tests {
      use super::*;
    
      #[test]
      #[should_panic]
      fn greater_than_100(){
        Guess::new(200);
      }
    }
    
  • 可以为should_panic属性添加可选的expected参数,此时将检查失败的消息中是否包含所指定的文字

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    pub struct Guess {
      value: u32,
    }
    impl Guess {
      pub fn new(value: u32) -> Guess {
        if value < 1 {
          panic!("Guess value must be greater than or equal to 1, got {}.", value)
        }else if value > 100 {
          panic!("Guess value must be less than or equal to 100, got {}.", value)
        }
        Guess { value }
      }
    #[cfg(test)]
    mod tests {
      use super::*;
    
      #[test]
      #[should_panic(expected="Guess value must be greater than")]
      fn greater_than_100(){
        Guess::new(200);
      }
    }
    }
    
  • 当错误发生时,有时也无需panic,同样可以使用Result<T, E>作为返回类型编写测试,返回Ok时测试通过,返回Err则测试失败
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    #[cfg(test)]
    mod tests {
      #[test]
      fn it_works() -> Result<(), String> {
        if 2+2 == 4 {
          Ok(())
        } else {
          Err(String::from("two plus two does not equal to four"))
        }
      }
    }
    
  • 值得注意的是,在使用Result<T,E>作为返回结果时,函数无需发生恐慌,此时标注#[should_panic]就没有意义了
  • cargo testcargo run一样,在运行时会生成二进制代码用于测试,可以通过添加命令行参数来改变其测试的行为。如果不添加任何行为时,其默认的行为是并行运行所有测试,成功时捕获(不显示)所有输出,仅读取和保留与测试结果相关的信息

  • 针对于cargo test这个命令本身的参数,其紧跟cargo test之后;若针对于测试生成的可执行文件,则需要放在--之后,例如cargo test --help显示该命令可用的参数,cargo test -- --help则会在程序编译后显示可以放在这个横线后的参数有哪些

  • 并行/连续/串行测试

    • 在运行多个测试时,默认会使用多个现成来并行运行,这样运行得会更快,但是,需要确保测试之间不会相互依赖或者不依赖某个共享状态(如环境、工作目录、环境变量等)

    • 当不想要并行测试,或者想要精准控制测试时所使用的线程数量,可以使用cargo test -- --test-threads=1参数传递具体的线程数量给二进制文件,此时虽然测试执行速度变慢,但其因共享状态而出现干扰的可能性会比较小

    • 默认情况下,如果测试通过,rust的test标准库会捕获所有打印到标准输出的内容,例如prinln!()中的内容在测试成功时不会显示,只有失败时才能看到。可以使用cargo test -- --show-output参数将成功通过测试的结果也打印到屏幕上

  • 运行部分测试

    • 当需要与选择一部分函数进行测试时,可以将测试的名称(对应1个测试的全称或对应多个测试的前半部分简都可以,也可以使用某个特定模块的名称也可以)作为cargo test的参数,此时rust会按照测试的名称来运行测试的子集;例如cargo test add命令就只会运行下面内容的前两个
    1
    2
    3
    
    #[test] fn add_two_and_two(){ ... }
    #[test] fn add_two_and_two(){ ... }
    #[test] fn one_hundred(){ ... }
    
    • 可以使用#[ignore]指令来显式地忽略某些测试,只运行剩余测试。这种代码应用于某些特别消耗计算资源的一些测试中,此时可以先将其屏蔽掉,不需要每一次都运行该测试
    1
    2
    3
    4
    5
    
    #[test]
    #[ignore]
    fn expensive_test(){
      assert_eq(5, 1+1+1+1+1);
    }
    
  • 可以使用cargo test -- --ignored参数来运行带有ignore属性标记的测试

  • rust社区对测试分为两类:单元测试和集成测试。单元测试用于将一小块代码隔离出来,看看其是否符合预期,其比较小而专注,每次只对一个模块进行隔离测试,可以测试private的接口;而集成测试位于代码库之外,像其他外部代码一样调用代码,其只能访问公共接口,可以在每个测试中用到多个模块

  • 单元测试放在src目录下,使用#[cfg(test)]标注tests模块,cfg是configuration的缩写,用于告诉rust下面的条目只有在指定的配置选项下才被包含,配制选项test由rust自身提供,只有运行cargo test时才编译和运行代码(使用cargo build时不会),包括代码中的helper函数和#[test]标注的函数。没有#[test]标注的函数会被编译,但不会执行测试。

    1
    2
    3
    4
    5
    6
    7
    
    fn private_adder(a: i32, b: i32) -> { a+b }
    
    #[cfg(test)]
    mod tests{
      use super::*;
      #[test] fn it_works() { assert_eq!(4, private_adder(2,2)); }
    }
    
  • 在rust中,集成测试位于被测试库的外部,只能测试外部可访问的公开API,用于测试该库的多个部分是否能正确的一起工作。继承测试的覆盖率会是很重要的测试指标。通常,集成测试需要在项目根目录下创建tests文件夹,cargo会认识该目录(无需再手动标注#[cfg(test)]),并在其下寻找集成测试信息。

    1
    2
    3
    4
    5
    6
    
    // tests/integration.rs
    use project;
    #[test]
    fn it_adds_two() {
      assert_eq!(4, add_two(2));
    }
    
  • cargo test 函数名可以用于运行一个指定的测试;cargo test --test 文件名可以运行某个测试文件内的所有测试。另外需要注意的是该目录下的每一个测试文件都是单独的一个crate,文件之间不共享行为(和src目录下的规则不同)。如果需要一些helper函数,可以将其放在子文件夹内,避免被cargo识别为测试文件。

    1
    2
    
    // tests/common/mod.rs
    pub fn setup(){}
    
    1
    2
    3
    4
    5
    6
    
    // tests/intergration.rs
    mod common;
    #[test]
    fn it_adds_two() {
      common::setup();
    }
    
  • 如果项目是binary crate,则项目只含有src/main.rs,没有src/lib.rs,此时不能在tests目录下创建继承测试,也无法把main.rs的函数导入作用域(因为只有library crate才能暴露函数给其他crate用,binary crate意味着独立运行)
  • 这是时候应该将代码的核心逻辑都尽可能地迁移进lib.rs中,在main.rs中只保留少部分胶水代码,此时再利用lib的特性进行测试

这一部分课代码实操,跟着视频写了下,这里总结几点关键的经验

  • 代码重构要趁早,确保每一个函数只干一件事,每一个代码块都是人脑可以读懂并编译的安全代码,把这样的代码封装起来
  • Rust社区有给出官方建议的二进制程序关注点分离的指导性原则
    • 将程序拆分为main.rslib.rs,将业务逻辑都放入lib.rs
    • 当命令行逻辑较少时,放在main.rs中也行,但如果解析变得复杂时,也需要将其提取到lib.rs
  • 有关联的几个变量最好放在结构体里,将其数据的约束放在构造函数里,这样不仅仅有易读的逻辑,也可以方便地确保数据类型或值符合要求
  • 错误处理时不要大类扔出,需要地做一下细分,搞清楚是程序内部错误,还是用户的使用错误,尽可能给出用户友好的提示信息
  • 当发生错误时,不要总是panic!(),这个是给程序员调试代码用的,最好习惯使用Result<T, E>来书写构造函数或者run()函数,解析和处理可能的一切错误
  • 测试驱动开发TDD(Test-Driven Development),其基本步骤如下
    • 编写一个会失败的测试,运行该测试,确保它是按照预期的原因失败
    • 编写或修改刚好足够的代码,让新测试通过
    • 重构刚刚添加或修改的代码,确保测试会始终通过
    • 返回步骤1,继续
  • 大部分的终端,是区分标准输出(stdout)和标准错误(stderr)的,前者使用println!()输出,后者使用eprintln!()。这样做的好处是可以将正确的输出重定向到文件中,而错误的输出直接打印到屏幕上