Rust —— 基础篇

Zephyr Lv3

Hello Wrold

创建一个项目

cargo new hello_world

该项目的结构和配置文件都是由cargo生成

运行项目

cargo run

# 相当于运行下面的代码
cargo build
./target/debug/hello_world

这个命令会运行当前所在文件夹的项目,它首先会对项目进行编译,然后执行。

可以看到生成的代码放在debug文件夹下,这是因为rust在默认情况下,会按照debug模式运行,这种情况下编译速度会非常快,但运行速度比较慢。

因此在生产环境中,我们会使用release模式运行代码,确保运行速度。操作也很简单,只需要在运行/编译命令中添加--release参数即可。

在项目大了之后,上面两个命令的速度不可避免的会变慢,此时我们可以改用cargo check命令,它会快速检查代码能否编译通过。

项目管理

Cargo.toml和Cargo.lock是cargo的核心文件。前者是cargo特有的项目数据描述文件。他存储所有项目配置的元数据。后者则是cargo工具根据同一项目下的toml文件生成的项目依赖清单。

定义项目依赖:

  • 基于 Rust 官方仓库 crates.io,通过版本说明来描述
  • 基于项目源代码的 git 仓库地址,通过 URL 来描述
  • 基于本地项目的绝对路径或者相对路径,通过类 Unix 模式的路径来描述
[dependencies]
rand = "0.3"
hammer = { version = "0.5.0"}
color = { git = "https://github.com/bjz/color-rs" }
geometry = { path = "crates/geometry" }

强化一下 Hello World

fn main() {
    // println!("Hello, world!");
    greet_world();
}

fn greet_world() {
    let chinese = "你好世界";
    let english = "Hello world";
    let regions = [chinese, english];
    for ele in regions.iter() {
        println!("{}", &ele);
    }
}

变量声明,函数声明等等都和其他语言基本一致。主要需要关注一下println之后的!,在Rust中,它是红操作符,我们目前可以认为宏是一特殊类型函数。

Hello World PLUS

fn funny() {
    let penguin_data = "\
   common name,length (cm)
   Little penguin,33
   Yellow-eyed penguin,65
   Fiordland penguin,60
   Invalid,data
   ";

    let records = penguin_data.lines();

	// 获取数组中的下标以及对应元素
    for (i, record) in records.enumerate() {
        if i == 0 || record.trim().len() == 0 {
            continue;
        }
		// 声明一个变量,类型是Vector,里面的类型由编译器自行判断
		// map的参数为一个lambada表达式
        let fields: Vec<_> = record.split(',').map(|field| field.trim()).collect();

		// 启用debug模式时开启
        if cfg!(debug_assertions) {
            eprintln!("debug: {:?} -> {:?}", record, fields)
        }
		
        let name = fields[0];
        // 尝试将切片中的某个元素转换为f32类型,如果转换成功,这个变量会被赋值给length
        // 右边的部分如果执行成功会返回一个Ok(f32)类型,如果失败,则会返回一个Err(e)类型,if let 的作用就是仅匹配Ok的情况
        // 然后if let还会做一次解构匹配,通过 Ok(length) 去匹配右边的 Ok(f32),最终把相应的 f32 值赋给 length
        if let Ok(length) = fields[1].parse::<f32>() {
            println!("{}, {}cm", name, length);
        }
    }
}

变量绑定与解构

变量绑定


在其他语言中的赋值操作,在Rust中被称为变量绑定。这里面涉及Rust的核心原则 —— 所有权。通俗来说就是,每块内存对象都是有主人的,并且一般情况下,它完全属于它的主人。所谓的绑定就是让某个变量称为指定对象的主人
敲重点:这里的意思就是如果某个内存对象被赋值给了另一个变量,就代表原来的变量失去了它的所有权。这就代表着不存在引用泄露的问题,因为一个内存对象最多同时被一个变量所有。

变量可变性


Rust的变量默认情况下是不可变的。也就是说一个变量不能被多次赋值。这可以显著提高代码的安全性,并且性能也更好,因为不可变对象不需要runtime期间的一些检查。同时,它也能帮我们避免变量在不知情的情况下被修改。

但不可变变量也有一定的问题,如果每次改变都需要重新生成一个对象,那显然会带来大量的内存拷贝。因此,Rust同样支持可变变量,只需要在声明时加上mut即可。

通常情况下,对于一些庞大的数据结构,修改它的内部内容显示是更加高效的。而对于一些比较小的结构,通过牺牲一定的性能换取安全性是值得的。

变量解构


定义:从一个相对复杂的变量中,匹配出该变量的一部分内容

fn main() {
    let (a, mut b): (bool,bool) = (true, false);
    // a = true,不可变; b = false,可变
    println!("a = {:?}, b = {:?}", a, b);

    b = true;
    assert_eq!(a, b);
}

变量遮蔽


Rust允许声明相同的变量名,在后面声明的变量会遮蔽前面声明的。它的作用在于,如果在某个作用域内无需再使用之前的变量,就可以重复使用之前使用过的变量名。

基本类型

整数类型

长度 有符号类型 无符号类型
8位 i8 u8
16位 i16 u16
32位 i32 u32
64位 i64 u64
128位 i128 u128
视架构而定 isize usize

