Rust

  • 学习曲线非常陡峭, 掌握它的困难程度超过C++和Haskell之外的大部分语言.
  • 没有传统意义上的接口.
  • 变量所有权和生命周期导致代码重构困难重重, 想写出不丑的代码非常困难.
  • 变量所有权和生命周期导致大量时间被花在与编译器对抗上.
  • 不得不访问共享状态时(例如多线程), 仍需要使用Arc和Mutex解围.
  • 编译时的内存用量过高.
  • 类似于ECMAScript, rust从2015年开始每三年发布一个版本, 在发展过程中会淘汰掉旧模式.
    问题在于大多数文档/博客编写者并没有意识到需要区分版本,
    这种模糊造成学习成本徒增, 不理解rust发展史的新人会因此走弯路.
  • 不支持类TypeScript的联合类型(union types)和其他语言常见的函数重载.
    这使得类型会受到严格限制, 导致需要针对结构相似的类型编写重复的代码,
    目前可以通过枚举来曲线救国.
  • 类型系统的设计在一些情况下很愚蠢, 这种时候会单纯因为类型而无法实现功能.
    例子:
    • 无法为闭包手写类型, 并且会出现下方链接中每一种解决方案都无效的情况.
      https://stackoverflow.com/questions/27831944/how-do-i-store-a-closure-in-a-struct-in-rust
  • 基于Option和Result的链式错误处理非常糟糕:
    • 创建了一堆很难仅凭名字区分用途的链式方法.
    • 不同分支的后续代码被穿插在一起, 造成混乱.
    • 链式方法经常需要使用闭包, 然而闭包会引入rust的所有权机制, 招来更大的麻烦.
    • 代码很丑陋.
  • 有大量本该成为语法的功能是由社区实现的, 遍地都是宏.
  • 没有C-like for循环, 这在实现一些算法时相当致命, 将被迫使用while循环来替代.
  • 有少数情况, 保持代码处于Rust的安全定义下是不可能的.
    例如不可能在Rust里安全使用mmap.
  • match表达式没有静态路径分析, 嵌套处理时会被迫处理已经处理过的路径.
  • 有太多不稳定特性, 推动特性稳定的速度很缓慢.
  • 泛型的约束条件有时非常难写.
    https://stackoverflow.com/questions/27535289/what-is-the-correct-way-to-return-an-iterator-or-any-other-trait
  • 缺乏栈溢出时的有效错误消息(Rust目前只是直接程序崩溃, RUST_BACKTRACE 不适用于栈溢出), 无法定位导致栈溢出的代码.
    https://github.com/rust-lang/rust/issues/51405
https://github.com/rust-lang/rust-clippy
Rust生态中的低级正则表达式库.
regex库不支持环视(lookaround)和反向引用(backreference), 这导致它的匹配结果与其他编程语言的匹配结果不同.
Rust生态中的高级正则表达式库, 建立在regex之上.
Rust在性能分析方面共享了与C/C++相同的工具链.
Cargo.toml 里启用源代码调试信息.
[profile.release]
debug = true
Rust基准测试的实施标准.
https://github.com/flamegraph-rs/flamegraph
Rust语言的FlameGraph集成, 用于方便地生成火焰图.
在内部使用perf和flamegraph.
# perf record的默认采样频率是1000, 输出的perf.data文件很大, 通过修改命令行参数来降低频率.
# 99是perf record常用的经验值.
cargo flamegraph --cmd 'record --freq=99 --call-graph dwarf'
Linux的内核命令, 用于CPU profiling, 需要root权限.
为了在Ubuntu上使用perf, 需要在 /etc/sysctl.conf 添加以下两行:
kernel.perf_event_paranoid=-1
kernel.kptr_restrict=0
  • Hotspot (Linux): https://github.com/KDAB/hotspot
  • Firefox Profiler (Web, 推荐): https://profiler.firefox.com/
https://github.com/tikv/pprof-rs
能够配合criterion生成火焰图.
https://github.com/KDE/heaptrack
https://valgrind.org/docs/manual/dh-manual.html
https://github.com/casey/just
https://github.com/sagiegurari/cargo-make
https://github.com/google/evcxr
下载量最大的HTTP客户端库, 基于hyper, 支持WASM.
纯Rust的阻塞式HTTP客户端库.
HTTP多后端客户端库, 可用于WASM.
https://github.com/sagebind/isahc/
使用libcurl的HTTP客户端库.
Rust里最快的, 专注于基准测试性能的Web服务器项目.
actix-web为人诟病的一点是它内部使用了大量unsafe代码, 因此引发了一些drama.
tokio的子项目, 性能表现不俗, 人体工学很好.
下载量正在迎头赶上actix-web, 目前的主要缺点是没有稳定版本.
在基准测试中多次小幅胜过actix-web.
引入了filter概念.
人体工学优先的Web服务器, 性能只有那些专注于性能的项目的一半.
项目作者有过暂停维护的情况.
官方书籍: https://rust-lang.github.io/async-book/
Rust异步的独特之处是Rust只引入了接口和语法, 剩下的都需要由社区来实现.
这种设计避免了对未来的Rust造成破坏性改变, 但也无疑增加了学习的难度.
Rust中的async/.await由编译器实现, 是零开销的.
被async修饰的函数或块返回 impl Future<Output = T>.
被async修饰的函数或块在运行时可以通过 Future.await 让出线程, 而不是阻塞线程.
async fn do_foo_and_bar() {
foo().await;
bar().await;
}
Rust中标准库实现的Future类型, 是使async/.await语法成立的基础.
各种社区库很可能会重新导出Future.
https://crates.io/crates/futures
一个官方社区库, 提供了各种有利于Future使用的函数和宏, 这些功能最终可能会被移入标准库.
例如 futures::join! (名称来自于Fork-Join模型, 功能相当于JavaScript里的 Promise.all).
async fn do_foo_and_bar_at_same_time() {
let future1 = foo();
let future2 = bar();
join!(future1, future2);
}
异步生态系统的比较:
https://runrust.miraheze.org/wiki/Async_crate_comparison#Comparison_of_Async_Ecosystems
最流行也最成熟的异步运行时, 是Rust异步的事实标准.
Tokio被Deno等多个知名项目使用.
类似async-std所做的, tokio也提供了标准库API的异步版本, 并且不拘泥于与标准库保持一致.
#[tokio::main]
async fn main() {
...
}
如何将tokio用于多线程:
https://ryhl.io/blog/async-what-is-blocking/
tokio内置了一个线程池, 提供了一个用于将阻塞代码转换为异步代码的spawn_blocking函数.
但需要注意, 该函数只适用于I/O密集型任务.
如果需要CPU密集型任务, 请组合使用rayon.
一个社区异步库, 提供std的异步版本.
由Rust核心成员创建的社区库, 提供几种不同级别的工作窃取并行功能, 非常适合用于CPU密集型任务.
使用rayon后基本上就不需要使用标准库的线程了, 毕竟标准库没有线程池.
rayon的主打特性, 将集合转换为并行迭代器后, 即可调用相应的并行方法完成并行化,
类似于Java 8里的Stream.
在正确使用的情况下, 可以达到非常高的CPU使用率.
在一个案例中, 无论是基于通道的多线程代码, 还是基于锁的多线程代码, 最高也只能达到它性能的30%.
在这压倒性的效率面前, 其他的CPU密集型多线程方案变得没有意义.
并行迭代器并非银弹, 多线程本身是有开销的, 最终性能也有可能不及单线程版本.
rayon的fold与迭代器的fold的区别在于, 由于并行化, rayon的fold并不一定会将集合减少为单个项目.
因此, 需要在fold之后利用reduce来将结果真正减少为单个项目.
相比fold, 具有一些限制, 因此可能会出现需要先fold再reduce的情况.
限制:
  • reduce要求闭包的参数a和参数b具有相同的类型, fold没有此限制.
  • reduce要求闭包的结果类型与参数类型相同, fold没有此限制.
    此限制极大地破坏了reduce的使用场景.
  • reduce的参数a和参数b哪个是acculator是不确定的, fold总是确保左值(即参数a)是acculator.
