《Rust语言圣经》笔记(基础入门)

《Rust语言圣经》笔记(基础入门)

最近在学习《Rust语言圣经》 ,并参与了贡献,希望大家多提意见,一起完善这个项目。

usize、isize类型的引入

isize 和 usize 类型取决于程序运行的计算机cpu类型:若cpu是32位的,则这两个类型是32位的,同理,若cpu是64位,那么它们则是64位。

所有权的引入

Rust之所以能万众瞩目,是因为其内存安全的特性。在以往,内存安全几乎都是通过GC来保证的,但是GC会带来性能、内存占用以及Stop the world等问题,在高性能场景和系统编程上是不可接受的,因此Rust采用了所有权系统。

所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。在计算机语言不断演变过程中,出现了三种流派:

  • 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
  • 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C、C++
  • 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查

其中Rust选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。

手动管理内存的分配和释放很可能由于程序员的疏忽导致异常,C语言中一段不安全的代码如下:

int* foo() {
    int x = 100; 
    return &x;
}
// 变量x是整型,所以声明后会在栈上申请空间,退出函数后栈释放掉了,造成了悬浮指针。
//(在栈上声明的变量离开作用域后都会自动释放)
// 想要解决这个问题可以通过malloc在堆上申请空间

堆与栈的性能区别

栈和堆是编程语言最核心的数据结构,但是在很多语言中,你并不需要深入了解栈与堆。 但对于Rust这样的系统编程语言,值是位于栈上还是堆上非常重要, 因为这会影响程序的行为和性能。

栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。

写入方面:入栈比在堆上分配内存要快,因为入栈时操作系统无需分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备。

读取方面:得益于CPU高速缓存,使得处理器可以减少对内存的访问,高速缓存和内存的访问速度差异在10倍以上!栈数据往往可以直接存储在CPU高速缓存中,而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问堆再通过堆上的指针来访问内存。

因此,处理器处理和分配在栈上数据会比在堆上的数据更加高效。

所有权的规则

某些语言中存在深拷贝和浅拷贝的概念,在Rust中也有类似的概念

存储在栈上的基本数据类型(如整型、浮点型、字符、布尔)是通过自动拷贝的方式来赋值的,因为存放在栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32,4个字节)的内存即可,相当于在栈上做了深拷贝。

存储在堆上的数据赋值与浅拷贝类似(如String),不同的是,赋值之后,之前的变量无效了,因此这个操作成为移动(move)。Rust中可以通过clone方法来完成深拷贝,但是使用频繁会降低程序性能。

借用的规则

同一时刻,只能有一个可变引用或者任意多个不可变引用

字符串

在对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是UTF8字符的边界,例如中文在UT8中占用三个字节,下面的代码就会崩溃:

#![allow(unused)]
fn main() {
 let s = "中国人";
 let a = &s[0..2];
 println!("{}",a);
}

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

str:str是“预分配文本(preallocated text)”的字符串,这个预分配文本存储在可执行程序的只读内存中。换句话说,这是装载我们程序的内存并且不依赖于在堆上分配的缓冲区。只可以读,不可以修改。

&str:str切片,只是对str的一个引用()。只可以读,不可以通过切片对str进行修改。

String:Rust会在栈上存储String对象。这个对象里包含以下三个信息: 一个指针指向一块分配在堆上的缓冲区,这也是数据真正存储的地方,数据的容量和长度。因此,String对象本身长度总是固定的三个字(word)。String之所以为String的一个原因在于它能够根据需要调整缓冲区的容量。例如,我们能够使用push_str()方法追加更多的文本,这种追加操作可能会引起缓冲区的增长。

结构体

必须要将整个结构体都声明为可变的,才能修改其中的字段,Rust不允许单独将某个字段标记为可变。

把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段。

匹配守卫与@绑定

匹配守卫(match guard)是一个位于 match 分支模式之后的额外 if 条件,它能为分支提供更进一步的匹配条件,这个条件可以使用模式中创建的变量:

let num = Some(4);

match num {
    Some(x) if x < 5 => println!("less than five: {}", x),
    Some(x) => println!("{}", x),
    None => (),
}