Rust整型默认用i32。它的性能一般也是最好的。

整型溢出

Rust在debug模式下会检测整型溢出,而在release模式下不会。默认情况下,Rust会按照补码循环溢出的规则处理。
如果要显示处理可能的溢出,可以使用标准库相关函数

  • 使用 wrapping_* 方法在所有模式下都按照补码循环溢出规则处理,例如 wrapping_add
  • 如果使用 checked_* 方法时发生溢出,则返回 None 值
  • 使用 overflowing_* 方法返回该值和一个指示是否存在溢出的布尔值
  • 使用 saturating_* 方法使值达到最小值或最大值
fn main() {
    let a : u8 = 255;
    let b = a.wrapping_add(20);
    println!("{}", b);  // 19
}

浮点数类型

浮点类型数字是带有小数点的数字,在Rust中有两种f32f64

浮点数陷阱

  1. 浮点数往往是想要数字的近似表达
  2. 浮点数具备一些反直觉的特性,浮点数的比较运算实现的是std::cmp::PartialEq而非std::cmp::Eq。后者是其他数据类型进行相等比较使用的。

序列

Rust提供了序列用来生成连续的数值,例如1..5代表生成1-4的连续数字,1..=5生成1-5。序列只允许用于数字或字符,原因是他们可以连续,并且可以在编译期检查序列是否为空

for i in 1..=5 {
    println!("{}",i);
}

有理数和负数

Rust提供了num数值库,它可以处理有理数和复数。要想使用num库,首先需要在toml文件中的dependencies中添加一行 num = 0.4.0

然后编写代码

use num::Complex;

fn main() {
    let a = Complex {re: 2.1, im: -1.2};
    let b = Complex::new(11.1, 22.2);
    let result = a + b;
    print!("{}", result)
}

字符类型

Rust的字符不仅仅包含ASCII,所有的Unicode字符都可以作为Rust的字符。由于Unicode字符是4个字节编码,因此字符类型也是占用4个字节。

fn main() {
    let x = '中';
    println!("字符'中'占用了{}字节的内存大小",std::mem::size_of_val(&x));
}

单元类型

Rust中的单元类型就是(),它的唯一取值也就是()。main函数的返回值就是单元类型,因此它并不算是一个无返回值的函数,在Rust中,无返回值的函数有单独定义:发散函数

()也可以用作map的值,表示不关注具体的值,只关注key。这种用法类似Go的struct{},特点是不占用任何内存。

发散函数:把 ! 用作返回值的函数就称作发散函数,这类函数永远不会返回,它通常用在一些会导致程序崩溃的函数。

语句和表达式

语句是执行一系列操作,但最后不会有返回值。而表达式会在求值后返回一个值。

fn add_with_extra(x: i32, y: i32) -> i32 {
    let x = x + 1; // 语句
    let y = y + 5; // 语句
    x + y // 表达式
}

调用一个函数是表达式,因为会返回一个值,调用宏也是表达式,用花括号包裹一段代码块,且这段代码块里有返回值也代表它是一个表达式。

PS:表达式不能包含分号

最后,如果编译器发现某个表达式不返回任何值,就会让他隐式返回一个()

所有权和借用

所有权

所有权规则:

  1. Rust中每一个值都只被一个变量所拥有,该变量称为值的所有者。
  2. 一个值在同一时间只能被一个变量拥有
  3. 当所有者离开作用域后,这个就会被丢弃。(这样就不需要GC了)

转移所有权


let x = 5;
let y = x;

上面的代码首先将5绑定到变量x,接着拷贝x的值赋给y,最终x和y都等于5。这看上去与我们的所有权规则有点冲突,但实际上,因为基本类型的大小都是已知的,所以他们都会分配在栈上。而对于值在栈上的变量,因为一旦离开作用域就会被回收掉,所以并不需要利用所有权进行跟踪。

let s1 = String::from("hello");
let s2 = s1;

但上面的这段代码就有点区别了,因为String是一种复杂类型,编译期无法知晓它最终的大小,因此它黑背分配到堆上。而对于堆上的值,所有权就有用武之地了。当s1被赋给s2之后,s1就失去了对应值的所有权,也就无法在程序中接着使用他了。

如果一个值可以有多个所有者,那么在变量离开作用域之后可能就会导致多次内存释放,这会导致内存污染。因此Rust采取了所有者规则来解决这个问题。

深拷贝


Rust永远不会自动创建数据的深拷贝,因此任何自动的复制操作都不会对内存有较大影响。如果我们确实需要深拷贝,那么可以调用对应的clone()方法。此时会在堆上分配一块新的内存空间,并将所有权交给新的变量,对原来变量的所有权没有影响。

let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);

浅拷贝


浅拷贝只会发生在栈上,所有在编译时已知大小的类型,都会被分配在栈上。因此栈上基本类型的拷贝并不会导致所有权丢失,因为所有权针对的是堆中的数据。

Rust有一个叫Copy的特征,它可以用在可以在栈中存储的类型,如果一个类型拥有Copy特征,就代表它的拷贝不会导致所有权的移动,拷贝以及原值都可以继续使用。