在并行迭代器中使用任何锁都会拖累并行迭代器的速度.
利用复杂数据结构收集数据是一种常见需求, 直觉上会采用互斥锁来访问这些数据结构, 最终造成反模式.
相关需求往往可以通过使用 .reduce 或组合使用 .fold.reduce 来解决.
rayon里的底层功能, 由于其接口只适用于同时并行两个任务, 基本不会使用.
rayon里的内部功能, 只能持有生命周期为 'static 的引用, 用途非常受限, 基本不会使用.
rayon主要的线程池API, 提供作用域线程, 可以生成任意数量的任务, 相比join有一定的性能损耗.
作用域线程是保证在父线程退出前退出的线程, 因此scope调用本身是阻塞的.
作用域线程可以直接访问闭包外的栈而不需要转移所有权, 让人印象深刻.
标准库也有作用域线程实现 std::thread::scope,
但考虑到相关API曾经在Rust的稳定版本里消失长达7年之久, 所以恐怕还是用久经考验的社区库更好.
https://github.com/rayon-rs/rayon/issues/522
这导致调用spawn需要非常小心, 如果spawn在循环里被调用, 则可能有数千, 数万个任务等待排队.
值得一提的是, 标准库的 thread::scope 也遵循相同的行为.
手工创建线程池.
通过它创建的线程池和直接使用scope时没什么分别, 因此没有使用它的意义.
一个奇怪的线程池实现, 它的实现是非阻塞的(内部使用了 mpsc::channel 而不是 mpsc::sync_channel),
这导致线程池实际上变成了一个无限长度的工作队列, 无限制地让任务排队可以轻易地用光内存.
由Rust核心成员创建的社区库, 提供几种在并行项目中有用的数据结构, 例如MPMC.
Rust中的编译单元, 可以生成为库(lib, dll)或可执行文件.
crate之间不能出现循环引用.
cargo是Rust的命令行包管理器.
cargo new hello_world --bin 创建可执行文件项目
cargo new hello_world --lib 创建库项目
cargo build 编译(实际进行编译使用的是rustc)
RUSTFLAGS="-C target-feature=-crt-static" cargo build 用于musl工具链的编译
cargo run 编译执行
cargo clean 清除编译结果
cargo doc 生成文档
cargo test 单元测试
cargo test -- --nocapture 保留输出(println)的单元测试
cargo bench 性能测试
cargo update 更新依赖项的锁定文件
cargo install 安装可执行程序
cargo install --path . 将当前项目安装为可执行程序
cargo uninstall 删除可执行程序
由社区开发的cargo子命令.
cargo add 添加依赖项
cargo rm 删除依赖项
cargo upgrade 升级依赖项, 会修改Cargo.toml文件
Rust的registry索引原始设计十分病态:
它是一个构建在Git仓库上的平面文件数据库, 实际的数据文件是NDJSON格式.
这项莫明其妙的设计导致:
  • registry服务不仅需要操作Git仓库, 还需要向外暴露Git仓库.
    这为实施替代品增添了很多不必要的麻烦, 并且引入了很多决策问题.
  • cargo为了下载软件包, 需要克隆Git仓库, 而不是简单查询服务器获得结果.
