Rust 笔记 -- 错误处理、泛型、特质、测试

The Rust Programming Language

Rust 编程语言笔记。

来源:The Rust Programming Language By Steve Klabnik, Carol Nichols

翻译参考:Rust 语言术语中英文对照表

错误处理

Rust 把错误分为两类:

  1. 可恢复的(recoverable):例如:文件未找到等。该类错误可以提示用户查错后继续运行程序
  2. 不可恢复的(unrecoverable):例如:数组访问越界等。该类错误出现后必须终止程序

对于可恢复错误,Rust 采用 Result<T, E> 来处理;对于不可恢复错误,Rust 采用 panic!() 宏(macro) 来处理。

在其他编程语言中,通常不会对错误进行分类,而是使用 Exception 统一处理。

不可恢复错误和 panic!

有两种情况会执行 panic!

  1. 显式调用 panic!() 宏(macro)
  2. 程序出现错误,例如:数组访问越界

默认情况下,Rust 会打印错误信息、解开(unwind)、清理栈内存、退出程序。通过环境变量,可以打印调用栈(calling stack),有助于更好 debug。

解开(unwind)栈内存 VS 立即终止

默认情况下,当 panic 发生时,程序会开始解开(unwinding),Rust 会回到栈内存中,找到每个函数并清理数据。该操作需要花费大量资源。另一种替代方式是,立即终止(abort)程序,清理内存。

此时,程序使用的内存会由操作系统来清理。要切换到立即终止选项,在 Cargo.toml 文件中的 [profile.release] 区域添加 panic = 'abort';

[profile.release]
panic = 'abort'

让我们看一下显式调用 panic!() 的情况:

fn main() {
  	panic!("Crash and burn");
}

如果运行上述程序,编译器会弹出:

$ ./test

thread 'main' panicked at 'Crash and burn', test.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

说明此时程序 panicked

回溯

在 C 语言中,数组访问越界是一种未定义的行为。因此,如果索引不合法,C 会返回内存中某处的数据,即使该处的内存不属于数组保存处的内存。这种行为称为“缓冲区溢出(buffer overread)”,会导致很多安全问题。

在 Rust 中,数组访问越界会导致错误。

可以调用环境变量 RUST_BACKTRACE 来显式调用栈的信息:

  • RUST_BACKTRACE=1: 打印简单信息
  • RUST_BACKTRACE=full:打印全部信息
$ RUST_BACKTRACE=1 ./test

thread 'main' panicked at 'Crash and burn', test.rs:2:5
stack backtrace:
   0: std::panicking::begin_panic
   1: test::main
   2: core::ops::function::FnOnce::call_once
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Backtrace 就是一个包含所有函数的列表。Rust 对回溯的处理和其他语言一样,从上往下读,首先找到源文件行,代表问题/导致 panic 的函数,该行上面的所有行表示该行调用的函数;该行下面的所有行代表被该行调用的函数。

可恢复错误和 Result

Result

对于可恢复的错误,Rust 提供了 Result<T, E> 枚举类型来处理这种错误。

enum Result<T, E> {
  	Ok(T),
  	Err(E),
}

可以看到,Result 中的 TE 采用泛型(generic)定义,前者和 Ok 一起作为正常情况返回,后者和 Err 一起作为异常情况的返回。

例如:

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}
  • 如果匹配 OkOk(File { fd: 3, path: "", read: true, write: false })
  • 如果匹配 ErrErr(Os { code: 2, kind: NotFound, message: "No such file or directory" })

处理错误类型

如果要进一步细化错误的类型,例如对于读文件错误,可以分为文件不存在或没有权限访问文件等。那么通过嵌套 match 可以处理多种错误的类型:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
		let greeting_file_result = File::open("hello.txt");
  	let greeting_file = match greeting_file_result {
    		Ok(file) => file,
      	Err(error) => match error.kind() {
        		ErrorKind::NotFound => match File::create("hello.txt") {
            		Ok(fc) => fc,
              	Err(e) => panic!("There is a problem when creating file: {:?}", e),
          	},
          	other_error => {
            		panic!("There is a problem when open the file: {:?}", other_error); 	
          	}
      	},
	  };
}

ErrorKind 也是一种枚举类型,和 Result 以及 Option 不同,ErrorKind 需要使用 use 引入当前的作用域。上面代码中处理了NotFoundother_error 两个枚举值。

解包(unwrap) 和 expect

嵌套 match 的写法有些冗余(verbose),因此,Rust 还提供了 unwrapexpect 方法来处理 panic 或者 Error,这两个函数都定义在 Result 上。

use std::fs::File;

fn main() {
		let greeting_file_result = File::open("hello.txt").unwrap();
}
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', test.rs:4:60
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
use std::fs::File;

fn main() {
		let greeting_file_result = File::open("hello.txt")
  																	.expect("There is a problem when reading the file");
}
thread 'main' panicked at 'There is a problem when reading the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', test.rs:5:72
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

两个方法的作用几乎相同,在 production 代码中,Rustaceans 倾向于使用 expect,因为其可以提供更多提示信息。