函数传值与返回


将值传递给函数,也会发生移动或复制

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值移动到函数里 ...
                                    // ... 所以到这里不再有效

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // x 应该移动函数里,
                                    // 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

部分move

Rust还有一个概念叫部分move,如果一个复杂类型P中包含其他复杂类型A,B,C,那么如果其中某一个变量的所有权丢失了,我们就无法访问P这个整体。但我们仍可以直接访问那些没有丢失所有权的部分。


fn main() {
    #[derive(Debug)]
    struct Person {
        name: String,
        age: Box<u8>,
    }

    let person = Person {
        name: String::from("Alice"),
        age: Box::new(20),
    };

    // 通过这种解构式模式匹配,person.name 的所有权被转移给新的变量 `name`
    // 但是,这里 `age` 变量却是对 person.age 的引用, 这里 ref 的使用相当于: let age = &person.age 
    let Person { name, ref age } = person;

    println!("The person's age is {}", age);

    println!("The person's name is {}", name);

    // Error! 原因是 person 的一部分已经被转移了所有权,因此我们无法再使用它
    //println!("The person struct is {:?}", person);

    // 虽然 `person` 作为一个整体无法再被使用,但是 `person.age` 依然可以使用
    println!("The person's age from person struct is {}", person.age);
}

引用与借用

如果Rust仅仅支持转移所有权来传递一个值,那显然是非常低效的,并且会让程序变得格外复杂。因此Rust引入了引用和借用。

常规的引用就是一个普通的指针类型,它指向了对象存储的内存地址,但并不具有指向对象的所有权。因此这不会导致我们在传递参数时就丢失指定对象的所有权,因为指针类型属于直接分配在栈上的基本类型,因此他在拷贝时不会涉及所有权的转移。下面是引用的一般用法。

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

一般来说,引用都是不可变引用,也就是他们无法修改指向对象中的内容。

但Rust同样也支持可变引用,只需要确保引用指向的内容是可变的,并且该引用被声明为可变引用。这样我们就可以通过引用进行修改。

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

但要注意的是,特定数据的可变引用在相同作用域内只能存在一个。这种限制的好处在于可以避免数据竞争。通常情况下,我们会用大括号手动限制引用的作用域,离开作用域后我们就可以创建一个新的引用。

可变引用还有一个限制就是不可以和不可变引用在相同作用域中同时存在,因为不可变引用显然不希望有谁在自己的作用域内对目标进行修改。

Rust中引用的作用域是从它创建开始,到它被最后一次使用截止。不像变量那样以函数或花括号为界限。

Rust还对悬垂引用做了限制,如果一个指针指向的值已经被释放,那么Rust编译器会直接报错。

复合类型

切片

切片允许我们引用集合中部分连续的元素序列,而不是引用整个集合。而在字符串中,切片就是对String类型中某一部分的引用。

切片引用通常占用2个字(64位系统中就是16字节)大小的空间。切片的第一个字是指向数据的指针,第二个字是切片的长度。

fn main() {
    let s = String::from("Hello, World");
    let hello = &s[..5];
    let world = &s[7..];
    println!("{}", hello);
    println!("{}", world);
}

在对字符串使用切片时必须格外小心,因为Rust字符串使用UTF-8编码,因此如果字符串中有中文,必须按照1个中文3个字节的方式截取。


#![allow(unused)]
fn main() {
 let s = "中国人";
 // 这里会报错,因为一个中文占3个字节,只截取两个字节无法正确解码
 let a = &s[0..2];
 println!("{}",a);
}

字符串

字符串就是由字符组成的连续集合,Rust中字符串是UTF-8编码。

Rust在语言级别只有一种字符串类型:str,它通常以引用类型出现,也就是字符串切片。str类型是硬编码进可执行文件,无法被修改的类型。但是String类型则是可修改,可增长的字符串类型。

二者的转换方式:

fn main() {
    // &str转String
    let a = String::from("hello");
    let b = "hello".to_string();
    // String转&str
    let mut s = a.as_str();
    s = &a
}

PS:Rust的字符串不提供索引,因为Rust字符串底层使用u8类型存储字符的编码,也就是说对于不同的字符,我们的访问方式也会不同。例如:英文字符串与中文字符串同时访问索引为0的位置,前者可以获取一个英文字母而后者只是获取了某个字的组成部分。

字符串的具体操作比较多,这里不多赘述,需要的时候直接查询即可 操作字符串 。要注意的一点是,字符串的所有操作全都基于底层的字节数组,因此如果操作涉及_索引_(虽然Rust没有索引,但这里懂什么意思就行),就必须要考虑存储字符的编码。

操作UTF-8字符串


字符

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

for c in "中国人".chars() {
	println!("{}", c);
}

字节

for b in "中国人".bytes() {
	println!("{}", b);
}

获取子串

如果想要准确的从UTF-8字符串中获取子串,需要使用额外的库 utf8_slice

结构体

定义结构体

struct User {
	active: bool,
	username: String,
	email: String,
	sign_in_count: u64
}

创建结构体实例

let user1 = User {
	email: String::from("someone@example.com"),
	username: String::from("someusername123"),
	active: true,
	sign_in_count: 1,
};