RFC2789引入了基于HTTP静态文件服务器的registry来解决registry早期设计的缺陷,
但相关实现(https://github.com/rust-lang/cargo/pull/8890)还没有就位,
并且与身份验证相关的RFC3139还没有合并.
https://github.com/rust-lang/cargo/wiki/Third-party-registries
Rust中的模块系统.
Rust中的每一个源文件都是模块, 模块名与文件名相同.
在模块外部使用这些模块时, 需要通过 mod 导入这些模块, 然后才能 use.
在源文件里可以继续用 mod 定义子模块.
模块默认是私有, 对其他文件不可见, 通过 pub 修饰符将它们变成公有.
以下内容于Rust 2018淘汰:
  • extern crate
  • #[macro_use]
Rust会自动识别 main.rs, lib.rs, mod.rs 文件, 因此不需要也不应该显式通过 mod 导入它们.
相当于Node.js的index.js, 通常用来作为模块的索引入口, 不需要手动导入.
在Rust 2018里, 允许用户在模块的上一级文件夹中使用与模块同名的rs文件替代mod.rs,
如此设计是因为mod.rs文件对文本编辑器不友好(多个mod.rs文件难以区分).
普遍认为Rust 2015的目录结构更加"内聚", 所以不建议使用Rust 2018新增的目录结构.
识别当前目录下的文件/目录为一个模块, 或在当前文件模块里建立一个模块.
默认情况下, mod 识别出的模块不具有外部可见性.
将模块暴露给外部.
use crate::, .
use self::, self相当于路径里的 ., 指的是当前的模块(但由于每个文件都是模块, self其实就是指向该文件模块本身).
use super::, super相当于路径里的 ...
只有访问路径中经过的项目都是pub时, 该项目才可以被use.
尽管名称有一定的迷惑性, 但它的功能是使用模块的同时将模块内容再次导出.
https://doc.rust-lang.org/reference/visibility-and-privacy.html?highlight=pub#visibility-and-privacy
pub(in path)语法将相应内容暴露到指定的范围.
pub(crate), 将相应内容暴露给整个crate, 这在库开发中很常用, 因为"向crate以外隐藏细节, 但又允许在crate内使用"是很常见的需求.
pub(super), pub(self)pub(in super)pub(in self) 的语法糖.
pub(self) 没有实际意义, 因为它相当于"仅当前模块内部可见".
单行写法:
[dependencies]
rand = { git = "https://github.com/rust-lang-nursery/rand", branch = "next" }
可读性更好的多行写法:
[dependencies.rand]
git = "https://github.com/rust-lang-nursery/rand"
branch = "next"
cargo访问私有Git仓库时通过ssh-agent进行认证, 因此必须启用ssh-agent:
# 启动ssh-agent, 并执行ssh-agent的返回值:
# SSH_AUTH_SOCK=/tmp/ssh-xxxxx/agent.xxxxxx
# 作用是将sock地址赋值给环境变量SSH_AUTH_SOCK, 以供其他程序访问.
eval `ssh-agent`
# 加载本地的ssh凭证
ssh-add
由于缺乏流行的私有registry实现, 且一些改进提案仍在推进,
目前只推荐使用基于私有Git仓库的私有包方案.
为了能在CI/CD里正确拉取项目代码, 需要在对应的代码仓库添加只读的部署公钥.
[dependencies]
crate-name = { path = "../../library/crate-name" }
一个包含最常见的type和trait的模块, 会由Rust编译器自动导入.
Rust可以用type关键字给类型起别名, 以增强代码的可读性.
type Age = u32;
// 支持泛型
type Double<T> = (T, Vec<T>);
Rust允许在一个代码块中重复声明变量, 后声明的变量会遮蔽掉先声明的变量.
这同样能用来改变变量的修饰符:
let mut v = Vec::new();
let v = v;
// 反之亦然
let v = Vec::new();
let mut v = v;
变量遮蔽不会导致被遮蔽变量的生命周期提前结束.
Rust中的变量在默认情况下是不可变的.
如果需要让其可变, 必须加上 mut 修饰符.
Rust中的下划线用于忽略变量绑定.
绑定到下划线上的内存会立即调用析构函数(被销毁).
用static关键字声明的变量是静态变量(生命周期直到程序结束的一种变量).
static也是Rust中声明全局变量的唯一方法.
static GLOBAL: i32 = 0;
用const关键字声明常量.
const GLOBAL: i32 = 0;
const声明的常量很可能会被编译器优化为内联值.
Rust里极少发生隐式类型转换, 不同类型的转换通常需要用 as 显式完成.
隐式类型转换通常只发生在智能指针上:
  • &String 会转换为 &str
  • &Vec<i32> 会转换为 &[i32]
  • &Box<Chessboard> 会转换为 &Chessboard.
  • true
  • false
char表示任何一个unicode字符值, 占用4个字节的内存空间.
let love = 'c';
let c1 = '\n';
let c2 = '\x7f';
let c3 = '\u{7FFF}';
可以用u8表示单个ASCII字符, 只占用1个字节.
let x: u8 = 1;
let y: u8 = b'A'; // 字节
let s: &[u8; 5] = b"hello"; // 字节字符串
Rust的整数类型由类型定义其占用的内存空间, 而不是像C类语言那样由编译器定义类型的内存空间.
类型 有符号 无符号
8 bits i8 u8
16 bits i16 u16
32 bits i32 u32
64 bits i64 u64
128 bits i128 u128
Pointer size isize usize
pointer size会根据平台改变实际的类型,
在32位平台上, usize是u32, 在64位上, usize是u64.
标准库和类型推导都倾向于使用pointer size而不是具体的类型.
let var1: i32 = 32;
let var2: i32 = 0xFF;
let var3: i32 = 0o55;
let var4: i32 = 0b1001;
let var5 = 0x_1234_ABCD; // 任意添加_以增强可读性
let var6 = 123usize; // 指定字面量类型
let var7 = 32; // 默认为i32类型
9_i32.pow(3); // 直接在字面量上调用函数
整数会自带溢出检查(降低性能), 溢出时引发panic.
溢出时会自动舍弃高位.
Rust使用IEEE 754-2008标准的浮点数, 分为 f32 和 f64 两种.
let f1 = 123.0f64;
let f2 = 0.1f64;
let f3 = 0.1f32;
let f4 = 12E+99_f64;
let f5: f64 = 2;
根据标准, 浮点数分为以下类型:
  • Nan 不是数字 std::f32::NAN
  • Infinite 无穷大 std::f32::INFINITY
  • Zero 零
  • Subnormal
  • Normal 正常浮点数
类型 含义
Box<T> 指向类型T的, 独占所有权的, 有权释放内存的指针.
&T 指向类型T的借用指针, 或称引用. 无权释放内存, 无权写数据.
&mut T &T加上mut修饰符的版本, 有权写数据.
*const T 指向类型T的只读裸指针, 没有生命周期信息, 无权写数据.
*mut T 指向类型T的可读写裸指针, 没有声明周期信息, 有权写数据.
Rc<T> 指向类型T的引用计数指针, 共享所有权, 线程不安全.
Arc<T> 指向类型T的原子引用计数指针, 共享所有权, 线程安全.
Cow<'a, T> 写时复制(clone-on-write)指针.
let a = (1i32, false);
let b = ("a", (1i32, 2i32));
let c = (0,); // 只有一个元素的元组
let d: () = (); // 空元组(unit, 单元类型), 不占用内存空间
// 通过模式匹配访问元组成员
let p = (1i32, 2i32);
let (a, b) = p;
// 通过索引访问元组成员
let x = p.0
let y = p.1
结构体有时会提供一个new函数作为该结构体的工厂函数.
结构体有时会提供一个default函数返回该结构体的默认值.
Rust不支持继承/混入结构体.
结构体默认位于栈.
struct Point {
x: i32,
y: 32, // 最后的逗号可以省略, 但出于习惯和版本控制目的, 建议使用
}
fn main() {
let p = Point { x: 0, y: 0 };
println!("Point is at {} {}", p.x, p.y);
// 简写
let x = 10;
let y = 20;
let p = Point { x, y };
// 类似JavaScript的spread语法
let p = Point { ..p };
let Point { x: px, y: py } = p;
let Point { x, y } = p;
}
// 空结构体
struct Foo1;
struct Foo2();
struct Foo3 {}
struct Color(i32, i32, i32);
struct Color {
0: i32,
1: i32,
2: i32,
}
// 用来创造独立的类型
fn main() {
struct Inches(i32);
fn f(value: Inches) {}
let v: i32 = 0;
f(v); // 编译不通过, 因为Inches和i32不是一个类型, 且Rust不允许隐式类型转换
}
newtype模式是一种用于绕开Rust实现trait限制的模式.
该模式也被用来语义化字面量, 最常见的用法是变更数字字面量的单位, 例如从"厘米"变更为"米".
通过struct创建一个只有一个元素的元组, 以此来让被包装的类型变成自己的.
该模式的缺点很明显, 即如果需要访问被包装的值本身, 需要以 x.0 的方式访问(虽然这可以通过实现Deref解决).
struct Years(i64);
impl Years {
// ...
}
Rust中的枚举类型是一种带有类型注记(变体, variant)的代数数据类型(ADT), 而不是其他编程语言里常见的常量枚举.
和Haskell一样, variant可以作为函数使用.
enum Number {
Int(i32),
Float(f32),
}
fn read_num(num: &Number) {
// 枚举用于模式匹配
match num {
&Number::Int(value) => println!("Integer {}", value),
&Number::Float(value) => println!("Float {}", value),
}
}
fn main() {
let n: Number = Number::Int(10);
read_num(&n);
}
枚举可以用来表示JSON数据类型.
enum Json {
Null,
Boolean(bool),
Number(f64),
String(String),
Array(Vec<Json>),
Object(Box<HashMap<String, Json>>)
}
enum BinaryTree<T> {
Empty,
NonEmpty(Box<TreeNode<T>>)
}
struct TreeNode<T> {
element: T,
left: BinaryTree<T>,
right: BinaryTree<T>
}
Rust标准库中的Result和Option就是enum类型.
enum Result<T, E> {
Ok(T),
Err(E)
}
enum Option<T> {
None,
Some(T),
}
Rust数组的元素个数是在编译阶段决定的, 因此是固定长度.
在数组上可以使用切片的方法.
除非使用引用类型, 否则Rust中的数组赋值默认是clone.
let xs: [i32; 5] = [1, 2, 3, 4, 5];
let xs: [i32; 500] = [0; 500]; // 批量初始化
let xs: [i32; 0] = []; // 空数组
// 多维数组
let xs: [[i32; 2]; 3] = [[0, 0], [0, 0], [0, 0]];
Rust中的数组索引访问会带有运行时的边界检查, 索引访问的性能不如C/C++.
使用迭代器访问时则不会有边界检查.
for (index, value) in v.iter().enumerate() {
...
}
在数组上调用borrow(借用元素的引用)方法, 可以生成切片, 用 & 作为语法糖.
切片是一种没有所有权的数据视图(指针), 可以视作是数组的取只读引用.
切片是胖指针(fat pointer), 同时包含指向的地址和数据的长度.
let r = 1..10; // Range<i32>, [1, 10)
let r = 1i32..10;
// 等同于
let r = Range { start: 1, end: 10 };
// 用于数组切片
let arr: [i32; 5] = [1, 2, 3, 4, 5];
&arr[..]
&arr[2..];
&arr[..2];
// 左闭右闭区间
start..=end // std::ops::RangeInclusive
// 右闭区间(左边在有符号时为负无穷, 无符号时为0)
..=end // std::ops::RangeToInclusive
Rust不像Python那样忽略越界内容,
如果切片时给的索引值在运行时越界(例如 source[100..]), 程序会直接panic.
需要注意的是, 通过集合的 .len() 方法获得的索引值有语法糖:
如果切片是 source[source.len()..]slice[slice.len()..slice.len()], 程序不会panic.
Vec的元素数量超过其缓冲区容量时, 会创建一个比原来大一倍的新缓冲区.
Rust里的字符串切片视图, 是一个指向内存的定长指针, 同时也是字面量字符串的类型.
加上 mut 时可变, 但不能修改其长度.
它不是char类型(Unicode)的数组, 而是u8类型的数组,
当需要将它视作高级编程语言的字符串时, 需要将其转为 Vec<char>.
在结构体中使用 &str 时, 需要显式声明其生命周期参数.
这会让编码变得非常痛苦, 因此一些人倾向于在结构体里使用 String.
let s: &str = "Hello"
// 由于字符串使用了utf-8变长编码, 字符所处的内存位置不能直接得到.
// Rust里按索引取字符需要这样写':
s.chars().nth(n);
// 原始字符串
// r之后可以添加任意数量的井号(#), 字符串末端需要以相同数量的井号结束, 从而允许在原始字符串里输入双引号(").
let path = r"C:\Program Files\Rust";
// 字符串连接
let new_str = &str + &str; // 反直觉的是, 这在Rust里会报错.
// 解决方案: 创建一个String作为新字符串的容器
let mut new_string = String::new();
new_string.push_str(s);
new_string.push_str(s);
let new_str = new_string.as_str(); // 转换为&str
String 可以扩容, 其内部实现类似于 Vec<u8> 类型.
let mut s: String = String::from("Hello");
s.push(' ');
s.push_str("World.");
s.as_str(); // 转换为&str类型
String 的切片和数组切片行为类似, 本质上是在相应内存创建了一个数据更少的新视图.
  • String 是一种类似于 Vec 的动态字符串类型, 位于堆, 可以从中得到切片视图 &str.
  • &str 是一个指向内存中数据的指针/切片视图(C语言的 char*).
    指向的地方可以是 string.as_str() (指向堆),
    可以是在创建时就知道其长度的字面量 &str (指向栈),
    也可以是 &'static str (指向程序的静态存储, 静态存储在程序运行时加载进内存).
if语句后的大括号是强制的.
if n < 0 {
...
} else if n > 0 {
...
} else {
...
}
// 模式匹配解构赋值
if let Some(x) = optVal {
...
}
if本身是一种表达式, 相当于三元运算符.
let x: i32 = if condition { 1 } else { 2 };
match是Rust的模式匹配功能, 编译器会强制要求程序员处理所有match情况.
Rust的match表达式能够支持非常复杂的模式匹配.
enum Direction {
East, West, South, North
}
fn print(x: Direction) {
match x {
Direction::East => { ... }
Direction::West => { ... }
Direction::South => { ... }
Direction::North => { ... }
}
}
match x {
i if i > 5 => ...,
i if i <= 5 => ...,
_ => unreachable!(), // 由于编译器不懂得数学运算, 因此需要手动提示编译器
}
match x {
'0' ... '9' => read_number(),
'a' ... 'z' => read_word(),
' ' | '\t' | '\n' => read_whitespace(),
_ => skip()
}
match x {
ref r => ..., // 用ref关键字表示匹配的是引用而不是值, 从而避免所有权转移
}
enum OptionalInt {
Value(i32),
Missing,
}
let x = OptionalInt::Value(5);
match x {
// 通过if表达式强化变体的条件匹配
OptionalInt::Value(i) if i > 5 => ...,
// `..`表示不在乎内容是什么
OptionalInt::Value(..) => ...,
OptionalInt::Missing => ...,
}
一种语法糖, 直接将匹配到的右值绑定到左值代表的变量上.
// 原始代码
match self.get_selection() {
Shape::Rect(top_left, bottom_right) =>
optimized_paint(&Shape::Rect(top_left, bottom_right)),
_ => // ...
}
// 用@模式简写
match self.get_selection() {
rect @ Shape::Rect(..) =>
optimized_paint(&rect),
_ => // ...
}
与其他语言不同, 语句块在Rust里是一个较为常用的功能:
由于Rust独特的变量所有权机制, 有时候需要使用语句块来控制变量的生存周期.
let x: i32 = {
println("Hello");
1 + 2
}
无限循环, 之所以存在单独的loop关键字, 是为了方便编译器优化.
loop {
continue;
break;
}
// 带有label的循环
'a: loop {
'b: loop {
break 'a;
continue 'a;
}
}
let v = loop {
break 10;
}
while condition {
...
}
while let Some(x) = optVal {
}
Rust没有C类的for循环, 只提供for-each.
let array = &[1, 2, 3, 4, 5]; // 一个可迭代对象
for i in array {
println!("The number is {}", i);
}
// 用语法题生成一个Range, Range是可迭代的
for i in 1..10 {
println!("The nubmer is {}", i);
}
Rust中的函数偏向于函数式语言的用法, 通常不使用return返回值, 而是直接返回最后一个表达式.
空返回值表示为 ().
函数的参数类型在实际使用中会作为函数是否要求所有权的提示, 详见函数调用.
fn add((x, y): (i32, i32)) -> i32 {
x + y
}
发散函数相当于TypeScript里返回值类型为never的函数.
fn diverges() -> ! {
panic!("This function never returns!");
}
每个函数都有自己单独的类型, 因此不能直接作为类型赋值, 需要用fn类型.
let mut func = add as fn((i32, i32)) -> i32;
let mut func: fn((i32, i32)) -> i32 = add;
Rust里的一般函数不能访问外部环境的变量, 但闭包函数可以.
由于不能支持垃圾回收, 在Rust里使用闭包比高级语言要复杂很多.
let add = |a: i32, b: i32| -> i32 { return a + b; };
let add = |a, b| { return a + b; };
let add = |a, b| { a + b };
let add = |a, b| a + b;
// Fn, FnMut, FnOnce是专用于闭包的trait, 用来表示闭包捕获变量的方式
// `Fn` 相当于 `&T`, 即对参数只读的闭包.
// `FnMut` 相当于 `&mut T`, 即可以写入参数的闭包.
// `FnOnce` 相当于 `T`, 即参数的所有权可以被转移的闭包.
// Fn(i32) -> i32是专用于闭包的语法糖, 等价于Fn<i32, i32>
fn make_adder(x: i32) -> Box<Fn(i32) -> i32> {
// 当闭包的生命周期超出被捕捉变量的生命周期时, 需要用move关键字将所有权转移.
Box::new(move |y| x + y)
}
Rust中的协程是从生成器(Generator)特性改名而来的.
协程是内部带有yield关键字的闭包.
Rust中的迭代器方法不在集合上直接提供, 需要先通过各种方法从集合里创建相应的迭代器.
创建一个 IntoIter<T> 类型, 和 Iter<'a, T> 不同, 具有值的所有权.
本质上, Rust里的for循环只是 .into_iter() 的语法糖.
总是建议优先使用 .into_iter() 而不是其他方法, 因为所有权转移的内存用量最小.
直到所有权转移成为问题, 才应该考虑其他方法.
创建一个具有对每个集合元素的借用的迭代器 Iter<'a, T>.
由于在后续方法里访问到的是借用, 因此一个 &str 会在迭代器里变成 &&str, 这有时候很烦人.
.iter() 类似, 用于创建具有元素可变引用的迭代器.
迭代器特型本身, 提供 .next 方法.
具有此特型的结构可以被转换为Iterator, 该特型提供 .iter(), .into_iter() 等方法.
经常被用作泛型参数的约束.
trait是Rust的一种类似于Haskell的功能, 它有两个功能:
  • 用来表示此类型具有某种特型, 让符合此特型的类型自动获得该特型支持的方法(即"分派").
  • impl 为特型扩展方法.
    Rust对此行为有一些限制规则(称为一致性原则 coherence rule 或孤儿规则 orphan rule),
    以避免对来自外部的trait扩展方法:
    • impl块与trait声明在同一个crate中
    • impl块与type声明在同一个crate中.
      这些规则带来很大的限制, 因此crate的API指南高度建议库作者为结构实现所有常见的trait.
      如需绕开这些限制规则, 考虑使用newtype模式.