传播错误和 ?运算符

传播错误

当被调用函数体中出现错误时,与其在该函数中处理错误,更常见的方式是把把错误返回给调用函数以更好控制代码的流程,这被称为传播错误(propagating error)

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

? 运算符

上面的代码有些冗长,可以使用 ? 运算符缩短传播错误代码:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
  	let mut username_file = File::open("hello.txt")?;
  	let mut username = String::new();
  	username_file.read_to_string(&mut username)?;
  	Ok(username);
}

或者通过链式调用使上面的代码更简洁:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
  	let mut username = String::new();
  	File::open("hello.txt")?.read_to_string(&mut username)?;
  	Ok(username)
}

? 运算符的作用和 match 几乎相同,区别在于:? 运算符包含了一个类型转化的过程,把多种错误返回值统一转化同一种类型。该操作通过定义在 From trait 中的 from 函数来实现,该函数把一种类型转化为另一种类型。

具体来说,? 运算符把它所调用的返回错误类型转化为当前函数定义的返回错误类型。例如:当前函数返回我们自定义的错误类型 OurError,而 ? 所作用的函数返回的是 io::Error,那么 ? 会调用 from 函数把 io::Error 转化为 OurError

?的作用条件

使用 ? 运算符时需要注意:该运算符只能用于其作用值的类型和返回值类型兼容的函数。这是因为 ? 的作用在函数结束前提前返回值,类似于 match

例如:match 作用的类型是 Result,而返回的错误类型是 Err(e)。根据 Result 的定义,这两者是兼容的。

但是:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

这种情况就会出现错误。因为 main 函数的返回类型是 (),而 File::open 的返回类型是 Result

处理该错误有两种方法:

  1. 把函数的返回值类型改为 ? 作用值兼容的类型
  2. ? 替换为 match

main 函数的返回值

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

与 C 语言的规范一致,在 Rust 中,当 main 函数返回 Result<(), E> 时:

  • 如果返回的是 OK(()),那么 main 函数的返回值是 0
  • 如果返回的是 Err,那么 main 函数的返回值是 非零值

泛型、特质和生命周期

泛型

泛型(generics)用抽象类型来替代某种具体类型,大大减少了代码的冗余。

函数、方法、结构体、枚举等都可以使用泛型。

定义泛型

使用泛型包括两个步骤:

  1. 使用尖括号(angle brackets)<> 包裹标识符 T<T>
  2. 在需要声明类型处使用 T

一般来说,标识符的名称可以任意选定。但是在 Rust 中,为了简单,通常使用简短且大写字母 T,表示 Type。

用于函数定义

泛型可以用于函数定义:

fn largest<T>(list: &<T>) -> &T {
		let mut largest = &list[0];
  
  	for item in list {
      	if item > largest {
        		largest = item;  
      	}
  	}
  	larest
}

使用泛型时要注意潜在的错误。例如:上面的函数找到列表中的最大值。但是编译器会在 item > largest 行报错,这是因为两者都是 &<T> 类型,但不是所有的类型都可以比较。

用于结构体

泛型可以用于结构体定义:

struct Point<T> {
  	x: T,
  	y: T,
}

struct Point<T, U> {
		x: T,
  	y: U,
}

第一个 Point 只使用了个类型,所以字段 xy 必须是同种类型。

第二个 Point 使得 xy 的类型既可以相同也可以不同。

用于枚举

泛型可以用于枚举定义,例如:Option 和 Result:

enum Option<T> {
  	Some(T),
  	None
}

enum Result<T, E> {
  	Ok(T),
  	Err(E),
}

用于方法

泛型可以用于方法定义:

struct Point<T> {
  	x: T,
  	y: T,
}

impl<T> Point<T> {
  	fn x(&self) -> &T {
    		&self.x 
  	}	
}

注意:在方法上使用泛型时,需要在 impl 关键字后添加 <T>,这是为了告诉 Rust 该方法使用了泛型。

也可以仅给某些类型添加方法:

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

上面的代码表示,只有类型为 f32Point 结构体才有 distance_from_origin 方法。

性能

与使用具体类型相比,使用泛型不会导致性能变差。

Rust 使用单态(Monomorphization) 完成这一点。单态在编译时把所有的泛型转化为具体类型。

特质

特质(traits)定义了某种特定类型的功能,并且可以和其他类型共享。

特质约束(trait bound)定义泛型能够具有某种特定行为。

特质类似于其他编程语言的 接口(interface),但是也有着一些区别。

定义特质

使用关键字 trait 定义特质:

pub trait Summary {
  	fn summarize(&self) -> String;
}

trait 块由函数签名组成。

实现特质

类似于方法,实现特质同样使用 impl 关键字,此外还要使用 for 关键字指明要实现的对象。

pub struct Tweet {
  	pub author: String,
  	pub content: String,
  	pub length: u32,
}

impl Summary for Tweet {
		fn summarize(&self) -> String {
    		format!("{} {}", self.author, self.content);  
  	}
}

/*
// 给另一结构体实现同名 trait
impl Summary for Article {
		--snip--
}
*/