PS:初始化实例时每个字段都要初始化

如果结构体字段与传入的参数名相同,可以进行简化

// 简化前
fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

// 简化后
fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

结构体更新语法,如果我们想通过同类型的结构体实例来创建一个新的结构体实例,可以使用一种简化的语法。

// 简化前
let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };

// 简化后
let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };

..表示凡是没有显式声明的字段,都从user1中获取(这中间会发生所有权转移哦)。

元组结构体


结构体必须要有名称,但结构体的字段不一定要有名称,因此没有字段名称的结构体就是元组结构体,他们通常用在我们希望结构体有一个整体名称,但又不需要字段名的时候

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

单元结构体


如果我们不关心结构体需要的字段,只关心它的行为,就可以使用单元结构体。

struct AlwaysEqual;

let subject = AlwaysEqual;

// 我们不关心 AlwaysEqual 的字段数据,只关心它的行为,因此将它声明为单元结构体,然后再为它实现某个特征
impl SomeTrait for AlwaysEqual {

}

结构体数据的所有权


很多情况下我们希望结构体能够拥有专属于自身的值,而不是从其他结构体里借用一个值。如果一定要借用,我们必须要添上声明周期,避免借用值的生命周期比借用者小。

枚举

枚举类型是一个类型,包含所有可能的枚举成员,枚举中的内容可以看成是一系列基础类型和复杂类型的集合。

Rust中的枚举可以直接将信息关联到枚举成员上

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(u8),
    Hearts(u8),
}

并且每个枚举成员关联的信息类型还可以有所不同。任何类型的数据都可以放入枚举中

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Option枚举

Option枚举是Rust中用于处理空值的一种手段,它的定义如下:

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

相比于null,Option的优势在于,它可以让可能会出现空指针异常的情况直接在编译期就被察觉。例如 a + b这个简单的运算,如果二者中有一个为空,就会导致程序出现异常,但如果使用Option,因为它不可以与其他类型进行加法运算,因此我们必须先从里面取出值,期间需要判断是否是有效值。这就保证了所有参与程序运算的值都是有效值,而被Option包裹的则可能是无效值。

一个简单的链表实现

use List::*;

pub enum List {
    Cons(u32, Box<List>),
    Nil
}

// 为链表添加功能
impl List {
    // 创建一个空链表
    pub fn new() -> List {
        Nil
    }

    pub fn prepend(self, elem: u32) -> List {
        // 头插法,此时传入的是一个链表的值,以及待插入的元素
        // 返回一个新结点,并将self的所有权交给新的Box
        Cons(elem, Box::new(self))
    }

    pub fn len(&self) -> u32 {
        // &self是一个&List,因此这里需要解除引用
        // 此时self里面存储的就是持有所有权的实际值,但我们不能拿走后一个节点的所有权,
        // 不然会导致他被drop掉,因此使用引用来处理

        // 如果我们不解除引用,那么就不必加上ref,因为此时的tail直接就是一个引用了
        match *self {
            Cons(_, ref tail) => 1 + tail.len(),
            Nil => 0,
        }
    }

    pub fn stringify(&self) -> String {
        match *self {
            Cons(head, ref tail) => {
                format!("{}, {}", head, tail.stringify())
            },
            Nil => {
                format!("")
            },
        }
    }
}

数组

Rust中的数组直接分配在栈上,因此拥有非常优秀的性能,但缺点就是无法扩容,这也代表着我们数组的长度必须在编译期已知,也就是我们不能通过获取运行时参数来创建数组。与之相对的,还有动态数组Vec,它具备动态扩容的能力,被分配在堆上。

数组类型声明

let a: [i32; 5] = [1, 2, 3, 4, 5];

// 初始化一个某个值重复出现N次的数组
let a = [3; 5]; // 3这个元素出现5次

如果数组元素不是基本类型,那么就需要注意了

let arr = [String::from("a"); 5];

上面这段代码会抛出错误,因为数组的这种语法是通过Copy特征来实现的,但是对于复杂类型,由于他们没有实现Copy特征,因此不能使用这种语法。

而解决方案就是使用std::array::from_fn函数

let array: [String; 8] = core::array::from_fn(|i| String::from("rust is good!"));

模式匹配

match匹配

match target {
    模式1 => 表达式1,
    模式2 => {
        语句1;
        语句2;
        表达式2
    },
    _ => 表达式3
}

将模式与target匹配,即为模式匹配。match后紧跟的是一个表达式,这和if很像,但是if要求表达式的返回值必须是bool。而match则可以接收任何返回值的表达式,只要能和模式进行匹配即可。

PS:match本身也是个表达式,因此可以用它来赋值。

模式绑定


模式匹配的另一个功能是从模式中取出绑定的值。

enum Action {
    Say(String),
    MoveTo(i32, i32),
    ChangeColorRGB(u16, u16, u16),
}

fn main() {
    let actions = [
        Action::Say("Hello Rust".to_string()),
        Action::MoveTo(1,2),
        Action::ChangeColorRGB(255,255,0),
    ];
    for action in actions {
        match action {
            Action::Say(s) => {
                println!("{}", s);
            },
            Action::MoveTo(x, y) => {
                println!("point from (0, 0) move to ({}, {})", x, y);
            },
            Action::ChangeColorRGB(r, g, _) => {
                println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored",
                    r, g,
                );
            }
        }
    }
}

