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),而是通过一套特殊的约定规则来保证线程安全。
而垃圾回收是一项复杂而损耗性能的工作。一般通过引用计数
、标记-清除
等技术实现。
线程安全就是当多个引用(或者说线程)同时访问同一个内存地址的数据,就可能发生数据冲突。
数据冲突发生的条件:
- 有引用尝试修改(写)数据
- 有一个或者多个引用尝试读取数据
默认地,当变量
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
来处理可能的错误,将错误的处理方式交给了函数的调用者。
语法糖
Result
的unwrap()
方法将在运行正常时返回处理结果,否则抛出异常。- 如果函数签名的返回内容是
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(约束)
:Fn
、FnMut
和FnOnce
。
这是在 Rust 中经常见到的三种模式的延续:借用、可变借用和获取所有权。
用Fn
来指定可能只会借用其环境中值的闭包。用FnMut
来指定会修改环境中值的闭包,而如果闭包会获取环境值的所有权则使用FnOnce
。大部分情况可以从Fn
开始,而编译器会根据调用闭包时会发生什么来告诉你是否需要FnMut
或FnOnce
。
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>
其本身并不是智能指针,不过它管理智能指针Ref
和RefMut
的访问,在运行时而不是在编译时执行借用规则。
并发
线程
使用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
的类型组成的类型也会自动被标记为 Send
。Sync
同理。
几乎所有基本类型都是 Send
的,大部分标准库类型是Send
的,除了Rc<T>
,以及裸指针(raw pointer)
。
RefCell<T>
和Cell<T>
系列类型不是Sync
的。RefCell<T>
在运行时所进行的借用检查也不是线程安全的。Mutex<T>
是Sync
的。
手动实现Send
和Sync
是不安全的。
Trait
对象
trait对象是指实现某个(些)trait的struct
,类似于Python的鸭子类型(duck type)
,以及Golang的接口类型
。
对象安全性
不是所有的 trait
都可以被放进 trait
对象中; 只有对象安全
的(object safe)trait
才可以这样做.
两个必要条件:
- 该
trait
要求Self
不是Sized
- 该
trait
的所有方法都是对象安全的
其中的 Self
是指实现此 trait
的具体实际类型。Sized
是一个默认会被绑定到所有常规类型的内隐 supertrait
。
其中方法安全
的两个充要条件:
- 它要求
Self
是Sized
- 它符合下面全部三点:
- 它不包含任意类型的常规参数
- 它的第一个参数必须是类型
Self
或一个引用到Self
的类型(也就是说它必须是一个方法而非关联函数并且以self
、&self
或&mut self
作为第一个参数) - 除了第一个参数外它不能在其它地方用
Self
作为方法的参数签名
而Self
是Sized
的 trait
不允许成为 trait 对象
,所以只有上面第二种情况(三个条件)才是 trait 对象的方法安全。
被遗忘的真实类型
为什么有上面这些限制条件?
如果你的方法在它的参数签名的其它地方也需要具体的 Self
类型参数, 但是一个对象又忘记了它的具体类型是什么(真实类型被 trait 对象
类型替代), 这时该方法就无法使用被它忘记的原先的具体类型.
当该 trait
被使用时, 被具体类型参数填充的常规类型参数也是如此: 这个具体的类型就成了实现该 trait
的类型的某一部分, 如果使用一个 trait 对象
时这个类型被抹(替换)掉了, 就没有办法知道该用什么类型来填充这个常规类型参数.
模式匹配
(略)
未完待续…
欢迎评论区交流