fn main() {
  	let tweet = Tweet {
  			author: String::from("Mitchell"),
      	content: String::from("Implementing a trait"),
	  };
  	tweet.summarize();
}

注意:当且仅当其中一个 trait 或者实现 trait 的类型位于当前 crate 的作用域时,才可以在其他的 trait 中引用同名 trait,并给出不同的实现。该限制是 coherence 特性的一部分,也被称为孤儿原则(orphan rule)


测试

Rust 中的测试函数用于测试被测试代码是否按照预期运行。

测试函数体通常包含三部分:

  1. 设置所需的变量或者状态
  2. 运行代码并测试
  3. 判断是否为预期结果

测试函数

Rust 中的测试函数使用 test 属性。属性是关于 Rust 代码的元数据(metadata)。例如:derive 就是一种元数据。

为了把某函数变为测试函数,需要在函数签名行之上添加 #[test]。使用 cargo test运行测试。

一般在创建新项目时,Rust 会自动添加含有测试函数的测试模块,测试模块包含了测试代码的模版。

例如:

#[cfg(test)]
mod tests {
		#[test]
  	fn it_works() {
    		let result = 2 + 2;
      	assert_eq!(result, 4);
  	}
}

assert! 宏

assert! 宏由标准库提供,它用于评估布尔值。如果评估结果为 true,程序正常运行;否则,assert! 宏调用 panic! 宏导致测试失败。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

assert_eq!, assert_ne!

assert_eq!, assert_ne! 分别测试参数相等或者不等。

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

在 Rust 中,assert_eq! 的参数为 leftright,如果两者相等,那么测试通过;否则测试失败。assert_ne! 则正好相反。

参数 leftright 表明参数的顺序不重要。而在其他编程语言中,测试相等性的函数通常有着严格的顺序,例如:参数分别为 expectactual,那么第一个参数只能是预期值,第二个参数是测试值。

在底层实现中,assert_eq!, assert_ne! 分别使用了 ==!= 运算符。

添加个性化错误信息

可以在 assert!, assert_eq!, assert_ne! 中把个性化错误信息作为可选参数传入,使得用户交互更加友好。

#[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{}`",
            result
        );
    }

should_panic

should_panic 是一种属性,用于测试函数体中的内容是否 panic,如果 panic 则测试通过。

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> 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);
    }
}

Result<T, E>

使用 Result<T, E> 来编写测试:

#[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 four"))
        }
    }
}

在使用了 Result<T, E> 的测试中不能使用 #[should_panic]

控制测试的运行

通过给 cargo test 添加参数来控制测试的运行。

并行还是连续

在默认情况下,Rust 的测试是并行(parallel)的,这意味着测试的速度会更快,但是需要测试之间互不依赖

如果要改为连续执行,通过添加 ``--test-threads=1flag 来表示希望使用1` 个线程来运行测试:

cargo test -- --test-threads=1

显示输出结果

在默认情况下,Rust 只会显示测试失败的用例。

通过添加 --show-output flag 来额外显示测试通过的用例:

cargo test -- --show-output

按名称运行子测试

有些时候只需要运行部分测试,可以通过具体指定测试的名称来部分执行测试,假设有三个测试:

#[cfg(test)]
mod tests {
  	use super::*;
  
  	#[test]
  	fn add_one() { /* --snip */ };
  	#[test]
  	fn add_two() { /* --snip */ };
	  #[test]
  	fn add_three() { /* --snip */ };
}
  • cargo test 命令运行全部测试
  • carge test add_one 只运行 add_one() 测试函数
  • cargo test add 运行所有名称包含 add 的函数,在本例中,运行全部函数

忽略某些测试

通过添加 #[ignore] 属性来忽略某些测试。

运行 cargo test 命令后,被忽略的测试函数不会进行测试。

#[test]
#[ignore]
fn ingored_test() { /* --snip-- */ }

组织测试代码

Rust 中主要有两种测试方式:

  1. 单元测试(unit test):一次独立测试一个模块
  2. 集成测试(integration test):作为外部库测试代码

单元测试

单元测试的惯例是:在每个文件中创建 tests 模块,该模块包含所有测试函数,以 cfg(test) 标识。

标识符 #[cfg(test)] 表示只有当 cargo test 时才运行测试,在 cargo build 时并不运行测试。这样的设计可以节约编译时间。cfg 的意思是 configuration

私有函数

单元测试可以测试私有函数。

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

集成测试

对于本地代码来说,集成测试作为外部库的形式,因此只能用于测试公有函数。

可以这样组织集成测试的目录:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

这是旧规范的命名方式,这样命名使得 Rust 得知 common 中的 mod.rs 并不作为集成测试的一部分。

我们可以把要测试的函数都写在 common/mod.rs 中,在 integration_test.rs 中开展具体测试。

集成测试只针对库 crate,如果代码中只包含二进制 crate,那么不能使用集成测试。

posted @ 2023-05-19 12:26  Mitchell_C  阅读(187)  评论(0编辑  收藏  举报