上面的代码中,如果action匹配Action::Say,就会将里面的值绑定到s中。注意,这里也会发生所有权转移

Rust中match要求穷尽匹配,也就是匹配出target的所有可能结果,如果我们不需要匹配出所有的可能性,那么可以使用_进行匹配,它会自动匹配所有没有提到的模式。

if let匹配

如果我们只有一个匹配条件,那么可以使用if let实现,而不需要使用match。

let v = Some(3u8);
if let Some(3) = v {
    println!("three");
}

和if let类似的一种匹配语法是while let,它会一直匹配到模式无法匹配的时候

matches!宏

Rust中提供了matches!宏,它可以将一个表达式跟模式匹配,然后返回匹配结果。

let foo = 'f';
assert!(matches!(foo, 'A'..='Z' | 'a'..='z'));

let bar = Some(4);
assert!(matches!(bar, Some(x) if x > 2));

@绑定

如果我们想要在match中对结构体中的字段进行变量绑定,方法在上文中已经提到。但如果我们想在绑定变量的同时还要限定分支范围,就需要用到@绑定

enum Message {
    Hello { id: i32 },
}

let msg = Message::Hello { id: 5 };

match msg {
	// 这里首先匹配id是否在3到7之内,接着将这个字段绑定到id_variable上
    Message::Hello { id: id_variable @ 3..=7 } => {
        println!("Found an id in range: {}", id_variable)
    },
    Message::Hello { id: 10..=12 } => {
        println!("Found an id in another range")
    },
    // 这里直接将id绑定到局部变量id上
    Message::Hello { id } => {
        println!("Found some other id: {}", id)
    },
}

方法

Rust的方法通常与结构体,枚举以及特征一起使用。示例如下:

struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

impl Circle {
    // new是Circle的关联函数,因为它的第一个参数不是self,且new并不是关键字
    // 这种方法往往用于初始化当前结构体的实例
    fn new(x: f64, y: f64, radius: f64) -> Circle {
        Circle {
            x: x,
            y: y,
            radius: radius,
        }
    }

    // Circle的方法,&self表示借用当前的Circle结构体
    fn area(&self) -> f64 {
        std::f64::consts::PI * (self.radius * self.radius)
    }
}

可以看到,Rust中对象和方法的定义是分离的,这给我们提供了更多的灵活性(感觉有点问题)。

如果要让一个方法与某个特定的实例绑定,那就需要在参数中添加self&self&mut self。这个参数就代表着要执行当前方法的对应实例。而实例对应的类型则是Self。因此self实际上就是self: Self的简写,这里同样涉及到所有权的转移。

如果我们想要调用某个方法,可以直接使用.运算符,而不需要关注作用的对象是否是引用,因为Rust会自动引用或解引用。

在上面的例子中,我们还看到new方法的参数中没有self,这代表它是结构体的关联函数,可以类比为静态方法。通常,我们用new作为构造器方法的名称。

泛型

函数中使用泛型

fn largest<T>(list: &[T]) -> T

如果我们想要在函数中进行比较操作,那么还需要确保泛型是可以比较的,在Rust中就是要求传入的类型实现了对应的特征。

结构体中使用泛型

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

方法中使用泛型

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

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

// 为具体的泛型定义方法
impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

// ----------------

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


