官方 Rust Book 学习笔记

发布于 2017-07-09 16:17:24

Rust

Rust is a systems programming language that runs blazingly fast, prevents segfaults, and guarantees thread safety.

Rust 语言着重于内存安全,运行快速。

数据存储的物理结构

栈(Stack)

在很多语言中并不经常需要考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的选择。我们会在本章的稍后部分描述所有权与堆与栈相关的部分,所以这里只是一个用来预热的简要解释。

栈和堆都是代码在运行时可供使用的内存部分,不过他们以不同的结构组成。栈以放入值的顺序存储并以相反顺序取出值。这也被称作后进先出(last in, first out)。想象一下一叠盘子:当增加更多盘子时,把他们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做进栈(pushing onto the stack),而移出数据叫做出栈(popping off the stack)。

操作栈是非常快的,因为它访问数据的方式:永远也不需要寻找一个位置放入新数据或者取出数据因为这个位置总是在栈顶。另一个使得栈快速的性质是栈中的所有数据都必须是一个已知的固定的大小

堆(Heap)

相反对于在编译时未知大小或大小可能变化的数据,可以把他们储存在堆上。堆是缺乏组织的:当向堆放入数据时,我们请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回给我们一个它位置的指针。这个过程称作在堆上分配内存(allocating on the heap),并且有时这个过程就简称为“分配”(allocating)。

向栈中放入数据并不被认为是分配。因为指针是已知的固定大小的,我们可以将指针储存在栈上,不过当需要实际数据时,必须访问指针。

想象一下去餐馆就坐吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

访问堆上的数据要比访问栈上的数据要慢因为必须通过指针来访问。现代的处理器在内存中跳转越少就越快。继续类比,假设有一台服务器来处理来自多个桌子的订单。它在处理完一个桌子的所有订单后再移动到下一个桌子是最有效率的。从桌子 A 获取一个订单,接着再从桌子 B 获取一个订单,然后再从桌子 A,然后再从桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据之间彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间

当调用一个函数,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

记录何处的代码在使用堆上的什么数据,最小化堆上的冗余数据的数量以及清理堆上不再使用的数据以致不至于耗尽空间,这些所有的问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过理解如何管理堆内存可以帮助我们理解所有权为何存在以及为什么以这种方式工作。

String&str

线程安全保证的基础

与其他语言(Python, JavaScript, Java, Golang)不同,Rust 没有垃圾回收(GC,garbage collector),而是通过一套特殊的约定规则来保证线程安全。

而垃圾回收是一项复杂而损耗性能的工作。一般通过引用计数标记-清除等技术实现。

线程安全就是当多个引用(或者说线程)同时访问同一个内存地址的数据,就可能发生数据冲突。

数据冲突发生的条件:

  1. 有引用尝试修改(写)数据
  2. 有一个或者多个引用尝试读取数据

默认地,当变量A在某一个作用域定义时,那么当程序运行到这个作用域结束的地方,就会主动地释放所定义的变量所占用的内存空间。

变量不可变性

默认地,变量定义后是不可以修改其值(数据)的(immutable)。

let val = 3;

要想在后面可以对其修改(mutable),则必须添加mut语言关键字。

let mut val = 3;

当然可以随时对变量的可变性进行转换,不过得遵守一定的条件(宗旨是不能产生数据竞争)。

所有权

当一个变量被定义时,对应的变量标识符就拥有对这个数据的主权。

主权只存在一个,但是可能存在两种不同状态:可读可写。当然可写的时候,也是可读的。

移动

默认地,当把变量赋值给其他变量时,主权发生了转移,原有的变量标识符不能再被使用来访问(读、写)指向的数据。

let val = 8;
let val2 = val; // 此时变量val不可再使用
let val = val2; // 但是标识符本身可以被重复使用

特别地,当把变量传递给一个函数时,相当于把变量(实参 数据本身)传递给了函数内部的形参标识符),也会发生所有权移动。

引用和借用

如果通过一个函数对变量进行处理,但是又不想在这个函数结束之后丢弃变量(所有权已经转移到了调用函数的作用域中),就得在函数返回这个修改后的变量。 这样会造成代码很繁琐。于是,产生了引用这个概念。

通过在变量标识符前面添加&符号来表示引用(reference),而不是作为参数传递(同时传递所有权)。e.g. &val

通过将不包含所有权的引用传递给函数,称为借用(borrowing)

这样在函数结束后,在此函数被调用的作用域中还可以继续使用这个变量。

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

    change(&s); // &s 是引用,然后传递给函数是借用
}

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

Slices

另一个没有所有权的数据类型是 slice。slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。

生命周期

生命周期主要是为了防止悬垂指针而产生的。当一个指针指向的内存地址被释放了,那么这个指针指向的可能是未被使用的随机内容,或者被其他变量占用的数据。

生命周期注解并不会实际上改变变量的回收时机,只是让编译器知道这些引用变量之间的相互关系,从而能在编译的时候对代码进行检查。

