鉴于贵司大作tikv、tidb、tiflash在Rust、Go和C++之间横跳,因此学习Rust被提上了日程。
本文简称叫Rust: ACPPPP,它主要是用来讨论Rust在一些方面和C++的异同,而不是介绍这一门语言。所以文章是话题形式的,会有很多穿插,例如在讨论所有权时,会直接讲结构体。
rustup:Toolchain 管理工具
安装 nightly toolchain
1 | rustup toolchain install nightly |
然后激活
1 | rustup default nightly |
Override
Toolchain 的选择使用下面:
- 在命令行中指定,如
cargo +beta
- RUSTUP_TOOLCHAIN环境变量
- 用
rustup override set
覆盖当前目录以及子目录的设置
rustup show
和rustup override unset
可以查看和取消 override - rust-toolchain.toml 或者 rust-toolchain
- 使用 default toolchain
Cargo:包管理工具
workspace、crate 和 mod
C++ 并没有什么包管理,如果我们想要引用什么东西,代码声明一下,然后确保链接器能够看到定义就行。并且因为模板的引入,很多都是头文件,直接 include 就行。
访问 mod
crate内
src/main.rs 和 src/lib.rs 被称为 crate 根。
一个 crate 下有若干 mod,每个 mod 的成员在对应 mod 文件夹的 mod.rs 中列出。
例如下面的声明,会查找当前目录下的 hello.rs,或者 hello 目录下的 mod.rs。
1 | // mod.rs |
可以通过#[path = "foo.rs"]
来指定 mod 的位置。这种用法可以在函数中 inline。可以在这段代码中查看具体用法。
跨crate
跨 crate 访问,需要使用 Cargo.toml 中定义的 crate 别名。
rustc 和 crate
rustc只接受一个文件,并只生成一个crate。
1 | rustc hello.rs --crate-type=lib |
workspace
workspace 不能嵌套。所以如果两个 Cargo 工程,并且工程 A 依赖于工程 B,比较好的方案是平行摆放两个工程,并设置 dependencies。
virtual workspace
crate 内部组织形式
- mod.rs
- 一个 common.rs 用作公共依赖,mod.rs 注明只对该 mod 服务
- 一个或多个 xxx_engine.rs 用来定义 mod 对外暴露的主要功能
在 mod 中 pub mod 和 pub use。
不 pub use。 - 一个或多个 yyy_impls.rs,用来辅助 xxx_engine.rs 的实现
可能需要 use xxx_engine.rs 中的一些东西。
反过来,xxx_engine.rs 中会use yyy_impls::*
。
不 pub use。
编译与链接
调试信息
可以通过 -C debug_info
来指定调试信息的等级,其中0(false)、1、2(true) 分别对应无/行信息以及全部信息。如果设置为0,那么很可能部分代码的行号是打印不出来的。
另外,Cargo.toml 中的 [profile]
也可以修改。
条件编译
features
features 用来支持条件编译和可选依赖。
在编译时,可以通过 --features
去 enable 某个 feature。
例如在 Cargo.toml 中,webp 是一个 feature,并且它没有 enable 其他 feature。而 ico 这个 feature 会 enable 两个 feature 即 bmp 和 png。
1 | [features] |
在代码中,可以用下面两种方式,让代码只对 webp 被 enable 的情况下生效,
1 | // 1 |
默认情况下,所有的 feature 都是 disable 的,但可以把 feature 加入 default 中来默认 enable 它。
1 | default = ["webp"] |
在编译时,可以指定 --no-default-features
来 disable default feature。
dependency features
在指定 dependency 时,也可以指定 features。
例如下面的配置中,将 flate2 的 default features 去 disable 掉,但额外 enable 了 zlib 这个 feature。
1 | [dependencies] |
optional dependency
下面的语句表示 gif 默认不会作为依赖
1 | [dependencies] |
它会隐式定义了如下的 feature
1 | [features] |
可以通过 cfg(feature = "gif")
来判断 dependency 是否被启用,通过 --features gif
来显式启用 dependency。
如下的代码表示 avif 会 enable ravif 和 rgb 这两个 feature,但因为显式使用了 dep:ravif
和 dep:rgb
,所以系统不会隐式生成 ravif
和 rgb
这两个 feature。
1 | [dependencies] |
feature 的传递
假设一个 Cargo 工程的 default-member 是 x,其中有个 feature f。如果使用 edition 2018,那么指定 –features f 是不行的,需要在 workspace 下面传递一下 feature f,写成 f = [“x/f”] 才行
Cargo.toml 解读
[dependencies]
依赖的第三方package[dev-dependencies]
只有tests/examples/benchmarks依赖的第三方package[features]
用来支持条件编译和可选依赖[lib]
[[test]]
两个中括号说明是表数组,可以这样写1
2
3
4
5
6[[test]]
path = ""
name = ""
[[test]]
path = ""
name = ""[package]
[workspace]
相对于 package 而言,workspace 是一系列共享同样的 Cargo.lock 和输出目录的包。
包含 members 数组。[profile]
[patch.crates-io]
Cargo.lock 解读
Cargo.lock 是记录每个 crate 对应版本的工具。例如下面的配置表示依赖一个0.1.0版本的 azure_core
库,可是这个版本具体对应哪个 rev 呢?github 打开发现 master 上已经是0.2.1的版本了,我们显然不可能是用的 master 啊。
1 | azure_core = { version = "0.1.0", git = "https://github.com/Azure/azure-sdk-for-rust"} |
此时查看 Cargo.lock 就能发现类似下面的配置,其中具体指出了0.1.0对应的 git commit
1 | [[package]] |
一个 workspace 只在根目录有一个 Cargo.lock。这确保了所有的 crate 都使用完全相同版本的依赖。
如果在 Cargo.toml 和 add-one/Cargo.toml 中都增加 rand crate,则 Cargo 会将其都解析为同一版本并记录到唯一的 Cargo.lock 中。
Cargo 的常见问题
failed to authenticate when downloading repository这样的错误一般出现在和github交互的场景中。使用下面的办法可解决
1 | eval `ssh-agent -s` |
Blocking waiting for file lock on the registry index 这样的错误一般删除 rm $CARGO_HOME/.package-cache
.
所有权、生命周期
为了检验是否初步理解Rust所有权,可以尝试自己实现一个双向链表。
绑定和可变性
let和let mut
let x = y
表示把 y 这个值 bound/assign 到变量 x 上,因为 let 是 immutable 的,所以就不能修改变量x,也就是再次给它赋值(assign)了。如果需要能re-bound或者re-assign,就需要let mut x = y
这种形式。
对结构体而言,如果它是immutable的,那么它的所有成员也都是immutable的。在C++中,可以声明类中的某个成员是mutable的,这样即使在const类中也可以修改它,但Rust不允许这样。
由此还派生出了&mut
和&
两种引用。可以可变或者不可变地借用let mut绑定的值,但只能不可变地借用let绑定的值。
Pattern Matching
下面的语句都在尝试定义一个&mut {interger}
类型的a,但第三条语句是编译不过的。原因是它触发了Rust里面的pattern matching。
1 | let ref mut a = 5; |
我们很熟悉对 enum 类型(诸如Option
和Result
)进行 Pattern Matching 的做法。下面介绍一些不一样的,例如可以 Pattern Match 一个 struct,有点类似 C++ 的 structual binding。
1 | struct Point { |
通过@
,可以在 Pattern Matching 的时候同时指定期待的值,并将该值保存到局部变量中,有点类似于 Haskell 的用法。
1 |
|
如何在pattern matching的时候不move,而是borrow呢?如下所示,g
是一个owned值,而不是一个mutable borrow的值。解决方案就是直接match v.intention_mut()
1 | let intention = v.intention_mut(); |
Variable shadow
在Rust中有如下称为Variable shadow的做法。一个问题油然而生,既然可以直接let mut
,为什么还需要如下的做法呢?
1 | let x = 1; |
其实 shadow 的含义是这个变量的生命周期没变,只是无法通过从前的名字访问它了,而 let mut 在重新assign 之后,原来的 value 就会被析构掉。进一步举个例子,给出下面这个程序,它的输出是啥?
1 | struct S { |
结论如下
1 | drop 2 |
为什么呢?对于第一种情况,a被rebound了,但是S {x: 1}
只是被shadow了,并没有立即析构。但对于第二种情况,在rebound的时候,S { x: 1 }
就被析构了。
移动和借用
可以把所有对值的使用方式归纳为三种:复制、移动和引用(或者称为指针):
- 复制的缺点是浪费空间和时间。
- 移动的缺点是很多变量的地址会变,这个 FFI 带来很多麻烦,需要用 Box/Pin 将一些东西分配到堆上的固定地址,并且传出裸指针。
- 引用的缺点是存在 NULL,为了避免 NULL,又要引入生命周期注解等机制。此外,即使在有了移动语义后,多线程之间依然可以通过引用来访问同一个值,产生并发问题。
Rust中的移动可能伴随着内存地址的变化。很显然,一个对象从A方法通过调用被移动到B方法中,那么肯定出于不同的栈帧中,它的地址肯定会变化,所以要提防这个。而C++中移动更类似金蝉脱壳,将老对象中的东西拆出来用来构建新对象。
移动
1 | // Can compile |
引用
引用和借用是什么关系呢?创建一个引用的行为称为借用,在借用过程中,是不可以访问owned值的,否则出现use of borrowed xxx
错误。
在C++中,引用必须在定义时就绑定,并且,无论它是可变引用T&
还是不可变引用const T&
,都不能重新绑定。这很难受,并且std::reference_wrapper
也不是什么时候都可以用的。Rust中这些都不是问题,例如下面的代码就可以正常运行。
1 | let mut a: &i32; |
只能有一个可变借用,或多个不可变借用
考虑下面的Race Condition:
- 多个指针访问同一块数据
- 至少一个指针被用来修改数据
- 没有同步机制
Rust解决方案是只能同时有一个可变借用,或者多个不可变借用。问题来了,如果Owner在写,有一个可变引用在写,或者有一个不可变引用在读呢?
对于对象的成员函数的调用,这种情况是不存在的。如下所示,成员函数需要&self
或者&mut self
。
1 | let mut x = "123".to_string(); |
那么对于primitive types呢?运行下面的代码,发现出现错误提示”use of borrowed aaa
“,这也就是说在借用期间,是无法访问owned value的,毕竟被借走了嘛。
1 | let mut aaa: i32 = 1; |
注意,下面的代码给人一种”可以同时使用借用和owned的值的错觉“,但并不是这样。因为change_aaa
对aaa
的借用在调用完成之后就结束了,后面aaa = 2
的时候就没有其他借用情况了。
1 | fn change_aaa(bbb: &mut i32){ |
demo
通过移动来实现析构
std::mem::drop
函数用来析构T的对象,这是对移动的应用。在调用drop函数时,_x
的所有权会被移入。当然,如果实现了Copy
,那么drop
就无效了。
1 | pub fn drop<T>(_x: T) { } |
借用的demo
当一个函数接受引用作为参数时,需要显式借用,这一点和C++不一样。
1 | fn fn_takes_ref(i: &int) { |
Clone和Copy
Copy
Rust有一个叫做std::marker::Copy
的特殊trait,其中不带有任何方法,所以基本可以视作是给编译器提供的一个marker。如果一个类型实现了Copy trait,在赋值的时候使用复制语义而不是移动语义。
Rust不允许自身或其任何部分实现了Drop trait的类型使用Copy trait。这听起来很奇怪,但如果我说Copy trait的实现就是bitwise的Copy,就合理了。所以可以近似理解为Copy只适用于C++中的trivial的对象。
Clone
对于非trivial对象,又想复制怎么办呢?一个方法是实现Clone trait。可以理解为是C++中的拷贝构造函数。
容易想到,如果仅仅实现深复制,那么实际上就是递归调用所有field的.clone()
而已,这其实等价于下面的代码
1 |
|
但注意,编译器在要求实现Copy后,Clone的含义也必须代表bitwise memcpy。因此我们通常会通过#[derive(Copy,Clone)]
来支持自动生成Copy特性。
所有权相关设施
介绍Borrow(.borrow())/BorrowMut(.borrow_mut())/AsRef(.as_ref())/AsMut(.as_mut())/ToOwned(.to_owned())等基础的实现。
as_ref/as_mut 和借用
什么时候用 as_ref/as_mut 呢?如下代码所示,如果需要获得容器 Option 持有的对象的借用,那么我们不能先 unwrap 再 &mut 借用,而应该先 as_mut 再 unwrap。
1 | struct S { |
Borrow和AsRef的区别是什么?
可以看到AsRef和Borrow两个trait的定义不能说非常相似,也可以说是一模一样了,那为什么会分成两个呢?
1 | pub trait AsRef<T: ?Sized> { |
显然这个疑问是普遍的,通常的说法是 Borrow 更严格,目的是借用;AsRef 支持的类型更广,目的是类型转换。但说实话,还是一头雾水。这篇文章讲解了个例子,概括如下:
- HashMap 存储
(K, V)
对,并且可以通过提供的&K
查找对应的&mut V
。因为按K
写,按&K
取,所以需要保证这两个的行为是一致的。
【Q】为什么 HashMap要按照&K
取呢? - 于此同时,我们可以实现一个
CaseInsensitiveString
结构,它可以看做是忽略大小写比较的一个String。 - 问题来了,我们有
impl Borrow<str> for String
,那么是否可以实现impl Borrow<str> for CaseInsensitiveString
呢?
答案是不可以的,这样会破坏HashMap的一致性。例如我两个只是大小写不同的字符串,按照s: CaseInsensitiveString
比较是相等的,按照s.borrow()
比较就不相等了。
但这就够了么?难道CaseInsensitiveString
不可以转换成&str
么?当然可以,所以有AsRef。
cannot infer type for type parameter Borrowed
declared on the trait BorrowMut
1 | let a = Box::new(RefCell::new(1)); |
为什么不能从&mut
调用Clone?
从下面的实现可以看到,标准库没有为 &mut
提供 Clone,原因是会产生指向同一个位置的两个 &mut
。
1 | impl<T: ?Sized> Clone for *const T { |
下面的代码中,如果 clone 了 &mut MyStruct2
,会出现多个指向同一个地址的 &'a mut MyStruct
1 |
|
但需要注意,clone 的目标不是 &T
而是 T
。上面例子为什么会失败,原因是在 Clone MyStruct
的时候递归地需要 Clone &'a mut MyStruct
导致的。但如果直接对一个 &mut T
调用 Clone 就不会出现编译问题,如下所示
1 |
|
ToOwned和Clone的区别是什么?
1 | pub trait ToOwned { |
下面这个例子很经典,"123"
是一个 &str
类型,对它调用 clone,还会得到一个 &str
类型。但调用 to_owned
则会得到一个 String 类型。
1 | let become_str = "123".clone(); |
异常和错误处理
Option
如何处理Option呢?
- unwrap+if
- match,并处理
Some(e)
和None
unwrap_or
map
组合子,and_then
组合子?
得到Result<T, NoneError>
Result
如何处理 Result 呢?
try!
?
常见组合子
- filter_map
F 如果返回 None,则跳过处理这个元素
panic
1 | macro_rules! panic { |
一个 panic 操作会使得当前线程 panic。
诸如 Option 和 Result 的 unwrap 方法,如果结果是 None 或者 Err,则会导致 panic。
Panic 和 thread
1 | let thread_join_handle = std::thread::spawn(move || { |
可以看到,如果线程 panic 了,那么 join 会得到一个 Err。
1 | thread '<unnamed>' panicked at 'Pan', src\mod_thread\mod.rs:4:9 |
捕获 panic
对 panic 的处理,可以是直接 abort,也可以是 unwind。通过std::panic::catch_unwind可以捕获 unwind 形式的 panic。在 Rust 1.0 中,panic 只能被父线程捕获,所以如果要捕获 panic,就必须为可能 panic 的代码启动一个新的线程,而 catch_unwind 可以缓解这问题。
catch_unwind
的用法通常是在 FFI 的边界中用来捕获所有的 panic,但我们无法获取和 panic 有关的信息,例如 backtrace。此时可以使用panic::set_hook
。
另外一种捕获 panic 的方法,是 panic::set_hook
。
Exception Safety
考虑下面的代码,在 unsafe 中,clone 可能 panic。一旦它 panic,因为已经 set_len 了,所以我们可能读到一些未初始化的数据。
1 | impl<T: Clone> Vec<T> { |
double panic
避免 double panic,可以使用 safe_panic。double panic 的情况可能出现在:
- 如果在某个线程中已经 panic 了,并且后续在某个对象的 Drop 中也会有 panic 逻辑,那么可能 panic while panicking
例如这里的Drop->stop->cancel_applying_snap->check_applying_snap
和check_snap_status->check_applying_snap
。
指针和智能指针
C++中,为了突破栈上分配的限制会在堆上分配对象,Rust中为了避免移动,有更进一步的往堆上创建对象的需求。C++不会对指针进行资源管理,后面标准库也只是断断续续支持了一些智能指针,但Rust希望做得更周到一点。
在Rust中有下面的指针:
*mut T
/*const T
这是C的裸指针Box
Pin
Rc
Arc
原子引用计数Ref
RefCell
Cow
Cell
NotNull
raw pointer
Rust book 中介绍了一些 raw pointer 相对于其它 rust 指针的特点:
- 它们不保证能够指向有效内存。它们有可能是 null。
- 它们不会像 Box 一样会自动 gc,需要手动内存管理。
而Box::from_raw
可以用来获得 raw pointer 的所有权,并进行自动的内存管理。
也可以通过ptr::drop_in_place
来销毁。它可以处理 trait object 的情况,因为这个时候我们无法通过ptr::read
去读出这个对象,然后销毁。drop_in_place
是一个编译器内置的实现。 - 它们是 POD,也就是说它们不具备 ownership。不像 Box,Rust 编译器不会检查诸如 use-after-free 的场景。
- 不同于 &,raw pointer 不具备任何 lifetime,所以编译器不能推断出 dangling pointer。
- no guarantees about aliasing or mutability other than mutation not being allowed directly through a
*const T
。
Box
trait Deref/DerefMut
Deref 是 deref 操作符 *
的 trait,比如 *v
。它的作用是:
- 对于实现了 Copy 对象,获得其拷贝
- 对于没有实现 Copy 的对象,获得其所有权
如下所示,一个智能指针对象U比如Box,如果它实现了U: Deref<Target=T>
,那么Deref能够从它获得一个&T
。实现上,我们从一个&Box<T>
解两次引用,获得T
,再返回&T
。抽象一点来说,在实现了Deref后,能将&U
变成&T
,换种说法*x
的效果就是*Deref::deref(&x)
。这么做的好处是将所有奇怪的对智能指针的引用都转成&T
。
1 | impl<T: ?Sized> Deref for &T { |
而 DerefMut 如下所示,它允许我们从智能指针获取一个 &mut T
。容易发现,如果一个智能指针没实现 DerefMut,那么它实际上是 Immutable 的。
1 | pub trait DerefMut: Deref { |
Rc/Arc
如下图所示,三个 list 中,b
和 c
共享 a
的所有权。我们可以用 Rc 来描述。
注意虽然 Rc::clone(a)
等价于 a.clone()
,但推荐使用 Rc::clone
,因为这显式表示它只增加引用计数。
1 | enum List { |
只读和 &mut
需要注意的是,Rc 只允许各个所有者之间只读地进行共享。否则,如果各个所有者能修改,那么就有可能data race。也就是说,Rc/Arc 不实现 AsMut 和 DerefMut,从而做到禁止可变借用。事实上,Rc 会在编译期进行不可变借用的检查。
例如,如果 Rc 中持有 FnMut,则会导致 “cannot borrow data in an Rc
as mutable” 报错
1 | let mut a = 10; |
尽管如此,Rc 还是通过 Rc::get_mut
提供一种获得 &mut T
的方法。它会在运行期用 Rc::is_unique
来判断是否为唯一引用,并返回 Some(&mut T)
或者 None
。
内部可变性(interior mutability)引用
总所周知,Rust 要求一个对象可以:
- 有多个不可变引用(aliasing)
- 有一个可变引用(mutability)
通过 Cell 和 RefCell 可以允许“多个可变引用”。其主要做法是可以通过&
来 mutate 对象。
RefCell
RefCell
是类似于 Box
的指针,但不同于引用和 Box 类型,RefCell 在运行期检查借用。具体来说,RefCell 在运行期检查:
- 在任意时刻只能获得一个
&mut
或任意个&
- 引用指向的对象是存在的
容易想到,RefCell 的内部实现肯定会有 unsafe 块,才能绕过编译期的可变/不可变借用检查,而 delay 到运行期检查。但仍然是要求在任何时候只允许有多个不可变借用或一个可变借用。如果运行时检查出现问题,则会 panic,如下所示。
1 | // panic: already borrowed: BorrowMutError |
RefCell
在自己不可变的情况下,修改内部的值,这也就是内部可变性。可以类比为C++中一个const对象里面的mutable成员。那么RefCell
也可以用在类似的场景下,例如一些需要存中间状态的状态机、Mocker等。
对RefCell
的&
和&mut
借用,分别对应了.borrow()
和.borrow_mut()
方法。
RefCell是Send/Sync的么?将在Sync/Send章节中介绍。
RefCell 的 borrow_mut 和 get_mut
上文介绍了,RefCell 访问对象需要通过 borrow
系列方法,但还有一个 get_mut
方法,它是做啥的呢?
根据文档可以发现,这个方法直接在编译器从 RefCell 中获取 &mut T
。如下所示,这遵循编译期的检查,比如两次 &mut T
会在编译器挡掉而不是在运行期 panic,有点不把它当 RefCell 用的感觉。
1 | // OK |
如果我们联用,会在编译期报错,不过报错内容比较有趣。它说b.get_mut()
是个可变借用,而b.borrow_mut()
是个不可变借用。为什么不是两次可变借用的冲突?原因很简单,RefCell 本来就是支持的内部可变性嘛,所以对于 Rust 来讲,这是个不可变借用没问题。
1 | let x1 = b.get_mut(); |
Rc+RefCell
1 | fn make_value(i: i32) -> Rc<RefCell<i32>> { |
Cell
不同于 RefCell,Cell 实现可变性的办法是将持有的对象移出或者移进。所以它不是操作对象的可变引用 &mut,而是操作对象本身。这也意味着我们只能动态
- 对于实现了 Copy 的类型,get 方法可以获取当前值。
- 对于实现了 Default 的类型,take 可以取出当前值,并用 Default::default() 代替。
- 对于所有的类型
- replace 可以替换并返回旧值。
- into_inner 可以消费 Cell,并返回内部值。
- set 类似于 replace,但直接 drop 旧值。
Mutex 可以和 RefCell/Cell 联用么?
Mutex 自带了内部可变性。完全可以理解,如果一个函数不可变,那么为何需要用 Mutex 保护呢?
1 | let a = Mutex::new(1); |
同时,RefCell 和 Cell 都没有实现 Sync,也就是说它们不是线程安全的。
GhostCell
见 GhostCell。
Pin
【建议在学习Pin之前,了解 Deref 和 DerefMut】
一个async fn会产生一个自引用结构AsyncFuture
,因此它不能被移动。让一个对象不能被移动的第一步是将它分配到堆上,Box
可以做到这一点。但这并不够,因为如下所示,std::mem::swap
能够移动Box中的对象:
1 | // Note we don't use &TestNUnpin |
另一方面,很多 FFI 会跨语言边界传递指针,这也需要保证地址是不变的。综上于是就有了 Pin。Pin 中包裹了一个指针,如 Pin<&mut T>
, Pin<&T>
, Pin<Box<T>>
,Pin 保证对应的 T 不会被移动。
其实在 C++ 中也会有自引用结构,并且也会造成相同的问题。可以参考付老板的文章。
1 | struct X { |
Pin 分析了下,诸如 std::mem::swap
之流为什么能移动,原因是它们都能获得 &mut T
。所以只要限制可变借用,就可以在把对象 Pin 在堆上。限制获得可变引用简单啊,不实现 AsMut
就行。
Unpin 和 !Unpin 和 PhantomPinned
大部分的类型都被实现了 Unpin trait,表示能够随意被移动。
而一个可以被 Pin 住的值需要实现 !Unpin
。因为 Rust 中带 !
这样的称为 negative bounds,Rust 对它的支持还没有稳定下来。所以更一般的做法是让结构中持有一个 PhantomPinned 的 marker。
1 |
|
std::marker::PhantomPinned
中被实现了!Unpin
,它会是持有它的结构体变成 !Unpin
,从而无法被移动。
以上的容易理解,但为什么会有Unpin
和!Unpin
呢?原因是需要给类型分类,讨论在Pin之前和之后类型的行为。这肯定难以理解,所以不妨先看看Pin是如何创建的,再回过来看。
Pin 对象的创建方式
在下面的代码中,对一个实现了 trait Unpin
的类型 Target,可以直接通过 Pin::new
产生一个 Pin<P>
对象。
1 | impl<P: Deref<Target: Unpin>> Pin<P> { |
但如果说是一个!Unpin
的对象,Pin::new
会返回错误 “error[E0277]: PhantomPinned
cannot be unpinned”;或者错误 “the trait Unpin
is not implemented for TestNUnpin
“,和”note: consider using Box::pin
“。可以通过打开下面代码的注释来检查。
1 | use std::pin::Pin; |
使用不安全的 new_unchecked
我们可以通过 Pin::new_unchecked
来创建 !Unpin
的对象。但这是不安全的,因为我们不能保证传入的 pointer: P
指向的数据是被 pin 的。使用这个方法,需要保证 P::Deref/DerefMut
的实现中不能将 self 中的东西进行移动。这是因为 Pin 的 as_mut
和 as_def
会调用 P 的 deref(_mut)
。
1 | pub fn as_mut(&mut self) -> Pin<&mut P::Target> { |
我们可以构造出一个 evil 有问题的 case。在 DerefMut 中,我们将 b 的原值 move 了出来。然后我们将 EvilNUnpin
作为一个 String 的指针传进去。结果打印出来已经有问题了。解决方案也很简单,如果想要 T 不被移动,那么始终 pin 住 &mut T
就行。
此外,还需要保证这个 pointer 指向的对象不会再被移动,特别要注意不能以 &mut P::Target
这样的方式被移动,例如通过之前提的 mem::swap。
特别地,Pin 需要保证自己维护的指针不会再被移动了,**即使在自己销毁之后,也是不能被移动的**,但这个很难在编译期判定。如下代码所示,在两个 Pin 对象析构后,我们又可以移动对象 x1 和 x2 了。
1 | fn test_new_unchecked() { |
对 Rc 使用 new_unchecked 也不安全
如下所示,我们可以获得 &mut T,从而又可以乱搞了。
1 | use std::rc::Rc; |
使用安全的 Box::pin
使用 Box::pin
会产生一个 Pin<Box<T>>
。
1 | let mut x1 = TestNUnpin{ b: "1".to_owned() }; |
为什么 Box::pin
可以 Pin 住 !Unpin
?
查看 Box::pin
的实现。它传入一个 T
,然后创建一个Box<T>
并立马 Pin 住它。
1 | pub fn pin(x: T) -> Pin<Box<T>> { |
在 Pin 之前,无法移动 T,这是因为只能同时有一个可变借用 &mut T
。
在 Pin 之后,无法移动 T,这是因为 Box 被实现为 owned 且 unique 的。可以参考下面的代码
1 | let mut t = TestNUnpin{b: "b".to_owned()}; |
使用安全的 pin_utils
还可以使用 pin_utils::pin_mut!
。对于下面的代码,我们考量上述 new_unchecked
安全性的几点保证:
- 控制
Deref(Mut)
pin 的是&mut T
而不是T
。 - 不能取出
&mut T
这很简单,因为开始的$x
已经被 shadow 了。 - 不能再次移动
T
同上。
1 |
|
这里的 shadow 非常重要,我们用下面的例子来说明。可以看到 xp
并没有 shadow 住 x
,因此在它被 drop 后,x
又可以被 mutable borrow 了。所以 pin_mut
的实现中保证了
1 | fn test_shadow() { |
另外,这里的 let mut $x = $x
也很重要,它使得下面的代码可以编译
1 | fn main() { |
同时它可以拒绝
1 | let mut foo = Foo { ... }; |
总结
总结几个疑问:
为什么可以直接
Pin::new
一个Unpin
对象?
因为对于实现Unpin类型的对象,Pin不做任何保证。为什么不能直接
Pin::new
一个!Unpin
的对象?
因为这是不安全的,所以要么 unsafe 地Pin::new_unchecked
来创建,要么借助于诸如 Box::pin 等安全的方法。如果 T 是 Unpin,能获得 Pin 里面的
&mut
么?
可以通过Pin::get_mut
获得1
2
3
4
5
6
7
8
9
10let mut p = TestUnpin{ "a".to_owned() };
let mut p2 = TestUnpin{ "b".to_owned() };
let mut rp = unsafe {
Pin::new(&mut p)
};
let mut rp2 = unsafe {
Pin::ne(&mut p2)
};
std::mem::swap(Pin::get_mut(rp), Pin::get_mut(rp2));
println!("{} {}", p.a, p2.a); // Should be `a b`但如果类型是 !Unpin,我们就不能调用
Pin::get_mut
。
现在回答为什么要有 Unpin 和 !Unpin 的问题。对于 Unpin 类型,它实际上是给 Pin 做了一个担保,告诉 Pin 即使我这个类型被移动了也没事,所以 Pin 对它的作用就是屏蔽了 &mut
的获取渠道。对于 !Unpin 和 PhantomPinned 类型,它们是真的不能被移动的,这不仅要借助 Pin,这些类型自己也要提供一个合适的接口,从它们来创建Pin。
Pin 和内部可变性
是不是被 Pin 的对象就不可以有内部可变性呢?不妨考虑下面一个更简单的对象,我们修改 a 的值,并不会导致任何地址上的变化,所以这个对象是可以有内部可变性的。
Pin 使得下面的代码不可编译,并报错”trait DerefMut
is required to modify through a dereference, but it is not implemented for Pin<&mut SimpleNUnPin>
“。
1 | struct SimpleNUnPin { |
那么,我们能够通过 RefCell 获得内部可变性么?这其实不安全。
NonNull
生命周期
生命周期(lifetime)是编译期中的 borrow checker 用来检查所有的借用都 valid 的结构。
当在结构体中持有一个引用时,需要指定生命周期,从而防止悬垂引用。
计算生命周期
下面的代码展示了 Lifetime 和 Scope 的区别。
1 | fn main() { |
如下所示,发生了移动,只有一次析构
1 | struct S { |
但对于下面的代码,则会返回错误”error[E0597]: s.a
does not live long enough”。这应该是 Rust 对自引用结构支持不够的问题。
1 | use std::cell::RefCell; |
声明周期注解
下面表示 foo 具有生命周期参数 ‘a 和 ‘b,并且 foo 的 lifetime 不会超过 ‘a 和 ‘b 的 lifetime。
1 | foo<'a, 'b> |
函数
不考虑省略:
- 所有引用参数都需要带一个生命周期参数
- 返回的引用要么是’static,要么是和输入一样的生命周期
下面编译出错。这里,'a
must live longer than the function,也就是说函数运行完之后,’a 应该还在 lifetime 中。而这里的 &String 在函数返回前就析构了,所以它肯定不满足 ‘a 的约束。
1 | fn invalid_output<'a>() -> &'a String { &String::from("foo") } |
方法
下面两个代码实际是等价的,所以如果希望 self 的生命周期就是 impl 的,那么应该加上 ‘a。
1 | impl<'a> Foo<'a> { |
当然,未必要给 impl 加 lifetime 参数
1 | struct Owner(i32); |
Elision
生命周期的协变和逆变
'a
是'b
的子类,这个在生命周期注解中的含义是'a
比'b
长,也就是说子类的生命周期大于等于父类。
1 | 'a : 'b |
根据里氏替换原则,可以得到定义:
- 协变
子类可以替换父类,也就是生命周期短的参数可以接受生命周期长的参数。大部分指针都是协变或者不变的。 - 逆变
讨论变性:
&'a T
对'a
协变,对 T 协变。&'a mut T
对'a
协变,对 T 不变。fn(T) -> U
对 T 逆变,也就是它可以接受生命周期更短的参数。
对 U 协变。
讨论上面几个问题。
为什么 &'a mut T
对 T 不变?不妨考虑 T 分别是 &'static str
和 &'a str
的情况,显然前者是后者的子类。那么如果 T 协变,则前者就能代替后者。现在我们考虑下面的 overwrite 函数。如果协变成立,就可以事实上将 string 这个生命周期更短的赋值给 forever_str 这个生命周期更长的了。
1 | fn overwrite<T: Copy>(input: &mut T, new: &mut T) { |
闭包和函数
Fn/FnMut/FnOnce
Rust对a(b,c,d)
这样的调用搞了个有点像Haskell中的$
的东西,目的是为了重载“对函数的调用”。
1 | Fn::call(&a, (b, c, d)) |
FnOnce
会获取自由变量的所有权,并且只能调用一次,调用完会把自己释放掉。FnMut
会可变借用自由变量。Fn
会不可变借用自由变量。FnMut
和Fn
都可以调用多次。
可以用下面的代码确定某个函数具体实现了哪个trait,实现了的trait能够通过编译。
1 | fn is_fn <A, R>(_x: fn(A) -> R) {} |
查看代码,发现三者具有继承关系Fn : FnMut : FnOnce
。
1 | pub trait FnOnce<Args> { |
为什么是这样的继承关系呢?这篇回答给出了解释。
确实可以让FnOnce、FnMut和FnOnce做7种自由组合,但其中只有三种traits是有意义的:
- Fn/FnMut/FnOnce
- FnMut/FnOnce
- FnOnce
这是因为,如果传入&self
可以解决的问题,传入&mut self
也可以解决。传入&mut self
可以解决的问题,传入self
也可以解决。但反之就不一定成立。
所以 self 是大哥级的人物,动用了伤害很大,它能够解决一切的问题,所以他是最 base 的 trait,而不是最 derive 的 trait。
闭包对三个trait的实现
- 所有的闭包都实现了FnOnce
- 如果闭包只移出了所有权,则只实现FnOnce
- 如果闭包没移出所捕获变量的所有权,并修改了变量,则实现FnMut
- 如果闭包没移出所捕获变量的所有权,且没有修改变量,则实现Fn
move关键字不会改变闭包具体实现的trait,而只影响变量的捕获方式,我们将在下节讨论。
捕获
上面的章节中介绍了闭包可能实现的三个trait,这个章节说明闭包如何捕获环境中的变量。
C++中捕获的问题
在C++中,返回一个捕获了Local变量的闭包,是有安全问题的,见get_f()
。
对于类中的方法,如果捕获了this指针(即使是[=]
)并传出,在对象析构之后也是有问题的,见print_this_proxy()
。
1 |
|
Rust的捕获
Rust的捕获相比C++使人比较困惑。首先它没有地方指定捕获哪些变量;另外,还有个move关键字;最后还会加上复制和移动语义。
1 | || 42; |
闭包按照什么方式捕获,取决于我们打算如何使用捕获后的变量。
我们不妨看一个例子,首先定义下面的结构。get_number
、inc_number
和destructor
分别需要传入不可变引用,可变引用以及值。
1 | struct MyStruct { |
下面代码展示了类似fn的情况,这里fn并没有捕获任何自由变量,因此下面的代码可以正常编译和运行。
1 | let obj1 = MyStruct::new("Hello", 15); |
下面的代码展示了Fn的情况,这里closure2捕获了obj1的引用。后面的代码进行验证,仍然可以obj1.get_number()
来不可变借用,但需要可变引用的obj1.inc_number()
就不能通过编译了。
1 | let obj1 = MyStruct::new("Hello", 15); |
事实上,闭包类似语法糖,相当于把需要捕获的上下文封装到一个Context里面传给真正的执行单元。例如我们可以改写closure2,得到一个自由函数func2。它接受一个Context对象,里面封装了一个不可变引用,并且其生命周期等于Context的生命周期。
1 | struct Context<'a>(&'a MyStruct); |
其实细心观察,就可以提出反对意见。上面的case中不能obj1.inc_number()
原因是我们没有let mut obj1 = ...
,如果加上去就能正常编译了。这不就是同时Immutable和Mutable Borrow了么?其实我们在最后加一行,再调用一次func2
就能报错了。看起来Rust还蛮智能的,func2虽然可变借用,但后续没有用到了,所以就不影响obj1.get_number()
。
1 | ... |
下面的代码展示了FnMut的情况。现在闭包里就直接是可变借用了。在闭包之外,我们既不能可变借用,也不能不变借用,否则都无法编译。
1 | let mut obj1 = MyStruct::new("Hello", 15); |
下面的代码展示了FnOnce的情况
1 | let obj1 = MyStruct::new("Hello", 15); |
尝试用四个函数检查下,发现上面三个trait的检查都无法通过编译,也就说明closure4没有实现上面三个trait。
1 | // Does not compile: |
可以发现,闭包捕获变量按照&T -> &mut T -> T
的顺序,和Fn -> FnMut -> FnOnce
的继承关系如出一辙。也就是先派小弟尝试捕获,小弟解决不了,再请老大出山的思路。
当然,可以通过move
关键字,强行请老大出山。
对于move/move async捕获,如果闭包中需要使用某个变量例如p,并且在闭包调用完之后,还需要继续访问,则需要在调用闭包前进行clone,例如得到pp。
1 |
|
闭包能否被多个线程使用?
https://stackoverflow.com/questions/36211389/can-a-rust-closure-be-used-by-multiple-threads
并发与异步
Send和Sync
trait Send
表示该类型的实例可以在线程之间移动。大多数的 Rust 类型都是 Send 的,另一些则不可以:
Rc<T>
只能在同一个线程内部使用,它就不能被实现为Send
的。- 裸指针也不是 Send 的。
由 Send 类型组成的新类型也是 Send 的。
trait Sync
表示多个线程中拥有该类型实例的引用。换句话说,对于任意类型 T,如果 &T
是 Send 的,那么 T
就是 Sync 的。Sync 的要求会更高一点。
一些常见类型对Send和Sync的支持
Rc
Rc并不是Send的。原因是Rc共享同一个引用计数块,并且更新引用计数并不是原子的。如果两个线程同时尝试clone,那么它们可能同时更新引用计数,从而可能会UB。
Arc
如果T是Send和Sync的,那么Arc<T>
是Send和Sync的
1 | unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {} |
初看这很奇怪,难道不是为了并发安全才用的Arc么?为什么反过来Arc还需要一个并发安全的类型呢?其实和C++一样,智能指针的线程安全包含两个层面,即智能指针本身实现,特别是引用计数的线程安全;以及智能指针保护的数据的线程安全:
- Arc相对Rc只是保证了引用计数这一块功能是并发安全的
- 如果类型不是并发安全的,通常需要配合RwLock和Mutex等使用。
RefCell
从定义看,RefCell是Send的,但不是Sync的。很容易理解,一个具有内部可变性的对象的引用被各个线程持有,那岂不是可以瞎改了?
1 |
|
引用
&T
需要T是Sync的,这个对应了上面的定义。&mut T
需要T是Send的
1 | unsafe impl<T: Sync + ?Sized> Send for &T {} |
裸指针
各类指针都不是Send的
1 | impl<T: ?Sized> !Send for *const T {} |
当然可以简单包一层,从而间接得到可以Send或Sync的裸指针
1 | struct MyBox(*mut u8); |
当然,也可以用同样的办法,通过negative_impls,取消某些已经被Send/Sync的类型的特性。
1 |
|
Mutex 和 RwLock
Mutex 需要 Send,但不需要 Sync。
1 |
|
RwLock 不仅需要 Send,还需要 Sync。这是因为它的不可变引用会被 Reader 们共享读取。
1 |
|
Future
Future 的所有权可能在各个线程之间移动,那为什么 Future 不是 Send 的呢?
多线程
因为 Rust 目前不支持可变参数包,所以只能通过 spawn 闭包的形式创建线程。
如果子线程 panic 了,其他线程是没影响的,除非:
- 某个线程 join 了 panic 的线程,此时会得到一个包含Err的Result,如果直接unwrap则会panic
- 如果线程在获得锁后panic,这种现象称为poison
此时,再次尝试mutex.lock()
会得到PoisonError,并且mutex.is_poisoned()
会返回true。
线程间同步
线程间通信
可以使用类似Go的Channel的方式来通信,也就是所谓的Do not communicate by sharing memory; instead, share memory by communicating。
这里mpsc是multiple producer, single consumer的意思。
send方法返回一个Result<T, E>
类型,所以如果接收端已经被丢弃了,将没有发送值的目标,所以发送操作会返回错误。
1 | use std::sync::mpsc; |
因为生产者是可以有多个的,所以tx.clone()
可以产生另一个生产之。
异步
async和await
下面展示了两种async的写法,一种是async函数plus_one
,另一种是async块plus_two
。对async函数,编译器在实现时也会最终转成async块的形式,并且会改写函数签名。
所以,可以理解为async函数最终是返回了一个impl Future<Output = ...>
类型,impl Future
表示这个类型实现了trait Future
,这应该就是impl Trait这个特性。Output是我们期望这个Future在Ready后实际返回的类型,比如在这里就是i32。
1 | async fn initial() -> i32 { |
plus_one_res
和plus_two_res
都是Future,可以通过block_on
获取结果。
1 | fn main() { |
我们也可以通过join同时await多个Future。
1 | futures::executor::block_on(async { |
trait Future
这里指的是std::future::Future
,因为在早前还有futures::future::Future
,它是一个“社区版”的时候,后来trait Future
被整合到了标准库中,剩余部分整合到了pub trait FutureExt: Future
,其中包含了map
/then
等操作。当然对于trait Future
还有其他的扩展,例如async-std。但回过头,先来看看最基础的trait Future
。
1 | pub trait Future { |
poll函数返回的Poll是个enum,包含Ready(T)
和Pending
两个状态。但它并不只有忙等,如果在一次poll后返回的是Pending
,那就会注册cx.waker
这个回调,在Future后调用进行通知。
Pin<&mut Self>
实际是个指针,它是为了解决自引用结构的问题。
impl Future
impl了trait Future
的类型有很多,例如f.map
生成的Map,f.then
生成的Then
这些组合子都是Future。
例如,下面代码为Map类型impl Future。
1 | pub struct Map<I, F> { |
这里e.map().map()
比较独特,前一个map是把self.f
应用到e里面的东西,并且清空self.f
,让它成为一次性的调用。后一个是把将map的结果包在Async::Ready
里面。
async的生命周期
async实现
我们知道,因为在async实现中会产生自引用结构,所以需要用Pin,那什么是自引用结构?为什么async中会存在这种结构呢?
首先得从async的实现讲起
普通情况
看下面代码,f1和f2两个await之间是串行的,那么编译器如何生成f.await
的代码呢?
1 | let f = async move { |
如果是用Then的方式,那么就通过回调实现,但这里Rust使用了状态机的方式,即编译器会生成类似下面的代码。AsyncFuture
实际上
1 | struct AsyncFuture { |
为什么async实现会涉及自引用结构?
在之前,已经讨论过编译async的普通情况。考虑下面的代码,应该如何编译呢?
1 | async { |
因为在await时可能发生线程切换,所以我们需要将x
也转移到生成的AsyncFuture
中,那么read_into_buf
就会产生一个指向x
的引用。如果在一个结构中,某个字段是指向另一个字段的引用,这就是一个自引用结构。
1 | struct ReadIntoBuf<'a> { |
自引用结构
自引用结构如下,b是一个指向a的引用
1 | struct Test<'a> { |
但很遗憾,Rust现在不支持自引用结构,导致下面的代码会报错(之前在 lifetime 部分也提过)
1 | fn main() { |
作为workaround,又得用裸指针。但这玩意有个问题,它的地址是绝对的。当Test被移动了,b
指向的地址并不会变化。这就好比反过来的刻舟求剑,我们希望b是一个刻在船(Test)上的地址,但实际上它是个GPS坐标。
1 | struct Test { |
then用法
在没有async和await时,可以使用then系列的用法。
类型系统
数组和切片
数组的签名是[T;N]
,和C++一样,数组类型中包含了它的大小,是编译期常量。数组是否是Copy/Clone取决于其内部的类型,但如果使用[x, N]
创建数组,则x
对应的类型必须是Copy的。数组引用&[T;N]
可以转换为切片引用&[T]
与之对应的是切片&[T]
和&mut [T]
。
切片(Slice)的方法
Sized、!Sized、?Sized和DST
Dynamically sized type(DST),即动态大小类型,表示在编译阶段无法确定大小的类型
如果一个类型在编译期是已知Size,并且Size固定不变的,那么它会自动实现trait Sized。
但有些类型是无法在编译期确定大小的,例如str
的大小是未知的(所以我们一般通过&str
来访问它),一个trait的大小也是未知的。
如果一个类型的大小是未知的,那么它的使用会有限制,例如不能写Vec<T>
,而只能将T放到Box里面,做成Vec<Box<T>>
。
胖指针
胖指针指的是指向 DST 的引用或者指针。
一个 Slice 是 DST,那么指向 Slice 的指针就是胖指针
1 | assert_eq!(size_of::<u32>(), 4); |
可以看到,&[u32]
具有两倍大小,原因是其中还储存了一份长度,如下所示
1 | struct SliceRef { |
不能直接把变量绑定到一个DST上,因为编译器无法计算出如何分配内存。例如我们经常见到&str
,但基本见不到str
。
特别强调,指针也是胖的。考虑到 C++ 允许直接使用 delete 析构 POD 数组,这也是和 C++ 部分一致的。
1 | assert_eq!(size_of::<*mut[u32]>(), 16); |
除了 Slice,trait object 也是 DST,它还包含了一个 vptr,将在后面讨论。
1 | struct TraitObjectRef { |
Rust中的字符串
Rust中的字符串是很好的比较数组和切片的工具。和C++一样,Rust有两种字符串:
- str
str是Rust的原生字符串类型。因为是DST,所以通常以&str
出现。 - String
String类型可以随时修改其长度和内容。
str
&str
相关方法实现在str.rs的impl str
中。通过.as_ptr()
将其转换为一个*const u8
指针,通过.len()
获得其长度。
字符串字面量的类型是&'static str
&str
和&[u8]
可以互相转换。
String
略
struct
tuple struct
Int 是一个别名,Interger 是一个新的类型。这种形式称为 tuple struct。
1 | type Int = i32 |
ZST
在C++中,会接触到这样的类型
1 | struct ZST { |
在 Rust 中
1 | struct A; |
在 Rust 中 ZST 实例的地址是什么呢?
never类型和!
诸如return
、break
、continue
、panic!()
、loop
没有返回值的,或者说返回值类型是 never
即 !
,对应到类型理论中就是 Bottom 类型 never 类型可以转换为其他任何类型,所以在诸如 match 中才能下入如下代码而不会产生类型错误
1 | None => panic! |
如下的发散函数也没有返回值,因此也具有never类型
1 | fn foo() -> u32 |
类型推导
通过 turbofish 可以辅助推导,下面列出一些例子
1 | x.parse::<i32>() |
trait
trait 类似于 Haskell 中的 typeclass。
trait和adhoc多态
见笔记
关联类型
关联类型(associated types)是一个将类型占位符(也就是下面的type Output
)与trait相关联的方式。
考虑如果某个类型impl了trait Add,那么它可以接受一个RHS类型的右操作数,并返回Output类型的结果。
1 | pub trait Add<RHS, Output> { |
但考虑到trait Add可以接受的RHS可能是多种(例如对String而言可以接受String
和&str
),但返回的Output类型是确定的,所以可以将Output类型从由用户指定改为由实现方指定。此时就可以定义一个关联类型type Output
。
1 | pub trait Add<RHS = Self> { |
trait的继承
struct不能继承,但是trait可以继承。
这里涉及到泛型约束的问题,例如我们impl的是两个Father的交集还是并集呢?
1 | trait Son: Father1 + Father2 { |
在这里Father1和Father2是取的交集,也就是说对所有实现了Father1和Father2的T实现Son。
孤儿规则(Orphan Rule)
注意,孤儿规则是对 trait 而言的。而 impl 和 struct 就不能出现在不同的 crate 中,否则会报错 “cannot define inherent impl
for a type outside of the crate where the type is defined”。
泛型约束
例如我们实现sum函数,它只能接受泛型参数T是实现了trait Add的。可以这样写
1 | fn sum<T: Add<T, Output=T>> |
因为使用了关联参数,所以还可以简写成这样
1 | fn sum<T: Add<Output=T>> |
如果要写的比较多,可以把里面的东西拿出来,用where来写
静态分发和动态分发
静态分发和动态分发是对 trait 而言的。
下面是静态分发,为 fly_static::<Pig>
和 fly_static::<Duck>
生成独立的代码。这类似于 C++ 里面的模板实例化。
1 | fn fly_static<T: Fly>(S: T) -> bool { |
下面是动态分发,在运行期查找 fly_dyn(&Fly)
对应类型的方法,例如实际传入的是 &Duck
还是 &Pig
,是不一样的。这类似 C++ 里面的动态绑定,是有运行时开销的。
1 | fn fly_dyn(S: &Fly) -> bool { |
问题来了,这里的&Fly
是啥呢?实际上这是后面讨论的trait对象。
trait 的常见问题
在 impl 中调用 trait 的默认实现
实现 dynamic cast
简单来说就是 PSElementWriteBatch 实现了 trait ElementWriteBatch。一个 PSElementWriteBatch 知道怎么 merge 一个 PSElementWriteBatch,但一个 PSElementWriteBatch 似乎很难知道如何 merge 一个 Box<dyn ElementWriteBatch>
,除非 Box<dyn ElementWriteBatch>
提供一个 as_any(),然后在 merge 里面把这个 Any 手动 downcast 成 PSElementWriteBatch。
trait作为存在类型(Existential Type)
存在类型,又被称为无法直接实例化,它的每个实例是具体类型的实例。
对于存在类型,编译期无法知道其功能和 Size,目前 Rust 使用 trait object 和 impl Trait 处理存在类型。
trait object
如下所示,fly_dyn
中的 &Fly
参数就是一个 trait object。
1 | fn fly_dyn(S: &Fly) -> bool { |
TraitObject 可以看成具有下面的组织结构
1 |
|
vtable 中包含了对象的析构函数、大小、对齐、方法(也就是虚函数指针)等信息。
Rust 中有个常见的 E0035 错误,和 trait 的对象安全有关。具体来说,只有对象安全的 trait 才可以作为 trait object 来使用,否则只能作为一般的 trait 来使用。
具体指下面几点:
- 该 trait 的 Self 不能被限定为 Sized
- 该 trait 的所有方法必须是对象安全的
- 方法受 Self: Sized 约束
- 不包含任何泛型参数
- 第一个参数必须为 Self 类型,或者可以解引用为 Self 的类型
- Self 不能出现在出第一个参数之外的地方,包括返回值中
在 E0038 错误中,列出了一些破坏上述原则的情况。
比如 T
中定义了带有 type parameter 的接口函数 cb,那么 T
就不能以 trait object 的形式使用。这是因为 trait object T 的虚表无法穷尽 F 取不同类型的值的时候的情况。
1 | pub trait T { |
现在只能将 cb 改为动态分发形式
1 | fn get_value_cf(&self, cf: &str, key: &[u8], cb: Box<dyn FnOnce(Result<Option<&[u8]>, String>)>) |
但这样会出现另一个问题。一个 S: impl T
可以通过 &S
的方式传给 &dyn T
的参数。但反过来,一个 S: Box<dyn T>
就没法传给 impl T
的参数。
1 | trait T {} |
在实践中,还观察到出现有 “Cast requires that variable is borrowed for ‘static” 这样的报错。在 demo 中如果删掉 impl RaftStoreProxy
和 impl RaftStoreProxyEngineTrait for RaftStoreProxyEngine
里面的 + '_
,就能复现报错。解决方案就是加上 + '_
。原因是 dyn Trait
会被默认为是 dyn Trait + 'static
。我们需要显式指定另一个生命周期。
impl Trait
在目前的版本中,不能在 trait 中返回 impl Trait,也就是下面的代码无法编译。只能使用 Trait Object。
1 | pub trait Vehicle { |
标准库
连接
默认情况下 Rust 编译时会 link 标准库,通过添加 no_std
属性可以关闭这个行为。
字符串
字符串相关的结构之间的转换
包括str
、String
、&[u8]
、Vec<u8>
。
macro 宏
macro 的 import 和 export
macro 有两种 scope,textual scope 和 path-based scope。这里的 path 有专门的定义,可以理解为类似 crate::a::b
或者 super::a::b
这样的东西。
1 | use lazy_static::lazy_static; // Path-based import. |
在通过 macro_rules!
定义了 macro 之后,进入 textual scope,直到退出外层的 scope。这就类似于通过 let 定义变量一样。如果定义多次,那么老的 macro 会被 shadow 掉。
如代码所示,在 mod.rs 中声明了 m 后,pub mod a
,于是在 a 中也能使用 m 了。这是因为这里是 textual scope,a 也在 mod_macro 这个 scope 下面。也就是说 textual scope 可以进入子 mod,甚至穿越多个文件
使用 #[macro_use]
可以将 mod inner
中的 macro 暴露给外部。#[macro_use]
甚至可以从另一个 crate import 指定的或者所有的 macro,如下所示:
1 | // Or #[macro_use] to import all macros. |
#[macro_use]
需要和#[macro_export]
配合使用。#[macro_export]
的作用是将 macro 的声明放到 crate root 中,这样就可以通过 crate::macro_name
来访问。
下面的代码中,helped 是定义在 mod mod_macro 中的,但它被 export 到了 crate root。所以我们可以通过 crate::helped 来访问。
macro 语法
基本定义
查看定义,MacroRule 就是一个被 match 的 pattern,它支持三种括号。
1 | MacroRules : |
所以可以写如下所示的代码
1 | macro_rules! add { |
重复
如下的代码是两种对列表求和的方案。
在 MacroTranscriber 中有个结构 $(+$a)*
,表示给列表的每个元素前面都加上一个 +
。
1 | macro_rules! add_list { |
TT munchers
TT munchers指的是$($tail:tt)*
这样的结构,它永远可以捕获到还没有被 macro 处理的部分。通过该结构可以“递归”调用 macro。
所以可以得到第三种求和方案。
1 | macro_rules! add_list3 { |
对 MacroMatch 的详细说明
1 | MacroMatch : |
MacroRepSep 能取什么呢?定义如下
1 | MacroRepSep : |
delimiter 是三个括号,Token 基本上啥都可以是了。但可写出 ($($a:expr)>>*)
或者 ($($a:expr)%*)
么?并不能,原因在”Follow-set Ambiguity Restrictions”中有讲到。
metavariable
- item
诸如 mod、extern crate、fn、struct、enum、union、trait、macro 这些结构都是 item - expr
- block
- pat(Pattern)
- path
类似crate::a::b
这样的东西 - tt(TokenTree)
unsafe
unsafe 操作
Rust 哪些操作是需要 unsafe 包裹的呢?
- 对
*mut T
解引用
注意,取引用是 safe 的 - 访问全局的
static
对象 - 访问 union
这也对应了 Rust 的两个机制,所有权(禁止裸指针)和并发安全。
FFI
常见报错
Pure virtual function called。通常是因为对象提前被析构了,导致虚表也被释放了。常常和 invalid memory reference 交替出现。
Fat pointer 和 FFI
Rust 中的 Fat pointer 包含以下几类,在传给 FFI 接口后,会丢失除了地址之外的信息:
- 切片
切片中包含了长度信息,但传递给 FFI 只会保留起始地址,而丢失长度信息。 - trait object
这个在下面会专门讲到
trait object 和 FFI
在处理 Fat pointer 和 FFI 的过程中,trait object 非常头疼。
考虑下面的场景,RaftStoreProxyPtr
位于 FFI 边界,负责向 C++ 端传递上下文 RaftStoreProxy
。C++ 端持有 RaftStoreProxyFFIHelper
对象,并通过里面的 fn_handle_get_proxy_status
接口来调用 Rust。在调用时会传入 RaftStoreProxyPtr
作为上下文。Rust 端在收到调用时,将传入的 RaftStoreProxyPtr
从指针转回为 RaftStoreProxy
,并调用 RaftStoreProxy
中的方法。
1 | pub trait RaftStoreProxyFFI: Sync { |
现在我们想通过 RaftStoreProxyPtr 调用 RaftStoreProxyFFI 的方法,然后由 RaftStoreProxyFFI 动态 dispatch 到 RaftStoreProxy 上。这样的目的是因为 RaftStoreProxy 里面涉及了很多细节实现,我们想解耦掉,提出一个较为干净的 FFI 层。但很快遇到了问题:
没法为 RaftStoreProxyPtr 实现到 RaftStoreProxyFFI 的解引用
显然,这是因为没办法从*const c_void
转成 trait object。1
2
3error[E0277]: the trait bound `c_void: RaftStoreProxyFFI` is not satisfied
the trait `RaftStoreProxyFFI` is implemented for `RaftStoreProxy`
required for the cast from `c_void` to the object type `dyn RaftStoreProxyFFI`没法为 RaftStoreProxyPtr 直接实现 RaftStoreProxyFFI
这相当于是为*const c_void
实现一些 trait。不论编译器的报错,单从这个指针我们都无法获得任何的可供后续转发的上下文。也许可以尝试将 RaftStoreProxyPtr 直接定义为 TraitObject,可是它并不是 FFI 安全的。有一些比较 hack 的做法,比如可以再定义一个 FFI 安全的平行于 trait object 的对象。
最后的做法是避免在 FFI 边界使用 trait object,而是用一个简单的对象,如 RaftStoreProxy。然后让 RaftStoreProxy 持有一个 trait object 即 RaftStoreProxyEngineTrait。
异常
C++ 中异常不能穿透语言边界,并且会产生一个 SIGABRT。
在 v1.cpp 中有
1 |
|
在 Rust 的 main.rs 中有
1 |
|
经过测试发现,C++ 异常在语言边界会产生如下的错误
1 | fatal runtime error: Rust cannot catch foreign exceptions |
Layout
FFI 中保持 C++ 和 Rust 端 struct 的 Layout 一致是重要的。我们的实践是使用一个代码生成工具,而不是手动管理。
在下面的代码中并没有使用代码生成工具,结果造成 SSTReaderPtr 和 ProxyFFI.h 中的声明不符合。这导致 C++ 中使用的 struct 比 Rust 中多一个字段。实际我们观察到的现象是在 x86 架构下代码能够照常运行,但是在 arm 架构下就有问题。在 C++ 端传的参数是对的,但是到了 Rust 端解析出来就错了。这里的原因可能是 x86 编译会带上一些 padding,导致错误被掩盖了。
在不同架构上,c_char 的定义可能是 i8 可能是 u8,所以很坑。
FFI 和库
考虑 Rust 动态库会链接到 C++ 上,但他们可能“菱形依赖”同一个库。一般来说,方案是:
- Rust 动态库 rename 掉自己的依赖,或者静态打包成 local
- Rust 动态库动态加载这些库
但对于诸如 jemalloc 这些库,只能用第二种办法。
测试
cargo test
相比 C++ 的各种测试库,Cargo 直接整合了 cargo test。测试一般分为两种:
- 单测
一般是某个 mod 下面的 #[cfg(test)] 的 mod。 - 集成测试
一般是单独的 crate,名字叫做 tests。
说到 test feature,有一个坑点。考虑集成测试的情况,创建两个 crate:tests 和 raftstore。在集成测试的 tests crate 中开启的 #[cfg(test)]
,或者 cargo test 自己带上的 test feature,都不会传递到 raftstore 中。如果有需要,得通过自定义一个 testexport 来传递:
- 如果 raftstore 需要感知 test 环境,就定义一个 testexport 在自己的 Cargo.toml
- tests 的 Cargo.toml 去 enable
raftstore/testexport
当然,如果是 raftstore 自己内部的单测,就不需要 testexport 了,所以我们常常看到代码
1 |
具体进行什么测试,会经过:
package selection
--package
表示只测试某个 package 下面的测试,--workspace
测试 workspace 中的所有测试。
如果不给定任何选项,则会根据--manifest-path
。如果在没有给定,则使用当前的工作目录。
如果工作目录是某个 workspace 的根,则运行所有的 default 成员的测试。即 [default-members]
中列出的项目。如果没有列出,对于 virtual workspace 会运行所有 workspace 成员的测试;对于非 virtual,则只运行 root crate 的测试。这里其实有点反直觉,按理说 virtual workspace 一个都不运行比较好。因为比如我哪天将 workspace 改成了 virtual,那么原来的 cargo test 脚本可能就会运行很多的测试。
target selection
如果没有指定 target selection,则TODO
cargo test 打印
1 | test_snap_append_restart-0 2022/11/15 16:02:38.526 thread.rs:396: [DEBG] tid 42023 thread name is region-worker::test_snap_append_restart-0 |
Demo
线程安全的双向链表
Reference
- Rust编程之道 by 张汉东
- https://course.rs/
Rust 语言圣经 - https://learnku.com/docs/rust-async-std/translation-notes/7132
异步rust学习 - https://huangjj27.github.io/async-book/01_getting_started/03_state_of_async_rust.html
同样是异步教程 - https://huangjj27.github.io/async-book/02_execution/02_future.html
对Future实现的讲解 - https://kangxiaoning.github.io/post/2021/04/writing-an-os-in-rust-01/
这个是用Rust写操作系统的教程,这一节讲的是如何移除标准库 - https://www.cnblogs.com/praying/p/14179397.html
future的实现,不关注async相关,包含各种组合子 - https://cloud.tencent.com/developer/article/1628311
对pin的讲解 - https://folyd.com/blog/rust-pin-unpin/
对pin的讲解 - https://doc.rust-lang.org/std/pin/
pin的官方文档 - https://www.zhihu.com/question/470049587
AsRef/Borrow/Deref的讲解 - https://dengjianping.github.io/2019/03/05/%E8%B0%88%E4%B8%80%E8%B0%88Fn,-FnMut,-FnOnce%E7%9A%84%E5%8C%BA%E5%88%AB.html
Fn FnOnce FnMut的区别 - https://zhuanlan.zhihu.com/p/341815515
对闭包的论述 - https://medium.com/swlh/understanding-closures-in-rust-21f286ed1759
对闭包的说明 - https://stackoverflow.com/questions/59593989/what-will-happen-in-rust-if-create-mutable-variable-and-mutable-reference-and-ch
Owner和&mut是否可以同时修改? - https://doc.rust-lang.org/cargo/reference/features.html
对features的论述 - https://danielkeep.github.io/tlborm/book/pat-incremental-tt-munchers.html
TT munchers