impl<T, U> Point<T, U> {
// 方法中也可以增加额外的泛型参数
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

Rust中的泛型对于运行时效率几乎没有任何影响,因为它是通过单态化实现的。编译器在编译时会获取泛型所有可能的取值,然后为他们生成对应的对象。例如Option<T>,如果我使用到了Option<i32>,那么编译器就会生成Option_i32这个数据结构。

这样做的好处是对运行时效率几乎没有任何影响,而缺点是降低了编译速度,并且增大了最终生成文件的大小。

默认泛型类型参数


Rust中使用泛型参数时,可以为其指定一个默认的具体类型

trait Add<RHS=Self> {
	type Output;
	fn add(self, rhs: RHS) -> Self::Output;
}

这里Add里面添加了默认的泛型参数,默认就是调用者的类型

const泛型

参考资料1 参考资料2

特征

Rust中的接口用于将某些行为抽象出来,作用类似于接口。比如上面的加法泛型函数,我们需要限定传入参数都实现了加法特征

fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {}

特征定义了一组可以被共享的行为,只要实现了特征就可以使用这组行为。

pub trait FileOp {
    fn read(&self) ->String;
    fn write(&self, s: String) {
        println!("{}", s);
    }
}

struct A {
    name: String,
}

impl FileOp for A {
    fn read(&self) ->String {
        format!("{}", self.name)
    }
}

fn main() {
    let t = A {name:String::from("ok")};
    println!("{}", t.read());
}

特征定义与实现的位置

如果想要为类型A实现特征T,那么A或者T至少有一个是在当前作用域定义的(所谓当前作用域似乎可以理解为自己写的代码)。这样做的目的是确保他人的代码对于自己只起到增强的作用,而非修改的作用。因为特征与实现中至少有一个是应用在自己这的,不会影响其他部分。

Rust中的特征可以有默认实现,他的语法同普通的方法定一样。这么做的价值在于,实现类没有必要实现那些自己不需要的,或者实现类共用的部分。

使用特征作为函数参数

相当于将接口传递给方法,他的语法如下:

pub fn notify(item: &impl Summary) {
	...
}

特征约束

上面的那种写法仅是一种语法糖,并且它的缺陷在于传入的参数可以是任意的实现类,我们无法实现类似要求多个相同类型实现类的能力。此时就要转用完整格式,他的完整格式如下:

pub fn notify<T: Summary>(item: &T) {
	...
}

<T: Summary>就是特征约束,他表示这个泛型必须是Summary的实现类。

多重约束


如果我们想要指定多个约束条件,可以使用如下语法:

pub fn notify(item: &(impl Summary + Display)) {}
// 或
pub fn notify<T: Summary + Display>(item: &T) {}

Where 约束


当约束条件变得很多时,函数的签名就会变得比较复杂,此时可以改用Where约束来进行简化

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {}

// -->

fn some_function<T, U>(t: T, u: U) -> i32
	where T: Display + Clone,
		  U: Clone + Debug
{}

有条件的实现特征


标准库为任何实现了Display特征的类型实现了ToString特性。这可以让所有实现了Display特征的类型同时实现ToString特征。

impl<T: Display> ToString for T {
	...
}

函数中返回特征实现

可以通过impl Trait来说明一个函数返回了一个实现了某个特征的类型

fn func() -> impl Summary{}

这就类似于多态,不过在Rust中,这个方法只能返回同种类型的值。如果在某种条件下返回了A,某种条件下返回B。那么即使他们都实现了需要的特征,也无法通过编译。

通过derive派生特征

derive会让编译器帮助我们自动派生出想要的特性,提供的是rust的默认实现。

特征对象

在上面的内容中讲到,Rust无法在函数中返回多种实现类,同样的,在泛型中也不能直接应用到多种实现类上。究其原因是实现类的大小不确定,如果有多种实现类,Rust无法计算出自己究竟该分配多少内存空间。

因此在需要多种返回值,且类型不确定或者不在当前作用域时可以使用特征对象来实现功能(如果满足上面的条件可以考虑枚举)。

trait Draw {
	fn draw(&self) -> String;
}

impl Draw for u8 {
	fn draw(&self) -> String {
		format!("u8: {}", *self)
	}
}

impl Draw for f32 {
	fn draw(&self) -> String {
		format!("f32: {}", *self)
	}
}

fn draw1(x: Box<dyn Draw>) {
	x.draw();
}

fn draw2(x: &dyn Draw) {
	x.draw();
}

dyn的作用就是表明这是一个特征对象,他会将映射关系存储在一个表中,他可以在运行时通过特征对象查找到具体调用的方法。

不过dyn只需要在特征对象的类型声明上,他也不能单独作为特征对象的定义,因为&和Box的大小都是确定的但dyn修饰的对象没有确定大小,因此它不可以单独出现。

特征对象的动态分发

Rust的泛型是用静态分发实现的,也就是在编译期为每个泛型类型参数对应的具体类型生成一份代码,整个过程都是在编译期完成的,对于运行时性能没有影响。

与之相对的就是动态分发,dyn使用的就是这种方式。因为编译器无法知晓所有可能用于特征对象代码的类型,也就不知道要调用哪个类型的方法。Rust在运行时使用特征对象中的指针来知晓要调用哪个方法。

动态分发

特征对象中,ptr指向实现了特征的实际类型。vptr指向虚表vtable,他保存了实现了特征的具体类型的对应方法,当要调用时Rust就到虚标中查找对应的对象与实现。

PS:此时程序只知道它指向了一个具有某个特征的对象,但不了解他具体是什么,因此这里只能调用特征的方法。

特征对象的限制

不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,他的对象才是安全的:

  • 方法的返回类型不能是Self
    有了特征对象就不需要知道实现该特征的具体类型是什么。如果使用了Self,而特征对象忘记了真正的类型,就会出现问题。(这tm是什么情况)
  • 方法没有任何泛型参数
    泛型使用静态分发实现,而特征对象在进行动态分发时会抹去集体类型,也就无从得知放入泛型参数的类型是什么。

关联类型

关联类型是在特征定义的语句块中,申明一个自定义类型,就可以在特征的方法签名中使用该类型

pub trait Iterator {
	type Item;

	fn next(&must self) -> Option<Self::Item>
}

Self是指调用者的具体类型,Self::Item就是指该类型中定义的Item类型。

impl Iterator for Counter {
	type Item = u32;