错误处理方式

程序中的错误总是存在的。或者由代码bug产生,或者由不可预期的输入内容导致。

处理错误的方式一般有两种:返回错误或者抛出异常。

Python和JavaScript采用抛出异常的方式,Golang采用返回错误的方式(也有抛出异常的方式)。

而Rust也同时具有这两种方式。

Result

Result是一个内置的enum类型,表示正常的结果错误

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

通过返回Result来处理可能的错误,将错误的处理方式交给了函数的调用者。

语法糖

  1. Resultunwrap()方法将在运行正常时返回处理结果,否则抛出异常。
  2. 如果函数签名的返回内容是Result类型,那么Result后面加上?将在出现错误的时候直接返回错误。

原则

panic 用于不可恢复的错误,Result用于可恢复的错误。

Option<T>

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

含有或不包含值。

测试的灵活性

就像cargo run会编译代码并运行生成的二进制文件,cargo test在测试模式下编译代码并运行生成的测试二进制文件。

这里有一些选项可以用来改变cargo test的默认行为。 可以指定命令行参数来改变这些默认行为。

例如,cargo test生成的二进制文件的默认行为是并行的运行所有测试,并捕获测试运行过程中产生的输出避免他们被显示出来使得阅读测试结果相关的内容变得更容易。

这些选项的一部分可以传递给cargo test,而另一些则需要传递给生成的测试二进制文件。为了分隔两种类型的参数,首先列出传递给cargo test的参数,接着是分隔符--,再之后是传递给测试二进制文件的参数。

例如,运行cargo test --help会告诉你cargo test的相关参数,而运行cargo test -- --help则会告诉你位于分隔符--之后的相关参数

禁用并行

$ cargo test -- --test-threads=1

禁用捕获测试的输出

$ cargo test -- --nocapture

通过名称来运行测试的子集

$ cargo test <partial-functions-name>

忽略测试

#[test]
#[ignore]
fn expensive_test() {
    // code that takes an hour to run
}

运行被忽略的测试

$ cargo test -- --ignored

trait

trait 是对未知类型 Self 定义的方法集。

函数式编程

闭包

Rust的lambda表达式的参数放在两个|之间,函数体可用大括号包含,同时可以引用环境的变量。

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

获取他们环境中值的闭包主要用于开始新线程的场景。

我们也可以定义以闭包作为参数的函数,通过使用Fn``trait

有三个可以指定为闭包的trait bound(约束)FnFnMutFnOnce

这是在 Rust 中经常见到的三种模式的延续:借用、可变借用和获取所有权。

Fn来指定可能只会借用其环境中值的闭包。用FnMut来指定会修改环境中值的闭包,而如果闭包会获取环境值的所有权则使用FnOnce。大部分情况可以从Fn开始,而编译器会根据调用闭包时会发生什么来告诉你是否需要FnMutFnOnce

fn call_with_one<F>(some_closure: F) -> i32
    where F: Fn(i32) -> i32 {
    some_closure(1)
}

let answer = call_with_one(|x| x + 2);

assert_eq!(3, answer);

迭代器

迭代器是 Rust 中的一个模式,它允许你对一个项的序列进行某些处理。

Rust迭代器默认实现一系列常用的迭代器适配器(iterator adaptors),他们获取一个迭代器并产生一个新的迭代器。 比如filter, map

迭代器是惰性的,只有在调用了消费适配器(consuming adaptors)才会真正地运行。之前了解过的Haskell语言的求值也是惰性的!而Rust编译器最开始由Ocaml语言编写。

Iterator trait

迭代器都实现了一个标准库中叫做Iterator的 trait。其定义看起来像这样:

trait Iterator {
    type Item;

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

当讲到Iterator的定义时,我们故意省略一个小的细节。Iterator定义了一系列默认实现,他们会调用next方法。因为next是唯一一个Iterator trait 没有默认实现的方法,一旦实现之后,Iterator的所有其他的适配器就都可用了。

Crates.io软件仓库

token

创建账号后,新建一个token来验证发布crate的权限

$ cargo login abcdefghijklmnopqrstuvwxyz012345

发布和撤销

标记一个版本的 crate 为 yank 意味着没有项目能够再开始依赖这个版本,不过现存的已经依赖这个版本的项目仍然能够下载和依赖这个版本的内容。crates.io 的一个主要目的是作为一个代码的永久档案库,这样能够保证所有的项目都能继续构建,而允许删除一个版本违反了这个目标。本质上来说,yank 意味着所有带有 Cargo.lock 的项目并不会被破坏,同时任何未来生成的 Cargo.lock 将不能使用被撤回的版本。 yank 并不意味着删除了任何代码。

为了 yank 一个版本的 crate,运行cargo yank并指定需要 yank 的版本:

$ cargo yank --vers 1.0.1

也可以撤销 yank,并允许项目开始依赖这个版本,通过在命令中加上–undo:

$ cargo yank --vers 1.0.1 --undo

智能指针

智能指针(Smart pointers)是一类数据结构,他们的表现类似指针,但是也拥有额外的元数据和能力,比如说引用计数。智能指针模式起源于 C++。在 Rust 中,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反大部分情况,智能指针拥有他们指向的数据。

Box<T>类型

Box<T>用于在堆上分配值

在 Rust 中,所有值默认都由分配。值也可以通过创建 Box<T>装箱(boxed,分配在堆上)。装箱类型是一个智能指针,指向堆分配的 T 类型的值。当一个装箱类型离开作用域时,它的析构器会被调用,内部的对象会被销毁,分配在堆上内存会被释放。

装箱的值可以使用 * 运算符进行解引用。

Deref trait

自动解引用直到满足所要求的我类型,避免过多地书写*解引用符号。

Drop trait

垃圾清理

Rc<T>

一个**引用计数(reference counter)**类型,其数据可以有多个所有者。只能用于单线程环境

RefCell<T>

其本身并不是智能指针,不过它管理智能指针RefRefMut的访问,在运行时而不是在编译时执行借用规则。

并发

线程

使用std::thread::spawn来产生子进程,返回一个handle。handle.join()等待进程退出。

通过在闭包之前增加move关键字,我们强制闭包获取它使用的值的所有权,而不是引用借用。

use std::thread;

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

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join();
}

消息传递模式

类似于Golang的chanels,通过通道来传递消息。

mpsc::channel函数创建一个新的通道。mpsc是多个生产者,单个消费者(multiple producer, single consumer)的缩写。简而言之,可以有多个产生值的发送端,但只能有一个消费这些值的接收端。

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::new(1, 0));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::new(1, 0));
        }
    });
}

共享数据模式

互斥器(mutex)是一种用于共享内存的并发原语。它是“mutual exclusion”的缩写,也就是说,任意时间,它只允许一个线程访问某些数据。

有一个类似Rc<T>并可以安全的用于并发环境的类型:Arc<T>。字母“a”代表原子性(atomic),所以这是一个原子引用计数(atomically reference counted)类型。原子性是另一类这里还未涉及到的并发原语;请查看标准库中std::sync::atomic的文档来获取更多细节。其中的要点就是:原子性类型工作起来类似原始类型,不过可以安全的在线程间共享。

use std::sync::{Mutex, Arc};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = counter.clone();
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

可扩展的并发

两个Trait

  • Send用于表明所有权可能被传送给其他线程,标记 trait 表明类型的所有权可能被在线程间传递。
  • Sync表明多线程访问是安全的,标记 trait 表明一个类型可以安全的在多个线程中拥有其值的引用。

任何完全由 Send 的类型组成的类型也会自动被标记为 SendSync同理。

几乎所有基本类型都是 Send 的,大部分标准库类型是Send的,除了Rc<T>,以及裸指针(raw pointer)

RefCell<T>Cell<T>系列类型不是Sync的。RefCell<T>在运行时所进行的借用检查也不是线程安全的。Mutex<T>Sync的。

手动实现SendSync是不安全的。

Trait对象

trait对象是指实现某个(些)trait的struct,类似于Python的鸭子类型(duck type),以及Golang的接口类型

对象安全性

不是所有的 trait 都可以被放进 trait 对象中; 只有对象安全的(object safe)trait 才可以这样做.

两个必要条件:

  1. trait 要求 Self 不是 Sized
  2. trait 的所有方法都是对象安全的

其中的 Self 是指实现此 trait 的具体实际类型。Sized 是一个默认会被绑定到所有常规类型的内隐 supertrait

其中方法安全的两个充要条件:

  1. 它要求 SelfSized
  2. 它符合下面全部三点:
    1. 它不包含任意类型的常规参数
    2. 它的第一个参数必须是类型 Self 或一个引用到 Self 的类型(也就是说它必须是一个方法而非关联函数并且以 self&self&mut self 作为第一个参数)
    3. 除了第一个参数外它不能在其它地方用 Self 作为方法的参数签名

SelfSizedtrait 不允许成为 trait 对象,所以只有上面第二种情况(三个条件)才是 trait 对象的方法安全。

被遗忘的真实类型

为什么有上面这些限制条件?

如果你的方法在它的参数签名的其它地方也需要具体的 Self 类型参数, 但是一个对象又忘记了它的具体类型是什么(真实类型被 trait 对象类型替代), 这时该方法就无法使用被它忘记的原先的具体类型.

当该 trait 被使用时, 被具体类型参数填充的常规类型参数也是如此: 这个具体的类型就成了实现该 trait 的类型的某一部分, 如果使用一个 trait 对象时这个类型被抹(替换)掉了, 就没有办法知道该用什么类型来填充这个常规类型参数.

模式匹配

(略)

未完待续…

欢迎评论区交流

comments powered by Disqus