// Rust可以在trait里直接编写实现.
trait Shape {
fn area(&self) -> f64;
}
trait Round {
fn get_radius(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Generic<T> {
value: T,
}
// 为Cirlce实现Shape特型
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
// 也可以直接针对结构体添加方法
impl Circle {
fn get_radius(&self) -> f64 { self.radius }
}
// 也可以为Shape实现Round特型(为特型实现特型)
impl Shape for dyn Round {
fn area(&self) -> f64 {
std::f64::consts::PI * self.get_radius() * self.get_radius()
}
}
// 处理泛型
impl<T: u32> Generic<T> {
fn get(&self) -> T // ...
}
// 当一个类型满足多个trait, 且具有相同方法签名的多个同名方法时, 必须手动指定trait:
<Cook>::start(&me);
<Chef as Wash>::start(&me);
self是trait方法支持的第一个变量名, 其类型为Self.
只有当self作为第一个变量名时, 才能使用 =Type.FunctionName()= 调用函数.
Type.FunctionName()<Type>::FunctionName(&Name) 的语法糖.
// 不使用语法糖
trait T {
fn method1(self: Self);
fn method2(self: &Self);
fn method3(self: &mut Self);
}
// 使用语法糖时, 可以省略Self类型.
trait T {
fn method1(self);
fn method2(&self);
fn method3(&mut self);
}
第一个函数的变量名不是self时, 函数只能通过 Type::FunctionName() 的形式调用.
struct T(i32);
impl T {
fn func(this: &Self) {
println!("value {}", this.0);
}
}
fn main() {
let x = T(42);
T::func(&x);
}
use std::fmt:Debug;
// 写法1
fn my_print<T: Debug>(x: T) {
...
}
// 写法2
fn my_print(x: T) where T: Debug {
...
}
在将trait作为约束使用这件事上, dyn前缀可以解决相同的问题, 但dyn解决此问题的方法不同:
dyn会在运行时导致动态分派(dynamic dispatch), 而泛型只是编译时生成相应的代码而已.
因此, 使用dyn比使用泛型的成本要高得多.
本质上, 动态分派是为了在编程语言里提供多态性.
trait Base { ... }
trait Dervied: Base { ... }
// 多重继承
trait Ord: Eq + PartialOrd<Self> {}
为结构体添加多个特型需要编写很多impl代码块, 为了简化这一过程, Rust提供了一个名为derive的attribute.
注: 只有支持自动derive的trait才可以用derive设置.
#[derive(Copy, Clone, Default, Debug, Hash, PartialEq, Eq, PartialOrd, Ord]
struct Foo {
data: i32
}
// 相当于
impl Copy for Foo { ... }
impl Clone for Foo { ... }
impl Default for Foo { ... }
impl Debug for Foo { ... }
impl Hash for Foo { ... }
impl PartialEq for Foo { ... }
...
是否具备该特型决定了Rust是否认为该结构可以在编译时知道它的大小.
切片是最常见的不具有此特型的结构.
类似于其他语言, Rust可以给函数添加属性/注解.
#[test]
fn test_add() {
// ...
}
Rust里的宏是卫生宏(hygiene), 是基于语法解析而不是文本替换的.
用来以数组表达式创建Vec对象的宏.
let v = vec![1, 2, 3];
在编译时将UTF-8编码的文本文件读取为字符串, 字符串会被编译到二进制里.
这个宏的亮点在于它接受的路径是相对于当前文件的, 因此很适合用来读取那些需要打包到二进制里的内容.
来自社区的宏, 生成直到运行时才会初始化的静态变量.
社区的另一个库once_cell有不使用宏的等价实现.
所有权是Rust实现内存安全和可靠并发的基石.
由于需要所有权, Rust在一定程度上放弃和反对建立对象之间的相互引用/循环引用的能力.
Rust的所有权基于以下规则:
  • 每块内存在Rust里被一个变量所有.
  • 每块内存在一个时间点只能有一个所有者.
    注意, 一个所有者可以被另一个所有者所有, 这些所有权关系可以用一棵树来表示.
  • 变量在作用域结束时, 变量及其值会被销毁.
转移语义是Rust里向标识符传值时的默认语义, 所有权从一个标识符被转移给了另一个标识符, 这种转移是零和的.
这与C/C++不同, 因为C/C++的默认语义是复制.
这与Python等语言基于引用的赋值不同, 因为Rust不允许多个绑定同时指向一块内存.
以下操作会使没有实现 std::marker::Copy 特型的类型发生所有权转移:
  • 赋值
  • 函数调用
  • 函数返回
  • 模式匹配
由于编译器难以跟踪基于索引的所有权转移(例如只转移其中的一个元素), 编译器会直接以编译错误拒绝掉相关代码.
因此, 基于索引的访问几乎总是需要事先借用(取得的是引用而不是所有权).
调用函数时的传参会导致所有权转移, 除非该函数将同一个变量以返回值形式转移, 否则调用者会失去所有权.
由于失去所有权的变量会被Rust销毁, 无法在接下来的代码中使用,
所以函数参数经常是借用或克隆(clone), 很少直接用转移.
函数的 最佳实践将参数类型视作函数是否要求所有权的提示:
  • 通过 &T 暗示函数不需要相关参数的所有权, 从而避免传参时需要复制所造成的成本.
  • 通过 T 暗示函数需要相关参数的所有权.
常见于在一个代码块里多次使用一个没有实现copy特型的数据类型的时候:
由于没有实现copy特型, 在第一次操作此数据类型时,
由于某种原因发生了所有权转移(比如作为函数参数传递), 所有权在转移之后没有归还,
导致之后的代码不能获得此数据类型的所有权.
如果是因为作为函数参数而导致所有权转移, 则可以通过将函数的参数改为数据类型的借用(&)来解决此问题.
由于大多数函数都不需要真正的所有权转移, 因此借用可以解决大多数问题.
通过克隆对象来避免所有权转移.
底层数据是否发生内存复制, 取决于编译器优化的结果.
和Copy区别在于Clone不一定会发生内存复制, 而Copy一定会发生.
常见于编写函数式风格代码的时候:
闭包的返回值引用了生命周期只在闭包里的变量(最常见的情况是间接引用了闭包的参数), Rust不承认这种所有权转移.
通过调用 .to_owned(), 相关变量会被克隆, 从而使所有权能够被转移出去.
带有std:marker::Copy trait的类型将使用复制语义而不是移动语义.
实现Copy的数据类型会在本应发生所有权转移的场合通过内存复制(memcpy)避免所有权转移.
  • 数字
  • 字符
  • bool
move语义很苛刻, 为了降低编写代码的难度, Rust还有一种被称作borrow的语义.
& 只读借用, 这同时也被称为取引用.
&mut 可读写借用
借用指针规则:
  • 借用指针不能比它指向的变量存活更长时间.
  • &mut 借用只能指向本身具有mut修饰符的变量.
  • &mut 借用指针存在时, 被借用的变量会处于冻结状态.
  • 对单一变量的 & 借用指针可以同时存在多个, &mut 借用指针只能存在一个.
支持借用的数据类型可以实现ToOwned, 它可以将数据类型从引用转换为非引用(即Rust所有权规则下的"所有").
例子:
  • &T 转换为具有所有权的 T
  • &[T] 转换为具有所有权的 Vec<T>
move, borrow只有语义差别, 并不决定内存是否被复制(memcpy),
决定内存是否复制的只有编译器优化, 而编译器优化的方式总是可能发生改变.
拿当前的Rust编译器举例, 在数据较小时, copy与borrow的性能差别是模糊不清的.
造成这一点的原因是编译器会对只有两个及两个以下指针的结构采取复制传递, 更大的结构采取引用传递.
有趣的是, Nim的编译器和Rust编译器采取一样的优化策略, 这可能是编译器优化时采取的惯例.
智能指针会自动发生解引用(deref, *), 从而让这些指针能够调用它们内部类型的方法.
解引用是取引用(ref, &, &mut)的反向操作符.
在使用 . 运算符时, 通常会自动发生隐式借用和解引用:
let mut v = vec![1, 2];
v.sort();
// 等价于
(&mut v).sort();
Box类型是永远 只会发生转移语义 的类型, 是一种智能指针.
除了 会将数据存储在堆(heap)而不是栈(stack)里 之外, 没有性能开销.
Box的一些使用场景:
  • 需要确保不会发生复制语义.
  • 需要将数据存放在堆而不是栈上.
    除非必要(例如栈的大小不够用, 或需要超越栈的生命周期), 否则一般不会将数据移动到堆, 因为栈和堆有很大的性能差异:
    栈非常频繁地被使用, 一般会被映射到CPU缓存上, 因此堆的性能会远不如栈的性能.
智能指针, 带有总大小和元素个数大小的数据.
utf8字符序列的指针.
通过引用计数来允许同一个值被多个所有者使用.
通过调用clone方法来复制指针, 然后将指针转移给需要它的所有者.
根据Rust的"共享不可变, 可变不共享"原则, 共享的引用不能提供可变性, 因此Arc类型在多线程中是只读的.
为了让引用可变, 通常会与Mutex搭配使用形成 Arc<Mutex<T>>.
对Arc的克隆只会克隆引用, 不会克隆它包装的值.
Arc的非线程安全版本, 性能更好, 但不允许跨线程使用(编译器会报错).
一些类型具有内部可变性, 在使用时 不需要mut修饰符就可以改变内部的值.
这之所以不违反Rust的内存安全原则, 是因为这些类型包装了内部值, 对内部值的修改都是由类型本身间接完成的.
  • Cell
  • RefCell
  • Mutex
  • RwLock
  • Atomic*
Rust里能在多线程使用的类型必须具有Sync或Send约束, 否则会出现编译错误.
一种marker trait, 带有此trait的类型在不同线程中的访问是安全的.
编译器会自动为确定合适的类型实现相关特型.
一种marker trait, 带有此trait的类型在不同线程中传递所有权是安全的.
编译器会自动为确定合适的类型实现相关特型.
使用方式是通过调用lock方法来获取锁, 得到 LockResult<MutexGuard<T>>.
Mutex有自己的析构函数, 会在作用域结束时自动调用内部的unlock方法来解锁, 用户无法自己手动调用unlock方法,
但可以通过 std::mem::drop(mutexguard) 间接实现unlock.
当锁的持有者发生panic, 导致锁不可能被释放时, Mutex会陷入中毒状态.
对于处于中毒状态而Mutex, 其他线程在获取锁时会立即抛出 PoisonError.
RwLock与Mutex相似, 但它的锁是一写多读的, 通过调用read方法获得读锁, 通过调用write方法获得写锁.
RwLock要求它的内容具有Sync.
RwLock只有写锁会中毒, 读锁不会中毒.
条件变量, 需要与Mutex成对使用, Arc<(Mutex<T>, Condvar)>.
一个变量从出生到死亡的过程被称作它的生命周期.
当类型具有 std::ops::Drop 特型时, 类型会在变量生命周期结束时自动调用它的析构函数, 从而销毁自己.
有析构函数的类型被编译器禁止拥有Copy特型.
生命周期参数使用单引号(读作"撇")开头, 后跟一个合法的标识符: 'name.
生命周期参数的name通常是从a开始的英文字母(类似于泛型的T, U, V), 它只用作标识, 没有任何其他意义.
特殊值 'static 表示生命周期为整个程序的运行阶段, 任何生命周期都等于或短于 'static.
函数, 类型可以带有生命周期参数, 它像泛型一样写在函数名后的尖括号 <> 里.
Rust里的所有值/对象都有生命周期, 只是大部分生命周期参数都会根据生命周期补全规则自动补充, 因此可以省略.
手写生命周期参数的主要目的是让一个变量/参数/成员可以与其相关的主体共享同一个生命周期参数,
从而让编译器放宽限制.
需要注意的是, 生命周期参数总是意味着"临时借用"的语义(这是编译器持续追踪它的原因),
然而, 一些场景显然并不适用这种语义:
例如, 大多数结构体在语义上都不适合"借用"来自外部的变量, 而是应该自己"拥有"这些变量,
这种情况下, 最好用不需要生命周期参数的方式重构它.
// 函数
fn select<'a>(arg1: &'a i32, arg2: &'a i32) -> &'a i32 {
if *arg1 > *arg2 { arg1 } else { arg2 }
}
// 类型
struct Test<'a> {
member: &'a str
}
impl<'t> Test<'t> {
fn test<'a>(&self, s: &'a str) {}
}
借用指针(函数的引用参数)都会带有生命周期参数, 用于局部变量时, 生命周期参数可以省略.
Rust会根据以下规则补全生命周期:
  • 带有生命周期参数的输入参数将对应不同的生命周期参数.
  • 如果只有一个输入参数有生命周期参数, 但其中有 &self&mut self, 那么返回值的生命周期会被指定为此参数.
  • 不满足以上条件时, 不能自动补全返回值的生命周期参数.