	fn next(&mut self) -> Option<Self::Item> {
		...
	}
}

特殊的方法调用

不同特征很可能有同名的方法,在Rust中,他会优先调用调用者自身实现的方法。如果我们希望调用它实现的某个特征的方法,例如Human有方法A,但他又实现了特征B定义的A方法,此时如果想要调用特征B的语法,就需要用B::A(&human),Rust会根据传入的引用类型寻找对应的实现。

除此之外,关联函数也有可能出现同名的情况,但由于关联函数没有self参数,因此上面的语法就行不通了,此时就要使用完全限定语法。<Human as B>::A(...)

特征定义中的特征约束

有时我们希望实现某些特征的类已经实现了需要的特征。比如我想要有一个能够以各种形式输出的特征,实现这个特征的前提是已经实现了Display特征,可以转为字符串。

trait OutlinePrint: Display {
	fn outline_print(&self) {
		let t = self.to_string();
		...
	}
}

上面的例子要求实现OutlinePrint的类实现了Display特征

在外部类型上实现外部特征

前文提到Rust特征与实现类需要遵循孤儿原则,以此避免本地代码与库的相互侵入。但如果我们确实需要绕开孤儿原则,比如给Vec实现Display特征,那我们可以给Vec进行一层封装,创建一个Wrapper结构体,然后让它去实现Display特征。

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {}", w);
}

new type模式非常灵活,唯一的缺点就是调用语句略显繁琐,每次都要用self.0获取内部存储的具体数据。不过Rust提供了Deref特征,实现了它的结构可以自动封装或解包。

Vec

Rust中的Vec是一种动态数组。

新建vector

let v: Vec<i32> = Vec::new();

// vec![]创建
let v = vec![1, 2, 3];

因为使用关联方法new创建对象时,编译器不知道里面究竟会存储什么元素,因此需要指定类型。

更新vector

let mut v = Vec::new();
v.push(1);

这里不需要声明的原因是,编译器可以通过下面的插入代码推断出里面存储什么元素。

读取vector

可以通过索引或get方法获取存储在vector中的值,区别在于索引的方式会出现数组越界的问题,而get方法返回一个Option,更加安全。

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}

tricks:

在对vector的读取和更新时也要考虑到所有权和借用规则,比如下面一段代码

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}

上面的代码中,first获取了一个不可变引用,它的作用域持续到了println语句,而在中间vec尝试获取了一个可变引用,这显然破坏了借用规则,所以无法通过编译。虽然这个限制看着非常无理,但实际上,因为vec插入元素可能导致扩容,此时会让之前获取到的引用失效,因此这个限制是很有必要的。

遍历vector

let v = vec![100, 32, 57];
for i in &v {
	println!("{i}");
}

let mut v = vec![100, 32, 57];
for i in &mut v {
	*i += 50;
}

无论可变还是不可变地遍历一个vector都是安全的,因为一旦获取到了vector中的引用,那么就相当于获取到了一把锁,如果要对vector进行修改,会发现还有可变/不可变引用在作用域内。可变引用不可与不可变引用处于同一作用域,相同作用域中不能有多个可变引用。

生命周期

借用检查

为了保证Rust的所有权和借用的正确性,Rust使用借用检查器来确保借用的正确性。当我们将一个引用赋给某个变量之后,编译器会检查这个引用变量的作用域是否小于引用对应的值的作用域,以此来避免出现悬挂引用。

函数中的生命周期

下面来看一个特殊的例子

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        return x;
    }
    y
}

上面这段代码看起来非常完美,但实际上编译器会报错,表示函数的返回值来自于函数的参数,但对他而言无法确定返回值的生命周期是否小于借用参数的生命周期。

也就是说,我们必须让编译器知道这里的返回值的生命周期与借用值的生命周期,这样他才能确保调用后的引用生命周期分析。但实际情况是,即使是人也不知道返回值的生命周期,因为究竟返回哪一个值是运行时决定的。

但实际上我们未必要确切推导出实际的生命周期,我们只要让编译器知道,函数参数会活的比返回值更久即可。因此Rust引入了生命周期标注语法。

重点!!! 生命周期标注并不会改变任何引用的实际作用域。 它的唯一作用是让编译器了解到多个引用之间的关系,对于实际的作用域不会产生任何影响。

修改上面的函数

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        return x;
    }
    y
}

首先我们声明了一个生命周期<‘a>,然后标记x和y以及返回值的生命周期大于等于’a。这里’a究竟有多久并不重要,因为编译器可以根据这个标注推断出返回值的作用域会与两个参数中较小作用域的那个一致。

结构体中的生命周期

之前我们在使用结构体时基本就没有用过引用类型的字段,因为非引用类型可以直接将所有权转移到结构体内,从而就不必关心生命周期的问题。

不过要想使用引用类型也并非不可,只需要给结构体中的引用标注上生命周期即可。

struct Excerpt<'a> {
	part: &'a str,
}

上面的这段结构体声明表示,part字段的生命周期大于等于’a,也就是它的生命周期大于结构体的生命周期。

生命周期消除

参数的生命周期被称为输入生命周期,返回值的生命周期被称为输出生命周期

消除规则

编译器按照3条消除规则来确定哪些场景不需要显示标注生命周期:

  1. 每一个引用都参数会获取独自的生命周期。对于没有标注生命周期的参数,编译器会给他们每一个都分配一个生命周期。
  2. 如果只有一个输入生命周期,则该生命周期会被赋给所有输出生命周期
  3. 若存在多个生命周期,且其中有&self&mut self。就会将self的生命周期赋给所有输出生命周期。

如果以上规则无法确定生命周期,就会要求用户手动指定生命周期