这个例子会打印出 less than five: 4。当 num 与模式中第一个分支匹配时, Some(4) 可以与 Some(x)匹配,接着匹配守卫检查 x 值是否小于 5,因为 4 小于 5,所以第一个分支被选择。

相反如果 numSome(10),因为 10 不小于 5 ,所以第一个分支的匹配守卫为假。接着 Rust 会前往第二个分支,因为这里没有匹配守卫所以会匹配任何 Some 成员。

模式中无法提供类如if x < 5的表达能力,我们可以通过匹配守卫的方式来实现。

也可以在匹配守卫中使用 运算符 | 来指定多个模式,同时匹配守卫的条件会作用于所有的模式。下面代码展示了匹配守卫与 | 的优先级。这个例子中看起来好像 if y 只作用于 6,但实际上匹配守卫 if y 作用于 45 6 ,在满足x属于 4 | 5 | 6 后才会判断 y 是否为 true

let x = 4;
let y = false;

match x {
    4 | 5 | 6 if y => println!("yes"),
    _ => println!("no"),
}

这个匹配条件表明此分支只匹配 x 值为 456 同时 ytrue 的情况。

虽然在第一个分支中,x 匹配了模式 4,但是对于匹配守卫 if y来说,因为 yfalse,因此该守卫条件的值永远是false`,也意味着第一个分支永远无法被匹配。

下面的文字图解释了匹配守卫作用于多个模式时的优先级规则,第一张是正确的:

(4 | 5 | 6) if y => ...

而第二张图是错误的

4 | 5 | (6 if y) => ...

可以通过运行代码时的情况看出这一点:如果匹配守卫只作用于由 | 运算符指定的值列表的最后一个值,这个分支就会匹配且程序会打印出 yes

@(读作at)运算符允许为一个字段绑定另外一个变量。下面例子中,我们希望测试 Message::Helloid 字段是否位于 3..=7 范围内,同时也希望能将其值绑定到 id_variable 变量中以便此分支中相关的代码可以使用它。我们可以将 id_variable 命名为 id,与字段同名,不过出于示例的目的这里选择了不同的名称。

enum Message {
    Hello { id: i32 },
}

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

match msg {
    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")
    },
    Message::Hello { id } => {
        println!("Found some other id: {}", id)
    },
}

上例会打印出 Found an id in range: 5。通过在 3..=7 之前指定 id_variable @,我们捕获了任何匹配此范围的值并同时将该值绑定到变量id_variable上。

第二个分支只在模式中指定了一个范围,id 字段的值可以是 10、11 或 12,不过这个模式的代码并不知情也不能使用 id 字段中的值,因为没有将 id 值保存进一个变量。

最后一个分支指定了一个没有范围的变量,此时确实拥有可以用于分支代码的变量 id,因为这里使用了结构体字段简写语法。不过此分支中没有像头两个分支那样对 id 字段的值进行测试:任何值都会匹配此分支。

当既想要限定分支范围,又想要使用分支的变量时,就可以用@来绑定到一个新的变量上,实现想要的功能。

关联类型

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

pub trait Iterator {
    type Item;

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

以上是标准库中的迭代器特征 Iterator,它有一个 Item 关联类型,用于替代遍历的值的类型。

同时,next 方法也返回了一个 Item 类型,不过使用 Option 枚举进行了包裹,假如迭代器中的值是 i32 类型,那么调用 next 方法就将获取一个 Option<i32> 的值。

Self 用来指代当前的特征实例,那么 Self::Item 就用来指代特征实例中具体的 Item 类型:

impl Iterator for Counter {
       type Item = u32;

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

在上述代码中,我们为 Counter 类型实现了 Iterator 特征,那么 Self 就是当前的 Iterator 特征对象, Item 就是 u32 类型。

这里也可以使用泛型,例如如下代码:

pub trait Iterator<Item> {
    fn next(&mut self) -> Option<Item>;
}

但是考虑到代码的可读性,当你使用了泛型后,你需要在所有地方都写 Iterator<Item>,而使用了关联类型,你只需要写 Iterator,当类型定义复杂时,这种写法可以极大的增加可读性:

pub trait CacheableItem: Clone + Default + fmt::Debug + Decodable + Encodable {
  type Address: AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash;
  fn is_null(&self) -> bool;
}

例如上面的代码, Address 的写法自然远比 AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash 要简单的多,而且含义清晰。

再例如,如果使用泛型,你将得到以下的代码:

trait Container<A,B> {
    fn contains(&self,a: A,b: B) -> bool;
}

fn difference<A,B,C>(container: &C) -> i32
  where C : Container<A,B> {
    ...
}

可以看到,由于使用了泛型,导致函数头部也必须增加泛型的声明,而使用关联类型,将得到可读性好得多的代码:

trait Container{
    type A;
    type B;
    fn contains(&self, a: &Self::A, b: &Self::B) -> bool;
}

fn difference<C: Container>(container: &C) {}

impl Trait 和 dyn Trait

impl Trait 和 dyn Trait 在 Rust 分别被称为静态分发和动态分发。

语法的角度:impl Trait会出现在两个位置:参数位置,返回值位置。

当代码涉及多态时, 需要某种机制决定实际调用类型,Rust 的 Trait 可以看作某些具有通过特性类型的集合, 静态分发, 正如静态类型语言的”静态”一词说明的, 在编译期就确定了具体调用类型。 Rust 编译器会通过单态化(Monomorphization) 将泛型函数展开。通过单态化,编译器消除了泛型,而且没有性能损耗,这也是 Rust 提倡的形式, 缺点是过多展开可能会导致编译生成的二级制文件体积过大,这时候可能需要重构代码。

静态分发虽然有很高的性能,但在文章开头其另一个缺点也有所体现,那就是无法让函数返回多种类型,因此 Rust 也支持通过 trait object (Box)实现动态分发。既然 Trait 是具有某种特性的类型的集合,那我们可以把 Trait 也看作某种类型,但它是”抽象的”,就像 OOP 中的抽象类或基类,不能直接实例化。

Rust 的 trait object 使用了与 c++ 类似的 vtable 实现, trait object 含有1个指向实际类型的 data 指针,和一个指向实际类型实现 trait 函数的 vtable, 以此实现动态分发。更加详细的介绍可以参考:Exploring Dynamic Dispatch in Rust

参考:

特征Trait

特征对象

Rust:impl Trait vs impl dyn Trait

捋捋 Rust 中的 impl Trait 和 dyn Trait

从Vector中读取函数

读取数组中的元素可以通过下标索引和.get两种方式获取,区别是前者会检查下标是否越界,需要对返回的 Option 做处理,而且会有略微的性能损耗,但是如果可以确保下标不会越界,那么可以直接用索引访问。

哈希函数

目前,HashMap 使用的哈希函数是 SipHash,它的性能不是很高,但是安全性很高。SipHash 在中等大小的 Key 上,性能相当不错,但是对于小型的 Key (例如整数)或者大型 Key (例如字符串)来说,性能还是不够好。若你需要极致性能,可以考虑别的库的实现。

Transmute

mem::transmute<T, U> 将类型 T 直接转成类型 U,唯一的要求就是,这两个类型占用同样大小的字节数。转换后创建一个任意类型的实例会造成无法想象的混乱,而且根本无法预测。

panic

如果是 main 线程,则程序会终止,如果是其它子线程,该线程会终止,但是不会影响 main 线程。因此,尽量不要在 main 线程中做太多任务,将这些任务交由子线程去做,就算子线程 panic 也不会导致整个程序的结束。

当调用 panic! 宏时,它会:

  1. 格式化 panic 信息,然后使用该信息作为参数,调用 std::panic::panic_any() 函数

  2. panic_any 会检查应用是否使用了 panic hook,如果使用了,该 hook 函数就会被调用(hook是一个钩子函数,是外部代码设置的,用于在panic触发时,执行外部代码所需的功能)

     use std::panic;
    
     panic::set_hook(Box::new(|_| {
         println!("Custom panic hook");
     }));
    
     panic!("Normal panic");
    

unwrap 和 expect

expect相比unwrap能提供更精确的错误信息。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!