Rust的panic代表线程级别的 致命错误, 一般的错误处理应该由Option和Result完成.
自定义错误要求实现Display和Error特型.
出于调试目的, 也可以实现Debug特型, 这是可选的.
struct MyError {
details: String
}
impl MyError {
fn new(msg: &str) -> Self {
Self { details: msg.to_string() }
}
}
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.details)
}
}
impl Error for MyError {
fn description(&self) -> &str {
&self.details
}
}
// Debug是可选的, 用于提供人类可读的错误描述
#[derive(Debug)]
enum GenError {
Io(io::Error),
Parse(num::ParseIntError),
}
type GenResult<T> = Result<T, GenError>;
通过From特型来实现"其他类型错误的转换", 省去编写手动转换每个错误的样本代码.
impl From<io::Error> for GenError {
fn from(err: io::Error) -> {
GenError::Io(err)
}
}
impl From<num::ParseIntError> for GenError {
fn from(err: num::ParseIntError) -> GenError {
GenError::Parse(err)
}
}
该设计的缺陷在于, 错误处理方可能不知道向下转换时可能的错误类型, 或者无法访问可能的错误类型.
// 所有标准库里的错误类型都可以隐式转换为Box<std::error::Error>, 利用这一点, 可以将它作为通用错误类型使用
type GenError = Box<dyn std::error::Error>;
// 一种使用GenError的新型Result
type GenResult<T> = Result<T, GenError>;
// 当遇到GenError时, 处理程序可以通过downcast_ref转换为具体错误:
if let Some(err) = gen_err.downcast_ref::<MyError>() {
// ...
}
? 操作符是一个语法糖, 写在产生Result的表达式后面.
在Result是Ok时, 取消装箱;
在Result是Err时, 将Err作为当前函数的返回值.
? 操作符只能在返回类型为Result的函数里使用.
let weather = get_weather(hometown)?;
// 相当于以下代码
let weather = match get_weather(hometown) {
Ok(success_value) => success_value,
Err(err) => return Err(err)
};
? 操作符之前, 一种处理错误的风格是使用标准库里的 try! 宏, ? 操作符取代了它的作用.
已被弃用的错误处理库, 但仍被大量项目依赖.
该库提供了一个 failure::Fail, 它之后被标准库错误(std::error::Error)取代.
围绕标准库错误建立的failure继任者.
围绕标准库错误建立的宏.
  • ok_or()Option<T> 转换为 Result<T, E>
  • .is_ok(), .is_err() 用于判定Result是Ok还是Err
  • .unwrap() 取消装箱, 如果Result是 Ok<T>, 返回 T, 如果Result是Err, 会panic.
  • .expect(message) unwrap的自定义错误信息版本.
  • .unwrap_or(fallback) 取消装箱, 如果Result是 Ok<T>, 返回 T, 如果Result是Err, 则用fallback作为 T 返回.
  • .unwrap_or_else(fallback_fn) unwrap_or的函数版本, 会将err作为参数传给函数然后生成fallback.
  • .ok() 返回 Option<T>: 如果Result是 Ok<T>, 就返回 Some<T>; 如果Result是Err, 就返回 None
  • .err() 返回 Option<E>: .ok() 的Err版本.
  • .map_err(fn) 转换 Err<E>, 在将错误类型标准化的时候很有用.
    map_err 将错误转换为String是一种反模式:
    • String只是字符串, 很容易将错误信息与正常变量混淆.
    • String是从具体的错误有损转换得来, 错误会更难处理.
Result的大部分方法都会发生所有权转移, 使用借用可以避免那些方法销毁掉Result.
  • .as_ref()Result<T, E> 转换为 Result<&T, &E>
  • .as_mut()Result<T, E> 转换为 Result<&mut T, &mut E>