静态生命周期

Rust中有一种特殊的生命周期'static,被他标注的引用的生命周期和整个程序一样久(实际上可能没那么久)

错误处理

panic

程序一旦抛出了panic异常,代表它碰到了不可恢复错误,只能终止程序运行。如果我们想要主动抛出panic,可以调用panic!,当这个宏被调用时,程序会打印出一个错误信息,然后展开函数的调用栈,最后退出程序。

panic的使用时机

  1. 示例,原型,测试:如果正处于开发阶段,那么我们肯定希望优先加快编码速度,因此可以不专门处理错误,直接让程序被动触发panic
  2. 确定程序正确:当能够确定某个程序流是正确的时,可以直接用panic
  3. 可能导致全局有害状态
    1. 非预期的错误
    2. 后续代码的运行受到显著影响
    3. 可能导致内存安全问题,比如数组越界

panic原理

  1. 格式化 panic 信息,然后使用该信息作为参数,调用 std::panic::panic_any() 函数
  2. panic_any 会检查应用是否使用了 panic hook,如果使用了,该 hook 函数就会被调用(hook 是一个钩子函数,是外部代码设置的,用于在 panic 触发时,执行外部代码所需的功能)
  3. 当 hook 函数返回后,当前的线程就开始进行栈展开:从 panic_any 开始,如果寄存器或者栈因为某些原因信息错乱了,那很可能该展开会发生异常,最终线程会直接停止,展开也无法继续进行
  4. 展开的过程是一帧一帧的去回溯整个栈,每个帧的数据都会随之被丢弃,但是在展开过程中,你可能会遇到被用户标记为 catching 的帧(通过 std::panic::catch_unwind() 函数标记),此时用户提供的 catch 函数会被调用,展开也随之停止:当然,如果 catch 选择在内部调用 std::panic::resume_unwind() 函数,则展开还会继续。

传播错误

Rust定义了一个宏?用于错误传播,因为在程序中很多地方我们面对的并不是不可恢复错误,这时就需要用Result进行处理。

fn panic_test() -> Result<String, io::Error> {
	let f = File::open("a.txt");
	let mut f = match f {
		Ok(f) => f,
		Err(e) => return Err(e),
	};
	let mut s = String::new();
	let res = f.read_to_string(&mut s);
	match res {
		Ok(_) => Ok(s),
		Err(e) => return Err(e),
	}
}

上面的代码展示了如何用Result进行错误传播,可以看到代码还是非常繁琐的,并且最关键的问题是我们必须知道要调用的函数都会返回什么错误类型,这又会导致非常繁琐的错误类型转换。

因此接下来我们将介绍?的作用,它可以大幅简化上面的代码

fn panic_test() -> Result<String, Box<dyn std::error::Error>> {
	let mut f = File::open("a.txt")?;
	let mut s = String::new();
	f.read_to_string(&mut s)?;
	Ok(s)
}

这里?的作用就相当于是上面那一大段match的作用,与此同时,?还提供自动转型的功能,也就是说我们只要用一个足够大的错误类型包含所有可能返回的错误类型即可,?会自动替我们转换类型。

?除了用于处理Result之外,还可以用于Option的处理,如果返回错误,那么就直接返回一个None。

tips:?必须要有一个变量来承载调用成功的返回值,只有碰到错误值时,他才会直接返回。

包和模块

Crate

在Rust中,包是一个独立的可编译单元,它编译后会生成一个可执行文件或一个库

一个包会将相关联的功能打包在一起,方便该功能在多个项目中共享。

Package

Rust中的Package就是指一个项目,包含独立的Cargo.toml文件,以及因为功能性被组织在一起的一个或多个包。一个Package中只能包含一个库类型的包,但可以包含多个二进制可执行类型的包。

一个Package中可能有多个二进制包,他们往往放在src/bin下,文件名就是包名。

模块

Rust中创建模块的语法是

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

通过mod关键字来创建模块,再模块中可以定义结构体,函数,方法等。这样我们可以将功能想的代码被组织在一起。

src/main.rs与src/lib.rs是包的根,他们共同构成一个叫crate的模块。因此如果我们要用绝对路径引入front_of_house,对应的语法是use crate::front_of_house

可见性

Rust规定,默认情况下,所有类型都是私有的,甚至模块本身也是私有的。但是父模块对于子模块来说是完全可见的,不过父模块无法访问子模块的私有项。

如果要对外暴露,可以使用pub关键字,需要注意的是,pub修饰枚举之外的东西,都只代表外部可以引用这个东西本身,它内部的方法或变量并不会暴露出去。

Rust查找模块的逻辑
从crate开始查找,看当前所处的包下有没有对应模块名的文件,如果有,那么这个文件就是要找的模块。如果模块被放在一个文件夹里,那么就会到模块名所在的文件夹内查找mod.rs文件或是与模块同名的文件,如果能找到,就代表它是要找的模块

  • 标题: Rust —— 基础篇
  • 作者: Zephyr
  • 创建于 : 2023-04-09 11:36:22
  • 更新于 : 2023-04-19 15:02:28
  • 链接: https://faustpromaxpx.github.io/2023/04/09/rust/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论