Document not found (404)
-This URL is invalid, sorry. Please use the navigation bar or search to continue.
- - -diff --git a/tools/rust-course/.DS_Store b/tools/rust-course/.DS_Store deleted file mode 100644 index 9b3f48c6..00000000 Binary files a/tools/rust-course/.DS_Store and /dev/null differ diff --git a/tools/rust-course/.nojekyll b/tools/rust-course/.nojekyll deleted file mode 100644 index f1731109..00000000 --- a/tools/rust-course/.nojekyll +++ /dev/null @@ -1 +0,0 @@ -This file makes sure that Github Pages doesn't process mdBook's output. diff --git a/tools/rust-course/404.html b/tools/rust-course/404.html deleted file mode 100644 index 82652385..00000000 --- a/tools/rust-course/404.html +++ /dev/null @@ -1,205 +0,0 @@ - - -
- - -This URL is invalid, sorry. Please use the navigation bar or search to continue.
- - -Rust 语言真的好:连续七年成为全世界最受欢迎的语言、没有 GC 也无需手动内存管理、性能比肩 C++/C 还能直接调用它们的代码、安全性极高 - 总有公司说使用 Rust 后以前的大部分 bug 都将自动消失、全世界最好的包管理工具 Cargo 等等。但...
-有人说: "Rust 太难了,学了也没用"。
-对于后面一句话我们持保留意见,如果以找工作为标准,那国内环境确实还不好,但如果你想成为更优秀的程序员或者是玩转开源,那 Rust 还真是不错的选择,具体原因见下一章。
-至于 Rust 难学,那正是本书要解决的问题,如果看完后,你觉得没有学会 Rust,可以找我们退款,哦抱歉,这是开源书,那就退 🌟 吧 :)
-如果看到这里,大家觉得这本书的介绍并没有吸引到你,不要立即放弃,强烈建议读一下进入 Rust 编程世界,那里会有不一样的精彩。
-对于学习编程而言,读一篇文章不如做几道练习题,此话虽然夸张,但是也不无道理。既然如此,即读书又做练习题,效果会不会更好?再加上练习题是书本的配套呢? :P
-截至目前,Rust 语言圣经已写了 170 余章,110 余万字,历经 1000 多个小时,每一个章节都是手动写就,没有任何机翻和质量上的妥协( 相信深入阅读过的读者都能体会到这一点 )。
-曾经有读者问过 "这么好的书为何要开源,而不是出版?",原因很简单:只有完全开源才能完美地呈现出我想要的教学效果。
-总之,Rust 要在国内真正发展起来,必须得有一些追逐梦想的人在做着不计付出的事情,而我希望自己能贡献一份微薄之力。
-但是要说完全无欲无求,那也是不可能的,看到项目多了一颗 🌟,那感觉...棒极了,因为它代表了读者的认可和称赞。
-你们用指尖绘制的星空,那里繁星点点,每一颗都在鼓励着怀揣着开源梦想的程序员披荆斩棘、不断前行,不夸张的说,没有你们,开源世界就没有星光,自然也就不会有今天的开源盛世。
-因此,我恳请大家,如果觉得书还可以,就在你的指尖星空绘制一颗新的 🌟,指引我们继续砥砺前行。这个人世间,因善意而美好。
-最后,能通过开源在茫茫人海中与大家相识,这感觉真好 :D
-非常感谢本教程的所有贡献者,正是有了你们,才有了现在的高质量 Rust 教程!
-🏆 贡献榜前三(根据难易度、贡献次数、活跃度综合评定):
-
-
- - Sunface 🥇 - - |
-
-
- - AllanDowney 🥈 - - |
-
-
- - Rustln - - |
-
在经过多个章节的深入学习后,Tokio 对我们来说不再是一座隐于云雾中的高山,它其实蛮简单好用的,甚至还有一丝丝的可爱!?
-但从现在开始,如果想要进一步的深入 Tokio ,首先需要深入理解 async
的原理,其实我们在之前的章节已经深入学习过,这里结合 Tokio 再来回顾下。
先来回顾一下 async fn
异步函数 :
--#![allow(unused)] -fn main() { -use tokio::net::TcpStream; - -async fn my_async_fn() { - println!("hello from async"); - // 通过 .await 创建 socket 连接 - let _socket = TcpStream::connect("127.0.0.1:3000").await.unwrap(); - println!("async TCP operation complete"); - // 关闭socket -} -} -
接着对它进行调用获取一个返回值,再在返回值上调用 .await
:
-#[tokio::main] -async fn main() { - let what_is_this = my_async_fn(); - // 上面的调用不会产生任何效果 - - // ... 执行一些其它代码 - - - what_is_this.await; - // 直到 .await 后,文本才被打印,socket 连接也被创建和关闭 -} -
在上面代码中 my_async_fn
函数为何可以惰性执行( 直到 .await 调用时才执行)?秘密就在于 async fn
声明的函数返回一个 Future
。
Future
是一个实现了 std::future::Future
特征的值,该值包含了一系列异步计算过程,而这个过程直到 .await
调用时才会被执行。
std::future::Future
的定义如下所示:
--#![allow(unused)] -fn main() { -use std::pin::Pin; -use std::task::{Context, Poll}; - -pub trait Future { - type Output; - - fn poll(self: Pin<&mut Self>, cx: &mut Context) - -> Poll<Self::Output>; -} -} -
代码中有几个关键点:
- -和其它语言不同,Rust 中的 Future
不代表一个发生在后台的计算,而是 Future
就代表了计算本身,因此
-Future
的所有者有责任去推进该计算过程的执行,例如通过 Future::poll
函数。听上去好像还挺复杂?但是大家不必担心,因为这些都在 Tokio 中帮你自动完成了 :)
下面来一起实现个五脏俱全的 Future
,它将:1. 等待某个特定时间点的到来 2. 在标准输出打印文本 3. 生成一个字符串
-use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::{Duration, Instant}; - -struct Delay { - when: Instant, -} - -// 为我们的 Delay 类型实现 Future 特征 -impl Future for Delay { - type Output = &'static str; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) - -> Poll<&'static str> - { - if Instant::now() >= self.when { - // 时间到了,Future 可以结束 - println!("Hello world"); - // Future 执行结束并返回 "done" 字符串 - Poll::Ready("done") - } else { - // 目前先忽略下面这行代码 - cx.waker().wake_by_ref(); - Poll::Pending - } - } -} - -#[tokio::main] -async fn main() { - let when = Instant::now() + Duration::from_millis(10); - let future = Delay { when }; - - // 运行并等待 Future 的完成 - let out = future.await; - - // 判断 Future 返回的字符串是否是 "done" - assert_eq!(out, "done"); -} -
以上代码很清晰的解释了如何自定义一个 Future
,并指定它如何通过 poll
一步一步执行,直到最终完成返回 "done"
字符串。
大家有没有注意到,上面代码我们在 main
函数中初始化一个 Future
并使用 .await
对其进行调用执行,如果你是在 fn main
中这么做,是会报错的。
原因是 .await
只能用于 async fn
函数中,因此我们将 main
函数声明成 async fn main
同时使用 #[tokio::main]
进行了标注,此时 async fn main
生成的代码类似下面:
--#![allow(unused)] -fn main() { -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::{Duration, Instant}; - -enum MainFuture { - // 初始化,但永远不会被 poll - State0, - // 等待 `Delay` 运行,例如 `future.await` 代码行 - State1(Delay), - // Future 执行完成 - Terminated, -} - -impl Future for MainFuture { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) - -> Poll<()> - { - use MainFuture::*; - - loop { - match *self { - State0 => { - let when = Instant::now() + - Duration::from_millis(10); - let future = Delay { when }; - *self = State1(future); - } - State1(ref mut my_future) => { - match Pin::new(my_future).poll(cx) { - Poll::Ready(out) => { - assert_eq!(out, "done"); - *self = Terminated; - return Poll::Ready(()); - } - Poll::Pending => { - return Poll::Pending; - } - } - } - Terminated => { - panic!("future polled after completion") - } - } - } - } -} -} -
可以看出,编译器会将 Future
变成状态机, 其中 MainFuture
包含了 Future
可能处于的状态:从 State0
状态开始,当 poll
被调用时, Future
会尝试去尽可能的推进内部的状态,若它可以被完成时,就会返回 Poll::Ready
,其中还会包含最终的输出结果。
若 Future
无法被完成,例如它所等待的资源还没有准备好,此时就会返回 Poll::Pending
,该返回值会通知调用者: Future
会在稍后才能完成。
同时可以看到:当一个 Future
由其它 Future
组成时,调用外层 Future
的 poll
函数会同时调用一次内部 Future
的 poll
函数。
async fn
返回 Future
,而后者需要通过被不断的 poll
才能往前推进状态,同时该 Future
还能包含其它 Future
,那么问题来了谁来负责调用最外层 Future
的 poll
函数?
回一下之前的内容,为了运行一个异步函数,我们必须使用 tokio::spawn
或 通过 #[tokio::main]
标注的 async fn main
函数。它们有一个非常重要的作用:将最外层 Future
提交给 Tokio 的执行器。该执行器负责调用 poll
函数,然后推动 Future
的执行,最终直至完成。
为了更好理解相关的内容,我们一起来实现一个迷你版本的 Tokio,完整的代码见这里。
-先来看一段基础代码:
--use std::collections::VecDeque; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::{Duration, Instant}; -use futures::task; - -fn main() { - let mut mini_tokio = MiniTokio::new(); - - mini_tokio.spawn(async { - let when = Instant::now() + Duration::from_millis(10); - let future = Delay { when }; - - let out = future.await; - assert_eq!(out, "done"); - }); - - mini_tokio.run(); -} - -struct MiniTokio { - tasks: VecDeque<Task>, -} - -type Task = Pin<Box<dyn Future<Output = ()> + Send>>; - -impl MiniTokio { - fn new() -> MiniTokio { - MiniTokio { - tasks: VecDeque::new(), - } - } - - /// 生成一个 Future并放入 mini-tokio 实例的任务队列中 - fn spawn<F>(&mut self, future: F) - where - F: Future<Output = ()> + Send + 'static, - { - self.tasks.push_back(Box::pin(future)); - } - - fn run(&mut self) { - let waker = task::noop_waker(); - let mut cx = Context::from_waker(&waker); - - while let Some(mut task) = self.tasks.pop_front() { - if task.as_mut().poll(&mut cx).is_pending() { - self.tasks.push_back(task); - } - } - } -} -
以上代码运行了一个 async
语句块 mini_tokio.spawn(async {...})
, 还创建了一个 Delay
实例用于等待所需的时间。看上去相当不错,但这个实现有一个 重大缺陷:我们的执行器永远也不会休眠。执行器会持续的循环遍历所有的 Future
,然后不停的 poll
它们,但是事实上,大多数 poll
都是没有用的,因为此时 Future
并没有准备好,因此会继续返回 Poll::Pending
,最终这个循环遍历会让你的 CPU 疲于奔命,真打工人!
鉴于此,我们的 mini-tokio 只应该在 Future
准备好可以进一步运行后,才去 poll
它,例如该 Future
之前阻塞等待的资源已经准备好并可以被使用了,就可以对其进行 poll
。再比如,如果一个 Future
任务在阻塞等待从 TCP socket 中读取数据,那我们只想在 socket
中有数据可以读取后才去 poll
它,而不是没事就 poll
着玩。
回到上面的代码中,mini-tokio 只应该当任务的延迟时间到了后,才去 poll
它。 为了实现这个功能,我们需要 通知 -> 运行
机制:当任务可以进一步被推进运行时,它会主动通知执行器,然后执行器再来 poll
。
一切的答案都在 Waker
中,资源可以用它来通知正在等待的任务:该资源已经准备好,可以继续运行了。
再来看下 Future::poll
的定义:
--#![allow(unused)] -fn main() { -fn poll(self: Pin<&mut Self>, cx: &mut Context) - -> Poll<Self::Output>; -} -
Context
参数中包含有 waker()
方法。该方法返回一个绑定到当前任务上的 Waker
,然后 Waker
上定义了一个 wake()
方法,用于通知执行器相关的任务可以继续执行。
准确来说,当 Future
阻塞等待的资源已经准备好时(例如 socket 中有了可读取的数据),该资源可以调用 wake()
方法,来通知执行器可以继续调用该 Future
的 poll
函数来推进任务的执行。
现在,为 Delay
添加下 Waker
支持:
--#![allow(unused)] -fn main() { -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::{Duration, Instant}; -use std::thread; - -struct Delay { - when: Instant, -} - -impl Future for Delay { - type Output = &'static str; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) - -> Poll<&'static str> - { - if Instant::now() >= self.when { - println!("Hello world"); - Poll::Ready("done") - } else { - // 为当前任务克隆一个 waker 的句柄 - let waker = cx.waker().clone(); - let when = self.when; - - // 生成一个计时器线程 - thread::spawn(move || { - let now = Instant::now(); - - if now < when { - thread::sleep(when - now); - } - - waker.wake(); - }); - - Poll::Pending - } - } -} -} -
此时,计时器用来模拟一个阻塞等待的资源,一旦计时结束(该资源已经准备好),资源会通过 waker.wake()
调用通知执行器我们的任务再次被调度执行了。
当然,现在的实现还较为粗糙,等会我们会来进一步优化,在此之前,先来看看如何监听这个 wake
通知。
--当 Future 会返回
-Poll::Pending
时,一定要确保wake
能被正常调用,否则会导致任务永远被挂起,再也不会被执行器poll
。忘记在返回
-Poll::Pending
时调用wake
是很多难以发现 bug 的潜在源头!
再回忆下最早实现的 Delay
代码:
--#![allow(unused)] -fn main() { -impl Future for Delay { - type Output = &'static str; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) - -> Poll<&'static str> - { - if Instant::now() >= self.when { - // 时间到了,Future 可以结束 - println!("Hello world"); - // Future 执行结束并返回 "done" 字符串 - Poll::Ready("done") - } else { - // 目前先忽略下面这行代码 - cx.waker().wake_by_ref(); - Poll::Pending - } - } -} -} -
在返回 Poll::Pending
之前,先调用了 cx.waker().wake_by_ref()
,由于此时我们还没有模拟计时资源,因此这里直接调用了 wake
进行通知,这样做会导致当前的 Future
被立即再次调度执行。
由此可见,这种通知的控制权是在你手里的,甚至可以像上面代码这样,还没准备好资源,就直接进行 wake
通知,但是总归意义不大,而且浪费了 CPU,因为这种 执行 -> 立即通知再调度 -> 执行
的方式会造成一个非常繁忙的循环。
下面,让我们更新 mint-tokio 服务,让它能接收 wake 通知:当 waker.wake()
被调用后,相关联的任务会被放入执行器的队列中,然后等待执行器的调用执行。
为了实现这一点,我们将使用消息通道来排队存储这些被唤醒并等待调度的任务。有一点需要注意,从消息通道接收消息的线程(执行器所在的线程)和发送消息的线程(唤醒任务时所在的线程)可能是不同的,因此消息( Waker
)必须要实现 Send
和 Sync
,才能跨线程使用。
--关于
-Send
和Sync
的具体讲解见这里
基于以上理由,我们选择使用来自于 crossbeam
的消息通道,因为标准库中的消息通道不是 Sync
的。在 Cargo.toml
中添加以下依赖:
crossbeam = "0.8"
-
-再来更新下 MiniTokio
结构体:
--#![allow(unused)] -fn main() { -use crossbeam::channel; -use std::sync::Arc; - -struct MiniTokio { - scheduled: channel::Receiver<Arc<Task>>, - sender: channel::Sender<Arc<Task>>, -} - -struct Task { - // 先空着,后面会填充代码 -} -} -
Waker
实现了 Sync
特征,同时还可以被克隆,当 wake
被调用时,任务就会被调度执行。
为了实现上述的目的,我们引入了消息通道,当 waker.wake()
函数被调用时,任务会被发送到该消息通道中:
--#![allow(unused)] -fn main() { -use std::sync::{Arc, Mutex}; - -struct Task { - // `Mutex` 是为了让 `Task` 实现 `Sync` 特征,它能保证同一时间只有一个线程可以访问 `Future`。 - // 事实上 `Mutex` 并没有在 Tokio 中被使用,这里我们只是为了简化: Tokio 的真实代码实在太长了 :D - future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>, - executor: channel::Sender<Arc<Task>>, -} - -impl Task { - fn schedule(self: &Arc<Self>) { - self.executor.send(self.clone()); - } -} -} -
接下来,我们需要让 std::task::Waker
能准确的找到所需的调度函数 关联起来,对此标准库中提供了一个底层的 API std::task::RawWakerVTable
可以用于手动的访问 vtable
,这种实现提供了最大的灵活性,但是需要大量 unsafe
的代码。
因此我们选择更加高级的实现:由 futures
包提供的 ArcWake
特征,只要简单实现该特征,就可以将我们的 Task
转变成一个 waker
。在 Cargo.toml
中添加以下包:
futures = "0.3"
-
-然后为我们的任务 Task
实现 ArcWake
:
--#![allow(unused)] -fn main() { -use futures::task::{self, ArcWake}; -use std::sync::Arc; -impl ArcWake for Task { - fn wake_by_ref(arc_self: &Arc<Self>) { - arc_self.schedule(); - } -} -} -
当之前的计时器线程调用 waker.wake()
时,所在的任务会被推入到消息通道中。因此接下来,我们需要实现接收端的功能,然后 MiniTokio::run()
函数中执行该任务:
--#![allow(unused)] -fn main() { -impl MiniTokio { - // 从消息通道中接收任务,然后通过 poll 来执行 - fn run(&self) { - while let Ok(task) = self.scheduled.recv() { - task.poll(); - } - } - - /// 初始化一个新的 mini-tokio 实例 - fn new() -> MiniTokio { - let (sender, scheduled) = channel::unbounded(); - - MiniTokio { scheduled, sender } - } - - - /// 在下面函数中,通过参数传入的 future 被 `Task` 包裹起来,然后会被推入到调度队列中,当 `run` 被调用时,该 future 将被执行 - fn spawn<F>(&self, future: F) - where - F: Future<Output = ()> + Send + 'static, - { - Task::spawn(future, &self.sender); - } -} - -impl Task { - fn poll(self: Arc<Self>) { - // 基于 Task 实例创建一个 waker, 它使用了之前的 `ArcWake` - let waker = task::waker(self.clone()); - let mut cx = Context::from_waker(&waker); - - // 没有其他线程在竞争锁时,我们将获取到目标 future - let mut future = self.future.try_lock().unwrap(); - - // 对 future 进行 poll - let _ = future.as_mut().poll(&mut cx); - } - - // 使用给定的 future 来生成新的任务 - // - // 新的任务会被推到 `sender` 中,接着该消息通道的接收端就可以获取该任务,然后执行 - fn spawn<F>(future: F, sender: &channel::Sender<Arc<Task>>) - where - F: Future<Output = ()> + Send + 'static, - { - let task = Arc::new(Task { - future: Mutex::new(Box::pin(future)), - executor: sender.clone(), - }); - - let _ = sender.send(task); - } - -} -} -
首先,我们实现了 MiniTokio::run()
函数,它会持续从消息通道中接收被唤醒的任务,然后通过 poll
来推动其继续执行。
其次,MiniTokio::new()
和 MiniTokio::spawn()
使用了消息通道而不是一个 VecDeque
。当新任务生成后,这些任务中会携带上消息通道的发送端,当任务中的资源准备就绪时,会使用该发送端将该任务放入消息通道的队列中,等待执行器 poll
。
Task::poll()
函数使用 futures
包提供的 ArcWake
创建了一个 waker
,后者可以用来创建 task::Context
,最终该 Context
会被传给执行器调用的 poll
函数。
--注意,Task::poll 和执行器调用的 poll 是完全不同的,大家别搞混了
-
至此,我们的程序已经差不多完成,还剩几个遗留问题需要解决下。
-之前实现 Delay Future
时,我们提到有几个问题需要解决。Rust 的异步模型允许一个 Future 在执行过程中可以跨任务迁移:
-use futures::future::poll_fn; -use std::future::Future; -use std::pin::Pin; - -#[tokio::main] -async fn main() { - let when = Instant::now() + Duration::from_millis(10); - let mut delay = Some(Delay { when }); - - poll_fn(move |cx| { - let mut delay = delay.take().unwrap(); - let res = Pin::new(&mut delay).poll(cx); - assert!(res.is_pending()); - tokio::spawn(async move { - delay.await; - }); - - Poll::Ready(()) - }).await; -} -
首先,poll_fn
函数使用闭包创建了一个 Future
,其次,上面代码还创建一个 Delay
实例,然后在闭包中,对其进行了一次 poll
,接着再将该 Delay
实例发送到一个新的任务,在此任务中使用 .await
进行了执行。
在例子中,Delay:poll
被调用了不止一次,且使用了不同的 Waker
实例,在这种场景下,你必须确保调用最近一次 poll
函数中的 Waker
参数中的wake
方法。也就是调用最内层 poll
函数参数( Waker
)上的 wake
方法。
当实现一个 Future
时,很关键的一点就是要假设每次 poll
调用都会应用到一个不同的 Waker
实例上。因此 poll
函数必须要使用一个新的 waker
去更新替代之前的 waker
。
我们之前的 Delay
实现中,会在每一次 poll
调用时都生成一个新的线程。这么做问题不大,但是当 poll
调用较多时会出现明显的性能问题!一个解决方法就是记录你是否已经生成了一个线程,然后只有在没有生成时才去创建一个新的线程。但是一旦这么做,就必须确保线程的 Waker
在后续 poll
调用中被正确更新,否则你无法唤醒最近的 Waker
!
这一段大家可能会看得云里雾里的,没办法,原文就饶来绕去,好在终于可以看代码了。。我们可以通过代码来解决疑惑:
---#![allow(unused)] -fn main() { -use std::future::Future; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use std::task::{Context, Poll, Waker}; -use std::thread; -use std::time::{Duration, Instant}; - -struct Delay { - when: Instant, - // 用于说明是否已经生成一个线程 - // Some 代表已经生成, None 代表还没有 - waker: Option<Arc<Mutex<Waker>>>, -} - -impl Future for Delay { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { - // 若这是 Future 第一次被调用,那么需要先生成一个计时器线程。 - // 若不是第一次调用(该线程已在运行),那要确保已存储的 `Waker` 跟当前任务的 `waker` 匹配 - if let Some(waker) = &self.waker { - let mut waker = waker.lock().unwrap(); - - // 检查之前存储的 `waker` 是否跟当前任务的 `waker` 相匹配. - // 这是必要的,原因是 `Delay Future` 的实例可能会在两次 `poll` 之间被转移到另一个任务中,然后 - // 存储的 waker 被该任务进行了更新。 - // 这种情况一旦发生,`Context` 包含的 `waker` 将不同于存储的 `waker`。 - // 因此我们必须对存储的 `waker` 进行更新 - if !waker.will_wake(cx.waker()) { - *waker = cx.waker().clone(); - } - } else { - let when = self.when; - let waker = Arc::new(Mutex::new(cx.waker().clone())); - self.waker = Some(waker.clone()); - - // 第一次调用 `poll`,生成计时器线程 - thread::spawn(move || { - let now = Instant::now(); - - if now < when { - thread::sleep(when - now); - } - - // 计时结束,通过调用 `waker` 来通知执行器 - let waker = waker.lock().unwrap(); - waker.wake_by_ref(); - }); - } - - // 一旦 waker 被存储且计时器线程已经开始,我们就需要检查 `delay` 是否已经完成 - // 若计时已完成,则当前 Future 就可以完成并返回 `Poll::Ready` - if Instant::now() >= self.when { - Poll::Ready(()) - } else { - // 计时尚未结束,Future 还未完成,因此返回 `Poll::Pending`. - // - // `Future` 特征要求当 `Pending` 被返回时,那我们要确保当资源准备好时,必须调用 `waker` 以通 - // 知执行器。 在我们的例子中,会通过生成的计时线程来保证 - // - // 如果忘记调用 waker, 那等待我们的将是深渊:该任务将被永远的挂起,无法再执行 - Poll::Pending - } - } -} -} -
这着实有些复杂(原文。。),但是简单来看就是:在每次 poll
调用时,都会检查 Context
中提供的 waker
和我们之前记录的 waker
是否匹配。若匹配,就什么都不用做,若不匹配,那之前存储的就必须进行更新。
我们之前证明了如何用手动编写的 waker
来实现 Delay Future
。 Waker
是 Rust 异步编程的基石,因此绝大多数时候,我们并不需要直接去使用它。例如,在 Delay
的例子中, 可以使用 tokio::sync::Notify
去实现。
该 Notify
提供了一个基础的任务通知机制,它会处理这些 waker
的细节,包括确保两次 waker
的匹配:
--#![allow(unused)] -fn main() { -use tokio::sync::Notify; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use std::thread; - -async fn delay(dur: Duration) { - let when = Instant::now() + dur; - let notify = Arc::new(Notify::new()); - let notify2 = notify.clone(); - - thread::spawn(move || { - let now = Instant::now(); - - if now < when { - thread::sleep(when - now); - } - - notify2.notify_one(); - }); - - - notify.notified().await; -} -} -
当使用 Notify
后,我们就可以轻松的实现如上的 delay
函数。
在看完这么长的文章后,我们来总结下,否则大家可能还会遗忘:
-async
是惰性的,直到执行器 poll
它们时,才会开始执行Waker
是 Future
被执行的关键,它可以链接起 Future
任务和执行器Poll::Pending
waker.wake
发出通知一些异步程序例如 tokio 指南 章节中的绝大多数例子,它们整个程序都是异步的,包括程序入口 main
函数:
-#[tokio::main] -async fn main() { - println!("Hello world"); -} -
在一些场景中,你可能只想在异步程序中运行一小部分同步代码,这种需求可以考虑下 spawn_blocking
。
但是在很多场景中,我们只想让程序的某一个部分成为异步的,也许是因为同步代码更好实现,又或许是同步代码可读性、兼容性都更好。例如一个 GUI
应用可能想要让 UI
相关的代码在主线程中,然后通过另一个线程使用 tokio
的运行时来处理一些异步任务。
因此本章节的目标很纯粹:如何在同步代码中使用一小部分异步代码。
-#[tokio::main]
的展开在 Rust 中, main
函数不能是异步的,有同学肯定不愿意了,我们在之前章节..不对,就在开头,你还用到了 async fn main
的声明方式,怎么就不能异步了呢?
其实,#[tokio::main]
该宏仅仅是提供语法糖,目的是让大家可以更简单、更一致的去写异步代码,它会将你写下的async fn main
函数替换为:
-fn main() { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - println!("Hello world"); - }) -} -
注意到上面的 block_on
方法了嘛?在我们自己的同步代码中,可以使用它开启一个 async/await
世界。
在下面,我们将一起构建一个同步的 mini-redis
,为了实现这一点,需要将 Runtime
对象存储起来,然后利用上面提到的 block_on
方法。
首先,创建一个文件 src/blocking_client.rs
,然后使用下面代码将异步的 Client
结构体包裹起来:
--#![allow(unused)] -fn main() { -use tokio::net::ToSocketAddrs; -use tokio::runtime::Runtime; - -pub use crate::client::Message; - -/// 建立到 redis 服务端的连接 -pub struct BlockingClient { - /// 之前实现的异步客户端 `Client` - inner: crate::client::Client, - - /// 一个 `current_thread` 模式的 `tokio` 运行时, - /// 使用阻塞的方式来执行异步客户端 `Client` 上的操作 - rt: Runtime, -} - -pub fn connect<T: ToSocketAddrs>(addr: T) -> crate::Result<BlockingClient> { - // 构建一个 tokio 运行时: Runtime - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - - // 使用运行时来调用异步的连接方法 - let inner = rt.block_on(crate::client::connect(addr))?; - - Ok(BlockingClient { inner, rt }) -} -} -
在这里,我们使用了一个构造器函数用于在同步代码中执行异步的方法:使用 Runtime
上的 block_on
方法来执行一个异步方法并返回结果。
有一个很重要的点,就是我们还使用了 current_thread
模式的运行时。这个可不常见,原因是异步程序往往要利用多线程的威力来实现更高的吞吐性能,相对应的模式就是 multi_thread
,该模式会生成多个运行在后台的线程,它们可以高效的实现多个任务的同时并行处理。
但是对于我们的使用场景来说,在同一时间点只需要做一件事,无需并行处理,多个线程并不能帮助到任何事情,因此 current_thread
此时成为了最佳的选择。
在构建 Runtime
的过程中还有一个 enable_all
方法调用,它可以开启 Tokio
运行时提供的 IO 和定时器服务。
--由于
-current_thread
运行时并不生成新的线程,只是运行在已有的主线程上,因此只有当block_on
被调用后,该运行时才能执行相应的操作。一旦block_on
返回,那运行时上所有生成的任务将再次冻结,直到block_on
的再次调用。如果这种模式不符合使用场景的需求,那大家还是需要用
-multi_thread
运行时来代替。事实上,在 tokio 之前的章节中,我们默认使用的就是multi_thread
模式。
--#![allow(unused)] -fn main() { -use bytes::Bytes; -use std::time::Duration; - -impl BlockingClient { - pub fn get(&mut self, key: &str) -> crate::Result<Option<Bytes>> { - self.rt.block_on(self.inner.get(key)) - } - - pub fn set(&mut self, key: &str, value: Bytes) -> crate::Result<()> { - self.rt.block_on(self.inner.set(key, value)) - } - - pub fn set_expires( - &mut self, - key: &str, - value: Bytes, - expiration: Duration, - ) -> crate::Result<()> { - self.rt.block_on(self.inner.set_expires(key, value, expiration)) - } - - pub fn publish(&mut self, channel: &str, message: Bytes) -> crate::Result<u64> { - self.rt.block_on(self.inner.publish(channel, message)) - } -} -} -
这代码看上去挺长,实际上很简单,通过 block_on
将异步形式的 Client
的方法变成同步调用的形式。例如 BlockingClient
的 get
方法实际上是对内部的异步 get
方法的同步调用。
与上面的平平无奇相比,下面的代码将更有趣,因为它将 Client
转变成一个 Subscriber
对象:
--#![allow(unused)] -fn main() { -/// 下面的客户端可以进入 pub/sub (发布/订阅) 模式 -/// -/// 一旦客户端订阅了某个消息通道,那就只能执行 pub/sub 相关的命令。 -/// 将`BlockingClient` 类型转换成 `BlockingSubscriber` 是为了防止非 `pub/sub` 方法被调用 -pub struct BlockingSubscriber { - /// 异步版本的 `Subscriber` - inner: crate::client::Subscriber, - - /// 一个 `current_thread` 模式的 `tokio` 运行时, - /// 使用阻塞的方式来执行异步客户端 `Client` 上的操作 - rt: Runtime, -} - -impl BlockingClient { - pub fn subscribe(self, channels: Vec<String>) -> crate::Result<BlockingSubscriber> { - let subscriber = self.rt.block_on(self.inner.subscribe(channels))?; - Ok(BlockingSubscriber { - inner: subscriber, - rt: self.rt, - }) - } -} - -impl BlockingSubscriber { - pub fn get_subscribed(&self) -> &[String] { - self.inner.get_subscribed() - } - - pub fn next_message(&mut self) -> crate::Result<Option<Message>> { - self.rt.block_on(self.inner.next_message()) - } - - pub fn subscribe(&mut self, channels: &[String]) -> crate::Result<()> { - self.rt.block_on(self.inner.subscribe(channels)) - } - - pub fn unsubscribe(&mut self, channels: &[String]) -> crate::Result<()> { - self.rt.block_on(self.inner.unsubscribe(channels)) - } -} -} -
由上可知,subscribe
方法会使用运行时将一个异步的 Client
转变成一个异步的 Subscriber
,此外,Subscriber
结构体有一个非异步的方法 get_subscribed
,对于这种方法,只需直接调用即可,而无需使用运行时。
上面介绍的是最简单的方法,但是,如果只有这一种, tokio 也不会如此大名鼎鼎。
-可以通过 Runtime
的 spawn
方法来创建一个基于该运行时的后台任务:
-use tokio::runtime::Builder; -use tokio::time::{sleep, Duration}; - -fn main() { - let runtime = Builder::new_multi_thread() - .worker_threads(1) - .enable_all() - .build() - .unwrap(); - - let mut handles = Vec::with_capacity(10); - for i in 0..10 { - handles.push(runtime.spawn(my_bg_task(i))); - } - - // 在后台任务运行的同时做一些耗费时间的事情 - std::thread::sleep(Duration::from_millis(750)); - println!("Finished time-consuming task."); - - // 等待这些后台任务的完成 - for handle in handles { - // `spawn` 方法返回一个 `JoinHandle`,它是一个 `Future`,因此可以通过 `block_on` 来等待它完成 - runtime.block_on(handle).unwrap(); - } -} - -async fn my_bg_task(i: u64) { - let millis = 1000 - 50 * i; - println!("Task {} sleeping for {} ms.", i, millis); - - sleep(Duration::from_millis(millis)).await; - - println!("Task {} stopping.", i); -} -
运行该程序,输出如下:
-Task 0 sleeping for 1000 ms.
-Task 1 sleeping for 950 ms.
-Task 2 sleeping for 900 ms.
-Task 3 sleeping for 850 ms.
-Task 4 sleeping for 800 ms.
-Task 5 sleeping for 750 ms.
-Task 6 sleeping for 700 ms.
-Task 7 sleeping for 650 ms.
-Task 8 sleeping for 600 ms.
-Task 9 sleeping for 550 ms.
-Task 9 stopping.
-Task 8 stopping.
-Task 7 stopping.
-Task 6 stopping.
-Finished time-consuming task.
-Task 5 stopping.
-Task 4 stopping.
-Task 3 stopping.
-Task 2 stopping.
-Task 1 stopping.
-Task 0 stopping.
-
-在此例中,我们生成了 10 个后台任务在运行时中运行,然后等待它们的完成。作为一个例子,想象一下在图形渲染应用( GUI )中,有时候需要通过网络访问远程服务来获取一些数据,那上面的这种模式就非常适合,因为这些网络访问比较耗时,而且不会影响图形的主体渲染,因此可以在主线程中渲染图形,然后使用其它线程来运行 Tokio 的运行时,并通过该运行时使用异步的方式完成网络访问,最后将这些网络访问的结果发送到 GUI 进行数据渲染,例如一个进度条。
-还有一点很重要,在本例子中只能使用 multi_thread
运行时。如果我们使用了 current_thread
,你会发现主线程的耗时任务会在后台任务开始之前就完成了。因为在 current_thread
模式下,生成的任务只会在 block_on
期间才执行。
在 multi_thread
模式下,我们并不需要通过 block_on
来触发任务的运行,这里仅仅是用来阻塞并等待最终的结果。而除了通过 block_on
等待结果外,你还可以:
tokio::sync::mpsc
,让异步任务将结果发送到主线程,然后主线程通过 .recv
方法等待这些结果Mutex
,这种方式非常适合实现 GUI 的进度条: GUI 在每个渲染帧读取该变量即可。在同步代码中使用异步的另一个方法就是生成一个运行时,然后使用消息传递的方式跟它进行交互。这个方法虽然更啰嗦一些,但是相对于之前的两种方法更加灵活:
---#![allow(unused)] -fn main() { -use tokio::runtime::Builder; -use tokio::sync::mpsc; - -pub struct Task { - name: String, - // 一些信息用于描述该任务 -} - -async fn handle_task(task: Task) { - println!("Got task {}", task.name); -} - -#[derive(Clone)] -pub struct TaskSpawner { - spawn: mpsc::Sender<Task>, -} - -impl TaskSpawner { - pub fn new() -> TaskSpawner { - // 创建一个消息通道用于通信 - let (send, mut recv) = mpsc::channel(16); - - let rt = Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - std::thread::spawn(move || { - rt.block_on(async move { - while let Some(task) = recv.recv().await { - tokio::spawn(handle_task(task)); - } - - // 一旦所有的发送端超出作用域被 drop 后,`.recv()` 方法会返回 None,同时 while 循环会退出,然后线程结束 - }); - }); - - TaskSpawner { - spawn: send, - } - } - - pub fn spawn_task(&self, task: Task) { - match self.spawn.blocking_send(task) { - Ok(()) => {}, - Err(_) => panic!("The shared runtime has shut down."), - } - } -} -} -
为何说这种方法比较灵活呢?以上面代码为例,它可以在很多方面进行配置。例如,可以使用信号量 Semaphore
来限制当前正在进行的任务数,或者你还可以使用一个消息通道将消息反向发送回任务生成器 spawner
。
抛开细节,抽象来看,这是不是很像一个 Actor ?
- - -迄今为止,你已经学了不少关于 Tokio 的并发编程的内容,是时候见识下真正的挑战了,接下来,我们一起来实现下客户端这块儿的功能。
-首先,将之前实现的 src/main.rs
文件中的服务器端代码放入到一个 bin 文件中,等下可以直接通过该文件来运行我们的服务器:
mkdir src/bin
-mv src/main.rs src/bin/server.rs
-
-接着创建一个新的 bin 文件,用于包含我们即将实现的客户端代码:
-touch src/bin/client.rs
-
-由于不再使用 main.rs
作为程序入口,我们需要使用以下命令来运行指定的 bin 文件:
--#![allow(unused)] -fn main() { -cargo run --bin server -} -
此时,服务器已经成功运行起来。 同样的,可以用 cargo run --bin client
这种方式运行即将实现的客户端。
万事俱备,只欠代码,一起来看看客户端该如何实现。
-如果想要同时运行两个 redis 命令,我们可能会为每一个命令生成一个任务,例如:
--use mini_redis::client; - -#[tokio::main] -async fn main() { - // 创建到服务器的连接 - let mut client = client::connect("127.0.0.1:6379").await.unwrap(); - - // 生成两个任务,一个用于获取 key, 一个用于设置 key - let t1 = tokio::spawn(async { - let res = client.get("hello").await; - }); - - let t2 = tokio::spawn(async { - client.set("foo", "bar".into()).await; - }); - - t1.await.unwrap(); - t2.await.unwrap(); -} -
这段代码不会编译,因为两个任务都需要去访问 client
,但是 client
并没有实现 Copy
特征,再加上我们并没有实现相应的共享代码,因此自然会报错。还有一个问题,方法 set
和 get
都使用了 client
的可变引用 &mut self
,由此还会造成同时借用两个可变引用的错误。
在上一节中,我们介绍了几个解决方法,但是它们大部分都不太适用于此时的情况,例如:
-std::sync::Mutex
无法被使用,这个问题在之前章节有详解介绍过,同步锁无法跨越 .await
调用时使用tokio::sync:Mutex
,答案是可以用,但是同时就只能运行一个请求。若客户端实现了 redis 的 pipelining, 那这个异步锁就会导致连接利用率不足这个不行,那个也不行,是不是没有办法解决了?还记得我们上一章节提到过几次的消息传递,但是一直没有看到它的庐山真面目吗?现在可以来看看了。
-之前章节我们提到可以创建一个专门的任务 C1
(消费者 Consumer) 和通过消息传递来管理共享的资源,这里的共享资源就是 client
。若任务 P1
(生产者 Producer) 想要发出 Redis 请求,首先需要发送信息给 C1
,然后 C1
会发出请求给服务器,在获取到结果后,再将结果返回给 P1
。
在这种模式下,只需要建立一条连接,然后由一个统一的任务来管理 client
和该连接,这样之前的 get
和 set
请求也将不存在资源共享的问题。
同时,P1
和 C1
进行通信的消息通道是有缓冲的,当大量的消息发送给 C1
时,首先会放入消息通道的缓冲区中,当 C1
处理完一条消息后,再从该缓冲区中取出下一条消息进行处理,这种方式跟消息队列( Message queue ) 非常类似,可以实现更高的吞吐。而且这种方式还有利于实现连接池,例如不止一个 P
和 C
时,多个 P
可以往消息通道中发送消息,同时多个 C
,其中每个 C
都维护一条连接,并从消息通道获取消息。
Tokio 提供了多种消息通道,可以满足不同场景的需求:
-mpsc
, 多生产者,单消费者模式oneshot
, 单生产者单消费,一次只能发送一条消息broadcast
,多生产者,多消费者,其中每一条发送的消息都可以被所有接收者收到,因此是广播watch
,单生产者,多消费者,只保存一条最新的消息,因此接收者只能看到最近的一条消息,例如,这种模式适用于配置文件变化的监听细心的同学可能会发现,这里还少了一种类型:多生产者、多消费者,且每一条消息只能被其中一个消费者接收,如果有这种需求,可以使用 async-channel
包。
以上这些消息通道都有一个共同点:适用于 async
编程,对于其它场景,你可以使用在多线程章节中提到过的 std::sync::mpsc
和 crossbeam::channel
, 这些通道在等待消息时会阻塞当前的线程,因此不适用于 async
编程。
在下面的代码中,我们将使用 mpsc
和 oneshot
, 本章节完整的代码见这里。
在大多数场景中使用消息传递时,都是多个发送者向一个任务发送消息,该任务在处理完后,需要将响应内容返回给相应的发送者。例如我们的例子中,任务需要将 GET
和 SET
命令处理的结果返回。首先,我们需要定一个 Command
枚举用于代表命令:
--#![allow(unused)] -fn main() { -use bytes::Bytes; - -#[derive(Debug)] -enum Command { - Get { - key: String, - }, - Set { - key: String, - val: Bytes, - } -} -} -
在 src/bin/client.rs
的 main
函数中,创建一个 mpsc
消息通道:
-use tokio::sync::mpsc; - -#[tokio::main] -async fn main() { - // 创建一个新通道,缓冲队列长度是 32 - let (tx, mut rx) = mpsc::channel(32); - - // ... 其它代码 -} -
一个任务可以通过此通道将命令发送给管理 redis 连接的任务,同时由于通道支持多个生产者,因此多个任务可以同时发送命令。创建该通道会返回一个发送和接收句柄,这两个句柄可以分别被使用,例如它们可以被移动到不同的任务中。
-通道的缓冲队列长度是 32,意味着如果消息发送的比接收的快,这些消息将被存储在缓冲队列中,一旦存满了 32 条消息,使用send(...).await
的发送者会进入睡眠,直到缓冲队列可以放入新的消息(被接收者消费了)。
-use tokio::sync::mpsc; - -#[tokio::main] -async fn main() { - let (tx, mut rx) = mpsc::channel(32); - let tx2 = tx.clone(); - - tokio::spawn(async move { - tx.send("sending from first handle").await; - }); - - tokio::spawn(async move { - tx2.send("sending from second handle").await; - }); - - while let Some(message) = rx.recv().await { - println!("GOT = {}", message); - } -} -
你可以使用 clone
方法克隆多个发送者,但是接收者无法被克隆,因为我们的通道是 mpsc
类型。
当所有的发送者都被 Drop
掉后(超出作用域或被 drop(...)
函数主动释放),就不再会有任何消息发送给该通道,此时 recv
方法将返回 None
,也意味着该通道已经被关闭。
在我们的例子中,接收者是在管理 redis 连接的任务中,当该任务发现所有发送者都关闭时,它知道它的使命可以完成了,因此它会关闭 redis 连接。
-下面,我们来一起创建一个管理任务,它会管理 redis 的连接,当然,首先需要创建一条到 redis 的连接:
---#![allow(unused)] -fn main() { -use mini_redis::client; -// 将消息通道接收者 rx 的所有权转移到管理任务中 -let manager = tokio::spawn(async move { - // Establish a connection to the server - // 建立到 redis 服务器的连接 - let mut client = client::connect("127.0.0.1:6379").await.unwrap(); - - // 开始接收消息 - while let Some(cmd) = rx.recv().await { - use Command::*; - - match cmd { - Get { key } => { - client.get(&key).await; - } - Set { key, val } => { - client.set(&key, val).await; - } - } - } -}); -} -
如上所示,当从消息通道接收到一个命令时,该管理任务会将此命令通过 redis 连接发送到服务器。
-现在,让两个任务发送命令到消息通道,而不是像最开始报错的那样,直接发送命令到各自的 redis 连接:
---#![allow(unused)] -fn main() { -// 由于有两个任务,因此我们需要两个发送者 -let tx2 = tx.clone(); - -// 生成两个任务,一个用于获取 key,一个用于设置 key -let t1 = tokio::spawn(async move { - let cmd = Command::Get { - key: "hello".to_string(), - }; - - tx.send(cmd).await.unwrap(); -}); - -let t2 = tokio::spawn(async move { - let cmd = Command::Set { - key: "foo".to_string(), - val: "bar".into(), - }; - - tx2.send(cmd).await.unwrap(); -}); -} -
在 main
函数的末尾,我们让 3 个任务,按照需要的顺序开始运行:
--#![allow(unused)] -fn main() { -t1.await.unwrap(); -t2.await.unwrap(); -manager.await.unwrap(); -} -
最后一步,就是让发出命令的任务从管理任务那里获取命令执行的结果。为了完成这个目标,我们将使用 oneshot
消息通道,因为它针对一发一收的使用类型做过特别优化,且特别适用于此时的场景:接收一条从管理任务发送的结果消息。
--#![allow(unused)] -fn main() { -use tokio::sync::oneshot; - -let (tx, rx) = oneshot::channel(); -} -
使用方式跟 mpsc
很像,但是它并没有缓存长度,因为只能发送一条,接收一条,还有一点不同:你无法对返回的两个句柄进行 clone
。
为了让管理任务将结果准确的返回到发送者手中,这个管道的发送端必须要随着命令一起发送, 然后发出命令的任务保留管道的发送端。一个比较好的实现就是将管道的发送端放入 Command
的数据结构中,同时使用一个别名来代表该发送端:
--#![allow(unused)] -fn main() { -use tokio::sync::oneshot; -use bytes::Bytes; - -#[derive(Debug)] -enum Command { - Get { - key: String, - resp: Responder<Option<Bytes>>, - }, - Set { - key: String, - val: Bytes, - resp: Responder<()>, - }, -} - - -/// 管理任务可以使用该发送端将命令执行的结果传回给发出命令的任务 -type Responder<T> = oneshot::Sender<mini_redis::Result<T>>; -} -
下面,更新发送命令的代码:
---#![allow(unused)] -fn main() { -let t1 = tokio::spawn(async move { - let (resp_tx, resp_rx) = oneshot::channel(); - let cmd = Command::Get { - key: "hello".to_string(), - resp: resp_tx, - }; - - // 发送 GET 请求 - tx.send(cmd).await.unwrap(); - - // 等待回复 - let res = resp_rx.await; - println!("GOT = {:?}", res); -}); - -let t2 = tokio::spawn(async move { - let (resp_tx, resp_rx) = oneshot::channel(); - let cmd = Command::Set { - key: "foo".to_string(), - val: "bar".into(), - resp: resp_tx, - }; - - // 发送 SET 请求 - tx2.send(cmd).await.unwrap(); - - // 等待回复 - let res = resp_rx.await; - println!("GOT = {:?}", res); -}); -} -
最后,更新管理任务:
---#![allow(unused)] -fn main() { -while let Some(cmd) = rx.recv().await { - match cmd { - Command::Get { key, resp } => { - let res = client.get(&key).await; - // 忽略错误 - let _ = resp.send(res); - } - Command::Set { key, val, resp } => { - let res = client.set(&key, val).await; - // 忽略错误 - let _ = resp.send(res); - } - } -} -} -
有一点值得注意,往 oneshot
中发送消息时,并没有使用 .await
,原因是该发送操作要么直接成功、要么失败,并不需要等待。
当 oneshot
的接受端被 drop
后,继续发送消息会直接返回 Err
错误,它表示接收者已经不感兴趣了。对于我们的场景,接收者不感兴趣是非常合理的操作,并不是一种错误,因此可以直接忽略。
本章的完整代码见这里。
-无论何时使用消息通道,我们都需要对缓存队列的长度进行限制,这样系统才能优雅的处理各种负载状况。如果不限制,假设接收端无法及时处理消息,那消息就会迅速堆积,最终可能会导致内存消耗殆尽,就算内存没有消耗完,也可能会导致整体性能的大幅下降。
-Tokio 在设计时就考虑了这种状况,例如 async
操作在 Tokio 中是惰性的:
--#![allow(unused)] -fn main() { -loop { - async_op(); -} -} -
如果上面代码中,async_op
不是惰性的,而是在每次循环时立即执行,那该循环会立即将一个 async_op
发送到缓冲队列中,然后开始执行下一个循环,因为无需等待任务执行完成,这种发送速度是非常恐怖的,一秒钟可能会有几十万、上百万的消息发送到消息队列中。在其它语言编程中,相信大家也或多或少遇到过这种情况。
然后在 Async Rust
和 Tokio 中,上面的代码 async_op
根本就不会运行,也就不会往消息队列中写入消息。原因是我们没有调用 .await
,就算使用了 .await
上面的代码也不会有问题,因为只有等当前循环的任务结束后,才会开始下一次循环。
--#![allow(unused)] -fn main() { -loop { - // 当前 `async_op` 完成后,才会开始下一次循环 - async_op().await; -} -} -
总之,在 Tokio 中我们必须要显式地引入并发和队列:
-tokio::spawn
select!
join!
mpsc::channel
当这么做时,我们需要小心的控制并发度来确保系统的安全。例如,当使用一个循环去接收 TCP 连接时,你要确保当前打开的 socket
数量在可控范围内,而不是毫无原则的接收连接。 再比如,当使用 mpsc::channel
时,要设置一个缓冲值。
挑选一个合适的限制值是 Tokio
编程中很重要的一部分,可以帮助我们的系统更加安全、可靠的运行。
现在,鉴于大家已经掌握了 Tokio 的基本 I/O 用法,我们可以开始实现 mini-redis
的帧 frame
。通过帧可以将字节流转换成帧组成的流。每个帧就是一个数据单元,例如客户端发送的一次请求就是一个帧。
--#![allow(unused)] -fn main() { -use bytes::Bytes; - -enum Frame { - Simple(String), - Error(String), - Integer(u64), - Bulk(Bytes), - Null, - Array(Vec<Frame>), -} -} -
可以看到帧除了数据之外,并不具备任何语义。命令解析和实现会在更高的层次进行(相比帧解析层)。我们再来通过 HTTP 的帧来帮大家加深下相关的理解:
---#![allow(unused)] -fn main() { -enum HttpFrame { - RequestHead { - method: Method, - uri: Uri, - version: Version, - headers: HeaderMap, - }, - ResponseHead { - status: StatusCode, - version: Version, - headers: HeaderMap, - }, - BodyChunk { - chunk: Bytes, - }, -} -} -
为了实现 mini-redis
的帧,我们需要一个 Connection
结构体,里面包含了一个 TcpStream
以及对帧进行读写的方法:
--#![allow(unused)] -fn main() { -use tokio::net::TcpStream; -use mini_redis::{Frame, Result}; - -struct Connection { - stream: TcpStream, - // ... 这里定义其它字段 -} - -impl Connection { - /// 从连接读取一个帧 - /// - /// 如果遇到EOF,则返回 None - pub async fn read_frame(&mut self) - -> Result<Option<Frame>> - { - // 具体实现 - } - - /// 将帧写入到连接中 - pub async fn write_frame(&mut self, frame: &Frame) - -> Result<()> - { - // 具体实现 - } -} -} -
关于 Redis 协议的说明,可以看看官方文档,Connection
代码的完整实现见这里.
read_frame
方法会等到一个完整的帧都读取完毕后才返回,与之相比,它底层调用的TcpStream::read
只会返回任意多的数据(填满传入的缓冲区 buffer ),它可能返回帧的一部分、一个帧、多个帧,总之这种读取行为是不确定的。
当 read_frame
的底层调用 TcpStream::read
读取到部分帧时,会将数据先缓冲起来,接着继续等待并读取数据。如果读到多个帧,那第一个帧会被返回,然后剩下的数据依然被缓冲起来,等待下一次 read_frame
被调用。
为了实现这种功能,我们需要为 Connection
增加一个读取缓冲区。数据首先从 socket
中读取到缓冲区中,接着这些数据会被解析为帧,当一个帧被解析后,该帧对应的数据会从缓冲区被移除。
这里使用 BytesMut
作为缓冲区类型,它是 Bytes
的可变版本。
--#![allow(unused)] -fn main() { -use bytes::BytesMut; -use tokio::net::TcpStream; - -pub struct Connection { - stream: TcpStream, - buffer: BytesMut, -} - -impl Connection { - pub fn new(stream: TcpStream) -> Connection { - Connection { - stream, - // 分配一个缓冲区,具有4kb的缓冲长度 - buffer: BytesMut::with_capacity(4096), - } - } -} -} -
接下来,实现 read_frame
方法:
--#![allow(unused)] -fn main() { -use tokio::io::AsyncReadExt; -use bytes::Buf; -use mini_redis::Result; - -pub async fn read_frame(&mut self) - -> Result<Option<Frame>> -{ - loop { - // 尝试从缓冲区的数据中解析出一个数据帧, - // 只有当数据足够被解析时,才返回对应的帧 - if let Some(frame) = self.parse_frame()? { - return Ok(Some(frame)); - } - - // 如果缓冲区中的数据还不足以被解析为一个数据帧, - // 那么我们需要从 socket 中读取更多的数据 - // - // 读取成功时,会返回读取到的字节数,0 代表着读到了数据流的末尾 - if 0 == self.stream.read_buf(&mut self.buffer).await? { - // 代码能执行到这里,说明了对端关闭了连接, - // 需要看看缓冲区是否还有数据,若没有数据,说明所有数据成功被处理, - // 若还有数据,说明对端在发送帧的过程中断开了连接,导致只发送了部分数据 - if self.buffer.is_empty() { - return Ok(None); - } else { - return Err("connection reset by peer".into()); - } - } - } -} -} -
read_frame
内部使用循环的方式读取数据,直到一个完整的帧被读取到时,才会返回。当然,当远程的对端关闭了连接后,也会返回。
Buf
特征在上面的 read_frame
方法中,我们使用了 read_buf
来读取 socket 中的数据,该方法的参数是来自 bytes
包的 BufMut
。
可以先来考虑下该如何使用 read()
和 Vec<u8>
来实现同样的功能 :
--#![allow(unused)] -fn main() { -use tokio::net::TcpStream; - -pub struct Connection { - stream: TcpStream, - buffer: Vec<u8>, - cursor: usize, -} - -impl Connection { - pub fn new(stream: TcpStream) -> Connection { - Connection { - stream, - // 4kb 大小的缓冲区 - buffer: vec![0; 4096], - cursor: 0, - } - } -} -} -
下面是相应的 read_frame
方法:
--#![allow(unused)] -fn main() { -use mini_redis::{Frame, Result}; - -pub async fn read_frame(&mut self) - -> Result<Option<Frame>> -{ - loop { - if let Some(frame) = self.parse_frame()? { - return Ok(Some(frame)); - } - - // 确保缓冲区长度足够 - if self.buffer.len() == self.cursor { - // 若不够,需要增加缓冲区长度 - self.buffer.resize(self.cursor * 2, 0); - } - - // 从游标位置开始将数据读入缓冲区 - let n = self.stream.read( - &mut self.buffer[self.cursor..]).await?; - - if 0 == n { - if self.cursor == 0 { - return Ok(None); - } else { - return Err("connection reset by peer".into()); - } - } else { - // 更新游标位置 - self.cursor += n; - } - } -} -} -
在这段代码中,我们使用了非常重要的技术:通过游标( cursor )跟踪已经读取的数据,并将下次读取的数据写入到游标之后的缓冲区中,只有这样才不会让新读取的数据将之前读取的数据覆盖掉。
-一旦缓冲区满了,还需要增加缓冲区的长度,这样才能继续写入数据。还有一点值得注意,在 parse_frame
方法的内部实现中,也需要通过游标来解析数据: self.buffer[..self.cursor]
,通过这种方式,我们可以准确获取到目前已经读取的全部数据。
在网络编程中,通过字节数组和游标的方式读取数据是非常普遍的,因此 bytes
包提供了一个 Buf
特征,如果一个类型可以被读取数据,那么该类型需要实现 Buf
特征。与之对应,当一个类型可以被写入数据时,它需要实现 BufMut
。
当 T: BufMut
( 特征约束,说明类型 T
实现了 BufMut
特征 ) 被传给 read_buf()
方法时,缓冲区 T
的内部游标会自动进行更新。正因为如此,在使用了 BufMut
版本的 read_frame
中,我们并不需要管理自己的游标。
除了游标之外,Vec<u8>
的使用也值得关注,该缓冲区在使用时必须要被初始化: vec![0; 4096]
,该初始化会创建一个 4096 字节长度的数组,然后将数组的每个元素都填充上 0 。当缓冲区长度不足时,新创建的缓冲区数组依然会使用 0 被重新填充一遍。 事实上,这种初始化过程会存在一定的性能开销。
与 Vec<u8>
相反, BytesMut
和 BufMut
就没有这个问题,它们无需被初始化,而且 BytesMut
还会阻止我们读取未初始化的内存。
在理解了该如何读取数据后, 再来看看该如何通过两个部分解析出一个帧:
---#![allow(unused)] -fn main() { -use mini_redis::{Frame, Result}; -use mini_redis::frame::Error::Incomplete; -use bytes::Buf; -use std::io::Cursor; - -fn parse_frame(&mut self) - -> Result<Option<Frame>> -{ - // 创建 `T: Buf` 类型 - let mut buf = Cursor::new(&self.buffer[..]); - - // 检查是否读取了足够解析出一个帧的数据 - match Frame::check(&mut buf) { - Ok(_) => { - // 获取组成该帧的字节数 - let len = buf.position() as usize; - - // 在解析开始之前,重置内部的游标位置 - buf.set_position(0); - - // 解析帧 - let frame = Frame::parse(&mut buf)?; - - // 解析完成,将缓冲区该帧的数据移除 - self.buffer.advance(len); - - // 返回解析出的帧 - Ok(Some(frame)) - } - // 缓冲区的数据不足以解析出一个完整的帧 - Err(Incomplete) => Ok(None), - // 遇到一个错误 - Err(e) => Err(e.into()), - } -} -} -
完整的 Frame::check
函数实现在这里,感兴趣的同学可以看看,在这里我们不会对它进行完整的介绍。
值得一提的是, Frame::check
使用了 Buf
的字节迭代风格的 API。例如,为了解析一个帧,首先需要检查它的第一个字节,该字节用于说明帧的类型。这种首字节检查是通过 Buf::get_u8
函数完成的,该函数会获取游标所在位置的字节,然后将游标位置向右移动一个字节。
关于帧操作的另一个 API 是 write_frame(frame)
函数,它会将一个完整的帧写入到 socket 中。 每一次写入,都会触发一次或数次系统调用,当程序中有大量的连接和写入时,系统调用的开销将变得非常高昂,具体可以看看 SyllaDB 团队写过的一篇性能调优文章。
为了降低系统调用的次数,我们需要使用一个写入缓冲区,当写入一个帧时,首先会写入该缓冲区,然后等缓冲区数据足够多时,再集中将其中的数据写入到 socket 中,这样就将多次系统调用优化减少到一次。
-还有,缓冲区也不总是能提升性能。 例如,考虑一个 bulk
帧(多个帧放在一起组成一个 bulk,通过批量发送提升效率),该帧的特点就是:由于由多个帧组合而成,因此帧体数据可能会很大。所以我们不能将其帧体数据写入到缓冲区中,因为数据较大时,先写入缓冲区再写入 socket 会有较大的性能开销(实际上缓冲区就是为了批量写入,既然 bulk 已经是批量了,因此不使用缓冲区也很正常)。
为了实现缓冲写,我们将使用 BufWriter
结构体。该结构体实现了 AsyncWrite
特征,当 write
方法被调用时,不会直接写入到 socket 中,而是先写入到缓冲区中。当缓冲区被填满时,其中的内容会自动刷到(写入到)内部的 socket 中,然后再将缓冲区清空。当然,其中还存在某些优化,通过这些优化可以绕过缓冲区直接访问 socket。
由于篇幅有限,我们不会实现完整的 write_frame
函数,想要看完整代码可以访问这里。
首先,更新下 Connection
的结构体:
--#![allow(unused)] -fn main() { -use tokio::io::BufWriter; -use tokio::net::TcpStream; -use bytes::BytesMut; - -pub struct Connection { - stream: BufWriter<TcpStream>, - buffer: BytesMut, -} - -impl Connection { - pub fn new(stream: TcpStream) -> Connection { - Connection { - stream: BufWriter::new(stream), - buffer: BytesMut::with_capacity(4096), - } - } -} -} -
接着来实现 write_frame
函数:
--#![allow(unused)] -fn main() { -use tokio::io::{self, AsyncWriteExt}; -use mini_redis::Frame; - -async fn write_frame(&mut self, frame: &Frame) - -> io::Result<()> -{ - match frame { - Frame::Simple(val) => { - self.stream.write_u8(b'+').await?; - self.stream.write_all(val.as_bytes()).await?; - self.stream.write_all(b"\r\n").await?; - } - Frame::Error(val) => { - self.stream.write_u8(b'-').await?; - self.stream.write_all(val.as_bytes()).await?; - self.stream.write_all(b"\r\n").await?; - } - Frame::Integer(val) => { - self.stream.write_u8(b':').await?; - self.write_decimal(*val).await?; - } - Frame::Null => { - self.stream.write_all(b"$-1\r\n").await?; - } - Frame::Bulk(val) => { - let len = val.len(); - - self.stream.write_u8(b'$').await?; - self.write_decimal(len as u64).await?; - self.stream.write_all(val).await?; - self.stream.write_all(b"\r\n").await?; - } - Frame::Array(_val) => unimplemented!(), - } - - self.stream.flush().await; - - Ok(()) -} -} -
这里使用的方法由 AsyncWriteExt
提供,它们在 TcpStream
中也有对应的函数。但是在没有缓冲区的情况下最好避免使用这种逐字节的写入方式!不然,每写入几个字节就会触发一次系统调用,写完整个数据帧可能需要几十次系统调用,可以说是丧心病狂!
write_u8
写入一个字节write_all
写入所有数据write_decimal
由 mini-redis 提供在函数结束前,我们还额外的调用了一次 self.stream.flush().await
,原因是缓冲区可能还存在数据,因此需要手动刷一次数据:flush
的调用会将缓冲区中剩余的数据立刻写入到 socket 中。
当然,当帧比较小的时候,每写一次帧就 flush 一次的模式性能开销会比较大,此时我们可以选择在 Connection
中实现 flush
函数,然后将等帧积累多个后,再一次性在 Connection
中进行 flush。当然,对于我们的例子来说,简洁性是非常重要的,因此选了将 flush
放入到 write_frame
中。
又到了喜闻乐见的初印象环节,这个环节决定了你心中的那 24 盏灯最终是全亮还是全灭。
-在本文中,我们将看看本专题的学习目标、tokio
该怎么引入以及如何实现一个 Hello Tokio
项目,最终亮灯还是灭灯的决定权留给各位看官。但我提前说好,如果你全灭了,但却找不到更好的,未来还是得回来真香 :P
通过 API 学项目无疑是无聊的,因此我们采用一个与众不同的方式:边学边练,在本专题的最后你将拥有一个 redis
客户端和服务端,当然不会实现一个完整版本的 redis
,只会提供基本的功能和部分常用的命令。
redis
的项目源码可以在这里访问,本项目是从官方地址 fork
而来,在未来会提供注释和文档汉化。
再次声明:该项目仅仅用于学习目的,因此它的文档注释非常全,但是它完全无法作为 redis
的替代品。
首先,我们假定你已经安装了 Rust 和相关的工具链,例如 cargo
。其中 Rust 版本的最低要求是 1.45.0
,建议使用最新版 1.58
:
sunfei@sunface $ rustc --version
-rustc 1.58.0 (02072b482 2022-01-11)
-
-接下来,安装 mini-redis
的服务器端,它可以用来测试我们后面将要实现的 redis
客户端:
$ cargo install mini-redis
-
---如果下载失败,也可以通过这个地址下载源码,然后在本地通过
-cargo run
运行。
下载成功后,启动服务端:
-$ mini-redis-server
-
-然后,再使用客户端测试下刚启动的服务端:
-$ mini-redis-cli set foo 1
-OK
-$ mini-redis-cli get foo
-"1"
-
-不得不说,还挺好用的,先自我陶醉下 :) 此时,万事俱备,只欠东风,接下来是时候亮"箭"了:实现我们的 Hello Tokio
项目。
与简单无比的 Hello World
有所不同(简单?还记得本书开头时,湖畔边的那个多国语言版本的你好,世界
嘛~~),Hello Tokio
它承载着"非常艰巨"的任务,那就是向刚启动的 redis
服务器写入一个 key=hello, value=world
,然后再读取出来,嗯,使用 mini-redis
客户端 :)
在详细讲解之前,我们先来看看完整的代码,让大家有一个直观的印象。首先,创建一个新的 Rust
项目:
$ cargo new my-redis
-$ cd my-redis
-
-然后在 Cargo.toml
中添加相关的依赖:
[dependencies]
-tokio = { version = "1", features = ["full"] }
-mini-redis = "0.4"
-
-接下来,使用以下代码替换 main.rs
中的内容:
-use mini_redis::{client, Result}; - -#[tokio::main] -async fn main() -> Result<()> { - // 建立与mini-redis服务器的连接 - let mut client = client::connect("127.0.0.1:6379").await?; - - // 设置 key: "hello" 和 值: "world" - client.set("hello", "world".into()).await?; - - // 获取"key=hello"的值 - let result = client.get("hello").await?; - - println!("从服务器端获取到结果={:?}", result); - - Ok(()) -} -
不知道你之前启动的 mini-redis-server
关闭没有,如果关了,记得重新启动下,否则我们的代码就是意大利空气炮。
最后,运行这个项目:
-$ cargo run
-从服务器端获取到结果=Some("world")
-
-Perfect, 代码成功运行,是时候来解释下其中蕴藏的至高奥秘了。
-代码篇幅虽然不长,但是还是有不少值得关注的地方,接下来我们一起来看看。
---#![allow(unused)] -fn main() { -let mut client = client::connect("127.0.0.1:6379").await?; -} -
client::connect
函数由mini-redis
包提供,它使用异步的方式跟指定的远程 IP
地址建立 TCP 长连接,一旦连接建立成功,那 client
的赋值初始化也将完成。
特别值得注意的是:虽然该连接是异步建立的,但是从代码本身来看,完全是同步的代码编写方式,唯一能说明异步的点就是 .await
。
大部分计算机程序都是按照代码编写的顺序来执行的:先执行第一行,然后第二行,以此类推(当然,还要考虑流程控制,例如循环)。当进行同步编程时,一旦程序遇到一个操作无法被立即完成,它就会进入阻塞状态,直到该操作完成为止。
-因此同步编程非常符合我们人类的思维习惯,是一个顺其自然的过程,被几乎每一个程序员所喜欢(本来想说所有,但我不敢打包票,毕竟总有特立独行之士)。例如,当建立 TCP 连接时,当前线程会被阻塞,直到等待该连接建立完成,然后才往下继续进行。
-而使用异步编程,无法立即完成的操作会被切到后台去等待,因此当前线程不会被阻塞,它会接着执行其它的操作。一旦之前的操作准备好可以继续执行后,它会通知执行器,然后执行器会调度它并从上次离开的点继续执行。但是大家想象下,如果没有使用 await
,而是按照这个异步的流程使用通知 -> 回调的方式实现,代码该多么的难写和难读!
好在 Rust 为我们提供了 async/await
的异步编程特性,让我们可以像写同步代码那样去写异步的代码,也让这个世界美好依旧。
一个函数可以通过async fn
的方式被标记为异步函数:
--#![allow(unused)] -fn main() { -use mini_redis::Result; -use mini_redis::client::Client; -use tokio::net::ToSocketAddrs; - -pub async fn connect<T: ToSocketAddrs>(addr: T) -> Result<Client> { - // ... -} -} -
在上例中,redis
的连接函数 connect
实现如上,它看上去很像是一个同步函数,但是 async fn
出卖了它。
-async fn
异步函数并不会直接返回值,而是返回一个 Future
,顾名思义,该 Future
会在未来某个时间点被执行,然后最终获取到真实的返回值 Result<Client>
。
--async/await 的原理就算大家不理解,也不妨碍使用
-tokio
写出能用的服务,但是如果想要更深入的用好,强烈建议认真读下本书的async/await
异步编程章节,你会对 Rust 的异步编程有一个全新且深刻的认识。
由于 async
会返回一个 Future
,因此我们还需要配合使用 .await
来让该 Future
运行起来,最终获得返回值:
-async fn say_to_world() -> String { - String::from("world") -} - -#[tokio::main] -async fn main() { - // 此处的函数调用是惰性的,并不会执行 `say_to_world()` 函数体中的代码 - let op = say_to_world(); - - // 首先打印出 "hello" - println!("hello"); - - // 使用 `.await` 让 `say_to_world` 开始运行起来 - println!("{}", op.await); -} -
上面代码输出如下:
-hello
-world
-
-而大家可能很好奇 async fn
到底返回什么吧?它实际上返回的是一个实现了 Future
特征的匿名类型: impl Future<Output = String>
。
在代码中,使用了一个与众不同的 main
函数 : async fn main
,而且是用 #[tokio::main]
属性进行了标记。异步 main
函数有以下意义:
.await
只能在 async
函数中使用,如果是以前的 fn main
,那它内部是无法直接使用 async
函数的!这个会极大的限制了我们的使用场景因此 #[tokio::main]
宏在将 async fn main
隐式的转换为 fn main
的同时还对整个异步运行时进行了初始化。例如以下代码:
-#[tokio::main] -async fn main() { - println!("hello"); -} -
将被转换成:
--fn main() { - let mut rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - println!("hello"); - }) -} -
最终,Rust 编译器就愉快地执行这段代码了。
-在引入 tokio
包时,我们在 Cargo.toml
文件中添加了这么一行:
tokio = { version = "1", features = ["full"] }
-
-里面有个 features = ["full"]
可能大家会比较迷惑,当然,关于它的具体解释在本书的 Cargo 详解专题 有介绍,这里就简单进行说明。
Tokio
有很多功能和特性,例如 TCP
,UDP
,Unix sockets
,同步工具,多调度类型等等,不是每个应用都需要所有的这些特性。为了优化编译时间和最终生成可执行文件大小、内存占用大小,应用可以对这些特性进行可选引入。
而这里为了演示的方便,我们使用 full
,表示直接引入所有的特性。
大家对 tokio
的初印象如何?可否 24 灯全亮通过?
总之,tokio
做的事情其实是细雨润无声的,在大多数时候,我们并不能感觉到它的存在,但是它确实是异步编程中最重要的一环(或者之一),深入了解它对我们的未来之路会有莫大的帮助。
接下来,正式开始 tokio
的学习之旅。
如果你的服务是一个小说阅读网站,那大概率用不到优雅关闭的,简单粗暴的关闭服务器,然后用户再次请求时获取一个错误就是了。但如果是一个 web 服务或数据库服务呢?当前的连接很可能在做着重要的事情,一旦关闭会导致数据的丢失甚至错误,此时,我们就需要优雅的关闭(graceful shutdown)了。
-要让一个异步应用优雅的关闭往往需要做到 3 点:
-在本文的下面部分,我们一起来看看该如何做到这三点。如果想要进一步了解在真实项目中该如何使用,大家可以看看 mini-redis 的完整代码实现,特别是 src/server.rs
和 src/shutdown.rs
。
一般来说,何时关闭是取决于应用自身的,但是一个常用的关闭准则就是当应用收到来自于操作系统的关闭信号时。例如通过 ctrl + c
来关闭正在运行的命令行程序。
为了检测来自操作系统的关闭信号,Tokio
提供了一个 tokio::signal::ctrl_c
函数,它将一直睡眠直到收到对应的信号:
-use tokio::signal; - -#[tokio::main] -async fn main() { - // ... spawn application as separate task ... - // 在一个单独的任务中处理应用逻辑 - - match signal::ctrl_c().await { - Ok(()) => {}, - Err(err) => { - eprintln!("Unable to listen for shutdown signal: {}", err); - }, - } - - // 发送关闭信号给应用所在的任务,然后等待 -} -
大家看到这个标题,不知道会想到用什么技术来解决问题,反正我首先想到的是,真的很像广播哎。。
-事实上也是如此,最常见的通知程序各个部分关闭的方式就是使用一个广播消息通道。关于如何实现,其实也不复杂:应用中的每个任务都持有一个广播消息通道的接收端,当消息被广播到该通道时,每个任务都可以收到该消息,并关闭自己:
---#![allow(unused)] -fn main() { -let next_frame = tokio::select! { - res = self.connection.read_frame() => res?, - _ = self.shutdown.recv() => { - // 当收到关闭信号后,直接从 `select!` 返回,此时 `select!` 中的另一个分支会自动释放,其中的任务也会结束 - return Ok(()); - } -}; -} -
在 mini-redis
中,当收到关闭消息时,任务会立即结束,但在实际项目中,这种方式可能会过于理想,例如当我们向文件或数据库写入数据时,立刻终止任务可能会导致一些无法预料的错误,因此,在结束前做一些收尾工作会是非常好的选择。
除此之外,还有两点值得注意:
-watch channel
实现同样的效果,与之前的方式相比,这两种方法并没有太大的区别在之前章节,我们讲到过一个 mpsc
消息通道有一个重要特性:当所有发送端都 drop
时,消息通道会自动关闭,此时继续接收消息就会报错。
大家发现没?这个特性特别适合优雅关闭的场景:主线程持有消息通道的接收端,然后每个代码部分拿走一个发送端,当该部分结束时,就 drop
掉发送端,因此所有发送端被 drop
也就意味着所有的部分都已关闭,此时主线程的接收端就会收到错误,进而结束。
-use tokio::sync::mpsc::{channel, Sender}; -use tokio::time::{sleep, Duration}; - -#[tokio::main] -async fn main() { - let (send, mut recv) = channel(1); - - for i in 0..10 { - tokio::spawn(some_operation(i, send.clone())); - } - - // 等待各个任务的完成 - // - // 我们需要 drop 自己的发送端,因为等下的 `recv()` 调用会阻塞, 如果不 `drop` ,那发送端就无法被全部关闭 - // `recv` 也将永远无法结束,这将陷入一个类似死锁的困境 - drop(send); - - // 当所有发送端都超出作用域被 `drop` 时 (当前的发送端并不是因为超出作用域被 `drop` 而是手动 `drop` 的) - // `recv` 调用会返回一个错误 - let _ = recv.recv().await; -} - -async fn some_operation(i: u64, _sender: Sender<()>) { - sleep(Duration::from_millis(100 * i)).await; - println!("Task {} shutting down.", i); - - // 发送端超出作用域,然后被 `drop` -} -
关于忘记 drop
本身持有的发送端进而导致 bug 的问题,大家可以看看这篇文章。
在入门实战,你可以说众览半山不咋小,但是能坚持到这里,甚至当完成后,就真的是一览众山小,余敌皆鱼虾了。
-在进阶实战中,我们要来真的了,之前的简单命令行程序,是真的简单,但是这次的简单 redis 是真的不简单,在这里你将被迫使用十八般武艺,特别的,我们还将学会 Rust 异步镇山之宝 tokio
包的使用。
-- - -本章在内容上大量借鉴和翻译了 tokio 官方文档Tokio Tutorial, 但是重新组织了内容形式并融入了很多自己的见解和感悟,给大家提供更好的可读性和知识扩展性
-
本章节中我们将深入学习 Tokio 中的 I/O 操作,了解它的原理以及该如何使用。
-Tokio 中的 I/O 操作和 std
在使用方式上几无区别,最大的区别就是前者是异步的,例如 Tokio 的读写特征分别是 AsyncRead
和 AsyncWrite
:
TcpStream
,File
,Stdout
Vec<u8>
、&[u8]
,这样就可以直接使用这些数据结构作为读写器( reader / writer)这两个特征为字节流的异步读写提供了便利,通常我们会使用 AsyncReadExt
和 AsyncWriteExt
提供的工具方法,这些方法都使用 async
声明,且需要通过 .await
进行调用,
AsyncReadExt::read
是一个异步方法可以将数据读入缓冲区( buffer
)中,然后返回读取的字节数。
-use tokio::fs::File; -use tokio::io::{self, AsyncReadExt}; - -#[tokio::main] -async fn main() -> io::Result<()> { - let mut f = File::open("foo.txt").await?; - let mut buffer = [0; 10]; - - // 由于 buffer 的长度限制,当次的 `read` 调用最多可以从文件中读取 10 个字节的数据 - let n = f.read(&mut buffer[..]).await?; - - println!("The bytes: {:?}", &buffer[..n]); - Ok(()) -} -
需要注意的是:当 read
返回 Ok(0)
时,意味着字节流( stream )已经关闭,在这之后继续调用 read
会立刻完成,依然获取到返回值 Ok(0)
。 例如,字节流如果是 TcpStream
类型,那 Ok(0)
说明该连接的读取端已经被关闭(写入端关闭,会报其它的错误)。
AsyncReadExt::read_to_end
方法会从字节流中读取所有的字节,直到遇到 EOF
:
-use tokio::io::{self, AsyncReadExt}; -use tokio::fs::File; - -#[tokio::main] -async fn main() -> io::Result<()> { - let mut f = File::open("foo.txt").await?; - let mut buffer = Vec::new(); - - // 读取整个文件的内容 - f.read_to_end(&mut buffer).await?; - Ok(()) -} -
AsyncWriteExt::write
异步方法会尝试将缓冲区的内容写入到写入器( writer
)中,同时返回写入的字节数:
-use tokio::io::{self, AsyncWriteExt}; -use tokio::fs::File; - -#[tokio::main] -async fn main() -> io::Result<()> { - let mut file = File::create("foo.txt").await?; - - let n = file.write(b"some bytes").await?; - - println!("Wrote the first {} bytes of 'some bytes'.", n); - Ok(()) -} -
上面代码很清晰,但是大家可能会疑惑 b"some bytes"
是什么意思。这种写法可以将一个 &str
字符串转变成一个字节数组:&[u8;10]
,然后 write
方法又会将这个 &[u8;10]
的数组类型隐式强转为数组切片: &[u8]
。
AsyncWriteExt::write_all
将缓冲区的内容全部写入到写入器中:
-use tokio::io::{self, AsyncWriteExt}; -use tokio::fs::File; - -#[tokio::main] -async fn main() -> io::Result<()> { - let mut file = File::create("foo.txt").await?; - - file.write_all(b"some bytes").await?; - Ok(()) -} -
以上只是部分方法,实际上还有一些实用的方法由于篇幅有限无法列出,大家可以通过 API 文档 查看完整的列表。
-另外,和标准库一样, tokio::io
模块包含了多个实用的函数或 API,可以用于处理标准输入/输出/错误等。
例如,tokio::io::copy
异步的将读取器( reader
)中的内容拷贝到写入器( writer
)中。
-use tokio::fs::File; -use tokio::io; - -#[tokio::main] -async fn main() -> io::Result<()> { - let mut reader: &[u8] = b"hello"; - let mut file = File::create("foo.txt").await?; - - io::copy(&mut reader, &mut file).await?; - Ok(()) -} -
还记得我们之前提到的字节数组 &[u8]
实现了 AsyncRead
吗?正因为这个原因,所以这里可以直接将 &u8
用作读取器。
就如同写代码必写 hello, world
,实现 web 服务器,往往会选择实现一个回声服务。该服务会将用户的输入内容直接返回给用户,就像回声壁一样。
具体来说,就是从用户建立的 TCP 连接的 socket 中读取到数据,然后立刻将同样的数据写回到该 socket 中。因此客户端会收到和自己发送的数据一模一样的回复。
-下面我们将使用两种稍有不同的方法实现该回声服务。
-io::copy()
先来创建一个新的 bin 文件,用于运行我们的回声服务:
-touch src/bin/echo-server-copy.rs
-
-然后可以通过以下命令运行它(跟上一章节的方式相同):
-cargo run --bin echo-server-copy
-
-至于客户端,可以简单的使用 telnet
的方式来连接,或者也可以使用 tokio::net::TcpStream
,它的文档示例非常适合大家进行参考。
先来实现一下基本的服务器框架:通过 loop 循环接收 TCP 连接,然后为每一条连接创建一个单独的任务去处理。
--use tokio::io; -use tokio::net::TcpListener; - -#[tokio::main] -async fn main() -> io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:6142").await?; - - loop { - let (mut socket, _) = listener.accept().await?; - - tokio::spawn(async move { - // 在这里拷贝数据 - }); - } -} -
下面,来看看重头戏 io::copy
,它有两个参数:一个读取器,一个写入器,然后将读取器中的数据直接拷贝到写入器中,类似的实现代码如下:
--#![allow(unused)] -fn main() { -io::copy(&mut socket, &mut socket).await -} -
这段代码相信大家一眼就能看出问题,由于我们的读取器和写入器都是同一个 socket,因此需要对其进行两次可变借用,这明显违背了 Rust 的借用规则。
-显然,使用同一个 socket 是不行的,为了实现目标功能,必须将 socket
分离成一个读取器和写入器。
任何一个读写器( reader + writer )都可以使用 io::split
方法进行分离,最终返回一个读取器和写入器,这两者可以独自的使用,例如可以放入不同的任务中。
例如,我们的回声客户端可以这样实现,以实现同时并发读写:
--use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; - -#[tokio::main] -async fn main() -> io::Result<()> { - let socket = TcpStream::connect("127.0.0.1:6142").await?; - let (mut rd, mut wr) = io::split(socket); - - // 创建异步任务,在后台写入数据 - tokio::spawn(async move { - wr.write_all(b"hello\r\n").await?; - wr.write_all(b"world\r\n").await?; - - // 有时,我们需要给予 Rust 一些类型暗示,它才能正确的推导出类型 - Ok::<_, io::Error>(()) - }); - - let mut buf = vec![0; 128]; - - loop { - let n = rd.read(&mut buf).await?; - - if n == 0 { - break; - } - - println!("GOT {:?}", &buf[..n]); - } - - Ok(()) -} -
实际上,io::split
可以用于任何同时实现了 AsyncRead
和 AsyncWrite
的值,它的内部使用了 Arc
和 Mutex
来实现相应的功能。如果大家觉得这种实现有些重,可以使用 Tokio 提供的 TcpStream
,它提供了两种方式进行分离:
TcpStream::split
会获取字节流的引用,然后将其分离成一个读取器和写入器。但由于使用了引用的方式,它们俩必须和 split
在同一个任务中。 优点就是,这种实现没有性能开销,因为无需 Arc
和 Mutex
。TcpStream::into_split
还提供了一种分离实现,分离出来的结果可以在任务间移动,内部是通过 Arc
实现再来分析下我们的使用场景,由于 io::copy()
调用时所在的任务和 split
所在的任务是同一个,因此可以使用性能最高的 TcpStream::split
:
--#![allow(unused)] -fn main() { -tokio::spawn(async move { - let (mut rd, mut wr) = socket.split(); - - if io::copy(&mut rd, &mut wr).await.is_err() { - eprintln!("failed to copy"); - } -}); -} -
使用 io::copy
实现的完整代码见此处。
程序员往往拥有一颗手动干翻一切的心,因此如果你不想用 io::copy
来简单实现,还可以自己手动去拷贝数据:
-use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; - -#[tokio::main] -async fn main() -> io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:6142").await?; - - loop { - let (mut socket, _) = listener.accept().await?; - - tokio::spawn(async move { - let mut buf = vec![0; 1024]; - - loop { - match socket.read(&mut buf).await { - // 返回值 `Ok(0)` 说明对端已经关闭 - Ok(0) => return, - Ok(n) => { - // Copy the data back to socket - // 将数据拷贝回 socket 中 - if socket.write_all(&buf[..n]).await.is_err() { - // 非预期错误,由于我们这里无需再做什么,因此直接停止处理 - return; - } - } - Err(_) => { - // 非预期错误,由于我们无需再做什么,因此直接停止处理 - return; - } - } - } - }); - } -} -
建议这段代码放入一个和之前 io::copy
不同的文件中 src/bin/echo-server.rs
, 然后使用 cargo run --bin echo-server
运行。
下面一起来看看这段代码有哪些值得注意的地方。首先,由于使用了 write_all
和 read
方法,需要先将对应的特征引入到当前作用域内:
--#![allow(unused)] -fn main() { -use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; -} -
在上面代码中,我们需要将数据从 socket
中读取到一个缓冲区 buffer
中:
--#![allow(unused)] -fn main() { -let mut buf = vec![0; 1024]; -} -
可以看到,此处的缓冲区是一个 Vec
动态数组,它的数据是存储在堆上,而不是栈上(若改成 let mut buf = [0; 1024];
,则存储在栈上)。
在之前,我们提到过一个数据如果想在 .await
调用过程中存在,那它必须存储在当前任务内。在我们的代码中,buf
会在 .await
调用过程中被使用,因此它必须要存储在任务内。
若该缓冲区数组创建在栈上,那每条连接所对应的任务的内部数据结构看上去可能如下所示:
---#![allow(unused)] -fn main() { -struct Task { - task: enum { - AwaitingRead { - socket: TcpStream, - buf: [BufferType], - }, - AwaitingWriteAll { - socket: TcpStream, - buf: [BufferType], - } - - } -} -} -
可以看到,栈数组要被使用,就必须存储在相应的结构体内,其中两个结构体分别持有了不同的栈数组 [BufferType]
,这种方式会导致任务结构变得很大。特别地,我们选择缓冲区长度往往会使用分页长度(page size),因此使用栈数组会导致任务的内存大小变得很奇怪甚至糟糕:$page-size + 一些额外的字节
。
当然,编译器会帮助我们做一些优化。例如,会进一步优化 async
语句块的布局,而不是像上面一样简单的使用 enum
。在实践中,变量也不会在枚举成员间移动。
但是再怎么优化,任务的结构体至少也会跟其中的栈数组一样大,因此通常情况下,使用堆上的缓冲区会高效实用的多。
---当任务因为调度在线程间移动时,存储在栈上的数据需要进行保存和恢复,过大的栈上变量会带来不小的数据拷贝开销
-因此,存储大量数据的变量最好放到堆上
-
当 TCP 连接的读取端关闭后,再调用 read
方法会返回 Ok(0)
。此时,再继续下去已经没有意义,因此我们需要退出循环。忘记在 EOF 时退出读取循环,是网络编程中一个常见的 bug :
--#![allow(unused)] -fn main() { -loop { - match socket.read(&mut buf).await { - Ok(0) => return, - // ... 其余错误处理 - } -} -} -
大家不妨深入思考下,如果没有退出循环会怎么样?之前我们提到过,一旦读取端关闭后,那后面的 read
调用就会立即返回 Ok(0)
,而不会阻塞等待,因此这种无阻塞循环会最终导致 CPU 立刻跑到 100% ,并将一直持续下去,直到程序关闭。
对于 Async Rust,最最重要的莫过于底层的异步运行时,它提供了执行器、任务调度、异步 API 等核心服务。简单来说,使用 Rust 提供的 async/await
特性编写的异步代码要运行起来,就必须依赖于异步运行时,否则这些代码将毫无用处。
Rust 语言本身只提供了异步编程所需的基本特性,例如 async/await
关键字,标准库中的 Future
特征,官方提供的 futures
实用库,这些特性单独使用没有任何用处,因此我们需要一个运行时来将这些特性实现的代码运行起来。
异步运行时是由 Rust 社区提供的,它们的核心是一个 reactor
和一个或多个 executor
(执行器):
reactor
用于提供外部事件的订阅机制,例如 I/O
、进程间通信、定时器等executor
在上一章我们有过深入介绍,它用于调度和执行相应的任务( Future
)目前最受欢迎的几个运行时有:
-tokio
,目前最受欢迎的异步运行时,功能强大,还提供了异步所需的各种工具(例如 tracing )、网络协议框架(例如 HTTP,gRPC )等等async-std
,最大的优点就是跟标准库兼容性较强smol
, 一个小巧的异步运行时但是,大浪淘沙,留下的才是金子,随着时间的流逝,tokio
越来越亮眼,无论是性能、功能还是社区、文档,它在各个方面都异常优秀,时至今日,可以说已成为事实上的标准。
为何选择异步运行时这么重要?不仅仅是它们在功能、性能上存在区别,更重要的是当你选择了一个,往往就无法切换到另外一个,除非异步代码很少。
-使用异步运行时,往往伴随着对它相关的生态系统的深入使用,因此耦合性会越来越强,直至最后你很难切换到另一个运行时,例如 tokio
和 async-std
,就存在这种问题。
如果你实在有这种需求,可以考虑使用 async-compat
,该包提供了一个中间层,用于兼容 tokio
和其它运行时。
相信大家看到现在,心中应该有一个结论了。首先,运行时之间的不兼容性,让我们必须提前选择一个运行时,并且在未来坚持用下去,那这个运行时就应该是最优秀、最成熟的那个,tokio
几乎成了不二选择,当然 tokio
也有自己的问题:更难上手和运行时之间的兼容性。
如果你只用 tokio
,那兼容性自然不是问题,至于难以上手,Rust 这么难,我们都学到现在了,何况区区一个异步运行时,在本书的帮助下,这些都不再是问题:)
tokio 是一个纸醉金迷之地,只要有钱就可以为所欲为,哦,抱歉,走错片场了。tokio
是 Rust 最优秀的异步运行时框架,它提供了写异步网络服务所需的几乎所有功能,不仅仅适用于大型服务器,还适用于小型嵌入式设备,它主要由以下组件构成:
async/await
编写的代码thread::sleep
会阻塞当前线程,tokio
中就提供了相应的异步实现版本tracing
用于日志和分布式追踪, 提供 console
用于 Debug 异步编程下面一起来看看使用 tokio
能给你提供哪些优势。
高性能
-因为快所以快,前者是 Rust 快,后者是 tokio
快。 tokio
在编写时充分利用了 Rust 提供的各种零成本抽象和高性能特性,而且贯彻了 Rust 的牛逼思想:如果你选择手写代码,那么最好的结果就是跟 tokio
一样快!
以下是一张官方提供的性能参考图,大致能体现出 tokio
的性能之恐怖:
-
高可靠
-Rust 语言的安全可靠性顺理成章的影响了 tokio
的可靠性,曾经有一个调查给出了令人乍舌的结论:软件系统 70%的高危漏洞都是由内存不安全性导致的。
在 Rust 提供的安全性之外,tokio
还致力于提供一致性的行为表现:无论你何时运行系统,它的预期表现和性能都是一致的,例如不会出现莫名其妙的请求延迟或响应时间大幅增加。
简单易用
-通过 Rust 提供的 async/await
特性,编写异步程序的复杂性相比当初已经大幅降低,同时 tokio
还为我们提供了丰富的生态,进一步大幅降低了其复杂性。
同时 tokio
遵循了标准库的命名规则,让熟悉标准库的用户可以很快习惯于 tokio
的语法,再借助于 Rust 强大的类型系统,用户可以轻松地编写和交付正确的代码。
使用灵活性
-tokio
支持你灵活的定制自己想要的运行时,例如你可以选择多线程 + 任务盗取模式的复杂运行时,也可以选择单线程的轻量级运行时。总之,几乎你的每一种需求在 tokio
中都能寻找到支持(画外音:强大的灵活性需要一定的复杂性来换取,并不是免费的午餐)。
虽然 tokio
对于大多数需要并发的项目都是非常适合的,但是确实有一些场景它并不适合使用:
tokio
非常适合于 IO 密集型任务,这些 IO 任务的绝大多数时间都用于阻塞等待 IO 的结果,而不是刷刷刷的单烤 CPU。如果你的应用是 CPU 密集型(例如并行计算),建议使用 rayon
,当然,对于其中的 IO 任务部分,你依然可以混用 tokio
tokio
的优势是给予你并发处理大量任务的能力,对于这种轻量级 HTTP 请求场景,tokio
除了增加你的代码复杂性,并无法带来什么额外的优势。因此,对于这种场景,你可以使用 reqwest
库,它会更加简单易用。--若大家使用 tokio,那 CPU 密集的任务尤其需要用线程的方式去处理,例如使用
-spawn_blocking
创建一个阻塞的线程去完成相应 CPU 密集任务。原因是:tokio 是协作式的调度器,如果某个 CPU 密集的异步任务是通过 tokio 创建的,那理论上来说,该异步任务需要跟其它的异步任务交错执行,最终大家都得到了执行,皆大欢喜。但实际情况是,CPU 密集的任务很可能会一直霸着着 CPU,此时 tokio 的调度方式决定了该任务会一直被执行,这意味着,其它的异步任务无法得到执行的机会,最终这些任务都会因为得不到资源而饿死。
-而使用
-spawn_blocking
后,会创建一个单独的 OS 线程,该线程并不会被 tokio 所调度( 被 OS 所调度 ),因此它所执行的 CPU 密集任务也不会导致 tokio 调度的那些异步任务被饿死
离开第三方开源社区提供的异步运行时, async/await
什么都不是,甚至还不如一堆破铜烂铁,除非你选择根据自己的需求手撸一个。
而 tokio
就是那颗皇冠上的夜明珠,也是值得我们投入时间去深入学习的开源库,它的设计原理和代码实现都异常优秀,在之后的章节中,我们将对其进行深入学习和剖析,敬请期待。
在实际使用时,一个重要的场景就是同时等待多个异步操作的结果,并且对其结果进行进一步处理,在本章节,我们来看看,强大的 select!
是如何帮助咱们更好的控制多个异步操作并发执行的。
select!
允许同时等待多个计算操作,然后当其中一个操作完成时就退出等待:
-use tokio::sync::oneshot; - -#[tokio::main] -async fn main() { - let (tx1, rx1) = oneshot::channel(); - let (tx2, rx2) = oneshot::channel(); - - tokio::spawn(async { - let _ = tx1.send("one"); - }); - - tokio::spawn(async { - let _ = tx2.send("two"); - }); - - tokio::select! { - val = rx1 => { - println!("rx1 completed first with {:?}", val); - } - val = rx2 => { - println!("rx2 completed first with {:?}", val); - } - } - - // 任何一个 select 分支结束后,都会继续执行接下来的代码 -} -
这里用到了两个 oneshot
消息通道,虽然两个操作的创建在代码上有先后顺序,但在实际执行时却不这样。因此, select
在从两个通道阻塞等待接收消息时,rx1
和 rx2
都有可能被先打印出来。
需要注意,任何一个 select
分支完成后,都会继续执行后面的代码,没被执行的分支会被丢弃( dropped
)。
对于 Async Rust
来说,释放( drop )掉一个 Future
就意味着取消任务。从上一章节可以得知, async
操作会返回一个 Future
,而后者是惰性的,直到被 poll
调用时,才会被执行。一旦 Future
被释放,那操作将无法继续,因为所有相关的状态都被释放。
对于 Tokio 的 oneshot
的接收端来说,它在被释放时会发送一个关闭通知到发送端,因此发送端可以通过释放任务的方式来终止正在执行的任务。
-use tokio::sync::oneshot; - -async fn some_operation() -> String { - // 在这里执行一些操作... -} - -#[tokio::main] -async fn main() { - let (mut tx1, rx1) = oneshot::channel(); - let (tx2, rx2) = oneshot::channel(); - - tokio::spawn(async { - // 等待 `some_operation` 的完成 - // 或者处理 `oneshot` 的关闭通知 - tokio::select! { - val = some_operation() => { - let _ = tx1.send(val); - } - _ = tx1.closed() => { - // 收到了发送端发来的关闭信号 - // `select` 即将结束,此时,正在进行的 `some_operation()` 任务会被取消,任务自动完成, - // tx1 被释放 - } - } - }); - - tokio::spawn(async { - let _ = tx2.send("two"); - }); - - tokio::select! { - val = rx1 => { - println!("rx1 completed first with {:?}", val); - } - val = rx2 => { - println!("rx2 completed first with {:?}", val); - } - } -} -
上面代码的重点就在于 tx1.closed
所在的分支,一旦发送端被关闭,那该分支就会被执行,然后 select
会退出,并清理掉还没执行的第一个分支 val = some_operation()
,这其中 some_operation
返回的 Future
也会被清理,根据之前的内容,Future
被清理那相应的任务会立即取消,因此 some_operation
会被取消,不再执行。
为了更好的理解 select
的工作原理,我们来看看如果使用 Future
该如何实现。当然,这里是一个简化版本,在实际中,select!
会包含一些额外的功能,例如一开始会随机选择一个分支进行 poll
。
-use tokio::sync::oneshot; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -struct MySelect { - rx1: oneshot::Receiver<&'static str>, - rx2: oneshot::Receiver<&'static str>, -} - -impl Future for MySelect { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { - if let Poll::Ready(val) = Pin::new(&mut self.rx1).poll(cx) { - println!("rx1 completed first with {:?}", val); - return Poll::Ready(()); - } - - if let Poll::Ready(val) = Pin::new(&mut self.rx2).poll(cx) { - println!("rx2 completed first with {:?}", val); - return Poll::Ready(()); - } - - Poll::Pending - } -} - -#[tokio::main] -async fn main() { - let (tx1, rx1) = oneshot::channel(); - let (tx2, rx2) = oneshot::channel(); - - // 使用 tx1 和 tx2 - - MySelect { - rx1, - rx2, - }.await; -} -
MySelect
包含了两个分支中的 Future
,当它被 poll
时,第一个分支会先执行。如果执行完成,那取出的值会被使用,然后 MySelect
也随之结束。而另一个分支对应的 Future
会被释放掉,对应的操作也会被取消。
还记得上一章节中很重要的一段话吗?
---当一个
-Future
返回Poll::Pending
时,它必须确保会在某一个时刻通过Waker
来唤醒,不然该Future
将永远地被挂起
但是仔细观察我们之前的代码,里面并没有任何的 wake
调用!事实上,这是因为参数 cx
被传入了内层的 poll
调用。 只要内部的 Future
实现了唤醒并且返回了 Poll::Pending
,那 MySelect
也等于实现了唤醒!
目前来说,select!
最多可以支持 64 个分支,每个分支形式如下:
--#![allow(unused)] -fn main() { -<模式> = <async 表达式> => <结果处理>, -} -
当 select
宏开始执行后,所有的分支会开始并发的执行。当任何一个表达式完成时,会将结果跟模式进行匹配。若匹配成功,则剩下的表达式会被释放。
最常用的模式就是用变量名去匹配表达式返回的值,然后该变量就可以在结果处理环节使用。
-如果当前的模式不能匹配,剩余的 async
表达式将继续并发的执行,直到下一个完成。
由于 select!
使用的是一个 async
表达式,因此我们可以定义一些更复杂的计算。
例如从在分支中进行 TCP 连接:
--use tokio::net::TcpStream; -use tokio::sync::oneshot; - -#[tokio::main] -async fn main() { - let (tx, rx) = oneshot::channel(); - - // 生成一个任务,用于向 oneshot 发送一条消息 - tokio::spawn(async move { - tx.send("done").unwrap(); - }); - - tokio::select! { - socket = TcpStream::connect("localhost:3465") => { - println!("Socket connected {:?}", socket); - } - msg = rx => { - println!("received message first {:?}", msg); - } - } -} -
再比如,在分支中进行 TCP 监听:
--use tokio::net::TcpListener; -use tokio::sync::oneshot; -use std::io; - -#[tokio::main] -async fn main() -> io::Result<()> { - let (tx, rx) = oneshot::channel(); - - tokio::spawn(async move { - tx.send(()).unwrap(); - }); - - let mut listener = TcpListener::bind("localhost:3465").await?; - - tokio::select! { - _ = async { - loop { - let (socket, _) = listener.accept().await?; - tokio::spawn(async move { process(socket) }); - } - - // 给予 Rust 类型暗示 - Ok::<_, io::Error>(()) - } => {} - _ = rx => { - println!("terminating accept loop"); - } - } - - Ok(()) -} -
分支中接收连接的循环会一直运行,直到遇到错误才停止,或者当 rx
中有值时,也会停止。 _
表示我们并不关心这个值,这样使用唯一的目的就是为了结束第一分支中的循环。
select!
还能返回一个值:
-async fn computation1() -> String { - // .. 计算 -} - -async fn computation2() -> String { - // .. 计算 -} - -#[tokio::main] -async fn main() { - let out = tokio::select! { - res1 = computation1() => res1, - res2 = computation2() => res2, - }; - - println!("Got = {}", out); -} -
需要注意的是,此时 select!
的所有分支必须返回一样的类型,否则编译器会报错!
在 Rust 中使用 ?
可以对错误进行传播,但是在 select!
中,?
如何工作取决于它是在分支中的 async
表达式使用还是在结果处理的代码中使用:
async
表达式使用会将该表达式的结果变成一个 Result
select!
之外-use tokio::net::TcpListener; -use tokio::sync::oneshot; -use std::io; - -#[tokio::main] -async fn main() -> io::Result<()> { - // [设置 `rx` oneshot 消息通道] - - let listener = TcpListener::bind("localhost:3465").await?; - - tokio::select! { - res = async { - loop { - let (socket, _) = listener.accept().await?; - tokio::spawn(async move { process(socket) }); - } - - Ok::<_, io::Error>(()) - } => { - res?; - } - _ = rx => { - println!("terminating accept loop"); - } - } - - Ok(()) -} -
listener.accept().await?
是分支表达式中的 ?
,因此它会将表达式的返回值变成 Result
类型,然后赋予给 res
变量。
与之不同的是,结果处理中的 res?;
会让 main
函数直接结束并返回一个 Result
,可以看出,这里 ?
的用法跟我们平时的用法并无区别。
既然是模式匹配,我们需要再来回忆下 select!
的分支语法形式:
--#![allow(unused)] -fn main() { -<模式> = <async 表达式> => <结果处理>, -} -
迄今为止,我们只用了变量绑定的模式,事实上,任何 Rust 模式都可以在此处使用。
--use tokio::sync::mpsc; - -#[tokio::main] -async fn main() { - let (mut tx1, mut rx1) = mpsc::channel(128); - let (mut tx2, mut rx2) = mpsc::channel(128); - - tokio::spawn(async move { - // 用 tx1 和 tx2 干一些不为人知的事 - }); - - tokio::select! { - Some(v) = rx1.recv() => { - println!("Got {:?} from rx1", v); - } - Some(v) = rx2.recv() => { - println!("Got {:?} from rx2", v); - } - else => { - println!("Both channels closed"); - } - } -} -
上面代码中,rx
通道关闭后,recv()
方法会返回一个 None
,可以看到没有任何模式能够匹配这个 None
,那为何不会报错?秘密就在于 else
上:当使用模式去匹配分支时,若之前的所有分支都无法被匹配,那 else
分支将被执行。
当在 Tokio 中生成( spawn )任务时,其 async 语句块必须拥有其中数据的所有权。而 select!
并没有这个限制,它的每个分支表达式可以直接借用数据,然后进行并发操作。只要遵循 Rust 的借用规则,多个分支表达式可以不可变的借用同一个数据,或者在一个表达式可变的借用某个数据。
来看个例子,在这里我们同时向两个 TCP 目标发送同样的数据:
---#![allow(unused)] -fn main() { -use tokio::io::AsyncWriteExt; -use tokio::net::TcpStream; -use std::io; -use std::net::SocketAddr; - -async fn race( - data: &[u8], - addr1: SocketAddr, - addr2: SocketAddr -) -> io::Result<()> { - tokio::select! { - Ok(_) = async { - let mut socket = TcpStream::connect(addr1).await?; - socket.write_all(data).await?; - Ok::<_, io::Error>(()) - } => {} - Ok(_) = async { - let mut socket = TcpStream::connect(addr2).await?; - socket.write_all(data).await?; - Ok::<_, io::Error>(()) - } => {} - else => {} - }; - - Ok(()) -} -} -
这里其实有一个很有趣的题外话,由于 TCP 连接过程是在模式中发生的,因此当某一个连接过程失败后,它通过 ?
返回的 Err
类型并无法匹配 Ok
,因此另一个分支会继续被执行,继续连接。
如果你把连接过程放在了结果处理中,那连接失败会直接从 race
函数中返回,而不是继续执行另一个分支中的连接!
还有一个非常重要的点,借用规则在分支表达式和结果处理中存在很大的不同。例如上面代码中,我们在两个分支表达式中分别对 data
做了不可变借用,这当然 ok,但是若是两次可变借用,那编译器会立即进行报错。但是转折来了:当在结果处理中进行两次可变借用时,却不会报错,大家可以思考下为什么,提示下:思考下分支在执行完成后会发生什么?
-use tokio::sync::oneshot; - -#[tokio::main] -async fn main() { - let (tx1, rx1) = oneshot::channel(); - let (tx2, rx2) = oneshot::channel(); - - let mut out = String::new(); - - tokio::spawn(async move { - }); - - tokio::select! { - _ = rx1 => { - out.push_str("rx1 completed"); - } - _ = rx2 => { - out.push_str("rx2 completed"); - } - } - - println!("{}", out); -} -
例如以上代码,就在两个分支的结果处理中分别进行了可变借用,并不会报错。原因就在于:select!
会保证只有一个分支的结果处理会被运行,然后在运行结束后,另一个分支会被直接丢弃。
来看看该如何在循环中使用 select!
,顺便说一句,跟循环一起使用是最常见的使用方式。
-use tokio::sync::mpsc; - -#[tokio::main] -async fn main() { - let (tx1, mut rx1) = mpsc::channel(128); - let (tx2, mut rx2) = mpsc::channel(128); - let (tx3, mut rx3) = mpsc::channel(128); - - loop { - let msg = tokio::select! { - Some(msg) = rx1.recv() => msg, - Some(msg) = rx2.recv() => msg, - Some(msg) = rx3.recv() => msg, - else => { break } - }; - - println!("Got {}", msg); - } - - println!("All channels have been closed."); -} -
在循环中使用 select!
最大的不同就是,当某一个分支执行完成后,select!
会继续循环等待并执行下一个分支,直到所有分支最终都完成,最终匹配到 else
分支,然后通过 break
跳出循环。
老生常谈的一句话:select!
中哪个分支先被执行是无法确定的,因此不要依赖于分支执行的顺序!想象一下,在异步编程场景,若 select!
按照分支的顺序来执行会如何:若 rx1
中总是有数据,那每次循环都只会去处理第一个分支,后面两个分支永远不会被执行。
-async fn action() { - // 一些异步逻辑 -} - -#[tokio::main] -async fn main() { - let (mut tx, mut rx) = tokio::sync::mpsc::channel(128); - - let operation = action(); - tokio::pin!(operation); - - loop { - tokio::select! { - _ = &mut operation => break, - Some(v) = rx.recv() => { - if v % 2 == 0 { - break; - } - } - } - } -} -
在上面代码中,我们没有直接在 select!
分支中调用 action()
,而是在 loop
循环外面先将 action()
赋值给 operation
,因此 operation
是一个 Future
。
重点来了,在 select!
循环中,我们使用了一个奇怪的语法 &mut operation
,大家想象一下,如果不加 &mut
会如何?答案是,每一次循环调用的都是一次全新的 action()
调用,但是当加了 &mut operatoion
后,每一次循环调用就变成了对同一次 action()
的调用。也就是我们实现了在每次循环中恢复了之前的异步操作!
select!
的另一个分支从消息通道收取消息,一旦收到值是偶数,就跳出循环,否则就继续循环。
还有一个就是我们使用了 tokio::pin!
,具体的细节这里先不介绍,值得注意的点是:如果要在一个引用上使用 .await
,那么引用的值就必须是不能移动的或者实现了 Unpin
,关于 Pin
和 Unpin
可以参见这里。
一旦移除 tokio::pin!
所在行的代码,然后试图编译,就会获得以下错误:
error[E0599]: no method named `poll` found for struct
- `std::pin::Pin<&mut &mut impl std::future::Future>`
- in the current scope
- --> src/main.rs:16:9
- |
-16 | / tokio::select! {
-17 | | _ = &mut operation => break,
-18 | | Some(v) = rx.recv() => {
-19 | | if v % 2 == 0 {
-... |
-22 | | }
-23 | | }
- | |_________^ method not found in
- | `std::pin::Pin<&mut &mut impl std::future::Future>`
- |
- = note: the method `poll` exists but the following trait bounds
- were not satisfied:
- `impl std::future::Future: std::marker::Unpin`
- which is required by
- `&mut impl std::future::Future: std::future::Future`
-
-虽然我们已经学了很多关于 Future
的知识,但是这个错误依然不太好理解。但是它不难解决:当你试图在一个引用上调用 .await
然后遇到了 Future 未实现
这种错误时,往往只需要将对应的 Future
进行固定即可: tokio::pin!(operation);
。
下面一起来看一个稍微复杂一些的 loop
循环,首先,我们拥有:
i32
类型的值i32
值上的一个异步操作想要实现的逻辑是:
--async fn action(input: Option<i32>) -> Option<String> { - // 若 input(输入)是None,则返回 None - // 事实上也可以这么写: `let i = input?;` - let i = match input { - Some(input) => input, - None => return None, - }; - - // 这里定义一些逻辑 -} - -#[tokio::main] -async fn main() { - let (mut tx, mut rx) = tokio::sync::mpsc::channel(128); - - let mut done = false; - let operation = action(None); - tokio::pin!(operation); - - tokio::spawn(async move { - let _ = tx.send(1).await; - let _ = tx.send(3).await; - let _ = tx.send(2).await; - }); - - loop { - tokio::select! { - res = &mut operation, if !done => { - done = true; - - if let Some(v) = res { - println!("GOT = {}", v); - return; - } - } - Some(v) = rx.recv() => { - if v % 2 == 0 { - // `.set` 是 `Pin` 上定义的方法 - operation.set(action(Some(v))); - done = false; - } - } - } - } -} -
当第一次循环开始时, 第一个分支会立即完成,因为 operation
的参数是 None
。当第一个分支执行完成时,done
会变成 true
,此时第一个分支的条件将无法被满足,开始执行第二个分支。
当第二个分支收到一个偶数时,done
会被修改为 false
,且 operation
被设置了值。 此后再一次循环时,第一个分支会被执行,且 operation
返回一个 Some(2)
,因此会触发 return
,最终结束循环并返回。
这段代码引入了一个新的语法: if !done
,在解释之前,先看看去掉后会如何:
thread 'main' panicked at '`async fn` resumed after completion', src/main.rs:1:55
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-'`async fn` resumed after completion'
错误的含义是:async fn
异步函数在完成后,依然被恢复了(继续使用)。
回到例子中来,这个错误是由于 operation
在它已经调用完成后依然被使用。通常来说,当使用 .await
后,调用 .await
的值会被消耗掉,因此并不存在这个问题。但是在这例子中,我们在引用上调用 .await
,因此之后该引用依然可以被使用。
为了避免这个问题,需要在第一个分支的 operation
完成后禁止再使用该分支。这里的 done
的引入就很好的解决了问题。对于 select!
来说 if !done
的语法被称为预条件( precondition ),该条件会在分支被 .await
执行前进行检查。
那大家肯定有疑问了,既然 operation
不能再被调用了,我们该如何在有偶数值时,再回到第一个分支对其进行调用呢?答案就是 operation.set(action(Some(v)));
,该操作会重新使用新的参数设置 operation
。
学到现在,相信大家对于 tokio::spawn
和 select!
已经非常熟悉,它们的共同点就是都可以并发的运行异步操作。
-然而它们使用的策略大相径庭。
tokio::spawn
函数会启动新的任务来运行一个异步操作,每个任务都是一个独立的对象可以单独被 Tokio 调度运行,因此两个不同的任务的调度都是独立进行的,甚至于它们可能会运行在两个不同的操作系统线程上。鉴于此,生成的任务和生成的线程有一个相同的限制:不允许对外部环境中的值进行借用。
而 select!
宏就不一样了,它在同一个任务中并发运行所有的分支。正是因为这样,在同一个任务中,这些分支无法被同时运行。 select!
宏在单个任务中实现了多路复用的功能。
上一章节中,咱们搭建了一个异步的 redis 服务器,并成功的提供了服务,但是其隐藏了一个巨大的问题:状态(数据)无法在多个连接之间共享,下面一起来看看该如何解决。
-好在 Tokio 十分强大,上面问题对应的解决方法也不止一种:
-Mutex
来保护数据的共享访问其中,第一种方法适合比较简单的数据,而第二种方法适用于需要异步工作的,例如 I/O 原语。由于我们使用的数据存储类型是 HashMap
,使用到的相关操作是 insert
和 get
,又因为这两个操作都不是异步的,因此只要使用 Mutex
即可解决问题。
在上面的描述中,说实话第二种方法及其适用的场景并不是很好理解,但没关系,在后面章节会进行详细介绍。
-bytes
依赖包在上一节中,我们使用 Vec<u8>
来保存目标数据,但是它有一个问题,对它进行克隆时会将底层数据也整个复制一份,效率很低,但是克隆操作对于我们在多连接间共享数据又是必不可少的。
因此这里咱们新引入一个 bytes
包,它包含一个 Bytes
类型,当对该类型的值进行克隆时,就不再会克隆底层数据。事实上,Bytes
是一个引用计数类型,跟 Arc
非常类似,或者准确的说,Bytes
就是基于 Arc
实现的,但相比后者Bytes
提供了一些额外的能力。
在 Cargo.toml
的 [dependencies]
中引入 bytes
:
bytes = "1"
-
-由于 HashMap
会在多个任务甚至多个线程间共享,再结合之前的选择,最终我们决定使用 Arc<Mutex<T>>
的方式对其进行包裹。
但是,大家先来畅想一下使用它进行包裹后的类型长什么样? 大概,可能,长这样:Arc<Mutex<HashMap<String, Bytes>>>
,天哪噜,一不小心,你就遇到了 Rust 的阴暗面:类型大串烧。可以想象,如果要在代码中到处使用这样的类型,可读性会极速下降,因此我们需要一个类型别名( type alias )来简化下:
--#![allow(unused)] -fn main() { -use bytes::Bytes; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -type Db = Arc<Mutex<HashMap<String, Bytes>>>; -} -
此时,Db
就是一个类型别名,使用它就可以替代那一大串的东东,等下你就能看到功效。
接着,我们需要在 main
函数中对 HashMap
进行初始化,然后使用 Arc
克隆一份它的所有权并将其传入到生成的异步任务中。事实上在 Tokio 中,这里的 Arc
被称为 handle,或者更宽泛的说,handle
在 Tokio 中可以用来访问某个共享状态。
-use tokio::net::TcpListener; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -#[tokio::main] -async fn main() { - let listener = TcpListener::bind("127.0.0.1:6379").await.unwrap(); - - println!("Listening"); - - let db = Arc::new(Mutex::new(HashMap::new())); - - loop { - let (socket, _) = listener.accept().await.unwrap(); - // 将 handle 克隆一份 - let db = db.clone(); - - println!("Accepted"); - tokio::spawn(async move { - process(socket, db).await; - }); - } -} -
std::sync::Mutex
上面代码还有一点非常重要,那就是我们使用了 std::sync::Mutex
来保护 HashMap
,而不是使用 tokio::sync::Mutex
。
在使用 Tokio 编写异步代码时,一个常见的错误无条件地使用 tokio::sync::Mutex
,而真相是:Tokio 提供的异步锁只应该在跨多个 .await
调用时使用,而且 Tokio 的 Mutex
实际上内部使用的也是 std::sync::Mutex
。
多补充几句,在异步代码中,关于锁的使用有以下经验之谈:
-.await
过程中持有,应该使用 Tokio 提供的锁,原因是 .await
的过程中锁可能在线程间转移,若使用标准库的同步锁存在死锁的可能性,例如某个任务刚获取完锁,还没使用完就因为 .await
让出了当前线程的所有权,结果下个任务又去获取了锁,造成死锁std::sync::Mutex
parking_lot::Mutex
process()
process()
函数不再初始化 HashMap
,取而代之的是它使用了 HashMap
的一个 handle
作为参数:
--#![allow(unused)] -fn main() { -use tokio::net::TcpStream; -use mini_redis::{Connection, Frame}; - -async fn process(socket: TcpStream, db: Db) { - use mini_redis::Command::{self, Get, Set}; - - let mut connection = Connection::new(socket); - - while let Some(frame) = connection.read_frame().await.unwrap() { - let response = match Command::from_frame(frame).unwrap() { - Set(cmd) => { - let mut db = db.lock().unwrap(); - db.insert(cmd.key().to_string(), cmd.value().clone()); - Frame::Simple("OK".to_string()) - } - Get(cmd) => { - let db = db.lock().unwrap(); - if let Some(value) = db.get(cmd.key()) { - Frame::Bulk(value.clone()) - } else { - Frame::Null - } - } - cmd => panic!("unimplemented {:?}", cmd), - }; - - connection.write_frame(&response).await.unwrap(); - } -} -} -
当竞争不多的时候,使用阻塞性的锁去保护共享数据是一个正确的选择。当一个锁竞争触发后,当前正在执行任务(请求锁)的线程会被阻塞,并等待锁被前一个使用者释放。这里的关键就是:锁竞争不仅仅会导致当前的任务被阻塞,还会导致执行任务的线程被阻塞,因此该线程准备执行的其它任务也会因此被阻塞!
-默认情况下,Tokio 调度器使用了多线程模式,此时如果有大量的任务都需要访问同一个锁,那么锁竞争将变得激烈起来。当然,你也可以使用 current_thread 运行时设置,在该设置下会使用一个单线程的调度器(执行器),所有的任务都会创建并执行在当前线程上,因此不再会有锁竞争。
---current_thread 是一个轻量级、单线程的运行时,当任务数不多或连接数不多时是一个很好的选择。例如你想在一个异步客户端库的基础上提供给用户同步的 API 访问时,该模式就很适用
-
当同步锁的竞争变成一个问题时,使用 Tokio 提供的异步锁几乎并不能帮你解决问题,此时可以考虑如下选项:
-在我们的例子中,由于每一个 key
都是独立的,因此对锁进行分片将成为一个不错的选择:
--#![allow(unused)] -fn main() { -type ShardedDb = Arc<Vec<Mutex<HashMap<String, Vec<u8>>>>>; - -fn new_sharded_db(num_shards: usize) -> ShardedDb { - let mut db = Vec::with_capacity(num_shards); - for _ in 0..num_shards { - db.push(Mutex::new(HashMap::new())); - } - Arc::new(db) -} -} -
在这里,我们创建了 N 个不同的存储实例,每个实例都会存储不同的分片数据,例如我们有a-i
共 9 个不同的 key
, 可以将存储分成 3 个实例,那么第一个实例可以存储 a-c
,第二个d-f
,以此类推。在这种情况下,访问 b
时,只需要锁住第一个实例,此时二、三实例依然可以正常访问,因此锁被成功的分片了。
在分片后,使用给定的 key 找到对应的值就变成了两个步骤:首先,使用 key
通过特定的算法寻找到对应的分片,然后再使用该 key
从分片中查询到值:
--#![allow(unused)] -fn main() { -let shard = db[hash(key) % db.len()].lock().unwrap(); -shard.insert(key, value); -} -
这里我们使用 hash
算法来进行分片,但是该算法有个缺陷:分片的数量不能变,一旦变了后,那之前落入分片 1 的key
很可能将落入到其它分片中,最终全部乱掉。此时你可以考虑dashmap,它提供了更复杂、更精妙的支持分片的hash map
。
.await
期间持有锁在某些时候,你可能会不经意写下这种代码:
---#![allow(unused)] -fn main() { -use std::sync::{Mutex, MutexGuard}; - -async fn increment_and_do_stuff(mutex: &Mutex<i32>) { - let mut lock: MutexGuard<i32> = mutex.lock().unwrap(); - *lock += 1; - - do_something_async().await; -} // 锁在这里超出作用域 -} -
如果你要 spawn
一个任务来执行上面的函数的话,会报错:
error: future cannot be sent between threads safely
- --> src/lib.rs:13:5
- |
-13 | tokio::spawn(async move {
- | ^^^^^^^^^^^^ future created by async block is not `Send`
- |
- ::: /playground/.cargo/registry/src/github.com-1ecc6299db9ec823/tokio-0.2.21/src/task/spawn.rs:127:21
- |
-127 | T: Future + Send + 'static,
- | ---- required by this bound in `tokio::task::spawn::spawn`
- |
- = help: within `impl std::future::Future`, the trait `std::marker::Send` is not implemented for `std::sync::MutexGuard<'_, i32>`
-note: future is not `Send` as this value is used across an await
- --> src/lib.rs:7:5
- |
-4 | let mut lock: MutexGuard<i32> = mutex.lock().unwrap();
- | -------- has type `std::sync::MutexGuard<'_, i32>` which is not `Send`
-...
-7 | do_something_async().await;
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here, with `mut lock` maybe used later
-8 | }
- | - `mut lock` is later dropped here
-
-错误的原因在于 std::sync::MutexGuard
类型并没有实现 Send
特征,这意味着你不能将一个 Mutex
锁发送到另一个线程,因为 .await
可能会让任务转移到另一个线程上执行,这个之前也介绍过。
要解决这个问题,就必须重构代码,让 Mutex
锁在 .await
被调用前就被释放掉。
--#![allow(unused)] -fn main() { -// 下面的代码可以工作! -async fn increment_and_do_stuff(mutex: &Mutex<i32>) { - { - let mut lock: MutexGuard<i32> = mutex.lock().unwrap(); - *lock += 1; - } // lock在这里超出作用域 (被释放) - - do_something_async().await; -} -} -
--大家可能已经发现,很多错误都是因为
-.await
引起的,其实你只要记住,在.await
执行期间,任务可能会在线程间转移,那么这些错误将变得很好理解,不必去死记硬背
但是下面的代码不工作:
---#![allow(unused)] -fn main() { -use std::sync::{Mutex, MutexGuard}; - -async fn increment_and_do_stuff(mutex: &Mutex<i32>) { - let mut lock: MutexGuard<i32> = mutex.lock().unwrap(); - *lock += 1; - drop(lock); - - do_something_async().await; -} -} -
原因我们之前解释过,编译器在这里不够聪明,目前它只能根据作用域的范围来判断,drop
虽然释放了锁,但是锁的作用域依然会持续到函数的结束,未来也许编译器会改进,但是现在至少还是不行的。
聪明的读者此时的小脑袋已经飞速运转起来,既然锁没有实现 Send
, 那我们主动给它实现如何?这样不就可以顺利运行了吗?答案依然是不可以,原因就是我们之前提到过的死锁,如果一个任务获取了锁,然后还没释放就在 .await
期间被挂起,接着开始执行另一个任务,这个任务又去获取锁,就会导致死锁。
再来看看其它解决方法:
-.await
期间不持有锁之前的代码其实也是为了在 .await
期间不持有锁,但是我们还有更好的实现方式,例如,你可以把 Mutex
放入一个结构体中,并且只在该结构体的非异步方法中使用该锁:
--#![allow(unused)] -fn main() { -use std::sync::Mutex; - -struct CanIncrement { - mutex: Mutex<i32>, -} -impl CanIncrement { - // 该方法不是 `async` - fn increment(&self) { - let mut lock = self.mutex.lock().unwrap(); - *lock += 1; - } -} - -async fn increment_and_do_stuff(can_incr: &CanIncrement) { - can_incr.increment(); - do_something_async().await; -} -} -
该方法常常用于共享的资源是 I/O 类型的资源时,我们在下一章节将详细介绍。
-Tokio 提供的锁最大的优点就是:它可以在 .await
执行期间被持有,而且不会有任何问题。但是代价就是,这种异步锁的性能开销会更高,因此如果可以,使用之前的两种方法来解决会更好。
- - --#![allow(unused)] -fn main() { -use tokio::sync::Mutex; // 注意,这里使用的是 Tokio 提供的锁 - -// 下面的代码会编译 -// 但是就这个例子而言,之前的方式会更好 -async fn increment_and_do_stuff(mutex: &Mutex<i32>) { - let mut lock = mutex.lock().await; - *lock += 1; - - do_something_async().await; -} // 锁在这里被释放 -} -
同志们,抓稳了,我们即将换挡提速,通向 mini-redis
服务端的高速之路已经开启。
不过在开始之前,先来做点收尾工作:上一章节中,我们实现了一个简易的 mini-redis
客户端并支持了 SET
/GET
操作, 现在将该代码移动到 examples
文件夹下,因为我们这个章节要实现的是服务器,后面可以通过运行 example
的方式,用之前客户端示例对我们的服务器端进行测试:
$ mkdir -p examples
-$ mv src/main.rs examples/hello-redis.rs
-
-并在 Cargo.toml
里添加 [[example]]
说明。关于 example
的详细说明,可以在Cargo使用指南里进一步了解。
[[example]]
-name = "hello-redis"
-path = "examples/hello-redis.rs"
-
-然后再重新创建一个空的 src/main.rs
文件,至此替换文档已经完成,提速正式开始。
作为服务器端,最基础的工作无疑是接收外部进来的 TCP 连接,可以通过 tokio::net::TcpListener
来完成。
--Tokio 中大多数类型的名称都和标准库中对应的同步类型名称相同,而且,如果没有特殊原因,Tokio 的 API 名称也和标准库保持一致,只不过用
-async fn
取代fn
来声明函数。
TcpListener
监听 6379 端口,然后通过循环来接收外部进来的连接,每个连接在处理完后会被关闭。对于目前来说,我们的任务很简单:读取命令、打印到标准输出 stdout
,最后回复给客户端一个错误。
-use tokio::net::{TcpListener, TcpStream}; -use mini_redis::{Connection, Frame}; - -#[tokio::main] -async fn main() { - // Bind the listener to the address - // 监听指定地址,等待 TCP 连接进来 - let listener = TcpListener::bind("127.0.0.1:6379").await.unwrap(); - - loop { - // 第二个被忽略的项中包含有新连接的 `IP` 和端口信息 - let (socket, _) = listener.accept().await.unwrap(); - process(socket).await; - } -} - -async fn process(socket: TcpStream) { - // `Connection` 对于 redis 的读写进行了抽象封装,因此我们读到的是一个一个数据帧frame(数据帧 = redis命令 + 数据),而不是字节流 - // `Connection` 是在 mini-redis 中定义 - let mut connection = Connection::new(socket); - - if let Some(frame) = connection.read_frame().await.unwrap() { - println!("GOT: {:?}", frame); - - // 回复一个错误 - let response = Frame::Error("unimplemented".to_string()); - connection.write_frame(&response).await.unwrap(); - } -} -
现在运行我们的简单服务器 :
-cargo run
-
-此时服务器会处于循环等待以接收连接的状态,接下来在一个新的终端窗口中启动上一章节中的 redis
客户端,由于相关代码已经放入 examples
文件夹下,因此我们可以使用 --example
来指定运行该客户端示例:
$ cargo run --example hello-redis
-
-此时,客户端的输出是: Error: "unimplemented"
, 同时服务器端打印出了客户端发来的由 redis 命令和数据 组成的数据帧: GOT: Array([Bulk(b"set"), Bulk(b"hello"), Bulk(b"world")])
。
上面的服务器,如果你仔细看,它其实一次只能接受和处理一条 TCP 连接,只有等当前的处理完并结束后,才能开始接收下一条连接。原因在于 loop
循环中的 await
会导致当前任务进入阻塞等待,也就是 loop
循环会被阻塞。
而这显然不是我们想要的,服务器能并发地处理多条连接的请求,才是正确的打开姿势,下面来看看如何实现真正的并发。
---关于并发和并行,在多线程章节中有详细的解释
-
为了并发的处理连接,需要为每一条进来的连接都生成( spawn
)一个新的任务, 然后在该任务中处理连接:
-use tokio::net::TcpListener; - -#[tokio::main] -async fn main() { - let listener = TcpListener::bind("127.0.0.1:6379").await.unwrap(); - - loop { - let (socket, _) = listener.accept().await.unwrap(); - // 为每一条连接都生成一个新的任务, - // `socket` 的所有权将被移动到新的任务中,并在那里进行处理 - tokio::spawn(async move { - process(socket).await; - }); - } -} -
一个 Tokio 任务是一个异步的绿色线程,它们通过 tokio::spawn
进行创建,该函数会返回一个 JoinHandle
类型的句柄,调用者可以使用该句柄跟创建的任务进行交互。
spawn
函数的参数是一个 async
语句块,该语句块甚至可以返回一个值,然后调用者可以通过 JoinHandle
句柄获取该值:
-#[tokio::main] -async fn main() { - let handle = tokio::spawn(async { - 10086 - }); - - let out = handle.await.unwrap(); - println!("GOT {}", out); -} -
以上代码会打印出GOT 10086
。实际上,上面代码中.await
会返回一个 Result
,若 spawn
创建的任务正常运行结束,则返回一个 Ok(T)
的值,否则会返回一个错误 Err
:例如任务内部发生了 panic
或任务因为运行时关闭被强制取消时。
任务是调度器管理的执行单元。spawn
生成的任务会首先提交给调度器,然后由它负责调度执行。需要注意的是,执行任务的线程未必是创建任务的线程,任务完全有可能运行在另一个不同的线程上,而且任务在生成后,它还可能会在线程间被移动。
任务在 Tokio 中远比看上去要更轻量,例如创建一个任务仅仅需要一次 64 字节大小的内存分配。因此应用程序在生成任务上,完全不应该有任何心理负担,除非你在一台没那么好的机器上疯狂生成了几百万个任务。。。
-'static
约束当使用 Tokio 创建一个任务时,该任务类型的生命周期必须是 'static
。意味着,在任务中不能使用外部数据的引用:
-use tokio::task; - -#[tokio::main] -async fn main() { - let v = vec![1, 2, 3]; - - task::spawn(async { - println!("Here's a vec: {:?}", v); - }); -} -
上面代码中,spawn
出的任务引用了外部环境中的变量 v
,导致以下报错:
error[E0373]: async block may outlive the current function, but
- it borrows `v`, which is owned by the current function
- --> src/main.rs:7:23
- |
-7 | task::spawn(async {
- | _______________________^
-8 | | println!("Here's a vec: {:?}", v);
- | | - `v` is borrowed here
-9 | | });
- | |_____^ may outlive borrowed value `v`
- |
-note: function requires argument type to outlive `'static`
- --> src/main.rs:7:17
- |
-7 | task::spawn(async {
- | _________________^
-8 | | println!("Here's a vector: {:?}", v);
-9 | | });
- | |_____^
-help: to force the async block to take ownership of `v` (and any other
- referenced variables), use the `move` keyword
- |
-7 | task::spawn(async move {
-8 | println!("Here's a vec: {:?}", v);
-9 | });
- |
-
-原因在于:默认情况下,变量并不是通过 move
的方式转移进 async
语句块的, v
变量的所有权依然属于 main
函数,因为任务内部的 println!
是通过借用的方式使用了 v
,但是这种借用并不能满足 'static
生命周期的要求。
在报错的同时,Rust 编译器还给出了相当有帮助的提示:为 async
语句块使用 move
关键字,这样就能将 v
的所有权从 main
函数转移到新创建的任务中。
但是 move
有一个问题,一个数据只能被一个任务使用,如果想要多个任务使用一个数据,就有些强人所难。不知道还有多少同学记得 Arc
,它可以轻松解决该问题,还是线程安全的。
在上面的报错中,还有一句很奇怪的信息function requires argument type to outlive `'static`
, 函数要求参数类型的生命周期必须比 'static
长,问题是 'static
已经活得跟整个程序一样久了,难道函数的参数还能活得更久?大家可能会觉得编译器秀逗了,毕竟其它语言编译器也有秀逗的时候:)
先别急着给它扣帽子,虽然我有时候也想这么做。。原因是它说的是类型必须活得比 'static
长,而不是值。当我们说一个值是 'static
时,意味着它将永远存活。这个很重要,因为编译器无法知道新创建的任务将存活多久,所以唯一的办法就是让任务永远存活。
如果大家对于 '&static
和 T: 'static
较为模糊,强烈建议回顾下该章节。
tokio::spawn
生成的任务必须实现 Send
特征,因为当这些任务在 .await
执行过程中发生阻塞时,Tokio 调度器会将任务在线程间移动。
一个任务要实现 Send
特征,那它在 .await
调用的过程中所持有的全部数据都必须实现 Send
特征。当 .await
调用发生阻塞时,任务会让出当前线程所有权给调度器,然后当任务准备好后,调度器会从上一次暂停的位置继续执行该任务。该流程能正确的工作,任务必须将.await
之后使用的所有状态保存起来,这样才能在中断后恢复现场并继续执行。若这些状态实现了 Send
特征(可以在线程间安全地移动),那任务自然也就可以在线程间安全地移动。
例如以下代码可以工作:
--use tokio::task::yield_now; -use std::rc::Rc; - -#[tokio::main] -async fn main() { - tokio::spawn(async { - // 语句块的使用强制了 `rc` 会在 `.await` 被调用前就被释放, - // 因此 `rc` 并不会影响 `.await`的安全性 - { - let rc = Rc::new("hello"); - println!("{}", rc); - } - - // `rc` 的作用范围已经失效,因此当任务让出所有权给当前线程时,它无需作为状态被保存起来 - yield_now().await; - }); -} -
但是下面代码就不行:
--use tokio::task::yield_now; -use std::rc::Rc; - -#[tokio::main] -async fn main() { - tokio::spawn(async { - let rc = Rc::new("hello"); - - - // `rc` 在 `.await` 后还被继续使用,因此它必须被作为任务的状态保存起来 - yield_now().await; - - - // 事实上,注释掉下面一行代码,依然会报错 - // 原因是:是否保存,不取决于 `rc` 是否被使用,而是取决于 `.await`在调用时是否仍然处于 `rc` 的作用域中 - println!("{}", rc); - - // rc 作用域在这里结束 - }); -} -
这里有一个很重要的点,代码注释里有讲到,但是我们再重复一次: rc
是否会保存到任务状态中,取决于 .await
的调用是否处于它的作用域中,上面代码中,就算你注释掉 println!
函数,该报错依然会报错,因为 rc
的作用域直到 async
的末尾才结束!
下面是相应的报错,在下一章节,我们还会继续深入讨论该错误:
-error: future cannot be sent between threads safely
- --> src/main.rs:6:5
- |
-6 | tokio::spawn(async {
- | ^^^^^^^^^^^^ future created by async block is not `Send`
- |
- ::: [..]spawn.rs:127:21
- |
-127 | T: Future + Send + 'static,
- | ---- required by this bound in
- | `tokio::task::spawn::spawn`
- |
- = help: within `impl std::future::Future`, the trait
- | `std::marker::Send` is not implemented for
- | `std::rc::Rc<&str>`
-note: future is not `Send` as this value is used across an await
- --> src/main.rs:10:9
- |
-7 | let rc = Rc::new("hello");
- | -- has type `std::rc::Rc<&str>` which is not `Send`
-...
-10 | yield_now().await;
- | ^^^^^^^^^^^^^^^^^ await occurs here, with `rc` maybe
- | used later
-11 | println!("{}", rc);
-12 | });
- | - `rc` is later dropped here
-
-现在,我们可以继续前进了,下面来实现 process
函数,它用于处理进入的命令。相应的值将被存储在 HashMap
中: 通过 SET
命令存值,通过 GET
命令来取值。
同时,我们将使用循环的方式在同一个客户端连接中处理多次连续的请求:
---#![allow(unused)] -fn main() { -use tokio::net::TcpStream; -use mini_redis::{Connection, Frame}; - -async fn process(socket: TcpStream) { - use mini_redis::Command::{self, Get, Set}; - use std::collections::HashMap; - - // 使用 hashmap 来存储 redis 的数据 - let mut db = HashMap::new(); - - // `mini-redis` 提供的便利函数,使用返回的 `connection` 可以用于从 socket 中读取数据并解析为数据帧 - let mut connection = Connection::new(socket); - - // 使用 `read_frame` 方法从连接获取一个数据帧:一条redis命令 + 相应的数据 - while let Some(frame) = connection.read_frame().await.unwrap() { - let response = match Command::from_frame(frame).unwrap() { - Set(cmd) => { - // 值被存储为 `Vec<u8>` 的形式 - db.insert(cmd.key().to_string(), cmd.value().to_vec()); - Frame::Simple("OK".to_string()) - } - Get(cmd) => { - if let Some(value) = db.get(cmd.key()) { - // `Frame::Bulk` 期待数据的类型是 `Bytes`, 该类型会在后面章节讲解, - // 此时,你只要知道 `&Vec<u8>` 可以使用 `into()` 方法转换成 `Bytes` 类型 - Frame::Bulk(value.clone().into()) - } else { - Frame::Null - } - } - cmd => panic!("unimplemented {:?}", cmd), - }; - - // 将请求响应返回给客户端 - connection.write_frame(&response).await.unwrap(); - } -} - -// main 函数在之前已实现 -} -
使用 cargo run
运行服务器,然后再打开另一个终端窗口,运行 hello-redis
客户端示例: cargo run --example hello-redis
。
Bingo,在看了这么多原理后,我们终于迈出了小小的第一步,并获取到了存在 HashMap
中的值: 从服务器端获取到结果=Some(b"world")
。
但是问题又来了:这些值无法在 TCP 连接中共享,如果另外一个用户连接上来并试图同时获取 hello
这个 key
,他将一无所获。
大家有没有想过, Rust 中的迭代器在迭代时能否异步进行?若不可以,是不是有相应的解决方案?
-以上的问题其实很重要,因为在实际场景中,迭代一个集合,然后异步的去执行是很常见的需求,好在 Tokio 为我们提供了 stream
,我们可以在异步函数中对其进行迭代,甚至和迭代器 Iterator
一样,stream
还能使用适配器,例如 map
! Tokio 在 StreamExt
特征上定义了常用的适配器。
要使用 stream
,目前还需要手动引入对应的包:
--#![allow(unused)] -fn main() { -tokio-stream = "0.1" -} -
--stream 没有放在
-tokio
包的原因在于标准库中的Stream
特征还没有稳定,一旦稳定后,stream
将移动到tokio
中来
目前, Rust 语言还不支持异步的 for
循环,因此我们需要 while let
循环和 StreamExt::next()
一起使用来实现迭代的目的:
-use tokio_stream::StreamExt; - -#[tokio::main] -async fn main() { - let mut stream = tokio_stream::iter(&[1, 2, 3]); - - while let Some(v) = stream.next().await { - println!("GOT = {:?}", v); - } -} -
和迭代器 Iterator
类似,next()
方法返回一个 Option<T>
,其中 T
是从 stream
中获取的值的类型。若收到 None
则意味着 stream
迭代已经结束。
下面我们来实现一个复杂一些的 mini-redis 客户端,完整代码见这里。
-在开始之前,首先启动一下完整的 mini-redis 服务器端:
-$ mini-redis-server
-
--use tokio_stream::StreamExt; -use mini_redis::client; - -async fn publish() -> mini_redis::Result<()> { - let mut client = client::connect("127.0.0.1:6379").await?; - - // 发布一些数据 - client.publish("numbers", "1".into()).await?; - client.publish("numbers", "two".into()).await?; - client.publish("numbers", "3".into()).await?; - client.publish("numbers", "four".into()).await?; - client.publish("numbers", "five".into()).await?; - client.publish("numbers", "6".into()).await?; - Ok(()) -} - -async fn subscribe() -> mini_redis::Result<()> { - let client = client::connect("127.0.0.1:6379").await?; - let subscriber = client.subscribe(vec!["numbers".to_string()]).await?; - let messages = subscriber.into_stream(); - - tokio::pin!(messages); - - while let Some(msg) = messages.next().await { - println!("got = {:?}", msg); - } - - Ok(()) -} - -#[tokio::main] -async fn main() -> mini_redis::Result<()> { - tokio::spawn(async { - publish().await - }); - - subscribe().await?; - - println!("DONE"); - - Ok(()) -} -
上面生成了一个异步任务专门用于发布消息到 min-redis 服务器端的 numbers
消息通道中。然后,在 main
中,我们订阅了 numbers
消息通道,并且打印从中接收到的消息。
还有几点值得注意的:
-into_stream
会将 Subscriber
变成一个 stream
stream
上调用 next
方法要求该 stream
被固定住(pinned
),因此需要调用 tokio::pin!
--关于 Pin 的详细解读,可以阅读这篇文章
-
大家可以去掉 pin!
的调用,然后观察下报错,若以后你遇到这种错误,可以尝试使用下 pin!
。
此时,可以运行下我们的客户端代码看看效果(别忘了先启动前面提到的 mini-redis 服务端):
-got = Ok(Message { channel: "numbers", content: b"1" })
-got = Ok(Message { channel: "numbers", content: b"two" })
-got = Ok(Message { channel: "numbers", content: b"3" })
-got = Ok(Message { channel: "numbers", content: b"four" })
-got = Ok(Message { channel: "numbers", content: b"five" })
-got = Ok(Message { channel: "numbers", content: b"6" })
-
-在了解了 stream
的基本用法后,我们再来看看如何使用适配器来扩展它。
在前面章节中,我们了解了迭代器有两种适配器:
-map
,filter
等collect
可以将迭代器收集成一个集合与迭代器类似,stream
也有适配器,例如一个 stream
适配器可以将一个 stream
转变成另一个 stream
,例如 map
、take
和 filter
。
在之前的客户端中,subscribe
订阅一直持续下去,直到程序被关闭。现在,让我们来升级下,让它在收到三条消息后就停止迭代,最终结束。
--#![allow(unused)] -fn main() { -let messages = subscriber - .into_stream() - .take(3); -} -
这里关键就在于 take
适配器,它会限制 stream
只能生成最多 n
条消息。运行下看看结果:
got = Ok(Message { channel: "numbers", content: b"1" })
-got = Ok(Message { channel: "numbers", content: b"two" })
-got = Ok(Message { channel: "numbers", content: b"3" })
-
-程序终于可以正常结束了。现在,让我们过滤 stream
中的消息,只保留数字类型的值:
--#![allow(unused)] -fn main() { -let messages = subscriber - .into_stream() - .filter(|msg| match msg { - Ok(msg) if msg.content.len() == 1 => true, - _ => false, - }) - .take(3); -} -
运行后输出:
-got = Ok(Message { channel: "numbers", content: b"1" })
-got = Ok(Message { channel: "numbers", content: b"3" })
-got = Ok(Message { channel: "numbers", content: b"6" })
-
-需要注意的是,适配器的顺序非常重要,.filter(...).take(3)
和 .take(3).filter(...)
的结果可能大相径庭,大家可以自己尝试下。
现在,还有一件事要做,咱们的消息被不太好看的 Ok(...)
所包裹,现在通过 map
适配器来简化下:
--#![allow(unused)] -fn main() { -let messages = subscriber - .into_stream() - .filter(|msg| match msg { - Ok(msg) if msg.content.len() == 1 => true, - _ => false, - }) - .map(|msg| msg.unwrap().content) - .take(3); -} -
注意到 msg.unwrap
了吗?大家可能会以为我们是出于示例的目的才这么用,实际上并不是,由于 filter
的先执行, map
中的 msg
只能是 Ok(...)
,因此 unwrap
非常安全。
got = b"1"
-got = b"3"
-got = b"6"
-
-还有一点可以改进的地方:当 filter
和 map
一起使用时,你往往可以用一个统一的方法来实现 filter_map
。
--#![allow(unused)] -fn main() { -let messages = subscriber - .into_stream() - .filter_map(|msg| match msg { - Ok(msg) if msg.content.len() == 1 => Some(msg.content), - _ => None, - }) - .take(3); -} -
想要学习更多的适配器,可以看看 StreamExt
特征。
如果大家还没忘记 Future
特征,那 Stream
特征相信你也会很快记住,因为它们非常类似:
--#![allow(unused)] -fn main() { -use std::pin::Pin; -use std::task::{Context, Poll}; - -pub trait Stream { - type Item; - - fn poll_next( - self: Pin<&mut Self>, - cx: &mut Context<'_> - ) -> Poll<Option<Self::Item>>; - - fn size_hint(&self) -> (usize, Option<usize>) { - (0, None) - } -} -} -
Stream::poll_next()
函数跟 Future::poll
很相似,区别就是前者为了从 stream
收到多个值需要重复的进行调用。 就像在 深入async
章节提到的那样,当一个 stream
没有做好返回一个值的准备时,它将返回一个 Poll::Pending
,同时将任务的 waker
进行注册。一旦 stream
准备好后, waker
将被调用。
通常来说,如果想要手动实现一个 Stream
,需要组合 Future
和其它 Stream
。下面,还记得在深入async
中构建的 Delay Future
吗?现在让我们来更进一步,将它转换成一个 stream
,每 10 毫秒生成一个值,总共生成 3 次:
--#![allow(unused)] -fn main() { -use tokio_stream::Stream; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Duration; - -struct Interval { - rem: usize, - delay: Delay, -} - -impl Stream for Interval { - type Item = (); - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) - -> Poll<Option<()>> - { - if self.rem == 0 { - // 去除计时器实现 - return Poll::Ready(None); - } - - match Pin::new(&mut self.delay).poll(cx) { - Poll::Ready(_) => { - let when = self.delay.when + Duration::from_millis(10); - self.delay = Delay { when }; - self.rem -= 1; - Poll::Ready(Some(())) - } - Poll::Pending => Poll::Pending, - } - } -} -} -
手动实现 Stream
特征实际上是相当麻烦的事,不幸地是,Rust 语言的 async/await
语法目前还不能用于定义 stream
,虽然相关的工作已经在进行中。
作为替代方案,async-stream
包提供了一个 stream!
宏,它可以将一个输入转换成 stream
,使用这个包,上面的代码可以这样实现:
--#![allow(unused)] -fn main() { -use async_stream::stream; -use std::time::{Duration, Instant}; - -stream! { - let mut when = Instant::now(); - for _ in 0..3 { - let delay = Delay { when }; - delay.await; - yield (); - when += Duration::from_millis(10); - } -} -} -
嗯,看上去还是相当不错的,代码可读性大幅提升!
- -是不是发现了一个关键字 yield
,他是用来配合生成器使用的。详见原文
之前的程序,如果使用 ctrl-c
的方法来关闭,所有的线程都会立即停止,这会造成正在请求的用户感知到一个明显的错误。
因此我们需要添加一些优雅关闭( Graceful Shutdown ),以更好的完成资源清理等收尾工作。
-当线程池被 drop 时,需要等待所有的子线程完成它们的工作,然后再退出,下面是一个初步尝试:
---#![allow(unused)] -fn main() { -impl Drop for ThreadPool { - fn drop(&mut self) { - for worker in &mut self.workers { - println!("Shutting down worker {}", worker.id); - - worker.thread.join().unwrap(); - } - } -} -} -
这里通过实现 Drop
特征来为线程池添加资源收尾工作,代码比较简单,就是依次调用每个线程的 join
方法。编译下试试:
--#![allow(unused)] -fn main() { -$ cargo check - Checking hello v0.1.0 (file:///projects/hello) -error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference - --> src/lib.rs:52:13 - | -52 | worker.thread.join().unwrap(); - | ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call - | | - | move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait - | -note: this function takes ownership of the receiver `self`, which moves `worker.thread` - -For more information about this error, try `rustc --explain E0507`. -error: could not compile `hello` due to previous error -} -
这里的报错很明显,worker.thread
试图拿走所有权,但是 worker
仅仅是一个可变借用,显然是不可行的。
目前来看,只能将 thread
从 worker
中移动出来,一个可行的尝试:
--#![allow(unused)] -fn main() { -struct Worker { - id: usize, - thread: Option<thread::JoinHandle<()>>, -} -} -
对于 Option
类型,可以使用 take
方法拿走内部值的所有权,同时留下一个 None
在风中孤独凌乱。继续尝试编译驱动开发模式:
$ cargo check
- Checking hello v0.1.0 (file:///projects/hello)
-error[E0599]: no method named `join` found for enum `Option` in the current scope
- --> src/lib.rs:52:27
- |
-52 | worker.thread.join().unwrap();
- | ^^^^ method not found in `Option<JoinHandle<()>>`
- |
-note: the method `join` exists on the type `JoinHandle<()>`
-help: consider using `Option::expect` to unwrap the `JoinHandle<()>` value, panicking if the value is an `Option::None`
- |
-52 | worker.thread.expect("REASON").join().unwrap();
- | +++++++++++++++++
-
-error[E0308]: mismatched types
- --> src/lib.rs:72:22
- |
-72 | Worker { id, thread }
- | ^^^^^^ expected enum `Option`, found struct `JoinHandle`
- |
- = note: expected enum `Option<JoinHandle<()>>`
- found struct `JoinHandle<_>`
-help: try wrapping the expression in `Some`
- |
-72 | Worker { id, thread: Some(thread) }
- | +++++++++++++ +
-
-先来解决第二个类型不匹配的错误:
---#![allow(unused)] -fn main() { -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - // --snip-- - - Worker { - id, - thread: Some(thread), - } - } -} -} -
简单搞定,回头看看第一个错误,既然换了 Option
,就可以用 take
拿走所有权:
--#![allow(unused)] -fn main() { -impl Drop for ThreadPool { - fn drop(&mut self) { - for worker in &mut self.workers { - println!("Shutting down worker {}", worker.id); - - if let Some(thread) = worker.thread.take() { - thread.join().unwrap(); - } - } - } -} -} -
注意这种 if let
的写法,若 worker.thread
已经是 None
,什么都不会发生,符合我们的预期; 若包含一个线程,那就拿走其所有权,然后调用 join
。
虽然调用了 join
,但是目标线程依然不会停止,原因在于它们在无限的 loop
循环等待,看起来需要借用 channel
的 drop
机制:释放 sender
发送端后,receiver
接收端会收到报错,然后再退出即可。
--#![allow(unused)] -fn main() { -pub struct ThreadPool { - workers: Vec<Worker>, - sender: Option<mpsc::Sender<Job>>, -} -// --snip-- -impl ThreadPool { - pub fn new(size: usize) -> ThreadPool { - // --snip-- - - ThreadPool { - workers, - sender: Some(sender), - } - } - - pub fn execute<F>(&self, f: F) - where - F: FnOnce() + Send + 'static, - { - let job = Box::new(f); - - self.sender.as_ref().unwrap().send(job).unwrap(); - } -} - -impl Drop for ThreadPool { - fn drop(&mut self) { - drop(self.sender.take()); - - for worker in &mut self.workers { - println!("Shutting down worker {}", worker.id); - - if let Some(thread) = worker.thread.take() { - thread.join().unwrap(); - } - } - } -} -} -
上面做了两处改变:
-sender
增加 Option
封装,这样可以用 take
拿走所有权,跟之前的 thread
一样drop
关闭发送端 sender
关闭 sender
后,将关闭对应的 channel
,意味着不会再有任何消息被发送。随后,所有的处于无限 loop
的接收端将收到一个错误,我们根据错误再进行进一步的处理。
--#![allow(unused)] -fn main() { -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - let thread = thread::spawn(move || loop { - let message = receiver.lock().unwrap().recv(); - - match message { - Ok(job) => { - println!("Worker {id} got a job; executing."); - - job(); - } - Err(_) => { - println!("Worker {id} disconnected; shutting down."); - break; - } - } - }); - - Worker { - id, - thread: Some(thread), - } - } -} -} -
为了快速验证代码是否正确,修改 main
函数,让其只接收前两个请求:
-fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - let pool = ThreadPool::new(4); - - for stream in listener.incoming().take(2) { - let stream = stream.unwrap(); - - pool.execute(|| { - handle_connection(stream); - }); - } - - println!("Shutting down."); -} -
take
是迭代器 Iterator
上的方法,会限制后续的迭代进行最多两次,然后就结束监听,随后 ThreadPool
也将超出作用域并自动触发 drop
。
$ cargo run
- Compiling hello v0.1.0 (file:///projects/hello)
- Finished dev [unoptimized + debuginfo] target(s) in 1.0s
- Running `target/debug/hello`
-Worker 0 got a job; executing.
-Shutting down.
-Shutting down worker 0
-Worker 3 got a job; executing.
-Worker 1 disconnected; shutting down.
-Worker 2 disconnected; shutting down.
-Worker 3 disconnected; shutting down.
-Worker 0 disconnected; shutting down.
-Shutting down worker 1
-Shutting down worker 2
-Shutting down worker 3
-
-可以看到,代码按照我们的设想如期运行,至此,一个基于线程池的简单 Web 服务器已经完成,下面是完整的代码:
--// src/main.rs -use hello::ThreadPool; -use std::fs; -use std::io::prelude::*; -use std::net::TcpListener; -use std::net::TcpStream; -use std::thread; -use std::time::Duration; - -fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - let pool = ThreadPool::new(4); - - for stream in listener.incoming().take(2) { - let stream = stream.unwrap(); - - pool.execute(|| { - handle_connection(stream); - }); - } - - println!("Shutting down."); -} - -fn handle_connection(mut stream: TcpStream) { - let mut buffer = [0; 1024]; - stream.read(&mut buffer).unwrap(); - - let get = b"GET / HTTP/1.1\r\n"; - let sleep = b"GET /sleep HTTP/1.1\r\n"; - - let (status_line, filename) = if buffer.starts_with(get) { - ("HTTP/1.1 200 OK", "hello.html") - } else if buffer.starts_with(sleep) { - thread::sleep(Duration::from_secs(5)); - ("HTTP/1.1 200 OK", "hello.html") - } else { - ("HTTP/1.1 404 NOT FOUND", "404.html") - }; - - let contents = fs::read_to_string(filename).unwrap(); - - let response = format!( - "{}\r\nContent-Length: {}\r\n\r\n{}", - status_line, - contents.len(), - contents - ); - - stream.write_all(response.as_bytes()).unwrap(); - stream.flush().unwrap(); -} -
--#![allow(unused)] -fn main() { -// src/lib.rs -use std::{ - sync::{mpsc, Arc, Mutex}, - thread, -}; - -pub struct ThreadPool { - workers: Vec<Worker>, - sender: Option<mpsc::Sender<Job>>, -} - -type Job = Box<dyn FnOnce() + Send + 'static>; - -impl ThreadPool { - /// Create a new ThreadPool. - /// - /// The size is the number of threads in the pool. - /// - /// # Panics - /// - /// The `new` function will panic if the size is zero. - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - let (sender, receiver) = mpsc::channel(); - - let receiver = Arc::new(Mutex::new(receiver)); - - let mut workers = Vec::with_capacity(size); - - for id in 0..size { - workers.push(Worker::new(id, Arc::clone(&receiver))); - } - - ThreadPool { - workers, - sender: Some(sender), - } - } - - pub fn execute<F>(&self, f: F) - where - F: FnOnce() + Send + 'static, - { - let job = Box::new(f); - - self.sender.as_ref().unwrap().send(job).unwrap(); - } -} - -impl Drop for ThreadPool { - fn drop(&mut self) { - drop(self.sender.take()); - - for worker in &mut self.workers { - println!("Shutting down worker {}", worker.id); - - if let Some(thread) = worker.thread.take() { - thread.join().unwrap(); - } - } - } -} - -struct Worker { - id: usize, - thread: Option<thread::JoinHandle<()>>, -} - -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - let thread = thread::spawn(move || loop { - let message = receiver.lock().unwrap().recv(); - - match message { - Ok(job) => { - println!("Worker {id} got a job; executing."); - - job(); - } - Err(_) => { - println!("Worker {id} disconnected; shutting down."); - break; - } - } - }); - - Worker { - id, - thread: Some(thread), - } - } -} -} -
事实上,我们还可以做更多,但是受制于篇幅,就不再展开,感兴趣的同学可以自行完成。
-unwrap
,替换为错误处理crates.io
上找到一个线程池实现,然后使用该包实现一个类似的 Web 服务器在上一章节的末尾,我们提到将 let
替换为 while let
后,多线程的优势将荡然无存,原因藏的很隐蔽:
Mutex
结构体没有提供显式的 unlock
,要依赖作用域结束后的 drop
来自动释放 let job = receiver.lock().unwrap().recv().unwrap();
在这行代码中,由于使用了 let
,右边的任何临时变量会在 let
语句结束后立即被 drop
,因此锁会自动释放while let
(还包括 if let
和 match
) 直到最后一个花括号后,才触发 drop
--#![allow(unused)] -fn main() { -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - let thread = thread::spawn(move || { - while let Ok(job) = receiver.lock().unwrap().recv() { - println!("Worker {id} got a job; executing."); - - job(); - } - }); - - Worker { id, thread } - } -} -} -
根据之前的分析,上面的代码直到 job()
任务执行结束后,才会释放锁,去执行另一个请求,最终造成请求排队。
一般来说,现代化的 web 服务器往往都基于更加轻量级的协程或 async/await 等模式实现,但是基于本章的内容,我们还是采取较为传统的多线程的方式来实现,即:一个请求连接分配一个线程去独立处理,当然还有升级版的线程池。
-在本章中你将了解:
--- - -本章的实现方法并不是在 Rust 中实现 Web 服务器的最佳方法,后续章节的 async/await 会更加适合!
-
目前的单线程版本只能依次处理用户的请求:一时间只能处理一个请求连接。随着用户的请求数增多,可以预料的是排在后面的用户可能要等待数十秒甚至超时!
-本章我们将解决这个问题,但是首先来模拟一个慢请求场景,看看单线程是否真的如此糟糕。
-下面的代码中,使用 sleep 的方式让每次请求持续 5 秒,模拟真实的慢请求:
---#![allow(unused)] -fn main() { -// in main.rs -use std::{ - fs, - io::{prelude::*, BufReader}, - net::{TcpListener, TcpStream}, - thread, - time::Duration, -}; -// --snip-- - -fn handle_connection(mut stream: TcpStream) { - // --snip-- - - let (status_line, filename) = match &request_line[..] { - "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"), - "GET /sleep HTTP/1.1" => { - thread::sleep(Duration::from_secs(5)); - ("HTTP/1.1 200 OK", "hello.html") - } - _ => ("HTTP/1.1 404 NOT FOUND", "404.html"), - }; - - // --snip-- -} -} -
由于增加了新的请求路径 /sleep
,之前的 if else
被修改为 match
,需要注意的是,由于 match
不会像方法那样自动做引用或者解引用,因此我们需要显式调用: match &request_line[..]
,来获取所需的 &str
类型。
可以看出,当用户访问 /sleep
时,请求会持续 5 秒后才返回,下面来试试,启动服务器后,打开你的浏览器,这次要分别打开两个页面(tab页): http://127.0.0.1:7878/
和 http://127.0.0.1:7878/sleep
。
此时,如果我们连续访问 /
路径,那效果跟之前一样:立刻看到请求的页面。但假如先访问 /sleep
,接着在另一个页面访问 /
,就会看到 /
的页面直到 5 秒后才会刷出来,验证了请求排队这个糟糕的事实。
至于如何解决,其实办法不少,本章我们来看看一个经典解决方案:线程池。
-线程池包含一组已生成的线程,它们时刻等待着接收并处理新的任务。当程序接收到新任务时,它会将线程池中的一个线程指派给该任务,在该线程忙着处理时,新来的任务会交给池中剩余的线程进行处理。最终,当执行任务的线程处理完后,它会被重新放入到线程池中,准备处理新任务。
-假设线程池中包含 N 个线程,那么可以推断出,服务器将拥有并发处理 N 个请求连接的能力,从而增加服务器的吞吐量。
-同时,我们将限制线程池中的线程数量,以保护服务器免受拒绝服务攻击(DoS)的影响:如果针对每个请求创建一个新线程,那么一个人向我们的服务器发出1000万个请求,会直接耗尽资源,导致后续用户的请求无法被处理,这也是拒绝服务名称的来源。
-因此,还需对线程池进行一定的架构设计,首先是设定最大线程数的上限,其次维护一个请求队列。池中的线程去队列中依次弹出请求并处理。这样就可以同时并发处理 N 个请求,其中 N 是线程数。
-但聪明的读者可能会想到,假如每个请求依然耗时很长,那请求队列依然会堆积,后续的用户请求还是需要等待较长的时间,毕竟你也就 N 个线程,但总归比单线程要强 N 倍吧 :D
-当然,线程池依然是较为传统的提升吞吐方法,比较新的有:单线程异步 IO,例如 redis;多线程异步 IO,例如 Rust 的主流 web 框架。事实上,大家在下一个实战项目中,会看到相关技术的应用。
-这显然不是我们的最终方案,原因在于它会生成无上限的线程数,最终导致资源耗尽。但它确实是一个好的起点:
--fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - - for stream in listener.incoming() { - let stream = stream.unwrap(); - - thread::spawn(|| { - handle_connection(stream); - }); - } -} -
这种实现下,依次访问 /sleep
和 /
就无需再等待,不错的开始。
原则上,我们希望在上面代码的基础上,尽量少的去修改,下面是一个假想的线程池 API 实现:
--fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - let pool = ThreadPool::new(4); - - for stream in listener.incoming() { - let stream = stream.unwrap(); - - pool.execute(|| { - handle_connection(stream); - }); - } -} -
代码跟之前的类似,也非常简洁明了, ThreadPool::new(4)
创建一个包含 4 个线程的线程池,接着通过 pool.execute
去分发执行请求。
显然,上面的代码无法编译,下面来逐步实现。
-你可能听说过测试驱动开发,但听过编译器驱动开发吗?来见识下 Rust 中的绝招吧。
-检查之前的代码,看看报什么错:
-$ cargo check
- Checking hello v0.1.0 (file:///projects/hello)
-error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
- --> src/main.rs:11:16
- |
-11 | let pool = ThreadPool::new(4);
- | ^^^^^^^^^^ use of undeclared type `ThreadPool`
-
-For more information about this error, try `rustc --explain E0433`.
-error: could not compile `hello` due to previous error
-
-俗话说,不怕敌人很强,就怕他们不犯错,很好,编译器漏出了破绽。看起来我们需要实现 ThreadPool
类型。看起来,还需要添加一个库包,未来线程池的代码都将在这个独立的包中完成,甚至于未来你要实现其它的服务,也可以复用这个多线程库包。
创建 src/lib.rs
文件并写入如下代码:
--#![allow(unused)] -fn main() { -pub struct ThreadPool; -} -
接着在 main.rs
中引入:
--#![allow(unused)] -fn main() { -// main.rs -use hello::ThreadPool; -} -
编译后依然报错:
-$ cargo check
- Checking hello v0.1.0 (file:///projects/hello)
-error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
- --> src/main.rs:12:28
- |
-12 | let pool = ThreadPool::new(4);
- | ^^^ function or associated item not found in `ThreadPool`
-
-For more information about this error, try `rustc --explain E0599`.
-error: could not compile `hello` due to previous error
-
-好,继续实现 new
函数 :
--#![allow(unused)] -fn main() { -pub struct ThreadPool; - -impl ThreadPool { - pub fn new(size: usize) -> ThreadPool { - ThreadPool - } -} -} -
继续检查:
-$ cargo check
- Checking hello v0.1.0 (file:///projects/hello)
-error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
- --> src/main.rs:17:14
- |
-17 | pool.execute(|| {
- | ^^^^^^^ method not found in `ThreadPool`
-
-For more information about this error, try `rustc --explain E0599`.
-error: could not compile `hello` due to previous error
-
-这个方法类似于 thread::spawn
,用于将闭包中的任务交给某个空闲的线程去执行。
其实这里有一个小难点:execute
的参数是一个闭包,回忆下之前学过的内容,闭包作为参数时可以由三个特征进行约束: Fn
、FnMut
和 FnOnce
,选哪个就成为一个问题。由于 execute
在实现上类似 thread::spawn
,我们可以参考下后者的签名如何声明。
--#![allow(unused)] -fn main() { -pub fn spawn<F, T>(f: F) -> JoinHandle<T> - where - F: FnOnce() -> T, - F: Send + 'static, - T: Send + 'static, -} -
可以看出,spawn
选择 FnOnce
作为 F
闭包的特征约束,原因是闭包作为任务只需被线程执行一次即可。
F
还有一个特征约束 Send
,也可以照抄过来,毕竟闭包需要从一个线程传递到另一个线程,至于生命周期约束 'static
,是因为我们并不知道线程需要多久时间来执行该任务。
--#![allow(unused)] -fn main() { -impl ThreadPool { - // --snip-- - pub fn execute<F>(&self, f: F) - where - F: FnOnce() + Send + 'static, - { - } -} -} -
在理解 spawn
后,就可以轻松写出如上的 execute
实现,注意这里的 FnOnce()
跟 spawn
有所不同,原因是要 execute
传入的闭包没有参数也没有返回值。
$ cargo check
- Checking hello v0.1.0 (file:///projects/hello)
- Finished dev [unoptimized + debuginfo] target(s) in 0.24s
-
-成功编译,但在浏览器访问依然会报之前类似的错误,下面来实现 execute
。
new
还是 build
关于 ThreadPool
的构造函数,存在两个选择 new
和 build
。
new
往往用于简单初始化一个实例,而 build
往往会完成更加复杂的构建工作,例如入门实战中的 Config::build
。
在这个项目中,我们并不需要在初始化线程池的同时创建相应的线程,因此 new
是更适合的选择:
--#![allow(unused)] -fn main() { -impl ThreadPool { - /// Create a new ThreadPool. - /// - /// The size is the number of threads in the pool. - /// - /// # Panics - /// - /// The `new` function will panic if the size is zero. - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - ThreadPool - } - - // --snip-- -} -} -
这里有两点值得注意:
-usize
类型包含 0
,但是创建没有任何线程的线程池显然是无意义的,因此做一下 assert!
验证ThreadPool
拥有不错的文档注释,甚至包含了可能 panic
的情况,通过 cargo doc --open
可以访问文档注释创建 ThreadPool
后,下一步就是存储具体的线程,既然要存放线程,一个绕不过去的问题就是:用什么类型来存放,例如假如使用 Vect<T>
来存储,那这个 T
应该是什么?
估计还得探索下 thread::spawn
的签名,毕竟它生成并返回一个线程:
--#![allow(unused)] -fn main() { -pub fn spawn<F, T>(f: F) -> JoinHandle<T> - where - F: FnOnce() -> T, - F: Send + 'static, - T: Send + 'static, -} -
看起来 JoinHandle<T>
是我们需要的,这里的 T
是传入的闭包任务所返回的,我们的任务无需任何返回,因此 T
直接使用 ()
即可。
--#![allow(unused)] -fn main() { -use std::thread; - -pub struct ThreadPool { - threads: Vec<thread::JoinHandle<()>>, -} - -impl ThreadPool { - // --snip-- - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - let mut threads = Vec::with_capacity(size); - - for _ in 0..size { - // create some threads and store them in the vector - } - - ThreadPool { threads } - } - // --snip-- -} -} -
如上所示,最终我们使用 Vec<thread::JoinHandle<()>>
来存储线程,同时设定了容量上限 with_capacity(size)
,该方法还可以提前分配好内存空间,比 Vec::new
的性能要更好一点。
上面的代码留下一个未实现的 for
循环,用于创建和存储线程。
学过多线程一章后,大家应该知道 thread::spawn
虽然是生成线程最好的方式,但是它会立即执行传入的任务,然而,在我们的使用场景中,创建线程和执行任务明显是要分离的,因此标准库看起来不再适合。
可以考虑创建一个 Worker
结构体,作为 ThreadPool
和任务线程联系的桥梁,它的任务是获得将要执行的代码,然后在具体的线程中去执行。想象一个场景:一个餐馆,Worker
等待顾客的点餐,然后将具体的点餐信息传递给厨房,感觉类似服务员?
引入 Worker
后,就无需再存储 JoinHandle<()>
实例,直接存储 Worker
实例:该实例内部会存储 JoinHandle<()>
。下面是新的线程池创建流程:
--#![allow(unused)] -fn main() { -use std::thread; - -pub struct ThreadPool { - workers: Vec<Worker>, -} - -impl ThreadPool { - // --snip-- - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - let mut workers = Vec::with_capacity(size); - - for id in 0..size { - workers.push(Worker::new(id)); - } - - ThreadPool { workers } - } - // --snip-- -} - -struct Worker { - id: usize, - thread: thread::JoinHandle<()>, -} - -impl Worker { - fn new(id: usize) -> Worker { - // 尚未实现.. - let thread = thread::spawn(|| {}); - // 每个 `Worker` 都拥有自己的唯一 id - Worker { id, thread } - } -} -} -
由于外部调用者无需知道 Worker
的存在,因此这里使用了私有的声明。
大家可以编译下代码,如果出错了,请仔细检查下,是否遗漏了什么,截止目前,代码是完全可以通过编译的,但是任务该怎么执行依然还没有实现。
-在上面的代码中, thread::spawn(|| {})
还没有给予实质性的内容,现在一起来完善下。
首先 Worker
结构体需要从线程池 TreadPool
的队列中获取待执行的代码,对于这类场景,消息传递非常适合:我们将使用消息通道( channel )作为任务队列。
--#![allow(unused)] -fn main() { -use std::{sync::mpsc, thread}; - -pub struct ThreadPool { - workers: Vec<Worker>, - sender: mpsc::Sender<Job>, -} - -struct Job; - -impl ThreadPool { - // --snip-- - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - let (sender, receiver) = mpsc::channel(); - - let mut workers = Vec::with_capacity(size); - - for id in 0..size { - workers.push(Worker::new(id)); - } - - ThreadPool { workers, sender } - } - // --snip-- -} -} -
阅读过之前内容的同学应该知道,消息通道有发送端和接收端,其中线程池 ThreadPool
持有发送端,通过 execute
方法来发送任务。那么问题来了,谁持有接收端呢?答案是 Worker
,它的内部线程将接收任务,然后进行处理。
--#![allow(unused)] -fn main() { -impl ThreadPool { - // --snip-- - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - let (sender, receiver) = mpsc::channel(); - - let mut workers = Vec::with_capacity(size); - - for id in 0..size { - workers.push(Worker::new(id, receiver)); - } - - ThreadPool { workers, sender } - } - // --snip-- -} - -// --snip-- - -impl Worker { - fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker { - let thread = thread::spawn(|| { - receiver; - }); - - Worker { id, thread } - } -} -} -
看起来很美好,但是很不幸,它会报错:
-$ cargo check
- Checking hello v0.1.0 (file:///projects/hello)
-error[E0382]: use of moved value: `receiver`
- --> src/lib.rs:26:42
- |
-21 | let (sender, receiver) = mpsc::channel();
- | -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
-...
-26 | workers.push(Worker::new(id, receiver));
- | ^^^^^^^^ value moved here, in previous iteration of loop
-
-For more information about this error, try `rustc --explain E0382`.
-error: could not compile `hello` due to previous error
-
-原因也很简单,receiver
并没有实现 Copy
,因此它的所有权在第一次循环中,就被传入到第一个 Worker
实例中,后续自然无法再使用。
报错就解决呗,但 Rust 中的 channel 实现是 mpsc,即多生产者单消费者,因此我们无法通过克隆消费者的方式来修复这个错误。当然,发送多条消息给多个接收者也不在考虑范畴,该怎么办?似乎陷入了绝境。
-雪上加霜的是,就算 receiver
可以克隆,但是你得保证同一个时间只有一个receiver
能接收消息,否则一个任务可能同时被多个 Worker
执行,因此多个线程需要安全的共享和使用 receiver
,等等,安全的共享?听上去 Arc
这个多所有权结构非常适合,互斥使用?貌似 Mutex
很适合,结合一下,Arc<Mutex<T>>
,这不就是我们之前见过多次的线程安全类型吗?
总之,Arc
允许多个 Worker
同时持有 receiver
,而 Mutex
可以确保一次只有一个 Worker
能从 receiver
接收消息。
--#![allow(unused)] -fn main() { -use std::{ - sync::{mpsc, Arc, Mutex}, - thread, -}; -// --snip-- - -impl ThreadPool { - // --snip-- - pub fn new(size: usize) -> ThreadPool { - assert!(size > 0); - - let (sender, receiver) = mpsc::channel(); - - let receiver = Arc::new(Mutex::new(receiver)); - - let mut workers = Vec::with_capacity(size); - - for id in 0..size { - workers.push(Worker::new(id, Arc::clone(&receiver))); - } - - ThreadPool { workers, sender } - } - - // --snip-- -} - -// --snip-- - -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - // --snip-- - } -} -} -
修改后,每一个 Worker 都可以安全的持有 receiver
,同时不必担心一个任务会被重复执行多次,完美!
首先,需要为一个很长的类型创建一个别名, 有多长呢?
---#![allow(unused)] -fn main() { -// --snip-- - -type Job = Box<dyn FnOnce() + Send + 'static>; - -impl ThreadPool { - // --snip-- - - pub fn execute<F>(&self, f: F) - where - F: FnOnce() + Send + 'static, - { - let job = Box::new(f); - - self.sender.send(job).unwrap(); - } -} - -// --snip-- -} -
创建别名的威力暂时还看不到,敬请期待。总之,这里的工作很简单,将传入的任务包装成 Job
类型后,发送出去。
但是还没完,接收的代码也要完善下:
---#![allow(unused)] -fn main() { -// --snip-- - -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - let thread = thread::spawn(move || loop { - let job = receiver.lock().unwrap().recv().unwrap(); - - println!("Worker {id} got a job; executing."); - - job(); - }); - - Worker { id, thread } - } -} -} -
修改后,就可以不停地循环去接收任务,最后进行执行。还可以看到因为之前 Job
别名的引入, new
函数的签名才没有过度复杂,否则你将看到的是 fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Box<dyn FnOnce() + Send + 'static>>>>) -> Worker
,感受下类型别名的威力吧 :D
lock()
方法可以获得一个 Mutex
锁,至于为何使用 unwrap
,难道获取锁还能失败?没错,假如当前持有锁的线程 panic
了,那么这些等待锁的线程就会获取一个错误,因此 通过 unwrap
来让当前等待的线程 panic
是一个不错的解决方案,当然你还可以换成 expect
。
一旦获取到锁里的内容 mpsc::Receiver<Job>>
后,就可以调用其上的 recv
方法来接收消息,依然是一个 unwrap
,原因在于持有发送端的线程可能会被关闭,这种情况下直接 panic
也是不错的。
recv
的调用过程是阻塞的,意味着若没有任何任务,那当前的调用线程将一直等待,直到接收到新的任务。Mutex<T>
可以同一个任务只会被一个 Worker 获取,不会被重复执行。
$ cargo run
- Compiling hello v0.1.0 (file:///projects/hello)
-warning: field is never read: `workers`
- --> src/lib.rs:7:5
- |
-7 | workers: Vec<Worker>,
- | ^^^^^^^^^^^^^^^^^^^^
- |
- = note: `#[warn(dead_code)]` on by default
-
-warning: field is never read: `id`
- --> src/lib.rs:48:5
- |
-48 | id: usize,
- | ^^^^^^^^^
-
-warning: field is never read: `thread`
- --> src/lib.rs:49:5
- |
-49 | thread: thread::JoinHandle<()>,
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-warning: `hello` (lib) generated 3 warnings
- Finished dev [unoptimized + debuginfo] target(s) in 1.40s
- Running `target/debug/hello`
-Worker 0 got a job; executing.
-Worker 2 got a job; executing.
-Worker 1 got a job; executing.
-Worker 3 got a job; executing.
-Worker 0 got a job; executing.
-Worker 2 got a job; executing.
-Worker 1 got a job; executing.
-Worker 3 got a job; executing.
-Worker 0 got a job; executing.
-Worker 2 got a job; executing.
-
-终于,程序如愿运行起来,我们的线程池可以并发处理任务了!从打印的数字可以看到,只有 4 个线程去执行任务,符合我们对线程池的要求,这样再也不用担心系统的线程资源会被消耗殆尽了!
---注意: 处于缓存的考虑,有些浏览器会对多次同样的请求进行顺序的执行,因此你可能还是会遇到访问
-/sleep
后,就无法访问另一个/sleep
的问题 :(
还有一个问题,为啥之前我们不用 while let
来循环?例如:
--#![allow(unused)] -fn main() { -// --snip-- - -impl Worker { - fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker { - let thread = thread::spawn(move || { - while let Ok(job) = receiver.lock().unwrap().recv() { - println!("Worker {id} got a job; executing."); - - job(); - } - }); - - Worker { id, thread } - } -} -} -
这段代码编译起来没问题,但是并不会产生我们预期的结果:后续请求依然需要等待慢请求的处理完成后,才能被处理。奇怪吧,仅仅是从 let
改成 while let
就会变成这样?大家可以思考下为什么会这样,具体答案会在下一章节末尾给出,这里先出给一个小提示:Mutex
获取的锁在作用域结束后才会被释放。
在开始之前先来简单回顾下构建所需的网络协议: HTTP 和 TCP。这两种协议都是请求-应答模式的网络协议,意味着在客户端发起请求后,服务器会监听并处理进入的请求,最后给予应答,至于这个过程怎么进行,取决于具体的协议定义。
-与 HTTP 有所不同, TCP 是一个底层协议,它仅描述客户端传递了信息给服务器,至于这个信息长什么样,怎么解析处理,则不在该协议的职责范畴内。
-而 HTTP 协议是更高层的通信协议,一般来说都基于 TCP 来构建 (HTTP/3 是基于 UDP 构建的协议),更高层的协议也意味着它会对传输的信息进行解析处理。
-更加深入的学习网络协议并不属于本书的范畴,因此让我们从如何读取 TCP 传输的字节流开始吧。
-先来创建一个全新的项目:
-$ cargo new hello
- Created binary (application) `hello` project
-$ cd hello
-
-接下来,使用 std::net
模块监听进入的请求连接,IP和端口是 127.0.0.1:7878
。
-use std::net::TcpListener; - -fn main() { - // 监听地址: 127.0.0.1:7878 - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - - for stream in listener.incoming() { - let stream = stream.unwrap(); - - println!("Connection established!"); - } -} -
选择 7878
端口的原因是,80
和 8080
往往都被 HTTP 服务器霸占,因此我们需要选择一个跟已经监听的端口不冲突的。
bind
非常类似 new
,它返回一个 TcpListener
实例,只不过我们一般都说 "绑定到某个端口",因此 bind
这个名称会更合适。
unwrap
的使用是因为 bind
返回 Result<T,E>
,毕竟监听是有可能报错的,例如:如果要监听 80
端口往往需要管理员权限;监听了同样的端口,等等。
incoming
会返回一个迭代器,它每一次迭代都会返回一个新的连接 stream
(客户端发起,web服务器监听接收),因此,接下来要做的就是从 stream
中读取数据,然后返回处理后的结果。
细心的同学可能会注意到,代码中对 stream
还进行了一次 unwrap
处理,原因在于我们并不是在迭代一个一个连接,而是在迭代处理一个一个请求建立连接的尝试,而这种尝试可能会失败!例如,操作系统的最大连接数限制。
现在,启动服务器,然后在你的浏览器中,访问地址 127.0.0.1:7878
,这时应该会看到一条错误信息,类似: "Connection reset",毕竟我们的服务器目前只是接收了连接,并没有回复任何数据。
$ cargo run
- Running `target/debug/hello`
-Connection established!
-Connection established!
-Connection established!
-
-无论浏览器怎么摆烂,我们的服务器还是成功打出了信息:TCP 连接已经成功建立。
-可能大家会疑问,为啥在浏览器访问一次,可能会在终端打印出多次请求建立的信息,难道不是应该一一对应吗?原因在于当 stream
超出作用域时,会触发 drop
的扫尾工作,其中包含了关闭连接。但是,浏览器可能会存在自动重试的情况,因此还会重新建立连接,最终打印了多次。
由于 listener.incoming
会在当前阻塞式监听,也就是 main
线程会被阻塞,我们最后需要通过 ctrl + c
来结束程序进程。
连接建立后,就可以开始读取客户端传来的数据:
--use std::{ - io::{prelude::*, BufReader}, - net::{TcpListener, TcpStream}, -}; - -fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - - for stream in listener.incoming() { - let stream = stream.unwrap(); - - handle_connection(stream); - } -} - -fn handle_connection(mut stream: TcpStream) { - let buf_reader = BufReader::new(&mut stream); - let http_request: Vec<_> = buf_reader - .lines() - .map(|result| result.unwrap()) - .take_while(|line| !line.is_empty()) - .collect(); - - println!("Request: {:#?}", http_request); -} -
这段代码有几点值得注意:
-std::io::prelude
和 std::io::BufReader
是引入相应的特征和类型,帮助我们读取和写入数据BufReader
可以实现缓冲区读取,底层其实是基于 std::io::Read
实现lines
方法来获取一个迭代器,可以对传输的内容流进行按行迭代读取,要使用该方法,必须先引入 std::io::BufRead
collect
消费掉迭代器,最终客户端发来的请求数据被存到 http_request
这个动态数组中大家可能会比较好奇,该如何判断客户端发来的 HTTP 数据是否读取完成,答案就在于客户端会在请求数据的结尾附上两个换行符,当我们检测到某一行字符串为空时,就意味着请求数据已经传输完毕,可以 collect
了。
来运行下试试:
-$ cargo run
- Compiling hello v0.1.0 (file:///projects/hello)
- Finished dev [unoptimized + debuginfo] target(s) in 0.42s
- Running `target/debug/hello`
-Request: [
- "GET / HTTP/1.1",
- "Host: 127.0.0.1:7878",
- "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
- "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
- "Accept-Language: en-US,en;q=0.5",
- "Accept-Encoding: gzip, deflate, br",
- "DNT: 1",
- "Connection: keep-alive",
- "Upgrade-Insecure-Requests: 1",
- "Sec-Fetch-Dest: document",
- "Sec-Fetch-Mode: navigate",
- "Sec-Fetch-Site: none",
- "Sec-Fetch-User: ?1",
- "Cache-Control: max-age=0",
-]
-
-呦,还挺长的,是不是长得很像我们以前见过的 HTTP 请求 JSON,来简单分析下。
-刚才的文本挺长的,但其实符合以下的格式:
-Method Request-URI HTTP-Version
-headers CRLF
-message-body
-
-GET
、POST
等,Request-URI 是该请求希望访问的目标资源路径,例如 /
、/hello/world
等"Host: 127.0.0.1:7878"
GET
请求,它是没有 message-body 的大家可以尝试换一个浏览器再访问一次,看看不同的浏览器请求携带的 headers 是否不同。
-目前为止,都是在服务器端的操作,浏览器的请求依然还会报错,是时候给予相应的请求应答了,HTTP 格式类似:
-HTTP-Version Status-Code Reason-Phrase CRLF
-headers CRLF
-message-body
-
-应答的格式与请求相差不大,其中 Status-Code 是最重要的,它用于告诉客户端,当前的请求是否成功,若失败,大概是什么原因,它就是著名的 HTTP 状态码,常用的有 200
: 请求成功,404
目标不存在,等等。
为了帮助大家更直观的感受下应答格式第一行长什么样,下面给出一个示例:
-HTTP/1.1 200 OK\r\n\r\n
-
-下面将该应答发送回客户端:
---#![allow(unused)] -fn main() { -fn handle_connection(mut stream: TcpStream) { - let buf_reader = BufReader::new(&mut stream); - let http_request: Vec<_> = buf_reader - .lines() - .map(|result| result.unwrap()) - .take_while(|line| !line.is_empty()) - .collect(); - - let response = "HTTP/1.1 200 OK\r\n\r\n"; - - stream.write_all(response.as_bytes()).unwrap(); -} -} -
由于 write_all
方法接受 &[u8]
类型作为参数,这里需要用 as_bytes
将字符串转换为字节数组。
重新启动服务器,然后再观察下浏览器中的输出,这次应该不再有报错,而是一个空白页面,因为没有返回任何具体的数据( message-body ),上面只是一条最简单的符合 HTTP 格式的数据。
-空白页面显然会让人不知所措,那就返回一个简单的 HTML 页面,给用户打给招呼。
-在项目的根目录下创建 hello.html
文件并写入如下内容:
<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="utf-8">
- <title>Hello!</title>
- </head>
- <body>
- <h1>Hello!</h1>
- <p>Hi from Rust</p>
- </body>
-</html>
-
-看得出来,这是一个非常简单的 HTML5 网页文档,基本上没人读不懂吧 :)
---#![allow(unused)] -fn main() { -use std::{ - fs, - io::{prelude::*, BufReader}, - net::{TcpListener, TcpStream}, -}; -// --snip-- - -fn handle_connection(mut stream: TcpStream) { - let buf_reader = BufReader::new(&mut stream); - let http_request: Vec<_> = buf_reader - .lines() - .map(|result| result.unwrap()) - .take_while(|line| !line.is_empty()) - .collect(); - - let status_line = "HTTP/1.1 200 OK"; - let contents = fs::read_to_string("hello.html").unwrap(); - let length = contents.len(); - - let response = - format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"); - - stream.write_all(response.as_bytes()).unwrap(); -} -} -
新修改的代码中,读取了新增 HTML 的内容,并按照 HTTP 格式,将内容传回给客户端。
-具体的运行验证就不再赘述,我们再来看看如何增加一些验证和选择性回复。
---用这么奇怪的格式返回应答数据,原因只有一个,我们在模拟实现真正的 http web 服务器框架。事实上,写逻辑代码时,只需使用现成的 web 框架( 例如
-rocket
)去启动 web 服务即可,解析请求数据和返回应答数据都已经被封装在 API 中,非常简单易用
用户想要获取他的个人信息,你给他 say hi,用户想要查看他的某篇文章内容,你给他 say hi, 好吧用户想要骂你,你还是给它 say hi。
-是的,这种服务态度我们很欣赏,但是这种服务质量属实令人堪忧。因此我们要针对用户的不同请求给出相应的不同回复,让场景模拟更加真实。
---#![allow(unused)] -fn main() { -fn handle_connection(mut stream: TcpStream) { - let buf_reader = BufReader::new(&mut stream); - let request_line = buf_reader.lines().next().unwrap().unwrap(); - - if request_line == "GET / HTTP/1.1" { - let status_line = "HTTP/1.1 200 OK"; - let contents = fs::read_to_string("hello.html").unwrap(); - let length = contents.len(); - - let response = format!( - "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}" - ); - - stream.write_all(response.as_bytes()).unwrap(); - } else { - // some other request - } -} -} -
注意迭代器方法 next
的使用,原因在于我们只需要读取第一行,判断具体的 HTTP METHOD 是什么。
接着判断了用户是否请求了 /
根路径,如果是,返回之前的 hello.html
页面;如果不是...尚未实现。
重新运行服务器,如果你继续访问 127.0.0.1:7878
,那么看到的依然是 hello.html
页面,因为默认访问根路径,但是一旦换一个路径访问,例如 127.0.0.1:7878/something-else
,那你将继续看到之前看过多次的连接错误。
下面来完善下,当用户访问根路径之外的页面时,给他展示一个友好的 404 页面( 相比直接报错 )。
---#![allow(unused)] -fn main() { - // --snip-- - } else { - let status_line = "HTTP/1.1 404 NOT FOUND"; - let contents = fs::read_to_string("404.html").unwrap(); - let length = contents.len(); - - let response = format!( - "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}" - ); - - stream.write_all(response.as_bytes()).unwrap(); - } -} -
哦对了,别忘了在根路径下创建 404.html
并填入下面内容:
<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="utf-8">
- <title>你好!</title>
- </head>
- <body>
- <h1>很抱歉!</h1>
- <p>由于运维删库跑路,我们的数据全部丢失,总监也已经准备跑路,88</p>
- </body>
-</html>
-
-最后,上面的代码其实有很多重复,可以提取出来进行简单重构:
---#![allow(unused)] -fn main() { -// --snip-- - -fn handle_connection(mut stream: TcpStream) { - // --snip-- - - let (status_line, filename) = if request_line == "GET / HTTP/1.1" { - ("HTTP/1.1 200 OK", "hello.html") - } else { - ("HTTP/1.1 404 NOT FOUND", "404.html") - }; - - let contents = fs::read_to_string(filename).unwrap(); - let length = contents.len(); - - let response = - format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"); - - stream.write_all(response.as_bytes()).unwrap(); -} -} -
至此,单线程版本的服务器已经完成,但是说实话,没啥用,总不能让你的用户排队等待访问吧,那也太糟糕了...
- - -在入门章节中,我们简单学习了该如何使用 async/.await
, 同时在后面也了解了一些底层原理,现在是时候继续深入了。
async/.await
是 Rust 语法的一部分,它在遇到阻塞操作时( 例如 IO )会让出当前线程的所有权而不是阻塞当前线程,这样就允许当前线程继续去执行其它代码,最终实现并发。
有两种方式可以使用 async
: async fn
用于声明函数,async { ... }
用于声明语句块,它们会返回一个实现 Future
特征的值:
--#![allow(unused)] -fn main() { -// `foo()`返回一个`Future<Output = u8>`, -// 当调用`foo().await`时,该`Future`将被运行,当调用结束后我们将获取到一个`u8`值 -async fn foo() -> u8 { 5 } - -fn bar() -> impl Future<Output = u8> { - // 下面的`async`语句块返回`Future<Output = u8>` - async { - let x: u8 = foo().await; - x + 5 - } -} -} -
async
是懒惰的,直到被执行器 poll
或者 .await
后才会开始运行,其中后者是最常用的运行 Future
的方法。 当 .await
被调用时,它会尝试运行 Future
直到完成,但是若该 Future
进入阻塞,那就会让出当前线程的控制权。当 Future
后面准备再一次被运行时(例如从 socket
中读取到了数据),执行器会得到通知,并再次运行该 Future
,如此循环,直到完成。
以上过程只是一个简述,详细内容在底层探秘中已经被深入讲解过,因此这里不再赘述。
-async
的生命周期async fn
函数如果拥有引用类型的参数,那它返回的 Future
的生命周期就会被这些参数的生命周期所限制:
--#![allow(unused)] -fn main() { -async fn foo(x: &u8) -> u8 { *x } - -// 上面的函数跟下面的函数是等价的: -fn foo_expanded<'a>(x: &'a u8) -> impl Future<Output = u8> + 'a { - async move { *x } -} -} -
意味着 async fn
函数返回的 Future
必须满足以下条件: 当 x
依然有效时, 该 Future
就必须继续等待( .await
), 也就是说 x
必须比 Future
活得更久。
在一般情况下,在函数调用后就立即 .await
不会存在任何问题,例如foo(&x).await
。但是,若 Future
被先存起来或发送到另一个任务或者线程,就可能存在问题了:
--#![allow(unused)] -fn main() { -use std::future::Future; -fn bad() -> impl Future<Output = u8> { - let x = 5; - borrow_x(&x) // ERROR: `x` does not live long enough -} - -async fn borrow_x(x: &u8) -> u8 { *x } -} -
以上代码会报错,因为 x
的生命周期只到 bad
函数的结尾。 但是 Future
显然会活得更久:
error[E0597]: `x` does not live long enough
- --> src/main.rs:4:14
- |
-4 | borrow_x(&x) // ERROR: `x` does not live long enough
- | ---------^^-
- | | |
- | | borrowed value does not live long enough
- | argument requires that `x` is borrowed for `'static`
-5 | }
- | - `x` dropped here while still borrowed
-
-其中一个常用的解决方法就是将具有引用参数的 async fn
函数转变成一个具有 'static
生命周期的 Future
。 以上解决方法可以通过将参数和对 async fn
的调用放在同一个 async
语句块来实现:
--#![allow(unused)] -fn main() { -use std::future::Future; - -async fn borrow_x(x: &u8) -> u8 { *x } - -fn good() -> impl Future<Output = u8> { - async { - let x = 5; - borrow_x(&x).await - } -} -} -
如上所示,通过将参数移动到 async
语句块内, 我们将它的生命周期扩展到 'static
, 并跟返回的 Future
保持了一致。
async
允许我们使用 move
关键字来将环境中变量的所有权转移到语句块内,就像闭包那样,好处是你不再发愁该如何解决借用生命周期的问题,坏处就是无法跟其它代码实现对变量的共享:
--#![allow(unused)] -fn main() { -// 多个不同的 `async` 语句块可以访问同一个本地变量,只要它们在该变量的作用域内执行 -async fn blocks() { - let my_string = "foo".to_string(); - - let future_one = async { - // ... - println!("{my_string}"); - }; - - let future_two = async { - // ... - println!("{my_string}"); - }; - - // 运行两个 Future 直到完成 - let ((), ()) = futures::join!(future_one, future_two); -} - - - -// 由于 `async move` 会捕获环境中的变量,因此只有一个 `async move` 语句块可以访问该变量, -// 但是它也有非常明显的好处: 变量可以转移到返回的 Future 中,不再受借用生命周期的限制 -fn move_block() -> impl Future<Output = ()> { - let my_string = "foo".to_string(); - async move { - // ... - println!("{my_string}"); - } -} -} -
需要注意的是,当使用多线程 Future
执行器( executor
)时, Future
可能会在线程间被移动,因此 async
语句块中的变量必须要能在线程间传递。 至于 Future
会在线程间移动的原因是:它内部的任何.await
都可能导致它被切换到一个新线程上去执行。
由于需要在多线程环境使用,意味着 Rc
、 RefCell
、没有实现 Send
的所有权类型、没有实现 Sync
的引用类型,它们都是不安全的,因此无法被使用
--需要注意!实际上它们还是有可能被使用的,只要在
-.await
调用期间,它们没有在作用域范围内。
类似的原因,在 .await
时使用普通的锁也不安全,例如 Mutex
。原因是,它可能会导致线程池被锁:当一个任务获取锁 A
后,若它将线程的控制权还给执行器,然后执行器又调度运行另一个任务,该任务也去尝试获取了锁 A
,结果当前线程会直接卡死,最终陷入死锁中。
因此,为了避免这种情况的发生,我们需要使用 futures
包下的锁 futures::lock
来替代 Mutex
完成任务。
Stream
特征类似于 Future
特征,但是前者在完成前可以生成多个值,这种行为跟标准库中的 Iterator
特征倒是颇为相似。
--#![allow(unused)] -fn main() { -trait Stream { - // Stream生成的值的类型 - type Item; - - // 尝试去解析Stream中的下一个值, - // 若无数据,返回`Poll::Pending`, 若有数据,返回 `Poll::Ready(Some(x))`, `Stream`完成则返回 `Poll::Ready(None)` - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) - -> Poll<Option<Self::Item>>; -} -} -
关于 Stream
的一个常见例子是消息通道( futures
包中的)的消费者 Receiver
。每次有消息从 Send
端发送后,它都可以接收到一个 Some(val)
值, 一旦 Send
端关闭( drop
),且消息通道中没有消息后,它会接收到一个 None
值。
--#![allow(unused)] -fn main() { -async fn send_recv() { - const BUFFER_SIZE: usize = 10; - let (mut tx, mut rx) = mpsc::channel::<i32>(BUFFER_SIZE); - - tx.send(1).await.unwrap(); - tx.send(2).await.unwrap(); - drop(tx); - - // `StreamExt::next` 类似于 `Iterator::next`, 但是前者返回的不是值,而是一个 `Future<Output = Option<T>>`, - // 因此还需要使用`.await`来获取具体的值 - assert_eq!(Some(1), rx.next().await); - assert_eq!(Some(2), rx.next().await); - assert_eq!(None, rx.next().await); -} -} -
跟迭代器类似,我们也可以迭代一个 Stream
。 例如使用 map
,filter
,fold
方法,以及它们的遇到错误提前返回的版本: try_map
,try_filter
,try_fold
。
但是跟迭代器又有所不同,for
循环无法在这里使用,但是命令式风格的循环while let
是可以用的,同时还可以使用next
和 try_next
方法:
--#![allow(unused)] -fn main() { -async fn sum_with_next(mut stream: Pin<&mut dyn Stream<Item = i32>>) -> i32 { - use futures::stream::StreamExt; // 引入 next - let mut sum = 0; - while let Some(item) = stream.next().await { - sum += item; - } - sum -} - -async fn sum_with_try_next( - mut stream: Pin<&mut dyn Stream<Item = Result<i32, io::Error>>>, -) -> Result<i32, io::Error> { - use futures::stream::TryStreamExt; // 引入 try_next - let mut sum = 0; - while let Some(item) = stream.try_next().await? { - sum += item; - } - Ok(sum) -} -} -
上面代码是一次处理一个值的模式,但是需要注意的是:如果你选择一次处理一个值的模式,可能会造成无法并发,这就失去了异步编程的意义。 因此,如果可以的话我们还是要选择从一个 Stream
并发处理多个值的方式,通过 for_each_concurrent
或 try_for_each_concurrent
方法来实现:
- - --#![allow(unused)] -fn main() { -async fn jump_around( - mut stream: Pin<&mut dyn Stream<Item = Result<u8, io::Error>>>, -) -> Result<(), io::Error> { - use futures::stream::TryStreamExt; // 引入 `try_for_each_concurrent` - const MAX_CONCURRENT_JUMPERS: usize = 100; - - stream.try_for_each_concurrent(MAX_CONCURRENT_JUMPERS, |num| async move { - jump_n_times(num).await?; - report_n_jumps(num).await?; - Ok(()) - }).await?; - - Ok(()) -} -} -
异步编程背后到底藏有什么秘密?究竟是哪只幕后之手在操纵这一切?如果你对这些感兴趣,就继续看下去,否则可以直接跳过,因为本章节的内容对于一个 API 工程师并没有太多帮助。
-但是如果你希望能深入理解 Rust
的 async/.await
代码是如何工作、理解运行时和性能,甚至未来想要构建自己的 async
运行时或相关工具,那么本章节终究不会辜负于你。
Future
特征是 Rust 异步编程的核心,毕竟异步函数是异步编程的核心,而 Future
恰恰是异步函数的返回值和被执行的关键。
首先,来给出 Future
的定义:它是一个能产出值的异步计算(虽然该值可能为空,例如 ()
)。光看这个定义,可能会觉得很空洞,我们来看看一个简化版的 Future
特征:
--#![allow(unused)] -fn main() { -trait SimpleFuture { - type Output; - fn poll(&mut self, wake: fn()) -> Poll<Self::Output>; -} - -enum Poll<T> { - Ready(T), - Pending, -} -} -
在上一章中,我们提到过 Future
需要被执行器poll
(轮询)后才能运行,诺,这里 poll
就来了,通过调用该方法,可以推进 Future
的进一步执行,直到被切走为止( 这里不好理解,但是你只需要知道 Future
并不能保证在一次 poll
中就被执行完,后面会详解介绍)。
若在当前 poll
中, Future
可以被完成,则会返回 Poll::Ready(result)
,反之则返回 Poll::Pending
, 并且安排一个 wake
函数:当未来 Future
准备好进一步执行时, 该函数会被调用,然后管理该 Future
的执行器(例如上一章节中的block_on
函数)会再次调用 poll
方法,此时 Future
就可以继续执行了。
如果没有 wake
方法,那执行器无法知道某个 Future
是否可以继续被执行,除非执行器定期的轮询每一个 Future
,确认它是否能被执行,但这种作法效率较低。而有了 wake
,Future
就可以主动通知执行器,然后执行器就可以精确的执行该 Future
。 这种“事件通知 -> 执行”的方式要远比定期对所有 Future
进行一次全遍历来的高效。
也许大家还是迷迷糊糊的,没事,我们用一个例子来说明下。考虑一个需要从 socket
读取数据的场景:如果有数据,可以直接读取数据并返回 Poll::Ready(data)
, 但如果没有数据,Future
会被阻塞且不会再继续执行,此时它会注册一个 wake
函数,当 socket
数据准备好时,该函数将被调用以通知执行器:我们的 Future
已经准备好了,可以继续执行。
下面的 SocketRead
结构体就是一个 Future
:
--#![allow(unused)] -fn main() { -pub struct SocketRead<'a> { - socket: &'a Socket, -} - -impl SimpleFuture for SocketRead<'_> { - type Output = Vec<u8>; - - fn poll(&mut self, wake: fn()) -> Poll<Self::Output> { - if self.socket.has_data_to_read() { - // socket有数据,写入buffer中并返回 - Poll::Ready(self.socket.read_buf()) - } else { - // socket中还没数据 - // - // 注册一个`wake`函数,当数据可用时,该函数会被调用, - // 然后当前Future的执行器会再次调用`poll`方法,此时就可以读取到数据 - self.socket.set_readable_callback(wake); - Poll::Pending - } - } -} -} -
这种 Future
模型允许将多个异步操作组合在一起,同时还无需任何内存分配。不仅仅如此,如果你需要同时运行多个 Future
或链式调用多个 Future
,也可以通过无内存分配的状态机实现,例如:
--#![allow(unused)] -fn main() { -trait SimpleFuture { - type Output; - fn poll(&mut self, wake: fn()) -> Poll<Self::Output>; -} - -enum Poll<T> { - Ready(T), - Pending, -} - -/// 一个SimpleFuture,它会并发地运行两个Future直到它们完成 -/// -/// 之所以可以并发,是因为两个Future的轮询可以交替进行,一个阻塞,另一个就可以立刻执行,反之亦然 -pub struct Join<FutureA, FutureB> { - // 结构体的每个字段都包含一个Future,可以运行直到完成. - // 等到Future完成后,字段会被设置为 `None`. 这样Future完成后,就不会再被轮询 - a: Option<FutureA>, - b: Option<FutureB>, -} - -impl<FutureA, FutureB> SimpleFuture for Join<FutureA, FutureB> -where - FutureA: SimpleFuture<Output = ()>, - FutureB: SimpleFuture<Output = ()>, -{ - type Output = (); - fn poll(&mut self, wake: fn()) -> Poll<Self::Output> { - // 尝试去完成一个 Future `a` - if let Some(a) = &mut self.a { - if let Poll::Ready(()) = a.poll(wake) { - self.a.take(); - } - } - - // 尝试去完成一个 Future `b` - if let Some(b) = &mut self.b { - if let Poll::Ready(()) = b.poll(wake) { - self.b.take(); - } - } - - if self.a.is_none() && self.b.is_none() { - // 两个 Future都已完成 - 我们可以成功地返回了 - Poll::Ready(()) - } else { - // 至少还有一个 Future 没有完成任务,因此返回 `Poll::Pending`. - // 当该 Future 再次准备好时,通过调用`wake()`函数来继续执行 - Poll::Pending - } - } -} -} -
上面代码展示了如何同时运行多个 Future
, 且在此过程中没有任何内存分配,让并发编程更加高效。 类似的,多个Future
也可以一个接一个的连续运行:
--#![allow(unused)] -fn main() { -/// 一个SimpleFuture, 它使用顺序的方式,一个接一个地运行两个Future -// -// 注意: 由于本例子用于演示,因此功能简单,`AndThenFut` 会假设两个 Future 在创建时就可用了. -// 而真实的`Andthen`允许根据第一个`Future`的输出来创建第二个`Future`,因此复杂的多。 -pub struct AndThenFut<FutureA, FutureB> { - first: Option<FutureA>, - second: FutureB, -} - -impl<FutureA, FutureB> SimpleFuture for AndThenFut<FutureA, FutureB> -where - FutureA: SimpleFuture<Output = ()>, - FutureB: SimpleFuture<Output = ()>, -{ - type Output = (); - fn poll(&mut self, wake: fn()) -> Poll<Self::Output> { - if let Some(first) = &mut self.first { - match first.poll(wake) { - // 我们已经完成了第一个 Future, 可以将它移除, 然后准备开始运行第二个 - Poll::Ready(()) => self.first.take(), - // 第一个 Future 还不能完成 - Poll::Pending => return Poll::Pending, - }; - } - - // 运行到这里,说明第一个Future已经完成,尝试去完成第二个 - self.second.poll(wake) - } -} -} -
这些例子展示了在不需要内存对象分配以及深层嵌套回调的情况下,该如何使用 Future
特征去表达异步控制流。 在了解了基础的控制流后,我们再来看看真实的 Future
特征有何不同之处。
--#![allow(unused)] -fn main() { -trait Future { - type Output; - fn poll( - // 首先值得注意的地方是,`self`的类型从`&mut self`变成了`Pin<&mut Self>`: - self: Pin<&mut Self>, - // 其次将`wake: fn()` 修改为 `cx: &mut Context<'_>`: - cx: &mut Context<'_>, - ) -> Poll<Self::Output>; -} -} -
首先这里多了一个 Pin
,关于它我们会在后面章节详细介绍,现在你只需要知道使用它可以创建一个无法被移动的 Future
,因为无法被移动,所以它将具有固定的内存地址,意味着我们可以存储它的指针(如果内存地址可能会变动,那存储指针地址将毫无意义!),也意味着可以实现一个自引用数据结构: struct MyFut { a: i32, ptr_to_a: *const i32 }
。 而对于 async/await
来说,Pin
是不可或缺的关键特性。
其次,从 wake: fn()
变成了 &mut Context<'_>
。意味着 wake
函数可以携带数据了,为何要携带数据?考虑一个真实世界的场景,一个复杂应用例如 web 服务器可能有数千连接同时在线,那么同时就有数千 Future
在被同时管理着,如果不能携带数据,当一个 Future
调用 wake
后,执行器该如何知道是哪个 Future
调用了 wake
,然后进一步去 poll
对应的 Future
?没有办法!那之前的例子为啥就可以使用没有携带数据的 wake
? 因为足够简单,不存在歧义性。
总之,在正式场景要进行 wake
,就必须携带上数据。 而 Context
类型通过提供一个 Waker
类型的值,就可以用来唤醒特定的的任务。
对于 Future
来说,第一次被 poll
时无法完成任务是很正常的。但它需要确保在未来一旦准备好时,可以通知执行器再次对其进行 poll
进而继续往下执行,该通知就是通过 Waker
类型完成的。
Waker
提供了一个 wake()
方法可以用于告诉执行器:相关的任务可以被唤醒了,此时执行器就可以对相应的 Future
再次进行 poll
操作。
下面一起来实现一个简单的定时器 Future
。为了让例子尽量简单,当计时器创建时,我们会启动一个线程接着让该线程进入睡眠,等睡眠结束后再通知给 Future
。
注意本例子还会在后面继续使用,因此我们重新创建一个工程来演示:使用 cargo new --lib timer_future
来创建一个新工程,在 lib
包的根路径 src/lib.rs
中添加以下内容:
--#![allow(unused)] -fn main() { -use std::{ - future::Future, - pin::Pin, - sync::{Arc, Mutex}, - task::{Context, Poll, Waker}, - thread, - time::Duration, -}; -} -
继续来实现 Future
定时器,之前提到: 新建线程在睡眠结束后会需要将状态同步给定时器 Future
,由于是多线程环境,我们需要使用 Arc<Mutex<T>>
来作为一个共享状态,用于在新线程和 Future
定时器间共享。
--#![allow(unused)] -fn main() { -pub struct TimerFuture { - shared_state: Arc<Mutex<SharedState>>, -} - -/// 在Future和等待的线程间共享状态 -struct SharedState { - /// 定时(睡眠)是否结束 - completed: bool, - - /// 当睡眠结束后,线程可以用`waker`通知`TimerFuture`来唤醒任务 - waker: Option<Waker>, -} -} -
下面给出 Future
的具体实现:
--#![allow(unused)] -fn main() { -impl Future for TimerFuture { - type Output = (); - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { - // 通过检查共享状态,来确定定时器是否已经完成 - let mut shared_state = self.shared_state.lock().unwrap(); - if shared_state.completed { - Poll::Ready(()) - } else { - // 设置`waker`,这样新线程在睡眠(计时)结束后可以唤醒当前的任务,接着再次对`Future`进行`poll`操作, - // - // 下面的`clone`每次被`poll`时都会发生一次,实际上,应该是只`clone`一次更加合理。 - // 选择每次都`clone`的原因是: `TimerFuture`可以在执行器的不同任务间移动,如果只克隆一次, - // 那么获取到的`waker`可能已经被篡改并指向了其它任务,最终导致执行器运行了错误的任务 - shared_state.waker = Some(cx.waker().clone()); - Poll::Pending - } - } -} -} -
代码很简单,只要新线程设置了 shared_state.completed = true
,那任务就能顺利结束。如果没有设置,会为当前的任务克隆一份 Waker
,这样新线程就可以使用它来唤醒当前的任务。
最后,再来创建一个 API 用于构建定时器和启动计时线程:
---#![allow(unused)] -fn main() { -impl TimerFuture { - /// 创建一个新的`TimerFuture`,在指定的时间结束后,该`Future`可以完成 - pub fn new(duration: Duration) -> Self { - let shared_state = Arc::new(Mutex::new(SharedState { - completed: false, - waker: None, - })); - - // 创建新线程 - let thread_shared_state = shared_state.clone(); - thread::spawn(move || { - // 睡眠指定时间实现计时功能 - thread::sleep(duration); - let mut shared_state = thread_shared_state.lock().unwrap(); - // 通知执行器定时器已经完成,可以继续`poll`对应的`Future`了 - shared_state.completed = true; - if let Some(waker) = shared_state.waker.take() { - waker.wake() - } - }); - - TimerFuture { shared_state } - } -} -} -
至此,一个简单的定时器 Future
就已创建成功,那么该如何使用它呢?相信部分爱动脑筋的读者已经猜到了:我们需要创建一个执行器,才能让程序动起来。
Rust 的 Future
是惰性的:只有屁股上拍一拍,它才会努力动一动。其中一个推动它的方式就是在 async
函数中使用 .await
来调用另一个 async
函数,但是这个只能解决 async
内部的问题,那么这些最外层的 async
函数,谁来推动它们运行呢?答案就是我们之前多次提到的执行器 executor
。
执行器会管理一批 Future
(最外层的 async
函数),然后通过不停地 poll
推动它们直到完成。 最开始,执行器会先 poll
一次 Future
,后面就不会主动去 poll
了,而是等待 Future
通过调用 wake
函数来通知它可以继续,它才会继续去 poll
。这种 wake 通知然后 poll 的方式会不断重复,直到 Future
完成。
下面我们将实现一个简单的执行器,它可以同时并发运行多个 Future
。例子中,需要用到 futures
包的 ArcWake
特征,它可以提供一个方便的途径去构建一个 Waker
。编辑 Cargo.toml
,添加下面依赖:
--#![allow(unused)] -fn main() { -[dependencies] -futures = "0.3" -} -
在之前的内容中,我们在 src/lib.rs
中创建了定时器 Future
,现在在 src/main.rs
中来创建程序的主体内容,开始之前,先引入所需的包:
--#![allow(unused)] -fn main() { -use { - futures::{ - future::{BoxFuture, FutureExt}, - task::{waker_ref, ArcWake}, - }, - std::{ - future::Future, - sync::mpsc::{sync_channel, Receiver, SyncSender}, - sync::{Arc, Mutex}, - task::{Context, Poll}, - time::Duration, - }, - // 引入之前实现的定时器模块 - timer_future::TimerFuture, -}; -} -
执行器需要从一个消息通道( channel
)中拉取事件,然后运行它们。当一个任务准备好后(可以继续执行),它会将自己放入消息通道中,然后等待执行器 poll
。
--#![allow(unused)] -fn main() { -/// 任务执行器,负责从通道中接收任务然后执行 -struct Executor { - ready_queue: Receiver<Arc<Task>>, -} - -/// `Spawner`负责创建新的`Future`然后将它发送到任务通道中 -#[derive(Clone)] -struct Spawner { - task_sender: SyncSender<Arc<Task>>, -} - -/// 一个Future,它可以调度自己(将自己放入任务通道中),然后等待执行器去`poll` -struct Task { - /// 进行中的Future,在未来的某个时间点会被完成 - /// - /// 按理来说`Mutex`在这里是多余的,因为我们只有一个线程来执行任务。但是由于 - /// Rust并不聪明,它无法知道`Future`只会在一个线程内被修改,并不会被跨线程修改。因此 - /// 我们需要使用`Mutex`来满足这个笨笨的编译器对线程安全的执着。 - /// - /// 如果是生产级的执行器实现,不会使用`Mutex`,因为会带来性能上的开销,取而代之的是使用`UnsafeCell` - future: Mutex<Option<BoxFuture<'static, ()>>>, - - /// 可以将该任务自身放回到任务通道中,等待执行器的poll - task_sender: SyncSender<Arc<Task>>, -} - -fn new_executor_and_spawner() -> (Executor, Spawner) { - // 任务通道允许的最大缓冲数(任务队列的最大长度) - // 当前的实现仅仅是为了简单,在实际的执行中,并不会这么使用 - const MAX_QUEUED_TASKS: usize = 10_000; - let (task_sender, ready_queue) = sync_channel(MAX_QUEUED_TASKS); - (Executor { ready_queue }, Spawner { task_sender }) -} -} -
下面再来添加一个方法用于生成 Future
, 然后将它放入任务通道中:
--#![allow(unused)] -fn main() { -impl Spawner { - fn spawn(&self, future: impl Future<Output = ()> + 'static + Send) { - let future = future.boxed(); - let task = Arc::new(Task { - future: Mutex::new(Some(future)), - task_sender: self.task_sender.clone(), - }); - self.task_sender.send(task).expect("任务队列已满"); - } -} -} -
在执行器 poll
一个 Future
之前,首先需要调用 wake
方法进行唤醒,然后再由 Waker
负责调度该任务并将其放入任务通道中。创建 Waker
的最简单的方式就是实现 ArcWake
特征,先来为我们的任务实现 ArcWake
特征,这样它们就能被转变成 Waker
然后被唤醒:
--#![allow(unused)] -fn main() { -impl ArcWake for Task { - fn wake_by_ref(arc_self: &Arc<Self>) { - // 通过发送任务到任务管道的方式来实现`wake`,这样`wake`后,任务就能被执行器`poll` - let cloned = arc_self.clone(); - arc_self - .task_sender - .send(cloned) - .expect("任务队列已满"); - } -} -} -
当任务实现了 ArcWake
特征后,它就变成了 Waker
,在调用 wake()
对其唤醒后会将任务复制一份所有权( Arc
),然后将其发送到任务通道中。最后我们的执行器将从通道中获取任务,然后进行 poll
执行:
--#![allow(unused)] -fn main() { -impl Executor { - fn run(&self) { - while let Ok(task) = self.ready_queue.recv() { - // 获取一个future,若它还没有完成(仍然是Some,不是None),则对它进行一次poll并尝试完成它 - let mut future_slot = task.future.lock().unwrap(); - if let Some(mut future) = future_slot.take() { - // 基于任务自身创建一个 `LocalWaker` - let waker = waker_ref(&task); - let context = &mut Context::from_waker(&*waker); - // `BoxFuture<T>`是`Pin<Box<dyn Future<Output = T> + Send + 'static>>`的类型别名 - // 通过调用`as_mut`方法,可以将上面的类型转换成`Pin<&mut dyn Future + Send + 'static>` - if future.as_mut().poll(context).is_pending() { - // Future还没执行完,因此将它放回任务中,等待下次被poll - *future_slot = Some(future); - } - } - } - } -} -} -
恭喜!我们终于拥有了自己的执行器,下面再来写一段代码使用该执行器去运行之前的定时器 Future
:
-fn main() { - let (executor, spawner) = new_executor_and_spawner(); - - // 生成一个任务 - spawner.spawn(async { - println!("howdy!"); - // 创建定时器Future,并等待它完成 - TimerFuture::new(Duration::new(2, 0)).await; - println!("done!"); - }); - - // drop掉任务,这样执行器就知道任务已经完成,不会再有新的任务进来 - drop(spawner); - - // 运行执行器直到任务队列为空 - // 任务运行后,会先打印`howdy!`, 暂停2秒,接着打印 `done!` - executor.run(); -} -
前面我们一起看过一个使用 Future
从 Socket
中异步读取数据的例子:
--#![allow(unused)] -fn main() { -pub struct SocketRead<'a> { - socket: &'a Socket, -} - -impl SimpleFuture for SocketRead<'_> { - type Output = Vec<u8>; - - fn poll(&mut self, wake: fn()) -> Poll<Self::Output> { - if self.socket.has_data_to_read() { - // socket有数据,写入buffer中并返回 - Poll::Ready(self.socket.read_buf()) - } else { - // socket中还没数据 - // - // 注册一个`wake`函数,当数据可用时,该函数会被调用, - // 然后当前Future的执行器会再次调用`poll`方法,此时就可以读取到数据 - self.socket.set_readable_callback(wake); - Poll::Pending - } - } -} -} -
该例子中,Future
将从 Socket
读取数据,若当前还没有数据,则会让出当前线程的所有权,允许执行器去执行其它的 Future
。当数据准备好后,会调用 wake()
函数将该 Future
的任务放入任务通道中,等待执行器的 poll
。
关于该流程已经反复讲了很多次,相信大家应该非常清楚了。然而该例子中还有一个疑问没有解决:
-set_readable_callback
方法到底是怎么工作的?怎么才能知道 socket
中的数据已经可以被读取了?关于第二点,其中一个简单粗暴的方法就是使用一个新线程不停的检查 socket
中是否有了数据,当有了后,就调用 wake()
函数。该方法确实可以满足需求,但是性能着实太低了,需要为每个阻塞的 Future
都创建一个单独的线程!
在现实世界中,该问题往往是通过操作系统提供的 IO
多路复用机制来完成,例如 Linux
中的 epoll
,FreeBSD
和 macOS
中的 kqueue
,Windows
中的 IOCP
, Fuchisa
中的 ports
等(可以通过 Rust 的跨平台包 mio
来使用它们)。借助 IO 多路复用机制,可以实现一个线程同时阻塞地去等待多个异步 IO 事件,一旦某个事件完成就立即退出阻塞并返回数据。相关实现类似于以下代码:
--#![allow(unused)] -fn main() { -struct IoBlocker { - /* ... */ -} - -struct Event { - // Event的唯一ID,该事件发生后,就会被监听起来 - id: usize, - - // 一组需要等待或者已发生的信号 - signals: Signals, -} - -impl IoBlocker { - /// 创建需要阻塞等待的异步IO事件的集合 - fn new() -> Self { /* ... */ } - - /// 对指定的IO事件表示兴趣 - fn add_io_event_interest( - &self, - - /// 事件所绑定的socket - io_object: &IoObject, - - event: Event, - ) { /* ... */ } - - /// 进入阻塞,直到某个事件出现 - fn block(&self) -> Event { /* ... */ } -} - -let mut io_blocker = IoBlocker::new(); -io_blocker.add_io_event_interest( - &socket_1, - Event { id: 1, signals: READABLE }, -); -io_blocker.add_io_event_interest( - &socket_2, - Event { id: 2, signals: READABLE | WRITABLE }, -); -let event = io_blocker.block(); - -// 当socket的数据可以读取时,打印 "Socket 1 is now READABLE" -println!("Socket {:?} is now {:?}", event.id, event.signals); -} -
这样,我们只需要一个执行器线程,它会接收 IO 事件并将其分发到对应的 Waker
中,接着后者会唤醒相关的任务,最终通过执行器 poll
后,任务可以顺利地继续执行, 这种 IO 读取流程可以不停的循环,直到 socket
关闭。
众所周知,Rust 可以让我们写出性能高且安全的软件,那么异步编程这块儿呢?是否依然在高性能的同时保证了安全?
-我们先通过一张 web 框架性能对比图来感受下 Rust 异步编程的性能:
-上图并不能说 Rust 写的 actix
框架比 Go 的 gin
更好、更优秀,但是确实可以一定程度上说明 Rust 的异步性能非常的高!
简单来说,异步编程是一个并发编程模型,目前主流语言基本都支持了,当然,支持的方式有所不同。异步编程允许我们同时并发运行大量的任务,却仅仅需要几个甚至一个 OS 线程或 CPU 核心,现代化的异步编程在使用体验上跟同步编程也几无区别,例如 Go 语言的 go
关键字,也包括我们后面将介绍的 async/await
语法,该语法是 JavaScript
和 Rust
的核心特性之一。
async
是 Rust 选择的异步编程模型,下面我们来介绍下它的优缺点,以及何时适合使用。
由于并发编程在现代社会非常重要,因此每个主流语言都对自己的并发模型进行过权衡取舍和精心设计,Rust 语言也不例外。下面的列表可以帮助大家理解不同并发模型的取舍:
-JavaScript
曾经就存在回调地狱。Go
语言的协程设计就非常优秀,这也是 Go
语言能够迅速火遍全球的杀手锏之一。协程跟线程类似,无需改变编程模型,同时,它也跟 async
类似,可以支持大量的任务并发运行。但协程抽象层次过高,导致用户无法接触到底层的细节,这对于系统编程语言和自定义异步运行时是难以接受的actor
, 单元之间通过消息传递的方式进行通信和数据传递,跟分布式系统的设计理念非常相像。由于 actor
模型跟现实很贴近,因此它相对来说更容易实现,但是一旦遇到流控制、失败重试等场景时,就会变得不太好用async
模型的问题就是内部实现机制过于复杂,对于用户来说,理解和使用起来也没有线程和协程简单,好在前者的复杂性开发者们已经帮我们封装好,而理解和使用起来不够简单,正是本章试图解决的问题。总之,Rust 经过权衡取舍后,最终选择了同时提供多线程编程和 async 编程:
-I/O
时,选择它就对了目前已经有诸多语言都通过 async
的方式提供了异步编程,例如 JavaScript
,但 Rust
在实现上有所区别:
poll
)时才会运行, 因此丢弃一个 future
会阻止它未来再被运行, 你可以将Future
理解为一个在未来某个时间点被调度执行的任务。async
内部实现)都没有性能损耗,例如,你可以无需分配任何堆内存、也无需任何动态分发来使用 async
,这对于热点路径的性能有非常大的好处,正是得益于此,Rust 的异步编程性能才会这么高。tokio
虽然 async
和多线程都可以实现并发编程,后者甚至还能通过线程池来增强并发能力,但是这两个方式并不互通,从一个方式切换成另一个需要大量的代码重构工作,因此提前为自己的项目选择适合的并发模型就变得至关重要。
OS
线程非常适合少量任务并发,因为线程的创建和上下文切换是非常昂贵的,甚至于空闲的线程都会消耗系统资源。虽说线程池可以有效的降低性能损耗,但是也无法彻底解决问题。当然,线程模型也有其优点,例如它不会破坏你的代码逻辑和编程模型,你之前的顺序代码,经过少量修改适配后依然可以在新线程中直接运行,同时在某些操作系统中,你还可以改变线程的优先级,这对于实现驱动程序或延迟敏感的应用(例如硬实时系统)很有帮助。
对于长时间运行的 CPU 密集型任务,例如并行计算,使用线程将更有优势。 这种密集任务往往会让所在的线程持续运行,任何不必要的线程切换都会带来性能损耗,因此高并发反而在此时成为了一种多余。同时你所创建的线程数应该等于 CPU 核心数,充分利用 CPU 的并行能力,甚至还可以将线程绑定到 CPU 核心上,进一步减少线程上下文切换。
-而高并发更适合 IO
密集型任务,例如 web 服务器、数据库连接等等网络服务,因为这些任务绝大部分时间都处于等待状态,如果使用多线程,那线程大量时间会处于无所事事的状态,再加上线程上下文切换的高昂代价,让多线程做 IO
密集任务变成了一件非常奢侈的事。而使用async
,既可以有效的降低 CPU
和内存的负担,又可以让大量的任务并发的运行,一个任务一旦处于IO
或者其他等待(阻塞)状态,就会被立刻切走并执行另一个任务,而这里的任务切换的性能开销要远远低于使用多线程时的线程上下文切换。
事实上, async
底层也是基于线程实现,但是它基于线程封装了一个运行时,可以将多个任务映射到少量线程上,然后将线程切换变成了任务切换,后者仅仅是内存中的访问,因此要高效的多。
不过async
也有其缺点,原因是编译器会为async
函数生成状态机,然后将整个运行时打包进来,这会造成我们编译出的二进制可执行文件体积显著增大。
总之,async
编程并没有比多线程更好,最终还是根据你的使用场景作出合适的选择,如果无需高并发,或者也不在意线程切换带来的性能损耗,那么多线程使用起来会简单、方便的多!最后再简单总结下:
--若大家使用 tokio,那 CPU 密集的任务尤其需要用线程的方式去处理,例如使用
-spawn_blocking
创建一个阻塞的线程去完成相应 CPU 密集任务。至于具体的原因,不仅是上文说到的那些,还有一个是:tokio 是协作式的调度器,如果某个 CPU 密集的异步任务是通过 tokio 创建的,那理论上来说,该异步任务需要跟其它的异步任务交错执行,最终大家都得到了执行,皆大欢喜。但实际情况是,CPU 密集的任务很可能会一直霸占着 CPU,此时 tokio 的调度方式决定了该任务会一直被执行,这意味着,其它的异步任务无法得到执行的机会,最终这些任务都会因为得不到资源而饿死。
-而使用
-spawn_blocking
后,会创建一个单独的 OS 线程,该线程并不会被 tokio 所调度( 被 OS 所调度 ),因此它所执行的 CPU 密集任务也不会导致 tokio 调度的那些异步任务被饿死
IO
任务需要并发运行时,选 async
模型IO
任务需要并发运行时,选多线程,如果想要降低线程创建和销毁的开销,可以使用线程池CPU
密集任务需要并行运行时,例如并行计算,选多线程模型,且让线程数等于或者稍大于 CPU
核心数操作 | async | 线程 |
---|---|---|
创建 | 0.3 微秒 | 17 微秒 |
线程切换 | 0.2 微秒 | 1.7 微秒 |
可以看出,async
在线程切换的开销显著低于多线程,对于 IO 密集的场景,这种性能开销累计下来会非常可怕!
在大概理解async
后,我们再来看一个简单的例子。如果想并发的下载文件,你可以使用多线程如下实现:
--#![allow(unused)] -fn main() { -fn get_two_sites() { - // 创建两个新线程执行任务 - let thread_one = thread::spawn(|| download("https://course.rs")); - let thread_two = thread::spawn(|| download("https://fancy.rs")); - - // 等待两个线程的完成 - thread_one.join().expect("thread one panicked"); - thread_two.join().expect("thread two panicked"); -} -} -
如果是在一个小项目中简单的去下载文件,这么写没有任何问题,但是一旦下载文件的并发请求多起来,那一个下载任务占用一个线程的模式就太重了,会很容易成为程序的瓶颈。好在,我们可以使用async
的方式来解决:
--#![allow(unused)] -fn main() { -async fn get_two_sites_async() { - // 创建两个不同的`future`,你可以把`future`理解为未来某个时刻会被执行的计划任务 - // 当两个`future`被同时执行后,它们将并发的去下载目标页面 - let future_one = download_async("https://www.foo.com"); - let future_two = download_async("https://www.bar.com"); - - // 同时运行两个`future`,直至完成 - join!(future_one, future_two); -} -} -
此时,不再有线程创建和切换的昂贵开销,所有的函数都是通过静态的方式进行分发,同时也没有任何内存分配发生。这段代码的性能简直无懈可击!
-事实上,async
和多线程并不是二选一,在同一应用中,可以根据情况两者一起使用,当然,我们还可以使用其它的并发模型,例如上面提到事件驱动模型,前提是有三方库提供了相应的实现。
简而言之,Rust 语言的 async
目前还没有达到多线程的成熟度,其中一部分内容还在不断进化中,当然,这并不影响我们在生产级项目中使用,因为社区中还有 tokio
这种大杀器。
使用 async
时,你会遇到好的,也会遇到不好的,例如:
tokio
与 async-std
)async
和社区开发的运行时依然在不停的进化总之,async
在 Rust 中并不是一个善茬,你会遇到更多的困难或者说坑,也会带来更高的代码阅读成本及维护成本,但是为了性能,一切都值了,不是吗?
不过好在,这些进化早晚会彻底稳定成熟,而且在实际项目中,我们往往会使用成熟的三方库,例如tokio
,因此可以避免一些类似的问题,但是对于本章的学习来说,async
的一些难点还是我们必须要去面对和征服的。
async
的底层实现非常复杂,且会导致编译后文件体积显著增加,因此 Rust 没有选择像 Go 语言那样内置了完整的特性和运行时,而是选择了通过 Rust 语言提供了必要的特性支持,再通过社区来提供 async
运行时的支持。 因此要完整的使用 async
异步编程,你需要依赖以下特性和外部库:
Future
)、类型和函数,由标准库提供实现async/await
由 Rust 语言提供,并进行了编译器层面的支持futures
包提供(不是标准库),它们可以用于任何 async
应用中。async
代码的执行、IO
操作、任务创建和调度等等复杂功能由社区的 async
运行时提供,例如 tokio
和 async-std
还有,你在同步( synchronous
)代码中使用的一些语言特性在 async
中可能将无法再使用,而且 Rust 也不允许你在特征中声明 async
函数(可以通过三方库实现), 总之,你会遇到一些在同步代码中不会遇到的奇奇怪怪、形形色色的问题,不过不用担心,本章会专门用一个章节罗列这些问题,并给出相应的解决方案。
在大多数情况下,async
中的编译错误和运行时错误跟之前没啥区别,但是依然有以下几点值得注意:
async
编程时需要经常使用复杂的语言特性,例如生命周期和Pin
,因此相关的错误可能会出现的更加频繁async
函数生成状态机,这会导致在栈跟踪时会包含这些状态机的细节,同时还包含了运行时对函数的调用,因此,栈跟踪记录(例如 panic
时)将变得更加难以解读async
上下文中去调用一个阻塞的函数,或者没有正确的实现 Future
特征都有可能导致这种错误。这种错误可能会悄无声息的通过编译检查甚至有时候会通过单元测试。好在一旦你深入学习并掌握了本章的内容和 async
原理,可以有效的降低遇到这些错误的概率异步代码和同步代码并不总能和睦共处。例如,我们无法在一个同步函数中去调用一个 async
异步函数,同步和异步代码也往往使用不同的设计模式,这些都会导致两者融合上的困难。
甚至于有时候,异步代码之间也存在类似的问题,如果一个库依赖于特定的 async
运行时来运行,那么这个库非常有必要告诉它的用户,它用了这个运行时。否则一旦用户选了不同的或不兼容的运行时,就会导致不可预知的麻烦。
async
代码的性能主要取决于你使用的 async
运行时,好在这些运行时都经过了精心的设计,在你能遇到的绝大多数场景中,它们都能拥有非常棒的性能表现。
但是世事皆有例外。目前主流的 async
运行时几乎都使用了多线程实现,相比单线程虽然增加了并发表现,但是对于执行性能会有所损失,因为多线程实现会有同步和切换上的性能开销,若你需要极致的顺序执行性能,那么 async
目前并不是一个好的选择。
同样的,对于延迟敏感的任务来说,任务的执行次序需要能被严格掌控,而不是交由运行时去自动调度,后者会导致不可预知的延迟,例如一个 web 服务器总是有 1%
的请求,它们的延迟会远高于其它请求,因为调度过于繁忙导致了部分任务被延迟调度,最终导致了较高的延时。正因为此,这些延迟敏感的任务非常依赖于运行时或操作系统提供调度次序上的支持。
以上的两个需求,目前的 async
运行时并不能很好的支持,在未来可能会有更好的支持,但在此之前,我们可以尝试用多线程解决。
async/.await
是 Rust 内置的语言特性,可以让我们用同步的方式去编写异步的代码。
通过 async
标记的语法块会被转换成实现了Future
特征的状态机。 与同步调用阻塞当前线程不同,当Future
执行并遇到阻塞时,它会让出当前线程的控制权,这样其它的Future
就可以在该线程中运行,这种方式完全不会导致当前线程的阻塞。
下面我们来通过例子学习 async/.await
关键字该如何使用,在开始之前,需要先引入 futures
包。编辑 Cargo.toml
文件并添加以下内容:
[dependencies]
-futures = "0.3"
-
-首先,使用 async fn
语法来创建一个异步函数:
--#![allow(unused)] -fn main() { -async fn do_something() { - println!("go go go !"); -} -} -
需要注意,异步函数的返回值是一个 Future
,若直接调用该函数,不会输出任何结果,因为 Future
还未被执行:
-fn main() { - do_something(); -} -
运行后,go go go
并没有打印,同时编译器给予一个提示:warning: unused implementer of Future that must be used
,告诉我们 Future
未被使用,那么到底该如何使用?答案是使用一个执行器( executor
):
-// `block_on`会阻塞当前线程直到指定的`Future`执行完成,这种阻塞当前线程以等待任务完成的方式较为简单、粗暴, -// 好在其它运行时的执行器(executor)会提供更加复杂的行为,例如将多个`future`调度到同一个线程上执行。 -use futures::executor::block_on; - -async fn hello_world() { - println!("hello, world!"); -} - -fn main() { - let future = hello_world(); // 返回一个Future, 因此不会打印任何输出 - block_on(future); // 执行`Future`并等待其运行完成,此时"hello, world!"会被打印输出 -} -
在上述代码的main
函数中,我们使用block_on
这个执行器等待Future
的完成,让代码看上去非常像是同步代码,但是如果你要在一个async fn
函数中去调用另一个async fn
并等待其完成后再执行后续的代码,该如何做?例如:
-use futures::executor::block_on; - -async fn hello_world() { - hello_cat(); - println!("hello, world!"); -} - -async fn hello_cat() { - println!("hello, kitty!"); -} -fn main() { - let future = hello_world(); - block_on(future); -} -
这里,我们在hello_world
异步函数中先调用了另一个异步函数hello_cat
,然后再输出hello, world!
,看看运行结果:
warning: unused implementer of `futures::Future` that must be used
- --> src/main.rs:6:5
- |
-6 | hello_cat();
- | ^^^^^^^^^^^^
-= note: futures do nothing unless you `.await` or poll them
-...
-hello, world!
-
-不出所料,main
函数中的future
我们通过block_on
函数进行了运行,但是这里的hello_cat
返回的Future
却没有任何人去执行它,不过好在编译器友善的给出了提示:futures do nothing unless you `.await` or poll them
,两种解决方法:使用.await
语法或者对Future
进行轮询(poll
)。
后者较为复杂,暂且不表,先来使用.await
试试:
-use futures::executor::block_on; - -async fn hello_world() { - hello_cat().await; - println!("hello, world!"); -} - -async fn hello_cat() { - println!("hello, kitty!"); -} -fn main() { - let future = hello_world(); - block_on(future); -} -
为hello_cat()
添加上.await
后,结果立刻大为不同:
hello, kitty!
-hello, world!
-
-输出的顺序跟代码定义的顺序完全符合,因此,我们在上面代码中使用同步的代码顺序实现了异步的执行效果,非常简单、高效,而且很好理解,未来也绝对不会有回调地狱的发生。
-总之,在async fn
函数中使用.await
可以等待另一个异步调用的完成。但是与block_on
不同,.await
并不会阻塞当前的线程,而是异步的等待Future A
的完成,在等待的过程中,该线程还可以继续执行其它的Future B
,最终实现了并发处理的效果。
考虑一个载歌载舞的例子,如果不用.await
,我们可能会有如下实现:
-use futures::executor::block_on; - -struct Song { - author: String, - name: String, -} - -async fn learn_song() -> Song { - Song { - author: "周杰伦".to_string(), - name: String::from("《菊花台》"), - } -} - -async fn sing_song(song: Song) { - println!( - "给大家献上一首{}的{} ~ {}", - song.author, song.name, "菊花残,满地伤~ ~" - ); -} - -async fn dance() { - println!("唱到情深处,身体不由自主的动了起来~ ~"); -} - -fn main() { - let song = block_on(learn_song()); - block_on(sing_song(song)); - block_on(dance()); -} -
当然,以上代码运行结果无疑是正确的,但。。。它的性能何在?需要通过连续三次阻塞去等待三个任务的完成,一次只能做一件事,实际上我们完全可以载歌载舞啊:
--use futures::executor::block_on; - -struct Song { - author: String, - name: String, -} - -async fn learn_song() -> Song { - Song { - author: "曲婉婷".to_string(), - name: String::from("《我的歌声里》"), - } -} - -async fn sing_song(song: Song) { - println!( - "给大家献上一首{}的{} ~ {}", - song.author, song.name, "你存在我深深的脑海里~ ~" - ); -} - -async fn dance() { - println!("唱到情深处,身体不由自主的动了起来~ ~"); -} - -async fn learn_and_sing() { - // 这里使用`.await`来等待学歌的完成,但是并不会阻塞当前线程,该线程在学歌的任务`.await`后,完全可以去执行跳舞的任务 - let song = learn_song().await; - - // 唱歌必须要在学歌之后 - sing_song(song).await; -} - -async fn async_main() { - let f1 = learn_and_sing(); - let f2 = dance(); - - // `join!`可以并发的处理和等待多个`Future`,若`learn_and_sing Future`被阻塞,那`dance Future`可以拿过线程的所有权继续执行。若`dance`也变成阻塞状态,那`learn_and_sing`又可以再次拿回线程所有权,继续执行。 - // 若两个都被阻塞,那么`async main`会变成阻塞状态,然后让出线程所有权,并将其交给`main`函数中的`block_on`执行器 - futures::join!(f1, f2); -} - -fn main() { - block_on(async_main()); -} -
上面代码中,学歌和唱歌具有明显的先后顺序,但是这两者都可以跟跳舞一同存在,也就是你可以在跳舞的时候学歌,也可以在跳舞的时候唱歌。如果上面代码不使用.await
,而是使用block_on(learn_song())
, 那在学歌时,当前线程就会阻塞,不再可以做其它任何事,包括跳舞。
因此.await
对于实现异步编程至关重要,它允许我们在同一个线程内并发的运行多个任务,而不是一个一个先后完成。若大家看到这里还是不太明白,强烈建议回头再仔细看一遍,同时亲自上手修改代码试试效果。
至此,读者应该对 Rust 的async/.await
异步编程有了一个清晰的初步印象,下面让我们一起来看看这背后的原理:Future
和任务在底层如何被执行。
在艰难的学完 Rust 入门和进阶所有的 70 个章节后,我们终于来到了这里。假如之前攀登的是珠穆朗玛峰,那么现在攀登的就是乔戈里峰( 比珠峰还难攀爬... )。
-如果你想开发 Web 服务器、数据库驱动、消息服务等需要高并发的服务,那么本章的内容将值得认真对待和学习。
-接下来,我们将深入了解 async/await 的使用方式及背后的原理。
--- - -本章在内容上大量借鉴和翻译了原版英文书籍Asynchronous Programming In Rust, 特此感谢
-
join!
和 select!
同时运行多个 Future招数单一,杀伤力惊人,说的就是 .await
,但是光用它,还真做不到一招鲜吃遍天。比如我们该如何同时运行多个任务,而不是使用 .await
慢悠悠地排队完成。
futures
包中提供了很多实用的工具,其中一个就是 join!
宏, 它允许我们同时等待多个不同 Future
的完成,且可以并发地运行这些 Future
。
先来看一个不是很给力的、使用.await
的版本:
--#![allow(unused)] -fn main() { -async fn enjoy_book_and_music() -> (Book, Music) { - let book = enjoy_book().await; - let music = enjoy_music().await; - (book, music) -} -} -
这段代码可以顺利运行,但是有一个很大的问题,就是必须先看完书后,才能听音乐。咱们以前,谁又不是那个摇头晃脑爱读书(耳朵里偷偷塞着耳机,听的正 high)的好学生呢?
-要支持同时看书和听歌,有些人可能会凭空生成下面代码:
---#![allow(unused)] -fn main() { -// WRONG -- 别这么做 -async fn enjoy_book_and_music() -> (Book, Music) { - let book_future = enjoy_book(); - let music_future = enjoy_music(); - (book_future.await, music_future.await) -} -} -
看上去像模像样,嗯,在某些语言中也许可以,但是 Rust 不行。因为在某些语言中,Future
一旦创建就开始运行,等到返回的时候,基本就可以同时结束并返回了。 但是 Rust 中的 Future
是惰性的,直到调用 .await
时,才会开始运行。而那两个 await
由于在代码中有先后顺序,因此它们是顺序运行的。
为了正确的并发运行两个 Future
, 我们来试试 futures::join!
宏:
--#![allow(unused)] -fn main() { -use futures::join; - -async fn enjoy_book_and_music() -> (Book, Music) { - let book_fut = enjoy_book(); - let music_fut = enjoy_music(); - join!(book_fut, music_fut) -} -} -
Duang
,目标顺利达成。同时 join!
会返回一个元组,里面的值是对应的 Future
执行结束后输出的值。
--如果希望同时运行一个数组里的多个异步任务,可以使用
-futures::future::join_all
方法
由于 join!
必须等待它管理的所有 Future
完成后才能完成,如果你希望在某一个 Future
报错后就立即停止所有 Future
的执行,可以使用 try_join!
,特别是当 Future
返回 Result
时:
--#![allow(unused)] -fn main() { -use futures::try_join; - -async fn get_book() -> Result<Book, String> { /* ... */ Ok(Book) } -async fn get_music() -> Result<Music, String> { /* ... */ Ok(Music) } - -async fn get_book_and_music() -> Result<(Book, Music), String> { - let book_fut = get_book(); - let music_fut = get_music(); - try_join!(book_fut, music_fut) -} -} -
有一点需要注意,传给 try_join!
的所有 Future
都必须拥有相同的错误类型。如果错误类型不同,可以考虑使用来自 futures::future::TryFutureExt
模块的 map_err
和 err_info
方法将错误进行转换:
--#![allow(unused)] -fn main() { -use futures::{ - future::TryFutureExt, - try_join, -}; - -async fn get_book() -> Result<Book, ()> { /* ... */ Ok(Book) } -async fn get_music() -> Result<Music, String> { /* ... */ Ok(Music) } - -async fn get_book_and_music() -> Result<(Book, Music), String> { - let book_fut = get_book().map_err(|()| "Unable to get book".to_string()); - let music_fut = get_music(); - try_join!(book_fut, music_fut) -} -} -
join!
很好很强大,但是人无完人,J 无完 J, 它有一个很大的问题。
join!
只有等所有 Future
结束后,才能集中处理结果,如果你想同时等待多个 Future
,且任何一个 Future
结束后,都可以立即被处理,可以考虑使用 futures::select!
:
--#![allow(unused)] -fn main() { -use futures::{ - future::FutureExt, // for `.fuse()` - pin_mut, - select, -}; - -async fn task_one() { /* ... */ } -async fn task_two() { /* ... */ } - -async fn race_tasks() { - let t1 = task_one().fuse(); - let t2 = task_two().fuse(); - - pin_mut!(t1, t2); - - select! { - () = t1 => println!("任务1率先完成"), - () = t2 => println!("任务2率先完成"), - } -} -} -
上面的代码会同时并发地运行 t1
和 t2
, 无论两者哪个先完成,都会调用对应的 println!
打印相应的输出,然后函数结束且不会等待另一个任务的完成。
但是,在实际项目中,我们往往需要等待多个任务都完成后,再结束,像上面这种其中一个任务结束就立刻结束的场景着实不多。
-select!
还支持 default
和 complete
分支:
complete
分支当所有的 Future
和 Stream
完成后才会被执行,它往往配合 loop
使用,loop
用于循环完成所有的 Future
default
分支,若没有任何 Future
或 Stream
处于 Ready
状态, 则该分支会被立即执行-use futures::future; -use futures::select; -pub fn main() { - let mut a_fut = future::ready(4); - let mut b_fut = future::ready(6); - let mut total = 0; - - loop { - select! { - a = a_fut => total += a, - b = b_fut => total += b, - complete => break, - default => panic!(), // 该分支永远不会运行,因为 `Future` 会先运行,然后是 `complete` - }; - } - assert_eq!(total, 10); -} -
以上代码 default
分支由于最后一个运行,而在它之前 complete
分支已经通过 break
跳出了循环,因此 default
永远不会被执行。
如果你希望 default
也有机会露下脸,可以将 complete
的 break
修改为其它的,例如 println!("completed!")
,然后再观察下运行结果。
再回到 select
的第一个例子中,里面有一段代码长这样:
--#![allow(unused)] -fn main() { -let t1 = task_one().fuse(); -let t2 = task_two().fuse(); - -pin_mut!(t1, t2); -} -
当时没有展开讲,相信大家也有疑惑,下面我们来一起看看。
-Unpin
和 FusedFuture
进行交互首先,.fuse()
方法可以让 Future
实现 FusedFuture
特征, 而 pin_mut!
宏会为 Future
实现 Unpin
特征,这两个特征恰恰是使用 select
所必须的:
Unpin
,由于 select
不会通过拿走所有权的方式使用 Future
,而是通过可变引用的方式去使用,这样当 select
结束后,该 Future
若没有被完成,它的所有权还可以继续被其它代码使用。FusedFuture
的原因跟上面类似,当 Future
一旦完成后,那 select
就不能再对其进行轮询使用。Fuse
意味着熔断,相当于 Future
一旦完成,再次调用 poll
会直接返回 Poll::Pending
。只有实现了 FusedFuture
,select
才能配合 loop
一起使用。假如没有实现,就算一个 Future
已经完成了,它依然会被 select
不停的轮询执行。
Stream
稍有不同,它们使用的特征是 FusedStream
。 通过 .fuse()
(也可以手动实现)实现了该特征的 Stream
,对其调用 .next()
或 .try_next()
方法可以获取实现了 FusedFuture
特征的Future
:
--#![allow(unused)] -fn main() { -use futures::{ - stream::{Stream, StreamExt, FusedStream}, - select, -}; - -async fn add_two_streams( - mut s1: impl Stream<Item = u8> + FusedStream + Unpin, - mut s2: impl Stream<Item = u8> + FusedStream + Unpin, -) -> u8 { - let mut total = 0; - - loop { - let item = select! { - x = s1.next() => x, - x = s2.next() => x, - complete => break, - }; - if let Some(next_num) = item { - total += next_num; - } - } - - total -} -} -
一个很实用但又鲜为人知的函数是 Fuse::terminated()
,可以使用它构建一个空的 Future
,空自然没啥用,但是如果它能在后面再被填充呢?
考虑以下场景:当你要在 select
循环中运行一个任务,但是该任务却是在 select
循环内部创建时,上面的函数就非常好用了。
--#![allow(unused)] -fn main() { -use futures::{ - future::{Fuse, FusedFuture, FutureExt}, - stream::{FusedStream, Stream, StreamExt}, - pin_mut, - select, -}; - -async fn get_new_num() -> u8 { /* ... */ 5 } - -async fn run_on_new_num(_: u8) { /* ... */ } - -async fn run_loop( - mut interval_timer: impl Stream<Item = ()> + FusedStream + Unpin, - starting_num: u8, -) { - let run_on_new_num_fut = run_on_new_num(starting_num).fuse(); - let get_new_num_fut = Fuse::terminated(); - pin_mut!(run_on_new_num_fut, get_new_num_fut); - loop { - select! { - () = interval_timer.select_next_some() => { - // 定时器已结束,若`get_new_num_fut`没有在运行,就创建一个新的 - if get_new_num_fut.is_terminated() { - get_new_num_fut.set(get_new_num().fuse()); - } - }, - new_num = get_new_num_fut => { - // 收到新的数字 -- 创建一个新的`run_on_new_num_fut`并丢弃掉旧的 - run_on_new_num_fut.set(run_on_new_num(new_num).fuse()); - }, - // 运行 `run_on_new_num_fut` - () = run_on_new_num_fut => {}, - // 若所有任务都完成,直接 `panic`, 原因是 `interval_timer` 应该连续不断的产生值,而不是结束 - //后,执行到 `complete` 分支 - complete => panic!("`interval_timer` completed unexpectedly"), - } - } -} -} -
当某个 Future
有多个拷贝都需要同时运行时,可以使用 FuturesUnordered
类型。下面的例子跟上个例子大体相似,但是它会将 run_on_new_num_fut
的每一个拷贝都运行到完成,而不是像之前那样一旦创建新的就终止旧的。
- - --#![allow(unused)] -fn main() { -use futures::{ - future::{Fuse, FusedFuture, FutureExt}, - stream::{FusedStream, FuturesUnordered, Stream, StreamExt}, - pin_mut, - select, -}; - -async fn get_new_num() -> u8 { /* ... */ 5 } - -async fn run_on_new_num(_: u8) -> u8 { /* ... */ 5 } - - -// 使用从 `get_new_num` 获取的最新数字 来运行 `run_on_new_num` -// -// 每当计时器结束后,`get_new_num` 就会运行一次,它会立即取消当前正在运行的`run_on_new_num` , -// 并且使用新返回的值来替换 -async fn run_loop( - mut interval_timer: impl Stream<Item = ()> + FusedStream + Unpin, - starting_num: u8, -) { - let mut run_on_new_num_futs = FuturesUnordered::new(); - run_on_new_num_futs.push(run_on_new_num(starting_num)); - let get_new_num_fut = Fuse::terminated(); - pin_mut!(get_new_num_fut); - loop { - select! { - () = interval_timer.select_next_some() => { - // 定时器已结束,若 `get_new_num_fut` 没有在运行,就创建一个新的 - if get_new_num_fut.is_terminated() { - get_new_num_fut.set(get_new_num().fuse()); - } - }, - new_num = get_new_num_fut => { - // 收到新的数字 -- 创建一个新的 `run_on_new_num_fut` (并没有像之前的例子那样丢弃掉旧值) - run_on_new_num_futs.push(run_on_new_num(new_num)); - }, - // 运行 `run_on_new_num_futs`, 并检查是否有已经完成的 - res = run_on_new_num_futs.select_next_some() => { - println!("run_on_new_num_fut returned {:?}", res); - }, - // 若所有任务都完成,直接 `panic`, 原因是 `interval_timer` 应该连续不断的产生值,而不是结束 - //后,执行到 `complete` 分支 - complete => panic!("`interval_timer` completed unexpectedly"), - } - } -} -} -
async
在 Rust 依然比较新,疑难杂症少不了,而它们往往还处于活跃开发状态,短时间内无法被解决,因此才有了本文。下面一起来看看这些问题以及相应的临时解决方案。
async
语句块和 async fn
最大的区别就是前者无法显式的声明返回值,在大多数时候这都不是问题,但是当配合 ?
一起使用时,问题就有所不同:
-async fn foo() -> Result<u8, String> { - Ok(1) -} -async fn bar() -> Result<u8, String> { - Ok(1) -} -pub fn main() { - let fut = async { - foo().await?; - bar().await?; - Ok(()) - }; -} -
以上代码编译后会报错:
-error[E0282]: type annotations needed
- --> src/main.rs:14:9
- |
-11 | let fut = async {
- | --- consider giving `fut` a type
-...
-14 | Ok(1)
- | ^^ cannot infer type for type parameter `E` declared on the enum `Result`
-
-原因在于编译器无法推断出 Result<T, E>
中的 E
的类型, 而且编译器的提示 consider giving `fut` a type
你也别傻乎乎的相信,然后尝试半天,最后无奈放弃:目前还没有办法为 async
语句块指定返回类型。
既然编译器无法推断出类型,那咱就给它更多提示,可以使用 ::< ... >
的方式来增加类型注释:
--#![allow(unused)] -fn main() { -let fut = async { - foo().await?; - bar().await?; - Ok::<(), String>(()) // 在这一行进行显式的类型注释 -}; -} -
给予类型注释后此时编译器就知道 Result<T, E>
中的 E
的类型是 String
,进而成功通过编译。
在多线程章节我们深入讲过 Send
特征对于多线程间数据传递的重要性,对于 async fn
也是如此,它返回的 Future
能否在线程间传递的关键在于 .await
运行过程中,作用域中的变量类型是否是 Send
。
学到这里,相信大家已经很清楚 Rc
无法在多线程环境使用,原因就在于它并未实现 Send
特征,那咱就用它来做例子:
--#![allow(unused)] -fn main() { -use std::rc::Rc; - -#[derive(Default)] -struct NotSend(Rc<()>); -} -
事实上,未实现 Send
特征的变量可以出现在 async fn
语句块中:
-async fn bar() {} -async fn foo() { - NotSend::default(); - bar().await; -} - -fn require_send(_: impl Send) {} - -fn main() { - require_send(foo()); -} -
即使上面的 foo
返回的 Future
是 Send
, 但是在它内部短暂的使用 NotSend
依然是安全的,原因在于它的作用域并没有影响到 .await
,下面来试试声明一个变量,然后让 .await
的调用处于变量的作用域中试试:
--#![allow(unused)] -fn main() { -async fn foo() { - let x = NotSend::default(); - bar().await; -} -} -
不出所料,错误如期而至:
-error: future cannot be sent between threads safely
- --> src/main.rs:17:18
- |
-17 | require_send(foo());
- | ^^^^^ future returned by `foo` is not `Send`
- |
- = help: within `impl futures::Future<Output = ()>`, the trait `std::marker::Send` is not implemented for `Rc<()>`
-note: future is not `Send` as this value is used across an await
- --> src/main.rs:11:5
- |
-10 | let x = NotSend::default();
- | - has type `NotSend` which is not `Send`
-11 | bar().await;
- | ^^^^^^^^^^^ await occurs here, with `x` maybe used later
-12 | }
- | - `x` is later dropped here
-
-提示很清晰,.await
在运行时处于 x
的作用域内。在之前章节有提到过, .await
有可能被执行器调度到另一个线程上运行,而 Rc
并没有实现 Send
,因此编译器无情拒绝了咱们。
其中一个可能的解决方法是在 .await
之前就使用 std::mem::drop
释放掉 Rc
,但是很可惜,截止今天,该方法依然不能解决这种问题。
不知道有多少同学还记得语句块 { ... }
在 Rust 中其实具有非常重要的作用(特别是相比其它大多数语言来说时):可以将变量声明在语句块内,当语句块结束时,变量会自动被 Drop,这个规则可以帮助我们解决很多借用冲突问题,特别是在 NLL
出来之前。
--#![allow(unused)] -fn main() { -async fn foo() { - { - let x = NotSend::default(); - } - bar().await; -} -} -
是不是很简单?最终我们还是通过 Drop 的方式解决了这个问题,当然,还是期待未来 std::mem::drop
也能派上用场。
在内部实现中,async fn
被编译成一个状态机,这会导致递归使用 async fn
变得较为复杂, 因为编译后的状态机还需要包含自身。
--#![allow(unused)] -fn main() { -// foo函数: -async fn foo() { - step_one().await; - step_two().await; -} -// 会被编译成类似下面的类型: -enum Foo { - First(StepOne), - Second(StepTwo), -} - -// 因此 recursive 函数 -async fn recursive() { - recursive().await; - recursive().await; -} - -// 会生成类似以下的类型 -enum Recursive { - First(Recursive), - Second(Recursive), -} -} -
这是典型的动态大小类型,它的大小会无限增长,因此编译器会直接报错:
-error[E0733]: recursion in an `async fn` requires boxing
- --> src/lib.rs:1:22
- |
-1 | async fn recursive() {
- | ^ an `async fn` cannot invoke itself directly
- |
- = note: a recursive `async fn` must be rewritten to return a boxed future.
-
-如果认真学过之前的章节,大家应该知道只要将其使用 Box
放到堆上而不是栈上,就可以解决,在这里还是要称赞下 Rust 的编译器,给出的提示总是这么精确 recursion in an `async fn` requires boxing
。
就算是使用 Box
,这里也大有讲究。如果我们试图使用 Box::pin
这种方式去包裹是不行的,因为编译器自身的限制限制了我们(刚夸过它。。。)。为了解决这种问题,我们只能将 recursive
转变成一个正常的函数,该函数返回一个使用 Box
包裹的 async
语句块:
--#![allow(unused)] -fn main() { -use futures::future::{BoxFuture, FutureExt}; - -fn recursive() -> BoxFuture<'static, ()> { - async move { - recursive().await; - recursive().await; - }.boxed() -} -} -
在目前版本中,我们还无法在特征中定义 async fn
函数,不过大家也不用担心,目前已经有计划在未来移除这个限制了。
--#![allow(unused)] -fn main() { -trait Test { - async fn test(); -} -} -
运行后报错:
-error[E0706]: functions in traits cannot be declared `async`
- --> src/main.rs:5:5
- |
-5 | async fn test();
- | -----^^^^^^^^^^^
- | |
- | `async` because of this
- |
- = note: `async` trait functions are not currently supported
- = note: consider using the `async-trait` crate: https://crates.io/crates/async-trait
-
-好在编译器给出了提示,让我们使用 async-trait
解决这个问题:
--#![allow(unused)] -fn main() { -use async_trait::async_trait; - -#[async_trait] -trait Advertisement { - async fn run(&self); -} - -struct Modal; - -#[async_trait] -impl Advertisement for Modal { - async fn run(&self) { - self.render_fullscreen().await; - for _ in 0..4u16 { - remind_user_to_join_mailing_list().await; - } - self.hide_for_now().await; - } -} - -struct AutoplayingVideo { - media_url: String, -} - -#[async_trait] -impl Advertisement for AutoplayingVideo { - async fn run(&self) { - let stream = connect(&self.media_url).await; - stream.play().await; - - // 用视频说服用户加入我们的邮件列表 - Modal.run().await; - } -} -} -
不过使用该包并不是免费的,每一次特征中的 async
函数被调用时,都会产生一次堆内存分配。对于大多数场景,这个性能开销都可以接受,但是当函数一秒调用几十万、几百万次时,就得小心这块儿代码的性能了!
在 Rust 异步编程中,有一个定海神针般的存在,它就是 Pin
,作用说简单也简单,说复杂也非常复杂,当初刚出来时就连一些 Rust 大佬都一头雾水,何况瑟瑟发抖的我。好在今非昔比,目前网上的资料已经很全,而我就借花献佛,给大家好好讲讲这个 Pin
。
在 Rust 中,所有的类型可以分为两类:
-下面就是一个自引用类型
---#![allow(unused)] -fn main() { -struct SelfRef { - value: String, - pointer_to_value: *mut String, -} -} -
在上面的结构体中,pointer_to_value
是一个裸指针,指向第一个字段 value
持有的字符串 String
。很简单对吧?现在考虑一个情况, 若String
被移动了怎么办?
此时一个致命的问题就出现了:新的字符串的内存地址变了,而 pointer_to_value
依然指向之前的地址,一个重大 bug 就出现了!
灾难发生,英雄在哪?只见 Pin
闪亮登场,它可以防止一个类型在内存中被移动。再来回忆下之前在 Future
章节中,我们提到过在 poll
方法的签名中有一个 self: Pin<&mut Self>
,那么为何要在这里使用 Pin
呢?
其实 Pin
还有一个小伙伴 UnPin
,与前者相反,后者表示类型可以在内存中安全地移动。在深入之前,我们先来回忆下 async/.await
是如何工作的:
--#![allow(unused)] -fn main() { -let fut_one = /* ... */; // Future 1 -let fut_two = /* ... */; // Future 2 -async move { - fut_one.await; - fut_two.await; -} -} -
在底层,async
会创建一个实现了 Future
的匿名类型,并提供了一个 poll
方法:
--#![allow(unused)] -fn main() { -// `async { ... }`语句块创建的 `Future` 类型 -struct AsyncFuture { - fut_one: FutOne, - fut_two: FutTwo, - state: State, -} - -// `async` 语句块可能处于的状态 -enum State { - AwaitingFutOne, - AwaitingFutTwo, - Done, -} - -impl Future for AsyncFuture { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { - loop { - match self.state { - State::AwaitingFutOne => match self.fut_one.poll(..) { - Poll::Ready(()) => self.state = State::AwaitingFutTwo, - Poll::Pending => return Poll::Pending, - } - State::AwaitingFutTwo => match self.fut_two.poll(..) { - Poll::Ready(()) => self.state = State::Done, - Poll::Pending => return Poll::Pending, - } - State::Done => return Poll::Ready(()), - } - } - } -} -} -
当 poll
第一次被调用时,它会去查询 fut_one
的状态,若 fut_one
无法完成,则 poll
方法会返回。未来对 poll
的调用将从上一次调用结束的地方开始。该过程会一直持续,直到 Future
完成为止。
然而,如果我们的 async
语句块中使用了引用类型,会发生什么?例如下面例子:
--#![allow(unused)] -fn main() { -async { - let mut x = [0; 128]; - let read_into_buf_fut = read_into_buf(&mut x); - read_into_buf_fut.await; - println!("{:?}", x); -} -} -
这段代码会编译成下面的形式:
---#![allow(unused)] -fn main() { -struct ReadIntoBuf<'a> { - buf: &'a mut [u8], // 指向下面的`x`字段 -} - -struct AsyncFuture { - x: [u8; 128], - read_into_buf_fut: ReadIntoBuf<'what_lifetime?>, -} -} -
这里,ReadIntoBuf
拥有一个引用字段,指向了结构体的另一个字段 x
,一旦 AsyncFuture
被移动,那 x
的地址也将随之变化,此时对 x
的引用就变成了不合法的,也就是 read_into_buf_fut.buf
会变为不合法的。
若能将 Future
在内存中固定到一个位置,就可以避免这种问题的发生,也就可以安全的创建上面这种引用类型。
事实上,绝大多数类型都不在意是否被移动(开篇提到的第一种类型),因此它们都自动实现了 Unpin
特征。
从名字推测,大家可能以为 Pin
和 Unpin
都是特征吧?实际上,Pin
不按套路出牌,它是一个结构体:
--#![allow(unused)] -fn main() { -pub struct Pin<P> { - pointer: P, -} -} -
它包裹一个指针,并且能确保该指针指向的数据不会被移动,例如 Pin<&mut T>
, Pin<&T>
, Pin<Box<T>>
,都能确保 T
不会被移动。
而 Unpin
才是一个特征,它表明一个类型可以随意被移动,那么问题来了,可以被 Pin
住的值,它有没有实现什么特征呢? 答案很出乎意料,可以被 Pin
住的值实现的特征是 !Unpin
,大家可能之前没有见过,但是它其实很简单,!
代表没有实现某个特征的意思,!Unpin
说明类型没有实现 Unpin
特征,那自然就可以被 Pin
了。
那是不是意味着类型如果实现了 Unpin
特征,就不能被 Pin
了?其实,还是可以 Pin
的,毕竟它只是一个结构体,你可以随意使用,但是不再有任何效果而已,该值一样可以被移动!
例如 Pin<&mut u8>
,显然 u8
实现了 Unpin
特征,它可以在内存中被移动,因此 Pin<&mut u8>
跟 &mut u8
实际上并无区别,一样可以被移动。
因此,一个类型如果不能被移动,它必须实现 !Unpin
特征。如果大家对 Pin
、 Unpin
还是模模糊糊,建议再重复看一遍之前的内容,理解它们对于我们后面要讲到的内容非常重要!
如果将 Unpin
与之前章节学过的 Send/Sync
进行下对比,会发现它们都很像:
!
语法去除实现对于上面的问题,我们可以简单的归结为如何在 Rust 中处理自引用类型(果然,只要是难点,都和自引用脱离不了关系),下面用一个稍微简单点的例子来理解下 Pin
:
--#![allow(unused)] -fn main() { -#[derive(Debug)] -struct Test { - a: String, - b: *const String, -} - -impl Test { - fn new(txt: &str) -> Self { - Test { - a: String::from(txt), - b: std::ptr::null(), - } - } - - fn init(&mut self) { - let self_ref: *const String = &self.a; - self.b = self_ref; - } - - fn a(&self) -> &str { - &self.a - } - - fn b(&self) -> &String { - assert!(!self.b.is_null(), "Test::b called without Test::init being called first"); - unsafe { &*(self.b) } - } -} -} -
Test
提供了方法用于获取字段 a
和 b
的值的引用。这里b
是 a
的一个引用,但是我们并没有使用引用类型而是用了裸指针,原因是:Rust 的借用规则不允许我们这样用,因为不符合生命周期的要求。 此时的 Test
就是一个自引用结构体。
如果不移动任何值,那么上面的例子将没有任何问题,例如:
--fn main() { - let mut test1 = Test::new("test1"); - test1.init(); - let mut test2 = Test::new("test2"); - test2.init(); - - println!("a: {}, b: {}", test1.a(), test1.b()); - println!("a: {}, b: {}", test2.a(), test2.b()); - -} -
输出非常正常:
-a: test1, b: test1
-a: test2, b: test2
-
-明知山有虎,偏向虎山行,这才是我辈年轻人的风华。既然移动数据会导致指针不合法,那我们就移动下数据试试,将 test1
和 test2
进行下交换:
-fn main() { - let mut test1 = Test::new("test1"); - test1.init(); - let mut test2 = Test::new("test2"); - test2.init(); - - println!("a: {}, b: {}", test1.a(), test1.b()); - std::mem::swap(&mut test1, &mut test2); - println!("a: {}, b: {}", test2.a(), test2.b()); - -} -
按理来说,这样修改后,输出应该如下:
---#![allow(unused)] -fn main() { -a: test1, b: test1 -a: test1, b: test1 -} -
但是实际运行后,却产生了下面的输出:
---#![allow(unused)] -fn main() { -a: test1, b: test1 -a: test1, b: test2 -} -
原因是 test2.b
指针依然指向了旧的地址,而该地址对应的值现在在 test1
里,最终会打印出意料之外的值。
如果大家还是将信将疑,那再看看下面的代码:
--fn main() { - let mut test1 = Test::new("test1"); - test1.init(); - let mut test2 = Test::new("test2"); - test2.init(); - - println!("a: {}, b: {}", test1.a(), test1.b()); - std::mem::swap(&mut test1, &mut test2); - test1.a = "I've totally changed now!".to_string(); - println!("a: {}, b: {}", test2.a(), test2.b()); - -} -
下面的图片也可以帮助更好的理解这个过程:
-在理解了 Pin
的作用后,我们再来看看它怎么帮我们解决问题。
回到之前的例子,我们可以用 Pin
来解决指针指向的数据被移动的问题:
--#![allow(unused)] -fn main() { -use std::pin::Pin; -use std::marker::PhantomPinned; - -#[derive(Debug)] -struct Test { - a: String, - b: *const String, - _marker: PhantomPinned, -} - - -impl Test { - fn new(txt: &str) -> Self { - Test { - a: String::from(txt), - b: std::ptr::null(), - _marker: PhantomPinned, // 这个标记可以让我们的类型自动实现特征`!Unpin` - } - } - - fn init(self: Pin<&mut Self>) { - let self_ptr: *const String = &self.a; - let this = unsafe { self.get_unchecked_mut() }; - this.b = self_ptr; - } - - fn a(self: Pin<&Self>) -> &str { - &self.get_ref().a - } - - fn b(self: Pin<&Self>) -> &String { - assert!(!self.b.is_null(), "Test::b called without Test::init being called first"); - unsafe { &*(self.b) } - } -} -} -
上面代码中,我们使用了一个标记类型 PhantomPinned
将自定义结构体 Test
变成了 !Unpin
(编译器会自动帮我们实现),因此该结构体无法再被移动。
一旦类型实现了 !Unpin
,那将它的值固定到栈( stack
)上就是不安全的行为,因此在代码中我们使用了 unsafe
语句块来进行处理,你也可以使用 pin_utils
来避免 unsafe
的使用。
--BTW, Rust 中的 unsafe 其实没有那么可怕,虽然听上去很不安全,但是实际上 Rust 依然提供了很多机制来帮我们提升了安全性,因此不必像对待 Go 语言的
-unsafe
那样去畏惧于使用 Rust 中的unsafe
,大致使用原则总结如下:没必要用时,就不要用,当有必要用时,就大胆用,但是尽量控制好边界,让unsafe
的范围尽可能小
此时,再去尝试移动被固定的值,就会导致编译错误:
--pub fn main() { - // 此时的`test1`可以被安全的移动 - let mut test1 = Test::new("test1"); - // 新的`test1`由于使用了`Pin`,因此无法再被移动,这里的声明会将之前的`test1`遮蔽掉(shadow) - let mut test1 = unsafe { Pin::new_unchecked(&mut test1) }; - Test::init(test1.as_mut()); - - let mut test2 = Test::new("test2"); - let mut test2 = unsafe { Pin::new_unchecked(&mut test2) }; - Test::init(test2.as_mut()); - - println!("a: {}, b: {}", Test::a(test1.as_ref()), Test::b(test1.as_ref())); - std::mem::swap(test1.get_mut(), test2.get_mut()); - println!("a: {}, b: {}", Test::a(test2.as_ref()), Test::b(test2.as_ref())); -} -
注意到之前的粗体字了吗?是的,Rust 并不是在运行时做这件事,而是在编译期就完成了,因此没有额外的性能开销!来看看报错:
-error[E0277]: `PhantomPinned` cannot be unpinned
- --> src/main.rs:47:43
- |
-47 | std::mem::swap(test1.get_mut(), test2.get_mut());
- | ^^^^^^^ within `Test`, the trait `Unpin` is not implemented for `PhantomPinned`
-
---需要注意的是固定在栈上非常依赖于你写出的
-unsafe
代码的正确性。我们知道&'a mut T
可以固定的生命周期是'a
,但是我们却不知道当生命周期'a
结束后,该指针指向的数据是否会被移走。如果你的unsafe
代码里这么实现了,那么就会违背Pin
应该具有的作用!一个常见的错误就是忘记去遮蔽( shadow )初始的变量,因为你可以
-drop
掉Pin
,然后在&'a mut T
结束后去移动数据:-fn main() { - let mut test1 = Test::new("test1"); - let mut test1_pin = unsafe { Pin::new_unchecked(&mut test1) }; - Test::init(test1_pin.as_mut()); - - drop(test1_pin); - println!(r#"test1.b points to "test1": {:?}..."#, test1.b); - - let mut test2 = Test::new("test2"); - mem::swap(&mut test1, &mut test2); - println!("... and now it points nowhere: {:?}", test1.b); -} -use std::pin::Pin; -use std::marker::PhantomPinned; -use std::mem; - -#[derive(Debug)] -struct Test { - a: String, - b: *const String, - _marker: PhantomPinned, -} - - -impl Test { - fn new(txt: &str) -> Self { - Test { - a: String::from(txt), - b: std::ptr::null(), - // This makes our type `!Unpin` - _marker: PhantomPinned, - } - } - - fn init<'a>(self: Pin<&'a mut Self>) { - let self_ptr: *const String = &self.a; - let this = unsafe { self.get_unchecked_mut() }; - this.b = self_ptr; - } - - fn a<'a>(self: Pin<&'a Self>) -> &'a str { - &self.get_ref().a - } - - fn b<'a>(self: Pin<&'a Self>) -> &'a String { - assert!(!self.b.is_null(), "Test::b called without Test::init being called first"); - unsafe { &*(self.b) } - } -} -
将一个 !Unpin
类型的值固定到堆上,会给予该值一个稳定的内存地址,它指向的堆中的值在 Pin
后是无法被移动的。而且与固定在栈上不同,我们知道堆上的值在整个生命周期内都会被稳稳地固定住。
-use std::pin::Pin; -use std::marker::PhantomPinned; - -#[derive(Debug)] -struct Test { - a: String, - b: *const String, - _marker: PhantomPinned, -} - -impl Test { - fn new(txt: &str) -> Pin<Box<Self>> { - let t = Test { - a: String::from(txt), - b: std::ptr::null(), - _marker: PhantomPinned, - }; - let mut boxed = Box::pin(t); - let self_ptr: *const String = &boxed.as_ref().a; - unsafe { boxed.as_mut().get_unchecked_mut().b = self_ptr }; - - boxed - } - - fn a(self: Pin<&Self>) -> &str { - &self.get_ref().a - } - - fn b(self: Pin<&Self>) -> &String { - unsafe { &*(self.b) } - } -} - -pub fn main() { - let test1 = Test::new("test1"); - let test2 = Test::new("test2"); - - println!("a: {}, b: {}",test1.as_ref().a(), test1.as_ref().b()); - println!("a: {}, b: {}",test2.as_ref().a(), test2.as_ref().b()); -} -
Future
变为 Unpin
之前的章节我们有提到 async
函数返回的 Future
默认就是 !Unpin
的。
但是,在实际应用中,一些函数会要求它们处理的 Future
是 Unpin
的,此时,若你使用的 Future
是 !Unpin
的,必须要使用以下的方法先将 Future
进行固定:
Box::pin
, 创建一个 Pin<Box<T>>
pin_utils::pin_mut!
, 创建一个 Pin<&mut T>
固定后获得的 Pin<Box<T>>
和 Pin<&mut T>
既可以用于 Future
,又会自动实现 Unpin
。
--#![allow(unused)] -fn main() { -use pin_utils::pin_mut; // `pin_utils` 可以在crates.io中找到 - -// 函数的参数是一个`Future`,但是要求该`Future`实现`Unpin` -fn execute_unpin_future(x: impl Future<Output = ()> + Unpin) { /* ... */ } - -let fut = async { /* ... */ }; -// 下面代码报错: 默认情况下,`fut` 实现的是`!Unpin`,并没有实现`Unpin` -// execute_unpin_future(fut); - -// 使用`Box`进行固定 -let fut = async { /* ... */ }; -let fut = Box::pin(fut); -execute_unpin_future(fut); // OK - -// 使用`pin_mut!`进行固定 -let fut = async { /* ... */ }; -pin_mut!(fut); -execute_unpin_future(fut); // OK -} -
相信大家看到这里,脑袋里已经快被 Pin
、 Unpin
、 !Unpin
整爆炸了,没事,我们再来火上浇油下:)
T: Unpin
( Rust 类型的默认实现),那么 Pin<'a, T>
跟 &'a mut T
完全相同,也就是 Pin
将没有任何效果, 该移动还是照常移动Unpin
,事实上,对于 Rust 中你能遇到的绝大多数类型,该结论依然成立
-,其中一个例外就是:async/await
生成的 Future
没有实现 Unpin
!Unpin
约束:
-std::marker::PhantomPinned
nightly
版本下的 feature flag
!Unpin
值固定到栈上需要使用 unsafe
!Unpin
值固定到堆上无需 unsafe
,可以通过 Box::pin
来简单的实现T: !Unpin
时,你需要保证数据从被固定到被 drop 这段时期内,其内存不会变得非法或者被重用知识学得再多,不实际应用也是纸上谈兵,不是忘掉就是废掉,对于技术学习尤为如此。在之前章节中,我们已经学习了 Async Rust
的方方面面,现在来将这些知识融会贯通,最终实现一个并发 Web 服务器。
在正式开始前,先来看一个单线程版本的 Web
服务器,该例子来源于 Rust Book
一书。
src/main.rs
:
-use std::fs; -use std::io::prelude::*; -use std::net::TcpListener; -use std::net::TcpStream; - -fn main() { - // 监听本地端口 7878 ,等待 TCP 连接的建立 - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - - // 阻塞等待请求的进入 - for stream in listener.incoming() { - let stream = stream.unwrap(); - - handle_connection(stream); - } -} - -fn handle_connection(mut stream: TcpStream) { - // 从连接中顺序读取 1024 字节数据 - let mut buffer = [0; 1024]; - stream.read(&mut buffer).unwrap(); - - let get = b"GET / HTTP/1.1\r\n"; - - - // 处理HTTP协议头,若不符合则返回404和对应的 `html` 文件 - let (status_line, filename) = if buffer.starts_with(get) { - ("HTTP/1.1 200 OK\r\n\r\n", "hello.html") - } else { - ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html") - }; - let contents = fs::read_to_string(filename).unwrap(); - - // 将回复内容写入连接缓存中 - let response = format!("{status_line}{contents}"); - stream.write_all(response.as_bytes()).unwrap(); - // 使用 flush 将缓存中的内容发送到客户端 - stream.flush().unwrap(); -} -
hello.html
:
<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="utf-8">
- <title>Hello!</title>
- </head>
- <body>
- <h1>Hello!</h1>
- <p>Hi from Rust</p>
- </body>
-</html>
-
-404.html
:
<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="utf-8">
- <title>Hello!</title>
- </head>
- <body>
- <h1>Oops!</h1>
- <p>Sorry, I don't know what you're asking for.</p>
- </body>
-</html>
-
-运行以上代码,并从浏览器访问 127.0.0.1:7878
你将看到一条来自 Ferris
的问候。
在回忆了单线程版本该如何实现后,我们也将进入正题,一起来实现一个基于 async
的异步 Web 服务器。
一个 Web 服务器必须要能并发的处理大量来自用户的请求,也就是我们不能在处理完上一个用户的请求后,再处理下一个用户的请求。上面的单线程版本可以修改为多线程甚至于线程池来实现并发处理,但是线程还是太重了,使用 async
实现 Web
服务器才是最适合的。
首先将 handle_connection
修改为 async
实现:
--#![allow(unused)] -fn main() { -async fn handle_connection(mut stream: TcpStream) { - //<-- snip --> -} -} -
该修改会将函数的返回值从 ()
变成 Future<Output=()>
,因此直接运行将不再有任何效果,只用通过 .await
或执行器的 poll
调用后才能获取 Future
的结果。
在之前的代码中,我们使用了自己实现的简单的执行器来进行 .await
或 poll
,实际上这只是为了学习原理,在实际项目中,需要选择一个三方的 async
运行时来实现相关的功能。 具体的选择我们将在下一章节进行讲解,现在先选择 async-std
,该包的最大优点就是跟标准库的 API 类似,相对来说更简单易用。
async-std
作为异步运行时下面的例子将演示如何使用一个异步运行时 async-std
来让之前的 async fn
函数运行起来,该运行时允许使用属性 #[async_std::main]
将我们的 fn main
函数变成 async fn main
,这样就可以在 main
函数中直接调用其它 async
函数,否则你得用之前章节的 block_on
方法来让 main
去阻塞等待异步函数的完成,但是这种简单粗暴的阻塞等待方式并不灵活。
修改 Cargo.toml
添加 async-std
包并开启相应的属性:
[dependencies]
-futures = "0.3"
-
-[dependencies.async-std]
-version = "1.6"
-features = ["attributes"]
-
-下面将 main
函数修改为异步的,并在其中调用前面修改的异步版本 handle_connection
:
-#[async_std::main] -async fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").unwrap(); - for stream in listener.incoming() { - let stream = stream.unwrap(); - // 警告,这里无法并发 - handle_connection(stream).await; - } -} -
上面的代码虽然已经是异步的,实际上它还无法并发,原因我们后面会解释,先来模拟一下慢请求:
---#![allow(unused)] -fn main() { -use async_std::task; - -async fn handle_connection(mut stream: TcpStream) { - let mut buffer = [0; 1024]; - stream.read(&mut buffer).unwrap(); - - let get = b"GET / HTTP/1.1\r\n"; - let sleep = b"GET /sleep HTTP/1.1\r\n"; - - let (status_line, filename) = if buffer.starts_with(get) { - ("HTTP/1.1 200 OK\r\n\r\n", "hello.html") - } else if buffer.starts_with(sleep) { - task::sleep(Duration::from_secs(5)).await; - ("HTTP/1.1 200 OK\r\n\r\n", "hello.html") - } else { - ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "404.html") - }; - let contents = fs::read_to_string(filename).unwrap(); - - let response = format!("{status_line}{contents}"); - stream.write(response.as_bytes()).unwrap(); - stream.flush().unwrap(); -} -} -
上面是全新实现的 handle_connection
,它会在内部睡眠 5 秒,模拟一次用户慢请求,需要注意的是,我们并没有使用 std::thread::sleep
进行睡眠,原因是该函数是阻塞的,它会让当前线程陷入睡眠中,导致其它任务无法继续运行!因此我们需要一个睡眠函数 async_std::task::sleep
,它仅会让当前的任务陷入睡眠,然后该任务会让出线程的控制权,这样线程就可以继续运行其它任务。
因此,光把函数变成 async
往往是不够的,还需要将它内部的代码也都变成异步兼容的,阻塞线程绝对是不可行的。
现在运行服务器,并访问 127.0.0.1:7878/sleep
, 你会发现只有在完成第一个用户请求(5 秒后),才能开始处理第二个用户请求。现在再来看看该如何解决这个问题,让请求并发起来。
上面代码最大的问题是 listener.incoming()
是阻塞的迭代器。当 listener
在等待连接时,执行器是无法执行其它 Future
的,而且只有在我们处理完已有的连接后,才能接收新的连接。
解决方法是将 listener.incoming()
从一个阻塞的迭代器变成一个非阻塞的 Stream
, 后者在前面章节有过专门介绍:
-use async_std::net::TcpListener; -use async_std::net::TcpStream; -use futures::stream::StreamExt; - -#[async_std::main] -async fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap(); - listener - .incoming() - .for_each_concurrent(/* limit */ None, |tcpstream| async move { - let tcpstream = tcpstream.unwrap(); - handle_connection(tcpstream).await; - }) - .await; -} -
异步版本的 TcpListener
为 listener.incoming()
实现了 Stream
特征,以上修改有两个好处:
listener.incoming()
不再阻塞for_each_concurrent
并发地处理从 Stream
获取的元素现在上面的实现的关键在于 handle_connection
不能再阻塞:
--#![allow(unused)] -fn main() { -use async_std::prelude::*; - -async fn handle_connection(mut stream: TcpStream) { - let mut buffer = [0; 1024]; - stream.read(&mut buffer).await.unwrap(); - - //<-- snip --> - stream.write(response.as_bytes()).await.unwrap(); - stream.flush().await.unwrap(); -} -} -
在将数据读写改造成异步后,现在该函数也彻底变成了异步的版本,因此一次慢请求不再会阻止其它请求的运行。
-聪明的读者不知道有没有发现,之前的例子有一个致命的缺陷:只能使用一个线程并发的处理用户请求。是的,这样也可以实现并发,一秒处理几千次请求问题不大,但是这毕竟没有利用上 CPU 的多核并行能力,无法实现性能最大化。
-async
并发和多线程其实并不冲突,而 async-std
包也允许我们使用多个线程去处理,由于 handle_connection
实现了 Send
特征且不会阻塞,因此使用 async_std::task::spawn
是非常安全的:
-use async_std::task::spawn; - -#[async_std::main] -async fn main() { - let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap(); - listener - .incoming() - .for_each_concurrent(/* limit */ None, |stream| async move { - let stream = stream.unwrap(); - spawn(handle_connection(stream)); - }) - .await; -} -
至此,我们实现了同时使用并行(多线程)和并发( async
)来同时处理多个请求!
handle_connection
函数对于测试 Web 服务器,使用集成测试往往是最简单的,但是在本例子中,将使用单元测试来测试连接处理函数的正确性。
-为了保证单元测试的隔离性和确定性,我们使用 MockTcpStream
来替代 TcpStream
。首先,修改 handle_connection
的函数签名让测试更简单,之所以可以修改签名,原因在于 async_std::net::TcpStream
实际上并不是必须的,只要任何结构体实现了 async_std::io::Read
, async_std::io::Write
和 marker::Unpin
就可以替代它:
--#![allow(unused)] -fn main() { -use std::marker::Unpin; -use async_std::io::{Read, Write}; - -async fn handle_connection(mut stream: impl Read + Write + Unpin) { -} -
下面,来构建一个 mock 的 TcpStream
并实现了上面这些特征,它包含一些数据,这些数据将被拷贝到 read
缓存中, 然后返回 Poll::Ready
说明 read
已经结束:
--#![allow(unused)] -fn main() { -use super::*; -use futures::io::Error; -use futures::task::{Context, Poll}; - -use std::cmp::min; -use std::pin::Pin; - -struct MockTcpStream { - read_data: Vec<u8>, - write_data: Vec<u8>, -} - -impl Read for MockTcpStream { - fn poll_read( - self: Pin<&mut Self>, - _: &mut Context, - buf: &mut [u8], - ) -> Poll<Result<usize, Error>> { - let size: usize = min(self.read_data.len(), buf.len()); - buf[..size].copy_from_slice(&self.read_data[..size]); - Poll::Ready(Ok(size)) - } -} -} -
Write
的实现也类似,需要实现三个方法 : poll_write
, poll_flush
, 与 poll_close
。 poll_write
会拷贝输入数据到 mock 的 TcpStream
中,当完成后返回 Poll::Ready
。由于 TcpStream
无需 flush
和 close
,因此另两个方法直接返回 Poll::Ready
即可。
--#![allow(unused)] -fn main() { -impl Write for MockTcpStream { - fn poll_write( - mut self: Pin<&mut Self>, - _: &mut Context, - buf: &[u8], - ) -> Poll<Result<usize, Error>> { - self.write_data = Vec::from(buf); - - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _: &mut Context) -> Poll<Result<(), Error>> { - Poll::Ready(Ok(())) - } - - fn poll_close(self: Pin<&mut Self>, _: &mut Context) -> Poll<Result<(), Error>> { - Poll::Ready(Ok(())) - } -} -} -
最后,我们的 mock 需要实现 Unpin
特征,表示它可以在内存中安全的移动,具体内容在前面章节有讲。
--#![allow(unused)] -fn main() { -use std::marker::Unpin; -impl Unpin for MockTcpStream {} -} -
现在可以准备开始测试了,在使用初始化数据设置好 MockTcpStream
后,我们可以使用 #[async_std::test]
来运行 handle_connection
函数,该函数跟 #[async_std::main]
的作用类似。为了确保 handle_connection
函数正确工作,需要根据初始化数据检查正确的数据被写入到 MockTcpStream
中。
- - --#![allow(unused)] -fn main() { -use std::fs; - -#[async_std::test] -async fn test_handle_connection() { - let input_bytes = b"GET / HTTP/1.1\r\n"; - let mut contents = vec![0u8; 1024]; - contents[..input_bytes.len()].clone_from_slice(input_bytes); - let mut stream = MockTcpStream { - read_data: contents, - write_data: Vec::new(), - }; - - handle_connection(&mut stream).await; - let mut buf = [0u8; 1024]; - stream.read(&mut buf).await.unwrap(); - - let expected_contents = fs::read_to_string("hello.html").unwrap(); - let expected_response = format!("HTTP/1.1 200 OK\r\n\r\n{}", expected_contents); - assert!(stream.write_data.starts_with(expected_response.as_bytes())); -} -} -
Rust 的安全性是众所周知的,但是不代表它不会内存泄漏。一个典型的例子就是同时使用 Rc<T>
和 RefCell<T>
创建循环引用,最终这些引用的计数都无法被归零,因此 Rc<T>
拥有的值也不会被释放清理。
关于内存泄漏,如果你没有充足的 Rust 经验,可能都无法造出一份代码来再现它:
--use crate::List::{Cons, Nil}; -use std::cell::RefCell; -use std::rc::Rc; - -#[derive(Debug)] -enum List { - Cons(i32, RefCell<Rc<List>>), - Nil, -} - -impl List { - fn tail(&self) -> Option<&RefCell<Rc<List>>> { - match self { - Cons(_, item) => Some(item), - Nil => None, - } - } -} - -fn main() {} -
这里我们创建一个有些复杂的枚举类型 List
,这个类型很有意思,它的每个值都指向了另一个 List
,此外,得益于 Rc
的使用还允许多个值指向一个 List
:
如上图所示,每个矩形框节点都是一个 List
类型,它们或者是拥有值且指向另一个 List
的Cons
,或者是一个没有值的终结点 Nil
。同时,由于 RefCell
的使用,每个 List
所指向的 List
还能够被修改。
下面来使用一下这个复杂的 List
枚举:
-fn main() { - let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil)))); - - println!("a的初始化rc计数 = {}", Rc::strong_count(&a)); - println!("a指向的节点 = {:?}", a.tail()); - - // 创建`b`到`a`的引用 - let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a)))); - - println!("在b创建后,a的rc计数 = {}", Rc::strong_count(&a)); - println!("b的初始化rc计数 = {}", Rc::strong_count(&b)); - println!("b指向的节点 = {:?}", b.tail()); - - // 利用RefCell的可变性,创建了`a`到`b`的引用 - if let Some(link) = a.tail() { - *link.borrow_mut() = Rc::clone(&b); - } - - println!("在更改a后,b的rc计数 = {}", Rc::strong_count(&b)); - println!("在更改a后,a的rc计数 = {}", Rc::strong_count(&a)); - - // 下面一行println!将导致循环引用 - // 我们可怜的8MB大小的main线程栈空间将被它冲垮,最终造成栈溢出 - // println!("a next item = {:?}", a.tail()); -} -
这个类型定义看着复杂,使用起来更复杂!不过排除这些因素,我们可以清晰看出:
-a
后,紧接着就使用 a
创建了 b
,因此 b
引用了 a
Rc
克隆了 b
,然后通过 RefCell
的可变性,让 a
引用了 b
至此我们成功创建了循环引用a
-> b
-> a
-> b
····
先来观察下引用计数:
-a的初始化rc计数 = 1
-a指向的节点 = Some(RefCell { value: Nil })
-在b创建后,a的rc计数 = 2
-b的初始化rc计数 = 1
-b指向的节点 = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
-在更改a后,b的rc计数 = 2
-在更改a后,a的rc计数 = 2
-
-在 main
函数结束前,a
和 b
的引用计数均是 2
,随后 b
触发 Drop
,此时引用计数会变为 1
,并不会归 0
,因此 b
所指向内存不会被释放,同理可得 a
指向的内存也不会被释放,最终发生了内存泄漏。
下面一张图很好的展示了这种引用循环关系:
-
现在我们还需要轻轻的推一下,让塔米诺骨牌轰然倒塌。反注释最后一行代码,试着运行下:
-RefCell { value: Cons(5, RefCell { value: Cons(10, RefCell { value: Cons(5, RefCell { value: Cons(10, RefCell { value: Cons(5, RefCell { value: Cons(10, RefCell {
-...无穷无尽
-thread 'main' has overflowed its stack
-fatal runtime error: stack overflow
-
-通过 a.tail
的调用,Rust 试图打印出 a -> b -> a ···
的所有内容,但是在不懈的努力后,main
线程终于不堪重负,发生了栈溢出。
以上的代码可能并不会造成什么大的问题,但是在一个更加复杂的程序中,类似的问题可能会造成你的程序不断地分配内存、泄漏内存,最终程序会不幸OOM,当然这其中的 CPU 损耗也不可小觑。
-总之,创建循环引用并不简单,但是也并不是完全遇不到,当你使用 RefCell<Rc<T>>
或者类似的类型嵌套组合(具备内部可变性和引用计数)时,就要打起万分精神,前面可能是深渊!
那么问题来了? 如果我们确实需要实现上面的功能,该怎么办?答案是使用 Weak
。
Weak
非常类似于 Rc
,但是与 Rc
持有所有权不同,Weak
不持有所有权,它仅仅保存一份指向数据的弱引用:如果你想要访问数据,需要通过 Weak
指针的 upgrade
方法实现,该方法返回一个类型为 Option<Rc<T>>
的值。
看到这个返回,相信大家就懂了:何为弱引用?就是不保证引用关系依然存在,如果不存在,就返回一个 None
!
因为 Weak
引用不计入所有权,因此它无法阻止所引用的内存值被释放掉,而且 Weak
本身不对值的存在性做任何担保,引用的值还存在就返回 Some
,不存在就返回 None
。
我们来将 Weak
与 Rc
进行以下简单对比:
Weak | Rc |
---|---|
不计数 | 引用计数 |
不拥有所有权 | 拥有值的所有权 |
不阻止值被释放(drop) | 所有权计数归零,才能 drop |
引用的值存在返回 Some ,不存在返回 None | 引用的值必定存在 |
通过 upgrade 取到 Option<Rc<T>> ,然后再取值 | 通过 Deref 自动解引用,取值无需任何操作 |
通过这个对比,可以非常清晰的看出 Weak
为何这么弱,而这种弱恰恰非常适合我们实现以下的场景:
Rc
对象的临时引用,并且不在乎引用的值是否依然存在Rc
导致的循环引用,因为 Rc
的所有权机制,会导致多个 Rc
都无法计数归零使用方式简单总结下:对于父子引用关系,可以让父节点通过 Rc
来引用子节点,然后让子节点通过 Weak
来引用父节点。
因为 Weak
本身并不是很好理解,因此我们再来帮大家梳理总结下,然后再通过一个例子,来彻底掌握。
Weak
通过 use std::rc::Weak
来引入,它具有以下特点:
Rc<T>
调用 downgrade
方法转换成 Weak<T>
Weak<T>
可使用 upgrade
方法转换成 Option<Rc<T>>
,如果资源已经被释放,则 Option
的值是 None
一个简单的例子:
--use std::rc::Rc; -fn main() { - // 创建Rc,持有一个值5 - let five = Rc::new(5); - - // 通过Rc,创建一个Weak指针 - let weak_five = Rc::downgrade(&five); - - // Weak引用的资源依然存在,取到值5 - let strong_five: Option<Rc<_>> = weak_five.upgrade(); - assert_eq!(*strong_five.unwrap(), 5); - - // 手动释放资源`five` - drop(five); - - // Weak引用的资源已不存在,因此返回None - let strong_five: Option<Rc<_>> = weak_five.upgrade(); - assert_eq!(strong_five, None); -} -
需要承认的是,使用 Weak
让 Rust 本来就堪忧的代码可读性又下降了不少,但是。。。真香,因为可以解决循环引用了。
理论知识已经足够,现在用两个例子来模拟下真实场景下可能会遇到的循环引用。
-工具间里,每个工具都有其主人,且多个工具可以拥有一个主人;同时一个主人也可以拥有多个工具,在这种场景下,就很容易形成循环引用,好在我们有 Weak
:
-use std::rc::Rc; -use std::rc::Weak; -use std::cell::RefCell; - -// 主人 -struct Owner { - name: String, - gadgets: RefCell<Vec<Weak<Gadget>>>, -} - -// 工具 -struct Gadget { - id: i32, - owner: Rc<Owner>, -} - -fn main() { - // 创建一个 Owner - // 需要注意,该 Owner 也拥有多个 `gadgets` - let gadget_owner : Rc<Owner> = Rc::new( - Owner { - name: "Gadget Man".to_string(), - gadgets: RefCell::new(Vec::new()), - } - ); - - // 创建工具,同时与主人进行关联:创建两个 gadget,他们分别持有 gadget_owner 的一个引用。 - let gadget1 = Rc::new(Gadget{id: 1, owner: gadget_owner.clone()}); - let gadget2 = Rc::new(Gadget{id: 2, owner: gadget_owner.clone()}); - - // 为主人更新它所拥有的工具 - // 因为之前使用了 `Rc`,现在必须要使用 `Weak`,否则就会循环引用 - gadget_owner.gadgets.borrow_mut().push(Rc::downgrade(&gadget1)); - gadget_owner.gadgets.borrow_mut().push(Rc::downgrade(&gadget2)); - - // 遍历 gadget_owner 的 gadgets 字段 - for gadget_opt in gadget_owner.gadgets.borrow().iter() { - - // gadget_opt 是一个 Weak<Gadget> 。 因为 weak 指针不能保证他所引用的对象 - // 仍然存在。所以我们需要显式的调用 upgrade() 来通过其返回值(Option<_>)来判 - // 断其所指向的对象是否存在。 - // 当然,Option 为 None 的时候这个引用原对象就不存在了。 - let gadget = gadget_opt.upgrade().unwrap(); - println!("Gadget {} owned by {}", gadget.id, gadget.owner.name); - } - - // 在 main 函数的最后,gadget_owner,gadget1 和 gadget2 都被销毁。 - // 具体是,因为这几个结构体之间没有了强引用(`Rc<T>`),所以,当他们销毁的时候。 - // 首先 gadget2 和 gadget1 被销毁。 - // 然后因为 gadget_owner 的引用数量为 0,所以这个对象可以被销毁了。 - // 循环引用问题也就避免了 -} -
-use std::cell::RefCell; -use std::rc::{Rc, Weak}; - -#[derive(Debug)] -struct Node { - value: i32, - parent: RefCell<Weak<Node>>, - children: RefCell<Vec<Rc<Node>>>, -} - -fn main() { - let leaf = Rc::new(Node { - value: 3, - parent: RefCell::new(Weak::new()), - children: RefCell::new(vec![]), - }); - - println!( - "leaf strong = {}, weak = {}", - Rc::strong_count(&leaf), - Rc::weak_count(&leaf), - ); - - { - let branch = Rc::new(Node { - value: 5, - parent: RefCell::new(Weak::new()), - children: RefCell::new(vec![Rc::clone(&leaf)]), - }); - - *leaf.parent.borrow_mut() = Rc::downgrade(&branch); - - println!( - "branch strong = {}, weak = {}", - Rc::strong_count(&branch), - Rc::weak_count(&branch), - ); - - println!( - "leaf strong = {}, weak = {}", - Rc::strong_count(&leaf), - Rc::weak_count(&leaf), - ); - } - - println!("leaf parent = {:?}", leaf.parent.borrow().upgrade()); - println!( - "leaf strong = {}, weak = {}", - Rc::strong_count(&leaf), - Rc::weak_count(&leaf), - ); -} - -
这个例子就留给读者自己解读和分析,我们就不画蛇添足了:)
-除了使用 Rust 标准库提供的这些类型,你还可以使用 unsafe
里的裸指针来解决这些棘手的问题,但是由于我们还没有讲解 unsafe
,因此这里就不进行展开,只附上源码链接, 挺长的,需要耐心 o_o
虽然 unsafe
不安全,但是在各种库的代码中依然很常见用它来实现自引用结构,主要优点如下:
Option<Rc<RefCell<Node>>>
本文深入讲解了何为循环引用以及如何使用 Weak
来解决,同时还结合 Rc
、RefCell
、Weak
等实现了两个有实战价值的例子,让大家对智能指针的使用更加融会贯通。
至此,智能指针一章即将结束(严格来说还有一个 Mutex
放在多线程一章讲解),而 Rust 语言本身的学习之旅也即将结束,后面我们将深入多线程、项目工程、应用实践、性能分析等特色专题,来一睹 Rust 在这些领域的风采。
实现一个链表是学习各大编程语言的常用技巧,但是在 Rust 中实现链表意味着····Hell,是的,你没看错,Welcome to hell。
-链表在 Rust 中之所以这么难,完全是因为循环引用和自引用的问题引起的,这两个问题可以说综合了 Rust 的很多难点,难出了新高度,因此本书专门开辟一章,分为上下两篇,试图彻底解决这两个老大难。
-本章难度较高,但是非常值得深入阅读,它会让你对 Rust 的理解上升到一个新的境界。
- - -结构体自引用在 Rust 中是一个众所周知的难题,而且众说纷纭,也没有一篇文章能把相关的话题讲透,那本文就王婆卖瓜,来试试看能不能讲透这一块儿内容,让读者大大们舒心。
-可能也有不少人第一次听说自引用结构体,那咱们先来看看它们长啥样。
---#![allow(unused)] -fn main() { -struct SelfRef<'a> { - value: String, - - // 该引用指向上面的value - pointer_to_value: &'a str, -} -} -
以上就是一个很简单的自引用结构体,看上去好像没什么,那来试着运行下:
--fn main(){ - let s = "aaa".to_string(); - let v = SelfRef { - value: s, - pointer_to_value: &s - }; -} -
运行后报错:
- let v = SelfRef {
-12 | value: s,
- | - value moved here
-13 | pointer_to_value: &s
- | ^^ value borrowed here after move
-
-因为我们试图同时使用值和值的引用,最终所有权转移和借用一起发生了。所以,这个问题貌似并没有那么好解决,不信你可以回想下自己具有的知识,是否可以解决?
-最简单的方式就是使用 Option
分两步来实现:
-#[derive(Debug)] -struct WhatAboutThis<'a> { - name: String, - nickname: Option<&'a str>, -} - -fn main() { - let mut tricky = WhatAboutThis { - name: "Annabelle".to_string(), - nickname: None, - }; - tricky.nickname = Some(&tricky.name[..4]); - - println!("{:?}", tricky); -} -
在某种程度上来说,Option
这个方法可以工作,但是这个方法的限制较多,例如从一个函数创建并返回它是不可能的:
--#![allow(unused)] -fn main() { -fn creator<'a>() -> WhatAboutThis<'a> { - let mut tricky = WhatAboutThis { - name: "Annabelle".to_string(), - nickname: None, - }; - tricky.nickname = Some(&tricky.name[..4]); - - tricky -} -} -
报错如下:
-error[E0515]: cannot return value referencing local data `tricky.name`
- --> src/main.rs:24:5
- |
-22 | tricky.nickname = Some(&tricky.name[..4]);
- | ----------- `tricky.name` is borrowed here
-23 |
-24 | tricky
- | ^^^^^^ returns a value referencing data owned by the current function
-
-其实从函数签名就能看出来端倪,'a
生命周期是凭空产生的!
如果是通过方法使用,你需要一个无用 &'a self
生命周期标识,一旦有了这个标识,代码将变得更加受限,你将很容易就获得借用错误,就连 NLL 规则都没用:
-#[derive(Debug)] -struct WhatAboutThis<'a> { - name: String, - nickname: Option<&'a str>, -} - -impl<'a> WhatAboutThis<'a> { - fn tie_the_knot(&'a mut self) { - self.nickname = Some(&self.name[..4]); - } -} - -fn main() { - let mut tricky = WhatAboutThis { - name: "Annabelle".to_string(), - nickname: None, - }; - tricky.tie_the_knot(); - - // cannot borrow `tricky` as immutable because it is also borrowed as mutable - // println!("{:?}", tricky); -} -
既然借用规则妨碍了我们,那就一脚踢开:
--#[derive(Debug)] -struct SelfRef { - value: String, - pointer_to_value: *const String, -} - -impl SelfRef { - fn new(txt: &str) -> Self { - SelfRef { - value: String::from(txt), - pointer_to_value: std::ptr::null(), - } - } - - fn init(&mut self) { - let self_ref: *const String = &self.value; - self.pointer_to_value = self_ref; - } - - fn value(&self) -> &str { - &self.value - } - - fn pointer_to_value(&self) -> &String { - assert!(!self.pointer_to_value.is_null(), - "Test::b called without Test::init being called first"); - unsafe { &*(self.pointer_to_value) } - } -} - -fn main() { - let mut t = SelfRef::new("hello"); - t.init(); - // 打印值和指针地址 - println!("{}, {:p}", t.value(), t.pointer_to_value()); -} -
在这里,我们在 pointer_to_value
中直接存储裸指针,而不是 Rust 的引用,因此不再受到 Rust 借用规则和生命周期的限制,而且实现起来非常清晰、简洁。但是缺点就是,通过指针获取值时需要使用 unsafe
代码。
当然,上面的代码你还能通过裸指针来修改 String
,但是需要将 *const
修改为 *mut
:
-#[derive(Debug)] -struct SelfRef { - value: String, - pointer_to_value: *mut String, -} - -impl SelfRef { - fn new(txt: &str) -> Self { - SelfRef { - value: String::from(txt), - pointer_to_value: std::ptr::null_mut(), - } - } - - fn init(&mut self) { - let self_ref: *mut String = &mut self.value; - self.pointer_to_value = self_ref; - } - - fn value(&self) -> &str { - &self.value - } - - fn pointer_to_value(&self) -> &String { - assert!(!self.pointer_to_value.is_null(), "Test::b called without Test::init being called first"); - unsafe { &*(self.pointer_to_value) } - } -} - -fn main() { - let mut t = SelfRef::new("hello"); - t.init(); - println!("{}, {:p}", t.value(), t.pointer_to_value()); - - t.value.push_str(", world"); - unsafe { - (&mut *t.pointer_to_value).push_str("!"); - } - - println!("{}, {:p}", t.value(), t.pointer_to_value()); -} -
运行后输出:
-hello, 0x16f3aec70
-hello, world!, 0x16f3aec70
-
-上面的 unsafe
虽然简单好用,但是它不太安全,是否还有其他选择?还真的有,那就是 Pin
。
Pin
在后续章节会深入讲解,目前你只需要知道它可以固定住一个值,防止该值在内存中被移动。
通过开头我们知道,自引用最麻烦的就是创建引用的同时,值的所有权会被转移,而通过 Pin
就可以很好的防止这一点:
-use std::marker::PhantomPinned; -use std::pin::Pin; -use std::ptr::NonNull; - -// 下面是一个自引用数据结构体,因为 slice 字段是一个指针,指向了 data 字段 -// 我们无法使用普通引用来实现,因为违背了 Rust 的编译规则 -// 因此,这里我们使用了一个裸指针,通过 NonNull 来确保它不会为 null -struct Unmovable { - data: String, - slice: NonNull<String>, - _pin: PhantomPinned, -} - -impl Unmovable { - // 为了确保函数返回时数据的所有权不会被转移,我们将它放在堆上,唯一的访问方式就是通过指针 - fn new(data: String) -> Pin<Box<Self>> { - let res = Unmovable { - data, - // 只有在数据到位时,才创建指针,否则数据会在开始之前就被转移所有权 - slice: NonNull::dangling(), - _pin: PhantomPinned, - }; - let mut boxed = Box::pin(res); - - let slice = NonNull::from(&boxed.data); - // 这里其实安全的,因为修改一个字段不会转移整个结构体的所有权 - unsafe { - let mut_ref: Pin<&mut Self> = Pin::as_mut(&mut boxed); - Pin::get_unchecked_mut(mut_ref).slice = slice; - } - boxed - } -} - -fn main() { - let unmoved = Unmovable::new("hello".to_string()); - // 只要结构体没有被转移,那指针就应该指向正确的位置,而且我们可以随意移动指针 - let mut still_unmoved = unmoved; - assert_eq!(still_unmoved.slice, NonNull::from(&still_unmoved.data)); - - // 因为我们的类型没有实现 `Unpin` 特征,下面这段代码将无法编译 - // let mut new_unmoved = Unmovable::new("world".to_string()); - // std::mem::swap(&mut *still_unmoved, &mut *new_unmoved); -} -
上面的代码也非常清晰,虽然使用了 unsafe
,其实更多的是无奈之举,跟之前的 unsafe
实现完全不可同日而语。
其实 Pin
在这里并没有魔法,它也并不是实现自引用类型的主要原因,最关键的还是里面的裸指针的使用,而 Pin
起到的作用就是确保我们的值不会被移走,否则指针就会指向一个错误的地址!
对于自引用结构体,三方库也有支持的,其中一个就是 ouroboros,当然它也有自己的限制,我们后面会提到,先来看看该如何使用:
--use ouroboros::self_referencing; - -#[self_referencing] -struct SelfRef { - value: String, - - #[borrows(value)] - pointer_to_value: &'this str, -} - -fn main(){ - let v = SelfRefBuilder { - value: "aaa".to_string(), - pointer_to_value_builder: |value: &String| value, - }.build(); - - // 借用value值 - let s = v.borrow_value(); - // 借用指针 - let p = v.borrow_pointer_to_value(); - // value值和指针指向的值相等 - assert_eq!(s, *p); -} -
可以看到,ouroboros
使用起来并不复杂,就是需要你去按照它的方式创建结构体和引用类型:SelfRef
变成 SelfRefBuilder
,引用字段从 pointer_to_value
变成 pointer_to_value_builder
,并且连类型都变了。
在使用时,通过 borrow_value
来借用 value
的值,通过 borrow_pointer_to_value
来借用 pointer_to_value
这个指针。
看上去很美好对吧?但是你可以尝试着去修改 String
字符串的值试试,ouroboros
限制还是较多的,但是对于基本类型依然是支持的不错,以下例子来源于官方:
-use ouroboros::self_referencing; - -#[self_referencing] -struct MyStruct { - int_data: i32, - float_data: f32, - #[borrows(int_data)] - int_reference: &'this i32, - #[borrows(mut float_data)] - float_reference: &'this mut f32, -} - -fn main() { - let mut my_value = MyStructBuilder { - int_data: 42, - float_data: 3.14, - int_reference_builder: |int_data: &i32| int_data, - float_reference_builder: |float_data: &mut f32| float_data, - }.build(); - - // Prints 42 - println!("{:?}", my_value.borrow_int_data()); - // Prints 3.14 - println!("{:?}", my_value.borrow_float_reference()); - // Sets the value of float_data to 84.0 - my_value.with_mut(|fields| { - **fields.float_reference = (**fields.int_reference as f32) * 2.0; - }); - - // We can hold on to this reference... - let int_ref = *my_value.borrow_int_reference(); - println!("{:?}", *int_ref); - // As long as the struct is still alive. - drop(my_value); - // This will cause an error! - // println!("{:?}", *int_ref); -} -
总之,使用这个库前,强烈建议看一些官方的例子中支持什么样的类型和 API,如果能满足的你的需求,就果断使用它,如果不能满足,就继续往下看。
-只能说,它确实帮助我们解决了问题,但是一个是破坏了原有的结构,另外就是并不是所有数据类型都支持:它需要目标值的内存地址不会改变,因此 Vec
动态数组就不适合,因为当内存空间不够时,Rust 会重新分配一块空间来存放该数组,这会导致内存地址的改变。
类似的库还有:
-这三个库,各有各的特点,也各有各的缺陷,建议大家需要时,一定要仔细调研,并且写 demo 进行测试,不可大意。
---rental 虽然不怎么维护,但是可能依然是这三个里面最强大的,而且网上的用例也比较多,容易找到参考代码
-
类似于循环引用的解决方式,自引用也可以用这种组合来解决,但是会导致代码的类型标识到处都是,大大的影响了可读性。
-如果两个放在一起会报错,那就分开它们。对,终极大法就这么简单,当然思路上的简单不代表实现上的简单,最终结果就是导致代码复杂度的上升。
-最后,推荐一本专门将如何实现链表的书(真是富有 Rust 特色,链表都能复杂到出书了 o_o),Learn Rust by writing Entirely Too Many Linked Lists
-上面讲了这么多方法,但是我们依然无法正确的告诉你在某个场景应该使用哪个方法,这个需要你自己的判断,因为自引用实在是过于复杂。
-我们能做的就是告诉你,有这些办法可以解决自引用问题,而这些办法每个都有自己适用的范围,需要你未来去深入的挖掘和发现。
-偷偷说一句,就算是我,遇到自引用一样挺头疼,好在这种情况真的不常见,往往是实现特定的算法和数据结构时才需要,应用代码中几乎用不到。
- - ---并发是同一时间应对多件事情的能力 - Rob Pike
-
并行和并发其实并不难,但是也给一些用户造成了困扰,因此我们专门开辟一个章节,用于讲清楚这两者的区别。
-Erlang
之父 Joe Armstrong
(伟大的异步编程先驱,开创一个时代的殿堂级计算机科学家,我还犹记得当年刚学到 Erlang
时的震撼,respect!)用一张 5 岁小孩都能看懂的图片解释了并发与并行的区别:
上图很直观的体现了:
-当然,我们还可以对比下串行:只有一个队列且仅使用一台咖啡机,前面哪个人接咖啡时突然发呆了几分钟,后面的人就只能等他结束才能继续接。可能有读者有疑问了,从图片来看,并发也存在这个问题啊,前面的人发呆了几分钟不接咖啡怎么办?很简单,另外一个队列的人把他推开就行了,自己队友不能在背后开枪,但是其它队的可以:)
-在正式开始之前,先给出一个结论:并发和并行都是对“多任务”处理的描述,其中并发是轮流处理,而并行是同时处理。
-现在的个人计算机动辄拥有十来个核心(M1 Max/Intel 12 代),如果使用串行的方式那真是太低效了,因此我们把各种任务简单分成多个队列,每个队列都交给一个 CPU 核心去执行,当某个 CPU 核心没有任务时,它还能去其它核心的队列中偷任务(真·老黄牛),这样就实现了并行化处理。
-那问题来了,在早期只有一个 CPU 核心时,我们的任务是怎么处理的呢?其实聪明的读者应该已经想到,是的,并发解君愁。当然,这里还得提到操作系统的多线程,正是操作系统多线程 + CPU 核心,才实现了现代化的多任务操作系统。
-在 OS 级别,多线程负责管理我们的任务队列,你可以简单认为一个线程管理着一个任务队列,然后线程之间还能根据空闲度进行任务调度。我们的程序只会跟 OS 线程打交道,并不关心 CPU 到底有多少个核心,真正关心的只是 OS,当线程把任务交给 CPU 核心去执行时,如果只有一个 CPU 核心,那么它就只能同时处理一个任务。
-相信大家都看出来了:CPU 核心对应的是上图的咖啡机,而多个线程的任务队列就对应的多个排队的队列,由于终受限于 CPU 核心数,每个队列每次只会有一个任务被处理。
-和排队一样,假如某个任务执行时间过长,就会导致用户界面的假死(相信使用 Windows 的同学或多或少都碰到过假死的问题), 那么就需要 CPU 的任务调度了(真实 CPU 的调度很复杂,我们这里做了简化),有一个调度器会按照某些条件从队列中选择任务进行执行,并且当一个任务执行时间过长时,会强行切换该任务到后台中(或者放入任务队列,真实情况很复杂!),去执行新的任务。
-不断这样的快速任务切换,对用户而言就实现了表面上的多任务同时处理,但是实际上最终也只有一个 CPU 核心在不停的工作。
-因此并发的关键在于:快速轮换处理不同的任务,给用户带来所有任务同时在运行的假象。
-当 CPU 核心增多到 N
时,那么同一时间就能有 N
个任务被处理,那么我们的并行度就是 N
,相应的处理效率也变成了单核心的 N
倍(实际情况并没有这么高)。
当核心增多到 N
时,操作系统同时在进行的任务肯定远不止 N
个,这些任务将被放入 M
个线程队列中,接着交给 N
个 CPU 核心去执行,最后实现了 M:N
的处理模型,在这种情况下,并发与并行是同时在发生的,所有用户任务从表面来看都在并发的运行,但实际上,同一时刻只有 N
个任务能被同时并行的处理。
看到这里,相信大家已经明白两者的区别,那么我们下面给出一个正式的定义(该定义摘选自<<并发的艺术>>)。
-如果某个系统支持两个或者多个动作的同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于 “存在” 这个词。
-在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是 同时“存在” 的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。
-相信你已经能够得出结论——“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。
-如果大家学过其它语言的多线程,可能就知道不同语言对于线程的实现可能大相径庭:
-总之,每一种模型都有其优缺点及选择上的权衡,而 Rust 在设计时考虑的权衡就是运行时(Runtime)。出于 Rust 的系统级使用场景,且要保证调用 C 时的极致性能,它最终选择了尽量小的运行时实现。
---运行时是那些会被打包到所有程序可执行文件中的 Rust 代码,根据每个语言的设计权衡,运行时虽然有大有小(例如 Go 语言由于实现了协程和 GC,运行时相对就会更大一些),但是除了汇编之外,每个语言都拥有它。小运行时的其中一个好处在于最终编译出的可执行文件会相对较小,同时也让该语言更容易被其它语言引入使用。
-
而绿色线程/协程的实现会显著增大运行时的大小,因此 Rust 只在标准库中提供了 1:1
的线程模型,如果你愿意牺牲一些性能来换取更精确的线程控制以及更小的线程上下文切换成本,那么可以选择 Rust 中的 M:N
模型,这些模型由三方库提供了实现,例如大名鼎鼎的 tokio
。
在了解了并发和并行后,我们可以正式开始 Rust 的多线程之旅。
- - -安全和高效的处理并发是 Rust 语言的主要目标之一。随着现代处理器的核心数不断增加,并发和并行已经成为日常编程不可或缺的一部分,甚至于 Go 语言已经将并发简化到一个 go
关键字就可以。
可惜的是,在 Rust 中由于语言设计理念、安全、性能的多方面考虑,并没有采用 Go 语言大道至简的方式,而是选择了多线程与 async/await
相结合,优点是可控性更强、性能更高,缺点是复杂度并不低,当然这也是系统级语言的应有选择:使用复杂度换取可控性和性能。
不过,大家也不用担心,本书的目标就是降低 Rust 使用门槛,这个门槛自然也包括如何在 Rust 中进行异步并发编程,我们将从多线程以及 async/await
两个方面去深入浅出地讲解,首先,从本章的多线程开始。
在本章,我们将深入讲解并发和并行的区别以及如何使用多线程进行 Rust 并发编程,那么先来看看何为并行与并发。
- - -在多线程间有多种方式可以共享、传递数据,最常用的方式就是通过消息传递或者将锁和Arc
联合使用,而对于前者,在编程界还有一个大名鼎鼎的Actor线程模型
为其背书,典型的有 Erlang 语言,还有 Go 语言中很经典的一句话:
--Do not communicate by sharing memory; instead, share memory by communicating
-
而对于后者,我们将在下一节中进行讲述。
-与 Go 语言内置的chan
不同,Rust 是在标准库里提供了消息通道(channel
),你可以将其想象成一场直播,多个主播联合起来在搞一场直播,最终内容通过通道传输给屏幕前的我们,其中主播被称之为发送者,观众被称之为接收者,显而易见的是:一个通道应该支持多个发送者和接收者。
但是,在实际使用中,我们需要使用不同的库来满足诸如:多发送者 -> 单接收者,多发送者 -> 多接收者等场景形式,此时一个标准库显然就不够了,不过别急,让我们先从标准库讲起。
-标准库提供了通道std::sync::mpsc
,其中mpsc
是multiple producer, single consumer的缩写,代表了该通道支持多个发送者,但是只支持唯一的接收者。 当然,支持多个发送者也意味着支持单个发送者,我们先来看看单发送者、单接收者的简单例子:
-use std::sync::mpsc; -use std::thread; - -fn main() { - // 创建一个消息通道, 返回一个元组:(发送者,接收者) - let (tx, rx) = mpsc::channel(); - - // 创建线程,并发送消息 - thread::spawn(move || { - // 发送一个数字1, send方法返回Result<T,E>,通过unwrap进行快速错误处理 - tx.send(1).unwrap(); - - // 下面代码将报错,因为编译器自动推导出通道传递的值是i32类型,那么Option<i32>类型将产生不匹配错误 - // tx.send(Some(1)).unwrap() - }); - - // 在主线程中接收子线程发送的消息并输出 - println!("receive {}", rx.recv().unwrap()); -} -
以上代码并不复杂,但仍有几点需要注意:
-tx
,rx
对应发送者和接收者,它们的类型由编译器自动推导: tx.send(1)
发送了整数,因此它们分别是mpsc::Sender<i32>
和mpsc::Receiver<i32>
类型,需要注意,由于内部是泛型实现,一旦类型被推导确定,该通道就只能传递对应类型的值, 例如此例中非i32
类型的值将导致编译错误rx.recv()
会阻塞当前线程,直到读取到值,或者通道被关闭move
将tx
的所有权转移到子线程的闭包中在注释中提到send
方法返回一个Result<T,E>
,说明它有可能返回一个错误,例如接收者被drop
导致了发送的值不会被任何人接收,此时继续发送毫无意义,因此返回一个错误最为合适,在代码中我们仅仅使用unwrap
进行了快速处理,但在实际项目中你需要对错误进行进一步的处理。
同样的,对于recv
方法来说,当发送者关闭时,它也会接收到一个错误,用于说明不会再有任何值被发送过来。
除了上述recv
方法,还可以使用try_recv
尝试接收一次消息,该方法并不会阻塞线程,当通道中没有消息时,它会立刻返回一个错误:
-use std::sync::mpsc; -use std::thread; - -fn main() { - let (tx, rx) = mpsc::channel(); - - thread::spawn(move || { - tx.send(1).unwrap(); - }); - - println!("receive {:?}", rx.try_recv()); -} -
由于子线程的创建需要时间,因此println!
和try_recv
方法会先执行,而此时子线程的消息还未被发出。try_recv
会尝试立即读取一次消息,因为消息没有发出,此次读取最终会报错,且主线程运行结束(可悲的是,相对于主线程中的代码,子线程的创建速度实在是过慢,直到主线程结束,都无法完成子线程的初始化。。):
receive Err(Empty)
-
-如上,try_recv
返回了一个错误,错误内容是Empty
,代表通道并没有消息。如果你尝试把println!
复制一些行,就会发现一个有趣的输出:
···
-receive Err(Empty)
-receive Ok(1)
-receive Err(Disconnected)
-···
-
-如上,当子线程创建成功且发送消息后,主线程会接收到Ok(1)
的消息内容,紧接着子线程结束,发送者也随着被drop
,此时接收者又会报错,但是这次错误原因有所不同:Disconnected
代表发送者已经被关闭。
使用通道来传输数据,一样要遵循 Rust 的所有权规则:
-Copy
特征,则直接复制一份该值,然后传输过去,例如之前的i32
类型Copy
,则它的所有权会被转移给接收端,在发送端继续使用该值将报错一起来看看第二种情况:
--use std::sync::mpsc; -use std::thread; - -fn main() { - let (tx, rx) = mpsc::channel(); - - thread::spawn(move || { - let s = String::from("我,飞走咯!"); - tx.send(s).unwrap(); - println!("val is {}", s); - }); - - let received = rx.recv().unwrap(); - println!("Got: {}", received); -} -
以上代码中,String
底层的字符串是存储在堆上,并没有实现Copy
特征,当它被发送后,会将所有权从发送端的s
转移给接收端的received
,之后s
将无法被使用:
error[E0382]: borrow of moved value: `s`
- --> src/main.rs:10:31
- |
-8 | let s = String::from("我,飞走咯!");
- | - move occurs because `s` has type `String`, which does not implement the `Copy` trait // 所有权被转移,由于`String`没有实现`Copy`特征
-9 | tx.send(s).unwrap();
- | - value moved here // 所有权被转移走
-10 | println!("val is {}", s);
- | ^ value borrowed here after move // 所有权被转移后,依然对s进行了借用
-
-各种细节不禁令人感叹:Rust 还是安全!假如没有所有权的保护,String
字符串将被两个线程同时持有,任何一个线程对字符串内容的修改都会导致另外一个线程持有的字符串被改变,除非你故意这么设计,否则这就是不安全的隐患。
下面来看看如何连续接收通道中的值:
--use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -fn main() { - let (tx, rx) = mpsc::channel(); - - thread::spawn(move || { - let vals = vec![ - String::from("hi"), - String::from("from"), - String::from("the"), - String::from("thread"), - ]; - - for val in vals { - tx.send(val).unwrap(); - thread::sleep(Duration::from_secs(1)); - } - }); - - for received in rx { - println!("Got: {}", received); - } -} -
在上面代码中,主线程和子线程是并发运行的,子线程在不停的发送消息 -> 休眠 1 秒,与此同时,主线程使用for
循环阻塞的从rx
迭代器中接收消息,当子线程运行完成时,发送者tx
会随之被drop
,此时for
循环将被终止,最终main
线程成功结束。
由于子线程会拿走发送者的所有权,因此我们必须对发送者进行克隆,然后让每个线程拿走它的一份拷贝:
--use std::sync::mpsc; -use std::thread; - -fn main() { - let (tx, rx) = mpsc::channel(); - let tx1 = tx.clone(); - thread::spawn(move || { - tx.send(String::from("hi from raw tx")).unwrap(); - }); - - thread::spawn(move || { - tx1.send(String::from("hi from cloned tx")).unwrap(); - }); - - for received in rx { - println!("Got: {}", received); - } -} -
代码并无太大区别,就多了一个对发送者的克隆let tx1 = tx.clone();
,然后一个子线程拿走tx
的所有权,另一个子线程拿走tx1
的所有权,皆大欢喜。
但是有几点需要注意:
-drop
掉后,接收者rx
才会收到错误,进而跳出for
循环,最终结束主线程clone
但是并不会影响性能,因为它并不在热点代码路径中,仅仅会被执行一次上述第三点的消息顺序仅仅是因为线程创建引起的,并不代表通道中的消息是无序的,对于通道而言,消息的发送顺序和接收顺序是一致的,满足FIFO
原则(先进先出)。
由于篇幅有限,具体的代码这里就不再给出,感兴趣的读者可以自己验证下。
-Rust 标准库的mpsc
通道其实分为两种类型:同步和异步。
之前我们使用的都是异步通道:无论接收者是否正在接收消息,消息发送者在发送消息时都不会阻塞:
--use std::sync::mpsc; -use std::thread; -use std::time::Duration; -fn main() { - let (tx, rx)= mpsc::channel(); - - let handle = thread::spawn(move || { - println!("发送之前"); - tx.send(1).unwrap(); - println!("发送之后"); - }); - - println!("睡眠之前"); - thread::sleep(Duration::from_secs(3)); - println!("睡眠之后"); - - println!("receive {}", rx.recv().unwrap()); - handle.join().unwrap(); -} -
运行后输出如下:
-睡眠之前
-发送之前
-发送之后
-//···睡眠3秒
-睡眠之后
-receive 1
-
-主线程因为睡眠阻塞了 3 秒,因此并没有进行消息接收,而子线程却在此期间轻松完成了消息的发送。等主线程睡眠结束后,才姗姗来迟的从通道中接收了子线程老早之前发送的消息。
-从输出还可以看出,发送之前
和发送之后
是连续输出的,没有受到接收端主线程的任何影响,因此通过mpsc::channel
创建的通道是异步通道。
与异步通道相反,同步通道发送消息是阻塞的,只有在消息被接收后才解除阻塞,例如:
--use std::sync::mpsc; -use std::thread; -use std::time::Duration; -fn main() { - let (tx, rx)= mpsc::sync_channel(0); - - let handle = thread::spawn(move || { - println!("发送之前"); - tx.send(1).unwrap(); - println!("发送之后"); - }); - - println!("睡眠之前"); - thread::sleep(Duration::from_secs(3)); - println!("睡眠之后"); - - println!("receive {}", rx.recv().unwrap()); - handle.join().unwrap(); -} -
运行后输出如下:
-睡眠之前
-发送之前
-//···睡眠3秒
-睡眠之后
-receive 1
-发送之后
-
-可以看出,主线程由于睡眠被阻塞导致无法接收消息,因此子线程的发送也一直被阻塞,直到主线程结束睡眠并成功接收消息后,发送才成功:发送之后的输出是在receive 1之后,说明只有接收消息彻底成功后,发送消息才算完成。
-细心的读者可能已经发现在创建同步通道时,我们传递了一个参数0
: mpsc::sync_channel(0);
,这是什么意思呢?
答案不急给出,先将0
改成1
,然后再运行试试:
睡眠之前
-发送之前
-发送之后
-睡眠之后
-receive 1
-
-纳尼。。竟然得到了和异步通道一样的效果:根本没有等待主线程的接收开始,消息发送就立即完成了! 难道同步通道变成了异步通道? 别急,将子线程中的代码修改下试试:
---#![allow(unused)] -fn main() { -println!("首次发送之前"); -tx.send(1).unwrap(); -println!("首次发送之后"); -tx.send(1).unwrap(); -println!("再次发送之后"); -} -
在子线程中,我们又多发了一条消息,此时输出如下:
-睡眠之前
-首次发送之前
-首次发送之后
-//···睡眠3秒
-睡眠之后
-receive 1
-再次发送之后
-
-Bingo,更奇怪的事出现了,第一条消息瞬间发送完成,没有阻塞,而发送第二条消息时却符合同步通道的特点:阻塞了,直到主线程接收后,才发送完成。
-其实,一切的关键就在于1
上,该值可以用来指定同步通道的消息缓存条数,当你设定为N
时,发送者就可以无阻塞的往通道中发送N
条消息,当消息缓冲队列满了后,新的消息发送将被阻塞(如果没有接收者消费缓冲队列中的消息,那么第N+1
条消息就将触发发送阻塞)。
问题又来了,异步通道创建时完全没有这个缓冲值参数mpsc::channel()
,它的缓冲值怎么设置呢? 额。。。都异步了,都可以无限发送了,都有摩托车了,还要自行车做啥子哦?事实上异步通道的缓冲上限取决于你的内存大小,不要撑爆就行。
因此,使用异步消息虽然能非常高效且不会造成发送线程的阻塞,但是存在消息未及时消费,最终内存过大的问题。在实际项目中,可以考虑使用一个带缓冲值的同步通道来避免这种风险。
-之前我们数次提到了通道关闭,并且提到了当通道关闭后,发送消息或接收消息将会报错。那么如何关闭通道呢? 很简单:所有发送者被drop
或者所有接收者被drop
后,通道会自动关闭。
神奇的是,这件事是在编译期实现的,完全没有运行期性能损耗!只能说 Rust 的Drop
特征 YYDS!
之前提到过,一个消息通道只能传输一种类型的数据,如果你想要传输多种类型的数据,可以为每个类型创建一个通道,你也可以使用枚举类型来实现:
--use std::sync::mpsc::{self, Receiver, Sender}; - -enum Fruit { - Apple(u8), - Orange(String) -} - -fn main() { - let (tx, rx): (Sender<Fruit>, Receiver<Fruit>) = mpsc::channel(); - - tx.send(Fruit::Orange("sweet".to_string())).unwrap(); - tx.send(Fruit::Apple(2)).unwrap(); - - for _ in 0..2 { - match rx.recv().unwrap() { - Fruit::Apple(count) => println!("received {} apples", count), - Fruit::Orange(flavor) => println!("received {} oranges", flavor), - } - } -} -
如上所示,枚举类型还能让我们带上想要传输的数据,但是有一点需要注意,Rust 会按照枚举中占用内存最大的那个成员进行内存对齐,这意味着就算你传输的是枚举中占用内存最小的成员,它占用的内存依然和最大的成员相同, 因此会造成内存上的浪费。
-mpsc
虽然相当简洁明了,但是在使用起来还是可能存在坑:
-use std::sync::mpsc; -fn main() { - - use std::thread; - - let (send, recv) = mpsc::channel(); - let num_threads = 3; - for i in 0..num_threads { - let thread_send = send.clone(); - thread::spawn(move || { - thread_send.send(i).unwrap(); - println!("thread {:?} finished", i); - }); - } - - // 在这里drop send... - - for x in recv { - println!("Got: {}", x); - } - println!("finished iterating"); -} -
以上代码看起来非常正常,但是运行后主线程会一直阻塞,最后一行打印输出也不会被执行,原因在于: 子线程拿走的是复制后的send
的所有权,这些拷贝会在子线程结束后被drop
,因此无需担心,但是send
本身却直到main
函数的结束才会被drop
。
之前提到,通道关闭的两个条件:发送者全部drop
或接收者被drop
,要结束for
循环显然是要求发送者全部drop
,但是由于send
自身没有被drop
,会导致该循环永远无法结束,最终主线程会一直阻塞。
解决办法很简单,drop
掉send
即可:在代码中的注释下面添加一行drop(send);
。
如果你需要 mpmc(多发送者,多接收者)或者需要更高的性能,可以考虑第三方库:
-crossbeam
主仓库中为何 Rc、RefCell 和裸指针不可以在多线程间使用?如何让裸指针可以在多线程使用?我们一起来探寻下这些问题的答案。
-Rc
先来看一段多线程使用Rc
的代码:
-use std::thread; -use std::rc::Rc; -fn main() { - let v = Rc::new(5); - let t = thread::spawn(move || { - println!("{}",v); - }); - - t.join().unwrap(); -} -
以上代码将v
的所有权通过move
转移到子线程中,看似正确实则会报错:
error[E0277]: `Rc<i32>` cannot be sent between threads safely
------- 省略部分报错 --------
- = help: within `[closure@src/main.rs:5:27: 7:6]`, the trait `Send` is not implemented for `Rc<i32>`
-
-表面原因是Rc
无法在线程间安全的转移,实际是编译器给予我们的那句帮助: the trait `Send` is not implemented for `Rc<i32>`
(Rc<i32>
未实现Send
特征), 那么此处的Send
特征又是何方神圣?
在介绍Send
特征之前,再来看看Arc
为何可以在多线程使用,玄机在于两者的源码实现上:
--#![allow(unused)] -fn main() { -// Rc源码片段 -impl<T: ?Sized> !marker::Send for Rc<T> {} -impl<T: ?Sized> !marker::Sync for Rc<T> {} - -// Arc源码片段 -unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {} -unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {} -} -
!
代表移除特征的相应实现,上面代码中Rc<T>
的Send
和Sync
特征被特地移除了实现,而Arc<T>
则相反,实现了Sync + Send
,再结合之前的编译器报错,大概可以明白了:Send
和Sync
是在线程间安全使用一个值的关键。
Send
和Sync
是 Rust 安全并发的重中之重,但是实际上它们只是标记特征(marker trait,该特征未定义任何行为,因此非常适合用于标记), 来看看它们的作用:
Send
的类型可以在线程间安全的传递其所有权Sync
的类型可以在线程间安全的共享(通过引用)这里还有一个潜在的依赖:一个类型要在线程间安全的共享的前提是,指向它的引用必须能在线程间传递。因为如果引用都不能被传递,我们就无法在多个线程间使用引用去访问同一个数据了。
-由上可知,若类型 T 的引用&T
是Send
,则T
是Sync
。
没有例子的概念讲解都是耍流氓,来看看RwLock
的实现:
--#![allow(unused)] -fn main() { -unsafe impl<T: ?Sized + Send + Sync> Sync for RwLock<T> {} -} -
首先RwLock
可以在线程间安全的共享,那它肯定是实现了Sync
,但是我们的关注点不在这里。众所周知,RwLock
可以并发的读,说明其中的值T
必定也可以在线程间共享,那T
必定要实现Sync
。
果不其然,上述代码中,T
的特征约束中就有一个Sync
特征,那问题又来了,Mutex
是不是相反?再来看看:
--#![allow(unused)] -fn main() { -unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {} -} -
不出所料,Mutex<T>
中的T
并没有Sync
特征约束。
武学秘籍再好,不见生死也是花拳绣腿。同样的,我们需要通过实战来彻底掌握Send
和Sync
,但在实战之前,先来简单看看有哪些类型实现了它们。
Send
和Sync
的类型在 Rust 中,几乎所有类型都默认实现了Send
和Sync
,而且由于这两个特征都是可自动派生的特征(通过derive
派生),意味着一个复合类型(例如结构体), 只要它内部的所有成员都实现了Send
或者Sync
,那么它就自动实现了Send
或Sync
。
正是因为以上规则,Rust 中绝大多数类型都实现了Send
和Sync
,除了以下几个(事实上不止这几个,只不过它们比较常见):
UnsafeCell
不是Sync
,因此Cell
和RefCell
也不是Rc
两者都没实现(因为内部的引用计数器不是线程安全的)当然,如果是自定义的复合类型,那没实现那哥俩的就较为常见了:只要复合类型中有一个成员不是Send
或Sync
,那么该复合类型也就不是Send
或Sync
。
手动实现 Send
和 Sync
是不安全的,通常并不需要手动实现 Send 和 Sync trait,实现者需要使用unsafe
小心维护并发安全保证。
至此,相关的概念大家已经掌握,但是我敢肯定,对于这两个滑不溜秋的家伙,大家依然会非常模糊,不知道它们该如何使用。那么我们来一起看看如何让裸指针可以在线程间安全的使用。
-Send
上面我们提到裸指针既没实现Send
,意味着下面代码会报错:
-use std::thread; -fn main() { - let p = 5 as *mut u8; - let t = thread::spawn(move || { - println!("{:?}",p); - }); - - t.join().unwrap(); -} -
报错跟之前无二: `*mut u8` cannot be sent between threads safely
, 但是有一个问题,我们无法为其直接实现Send
特征,好在可以用newtype
类型 :struct MyBox(*mut u8);
。
还记得之前的规则吗:复合类型中有一个成员没实现Send
,该复合类型就不是Send
,因此我们需要手动为它实现:
-use std::thread; - -#[derive(Debug)] -struct MyBox(*mut u8); -unsafe impl Send for MyBox {} -fn main() { - let p = MyBox(5 as *mut u8); - let t = thread::spawn(move || { - println!("{:?}",p); - }); - - t.join().unwrap(); -} -
此时,我们的指针已经可以欢快的在多线程间撒欢,以上代码很简单,但有一点需要注意:Send
和Sync
是unsafe
特征,实现时需要用unsafe
代码块包裹。
Sync
由于Sync
是多线程间共享一个值,大家可能会想这么实现:
-use std::thread; -fn main() { - let v = 5; - let t = thread::spawn(|| { - println!("{:?}",&v); - }); - - t.join().unwrap(); -} -
关于这种用法,在多线程章节也提到过,线程如果直接去借用其它线程的变量,会报错:closure may outlive the current function,
, 原因在于编译器无法确定主线程main
和子线程t
谁的生命周期更长,特别是当两个线程都是子线程时,没有任何人知道哪个子线程会先结束,包括编译器!
因此我们得配合Arc
去使用:
-use std::thread; -use std::sync::Arc; -use std::sync::Mutex; - -#[derive(Debug)] -struct MyBox(*const u8); -unsafe impl Send for MyBox {} - -fn main() { - let b = &MyBox(5 as *const u8); - let v = Arc::new(Mutex::new(b)); - let t = thread::spawn(move || { - let _v1 = v.lock().unwrap(); - }); - - t.join().unwrap(); -} -
上面代码将智能指针v
的所有权转移给新线程,同时v
包含了一个引用类型b
,当在新的线程中试图获取内部的引用时,会报错:
error[E0277]: `*const u8` cannot be shared between threads safely
---> src/main.rs:25:13
-|
-25 | let t = thread::spawn(move || {
-| ^^^^^^^^^^^^^ `*const u8` cannot be shared between threads safely
-|
-= help: within `MyBox`, the trait `Sync` is not implemented for `*const u8`
-
-因为我们访问的引用实际上还是对主线程中的数据的借用,转移进来的仅仅是外层的智能指针引用。要解决很简单,为MyBox
实现Sync
:
--#![allow(unused)] -fn main() { -unsafe impl Sync for MyBox {} -} -
通过上面的两个裸指针的例子,我们了解了如何实现Send
和Sync
,以及如何只实现Send
而不实现Sync
,简单总结下:
Send
的类型可以在线程间安全的传递其所有权, 实现Sync
的类型可以在线程间安全的共享(通过引用)Send
和Sync
,常见的未实现的有:裸指针、Cell
、RefCell
、Rc
等Send
和Sync
,但是需要unsafe
代码块Send
、Sync
,但是需要使用newtype
,例如文中的裸指针例子在多线程编程中,同步性极其的重要,当你需要同时访问一个资源、控制不同线程的执行次序时,都需要使用到同步性。
-在 Rust 中有多种方式可以实现同步性。在上一节中讲到的消息传递就是同步性的一种实现方式,例如我们可以通过消息传递来控制不同线程间的执行次序。还可以使用共享内存来实现同步性,例如通过锁和原子操作等并发原语来实现多个线程同时且安全地去访问一个资源。
-共享内存可以说是同步的灵魂,因为消息传递的底层实际上也是通过共享内存来实现,两者的区别如下:
-消息传递适用的场景很多,我们下面列出了几个主要的使用场景:
-而使用共享内存(并发原语)的场景往往就比较简单粗暴:需要简洁的实现以及更高的性能时。
-总之,消息传递类似一个单所有权的系统:一个值同时只能有一个所有者,如果另一个线程需要该值的所有权,需要将所有权通过消息传递进行转移。而共享内存类似于一个多所有权的系统:多个线程可以同时访问同一个值。
-既然是共享内存,那并发原语自然是重中之重,先来一起看看皇冠上的明珠: 互斥锁Mutex
(mutual exclusion 的缩写)。
Mutex
让多个线程并发的访问同一个值变成了排队访问:同一时间,只允许一个线程A
访问该值,其它线程需要等待A
访问完成后才能继续。
先来看看单线程中Mutex
该如何使用:
-use std::sync::Mutex; - -fn main() { - // 使用`Mutex`结构体的关联函数创建新的互斥锁实例 - let m = Mutex::new(5); - - { - // 获取锁,然后deref为`m`的引用 - // lock返回的是Result - let mut num = m.lock().unwrap(); - *num = 6; - // 锁自动被drop - } - - println!("m = {:?}", m); -} -
在注释中,已经大致描述了代码的功能,不过有一点需要注意:和Box
类似,数据被Mutex
所拥有,要访问内部的数据,需要使用方法m.lock()
向m
申请一个锁, 该方法会阻塞当前线程,直到获取到锁,因此当多个线程同时访问该数据时,只有一个线程能获取到锁,其它线程只能阻塞着等待,这样就保证了数据能被安全的修改!
m.lock()
方法也有可能报错,例如当前正在持有锁的线程panic
了。在这种情况下,其它线程不可能再获得锁,因此lock
方法会返回一个错误。
这里你可能奇怪,m.lock
明明返回一个锁,怎么就变成我们的num
数值了?聪明的读者可能会想到智能指针,没错,因为Mutex<T>
是一个智能指针,准确的说是m.lock()
返回一个智能指针MutexGuard<T>
:
Deref
特征,会被自动解引用后获得一个引用类型,该引用指向Mutex
内部的数据Drop
特征,在超出作用域后,自动释放锁,以便其它线程能继续获取锁正因为智能指针的使用,使得我们无需任何操作就能获取其中的数据。 如果释放锁,你需要做的仅仅是做好锁的作用域管理,例如上述代码的内部花括号使用,建议读者尝试下去掉内部的花括号,然后再次尝试获取第二个锁num1
,看看会发生什么,友情提示:不会报错,但是主线程会永远阻塞,因为不幸发生了死锁。
-use std::sync::Mutex; - -fn main() { - let m = Mutex::new(5); - - let mut num = m.lock().unwrap(); - *num = 6; - // 锁还没有被 drop 就尝试申请下一个锁,导致主线程阻塞 - // drop(num); // 手动 drop num ,可以让 num1 申请到下个锁 - let mut num1 = m.lock().unwrap(); - *num1 = 7; - // drop(num1); // 手动 drop num1 ,观察打印结果的不同 - - println!("m = {:?}", m); -} -
单线程中使用锁,说实话纯粹是为了演示功能,毕竟多线程才是锁的舞台。 现在,我们再来看看,如何在多线程下使用Mutex
来访问同一个资源.
Rc<T>
-use std::rc::Rc; -use std::sync::Mutex; -use std::thread; - -fn main() { - // 通过`Rc`实现`Mutex`的多所有权 - let counter = Rc::new(Mutex::new(0)); - let mut handles = vec![]; - - for _ in 0..10 { - let counter = Rc::clone(&counter); - // 创建子线程,并将`Mutex`的所有权拷贝传入到子线程中 - 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()); -} -
由于子线程需要通过move
拿走锁的所有权,因此我们需要使用多所有权来保证每个线程都拿到数据的独立所有权,恰好智能指针Rc<T>
可以做到(上面代码会报错!具体往下看,别跳过-, -)。
以上代码实现了在多线程中计数的功能,由于多个线程都需要去修改该计数器,因此我们需要使用锁来保证同一时间只有一个线程可以修改计数器,否则会导致脏数据:想象一下 A 线程和 B 线程同时拿到计数器,获取了当前值1
, 并且同时对其进行了修改,最后值变成2
,你会不会在风中凌乱?毕竟正确的值是3
,因为两个线程各自加 1。
可能有人会说,有那么巧的事情吗?事实上,对于人类来说,因为干啥啥慢,并没有那么多巧合,所以人总会存在巧合心理。但是对于计算机而言,每秒可以轻松运行上亿次,在这种频次下,一切巧合几乎都将必然发生,因此千万不要有任何侥幸心理。
---如果事情有变坏的可能,不管这种可能性有多小,它都会发生! - 在计算机领域歪打正着的墨菲定律
-
事实上,上面的代码会报错:
-error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
- // `Rc`无法在线程中安全的传输
- --> src/main.rs:11:22
- |
-13 | let handle = thread::spawn(move || {
- | ______________________^^^^^^^^^^^^^_-
- | | |
- | | `Rc<Mutex<i32>>` cannot be sent between threads safely
-14 | | let mut num = counter.lock().unwrap();
-15 | |
-16 | | *num += 1;
-17 | | });
- | |_________- within this `[closure@src/main.rs:11:36: 15:10]`
- |
- = help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
- // `Rc`没有实现`Send`特征
- = note: required because it appears within the type `[closure@src/main.rs:11:36: 15:10]`
-
-错误中提到了一个关键点:Rc<T>
无法在线程中传输,因为它没有实现Send
特征(在下一节将详细介绍),而该特征可以确保数据在线程中安全的传输。
Arc<T>
好在,我们有Arc<T>
,得益于它的内部计数器是多线程安全的,因此可以在多线程环境中使用:
-use std::sync::{Arc, Mutex}; -use std::thread; - -fn main() { - let counter = Arc::new(Mutex::new(0)); - let mut handles = vec![]; - - for _ in 0..10 { - let counter = Arc::clone(&counter); - 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()); -} -
以上代码可以顺利运行:
-Result: 10
-
-在之前章节,我们提到过内部可变性,其中Rc<T>
和RefCell<T>
的结合,可以实现单线程的内部可变性。
现在我们又有了新的武器,由于Mutex<T>
可以支持修改内部数据,当结合Arc<T>
一起使用时,可以实现多线程的内部可变性。
简单总结下:Rc<T>/RefCell<T>
用于单线程内部可变性, Arc<T>/Mutex<T>
用于多线程内部可变性。
如果有其它语言的编程经验,就知道互斥锁这家伙不好对付,想要正确使用,你得牢记在心:
-这两点看起来不起眼,但要正确的使用,其实是相当不简单的,对于其它语言,忘记释放锁是经常发生的,虽然 Rust 通过智能指针的drop
机制帮助我们避免了这一点,但是由于不及时释放锁导致的性能问题也是常见的。
正因为这种困难性,导致很多用户都热衷于使用消息传递的方式来实现同步,例如 Go 语言直接把channel
内置在语言特性中,甚至还有无锁的语言,例如erlang
,完全使用Actor
模型,依赖消息传递来完成共享和同步。幸好 Rust 的类型系统、所有权机制、智能指针等可以很好的帮助我们减轻使用锁时的负担。
另一个值的注意的是在使用Mutex<T>
时,Rust 无法帮我们避免所有的逻辑错误,例如在之前章节,我们提到过使用Rc<T>
可能会导致循环引用的问题。类似的,Mutex<T>
也存在使用上的风险,例如创建死锁(deadlock):当一个操作试图锁住两个资源,然后两个线程各自获取其中一个锁,并试图获取另一个锁时,就会造成死锁。
在 Rust 中有多种方式可以创建死锁,了解这些方式有助于你提前规避可能的风险,一起来看看。
-这种死锁比较容易规避,但是当代码复杂后还是有可能遇到:
--use std::sync::Mutex; - -fn main() { - let data = Mutex::new(0); - let d1 = data.lock(); - let d2 = data.lock(); -} // d1锁在此处释放 -
非常简单,只要你在另一个锁还未被释放时去申请新的锁,就会触发,当代码复杂后,这种情况可能就没有那么显眼。
-当我们拥有两个锁,且两个线程各自使用了其中一个锁,然后试图去访问另一个锁时,就可能发生死锁:
--use std::{sync::{Mutex, MutexGuard}, thread}; -use std::thread::sleep; -use std::time::Duration; - -use lazy_static::lazy_static; -lazy_static! { - static ref MUTEX1: Mutex<i64> = Mutex::new(0); - static ref MUTEX2: Mutex<i64> = Mutex::new(0); -} - -fn main() { - // 存放子线程的句柄 - let mut children = vec![]; - for i_thread in 0..2 { - children.push(thread::spawn(move || { - for _ in 0..1 { - // 线程1 - if i_thread % 2 == 0 { - // 锁住MUTEX1 - let guard: MutexGuard<i64> = MUTEX1.lock().unwrap(); - - println!("线程 {} 锁住了MUTEX1,接着准备去锁MUTEX2 !", i_thread); - - // 当前线程睡眠一小会儿,等待线程2锁住MUTEX2 - sleep(Duration::from_millis(10)); - - // 去锁MUTEX2 - let guard = MUTEX2.lock().unwrap(); - // 线程2 - } else { - // 锁住MUTEX2 - let _guard = MUTEX2.lock().unwrap(); - - println!("线程 {} 锁住了MUTEX2, 准备去锁MUTEX1", i_thread); - - let _guard = MUTEX1.lock().unwrap(); - } - } - })); - } - - // 等子线程完成 - for child in children { - let _ = child.join(); - } - - println!("死锁没有发生"); -} -
在上面的描述中,我们用了"可能"二字,原因在于死锁在这段代码中不是必然发生的,总有一次运行你能看到最后一行打印输出。这是由于子线程的初始化顺序和执行速度并不确定,我们无法确定哪个线程中的锁先被执行,因此也无法确定两个线程对锁的具体使用顺序。
-但是,可以简单的说明下死锁发生的必然条件:线程 1 锁住了MUTEX1
并且线程2
锁住了MUTEX2
,然后线程 1 试图去访问MUTEX2
,同时线程2
试图去访问MUTEX1
,就会死锁。 因为线程 2 需要等待线程 1 释放MUTEX1
后,才会释放MUTEX2
,而与此同时,线程 1 需要等待线程 2 释放MUTEX2
后才能释放MUTEX1
,这种情况造成了两个线程都无法释放对方需要的锁,最终死锁。
那么为何某些时候,死锁不会发生?原因很简单,线程 2 在线程 1 锁MUTEX1
之前,就已经全部执行完了,随之线程 2 的MUTEX2
和MUTEX1
被全部释放,线程 1 对锁的获取将不再有竞争者。 同理,线程 1 若全部被执行完,那线程 2 也不会被锁,因此我们在线程 1 中间加一个睡眠,增加死锁发生的概率。如果你在线程 2 中同样的位置也增加一个睡眠,那死锁将必然发生!
与lock
方法不同,try_lock
会尝试去获取一次锁,如果无法获取会返回一个错误,因此不会发生阻塞:
-use std::{sync::{Mutex, MutexGuard}, thread}; -use std::thread::sleep; -use std::time::Duration; - -use lazy_static::lazy_static; -lazy_static! { - static ref MUTEX1: Mutex<i64> = Mutex::new(0); - static ref MUTEX2: Mutex<i64> = Mutex::new(0); -} - -fn main() { - // 存放子线程的句柄 - let mut children = vec![]; - for i_thread in 0..2 { - children.push(thread::spawn(move || { - for _ in 0..1 { - // 线程1 - if i_thread % 2 == 0 { - // 锁住MUTEX1 - let guard: MutexGuard<i64> = MUTEX1.lock().unwrap(); - - println!("线程 {} 锁住了MUTEX1,接着准备去锁MUTEX2 !", i_thread); - - // 当前线程睡眠一小会儿,等待线程2锁住MUTEX2 - sleep(Duration::from_millis(10)); - - // 去锁MUTEX2 - let guard = MUTEX2.try_lock(); - println!("线程 {} 获取 MUTEX2 锁的结果: {:?}", i_thread, guard); - // 线程2 - } else { - // 锁住MUTEX2 - let _guard = MUTEX2.lock().unwrap(); - - println!("线程 {} 锁住了MUTEX2, 准备去锁MUTEX1", i_thread); - sleep(Duration::from_millis(10)); - let guard = MUTEX1.try_lock(); - println!("线程 {} 获取 MUTEX1 锁的结果: {:?}", i_thread, guard); - } - } - })); - } - - // 等子线程完成 - for child in children { - let _ = child.join(); - } - - println!("死锁没有发生"); -} -
为了演示try_lock
的作用,我们特定使用了之前必定会死锁的代码,并且将lock
替换成try_lock
,与之前的结果不同,这段代码将不会再有死锁发生:
线程 0 锁住了MUTEX1,接着准备去锁MUTEX2 !
-线程 1 锁住了MUTEX2, 准备去锁MUTEX1
-线程 1 获取 MUTEX1 锁的结果: Err("WouldBlock")
-线程 0 获取 MUTEX2 锁的结果: Ok(0)
-死锁没有发生
-
-如上所示,当try_lock
失败时,会报出一个错误:Err("WouldBlock")
,接着线程中的剩余代码会继续执行,不会被阻塞。
--一个有趣的命名规则:在 Rust 标准库中,使用
-try_xxx
都会尝试进行一次操作,如果无法完成,就立即返回,不会发生阻塞。例如消息传递章节中的try_recv
以及本章节中的try_lock
Mutex
会对每次读写都进行加锁,但某些时候,我们需要大量的并发读,Mutex
就无法满足需求了,此时就可以使用RwLock
:
-use std::sync::RwLock; - -fn main() { - let lock = RwLock::new(5); - - // 同一时间允许多个读 - { - let r1 = lock.read().unwrap(); - let r2 = lock.read().unwrap(); - assert_eq!(*r1, 5); - assert_eq!(*r2, 5); - } // 读锁在此处被drop - - // 同一时间只允许一个写 - { - let mut w = lock.write().unwrap(); - *w += 1; - assert_eq!(*w, 6); - - // 以下代码会panic,因为读和写不允许同时存在 - // 写锁w直到该语句块结束才被释放,因此下面的读锁依然处于`w`的作用域中 - // let r1 = lock.read(); - // println!("{:?}",r1); - }// 写锁在此处被drop -} -
RwLock
在使用上和Mutex
区别不大,需要注意的是,当读写同时发生时,程序会直接panic
(本例是单线程,实际上多个线程中也是如此),因为会发生死锁:
thread 'main' panicked at 'rwlock read lock would result in deadlock', /rustc/efec545293b9263be9edfb283a7aa66350b3acbf/library/std/src/sys/unix/rwlock.rs:49:13
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-好在我们可以使用try_write
和try_read
来尝试进行一次写/读,若失败则返回错误:
Err("WouldBlock")
-
-简单总结下RwLock
:
read
、try_read
,写write
、try_write
, 在实际项目中,try_xxx
会安全的多首先简单性上Mutex
完胜,因为使用RwLock
你得操心几个问题:
try_xxx
解决,就必须做大量的错误处理和失败重试机制Mutex
复杂的多,因此单就锁的性能而言,比不上原生实现的Mutex
再来简单总结下两者的使用场景:
-RwLock
,因为Mutex
一次只允许一个线程去读取Mutex
Mutex
需要注意的是,RwLock
虽然看上去貌似提供了高并发读取的能力,但这个不能说明它的性能比Mutex
高,事实上Mutex
性能要好不少,后者唯一的问题也仅仅在于不能并发读取。
一个常见的、错误的使用RwLock
的场景就是使用HashMap
进行简单读写,因为HashMap
的读和写都非常快,RwLock
的复杂实现和相对低的性能反而会导致整体性能的降低,因此一般来说更适合使用Mutex
。
总之,如果你要使用RwLock
要确保满足以下两个条件:并发读,且需要对读到的资源进行"长时间"的操作,HashMap
也许满足了并发读的需求,但是往往并不能满足后者:"长时间"的操作。
--benchmark 永远是你在迷茫时最好的朋友!
-
标准库在设计时总会存在取舍,因为往往性能并不是最好的,如果你追求性能,可以使用三方库提供的并发原语:
-parking_lot
高一点,最近没怎么更新如果不是追求特别极致的性能,建议选择前者。
-Mutex
用于解决资源安全访问的问题,但是我们还需要一个手段来解决资源访问顺序的问题。而 Rust 考虑到了这一点,为我们提供了条件变量(Condition Variables),它经常和Mutex
一起使用,可以让线程挂起,直到某个条件发生后再继续执行,其实Condvar
我们在之前的多线程章节就已经见到过,现在再来看一个不同的例子:
-use std::sync::{Arc,Mutex,Condvar}; -use std::thread::{spawn,sleep}; -use std::time::Duration; - -fn main() { - let flag = Arc::new(Mutex::new(false)); - let cond = Arc::new(Condvar::new()); - let cflag = flag.clone(); - let ccond = cond.clone(); - - let hdl = spawn(move || { - let mut m = { *cflag.lock().unwrap() }; - let mut counter = 0; - - while counter < 3 { - while !m { - m = *ccond.wait(cflag.lock().unwrap()).unwrap(); - } - - { - m = false; - *cflag.lock().unwrap() = false; - } - - counter += 1; - println!("inner counter: {}", counter); - } - }); - - let mut counter = 0; - loop { - sleep(Duration::from_millis(1000)); - *flag.lock().unwrap() = true; - counter += 1; - if counter > 3 { - break; - } - println!("outside counter: {}", counter); - cond.notify_one(); - } - hdl.join().unwrap(); - println!("{:?}", flag); -} -
例子中通过主线程来触发子线程实现交替打印输出:
-outside counter: 1
-inner counter: 1
-outside counter: 2
-inner counter: 2
-outside counter: 3
-inner counter: 3
-Mutex { data: true, poisoned: false, .. }
-
-在多线程中,另一个重要的概念就是信号量,使用它可以让我们精准的控制当前正在运行的任务最大数量。想象一下,当一个新游戏刚开服时(有些较火的老游戏也会,比如wow
),往往会控制游戏内玩家的同时在线数,一旦超过某个临界值,就开始进行排队进服。而在实际使用中,也有很多时候,我们需要通过信号量来控制最大并发数,防止服务器资源被撑爆。
本来 Rust 在标准库中有提供一个信号量实现, 但是由于各种原因这个库现在已经不再推荐使用了,因此我们推荐使用tokio
中提供的Semaphore
实现: tokio::sync::Semaphore
。
-use std::sync::Arc; -use tokio::sync::Semaphore; - -#[tokio::main] -async fn main() { - let semaphore = Arc::new(Semaphore::new(3)); - let mut join_handles = Vec::new(); - - for _ in 0..5 { - let permit = semaphore.clone().acquire_owned().await.unwrap(); - join_handles.push(tokio::spawn(async move { - // - // 在这里执行任务... - // - drop(permit); - })); - } - - for handle in join_handles { - handle.await.unwrap(); - } -} -
上面代码创建了一个容量为 3 的信号量,当正在执行的任务超过 3 时,剩下的任务需要等待正在执行任务完成并减少信号量后到 3 以内时,才能继续执行。
-这里的关键其实说白了就在于:信号量的申请和归还,使用前需要申请信号量,如果容量满了,就需要等待;使用后需要释放信号量,以便其它等待者可以继续。
-在很多时候,消息传递都是非常好用的手段,它可以让我们的数据在任务流水线上不断流转,实现起来非常优雅。
-但是它并不能优雅的解决所有问题,因为我们面临的真实世界是非常复杂的,无法用某一种银弹统一解决。当面临消息传递不太适用的场景时,或者需要更好的性能和简洁性时,我们往往需要用锁来解决这些问题,因为锁允许多个线程同时访问同一个资源,简单粗暴。
-除了锁之外,其实还有一种并发原语可以帮助我们解决并发访问数据的问题,那就是原子类型 Atomic,在下一章节中,我们会对其进行深入讲解。
- - -Mutex
用起来简单,但是无法并发读,RwLock
可以并发读,但是使用场景较为受限且性能不够,那么有没有一种全能性选手呢? 欢迎我们的Atomic
闪亮登场。
从 Rust1.34 版本后,就正式支持原子类型。原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。
-由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。
-可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了CAS
循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。
--CAS 全称是 Compare and swap, 它通过一条指令读取指定的内存地址,然后判断其中的值是否等于给定的前置值,如果相等,则将其修改为新的值
-
原子类型的一个常用场景,就是作为全局变量来使用:
--use std::ops::Sub; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::thread::{self, JoinHandle}; -use std::time::Instant; - -const N_TIMES: u64 = 10000000; -const N_THREADS: usize = 10; - -static R: AtomicU64 = AtomicU64::new(0); - -fn add_n_times(n: u64) -> JoinHandle<()> { - thread::spawn(move || { - for _ in 0..n { - R.fetch_add(1, Ordering::Relaxed); - } - }) -} - -fn main() { - let s = Instant::now(); - let mut threads = Vec::with_capacity(N_THREADS); - - for _ in 0..N_THREADS { - threads.push(add_n_times(N_TIMES)); - } - - for thread in threads { - thread.join().unwrap(); - } - - assert_eq!(N_TIMES * N_THREADS as u64, R.load(Ordering::Relaxed)); - - println!("{:?}",Instant::now().sub(s)); -} -
以上代码启动了数个线程,每个线程都在疯狂对全局变量进行加 1 操作, 最后将它与线程数 * 加1次数
进行比较,如果发生了因为多个线程同时修改导致了脏数据,那么这两个必将不相等。好在,它没有让我们失望,不仅快速的完成了任务,而且保证了 100%的并发安全性。
当然以上代码的功能其实也可以通过Mutex
来实现,但是后者的强大功能是建立在额外的性能损耗基础上的,因此性能会逊色不少:
Atomic实现:673ms
-Mutex实现: 1136ms
-
-可以看到Atomic
实现会比Mutex
快41%,实际上在复杂场景下还能更快(甚至达到 4 倍的性能差距)!
还有一点值得注意: 和Mutex
一样,Atomic
的值具有内部可变性,你无需将其声明为mut
:
-use std::sync::Mutex; -use std::sync::atomic::{Ordering, AtomicU64}; - -struct Counter { - count: u64 -} - -fn main() { - let n = Mutex::new(Counter { - count: 0 - }); - - n.lock().unwrap().count += 1; - - let n = AtomicU64::new(0); - - n.fetch_add(0, Ordering::Relaxed); -} -
这里有一个奇怪的枚举成员Ordering::Relaxed
, 看上去很像是排序作用,但是我们并没有做排序操作啊?实际上它用于控制原子操作使用的内存顺序。
内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响:
-对于第二点,我们举个例子:
--static mut X: u64 = 0; -static mut Y: u64 = 1; - -fn main() { - ... // A - - unsafe { - ... // B - X = 1; - ... // C - Y = 3; - ... // D - X = 2; - ... // E - } -} -
假如在C
和D
代码片段中,根本没有用到X = 1
,那么编译器很可能会将X = 1
和X = 2
进行合并:
--#![allow(unused)] -fn main() { - ... // A - -unsafe { - ... // B - X = 2; - ... // C - Y = 3; - ... // D - ... // E -} -} -
若代码A
中创建了一个新的线程用于读取全局静态变量X
,则该线程将无法读取到X = 1
的结果,因为在编译阶段就已经被优化掉。
假设之前的X = 1
没有被优化掉,并且在代码片段A
中有一个新的线程:
initial state: X = 0, Y = 1
-
-THREAD Main THREAD A
-X = 1; if X == 1 {
-Y = 3; Y *= 2;
-X = 2; }
-
-我们来讨论下以上线程状态,Y
最终的可能值(可能性依次降低):
Y = 3
: 线程Main
运行完后才运行线程A
,或者线程A
运行完后再运行线程Main
Y = 6
: 线程Main
的Y = 3
运行完,但X = 2
还没被运行, 此时线程 A 开始运行Y *= 2
, 最后才运行Main
线程的X = 2
Y = 2
: 线程Main
正在运行Y = 3
还没结束,此时线程A
正在运行Y *= 2
, 因此Y
取到了值 1,然后Main
的线程将Y
设置为 3, 紧接着就被线程A
的Y = 2
所覆盖Y = 2
: 上面的还只是一般的数据竞争,这里虽然产生了相同的结果2
,但是背后的原理大相径庭: 线程Main
运行完Y = 3
,但是 CPU 缓存中的Y = 3
还没有被同步到其它 CPU 缓存中,此时线程A
中的Y *= 2
就开始读取Y
,结果读到了值1
,最终计算出结果2
甚至更改成:
-initial state: X = 0, Y = 1
-
-THREAD Main THREAD A
-X = 1; if X == 2 {
-Y = 3; Y *= 2;
-X = 2; }
-
-还是可能出现Y = 2
,因为Main
线程中的X
和Y
被同步到其它 CPU 缓存中的顺序未必一致。
在理解了内存顺序可能存在的改变后,你就可以明白为什么 Rust 提供了Ordering::Relaxed
用于限定内存顺序了,事实上,该枚举有 5 个成员:
Release
在不同线程中联合使用atomic
自增 1,同时希望该操作之前和之后的读取或写入操作不会被重新排序SeqCst
就像是AcqRel
的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到SeqCst
的原子操作,线程中该SeqCst
操作前的数据操作绝对不会被重新排在该SeqCst
操作之后,且该SeqCst
操作后的数据操作也绝对不会被重新排在SeqCst
操作前。这些规则由于是系统提供的,因此其它语言提供的相应规则也大同小异,大家如果不明白可以看看其它语言的相关解释。
-下面我们以Release
和Acquire
为例,使用它们构筑出一对内存屏障,防止编译器和 CPU 将屏障前(Release)和屏障后(Acquire)中的数据操作重新排在屏障围成的范围之外:
-use std::thread::{self, JoinHandle}; -use std::sync::atomic::{Ordering, AtomicBool}; - -static mut DATA: u64 = 0; -static READY: AtomicBool = AtomicBool::new(false); - -fn reset() { - unsafe { - DATA = 0; - } - READY.store(false, Ordering::Relaxed); -} - -fn producer() -> JoinHandle<()> { - thread::spawn(move || { - unsafe { - DATA = 100; // A - } - READY.store(true, Ordering::Release); // B: 内存屏障 ↑ - }) -} - -fn consumer() -> JoinHandle<()> { - thread::spawn(move || { - while !READY.load(Ordering::Acquire) {} // C: 内存屏障 ↓ - - assert_eq!(100, unsafe { DATA }); // D - }) -} - - -fn main() { - loop { - reset(); - - let t_producer = producer(); - let t_consumer = consumer(); - - t_producer.join().unwrap(); - t_consumer.join().unwrap(); - } -} -
原则上,Acquire
用于读取,而Release
用于写入。但是由于有些原子操作同时拥有读取和写入的功能,此时就需要使用AcqRel
来设置内存顺序了。在内存屏障中被写入的数据,都可以被其它线程读取到,不会有 CPU 缓存的问题。
内存顺序的选择
-SeqCst
,虽然会稍微减慢速度,但是慢一点也比出现错误好fetch_add
而不使用该值触发其他逻辑分支的简单使用场景,可以使用Relaxed
在多线程环境中要使用Atomic
需要配合Arc
:
-use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::{hint, thread}; - -fn main() { - let spinlock = Arc::new(AtomicUsize::new(1)); - - let spinlock_clone = Arc::clone(&spinlock); - let thread = thread::spawn(move|| { - spinlock_clone.store(0, Ordering::SeqCst); - }); - - // 等待其它线程释放锁 - while spinlock.load(Ordering::SeqCst) != 0 { - hint::spin_loop(); - } - - if let Err(panic) = thread.join() { - println!("Thread had an error: {:?}", panic); - } -} -
那么原子类型既然这么全能,它可以替代锁吗?答案是不行:
-std::sync::atomic
包中仅提供了数值类型的原子操作:AtomicBool
, AtomicIsize
, AtomicUsize
, AtomicI8
, AtomicU16
等,而锁可以应用于各种类型Mutex
配合Condvar
事实上,Atomic
虽然对于用户不太常用,但是对于高性能库的开发者、标准库开发者都非常常用,它是并发原语的基石,除此之外,还有一些场景适用:
以上列出的只是Atomic
适用的部分场景,具体场景需要大家未来根据自己的需求进行权衡选择。
放在十年前,多线程编程可能还是一个少数人才掌握的核心概念,但是在今天,随着编程语言的不断发展,多线程、多协程、Actor 等并发编程方式已经深入人心,同时多线程编程的门槛也在不断降低,本章节我们来看看在 Rust 中该如何使用多线程。
-由于多线程的代码是同时运行的,因此我们无法保证线程间的执行顺序,这会导致一些问题:
-虽然 Rust 已经通过各种机制减少了上述情况的发生,但是依然无法完全避免上述情况,因此我们在编程时需要格外的小心,同时本书也会列出多线程编程时常见的陷阱,让你提前规避可能的风险。
-使用 thread::spawn
可以创建线程:
-use std::thread; -use std::time::Duration; - -fn main() { - thread::spawn(|| { - for i in 1..10 { - println!("hi number {} from the spawned thread!", i); - thread::sleep(Duration::from_millis(1)); - } - }); - - for i in 1..5 { - println!("hi number {} from the main thread!", i); - thread::sleep(Duration::from_millis(1)); - } -} -
有几点值得注意:
-main
线程一旦结束,程序就立刻结束,因此需要保持它的存活,直到其它子线程完成自己的任务thread::sleep
会让当前线程休眠指定的时间,随后其它线程会被调度运行(上一节并发与并行中有简单介绍过),因此就算你的电脑只有一个 CPU 核心,该程序也会表现的如同多 CPU 核心一般,这就是并发!来看看输出:
-hi number 1 from the main thread!
-hi number 1 from the spawned thread!
-hi number 2 from the main thread!
-hi number 2 from the spawned thread!
-hi number 3 from the main thread!
-hi number 3 from the spawned thread!
-hi number 4 from the spawned thread!
-hi number 4 from the main thread!
-hi number 5 from the spawned thread!
-
-如果多运行几次,你会发现好像每次输出会不太一样,因为:虽说线程往往是轮流执行的,但是这一点无法被保证!线程调度的方式往往取决于你使用的操作系统。总之,千万不要依赖线程的执行顺序。
-上面的代码你不但可能无法让子线程从 1 顺序打印到 10,而且可能打印的数字会变少,因为主线程会提前结束,导致子线程也随之结束,更过分的是,如果当前系统繁忙,甚至该子线程还没被创建,主线程就已经结束了!
-因此我们需要一个方法,让主线程安全、可靠地等所有子线程完成任务后,再 kill self:
--use std::thread; -use std::time::Duration; - -fn main() { - let handle = thread::spawn(|| { - for i in 1..5 { - println!("hi number {} from the spawned thread!", i); - thread::sleep(Duration::from_millis(1)); - } - }); - - handle.join().unwrap(); - - for i in 1..5 { - println!("hi number {} from the main thread!", i); - thread::sleep(Duration::from_millis(1)); - } -} -
通过调用 handle.join
,可以让当前线程阻塞,直到它等待的子线程的结束,在上面代码中,由于 main
线程会被阻塞,因此它直到子线程结束后才会输出自己的 1..5
:
hi number 1 from the spawned thread!
-hi number 2 from the spawned thread!
-hi number 3 from the spawned thread!
-hi number 4 from the spawned thread!
-hi number 1 from the main thread!
-hi number 2 from the main thread!
-hi number 3 from the main thread!
-hi number 4 from the main thread!
-
-以上输出清晰的展示了线程阻塞的作用,如果你将 handle.join
放置在 main
线程中的 for
循环后面,那就是另外一个结果:两个线程交替输出。
在闭包章节中,有讲过 move
关键字在闭包中的使用可以让该闭包拿走环境中某个值的所有权,同样地,你可以使用 move
来将所有权从一个线程转移到另外一个线程。
首先,来看看在一个线程中直接使用另一个线程中的数据会如何:
--use std::thread; - -fn main() { - let v = vec![1, 2, 3]; - - let handle = thread::spawn(|| { - println!("Here's a vector: {:?}", v); - }); - - handle.join().unwrap(); -} -
以上代码在子线程的闭包中捕获了环境中的 v
变量,来看看结果:
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
- --> src/main.rs:6:32
- |
-6 | let handle = thread::spawn(|| {
- | ^^ may outlive borrowed value `v`
-7 | println!("Here's a vector: {:?}", v);
- | - `v` is borrowed here
- |
-note: function requires argument type to outlive `'static`
- --> src/main.rs:6:18
- |
-6 | let handle = thread::spawn(|| {
- | __________________^
-7 | | println!("Here's a vector: {:?}", v);
-8 | | });
- | |______^
-help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
- |
-6 | let handle = thread::spawn(move || {
- | ++++
-
-其实代码本身并没有什么问题,问题在于 Rust 无法确定新的线程会活多久(多个线程的结束顺序并不是固定的),所以也无法确定新线程所引用的 v
是否在使用过程中一直合法:
-use std::thread; - -fn main() { - let v = vec![1, 2, 3]; - - let handle = thread::spawn(|| { - println!("Here's a vector: {:?}", v); - }); - - drop(v); // oh no! - - handle.join().unwrap(); -} -
大家要记住,线程的启动时间点和结束时间点是不确定的,因此存在一种可能,当主线程执行完, v
被释放掉时,新的线程很可能还没有结束甚至还没有被创建成功,此时新线程对 v
的引用立刻就不再合法!
好在报错里进行了提示:to force the closure to take ownership of v (and any other referenced variables), use the `move` keyword
,让我们使用 move
关键字拿走 v
的所有权即可:
-use std::thread; - -fn main() { - let v = vec![1, 2, 3]; - - let handle = thread::spawn(move || { - println!("Here's a vector: {:?}", v); - }); - - handle.join().unwrap(); - - // 下面代码会报错borrow of moved value: `v` - // println!("{:?}",v); -} -
如上所示,很简单的代码,而且 Rust 的所有权机制保证了数据使用上的安全:v
的所有权被转移给新的线程后,main
线程将无法继续使用:最后一行代码将报错。
之前我们提到 main
线程是程序的主线程,一旦结束,则程序随之结束,同时各个子线程也将被强行终止。那么有一个问题,如果父线程不是 main
线程,那么父线程的结束会导致什么?自生自灭还是被干掉?
在系统编程中,操作系统提供了直接杀死线程的接口,简单粗暴,但是 Rust 并没有提供这样的接口,原因在于,粗暴地终止一个线程可能会导致资源没有释放、状态混乱等不可预期的结果,一向以安全自称的 Rust,自然不会砸自己的饭碗。
-那么 Rust 中线程是如何结束的呢?答案很简单:线程的代码执行完,线程就会自动结束。但是如果线程中的代码不会执行完呢?那么情况可以分为两种进行讨论:
-main
线程的结束第一情况很常见,我们来模拟看看第二种情况:
--use std::thread; -use std::time::Duration; -fn main() { - // 创建一个线程A - let new_thread = thread::spawn(move || { - // 再创建一个线程B - thread::spawn(move || { - loop { - println!("I am a new thread."); - } - }) - }); - - // 等待新创建的线程执行完成 - new_thread.join().unwrap(); - println!("Child thread is finish!"); - - // 睡眠一段时间,看子线程创建的子线程是否还在运行 - thread::sleep(Duration::from_millis(100)); -} -
以上代码中,main
线程创建了一个新的线程 A
,同时该新线程又创建了一个新的线程 B
,可以看到 A
线程在创建完 B
线程后就立即结束了,而 B
线程则在不停地循环输出。
从之前的线程结束规则,我们可以猜测程序将这样执行:A
线程结束后,由它创建的 B
线程仍在疯狂输出,直到 main
线程在 100 毫秒后结束。如果你把该时间增加到几十秒,就可以看到你的 CPU 核心 100% 的盛况了-,-
下面我们从多个方面来看看多线程的性能大概是怎么样的。
-据不精确估算,创建一个线程大概需要 0.24 毫秒,随着线程的变多,这个值会变得更大,因此线程的创建耗时并不是不可忽略的,只有当真的需要处理一个值得用线程去处理的任务时,才使用线程,一些鸡毛蒜皮的任务,就无需创建线程了。
-因为 CPU 的核心数限制,当任务是 CPU 密集型时,就算线程数超过了 CPU 核心数,也并不能帮你获得更好的性能,因为每个线程的任务都可以轻松让 CPU 的某个核心跑满,既然如此,让线程数等于 CPU 核心数是最好的。
-但是当你的任务大部分时间都处于阻塞状态时,就可以考虑增多线程数量,这样当某个线程处于阻塞状态时,会被切走,进而运行其它的线程,典型就是网络 IO 操作,我们可以为每一个进来的用户连接创建一个线程去处理,该连接绝大部分时间都是处于 IO 读取阻塞状态,因此有限的 CPU 核心完全可以处理成百上千的用户连接线程,但是事实上,对于这种网络 IO 情况,一般都不再使用多线程的方式了,毕竟操作系统的线程数是有限的,意味着并发数也很容易达到上限,而且过多的线程也会导致线程上下文切换的代价过大,使用 async/await
的 M:N
并发模型,就没有这个烦恼。
下面的代码是一个无锁实现(CAS)的 Hashmap
在多线程下的使用:
--#![allow(unused)] -fn main() { -for i in 0..num_threads { - let ht = Arc::clone(&ht); - - let handle = thread::spawn(move || { - for j in 0..adds_per_thread { - let key = thread_rng().gen::<u32>(); - let value = thread_rng().gen::<u32>(); - ht.set_item(key, value); - } - }); - - handles.push(handle); -} - -for handle in handles { - handle.join().unwrap(); -} -} -
按理来说,既然是无锁实现了,那么锁的开销应该几乎没有,性能会随着线程数的增加接近线性增长,但是真的是这样吗?
-下图是该代码在 48
核机器上的运行结果:
从图上可以明显的看出:吞吐并不是线性增长,尤其从 16
核开始,甚至开始肉眼可见的下降,这是为什么呢?
限于书本的篇幅有限,我们只能给出大概的原因:
-总之,多线程的开销往往是在锁、数据竞争、缓存失效上,这些限制了现代化软件系统随着 CPU 核心的增多性能也线性增加的野心。
-在 Rust 中,可以使用 Barrier
让多个线程都执行到某个点后,才继续一起往后执行:
-use std::sync::{Arc, Barrier}; -use std::thread; - -fn main() { - let mut handles = Vec::with_capacity(6); - let barrier = Arc::new(Barrier::new(6)); - - for _ in 0..6 { - let b = barrier.clone(); - handles.push(thread::spawn(move|| { - println!("before wait"); - b.wait(); - println!("after wait"); - })); - } - - for handle in handles { - handle.join().unwrap(); - } -} -
上面代码,我们在线程打印出 before wait
后增加了一个屏障,目的就是等所有的线程都打印出before wait后,各个线程再继续执行:
before wait
-before wait
-before wait
-before wait
-before wait
-before wait
-after wait
-after wait
-after wait
-after wait
-after wait
-after wait
-
-对于多线程编程,线程局部变量在一些场景下非常有用,而 Rust 通过标准库和三方库对此进行了支持。
-使用 thread_local
宏可以初始化线程局部变量,然后在线程内部使用该变量的 with
方法获取变量值:
--#![allow(unused)] -fn main() { -use std::cell::RefCell; -use std::thread; - -thread_local!(static FOO: RefCell<u32> = RefCell::new(1)); - -FOO.with(|f| { - assert_eq!(*f.borrow(), 1); - *f.borrow_mut() = 2; -}); - -// 每个线程开始时都会拿到线程局部变量的FOO的初始值 -let t = thread::spawn(move|| { - FOO.with(|f| { - assert_eq!(*f.borrow(), 1); - *f.borrow_mut() = 3; - }); -}); - -// 等待线程完成 -t.join().unwrap(); - -// 尽管子线程中修改为了3,我们在这里依然拥有main线程中的局部值:2 -FOO.with(|f| { - assert_eq!(*f.borrow(), 2); -}); -} -
上面代码中,FOO
即是我们创建的线程局部变量,每个新的线程访问它时,都会使用它的初始值作为开始,各个线程中的 FOO
值彼此互不干扰。注意 FOO
使用 static
声明为生命周期为 'static
的静态变量。
可以注意到,线程中对 FOO
的使用是通过借用的方式,但是若我们需要每个线程独自获取它的拷贝,最后进行汇总,就有些强人所难了。
你还可以在结构体中使用线程局部变量:
--use std::cell::RefCell; - -struct Foo; -impl Foo { - thread_local! { - static FOO: RefCell<usize> = RefCell::new(0); - } -} - -fn main() { - Foo::FOO.with(|x| println!("{:?}", x)); -} -
或者通过引用的方式使用它:
---#![allow(unused)] -fn main() { -use std::cell::RefCell; -use std::thread::LocalKey; - -thread_local! { - static FOO: RefCell<usize> = RefCell::new(0); -} -struct Bar { - foo: &'static LocalKey<RefCell<usize>>, -} -impl Bar { - fn constructor() -> Self { - Self { - foo: &FOO, - } - } -} -} -
除了标准库外,一位大神还开发了 thread-local 库,它允许每个线程持有值的独立拷贝:
---#![allow(unused)] -fn main() { -use thread_local::ThreadLocal; -use std::sync::Arc; -use std::cell::Cell; -use std::thread; - -let tls = Arc::new(ThreadLocal::new()); - -// 创建多个线程 -for _ in 0..5 { - let tls2 = tls.clone(); - thread::spawn(move || { - // 将计数器加1 - let cell = tls2.get_or(|| Cell::new(0)); - cell.set(cell.get() + 1); - }).join().unwrap(); -} - -// 一旦所有子线程结束,收集它们的线程局部变量中的计数器值,然后进行求和 -let tls = Arc::try_unwrap(tls).unwrap(); -let total = tls.into_iter().fold(0, |x, y| x + y.get()); - -// 和为5 -assert_eq!(total, 5); -} -
该库不仅仅使用了值的拷贝,而且还能自动把多个拷贝汇总到一个迭代器中,最后进行求和,非常好用。
-条件变量(Condition Variables)经常和 Mutex
一起使用,可以让线程挂起,直到某个条件发生后再继续执行:
-use std::thread; -use std::sync::{Arc, Mutex, Condvar}; - -fn main() { - let pair = Arc::new((Mutex::new(false), Condvar::new())); - let pair2 = pair.clone(); - - thread::spawn(move|| { - let (lock, cvar) = &*pair2; - let mut started = lock.lock().unwrap(); - println!("changing started"); - *started = true; - cvar.notify_one(); - }); - - let (lock, cvar) = &*pair; - let mut started = lock.lock().unwrap(); - while !*started { - started = cvar.wait(started).unwrap(); - } - - println!("started changed"); -} -
上述代码流程如下:
-main
线程首先进入 while
循环,调用 wait
方法挂起等待子线程的通知,并释放了锁 started
true
,然后调用条件变量的 notify_one
方法来通知主线程继续执行有时,我们会需要某个函数在多线程环境下只被调用一次,例如初始化全局变量,无论是哪个线程先调用函数来初始化,都会保证全局变量只会被初始化一次,随后的其它线程调用就会忽略该函数:
--use std::thread; -use std::sync::Once; - -static mut VAL: usize = 0; -static INIT: Once = Once::new(); - -fn main() { - let handle1 = thread::spawn(move || { - INIT.call_once(|| { - unsafe { - VAL = 1; - } - }); - }); - - let handle2 = thread::spawn(move || { - INIT.call_once(|| { - unsafe { - VAL = 2; - } - }); - }); - - handle1.join().unwrap(); - handle2.join().unwrap(); - - println!("{}", unsafe { VAL }); -} -
代码运行的结果取决于哪个线程先调用 INIT.call_once
(虽然代码具有先后顺序,但是线程的初始化顺序并无法被保证!因为线程初始化是异步的,且耗时较久),若 handle1
先,则输出 1
,否则输出 2
。
call_once 方法
-执行初始化过程一次,并且只执行一次。
-如果当前有另一个初始化过程正在运行,线程将阻止该方法被调用。
-当这个函数返回时,保证一些初始化已经运行并完成,它还保证由执行的闭包所执行的任何内存写入都能被其他线程在这时可靠地观察到。
-Rust 的线程模型是 1:1
模型,因为 Rust 要保持尽量小的运行时。
我们可以使用 thread::spawn
来创建线程,创建出的多个线程之间并不存在执行顺序关系,因此代码逻辑千万不要依赖于线程间的执行顺序。
main
线程若是结束,则所有子线程都将被终止,如果希望等待子线程结束后,再结束 main
线程,你需要使用创建线程时返回的句柄的 join
方法。
在线程中无法直接借用外部环境中的变量值,因为新线程的启动时间点和结束时间点是不确定的,所以 Rust 无法保证该线程中借用的变量在使用过程中依然是合法的。你可以使用 move
关键字将变量的所有权转移给新的线程,来解决此问题。
父线程结束后,子线程仍在持续运行,直到子线程的代码运行完成或者 main
线程的结束。
在之前的返回值和错误处理章节中,我们学习了几个重要的概念,例如 Result
用于返回结果处理,?
用于错误的传播,若大家对此还较为模糊,强烈建议回头温习下。
在本章节中一起来看看如何对 Result
( Option
) 做进一步的处理,以及如何定义自己的错误类型。
在设计模式中,有一个组合器模式,相信有 Java 背景的同学对此并不陌生。
---将对象组合成树形结构以表示“部分整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。–GoF <<设计模式>>
-
与组合器模式有所不同,在 Rust 中,组合器更多的是用于对返回结果的类型进行变换:例如使用 ok_or
将一个 Option
类型转换成 Result
类型。
下面我们来看看一些常见的组合器。
-跟布尔关系的与/或很像,这两个方法会对两个表达式做逻辑组合,最终返回 Option
/ Result
。
or()
,表达式按照顺序求值,若任何一个表达式的结果是 Some
或 Ok
,则该值会立刻返回and()
,若两个表达式的结果都是 Some
或 Ok
,则第二个表达式中的值被返回。若任何一个的结果是 None
或 Err
,则立刻返回。实际上,只要将布尔表达式的 true
/ false
,替换成 Some
/ None
或 Ok
/ Err
就很好理解了。
-fn main() { - let s1 = Some("some1"); - let s2 = Some("some2"); - let n: Option<&str> = None; - - let o1: Result<&str, &str> = Ok("ok1"); - let o2: Result<&str, &str> = Ok("ok2"); - let e1: Result<&str, &str> = Err("error1"); - let e2: Result<&str, &str> = Err("error2"); - - assert_eq!(s1.or(s2), s1); // Some1 or Some2 = Some1 - assert_eq!(s1.or(n), s1); // Some or None = Some - assert_eq!(n.or(s1), s1); // None or Some = Some - assert_eq!(n.or(n), n); // None1 or None2 = None2 - - assert_eq!(o1.or(o2), o1); // Ok1 or Ok2 = Ok1 - assert_eq!(o1.or(e1), o1); // Ok or Err = Ok - assert_eq!(e1.or(o1), o1); // Err or Ok = Ok - assert_eq!(e1.or(e2), e2); // Err1 or Err2 = Err2 - - assert_eq!(s1.and(s2), s2); // Some1 and Some2 = Some2 - assert_eq!(s1.and(n), n); // Some and None = None - assert_eq!(n.and(s1), n); // None and Some = None - assert_eq!(n.and(n), n); // None1 and None2 = None1 - - assert_eq!(o1.and(o2), o2); // Ok1 and Ok2 = Ok2 - assert_eq!(o1.and(e1), e1); // Ok and Err = Err - assert_eq!(e1.and(o1), e1); // Err and Ok = Err - assert_eq!(e1.and(e2), e1); // Err1 and Err2 = Err1 -} -
除了 or
和 and
之外,Rust 还为我们提供了 xor
,但是它只能应用在 Option
上,其实想想也是这个理,如果能应用在 Result
上,那你又该如何对一个值和错误进行异或操作?
它们跟 or()
和 and()
类似,唯一的区别在于,它们的第二个表达式是一个闭包。
-fn main() { - // or_else with Option - let s1 = Some("some1"); - let s2 = Some("some2"); - let fn_some = || Some("some2"); // 类似于: let fn_some = || -> Option<&str> { Some("some2") }; - - let n: Option<&str> = None; - let fn_none = || None; - - assert_eq!(s1.or_else(fn_some), s1); // Some1 or_else Some2 = Some1 - assert_eq!(s1.or_else(fn_none), s1); // Some or_else None = Some - assert_eq!(n.or_else(fn_some), s2); // None or_else Some = Some - assert_eq!(n.or_else(fn_none), None); // None1 or_else None2 = None2 - - // or_else with Result - let o1: Result<&str, &str> = Ok("ok1"); - let o2: Result<&str, &str> = Ok("ok2"); - let fn_ok = |_| Ok("ok2"); // 类似于: let fn_ok = |_| -> Result<&str, &str> { Ok("ok2") }; - - let e1: Result<&str, &str> = Err("error1"); - let e2: Result<&str, &str> = Err("error2"); - let fn_err = |_| Err("error2"); - - assert_eq!(o1.or_else(fn_ok), o1); // Ok1 or_else Ok2 = Ok1 - assert_eq!(o1.or_else(fn_err), o1); // Ok or_else Err = Ok - assert_eq!(e1.or_else(fn_ok), o2); // Err or_else Ok = Ok - assert_eq!(e1.or_else(fn_err), e2); // Err1 or_else Err2 = Err2 -} -
-fn main() { - // and_then with Option - let s1 = Some("some1"); - let s2 = Some("some2"); - let fn_some = |_| Some("some2"); // 类似于: let fn_some = |_| -> Option<&str> { Some("some2") }; - - let n: Option<&str> = None; - let fn_none = |_| None; - - assert_eq!(s1.and_then(fn_some), s2); // Some1 and_then Some2 = Some2 - assert_eq!(s1.and_then(fn_none), n); // Some and_then None = None - assert_eq!(n.and_then(fn_some), n); // None and_then Some = None - assert_eq!(n.and_then(fn_none), n); // None1 and_then None2 = None1 - - // and_then with Result - let o1: Result<&str, &str> = Ok("ok1"); - let o2: Result<&str, &str> = Ok("ok2"); - let fn_ok = |_| Ok("ok2"); // 类似于: let fn_ok = |_| -> Result<&str, &str> { Ok("ok2") }; - - let e1: Result<&str, &str> = Err("error1"); - let e2: Result<&str, &str> = Err("error2"); - let fn_err = |_| Err("error2"); - - assert_eq!(o1.and_then(fn_ok), o2); // Ok1 and_then Ok2 = Ok2 - assert_eq!(o1.and_then(fn_err), e2); // Ok and_then Err = Err - assert_eq!(e1.and_then(fn_ok), e1); // Err and_then Ok = Err - assert_eq!(e1.and_then(fn_err), e1); // Err1 and_then Err2 = Err1 -} -
filter
用于对 Option
进行过滤:
-fn main() { - let s1 = Some(3); - let s2 = Some(6); - let n = None; - - let fn_is_even = |x: &i8| x % 2 == 0; - - assert_eq!(s1.filter(fn_is_even), n); // Some(3) -> 3 is not even -> None - assert_eq!(s2.filter(fn_is_even), s2); // Some(6) -> 6 is even -> Some(6) - assert_eq!(n.filter(fn_is_even), n); // None -> no value -> None -} -
map
可以将 Some
或 Ok
中的值映射为另一个:
-fn main() { - let s1 = Some("abcde"); - let s2 = Some(5); - - let n1: Option<&str> = None; - let n2: Option<usize> = None; - - let o1: Result<&str, &str> = Ok("abcde"); - let o2: Result<usize, &str> = Ok(5); - - let e1: Result<&str, &str> = Err("abcde"); - let e2: Result<usize, &str> = Err("abcde"); - - let fn_character_count = |s: &str| s.chars().count(); - - assert_eq!(s1.map(fn_character_count), s2); // Some1 map = Some2 - assert_eq!(n1.map(fn_character_count), n2); // None1 map = None2 - - assert_eq!(o1.map(fn_character_count), o2); // Ok1 map = Ok2 - assert_eq!(e1.map(fn_character_count), e2); // Err1 map = Err2 -} -
但是如果你想要将 Err
中的值进行改变, map
就无能为力了,此时我们需要用 map_err
:
-fn main() { - let o1: Result<&str, &str> = Ok("abcde"); - let o2: Result<&str, isize> = Ok("abcde"); - - let e1: Result<&str, &str> = Err("404"); - let e2: Result<&str, isize> = Err(404); - - let fn_character_count = |s: &str| -> isize { s.parse().unwrap() }; // 该函数返回一个 isize - - assert_eq!(o1.map_err(fn_character_count), o2); // Ok1 map = Ok2 - assert_eq!(e1.map_err(fn_character_count), e2); // Err1 map = Err2 -} -
通过对 o1
的操作可以看出,与 map
面对 Err
时的短小类似, map_err
面对 Ok
时也是相当无力的。
map_or
在 map
的基础上提供了一个默认值:
-fn main() { - const V_DEFAULT: u32 = 1; - - let s: Result<u32, ()> = Ok(10); - let n: Option<u32> = None; - let fn_closure = |v: u32| v + 2; - - assert_eq!(s.map_or(V_DEFAULT, fn_closure), 12); - assert_eq!(n.map_or(V_DEFAULT, fn_closure), V_DEFAULT); -} -
如上所示,当处理 None
的时候,V_DEFAULT
作为默认值被直接返回。
map_or_else
与 map_or
类似,但是它是通过一个闭包来提供默认值:
-fn main() { - let s = Some(10); - let n: Option<i8> = None; - - let fn_closure = |v: i8| v + 2; - let fn_default = || 1; - - assert_eq!(s.map_or_else(fn_default, fn_closure), 12); - assert_eq!(n.map_or_else(fn_default, fn_closure), 1); - - let o = Ok(10); - let e = Err(5); - let fn_default_for_result = |v: i8| v + 1; // 闭包可以对 Err 中的值进行处理,并返回一个新值 - - assert_eq!(o.map_or_else(fn_default_for_result, fn_closure), 12); - assert_eq!(e.map_or_else(fn_default_for_result, fn_closure), 6); -} -
这两兄弟可以将 Option
类型转换为 Result
类型。其中 ok_or
接收一个默认的 Err
参数:
-fn main() { - const ERR_DEFAULT: &str = "error message"; - - let s = Some("abcde"); - let n: Option<&str> = None; - - let o: Result<&str, &str> = Ok("abcde"); - let e: Result<&str, &str> = Err(ERR_DEFAULT); - - assert_eq!(s.ok_or(ERR_DEFAULT), o); // Some(T) -> Ok(T) - assert_eq!(n.ok_or(ERR_DEFAULT), e); // None -> Err(default) -} -
而 ok_or_else
接收一个闭包作为 Err
参数:
-fn main() { - let s = Some("abcde"); - let n: Option<&str> = None; - let fn_err_message = || "error message"; - - let o: Result<&str, &str> = Ok("abcde"); - let e: Result<&str, &str> = Err("error message"); - - assert_eq!(s.ok_or_else(fn_err_message), o); // Some(T) -> Ok(T) - assert_eq!(n.ok_or_else(fn_err_message), e); // None -> Err(default) -} -
以上列出的只是常用的一部分,强烈建议大家看看标准库中有哪些可用的 API,在实际项目中,这些 API 将会非常有用: Option 和 Result。
-虽然标准库定义了大量的错误类型,但是一个严谨的项目,光使用这些错误类型往往是不够的,例如我们可能会为暴露给用户的错误定义相应的类型。
-为了帮助我们更好的定义错误,Rust 在标准库中提供了一些可复用的特征,例如 std::error::Error
特征:
--#![allow(unused)] -fn main() { -use std::fmt::{Debug, Display}; - -pub trait Error: Debug + Display { - fn source(&self) -> Option<&(Error + 'static)> { ... } -} -} -
当自定义类型实现该特征后,该类型就可以作为 Err
来使用,下面一起来看看。
--实际上,自定义错误类型只需要实现
-Debug
和Display
特征即可,source
方法是可选的,而Debug
特征往往也无需手动实现,可以直接通过derive
来派生
-use std::fmt; - -// AppError 是自定义错误类型,它可以是当前包中定义的任何类型,在这里为了简化,我们使用了单元结构体作为例子。 -// 为 AppError 自动派生 Debug 特征 -#[derive(Debug)] -struct AppError; - -// 为 AppError 实现 std::fmt::Display 特征 -impl fmt::Display for AppError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "An Error Occurred, Please Try Again!") // user-facing output - } -} - -// 一个示例函数用于产生 AppError 错误 -fn produce_error() -> Result<(), AppError> { - Err(AppError) -} - -fn main(){ - match produce_error() { - Err(e) => eprintln!("{}", e), - _ => println!("No error"), - } - - eprintln!("{:?}", produce_error()); // Err({ file: src/main.rs, line: 17 }) -} -
上面的例子很简单,我们定义了一个错误类型,当为它派生了 Debug
特征,同时手动实现了 Display
特征后,该错误类型就可以作为 Err
来使用了。
事实上,实现 Debug
和 Display
特征并不是作为 Err
使用的必要条件,大家可以把这两个特征实现和相应使用去除,然后看看代码会否报错。既然如此,我们为何要为自定义类型实现这两个特征呢?原因有二:
Box<dyn std::error:Error>
特征对象,在后面的归一化不同错误类型部分,我们会详细介绍上一个例子中定义的错误非常简单,我们无法从错误中得到更多的信息,现在再来定义一个具有错误码和信息的错误:
--use std::fmt; - -struct AppError { - code: usize, - message: String, -} - -// 根据错误码显示不同的错误信息 -impl fmt::Display for AppError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let err_msg = match self.code { - 404 => "Sorry, Can not find the Page!", - _ => "Sorry, something is wrong! Please Try Again!", - }; - - write!(f, "{}", err_msg) - } -} - -impl fmt::Debug for AppError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "AppError {{ code: {}, message: {} }}", - self.code, self.message - ) - } -} - -fn produce_error() -> Result<(), AppError> { - Err(AppError { - code: 404, - message: String::from("Page not found"), - }) -} - -fn main() { - match produce_error() { - Err(e) => eprintln!("{}", e), // 抱歉,未找到指定的页面! - _ => println!("No error"), - } - - eprintln!("{:?}", produce_error()); // Err(AppError { code: 404, message: Page not found }) - - eprintln!("{:#?}", produce_error()); - // Err( - // AppError { code: 404, message: Page not found } - // ) -} -
在本例中,我们除了增加了错误码和消息外,还手动实现了 Debug
特征,原因在于,我们希望能自定义 Debug
的输出内容,而不是使用派生后系统提供的默认输出形式。
From
特征标准库、三方库、本地库,各有各的精彩,各也有各的错误。那么问题就来了,我们该如何将其它的错误类型转换成自定义的错误类型?总不能神鬼牛魔,同台共舞吧。。
-好在 Rust 为我们提供了 std::convert::From
特征:
--#![allow(unused)] -fn main() { -pub trait From<T>: Sized { - fn from(_: T) -> Self; -} -} -
--事实上,该特征在之前的
-?
操作符章节中就有所介绍。大家都使用过
-String::from
函数吧?它可以通过&str
来创建一个String
,其实该函数就是From
特征提供的
下面一起来看看如何为自定义类型实现 From
特征:
-use std::fs::File; -use std::io; - -#[derive(Debug)] -struct AppError { - kind: String, // 错误类型 - message: String, // 错误信息 -} - -// 为 AppError 实现 std::convert::From 特征,由于 From 包含在 std::prelude 中,因此可以直接简化引入。 -// 实现 From<io::Error> 意味着我们可以将 io::Error 错误转换成自定义的 AppError 错误 -impl From<io::Error> for AppError { - fn from(error: io::Error) -> Self { - AppError { - kind: String::from("io"), - message: error.to_string(), - } - } -} - -fn main() -> Result<(), AppError> { - let _file = File::open("nonexistent_file.txt")?; - - Ok(()) -} - -// --------------- 上述代码运行后输出 --------------- -Error: AppError { kind: "io", message: "No such file or directory (os error 2)" } -
上面的代码中除了实现 From
外,还有一点特别重要,那就是 ?
可以将错误进行隐式的强制转换:File::open
返回的是 std::io::Error
, 我们并没有进行任何显式的转换,它就能自动变成 AppError
,这就是 ?
的强大之处!
上面的例子只有一个标准库错误,再来看看多个不同的错误转换成 AppError
的实现:
-use std::fs::File; -use std::io::{self, Read}; -use std::num; - -#[derive(Debug)] -struct AppError { - kind: String, - message: String, -} - -impl From<io::Error> for AppError { - fn from(error: io::Error) -> Self { - AppError { - kind: String::from("io"), - message: error.to_string(), - } - } -} - -impl From<num::ParseIntError> for AppError { - fn from(error: num::ParseIntError) -> Self { - AppError { - kind: String::from("parse"), - message: error.to_string(), - } - } -} - -fn main() -> Result<(), AppError> { - let mut file = File::open("hello_world.txt")?; - - let mut content = String::new(); - file.read_to_string(&mut content)?; - - let _number: usize; - _number = content.parse()?; - - Ok(()) -} - - -// --------------- 上述代码运行后的可能输出 --------------- - -// 01. 若 hello_world.txt 文件不存在 -Error: AppError { kind: "io", message: "No such file or directory (os error 2)" } - -// 02. 若用户没有相关的权限访问 hello_world.txt -Error: AppError { kind: "io", message: "Permission denied (os error 13)" } - -// 03. 若 hello_world.txt 包含有非数字的内容,例如 Hello, world! -Error: AppError { kind: "parse", message: "invalid digit found in string" } -
至此,关于 Rust 的错误处理大家已经了若指掌了,下面再来看看一些实战中的问题。
-在实际项目中,我们往往会为不同的错误定义不同的类型,这样做非常好,但是如果你要在一个函数中返回不同的错误呢?例如:
--use std::fs::read_to_string; - -fn main() -> Result<(), std::io::Error> { - let html = render()?; - println!("{}", html); - Ok(()) -} - -fn render() -> Result<String, std::io::Error> { - let file = std::env::var("MARKDOWN")?; - let source = read_to_string(file)?; - Ok(source) -} -
上面的代码会报错,原因在于 render
函数中的两个 ?
返回的实际上是不同的错误:env::var()
返回的是 std::env::VarError
,而 read_to_string
返回的是 std::io::Error
。
为了满足 render
函数的签名,我们就需要将 env::VarError
和 io::Error
归一化为同一种错误类型。要实现这个目的有三种方式:
Box<dyn Error>
thiserror
下面依次来看看相关的解决方式。
-Box<dyn Error>
大家还记得我们之前提到的 std::error::Error
特征吧,当时有说:自定义类型实现 Debug + Display
特征的主要原因就是为了能转换成 Error
的特征对象,而特征对象恰恰是在同一个地方使用不同类型的关键:
-use std::fs::read_to_string; -use std::error::Error; -fn main() -> Result<(), Box<dyn Error>> { - let html = render()?; - println!("{}", html); - Ok(()) -} - -fn render() -> Result<String, Box<dyn Error>> { - let file = std::env::var("MARKDOWN")?; - let source = read_to_string(file)?; - Ok(source) -} -
这个方法很简单,在绝大多数场景中,性能也非常够用,但是有一个问题:Result
实际上不会限制错误的类型,也就是一个类型就算不实现 Error
特征,它依然可以在 Result<T, E>
中作为 E
来使用,此时这种特征对象的解决方案就无能为力了。
与特征对象相比,自定义错误类型麻烦归麻烦,但是它非常灵活,因此也不具有上面的类似限制:
--use std::fs::read_to_string; - -fn main() -> Result<(), MyError> { - let html = render()?; - println!("{}", html); - Ok(()) -} - -fn render() -> Result<String, MyError> { - let file = std::env::var("MARKDOWN")?; - let source = read_to_string(file)?; - Ok(source) -} - -#[derive(Debug)] -enum MyError { - EnvironmentVariableNotFound, - IOError(std::io::Error), -} - -impl From<std::env::VarError> for MyError { - fn from(_: std::env::VarError) -> Self { - Self::EnvironmentVariableNotFound - } -} - -impl From<std::io::Error> for MyError { - fn from(value: std::io::Error) -> Self { - Self::IOError(value) - } -} - -impl std::error::Error for MyError {} - -impl std::fmt::Display for MyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MyError::EnvironmentVariableNotFound => write!(f, "Environment variable not found"), - MyError::IOError(err) => write!(f, "IO Error: {}", err.to_string()), - } - } -} -
上面代码中有一行值得注意:impl std::error::Error for MyError {}
,只有为自定义错误类型实现 Error
特征后,才能转换成相应的特征对象。
不得不说,真是啰嗦啊。因此在能用特征对象的时候,建议大家还是使用特征对象,无论如何,代码可读性还是很重要的!
-上面的第二种方式灵活归灵活,啰嗦也是真啰嗦,好在 Rust 的社区为我们提供了 thiserror
解决方案,下面一起来看看该如何简化 Rust 中的错误处理。
对于开发者而言,错误处理是代码中打交道最多的部分之一,因此选择一把趁手的武器也很重要,它可以帮助我们节省大量的时间和精力,好钢应该用在代码逻辑而不是冗长的错误处理上。
-thiserror
可以帮助我们简化上面的第二种解决方案:
-use std::fs::read_to_string; - -fn main() -> Result<(), MyError> { - let html = render()?; - println!("{}", html); - Ok(()) -} - -fn render() -> Result<String, MyError> { - let file = std::env::var("MARKDOWN")?; - let source = read_to_string(file)?; - Ok(source) -} - -#[derive(thiserror::Error, Debug)] -enum MyError { - #[error("Environment variable not found")] - EnvironmentVariableNotFound(#[from] std::env::VarError), - #[error(transparent)] - IOError(#[from] std::io::Error), -} -
如上所示,只要简单写写注释,就可以实现错误处理了,惊不惊喜?
-error-chain
也是简单好用的库,可惜不再维护了,但是我觉得它依然可以在合适的地方大放光彩,值得大家去了解下。
-use std::fs::read_to_string; - -error_chain::error_chain! { - foreign_links { - EnvironmentVariableNotFound(::std::env::VarError); - IOError(::std::io::Error); - } -} - -fn main() -> Result<()> { - let html = render()?; - println!("{}", html); - Ok(()) -} - -fn render() -> Result<String> { - let file = std::env::var("MARKDOWN")?; - let source = read_to_string(file)?; - Ok(source) -} -
喏,简单吧?使用 error-chain
的宏你可以获得:Error
结构体,错误类型 ErrorKind
枚举 以及一个自定义的 Result
类型。
anyhow
和 thiserror
是同一个作者开发的,这里是作者关于 anyhow
和 thiserror
的原话:
--如果你想要设计自己的错误类型,同时给调用者提供具体的信息时,就使用
-thiserror
,例如当你在开发一个三方库代码时。如果你只想要简单,就使用anyhow
,例如在自己的应用服务中。
-use std::fs::read_to_string; - -use anyhow::Result; - -fn main() -> Result<()> { - let html = render()?; - println!("{}", html); - Ok(()) -} - -fn render() -> Result<String> { - let file = std::env::var("MARKDOWN")?; - let source = read_to_string(file)?; - Ok(source) -} -
关于如何选用 thiserror
和 anyhow
只需要遵循一个原则即可:是否关注自定义错误消息,关注则使用 thiserror
(常见业务代码),否则使用 anyhow
(编写第三方库代码)。
Rust 一个为人津津乐道的点就是强大、易用的错误处理,对于新手来说,这个机制可能会有些复杂,但是一旦体会到了其中的好处,你将跟我一样沉醉其中不能自拔。
- - -闭包这个词语由来已久,自上世纪 60 年代就由 Scheme
语言引进之后,被广泛用于函数式编程语言中,进入 21 世纪后,各种现代化的编程语言也都不约而同地把闭包作为核心特性纳入到语言设计中来。那么到底何为闭包?
闭包是一种匿名函数,它可以赋值给变量也可以作为参数传递给其它函数,不同于函数的是,它允许捕获调用者作用域中的值,例如:
--fn main() { - let x = 1; - let sum = |y| x + y; - - assert_eq!(3, sum(2)); -} -
上面的代码展示了非常简单的闭包 sum
,它拥有一个入参 y
,同时捕获了作用域中的 x
的值,因此调用 sum(2)
意味着将 2(参数 y
)跟 1(x
)进行相加,最终返回它们的和:3
。
可以看到 sum
非常符合闭包的定义:可以赋值给变量,允许捕获调用者作用域中的值。
想象一下,我们要进行健身,用代码怎么实现(写代码什么鬼,健身难道不应该去健身房嘛?答曰:健身太累了,还是虚拟健身好,点到为止)?这里是我的想法:
--use std::thread; -use std::time::Duration; - -// 开始健身,好累,我得发出声音:muuuu... -fn muuuuu(intensity: u32) -> u32 { - println!("muuuu....."); - thread::sleep(Duration::from_secs(2)); - intensity -} - -fn workout(intensity: u32, random_number: u32) { - if intensity < 25 { - println!( - "今天活力满满,先做 {} 个俯卧撑!", - muuuuu(intensity) - ); - println!( - "旁边有妹子在看,俯卧撑太low,再来 {} 组卧推!", - muuuuu(intensity) - ); - } else if random_number == 3 { - println!("昨天练过度了,今天还是休息下吧!"); - } else { - println!( - "昨天练过度了,今天干干有氧,跑步 {} 分钟!", - muuuuu(intensity) - ); - } -} - -fn main() { - // 强度 - let intensity = 10; - // 随机值用来决定某个选择 - let random_number = 7; - - // 开始健身 - workout(intensity, random_number); -} -
可以看到,在健身时我们根据想要的强度来调整具体的动作,然后调用 muuuuu
函数来开始健身。这个程序本身很简单,没啥好说的,但是假如未来不用 muuuuu
函数了,是不是得把所有 muuuuu
都替换成,比如说 woooo
?如果 muuuuu
出现了几十次,那意味着我们要修改几十处地方。
一个可行的办法是,把函数赋值给一个变量,然后通过变量调用:
--use std::thread; -use std::time::Duration; - -// 开始健身,好累,我得发出声音:muuuu... -fn muuuuu(intensity: u32) -> u32 { - println!("muuuu....."); - thread::sleep(Duration::from_secs(2)); - intensity -} - -fn workout(intensity: u32, random_number: u32) { - let action = muuuuu; - if intensity < 25 { - println!( - "今天活力满满, 先做 {} 个俯卧撑!", - action(intensity) - ); - println!( - "旁边有妹子在看,俯卧撑太low, 再来 {} 组卧推!", - action(intensity) - ); - } else if random_number == 3 { - println!("昨天练过度了,今天还是休息下吧!"); - } else { - println!( - "昨天练过度了,今天干干有氧, 跑步 {} 分钟!", - action(intensity) - ); - } -} - -fn main() { - // 强度 - let intensity = 10; - // 随机值用来决定某个选择 - let random_number = 7; - - // 开始健身 - workout(intensity, random_number); -} -
经过上面修改后,所有的调用都通过 action
来完成,若未来声(动)音(作)变了,只要修改为 let action = woooo
即可。
但是问题又来了,若 intensity
也变了怎么办?例如变成 action(intensity + 1)
,那你又得哐哐哐修改几十处调用。
该怎么办?没太好的办法了,只能祭出大杀器:闭包。
-上面提到 intensity
要是变化怎么办,简单,使用闭包来捕获它,这是我们的拿手好戏:
-use std::thread; -use std::time::Duration; - -fn workout(intensity: u32, random_number: u32) { - let action = || { - println!("muuuu....."); - thread::sleep(Duration::from_secs(2)); - intensity - }; - - if intensity < 25 { - println!( - "今天活力满满,先做 {} 个俯卧撑!", - action() - ); - println!( - "旁边有妹子在看,俯卧撑太low,再来 {} 组卧推!", - action() - ); - } else if random_number == 3 { - println!("昨天练过度了,今天还是休息下吧!"); - } else { - println!( - "昨天练过度了,今天干干有氧,跑步 {} 分钟!", - action() - ); - } -} - -fn main() { - // 动作次数 - let intensity = 10; - // 随机值用来决定某个选择 - let random_number = 7; - - // 开始健身 - workout(intensity, random_number); -} -
在上面代码中,无论你要修改什么,只要修改闭包 action
的实现即可,其它地方只负责调用,完美解决了我们的问题!
Rust 闭包在形式上借鉴了 Smalltalk
和 Ruby
语言,与函数最大的不同就是它的参数是通过 |parm1|
的形式进行声明,如果是多个参数就 |param1, param2,...|
, 下面给出闭包的形式定义:
--#![allow(unused)] -fn main() { -|param1, param2,...| { - 语句1; - 语句2; - 返回表达式 -} -} -
如果只有一个返回表达式的话,定义可以简化为:
---#![allow(unused)] -fn main() { -|param1| 返回表达式 -} -
上例中还有两点值得注意:
-action()
调用返回了 intensity
的值 10
let action = ||...
只是把闭包赋值给变量 action
,并不是把闭包执行后的结果赋值给 action
,因此这里 action
就相当于闭包函数,可以跟函数一样进行调用:action()
Rust 是静态语言,因此所有的变量都具有类型,但是得益于编译器的强大类型推导能力,在很多时候我们并不需要显式地去声明类型,但是显然函数并不在此列,必须手动为函数的所有参数和返回值指定类型,原因在于函数往往会作为 API 提供给你的用户,因此你的用户必须在使用时知道传入参数的类型和返回值类型。
-与函数相反,闭包并不会作为 API 对外提供,因此它可以享受编译器的类型推导能力,无需标注参数和返回值的类型。
-为了增加代码可读性,有时候我们会显式地给类型进行标注,出于同样的目的,也可以给闭包标注类型:
---#![allow(unused)] -fn main() { -let sum = |x: i32, y: i32| -> i32 { - x + y -} -} -
与之相比,不标注类型的闭包声明会更简洁些:let sum = |x, y| x + y
,需要注意的是,针对 sum
闭包,如果你只进行了声明,但是没有使用,编译器会提示你为 x, y
添加类型标注,因为它缺乏必要的上下文:
--#![allow(unused)] -fn main() { -let sum = |x, y| x + y; -let v = sum(1, 2); -} -
这里我们使用了 sum
,同时把 1
传给了 x
,2
传给了 y
,因此编译器才可以推导出 x,y
的类型为 i32
。
下面展示了同一个功能的函数和闭包实现形式:
---#![allow(unused)] -fn main() { -fn add_one_v1 (x: u32) -> u32 { x + 1 } -let add_one_v2 = |x: u32| -> u32 { x + 1 }; -let add_one_v3 = |x| { x + 1 }; -let add_one_v4 = |x| x + 1 ; -} -
可以看出第一行的函数和后面的闭包其实在形式上是非常接近的,同时三种不同的闭包也展示了三种不同的使用方式:省略参数、返回值类型和花括号对。
-虽然类型推导很好用,但是它不是泛型,当编译器推导出一种类型后,它就会一直使用该类型:
---#![allow(unused)] -fn main() { -let example_closure = |x| x; - -let s = example_closure(String::from("hello")); -let n = example_closure(5); -} -
首先,在 s
中,编译器为 x
推导出类型 String
,但是紧接着 n
试图用 5
这个整型去调用闭包,跟编译器之前推导的 String
类型不符,因此报错:
error[E0308]: mismatched types
- --> src/main.rs:5:29
- |
-5 | let n = example_closure(5);
- | ^
- | |
- | expected struct `String`, found integer // 期待String类型,却发现一个整数
- | help: try using a conversion method: `5.to_string()`
-
-假设我们要实现一个简易缓存,功能是获取一个值,然后将其缓存起来,那么可以这样设计:
-可以使用结构体来代表缓存对象,最终设计如下:
---#![allow(unused)] -fn main() { -struct Cacher<T> -where - T: Fn(u32) -> u32, -{ - query: T, - value: Option<u32>, -} -} -
等等,我都跟着这本教程学完 Rust 基础了,为何还有我不认识的东东?Fn(u32) -> u32
是什么鬼?别急,先回答你第一个问题:骚年,too young too naive,你以为 Rust 的语法特性就基础入门那一些吗?太年轻了!如果是长征,你才刚到赤水河。
其实,可以看得出这一长串是 T
的特征约束,再结合之前的已知信息:query
是一个闭包,大概可以推测出,Fn(u32) -> u32
是一个特征,用来表示 T
是一个闭包类型?Bingo,恭喜你,答对了!
那为什么不用具体的类型来标注 query
呢?原因很简单,每一个闭包实例都有独属于自己的类型,即使于两个签名一模一样的闭包,它们的类型也是不同的,因此你无法用一个统一的类型来标注 query
闭包。
而标准库提供的 Fn
系列特征,再结合特征约束,就能很好的解决了这个问题. T: Fn(u32) -> u32
意味着 query
的类型是 T
,该类型必须实现了相应的闭包特征 Fn(u32) -> u32
。从特征的角度来看它长得非常反直觉,但是如果从闭包的角度来看又极其符合直觉,不得不佩服 Rust 团队的鬼才设计。。。
特征 Fn(u32) -> u32
从表面来看,就对闭包形式进行了显而易见的限制:该闭包拥有一个u32
类型的参数,同时返回一个u32
类型的值。
--需要注意的是,其实 Fn 特征不仅仅适用于闭包,还适用于函数,因此上面的
-query
字段除了使用闭包作为值外,还能使用一个具名的函数来作为它的值
接着,为缓存实现方法:
---#![allow(unused)] -fn main() { -impl<T> Cacher<T> -where - T: Fn(u32) -> u32, -{ - fn new(query: T) -> Cacher<T> { - Cacher { - query, - value: None, - } - } - - // 先查询缓存值 `self.value`,若不存在,则调用 `query` 加载 - fn value(&mut self, arg: u32) -> u32 { - match self.value { - Some(v) => v, - None => { - let v = (self.query)(arg); - self.value = Some(v); - v - } - } - } -} -} -
上面的缓存有一个很大的问题:只支持 u32
类型的值,若我们想要缓存 &str
类型,显然就行不通了,因此需要将 u32
替换成泛型 E
,该练习就留给读者自己完成,具体代码可以参考这里
在之前代码中,我们一直在用闭包的匿名函数特性(赋值给变量),然而闭包还拥有一项函数所不具备的特性:捕获作用域中的值。
--fn main() { - let x = 4; - - let equal_to_x = |z| z == x; - - let y = 4; - - assert!(equal_to_x(y)); -} -
上面代码中,x
并不是闭包 equal_to_x
的参数,但是它依然可以去使用 x
,因为 equal_to_x
在 x
的作用域范围内。
对于函数来说,就算你把函数定义在 main
函数体中,它也不能访问 x
:
-fn main() { - let x = 4; - - fn equal_to_x(z: i32) -> bool { - z == x - } - - let y = 4; - - assert!(equal_to_x(y)); -} -
报错如下:
-error[E0434]: can't capture dynamic environment in a fn item // 在函数中无法捕获动态的环境
- --> src/main.rs:5:14
- |
-5 | z == x
- | ^
- |
- = help: use the `|| { ... }` closure form instead // 使用闭包替代
-
-如上所示,编译器准确地告诉了我们错误,甚至同时给出了提示:使用闭包来替代函数,这种聪明令我有些无所适从,总感觉会显得我很笨。
-当闭包从环境中捕获一个值时,会分配内存去存储这些值。对于有些场景来说,这种额外的内存分配会成为一种负担。与之相比,函数就不会去捕获这些环境值,因此定义和使用函数不会拥有这种内存负担。
-闭包捕获变量有三种途径,恰好对应函数参数的三种传入方式:转移所有权、可变借用、不可变借用,因此相应的 Fn
特征也有三种:
FnOnce
,该类型的闭包会拿走被捕获变量的所有权。Once
顾名思义,说明该闭包只能运行一次:-fn fn_once<F>(func: F) -where - F: FnOnce(usize) -> bool, -{ - println!("{}", func(3)); - println!("{}", func(4)); -} - -fn main() { - let x = vec![1, 2, 3]; - fn_once(|z|{z == x.len()}) -} -
仅实现 FnOnce
特征的闭包在调用时会转移所有权,所以显然不能对已失去所有权的闭包变量进行二次调用:
error[E0382]: use of moved value: `func`
- --> src\main.rs:6:20
- |
-1 | fn fn_once<F>(func: F)
- | ---- move occurs because `func` has type `F`, which does not implement the `Copy` trait
- // 因为`func`的类型是没有实现`Copy`特性的 `F`,所以发生了所有权的转移
-...
-5 | println!("{}", func(3));
- | ------- `func` moved due to this call // 转移在这
-6 | println!("{}", func(4));
- | ^^^^ value used here after move // 转移后再次用
- |
-
-这里面有一个很重要的提示,因为 F
没有实现 Copy
特征,所以会报错,那么我们添加一个约束,试试实现了 Copy
的闭包:
-fn fn_once<F>(func: F) -where - F: FnOnce(usize) -> bool + Copy,// 改动在这里 -{ - println!("{}", func(3)); - println!("{}", func(4)); -} - -fn main() { - let x = vec![1, 2, 3]; - fn_once(|z|{z == x.len()}) -} -
上面代码中,func
的类型 F
实现了 Copy
特征,调用时使用的将是它的拷贝,所以并没有发生所有权的转移。
true
-false
-
-如果你想强制闭包取得捕获变量的所有权,可以在参数列表前添加 move
关键字,这种用法通常用于闭包的生命周期大于捕获变量的生命周期时,例如将闭包返回或移入其他线程。
--#![allow(unused)] -fn main() { -use std::thread; -let v = vec![1, 2, 3]; -let handle = thread::spawn(move || { - println!("Here's a vector: {:?}", v); -}); -handle.join().unwrap(); -} -
FnMut
,它以可变借用的方式捕获了环境中的值,因此可以修改该值:-fn main() { - let mut s = String::new(); - - let update_string = |str| s.push_str(str); - update_string("hello"); - - println!("{:?}",s); -} -
在闭包中,我们调用 s.push_str
去改变外部 s
的字符串值,因此这里捕获了它的可变借用,运行下试试:
error[E0596]: cannot borrow `update_string` as mutable, as it is not declared as mutable
- --> src/main.rs:5:5
- |
-4 | let update_string = |str| s.push_str(str);
- | ------------- - calling `update_string` requires mutable binding due to mutable borrow of `s`
- | |
- | help: consider changing this to be mutable: `mut update_string`
-5 | update_string("hello");
- | ^^^^^^^^^^^^^ cannot borrow as mutable
-
-虽然报错了,但是编译器给出了非常清晰的提示,想要在闭包内部捕获可变借用,需要把该闭包声明为可变类型,也就是 update_string
要修改为 mut update_string
:
-fn main() { - let mut s = String::new(); - - let mut update_string = |str| s.push_str(str); - update_string("hello"); - - println!("{:?}",s); -} -
这种写法有点反直觉,相比起来前面的 move
更符合使用和阅读习惯。但是如果你忽略 update_string
的类型,仅仅把它当成一个普通变量,那么这种声明就比较合理了。
再来看一个复杂点的:
--fn main() { - let mut s = String::new(); - - let update_string = |str| s.push_str(str); - - exec(update_string); - - println!("{:?}",s); -} - -fn exec<'a, F: FnMut(&'a str)>(mut f: F) { - f("hello") -} -
这段代码非常清晰的说明了 update_string
实现了 FnMut
特征
Fn
特征,它以不可变借用的方式捕获环境中的值
-让我们把上面的代码中 exec
的 F
泛型参数类型修改为 Fn(&'a str)
:-fn main() { - let mut s = String::new(); - - let update_string = |str| s.push_str(str); - - exec(update_string); - - println!("{:?}",s); -} - -fn exec<'a, F: Fn(&'a str)>(mut f: F) { - f("hello") -} -
然后运行看看结果:
-error[E0525]: expected a closure that implements the `Fn` trait, but this closure only implements `FnMut`
- --> src/main.rs:4:26 // 期望闭包实现的是`Fn`特征,但是它只实现了`FnMut`特征
- |
-4 | let update_string = |str| s.push_str(str);
- | ^^^^^^-^^^^^^^^^^^^^^
- | | |
- | | closure is `FnMut` because it mutates the variable `s` here
- | this closure implements `FnMut`, not `Fn` //闭包实现的是FnMut,而不是Fn
-5 |
-6 | exec(update_string);
- | ---- the requirement to implement `Fn` derives from here
-
-从报错中很清晰的看出,我们的闭包实现的是 FnMut
特征,需要的是可变借用,但是在 exec
中却给它标注了 Fn
特征,因此产生了不匹配,再来看看正确的不可变借用方式:
-fn main() { - let s = "hello, ".to_string(); - - let update_string = |str| println!("{},{}",s,str); - - exec(update_string); - - println!("{:?}",s); -} - -fn exec<'a, F: Fn(String) -> ()>(f: F) { - f("world".to_string()) -} -
在这里,因为无需改变 s
,因此闭包中只对 s
进行了不可变借用,那么在 exec
中,将其标记为 Fn
特征就完全正确。
在上面,我们讲到了 move
关键字对于 FnOnce
特征的重要性,但是实际上使用了 move
的闭包依然可能实现了 Fn
或 FnMut
特征。
因为,一个闭包实现了哪种 Fn 特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们。move
本身强调的就是后者,闭包如何捕获变量:
-fn main() { - let s = String::new(); - - let update_string = move || println!("{}",s); - - exec(update_string); -} - -fn exec<F: FnOnce()>(f: F) { - f() -} -
我们在上面的闭包中使用了 move
关键字,所以我们的闭包捕获了它,但是由于闭包对 s
的使用仅仅是不可变借用,因此该闭包实际上还实现了 Fn
特征。
细心的读者肯定发现我在上段中使用了一个 还
字,这是什么意思呢?因为该闭包不仅仅实现了 FnOnce
特征,还实现了 Fn
特征,将代码修改成下面这样,依然可以编译:
-fn main() { - let s = String::new(); - - let update_string = move || println!("{}",s); - - exec(update_string); -} - -fn exec<F: Fn()>(f: F) { - f() -} -
实际上,一个闭包并不仅仅实现某一种 Fn
特征,规则如下:
FnOnce
特征,因此任何一个闭包都至少可以被调用一次FnMut
特征Fn
特征用一段代码来简单诠释上述规则:
--fn main() { - let s = String::new(); - - let update_string = || println!("{}",s); - - exec(update_string); - exec1(update_string); - exec2(update_string); -} - -fn exec<F: FnOnce()>(f: F) { - f() -} - -fn exec1<F: FnMut()>(mut f: F) { - f() -} - -fn exec2<F: Fn()>(f: F) { - f() -} -
虽然,闭包只是对 s
进行了不可变借用,实际上,它可以适用于任何一种 Fn
特征:三个 exec
函数说明了一切。强烈建议读者亲自动手试试各种情况下使用的 Fn
特征,更有助于加深这方面的理解。
关于第二条规则,有如下示例:
--fn main() { - let mut s = String::new(); - - let update_string = |str| -> String {s.push_str(str); s }; - - exec(update_string); -} - -fn exec<'a, F: FnMut(&'a str) -> String>(mut f: F) { - f("hello"); -} -
5 | let update_string = |str| -> String {s.push_str(str); s };
- | ^^^^^^^^^^^^^^^ - closure is `FnOnce` because it moves the variable `s` out of its environment
- | // 闭包实现了`FnOnce`,因为它从捕获环境中移出了变量`s`
- | |
- | this closure implements `FnOnce`, not `FnMut`
-
-此例中,闭包从捕获环境中移出了变量 s
的所有权,因此这个闭包仅自动实现了 FnOnce
,未实现 FnMut
和 Fn
。再次印证之前讲的一个闭包实现了哪种 Fn 特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们,跟是否使用 move
没有必然联系。
如果还是有疑惑?没关系,我们来看看这三个特征的简化版源码:
---#![allow(unused)] -fn main() { -pub trait Fn<Args> : FnMut<Args> { - extern "rust-call" fn call(&self, args: Args) -> Self::Output; -} - -pub trait FnMut<Args> : FnOnce<Args> { - extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output; -} - -pub trait FnOnce<Args> { - type Output; - - extern "rust-call" fn call_once(self, args: Args) -> Self::Output; -} -} -
看到没?从特征约束能看出来 Fn
的前提是实现 FnMut
,FnMut
的前提是实现 FnOnce
,因此要实现 Fn
就要同时实现 FnMut
和 FnOnce
,这段源码从侧面印证了之前规则的正确性。
从源码中还能看出一点:Fn
获取 &self
,FnMut
获取 &mut self
,而 FnOnce
获取 self
。
-在实际项目中,建议先使用 Fn
特征,然后编译器会告诉你正误以及该如何选择。
看到这里,相信大家对于如何使用闭包作为函数参数,已经很熟悉了,但是如果要使用闭包作为函数返回值,该如何做?
-先来看一段代码:
---#![allow(unused)] -fn main() { -fn factory() -> Fn(i32) -> i32 { - let num = 5; - - |x| x + num -} - -let f = factory(); - -let answer = f(1); -assert_eq!(6, answer); -} -
上面这段代码看起来还是蛮正常的,用 Fn(i32) -> i32
特征来代表 |x| x + num
,非常合理嘛,肯定可以编译通过, 可惜理想总是难以照进现实,编译器给我们报了一大堆错误,先挑几个重点来看看:
fn factory<T>() -> Fn(i32) -> i32 {
- | ^^^^^^^^^^^^^^ doesn't have a size known at compile-time // 该类型在编译器没有固定的大小
-
-Rust 要求函数的参数和返回类型,必须有固定的内存大小,例如 i32
就是 4 个字节,引用类型是 8 个字节,总之,绝大部分类型都有固定的大小,但是不包括特征,因为特征类似接口,对于编译器来说,无法知道它后面藏的真实类型是什么,因为也无法得知具体的大小。
同样,我们也无法知道闭包的具体类型,该怎么办呢?再看看报错提示:
-help: use `impl Fn(i32) -> i32` as the return type, as all return paths are of type `[closure@src/main.rs:11:5: 11:21]`, which implements `Fn(i32) -> i32`
- |
-8 | fn factory<T>() -> impl Fn(i32) -> i32 {
-
-嗯,编译器提示我们加一个 impl
关键字,哦,这样一说,读者可能就想起来了,impl Trait
可以用来返回一个实现了指定特征的类型,那么这里 impl Fn(i32) -> i32
的返回值形式,说明我们要返回一个闭包类型,它实现了 Fn(i32) -> i32
特征。
完美解决,但是,在特征那一章,我们提到过,impl Trait
的返回方式有一个非常大的局限,就是你只能返回同样的类型,例如:
--#![allow(unused)] -fn main() { -fn factory(x:i32) -> impl Fn(i32) -> i32 { - - let num = 5; - - if x > 1{ - move |x| x + num - } else { - move |x| x - num - } -} -} -
运行后,编译器报错:
-error[E0308]: `if` and `else` have incompatible types
- --> src/main.rs:15:9
- |
-12 | / if x > 1{
-13 | | move |x| x + num
- | | ---------------- expected because of this
-14 | | } else {
-15 | | move |x| x - num
- | | ^^^^^^^^^^^^^^^^ expected closure, found a different closure
-16 | | }
- | |_____- `if` and `else` have incompatible types
- |
-
-嗯,提示很清晰:if
和 else
分支中返回了不同的闭包类型,这就很奇怪了,明明这两个闭包长的一样的,好在细心的读者应该回想起来,本章节前面咱们有提到:就算签名一样的闭包,类型也是不同的,因此在这种情况下,就无法再使用 impl Trait
的方式去返回闭包。
怎么办?再看看编译器提示,里面有这样一行小字:
-= help: consider boxing your closure and/or using it as a trait object
-
-哦,相信你已经恍然大悟,可以用特征对象!只需要用 Box
的方式即可实现:
--#![allow(unused)] -fn main() { -fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> { - let num = 5; - - if x > 1{ - Box::new(move |x| x + num) - } else { - Box::new(move |x| x - num) - } -} -} -
至此,闭包作为函数返回值就已完美解决,若以后你再遇到报错时,一定要仔细阅读编译器的提示,很多时候,转角都能遇到爱。
-这块儿内容在进阶生命周期章节中有讲,这里就不再赘述,读者可移步此处进行回顾。
-罗马不是一天建成的,编程语言亦是如此,每一门编程语言在借鉴前辈的同时,也会提出自己独有的特性,Rust 即是如此。当站在巨人肩膀上时,一个人所能看到的就更高更远,恰好,我们看到了函数式语言的优秀特性,例如:
-见猎心喜,我们忍不住就借鉴了过来,于是你能看到本章的内容,天下语言一大。。。跑题了。
-关于函数式编程到底是什么的争论由来已久,本章节并不会踏足这个泥潭,因此我们在这里主要关注的是函数式特性:
-其中后两个在前面章节我们已经深入学习过,因此本章的重点就是闭包和迭代器,这些函数式特性可以让代码的可读性和易写性大幅提升。对于 Rust 语言来说,掌握这两者就相当于你同时拥有了倚天剑屠龙刀,威力无穷。
- - -如果你询问一个 Rust 资深开发:写 Rust 项目最需要掌握什么?相信迭代器往往就是答案之一。无论你是编程新手亦或是高手,实际上大概率都用过迭代器,虽然自己可能并没有意识到这一点:)
-迭代器允许我们迭代一个连续的集合,例如数组、动态数组 Vec
、HashMap
等,在此过程中,只需关心集合中的元素如何处理,而无需关心如何开始、如何结束、按照什么样的索引去访问等问题。
从用途来看,迭代器跟 for
循环颇为相似,都是去遍历一个集合,但是实际上它们存在不小的差别,其中最主要的差别就是:是否通过索引来访问集合。
例如以下的 JS 代码就是一个循环:
-let arr = [1, 2, 3];
-for (let i = 0; i < arr.length; i++) {
- console.log(arr[i]);
-}
-
-在上面代码中,我们设置索引的开始点和结束点,然后再通过索引去访问元素 arr[i]
,这就是典型的循环,来对比下 Rust 中的 for
:
--#![allow(unused)] -fn main() { -let arr = [1, 2, 3]; -for v in arr { - println!("{}",v); -} -} -
首先,不得不说这两语法还挺像!与 JS 循环不同,Rust
中没有使用索引,它把 arr
数组当成一个迭代器,直接去遍历其中的元素,从哪里开始,从哪里结束,都无需操心。因此严格来说,Rust 中的 for
循环是编译器提供的语法糖,最终还是对迭代器中的元素进行遍历。
那又有同学要发问了,在 Rust 中数组是迭代器吗?因为在之前的代码中直接对数组 arr
进行了迭代,答案是 No
。那既然数组不是迭代器,为啥咱可以对它的元素进行迭代呢?
简而言之就是数组实现了 IntoIterator
特征,Rust 通过 for
语法糖,自动把实现了该特征的数组类型转换为迭代器(你也可以为自己的集合类型实现此特征),最终让我们可以直接对一个数组进行迭代,类似的还有:
--#![allow(unused)] -fn main() { -for i in 1..10 { - println!("{}", i); -} -} -
直接对数值序列进行迭代,也是很常见的使用方式。
-IntoIterator
特征拥有一个 into_iter
方法,因此我们还可以显式的把数组转换成迭代器:
--#![allow(unused)] -fn main() { -let arr = [1, 2, 3]; -for v in arr.into_iter() { - println!("{}", v); -} -} -
迭代器是函数语言的核心特性,它赋予了 Rust 远超于循环的强大表达能力,我们将在本章中一一为大家进行展现。
-在 Rust 中,迭代器是惰性的,意味着如果你不使用它,那么它将不会发生任何事:
---#![allow(unused)] -fn main() { -let v1 = vec![1, 2, 3]; - -let v1_iter = v1.iter(); - -for val in v1_iter { - println!("{}", val); -} -} -
在 for
循环之前,我们只是简单的创建了一个迭代器 v1_iter
,此时不会发生任何迭代行为,只有在 for
循环开始后,迭代器才会开始迭代其中的元素,最后打印出来。
这种惰性初始化的方式确保了创建迭代器不会有任何额外的性能损耗,其中的元素也不会被消耗,只有使用到该迭代器的时候,一切才开始。
-对于 for
如何遍历迭代器,还有一个问题,它如何取出迭代器中的元素?
先来看一个特征:
---#![allow(unused)] -fn main() { -pub trait Iterator { - type Item; - - fn next(&mut self) -> Option<Self::Item>; - - // 省略其余有默认实现的方法 -} -} -
呦,该特征竟然和迭代器 iterator
同名,难不成。。。没错,它们就是有一腿。迭代器之所以成为迭代器,就是因为实现了 Iterator
特征,要实现该特征,最主要的就是实现其中的 next
方法,该方法控制如何从集合中取值,最终返回值的类型是关联类型 Item
。
因此,之前问题的答案已经很明显:for
循环通过不停调用迭代器上的 next
方法,来获取迭代器中的元素。
既然 for
可以调用 next
方法,是不是意味着我们也可以?来试试:
-fn main() { - let arr = [1, 2, 3]; - let mut arr_iter = arr.into_iter(); - - assert_eq!(arr_iter.next(), Some(1)); - assert_eq!(arr_iter.next(), Some(2)); - assert_eq!(arr_iter.next(), Some(3)); - assert_eq!(arr_iter.next(), None); -} -
果不其然,将 arr
转换成迭代器后,通过调用其上的 next
方法,我们获取了 arr
中的元素,有两点需要注意:
next
方法返回的是 Option
类型,当有值时返回 Some(i32)
,无值时返回 None
Some(1)
,Some(2)
,Some(3)
mut
可变,因为调用 next
会改变迭代器其中的状态数据(当前遍历的位置等),而 for
循环去迭代则无需标注 mut
,因为它会帮我们自动完成总之,next
方法对迭代器的遍历是消耗性的,每次消耗它一个元素,最终迭代器中将没有任何元素,只能返回 None
。
因为 for
循环是迭代器的语法糖,因此我们完全可以通过迭代器来模拟实现它:
--#![allow(unused)] -fn main() { -let values = vec![1, 2, 3]; - -{ - let result = match IntoIterator::into_iter(values) { - mut iter => loop { - match iter.next() { - Some(x) => { println!("{}", x); }, - None => break, - } - }, - }; - result -} -} -
IntoIterator::into_iter
是使用完全限定的方式去调用 into_iter
方法,这种调用方式跟 values.into_iter()
是等价的。
同时我们使用了 loop
循环配合 next
方法来遍历迭代器中的元素,当迭代器返回 None
时,跳出循环。
其实有一个细节,由于 Vec
动态数组实现了 IntoIterator
特征,因此可以通过 into_iter
将其转换为迭代器,那如果本身就是一个迭代器,该怎么办?实际上,迭代器自身也实现了 IntoIterator
,标准库早就帮我们考虑好了:
--#![allow(unused)] -fn main() { -impl<I: Iterator> IntoIterator for I { - type Item = I::Item; - type IntoIter = I; - - #[inline] - fn into_iter(self) -> I { - self - } -} -} -
最终你完全可以写出这样的奇怪代码:
--fn main() { - let values = vec![1, 2, 3]; - - for v in values.into_iter().into_iter().into_iter() { - println!("{}",v) - } -} -
在之前的代码中,我们统一使用了 into_iter
的方式将数组转化为迭代器,除此之外,还有 iter
和 iter_mut
,聪明的读者应该大概能猜到这三者的区别:
into_iter
会夺走所有权iter
是借用iter_mut
是可变借用其实如果以后见多识广了,你会发现这种问题一眼就能看穿,into_
之类的,都是拿走所有权,_mut
之类的都是可变借用,剩下的就是不可变借用。
使用一段代码来解释下:
--fn main() { - let values = vec![1, 2, 3]; - - for v in values.into_iter() { - println!("{}", v) - } - - // 下面的代码将报错,因为 values 的所有权在上面 `for` 循环中已经被转移走 - // println!("{:?}",values); - - let values = vec![1, 2, 3]; - let _values_iter = values.iter(); - - // 不会报错,因为 values_iter 只是借用了 values 中的元素 - println!("{:?}", values); - - let mut values = vec![1, 2, 3]; - // 对 values 中的元素进行可变借用 - let mut values_iter_mut = values.iter_mut(); - - // 取出第一个元素,并修改为0 - if let Some(v) = values_iter_mut.next() { - *v = 0; - } - - // 输出[0, 2, 3] - println!("{:?}", values); -} -
具体解释在代码注释中,就不再赘述,不过有两点需要注意的是:
-.iter()
方法实现的迭代器,调用 next
方法返回的类型是 Some(&T)
.iter_mut()
方法实现的迭代器,调用 next
方法返回的类型是 Some(&mut T)
,因此在 if let Some(v) = values_iter_mut.next()
中,v
的类型是 &mut i32
,最终我们可以通过 *v = 0
的方式修改其值这两个其实还蛮容易搞混的,但我们只需要记住,Iterator
就是迭代器特征,只有实现了它才能称为迭代器,才能调用 next
。
而 IntoIterator
强调的是某一个类型如果实现了该特征,它可以通过 into_iter
,iter
等方法变成一个迭代器。
消费者是迭代器上的方法,它会消费掉迭代器中的元素,然后返回其类型的值,这些消费者都有一个共同的特点:在它们的定义中,都依赖 next
方法来消费元素,因此这也是为什么迭代器要实现 Iterator
特征,而该特征必须要实现 next
方法的原因。
只要迭代器上的某个方法 A
在其内部调用了 next
方法,那么 A
就被称为消费性适配器:因为 next
方法会消耗掉迭代器上的元素,所以方法 A
的调用也会消耗掉迭代器上的元素。
其中一个例子是 sum
方法,它会拿走迭代器的所有权,然后通过不断调用 next
方法对里面的元素进行求和:
-fn main() { - let v1 = vec![1, 2, 3]; - - let v1_iter = v1.iter(); - - let total: i32 = v1_iter.sum(); - - assert_eq!(total, 6); - - // v1_iter 是借用了 v1,因此 v1 可以照常使用 - println!("{:?}",v1); - - // 以下代码会报错,因为 `sum` 拿到了迭代器 `v1_iter` 的所有权 - // println!("{:?}",v1_iter); -} -
如代码注释中所说明的:在使用 sum
方法后,我们将无法再使用 v1_iter
,因为 sum
拿走了该迭代器的所有权:
--#![allow(unused)] -fn main() { -fn sum<S>(self) -> S - where - Self: Sized, - S: Sum<Self::Item>, - { - Sum::sum(self) - } - -} -
从 sum
源码中也可以清晰看出,self
类型的方法参数拿走了所有权。
既然消费者适配器是消费掉迭代器,然后返回一个值。那么迭代器适配器,顾名思义,会返回一个新的迭代器,这是实现链式方法调用的关键:v.iter().map().filter()...
。
与消费者适配器不同,迭代器适配器是惰性的,意味着你需要一个消费者适配器来收尾,最终将迭代器转换成一个具体的值:
---#![allow(unused)] -fn main() { -let v1: Vec<i32> = vec![1, 2, 3]; - -v1.iter().map(|x| x + 1); -} -
运行后输出:
-warning: unused `Map` that must be used
- --> src/main.rs:4:5
- |
-4 | v1.iter().map(|x| x + 1);
- | ^^^^^^^^^^^^^^^^^^^^^^^^^
- |
- = note: `#[warn(unused_must_use)]` on by default
- = note: iterators are lazy and do nothing unless consumed // 迭代器 map 是惰性的,这里不产生任何效果
-
-如上述中文注释所说,这里的 map
方法是一个迭代者适配器,它是惰性的,不产生任何行为,因此我们还需要一个消费者适配器进行收尾:
--#![allow(unused)] -fn main() { -let v1: Vec<i32> = vec![1, 2, 3]; - -let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); - -assert_eq!(v2, vec![2, 3, 4]); -} -
上面代码中,使用了 collect
方法,该方法就是一个消费者适配器,使用它可以将一个迭代器中的元素收集到指定类型中,这里我们为 v2
标注了 Vec<_>
类型,就是为了告诉 collect
:请把迭代器中的元素消费掉,然后把值收集成 Vec<_>
类型,至于为何使用 _
,因为编译器会帮我们自动推导。
为何 collect
在消费时要指定类型?是因为该方法其实很强大,可以收集成多种不同的集合类型,Vec<T>
仅仅是其中之一,因此我们必须显式的告诉编译器我们想要收集成的集合类型。
还有一点值得注意,map
会对迭代器中的每一个值进行一系列操作,然后把该值转换成另外一个新值,该操作是通过闭包 |x| x + 1
来完成:最终迭代器中的每个值都增加了 1
,从 [1, 2, 3]
变为 [2, 3, 4]
。
再来看看如何使用 collect
收集成 HashMap
集合:
-use std::collections::HashMap; -fn main() { - let names = ["sunface", "sunfei"]; - let ages = [18, 18]; - let folks: HashMap<_, _> = names.into_iter().zip(ages.into_iter()).collect(); - - println!("{:?}",folks); -} -
zip
是一个迭代器适配器,它的作用就是将两个迭代器的内容压缩到一起,形成 Iterator<Item=(ValueFromA, ValueFromB)>
这样的新的迭代器,在此处就是形如 [(name1, age1), (name2, age2)]
的迭代器。
然后再通过 collect
将新迭代器中(K, V)
形式的值收集成 HashMap<K, V>
,同样的,这里必须显式声明类型,然后 HashMap
内部的 KV
类型可以交给编译器去推导,最终编译器会推导出 HashMap<&str, i32>
,完全正确!
之前的 map
方法中,我们使用闭包来作为迭代器适配器的参数,它最大的好处不仅在于可以就地实现迭代器中元素的处理,还在于可以捕获环境值:
--#![allow(unused)] -fn main() { -struct Shoe { - size: u32, - style: String, -} - -fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> { - shoes.into_iter().filter(|s| s.size == shoe_size).collect() -} -} -
filter
是迭代器适配器,用于对迭代器中的每个值进行过滤。 它使用闭包作为参数,该闭包的参数 s
是来自迭代器中的值,然后使用 s
跟外部环境中的 shoe_size
进行比较,若相等,则在迭代器中保留 s
值,若不相等,则从迭代器中剔除 s
值,最终通过 collect
收集为 Vec<Shoe>
类型。
之前的内容我们一直基于数组来创建迭代器,实际上,不仅仅是数组,基于其它集合类型一样可以创建迭代器,例如 HashMap
。 你也可以创建自己的迭代器 —— 只要为自定义类型实现 Iterator
特征即可。
首先,创建一个计数器:
---#![allow(unused)] -fn main() { -struct Counter { - count: u32, -} - -impl Counter { - fn new() -> Counter { - Counter { count: 0 } - } -} -} -
我们为计数器 Counter
实现了一个关联函数 new
,用于创建新的计数器实例。下面我们继续为计数器实现 Iterator
特征:
--#![allow(unused)] -fn main() { -impl Iterator for Counter { - type Item = u32; - - fn next(&mut self) -> Option<Self::Item> { - if self.count < 5 { - self.count += 1; - Some(self.count) - } else { - None - } - } -} -} -
首先,将该特征的关联类型设置为 u32
,由于我们的计数器保存的 count
字段就是 u32
类型, 因此在 next
方法中,最后返回的是实际上是 Option<u32>
类型。
每次调用 next
方法,都会让计数器的值加一,然后返回最新的计数值,一旦计数大于 5,就返回 None
。
最后,使用我们新建的 Counter
进行迭代:
--#![allow(unused)] -fn main() { - let mut counter = Counter::new(); - -assert_eq!(counter.next(), Some(1)); -assert_eq!(counter.next(), Some(2)); -assert_eq!(counter.next(), Some(3)); -assert_eq!(counter.next(), Some(4)); -assert_eq!(counter.next(), Some(5)); -assert_eq!(counter.next(), None); -} -
可以看出,实现自己的迭代器非常简单,但是 Iterator
特征中,不仅仅是只有 next
一个方法,那为什么我们只需要实现它呢?因为其它方法都具有默认实现,所以无需像 next
这样手动去实现,而且这些默认实现的方法其实都是基于 next
方法实现的。
下面的代码演示了部分方法的使用:
---#![allow(unused)] -fn main() { -let sum: u32 = Counter::new() - .zip(Counter::new().skip(1)) - .map(|(a, b)| a * b) - .filter(|x| x % 3 == 0) - .sum(); -assert_eq!(18, sum); -} -
其中 zip
,map
,filter
是迭代器适配器:
zip
把两个迭代器合并成一个迭代器,新迭代器中,每个元素都是一个元组,由之前两个迭代器的元素组成。例如将形如 [1, 2, 3, 4, 5]
和 [2, 3, 4, 5]
的迭代器合并后,新的迭代器形如 [(1, 2),(2, 3),(3, 4),(4, 5)]
map
是将迭代器中的值经过映射后,转换成新的值[2, 6, 12, 20]filter
对迭代器中的元素进行过滤,若闭包返回 true
则保留元素[6, 12],反之剔除而 sum
是消费者适配器,对迭代器中的所有元素求和,最终返回一个 u32
值 18
。
在之前的流程控制章节,针对 for
循环,我们提供了一种方法可以获取迭代时的索引:
--#![allow(unused)] -fn main() { -let v = vec![1u64, 2, 3, 4, 5, 6]; -for (i,v) in v.iter().enumerate() { - println!("第{}个值是{}",i,v) -} -} -
相信当时,很多读者还是很迷茫的,不知道为什么要这么复杂才能获取到索引,学习本章节后,相信你有了全新的理解,首先 v.iter()
创建迭代器,其次
-调用 Iterator
特征上的方法 enumerate
,该方法产生一个新的迭代器,其中每个元素均是元组 (索引,值)
。
因为 enumerate
是迭代器适配器,因此我们可以对它返回的迭代器调用其它 Iterator
特征方法:
--#![allow(unused)] -fn main() { -let v = vec![1u64, 2, 3, 4, 5, 6]; -let val = v.iter() - .enumerate() - // 每两个元素剔除一个 - // [1, 3, 5] - .filter(|&(idx, _)| idx % 2 == 0) - .map(|(idx, val)| val) - // 累加 1+3+5 = 9 - .fold(0u64, |sum, acm| sum + acm); - -println!("{}", val); -} -
前面提到,要完成集合遍历,既可以使用 for
循环也可以使用迭代器,那么二者之间该怎么选择呢,性能有多大差距呢?
理论分析不会有结果,直接测试最为靠谱:
---#![allow(unused)] -#![feature(test)] - -fn main() { -extern crate rand; -extern crate test; - -fn sum_for(x: &[f64]) -> f64 { - let mut result: f64 = 0.0; - for i in 0..x.len() { - result += x[i]; - } - result -} - -fn sum_iter(x: &[f64]) -> f64 { - x.iter().sum::<f64>() -} - -#[cfg(test)] -mod bench { - use test::Bencher; - use rand::{Rng,thread_rng}; - use super::*; - - const LEN: usize = 1024*1024; - - fn rand_array(cnt: u32) -> Vec<f64> { - let mut rng = thread_rng(); - (0..cnt).map(|_| rng.gen::<f64>()).collect() - } - - #[bench] - fn bench_for(b: &mut Bencher) { - let samples = rand_array(LEN as u32); - b.iter(|| { - sum_for(&samples) - }) - } - - #[bench] - fn bench_iter(b: &mut Bencher) { - let samples = rand_array(LEN as u32); - b.iter(|| { - sum_iter(&samples) - }) - } -} -} -
上面的代码对比了 for
循环和迭代器 iterator
完成同样的求和任务的性能对比,可以看到迭代器还要更快一点。
test bench::bench_for ... bench: 998,331 ns/iter (+/- 36,250)
-test bench::bench_iter ... bench: 983,858 ns/iter (+/- 44,673)
-
-迭代器是 Rust 的 零成本抽象(zero-cost abstractions)之一,意味着抽象并不会引入运行时开销,这与 Bjarne Stroustrup
(C++ 的设计和实现者)在 Foundations of C++(2012)
中所定义的 零开销(zero-overhead)如出一辙:
--In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. -And further: What you do use, you couldn’t hand code any better.
-一般来说,C++的实现遵循零开销原则:没有使用时,你不必为其买单。 -更进一步说,需要使用时,你也无法写出更优的代码了。 -(翻译一下:用就完事了)
-
总之,迭代器是 Rust 受函数式语言启发而提供的高级语言特性,可以写出更加简洁、逻辑清晰的代码。编译器还可以通过循环展开(Unrolling)、向量化、消除边界检查等优化手段,使得迭代器和 for
循环都有极为高效的执行效率。
所以请放心大胆的使用迭代器,在获得更高的表达力的同时,也不会导致运行时的损失,何乐而不为呢!
-迭代器用的好不好,就在于你是否掌握了它的常用方法,且能活学活用,因此多多看看标准库是有好处的,只有知道有什么方法,在需要的时候你才能知道该用什么,就和算法学习一样。
-同时,本书在后续章节还提供了对迭代器常用方法的深入讲解,方便大家学习和查阅。
- - -在一些场景,我们可能需要全局变量来简化状态共享的代码,包括全局 ID,全局数据存储等等,下面一起来看看有哪些创建全局变量的方法。
-首先,有一点可以肯定,全局变量的生命周期肯定是'static
,但是不代表它需要用static
来声明,例如常量、字符串字面值等无需使用static
进行声明,原因是它们已经被打包到二进制可执行文件中。
下面我们从编译期初始化及运行期初始化两个类别来介绍下全局变量有哪些类型及该如何使用。
-我们大多数使用的全局变量都只需要在编译期初始化即可,例如静态配置、计数器、状态值等等。
-全局常量可以在程序任何一部分使用,当然,如果它是定义在某个模块中,你需要引入对应的模块才能使用。常量,顾名思义它是不可变的,很适合用作静态配置:
--const MAX_ID: usize = usize::MAX / 2; -fn main() { - println!("用户ID允许的最大值是{}",MAX_ID); -} -
常量与普通变量的区别
-const
而不是let
静态变量允许声明一个全局的变量,常用于全局数据统计,例如我们希望用一个变量来统计程序当前的总请求数:
--static mut REQUEST_RECV: usize = 0; -fn main() { - unsafe { - REQUEST_RECV += 1; - assert_eq!(REQUEST_RECV, 1); - } -} -
Rust 要求必须使用unsafe
语句块才能访问和修改static
变量,因为这种使用方式往往并不安全,其实编译器是对的,当在多线程中同时去修改时,会不可避免的遇到脏数据。
只有在同一线程内或者不在乎数据的准确性时,才应该使用全局静态变量。
-和常量相同,定义静态变量的时候必须赋值为在编译期就可以计算出的值(常量表达式/数学表达式),不能是运行时才能计算出的值(如函数)
-静态变量和常量的区别
-想要全局计数器、状态控制等功能,又想要线程安全的实现,原子类型是非常好的办法。
--use std::sync::atomic::{AtomicUsize, Ordering}; -static REQUEST_RECV: AtomicUsize = AtomicUsize::new(0); -fn main() { - for _ in 0..100 { - REQUEST_RECV.fetch_add(1, Ordering::Relaxed); - } - - println!("当前用户请求数{:?}",REQUEST_RECV); -} -
关于原子类型的讲解看这篇文章
-来看看如何使用上面的内容实现一个全局 ID 生成器:
---#![allow(unused)] -fn main() { -use std::sync::atomic::{Ordering, AtomicUsize}; - -struct Factory{ - factory_id: usize, -} - -static GLOBAL_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); -const MAX_ID: usize = usize::MAX / 2; - -fn generate_id()->usize{ - // 检查两次溢出,否则直接加一可能导致溢出 - let current_val = GLOBAL_ID_COUNTER.load(Ordering::Relaxed); - if current_val > MAX_ID{ - panic!("Factory ids overflowed"); - } - let next_id = GLOBAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed); - if next_id > MAX_ID{ - panic!("Factory ids overflowed"); - } - next_id -} - -impl Factory{ - fn new()->Self{ - Self{ - factory_id: generate_id() - } - } -} -} -
以上的静态初始化有一个致命的问题:无法用函数进行静态初始化,例如你如果想声明一个全局的Mutex
锁:
-use std::sync::Mutex; -static NAMES: Mutex<String> = Mutex::new(String::from("Sunface, Jack, Allen")); - -fn main() { - let v = NAMES.lock().unwrap(); - println!("{}",v); -} -
运行后报错如下:
-error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
- --> src/main.rs:3:42
- |
-3 | static NAMES: Mutex<String> = Mutex::new(String::from("sunface"));
-
-但你又必须在声明时就对NAMES
进行初始化,此时就陷入了两难的境地。好在天无绝人之路,我们可以使用lazy_static
包来解决这个问题。
lazy_static
是社区提供的非常强大的宏,用于懒初始化静态变量,之前的静态变量都是在编译期初始化的,因此无法使用函数调用进行赋值,而lazy_static
允许我们在运行期初始化静态变量!
-use std::sync::Mutex; -use lazy_static::lazy_static; -lazy_static! { - static ref NAMES: Mutex<String> = Mutex::new(String::from("Sunface, Jack, Allen")); -} - -fn main() { - let mut v = NAMES.lock().unwrap(); - v.push_str(", Myth"); - println!("{}",v); -} -
当然,使用lazy_static
在每次访问静态变量时,会有轻微的性能损失,因为其内部实现用了一个底层的并发原语std::sync::Once
,在每次访问该变量时,程序都会执行一次原子指令用于确认静态变量的初始化是否完成。
lazy_static
宏,匹配的是static ref
,所以定义的静态变量都是不可变引用
可能有读者会问,为何需要在运行期初始化一个静态变量,除了上面的全局锁,你会遇到最常见的场景就是:一个全局的动态配置,它在程序开始后,才加载数据进行初始化,最终可以让各个线程直接访问使用
-再来看一个使用lazy_static
实现全局缓存的例子:
-use lazy_static::lazy_static; -use std::collections::HashMap; - -lazy_static! { - static ref HASHMAP: HashMap<u32, &'static str> = { - let mut m = HashMap::new(); - m.insert(0, "foo"); - m.insert(1, "bar"); - m.insert(2, "baz"); - m - }; -} - -fn main() { - // 首次访问`HASHMAP`的同时对其进行初始化 - println!("The entry for `0` is \"{}\".", HASHMAP.get(&0).unwrap()); - - // 后续的访问仅仅获取值,再不会进行任何初始化操作 - println!("The entry for `1` is \"{}\".", HASHMAP.get(&1).unwrap()); -} -
需要注意的是,lazy_static
直到运行到main
中的第一行代码时,才进行初始化,非常lazy static
。
在Box
智能指针章节中,我们提到了Box::leak
可以用于全局变量,例如用作运行期初始化的全局动态配置,先来看看如果不使用lazy_static
也不使用Box::leak
,会发生什么:
-#[derive(Debug)] -struct Config { - a: String, - b: String, -} -static mut CONFIG: Option<&mut Config> = None; - -fn main() { - unsafe { - CONFIG = Some(&mut Config { - a: "A".to_string(), - b: "B".to_string(), - }); - - println!("{:?}", CONFIG) - } -} -
以上代码我们声明了一个全局动态配置CONFIG
,并且其值初始化为None
,然后在程序开始运行后,给它赋予相应的值,运行后报错:
error[E0716]: temporary value dropped while borrowed
- --> src/main.rs:10:28
- |
-10 | CONFIG = Some(&mut Config {
- | _________-__________________^
- | |_________|
- | ||
-11 | || a: "A".to_string(),
-12 | || b: "B".to_string(),
-13 | || });
- | || ^-- temporary value is freed at the end of this statement
- | ||_________||
- | |_________|assignment requires that borrow lasts for `'static`
- | creates a temporary which is freed while still in use
-
-可以看到,Rust 的借用和生命周期规则限制了我们做到这一点,因为试图将一个局部生命周期的变量赋值给全局生命周期的CONFIG
,这明显是不安全的。
好在Rust
为我们提供了Box::leak
方法,它可以将一个变量从内存中泄漏(听上去怪怪的,竟然做主动内存泄漏),然后将其变为'static
生命周期,最终该变量将和程序活得一样久,因此可以赋值给全局静态变量CONFIG
。
-#[derive(Debug)] -struct Config { - a: String, - b: String -} -static mut CONFIG: Option<&mut Config> = None; - -fn main() { - let c = Box::new(Config { - a: "A".to_string(), - b: "B".to_string(), - }); - - unsafe { - // 将`c`从内存中泄漏,变成`'static`生命周期 - CONFIG = Some(Box::leak(c)); - println!("{:?}", CONFIG); - } -} -
问题又来了,如果我们需要在运行期,从一个函数返回一个全局变量该如何做?例如:
--#[derive(Debug)] -struct Config { - a: String, - b: String, -} -static mut CONFIG: Option<&mut Config> = None; - -fn init() -> Option<&'static mut Config> { - Some(&mut Config { - a: "A".to_string(), - b: "B".to_string(), - }) -} - - -fn main() { - unsafe { - CONFIG = init(); - - println!("{:?}", CONFIG) - } -} -
报错这里就不展示了,跟之前大同小异,还是生命周期引起的,那么该如何解决呢?依然可以用Box::leak
:
-#[derive(Debug)] -struct Config { - a: String, - b: String, -} -static mut CONFIG: Option<&mut Config> = None; - -fn init() -> Option<&'static mut Config> { - let c = Box::new(Config { - a: "A".to_string(), - b: "B".to_string(), - }); - - Some(Box::leak(c)) -} - - -fn main() { - unsafe { - CONFIG = init(); - - println!("{:?}", CONFIG) - } -} -
在 Rust
标准库中提供 lazy::OnceCell
和 lazy::SyncOnceCell
两种 Cell
,前者用于单线程,后者用于多线程,它们用来存储堆上的信息,并且具有最多只能赋值一次的特性。 如实现一个多线程的日志组件 Logger
:
-#![feature(once_cell)] - -use std::{lazy::SyncOnceCell, thread}; - -fn main() { - // 子线程中调用 - let handle = thread::spawn(|| { - let logger = Logger::global(); - logger.log("thread message".to_string()); - }); - - // 主线程调用 - let logger = Logger::global(); - logger.log("some message".to_string()); - - let logger2 = Logger::global(); - logger2.log("other message".to_string()); - - handle.join().unwrap(); -} - -#[derive(Debug)] -struct Logger; - -static LOGGER: SyncOnceCell<Logger> = SyncOnceCell::new(); - -impl Logger { - fn global() -> &'static Logger { - // 获取或初始化 Logger - LOGGER.get_or_init(|| { - println!("Logger is being created..."); // 初始化打印 - Logger - }) - } - - fn log(&self, message: String) { - println!("{}", message) - } -} -
以上代码我们声明了一个 global()
关联函数,并在其内部调用 get_or_init
进行初始化 Logger
,之后在不同线程上多次调用 Logger::global()
获取其实例:
Logger is being created...
-some message
-other message
-thread message
-
-可以看到,Logger is being created...
在多个线程中使用也只被打印了一次。
特别注意,目前 OnceCell
和 SyncOnceCell
API 暂未稳定,需启用特性 #![feature(once_cell)]
。
在 Rust 中有很多方式可以创建一个全局变量,本章也只是介绍了其中一部分,更多的还等待大家自己去挖掘学习(当然,未来可能本章节会不断完善,最后变成一个巨无霸- , -)。
-简单来说,全局变量可以分为两种:
-const
创建常量,static
创建静态变量,Atomic
创建原子类型lazy_static
用于懒初始化,Box::leak
利用内存泄漏将一个变量的生命周期变为'static
Rust 是类型安全的语言,因此在 Rust 中做类型转换不是一件简单的事,这一章节我们将对 Rust 中的类型转换进行详尽讲解。
---高能预警:本章节有些难,可以考虑学了进阶后回头再看
-
as
转换先来看一段代码:
--fn main() { - let a: i32 = 10; - let b: u16 = 100; - - if a < b { - println!("Ten is less than one hundred."); - } -} -
能跟着这本书一直学习到这里,说明你对 Rust 已经有了一定的理解,那么一眼就能看出这段代码注定会报错,因为 a
和 b
拥有不同的类型,Rust 不允许两种不同的类型进行比较。
解决办法很简单,只要把 b
转换成 i32
类型即可,Rust 中内置了一些基本类型之间的转换,这里使用 as
操作符来完成: if a < (b as i32) {...}
。那么为什么不把 a
转换成 u16
类型呢?
因为每个类型能表达的数据范围不同,如果把范围较大的类型转换成较小的类型,会造成错误,因此我们需要把范围较小的类型转换成较大的类型,来避免这些问题的发生。
---使用类型转换需要小心,因为如果执行以下操作
-300_i32 as i8
,你将获得44
这个值,而不是300
,因为i8
类型能表达的的最大值为2^7 - 1
,使用以下代码可以查看i8
的最大值:
--#![allow(unused)] -fn main() { -let a = i8::MAX; -println!("{}",a); -} -
下面列出了常用的转换形式:
--fn main() { - let a = 3.1 as i8; - let b = 100_i8 as i32; - let c = 'a' as u8; // 将字符'a'转换为整数,97 - - println!("{},{},{}",a,b,c) -} -
--#![allow(unused)] -fn main() { -let mut values: [i32; 2] = [1, 2]; -let p1: *mut i32 = values.as_mut_ptr(); -let first_address = p1 as usize; // 将p1内存地址转换为一个整数 -let second_address = first_address + 4; // 4 == std::mem::size_of::<i32>(),i32类型占用4个字节,因此将内存地址 + 4 -let p2 = second_address as *mut i32; // 访问该地址指向的下一个整数p2 -unsafe { - *p2 += 1; -} -assert_eq!(values[1], 3); -} -
e as U1 as U2
是合法的,也不能说明 e as U2
是合法的(e
不能直接转换成 U2
)。在一些场景中,使用 as
关键字会有比较大的限制。如果你想要在类型转换上拥有完全的控制而不依赖内置的转换,例如处理转换错误,那么可以使用 TryInto
:
-use std::convert::TryInto; - -fn main() { - let a: u8 = 10; - let b: u16 = 1500; - - let b_: u8 = b.try_into().unwrap(); - - if a < b_ { - println!("Ten is less than one hundred."); - } -} -
上面代码中引入了 std::convert::TryInto
特征,但是却没有使用它,可能有些同学会为此困惑,主要原因在于如果你要使用一个特征的方法,那么你需要引入该特征到当前的作用域中,我们在上面用到了 try_into
方法,因此需要引入对应的特征。但是 Rust 又提供了一个非常便利的办法,把最常用的标准库中的特征通过std::prelude
模块提前引入到当前作用域中,其中包括了 std::convert::TryInto
,你可以尝试删除第一行的代码 use ...
,看看是否会报错。
try_into
会尝试进行一次转换,并返回一个 Result
,此时就可以对其进行相应的错误处理。由于我们的例子只是为了快速测试,因此使用了 unwrap
方法,该方法在发现错误时,会直接调用 panic
导致程序的崩溃退出,在实际项目中,请不要这么使用,具体见panic部分。
最主要的是 try_into
转换会捕获大类型向小类型转换时导致的溢出错误:
-fn main() { - let b: i16 = 1500; - - let b_: u8 = match b.try_into() { - Ok(b1) => b1, - Err(e) => { - println!("{:?}", e.to_string()); - 0 - } - }; -} -
运行后输出如下 "out of range integral type conversion attempted"
,在这里我们程序捕获了错误,编译器告诉我们类型范围超出的转换是不被允许的,因为我们试图把 1500_i16
转换为 u8
类型,后者明显不足以承载这么大的值。
虽然 as
和 TryInto
很强大,但是只能应用在数值类型上,可是 Rust 有如此多的类型,想要为这些类型实现转换,我们需要另谋出路,先来看看在一个笨办法,将一个结构体转换为另外一个结构体:
--#![allow(unused)] -fn main() { -struct Foo { - x: u32, - y: u16, -} - -struct Bar { - a: u32, - b: u16, -} - -fn reinterpret(foo: Foo) -> Bar { - let Foo { x, y } = foo; - Bar { a: x, b: y } -} -} -
简单粗暴,但是从另外一个角度来看,也挺啰嗦的,好在 Rust 为我们提供了更通用的方式来完成这个目的。
-在某些情况下,类型是可以进行隐式强制转换的,虽然这些转换弱化了 Rust 的类型系统,但是它们的存在是为了让 Rust 在大多数场景可以工作(说白了,帮助用户省事),而不是报各种类型上的编译错误。
-首先,在匹配特征时,不会做任何强制转换(除了方法)。一个类型 T
可以强制转换为 U
,不代表 impl T
可以强制转换为 impl U
,例如下面的代码就无法通过编译检查:
-trait Trait {} - -fn foo<X: Trait>(t: X) {} - -impl<'a> Trait for &'a i32 {} - -fn main() { - let t: &mut i32 = &mut 0; - foo(t); -} -
报错如下:
-error[E0277]: the trait bound `&mut i32: Trait` is not satisfied
---> src/main.rs:9:9
-|
-9 | foo(t);
-| ^ the trait `Trait` is not implemented for `&mut i32`
-|
-= help: the following implementations were found:
- <&'a i32 as Trait>
-= note: `Trait` is implemented for `&i32`, but not for `&mut i32`
-
-&i32
实现了特征 Trait
, &mut i32
可以转换为 &i32
,但是 &mut i32
依然无法作为 Trait
来使用。
方法调用的点操作符看起来简单,实际上非常不简单,它在调用时,会发生很多魔法般的类型转换,例如:自动引用、自动解引用,强制类型转换直到类型能匹配等。
-假设有一个方法 foo
,它有一个接收器(接收器就是 self
、&self
、&mut self
参数)。如果调用 value.foo()
,编译器在调用 foo
之前,需要决定到底使用哪个 Self
类型来调用。现在假设 value
拥有类型 T
。
再进一步,我们使用完全限定语法来进行准确的函数调用:
-T::foo(value)
,称之为值方法调用Self
进行实现,上文提到过特征不能进行强制转换),那么编译器会尝试增加自动引用,例如会尝试以下调用: <&T>::foo(value)
和 <&mut T>::foo(value)
,称之为引用方法调用T
,然后再进行尝试。这里使用了 Deref
特征 —— 若 T: Deref<Target = U>
(T
可以被解引用为 U
),那么编译器会使用 U
类型进行尝试,称之为解引用方法调用T
不能被解引用,且 T
是一个定长类型(在编译器类型长度是已知的),那么编译器也会尝试将 T
从定长类型转为不定长类型,例如将 [i32; 2]
转为 [i32]
下面我们来用一个例子来解释上面的方法查找算法:
---#![allow(unused)] -fn main() { -let array: Rc<Box<[T; 3]>> = ...; -let first_entry = array[0]; -} -
array
数组的底层数据隐藏在了重重封锁之后,那么编译器如何使用 array[0]
这种数组原生访问语法通过重重封锁,准确的访问到数组中的第一个元素?
array[0]
只是Index
特征的语法糖:编译器会将 array[0]
转换为 array.index(0)
调用,当然在调用之前,编译器会先检查 array
是否实现了 Index
特征。Rc<Box<[T; 3]>>
是否有实现 Index
特征,结果是否,不仅如此,&Rc<Box<[T; 3]>>
与 &mut Rc<Box<[T; 3]>>
也没有实现。Rc<Box<[T; 3]>>
进行解引用,把它转变成 Box<[T; 3]>
Box<[T; 3]>
进行上面的操作 :Box<[T; 3]>
, &Box<[T; 3]>
,和 &mut Box<[T; 3]>
都没有实现 Index
特征,所以编译器开始对 Box<[T; 3]>
进行解引用,然后我们得到了 [T; 3]
[T; 3]
以及它的各种引用都没有实现 Index
索引(是不是很反直觉:D,在直觉中,数组都可以通过索引访问,实际上只有数组切片才可以!),它也不能再进行解引用,因此编译器只能祭出最后的大杀器:将定长转为不定长,因此 [T; 3]
被转换成 [T]
,也就是数组切片,它实现了 Index
特征,因此最终我们可以通过 index
方法访问到对应的元素。过程看起来很复杂,但是也还好,挺好理解,如果你现在不能彻底理解,也不要紧,等以后对 Rust 理解更深了,同时需要深入理解类型转换时,再来细细品读本章。
-再来看看以下更复杂的例子:
---#![allow(unused)] -fn main() { -fn do_stuff<T: Clone>(value: &T) { - let cloned = value.clone(); -} -} -
上面例子中 cloned
的类型是什么?首先编译器检查能不能进行值方法调用, value
的类型是 &T
,同时 clone
方法的签名也是 &T
: fn clone(&T) -> T
,因此可以进行值方法调用,再加上编译器知道了 T
实现了 Clone
,因此 cloned
的类型是 T
。
如果 T: Clone
的特征约束被移除呢?
--#![allow(unused)] -fn main() { -fn do_stuff<T>(value: &T) { - let cloned = value.clone(); -} -} -
首先,从直觉上来说,该方法会报错,因为 T
没有实现 Clone
特征,但是真实情况是什么呢?
我们先来推导一番。 首先通过值方法调用就不再可行,因为 T
没有实现 Clone
特征,也就无法调用 T
的 clone
方法。接着编译器尝试引用方法调用,此时 T
变成 &T
,在这种情况下, clone
方法的签名如下: fn clone(&&T) -> &T
,接着我们现在对 value
进行了引用。 编译器发现 &T
实现了 Clone
类型(所有的引用类型都可以被复制,因为其实就是复制一份地址),因此可以推出 cloned
也是 &T
类型。
最终,我们复制出一份引用指针,这很合理,因为值类型 T
没有实现 Clone
,只能去复制一个指针了。
下面的例子也是自动引用生效的地方:
---#![allow(unused)] -fn main() { -#[derive(Clone)] -struct Container<T>(Arc<T>); - -fn clone_containers<T>(foo: &Container<i32>, bar: &Container<T>) { - let foo_cloned = foo.clone(); - let bar_cloned = bar.clone(); -} -} -
推断下上面的 foo_cloned
和 bar_cloned
是什么类型?提示: 关键在 Container
的泛型参数,一个是 i32
的具体类型,一个是泛型类型,其中 i32
实现了 Clone
,但是 T
并没有。
首先要复习一下复杂类型派生 Clone
的规则:一个复杂类型能否派生 Clone
,需要它内部的所有子类型都能进行 Clone
。因此 Container<T>(Arc<T>)
是否实现 Clone
的关键在于 T
类型是否实现了 Clone
特征。
上面代码中,Container<i32>
实现了 Clone
特征,因此编译器可以直接进行值方法调用,此时相当于直接调用 foo.clone
,其中 clone
的函数签名是 fn clone(&T) -> T
,由此可以看出 foo_cloned
的类型是 Container<i32>
。
然而,bar_cloned
的类型却是 &Container<T>
,这个不合理啊,明明我们为 Container<T>
派生了 Clone
特征,因此它也应该是 Container<T>
类型才对。万事皆有因,我们先来看下 derive
宏最终生成的代码大概是啥样的:
--#![allow(unused)] -fn main() { -impl<T> Clone for Container<T> where T: Clone { - fn clone(&self) -> Self { - Self(Arc::clone(&self.0)) - } -} -} -
从上面代码可以看出,派生 Clone
能实现的根本是 T
实现了Clone
特征:where T: Clone
, 因此 Container<T>
就没有实现 Clone
特征。
编译器接着会去尝试引用方法调用,此时 &Container<T>
引用实现了 Clone
,最终可以得出 bar_cloned
的类型是 &Container<T>
。
当然,也可以为 Container<T>
手动实现 Clone
特征:
--#![allow(unused)] -fn main() { -impl<T> Clone for Container<T> { - fn clone(&self) -> Self { - Self(Arc::clone(&self.0)) - } -} -} -
此时,编译器首次尝试值方法调用即可通过,因此 bar_cloned
的类型变成 Container<T>
。
这一块儿内容真的挺复杂,每一个坚持看完的读者都是真正的勇士,我也是:为了写好这块儿内容,作者足足花了 4 个小时!
-前方危险,敬请绕行!
-类型系统,你让开!我要自己转换这些类型,不成功便成仁!虽然本书大多是关于安全的内容,我还是希望你能仔细考虑避免使用本章讲到的内容。这是你在 Rust 中所能做到的真真正正、彻彻底底、最最可怕的非安全行为,在这里,所有的保护机制都形同虚设。
-先让你看看深渊长什么样,开开眼,然后你再决定是否深入: mem::transmute<T, U>
将类型 T
直接转成类型 U
,唯一的要求就是,这两个类型占用同样大小的字节数!我的天,这也算限制?这简直就是无底线的转换好吧?看看会导致什么问题:
3
转换成 bool
类型,就算你根本不会去使用该 bool
类型,也不要去这样转换&
变形为 &mut
是未定义的行为
-对于第 5 条,你该如何知道内存的排列布局是一样的呢?对于 repr(C)
类型和 repr(transparent)
类型来说,它们的布局是有着精确定义的。但是对于你自己的"普通却自信"的 Rust 类型 repr(Rust)
来说,它可不是有着精确定义的。甚至同一个泛型类型的不同实例都可以有不同的内存布局。 Vec<i32>
和 Vec<u32>
它们的字段可能有着相同的顺序,也可能没有。对于数据排列布局来说,什么能保证,什么不能保证目前还在 Rust 开发组的工作任务中呢。
你以为你之前凝视的是深渊吗?不,你凝视的只是深渊的大门。 mem::transmute_copy<T, U>
才是真正的深渊,它比之前的还要更加危险和不安全。它从 T
类型中拷贝出 U
类型所需的字节数,然后转换成 U
。 mem::transmute
尚有大小检查,能保证两个数据的内存大小一致,现在这哥们干脆连这个也丢了,只不过 U
的尺寸若是比 T
大,会是一个未定义行为。
当然,你也可以通过裸指针转换和 unions
(todo!)获得所有的这些功能,但是你将无法获得任何编译提示或者检查。裸指针转换和 unions
也不是魔法,无法逃避上面说的规则。
transmute
虽然危险,但作为一本工具书,知识当然要全面,下面列举两个有用的 transmute
应用场景 :)。
--#![allow(unused)] -fn main() { -fn foo() -> i32 { - 0 -} - -let pointer = foo as *const (); -let function = unsafe { - // 将裸指针转换为函数指针 - std::mem::transmute::<*const (), fn() -> i32>(pointer) -}; -assert_eq!(function(), 0); -} -
--#![allow(unused)] -fn main() { -struct R<'a>(&'a i32); - -// 将 'b 生命周期延长至 'static 生命周期 -unsafe fn extend_lifetime<'b>(r: R<'b>) -> R<'static> { - std::mem::transmute::<R<'b>, R<'static>>(r) -} - -// 将 'static 生命周期缩短至 'c 生命周期 -unsafe fn shorten_invariant_lifetime<'b, 'c>(r: &'b mut R<'static>) -> &'b mut R<'c> { - std::mem::transmute::<&'b mut R<'static>, &'b mut R<'c>>(r) -} -} -
以上例子非常先进!但是是非常不安全的 Rust 行为!
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
- -
弱弱地、不负责任地说,Rust 的学习难度之恶名,可能有一半来源于 Rust 的类型系统,而其中一半的一半则来自于本章节的内容。在本章,我们将重点学习如何创建自定义类型,以及了解何为动态大小的类型。
-何为 newtype
?简单来说,就是使用元组结构体的方式将已有的类型包裹起来:struct Meters(u32);
,那么此处 Meters
就是一个 newtype
。
为何需要 newtype
?Rust 这多如繁星的 Old 类型满足不了我们吗?这是因为:
u32
作为距离的单位类型,我们可以使用 Meters
,它的可读性要好得多newtype
可以很好地解决一箩筐的理由~~ 让我们先从第二点讲起。
-在之前的章节中,我们有讲过,如果在外部类型上实现外部特征必须使用 newtype
的方式,否则你就得遵循孤儿规则:要为类型 A
实现特征 T
,那么 A
或者 T
必须至少有一个在当前的作用范围内。
例如,如果想使用 println!("{}", v)
的方式去格式化输出一个动态数组 Vec
,以期给用户提供更加清晰可读的内容,那么就需要为 Vec
实现 Display
特征,但是这里有一个问题: Vec
类型定义在标准库中,Display
亦然,这时就可以祭出大杀器 newtype
来解决:
-use std::fmt; - -struct Wrapper(Vec<String>); - -impl fmt::Display for Wrapper { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "[{}]", self.0.join(", ")) - } -} - -fn main() { - let w = Wrapper(vec![String::from("hello"), String::from("world")]); - println!("w = {}", w); -} -
如上所示,使用元组结构体语法 struct Wrapper(Vec<String>)
创建了一个 newtype
Wrapper,然后为它实现 Display
特征,最终实现了对 Vec
动态数组的格式化输出。
首先,更好的可读性不等于更少的代码(如果你学过 Scala,相信会深有体会),其次下面的例子只是一个示例,未必能体现出更好的可读性:
--use std::ops::Add; -use std::fmt; - -struct Meters(u32); -impl fmt::Display for Meters { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "目标地点距离你{}米", self.0) - } -} - -impl Add for Meters { - type Output = Self; - - fn add(self, other: Meters) -> Self { - Self(self.0 + other.0) - } -} -fn main() { - let d = calculate_distance(Meters(10), Meters(20)); - println!("{}", d); -} - -fn calculate_distance(d1: Meters, d2: Meters) -> Meters { - d1 + d2 -} -
上面代码创建了一个 newtype
Meters,为其实现 Display
和 Add
特征,接着对两个距离进行求和计算,最终打印出该距离:
目标地点距离你30米
-
-事实上,除了可读性外,还有一个极大的优点:如果给 calculate_distance
传一个其它的类型,例如 struct MilliMeters(u32);
,该代码将无法编译。尽管 Meters
和 MilliMeters
都是对 u32
类型的简单包装,但是它们是不同的类型!
众所周知,Rust 的类型有很多自定义的方法,假如我们把某个类型传给了用户,但是又不想用户调用这些方法,就可以使用 newtype
:
-struct Meters(u32); - -fn main() { - let i: u32 = 2; - assert_eq!(i.pow(2), 4); - - let n = Meters(i); - // 下面的代码将报错,因为`Meters`类型上没有`pow`方法 - // assert_eq!(n.pow(2), 4); -} -
不过需要偷偷告诉你的是,这种方式实际上是掩耳盗铃,因为用户依然可以通过 n.0.pow(2)
的方式来调用内部类型的方法 :)
除了使用 newtype
,我们还可以使用一个更传统的方式来创建新类型:类型别名
--#![allow(unused)] -fn main() { -type Meters = u32 -} -
嗯,不得不说,类型别名的方式看起来比 newtype
顺眼的多,而且跟其它语言的使用方式几乎一致,但是:
-类型别名并不是一个独立的全新的类型,而是某一个类型的别名,因此编译器依然会把 Meters
当 u32
来使用:
--#![allow(unused)] -fn main() { -type Meters = u32; - -let x: u32 = 5; -let y: Meters = 5; - -println!("x + y = {}", x + y); -} -
上面的代码将顺利编译通过,但是如果你使用 newtype
模式,该代码将无情报错,简单做个总结:
newtype
才是!newtype
可以类型别名除了让类型可读性更好,还能减少模版代码的使用:
---#![allow(unused)] -fn main() { -let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi")); - -fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) { - // --snip-- -} - -fn returns_long_type() -> Box<dyn Fn() + Send + 'static> { - // --snip-- -} -} -
f
是一个令人眼花缭乱的类型 Box<dyn Fn() + Send + 'static>
,如果仔细看,会发现其实只有一个 Send
特征不认识,Send
是什么在这里不重要,你只需理解,f
就是一个 Box<dyn T>
类型的特征对象,实现了 Fn()
和 Send
特征,同时生命周期为 'static
。
因为 f
的类型贼长,导致了后面我们在使用它时,到处都充斥这些不太优美的类型标注,好在类型别名可解君忧:
--#![allow(unused)] -fn main() { -type Thunk = Box<dyn Fn() + Send + 'static>; - -let f: Thunk = Box::new(|| println!("hi")); - -fn takes_long_type(f: Thunk) { - // --snip-- -} - -fn returns_long_type() -> Thunk { - // --snip-- -} -} -
Bang!是不是?!立刻大幅简化了我们的使用。喝着奶茶、哼着歌、我写起代码撩起妹,何其快哉!
-在标准库中,类型别名应用最广的就是简化 Result<T, E>
枚举。
例如在 std::io
库中,它定义了自己的 Error
类型:std::io::Error
,那么如果要使用该 Result
就要用这样的语法:std::result::Result<T, std::io::Error>;
,想象一下代码中充斥着这样的东东是一种什么感受?颤抖吧。。。
由于使用 std::io
库时,它的所有错误类型都是 std::io::Error
,那么我们完全可以把该错误对用户隐藏起来,只在内部使用即可,因此就可以使用类型别名来简化实现:
--#![allow(unused)] -fn main() { -type Result<T> = std::result::Result<T, std::io::Error>; -} -
Bingo,这样一来,其它库只需要使用 std::io::Result<T>
即可替代冗长的 std::result::Result<T, std::io::Error>
类型。
更香的是,由于它只是别名,因此我们可以用它来调用真实类型的所有方法,甚至包括 ?
符号!
在函数那章,曾经介绍过 !
类型:!
用来说明一个函数永不返回任何值,当时可能体会不深,没事,在学习了更多手法后,保证你有全新的体验:
-fn main() { - let i = 2; - let v = match i { - 0..=3 => i, - _ => println!("不合规定的值:{}", i) - }; -} -
上面函数,会报出一个编译错误:
-error[E0308]: `match` arms have incompatible types // match的分支类型不同
- --> src/main.rs:5:13
- |
-3 | let v = match i {
- | _____________-
-4 | | 0..3 => i,
- | | - this is found to be of type `{integer}` // 该分支返回整数类型
-5 | | _ => println!("不合规定的值:{}", i)
- | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected integer, found `()` // 该分支返回()单元类型
-6 | | };
- | |_____- `match` arms have incompatible types
-
-原因很简单: 要赋值给 v
,就必须保证 match
的各个分支返回的值是同一个类型,但是上面一个分支返回数值、另一个分支返回元类型 ()
,自然会出错。
既然 println
不行,那再试试 panic
-fn main() { - let i = 2; - let v = match i { - 0..=3 => i, - _ => panic!("不合规定的值:{}", i) - }; -} -
神奇的事发生了,此处 panic
竟然通过了编译。难道这两个宏拥有不同的返回类型?
你猜的没错:panic
的返回值是 !
,代表它决不会返回任何值,既然没有任何返回值,那自然不会存在分支类型不匹配的情况。
在 Rust 中,从枚举到整数的转换很容易,但是反过来,就没那么容易,甚至部分实现还挺邪恶, 例如使用transmute
。
在实际场景中,从枚举到整数的转换有时还是非常需要的,例如你有一个枚举类型,然后需要从外面传入一个整数,用于控制后续的流程走向,此时就需要用整数去匹配相应的枚举(你也可以用整数匹配整数-, -,看看会不会被喷)。
-既然有了需求,剩下的就是看看该如何实现,这篇文章的水远比你想象的要深,且看八仙过海各显神通。
-对于 C 语言来说,万物皆邪恶,因此我们不讨论安全,只看实现,不得不说很简洁:
-#include <stdio.h>
-
-enum atomic_number {
- HYDROGEN = 1,
- HELIUM = 2,
- // ...
- IRON = 26,
-};
-
-int main(void)
-{
- enum atomic_number element = 26;
-
- if (element == IRON) {
- printf("Beware of Rust!\n");
- }
-
- return 0;
-}
-
-但是在 Rust 中,以下代码:
--enum MyEnum { - A = 1, - B, - C, -} - -fn main() { - // 将枚举转换成整数,顺利通过 - let x = MyEnum::C as i32; - - // 将整数转换为枚举,失败 - match x { - MyEnum::A => {} - MyEnum::B => {} - MyEnum::C => {} - _ => {} - } -} -
就会报错: MyEnum::A => {} mismatched types, expected i32, found enum MyEnum
。
首先可以想到的肯定是三方库,毕竟 Rust 的生态目前已经发展的很不错,类似的需求总是有的,这里我们先使用num-traits
和num-derive
来试试。
在Cargo.toml
中引入:
[dependencies]
-num-traits = "0.2.14"
-num-derive = "0.3.3"
-
-代码如下:
--use num_derive::FromPrimitive; -use num_traits::FromPrimitive; - -#[derive(FromPrimitive)] -enum MyEnum { - A = 1, - B, - C, -} - -fn main() { - let x = 2; - - match FromPrimitive::from_i32(x) { - Some(MyEnum::A) => println!("Got A"), - Some(MyEnum::B) => println!("Got B"), - Some(MyEnum::C) => println!("Got C"), - None => println!("Couldn't convert {}", x), - } -} -
除了上面的库,还可以使用一个较新的库: num_enums
。
在 Rust 1.34 后,可以实现TryFrom
特征来做转换:
--#![allow(unused)] -fn main() { -use std::convert::TryFrom; - -impl TryFrom<i32> for MyEnum { - type Error = (); - - fn try_from(v: i32) -> Result<Self, Self::Error> { - match v { - x if x == MyEnum::A as i32 => Ok(MyEnum::A), - x if x == MyEnum::B as i32 => Ok(MyEnum::B), - x if x == MyEnum::C as i32 => Ok(MyEnum::C), - _ => Err(()), - } - } -} -} -
以上代码定义了从i32
到MyEnum
的转换,接着就可以使用TryInto
来实现转换:
-use std::convert::TryInto; - -fn main() { - let x = MyEnum::C as i32; - - match x.try_into() { - Ok(MyEnum::A) => println!("a"), - Ok(MyEnum::B) => println!("b"), - Ok(MyEnum::C) => println!("c"), - Err(_) => eprintln!("unknown number"), - } -} -
但是上面的代码有个问题,你需要为每个枚举成员都实现一个转换分支,非常麻烦。好在可以使用宏来简化,自动根据枚举的定义来实现TryFrom
特征:
--#![allow(unused)] -fn main() { -#[macro_export] -macro_rules! back_to_enum { - ($(#[$meta:meta])* $vis:vis enum $name:ident { - $($(#[$vmeta:meta])* $vname:ident $(= $val:expr)?,)* - }) => { - $(#[$meta])* - $vis enum $name { - $($(#[$vmeta])* $vname $(= $val)?,)* - } - - impl std::convert::TryFrom<i32> for $name { - type Error = (); - - fn try_from(v: i32) -> Result<Self, Self::Error> { - match v { - $(x if x == $name::$vname as i32 => Ok($name::$vname),)* - _ => Err(()), - } - } - } - } -} - -back_to_enum! { - enum MyEnum { - A = 1, - B, - C, - } -} -} -
这个方法原则上并不推荐,但是有其存在的意义,如果要使用,你需要清晰的知道自己为什么使用。
-在之前的类型转换章节,我们提到过非常邪恶的transmute
转换,其实,当你知道数值一定不会超过枚举的范围时(例如枚举成员对应 1,2,3,传入的整数也在这个范围内),就可以使用这个方法完成变形。
--最好使用#[repr(..)]来控制底层类型的大小,免得本来需要 i32,结果传入 i64,最终内存无法对齐,产生奇怪的结果
-
-#[repr(i32)] -enum MyEnum { - A = 1, B, C -} - -fn main() { - let x = MyEnum::C; - let y = x as i32; - let z: MyEnum = unsafe { std::mem::transmute(y) }; - - // match the enum that came from an int - match z { - MyEnum::A => { println!("Found A"); } - MyEnum::B => { println!("Found B"); } - MyEnum::C => { println!("Found C"); } - } -} -
既然是邪恶之王,当然得有真本事,无需标准库、也无需 unstable 的 Rust 版本,我们就完成了转换!awesome!??
-本文列举了常用(其实差不多也是全部了,还有一个 unstable 特性没提到)的从整数转换为枚举的方式,推荐度按照出现的先后顺序递减。
-但是推荐度最低,不代表它就没有出场的机会,只要使用边界清晰,一样可以大放光彩,例如最后的transmute
函数.
Rust 是强类型语言,同时也是强安全语言,这些特性导致了 Rust 的类型注定比一般语言要更深入也更困难。
-本章将深入讲解一些进阶的 Rust 类型以及类型转换,希望大家喜欢。
- - -在 Rust 中类型有多种抽象的分类方式,例如本书之前章节的:基本类型、集合类型、复合类型等。再比如说,如果从编译器何时能获知类型大小的角度出发,可以分成两类:
-首先,我们来深入看看何为 DST。
-读者大大们之前学过的几乎所有类型,都是固定大小的类型,包括集合 Vec
、String
和 HashMap
等,而动态大小类型刚好与之相反:编译器无法在编译期得知该类型值的大小,只有到了程序运行时,才能动态获知。对于动态类型,我们使用 DST
(dynamically sized types)或者 unsized
类型来称呼它。
上述的这些集合虽然底层数据可动态变化,感觉像是动态大小的类型。但是实际上,这些底层数据只是保存在堆上,在栈中还存有一个引用类型,该引用包含了集合的内存地址、元素数目、分配空间信息,通过这些信息,编译器对于该集合的实际大小了若指掌,最最重要的是:栈上的引用类型是固定大小的,因此它们依然是固定大小的类型。
-正因为编译器无法在编译期获知类型大小,若你试图在代码中直接使用 DST 类型,将无法通过编译。
-现在给你一个挑战:想出几个 DST 类型。俺厚黑地说一句,估计大部分人都想不出这样的一个类型,就连我,如果不是查询着资料在写,估计一时半会儿也想不到一个。
-先来看一个最直白的:
---#![allow(unused)] -fn main() { -fn my_function(n: usize) { - let array = [123; n]; -} -} -
以上代码就会报错(错误输出的内容并不是因为 DST,但根本原因是类似的),因为 n
在编译期无法得知,而数组类型的一个组成部分就是长度,长度变为动态的,自然类型就变成了 unsized 。
切片也是一个典型的 DST 类型,具体详情参见另一篇文章: 易混淆的切片和切片引用。
-考虑一下这个类型:str
,感觉有点眼生?是的,它既不是 String
动态字符串,也不是 &str
字符串切片,而是一个 str
。它是一个动态类型,同时还是 String
和 &str
的底层数据类型。 由于 str
是动态类型,因此它的大小直到运行期才知道,下面的代码会因此报错:
--#![allow(unused)] -fn main() { -// error -let s1: str = "Hello there!"; -let s2: str = "How's it going?"; - -// ok -let s3: &str = "on?" -} -
Rust 需要明确地知道一个特定类型的值占据了多少内存空间,同时该类型的所有值都必须使用相同大小的内存。如果 Rust 允许我们使用这种动态类型,那么这两个 str
值就需要占用同样大小的内存,这显然是不现实的: s1
占用了 12 字节,s2
占用了 15 字节,总不至于为了满足同样的内存大小,用空白字符去填补字符串吧?
所以,我们只有一条路走,那就是给它们一个固定大小的类型:&str
。那么为何字符串切片 &str
就是固定大小呢?因为它的引用存储在栈上,具有固定大小(类似指针),同时它指向的数据存储在堆中,也是已知的大小,再加上 &str
引用中包含有堆上数据内存地址、长度等信息,因此最终可以得出字符串切片是固定大小类型的结论。
与 &str
类似,String
字符串也是固定大小的类型。
正是因为 &str
的引用有了底层堆数据的明确信息,它才是固定大小类型。假设如果它没有这些信息呢?那它也将变成一个动态类型。因此,将动态数据固定化的秘诀就是使用引用指向这些动态数据,然后在引用中存储相关的内存位置、长度等信息。
--#![allow(unused)] -fn main() { -fn foobar_1(thing: &dyn MyThing) {} // OK -fn foobar_2(thing: Box<dyn MyThing>) {} // OK -fn foobar_3(thing: MyThing) {} // ERROR! -} -
如上所示,只能通过引用或 Box
的方式来使用特征对象,直接使用将报错!
Rust 中常见的 DST
类型有: str
、[T]
、dyn Trait
,它们都无法单独被使用,必须要通过引用或者 Box
来间接使用 。
我们之前已经见过,使用 Box
将一个没有固定大小的特征变成一个有固定大小的特征对象,那能否故技重施,将 str
封装成一个固定大小类型?留个悬念先,我们来看看 Sized
特征。
既然动态类型的问题这么大,那么在使用泛型时,Rust 如何保证我们的泛型参数是固定大小的类型呢?例如以下泛型函数:
---#![allow(unused)] -fn main() { -fn generic<T>(t: T) { - // --snip-- -} -} -
该函数很简单,就一个泛型参数 T,那么如何保证 T
是固定大小的类型?仔细回想下,貌似在之前的课程章节中,我们也没有做过任何事情去做相关的限制,那 T
怎么就成了固定大小的类型了?奥秘在于编译器自动帮我们加上了 Sized
特征约束:
--#![allow(unused)] -fn main() { -fn generic<T: Sized>(t: T) { - // --snip-- -} -} -
在上面,Rust 自动添加的特征约束 T: Sized
,表示泛型函数只能用于一切实现了 Sized
特征的类型上,而所有在编译时就能知道其大小的类型,都会自动实现 Sized
特征,例如。。。。也没啥好例如的,你能想到的几乎所有类型都实现了 Sized
特征,除了上面那个坑坑的 str
,哦,还有特征。
每一个特征都是一个可以通过名称来引用的动态大小类型。因此如果想把特征作为具体的类型来传递给函数,你必须将其转换成一个特征对象:诸如 &dyn Trait
或者 Box<dyn Trait>
(还有 Rc<dyn Trait>
)这些引用类型。
现在还有一个问题:假如想在泛型函数中使用动态数据类型怎么办?可以使用 ?Sized
特征(不得不说这个命名方式很 Rusty,竟然有点幽默):
--#![allow(unused)] -fn main() { -fn generic<T: ?Sized>(t: &T) { - // --snip-- -} -} -
?Sized
特征用于表明类型 T
既有可能是固定大小的类型,也可能是动态大小的类型。还有一点要注意的是,函数参数类型从 T
变成了 &T
,因为 T
可能是动态大小的,因此需要用一个固定大小的指针(引用)来包裹它。
Box<str>
在结束前,再来看看之前遗留的问题:使用 Box
可以将一个动态大小的特征变成一个具有固定大小的特征对象,能否故技重施,将 str
封装成一个固定大小类型?
先回想下,章节前面的内容介绍过该如何把一个动态大小类型转换成固定大小的类型: 使用引用指向这些动态数据,然后在引用中存储相关的内存位置、长度等信息。
-好的,根据这个,我们来一起推测。首先,Box<str>
使用了一个引用来指向 str
,嗯,满足了第一个条件。但是第二个条件呢?Box
中有该 str
的长度信息吗?显然是 No
。那为什么特征就可以变成特征对象?其实这个还蛮复杂的,简单来说,对于特征对象,编译器无需知道它具体是什么类型,只要知道它能调用哪几个方法即可,因此编译器帮我们实现了剩下的一切。
来验证下我们的推测:
--fn main() { - let s1: Box<str> = Box::new("Hello there!" as str); -} -
报错如下:
-error[E0277]: the size for values of type `str` cannot be known at compilation time
- --> src/main.rs:2:24
- |
-2 | let s1: Box<str> = Box::new("Hello there!" as str);
- | ^^^^^^^^ doesn't have a size known at compile-time
- |
- = help: the trait `Sized` is not implemented for `str`
- = note: all function arguments must have a statically known size
-
-提示得很清晰,不知道 str
的大小,因此无法使用这种语法进行 Box
进装,但是你可以这么做:
--#![allow(unused)] -fn main() { -let s1: Box<str> = "Hello there!".into(); -} -
主动转换成 str
的方式不可行,但是可以让编译器来帮我们完成,只要告诉它我们需要的类型即可。
恭喜你,学会 Rust 基础后,金丹大道已在向你招手,大部分 Rust 代码对你来说都是家常便饭,简单得很。可是,对于一门难度传言在外的语言,怎么可能如此简单的就被征服,最难的生命周期,咱还没见过长啥样呢。
-从本章开始,我们将进入 Rust 的进阶学习环节,与基础环节不同的是,由于你已经对 Rust 有了一定的认识,因此我们不会再对很多细节进行翻来覆去的详细讲解,甚至会一带而过。
-总之,欢迎来到高级 Rust 的世界,全新的 Boss,全新的装备,你准备好了吗?
- - -其实关于生命周期的常用特性,在上一节中,我们已经概括得差不多了,本章主要讲解生命周期的一些高级或者不为人知的特性。对于新手,完全可以跳过本节内容,进行下一章节的学习。
-在 Rust 语言学习中,一个很重要的部分就是阅读一些你可能不经常遇到,但是一旦遇到就难以理解的代码,这些代码往往最令人头疼的就是生命周期,这里我们就来看看一些本以为可以编译,但是却因为生命周期系统不够聪明导致编译失败的代码。
--#[derive(Debug)] -struct Foo; - -impl Foo { - fn mutate_and_share(&mut self) -> &Self { - &*self - } - fn share(&self) {} -} - -fn main() { - let mut foo = Foo; - let loan = foo.mutate_and_share(); - foo.share(); - println!("{:?}", loan); -} -
上面的代码中,foo.mutate_and_share()
虽然借用了 &mut self
,但是它最终返回的是一个 &self
,然后赋值给 loan
,因此理论上来说它最终是进行了不可变借用,同时 foo.share
也进行了不可变借用,那么根据 Rust 的借用规则:多个不可变借用可以同时存在,因此该代码应该编译通过。
事实上,运行代码后,你将看到一个错误:
-error[E0502]: cannot borrow `foo` as immutable because it is also borrowed as mutable
- --> src/main.rs:12:5
- |
-11 | let loan = foo.mutate_and_share();
- | ---------------------- mutable borrow occurs here
-12 | foo.share();
- | ^^^^^^^^^^^ immutable borrow occurs here
-13 | println!("{:?}", loan);
- | ---- mutable borrow later used here
-
-编译器的提示在这里其实有些难以理解,因为可变借用仅在 mutate_and_share
方法内部有效,出了该方法后,就只有返回的不可变借用,因此,按理来说可变借用不应该在 main
的作用范围内存在。
对于这个反直觉的事情,让我们用生命周期来解释下,可能你就很好理解了:
--struct Foo; - -impl Foo { - fn mutate_and_share<'a>(&'a mut self) -> &'a Self { - &'a *self - } - fn share<'a>(&'a self) {} -} - -fn main() { - 'b: { - let mut foo: Foo = Foo; - 'c: { - let loan: &'c Foo = Foo::mutate_and_share::<'c>(&'c mut foo); - 'd: { - Foo::share::<'d>(&'d foo); - } - println!("{:?}", loan); - } - } -} -
以上是模拟了编译器的生命周期标注后的代码,可以注意到 &mut foo
和 loan
的生命周期都是 'c
。
还记得生命周期消除规则中的第三条吗?因为该规则,导致了 mutate_and_share
方法中,参数 &mut self
和返回值 &self
的生命周期是相同的,因此,若返回值的生命周期在 main
函数有效,那 &mut self
的借用也是在 main
函数有效。
这就解释了可变借用为啥会在 main
函数作用域内有效,最终导致 foo.share()
无法再进行不可变借用。
总结下:&mut self
借用的生命周期和 loan
的生命周期相同,将持续到 println
结束。而在此期间 foo.share()
又进行了一次不可变 &foo
借用,违背了可变借用与不可变借用不能同时存在的规则,最终导致了编译错误。
上述代码实际上完全是正确的,但是因为生命周期系统的“粗糙实现”,导致了编译错误,目前来说,遇到这种生命周期系统不够聪明导致的编译错误,我们也没有太好的办法,只能修改代码去满足它的需求,并期待以后它会更聪明。
-再来看一个例子:
--#![allow(unused)] -fn main() { - use std::collections::HashMap; - use std::hash::Hash; - fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V - where - K: Clone + Eq + Hash, - V: Default, - { - match map.get_mut(&key) { - Some(value) => value, - None => { - map.insert(key.clone(), V::default()); - map.get_mut(&key).unwrap() - } - } - } -} - -
这段代码不能通过编译的原因是编译器未能精确地判断出某个可变借用不再需要,反而谨慎的给该借用安排了一个很大的作用域,结果导致后续的借用失败:
-error[E0499]: cannot borrow `*map` as mutable more than once at a time
- --> src/main.rs:13:17
- |
-5 | fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
- | -- lifetime `'m` defined here
-...
-10 | match map.get_mut(&key) {
- | - ----------------- first mutable borrow occurs here
- | _________|
- | |
-11 | | Some(value) => value,
-12 | | None => {
-13 | | map.insert(key.clone(), V::default());
- | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here
-14 | | map.get_mut(&key).unwrap()
-15 | | }
-16 | | }
- | |_________- returning this value requires that `*map` is borrowed for `'m`
-
-分析代码可知在 match map.get_mut(&key)
方法调用完成后,对 map
的可变借用就可以结束了。但从报错看来,编译器不太聪明,它认为该借用会持续到整个 match
语句块的结束(第 16 行处),这便造成了后续借用的失败。
类似的例子还有很多,由于篇幅有限,就不在这里一一列举,如果大家想要阅读更多的类似代码,可以看看<<Rust 代码鉴赏>>一书。
-不安全代码(unsafe
)经常会凭空产生引用或生命周期,这些生命周期被称为是 无界(unbound) 的。
无界生命周期往往是在解引用一个裸指针(裸指针 raw pointer)时产生的,换句话说,它是凭空产生的,因为输入参数根本就没有这个生命周期:
---#![allow(unused)] -fn main() { -fn f<'a, T>(x: *const T) -> &'a T { - unsafe { - &*x - } -} -} -
上述代码中,参数 x
是一个裸指针,它并没有任何生命周期,然后通过 unsafe
操作后,它被进行了解引用,变成了一个 Rust 的标准引用类型,该类型必须要有生命周期,也就是 'a
。
可以看出 'a
是凭空产生的,因此它是无界生命周期。这种生命周期由于没有受到任何约束,因此它想要多大就多大,这实际上比 'static
要强大。例如 &'static &'a T
是无效类型,但是无界生命周期 &'unbounded &'a T
会被视为 &'a &'a T
从而通过编译检查,因为它可大可小,就像孙猴子的金箍棒一般。
我们在实际应用中,要尽量避免这种无界生命周期。最简单的避免无界生命周期的方式就是在函数声明中运用生命周期消除规则。若一个输出生命周期被消除了,那么必定因为有一个输入生命周期与之对应。
-生命周期约束跟特征约束类似,都是通过形如 'a: 'b
的语法,来说明两个生命周期的长短关系。
假设有两个引用 &'a i32
和 &'b i32
,它们的生命周期分别是 'a
和 'b
,若 'a
>= 'b
,则可以定义 'a:'b
,表示 'a
至少要活得跟 'b
一样久。
--#![allow(unused)] -fn main() { -struct DoubleRef<'a,'b:'a, T> { - r: &'a T, - s: &'b T -} -} -
例如上述代码定义一个结构体,它拥有两个引用字段,类型都是泛型 T
,每个引用都拥有自己的生命周期,由于我们使用了生命周期约束 'b: 'a
,因此 'b
必须活得比 'a
久,也就是结构体中的 s
字段引用的值必须要比 r
字段引用的值活得要久。
表示类型 T
必须比 'a
活得要久:
--#![allow(unused)] -fn main() { -struct Ref<'a, T: 'a> { - r: &'a T -} -} -
因为结构体字段 r
引用了 T
,因此 r
的生命周期 'a
必须要比 T
的生命周期更短(被引用者的生命周期必须要比引用长)。
在 Rust 1.30 版本之前,该写法是必须的,但是从 1.31 版本开始,编译器可以自动推导 T: 'a
类型的约束,因此我们只需这样写即可:
--#![allow(unused)] -fn main() { -struct Ref<'a, T> { - r: &'a T -} -} -
来看一个使用了生命周期约束的综合例子:
---#![allow(unused)] -fn main() { -struct ImportantExcerpt<'a> { - part: &'a str, -} - -impl<'a: 'b, 'b> ImportantExcerpt<'a> { - fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str { - println!("Attention please: {}", announcement); - self.part - } -} -} -
上面的例子中必须添加约束 'a: 'b
后,才能成功编译,因为 self.part
的生命周期与 self
的生命周期一致,将 &'a
类型的生命周期强行转换为 &'b
类型,会报错,只有在 'a
>= 'b
的情况下,'a
才能转换成 'b
。
先来看一段简单的代码:
---#![allow(unused)] -fn main() { -fn fn_elision(x: &i32) -> &i32 { x } -let closure_slision = |x: &i32| -> &i32 { x }; -} -
乍一看,这段代码比古天乐还平平无奇,能有什么问题呢?来,拄拐走两圈试试:
-error: lifetime may not live long enough
- --> src/main.rs:39:39
- |
-39 | let closure = |x: &i32| -> &i32 { x }; // fails
- | - - ^ returning this value requires that `'1` must outlive `'2`
- | | |
- | | let's call the lifetime of this reference `'2`
- | let's call the lifetime of this reference `'1`
-
-咦?竟然报错了,明明两个一模一样功能的函数,一个正常编译,一个却报错,错误原因是编译器无法推测返回的引用和传入的引用谁活得更久!
-真的是非常奇怪的错误,学过上一节的读者应该都记得这样一条生命周期消除规则:如果函数参数中只有一个引用类型,那该引用的生命周期会被自动分配给所有的返回引用。我们当前的情况完美符合, function
函数的顺利编译通过,就充分说明了问题。
先给出一个结论:这个问题,可能很难被解决,建议大家遇到后,还是老老实实用正常的函数,不要秀闭包了。
-对于函数的生命周期而言,它的消除规则之所以能生效是因为它的生命周期完全体现在签名的引用类型上,在函数体中无需任何体现:
---#![allow(unused)] -fn main() { -fn fn_elision(x: &i32) -> &i32 {..} -} -
因此编译器可以做各种编译优化,也很容易根据参数和返回值进行生命周期的分析,最终得出消除规则。
-可是闭包,并没有函数那么简单,它的生命周期分散在参数和闭包函数体中(主要是它没有确切的返回值签名):
---#![allow(unused)] -fn main() { -let closure_slision = |x: &i32| -> &i32 { x }; -} -
编译器就必须深入到闭包函数体中,去分析和推测生命周期,复杂度因此急剧提升:试想一下,编译器该如何从复杂的上下文中分析出参数引用的生命周期和闭包体中生命周期的关系?
-由于上述原因(当然,实际情况复杂的多),Rust 语言开发者目前其实是有意针对函数和闭包实现了两种不同的生命周期消除规则。
---用
-Fn
特征解决闭包生命周期之前我们提到了很难解决,但是并没有完全堵死(论文字的艺术- , -) 这不 @Ykong1337 同学就带了一个解决方法,为他点赞!
--fn main() { - let closure_slision = fun(|x: &i32| -> &i32 { x }); - assert_eq!(*closure_slision(&45), 45); - // Passed ! -} - -fn fun<T, F: Fn(&T) -> &T>(f: F) -> F { - f -} -
之前我们在引用与借用那一章其实有讲到过这个概念,简单来说就是:引用的生命周期正常来说应该从借用开始一直持续到作用域结束,但是这种规则会让多引用共存的情况变得更复杂:
--fn main() { - let mut s = String::from("hello"); - - let r1 = &s; - let r2 = &s; - println!("{} and {}", r1, r2); - // 新编译器中,r1,r2作用域在这里结束 - - let r3 = &mut s; - println!("{}", r3); -} -
按照上述规则,这段代码将会报错,因为 r1
和 r2
的不可变引用将持续到 main
函数结束,而在此范围内,我们又借用了 r3
的可变引用,这违反了借用的规则:要么多个不可变借用,要么一个可变借用。
好在,该规则从 1.31 版本引入 NLL
后,就变成了:引用的生命周期从借用处开始,一直持续到最后一次使用的地方。
按照最新的规则,我们再来分析一下上面的代码。r1
和 r2
不可变借用在 println!
后就不再使用,因此生命周期也随之结束,那么 r3
的借用就不再违反借用的规则,皆大欢喜。
再来看一段关于 NLL
的代码解释:
--#![allow(unused)] -fn main() { -let mut u = 0i32; -let mut v = 1i32; -let mut w = 2i32; - -// lifetime of `a` = α ∪ β ∪ γ -let mut a = &mut u; // --+ α. lifetime of `&mut u` --+ lexical "lifetime" of `&mut u`,`&mut u`, `&mut w` and `a` -use(a); // | | -*a = 3; // <-----------------+ | -... // | -a = &mut v; // --+ β. lifetime of `&mut v` | -use(a); // | | -*a = 4; // <-----------------+ | -... // | -a = &mut w; // --+ γ. lifetime of `&mut w` | -use(a); // | | -*a = 5; // <-----------------+ <--------------------------+ -} -
这段代码一目了然,a
有三段生命周期:α
,β
,γ
,每一段生命周期都随着当前值的最后一次使用而结束。
在实际项目中,NLL
规则可以大幅减少引用冲突的情况,极大的便利了用户,因此广受欢迎,最终该规则甚至演化成一个独立的项目,未来可能会进一步简化我们的使用,Polonius
:
学完 NLL
后,我们就有了一定的基础,可以继续学习关于借用和生命周期的一个高级内容:再借用。
先来看一段代码:
--#[derive(Debug)] -struct Point { - x: i32, - y: i32, -} - -impl Point { - fn move_to(&mut self, x: i32, y: i32) { - self.x = x; - self.y = y; - } -} - -fn main() { - let mut p = Point { x: 0, y: 0 }; - let r = &mut p; - let rr: &Point = &*r; - - println!("{:?}", rr); - r.move_to(10, 10); - println!("{:?}", r); -} -
以上代码,大家可能会觉得可变引用 r
和不可变引用 rr
同时存在会报错吧?但是事实上并不会,原因在于 rr
是对 r
的再借用。
对于再借用而言,rr
再借用时不会破坏借用规则,但是你不能在它的生命周期内再使用原来的借用 r
,来看看对上段代码的分析:
-fn main() { - let mut p = Point { x: 0, y: 0 }; - let r = &mut p; - // reborrow! 此时对`r`的再借用不会导致跟上面的借用冲突 - let rr: &Point = &*r; - - // 再借用`rr`最后一次使用发生在这里,在它的生命周期中,我们并没有使用原来的借用`r`,因此不会报错 - println!("{:?}", rr); - - // 再借用结束后,才去使用原来的借用`r` - r.move_to(10, 10); - println!("{:?}", r); -} -
再来看一个例子:
---#![allow(unused)] -fn main() { -use std::vec::Vec; -fn read_length(strings: &mut Vec<String>) -> usize { - strings.len() -} -} -
如上所示,函数体内对参数的二次借用也是典型的 Reborrow
场景。
那么下面让我们来做件坏事,破坏这条规则,使其报错:
--fn main() { - let mut p = Point { x: 0, y: 0 }; - let r = &mut p; - let rr: &Point = &*r; - - r.move_to(10, 10); - - println!("{:?}", rr); - - println!("{:?}", r); -} -
果然,破坏永远比重建简单 :) 只需要在 rr
再借用的生命周期内使用一次原来的借用 r
即可!
在上一节中,我们介绍了三大基础生命周期消除规则,实际上,随着 Rust 的版本进化,该规则也在不断演进,这里再介绍几个常见的消除规则:
---#![allow(unused)] -fn main() { -impl<'a> Reader for BufReader<'a> { - // methods go here - // impl内部实际上没有用到'a -} -} -
如果你以前写的impl
块长上面这样,同时在 impl
内部的方法中,根本就没有用到 'a
,那就可以写成下面的代码形式。
--#![allow(unused)] -fn main() { -impl Reader for BufReader<'_> { - // methods go here -} -} -
'_
生命周期表示 BufReader
有一个不使用的生命周期,我们可以忽略它,无需为它创建一个名称。
歪个楼,有读者估计会发问:既然用不到 'a
,为何还要写出来?如果你仔细回忆下上一节的内容,里面有一句专门用粗体标注的文字:生命周期参数也是类型的一部分,因此 BufReader<'a>
是一个完整的类型,在实现它的时候,你不能把 'a
给丢了!
--#![allow(unused)] -fn main() { -// Rust 2015 -struct Ref<'a, T: 'a> { - field: &'a T -} - -// Rust 2018 -struct Ref<'a, T> { - field: &'a T -} -} -
在本节的生命周期约束中,也提到过,新版本 Rust 中,上面情况中的 T: 'a
可以被消除掉,当然,你也可以显式的声明,但是会影响代码可读性。关于类似的场景,Rust 团队计划在未来提供更多的消除规则,但是,你懂的,计划未来就等于未知。
下面是一个关于生命周期声明过大的例子,会较为复杂,希望大家能细细阅读,它能帮你对生命周期的理解更加深入。
--struct Interface<'a> { - manager: &'a mut Manager<'a> -} - -impl<'a> Interface<'a> { - pub fn noop(self) { - println!("interface consumed"); - } -} - -struct Manager<'a> { - text: &'a str -} - -struct List<'a> { - manager: Manager<'a>, -} - -impl<'a> List<'a> { - pub fn get_interface(&'a mut self) -> Interface { - Interface { - manager: &mut self.manager - } - } -} - -fn main() { - let mut list = List { - manager: Manager { - text: "hello" - } - }; - - list.get_interface().noop(); - - println!("Interface should be dropped here and the borrow released"); - - // 下面的调用会失败,因为同时有不可变/可变借用 - // 但是Interface在之前调用完成后就应该被释放了 - use_list(&list); -} - -fn use_list(list: &List) { - println!("{}", list.manager.text); -} -
运行后报错:
-error[E0502]: cannot borrow `list` as immutable because it is also borrowed as mutable // `list`无法被借用,因为已经被可变借用
- --> src/main.rs:40:14
- |
-34 | list.get_interface().noop();
- | ---- mutable borrow occurs here // 可变借用发生在这里
-...
-40 | use_list(&list);
- | ^^^^^
- | |
- | immutable borrow occurs here // 新的不可变借用发生在这
- | mutable borrow later used here // 可变借用在这里结束
-
-这段代码看上去并不复杂,实际上难度挺高的,首先在直觉上,list.get_interface()
借用的可变引用,按理来说应该在这行代码结束后,就归还了,但是为什么还能持续到 use_list(&list)
后面呢?
这是因为我们在 get_interface
方法中声明的 lifetime
有问题,该方法的参数的生命周期是 'a
,而 List
的生命周期也是 'a
,说明该方法至少活得跟 List
一样久,再回到 main
函数中,list
可以活到 main
函数的结束,因此 list.get_interface()
借用的可变引用也会活到 main
函数的结束,在此期间,自然无法再进行借用了。
要解决这个问题,我们需要为 get_interface
方法的参数给予一个不同于 List<'a>
的生命周期 'b
,最终代码如下:
-struct Interface<'b, 'a: 'b> { - manager: &'b mut Manager<'a> -} - -impl<'b, 'a: 'b> Interface<'b, 'a> { - pub fn noop(self) { - println!("interface consumed"); - } -} - -struct Manager<'a> { - text: &'a str -} - -struct List<'a> { - manager: Manager<'a>, -} - -impl<'a> List<'a> { - pub fn get_interface<'b>(&'b mut self) -> Interface<'b, 'a> - where 'a: 'b { - Interface { - manager: &mut self.manager - } - } -} - -fn main() { - - let mut list = List { - manager: Manager { - text: "hello" - } - }; - - list.get_interface().noop(); - - println!("Interface should be dropped here and the borrow released"); - - // 下面的调用可以通过,因为Interface的生命周期不需要跟list一样长 - use_list(&list); -} - -fn use_list(list: &List) { - println!("{}", list.manager.text); -} -
至此,生命周期终于完结,两章超级长的内容,可以满足几乎所有对生命周期的学习目标。学完生命周期,意味着你正式入门了 Rust,只要再掌握几个常用概念,就可以上手写项目了。
- - -何为高阶?一个字:难,二个字:很难,七个字:其实也没那么难。至于到底难不难,还是交给各位看官评判吧 :D
-大家都知道,生命周期在 Rust 中是最难的部分之一,,因此相关内容被分成了两个章节:基础和进阶,其中基础部分已经在之前学习后,下面一起来看看真正的难
字怎么写。
Rust 的难点之一就在于它有不少容易混淆的概念,例如 &str
、str
与 String
, 再比如本文标题那两位。不过与字符串也有不同,这两位对于普通用户来说往往是无需进行区分的,但是当大家想要深入学习或使用 Rust 时,它们就会成为成功路上的拦路虎了。
与生命周期的其它章节不同,本文短小精悍,阅读过程可谓相当轻松愉快,话不多说,let's go。
-'static
在 Rust 中是相当常见的,例如字符串字面值就具有 'static
生命周期:
-fn main() { - let mark_twain: &str = "Samuel Clemens"; - print_author(mark_twain); -} -fn print_author(author: &'static str) { - println!("{}", author); -} -
除此之外,特征对象的生命周期也是 'static
,例如这里所提到的。
除了 &'static
的用法外,我们在另外一种场景中也可以见到 'static
的使用:
-use std::fmt::Display; -fn main() { - let mark_twain = "Samuel Clemens"; - print(&mark_twain); -} - -fn print<T: Display + 'static>(message: &T) { - println!("{}", message); -} -
在这里,很明显 'static
是作为生命周期约束来使用了。 那么问题来了, &'static
和 T: 'static
的用法到底有何区别?
&'static
&'static
对于生命周期有着非常强的要求:一个引用必须要活得跟剩下的程序一样久,才能被标注为 &'static
。
对于字符串字面量来说,它直接被打包到二进制文件中,永远不会被 drop
,因此它能跟程序活得一样久,自然它的生命周期是 'static
。
但是,&'static
生命周期针对的仅仅是引用,而不是持有该引用的变量,对于变量来说,还是要遵循相应的作用域规则 :
-use std::{slice::from_raw_parts, str::from_utf8_unchecked}; - -fn get_memory_location() -> (usize, usize) { - // “Hello World” 是字符串字面量,因此它的生命周期是 `'static`. - // 但持有它的变量 `string` 的生命周期就不一样了,它完全取决于变量作用域,对于该例子来说,也就是当前的函数范围 - let string = "Hello World!"; - let pointer = string.as_ptr() as usize; - let length = string.len(); - (pointer, length) - // `string` 在这里被 drop 释放 - // 虽然变量被释放,无法再被访问,但是数据依然还会继续存活 -} - -fn get_str_at_location(pointer: usize, length: usize) -> &'static str { - // 使用裸指针需要 `unsafe{}` 语句块 - unsafe { from_utf8_unchecked(from_raw_parts(pointer as *const u8, length)) } -} - -fn main() { - let (pointer, length) = get_memory_location(); - let message = get_str_at_location(pointer, length); - println!( - "The {} bytes at 0x{:X} stored: {}", - length, pointer, message - ); - // 如果大家想知道为何处理裸指针需要 `unsafe`,可以试着反注释以下代码 - // let message = get_str_at_location(1000, 10); -} -
上面代码有两点值得注意:
-&'static
的引用确实可以和程序活得一样久,因为我们通过 get_str_at_location
函数直接取到了对应的字符串&'static
引用的变量,它的生命周期受到作用域的限制,大家务必不要搞混了T: 'static
相比起来,这种形式的约束就有些复杂了。
-首先,在以下两种情况下,T: 'static
与 &'static
有相同的约束:T
必须活得和程序一样久。
-use std::fmt::Debug; - -fn print_it<T: Debug + 'static>( input: T) { - println!( "'static value passed in is: {:?}", input ); -} - -fn print_it1( input: impl Debug + 'static ) { - println!( "'static value passed in is: {:?}", input ); -} - - - -fn main() { - let i = 5; - - print_it(&i); - print_it1(&i); -} -
以上代码会报错,原因很简单: &i
的生命周期无法满足 'static
的约束,如果大家将 i
修改为常量,那自然一切 OK。
见证奇迹的时候,请不要眨眼,现在我们来稍微修改下 print_it
函数:
-use std::fmt::Debug; - -fn print_it<T: Debug + 'static>( input: &T) { - println!( "'static value passed in is: {:?}", input ); -} - -fn main() { - let i = 5; - - print_it(&i); -} -
这段代码竟然不报错了!原因在于我们约束的是 T
,但是使用的却是它的引用 &T
,换而言之,我们根本没有直接使用 T
,因此编译器就没有去检查 T
的生命周期约束!它只要确保 &T
的生命周期符合规则即可,在上面代码中,它自然是符合的。
再来看一个例子:
--use std::fmt::Display; - -fn main() { - let r1; - let r2; - { - static STATIC_EXAMPLE: i32 = 42; - r1 = &STATIC_EXAMPLE; - let x = "&'static str"; - r2 = x; - // r1 和 r2 持有的数据都是 'static 的,因此在花括号结束后,并不会被释放 - } - - println!("&'static i32: {}", r1); // -> 42 - println!("&'static str: {}", r2); // -> &'static str - - let r3: &str; - - { - let s1 = "String".to_string(); - - // s1 虽然没有 'static 生命周期,但是它依然可以满足 T: 'static 的约束 - // 充分说明这个约束是多么的弱。。 - static_bound(&s1); - - // s1 是 String 类型,没有 'static 的生命周期,因此下面代码会报错 - r3 = &s1; - - // s1 在这里被 drop - } - println!("{}", r3); -} - -fn static_bound<T: Display + 'static>(t: &T) { - println!("{}", t); -} -
大家有没有想过,到底是 &'static
这个引用还是该引用指向的数据活得跟程序一样久呢?
答案是引用指向的数据,而引用本身是要遵循其作用域范围的,我们来简单验证下:
--fn main() { - { - let static_string = "I'm in read-only memory"; - println!("static_string: {}", static_string); - - // 当 `static_string` 超出作用域时,该引用不能再被使用,但是数据依然会存在于 binary 所占用的内存中 - } - - println!("static_string reference remains alive: {}", static_string); -} -
以上代码不出所料会报错,原因在于虽然字符串字面量 "I'm in read-only memory" 的生命周期是 'static
,但是持有它的引用并不是,它的作用域在内部花括号 }
处就结束了。
--Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
-
总之, &'static
和 T: 'static
大体上相似,相比起来,后者的使用形式会更加复杂一些。
至此,相信大家对于 'static
和 T: 'static
也有了清晰的理解,那么我们应该如何使用它们呢?
作为经验之谈,可以这么来:
-&'static
来让代码工作,那很可能是设计上出问题了T: 'static
,很多时候它都能解决问题-- - -一个小知识,在 Rust 标准库中,有 48 处用到了 &'static ,112 处用到了
-T: 'static
,看来取悦编译器不仅仅是菜鸟需要的,高手也经常用到 :)
在编程世界可以说是谈“宏”色变,原因在于 C 语言中的宏是非常危险的东东,但并不是所有语言都像 C 这样,例如对于古老的语言 Lisp 来说,宏就是就是一个非常强大的好帮手。
-那话说回来,在 Rust 中宏到底是好是坏呢?本章将带你揭开它的神秘面纱。
-事实上,我们虽然没有见过宏,但是已经多次用过它,例如在全书的第一个例子中就用到了:println!("你好,世界")
,这里 println!
就是一个最常用的宏,可以看到它和函数最大的区别是:它在调用时多了一个 !
,除此之外还有 vec!
、assert_eq!
都是相当常用的,可以说宏在 Rust 中无处不在。
细心的读者可能会注意到 println!
后面跟着的是 ()
,而 vec!
后面跟着的是 []
,这是因为宏的参数可以使用 ()
、[]
以及 {}
:
-fn main() { - println!("aaaa"); - println!["aaaa"]; - println!{"aaaa"} -} -
虽然三种使用形式皆可,但是 Rust 内置的宏都有自己约定俗成的使用方式,例如 vec![...]
、assert_eq!(...)
等。
在 Rust 中宏分为两大类:声明式宏( declarative macros ) macro_rules!
和三种过程宏( procedural macros ):
#[derive]
,在之前多次见到的派生宏,可以为目标结构体或枚举派生指定的代码,例如 Debug
特征如果感觉难以理解,也不必担心,接下来我们将逐个看看它们的庐山真面目,在此之前,先来看下为何需要宏,特别是 Rust 的函数明明已经很强大了。
-宏和函数的区别并不少,而且对于宏擅长的领域,函数其实是有些无能为力的。
-从根本上来说,宏是通过一种代码来生成另一种代码,如果大家熟悉元编程,就会发现两者的共同点。
-在附录 D中讲到的 derive
属性,就会自动为结构体派生出相应特征所需的代码,例如 #[derive(Debug)]
,还有熟悉的 println!
和 vec!
,所有的这些宏都会展开成相应的代码,且很可能是长得多的代码。
总之,元编程可以帮我们减少所需编写的代码,也可以一定程度上减少维护的成本,虽然函数复用也有类似的作用,但是宏依然拥有自己独特的优势。
-Rust 的函数签名是固定的:定义了两个参数,就必须传入两个参数,多一个少一个都不行,对于从 JS/TS 过来的同学,这一点其实是有些恼人的。
-而宏就可以拥有可变数量的参数,例如可以调用一个参数的 println!("hello")
,也可以调用两个参数的 println!("hello {}", name)
。
由于宏会被展开成其它代码,且这个展开过程是发生在编译器对代码进行解释之前。因此,宏可以为指定的类型实现某个特征:先将宏展开成实现特征的代码后,再被编译。
-而函数就做不到这一点,因为它直到运行时才能被调用,而特征需要在编译期被实现。
-相对函数来说,由于宏是基于代码再展开成代码,因此实现相比函数来说会更加复杂,再加上宏的语法更为复杂,最终导致定义宏的代码相当地难读,也难以理解和维护。
-macro_rules!
在 Rust 中使用最广的就是声明式宏,它们也有一些其它的称呼,例如示例宏( macros by example )、macro_rules!
或干脆直接称呼为宏。
声明式宏允许我们写出类似 match
的代码。match
表达式是一个控制结构,其接收一个表达式,然后将表达式的结果与多个模式进行匹配,一旦匹配了某个模式,则该模式相关联的代码将被执行:
--#![allow(unused)] -fn main() { -match target { - 模式1 => 表达式1, - 模式2 => { - 语句1; - 语句2; - 表达式2 - }, - _ => 表达式3 -} -} -
而宏也是将一个值跟对应的模式进行匹配,且该模式会与特定的代码相关联。但是与 match
不同的是,宏里的值是一段 Rust 源代码(字面量),模式用于跟这段源代码的结构相比较,一旦匹配,传入宏的那段源代码将被模式关联的代码所替换,最终实现宏展开。值得注意的是,所有的这些都是在编译期发生,并没有运行期的性能损耗。
在动态数组 Vector 章节中,我们学习了使用 vec!
来便捷的初始化一个动态数组:
--#![allow(unused)] -fn main() { -let v: Vec<u32> = vec![1, 2, 3]; -} -
最重要的是,通过 vec!
创建的动态数组支持任何元素类型,也并没有限制数组的长度,如果使用函数,我们是无法做到这一点的。
好在我们有 macro_rules!
,来看看该如何使用它来实现 vec!
,以下是一个简化实现:
--#![allow(unused)] -fn main() { -#[macro_export] -macro_rules! vec { - ( $( $x:expr ),* ) => { - { - let mut temp_vec = Vec::new(); - $( - temp_vec.push($x); - )* - temp_vec - } - }; -} -} -
简化实现版本?这也太难了吧!!只能说,欢迎来到宏的世界,在这里你能见到优雅 Rust 的另一面:) 标准库中的 vec!
还包含了预分配内存空间的代码,如果引入进来,那大家将更难以接受。
#[macro_export]
注释将宏进行了导出,这样其它的包就可以将该宏引入到当前作用域中,然后才能使用。可能有同学会提问:我们在使用标准库 vec!
时也没有引入宏啊,那是因为 Rust 已经通过 std::prelude
的方式为我们自动引入了。
紧接着,就使用 macro_rules!
进行了宏定义,需要注意的是宏的名称是 vec
,而不是 vec!
,后者的感叹号只在调用时才需要。
vec
的定义结构跟 match
表达式很像,但这里我们只有一个分支,其中包含一个模式 ( $( $x:expr ),* )
,跟模式相关联的代码就在 =>
之后。一旦模式成功匹配,那这段相关联的代码就会替换传入的源代码。
由于 vec
宏只有一个模式,因此它只能匹配一种源代码,其它类型的都将导致报错,而更复杂的宏往往会拥有更多的分支。
虽然宏和 match
都称之为模式,但是前者跟后者的模式规则是不同的。如果大家想要更深入的了解宏的模式,可以查看这里。
而现在,我们先来简单讲解下 ( $( $x:expr ),* )
的含义。
首先,我们使用圆括号 ()
将整个宏模式包裹其中。紧随其后的是 $()
,跟括号中模式相匹配的值(传入的 Rust 源代码)会被捕获,然后用于代码替换。在这里,模式 $x:expr
会匹配任何 Rust 表达式并给予该模式一个名称:$x
。
$()
之后的逗号说明在 $()
所匹配的代码的后面会有一个可选的逗号分隔符,紧随逗号之后的 *
说明 *
之前的模式会被匹配零次或任意多次(类似正则表达式)。
当我们使用 vec![1, 2, 3]
来调用该宏时,$x
模式将被匹配三次,分别是 1
、2
、3
。为了帮助大家巩固,我们再来一起过一下:
$()
中包含的是模式 $x:expr
,该模式中的 expr
表示会匹配任何 Rust 表达式,并给予该模式一个名称 $x
$x
模式可以跟整数 1
进行匹配,也可以跟字符串 "hello" 进行匹配: vec!["hello", "world"]
$()
之后的逗号,意味着1
和 2
之间可以使用逗号进行分割,也意味着 3
既可以没有逗号,也可以有逗号:vec![1, 2, 3,]
*
说明之前的模式可以出现零次也可以任意次,这里出现了三次接下来,我们再来看看与模式相关联、在 =>
之后的代码:
--#![allow(unused)] -fn main() { -{ - { - let mut temp_vec = Vec::new(); - $( - temp_vec.push($x); - )* - temp_vec - } -}; -} -
这里就比较好理解了,$()
中的 temp_vec.push()
将根据模式匹配的次数生成对应的代码,当调用 vec![1, 2, 3]
时,下面这段生成的代码将替代传入的源代码,也就是替代 vec![1, 2, 3]
:
--#![allow(unused)] -fn main() { -{ - let mut temp_vec = Vec::new(); - temp_vec.push(1); - temp_vec.push(2); - temp_vec.push(3); - temp_vec -} -} -
如果是 let v = vec![1, 2, 3]
,那生成的代码最后返回的值 temp_vec
将被赋予给变量 v
,等同于 :
--#![allow(unused)] -fn main() { -let v = { - let mut temp_vec = Vec::new(); - temp_vec.push(1); - temp_vec.push(2); - temp_vec.push(3); - temp_vec -} -} -
至此,我们定义了一个宏,它可以接受任意类型和数量的参数,并且理解了其语法的含义。
-macro_rules
对于 macro_rules
来说,它是存在一些问题的,因此,Rust 计划在未来使用新的声明式宏来替换它:工作方式类似,但是解决了目前存在的一些问题,在那之后,macro_rules
将变为 deprecated
状态。
由于绝大多数 Rust 开发者都是宏的用户而不是编写者,因此在这里我们不会对 macro_rules
进行更深入的学习,如果大家感兴趣,可以看看这本书 “The Little Book of Rust Macros”。
第二种常用的宏就是过程宏 ( procedural macros ),从形式上来看,过程宏跟函数较为相像,但过程宏是使用源代码作为输入参数,基于代码进行一系列操作后,再输出一段全新的代码。注意,过程宏中的 derive 宏输出的代码并不会替换之前的代码,这一点与声明宏有很大的不同!
-至于前文提到的过程宏的三种类型(自定义 derive
、属性宏、函数宏),它们的工作方式都是类似的。
当创建过程宏时,它的定义必须要放入一个独立的包中,且包的类型也是特殊的,这么做的原因相当复杂,大家只要知道这种限制在未来可能会有所改变即可。
---事实上,根据这个说法,过程宏放入独立包的原因在于它必须先被编译后才能使用,如果过程宏和使用它的代码在一个包,就必须先单独对过程宏的代码进行编译,然后再对我们的代码进行编译,但悲剧的是 Rust 的编译单元是包,因此你无法做到这一点。
-
假设我们要创建一个 derive
类型的过程宏:
--#![allow(unused)] -fn main() { -use proc_macro; - -#[proc_macro_derive(HelloMacro)] -pub fn some_name(input: TokenStream) -> TokenStream { -} -} -
用于定义过程宏的函数 some_name
使用 TokenStream
作为输入参数,并且返回的也是同一个类型。TokenStream
是在 proc_macro
包中定义的,顾名思义,它代表了一个 Token
序列。
在理解了过程宏的基本定义后,我们再来看看该如何创建三种类型的过程宏,首先,从大家最熟悉的 derive
开始。
derive
过程宏假设我们有一个特征 HelloMacro
,现在有两种方式让用户使用它:
#[derive(HelloMacro)]
以上两种方式并没有孰优孰劣,主要在于不同的类型是否可以使用同样的默认特征实现,如果可以,那过程宏的方式可以帮我们减少很多代码实现:
--use hello_macro::HelloMacro; -use hello_macro_derive::HelloMacro; - -#[derive(HelloMacro)] -struct Sunfei; - -#[derive(HelloMacro)] -struct Sunface; - -fn main() { - Sunfei::hello_macro(); - Sunface::hello_macro(); -} -
简单吗?简单!不过为了实现这段代码展示的功能,我们还需要创建相应的过程宏才行。 首先,创建一个新的工程用于演示:
-$ cargo new hello_macro
-$ cd hello_macro/
-$ touch src/lib.rs
-
-此时,src
目录下包含两个文件 lib.rs
和 main.rs
,前者是 lib
包根,后者是二进制包根,如果大家对包根不熟悉,可以看看这里。
接下来,先在 src/lib.rs
中定义过程宏所需的 HelloMacro
特征和其关联函数:
--#![allow(unused)] -fn main() { -pub trait HelloMacro { - fn hello_macro(); -} -} -
然后在 src/main.rs
中编写主体代码,首先映入大家脑海的可能会是如下实现:
-use hello_macro::HelloMacro; - -struct Sunfei; - -impl HelloMacro for Sunfei { - fn hello_macro() { - println!("Hello, Macro! My name is Sunfei!"); - } -} - -struct Sunface; - -impl HelloMacro for Sunface { - fn hello_macro() { - println!("Hello, Macro! My name is Sunface!"); - } -} - -fn main() { - Sunfei::hello_macro(); -} -
但是这种方式有个问题,如果想要实现不同的招呼内容,就需要为每一个类型都实现一次相应的特征,Rust 不支持反射,因此我们无法在运行时获得类型名。
-使用宏,就不存在这个问题:
--use hello_macro::HelloMacro; -use hello_macro_derive::HelloMacro; - -#[derive(HelloMacro)] -struct Sunfei; - -#[derive(HelloMacro)] -struct Sunface; - -fn main() { - Sunfei::hello_macro(); - Sunface::hello_macro(); -} -
简单明了的代码总是令人愉快,为了让代码运行起来,还需要定义下过程宏。就如前文提到的,目前只能在单独的包中定义过程宏,尽管未来这种限制会被取消,但是现在我们还得遵循这个规则。
-宏所在的包名自然也有要求,必须以 derive
为后缀,对于 hello_macro
宏而言,包名就应该是 hello_macro_derive
。在之前创建的 hello_macro
项目根目录下,运行如下命令,创建一个单独的 lib
包:
--#![allow(unused)] -fn main() { -cargo new hello_macro_derive --lib -} -
至此, hello_macro
项目的目录结构如下:
hello_macro
-├── Cargo.toml
-├── src
-│ ├── main.rs
-│ └── lib.rs
-└── hello_macro_derive
- ├── Cargo.toml
- ├── src
- └── lib.rs
-
-由于过程宏所在的包跟我们的项目紧密相连,因此将它放在项目之中。现在,问题又来了,该如何在项目的 src/main.rs
中引用 hello_macro_derive
包的内容?
方法有两种,第一种是将 hello_macro_derive
发布到 crates.io
或 GitHub
中,就像我们引用的其它依赖一样;另一种就是使用相对路径引入的本地化方式,修改 hello_macro/Cargo.toml
文件添加以下内容:
[dependencies]
-hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
-# 也可以使用下面的相对路径
-# hello_macro_derive = { path = "./hello_macro_derive" }
-
-此时,hello_macro
项目就可以成功的引用到 hello_macro_derive
本地包了,对于项目依赖引入的详细介绍,可以参见 Cargo 章节。
另外,学习过程更好的办法是通过展开宏来阅读和调试自己写的宏,这里需要用到一个 cargo-expand 的工具,可以通过下面的命令安装
-cargo install cargo-expand
-
-接下来,就到了重头戏环节,一起来看看该如何定义过程宏。
-首先,在 hello_macro_derive/Cargo.toml
文件中添加以下内容:
[lib]
-proc-macro = true
-
-[dependencies]
-syn = "1.0"
-quote = "1.0"
-
-其中 syn
和 quote
依赖包都是定义过程宏所必需的,同时,还需要在 [lib]
中将过程宏的开关开启 : proc-macro = true
。
其次,在 hello_macro_derive/src/lib.rs
中添加如下代码:
--#![allow(unused)] -fn main() { -extern crate proc_macro; - -use proc_macro::TokenStream; -use quote::quote; -use syn; -use syn::DeriveInput; - -#[proc_macro_derive(HelloMacro)] -pub fn hello_macro_derive(input: TokenStream) -> TokenStream { - // 基于 input 构建 AST 语法树 - let ast:DeriveInput = syn::parse(input).unwrap(); - - // 构建特征实现代码 - impl_hello_macro(&ast) -} -} -
这个函数的签名我们在之前已经介绍过,总之,这种形式的过程宏定义是相当通用的,下面来分析下这段代码。
-首先有一点,对于绝大多数过程宏而言,这段代码往往只在 impl_hello_macro(&ast)
中的实现有所区别,对于其它部分基本都是一致的,例如包的引入、宏函数的签名、语法树构建等。
proc_macro
包是 Rust 自带的,因此无需在 Cargo.toml
中引入依赖,它包含了相关的编译器 API
,可以用于读取和操作 Rust 源代码。
由于我们为 hello_macro_derive
函数标记了 #[proc_macro_derive(HelloMacro)]
,当用户使用 #[derive(HelloMacro)]
标记了他的类型后,hello_macro_derive
函数就将被调用。这里的秘诀就是特征名 HelloMacro
,它就像一座桥梁,将用户的类型和过程宏联系在一起。
syn
将字符串形式的 Rust 代码解析为一个 AST 树的数据结构,该数据结构可以在随后的 impl_hello_macro
函数中进行操作。最后,操作的结果又会被 quote
包转换回 Rust 代码。这些包非常关键,可以帮我们节省大量的精力,否则你需要自己去编写支持代码解析和还原的解析器,这可不是一件简单的任务!
derive过程宏只能用在struct/enum/union上,多数用在结构体上,我们先来看一下一个结构体由哪些部分组成:
---#![allow(unused)] -fn main() { -// vis,可视范围 ident,标识符 generic,范型 fields: 结构体的字段 -pub struct User <'a, T> { - -// vis ident type - pub name: &'a T, - -} -} -
其中type还可以细分,具体请阅读syn文档或源码
-syn::parse
调用会返回一个 DeriveInput
结构体来代表解析后的 Rust 代码:
--#![allow(unused)] -fn main() { -DeriveInput { - // --snip-- - vis: Visibility, - generics: Generics - ident: Ident { - ident: "Sunfei", - span: #0 bytes(95..103) - }, - // Data是一个枚举,分别是DataStruct,DataEnum,DataUnion,这里以 DataStruct 为例 - data: Data( - DataStruct { - struct_token: Struct, - fields: Fields, - semi_token: Some( - Semi - ) - } - ) -} -} -
以上就是源代码 struct Sunfei;
解析后的结果,里面有几点值得注意:
fields: Fields
是一个枚举类型,FieldsNamed,FieldsUnnamed,FieldsUnnamed, 分别表示显示命名结构(如例子所示),匿名字段的结构(例如 struct A(u8);),和无字段定义的结构(例如 struct A;)ident: "Sunfei"
说明类型名称为 Sunfei
, ident
是标识符 identifier
的简写如果想要了解更多的信息,可以查看 syn
文档。
大家可能会注意到在 hello_macro_derive
函数中有 unwrap
的调用,也许会以为这是为了演示目的,没有做错误处理,实际上并不是的。由于该函数只能返回 TokenStream
而不是 Result
,那么在报错时直接 panic
来抛出错误就成了相当好的选择。当然,这里实际上还是做了简化,在生产项目中,你应该通过 panic!
或 expect
抛出更具体的报错信息。
至此,这个函数大家应该已经基本理解了,下面来看看如何构建特征实现的代码,也是过程宏的核心目标:
---#![allow(unused)] -fn main() { -fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream { - let name = &ast.ident; - let gen = quote! { - impl HelloMacro for #name { - fn hello_macro() { - println!("Hello, Macro! My name is {}!", stringify!(#name)); - } - } - }; - gen.into() -} -} -
首先,将结构体的名称赋予给 name
,也就是 name
中会包含一个字段,它的值是字符串 "Sunfei"。
其次,使用 quote!
可以定义我们想要返回的 Rust 代码。由于编译器需要的内容和 quote!
直接返回的不一样,因此还需要使用 .into
方法其转换为 TokenStream
。
大家注意到 #name
的使用了吗?这也是 quote!
提供的功能之一,如果想要深入了解 quote
,可以看看官方文档。
特征的 hell_macro()
函数只有一个功能,就是使用 println!
打印一行欢迎语句。
其中 stringify!
是 Rust 提供的内置宏,可以将一个表达式(例如 1 + 2
)在编译期转换成一个字符串字面值("1 + 2"
),该字面量会直接打包进编译出的二进制文件中,具有 'static
生命周期。而 format!
宏会对表达式进行求值,最终结果是一个 String
类型。在这里使用 stringify!
有两个好处:
#name
可能是一个表达式,我们需要它的字面值形式String
带来的内存分配在运行之前,可以显示用 expand 展开宏,观察是否有错误或是否符合预期:
-$ cargo expand
-
--struct Sunfei; -impl HelloMacro for Sunfei { - fn hello_macro() { - { - ::std::io::_print( - ::core::fmt::Arguments::new_v1( - &["Hello, Macro! My name is ", "!\n"], - &[::core::fmt::ArgumentV1::new_display(&"Sunfei")], - ), - ); - }; - } -} -struct Sunface; -impl HelloMacro for Sunface { - fn hello_macro() { - { - ::std::io::_print( - ::core::fmt::Arguments::new_v1( - &["Hello, Macro! My name is ", "!\n"], - &[::core::fmt::ArgumentV1::new_display(&"Sunface")], - ), - ); - }; - } -} -fn main() { - Sunfei::hello_macro(); - Sunface::hello_macro(); -} -
从展开的代码也能看出derive宏的特性,struct Sunfei; 和 struct Sunface; 都被保留了,也就是说最后 impl_hello_macro() 返回的token被加到结构体后面,这和类属性宏可以修改输入 -的token是不一样的,input的token并不能被修改
-至此,过程宏的定义、特征定义、主体代码都已经完成,运行下试试:
-$ cargo run
-
- Running `target/debug/hello_macro`
-Hello, Macro! My name is Sunfei!
-Hello, Macro! My name is Sunface!
-
-Bingo,虽然过程有些复杂,但是结果还是很喜人,我们终于完成了自己的第一个过程宏!
-下面来实现一个更实用的例子,实现官方的#[derive(Default)]宏,废话不说直接开干:
---#![allow(unused)] -fn main() { -extern crate proc_macro; -use proc_macro::TokenStream; -use quote::quote; -use syn::{self, Data}; -use syn::DeriveInput; - -#[proc_macro_derive(MyDefault)] -pub fn my_default(input: TokenStream) -> TokenStream { - let ast: DeriveInput = syn::parse(input).unwrap(); - let id = ast.ident; - - let Data::Struct(s) = ast.data else{ - panic!("MyDefault derive macro must use in struct"); - }; - - // 声明一个新的ast,用于动态构建字段赋值的token - let mut field_ast = quote!(); - - // 这里就是要动态添加token的地方了,需要动态完成Self的字段赋值 - for (idx,f) in s.fields.iter().enumerate() { - let (field_id, field_ty) = (&f.ident, &f.ty); - - - if field_id.is_none(){ - //没有ident表示是匿名字段,对于匿名字段,都需要添加 `#field_idx: #field_type::default(),` 这样的代码 - let field_idx = syn::Index::from(idx); - field_ast.extend(quote! { - field_idx: # field_ty::default(), - }); - }else{ - //对于命名字段,都需要添加 `#field_name: #field_type::default(),` 这样的代码 - field_ast.extend(quote! { - field_id: # field_ty::default(), - }); - } - } - - quote! { - impl Default for # id { - fn default() -> Self { - Self { - field_ast - } - } - } - }.into() -} -} -
然后来写使用代码:
--#[derive(MyDefault)] -struct SomeData (u32,String); - -#[derive(MyDefault)] -struct User { - name: String, - data: SomeData, -} - -fn main() { - -} -
然后我们先展开代码看一看
--struct SomeData(u32, String); -impl Default for SomeData { - fn default() -> Self { - Self { - 0: u32::default(), - 1: String::default(), - } - } -} -struct User { - name: String, - data: SomeData, -} -impl Default for User { - fn default() -> Self { - Self { - name: String::default(), - data: SomeData::default(), - } - } -} -fn main() {} -
展开的代码符合预期,然后我们修改一下使用代码并测试结果
--#[derive(MyDefault, Debug)] -struct SomeData (u32,String); - -#[derive(MyDefault, Debug)] -struct User { - name: String, - data: SomeData, -} - -fn main() { - println!("{:?}", User::default()); -} -
执行
-$ cargo run
-
- Running `target/debug/aaa`
-User { name: "", data: SomeData(0, "") }
-
-接下来,再来看看过程宏的另外两种类型跟 derive
类型有何区别。
类属性过程宏跟 derive
宏类似,但是前者允许我们定义自己的属性。除此之外,derive
只能用于结构体和枚举,而类属性宏可以用于其它类型项,例如函数。
假设我们在开发一个 web
框架,当用户通过 HTTP GET
请求访问 /
根路径时,使用 index
函数为其提供服务:
--#![allow(unused)] -fn main() { -#[route(GET, "/")] -fn index() { -} -
如上所示,代码功能非常清晰、简洁,这里的 #[route]
属性就是一个过程宏,它的定义函数大概如下:
--#![allow(unused)] -fn main() { -#[proc_macro_attribute] -pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { -} -
与 derive
宏不同,类属性宏的定义函数有两个参数:
Get, "/"
部分fn index() {...}
,注意,函数体也被包含其中除此之外,类属性宏跟 derive
宏的工作方式并无区别:创建一个包,类型是 proc-macro
,接着实现一个函数用于生成想要的代码。
类函数宏可以让我们定义像函数那样调用的宏,从这个角度来看,它跟声明宏 macro_rules
较为类似。
区别在于,macro_rules
的定义形式与 match
匹配非常相像,而类函数宏的定义形式则类似于之前讲过的两种过程宏:
--#![allow(unused)] -fn main() { -#[proc_macro] -pub fn sql(input: TokenStream) -> TokenStream { -} -
而使用形式则类似于函数调用:
---#![allow(unused)] -fn main() { -let sql = sql!(SELECT * FROM posts WHERE id=1); -} -
大家可能会好奇,为何我们不使用声明宏 macro_rules
来定义呢?原因是这里需要对 SQL
语句进行解析并检查其正确性,这个复杂的过程是 macro_rules
难以对付的,而过程宏相比起来就会灵活的多。
macro_rules!
Rust 中的宏主要分为两大类:声明宏和过程宏。
-声明宏目前使用 macro_rules
进行创建,它的形式类似于 match
匹配,对于用户而言,可读性和维护性都较差。由于其存在的问题和限制,在未来, macro_rules
会被 deprecated
,Rust 会使用一个新的声明宏来替代它。
而过程宏的定义更像是我们平时写函数的方式,因此它更加灵活,它分为三种类型:derive
宏、类属性宏、类函数宏,具体在文中都有介绍。
虽然 Rust 中的宏很强大,但是它并不应该成为我们的常规武器,原因是它会影响 Rust 代码的可读性和可维护性,我相信没有几个人愿意去维护别人写的宏 :)
-因此,大家应该熟悉宏的使用场景,但是不要滥用,当你真的需要时,再回来查看本章了解实现细节,这才是最完美的使用方式。
- - -Box<T>
堆对象分配关于作者帅不帅,估计争议还挺多的,但是如果说 Box<T>
是不是 Rust 中最常见的智能指针,那估计没有任何争议。因为 Box<T>
允许你将一个值分配到堆上,然后在栈上保留一个智能指针指向堆上的数据。
之前我们在所有权章节简单讲过堆栈的概念,这里再补充一些。
-高级语言 Python/Java 等往往会弱化堆栈的概念,但是要用好 C/C++/Rust,就必须对堆栈有深入的了解,原因是两者的内存管理方式不同:前者有 GC 垃圾回收机制,因此无需你去关心内存的细节。
-栈内存从高位地址向下增长,且栈内存是连续分配的,一般来说操作系统对栈内存的大小都有限制,因此 C 语言中无法创建任意长度的数组。在 Rust 中,main
线程的栈大小是 8MB
,普通线程是 2MB
,在函数调用时会在其中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的对象自动进入 Drop
流程,最后栈顶指针自动移动到上一个调用栈顶,无需程序员手动干预,因而栈内存申请和释放是非常高效的。
与栈相反,堆上内存则是从低位地址向上增长,堆内存通常只受物理内存限制,而且通常是不连续的,因此从性能的角度看,栈往往比堆更高。
-相比其它语言,Rust 堆上对象还有一个特殊之处,它们都拥有一个所有者,因此受所有权规则的限制:当赋值时,发生的是所有权的转移(只需浅拷贝栈上的引用或智能指针即可),例如以下代码:
--fn main() { - let b = foo("world"); - println!("{}", b); -} - -fn foo(x: &str) -> String { - let a = "Hello, ".to_string() + x; - a -} -
在 foo
函数中,a
是 String
类型,它其实是一个智能指针结构体,该智能指针存储在函数栈中,指向堆上的字符串数据。当被从 foo
函数转移给 main
中的 b
变量时,栈上的智能指针被复制一份赋予给 b
,而底层数据无需发生改变,这样就完成了所有权从 foo
函数内部到 b
的转移。
很多人可能会觉得栈的性能肯定比堆高,其实未必。 由于我们在后面的性能专题会专门讲解堆栈的性能问题,因此这里就大概给出结论:
-总之,栈的分配速度肯定比堆上快,但是读取速度往往取决于你的数据能不能放入寄存器或 CPU 高速缓存。 因此不要仅仅因为堆上性能不如栈这个印象,就总是优先选择栈,导致代码更复杂的实现。
-由于 Box
是简单的封装,除了将值存储在堆上外,并没有其它性能上的损耗。而性能和功能往往是鱼和熊掌,因此 Box
相比其它智能指针,功能较为单一,可以在以下场景中使用它:
以上场景,我们在本章将一一讲解,后面车速较快,请系好安全带。
-Box<T>
将数据存储在堆上如果一个变量拥有一个数值 let a = 3
,那变量 a
必然是存储在栈上的,那如果我们想要 a
的值存储在堆上就需要使用 Box<T>
:
-fn main() { - let a = Box::new(3); - println!("a = {}", a); // a = 3 - - // 下面一行代码将报错 - // let b = a + 1; // cannot add `{integer}` to `Box<{integer}>` -} -
这样就可以创建一个智能指针指向了存储在堆上的 3
,并且 a
持有了该指针。在本章的引言中,我们提到了智能指针往往都实现了 Deref
和 Drop
特征,因此:
println!
可以正常打印出 a
的值,是因为它隐式地调用了 Deref
对智能指针 a
进行了解引用 let b = a + 1
报错,是因为在表达式中,我们无法自动隐式地执行 Deref
解引用操作,你需要使用 *
操作符 let b = *a + 1
,来显式的进行解引用a
持有的智能指针将在作用域结束(main
函数结束)时,被释放掉,这是因为 Box<T>
实现了 Drop
特征以上的例子在实际代码中其实很少会存在,因为将一个简单的值分配到堆上并没有太大的意义。将其分配在栈上,由于寄存器、CPU 缓存的原因,它的性能将更好,而且代码可读性也更好。
-当栈上数据转移所有权时,实际上是把数据拷贝了一份,最终新旧变量各自拥有不同的数据,因此所有权并未转移。
-而堆上则不然,底层数据并不会被拷贝,转移所有权仅仅是复制一份栈中的指针,再将新的指针赋予新的变量,然后让拥有旧指针的变量失效,最终完成了所有权的转移:
--fn main() { - // 在栈上创建一个长度为1000的数组 - let arr = [0;1000]; - // 将arr所有权转移arr1,由于 `arr` 分配在栈上,因此这里实际上是直接重新深拷贝了一份数据 - let arr1 = arr; - - // arr 和 arr1 都拥有各自的栈上数组,因此不会报错 - println!("{:?}", arr.len()); - println!("{:?}", arr1.len()); - - // 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它 - let arr = Box::new([0;1000]); - // 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝 - // 所有权顺利转移给 arr1,arr 不再拥有所有权 - let arr1 = arr; - println!("{:?}", arr1.len()); - // 由于 arr 不再拥有底层数组的所有权,因此下面代码将报错 - // println!("{:?}", arr.len()); -} -
从以上代码,可以清晰看出大块的数据为何应该放入堆中,此时 Box
就成为了我们最好的帮手。
Rust 需要在编译时知道类型占用多少空间,如果一种类型在编译时无法知道具体的大小,那么被称为动态大小类型 DST。
-其中一种无法在编译时知道大小的类型是递归类型:在类型定义中又使用到了自身,或者说该类型的值的一部分可以是相同类型的其它值,这种值的嵌套理论上可以无限进行下去,所以 Rust 不知道递归类型需要多少空间:
---#![allow(unused)] -fn main() { -enum List { - Cons(i32, List), - Nil, -} -} -
以上就是函数式语言中常见的 Cons List
,它的每个节点包含一个 i32
值,还包含了一个新的 List
,因此这种嵌套可以无限进行下去,Rust 认为该类型是一个 DST 类型,并给予报错:
error[E0072]: recursive type `List` has infinite size //递归类型 `List` 拥有无限长的大小
- --> src/main.rs:3:1
- |
-3 | enum List {
- | ^^^^^^^^^ recursive type has infinite size
-4 | Cons(i32, List),
- | ---- recursive without indirection
-
-此时若想解决这个问题,就可以使用我们的 Box<T>
:
--#![allow(unused)] -fn main() { -enum List { - Cons(i32, Box<List>), - Nil, -} -} -
只需要将 List
存储到堆上,然后使用一个智能指针指向它,即可完成从 DST 到 Sized 类型(固定大小类型)的华丽转变。
在 Rust 中,想实现不同类型组成的数组只有两个办法:枚举和特征对象,前者限制较多,因此后者往往是最常用的解决办法。
--trait Draw { - fn draw(&self); -} - -struct Button { - id: u32, -} -impl Draw for Button { - fn draw(&self) { - println!("这是屏幕上第{}号按钮", self.id) - } -} - -struct Select { - id: u32, -} - -impl Draw for Select { - fn draw(&self) { - println!("这个选择框贼难用{}", self.id) - } -} - -fn main() { - let elems: Vec<Box<dyn Draw>> = vec![Box::new(Button { id: 1 }), Box::new(Select { id: 2 })]; - - for e in elems { - e.draw() - } -} -
以上代码将不同类型的 Button
和 Select
包装成 Draw
特征的特征对象,放入一个数组中,Box<dyn Draw>
就是特征对象。
其实,特征也是 DST 类型,而特征对象在做的就是将 DST 类型转换为固定大小类型。
-先来看看 Vec<i32>
的内存布局:
--#![allow(unused)] -fn main() { -(stack) (heap) -┌──────┐ ┌───┐ -│ vec1 │──→│ 1 │ -└──────┘ ├───┤ - │ 2 │ - ├───┤ - │ 3 │ - ├───┤ - │ 4 │ - └───┘ -} -
之前提到过 Vec
和 String
都是智能指针,从上图可以看出,该智能指针存储在栈中,然后指向堆上的数组数据。
那如果数组中每个元素都是一个 Box
对象呢?来看看 Vec<Box<i32>>
的内存布局:
--#![allow(unused)] -fn main() { - (heap) -(stack) (heap) ┌───┐ -┌──────┐ ┌───┐ ┌─→│ 1 │ -│ vec2 │──→│B1 │─┘ └───┘ -└──────┘ ├───┤ ┌───┐ - │B2 │───→│ 2 │ - ├───┤ └───┘ - │B3 │─┐ ┌───┐ - ├───┤ └─→│ 3 │ - │B4 │─┐ └───┘ - └───┘ │ ┌───┐ - └─→│ 4 │ - └───┘ -} -
上面的 B1
代表被 Box
分配到堆上的值 1
。
可以看出智能指针 vec2
依然是存储在栈上,然后指针指向一个堆上的数组,该数组中每个元素都是一个 Box
智能指针,最终 Box
智能指针又指向了存储在堆上的实际值。
因此当我们从数组中取出某个元素时,取到的是对应的智能指针 Box
,需要对该智能指针进行解引用,才能取出最终的值:
-fn main() { - let arr = vec![Box::new(1), Box::new(2)]; - let (first, second) = (&arr[0], &arr[1]); - let sum = **first + **second; -} -
以上代码有几个值得注意的点:
-&
借用数组中的元素,否则会报所有权错误**
做两次解引用,第一次将 &Box<i32>
类型转成 Box<i32>
,第二次将 Box<i32>
转成 i32
Box
中还提供了一个非常有用的关联函数:Box::leak
,它可以消费掉 Box
并且强制目标值从内存中泄漏,读者可能会觉得,这有啥用啊?
其实还真有点用,例如,你可以把一个 String
类型,变成一个 'static
生命周期的 &str
类型:
-fn main() { - let s = gen_static_str(); - println!("{}", s); -} - -fn gen_static_str() -> &'static str{ - let mut s = String::new(); - s.push_str("hello, world"); - - Box::leak(s.into_boxed_str()) -} -
在之前的代码中,如果 String
创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String
,而通过 Box::leak
我们不仅返回了一个 &str
字符串切片,它还是 'static
生命周期的!
要知道真正具有 'static
生命周期的往往都是编译期就创建的值,例如 let v = "hello, world"
,这里 v
是直接打包到二进制可执行文件中的,因此该字符串具有 'static
生命周期,再比如 const
常量。
又有读者要问了,我还可以手动为变量标注 'static
啊。其实你标注的 'static
只是用来忽悠编译器的,但是超出作用域,一样被释放回收。而使用 Box::leak
就可以将一个运行期的值转为 'static
。
光看上面的描述,大家可能还是云里雾里、一头雾水。
-那么我说一个简单的场景,你需要一个在运行期初始化的值,但是可以全局有效,也就是和整个程序活得一样久,那么就可以使用 Box::leak
,例如有一个存储配置的结构体实例,它是在运行期动态插入内容,那么就可以将其转为全局有效,虽然 Rc/Arc
也可以实现此功能,但是 Box::leak
是性能最高的。
Box
背后是调用 jemalloc
来做内存管理,所以堆上的空间无需我们的手动管理。与此类似,带 GC 的语言中的对象也是借助于 Box
概念来实现的,一切皆对象 = 一切皆 Box, 只不过我们无需自己去 Box
罢了。
其实很多时候,编译器的鞭笞可以助我们更快的成长,例如所有权规则里的借用、move、生命周期就是编译器在教我们做人,哦不是,是教我们深刻理解堆栈、内存布局、作用域等等你在其它 GC 语言无需去关注的东西。刚开始是很痛苦,但是一旦熟悉了这套规则,写代码的效率和代码本身的质量将飞速上升,直到你可以用 Java 开发的效率写出 Java 代码不可企及的性能和安全性,最终 Rust 语言所谓的开发效率低、心智负担高,对你来说终究不是个事。
-因此,不要怪 Rust,它只是在帮我们成为那个更好的程序员,而这些苦难终究成为我们走向优秀的垫脚石。
- - -Rust 的编译器之严格,可以说是举世无双。特别是在所有权方面,Rust 通过严格的规则来保证所有权和借用的正确性,最终为程序的安全保驾护航。
-但是严格是一把双刃剑,带来安全提升的同时,损失了灵活性,有时甚至会让用户痛苦不堪、怨声载道。因此 Rust 提供了 Cell
和 RefCell
用于内部可变性,简而言之,可以在拥有不可变引用的同时修改目标数据,对于正常的代码实现来说,这个是不可能做到的(要么一个可变借用,要么多个不可变借用)。
--内部可变性的实现是因为 Rust 使用了
-unsafe
来做到这一点,但是对于使用者来说,这些都是透明的,因为这些不安全代码都被封装到了安全的 API 中
Cell
和 RefCell
在功能上没有区别,区别在于 Cell<T>
适用于 T
实现 Copy
的情况:
-use std::cell::Cell; -fn main() { - let c = Cell::new("asdf"); - let one = c.get(); - c.set("qwer"); - let two = c.get(); - println!("{},{}", one, two); -} -
以上代码展示了 Cell
的基本用法,有几点值得注意:
&str
类型,它实现了 Copy
特征c.get
用来取值,c.set
用来设置新值取到值保存在 one
变量后,还能同时进行修改,这个违背了 Rust 的借用规则,但是由于 Cell
的存在,我们很优雅地做到了这一点,但是如果你尝试在 Cell
中存放String
:
--#![allow(unused)] -fn main() { - let c = Cell::new(String::from("asdf")); -} -
编译器会立刻报错,因为 String
没有实现 Copy
特征:
| pub struct String {
-| ----------------- doesn't satisfy `String: Copy`
-|
-= note: the following trait bounds were not satisfied:
- `String: Copy`
-
-由于 Cell
类型针对的是实现了 Copy
特征的值类型,因此在实际开发中,Cell
使用的并不多,因为我们要解决的往往是可变、不可变引用共存导致的问题,此时就需要借助于 RefCell
来达成目的。
我们可以将所有权、借用规则与这些智能指针做一个对比:
-Rust 规则 | 智能指针带来的额外规则 |
---|---|
一个数据只有一个所有者 | Rc/Arc 让一个数据可以拥有多个所有者 |
要么多个不可变借用,要么一个可变借用 | RefCell 实现编译期可变、不可变引用共存 |
违背规则导致编译错误 | 违背规则导致运行时panic |
可以看出,Rc/Arc
和 RefCell
合在一起,解决了 Rust 中严苛的所有权和借用规则带来的某些场景下难使用的问题。但是它们并不是银弹,例如 RefCell
实际上并没有解决可变引用和引用可以共存的问题,只是将报错从编译期推迟到运行时,从编译器错误变成了 panic
异常:
-use std::cell::RefCell; - -fn main() { - let s = RefCell::new(String::from("hello, world")); - let s1 = s.borrow(); - let s2 = s.borrow_mut(); - - println!("{},{}", s1, s2); -} -
上面代码在编译期不会报任何错误,你可以顺利运行程序:
-thread 'main' panicked at 'already borrowed: BorrowMutError', src/main.rs:6:16
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-但是依然会因为违背了借用规则导致了运行期 panic
,这非常像中国的天网,它也许会被罪犯蒙蔽一时,但是并不会被蒙蔽一世,任何导致安全风险的存在都将不能被容忍,法网恢恢,疏而不漏。
相信肯定有读者有疑问了,这么做有任何意义吗?还不如在编译期报错,至少能提前发现问题,而且性能还更好。
-存在即合理,究其根因,在于 Rust 编译期的宁可错杀,绝不放过的原则,当编译器不能确定你的代码是否正确时,就统统会判定为错误,因此难免会导致一些误报。
-而 RefCell
正是用于你确信代码是正确的,而编译器却发生了误判时。
对于大型的复杂程序,也可以选择使用 RefCell
来让事情简化。例如在 Rust 编译器的ctxt结构体
中有大量的 RefCell
类型的 map
字段,主要的原因是:这些 map
会被分散在各个地方的代码片段所广泛使用或修改。由于这种分散在各处的使用方式,导致了管理可变和不可变成为一件非常复杂的任务(甚至不可能),你很容易就碰到编译器抛出来的各种错误。而且 RefCell
的运行时错误在这种情况下也变得非常可爱:一旦有人做了不正确的使用,代码会 panic
,然后告诉我们哪些借用冲突了。
总之,当你确信编译器误报但不知道该如何解决时,或者你有一个引用类型,需要被四处使用和修改然后导致借用关系难以管理时,都可以优先考虑使用 RefCell
。
Cell
用于可 Copy
的值不同,RefCell
用于引用RefCell
只是将借用规则从编译期推迟到程序运行期,并不能帮你绕过这个规则RefCell
适用于编译期误报或者一个引用被在多处代码使用、修改以至于难于管理借用关系时RefCell
时,违背借用规则会导致运行期的 panic
Cell
还是 RefCell
根据本文的内容,我们可以大概总结下两者的区别:
-Cell
只适用于 Copy
类型,用于提供值,而 RefCell
用于提供引用Cell
不会 panic
,而 RefCell
会Cell
没有额外的性能损耗,例如以下两段代码的性能其实是一致的:
--#![allow(unused)] -fn main() { -// code snipet 1 -let x = Cell::new(1); -let y = &x; -let z = &x; -x.set(2); -y.set(3); -z.set(4); -println!("{}", x.get()); - -// code snipet 2 -let mut x = 1; -let y = &mut x; -let z = &mut x; -x = 2; -*y = 3; -*z = 4; -println!("{}", x); -} -
虽然性能一致,但代码 1
拥有代码 2
不具有的优势:它能编译成功:)
与 Cell
的 zero cost
不同,RefCell
其实是有一点运行期开销的,原因是它包含了一个字大小的“借用状态”指示器,该指示器在每次运行时借用时都会被修改,进而产生一点开销。
总之,当非要使用内部可变性时,首选 Cell
,只有你的类型没有实现 Copy
时,才去选择 RefCell
。
之前我们提到 RefCell
具有内部可变性,何为内部可变性?简单来说,对一个不可变的值进行可变借用,但这个并不符合 Rust 的基本借用规则:
-fn main() { - let x = 5; - let y = &mut x; -} -
上面的代码会报错,因为我们不能对一个不可变的值进行可变借用,这会破坏 Rust 的安全性保证,相反,你可以对一个可变值进行不可变借用。原因是:当值不可变时,可能会有多个不可变的引用指向它,此时若将修改其中一个为可变的,会造成可变引用与不可变引用共存的情况;而当值可变时,最多只会有一个可变引用指向它,将其修改为不可变,那么最终依然是只有一个不可变的引用指向它。
-虽然基本借用规则是 Rust 的基石,然而在某些场景中,一个值可以在其方法内部被修改,同时对于其它代码不可变,是很有用的:
---#![allow(unused)] -fn main() { -// 定义在外部库中的特征 -pub trait Messenger { - fn send(&self, msg: String); -} - -// -------------------------- -// 我们的代码中的数据结构和实现 -struct MsgQueue { - msg_cache: Vec<String>, -} - -impl Messenger for MsgQueue { - fn send(&self, msg: String) { - self.msg_cache.push(msg) - } -} -} -
如上所示,外部库中定义了一个消息发送器特征 Messenger
,它只有一个发送消息的功能:fn send(&self, msg: String)
,因为发送消息不需要修改自身,因此原作者在定义时,使用了 &self
的不可变借用,这个无可厚非。
我们要在自己的代码中使用该特征实现一个异步消息队列,出于性能的考虑,消息先写到本地缓存(内存)中,然后批量发送出去,因此在 send
方法中,需要将消息先行插入到本地缓存 msg_cache
中。但是问题来了,该 send
方法的签名是 &self
,因此上述代码会报错:
error[E0596]: cannot borrow `self.msg_cache` as mutable, as it is behind a `&` reference
- --> src/main.rs:11:9
- |
-2 | fn send(&self, msg: String);
- | ----- help: consider changing that to be a mutable reference: `&mut self`
-...
-11 | self.msg_cache.push(msg)
- | ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
-
-在报错的同时,编译器大聪明还善意地给出了提示:将 &self
修改为 &mut self
,但是。。。我们实现的特征是定义在外部库中,因此该签名根本不能修改。值此危急关头, RefCell
闪亮登场:
-use std::cell::RefCell; -pub trait Messenger { - fn send(&self, msg: String); -} - -pub struct MsgQueue { - msg_cache: RefCell<Vec<String>>, -} - -impl Messenger for MsgQueue { - fn send(&self, msg: String) { - self.msg_cache.borrow_mut().push(msg) - } -} - -fn main() { - let mq = MsgQueue { - msg_cache: RefCell::new(Vec::new()), - }; - mq.send("hello, world".to_string()); -} -
这个 MQ 功能很弱,但是并不妨碍我们演示内部可变性的核心用法:通过包裹一层 RefCell
,成功的让 &self
中的 msg_cache
成为一个可变值,然后实现对其的修改。
在 Rust 中,一个常见的组合就是 Rc
和 RefCell
在一起使用,前者可以实现一个数据拥有多个所有者,后者可以实现数据的可变性:
-use std::cell::RefCell; -use std::rc::Rc; -fn main() { - let s = Rc::new(RefCell::new("我很善变,还拥有多个主人".to_string())); - - let s1 = s.clone(); - let s2 = s.clone(); - // let mut s2 = s.borrow_mut(); - s2.borrow_mut().push_str(", on yeah!"); - - println!("{:?}\n{:?}\n{:?}", s, s1, s2); -} - -
上面代码中,我们使用 RefCell<String>
包裹一个字符串,同时通过 Rc
创建了它的三个所有者:s
、s1
和s2
,并且通过其中一个所有者 s2
对字符串内容进行了修改。
由于 Rc
的所有者们共享同一个底层的数据,因此当一个所有者修改了数据时,会导致全部所有者持有的数据都发生了变化。
程序的运行结果也在预料之中:
-RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
-RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
-RefCell { value: "我很善变,还拥有多个主人, on yeah!" }
-
-相信这两者组合在一起使用时,很多人会好奇到底性能如何,下面我们来简单分析下。
-首先给出一个大概的结论,这两者结合在一起使用的性能其实非常高,大致相当于没有线程安全版本的 C++ std::shared_ptr
指针,事实上,C++ 这个指针的主要开销也在于原子性这个并发原语上,毕竟线程安全在哪个语言中开销都不小。
两者结合的数据结构与下面类似:
---#![allow(unused)] -fn main() { -struct Wrapper<T> { - // Rc - strong_count: usize, - weak_count: usize, - - // Refcell - borrow_count: isize, - - // 包裹的数据 - item: T, -} -} -
从上面可以看出,从对内存的影响来看,仅仅多分配了三个usize/isize
,并没有其它额外的负担。
从 CPU 来看,损耗如下:
-Rc<T>
解引用是免费的(编译期),但是 *
带来的间接取值并不免费Rc<T>
需要将当前的引用计数跟 0
和 usize::Max
进行一次比较,然后将计数值加 1Rc<T>
需要将计数值减 1, 然后跟 0
进行一次比较RefCell
进行不可变借用,需要将 isize
类型的借用计数加 1,然后跟 0
进行比较RefCell
的不可变借用进行释放,需要将 isize
减 1RefCell
的可变借用大致流程跟上面差不多,但是需要先跟 0
比较,然后再减 1RefCell
的可变借用进行释放,需要将 isize
加 1其实这些细节不必过于关注,只要知道 CPU 消耗也非常低,甚至编译器还会对此进行进一步优化!
-唯一需要担心的可能就是这种组合数据结构对于 CPU 缓存是否亲和,这个我们无法证明,只能提出来存在这个可能性,最终的性能影响还需要在实际场景中进行测试。
-总之,分析这两者组合的性能还挺复杂的,大概总结下:
-Rc
额外的引入了一次间接取值(*
),在少数场景下可能会造成性能上的显著损失Cell::from_mut
解决借用冲突在 Rust 1.37 版本中新增了两个非常实用的方法:
-&mut T
转为 &Cell<T>
&Cell<[T]>
转为 &[Cell<T>]
这里我们不做深入的介绍,但是来看看如何使用这两个方法来解决一个常见的借用冲突问题:
---#![allow(unused)] -fn main() { -fn is_even(i: i32) -> bool { - i % 2 == 0 -} - -fn retain_even(nums: &mut Vec<i32>) { - let mut i = 0; - for num in nums.iter().filter(|&num| is_even(*num)) { - nums[i] = *num; - i += 1; - } - nums.truncate(i); -} -} -
以上代码会报错:
-error[E0502]: cannot borrow `*nums` as mutable because it is also borrowed as immutable
- --> src/main.rs:8:9
- |
-7 | for num in nums.iter().filter(|&num| is_even(*num)) {
- | ----------------------------------------
- | |
- | immutable borrow occurs here
- | immutable borrow later used here
-8 | nums[i] = *num;
- | ^^^^ mutable borrow occurs here
-
-很明显,报错是因为同时借用了不可变与可变引用,你可以通过索引的方式来避免这个问题:
---#![allow(unused)] -fn main() { -fn retain_even(nums: &mut Vec<i32>) { - let mut i = 0; - for j in 0..nums.len() { - if is_even(nums[j]) { - nums[i] = nums[j]; - i += 1; - } - } - nums.truncate(i); -} -} -
但是这样就违背我们的初衷了,毕竟迭代器会让代码更加简洁,那么还有其它的办法吗?
-这时就可以使用 Cell
新增的这两个方法:
--#![allow(unused)] -fn main() { -use std::cell::Cell; - -fn retain_even(nums: &mut Vec<i32>) { - let slice: &[Cell<i32>] = Cell::from_mut(&mut nums[..]) - .as_slice_of_cells(); - - let mut i = 0; - for num in slice.iter().filter(|num| is_even(num.get())) { - slice[i].set(num.get()); - i += 1; - } - - nums.truncate(i); -} -} -
此时代码将不会报错,因为 Cell
上的 set
方法获取的是不可变引用 pub fn set(&self, val: T)
。
当然,以上代码的本质还是对 Cell
的运用,只不过这两个方法可以很方便的帮我们把 &mut [T]
类型转换成 &[Cell<T>]
类型。
Cell
和 RefCell
都为我们带来了内部可变性这个重要特性,同时还将借用规则的检查从编译期推迟到运行期,但是这个检查并不能被绕过,该来早晚还是会来,RefCell
在运行期的报错会造成 panic
。
RefCell
适用于编译器误报或者一个引用被在多个代码中使用、修改以至于难于管理借用关系时,还有就是需要内部可变性时。
从性能上看,RefCell
由于是非线程安全的,因此无需保证原子性,性能虽然有一点损耗,但是依然非常好,而 Cell
则完全不存在任何额外的性能损耗。
Rc
跟 RefCell
结合使用可以实现多个所有者共享同一份数据,非常好用,但是潜在的性能损耗也要考虑进去,建议对于热点代码使用时,做好 benchmark
。
在开始之前,我们先来看一段代码:
---#![allow(unused)] -fn main() { -#[derive(Debug)] -struct Person { - name: String, - age: u8 -} - -impl Person { - fn new(name: String, age: u8) -> Self { - Person { name, age} - } - - fn display(self: &mut Person, age: u8) { - let Person{name, age} = &self; - } -} -} -
以上代码有一个很奇怪的地方:在 display
方法中,self
是 &mut Person
的类型,接着我们对其取了一次引用 &self
,此时 &self
的类型是 &&mut Person
,然后我们又将其和 Person
类型进行匹配,取出其中的值。
那么问题来了,Rust 不是号称安全的语言吗?为何允许将 &&mut Person
跟 Person
进行匹配呢?答案就在本章节中,等大家学完后,再回头自己来解决这个问题 :) 下面正式开始咱们的新章节学习。
何为智能指针?能不让你写出 ****s
形式的解引用,我认为就是智能: ),智能指针的名称来源,主要就在于它实现了 Deref
和 Drop
特征,这两个特征可以智能地帮助我们节省使用上的负担:
Deref
可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如 *T
Drop
允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作先来看看 Deref
特征是如何工作的。
*
获取引用背后的值在正式讲解 Deref
之前,我们先来看下常规引用的解引用。
常规引用是一个指针类型,包含了目标数据存储的内存地址。对常规引用使用 *
操作符,就可以通过解引用的方式获取到内存地址对应的数据值:
-fn main() { - let x = 5; - let y = &x; - - assert_eq!(5, x); - assert_eq!(5, *y); -} -
这里 y
就是一个常规引用,包含了值 5
所在的内存地址,然后通过解引用 *y
,我们获取到了值 5
。如果你试图执行 assert_eq!(5, y);
,代码就会无情报错,因为你无法将一个引用与一个数值做比较:
error[E0277]: can't compare `{integer}` with `&{integer}` //无法将{integer} 与&{integer}进行比较
- --> src/main.rs:6:5
- |
-6 | assert_eq!(5, y);
- | ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
- |
- = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
- // 你需要为{integer}实现用于比较的特征PartialEq<&{integer}>
-
-上面所说的解引用方式和其它大多数语言并无区别,但是 Rust 中将解引用提升到了一个新高度。考虑一下智能指针,它是一个结构体类型,如果你直接对它进行 *myStruct
,显然编译器不知道该如何办,因此我们可以为智能指针结构体实现 Deref
特征。
实现 Deref
后的智能指针结构体,就可以像普通引用一样,通过 *
进行解引用,例如 Box<T>
智能指针:
-fn main() { - let x = Box::new(1); - let sum = *x + 1; -} -
智能指针 x
被 *
解引用为 i32
类型的值 1
,然后再进行求和。
现在,让我们一起来实现一个智能指针,功能上类似 Box<T>
。由于 Box<T>
本身很简单,并没有包含类如长度、最大长度等信息,因此用一个元组结构体即可。
--#![allow(unused)] -fn main() { -struct MyBox<T>(T); - -impl<T> MyBox<T> { - fn new(x: T) -> MyBox<T> { - MyBox(x) - } -} -} -
跟 Box<T>
一样,我们的智能指针也持有一个 T
类型的值,然后使用关联函数 MyBox::new
来创建智能指针。由于还未实现 Deref
特征,此时使用 *
肯定会报错:
-fn main() { - let y = MyBox::new(5); - - assert_eq!(5, *y); -} -
运行后,报错如下:
-error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
- --> src/main.rs:12:19
- |
-12 | assert_eq!(5, *y);
- | ^^
-
-现在来为 MyBox
实现 Deref
特征,以支持 *
解引用操作符:
--#![allow(unused)] -fn main() { -use std::ops::Deref; - -impl<T> Deref for MyBox<T> { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} -} -
很简单,当解引用 MyBox
智能指针时,返回元组结构体中的元素 &self.0
,有几点要注意的:
Deref
特征中声明了关联类型 Target
,在之前章节中介绍过,关联类型主要是为了提升代码可读性deref
返回的是一个常规引用,可以被 *
进行解引用之前报错的代码此时已能顺利编译通过。当然,标准库实现的智能指针要考虑很多边边角角情况,肯定比我们的实现要复杂。
-*
背后的原理当我们对智能指针 Box
进行解引用时,实际上 Rust 为我们调用了以下方法:
--#![allow(unused)] -fn main() { -*(y.deref()) -} -
首先调用 deref
方法返回值的常规引用,然后通过 *
对常规引用进行解引用,最终获取到目标值。
至于 Rust 为何要使用这个有点啰嗦的方式实现,原因在于所有权系统的存在。如果 deref
方法直接返回一个值,而不是引用,那么该值的所有权将被转移给调用者,而我们不希望调用者仅仅只是 *T
一下,就拿走了智能指针中包含的值。
需要注意的是,*
不会无限递归替换,从 *y
到 *(y.deref())
只会发生一次,而不会继续进行替换然后产生形如 *((y.deref()).deref())
的怪物。
对于函数和方法的传参,Rust 提供了一个极其有用的隐式转换:Deref
转换。若一个类型实现了 Deref
特征,那它的引用在传给函数或方法时,会根据参数签名来决定是否进行隐式的 Deref
转换,例如:
-fn main() { - let s = String::from("hello world"); - display(&s) -} - -fn display(s: &str) { - println!("{}",s); -} -
以上代码有几点值得注意:
-String
实现了 Deref
特征,可以在需要时自动被转换为 &str
类型&s
是一个 &String
类型,当它被传给 display
函数时,自动通过 Deref
转换成了 &str
&s
的方式来触发 Deref
(仅引用类型的实参才会触发自动解引用)如果你以为 Deref
仅仅这点作用,那就大错特错了。Deref
可以支持连续的隐式转换,直到找到适合的形式为止:
-fn main() { - let s = MyBox::new(String::from("hello world")); - display(&s) -} - -fn display(s: &str) { - println!("{}",s); -} -
这里我们使用了之前自定义的智能指针 MyBox
,并将其通过连续的隐式转换变成 &str
类型:首先 MyBox
被 Deref
成 String
类型,结果并不能满足 display
函数参数的要求,编译器发现 String
还可以继续 Deref
成 &str
,最终成功的匹配了函数参数。
想象一下,假如 Rust
没有提供这种隐式转换,我们该如何调用 display
函数?
-fn main() { - let m = MyBox::new(String::from("Rust")); - display(&(*m)[..]); -} -
结果不言而喻,肯定是 &s
的方式优秀得多。总之,当参与其中的类型定义了 Deref
特征时,Rust 会分析该类型并且连续使用 Deref
直到最终获得一个引用来匹配函数或者方法的参数类型,这种行为完全不会造成任何的性能损耗,因为完全是在编译期完成。
但是 Deref
并不是没有缺点,缺点就是:如果你不知道某个类型是否实现了 Deref
特征,那么在看到某段代码时,并不能在第一时间反应过来该代码发生了隐式的 Deref
转换。事实上,不仅仅是 Deref
,在 Rust 中还有各种 From/Into
等等会给阅读代码带来一定负担的特征。还是那句话,一切选择都是权衡,有得必有失,得了代码的简洁性,往往就失去了可读性,Go 语言就是一个刚好相反的例子。
再来看一下在方法、赋值中自动应用 Deref
的例子:
-fn main() { - let s = MyBox::new(String::from("hello, world")); - let s1: &str = &s; - let s2: String = s.to_string(); -} -
对于 s1
,我们通过两次 Deref
将 &str
类型的值赋给了它(赋值操作需要手动解引用);而对于 s2
,我们在其上直接调用方法 to_string
,实际上 MyBox
根本没有没有实现该方法,能调用 to_string
,完全是因为编译器对 MyBox
应用了 Deref
的结果(方法调用会自动解引用)。
在上面,我们零碎的介绍了不少关于 Deref
特征的知识,下面来通过较为正式的方式来对其规则进行下总结。
一个类型为 T
的对象 foo
,如果 T: Deref<Target=U>
,那么,相关 foo
的引用 &foo
在应用的时候会自动转换为 &U
。
粗看这条规则,貌似有点类似于 AsRef
,而跟 解引用
似乎风马牛不相及,实际里面有些玄妙之处。
Rust 编译器实际上只能对 &v
形式的引用进行解引用操作,那么问题来了,如果是一个智能指针或者 &&&&v
类型的呢? 该如何对这两个进行解引用?
答案是:Rust 会在解引用时自动把智能指针和 &&&&v
做引用归一化操作,转换成 &v
形式,最终再对 &v
进行解引用:
&v
&
,例如 &&&&&&&v
,归一成 &v
关于第二种情况,这么干巴巴的说,也许大家会迷迷糊糊的,我们来看一段标准库源码:
---#![allow(unused)] -fn main() { -impl<T: ?Sized> Deref for &T { - type Target = T; - - fn deref(&self) -> &T { - *self - } -} -} -
在这段源码中,&T
被自动解引用为 T
,也就是 &T: Deref<Target=T>
。 按照这个代码,&&&&T
会被自动解引用为 &&&T
,然后再自动解引用为 &&T
,以此类推, 直到最终变成 &T
。
PS: 以下是 LLVM
编译后的部分中间层代码:
--#![allow(unused)] -fn main() { -// Rust 代码 -let mut _2: &i32; -let _3: &&&&i32; - -bb0: { - _2 = (*(*(*_3))) -} -} -
--#![allow(unused)] -fn main() { - fn foo(s: &str) {} - - // 由于 String 实现了 Deref<Target=str> - let owned = "Hello".to_string(); - - // 因此下面的函数可以正常运行: - foo(&owned); -} -
--#![allow(unused)] -fn main() { - use std::rc::Rc; - - fn foo(s: &str) {} - - // String 实现了 Deref<Target=str> - let owned = "Hello".to_string(); - // 且 Rc 智能指针可以被自动脱壳为内部的 `owned` 引用: &String ,然后 &String 再自动解引用为 &str - let counted = Rc::new(owned); - - // 因此下面的函数可以正常运行: - foo(&counted); -} -
--#![allow(unused)] -fn main() { - struct Foo; - - impl Foo { - fn foo(&self) { println!("Foo"); } - } - - let f = &&Foo; - - f.foo(); - (&f).foo(); - (&&f).foo(); - (&&&&&&&&f).foo(); -} -
在之前,我们讲的都是不可变的 Deref
转换,实际上 Rust 还支持将一个可变的引用转换成另一个可变的引用以及将一个可变引用转换成不可变的引用,规则如下:
T: Deref<Target=U>
,可以将 &T
转换成 &U
,也就是我们之前看到的例子T: DerefMut<Target=U>
,可以将 &mut T
转换成 &mut U
T: Deref<Target=U>
,可以将 &mut T
转换成 &U
来看一个关于 DerefMut
的例子:
-struct MyBox<T> { - v: T, -} - -impl<T> MyBox<T> { - fn new(x: T) -> MyBox<T> { - MyBox { v: x } - } -} - -use std::ops::Deref; - -impl<T> Deref for MyBox<T> { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.v - } -} - -use std::ops::DerefMut; - -impl<T> DerefMut for MyBox<T> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.v - } -} - -fn main() { - let mut s = MyBox::new(String::from("hello, ")); - display(&mut s) -} - -fn display(s: &mut String) { - s.push_str("world"); - println!("{}", s); -} -
以上代码有几点值得注意:
-DerefMut
必须要先实现 Deref
特征:pub trait DerefMut: Deref
T: DerefMut<Target=U>
解读:将 &mut T
类型通过 DerefMut
特征的方法转换为 &mut U
类型,对应上例中,就是将 &mut MyBox<String>
转换为 &mut String
对于上述三条规则中的第三条,它比另外两条稍微复杂了点:Rust 可以把可变引用隐式的转换成不可变引用,但反之则不行。
-如果从 Rust 的所有权和借用规则的角度考虑,当你拥有一个可变的引用,那该引用肯定是对应数据的唯一借用,那么此时将可变引用变成不可变引用并不会破坏借用规则;但是如果你拥有一个不可变引用,那同时可能还存在其它几个不可变的引用,如果此时将其中一个不可变引用转换成可变引用,就变成了可变引用与不可变引用的共存,最终破坏了借用规则。
-Deref
可以说是 Rust 中最常见的隐式类型转换,而且它可以连续的实现如 Box<String> -> String -> &str
的隐式转换,只要链条上的类型实现了 Deref
特征。
我们也可以为自己的类型实现 Deref
特征,但是原则上来说,只应该为自定义的智能指针实现 Deref
。例如,虽然你可以为自己的自定义数组类型实现 Deref
以避免 myArr.0[0]
的使用形式,但是 Rust 官方并不推荐这么做,特别是在你开发三方库时。
在 Rust 中,我们之所以可以一拳打跑 GC 的同时一脚踢翻手动资源回收,主要就归功于 Drop
特征,同时它也是智能指针的必备特征之一。
如何自动和手动释放资源及执行指定的收尾工作
-在一些无 GC 语言中,程序员在一个变量无需再被使用时,需要手动释放它占用的内存资源,如果忘记了,那么就会发生内存泄漏,最终臭名昭著的 OOM
问题可能就会发生。
而在 Rust 中,你可以指定在一个变量超出作用域时,执行一段特定的代码,最终编译器将帮你自动插入这段收尾代码。这样,就无需在每一个使用该变量的地方,都写一段代码来进行收尾工作和资源释放。不禁让人感叹,Rust 的大腿真粗,香!
-没错,指定这样一段收尾工作靠的就是咱这章的主角 - Drop
特征。
-struct HasDrop1; -struct HasDrop2; -impl Drop for HasDrop1 { - fn drop(&mut self) { - println!("Dropping HasDrop1!"); - } -} -impl Drop for HasDrop2 { - fn drop(&mut self) { - println!("Dropping HasDrop2!"); - } -} -struct HasTwoDrops { - one: HasDrop1, - two: HasDrop2, -} -impl Drop for HasTwoDrops { - fn drop(&mut self) { - println!("Dropping HasTwoDrops!"); - } -} - -struct Foo; - -impl Drop for Foo { - fn drop(&mut self) { - println!("Dropping Foo!") - } -} - -fn main() { - let _x = HasTwoDrops { - two: HasDrop2, - one: HasDrop1, - }; - let _foo = Foo; - println!("Running!"); -} -
上面代码虽然长,但是目的其实很单纯,就是为了观察不同情况下变量级别的、结构体内部字段的 Drop
,有几点值得注意:
Drop
特征中的 drop
方法借用了目标的可变引用,而不是拿走了所有权,这里先设置一个悬念,后边会讲Drop
来看看输出:
-Running!
-Dropping Foo!
-Dropping HasTwoDrops!
-Dropping HasDrop1!
-Dropping HasDrop2!
-
-嗯,结果符合预期,每个资源都成功的执行了收尾工作,虽然 println!
这种收尾工作毫无意义 =,=
观察以上输出,我们可以得出以下关于 Drop
顺序的结论
_x
在 _foo
之前创建,因此 _x
在 _foo
之后被 drop
_x
中的字段按照定义中的顺序依次 drop
实际上,就算你不为 _x
结构体实现 Drop
特征,它内部的两个字段依然会调用 drop
,移除以下代码,并观察输出:
--#![allow(unused)] -fn main() { -impl Drop for HasTwoDrops { - fn drop(&mut self) { - println!("Dropping HasTwoDrops!"); - } -} -} -
原因在于,Rust 自动为几乎所有类型都实现了 Drop
特征,因此就算你不手动为结构体实现 Drop
,它依然会调用默认实现的 drop
函数,同时再调用每个字段的 drop
方法,最终打印出:
Dropping HasDrop1!
-Dropping HasDrop2!
-
-当使用智能指针来管理锁的时候,你可能希望提前释放这个锁,然后让其它代码能及时获得锁,此时就需要提前去手动 drop
。
-但是在之前我们提到一个悬念,Drop::drop
只是借用了目标值的可变引用,所以,就算你提前调用了 drop
,后面的代码依然可以使用目标值,但是这就会访问一个并不存在的值,非常不安全,好在 Rust 会阻止你:
-#[derive(Debug)] -struct Foo; - -impl Drop for Foo { - fn drop(&mut self) { - println!("Dropping Foo!") - } -} - -fn main() { - let foo = Foo; - foo.drop(); - println!("Running!:{:?}", foo); -} -
报错如下:
-error[E0040]: explicit use of destructor method
- --> src/main.rs:37:9
- |
-37 | foo.drop();
- | ----^^^^--
- | | |
- | | explicit destructor calls not allowed
- | help: consider using `drop` function: `drop(foo)`
-
-如上所示,编译器直接阻止了我们调用 Drop
特征的 drop
方法,原因是对于 Rust 而言,不允许显式地调用析构函数(这是一个用来清理实例的通用编程概念)。好在在报错的同时,编译器还给出了一个提示:使用 drop
函数。
针对编译器提示的 drop
函数,我们可以大胆推测下:它能够拿走目标值的所有权。现在来看看这个猜测正确与否,以下是 std::mem::drop
函数的签名:
--#![allow(unused)] -fn main() { -pub fn drop<T>(_x: T) -} -
如上所示,drop
函数确实拿走了目标值的所有权,来验证下:
-fn main() { - let foo = Foo; - drop(foo); - // 以下代码会报错:借用了所有权被转移的值 - // println!("Running!:{:?}", foo); -} -
Bingo,完美拿走了所有权,而且这种实现保证了后续的使用必定会导致编译错误,因此非常安全!
-细心的同学可能已经注意到,这里直接调用了 drop
函数,并没有引入任何模块信息,原因是该函数在std::prelude
里。
对于 Drop 而言,主要有两个功能:
-对于第二点,在之前我们已经详细介绍过,因此这里主要对第一点进行下简单说明。
-在绝大多数情况下,我们都无需手动去 drop
以回收内存资源,因为 Rust 会自动帮我们完成这些工作,它甚至会对复杂类型的每个字段都单独的调用 drop
进行回收!但是确实有极少数情况,需要你自己来回收资源的,例如文件描述符、网络 socket 等,当这些值超出作用域不再使用时,就需要进行关闭以释放相关的资源,在这些情况下,就需要使用者自己来解决 Drop
的问题。
我们无法为一个类型同时实现 Copy
和 Drop
特征。因为实现了 Copy
的特征会被编译器隐式的复制,因此非常难以预测析构函数执行的时间和频率。因此这些实现了 Copy
的类型无法拥有析构函数。
--#![allow(unused)] -fn main() { -#[derive(Copy)] -struct Foo; - -impl Drop for Foo { - fn drop(&mut self) { - println!("Dropping Foo!") - } -} -} -
以上代码报错如下:
-error[E0184]: the trait `Copy` may not be implemented for this type; the type has a destructor
- --> src/main.rs:24:10
- |
-24 | #[derive(Copy)]
- | ^^^^ Copy not allowed on types with destructors
-
-Drop
可以用于许多方面,来使得资源清理及收尾工作变得方便和安全,甚至可以用其创建我们自己的内存分配器!通过 Drop
特征和 Rust 所有权系统,你无需担心之后的代码清理,Rust 会自动考虑这些问题。
我们也无需担心意外的清理掉仍在使用的值,这会造成编译器错误:所有权系统确保引用总是有效的,也会确保 drop
只会在值不再被使用时被调用一次。
在各个编程语言中,指针的概念几乎都是相同的:指针是一个包含了内存地址的变量,该内存地址引用或者指向了另外的数据。
-在 Rust 中,最常见的指针类型是引用,引用通过 &
符号表示。不同于其它语言,引用在 Rust 中被赋予了更深层次的含义,那就是:借用其它变量的值。引用本身很简单,除了指向某个值外并没有其它的功能,也不会造成性能上的额外损耗,因此是 Rust 中使用最多的指针类型。
而智能指针则不然,它虽然也号称指针,但是它是一个复杂的家伙:通过比引用更复杂的数据结构,包含比引用更多的信息,例如元数据,当前长度,最大可用长度等。总之,Rust 的智能指针并不是独创,在 C++ 或者其他语言中也存在相似的概念。
-Rust 标准库中定义的那些智能指针,虽重但强,可以提供比引用更多的功能特性,例如本章将讨论的引用计数智能指针。该智能指针允许你同时拥有同一个数据的多个所有权,它会跟踪每一个所有者并进行计数,当所有的所有者都归还后,该智能指针及指向的数据将自动被清理释放。
-引用和智能指针的另一个不同在于前者仅仅是借用了数据,而后者往往可以拥有它们指向的数据,然后再为其它人提供服务。
-在之前的章节中,实际上我们已经见识过多种智能指针,例如动态字符串 String
和动态数组 Vec
,它们的数据结构中不仅仅包含了指向底层数据的指针,还包含了当前长度、最大长度等信息,其中 String
智能指针还提供了一种担保信息:所有的数据都是合法的 UTF-8
格式。
智能指针往往是基于结构体实现,它与我们自定义的结构体最大的区别在于它实现了 Deref
和 Drop
特征:
Deref
可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如 *T
Drop
允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作智能指针在 Rust 中很常见,我们在本章不会全部讲解,而是挑选几个最常用、最有代表性的进行讲解:
-Box<T>
,可以将值分配到堆上Rc<T>
,引用计数类型,允许多所有权存在Ref<T>
和 RefMut<T>
,允许将借用规则检查从编译期移动到运行期进行Rust 所有权机制要求一个值只能有一个所有者,在大多数情况下,都没有问题,但是考虑以下情况:
-以上场景不是很常见,但是一旦遇到,就非常棘手,为了解决此类问题,Rust 在所有权机制之外又引入了额外的措施来简化相应的实现:通过引用计数的方式,允许一个数据资源在同一时刻拥有多个所有者。
-这种实现机制就是 Rc
和 Arc
,前者适用于单线程,后者适用于多线程。由于二者大部分情况下都相同,因此本章将以 Rc
作为讲解主体,对于 Arc
的不同之处,另外进行单独讲解。
Rc<T>
引用计数(reference counting),顾名思义,通过记录一个数据被引用的次数来确定该数据是否正在被使用。当引用次数归零时,就代表该数据不再被使用,因此可以被清理释放。
-而 Rc
正是引用计数的英文缩写。当我们希望在堆上分配一个对象供程序的多个部分使用且无法确定哪个部分最后一个结束时,就可以使用 Rc
成为数据值的所有者,例如之前提到的多线程场景就非常适合。
下面是经典的所有权被转移导致报错的例子:
--fn main() { - let s = String::from("hello, world"); - // s在这里被转移给a - let a = Box::new(s); - // 报错!此处继续尝试将 s 转移给 b - let b = Box::new(s); -} -
使用 Rc
就可以轻易解决:
-use std::rc::Rc; -fn main() { - let a = Rc::new(String::from("hello, world")); - let b = Rc::clone(&a); - - assert_eq!(2, Rc::strong_count(&a)); - assert_eq!(Rc::strong_count(&a), Rc::strong_count(&b)) -} -
以上代码我们使用 Rc::new
创建了一个新的 Rc<String>
智能指针并赋给变量 a
,该指针指向底层的字符串数据。
智能指针 Rc<T>
在创建时,还会将引用计数加 1,此时获取引用计数的关联函数 Rc::strong_count
返回的值将是 1
。
接着,我们又使用 Rc::clone
克隆了一份智能指针 Rc<String>
,并将该智能指针的引用计数增加到 2
。
由于 a
和 b
是同一个智能指针的两个副本,因此通过它们两个获取引用计数的结果都是 2
。
不要被 clone
字样所迷惑,以为所有的 clone
都是深拷贝。这里的 clone
仅仅复制了智能指针并增加了引用计数,并没有克隆底层数据,因此 a
和 b
是共享了底层的字符串 s
,这种复制效率是非常高的。当然你也可以使用 a.clone()
的方式来克隆,但是从可读性角度,我们更加推荐 Rc::clone
的方式。
实际上在 Rust 中,还有不少 clone
都是浅拷贝,例如迭代器的克隆。
使用关联函数 Rc::strong_count
可以获取当前引用计数的值,我们来观察下引用计数如何随着变量声明、释放而变化:
-use std::rc::Rc; -fn main() { - let a = Rc::new(String::from("test ref counting")); - println!("count after creating a = {}", Rc::strong_count(&a)); - let b = Rc::clone(&a); - println!("count after creating b = {}", Rc::strong_count(&a)); - { - let c = Rc::clone(&a); - println!("count after creating c = {}", Rc::strong_count(&c)); - } - println!("count after c goes out of scope = {}", Rc::strong_count(&a)); -} -
有几点值得注意:
-c
在语句块内部声明,当离开语句块时它会因为超出作用域而被释放,所以引用计数会减少 1,事实上这个得益于 Rc<T>
实现了 Drop
特征a
、b
、c
三个智能指针引用计数都是同样的,并且共享底层的数据,因此打印计数时用哪个都行a
、b
超出作用域后,引用计数会变成 0,最终智能指针和它指向的底层字符串都会被清理释放事实上,Rc<T>
是指向底层数据的不可变的引用,因此你无法通过它来修改数据,这也符合 Rust 的借用规则:要么存在多个不可变借用,要么只能存在一个可变借用。
但是实际开发中我们往往需要对数据进行修改,这时单独使用 Rc<T>
无法满足我们的需求,需要配合其它数据类型来一起使用,例如内部可变性的 RefCell<T>
类型以及互斥锁 Mutex<T>
。事实上,在多线程编程中,Arc
跟 Mutex
锁的组合使用非常常见,它们既可以让我们在不同的线程中共享数据,又允许在各个线程中对其进行修改。
考虑一个场景,有很多小工具,每个工具都有自己的主人,但是存在多个工具属于同一个主人的情况,此时使用 Rc<T>
就非常适合:
-use std::rc::Rc; - -struct Owner { - name: String, - // ...其它字段 -} - -struct Gadget { - id: i32, - owner: Rc<Owner>, - // ...其它字段 -} - -fn main() { - // 创建一个基于引用计数的 `Owner`. - let gadget_owner: Rc<Owner> = Rc::new(Owner { - name: "Gadget Man".to_string(), - }); - - // 创建两个不同的工具,它们属于同一个主人 - let gadget1 = Gadget { - id: 1, - owner: Rc::clone(&gadget_owner), - }; - let gadget2 = Gadget { - id: 2, - owner: Rc::clone(&gadget_owner), - }; - - // 释放掉第一个 `Rc<Owner>` - drop(gadget_owner); - - // 尽管在上面我们释放了 gadget_owner,但是依然可以在这里使用 owner 的信息 - // 原因是在 drop 之前,存在三个指向 Gadget Man 的智能指针引用,上面仅仅 - // drop 掉其中一个智能指针引用,而不是 drop 掉 owner 数据,外面还有两个 - // 引用指向底层的 owner 数据,引用计数尚未清零 - // 因此 owner 数据依然可以被使用 - println!("Gadget {} owned by {}", gadget1.id, gadget1.owner.name); - println!("Gadget {} owned by {}", gadget2.id, gadget2.owner.name); - - // 在函数最后,`gadget1` 和 `gadget2` 也被释放,最终引用计数归零,随后底层 - // 数据也被清理释放 -} -
以上代码很好的展示了 Rc<T>
的用途,当然你也可以用借用的方式,但是实现起来就会复杂得多,而且随着 Gadget
在代码的各个地方使用,引用生命周期也将变得更加复杂,毕竟结构体中的引用类型,总是令人不那么愉快,对不?
Rc/Arc
是不可变引用,你无法修改它指向的值,只能进行读取,如果要修改,需要配合后面章节的内部可变性 RefCell
或互斥锁 Mutex
Rc
只能用于同一线程内部,想要用于线程之间的对象共享,你需要使用 Arc
Rc<T>
是一个智能指针,实现了 Deref
特征,因此你无需先解开 Rc
指针,再使用里面的 T
,而是可以直接使用 T
,例如上例中的 gadget1.owner.name
Rc<T>
来看看在多线程场景使用 Rc<T>
会如何:
-use std::rc::Rc; -use std::thread; - -fn main() { - let s = Rc::new(String::from("多线程漫游者")); - for _ in 0..10 { - let s = Rc::clone(&s); - let handle = thread::spawn(move || { - println!("{}", s) - }); - } -} -
由于我们还没有学习多线程的章节,上面的例子就特地简化了相关的实现。首先通过 thread::spawn
创建一个线程,然后使用 move
关键字把克隆出的 s
的所有权转移到线程中。
能够实现这一点,完全得益于 Rc
带来的多所有权机制,但是以上代码会报错:
error[E0277]: `Rc<String>` cannot be sent between threads safely
-
-表面原因是 Rc<T>
不能在线程间安全的传递,实际上是因为它没有实现 Send
特征,而该特征是恰恰是多线程间传递数据的关键,我们会在多线程章节中进行讲解。
当然,还有更深层的原因:由于 Rc<T>
需要管理引用计数,但是该计数器并没有使用任何并发原语,因此无法实现原子化的计数操作,最终会导致计数错误。
好在天无绝人之路,一起来看看 Rust 为我们提供的功能类似但是多线程安全的 Arc
。
Arc
是 Atomic Rc
的缩写,顾名思义:原子化的 Rc<T>
智能指针。原子化是一种并发原语,我们在后续章节会进行深入讲解,这里你只要知道它能保证我们的数据能够安全的在线程间共享即可。
你可能好奇,为何不直接使用 Arc
,还要画蛇添足弄一个 Rc
,还有 Rust 的基本数据类型、标准库数据类型为什么不自动实现原子化操作?这样就不存在线程不安全的问题了。
原因在于原子化或者其它锁虽然可以带来的线程安全,但是都会伴随着性能损耗,而且这种性能损耗还不小。因此 Rust 把这种选择权交给你,毕竟需要线程安全的代码其实占比并不高,大部分时候我们开发的程序都在一个线程内。
-Arc
和 Rc
拥有完全一样的 API,修改起来很简单:
-use std::sync::Arc; -use std::thread; - -fn main() { - let s = Arc::new(String::from("多线程漫游者")); - for _ in 0..10 { - let s = Arc::clone(&s); - let handle = thread::spawn(move || { - println!("{}", s) - }); - } -} -
对了,两者还有一点区别:Arc
和 Rc
并没有定义在同一个模块,前者通过 use std::sync::Arc
来引入,后者通过 use std::rc::Rc
。
在 Rust 中,所有权机制保证了一个数据只会有一个所有者,但如果你想要在图数据结构、多线程等场景中共享数据,这种机制会成为极大的阻碍。好在 Rust 为我们提供了智能指针 Rc
和 Arc
,使用它们就能实现多个所有者共享一个数据的功能。
Rc
和 Arc
的区别在于,后者是原子化实现的引用计数,因此是线程安全的,可以用于多线程中共享数据。
这两者都是只读的,如果想要实现内部数据可修改,必须配合内部可变性 RefCell
或者互斥锁 Mutex
来一起使用。
--本章内容对于学习 Rust 不是必须的,而且难度很高,大家简单知道有这回事就好,不必非要学会 :D
-
Rust 提供了 asm!
宏,可以让大家在 Rust 代码中嵌入汇编代码,对于一些极致高性能或者底层的场景还是非常有用的,例如操作系统内核开发。但通常来说,大家并不应该在自己的项目中使用到该项技术,它为极客而生!
本章的例子是基于 x86/x86-64
汇编的,但是其它架构也是支持的,目前支持的包括:
当使用在不支持的平台上时,编译器会给出报错。
-先从一个简单例子开始:
---#![allow(unused)] -fn main() { -use std::arch::asm; - -unsafe { - asm!("nop"); -} -} -
注意 unsafe
语句块依然是必不可少的,因为可能在里面插入危险的指令,最终破坏代码的安全性。
上面代码将插入一个 NOP
指令( 空操作 ) 到编译器生成的汇编代码中,其中指令作为 asm!
的第一个参数传入。
上面的代码有够无聊的,来点实际的:
---#![allow(unused)] -fn main() { -use std::arch::asm; - -let x: u64; -unsafe { - asm!("mov {}, 5", out(reg) x); -} -assert_eq!(x, 5); -} -
这段代码将 5
赋给 u64
类型的变量 x
,值得注意的是 asm!
的指令参数实际上是一个格式化字符串。至于传给格式化字符串的参数,看起来还是比较陌生的:
out
reg
,编译器会自动选择合适的寄存器--#![allow(unused)] -fn main() { -use std::arch::asm; - -let i: u64 = 3; -let o: u64; -unsafe { - asm!( - "mov {0}, {1}", - "add {0}, 5", - out(reg) o, - in(reg) i, - ); -} -assert_eq!(o, 8); -} -
上面的代码中进一步使用了输入 in
,将 5
加到输入的变量 i
上,然后将结果写到输出变量 o
。实际的操作方式是首先将 i
的值拷贝到输出,然后再加上 5
。
上例还能看出几点:
-asm!
允许使用多个格式化字符串,每一个作为单独一行汇编代码存在,看起来跟阅读真实的汇编代码类似in
来声明事实上,还可以进一步优化代码,去掉 mov
指令:
--#![allow(unused)] -fn main() { -use std::arch::asm; - -let mut x: u64 = 3; -unsafe { - asm!("add {0}, 5", inout(reg) x); -} -assert_eq!(x, 8); -} -
又多出一个 inout
关键字,但是不难猜,它说明 x
即是输入又是输出。与之前的分离方式还有一点很大的区别,这种方式可以保证使用同一个寄存器来完成任务。
当然,你可以在使用 inout
的情况下,指定不同的输入和输出:
--#![allow(unused)] -fn main() { -use std::arch::asm; - -let x: u64 = 3; -let y: u64; -unsafe { - asm!("add {0}, 5", inout(reg) x => y); -} -assert_eq!(y, 8); -} -
Rust 编译器对于操作数分配是较为保守的,它会假设 out
可以在任何时间被写入,因此 out
不会跟其它参数共享它的位置。然而为了保证最佳性能,使用尽量少的寄存器是有必要的,这样它们不必在内联汇编的代码块内保存和重加载。
为了达成这个目标( 共享位置或者说寄存器,以实现减少寄存器使用的性能优化 ),Rust 提供一个 lateout
关键字,可以用于任何只在所有输入被消费后才被写入的输出,与之类似的还有一个 inlateout
。
但是 inlateout
在某些场景中是无法使用的,例如下面的例子:
--#![allow(unused)] -fn main() { -use std::arch::asm; - -let mut a: u64 = 4; -let b: u64 = 4; -let c: u64 = 4; -unsafe { - asm!( - "add {0}, {1}", - "add {0}, {2}", - inout(reg) a, - in(reg) b, - in(reg) c, - ); -} -assert_eq!(a, 12); -} -
一旦用了 inlateout
后,上面的代码就只能运行在 Debug
模式下,原因是 Debug
并没有做任何优化,但是 release
发布不同,为了性能是要做很多编译优化的。
在编译优化时,编译器可以很容易的为输入 b
和 c
分配同样的是寄存器,因为它知道它们有同样的值。如果这里使用 inlateout
, 那么 a
和 c
就可以被分配到相同的寄存器,在这种情况下,第一条指令将覆盖掉 c
的值,最终导致汇编代码产生错误的结果。
因此这里使用 inout
,那么编译器就会为 a
分配一个独立的寄存器.
但是下面的代码又不同,它是可以使用 inlateout
的:
--#![allow(unused)] -fn main() { -use std::arch::asm; - -let mut a: u64 = 4; -let b: u64 = 4; -unsafe { - asm!("add {0}, {1}", inlateout(reg) a, in(reg) b); -} -assert_eq!(a, 8); -} -
原因在于输出只有在所有寄存器都被读取后,才被修改。因此,即使 a
和 b
被分配了同样的寄存器,代码也会正常工作,不存在之前的覆盖问题。
一些指令会要求操作数只能存在特定的寄存器中,因此 Rust 的内联汇编提供了一些限制操作符。
-大家应该记得之前出现过的 reg
是适用于任何架构的通用寄存器,意味着编译器可以自己选择合适的寄存器,但是当你需要显式地指定寄存器时,很可能会变成平台相关的代码,适用移植性会差很多。例如 x86
下的寄存器:eax
, ebx
, ecx
, ebp
, esi
等等。
--#![allow(unused)] -fn main() { -use std::arch::asm; - -let cmd = 0xd1; -unsafe { - asm!("out 0x64, eax", in("eax") cmd); -} -} -
上面的例子调用 out
指令将 cmd
变量的值输出到 0x64
内存地址中。由于 out
指令只接收 eax
和它的子寄存器,因此我们需要使用 eax
来指定特定的寄存器。
--显式寄存器操作数无法用于格式化字符串中,例如我们之前使用的 {},只能直接在字符串中使用
-eax
。同时,该操作数只能出现在最后,也就是在其它操作数后面出现
--#![allow(unused)] -fn main() { -use std::arch::asm; - -fn mul(a: u64, b: u64) -> u128 { - let lo: u64; - let hi: u64; - - unsafe { - asm!( - // The x86 mul instruction takes rax as an implicit input and writes - // the 128-bit result of the multiplication to rax:rdx. - "mul {}", - in(reg) a, - inlateout("rax") b => lo, - lateout("rdx") hi - ); - } - - ((hi as u128) << 64) + lo as u128 -} -} -
这段代码使用了 mul
指令,将两个 64 位的输入相乘,生成一个 128 位的结果。
首先将变量 a
的值存到寄存器 reg
中,其次显式使用寄存器 rax
,它的值来源于变量 b
。结果的低 64 位存储在 rax
中,然后赋给变量 lo
,而结果的高 64 位则存在 rdx
中,最后赋给 hi
。
在很多情况下,无需作为输出的状态都会被内联汇编修改,这个状态被称之为 "clobbered"。
-我们需要告诉编译器相关的情况,因为编译器需要在内联汇编语句块的附近存储和恢复这种状态。
--use std::arch::asm; - -fn main() { - // three entries of four bytes each - let mut name_buf = [0_u8; 12]; - // String is stored as ascii in ebx, edx, ecx in order - // Because ebx is reserved, the asm needs to preserve the value of it. - // So we push and pop it around the main asm. - // (in 64 bit mode for 64 bit processors, 32 bit processors would use ebx) - - unsafe { - asm!( - "push rbx", - "cpuid", - "mov [rdi], ebx", - "mov [rdi + 4], edx", - "mov [rdi + 8], ecx", - "pop rbx", - // We use a pointer to an array for storing the values to simplify - // the Rust code at the cost of a couple more asm instructions - // This is more explicit with how the asm works however, as opposed - // to explicit register outputs such as `out("ecx") val` - // The *pointer itself* is only an input even though it's written behind - in("rdi") name_buf.as_mut_ptr(), - // select cpuid 0, also specify eax as clobbered - inout("eax") 0 => _, - // cpuid clobbers these registers too - out("ecx") _, - out("edx") _, - ); - } - - let name = core::str::from_utf8(&name_buf).unwrap(); - println!("CPU Manufacturer ID: {}", name); -} -
例子中,我们使用 cpuid
指令来读取 CPU ID,该指令会将值写入到 eax
、edx
和 ecx
中。
即使 eax
从没有被读取,我们依然需要告知编译器这个寄存器被修改过,这样编译器就可以在汇编代码之前存储寄存器中的值。这个需要通过将输出声明为 _
而不是一个具体的变量名,代表着该输出值被丢弃。
这段代码也会绕过一个限制: ebx
是一个 LLVM 保留寄存器,意味着 LLVM 会假设它拥有寄存器的全部控制权,并在汇编代码块结束时将寄存器的状态恢复到最开始的状态。由于这个限制,该寄存器无法被用于输入或者输出,除非编译器使用该寄存器的满足一个通用寄存器的需求(例如 in(reg)
)。 但这样使用后, reg
操作数就在使用保留寄存器时变得危险起来,原因是我们可能会无意识的破坏输入或者输出,毕竟它们共享同一个寄存器。
为了解决这个问题,我们使用 rdi
来存储指向输出数组的指针,通过 push
的方式存储 ebx
:在汇编代码块的内部读取 ebx
的值,然后写入到输出数组。后面再可以通过 pop
的方式来回复 ebx
到初始的状态。
push
和 pop
使用完成的 64 位 rbx
寄存器,来确保整个寄存器的内容都被保存。如果是在 32 位机器上,代码将使用 ebx
替代。
还还可以在汇编代码内部使用通用寄存器:
---#![allow(unused)] -fn main() { -use std::arch::asm; - -// Multiply x by 6 using shifts and adds -let mut x: u64 = 4; -unsafe { - asm!( - "mov {tmp}, {x}", - "shl {tmp}, 1", - "shl {x}, 2", - "add {x}, {tmp}", - x = inout(reg) x, - tmp = out(reg) _, - ); -} -assert_eq!(x, 4 * 6); -} -
由于这块儿内容过于专业,本书毕竟是通用的 Rust 学习书籍,因此关于内联汇编就不再赘述。事实上,如果你要真的写出可用的汇编代码,要学习的还很多...
-感兴趣的同学可以看看如下英文资料: Rust Reference 和 Rust By Example。
- - -圣人论迹不论心,论心世上无圣人,对于编程语言而言,亦是如此。
-虽然在本章之前,我们学到的代码都是在编译期就得到了 Rust 的安全保障,但是在其内心深处也隐藏了一些阴暗面,在这些阴暗面里,内存安全就存在一些变数了:当不娴熟的开发者接触到这些阴暗面,就可能写出不安全的代码,因此我们称这种代码为 unsafe
代码块。
几乎每个语言都有 unsafe
关键字,但 Rust 语言使用 unsafe
的原因可能与其它编程语言还有所不同。
说来尴尬,unsafe
的存在主要是因为 Rust 的静态检查太强了,但是强就算了,它还很保守,这就会导致当编译器在分析代码时,一些正确代码会因为编译器无法分析出它的所有正确性,结果将这段代码拒绝,导致编译错误。
这种保守的选择确实也没有错,毕竟安全就是要防微杜渐,但是对于使用者来说,就不是那么愉快的事了,特别是当配合 Rust 的所有权系统一起使用时,有个别问题是真的棘手和难以解决。
-举个例子,在之前的自引用章节中,我们就提到了相关的编译检查是很难绕过的,如果想要绕过,最常用的方法之一就是使用 unsafe
和 Pin
。
好在,当遇到这些情况时,我们可以使用 unsafe
来解决。此时,你需要替代编译器的部分职责对 unsafe
代码的正确性负责,例如在正常代码中不可能遇到的空指针解引用问题在 unsafe
中就可能会遇到,我们需要自己来处理好这些类似的问题。
至于 unsafe
存在的另一个原因就是:它必须要存在。原因是计算机底层的一些硬件就是不安全的,如果 Rust 只允许你做安全的操作,那一些任务就无法完成,换句话说,我们还怎么跟 C++ 干架?
Rust 的一个主要定位就是系统编程,众所周知,系统编程就是底层编程,往往需要直接跟操作系统打交道,甚至于去实现一个操作系统。而为了实现底层系统编程,unsafe
就是必不可少的。
在了解了为何会有 unsafe
后,我们再来看看,除了这些必要性,unsafe
还能给我们带来哪些超能力。
使用 unsafe
非常简单,只需要将对应的代码块标记下即可:
-fn main() { - let mut num = 5; - - let r1 = &num as *const i32; - - unsafe { - println!("r1 is: {}", *r1); - } -} -
上面代码中, r1
是一个裸指针(raw pointer),由于它具有破坏 Rust 内存安全的潜力,因此只能在 unsafe
代码块中使用,如果你去掉 unsafe {}
,编译器会立刻报错。
言归正传, unsafe
能赋予我们 5 种超能力,这些能力在安全的 Rust 代码中是无法获取的:
unsafe
或外部的函数unsafe
特征union
中的字段在本章中,我们将着重讲解裸指针和 FFI 的使用。
-曾经在 reddit
上有一个讨论还挺热闹的,是关于 unsafe
的命名是否合适,总之公有公理,婆有婆理,但有一点是不可否认的:虽然名称自带不安全,但是 Rust 依然提供了强大的安全支撑。
首先,unsafe
并不能绕过 Rust 的借用检查,也不能关闭任何 Rust 的安全检查规则,例如当你在 unsafe
中使用引用时,该有的检查一样都不会少。
因此 unsafe
能给大家提供的也仅仅是之前的 5 种超能力,在使用这 5 种能力时,编译器才不会进行内存安全方面的检查,最典型的就是使用裸指针(引用和裸指针有很大的区别)。
在网上充斥着这样的言论:千万不要使用 unsafe,因为它不安全
,甚至有些库会以没有 unsafe
代码作为噱头来吸引用户。事实上,大可不必,如果按照这个标准,Rust 的标准库也将不复存在!
Rust 中的 unsafe
其实没有那么可怕,虽然听上去很不安全,但是实际上 Rust 依然提供了很多机制来帮我们提升了安全性,因此不必像对待 Go 语言的 unsafe
那样去畏惧于使用 Rust 中的 unsafe
。
大致使用原则总结如下:没必要用时,就不要用,当有必要用时,就大胆用,但是尽量控制好边界,让 unsafe
的范围尽可能小。
unsafe
不安全,但是该用的时候就要用,在一些时候,它能帮助我们大幅降低代码实现的成本。
而作为使用者,你的水平决定了 unsafe
到底有多不安全,因此你需要在 unsafe
中小心谨慎地去访问内存。
即使做到小心谨慎,依然会有出错的可能性,但是 unsafe
语句块决定了:就算内存访问出错了,你也能立刻意识到,错误是在 unsafe
代码块中,而不花大量时间像无头苍蝇一样去寻找问题所在。
正因为此,写代码时要尽量控制好 unsafe
的边界大小,越小的 unsafe
越会让我们在未来感谢自己当初的选择。
除了控制边界大小,另一个很常用的方式就是在 unsafe
代码块外包裹一层 safe
的 API,例如一个函数声明为 safe 的,然后在其内部有一块儿是 unsafe
代码。
-- - -忍不住抱怨一句,内存安全方面的 bug ,是真心难查!
-
古龙有一部小说,名为"七种兵器",其中每一种都精妙绝伦,令人闻风丧胆,而 unsafe
也有五种兵器,它们可以让你拥有其它代码无法实现的能力,同时它们也像七种兵器一样令人闻风丧胆,下面一起来看看庐山真面目。
裸指针(raw pointer,又称原生指针) 在功能上跟引用类似,同时它也需要显式地注明可变性。但是又和引用有所不同,裸指针长这样: *const T
和 *mut T
,它们分别代表了不可变和可变。
大家在之前学过 *
操作符,知道它可以用于解引用,但是在裸指针 *const T
中,这里的 *
只是类型名称的一部分,并没有解引用的含义。
至此,我们已经学过三种类似指针的概念:引用、智能指针和裸指针。与前两者不同,裸指针:
-null
总之,裸指针跟 C 指针是非常像的,使用它需要以牺牲安全性为前提,但我们获得了更好的性能,也可以跟其它语言或硬件打交道。
-下面的代码基于值的引用同时创建了可变和不可变的裸指针:
---#![allow(unused)] -fn main() { -let mut num = 5; - -let r1 = &num as *const i32; -let r2 = &mut num as *mut i32; -} -
as
可以用于强制类型转换,在之前章节中有讲解。在这里,我们将引用 &num / &mut num
强转为相应的裸指针 *const i32 / *mut i32
。
细心的同学可能会发现,在这段代码中并没有 unsafe
的身影,原因在于:创建裸指针是安全的行为,而解引用裸指针才是不安全的行为 :
-fn main() { - let mut num = 5; - - let r1 = &num as *const i32; - - unsafe { - println!("r1 is: {}", *r1); - } -} -
在上面例子中,我们基于现有的引用来创建裸指针,这种行为是很安全的。但是接下来的方式就不安全了:
---#![allow(unused)] -fn main() { -let address = 0x012345usize; -let r = address as *const i32; -} -
这里基于一个内存地址来创建裸指针,可以想像,这种行为是相当危险的。试图使用任意的内存地址往往是一种未定义的行为(undefined behavior),因为该内存地址有可能存在值,也有可能没有,就算有值,也大概率不是你需要的值。
-同时编译器也有可能会优化这段代码,会造成没有任何内存访问发生,甚至程序还可能发生段错误(segmentation fault)。总之,你几乎没有好的理由像上面这样实现代码,虽然它是可行的。
-如果真的要使用内存地址,也是类似下面的用法,先取地址,再使用,而不是凭空捏造一个地址:
--use std::{slice::from_raw_parts, str::from_utf8_unchecked}; - -// 获取字符串的内存地址和长度 -fn get_memory_location() -> (usize, usize) { - let string = "Hello World!"; - let pointer = string.as_ptr() as usize; - let length = string.len(); - (pointer, length) -} - -// 在指定的内存地址读取字符串 -fn get_str_at_location(pointer: usize, length: usize) -> &'static str { - unsafe { from_utf8_unchecked(from_raw_parts(pointer as *const u8, length)) } -} - -fn main() { - let (pointer, length) = get_memory_location(); - let message = get_str_at_location(pointer, length); - println!( - "The {} bytes at 0x{:X} stored: {}", - length, pointer, message - ); - // 如果大家想知道为何处理裸指针需要 `unsafe`,可以试着反注释以下代码 - // let message = get_str_at_location(1000, 10); -} -
以上代码同时还演示了访问非法内存地址会发生什么,大家可以试着去反注释这段代码试试。
---#![allow(unused)] -fn main() { -let a = 1; -let b: *const i32 = &a as *const i32; -let c: *const i32 = &a; -unsafe { - println!("{}", *c); -} -} -
使用 *
可以对裸指针进行解引用,由于该指针的内存安全性并没有任何保证,因此我们需要使用 unsafe
来包裹解引用的逻辑(切记,unsafe
语句块的范围一定要尽可能的小,具体原因在上一章节有讲)。
以上代码另一个值得注意的点就是:除了使用 as
来显式的转换,我们还使用了隐式的转换方式 let c: *const i32 = &a;
。在实际使用中,我们建议使用 as
来转换,因为这种显式的方式更有助于提醒用户:你在使用的指针是裸指针,需要小心。
还有一种创建裸指针的方式,那就是基于智能指针来创建:
---#![allow(unused)] -fn main() { -let a: Box<i32> = Box::new(10); -// 需要先解引用a -let b: *const i32 = &*a; -// 使用 into_raw 来创建 -let c: *const i32 = Box::into_raw(a); -} -
像之前代码演示的那样,使用裸指针可以让我们创建两个可变指针都指向同一个数据,如果使用安全的 Rust,你是无法做到这一点的,违背了借用规则,编译器会对我们进行无情的阻止。因此裸指针可以绕过借用规则,但是由此带来的数据竞争问题,就需要大家自己来处理了,总之,需要小心!
-既然这么危险,为何还要使用裸指针?除了之前提到的性能等原因,还有一个重要用途就是跟 C
语言的代码进行交互( FFI ),在讲解 FFI 之前,先来看看如何调用 unsafe 函数或方法。
unsafe
函数从外表上来看跟普通函数并无区别,唯一的区别就是它需要使用 unsafe fn
来进行定义。这种定义方式是为了告诉调用者:当调用此函数时,你需要注意它的相关需求,因为 Rust 无法担保调用者在使用该函数时能满足它所需的一切需求。
强制调用者加上 unsafe
语句块,就可以让他清晰的认识到,正在调用一个不安全的函数,需要小心看看文档,看看函数有哪些特别的要求需要被满足。
-unsafe fn dangerous() {} -fn main() { - dangerous(); -} -
如果试图像上面这样调用,编译器就会报错:
-error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
- --> src/main.rs:3:5
- |
-3 | dangerous();
- | ^^^^^^^^^^^ call to unsafe function
-
-按照报错提示,加上 unsafe
语句块后,就能顺利执行了:
--#![allow(unused)] -fn main() { -unsafe { - dangerous(); -} -} -
道理很简单,但一定要牢记在心:使用 unsafe 声明的函数时,一定要看看相关的文档,确定自己没有遗漏什么。
-还有,unsafe
无需俄罗斯套娃,在 unsafe
函数体中使用 unsafe
语句块是多余的行为。
一个函数包含了 unsafe
代码不代表我们需要将整个函数都定义为 unsafe fn
。事实上,在标准库中有大量的安全函数,它们内部都包含了 unsafe
代码块,下面我们一起来看看一个很好用的标准库函数:split_at_mut
。
大家可以想象一下这个场景:需要将一个数组分成两个切片,且每一个切片都要求是可变的。类似需求在安全 Rust 中是很难实现的,因为要对同一个数组做两个可变借用:
--fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) { - let len = slice.len(); - - assert!(mid <= len); - - (&mut slice[..mid], &mut slice[mid..]) -} - -fn main() { - let mut v = vec![1, 2, 3, 4, 5, 6]; - - let r = &mut v[..]; - - let (a, b) = split_at_mut(r, 3); - - assert_eq!(a, &mut [1, 2, 3]); - assert_eq!(b, &mut [4, 5, 6]); -} -
上面代码一眼看过去就知道会报错,因为我们试图在自定义的 split_at_mut
函数中,可变借用 slice
两次:
error[E0499]: cannot borrow `*slice` as mutable more than once at a time
- --> src/main.rs:6:30
- |
-1 | fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
- | - let's call the lifetime of this reference `'1`
-...
-6 | (&mut slice[..mid], &mut slice[mid..])
- | -------------------------^^^^^--------
- | | | |
- | | | second mutable borrow occurs here
- | | first mutable borrow occurs here
- | returning this value requires that `*slice` is borrowed for `'1`
-
-对于 Rust 的借用检查器来说,它无法理解我们是分别借用了同一个切片的两个不同部分,但事实上,这种行为是没任何问题的,毕竟两个借用没有任何重叠之处。总之,不太聪明的 Rust 编译器阻碍了我们用这种简单且安全的方式去实现,那只能剑走偏锋,试试 unsafe
了。
-use std::slice; - -fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) { - let len = slice.len(); - let ptr = slice.as_mut_ptr(); - - assert!(mid <= len); - - unsafe { - ( - slice::from_raw_parts_mut(ptr, mid), - slice::from_raw_parts_mut(ptr.add(mid), len - mid), - ) - } -} - -fn main() { - let mut v = vec![1, 2, 3, 4, 5, 6]; - - let r = &mut v[..]; - - let (a, b) = split_at_mut(r, 3); - - assert_eq!(a, &mut [1, 2, 3]); - assert_eq!(b, &mut [4, 5, 6]); -} -
相比安全实现,这段代码就显得没那么好理解了,甚至于我们还需要像 C 语言那样,通过指针地址的偏移去控制数组的分割。
-as_mut_ptr
会返回指向 slice
首地址的裸指针 *mut i32
slice::from_raw_parts_mut
函数通过指针和长度来创建一个新的切片,简单来说,该切片的初始地址是 ptr
,长度为 mid
ptr.add(mid)
可以获取第二个切片的初始地址,由于切片中的元素是 i32
类型,每个元素都占用了 4 个字节的内存大小,因此我们不能简单的用 ptr + mid
来作为初始地址,而应该使用 ptr + 4 * mid
,但是这种使用方式并不安全,因此 .add
方法是最佳选择由于 slice::from_raw_parts_mut
使用裸指针作为参数,因此它是一个 unsafe fn
,我们在使用它时,就必须用 unsafe
语句块进行包裹,类似的,.add
方法也是如此(还是那句话,不要将无关的代码包含在 unsafe
语句块中)。
部分同学可能会有疑问,那这段代码我们怎么保证 unsafe
中使用的裸指针 ptr
和 ptr.add(mid)
是合法的呢?秘诀就在于 assert!(mid <= len);
,通过这个断言,我们保证了裸指针一定指向了 slice
切片中的某个元素,而不是一个莫名其妙的内存地址。
再回到我们的主题:虽然 split_at_mut 使用了 unsafe
,但我们无需将其声明为 unsafe fn
,这种情况下就是使用安全的抽象包裹 unsafe
代码,这里的 unsafe
使用是非常安全的,因为我们从合法数据中创建了的合法指针。
与之对比,下面的代码就非常危险了:
---#![allow(unused)] -fn main() { -use std::slice; - -let address = 0x01234usize; -let r = address as *mut i32; - -let slice: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) }; -println!("{:?}",slice); -} -
这段代码从一个任意的内存地址,创建了一个 10000 长度的 i32
切片,我们无法保证切片中的元素都是合法的 i32
值,这种访问就是一种未定义行为(UB = undefined behavior)。
zsh: segmentation fault
-
-不出所料,运行后看到了一个段错误。
-FFI
(Foreign Function Interface)可以用来与其它语言进行交互,但是并不是所有语言都这么称呼,例如 Java 称之为 JNI(Java Native Interface)
。
FFI
之所以存在是由于现实中很多代码库都是由不同语言编写的,如果我们需要使用某个库,但是它是由其它语言编写的,那么往往只有两个选择:
FFI
前者相当不错,但是在很多时候,并没有那么多时间去重写,因此 FFI
就成了最佳选择。回到 Rust 语言上,由于这门语言依然很年轻,一些生态是缺失的,我们在写一些不是那么大众的项目时,可能会同时遇到没有相应的 Rust 库可用的尴尬境况,此时通过 FFI
去调用 C 语言的库就成了相当棒的选择。
还有在将 C/C++ 的代码重构为 Rust 时,先将相关代码引入到 Rust 项目中,然后逐步重构,也是不错的(为什么用不错来形容?因为重构一个有一定规模的 C/C++ 项目远没有想象中美好,因此最好的选择还是对于新项目使用 Rust 实现,老项目。。就让它先运行着吧)。
-当然,除了 FFI
还有一个办法可以解决跨语言调用的问题,那就是将其作为一个独立的服务,然后使用网络调用的方式去访问,HTTP,gRPC 都可以。
言归正传,之前我们提到 unsafe
的另一个重要目的就是对 FFI
提供支持,它的全称是 Foreign Function Interface
,顾名思义,通过 FFI
, 我们的 Rust 代码可以跟其它语言的外部代码进行交互。
下面的例子演示了如何调用 C 标准库中的 abs
函数:
-extern "C" { - fn abs(input: i32) -> i32; -} - -fn main() { - unsafe { - println!("Absolute value of -3 according to C: {}", abs(-3)); - } -} -
C 语言的代码定义在了 extern
代码块中, 而 extern
必须使用 unsafe
才能进行进行调用,原因在于其它语言的代码并不会强制执行 Rust 的规则,因此 Rust 无法对这些代码进行检查,最终还是要靠开发者自己来保证代码的正确性和程序的安全性。
在 extern "C"
代码块中,我们列出了想要调用的外部函数的签名。其中 "C"
定义了外部函数所使用的应用二进制接口ABI
(Application Binary Interface):ABI
定义了如何在汇编层面来调用该函数。在所有 ABI
中,C 语言的是最常见的。
在 Rust 中调用其它语言的函数是让 Rust 利用其他语言的生态,那反过来可以吗?其他语言可以利用 Rust 的生态不?答案是肯定的。
-我们可以使用 extern
来创建一个接口,其它语言可以通过该接口来调用相关的 Rust 函数。但是此处的语法与之前有所不同,之前用的是语句块,而这里是在函数定义时加上 extern
关键字,当然,别忘了指定相应的 ABI
:
--#![allow(unused)] -fn main() { -#[no_mangle] -pub extern "C" fn call_from_c() { - println!("Just called a Rust function from C!"); -} -} -
上面的代码可以让 call_from_c
函数被 C
语言的代码调用,当然,前提是将其编译成一个共享库,然后链接到 C 语言中。
这里还有一个比较奇怪的注解 #[no_mangle]
,它用于告诉 Rust 编译器:不要乱改函数的名称。 Mangling
的定义是:当 Rust 因为编译需要去修改函数的名称,例如为了让名称包含更多的信息,这样其它的编译部分就能从该名称获取相应的信息,这种修改会导致函数名变得相当不可读。
因此,为了让 Rust 函数能顺利被其它语言调用,我们必须要禁止掉该功能。
-这部分我们在之前的全局变量章节中有过详细介绍,这里就不再赘述,大家可以前往此章节阅读。
-说实话,unsafe
的特征确实不多见,如果大家还记得的话,我们在之前的 Send 和 Sync 章节中实现过 unsafe
特征 Send
。
之所以会有 unsafe
的特征,是因为该特征至少有一个方法包含有编译器无法验证的内容。unsafe
特征的声明很简单:
-unsafe trait Foo { - // 方法列表 -} - -unsafe impl Foo for i32 { - // 实现相应的方法 -} - -fn main() {} -
通过 unsafe impl
的使用,我们告诉编译器:相应的正确性由我们自己来保证。
再回到刚提到的 Send
特征,若我们的类型中的所有字段都实现了 Send
特征,那该类型也会自动实现 Send
。但是如果我们想要为某个类型手动实现 Send
,例如为裸指针,那么就必须使用 unsafe
,相关的代码在之前的链接中也有,大家可以移步查看。
总之,Send
特征标记为 unsafe
是因为 Rust 无法验证我们的类型是否能在线程间安全的传递,因此就需要通过 unsafe
来告诉编译器,它无需操心,剩下的交给我们自己来处理。
截止目前,我们还没有介绍过 union
,原因很简单,它主要用于跟 C
代码进行交互。
访问 union
的字段是不安全的,因为 Rust 无法保证当前存储在 union
实例中的数据类型。
--#![allow(unused)] -fn main() { -#[repr(C)] -union MyUnion { - f1: u32, - f2: f32, -} -} -
上从可以看出,union
的使用方式跟结构体确实很相似,但是前者的所有字段都共享同一个存储空间,意味着往 union
的某个字段写入值,会导致其它字段的值会被覆盖。
关于 union
的更多信息,可以在这里查看。
由于 unsafe
和 FFI
在 Rust 的使用场景中是相当常见的(例如相对于 Go 的 unsafe
来说),因此社区已经开发出了相当一部分实用的工具,可以改善相应的开发体验。
对于 FFI
调用来说,保证接口的正确性是非常重要的,这两个库可以帮我们自动生成相应的接口,其中 rust-bindgen
用于在 Rust 中访问 C 代码,而 cbindgen
则反之。
下面以 rust-bindgen
为例,来看看如何自动生成调用 C 的代码,首先下面是 C 代码:
typedef struct Doggo {
- int many;
- char wow;
-} Doggo;
-
-void eleven_out_of_ten_majestic_af(Doggo* pupper);
-
-下面是自动生成的可以调用上面代码的 Rust 代码:
---#![allow(unused)] -fn main() { -/* automatically generated by rust-bindgen 0.99.9 */ - -#[repr(C)] -pub struct Doggo { - pub many: ::std::os::raw::c_int, - pub wow: ::std::os::raw::c_char, -} - -extern "C" { - pub fn eleven_out_of_ten_majestic_af(pupper: *mut Doggo); -} -} -
如果需要跟 C++ 代码交互,非常推荐使用 cxx
,它提供了双向的调用,最大的优点就是安全:是的,你无需通过 unsafe
来使用它!
miri
可以生成 Rust 的中间层表示 MIR,对于编译器来说,我们的 Rust 代码首先会被编译为 MIR ,然后再提交给 LLVM 进行处理。
可以通过 rustup component add miri
来安装它,并通过 cargo miri
来使用,同时还可以使用 cargo miri test
来运行测试代码。
miri
可以帮助我们检查常见的未定义行为(UB = Undefined Behavior),以下列出了一部分:
但是需要注意的是,它只能帮助识别被执行代码路径的风险,那些未被执行到的代码是没办法被识别的。
-官方的 clippy
检查器提供了有限的 unsafe
支持,虽然不多,但是至少有一定帮助。例如 missing_safety_docs
检查可以帮助我们检查哪些 unsafe
函数遗漏了文档。
需要注意的是: Rust 编译器并不会默认开启所有检查,大家可以调用 rustc -W help
来看看最新的信息。
prusti
需要大家自己来构建一个证明,然后通过它证明代码中的不变量是正确被使用的,当你在安全代码中使用不安全的不变量时,就会非常有用。具体的使用文档见这里。
在 Rust Fuzz Book 中列出了一些 Rust 可以使用的模糊测试方法。
-同时,我们还可以使用 rutenspitz
这个过程宏来测试有状态的代码,例如数据结构。
至此,unsafe
的五种兵器已介绍完毕,大家是否意犹未尽?我想说的是,就算意犹未尽,也没有其它兵器了。
就像上一章中所提到的,unsafe
只应该用于这五种场景,其它场景,你应该坚决的使用安全的代码,否则就会像 actix-web
的前作者一样,被很多人议论,甚至被喷。。。
总之,能不使用 unsafe
一定不要使用,就算使用也要控制好边界,让范围尽可能的小,就像本章的例子一样,只有真的需要 unsafe
的代码,才应该包含其中, 而不是将无关代码也纳入进来。
在本书的各个部分中,我们讨论了可应用于结构体和枚举定义的 derive
属性。被 derive
标记的对象会自动实现对应的默认特征代码,继承相应的功能。
在本附录中,我们列举了所有标准库存在的 derive
特征,每个特征覆盖了以下内容
derive
提供什么样的特征实现如果你希望不同于 derive
属性所提供的行为,请查阅 标准库文档 中每个特征的细节以了解如何手动实现它们。
除了本文列出的特征之外,标准库中定义的其它特征不能通过 derive
在类型上实现。这些特征不存在有意义的默认行为,所以由你负责以合理的方式实现它们。
一个无法被派生的特征例子是为终端用户处理格式化的 Display
。你应该时常考虑使用合适的方法来为终端用户显示一个类型。终端用户应该看到类型的什么部分?他们会找出相关部分吗?对他们来说最关心的数据格式是什么样的?Rust 编译器没有这样的洞察力,因此无法为你提供合适的默认行为。
本附录所提供的可派生特征列表其实并不全面:库可以为其内部的特征实现 derive
,因此除了本文列出的标准库 derive
之外,还有很多很多其它库的 derive
。实现 derive
涉及到过程宏的应用,这在宏章节中有介绍。
Debug
Debug
特征可以让指定对象输出调试格式的字符串,通过在 {}
占位符中增加 :?
表明,例如println!("show you some debug info: {:?}", MyObject);
.
Debug
特征允许以调试为目的来打印一个类型的实例,所以程序员可以在执行过程中看到该实例的具体信息。
例如,在使用 assert_eq!
宏时, Debug
特征是必须的。如果断言失败,这个宏就把给定实例的值打印出来,这样程序员就能看到两个实例为什么不相等。
PartialEq
和 Eq
PartialEq
特征可以比较一个类型的实例以检查是否相等,并开启了 ==
和 !=
运算符的功能。
派生的 PartialEq
实现了 eq
方法。当 PartialEq
在结构体上派生时,只有所有 的字段都相等时两个实例才相等,同时只要有任何字段不相等则两个实例就不相等。当在枚举上派生时,每一个成员都和其自身相等,且和其他成员都不相等。
例如,当使用 assert_eq!
宏时,需要比较一个类型的两个实例是否相等,则 PartialEq
特征是必须的。
Eq
特征没有方法, 其作用是表明每一个被标记类型的值都等于其自身。 Eq
特征只能应用于那些实现了 PartialEq
的类型,但并非所有实现了 PartialEq
的类型都可以实现 Eq
。浮点类型就是一个例子:浮点数的实现表明两个非数字( NaN
,not-a-number)值是互不相等的。
例如,对于一个 HashMap<K, V>
中的 key 来说, Eq
是必须的,这样 HashMap<K, V>
就可以知道两个 key 是否一样。
PartialOrd
和 Ord
PartialOrd
特征可以让一个类型的多个实例实现排序功能。实现了 PartialOrd
的类型可以使用 <
、 >
、<=
和 >=
操作符。一个类型想要实现 PartialOrd
的前提是该类型已经实现了 PartialEq
。
派生 PartialOrd
实现了 partial_cmp
方法,一般情况下其返回一个 Option<Ordering>
,但是当给定的值无法进行排序时将返回 None
。尽管大多数类型的值都可以比较,但一个无法产生顺序的例子是:浮点类型的非数字值。当在浮点数上调用 partial_cmp
时, NaN
的浮点数将返回 None
。
当在结构体上派生时, PartialOrd
以在结构体定义中字段出现的顺序比较每个字段的值来比较两个实例。当在枚举上派生时,认为在枚举定义中声明较早的枚举项小于其后的枚举项。
例如,对于来自于 rand
包的 gen_range
方法来说,当在一个大值和小值指定的范围内生成一个随机值时, PartialOrd
trait 是必须的。
对于派生了 Ord
特征的类型,任何两个该类型的值都能进行排序。 Ord
特征实现了 cmp
方法,它返回一个 Ordering
而不是 Option<Ordering>
,因为总存在一个合法的顺序。一个类型要想使用 Ord
特征,它必须要先实现 PartialOrd
和 Eq
。当在结构体或枚举上派生时, cmp
方法 和 PartialOrd
的 partial_cmp
方法表现是一致的。
例如,当在 BTreeSet<T>
(一种基于有序值存储数据的数据结构)上存值时, Ord
是必须的。
Clone
和 Copy
Clone
特征用于创建一个值的深拷贝(deep copy),复制过程可能包含代码的执行以及堆上数据的复制。查阅 通过 Clone 进行深拷贝获取有关 Clone
的更多信息。
派生 Clone
实现了 clone
方法,当为整个的类型实现 Clone
时,在该类型的每一部分上都会调用 clone
方法。这意味着类型中所有字段或值也必须实现了 Clone
,这样才能够派生 Clone
。
例如,当在一个切片(slice)上调用 to_vec
方法时, Clone
是必须的。切片只是一个引用,并不拥有其所包含的实例数据,但是从 to_vec
中返回的 Vector 需要拥有实例数据,因此, to_vec
需要在每个元素上调用 clone
来逐个复制。因此,存储在切片中的类型必须实现 Clone
。
Copy
特征允许你通过只拷贝存储在栈上的数据来复制值(浅拷贝),而无需复制存储在堆上的底层数据。查阅 通过 Copy 复制栈数据 的部分来获取有关 Copy
的更多信息。
实际上 Copy
特征并不阻止你在实现时使用了深拷贝,只是,我们不应该这么做,毕竟遵循一个语言的惯例是很重要的。当用户看到 Copy
时,潜意识就应该知道这是浅拷贝,复制一个值会非常快。
当一个类型的内部字段全部实现了 Copy
时,你就可以在该类型上派上 Copy
特征。 一个类型如果要实现 Copy
它必须先实现 Clone
,因为一个类型实现 Clone
后,就等于顺便实现了 Copy
。
总之, Copy
拥有更好的性能,当浅拷贝足够的时候,就不要使用 Clone
,不然会导致你的代码运行更慢,对于性能优化来说,一个很大的方面就是减少热点路径深拷贝的发生。
Hash
Hash
特征允许你使用 hash
函数把一个任意大小的实例映射到一个固定大小的值上。派生 Hash
实现了 hash
方法,对某个类型进行 hash
调用,其实就是对该类型下每个字段单独进行 hash
调用,然后把结果进行汇总,这意味着该类型下的所有的字段也必须实现了 Hash
,这样才能够派生 Hash
。
例如,在 HashMap<K, V>
上存储数据,存放 key 的时候, Hash
是必须的。
Default
Default
特征会帮你创建一个类型的默认值。 派生 Default
意味着自动实现了 default
函数。 default
函数的派生实现调用了类型每部分的 default
函数,这意味着类型中所有的字段也必须实现了 Default
,这样才能够派生 Default
。
Default::default
函数通常结合结构体更新语法一起使用,这在第五章的 结构体更新语法 部分有讨论。可以自定义一个结构体的一小部分字段而剩余字段则使用 ..Default::default()
设置为默认值。
例如,当你在 Option<T>
实例上使用 unwrap_or_default
方法时, Default
特征是必须的。如果 Option<T>
是 None
的话, unwrap_or_default
方法将返回 T
类型的 Default::default
的结果。
在语句与表达式章节中,我们对表达式有过介绍,下面对这些常用表达式进行一一说明。
---#![allow(unused)] -fn main() { -let n = 3; -let s = "test"; -} -
-fn main() { - let var1 = 10; - - let var2 = if var1 >= 10 { - var1 - } else { - var1 + 10 - }; - - println!("{}", var2); -} -
通过 if
表达式将值赋予 var2
。
你还可以在循环中结合 continue
、break
来使用:
--#![allow(unused)] -fn main() { -let mut v = 0; -for i in 1..10 { - v = if i == 9 { - continue - } else { - i - } -} -println!("{}", v); -} -
--#![allow(unused)] -fn main() { -let o = Some(3); -let v = if let Some(x) = o { - x -} else { - 0 -}; -} -
--#![allow(unused)] -fn main() { -let o = Some(3); -let v = match o { - Some(x) => x, - _ => 0 -}; -} -
--#![allow(unused)] -fn main() { -let mut n = 0; -let v = loop { - if n == 10 { - break n - } - n += 1; -}; -} -
- - --#![allow(unused)] -fn main() { -let mut n = 0; -let v = { - println!("before: {}", n); - n += 1; - println!("after: {}", n); - n -}; -println!("{}", v); -} -
下面的列表包含 Rust 中正在使用或者以后会用到的关键字。因此,这些关键字不能被用作标识符(除了原生标识符),包括函数、变量、参数、结构体字段、模块、包、常量、宏、静态值、属性、类型、特征或生命周期。
-如下关键字目前有对应其描述的功能。
-as
- 强制类型转换,或use
和 extern crate
包和模块引入语句中的重命名break
- 立刻退出循环const
- 定义常量或原生常量指针(constant raw pointer)continue
- 继续进入下一次循环迭代crate
- 链接外部包dyn
- 动态分发特征对象else
- 作为 if
和 if let
控制流结构的 fallbackenum
- 定义一个枚举类型extern
- 链接一个外部包,或者一个宏变量(该变量定义在另外一个包中)false
- 布尔值 false
fn
- 定义一个函数或 函数指针类型 (function pointer type)for
- 遍历一个迭代器或实现一个 trait 或者指定一个更高级的生命周期if
- 基于条件表达式的结果来执行相应的分支impl
- 为结构体或者特征实现具体功能in
- for
循环语法的一部分let
- 绑定一个变量loop
- 无条件循环match
- 模式匹配mod
- 定义一个模块move
- 使闭包获取其所捕获项的所有权mut
- 在引用、裸指针或模式绑定中使用,表明变量是可变的pub
- 表示结构体字段、impl
块或模块的公共可见性ref
- 通过引用绑定return
- 从函数中返回Self
- 实现特征类型的类型别名self
- 表示方法本身或当前模块static
- 表示全局变量或在整个程序执行期间保持其生命周期struct
- 定义一个结构体super
- 表示当前模块的父模块trait
- 定义一个特征true
- 布尔值 true
type
- 定义一个类型别名或关联类型unsafe
- 表示不安全的代码、函数、特征或实现use
- 在当前代码范围内(模块或者花括号对)引入外部的包、模块等where
- 表示一个约束类型的从句while
- 基于一个表达式的结果判断是否继续循环如下关键字没有任何功能,不过由 Rust 保留以备将来的应用。
-abstract
async
await
become
box
do
final
macro
override
priv
try
typeof
unsized
virtual
yield
原生标识符(Raw identifiers)允许你使用通常不能使用的关键字,其带有 r#
前缀。
例如,match
是关键字。如果尝试编译如下使用 match
作为名字的函数:
fn match(needle: &str, haystack: &str) -> bool {
- haystack.contains(needle)
-}
-
-会得到这个错误:
-error: expected identifier, found keyword `match`
- --> src/main.rs:4:4
- |
-4 | fn match(needle: &str, haystack: &str) -> bool {
- | ^^^^^ expected identifier, found keyword
-
-该错误表示你不能将关键字 match
用作函数标识符。你可以使用原生标识符将 match
作为函数名称使用:
文件名: src/main.rs
--fn r#match(needle: &str, haystack: &str) -> bool { - haystack.contains(needle) -} - -fn main() { - assert!(r#match("foo", "foobar")); -} -
此代码编译没有任何错误。注意 r#
前缀需同时用于函数名定义和 main
函数中的调用。
原生标识符允许使用你选择的任何单词作为标识符,即使该单词恰好是保留关键字。 此外,原生标识符允许你使用其它 Rust 版本编写的库。比如,try
在 Rust 2015 edition 中不是关键字,却在 Rust 2018 edition 是关键字。所以如果用 2015 edition 编写的库中带有 try
函数,在 2018 edition 中调用时就需要使用原始标识符语法,在这里是 r#try
。
该附录包含了 Rust 目前出现过的各种符号,这些符号之前都分散在各个章节中。
-表 B-1 包含了 Rust 中的运算符、上下文中的示例、简短解释以及该运算符是否可重载。如果一个运算符是可重载的,则该运算符上用于重载的特征也会列出。
-下表中,expr
是表达式,ident
是标识符,type
是类型,var
是变量,trait
是特征,pat
是匹配分支(pattern)。
表 B-1:运算符
-运算符 | 示例 | 解释 | 是否可重载 |
---|---|---|---|
! | ident!(...) , ident!{...} , ident![...] | 宏展开 | |
! | !expr | 按位非或逻辑非 | Not |
!= | var != expr | 不等比较 | PartialEq |
% | expr % expr | 算术求余 | Rem |
%= | var %= expr | 算术求余与赋值 | RemAssign |
& | &expr , &mut expr | 借用 | |
& | &type , &mut type , &'a type , &'a mut type | 借用指针类型 | |
& | expr & expr | 按位与 | BitAnd |
&= | var &= expr | 按位与及赋值 | BitAndAssign |
&& | expr && expr | 逻辑与 | |
* | expr * expr | 算术乘法 | Mul |
*= | var *= expr | 算术乘法与赋值 | MulAssign |
* | *expr | 解引用 | |
* | *const type , *mut type | 裸指针 | |
+ | trait + trait , 'a + trait | 复合类型限制 | |
+ | expr + expr | 算术加法 | Add |
+= | var += expr | 算术加法与赋值 | AddAssign |
, | expr, expr | 参数以及元素分隔符 | |
- | - expr | 算术取负 | Neg |
- | expr - expr | 算术减法 | Sub |
-= | var -= expr | 算术减法与赋值 | SubAssign |
-> | fn(...) -> type , |...| -> type | 函数与闭包,返回类型 | |
. | expr.ident | 成员访问 | |
.. | .. , expr.. , ..expr , expr..expr | 右半开区间 | PartialOrd |
..= | ..=expr , expr..=expr | 闭合区间 | PartialOrd |
.. | ..expr | 结构体更新语法 | |
.. | variant(x, ..) , struct_type { x, .. } | “代表剩余部分”的模式绑定 | |
... | expr...expr | (不推荐使用,用..= 替代) 闭合区间 | |
/ | expr / expr | 算术除法 | Div |
/= | var /= expr | 算术除法与赋值 | DivAssign |
: | pat: type , ident: type | 约束 | |
: | ident: expr | 结构体字段初始化 | |
: | 'a: loop {...} | 循环标志 | |
; | expr; | 语句和语句结束符 | |
; | [...; len] | 固定大小数组语法的部分 | |
<< | expr << expr | 左移 | Shl |
<<= | var <<= expr | 左移与赋值 | ShlAssign |
< | expr < expr | 小于比较 | PartialOrd |
<= | expr <= expr | 小于等于比较 | PartialOrd |
= | var = expr , ident = type | 赋值/等值 | |
== | expr == expr | 等于比较 | PartialEq |
=> | pat => expr | 匹配分支语法的部分 | |
> | expr > expr | 大于比较 | PartialOrd |
>= | expr >= expr | 大于等于比较 | PartialOrd |
>> | expr >> expr | 右移 | Shr |
>>= | var >>= expr | 右移与赋值 | ShrAssign |
@ | ident @ pat | 模式绑定 | |
^ | expr ^ expr | 按位异或 | BitXor |
^= | var ^= expr | 按位异或与赋值 | BitXorAssign |
| | pat | pat | 模式匹配中的多个可选条件 | |
| | expr | expr | 按位或 | BitOr |
|= | var |= expr | 按位或与赋值 | BitOrAssign |
|| | expr || expr | 逻辑或 | |
? | expr? | 错误传播 |
表 B-2:独立语法
-符号 | 解释 |
---|---|
'ident | 生命周期名称或循环标签 |
...u8 , ...i32 , ...f64 , ...usize , 等 | 指定类型的数值常量 |
"..." | 字符串常量 |
r"..." , r#"..."# , r##"..."## , etc. | 原生字符串, 未转义字符 |
b"..." | 将 &str 转换成 &[u8; N] 类型的数组 |
br"..." , br#"..."# , br##"..."## , 等 | 原生字节字符串,原生和字节字符串字面值的结合 |
'...' | Char 字符 |
b'...' | ASCII 字节 |
|...| expr | 闭包 |
! | 代表总是空的类型,用于发散函数(无返回值函数) |
_ | 模式绑定中表示忽略的意思;也用于增强整型字面值的可读性 |
表 B-3 展示了模块和对象调用路径的语法。
-表 B-3:路径相关语法
-符号 | 解释 |
---|---|
ident::ident | 命名空间路径 |
::path | 从当前的包的根路径开始的相对路径 |
self::path | 与当前模块相对的路径(如一个显式相对路径) |
super::path | 与父模块相对的路径 |
type::ident , <type as trait>::ident | 关联常量、关联函数、关联类型 |
<type>::... | 不可以被直接命名的关联项类型(如 <&T>::... ,<[T]>::... , 等) |
trait::method(...) | 使用特征名进行方法调用,以消除方法调用的二义性 |
type::method(...) | 使用类型名进行方法调用, 以消除方法调用的二义性 |
<type as trait>::method(...) | 将类型转换为特征,再进行方法调用,以消除方法调用的二义性 |
表 B-4 展示了使用泛型参数时用到的符号。
-表 B-4:泛型
-符号 | 解释 |
---|---|
path<...> | 为一个类型中的泛型指定具体参数(如 Vec<u8> ) |
path::<...> , method::<...> | 为一个泛型、函数或表达式中的方法指定具体参数,通常指双冒号(turbofish)(如 "42".parse::<i32>() ) |
fn ident<...> ... | 泛型函数定义 |
struct ident<...> ... | 泛型结构体定义 |
enum ident<...> ... | 泛型枚举定义 |
impl<...> ... | 实现泛型 |
for<...> type | 高阶生命周期限制 |
type<ident=type> | 泛型,其一个或多个相关类型必须被指定为特定类型(如 Iterator<Item=T> ) |
表 B-5 展示了使用特征约束来限制泛型参数的符号。
-表 B-5:特征约束
-符号 | 解释 |
---|---|
T: U | 泛型参数 T 需实现U 类型 |
T: 'a | 泛型 T 的生命周期必须长于 'a (意味着该类型不能传递包含生命周期短于 'a 的任何引用) |
T : 'static | 泛型 T 只能使用声明周期为'static 的引用 |
'b: 'a | 生命周期'b 必须长于生命周期'a |
T: ?Sized | 使用一个不定大小的泛型类型 |
'a + trait , trait + trait | 多个类型组成的复合类型限制 |
表 B-6 展示了宏以及在一个对象上定义属性的符号。
-表 B-6:宏与属性
-符号 | 解释 |
---|---|
#[meta] | 外部属性 |
#![meta] | 内部属性 |
$ident | 宏替换 |
$ident:kind | 宏捕获 |
$(…)… | 宏重复 |
ident!(...) , ident!{...} , ident![...] | 宏调用 |
表 B-7 展示了写注释的符号。
-表 B-7:注释
-符号 | 注释 |
---|---|
// | 行注释 |
//! | 内部行(hang)文档注释 |
/// | 外部行文档注释 |
/*...*/ | 块注释 |
/*!...*/ | 内部块文档注释 |
/**...*/ | 外部块文档注释 |
表 B-8 展示了出现在使用元组时的符号。
-表 B-8:元组
-符号 | 解释 |
---|---|
() | 空元组(亦称单元),即是字面值也是类型 |
(expr) | 括号表达式 |
(expr,) | 单一元素元组表达式 |
(type,) | 单一元素元组类型 |
(expr, ...) | 元组表达式 |
(type, ...) | 元组类型 |
expr(expr, ...) | 函数调用表达式;也用于初始化元组结构体 struct 以及元组枚举 enum 变体 |
expr.0 , expr.1 , etc. | 元组索引 |
表 B-9 展示了使用大括号的上下文。
-表 B-9:大括号
-符号 | 解释 |
---|---|
{...} | 代码块表达式 |
Type {...} | 结构体字面值 |
表 B-10 展示了使用方括号的上下文。
-表 B-10:方括号
-符号 | 解释 |
---|---|
[...] | 数组 |
[expr; len] | 数组里包含len 个expr |
[type; len] | 数组里包含了len 个type 类型的对象 |
expr[expr] | 集合索引。 重载(Index , IndexMut ) |
expr[..] , expr[a..] , expr[..b] , expr[a..b] | 集合索引,也称为集合切片,索引要实现以下特征中的其中一个:Range ,RangeFrom ,RangeTo 或 RangeFull |
早在第一章,我们见过 cargo new
在 Cargo.toml 中增加了一些有关 edition
的元数据。本附录将解释其意义!
与其它语言相比,Rust 的更新迭代较为频繁(得益于精心设计过的发布流程以及 Rust 语言开发者团队管理):
-好处在于,可以满足不同的用户群体的需求:
-在本文档编写时,Rust 已经有三个版本:Rust 2015、2018、2021。本书基于 Rust 2021 edition
编写。
Cargo.toml 中的 edition
字段表明代码应该使用哪个版本编译。如果该字段不存在,其默认为 2021
以提供后向兼容性。
每个项目都可以选择不同于默认的 Rust 2021 edition
的版本。这样,版本可能会包含不兼容的修改,比如新版本中新增的关键字可能会与老代码中的标识符冲突并导致错误。不过,除非你选择应用这些修改,否则旧代码依然能够被编译,即便你升级了编译器版本。
所有 Rust 编译器都支持任何之前存在的编译器版本,并可以链接任何支持版本的包。编译器修改只影响最初的解析代码的过程。因此,如果你使用 Rust 2021
而某个依赖使用 Rust 2018
,你的项目仍旧能够编译并使用该依赖。反之,若项目使用 Rust 2018
而依赖使用 Rust 2021
亦可工作。
有一点需要明确:大部分功能在所有版本中都能使用。开发者使用任何 Rust 版本将能继续接收最新稳定版的改进。然而在一些情况,主要是增加了新关键字的时候,则可能出现了只能用于新版本的功能。只需切换版本即可利用新版本的功能。
-请查看 Edition Guide 了解更多细节,这是一个完全介绍版本的书籍,包括如何通过 cargo fix
自动将代码迁移到新版本。
本附录介绍 Rust 语言自身是如何开发的以及这如何影响作为 Rust 开发者的你。
-作为一个语言,Rust 十分 注重代码的稳定性。我们希望 Rust 成为你代码坚实的基础,假如持续地有东西在变,这个希望就实现不了。但与此同时,如果不能实验新功能的话,在发布之前我们又无法发现其中重大的缺陷,而一旦发布便再也没有修改的机会了。
-对于这个问题我们的解决方案被称为 “无停滞稳定”(“stability without stagnation”),其指导性原则是:无需担心升级到最新的稳定版 Rust。每次升级应该是无痛的,并应带来新功能,更少的 Bug 和更快的编译速度。
-开发 Rust 语言是基于一个火车时刻表来进行的:所有的开发工作在 Master 分支上完成,但是发布就像火车时刻表一样,拥有不同的时间,发布采用的软件发布列车模型,被用于思科 IOS 和等其它软件项目。Rust 有三个 发布通道(release channel):
-大部分 Rust 开发者主要采用稳定版通道,不过希望实验新功能的开发者可能会使用 nightly 或 beta 版。
-如下是一个开发和发布过程如何运转的例子:假设 Rust 团队正在进行 Rust 1.5 的发布工作。该版本发布于 2015 年 12 月,这个版本和时间显然比较老了,不过这里只是为了提供一个真实的版本。Rust 新增了一项功能:一个 master
分支的新提交。每天晚上,会产生一个新的 nightly 版本。每天都是发布版本的日子,而这些发布由发布基础设施自动完成。所以随着时间推移,发布轨迹看起来像这样,版本一天一发:
nightly: * - - * - - *
-
-每 6 周时间,是准备发布新版本的时候了!Rust 仓库的 beta
分支会从用于 nightly 的 master
分支产生。现在,有了两个发布版本:
nightly: * - - * - - *
- |
-beta: *
-
-大部分 Rust 用户不会主要使用 beta 版本,不过在 CI 系统中对 beta 版本进行测试能够帮助 Rust 发现可能的回归缺陷(regression)。同时,每天仍产生 nightly 发布:
-nightly: * - - * - - * - - * - - *
- |
-beta: *
-
-比如我们发现了一个回归缺陷。好消息是在这些缺陷流入稳定发布之前还有一些时间来测试 beta 版本!fix 被合并到 master
,为此 nightly 版本得到了修复,接着这些 fix 将 backport 到 beta
分支,一个新的 beta 发布就产生了:
nightly: * - - * - - * - - * - - * - - *
- |
-beta: * - - - - - - - - *
-
-第一个 beta 版的 6 周后,是发布稳定版的时候了!stable
分支从 beta
分支生成:
nightly: * - - * - - * - - * - - * - - * - * - *
- |
-beta: * - - - - - - - - *
- |
-stable: *
-
-好的!Rust 1.5 发布了!然而,我们忘了些东西:因为又过了 6 周,我们还需发布 新版 Rust 的 beta 版,Rust 1.6。所以从 beta
分支生成 stable
分支后,新版的 beta
分支也再次从 nightly
生成:
nightly: * - - * - - * - - * - - * - - * - * - *
- | |
-beta: * - - - - - - - - * *
- |
-stable: *
-
-这被称为 “train model”,因为每 6 周,一个版本 “离开车站”(“leaves the station”),不过从 beta 通道到达稳定通道还有一段旅程。
-Rust 每 6 周发布一个版本,如时钟般准确。如果你知道了某个 Rust 版本的发布时间,就可以知道下个版本的时间:6 周后。每 6 周发布版本的一个好的方面是下一班车会来得更快。如果特定版本碰巧缺失某个功能也无需担心:另一个版本很快就会到来!这有助于减少因临近发版时间而偷偷释出未经完善的功能的压力。
-多亏了这个过程,你总是可以切换到下一版本的 Rust 并验证是否可以轻易的升级:如果 beta 版不能如期工作,你可以向 Rust 团队报告并在发布稳定版之前得到修复!beta 版造成的破坏是非常少见的,不过 rustc
也不过是一个软件,可能会存在 Bug。
这个发布模型中另一个值得注意的地方:不稳定功能(unstable features)。Rust 使用一个被称为 “功能标记”(“feature flags”)的技术来确定给定版本的某个功能是否启用。如果新功能正在积极地开发中,其提交到了 master
,因此会出现在 nightly 版中,不过会位于一个 功能标记 之后。作为用户,如果你希望尝试这个正在开发的功能,则可以在源码中使用合适的标记来开启,不过必须使用 nightly 版。
如果使用的是 beta 或稳定版 Rust,则不能使用任何功能标记。这是在新功能被宣布为永久稳定之前获得实用价值的关键。这既满足了希望使用最尖端技术的同学,那些坚持稳定版的同学也知道其代码不会被破坏。这就是无停滞稳定。
-本书只包含稳定的功能,因为还在开发中的功能仍可能改变,当其进入稳定版时肯定会与编写本书的时候有所不同。你可以在网上获取 nightly 版的文档。
-Rustup 使得改变不同发布通道的 Rust 更为简单,其在全局或分项目的层次工作。其默认会安装稳定版 Rust。例如为了安装 nightly:
-$ rustup install nightly
-
-你会发现 rustup
也安装了所有的 工具链(toolchains, Rust 和其相关组件)。如下是一位作者的 Windows 计算机上的例子:
> rustup toolchain list
-stable-x86_64-pc-windows-msvc (default)
-beta-x86_64-pc-windows-msvc
-nightly-x86_64-pc-windows-msvc
-
-如你所见,默认是稳定版。大部分 Rust 用户在大部分时间使用稳定版。你可能也会这么做,不过如果你关心最新的功能,可以为特定项目使用 nightly 版。为此,可以在项目目录使用 rustup override
来设置当前目录 rustup
使用 nightly 工具链:
$ cd ~/projects/needs-nightly
-$ rustup override set nightly
-
-现在,每次在 *~/需要 nightly 的项目/*下(在项目的根目录下,也就是 Cargo.toml
所在的目录) 调用 rustc
或 cargo
,rustup
会确保使用 nightly 版 Rust。在你有很多 Rust 项目时大有裨益!
那么你如何了解这些新功能呢?Rust 开发模式遵循一个 Request For Comments (RFC) 过程。如果你希望改进 Rust,可以编写一个提议,也就是 RFC。
-任何人都可以编写 RFC 来改进 Rust,同时这些 RFC 会被 Rust 团队评审和讨论,他们由很多不同分工的子团队组成。这里是 Rust 官网 上所有团队的总列表,其包含了项目中每个领域的团队:语言设计、编译器实现、基础设施、文档等。各个团队会阅读相应的提议和评论,编写回复,并最终达成接受或回绝功能的一致。
-如果功能被接受了,在 Rust 仓库会打开一个 issue,人们就可以实现它。实现功能的人可能不是最初提议功能的人!当实现完成后,其会合并到 master
分支并位于一个特性开关(feature gate)之后,正如不稳定功能 部分所讨论的。
在稍后的某个时间,一旦使用 nightly 版的 Rust 团队能够尝试这个功能了,团队成员会讨论这个功能在 nightly 中运行的情况,并决定是否应该进入稳定版。如果决定继续推进,特性开关会移除,然后这个功能就被认为是稳定的了!乘着“发布的列车”,最终在新的稳定版 Rust 中出现。
- - -众所周知,Rust 小版本发布非常频繁,6 周就发布一次,因此通常不会有特别值得普通用户关注的内容,但是这次 1.58 版本不同,新增了(stable 化了)一个非常好用的功能: 在格式化字符串时捕获环境中的值。
---Rust 1.58 官方 release doc: Announcing Rust 1.58.0 | Rust Blog
-
在以前,想要输出一个函数的返回值,你需要这么做:
--fn get_person() -> String { - String::from("sunface") -} -fn main() { - let p = get_person(); - println!("Hello, {}!", p); // implicit position - println!("Hello, {0}!", p); // explicit index - println!("Hello, {person}!", person = p); -} -
问题倒也不大,但是一旦格式化字符串长了后,就会非常冗余,而在 1.58 后,我们可以这么写:
--fn get_person() -> String { - String::from("sunface") -} -fn main() { - let person = get_person(); - println!("Hello, {person}!"); -} -
是不是清晰、简洁了很多?甚至还可以将环境中的值用于格式化参数:
---#![allow(unused)] -fn main() { -let (width, precision) = get_format(); -for (name, score) in get_scores() { - println!("{name}: {score:width$.precision$}"); -} -} -
但也有局限,它只能捕获普通的变量,对于更复杂的类型(例如表达式),可以先将它赋值给一个变量或使用以前的 name = expression
形式的格式化参数。
目前除了 panic!
外,其它接收格式化参数的宏,都可以使用新的特性。对于 panic!
而言,如果还在使用 2015版本
或 2018版本
版本 ,那 panic!("{ident}")
依然会被当成 正常的字符串来处理,同时编译器会给予 warn
提示。而对于 2021版本
,则可以正常使用:
-fn get_person() -> String { - String::from("sunface") -} -fn main() { - let person = get_person(); - panic!("Hello, {person}!"); -} -
输出:
-thread 'main' panicked at 'Hello, sunface!', src/main.rs:6:5
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-在 1.58 中为 Option
和 Result
新增了 unwrap_unchecked
方法,与 unwrap
遇到错误或者空值直接 panic
不同,unwrap_unchecked
遇到错误时处理方式糟糕的多:
-fn get_num() -> Option<i32> { - None -} -fn main() { - unsafe { - let n = get_num().unwrap_unchecked(); - } -} -
输出如下:
-zsh: segmentation fault cargo run
-
-嗯,段错误了,对比下 panic
,有一种泪流满面的冲动:我要这不安全的方法何用?
其实,还真有些用:
-panic
会导致二进制可执行文件变大不少unwrap
的指令分支, 虽然它只会增加区区几条分支预测指令Rust 团队于今天凌晨( 2022-02-25 )发布了最新的 1.59 版本,其中最引人瞩目的特性应该就是支持在代码中内联汇编了,一起来看看。
-该特性对于需要底层控制的应用非常有用,例如想要控制底层执行、访问特定的机器指令等。
-例如,如果目标平台是 x86-64
时,你可以这么写:
--#![allow(unused)] -fn main() { -use std::arch::asm; - -// 使用 shifts 和 adds 实现 x 乘以 6 -let mut x: u64 = 4; -unsafe {`` - asm!( - "mov {tmp}, {x}", - "shl {tmp}, 1", - "shl {x}, 2", - "add {x}, {tmp}", - x = inout(reg) x, - tmp = out(reg) _, - ); -} -assert_eq!(x, 4 * 6); -} -
大家发现没,这里的格式化字符串的使用方式跟我们平时的 println!
、format!
并无区别, 除了 asm!
之外, global_asm!
宏也可以这么使用。
内联汇编中使用的汇编语言和指令取决于相应的机器平台,截至目前,Rust 支持以下平台的内联汇编:
-如果大家希望深入了解,可以看官方的 Reference 文档,同时在 Rust Exercise 中提供了更多的示例(目前正在翻译中..)。
-现在你可以在赋值语句的左式中使用元组、切片和结构体模式了。
---#![allow(unused)] -fn main() { -let (a, b, c, d, e); - -(a, b) = (1, 2); -[c, .., d, _] = [1, 2, 3, 4, 5]; -Struct { e, .. } = Struct { e: 5, f: 3 }; - -assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e]); -} -
这种使用方式跟 let
保持了一致性,但是需要注意,使用 +=
的赋值语句还不支持解构式赋值。
现在我们可以为 const 泛型参数设置默认值:
---#![allow(unused)] -fn main() { -struct ArrayStorage<T, const N: usize = 2> { - arr: [T; N], -} - -impl<T> ArrayStorage<T> { - fn new(a: T, b: T) -> ArrayStorage<T> { - ArrayStorage { - arr: [a, b], - } - } -} -} -
在之前版本中,类型参数必须要在所有的 const 泛型参数之前,现在,这个限制被放宽了,例如你可以这样交替排列它们:
---#![allow(unused)] -fn main() { -fn cartesian_product< - T, const N: usize, - U, const M: usize, - V, F ->(a: [T; N], b: [U; M], f: F) -> [[V; N]; M] -where - F: FnMut(&T, &U) -> V -{ - // ... -} -} -
对于受限的环境来说,减少编译出的二进制文件体积是非常重要的。
-在之前,我们可以在二进制文件创建后,手动的来完成。现在 cargo 和 rustc 支持在链接( linked )后就删除 debug 信息,在 Cargo.toml
中新增以下配置:
[profile.release]
-strip = "debuginfo"
-
-以上配置会将 release
二进制文件中的 debug 信息移除。你也可以使用 "symbols"
或 true
选项来移除所有支持的 symbol
信息。
根据 reddit 网友的测试,如果使用了 strip = true
,那编译后的体积将大幅减少(50% 左右):
lto = true
: 4,397,320 bytesstrip = true
: 2,657,304 bytesopt-level = "z"
: 1,857,680 bytes如果是 WASM,还可以使用以下配置进一步减少体积:
-[package.metadata.wasm-pack.profile.release]
-wasm-opt = ['-Os']
-
-github 上一个开源仓库也证明了这一点,总体来看,这个配置的效果是非常显著的!
-1.59.0 版本默认关闭了增量编译的功能(你可以通过环境变量显式地启用:RUSTC_FORCE_INCREMENTAL=1
),这会降低已知 Bug #94124 的影响,该 Bug 会导致增量编译过程中的反序列化错误和 panic
。
不过大家也不用担心,这个 Bug 会在 1.60.0 版本修复,也就是 6 周后,增量编译会重新设置为默认开启,如果没有意外的话 :)
-一些方法和特征实现现在已经可以 stable 中使用,具体见官方发布说明
- - ---原文链接: https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html
-
通过 rustup 安装的同学可以使用以下命令升级到 1.60 版本:
-$ rustup update stable
-
-rustc 新增了基于 LLVM 的代码覆盖率测量,想要测试的同学可以通过以下方式重新构建你的项目:
-$ RUSTFLAGS="-C instrument-coverage" cargo build
-
-运行新生成的可执行文件将在当前目录下产生一个 default.profraw
文件( 路径和文件名可以通过环境变量进行覆盖 )。
llvm-tools-preview
组件包含了 llvm-profdata
,可以用于处理和合并原生的测量结果输出(测量区域执行数)。
llvm-cov
用于报告生成,它将 llvm-profdata
处理后的输出跟二进制可执行文件自身相结合,对于前者大家可能好理解,但是为何要跟后者可执行文件相结合呢?原因在于可执行文件中嵌入了一个从计数器到实际源代码单元的映射。
rustup component add llvm-tools-preview
-$(rustc --print sysroot)/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-profdata merge -sparse default.profraw -o default.profdata
-$(rustc --print sysroot)/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-cov show -Xdemangler=rustfilt target/debug/coverage-testing \
- -instr-profile=default.profdata \
- -show-line-counts-or-regions \
- -show-instantiations
-
-基于一个简单的 hello world 可执行文件,执行以上命令就可以获得如下带有标记的结果:
--1| 1|fn main() { -2| 1| println!("Hello, world!"); -3| 1|} -
从结果中可以看出:每一行代码都已经被成功覆盖。
-如果大家还想要了解更多,可以看下官方的 rustc 文档。目前来说,基准功能已经稳定了,并将以某种形式存在于未来所有的 Rust 发布版本中。 但输出格式和产生这些输出的 LLVM 工具可能依然会发生变化,基于此,大家在使用时需要确保 llvm-tools-preview
和 rustc ( 用于编译代码的 )使用了相同的版本。
新版本中,以下命令已经可以正常使用了:
-$ cargo build --timings
- Compiling hello-world v0.1.0 (hello-world)
- Timing report saved to target/cargo-timings/cargo-timing-20220318T174818Z.html
- Finished dev [unoptimized + debuginfo] target(s) in 0.98s
-
-此命令会生成一个 cargo build
的耗时详情报告,除了上面提到的路径外,报告还会被拷贝到 target/cargo-timings/cargo-timing.html
。这里是一个在线示例。该报告在你需要提升构建速度时会非常有用,更多的信息请查看文档。
--关于 Cargo Features ,强烈推荐大家看看 Cargo 使用指南,可能是目前最好的中文翻译版本。
-
新版本为 Cargo Features 引入了两个新的语法: 命名空间 ( Namespaced )和弱依赖,它们可以让 features 跟可选依赖进行更好的交互。
-Cargo 支持可选依赖已经很久了,例如以下代码所示:
-[dependencies]
-jpeg-decoder = { version = "0.1.20", default-features = false, optional = true }
-
-[features]
-# 通过开启 jpeg-decoder 依赖的 "rayon` feture,来启用并行化处理
-parallel = ["jpeg-decoder/rayon"]
-
-这个例子有两点值得注意:
-jpeg-decoder
隐式地定义了一个同名的 feature,当启用 jpeg-decoder
feature 时将同时启用 jpeg-decoder
"jpeg-decoder/rayon"
语法会启用 jpeg-decoder
依赖,并且还会启用 jpeg-decoder
依赖的 rayon
feature而命名空间正是为了处理第一个问题而出现的。新版本中,我们可以在 [features]
中使用 dep:
前缀来显式地引用一个可选的依赖。再无需像第一点一样:先隐式的将可选依赖暴露为一个 feature,再通过 feature 来启用它。
这样一来,我们将能更好的定义可选依赖所对应的 feture,包括将可选依赖隐藏在一个更具描述性的 feature 名称后面。
-弱依赖用于处理第二点: 根据第二点,optional-dependency/feature-name
必定会启用 optional-dependency
这个可选依赖。然而在一些场景中,我们只希望在其它 features 已经启用了可选依赖 optional-dependency
时才去启用 feature-name
这个 feature。
从 1.60 开始,我们可以使用 "package-name?/feature-name"
这种带有 ?
形式的语法: 只有当其它项已经启用了可选依赖 package-name
的情况下才去开启给定的 feature feature-name
。
--译者注:简单来说,要启用
-feature
必须需要别人先启用了其前置的可选依赖,再也无法像之前的第二点一样,既能开启可选依赖,又能启用 feature。
例如,我们希望为自己的库增加一些序列化功能,它需要开启某个可选依赖中的指定 feature,可以这么做:
-[dependencies]
-serde = { version = "1.0.133", optional = true }
-rgb = { version = "0.8.25", optional = true }
-
-[features]
-serde = ["dep:serde", "rgb?/serde"]
-
-这里定义了以下关系:
-serde
feature 将启用可选的 serde
依赖rgb
依赖在其它地方已经被启用后,此处才能启用 rgb
的 serde
feature在 1.59 更新说明中,我们有提到因为某些问题,增量编译被默认关闭了,现在官方修复了其中一些,并且确认目前的状态不会再影响用户的使用,因此在 1.60 版本中,增量编译又重新默认开启了。
---译者注:Instant 可以获取当前的时间,因此保证其单调增长是非常重要的,例如 uuid 的生成往往依赖于时间戳的单调增长,一旦时间回退,就可能出现 uuid 重复的情况。
-
在目前所有的平台上,Instant
会去尝试使用系统提供的 API 来保证单调性行为( 目前主要针对 tier 1 的平台 )。然而在实际场景中,这种单调性偶尔会因为硬件、虚拟化或操作系统bug 等原因而失效。
为了解决这些失效或是平台没有提供 API 的情况,Instant::duration_since
, Instant::elapsed
和 Instant::sub
现在饱和为零( 这里不太好翻译,原文是 now saturate to zero,大概意思是非负?)。而在老版本中,这种时间回退的情况会导致 panic。
Instant::checked_duration_since
也可以用于检测和处理单调性失败或 Instants
的减法顺序不正确的情况。
但是目前的解决方法会遮掩一些错误的发生,因此在未来版本中,Rust 可能会重新就某些场景引入 panic 机制。
-在 1.60 版本前,单调性主要通过标准库的互斥锁 Mutex 或原子性 atomic 来保证,但是在 Instant::now()
调用频繁时,可能会导致明显的性能问题。
--原文链接: https://blog.rust-lang.org/2022/05/19/Rust-1.61.0.html -翻译 by :AllanDowney
-
通过 rustup 安装的同学可以使用以下命令升级到 1.61 版本:
-$ rustup update stable
-
-一开始, Rust main
函数只能返回单元类型 ()
(隐式或显式),总是指示成功的退出状态,如果您要你想要其它的,必须调用 process::exit(code)
。从 Rust 1.26 开始, main
允许返回一个 Result
,其中 Ok
转换为 C EXIT_SUCCESS
,Err
转换为 EXIT_FAILURE
(也调试打印错误)。在底层,这些返回类型统一使用不稳定的 Termination
特征。
在此版本中,最终稳定了 Termination
特征,以及一个更通用的 ExitCode
类型,它封装了特定于平台的返回类型。它具有 SUCCESS
和 FAILURE
常量,并为更多任意值实现 From<u8>
。也可以为您自己的类型实现 Termination
特征,允许您在转换为 ExitCode
之前定制任何类型的报告。
例如,下面是一种类型安全的方式来编写 git bisect
运行脚本的退出代码:
-use std::process::{ExitCode, Termination}; - -#[repr(u8)] -pub enum GitBisectResult { - Good = 0, - Bad = 1, - Skip = 125, - Abort = 255, -} - -impl Termination for GitBisectResult { - fn report(self) -> ExitCode { - // Maybe print a message here - ExitCode::from(self as u8) - } -} - -fn main() -> GitBisectResult { - std::panic::catch_unwind(|| { - todo!("test the commit") - }).unwrap_or(GitBisectResult::Abort) -} -
这个版本稳定了几个增量特性,以支持 const 函数的更多功能:
-fn
指针的基本处理:现在可以在 const fn
中创建、传递和强制转换函数指针。例如,在为解释器构建编译时函数表时,这可能很有用。但是,仍然不允许调用 fn
指针。const fn
的泛型参数上,如 T: Copy
,以前只允许 Sized
。dyn Trait
类型:类似地,const fn
现在可以处理特征对象 dyn Trait
。impl Trait
类型:const fn
的参数和返回值现在可以是不透明的 impl Trait
类型。注意,特征特性还不支持在 const fn
中调用这些特征的方法。
三种标准 I/O 流 —— Stdin
、Stdout
和 Stderr
—— 都有一个 锁(&self)
,允许对同步读写进行更多控制。但是,它们返回的锁守卫具有从 &self
借来的生命周期,因此它们被限制在原始句柄的范围内。这被认为是一个不必要的限制,因为底层锁实际上是在静态存储中,所以现在守卫返回一个 'static
生命期,与句柄断开连接。
例如,一个常见的错误来自于试图获取一个句柄并将其锁定在一个语句中:
---#![allow(unused)] -fn main() { -// error[E0716]: temporary value dropped while borrowed -let out = std::io::stdout().lock(); -// ^^^^^^^^^^^^^^^^^ - temporary value is freed at the end of this statement -// | -// creates a temporary which is freed while still in use -} -
现在锁守卫是 'static
,而不是借用那个临时的,所以这个可以正常工作!
--原文链接: https://blog.rust-lang.org/2022/06/30/Rust-1.62.0.html -翻译 by :AllanDowney
-
通过 rustup 安装的同学可以使用以下命令升级到 1.62 版本:
-$ rustup update stable
-
-现在可以使用 cargo add
直接从命令行添加新的依赖项。此命令支持指定功能和版本。它还可以用来修改现有的依赖关系。
例如:
---#![allow(unused)] -fn main() { -$ cargo add log -$ cargo add serde --features derive -$ cargo add nom@5 -} -
有关更多信息,请参阅 cargo 文档。
-#[default]
枚举变量如果指定枚举默认变量,现在可以使用 #[derive(Default)]
。例如,到目前为止,您必须手动为此枚举写入 Default
:
--#![allow(unused)] -fn main() { -#[derive(Default)] -enum Maybe<T> { - #[default] - Nothing, - Something(T), -} -} -
到目前为止,只允许将“单元”变量(没有字段的变量)标记为#[default]。RFC 中提供了有关此功能的更多信息。
-以前,Linux 上的 pthreads
库支持 Mutex
、Condvar
和 RwLock
。 pthreads 锁
支持比 Rust API 本身更多的功能,包括运行时配置,并且设计用于比 Rust 提供的静态保证更少的语言中。
例如,Mutex
实现是 40 个字节,不能被移动(move)。这迫使标准库在后台为使用 pthreads
的平台的每个新 Mutex
分配一个 Box
。
现在 Rust 的标准库在 Linux 上提供了这些锁的原始 futex 实现,它非常轻量级,不需要额外分配。在 1.62.0 中,Mutex
在 Linux 上的内部状态只需要 5 个字节,尽管在未来的版本中可能会发生变化。
这是提高 Rust 的锁类型效率的长期努力的一部分,包括以前在 Windows 上的改进,如取消绑定其原语。您可以在跟踪问题中了解更多有关这方面的信息。
-x86_64
构架现在更容易为 x86_64
构建无操作系统的二进制文件,例如在编写内核时。x86_64-unknown-none
构架已升级到第 2 层,可以用 rustup
安装。
--#![allow(unused)] -fn main() { -$ rustup target add x86_64-unknown-none -$ rustc --target x86_64-unknown-none my_no_std_program.rs -} -
您可以在 Embedded Rust book 中阅读更多关于使用 no_std
进行开发的信息。
--Rust 1.63 官方 release doc: Announcing Rust 1.63.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.63 版本:
-$ rustup update stable
-
-Rust 从 1.0 版本起,就可以使用 std::thread::spawn
来创建一个线程,但是这个函数要求了其生成的线程必须拥有任何传递进去的参数的所有权,也就是说你不能把引用数据传递进去。在一些线程会在方法末尾退出的情况下(通常使用 join()
方法),这个严格的约束显得不必要,在此之前也通常使用 Arc
包裹数据的的方法来妥协。
随着 1.63 版本的推出,标准库新增了区域线程,允许在区域 scope
内创建使用当前调用栈内引用数据的线程。std::thread::scope
的API保证其中创建的线程会在自身返回前推出,也就允许安全的借用数据。看下面的例子,在 scope
内创建两个线程来,分别借用了数据:
--#![allow(unused)] -fn main() { -let mut a = vec![1, 2, 3]; -let mut x = 0; - -std::thread::scope(|s| { - s.spawn(|| { - println!("hello from the first scoped thread"); - // 可以借用变量 `a` - dbg!(&a); - }); - s.spawn(|| { - println!("hello from the second scoped thread"); - // 没有其它线程在使用,所以也可以可变借用 `x` - x += a[0] + a[2]; - }); - println!("hello from the main thread"); -}); - -// Scope 退出后,可以继续修改、访问变量。 -a.push(4); -assert_eq!(x, a.len()); -} -
之前 Rust 代码在使用平台相关 API ,涉及到文件描述符(file descriptor on unix)或句柄(handles on windows)的时候,都是直接使用对应的描述符(比如,c_int
alias RawFd
)。因此类型系统无法判断 API 是会获取文件描述符的所有权,还是仅仅借用它。
现在,Rust 提供了封装类型诸如 BorrowedFd
和 OwnedFd
。这些封装类型都标记为了 #[repr(transparent)]
,意味着 extern "C"
绑定下也可以直接使用这些类型来编码所有权语义。完整的封装类型参见原文下的 stabilized apis in 1.63
Mutex
, RwLock
, Condvar
作为静态变量Condvar::new
, Mutex::new
和 RwLock::new
可以在 const
上下文里被调用了,不必再使用 lazy_static
库来写全局静态的 Mutex
, RwLock
, Condvar
了。
impl Trait
的泛型函数上诸如 fn foo<T>(value: T, f: impl Copy)
的函数签名,使用 Turbofish foo::<u32>(3,3)
来指定 T
的具体类型会出现编译错误:
error[E0632]: cannot provide explicit generic arguments when `impl Trait` is used in argument position
- --> src/lib.rs:4:11
- |
-4 | foo::<u32>(3, 3);
- | ^^^ explicit generic argument not allowed
- |
- = note: see issue #83701 <https://github.com/rust-lang/rust/issues/83701> for more information
-
-1.63里这个限制被放松了,显式泛型类型可以用 Turbofish 来指定了。不过 impl Trait
参数,尽管已经脱糖(desugare)成了泛型,因为还是不透明的所以无法通过 Turbofish 指定。
1.63 的rustc,完全删除了之前的词法借用检查,完全启用了新的 NLL 借用检查器。这不会对编译结果有任何变化,但对编译器的借用错误检查有优化效果。
-如果对NLL不了解,在本书 引用与借用 一章里有介绍。
-或者看官方博客的介绍 NLL
-更详细内容可以看原博客 blog
- - -IntoFuture
, Cargo 优化--Rust 1.64 官方 release doc: Announcing Rust 1.64.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.64 版本:
-$ rustup update stable
-
-IntoFuture
增强 .await
1.64 稳定了 IntoFuture
trait,不同于用在 for ... in ...
的 IntoIterator
trait,IntoFuture
增强了 .awiat
关键字。现在 .await
可以 await 除了 futures 外,还可以 await 任何实现了 IntoFuture
trait 并经此转换成 Future
的对象。这可以让你的 api 对用户更加优化。
举一个用在网络存储供应端的例子:
---#![allow(unused)] -fn main() { -pub struct Error { ... } -pub struct StorageResponse { ... }: -pub struct StorageRequest(bool); - -impl StorageRequest { - /// 实例化一个 `StorageRequest` - pub fn new() -> Self { ... } - /// 是否开启 debug 模式 - pub fn set_debug(self, b: bool) -> Self { ... } - /// 发送请求并接受回复 - pub async fn send(self) -> Result<StorageResponse, Error> { ... } -} -} -
通常地使用方法可能类似如下代码:
---#![allow(unused)] -fn main() { -let response = StorageRequest::new() // 1. 实例化 - .set_debug(true) // 2. 设置一些选项 - .send() // 3. 构造 future - .await?; // 4. 执行 future ,传递 error -} -
这个代码已经不错了,不过 1.64 后可以做的更好。使用 IntoFuture
,把第三步的 “构造 future ” 和 第四步的 “执行 future ” 合并到一个步骤里:
let response = StorageRequest::new() // 1. 实例化
- .set_debug(true) // 2. 设置一些选项
- .await?; // 3. 构造并执行 future ,传递 error
-
-想要实现上面的效果,我们需要给 StorageRequest
实现 IntoFuture
trait。IntoFuture
需要确定好要返回的 future,可以用下面的代码来实现:
--#![allow(unused)] -fn main() { -// 首先需要引入一些必须的类型 -use std::pin::Pin; -use std::future::{Future, IntoFuture}; - -pub struct Error { ... } -pub struct StorageResponse { ... } -pub struct StorageRequest(bool); - -impl StorageRequest { - /// 实例化一个 `StorageRequest` - pub fn new() -> Self { ... } - /// 是否开启 debug 模式 - pub fn set_debug(self, b: bool) -> Self { ... } - /// 发送请求并接受回复 - pub async fn send(self) -> Result<StorageResponse, Error> { ... } -} - -// 新的实现内容 -// 1. 定义好返回的 future 类型 -pub type StorageRequestFuture = Pin<Box<dyn Future<Output = Result<StorageResponse, Error>> + Send + 'static>> -// 2. 给 `StorageRequest` 实现 `IntoFuture` -impl IntoFuture for StorageRequest { - type IntoFuture = StorageRequestFuture; - type Output = <StorageRequestFuture as Future>::Output; - fn into_future(self) -> Self::IntoFuture { - Box::pin(self.send()) - } -} -} -
这确实需要多写一点实现代码,不过可以给用户提供一个更简单的 api 。
-未来,Rust 异步团队 希望能够通过给类型别名提供 impl Trait
Type Alias Impl Trait,来简化定义 futures 实现 IntoFuture
的代码;再想办法移除 Box
来提升性能。
core
和 alloc
中和 C 语言兼容的 FFI 类型当调用 C-ABI 或者调用 C-ABI 的时候,Rust 代码通常会使用诸如 c_uint
或者 c_ulong
的类型别名来匹配目标语言里的对应类型。
在次之前,这些类型别名仅在 std
里可用,而在嵌入式或者其它仅能使用 core
或者 alloc
的场景下无法使用。
1.64 里在 core::ffi
里提供了所有 c_*
的类型别名,还有 core::ffi::CStr
对应 C 的字符串,还有仅用 alloc
库情况下可以用 alloc::ffi::CString
来对应 C 的字符串。
rust-analyzer 现在被加进 Rust 工具集里了。这让在各平台上下载使用 rust-analyzer 更加方便。通过 rustup component 来安装:
-rustup component add rust-analyzer
-
-目前,使用 rustup 安装的版本,需要这样启用:
-rustup run stable rust-analyzer
-
-下一次 rustup 的发布本把会提供一个内置的代理,来运行对应版本的 rust-analyzer 。
-当在一个 Cargo workspace 里管理多个相关的库/产品时,现在可以避免在多个库里使用相同的字段值了,比如相同的版本号,仓库链接,rust-version
。在更新的时候也可以更容易地保持这些信息地一致性。更多细节可以参考:
另外在构建多个目标地时候,现在可以直接传递多个 --target
选项给 cargo build
来一次性编译所有目标。也可以在 .cargo/config.toml
里设置一个 build.target
的 array 来改变默认构建时的对象。
更多稳定API列表和其它更新内容,请参考原文最后 stabilized-apis
- - ---Rust 1.65 官方 release doc: Announcing Rust 1.65.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.65 版本:
-$ rustup update stable
-
-关联类型(associated types)里现在可以加上生命周期、类型、const泛型了,类似于:
---#![allow(unused)] -fn main() { -trait Foo { - type Bar<'x>; -} -} -
三言两语说不清这个变化的好处,看几个例子来感受一下:
---#![allow(unused)] -fn main() { -/// 一个类似于 `Iterator` 的 trait ,可以借用 `Self`。 -trait LendingIterator { - type Item<'a> where Self: 'a; - - fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>; -} - -/// 可以给智能指针类型,比如 `Rc` 和 `Arc` 实现的 trait,来实现指针类型的泛用性 -trait PointerFamily { - type Pointer<T>: Deref<Target = T>; - - fn new<T>(value: T) -> Self::Pointer<T>; -} - -/// 允许借用数组对象,对不需要连续存储数据的固定长度数组类型很有用 -trait BorrowArray<T> { - type Array<'x, const N: usize> where Self: 'x; - - fn borrow_array<'a, const N: usize>(&'a self) -> Self::Array<'a, N>; -} -} -
泛型关联类型十分通用,能够写出许多之前无法实现的模式。更多的信息可以参考下面的链接:
- -第一个对上面的例子进行了更深入的讨论,第二个讨论了一些已知的局限性。
-更深入的阅读可以在关联类型的 nightly reference 和 原始 RFC(已经过去6.5年了!) 里找到。
-let
- else
语法新的 let
语法,尝试模式匹配,找不到匹配的情况下执行发散的 else
块。
--#![allow(unused)] -fn main() { -let PATTERN: TYPE = EXPRESSION else { - DIVERGING_CODE; -}; -} -
常规的 let
语法仅能使用 irrefutable patterns
,直译为不可反驳的模式,也就是一定要匹配上。一般情况下都是单个变量绑定,也用在解开结构体,元组,数组等复合类型上。原先并不适用条件匹配,比如从枚举里确定枚举值。直到现在我们有了 let
- else
。这是 refutable pattern
,直译为可反驳的模式,能够像常规 let
一样匹配并绑定变量到周围范围内,在模式不匹配的时候执行发送的 else
(可以是 break
, return
, panic!
)。
--#![allow(unused)] -fn main() { -fn get_count_item(s: &str) -> (u64, &str) { - let mut it = s.split(' '); - let (Some(count_str), Some(item)) = (it.next(), it.next()) else { - panic!("Can't segment count item pair: '{s}'"); - }; - let Ok(count) = u64::from_str(count_str) else { - panic!("Can't parse integer: '{count_str}'"); - }; - (count, item) -} -assert_eq!(get_count_item("3 chairs"), (3, "chairs")); -} -
if
- else
和 match
或者 if let
最大不一样的地方是变量绑定的范围,在此之前你需要多写一点重复的代码和一次外层的 let
绑定来完成:
--#![allow(unused)] -fn main() { - let (count_str, item) = match (it.next(), it.next()) { - (Some(count_str), Some(item)) => (count_str, item), - _ => panic!("Can't segment count item pair: '{s}'"), - }; - let count = if let Ok(count) = u64::from_str(count_str) { - count - } else { - panic!("Can't parse integer: '{count_str}'"); - }; -} -
break
跳出标记过的代码块块表达式现在可以标记为 break
的目标,来达到提前终止块的目的。这听起来有点像 goto
语法,不过这并不是随意的跳转,只能从块里跳转到块末尾。这在之前已经可以用 loop
块来实现了,你可能大概率见过这种总是只执行一次的 loop
。
1.65 可以直接给块语句添加标记来提前退出了,还可以携带返回值:
---#![allow(unused)] -fn main() { -let result = 'block: { - do_thing(); - if condition_not_met() { - break 'block 1; - } - do_next_thing(); - if condition_not_met() { - break 'block 2; - } - do_last_thing(); - 3 -}; -} -
其它更新细节,和稳定的API列表,参考原Blog
- - ---Rust 1.66 官方 release doc: Announcing Rust 1.66.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.66 版本:
-$ rustup update stable
-
-枚举的显示判别在跨语言传递值时很关键,需要两个语言里每个枚举值的判别是一致的,比如:
---#![allow(unused)] -fn main() { -#[repr(u8)] -enum Bar { - A, - B, - C = 42, - D, -} -} -
这个例子里,枚举 Bar
使用了 u8
作为原语表形(representation),并且 Bar::C
使用 42 来判别,其它没有显示判别的枚举值会按照源码里地顺序自动地递增赋值,这里的 Bar::A
是0,Bar::B
是1,Bar::D
是43。如果没有显示判别,那就只能在 Bar::B
和 Bar::C
之间加上 40 个无意义的枚举值了。
在1.66之前,枚举的显示判别只能用在无字段枚举上。现在对有字段枚举的显示判别也稳定了:
---#![allow(unused)] -fn main() { -#[repr(u8)] -enum Foo { - A(u8), - B(i8), - C(bool) = 42, -} -} -
注意:可以通过 as
转换(比如 Bar::C as u8
)来判断一个无字段枚举的判别值,但是 Rust 还没有给有字段枚举提供语言层面上的获取原始判别值的方法,只能通过 unsafe 的代码来检查有字段枚举的判别值。考虑到这个使用场景往往出现在必须使用 unsafe 代码的跨语言的 FFI 里,希望这没有造成太大的负担。如果你的确需要的话,参考 std::mem::discriminant
。
core::hint::black_box
当对编译器产生的代码做基准测试时,常常需要阻止一些优化,比如下面的代码里, push_cap
在一个循环里执行了4次 Vec::push
:
--#![allow(unused)] -fn main() { -fn push_cap(v: &mut Vec<i32>) { - for i in 0..4 { - v.push(i); - } -} - -pub fn bench_push() -> Duration { - let mut v = Vec::with_capacity(4); - let now = Instant::now(); - push_cap(&mut v); - now.elapsed() -} -} -
如果你检查一下在 x86_64 机器上编译的优化输出结果,你会注意到整个 push_cap
方法都被优化掉了...
example::bench_push:
- sub rsp, 24
- call qword ptr [rip + std::time::Instant::now@GOTPCREL]
- lea rdi, [rsp + 8]
- mov qword ptr [rsp + 8], rax
- mov dword ptr [rsp + 16], edx
- call qword ptr [rip + std::time::Instant::elapsed@GOTPCREL]
- add rsp, 24
- ret
-
-现在可以通过调用 black_box
来避免类似情况的发送。 虽然实际上 black_box
内部只会取走值并直接返回,但是编译器会认为这个方法可能做任何事情。
--#![allow(unused)] -fn main() { -use std::hint::black_box; - -fn push_cap(v: &mut Vec<i32>) { - for i in 0..4 { - v.push(i); - black_box(v.as_ptr()); - } -} -} -
这样就可以得到展开循环的结果:
- mov dword ptr [rbx], 0
- mov qword ptr [rsp + 8], rbx
- mov dword ptr [rbx + 4], 1
- mov qword ptr [rsp + 8], rbx
- mov dword ptr [rbx + 8], 2
- mov qword ptr [rsp + 8], rbx
- mov dword ptr [rbx + 12], 3
- mov qword ptr [rsp + 8], rbx
-
-你还能发现结果里有 black_box
带来的副作用,无意义的 mov qword ptr [rsp + 8], rbx
指令在每一次循环后出现,用来获取 v.as_ptr()
作为参数传递给并未真正使用的方法。
注意到上面的例子里,push
指令都不用考虑内存分配的问题,这是因为编译器运行在 Vec::with_capacity(4)
的条件下。你可以尝试改动一下 black_box
的位置或者在多处使用,来看看其对编译的优化输出的影响。
cargo remove
1.62里我们引入了 cargo add
来通过命令行给你的项目增加依赖项。现在可以使用 cargo remove
来移除依赖了。
其它更新细节,和稳定的API列表,参考原Blog
- - -#[must_use]
in async fn
--Rust 1.67 官方 release doc: Announcing Rust 1.67.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.67 版本:
-$ rustup update stable
-
-2023新年好!大年初五更新的新版本,来看看有什么新变化~
-#[must_use]
作用于 async fn
上注明了 #[must_use]
的 async
函数会把该属性应用在返回的 impl Future
结果上。Future
trait 已经注明了 #[must_use]
,所以所有实现了 Future
的类型都会自动加上 #[must_use]
。
所以在 1.67 版本,编译器会警告返回值没有被使用:
---#![allow(unused)] -fn main() { -#[must_use] -async fn bar() -> u32 { 0 } - -async fn caller() { - bar().await; -} -} -
warning: unused output of future returned by `bar` that must be used
- --> src/lib.rs:5:5
- |
-5 | bar().await;
- | ^^^^^^^^^^^
- |
- = note: `#[warn(unused_must_use)]` on by default
-
-std::sync::mpsc
实现更新标准库里的 mpsc(多生产者单消费者) 通道自从 1.0 版本就有了,这次版本更新将其实现修改成了基于 crossbeam-channel
。不涉及到API的变更,但是修改了一些已有的bug,提升了性能和代码可维护性。用户应该不太会感知到明显的变化。
其它更新细节,和稳定的API列表,参考原Blog
- - ---Rust 1.68 官方 release doc: Announcing Rust 1.68.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.68 版本:
-$ rustup update stable
-
-Cargo的“稀疏”注册协议已经稳定,它是用来读取注册在 crates.io 上的 crates 的索引的基础设施。以前的 git 协议(目前仍然是默认协议)会克隆一个包括所有 crates 的索引的仓库,但这已经开始遇到扩展限制问题,在更新该仓库时会出现明显的延迟。新协议应在访问 crates.io 时提供显着的性能提升,因为它只会下载有关实际用到的 crates 的索引。
-要使用新的协议,需要设置环境变量 CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
,或者编辑 .cargo/config.toml
文件添加:
[registries.crates-io]
-protocol = "sparse"
-
-稀疏注册协议目前计划于 1.70.0 版本成为默认的协议。更多细节可以看官方博客的 announcement, RFC 2789, 当前 Cargo Book 的 文档
-Pin
构造新增的 pin!
宏能够用 T
构造一个 Pin<&mut T>
,从而匿名捕获在局部状态内。这通常叫做 堆栈固定(stack-pinning),同时这个堆栈也可以被 async fn
或者 代码块 来捕获住。这个宏和一些 crates 里提供的(比如 tokio::pin!
)很像,但是标准库可以利用 Pin
的内部结构和 临时生命周期拓展(Temporary lifetime extension)来实现出更像表达式的宏。
--#![allow(unused)] -fn main() { -/// Runs a future to completion. -fn block_on<F: Future>(future: F) -> F::Output { - let waker_that_unparks_thread = todo!(); - let mut cx = Context::from_waker(&waker_that_unparks_thread); - // Pin the future so it can be polled. - let mut pinned_future = pin!(future); - loop { - match pinned_future.as_mut().poll(&mut cx) { - Poll::Pending => thread::park(), - Poll::Ready(result) => return result, - } - } -} -} -
在这个例子中,原来的 future
将被移动到一个临时的局部区域,由新的 pinned_future
引用,类型为 Pin<&mut F>
,并且该 pin 受制于正常的借用检查器以确保它不会超过局部作用域。
alloc
默认错误处理当 Rust 内存分配失败时,类似于 Box::new
和 Vec::push
的 API 无法反映出这个错误,从而采取了一些不同的措施。当使用 std
时,程序会打印 stderr
然后中止。从 Rust 1.68.0 开始,包含 std
的二进制程序仍然会继续这样,而不保护 std
只包含 alloc
的二进制程序会对内存分配错误调用 panic!
,如果需要可以再进一步通过 #[panic_handler]
来调整其行为。
未来,std
也可能会改成这样。
其它更新细节,和稳定的API列表,参考原Blog
- - ---Rust 1.69 官方 release doc: Announcing Rust 1.69.0 | Rust Blog
-
通过 rustup 安装的同学可以使用以下命令升级到 1.69 版本:
-$ rustup update stable
-
-在 Rust 1.29.0 版本添加的 cargo fix
子命令,能够自动修复一些简单的编译错误。从那以后,能够自动修复的错误/警告原因的数量一直在稳步增加。此外,还增加了对自动修复一些简单的 Clippy 警告的支持。
为了让更多人注意到这些能力,现在当检测到可自动修复的错误时,Cargo 会建议运行 cargo fix
或 cargo clippy --fix
命令:
warning: unused import: `std::hash::Hash`
- --> src/main.rs:1:5
- |
-1 | use std::hash::Hash;
- | ^^^^^^^^^^^^^^^
- |
- = note: `#[warn(unused_imports)]` on by default
-
-warning: `foo` (bin "foo") generated 1 warning (run `cargo fix --bin "foo"` to apply 1 suggestion)
-
-注意上面的完整命令(即包含 --bin foo
)仅在你想要精确修复一个单独的 crate 时需要附上。默认执行 workspace 下所有 fixs 只需要 cargo fix
。
为了提高编译速度,Cargo 现在默认避免在构建脚本中发出调试信息。构建脚本成功执行时不会有可见的效果,但构建脚本中的回溯(backtraces)将包含更少的信息。
-所以如果想要 debug 构建脚本,需要额外开启调试信息,在 Cargo.toml
文件里添加
[profile.dev.build-override]
-debug = true
-[profile.release.build-override]
-debug = true
-
-其它更新细节,和稳定的API列表,参考原Blog
- - -本目录包含了 Rust 历次版本更新的重要内容解读,需要注意,每个版本实际更新的内容要比这里记录的更多,全部内容请访问每节开头的官方链接查看。
- - -无论功能设计的再怎么花里胡哨,对于一个文件查找命令而言,首先得指定文件和待查找的字符串,它们需要用户从命令行给予输入,然后我们在程序内进行读取。
-国际惯例,先创建一个新的项目 minigrep
,该名字充分体现了我们的自信:就是不如 grep
。
cargo new minigrep
- Created binary (application) `minigrep` project
-$ cd minigrep
-
-首先来思考下,如果要传入文件路径和待搜索的字符串,那这个命令该长啥样,我觉得大概率是这样:
-cargo run -- searchstring example-filename.txt
-
---
告诉 cargo
后面的参数是给我们的程序使用的,而不是给 cargo
自己使用,例如 --
前的 run
就是给它用的。
接下来就是在程序中读取传入的参数,这个很简单,下面代码就可以:
--// in main.rs -use std::env; - -fn main() { - let args: Vec<String> = env::args().collect(); - dbg!(args); -} -
首先通过 use
引入标准库中的 env
包,然后 env::args
方法会读取并分析传入的命令行参数,最终通过 collect
方法输出一个集合类型 Vector
。
可能有同学疑惑,为啥不直接引入 args
,例如 use std::env::args
,这样就无需 env::args
来繁琐调用,直接args.collect()
即可。原因很简单,args
方法只会使用一次,啰嗦就啰嗦点吧,把相同的好名字让给 let args..
这位大哥不好吗?毕竟人家要出场多次的。
--不可信的输入
-所有的用户输入都不可信!不可信!不可信!
-重要的话说三遍,我们的命令行程序也是,用户会输入什么你根本就不知道,例如他输入了一个非 Unicode 字符,你能阻止吗?显然不能,但是这种输入会直接让我们的程序崩溃!
-原因是当传入的命令行参数包含非 Unicode 字符时,
-std::env::args
会直接崩溃,如果有这种特殊需求,建议大家使用std::env::args_os
,该方法产生的数组将包含OsString
类型,而不是之前的String
类型,前者对于非 Unicode 字符会有更好的处理。至于为啥我们不用,两个理由,你信哪个:1. 用户爱输入啥输入啥,反正崩溃了,他就知道自己错了 2.
-args_os
会引入额外的跨平台复杂性
collect
方法其实并不是std::env
包提供的,而是迭代器自带的方法(env::args()
会返回一个迭代器),它会将迭代器消费后转换成我们想要的集合类型,关于迭代器和 collect
的具体介绍,请参加这里。
最后,代码中使用 dbg!
宏来输出读取到的数组内容,来看看长啥样:
$ cargo run
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.61s
- Running `target/debug/minigrep`
-[src/main.rs:5] args = [
- "target/debug/minigrep",
-]
-
-$ cargo run -- needle haystack
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 1.57s
- Running `target/debug/minigrep needle haystack`
-[src/main.rs:5] args = [
- "target/debug/minigrep",
- "needle",
- "haystack",
-]
-
-上面两个版本分别是无参数和两个参数,其中无参数版本实际上也会读取到一个字符串,仔细看,是不是长得很像我们的程序名,Bingo! env::args
读取到的参数中第一个就是程序的可执行路径名。
在编程中,给予清晰合理的变量名是一项基本功,咱总不能到处都是 args[1]
、args[2]
这样的糟糕代码吧。
因此我们需要两个变量来存储文件路径和待搜索的字符串:
--use std::env; - -fn main() { - let args: Vec<String> = env::args().collect(); - - let query = &args[1]; - let file_path = &args[2]; - - println!("Searching for {}", query); - println!("In file {}", file_path); -} -
很简单的代码,来运行下:
-$ cargo run -- test sample.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep test sample.txt`
-Searching for test
-In file sample.txt
-
-输出结果很清晰的说明了我们的目标:在文件 sample.txt
中搜索包含 test
字符串的内容。
事实上,就算作为一个简单的程序,它也太过于简单了,例如用户不提供任何参数怎么办?因此,错误处理显然是不可少的,但是在添加之前,先来看看如何读取文件内容。
-既然读取文件,那么首先我们需要创建一个文件并给予一些内容,来首诗歌如何?"我啥也不是,你呢?"
-I'm nobody! Who are you?
-我啥也不是,你呢?
-Are you nobody, too?
-牛逼如你也是无名之辈吗?
-Then there's a pair of us - don't tell!
-那我们就是天生一对,嘘!别说话!
-They'd banish us, you know.
-你知道,我们不属于这里。
-How dreary to be somebody!
-因为这里属于没劲的大人物!
-How public, like a frog
-他们就像青蛙一样呱噪,
-To tell your name the livelong day
-成天将自己的大名
-To an admiring bog!
-传遍整个无聊的沼泽!
-
-在项目根目录创建 poem.txt
文件,并写入如上的优美诗歌(可能翻译的很烂,别打我,哈哈,事实上大家写入英文内容就够了)。
接下来修改 main.rs
来读取文件内容:
-use std::env; -use std::fs; - -fn main() { - // --省略之前的内容-- - println!("In file {}", file_path); - - let contents = fs::read_to_string(file_path) - .expect("Should have been able to read the file"); - - println!("With text:\n{contents}"); -} -
首先,通过 use std::fs
引入文件操作包,然后通过 fs::read_to_string
读取指定的文件内容,最后返回的 contents
是 std::io::Result<String>
类型。
运行下试试,这里无需输入第二个参数,因为我们还没有实现查询功能:
-$ cargo run -- the poem.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep the poem.txt`
-Searching for the
-In file poem.txt
-With text:
-I'm nobody! Who are you?
-Are you nobody, too?
-Then there's a pair of us - don't tell!
-They'd banish us, you know.
-
-How dreary to be somebody!
-How public, like a frog
-To tell your name the livelong day
-To an admiring bog!
-
-完美,虽然代码还有很多瑕疵,例如所有内容都在 main
函数,这个不符合软件工程,没有错误处理,功能不完善等。不过没关系,万事开头难,好歹我们成功迈开了第一步。
好了,是时候重构赚波 KPI 了,读者:are you serious? 这就开始重构了?
- - -在上一章节中,留下了一个悬念,该如何实现用户控制的大小写敏感,其实答案很简单,你在其它程序中肯定也遇到过不少,例如如何控制 panic
后的栈展开? Rust 提供的解决方案是通过命令行参数来控制:
RUST_BACKTRACE=1 cargo run
-
-与之类似,我们也可以使用环境变量来控制大小写敏感,例如:
-IGNORE_CASE=1 cargo run -- to poem.txt
-
-既然有了目标,那么一起来看看该如何实现吧。
-还是遵循之前的规则:测试驱动,这次是对一个新的大小写不敏感函数进行测试 search_case_insensitive
。
还记得 TDD 的测试步骤嘛?首先编写一个注定失败的用例:
---#![allow(unused)] -fn main() { -// in src/lib.rs -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn case_sensitive() { - let query = "duct"; - let contents = "\ -Rust: -safe, fast, productive. -Pick three. -Duct tape."; - - assert_eq!(vec!["safe, fast, productive."], search(query, contents)); - } - - #[test] - fn case_insensitive() { - let query = "rUsT"; - let contents = "\ -Rust: -safe, fast, productive. -Pick three. -Trust me."; - - assert_eq!( - vec!["Rust:", "Trust me."], - search_case_insensitive(query, contents) - ); - } -} -} -
可以看到,这里新增了一个 case_insensitive
测试用例,并对 search_case_insensitive
进行了测试,结果显而易见,函数都没有实现,自然会失败。
接着来实现这个大小写不敏感的搜索函数:
---#![allow(unused)] -fn main() { -pub fn search_case_insensitive<'a>( - query: &str, - contents: &'a str, -) -> Vec<&'a str> { - let query = query.to_lowercase(); - let mut results = Vec::new(); - - for line in contents.lines() { - if line.to_lowercase().contains(&query) { - results.push(line); - } - } - - results -} -} -
跟之前一样,但是引入了一个新的方法 to_lowercase
,它会将 line
转换成全小写的字符串,类似的方法在其它语言中也差不多,就不再赘述。
还要注意的是 query
现在是 String
类型,而不是之前的 &str
,因为 to_lowercase
返回的是 String
。
修改后,再来跑一次测试,看能否通过。
-$ cargo test
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished test [unoptimized + debuginfo] target(s) in 1.33s
- Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
-
-running 2 tests
-test tests::case_insensitive ... ok
-test tests::case_sensitive ... ok
-
-test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
- Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
-
-running 0 tests
-
-test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
- Doc-tests minigrep
-
-running 0 tests
-
-test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
-Ok,TDD的第二步也完成了,测试通过,接下来就是最后一步,在 run
中调用新的搜索函数。但是在此之前,要新增一个配置项,用于控制是否开启大小写敏感。
--#![allow(unused)] -fn main() { -// in lib.rs -pub struct Config { - pub query: String, - pub file_path: String, - pub ignore_case: bool, -} -} -
接下来就是检查该字段,来判断是否启动大小写敏感:
---#![allow(unused)] -fn main() { -pub fn run(config: Config) -> Result<(), Box<dyn Error>> { - let contents = fs::read_to_string(config.file_path)?; - - let results = if config.ignore_case { - search_case_insensitive(&config.query, &contents) - } else { - search(&config.query, &contents) - }; - - for line in results { - println!("{line}"); - } - - Ok(()) -} -} -
现在的问题来了,该如何控制这个配置项呢。这个就要借助于章节开头提到的环境变量,好在 Rust 的 env
包提供了相应的方法。
--#![allow(unused)] -fn main() { -use std::env; -// --snip-- - -impl Config { - pub fn build(args: &[String]) -> Result<Config, &'static str> { - if args.len() < 3 { - return Err("not enough arguments"); - } - - let query = args[1].clone(); - let file_path = args[2].clone(); - - let ignore_case = env::var("IGNORE_CASE").is_ok(); - - Ok(Config { - query, - file_path, - ignore_case, - }) - } -} -} -
env::var
没啥好说的,倒是 is_ok
值得说道下。该方法是 Result
提供的,用于检查是否有值,有就返回 true
,没有则返回 false
,刚好完美符合我们的使用场景,因为我们并不关心 Ok<T>
中具体的值。
运行下试试:
-$ cargo run -- to poem.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep to poem.txt`
-Are you nobody, too?
-How dreary to be somebody!
-
-看起来没有问题,接下来测试下大小写不敏感:
-$ IGNORE_CASE=1 cargo run -- to poem.txt
-
-Are you nobody, too?
-How dreary to be somebody!
-To tell your name the livelong day
-To an admiring bog!
-
-大小写不敏感后,查询到的内容明显多了很多,也很符合我们的预期。
-最后,给大家留一个小作业:同时使用命令行参数和环境变量的方式来控制大小写不敏感,其中环境变量的优先级更高,也就是两个都设置的情况下,优先使用环境变量的设置。
- - -在前往更高的山峰前,我们应该驻足欣赏下身后的风景,虽然是半览众山不咋小,但总比身在此山中无法窥全貌要强一丢丢。
-在本章中,我们将一起构建一个命令行程序,目标是尽可能帮大家融会贯通之前的学到的知识。
-linux 系统中的 grep
命令很强大,可以完成各种文件搜索任务,我们肯定做不了那么强大,但是假冒一个伪劣的版本还是可以的,它将从命令行参数中读取指定的文件名和字符串,然后在相应的文件中找到包含该字符串的内容,最终打印出来。
-- - -这里推荐一位大神写的知名 Rust 项目 ripgrep ,绝对是
-grep
真正的高替品,值得学习和使用
--本章节是可选内容,请大家在看完迭代器章节后,再来阅读
-
在之前的 minigrep
中,功能虽然已经 ok,但是一些细节上还值得打磨下,下面一起看看如何使用迭代器来改进 Config::build
和 serach
的实现。
clone
的使用虽然之前有讲过为什么这里可以使用 clone
,但是也许总有同学心有芥蒂,毕竟程序员嘛,都希望代码处处完美,而不是丑陋的处处妥协。
--#![allow(unused)] -fn main() { -impl Config { - pub fn build(args: &[String]) -> Result<Config, &'static str> { - if args.len() < 3 { - return Err("not enough arguments"); - } - - let query = args[1].clone(); - let file_path = args[2].clone(); - - let ignore_case = env::var("IGNORE_CASE").is_ok(); - - Ok(Config { - query, - file_path, - ignore_case, - }) - } -} -} -
之前的代码大致长这样,两行 clone
确实有点啰嗦,好在,在学习完迭代器后,我们知道了 build
函数实际上可以直接拿走迭代器的所有权,而不是去借用一个数组切 &[String]
。
这里先不给出代码,下面统一给出。
-在之前的实现中,我们的 args
是一个动态数组:
-fn main() { - let args: Vec<String> = env::args().collect(); - - let config = Config::build(&args).unwrap_or_else(|err| { - eprintln!("Problem parsing arguments: {err}"); - process::exit(1); - }); - - // --snip-- -} -
当时还提到了 collect
方法的使用,相信大家学完迭代器后,对这个方法会有更加深入的认识。
现在呢,无需数组了,直接传入迭代器即可:
--fn main() { - let config = Config::build(env::args()).unwrap_or_else(|err| { - eprintln!("Problem parsing arguments: {err}"); - process::exit(1); - }); - - // --snip-- -} -
如上所示,我们甚至省去了一行代码,原因是 env::args
可以直接返回一个迭代器,再作为 Config::build
的参数传入,下面再来改写 build
方法。
--#![allow(unused)] -fn main() { -impl Config { - pub fn build( - mut args: impl Iterator<Item = String>, - ) -> Result<Config, &'static str> { - // --snip-- -} -
为了可读性和更好的通用性,这里的 args
类型并没有使用本身的 std::env::Args
,而是使用了特征约束的方式来描述 impl Iterator<Item = String>
,这样意味着 arg
可以是任何实现了 String
迭代器的类型。
还有一点值得注意,由于迭代器的所有权已经转移到 build
内,因此可以直接对其进行修改,这里加上了 mut
关键字。
数组索引会越界,为了安全性和简洁性,使用 Iterator
特征自带的 next
方法是一个更好的选择:
--#![allow(unused)] -fn main() { -impl Config { - pub fn build( - mut args: impl Iterator<Item = String>, - ) -> Result<Config, &'static str> { - // 第一个参数是程序名,由于无需使用,因此这里直接空调用一次 - args.next(); - - let query = match args.next() { - Some(arg) => arg, - None => return Err("Didn't get a query string"), - }; - - let file_path = match args.next() { - Some(arg) => arg, - None => return Err("Didn't get a file path"), - }; - - let ignore_case = env::var("IGNORE_CASE").is_ok(); - - Ok(Config { - query, - file_path, - ignore_case, - }) - } -} -} -
喔,上面使用了迭代器和模式匹配的代码,看上去是不是很 Rust?我想我们已经走在了正确的道路上。
-为了帮大家更好的回忆和对比,之前的 search
长这样:
--#![allow(unused)] -fn main() { -// in lib.rs -pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { - let mut results = Vec::new(); - - for line in contents.lines() { - if line.contains(query) { - results.push(line); - } - } - - results -} -} -
引入了迭代器后,就连古板的 search
函数也可以变得更 rusty 些:
--#![allow(unused)] -fn main() { -pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { - contents - .lines() - .filter(|line| line.contains(query)) - .collect() -} -} -
Rock,让我们的函数编程 Style rock 起来,这种一行到底的写法有时真的让人沉迷。
-至此,整个大章节全部结束,本章没有试图覆盖已学的方方面面( 也许未来会 ),而是聚焦于 Rust 的一些核心知识:所有权、生命周期、借用、模式匹配等等。
-强烈推荐大家忘记已有的一切,自己重新实现一遍 minigrep
,甚至可以根据自己的想法和喜好,来完善一些,也欢迎在评论中附上自己的练习项目,供其它人学习参考( 提个小建议,项目主页写清楚新增的功能、亮点等 )。
从下一章开始,我们将正式开始 Rust 进阶学习,请深呼吸一口,然后问自己:你..准备好了吗?
- - -但凡稍微没那么糟糕的程序,都应该具有代码模块化和错误处理,不然连玩具都谈不上。
-梳理我们的代码和目标后,可以整理出大致四个改进点:
-minigrep
程序而言, main
函数当前执行两个任务:解析命令行参数和读取文件。但随着代码的增加,main
函数承载的功能也将快速增加。从软件工程角度来看,一个函数具有的功能越多,越是难以阅读和维护。因此最好的办法是将大的函数拆分成更小的功能单元。main
函数中的变量都是独立存在的,这些变量很可能被整个程序所访问,在这个背景下,独立的变量越多,越是难以维护,因此我们还可以将这些用于配置的变量整合到一个结构体中。expect
方法来输出文件读取失败时的错误信息,这个没问题,但是无论任何情况下,都只输出 Should have been able to read the file
这条错误提示信息,显然是有问题的,毕竟文件不存在、无权限等等都是可能的错误,一条大一统的消息无法给予用户更多的提示。index out of bounds
,一个数组访问越界的 panic
,但问题来了,用户能看懂吗?甚至于未来接收的维护者能看懂吗?因此需要增加合适的错误处理代码,来给予使用者给详细友善的提示。还有就是需要在一个统一的位置来处理所有错误,利人利己!关于如何处理庞大的 main
函数,Rust 社区给出了统一的指导方案:
main.rs
和 lib.rs
,并将程序的逻辑代码移动到后者内main.rs
中按照这个方案,将我们的代码重新梳理后,可以得出 main
函数应该包含的功能:
lib.rs
中的 run
函数,以启动逻辑代码的运行run
返回一个错误,需要对该错误进行处理这个方案有一个很优雅的名字: 关注点分离(Separation of Concerns)。简而言之,main.rs
负责启动程序,lib.rs
负责逻辑代码的运行。从测试的角度而言,这种分离也非常合理: lib.rs
中的主体逻辑代码可以得到简单且充分的测试,至于 main.rs
?确实没办法针对其编写额外的测试代码,但是它的代码也很少啊,很容易就能保证它的正确性。
--关于如何在 Rust 中编写测试代码,请参见如下章节:https://course.rs/test/intro.html
-
根据之前的分析,我们需要将命令行解析的代码分离到一个单独的函数,然后将该函数放置在 main.rs
中:
-// in main.rs -fn main() { - let args: Vec<String> = env::args().collect(); - - let (query, file_path) = parse_config(&args); - - // --省略-- -} - -fn parse_config(args: &[String]) -> (&str, &str) { - let query = &args[1]; - let file_path = &args[2]; - - (query, file_path) -} -
经过分离后,之前的设计目标完美达成,即精简了 main
函数,又将配置相关的代码放在了 main.rs
文件里。
看起来貌似是杀鸡用了牛刀,但是重构就是这样,一步一步,踏踏实实的前行,否则未来代码多一些后,你岂不是还要再重来一次重构?因此打好项目的基础是非常重要的!
-前文提到,配置变量并不适合分散的到处都是,因此使用一个结构体来统一存放是非常好的选择,这样修改后,后续的使用以及未来的代码维护都将更加简单明了。
--fn main() { - let args: Vec<String> = env::args().collect(); - - let config = parse_config(&args); - - println!("Searching for {}", config.query); - println!("In file {}", config.file_path); - - let contents = fs::read_to_string(config.file_path) - .expect("Should have been able to read the file"); - - // --snip-- -} - -struct Config { - query: String, - file_path: String, -} - -fn parse_config(args: &[String]) -> Config { - let query = args[1].clone(); - let file_path = args[2].clone(); - - Config { query, file_path } -} -
值得注意的是,Config
中存储的并不是 &str
这样的引用类型,而是一个 String
字符串,也就是 Config
并没有去借用外部的字符串,而是拥有内部字符串的所有权。clone
方法的使用也可以佐证这一点。大家可以尝试不用 clone
方法,看看该如何解决相关的报错 :D
---
clone
的得与失在上面的代码中,除了使用
-clone
,还有其它办法来达成同样的目的,但clone
无疑是最简单的方法:直接完整的复制目标数据,无需被所有权、借用等问题所困扰,但是它也有其缺点,那就是有一定的性能损耗。因此是否使用
-clone
更多是一种性能上的权衡,对于上面的使用而言,由于是配置的初始化,因此整个程序只需要执行一次,性能损耗几乎是可以忽略不计的。总之,判断是否使用
-clone
:-
-- 是否严肃的项目,玩具项目直接用
-clone
就行,简单不好吗?- 要看所在的代码路径是否是热点路径(hot path),例如执行次数较多的显然就是热点路径,热点路径就值得去使用性能更好的实现方式
-
好了,言归正传,从 C
语言过来的同学可能会觉得上面的代码已经很棒了,但是从 OO 语言角度来说,还差了那么一点意思。
下面我们试着来优化下,通过构造函数来初始化一个 Config
实例,而不是直接通过函数返回实例,典型的,标准库中的 String::new
函数就是一个范例。
-fn main() { - let args: Vec<String> = env::args().collect(); - - let config = Config::new(&args); - - // --snip-- -} - -// --snip-- - -impl Config { - fn new(args: &[String]) -> Config { - let query = args[1].clone(); - let file_path = args[2].clone(); - - Config { query, file_path } - } -} -
修改后,类似 String::new
的调用,我们可以通过 Config::new
来创建一个实例,看起来代码是不是更有那味儿了 :)
回顾一下,如果用户不输入任何命令行参数,我们的程序会怎么样?
-$ cargo run
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep`
-thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-结果喜闻乐见,由于 args
数组没有任何元素,因此通过索引访问时,会直接报出数组访问越界的 panic
。
报错信息对于开发者会很明确,但是对于使用者而言,就相当难理解了,下面一起来解决它。
-还记得在错误处理章节,我们提到过 panic
的两种用法: 被动触发和主动调用嘛?上面代码的出现方式很明显是被动触发,这种报错信息是不可控的,下面我们先改成主动调用的方式:
--#![allow(unused)] -fn main() { -// in main.rs - // --snip-- - fn new(args: &[String]) -> Config { - if args.len() < 3 { - panic!("not enough arguments"); - } - // --snip-- -} -
目的很明确,一旦传入的参数数组长度小于 3,则报错并让程序崩溃推出,这样后续的数组访问就不会再越界了。
-$ cargo run
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep`
-thread 'main' panicked at 'not enough arguments', src/main.rs:26:13
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-不错,用户看到了更为明确的提示,但是还是有一大堆 debug
输出,这些我们其实是不想让用户看到的。这么看来,想要输出对用户友好的信息, panic
是不太适合的,它更适合告知开发者,哪里出现了问题。
那只能祭出之前学过的错误处理大法了,也就是返回一个 Result
:成功时包含 Config
实例,失败时包含一条错误信息。
有一点需要额外注意下,从代码惯例的角度出发,new
往往不会失败,毕竟新建一个实例没道理失败,对不?因此修改为 build
会更加合适。
--#![allow(unused)] -fn main() { -impl Config { - fn build(args: &[String]) -> Result<Config, &'static str> { - if args.len() < 3 { - return Err("not enough arguments"); - } - - let query = args[1].clone(); - let file_path = args[2].clone(); - - Ok(Config { query, file_path }) - } -} -} -
这里的 Result
可能包含一个 Config
实例,也可能包含一条错误信息 &static str
,不熟悉这种字符串类型的同学可以回头看看字符串章节,代码中的字符串字面量都是该类型,且拥有 'static
生命周期。
接下来就是在调用 build
函数时,对返回的 Result
进行处理了,目的就是给出准确且友好的报错提示, 为了让大家更好的回顾我们修改过的内容,这里给出整体代码:
-use std::env; -use std::fs; -use std::process; - -fn main() { - let args: Vec<String> = env::args().collect(); - - // 对 build 返回的 `Result` 进行处理 - let config = Config::build(&args).unwrap_or_else(|err| { - println!("Problem parsing arguments: {err}"); - process::exit(1); - }); - - - println!("Searching for {}", config.query); - println!("In file {}", config.file_path); - - let contents = fs::read_to_string(config.file_path) - .expect("Should have been able to read the file"); - - println!("With text:\n{contents}"); -} - -struct Config { - query: String, - file_path: String, -} - -impl Config { - fn build(args: &[String]) -> Result<Config, &'static str> { - if args.len() < 3 { - return Err("not enough arguments"); - } - - let query = args[1].clone(); - let file_path = args[2].clone(); - - Ok(Config { query, file_path }) - } -} -
上面代码有几点值得注意:
-Result
包含错误时,我们不再调用 panic
让程序崩溃,而是通过 process::exit(1)
来终结进程,其中 1
是一个信号值(事实上非 0 值都可以),通知调用我们程序的进程,程序是因为错误而退出的。unwrap_or_else
是定义在 Result<T,E>
上的常用方法,如果 Result
是 Ok
,那该方法就类似 unwrap
:返回 Ok
内部的值;如果是 Err
,就调用闭包中的自定义代码对错误进行进一步处理综上可知,config
变量的值是一个 Config
实例,而 unwrap_or_else
闭包中的 err
参数,它的类型是 'static str
,值是 "not enough arguments" 那个字符串字面量。
运行后,可以看到以下输出:
-$ cargo run
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.48s
- Running `target/debug/minigrep`
-Problem parsing arguments: not enough arguments
-
-终于,我们得到了自己想要的输出:既告知了用户为何报错,又消除了多余的 debug 信息,非常棒。可能有用户疑惑,cargo run
底下还有一大堆 debug
信息呢,实际上,这是 cargo run
自带的,大家可以试试编译成二进制可执行文件后再调用,会是什么效果。
接下来可以继续精简 main
函数,那就是将主体逻辑( 例如业务逻辑 )从 main
中分离出去,这样 main
函数就保留主流程调用,非常简洁。
-// in main.rs -fn main() { - let args: Vec<String> = env::args().collect(); - - let config = Config::build(&args).unwrap_or_else(|err| { - println!("Problem parsing arguments: {err}"); - process::exit(1); - }); - - println!("Searching for {}", config.query); - println!("In file {}", config.file_path); - - run(config); -} - -fn run(config: Config) { - let contents = fs::read_to_string(config.file_path) - .expect("Should have been able to read the file"); - - println!("With text:\n{contents}"); -} - -// --snip-- -
如上所示,main
函数仅保留主流程各个环节的调用,一眼看过去非常简洁清晰。
继续之前,先请大家仔细看看 run
函数,你们觉得还缺少什么?提示:参考 build
函数的改进过程。
答案就是 run
函数没有错误处理,因为在文章开头我们提到过,错误处理最好统一在一个地方完成,这样极其有利于后续的代码维护。
--#![allow(unused)] -fn main() { -//in main.rs -use std::error::Error; - -// --snip-- - -fn run(config: Config) -> Result<(), Box<dyn Error>> { - let contents = fs::read_to_string(config.file_path)?; - - println!("With text:\n{contents}"); - - Ok(()) -} -} -
值得注意的是这里的 Result<(), Box<dyn Error>>
返回类型,首先我们的程序无需返回任何值,但是为了满足 Result<T,E>
的要求,因此使用了 Ok(())
返回一个单元类型 ()
。
最重要的是 Box<dyn Error>
, 如果按照顺序学到这里,大家应该知道这是一个Error
的特征对象(为了使用 Error
,我们通过 use std::error::Error;
进行了引入),它表示函数返回一个类型,该类型实现了 Error
特征,这样我们就无需指定具体的错误类型,否则你还需要查看 fs::read_to_string
返回的错误类型,然后复制到我们的 run
函数返回中,这么做一个是麻烦,最主要的是,一旦这么做,意味着我们无法在上层调用时统一处理错误,但是 Box<dyn Error>
不同,其它函数也可以返回这个特征对象,然后调用者就可以使用统一的方式来处理不同函数返回的 Box<dyn Error>
。
明白了 Box<dyn Error>
的重要战略地位,接下来大家分析下,fs::read_to_string
返回的具体错误类型是怎么被转化为 Box<dyn Error>
的?其实原因在之前章节都有讲过,这里就不直接给出答案了,参见 ?-传播界的大明星。
运行代码看看效果:
-$ cargo run the poem.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
-warning: unused `Result` that must be used
- --> src/main.rs:19:5
- |
-19 | run(config);
- | ^^^^^^^^^^^^
- |
- = note: `#[warn(unused_must_use)]` on by default
- = note: this `Result` may be an `Err` variant, which should be handled
-
-warning: `minigrep` (bin "minigrep") generated 1 warning
- Finished dev [unoptimized + debuginfo] target(s) in 0.71s
- Running `target/debug/minigrep the poem.txt`
-Searching for the
-In file poem.txt
-With text:
-I'm nobody! Who are you?
-Are you nobody, too?
-Then there's a pair of us - don't tell!
-They'd banish us, you know.
-
-How dreary to be somebody!
-How public, like a frog
-To tell your name the livelong day
-To an admiring bog!
-
-没任何问题,不过 Rust 编译器也给出了善意的提示,那就是 Result
并没有被使用,这可能意味着存在错误的潜在可能性。
-fn main() { - // --snip-- - - println!("Searching for {}", config.query); - println!("In file {}", config.file_path); - - if let Err(e) = run(config) { - println!("Application error: {e}"); - process::exit(1); - } -} -
先回忆下在 build
函数调用时,我们怎么处理错误的?然后与这里的方式做一下对比,是不是发现了一些区别?
没错 if let
的使用让代码变得更简洁,可读性也更加好,原因是,我们并不关注 run
返回的 Ok
值,因此只需要用 if let
去匹配是否存在错误即可。
好了,截止目前,代码看起来越来越美好了,距离我们的目标也只差一个:将主体逻辑代码分离到一个独立的文件 lib.rs
中。
--对于 Rust 的代码组织( 包和模块 )还不熟悉的同学,强烈建议回头温习下这一章。
-
首先,创建一个 src/lib.rs
文件,然后将所有的非 main
函数都移动到其中。代码大概类似:
--#![allow(unused)] -fn main() { -use std::error::Error; -use std::fs; - -pub struct Config { - pub query: String, - pub file_path: String, -} - -impl Config { - pub fn build(args: &[String]) -> Result<Config, &'static str> { - // --snip-- - } -} - -pub fn run(config: Config) -> Result<(), Box<dyn Error>> { - // --snip-- -} -} -
为了内容的简洁性,这里忽略了具体的实现,下一步就是在 main.rs
中引入 lib.rs
中定义的 Config
类型。
-use std::env; -use std::process; - -use minigrep::Config; - -fn main() { - // --snip-- - let args: Vec<String> = env::args().collect(); - - let config = Config::build(&args).unwrap_or_else(|err| { - println!("Problem parsing arguments: {err}"); - process::exit(1); - }); - - println!("Searching for {}", config.query); - println!("In file {}", config.file_path); - - if let Err(e) = minigrep::run(config) { - // --snip-- - println!("Application error: {e}"); - process::exit(1); - } -} -
很明显,这里的 mingrep::run
的调用,以及 Config
的引入,跟使用其它第三方包已经没有任何区别,也意味着我们成功的将逻辑代码放置到一个独立的库包中,其它包只要引入和调用就行。
呼,一顿书写猛如虎,回头一看。。。这么长的篇幅就写了这么点简单的代码??只能说,我也希望像很多国内的大学教材一样,只要列出定理和解题方法,然后留下足够的习题,就万事大吉了,但是咱们不行。
-接下来,到了最喜(令)闻(人)乐(讨)见(厌)的环节:写测试代码,一起来开心吧。
- - -迄今为止,所有的输出信息,无论 debug 还是 error 类型,都是通过 println!
宏输出到终端的标准输出( stdout
),但是对于程序来说,错误信息更适合输出到标准错误输出(stderr)。
这样修改后,用户就可以选择将普通的日志类信息输出到日志文件 1,然后将错误信息输出到日志文件 2,甚至还可以输出到终端命令行。
-我们先来观察下,目前的输出信息包括错误,是否是如上面所说,都写到标准错误输出。
-测试方式很简单,将标准错误输出的内容重定向到文件中,看看是否包含故意生成的错误信息即可。
-$ cargo run > output.txt
-
-首先,这里的运行没有带任何参数,因此会报出类如文件不存在的错误,其次,通过 >
操作符,标准输出上的内容被重定向到文件 output.txt
中,不再打印到控制上。
大家先观察下控制台,然后再看看 output.txt
,是否发现如下的错误信息已经如期被写入到文件中?
Problem parsing arguments: not enough arguments
-
-所以,可以得出一个结论,如果错信息输出到标准输出,那么它们将跟普通的日志信息混在一起,难以分辨,因此我们需要将错误信息进行单独输出。
-将错误信息重定向到 stderr
很简单,只需在打印错误的地方,将 println!
宏替换为 eprintln!
即可。
-fn main() { - let args: Vec<String> = env::args().collect(); - - let config = Config::build(&args).unwrap_or_else(|err| { - eprintln!("Problem parsing arguments: {err}"); - process::exit(1); - }); - - if let Err(e) = minigrep::run(config) { - eprintln!("Application error: {e}"); - process::exit(1); - } -} -
接下来,还是同样的运行命令:
-$ cargo run > output.txt
-Problem parsing arguments: not enough arguments
-
-可以看到,日志信息成功的重定向到 output.txt
文件中,而错误信息由于 eprintln!
的使用,被写入到标准错误输出中,默认还是输出在控制台中。
再来试试没有错误的情况:
-$ cargo run -- to poem.txt > output.txt
-
-这次运行参数很正确,因此也没有任何错误信息产生,同时由于我们重定向了标准输出,因此相应的输出日志会写入到 output.txt
中,打开可以看到如下内容:
Are you nobody, too?
-How dreary to be somebody!
-
-至此,简易搜索程序 minigrep
已经基本完成,下一章节将使用迭代器进行部分改进,请大家在看完迭代器章节后,再回头阅读。
--开始之前,推荐大家先了解下如何在 Rust 中编写测试代码,这块儿内容不复杂,先了解下有利于本章的继续阅读
-
在之前的章节中,我们完成了对项目结构的重构,并将进入逻辑代码编程的环节,但在此之前,我们需要先编写一些测试代码,也是最近颇为流行的测试驱动开发模式(TDD, Test Driven Development):
-这三个步骤将在我们的开发过程中不断循环,直到所有的代码都开发完成并成功通过所有测试。
-既然要添加测试,那之前的 println!
语句将没有大的用处,毕竟 println!
存在的目的就是为了让我们看到结果是否正确,而现在测试用例将取而代之。
接下来,在 lib.rs
文件中,添加 tests
模块和 test
函数:
--#![allow(unused)] -fn main() { -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn one_result() { - let query = "duct"; - let contents = "\ -Rust: -safe, fast, productive. -Pick three."; - - assert_eq!(vec!["safe, fast, productive."], search(query, contents)); - } -} -} -
测试用例将在指定的内容中搜索 duct
字符串,目测可得:其中有一行内容是包含有目标字符串的。
但目前为止,还无法运行该测试用例,更何况还想幸灾乐祸的看其失败,原因是 search
函数还没有实现!毕竟是测试驱动、测试先行。
--#![allow(unused)] -fn main() { -// in lib.rs -pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { - vec![] -} -} -
先添加一个简单的 search
函数实现,非常简单粗暴的返回一个空的数组,显而易见测试用例将成功通过,真是一个居心叵测的测试用例!
注意这里生命周期 'a
的使用,之前的章节有详细介绍,不太明白的同学可以回头看看。
喔,这么复杂的代码,都用上生命周期了!嘚瑟两下试试:
-$ cargo test
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished test [unoptimized + debuginfo] target(s) in 0.97s
- Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
-
-running 1 test
-test tests::one_result ... FAILED
-
-failures:
-
----- tests::one_result stdout ----
-thread 'main' panicked at 'assertion failed: `(left == right)`
- left: `["safe, fast, productive."]`,
- right: `[]`', src/lib.rs:44:9
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-
-failures:
- tests::one_result
-
-test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
-error: test failed, to rerun pass `--lib`
-
-太棒了!它失败了...
-接着就是测试驱动的第二步:编写注定成功的测试。当然,前提条件是实现我们的 search
函数。它包含以下步骤:
contents
的每一行Rust 提供了一个很便利的 lines
方法将目标字符串进行按行分割:
--#![allow(unused)] -fn main() { -// in lib.rs -pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { - for line in contents.lines() { - // do something with line - } -} -} -
这里的 lines
返回一个迭代器,关于迭代器在后续章节会详细讲解,现在只要知道 for
可以遍历取出迭代器中的值即可。
--#![allow(unused)] -fn main() { -// in lib.rs -pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { - for line in contents.lines() { - if line.contains(query) { - // do something with line - } - } -} -} -
与之前的 lines
函数类似,Rust 的字符串还提供了 contains
方法,用于检查 line
是否包含待查询的 query
。
接下来,只要返回合适的值,就可以完成 search
函数的编写。
简单,创建一个 Vec
动态数组,然后将查询到的每一个 line
推进数组中即可:
--#![allow(unused)] -fn main() { -// in lib.rs -pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { - let mut results = Vec::new(); - - for line in contents.lines() { - if line.contains(query) { - results.push(line); - } - } - - results -} -} -
至此,search
函数已经完成了既定目标,为了检查功能是否正确,运行下我们之前编写的测试用例:
$ cargo test
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished test [unoptimized + debuginfo] target(s) in 1.22s
- Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
-
-running 1 test
-test tests::one_result ... ok
-
-test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
- Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
-
-running 0 tests
-
-test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
- Doc-tests minigrep
-
-running 0 tests
-
-test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
-测试通过,意味着我们的代码也完美运行,接下来就是在 run
函数中大显身手了。
--#![allow(unused)] -fn main() { -// in src/lib.rs -pub fn run(config: Config) -> Result<(), Box<dyn Error>> { - let contents = fs::read_to_string(config.file_path)?; - - for line in search(&config.query, &contents) { - println!("{line}"); - } - - Ok(()) -} -} -
好,再运行下看看结果,看起来我们距离成功从未如此之近!
-$ cargo run -- frog poem.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.38s
- Running `target/debug/minigrep frog poem.txt`
-How public, like a frog
-
-酷!成功查询到包含 frog
的行,再来试试 body
:
$ cargo run -- body poem.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep body poem.txt`
-I'm nobody! Who are you?
-Are you nobody, too?
-How dreary to be somebody!
-
-完美,三行,一行不少,为了确保万无一失,再来试试查询一个不存在的单词:
-cargo run -- monomorphization poem.txt
- Compiling minigrep v0.1.0 (file:///projects/minigrep)
- Finished dev [unoptimized + debuginfo] target(s) in 0.0s
- Running `target/debug/minigrep monomorphization poem.txt`
-
-至此,章节开头的目标已经全部完成,接下来思考一个小问题:如果要为程序加上大小写不敏感的控制命令,由用户进行输入,该怎么实现比较好呢?毕竟在实际搜索查询中,同时支持大小写敏感和不敏感还是很重要的。
-答案留待下一章节揭晓。
- - -这三个类型所处的地位比较尴尬,你说它们重要吧,确实出现的身影不是很多,说它们不重要吧,有时候也是不可或缺,而且这三个类型都有一个共同点:简单,因此我们统一放在一起讲。
-字符,对于没有其它编程经验的新手来说可能不太好理解(没有编程经验敢来学 Rust 的绝对是好汉),但是你可以把它理解为英文中的字母,中文中的汉字。
-下面的代码展示了几个颇具异域风情的字符:
--fn main() { - let c = 'z'; - let z = 'ℤ'; - let g = '国'; - let heart_eyed_cat = '😻'; -} -
如果大家是从有年代感的编程语言过来,可能会大喊一声:这 XX 叫字符?是的,在 Rust 语言中这些都是字符,Rust 的字符不仅仅是 ASCII
,所有的 Unicode
值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode
值的范围从 U+0000 ~ U+D7FF
和 U+E000 ~ U+10FFFF
。不过“字符”并不是 Unicode
中的一个概念,所以人在直觉上对“字符”的理解和 Rust 的字符概念并不一致。
由于 Unicode
都是 4 个字节编码,因此字符类型也是占用 4 个字节:
-fn main() { - let x = '中'; - println!("字符'中'占用了{}字节的内存大小",std::mem::size_of_val(&x)); -} -
输出如下:
-$ cargo run
- Compiling ...
-
-字符'中'占用了4字节的内存大小
-
---注意,我们还没开始讲字符串,但是这里提前说一下,和一些语言不同,Rust 的字符只能用
-''
来表示,""
是留给字符串的。
Rust 中的布尔类型有两个可能的值:true
和 false
,布尔值占用内存的大小为 1
个字节:
-fn main() { - let t = true; - - let f: bool = false; // 使用类型标注,显式指定f的类型 - - if f { - println!("这是段毫无意义的代码"); - } -} -
使用布尔类型的场景主要在于流程控制,例如上述代码的中的 if
就是其中之一。
单元类型就是 ()
,对,你没看错,就是 ()
,唯一的值也是 ()
,一些读者读到这里可能就不愿意了,你也太敷衍了吧,管这叫类型?
只能说,再不起眼的东西,都有其用途,在目前为止的学习过程中,大家已经看到过很多次 fn main()
函数的使用吧?那么这个函数返回什么呢?
没错, main
函数就返回这个单元类型 ()
,你不能说 main
函数无返回值,因为没有返回值的函数在 Rust 中是有单独的定义的:发散函数( diverge function )
,顾名思义,无法收敛的函数。
例如常见的 println!()
的返回值也是单元类型 ()
。
再比如,你可以用 ()
作为 map
的值,表示我们不关注具体的值,只关注 key
。 这种用法和 Go 语言的 struct{} 类似,可以作为一个值用来占位,但是完全不占用任何内存。
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
Rust 的函数我们在之前已经见过不少,跟其他语言几乎没有什么区别。因此本章的学习之路将轻松和愉快,骚年们,请珍惜这种愉快,下一章你将体验到不一样的 Rust。
-在函数界,有一个函数只闻其名不闻其声,可以止小孩啼!在程序界只有 hello,world!
可以与之媲美,它就是 add
函数:
--#![allow(unused)] -fn main() { -fn add(i: i32, j: i32) -> i32 { - i + j - } -} -
该函数如此简单,但是又是如此的五脏俱全,声明函数的关键字 fn
,函数名 add()
,参数 i
和 j
,参数类型和返回值类型都是 i32
,总之一切那么的普通,但是又那么的自信,直到你看到了下面这张图:
当你看懂了这张图,其实就等于差不多完成了函数章节的学习,但是这么短的章节显然对不起读者老爷们的厚爱,所以我们来展开下。
-fn add_two() -> {}
Rust 是强类型语言,因此需要你为每一个函数参数都标识出它的具体类型,例如:
--fn main() { - another_function(5, 6.1); -} - -fn another_function(x: i32, y: f32) { - println!("The value of x is: {}", x); - println!("The value of y is: {}", y); -} -
another_function
函数有两个参数,其中 x
是 i32
类型,y
是 f32
类型,然后在该函数内部,打印出这两个值。这里去掉 x
或者 y
的任何一个的类型,都会报错:
-fn main() { - another_function(5, 6.1); -} - -fn another_function(x: i32, y) { - println!("The value of x is: {}", x); - println!("The value of y is: {}", y); -} -
错误如下:
-error: expected one of `:`, `@`, or `|`, found `)`
- --> src/main.rs:5:30
- |
-5 | fn another_function(x: i32, y) {
- | ^ expected one of `:`, `@`, or `|` // 期待以下符号之一 `:`, `@`, or `|`
- |
- = note: anonymous parameters are removed in the 2018 edition (see RFC 1685)
- // 匿名参数在 Rust 2018 edition 中就已经移除
-help: if this is a parameter name, give it a type // 如果y是一个参数名,请给予它一个类型
- |
-5 | fn another_function(x: i32, y: TypeName) {
- | ~~~~~~~~~~~
-help: if this is a type, explicitly ignore the parameter name // 如果y是一个类型,请使用_忽略参数名
- |
-5 | fn another_function(x: i32, _: y) {
- | ~~~~
-
-在上一章节语句和表达式中,我们有提到,在 Rust 中函数就是表达式,因此我们可以把函数的返回值直接赋给调用者。
-函数的返回值就是函数体最后一条表达式的返回值,当然我们也可以使用 return
提前返回,下面的函数使用最后一条表达式来返回一个值:
-fn plus_five(x:i32) -> i32 { - x + 5 -} - -fn main() { - let x = plus_five(5); - - println!("The value of x is: {}", x); -} -
x + 5
是一条表达式,求值后,返回一个值,因为它是函数的最后一行,因此该表达式的值也是函数的返回值。
再来看两个重点:
-let x = plus_five(5)
,说明我们用一个函数的返回值来初始化 x
变量,因此侧面说明了在 Rust 中函数也是表达式,这种写法等同于 let x = 5 + 5;
x + 5
没有分号,因为它是一条表达式,这个在上一节中我们也有详细介绍再来看一段代码,同时使用 return
和表达式作为返回值:
-fn plus_or_minus(x:i32) -> i32 { - if x > 5 { - return x - 5 - } - - x + 5 -} - -fn main() { - let x = plus_or_minus(5); - - println!("The value of x is: {}", x); -} -
plus_or_minus
函数根据传入 x
的大小来决定是做加法还是减法,若 x > 5
则通过 return
提前返回 x - 5
的值,否则返回 x + 5
的值。
()
对于 Rust 新手来说,有些返回类型很难理解,而且如果你想通过百度或者谷歌去搜索,都不好查询,因为这些符号太常见了,根本难以精确搜索到。
-例如单元类型 ()
,是一个零长度的元组。它没啥作用,但是可以用来表达一个函数没有返回值:
()
;
结尾的表达式返回一个 ()
例如下面的 report
函数会隐式返回一个 ()
:
--#![allow(unused)] -fn main() { -use std::fmt::Debug; - -fn report<T: Debug>(item: T) { - println!("{:?}", item); - -} -} -
与上面的函数返回值相同,但是下面的函数显式的返回了 ()
:
--#![allow(unused)] -fn main() { -fn clear(text: &mut String) -> () { - *text = String::from(""); -} -} -
在实际编程中,你会经常在错误提示中看到该 ()
的身影出没,假如你的函数需要返回一个 u32
值,但是如果你不幸的以 表达式;
的方式作为函数的最后一行代码,就会报错:
--#![allow(unused)] -fn main() { -fn add(x:u32,y:u32) -> u32 { - x + y; -} -} -
错误如下:
-error[E0308]: mismatched types // 类型不匹配
- --> src/main.rs:6:24
- |
-6 | fn add(x:u32,y:u32) -> u32 {
- | --- ^^^ expected `u32`, found `()` // 期望返回u32,却返回()
- | |
- | implicitly returns `()` as its body has no tail or `return` expression
-7 | x + y;
- | - help: consider removing this semicolon
-
-还记得我们在语句与表达式中讲过的吗?只有表达式能返回值,而 ;
结尾的是语句,在 Rust 中,一定要严格区分表达式和语句的区别,这个在其它语言中往往是被忽视的点。
!
当用 !
作函数返回类型的时候,表示该函数永不返回( diverge function ),特别的,这种语法往往用做会导致程序崩溃的函数:
--#![allow(unused)] -fn main() { -fn dead_end() -> ! { - panic!("你已经到了穷途末路,崩溃吧!"); -} -} -
下面的函数创建了一个无限循环,该循环永不跳出,因此函数也永不返回:
---#![allow(unused)] -fn main() { -fn forever() -> ! { - loop { - //... - }; -} -} -
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
当一门语言不谈类型时,你得小心,这大概率是动态语言(别拍我,我承认是废话)。但是把类型大张旗鼓的用多个章节去讲的,Rust 是其中之一。
-Rust 每个值都有其确切的数据类型,总的来说可以分为两类:基本类型和复合类型。 基本类型意味着它们往往是一个最小化原子类型,无法解构为其它类型(一般意义上来说),由以下组成:
-i8
, i16
, i32
, i64
, isize
)、 无符号整数 (u8
, u16
, u32
, u64
, usize
) 、浮点数 (f32
, f64
)、以及有理数、复数&str
true
和false
()
,其唯一的值也是 ()
与 Python、JavaScript 等动态语言不同,Rust 是一门静态类型语言,也就是编译器必须在编译期知道我们所有变量的类型,但这不意味着你需要为每个变量指定类型,因为 Rust 编译器很聪明,它可以根据变量的值和上下文中的使用方式来自动推导出变量的类型,同时编译器也不够聪明,在某些情况下,它无法推导出变量类型,需要手动去给予一个类型标注,关于这一点在 Rust 语言初印象中有过展示。
-来看段代码:
---#![allow(unused)] -fn main() { -let guess = "42".parse().expect("Not a number!"); -} -
先忽略 .parse().expect..
部分,这段代码的目的是将字符串 "42"
进行解析,而编译器在这里无法推导出我们想要的类型:整数?浮点数?字符串?因此编译器会报错:
$ cargo build
- Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
-error[E0282]: type annotations needed
- --> src/main.rs:2:9
- |
-2 | let guess = "42".parse().expect("Not a number!");
- | ^^^^^ consider giving `guess` a type
-
-因此我们需要提供给编译器更多的信息,例如给 guess
变量一个显式的类型标注:let guess: i32 = ...
或者 "42".parse::<i32>()
。
我朋友有一个领导(读者:你朋友?黑人问号)说过一句话:所有代码就是 0 和 1 ,简单的很。咱不评价这句话的正确性,但是计算机底层由 01 组成倒是真的。
-计算机和数值关联在一起的时间,远比我们想象的要长,因此数值类型可以说是有计算机以来就有的类型,下面内容将深入讨论 Rust 的数值类型以及相关的运算符。
-Rust 使用一个相对传统的语法来创建整数(1
,2
,...)和浮点数(1.0
,1.1
,...)。整数、浮点数的运算和你在其它语言上见过的一致,都是通过常见的运算符来完成。
--不仅仅是数值类型,Rust 也允许在复杂类型上定义运算符,例如在自定义类型上定义
-+
运算符,这种行为被称为运算符重载,Rust 具体支持的可重载运算符见附录 B。
整数是没有小数部分的数字。之前使用过的 i32
类型,表示有符号的 32 位整数( i
是英文单词 integer 的首字母,与之相反的是 u
,代表无符号 unsigned
类型)。下表显示了 Rust 中的内置的整数类型:
长度 | 有符号类型 | 无符号类型 |
---|---|---|
8 位 | i8 | u8 |
16 位 | i16 | u16 |
32 位 | i32 | u32 |
64 位 | i64 | u64 |
128 位 | i128 | u128 |
视架构而定 | isize | usize |
类型定义的形式统一为:有无符号 + 类型大小(位数)
。无符号数表示数字只能取正数,而有符号则表示数字既可以取正数又可以取负数。就像在纸上写数字一样:当要强调符号时,数字前面可以带上正号或负号;然而,当很明显确定数字为正数时,就不需要加上正号了。有符号数字以补码形式存储。
每个有符号类型规定的数字范围是 -(2n - 1) ~ 2n -
-1 - 1,其中 n
是该定义形式的位长度。因此 i8
可存储数字范围是 -(27) ~ 27 - 1,即 -128 ~ 127。无符号类型可以存储的数字范围是 0 ~ 2n - 1,所以 u8
能够存储的数字为 0 ~ 28 - 1,即 0 ~ 255。
此外,isize
和 usize
类型取决于程序运行的计算机 CPU 类型: 若 CPU 是 32 位的,则这两个类型是 32 位的,同理,若 CPU 是 64 位,那么它们则是 64 位。
整形字面量可以用下表的形式书写:
-数字字面量 | 示例 |
---|---|
十进制 | 98_222 |
十六进制 | 0xff |
八进制 | 0o77 |
二进制 | 0b1111_0000 |
字节 (仅限于 u8 ) | b'A' |
这么多类型,有没有一个简单的使用准则?答案是肯定的, Rust 整型默认使用 i32
,例如 let i = 1
,那 i
就是 i32
类型,因此你可以首选它,同时该类型也往往是性能最好的。isize
和 usize
的主要应用场景是用作集合的索引。
假设有一个 u8
,它可以存放从 0 到 255 的值。那么当你将其修改为范围之外的值,比如 256,则会发生整型溢出。关于这一行为 Rust 有一些有趣的规则:当在 debug 模式编译时,Rust 会检查整型溢出,若存在这些问题,则使程序在编译时 panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。
在当使用 --release
参数进行 release 模式构建时,Rust 不检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理。简而言之,大于该类型最大值的数值会被补码转换成该类型能够支持的对应数字的最小值。比如在 u8
的情况下,256 变成 0,257 变成 1,依此类推。程序不会 panic,但是该变量的值可能不是你期望的值。依赖这种默认行为的代码都应该被认为是错误的代码。
要显式处理可能的溢出,可以使用标准库针对原始数字类型提供的这些方法:
-wrapping_*
方法在所有模式下都按照补码循环溢出规则处理,例如 wrapping_add
checked_*
方法时发生溢出,则返回 None
值overflowing_*
方法返回该值和一个指示是否存在溢出的布尔值saturating_*
方法使值达到最小值或最大值下面是一个演示wrapping_*
方法的示例:
-fn main() { - let a : u8 = 255; - let b = a.wrapping_add(20); - println!("{}", b); // 19 -} -
浮点类型数字 是带有小数点的数字,在 Rust 中浮点类型数字也有两种基本类型: f32
和 f64
,分别为 32 位和 64 位大小。默认浮点类型是 f64
,在现代的 CPU 中它的速度与 f32
几乎相同,但精度更高。
下面是一个演示浮点数的示例:
--fn main() { - let x = 2.0; // f64 - - let y: f32 = 3.0; // f32 -} -
浮点数根据 IEEE-754
标准实现。f32
类型是单精度浮点型,f64
为双精度。
浮点数由于底层格式的特殊性,导致了如果在使用浮点数时不够谨慎,就可能造成危险,有两个原因:
-浮点数往往是你想要数字的近似表达
-浮点数类型是基于二进制实现的,但是我们想要计算的数字往往是基于十进制,例如 0.1
在二进制上并不存在精确的表达形式,但是在十进制上就存在。这种不匹配性导致一定的歧义性,更多的,虽然浮点数能代表真实的数值,但是由于底层格式问题,它往往受限于定长的浮点数精度,如果你想要表达完全精准的真实数字,只有使用无限精度的浮点数才行
浮点数在某些特性上是反直觉的
-例如大家都会觉得浮点数可以进行比较,对吧?是的,它们确实可以使用 >
,>=
等进行比较,但是在某些场景下,这种直觉上的比较特性反而会害了你。因为 f32
, f64
上的比较运算实现的是 std::cmp::PartialEq
特征(类似其他语言的接口),但是并没有实现 std::cmp::Eq
特征,但是后者在其它数值类型上都有定义,说了这么多,可能大家还是云里雾里,用一个例子来举例:
Rust 的 HashMap
数据结构,是一个 KV 类型的 Hash Map 实现,它对于 K
没有特定类型的限制,但是要求能用作 K
的类型必须实现了 std::cmp::Eq
特征,因此这意味着你无法使用浮点数作为 HashMap
的 Key
,来存储键值对,但是作为对比,Rust 的整数类型、字符串类型、布尔类型都实现了该特征,因此可以作为 HashMap
的 Key
。
为了避免上面说的两个陷阱,你需要遵守以下准则:
-来看个小例子:
--fn main() { - // 断言0.1 + 0.2与0.3相等 - assert!(0.1 + 0.2 == 0.3); -} -
你可能以为,这段代码没啥问题吧,实际上它会 panic(程序崩溃,抛出异常),因为二进制精度问题,导致了 0.1 + 0.2 并不严格等于 0.3,它们可能在小数点 N 位后存在误差。
-那如果非要进行比较呢?可以考虑用这种方式 (0.1_f64 + 0.2 - 0.3).abs() < 0.00001
,具体小于多少,取决于你对精度的需求。
讲到这里,相信大家基本已经明白了,为什么操作浮点数时要格外的小心,但是还不够,下面再来一段代码,直接震撼你的灵魂:
--fn main() { - let abc: (f32, f32, f32) = (0.1, 0.2, 0.3); - let xyz: (f64, f64, f64) = (0.1, 0.2, 0.3); - - println!("abc (f32)"); - println!(" 0.1 + 0.2: {:x}", (abc.0 + abc.1).to_bits()); - println!(" 0.3: {:x}", (abc.2).to_bits()); - println!(); - - println!("xyz (f64)"); - println!(" 0.1 + 0.2: {:x}", (xyz.0 + xyz.1).to_bits()); - println!(" 0.3: {:x}", (xyz.2).to_bits()); - println!(); - - assert!(abc.0 + abc.1 == abc.2); - assert!(xyz.0 + xyz.1 == xyz.2); -} -
运行该程序,输出如下:
-abc (f32)
- 0.1 + 0.2: 3e99999a
- 0.3: 3e99999a
-
-xyz (f64)
- 0.1 + 0.2: 3fd3333333333334
- 0.3: 3fd3333333333333
-
-thread 'main' panicked at 'assertion failed: xyz.0 + xyz.1 == xyz.2',
-➥ch2-add-floats.rs.rs:14:5
-note: run with `RUST_BACKTRACE=1` environment variable to display
-➥a backtrace
-
-仔细看,对 f32
类型做加法时,0.1 + 0.2
的结果是 3e99999a
,0.3
也是 3e99999a
,因此 f32
下的 0.1 + 0.2 == 0.3
通过测试,但是到了 f64
类型时,结果就不一样了,因为 f64
精度高很多,因此在小数点非常后面发生了一点微小的变化,0.1 + 0.2
以 4
结尾,但是 0.3
以3
结尾,这个细微区别导致 f64
下的测试失败了,并且抛出了异常。
是不是blow your mind away? 没关系,在本书的后续章节中类似的直击灵魂的地方还很多,这就是敢号称 Rust语言圣经(Rust Course)
的底气!
对于数学上未定义的结果,例如对负数取平方根 -42.1.sqrt()
,会产生一个特殊的结果:Rust 的浮点数类型使用 NaN
(not a number)来处理这些情况。
所有跟 NaN
交互的操作,都会返回一个 NaN
,而且 NaN
不能用来比较,下面的代码会崩溃:
-fn main() { - let x = (-42.0_f32).sqrt(); - assert_eq!(x, x); -} -
出于防御性编程的考虑,可以使用 is_nan()
等方法,可以用来判断一个数值是否是 NaN
:
-fn main() { - let x = (-42.0_f32).sqrt(); - if x.is_nan() { - println!("未定义的数学行为") - } -} -
Rust 支持所有数字类型的基本数学运算:加法、减法、乘法、除法和取模运算。下面代码各使用一条 let
语句来说明相应运算的用法:
-fn main() { - // 加法 - let sum = 5 + 10; - - // 减法 - let difference = 95.5 - 4.3; - - // 乘法 - let product = 4 * 30; - - // 除法 - let quotient = 56.7 / 32.2; - - // 求余 - let remainder = 43 % 5; -} -
这些语句中的每个表达式都使用了数学运算符,并且计算结果为一个值,然后绑定到一个变量上。附录 B 中给出了 Rust 提供的所有运算符的列表。
-再来看一个综合性的示例:
--fn main() { - // 编译器会进行自动推导,给予twenty i32的类型 - let twenty = 20; - // 类型标注 - let twenty_one: i32 = 21; - // 通过类型后缀的方式进行类型标注:22是i32类型 - let twenty_two = 22i32; - - // 只有同样类型,才能运算 - let addition = twenty + twenty_one + twenty_two; - println!("{} + {} + {} = {}", twenty, twenty_one, twenty_two, addition); - - // 对于较长的数字,可以用_进行分割,提升可读性 - let one_million: i64 = 1_000_000; - println!("{}", one_million.pow(2)); - - // 定义一个f32数组,其中42.0会自动被推导为f32类型 - let forty_twos = [ - 42.0, - 42f32, - 42.0_f32, - ]; - - // 打印数组中第一个值,并控制小数位为2位 - println!("{:.2}", forty_twos[0]); -} -
Rust的运算基本上和其他语言一样
-运算符 | 说明 |
---|---|
& 位与 | 相同位置均为1时则为1,否则为0 |
| 位或 | 相同位置只要有1时则为1,否则为0 |
^ 异或 | 相同位置不相同则为1,相同则为0 |
! 位非 | 把位中的0和1相互取反,即0置为1,1置为0 |
<< 左移 | 所有位向左移动指定位数,右位补0 |
>> 右移 | 所有位向右移动指定位数,带符号移动(正数补0,负数补1) |
-fn main() { - // 二进制为00000010 - let a:i32 = 2; - // 二进制为00000011 - let b:i32 = 3; - - println!("(a & b) value is {}", a & b); - - println!("(a | b) value is {}", a | b); - - println!("(a ^ b) value is {}", a ^ b); - - println!("(!b) value is {} ", !b); - - println!("(a << b) value is {}", a << b); - - println!("(a >> b) value is {}", a >> b); - - let mut a = a; - // 注意这些计算符除了!之外都可以加上=进行赋值 (因为!=要用来判断不等于) - a <<= b; - println!("(a << b) value is {}", a); -} -
Rust 提供了一个非常简洁的方式,用来生成连续的数值,例如 1..5
,生成从 1 到 4 的连续数字,不包含 5 ;1..=5
,生成从 1 到 5 的连续数字,包含 5,它的用途很简单,常常用于循环中:
--#![allow(unused)] -fn main() { -for i in 1..=5 { - println!("{}",i); -} -} -
最终程序输出:
-1
-2
-3
-4
-5
-
-序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。如下是一个使用字符类型序列的例子:
---#![allow(unused)] -fn main() { -for i in 'a'..='z' { - println!("{}",i); -} -} -
Rust 的标准库相比其它语言,准入门槛较高,因此有理数和复数并未包含在标准库中:
-好在社区已经开发出高质量的 Rust 数值库:num。
-按照以下步骤来引入 num
库:
cargo new complex-num && cd complex-num
Cargo.toml
中的 [dependencies]
下添加一行 num = "0.4.0"
src/main.rs
文件中的 main
函数替换为下面的代码cargo run
-use num::complex::Complex; - - fn main() { - let a = Complex { re: 2.1, im: -1.2 }; - let b = Complex::new(11.1, 22.2); - let result = a + b; - - println!("{} + {}i", result.re, result.im) - } -
之前提到了过 Rust 的数值类型和运算跟其他语言较为相似,但是实际上,除了语法上的不同之外,还是存在一些差异点:
-13.14
取整:13.14_f32.round()
,在这里我们使用了类型后缀,因为编译器需要知道 13.14
的具体类型数值类型的讲解已经基本结束,接下来,来看看字符和布尔类型。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
Rust 的函数体是由一系列语句组成,最后由一个表达式来返回值,例如:
---#![allow(unused)] -fn main() { -fn add_with_extra(x: i32, y: i32) -> i32 { - let x = x + 1; // 语句 - let y = y + 5; // 语句 - x + y // 表达式 -} -} -
语句会执行一些操作但是不会返回一个值,而表达式会在求值后返回一个值,因此在上述函数体的三行代码中,前两行是语句,最后一行是表达式。
-对于 Rust 语言而言,这种基于语句(statement)和表达式(expression)的方式是非常重要的,你需要能明确的区分这两个概念, 但是对于很多其它语言而言,这两个往往无需区分。基于表达式是函数式语言的重要特征,表达式总要返回值。
-其实,在此之前,我们已经多次使用过语句和表达式。
---#![allow(unused)] -fn main() { -let a = 8; -let b: Vec<f64> = Vec::new(); -let (a, c) = ("hi", false); -} -
以上都是语句,它们完成了一个具体的操作,但是并没有返回值,因此是语句。
-由于 let
是语句,因此不能将 let
语句赋值给其它值,如下形式是错误的:
--#![allow(unused)] -fn main() { -let b = (let a = 8); -} -
错误如下:
-error: expected expression, found statement (`let`) // 期望表达式,却发现`let`语句
- --> src/main.rs:2:13
- |
-2 | let b = let a = 8;
- | ^^^^^^^^^
- |
- = note: variable declaration using `let` is a statement `let`是一条语句
-
-error[E0658]: `let` expressions in this position are experimental
- // 下面的 `let` 用法目前是试验性的,在稳定版中尚不能使用
- --> src/main.rs:2:13
- |
-2 | let b = let a = 8;
- | ^^^^^^^^^
- |
- = note: see issue #53667 <https://github.com/rust-lang/rust/issues/53667> for more information
- = help: you can write `matches!(<expr>, <pattern>)` instead of `let <pattern> = <expr>`
-
-
-以上的错误告诉我们 let
是语句,不是表达式,因此它不返回值,也就不能给其它变量赋值。但是该错误还透漏了一个重要的信息, let
作为表达式已经是试验功能了,也许不久的将来,我们在 stable rust
下可以这样使用。
表达式会进行求值,然后返回一个值。例如 5 + 6
,在求值后,返回值 11
,因此它就是一条表达式。
表达式可以成为语句的一部分,例如 let y = 6
中,6
就是一个表达式,它在求值后返回一个值 6
(有些反直觉,但是确实是表达式)。
调用一个函数是表达式,因为会返回一个值,调用宏也是表达式,用花括号包裹最终返回一个值的语句块也是表达式,总之,能返回值,它就是表达式:
--fn main() { - let y = { - let x = 3; - x + 1 - }; - - println!("The value of y is: {}", y); -} -
上面使用一个语句块表达式将值赋给 y
变量,语句块长这样:
--#![allow(unused)] -fn main() { -{ - let x = 3; - x + 1 -} -} -
该语句块是表达式的原因是:它的最后一行是表达式,返回了 x + 1
的值,注意 x + 1
不能以分号结尾,否则就会从表达式变成语句, 表达式不能包含分号。这一点非常重要,一旦你在表达式后加上分号,它就会变成一条语句,再也不会返回一个值,请牢记!
最后,表达式如果不返回任何值,会隐式地返回一个 ()
。
-fn main() { - assert_eq!(ret_unit_type(), ()) -} - -fn ret_unit_type() { - let x = 1; - // if 语句块也是一个表达式,因此可以用于赋值,也可以直接返回 - // 类似三元运算符,在Rust里我们可以这样写 - let y = if x % 2 == 1 { - "odd" - } else { - "even" - }; - // 或者写成一行 - let z = if x % 2 == 1 { "odd" } else { "even" }; -} -
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
和动态数组一样,HashMap
也是 Rust 标准库中提供的集合类型,但是又与动态数组不同,HashMap
中存储的是一一映射的 KV
键值对,并提供了平均复杂度为 O(1)
的查询方法,当我们希望通过一个 Key
去查询值时,该类型非常有用,以致于 Go 语言将该类型设置成了语言级别的内置特性。
Rust 中哈希类型(哈希映射)为 HashMap<K,V>
,在其它语言中,也有类似的数据结构,例如 hash map
,map
,object
,hash table
,字典
等等,引用小品演员孙涛的一句台词:大家都是本地狐狸,别搁那装貂 :)。
跟创建动态数组 Vec
的方法类似,可以使用 new
方法来创建 HashMap
,然后通过 insert
方法插入键值对。
--#![allow(unused)] -fn main() { -use std::collections::HashMap; - -// 创建一个HashMap,用于存储宝石种类和对应的数量 -let mut my_gems = HashMap::new(); - -// 将宝石类型和对应的数量写入表中 -my_gems.insert("红宝石", 1); -my_gems.insert("蓝宝石", 2); -my_gems.insert("河边捡的误以为是宝石的破石头", 18); -} -
很简单对吧?跟其它语言没有区别,聪明的同学甚至能够猜到该 HashMap
的类型:HashMap<&str,i32>
。
但是还有一点,你可能没有注意,那就是使用 HashMap
需要手动通过 use ...
从标准库中引入到我们当前的作用域中来,仔细回忆下,之前使用另外两个集合类型 String
和 Vec
时,我们是否有手动引用过?答案是 No,因为 HashMap
并没有包含在 Rust 的 prelude
中(Rust 为了简化用户使用,提前将最常用的类型自动引入到作用域中)。
所有的集合类型都是动态的,意味着它们没有固定的内存大小,因此它们底层的数据都存储在内存堆上,然后通过一个存储在栈中的引用类型来访问。同时,跟其它集合类型一致,HashMap
也是内聚性的,即所有的 K
必须拥有同样的类型,V
也是如此。
--跟
-Vec
一样,如果预先知道要存储的KV
对个数,可以使用HashMap::with_capacity(capacity)
创建指定大小的HashMap
,避免频繁的内存分配和拷贝,提升性能。
在实际使用中,不是所有的场景都能 new
一个哈希表后,然后悠哉悠哉的依次插入对应的键值对,而是可能会从另外一个数据结构中,获取到对应的数据,最终生成 HashMap
。
例如考虑一个场景,有一张表格中记录了足球联赛中各队伍名称和积分的信息,这张表如果被导入到 Rust 项目中,一个合理的数据结构是 Vec<(String, u32)>
类型,该数组中的元素是一个个元组,该数据结构跟表格数据非常契合:表格中的数据都是逐行存储,每一个行都存有一个 (队伍名称, 积分)
的信息。
但是在很多时候,又需要通过队伍名称来查询对应的积分,此时动态数组就不适用了,因此可以用 HashMap
来保存相关的队伍名称 -> 积分映射关系。 理想很丰满,现实很骨感,如何将 Vec<(String, u32)>
中的数据快速写入到 HashMap<String, u32>
中?
一个动动脚趾头就能想到的笨方法如下:
--fn main() { - use std::collections::HashMap; - - let teams_list = vec![ - ("中国队".to_string(), 100), - ("美国队".to_string(), 10), - ("日本队".to_string(), 50), - ]; - - let mut teams_map = HashMap::new(); - for team in &teams_list { - teams_map.insert(&team.0, team.1); - } - - println!("{:?}",teams_map) -} -
遍历列表,将每一个元组作为一对 KV
插入到 HashMap
中,很简单,但是……也不太聪明的样子,换个词说就是 —— 不够 rusty。
好在,Rust 为我们提供了一个非常精妙的解决办法:先将 Vec
转为迭代器,接着通过 collect
方法,将迭代器中的元素收集后,转成 HashMap
:
-fn main() { - use std::collections::HashMap; - - let teams_list = vec![ - ("中国队".to_string(), 100), - ("美国队".to_string(), 10), - ("日本队".to_string(), 50), - ]; - - let teams_map: HashMap<_,_> = teams_list.into_iter().collect(); - - println!("{:?}",teams_map) -} -
代码很简单,into_iter
方法将列表转为迭代器,接着通过 collect
进行收集,不过需要注意的是,collect
方法在内部实际上支持生成多种类型的目标集合,因此我们需要通过类型标注 HashMap<_,_>
来告诉编译器:请帮我们收集为 HashMap
集合类型,具体的 KV
类型,麻烦编译器您老人家帮我们推导。
由此可见,Rust 中的编译器时而小聪明,时而大聪明,不过好在,它大聪明的时候,会自家人知道自己事,总归会通知你一声:
-error[E0282]: type annotations needed // 需要类型标注
- --> src/main.rs:10:9
- |
-10 | let teams_map = teams_list.into_iter().collect();
- | ^^^^^^^^^ consider giving `teams_map` a type // 给予 `teams_map` 一个具体的类型
-
-HashMap
的所有权规则与其它 Rust 类型没有区别:
Copy
特征,该类型会被复制进 HashMap
,因此无所谓所有权Copy
特征,所有权将被转移给 HashMap
中例如我参选帅气男孩时的场景再现:
--fn main() { - use std::collections::HashMap; - - let name = String::from("Sunface"); - let age = 18; - - let mut handsome_boys = HashMap::new(); - handsome_boys.insert(name, age); - - println!("因为过于无耻,{}已经被从帅气男孩名单中除名", name); - println!("还有,他的真实年龄远远不止{}岁", age); -} -
运行代码,报错如下:
-error[E0382]: borrow of moved value: `name`
- --> src/main.rs:10:32
- |
-4 | let name = String::from("Sunface");
- | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
-...
-8 | handsome_boys.insert(name, age);
- | ---- value moved here
-9 |
-10 | println!("因为过于无耻,{}已经被除名", name);
- | ^^^^ value borrowed here after move
-
-提示很清晰,name
是 String
类型,因此它受到所有权的限制,在 insert
时,它的所有权被转移给 handsome_boys
,所以最后在使用时,会遇到这个无情但是意料之中的报错。
如果你使用引用类型放入 HashMap 中,请确保该引用的生命周期至少跟 HashMap
活得一样久:
-fn main() { - use std::collections::HashMap; - - let name = String::from("Sunface"); - let age = 18; - - let mut handsome_boys = HashMap::new(); - handsome_boys.insert(&name, age); - - std::mem::drop(name); - println!("因为过于无耻,{:?}已经被除名", handsome_boys); - println!("还有,他的真实年龄远远不止{}岁", age); -} -
上面代码,我们借用 name
获取了它的引用,然后插入到 handsome_boys
中,至此一切都很完美。但是紧接着,就通过 drop
函数手动将 name
字符串从内存中移除,再然后就报错了:
handsome_boys.insert(&name, age);
- | ----- borrow of `name` occurs here // name借用发生在此处
-9 |
-10 | std::mem::drop(name);
- | ^^^^ move out of `name` occurs here // name的所有权被转移走
-11 | println!("因为过于无耻,{:?}已经被除名", handsome_boys);
- | ------------- borrow later used here // 所有权转移后,还试图使用name
-
-最终,某人因为过于无耻,真正的被除名了 :)
-通过 get
方法可以获取元素:
--#![allow(unused)] -fn main() { -use std::collections::HashMap; - -let mut scores = HashMap::new(); - -scores.insert(String::from("Blue"), 10); -scores.insert(String::from("Yellow"), 50); - -let team_name = String::from("Blue"); -let score: Option<&i32> = scores.get(&team_name); -} -
上面有几点需要注意:
-get
方法返回一个 Option<&i32>
类型:当查询不到时,会返回一个 None
,查询到时返回 Some(&i32)
&i32
是对 HashMap
中值的借用,如果不使用借用,可能会发生所有权的转移还可以继续拓展下,上面的代码中,如果我们想直接获得值类型的 score
该怎么办,答案简约但不简单:
--#![allow(unused)] -fn main() { -let score: i32 = scores.get(&team_name).copied().unwrap_or(0); -} -
这里留给大家一个小作业: 去官方文档中查询下 Option
的 copied
方法和 unwrap_or
方法的含义及该如何使用。
还可以通过循环的方式依次遍历 KV
对:
--#![allow(unused)] -fn main() { -use std::collections::HashMap; - -let mut scores = HashMap::new(); - -scores.insert(String::from("Blue"), 10); -scores.insert(String::from("Yellow"), 50); - -for (key, value) in &scores { - println!("{}: {}", key, value); -} -} -
最终输出:
-Yellow: 50
-Blue: 10
-
-更新值的时候,涉及多种情况,咱们在代码中一一进行说明:
--fn main() { - use std::collections::HashMap; - - let mut scores = HashMap::new(); - - scores.insert("Blue", 10); - - // 覆盖已有的值 - let old = scores.insert("Blue", 20); - assert_eq!(old, Some(10)); - - // 查询新插入的值 - let new = scores.get("Blue"); - assert_eq!(new, Some(&20)); - - // 查询Yellow对应的值,若不存在则插入新值 - let v = scores.entry("Yellow").or_insert(5); - assert_eq!(*v, 5); // 不存在,插入5 - - // 查询Yellow对应的值,若不存在则插入新值 - let v = scores.entry("Yellow").or_insert(50); - assert_eq!(*v, 5); // 已经存在,因此50没有插入 -} -
具体的解释在代码注释中已有,这里不再进行赘述。
-另一个常用场景如下:查询某个 key
对应的值,若不存在则插入新值,若存在则对已有的值进行更新,例如在文本中统计词语出现的次数:
--#![allow(unused)] -fn main() { -use std::collections::HashMap; - -let text = "hello world wonderful world"; - -let mut map = HashMap::new(); -// 根据空格来切分字符串(英文单词都是通过空格切分) -for word in text.split_whitespace() { - let count = map.entry(word).or_insert(0); - *count += 1; -} - -println!("{:?}", map); -} -
上面代码中,新建一个 map
用于保存词语出现的次数,插入一个词语时会进行判断:若之前没有插入过,则使用该词语作 Key
,插入次数 0 作为 Value
,若之前插入过则取出之前统计的该词语出现的次数,对其加一。
有两点值得注意:
-or_insert
返回了 &mut v
引用,因此可以通过该可变引用直接修改 map
中对应的值count
引用时,需要先进行解引用 *count
,否则会出现类型不匹配你肯定比较好奇,为何叫哈希表,到底什么是哈希。
-先来设想下,如果要实现 Key
与 Value
的一一对应,是不是意味着我们要能比较两个 Key
的相等性?例如 "a" 和 "b",1 和 2,当这些类型做 Key
且能比较时,可以很容易知道 1
对应的值不会错误的映射到 2
上,因为 1
不等于 2
。因此,一个类型能否作为 Key
的关键就是是否能进行相等比较,或者说该类型是否实现了 std::cmp::Eq
特征。
--f32 和 f64 浮点数,没有实现
-std::cmp::Eq
特征,因此不可以用作HashMap
的Key
。
好了,理解完这个,再来设想一点,若一个复杂点的类型作为 Key
,那怎么在底层对它进行存储,怎么使用它进行查询和比较? 是不是很棘手?好在我们有哈希函数:通过它把 Key
计算后映射为哈希值,然后使用该哈希值来进行存储、查询、比较等操作。
但是问题又来了,如何保证不同 Key
通过哈希后的两个值不会相同?如果相同,那意味着我们使用不同的 Key
,却查到了同一个结果,这种明显是错误的行为。
-此时,就涉及到安全性跟性能的取舍了。
若要追求安全,尽可能减少冲突,同时防止拒绝服务(Denial of Service, DoS)攻击,就要使用密码学安全的哈希函数,HashMap
就是使用了这样的哈希函数。反之若要追求性能,就需要使用没有那么安全的算法。
因此若性能测试显示当前标准库默认的哈希函数不能满足你的性能需求,就需要去 crates.io
上寻找其它的哈希函数实现,使用方法很简单:
--#![allow(unused)] -fn main() { -use std::hash::BuildHasherDefault; -use std::collections::HashMap; -// 引入第三方的哈希函数 -use twox_hash::XxHash64; - -// 指定HashMap使用第三方的哈希函数XxHash64 -let mut hash: HashMap<_, _, BuildHasherDefault<XxHash64>> = Default::default(); -hash.insert(42, "the answer"); -assert_eq!(hash.get(&42), Some(&"the answer")); -} -
--目前,
-HashMap
使用的哈希函数是SipHash
,它的性能不是很高,但是安全性很高。SipHash
在中等大小的Key
上,性能相当不错,但是对于小型的Key
(例如整数)或者大型Key
(例如字符串)来说,性能还是不够好。若你需要极致性能,例如实现算法,可以考虑这个库:ahash。
最后,如果你想要了解 HashMap
更多的用法,请参见本书的标准库解析章节:HashMap 常用方法
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
在 Rust 标准库中有这样一批原住民,它们天生贵族,当你看到的一瞬间,就能爱上它们,上面是我瞎编的,其实主要是离了它们不行,不信等会我介绍后,你放个狠话,偏不用它们试试?
-集合在 Rust 中是一类比较特殊的类型,因为 Rust 中大多数数据类型都只能代表一个特定的值,但是集合却可以代表一大堆值。而且与语言级别的数组、字符串类型不同,标准库里的这些家伙是分配在堆上,因此都可以进行动态的增加和减少。
-瞧,第一个集合排着整体的队列登场了,它里面的每个元素都雄赳赳气昂昂跟在另外一个元素后面,大小、宽度、高度竟然全部一致,真是令人惊叹。 它就是 Vector
类型,允许你创建一个动态数组,它里面的元素是一个紧挨着另一个排列的。
紧接着,第二个集合在全场的嘘声和羡慕眼光中闪亮登场,只见里面的元素排成一对一对的,彼此都手牵着手,非对方莫属,这种情深深雨蒙蒙的样子真是...挺欠扁的。 它就是 HashMap
类型,该类型允许你在里面存储 KV
对,每一个 K
都有唯一的 V
与之配对。
最后,请用热烈的掌声迎接我们的 String
集合,哦,抱歉,String
集合天生低调,见不得前两个那样,因此被气走了,你可以去这里找它。
言归正传,本章所讲的 Vector
、HashMap
再加上之前的 String
类型,是标准库中最最常用的集合类型,可以说,几乎任何一段代码中都可以找到它们的身影,那么先来看看 Vector
。
动态数组类型用 Vec<T>
表示,事实上,在之前的章节,它的身影多次出现,我们一直没有细讲,只是简单的把它当作数组处理。
动态数组允许你存储多个值,这些值在内存中一个紧挨着另一个排列,因此访问其中某个元素的成本非常低。动态数组只能存储相同类型的元素,如果你想存储不同类型的元素,可以使用之前讲过的枚举类型或者特征对象。
-总之,当我们想拥有一个列表,里面都是相同类型的数据时,动态数组将会非常有用。
-在 Rust 中,有多种方式可以创建动态数组。
-使用 Vec::new
创建动态数组是最 rusty 的方式,它调用了 Vec
中的 new
关联函数:
--#![allow(unused)] -fn main() { -let v: Vec<i32> = Vec::new(); -} -
这里,v
被显式地声明了类型 Vec<i32>
,这是因为 Rust 编译器无法从 Vec::new()
中得到任何关于类型的暗示信息,因此也无法推导出 v
的具体类型,但是当你向里面增加一个元素后,一切又不同了:
--#![allow(unused)] -fn main() { -let mut v = Vec::new(); -v.push(1); -} -
此时,v
就无需手动声明类型,因为编译器通过 v.push(1)
,推测出 v
中的元素类型是 i32
,因此推导出 v
的类型是 Vec<i32>
。
--如果预先知道要存储的元素个数,可以使用
-Vec::with_capacity(capacity)
创建动态数组,这样可以避免因为插入大量新数据导致频繁的内存分配和拷贝,提升性能
还可以使用宏 vec!
来创建数组,与 Vec::new
有所不同,前者能在创建同时给予初始化值:
--#![allow(unused)] -fn main() { -let v = vec![1, 2, 3]; -} -
同样,此处的 v
也无需标注类型,编译器只需检查它内部的元素即可自动推导出 v
的类型是 Vec<i32>
(Rust 中,整数默认类型是 i32
,在数值类型中有详细介绍)。
向数组尾部添加元素,可以使用 push
方法:
--#![allow(unused)] -fn main() { -let mut v = Vec::new(); -v.push(1); -} -
与其它类型一样,必须将 v
声明为 mut
后,才能进行修改。
跟结构体一样,Vector
类型在超出作用域范围后,会被自动删除:
--#![allow(unused)] -fn main() { -{ - let v = vec![1, 2, 3]; - - // ... -} // <- v超出作用域并在此处被删除 -} -
当 Vector
被删除后,它内部存储的所有内容也会随之被删除。目前来看,这种解决方案简单直白,但是当 Vector
中的元素被引用后,事情可能会没那么简单。
读取指定位置的元素有两种方式可选:
-get
方法。--#![allow(unused)] -fn main() { -let v = vec![1, 2, 3, 4, 5]; - -let third: &i32 = &v[2]; -println!("第三个元素是 {}", third); - -match v.get(2) { - Some(third) => println!("第三个元素是 {third}"), - None => println!("去你的第三个元素,根本没有!"), -} -} -
和其它语言一样,集合类型的索引下标都是从 0
开始,&v[2]
表示借用 v
中的第三个元素,最终会获得该元素的引用。而 v.get(2)
也是访问第三个元素,但是有所不同的是,它返回了 Option<&T>
,因此还需要额外的 match
来匹配解构出具体的值。
--细心的同学会注意到这里使用了两种格式化输出的方式,其中第一种我们在之前已经见过,而第二种是后续新版本中引入的写法,也是更推荐的用法,具体介绍请参见格式化输出章节。
-
.get
的区别这两种方式都能成功的读取到指定的数组元素,既然如此为什么会存在两种方法?何况 .get
还会增加使用复杂度,这就涉及到数组越界的问题了,让我们通过示例说明:
--#![allow(unused)] -fn main() { -let v = vec![1, 2, 3, 4, 5]; - -let does_not_exist = &v[100]; -let does_not_exist = v.get(100); -} -
运行以上代码,&v[100]
的访问方式会导致程序无情报错退出,因为发生了数组越界访问。 但是 v.get
就不会,它在内部做了处理,有值的时候返回 Some(T)
,无值的时候返回 None
,因此 v.get
的使用方式非常安全。
既然如此,为何不统一使用 v.get
的形式?因为实在是有些啰嗦,Rust 语言的设计者和使用者在审美这方面还是相当统一的:简洁即正义,何况性能上也会有轻微的损耗。
既然有两个选择,肯定就有如何选择的问题,答案很简单,当你确保索引不会越界的时候,就用索引访问,否则用 .get
。例如,访问第几个数组元素并不取决于我们,而是取决于用户的输入时,用 .get
会非常适合,天知道那些可爱的用户会输入一个什么样的数字进来!
既然涉及到借用数组元素,那么很可能会遇到同时借用多个数组元素的情况,还记得在所有权和借用章节咱们讲过的借用规则嘛?如果记得,就来看看下面的代码 :)
---#![allow(unused)] -fn main() { -let mut v = vec![1, 2, 3, 4, 5]; - -let first = &v[0]; - -v.push(6); - -println!("The first element is: {first}"); -} -
先不运行,来推断下结果,首先 first = &v[0]
进行了不可变借用,v.push
进行了可变借用,如果 first
在 v.push
之后不再使用,那么该段代码可以成功编译(原因见引用的作用域)。
可是上面的代码中,first
这个不可变借用在可变借用 v.push
后被使用了,那么妥妥的,编译器就会报错:
$ cargo run
-Compiling collections v0.1.0 (file:///projects/collections)
-error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable 无法对v进行可变借用,因此之前已经进行了不可变借用
---> src/main.rs:6:5
-|
-4 | let first = &v[0];
-| - immutable borrow occurs here // 不可变借用发生在此处
-5 |
-6 | v.push(6);
-| ^^^^^^^^^ mutable borrow occurs here // 可变借用发生在此处
-7 |
-8 | println!("The first element is: {}", first);
-| ----- immutable borrow later used here // 不可变借用在这里被使用
-
-For more information about this error, try `rustc --explain E0502`.
-error: could not compile `collections` due to previous error
-
-其实,按理来说,这两个引用不应该互相影响的:一个是查询元素,一个是在数组尾部插入元素,完全不相干的操作,为何编译器要这么严格呢?
-原因在于:数组的大小是可变的,当旧数组的大小不够用时,Rust 会重新分配一块更大的内存空间,然后把旧数组拷贝过来。这种情况下,之前的引用显然会指向一块无效的内存,这非常 rusty —— 对用户进行严格的教育。
-其实想想,在长大之后,我们感激人生路上遇到过的严师益友,正是因为他们,我们才在正确的道路上不断前行,虽然在那个时候,并不能理解他们,而 Rust 就如那个良师益友,它不断的在纠正我们不好的编程习惯,直到某一天,你发现自己能写出一次性通过的漂亮代码时,就能明白它的良苦用心。
---若读者想要更深入的了解
-Vec<T>
,可以看看Rustonomicon,其中从零手撸一个动态数组,非常适合深入学习。
如果想要依次访问数组中的元素,可以使用迭代的方式去遍历数组,这种方式比用下标的方式去遍历数组更安全也更高效(每次下标访问都会触发数组边界检查):
---#![allow(unused)] -fn main() { -let v = vec![1, 2, 3]; -for i in &v { - println!("{i}"); -} -} -
也可以在迭代过程中,修改 Vector
中的元素:
--#![allow(unused)] -fn main() { -let mut v = vec![1, 2, 3]; -for i in &mut v { - *i += 10 -} -} -
在本节开头,有讲到数组的元素必须类型相同,但是也提到了解决方案:那就是通过使用枚举类型和特征对象来实现不同类型元素的存储。先来看看通过枚举如何实现:
--#[derive(Debug)] -enum IpAddr { - V4(String), - V6(String) -} -fn main() { - let v = vec![ - IpAddr::V4("127.0.0.1".to_string()), - IpAddr::V6("::1".to_string()) - ]; - - for ip in v { - show_addr(ip) - } -} - -fn show_addr(ip: IpAddr) { - println!("{:?}",ip); -} -
数组 v
中存储了两种不同的 ip
地址,但是这两种都属于 IpAddr
枚举类型的成员,因此可以存储在数组中。
再来看看特征对象的实现:
--trait IpAddr { - fn display(&self); -} - -struct V4(String); -impl IpAddr for V4 { - fn display(&self) { - println!("ipv4: {:?}",self.0) - } -} -struct V6(String); -impl IpAddr for V6 { - fn display(&self) { - println!("ipv6: {:?}",self.0) - } -} - -fn main() { - let v: Vec<Box<dyn IpAddr>> = vec![ - Box::new(V4("127.0.0.1".to_string())), - Box::new(V6("::1".to_string())), - ]; - - for ip in v { - ip.display(); - } -} -
比枚举实现要稍微复杂一些,我们为 V4
和 V6
都实现了特征 IpAddr
,然后将它俩的实例用 Box::new
包裹后,存在了数组 v
中,需要注意的是,这里必须手动地指定类型:Vec<Box<dyn IpAddr>>
,表示数组 v
存储的是特征 IpAddr
的对象,这样就实现了在数组中存储不同的类型。
在实际使用场景中,特征对象数组要比枚举数组常见很多,主要原因在于特征对象非常灵活,而编译器对枚举的限制较多,且无法动态增加类型。
-最后,如果你想要了解 Vector
更多的用法,请参见本书的标准库解析章节:Vector
常用方法
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
好的代码会说话,好的程序员不写注释,这些都是烂大街的“编程界俚语”。但是,如果你真的遇到一个不写注释的项目或程序员,那一定会对它/他“刮目相看”。
-在之前的章节我们学习了包和模块如何使用,在此章节将进一步学习如何书写文档注释,以及如何使用 cargo doc
生成项目的文档,最后将以一个包、模块和文档的综合性例子,来将这些知识融会贯通。
在 Rust 中,注释分为三类:
-Markdown
,对项目描述、公共 API 等用户关心的功能进行介绍,同时还能提供示例代码,目标读者往往是想要了解你项目的人通过这些注释,实现了 Rust 极其优秀的文档化支持,甚至你还能在文档注释中写测试用例,省去了单独写测试用例的环节,我直呼好家伙!
-显然之前的刮目相看是打了引号的,想要去掉引号,该写注释的时候,就老老实实的,不过写时需要遵循八字原则:围绕目标,言简意赅,记住,洋洋洒洒那是用来形容文章的,不是形容注释!
-代码注释方式有两种:
-//
-fn main() { - // 我是Sun... - // face - let name = "sunface"; - let age = 18; // 今年好像是18岁 -} -
如上所示,行注释可以放在某一行代码的上方,也可以放在当前代码行的后方。如果超出一行的长度,需要在新行的开头也加上 //
。
当注释行数较多时,你还可以使用块注释
-/* ..... */
-fn main() { - /* - 我 - 是 - S - u - n - ... 哎,好长! - */ - let name = "sunface"; - let age = "???"; // 今年其实。。。挺大了 -} -
如上所示,只需要将注释内容使用 /* */
进行包裹即可。
你会发现,Rust 的代码注释跟其它语言并没有区别,主要区别其实在于文档注释这一块,也是本章节内容的重点。
-当查看一个 crates.io
上的包时,往往需要通过它提供的文档来浏览相关的功能特性、使用方式,这种文档就是通过文档注释实现的。
Rust 提供了 cargo doc
的命令,可以用于把这些文档注释转换成 HTML
网页文件,最终展示给用户浏览,这样用户就知道这个包是做什么的以及该如何使用。
///
本书的一大特点就是废话不多,因此我们开门见山:
---#![allow(unused)] -fn main() { -/// `add_one` 将指定值加1 -/// -/// # Examples -/// -/// ``` -/// let arg = 5; -/// let answer = my_crate::add_one(arg); -/// -/// assert_eq!(6, answer); -/// ``` -pub fn add_one(x: i32) -> i32 { - x + 1 -} -} -
以上代码有几点需要注意:
-lib
类型的包中,例如 src/lib.rs
中markdown
语法!例如 # Examples
的标题,以及代码块高亮pub
对外可见,记住:文档注释是给用户看的,内部实现细节不应该被暴露出去咦?文档注释中的例子,为什看上去像是能运行的样子?竟然还是有 assert_eq
这种常用于测试目的的宏。 嗯,你的感觉没错,详细内容会在本章后面讲解,容我先卖个关子。
/** ... */
与代码注释一样,文档也有块注释,当注释内容多时,使用块注释可以减少 ///
的使用:
--#![allow(unused)] -fn main() { -/** `add_two` 将指定值加2 - -Examples - -``` -let arg = 5; -let answer = my_crate::add_two(arg); - -assert_eq!(7, answer); -``` -*/ -pub fn add_two(x: i32) -> i32 { - x + 2 -} -} -
锦衣不夜行,这是中国人的传统美德。我们写了这么漂亮的文档注释,当然要看看网页中是什么效果咯。
-很简单,运行 cargo doc
可以直接生成 HTML
文件,放入target/doc目录下。
当然,为了方便,我们使用 cargo doc --open
命令,可以在生成文档后,自动在浏览器中打开网页,最终效果如图所示:
非常棒,而且非常简单,这就是 Rust 工具链的强大之处。
-之前我们见到了在文档注释中该如何使用 markdown
,其中包括 # Examples
标题。除了这个标题,还有一些常用的,你可以在项目中酌情使用:
unsafe
代码,那么调用者就需要注意一些使用条件,以确保 unsafe
代码块的正常工作话说回来,这些标题更多的是一种惯例,如果你非要用中文标题也没问题,但是最好在团队中保持同样的风格 :)
-除了函数、结构体等 Rust 项的注释,你还可以给包和模块添加注释,需要注意的是,这些注释要添加到包、模块的最上方!
-与之前的任何注释一样,包级别的注释也分为两种:行注释 //!
和块注释 /*! ... */
。
现在,为我们的包增加注释,在 src/lib.rs
包根的最上方,添加:
--#![allow(unused)] -fn main() { -/*! lib包是world_hello二进制包的依赖包, - 里面包含了compute等有用模块 */ - -pub mod compute; -} -
然后再为该包根的子模块 src/compute.rs
添加注释:
--#![allow(unused)] -fn main() { -//! 计算一些你口算算不出来的复杂算术题 - - -/// `add_one`将指定值加1 -/// -} -
运行 cargo doc --open
查看下效果:
包模块注释,可以让用户从整体的角度理解包的用途,对于用户来说是非常友好的,就和一篇文章的开头一样,总是要对文章的内容进行大致的介绍,让用户在看的时候心中有数。
-至此,关于如何注释的内容,就结束了,那么注释还能用来做什么?可以玩出花来吗?答案是Yes
.
相信读者之前都写过单元测试用例,其中一个很蛋疼的问题就是,随着代码的进化,单元测试用例经常会失效,过段时间后(为何是过段时间?应该这么问,有几个开发喜欢写测试用例 =,=),你发现需要连续修改不少处代码,才能让测试重新工作起来。然而,在 Rust 中,大可不必。
-在之前的 add_one
中,我们写的示例代码非常像是一个单元测试的用例,这是偶然吗?并不是。因为 Rust 允许我们在文档注释中写单元测试用例!方法就如同之前做的:
--#![allow(unused)] -fn main() { -/// `add_one` 将指定值加1 -/// -/// # Examples11 -/// -/// ``` -/// let arg = 5; -/// let answer = world_hello::compute::add_one(arg); -/// -/// assert_eq!(6, answer); -/// ``` -pub fn add_one(x: i32) -> i32 { - x + 1 -} -} -
以上的注释不仅仅是文档,还可以作为单元测试的用例运行,使用 cargo test
运行测试:
Doc-tests world_hello
-
-running 2 tests
-test src/compute.rs - compute::add_one (line 8) ... ok
-test src/compute.rs - compute::add_two (line 22) ... ok
-
-test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.00s
-
-可以看到,文档中的测试用例被完美运行,而且输出中也明确提示了 Doc-tests world_hello
,意味着这些测试的名字叫 Doc test
文档测试。
--需要注意的是,你可能需要使用类如
-world_hello::compute::add_one(arg)
的完整路径来调用函数,因为测试是在另外一个独立的线程中运行的
文档测试中的用例还可以造成 panic
:
--#![allow(unused)] -fn main() { -/// # Panics -/// -/// The function panics if the second argument is zero. -/// -/// ```rust -/// // panics on division by zero -/// world_hello::compute::div(10, 0); -/// ``` -pub fn div(a: i32, b: i32) -> i32 { - if b == 0 { - panic!("Divide-by-zero error"); - } - - a / b -} -} -
以上测试运行后会 panic
:
---- src/compute.rs - compute::div (line 38) stdout ----
-Test executable failed (exit code 101).
-
-stderr:
-thread 'main' panicked at 'Divide-by-zero error', src/compute.rs:44:9
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-如果想要通过这种测试,可以添加 should_panic
:
--#![allow(unused)] -fn main() { -/// # Panics -/// -/// The function panics if the second argument is zero. -/// -/// ```rust,should_panic -/// // panics on division by zero -/// world_hello::compute::div(10, 0); -/// ``` -} -
通过 should_panic
,告诉 Rust 我们这个用例会导致 panic
,这样测试用例就能顺利通过。
在某些时候,我们希望保留文档测试的功能,但是又要将某些测试用例的内容从文档中隐藏起来:
--/// ``` -/// # // 使用#开头的行会在文档中被隐藏起来,但是依然会在文档测试中运行 -/// # fn try_main() -> Result<(), String> { -/// let res = world_hello::compute::try_div(10, 0)?; -/// # Ok(()) // returning from try_main -/// # } -/// # fn main() { -/// # try_main().unwrap(); -/// # -/// # } -/// ``` -pub fn try_div(a: i32, b: i32) -> Result<i32, String> { - if b == 0 { - Err(String::from("Divide-by-zero")) - } else { - Ok(a / b) - } -} -
以上文档注释中,我们使用 #
将不想让用户看到的内容隐藏起来,但是又不影响测试用例的运行,最终用户将只能看到那行没有隐藏的 let res = world_hello::compute::try_div(10, 0)?;
:
Rust 在文档注释中还提供了一个非常强大的功能,那就是可以实现对外部项的链接:
---#![allow(unused)] -fn main() { -/// `add_one` 返回一个[`Option`]类型 -pub fn add_one(x: i32) -> Option<i32> { - Some(x + 1) -} -} -
此处的 [Option
] 就是一个链接,指向了标准库中的 Option
枚举类型,有两种方式可以进行跳转:
Command + 鼠标左键
(macOS),CTRL + 鼠标左键
(Windows)再比如,还可以使用路径的方式跳转:
---#![allow(unused)] -fn main() { -use std::sync::mpsc::Receiver; - -/// [`Receiver<T>`] [`std::future`]. -/// -/// [`std::future::Future`] [`Self::recv()`]. -pub struct AsyncReceiver<T> { - sender: Receiver<T>, -} - -impl<T> AsyncReceiver<T> { - pub async fn recv() -> T { - unimplemented!() - } -} -} -
除了跳转到标准库,你还可以通过指定具体的路径跳转到自己代码或者其它库的指定项,例如在 lib.rs
中添加以下代码:
--#![allow(unused)] -fn main() { -pub mod a { - /// `add_one` 返回一个[`Option`]类型 - /// 跳转到[`crate::MySpecialFormatter`] - pub fn add_one(x: i32) -> Option<i32> { - Some(x + 1) - } -} - -pub struct MySpecialFormatter; -} -
使用 crate::MySpecialFormatter
这种路径就可以实现跳转到 lib.rs
中定义的结构体上。
如果遇到同名项,可以使用标示类型的方式进行跳转:
---#![allow(unused)] -fn main() { -/// 跳转到结构体 [`Foo`](struct@Foo) -pub struct Bar; - -/// 跳转到同名函数 [`Foo`](fn@Foo) -pub struct Foo {} - -/// 跳转到同名宏 [`foo!`] -pub fn Foo() {} - -#[macro_export] -macro_rules! foo { - () => {} -} -} -
Rust 文档支持搜索功能,我们可以为自己的类型定义几个别名,以实现更好的搜索展现,当别名命中时,搜索结果会被放在第一位:
---#![allow(unused)] -fn main() { -#[doc(alias = "x")] -#[doc(alias = "big")] -pub struct BigX; - -#[doc(alias("y", "big"))] -pub struct BigY; -} -
结果如下图所示:
-
这个例子我们将重点应用几个知识点:
-lib
包(库包),它们的包根分别是 src/main.rs
和 src/lib.rs
lib
包pub use
再导出 API,并观察文档首先,使用 cargo new art
创建一个 Package art
:
Created binary (application) `art` package
-
-系统提示我们创建了一个二进制 Package
,根据之前章节学过的内容,可以知道该 Package
包含一个同名的二进制包:包名为 art
,包根为 src/main.rs
,该包可以编译成二进制然后运行。
现在,在 src
目录下创建一个 lib.rs
文件,同样,根据之前学习的知识,创建该文件等于又创建了一个库类型的包,包名也是 art
,包根为 src/lib.rs
,该包是是库类型的,因此往往作为依赖库被引入。
将以下内容添加到 src/lib.rs
中:
--#![allow(unused)] -fn main() { -//! # Art -//! -//! 未来的艺术建模库,现在的调色库 - -pub use self::kinds::PrimaryColor; -pub use self::kinds::SecondaryColor; -pub use self::utils::mix; - -pub mod kinds { - //! 定义颜色的类型 - - /// 主色 - pub enum PrimaryColor { - Red, - Yellow, - Blue, - } - - /// 副色 - #[derive(Debug,PartialEq)] - pub enum SecondaryColor { - Orange, - Green, - Purple, - } -} - -pub mod utils { - //! 实用工具,目前只实现了调色板 - use crate::kinds::*; - - /// 将两种主色调成副色 - /// ```rust - /// use art::utils::mix; - /// use art::kinds::{PrimaryColor,SecondaryColor}; - /// assert!(matches!(mix(PrimaryColor::Yellow, PrimaryColor::Blue), SecondaryColor::Green)); - /// ``` - pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor { - SecondaryColor::Green - } -} -} -
在库包的包根 src/lib.rs
下,我们又定义了几个子模块,同时将子模块中的三个项通过 pub use
进行了再导出。
接着,将下面内容添加到 src/main.rs
中:
-use art::kinds::PrimaryColor; -use art::utils::mix; - -fn main() { - let blue = PrimaryColor::Blue; - let yellow = PrimaryColor::Yellow; - println!("{:?}",mix(blue, yellow)); -} -
在二进制可执行包的包根 src/main.rs
下,我们引入了库包 art
中的模块项,同时使用 main
函数作为程序的入口,该二进制包可以使用 cargo run
运行:
Green
-
-至此,库包完美提供了用于调色的 API,二进制包引入这些 API 完美的实现了调色并打印输出。
-最后,再来看看文档长啥样:
-在 Rust 中,注释分为三个主要类型:代码注释、文档注释、包和模块注释,每个注释类型都拥有两种形式:行注释和块注释,熟练掌握包模块和注释的知识,非常有助于我们创建工程性更强的项目。
-如果读者看到这里对于包模块还是有些模糊,强烈建议回头看看相关的章节以及本章节的最后一个综合例子。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
-
在日常开发中,使用最广的数据结构之一就是数组,在 Rust 中,最常用的数组有两种,第一种是速度很快但是长度固定的 array
,第二种是可动态增长的但是有性能损耗的 Vector
,在本书中,我们称 array
为数组,Vector
为动态数组。
不知道你们发现没,这两个数组的关系跟 &str
与 String
的关系很像,前者是长度固定的字符串切片,后者是可动态增长的字符串。其实,在 Rust 中无论是 String
还是 Vector
,它们都是 Rust 的高级类型:集合类型,在后面章节会有详细介绍。
对于本章节,我们的重点还是放在数组 array
上。数组的具体定义很简单:将多个类型相同的元素依次组合在一起,就是一个数组。结合上面的内容,可以得出数组的三要素:
这里再啰嗦一句,我们这里说的数组是 Rust 的基本类型,是固定长度的,这点与其他编程语言不同,其它编程语言的数组往往是可变长度的,与 Rust 中的动态数组 Vector
类似,希望读者大大牢记此点。
在 Rust 中,数组是这样定义的:
--fn main() { - let a = [1, 2, 3, 4, 5]; -} -
数组语法跟 JavaScript 很像,也跟大多数编程语言很像。由于它的元素类型大小固定,且长度也是固定,因此数组 array
是存储在栈上,性能也会非常优秀。与此对应,动态数组 Vector
是存储在堆上,因此长度可以动态改变。当你不确定是使用数组还是动态数组时,那就应该使用后者,具体见动态数组 Vector。
举个例子,在需要知道一年中各个月份名称的程序中,你很可能希望使用的是数组而不是动态数组。因为月份是固定的,它总是只包含 12 个元素:
---#![allow(unused)] -fn main() { -let months = ["January", "February", "March", "April", "May", "June", "July", - "August", "September", "October", "November", "December"]; -} -
在一些时候,还需要为数组声明类型,如下所示:
---#![allow(unused)] -fn main() { -let a: [i32; 5] = [1, 2, 3, 4, 5]; -} -
这里,数组类型是通过方括号语法声明,i32
是元素类型,分号后面的数字 5
是数组长度,数组类型也从侧面说明了数组的元素类型要统一,长度要固定。
还可以使用下面的语法初始化一个某个值重复出现 N 次的数组:
---#![allow(unused)] -fn main() { -let a = [3; 5]; -} -
a
数组包含 5
个元素,这些元素的初始化值为 3
,聪明的读者已经发现,这种语法跟数组类型的声明语法其实是保持一致的:[3; 5]
和 [类型; 长度]
。
在元素重复的场景,这种写法要简单的多,否则你就得疯狂敲击键盘:let a = [3, 3, 3, 3, 3];
,不过老板可能很喜欢你的这种疯狂编程的状态。
因为数组是连续存放元素的,因此可以通过索引的方式来访问存放其中的元素:
--fn main() { - let a = [9, 8, 7, 6, 5]; - - let first = a[0]; // 获取a数组第一个元素 - let second = a[1]; // 获取第二个元素 -} -
与许多语言类似,数组的索引下标是从 0 开始的。此处,first
获取到的值是 9
,second
是 8
。
如果使用超出数组范围的索引访问数组元素,会怎么样?下面是一个接收用户的控制台输入,然后将其作为索引访问数组元素的例子:
--use std::io; - -fn main() { - let a = [1, 2, 3, 4, 5]; - - println!("Please enter an array index."); - - let mut index = String::new(); - // 读取控制台的输出 - io::stdin() - .read_line(&mut index) - .expect("Failed to read line"); - - let index: usize = index - .trim() - .parse() - .expect("Index entered was not a number"); - - let element = a[index]; - - println!( - "The value of the element at index {} is: {}", - index, element - ); -} -
使用 cargo run
来运行代码,因为数组只有 5 个元素,如果我们试图输入 5
去访问第 6 个元素,则会访问到不存在的数组元素,最终程序会崩溃退出:
Please enter an array index.
-5
-thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:19:19
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-这就是数组访问越界,访问了数组中不存在的元素,导致 Rust 运行时错误。程序因此退出并显示错误消息,未执行最后的 println!
语句。
当你尝试使用索引访问元素时,Rust 将检查你指定的索引是否小于数组长度。如果索引大于或等于数组长度,Rust 会出现 panic。这种检查只能在运行时进行,比如在上面这种情况下,编译器无法在编译期知道用户运行代码时将输入什么值。
-这种就是 Rust 的安全特性之一。在很多系统编程语言中,并不会检查数组越界问题,你会访问到无效的内存地址获取到一个风马牛不相及的值,最终导致在程序逻辑上出现大问题,而且这种问题会非常难以检查。
-学习了上面的知识,很多朋友肯定觉得已经学会了Rust的数组类型,但现实会给我们一记重锤,实际开发中还会碰到一种情况,就是数组元素是非基本类型的,这时候大家一定会这样写。
---#![allow(unused)] -fn main() { -let array = [String::from("rust is good!"); 8]; - -println!("{:#?}", array); -} -
然后你会惊喜的得到编译错误。
-error[E0277]: the trait bound `String: std::marker::Copy` is not satisfied
- --> src/main.rs:7:18
- |
-7 | let array = [String::from("rust is good!"); 8];
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::marker::Copy` is not implemented for `String`
- |
- = note: the `Copy` trait is required because this value will be copied for each element of the array
-
-有些还没有看过特征的小伙伴,有可能不太明白这个报错,不过这个目前可以不提,我们就拿之前所学的所有权知识,就可以思考明白,前面几个例子都是Rust的基本类型,而基本类型在Rust中赋值是以Copy的形式,这时候你就懂了吧,let array=[3;5]
底层就是不断的Copy出来的,但很可惜复杂类型都没有深拷贝,只能一个个创建。
接着就有小伙伴会这样写。
---#![allow(unused)] -fn main() { -let array = [String::from("rust is good!"),String::from("rust is good!"),String::from("rust is good!")]; - -println!("{:#?}", array); -} -
作为一个追求极致完美的Rust开发者,怎么能容忍上面这么难看的代码存在!
-正确的写法,应该调用std::array::from_fn
--#![allow(unused)] -fn main() { -let array: [String; 8] = std::array::from_fn(|i| String::from("rust is good!")); - -println!("{:#?}", array); -} -
在之前的章节,我们有讲到 切片
这个概念,它允许你引用集合中的部分连续片段,而不是整个集合,对于数组也是,数组切片允许我们引用数组的一部分:
--#![allow(unused)] -fn main() { -let a: [i32; 5] = [1, 2, 3, 4, 5]; - -let slice: &[i32] = &a[1..3]; - -assert_eq!(slice, &[2, 3]); -} -
上面的数组切片 slice
的类型是&[i32]
,与之对比,数组的类型是[i32;5]
,简单总结下切片的特点:
&str
字符串切片也同理最后,让我们以一个综合性使用数组的例子,来结束本章节的学习:
--fn main() { - // 编译器自动推导出one的类型 - let one = [1, 2, 3]; - // 显式类型标注 - let two: [u8; 3] = [1, 2, 3]; - let blank1 = [0; 3]; - let blank2: [u8; 3] = [0; 3]; - - // arrays是一个二维数组,其中每一个元素都是一个数组,元素类型是[u8; 3] - let arrays: [[u8; 3]; 4] = [one, two, blank1, blank2]; - - // 借用arrays的元素用作循环中 - for a in &arrays { - print!("{:?}: ", a); - // 将a变成一个迭代器,用于循环 - // 你也可以直接用for n in a {}来进行循环 - for n in a.iter() { - print!("\t{} + 10 = {}", n, n+10); - } - - let mut sum = 0; - // 0..a.len,是一个 Rust 的语法糖,其实就等于一个数组,元素是从0,1,2一直增加到到a.len-1 - for i in 0..a.len() { - sum += a[i]; - } - println!("\t({:?} = {})", a, sum); - } -} -
做个总结,数组虽然很简单,但是其实还是存在几个要注意的点:
-[u8; 3]
和[u8; 4]
是不同的类型,数组的长度也是类型的一部分&[T]
,因为后者有固定的类型大小至此,关于数据类型部分,我们已经全部学完了,对于 Rust 学习而言,我们也迈出了坚定的第一步,后面将开始更高级特性的学习。未来如果大家有疑惑需要检索知识,一样可以继续回顾过往的章节,因为本书不仅仅是一门 Rust 的教程,还是一本厚重的 Rust 工具书。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
枚举(enum 或 enumeration)允许你通过列举可能的成员来定义一个枚举类型,例如扑克牌花色:
---#![allow(unused)] -fn main() { -enum PokerSuit { - Clubs, - Spades, - Diamonds, - Hearts, -} -} -
如果在此之前你没有在其它语言中使用过枚举,那么可能需要花费一些时间来理解这些概念,一旦上手,就会发现枚举的强大,甚至对它爱不释手,枚举虽好,可不要滥用哦。
-再回到之前创建的 PokerSuit
,扑克总共有四种花色,而这里我们枚举出所有的可能值,这也正是 枚举
名称的由来。
任何一张扑克,它的花色肯定会落在四种花色中,而且也只会落在其中一个花色上,这种特性非常适合枚举的使用,因为枚举值只可能是其中某一个成员。抽象来看,四种花色尽管是不同的花色,但是它们都是扑克花色这个概念,因此当某个函数处理扑克花色时,可以把它们当作相同的类型进行传参。
-细心的读者应该注意到,我们对之前的 枚举类型
和 枚举值
进行了重点标注,这是因为对于新人来说容易混淆相应的概念,总而言之:
-枚举类型是一个类型,它会包含所有可能的枚举成员, 而枚举值是该类型中的具体某个成员的实例。
现在来创建 PokerSuit
枚举类型的两个成员实例:
--#![allow(unused)] -fn main() { -let heart = PokerSuit::Hearts; -let diamond = PokerSuit::Diamonds; -} -
我们通过 ::
操作符来访问 PokerSuit
下的具体成员,从代码可以清晰看出,heart
和 diamond
都是 PokerSuit
枚举类型的,接着可以定义一个函数来使用它们:
-fn main() { - let heart = PokerSuit::Hearts; - let diamond = PokerSuit::Diamonds; - - print_suit(heart); - print_suit(diamond); -} - -fn print_suit(card: PokerSuit) { - println!("{:?}",card); -} -
print_suit
函数的参数类型是 PokerSuit
,因此我们可以把 heart
和 diamond
传给它,虽然 heart
是基于 PokerSuit
下的 Hearts
成员实例化的,但是它是货真价实的 PokerSuit
枚举类型。
接下来,我们想让扑克牌变得更加实用,那么需要给每张牌赋予一个值:A
(1)-K
(13),这样再加上花色,就是一张真实的扑克牌了,例如红心 A。
目前来说,枚举值还不能带有值,因此先用结构体来实现:
--enum PokerSuit { - Clubs, - Spades, - Diamonds, - Hearts, -} - -struct PokerCard { - suit: PokerSuit, - value: u8 -} - -fn main() { - let c1 = PokerCard { - suit: PokerSuit::Clubs, - value: 1, - }; - let c2 = PokerCard { - suit: PokerSuit::Diamonds, - value: 12, - }; -} -
这段代码很好的完成了它的使命,通过结构体 PokerCard
来代表一张牌,结构体的 suit
字段表示牌的花色,类型是 PokerSuit
枚举类型,value
字段代表扑克牌的数值。
可以吗?可以!好吗?说实话,不咋地,因为还有简洁得多的方式来实现:
--enum PokerCard { - Clubs(u8), - Spades(u8), - Diamonds(u8), - Hearts(u8), -} - -fn main() { - let c1 = PokerCard::Spades(5); - let c2 = PokerCard::Diamonds(13); -} -
直接将数据信息关联到枚举成员上,省去近一半的代码,这种实现是不是更优雅?
-不仅如此,同一个枚举类型下的不同成员还能持有不同的数据类型,例如让某些花色打印 1-13
的字样,另外的花色打印上 A-K
的字样:
-enum PokerCard { - Clubs(u8), - Spades(u8), - Diamonds(char), - Hearts(char), -} - -fn main() { - let c1 = PokerCard::Spades(5); - let c2 = PokerCard::Diamonds('A'); -} -
回想一下,遇到这种不同类型的情况,再用我们之前的结构体实现方式,可行吗?也许可行,但是会复杂很多。
-再来看一个来自标准库中的例子:
---#![allow(unused)] -fn main() { -struct Ipv4Addr { - // --snip-- -} - -struct Ipv6Addr { - // --snip-- -} - -enum IpAddr { - V4(Ipv4Addr), - V6(Ipv6Addr), -} -} -
这个例子跟我们之前的扑克牌很像,只不过枚举成员包含的类型更复杂了,变成了结构体:分别通过 Ipv4Addr
和 Ipv6Addr
来定义两种不同的 IP 数据。
从这些例子可以看出,任何类型的数据都可以放入枚举成员中: 例如字符串、数值、结构体甚至另一个枚举。
-增加一些挑战?先看以下代码:
--enum Message { - Quit, - Move { x: i32, y: i32 }, - Write(String), - ChangeColor(i32, i32, i32), -} - -fn main() { - let m1 = Message::Quit; - let m2 = Message::Move{x:1,y:1}; - let m3 = Message::ChangeColor(255,255,0); -} -
该枚举类型代表一条消息,它包含四个不同的成员:
-Quit
没有任何关联数据Move
包含一个匿名结构体Write
包含一个 String
字符串ChangeColor
包含三个 i32
当然,我们也可以用结构体的方式来定义这些消息:
---#![allow(unused)] -fn main() { -struct QuitMessage; // 单元结构体 -struct MoveMessage { - x: i32, - y: i32, -} -struct WriteMessage(String); // 元组结构体 -struct ChangeColorMessage(i32, i32, i32); // 元组结构体 -} -
由于每个结构体都有自己的类型,因此我们无法在需要同一类型的地方进行使用,例如某个函数它的功能是接受消息并进行发送,那么用枚举的方式,就可以接收不同的消息,但是用结构体,该函数无法接受 4 个不同的结构体作为参数。
-而且从代码规范角度来看,枚举的实现更简洁,代码内聚性更强,不像结构体的实现,分散在各个地方。
-最后,再用一个实际项目中的简化片段,来结束枚举类型的语法学习。
-例如我们有一个 WEB 服务,需要接受用户的长连接,假设连接有两种:TcpStream
和 TlsStream
,但是我们希望对这两个连接的处理流程相同,也就是用同一个函数来处理这两个连接,代码如下:
--#![allow(unused)] -fn main() { -fn new (stream: TcpStream) { - let mut s = stream; - if tls { - s = negotiate_tls(stream) - } - - // websocket是一个WebSocket<TcpStream>或者 - // WebSocket<native_tls::TlsStream<TcpStream>>类型 - websocket = WebSocket::from_raw_socket( - stream, ......) -} -} -
此时,枚举类型就能帮上大忙:
---#![allow(unused)] -fn main() { -enum Websocket { - Tcp(Websocket<TcpStream>), - Tls(Websocket<native_tls::TlsStream<TcpStream>>), -} -} -
在其它编程语言中,往往都有一个 null
关键字,该关键字用于表明一个变量当前的值为空(不是零值,例如整型的零值是 0),也就是不存在值。当你对这些 null
进行操作时,例如调用一个方法,就会直接抛出null 异常,导致程序的崩溃,因此我们在编程时需要格外的小心去处理这些 null
空值。
--Tony Hoare,
-null
的发明者,曾经说过一段非常有名的话:我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。
-
尽管如此,空值的表达依然非常有意义,因为空值表示当前时刻变量的值是缺失的。有鉴于此,Rust 吸取了众多教训,决定抛弃 null
,而改为使用 Option
枚举变量来表述这种结果。
Option
枚举包含两个成员,一个成员表示含有值:Some(T)
, 另一个表示没有值:None
,定义如下:
--#![allow(unused)] -fn main() { -enum Option<T> { - Some(T), - None, -} -} -
其中 T
是泛型参数,Some(T)
表示该枚举成员的数据类型是 T
,换句话说,Some
可以包含任何类型的数据。
Option<T>
枚举是如此有用以至于它被包含在了 prelude
(prelude 属于 Rust 标准库,Rust 会将最常用的类型、函数等提前引入其中,省得我们再手动引入)之中,你不需要将其显式引入作用域。另外,它的成员 Some
和 None
也是如此,无需使用 Option::
前缀就可直接使用 Some
和 None
。总之,不能因为 Some(T)
和 None
中没有 Option::
的身影,就否认它们是 Option
下的卧龙凤雏。
再来看以下代码:
---#![allow(unused)] -fn main() { -let some_number = Some(5); -let some_string = Some("a string"); - -let absent_number: Option<i32> = None; -} -
如果使用 None
而不是 Some
,需要告诉 Rust Option<T>
是什么类型的,因为编译器只通过 None
值无法推断出 Some
成员保存的值的类型。
当有一个 Some
值时,我们就知道存在一个值,而这个值保存在 Some
中。当有个 None
值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T>
为什么就比空值要好呢?
简而言之,因为 Option<T>
和 T
(这里 T
可以是任何类型)是不同的类型,例如,这段代码不能编译,因为它尝试将 Option<i8>
(Option<T>
) 与 i8
(T
) 相加:
--#![allow(unused)] -fn main() { -let x: i8 = 5; -let y: Option<i8> = Some(5); - -let sum = x + y; -} -
如果运行这些代码,将得到类似这样的错误信息:
-error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
-not satisfied
- -->
- |
-5 | let sum = x + y;
- | ^ no implementation for `i8 + std::option::Option<i8>`
- |
-
-很好!事实上,错误信息意味着 Rust 不知道该如何将 Option<i8>
与 i8
相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8
这样类型的值时,编译器确保它总是有一个有效的值,我们可以放心使用而无需做空值检查。只有当使用 Option<i8>
(或者任何用到的类型)的时候才需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。
换句话说,在对 Option<T>
进行 T
的运算之前必须将其转换为 T
。通常这能帮助我们捕获到空值最常见的问题之一:期望某值不为空但实际上为空的情况。
不再担心会错误的使用一个空值,会让你对代码更加有信心。为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T>
中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T>
类型,你就 可以 安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。
那么当有一个 Option<T>
的值时,如何从 Some
成员中取出 T
的值来使用它呢?Option<T>
枚举拥有大量用于各种情况的方法:你可以查看它的文档。熟悉 Option<T>
的方法将对你的 Rust 之旅非常有用。
总的来说,为了使用 Option<T>
值,需要编写处理每个成员的代码。你想要一些代码只当拥有 Some(T)
值时运行,允许这些代码使用其中的 T
。也希望一些代码在值为 None
时运行,这些代码并没有一个可用的 T
值。match
表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。
这里先简单看一下 match
的大致模样,在模式匹配中,我们会详细讲解:
--#![allow(unused)] -fn main() { -fn plus_one(x: Option<i32>) -> Option<i32> { - match x { - None => None, - Some(i) => Some(i + 1), - } -} - -let five = Some(5); -let six = plus_one(five); -let none = plus_one(None); -} -
plus_one
通过 match
来处理不同 Option
的情况。
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
行百里者半九十,欢迎大家来到这里,虽然还不到中点,但是已经不远了。如果说之前学的基础数据类型是原子,那么本章将讲的数据类型可以认为是分子。
-本章的重点在复合类型上,顾名思义,复合类型是由其它类型组合而成的,最典型的就是结构体 struct
和枚举 enum
。例如平面上的一个点 point(x, y)
,它由两个数值类型的值 x
和 y
组合而来。我们无法单独去维护这两个数值,因为单独一个 x
或者 y
是含义不完整的,无法标识平面上的一个点,应该把它们看作一个整体去理解和处理。
来看一段代码,它使用我们之前学过的内容来构建文件操作:
--#![allow(unused_variables)] -type File = String; - -fn open(f: &mut File) -> bool { - true -} -fn close(f: &mut File) -> bool { - true -} - -#[allow(dead_code)] -fn read(f: &mut File, save_to: &mut Vec<u8>) -> ! { - unimplemented!() -} - -fn main() { - let mut f1 = File::from("f1.txt"); - open(&mut f1); - //read(&mut f1, &mut vec![]); - close(&mut f1); -} -
接下来我们的学习非常类似原型设计:有的方法只提供 API 接口,但是不提供具体实现。此外,有的变量在声明之后并未使用,因此在这个阶段我们需要排除一些编译器噪音(Rust 在编译的时候会扫描代码,变量声明后未使用会以 warning
警告的形式进行提示),引入 #![allow(unused_variables)]
属性标记,该标记会告诉编译器忽略未使用的变量,不要抛出 warning
警告,具体的常见编译器属性你可以在这里查阅:编译器属性标记。
read
函数也非常有趣,它返回一个 !
类型,这个表明该函数是一个发散函数,不会返回任何值,包括 ()
。unimplemented!()
告诉编译器该函数尚未实现,unimplemented!()
标记通常意味着我们期望快速完成主要代码,回头再通过搜索这些标记来完成次要代码,类似的标记还有 todo!()
,当代码执行到这种未实现的地方时,程序会直接报错。你可以反注释 read(&mut f1, &mut vec![]);
这行,然后再观察下结果。
同时,从代码设计角度来看,关于文件操作的类型和函数应该组织在一起,散落得到处都是,是难以管理和使用的。而且通过 open(&mut f1)
进行调用,也远没有使用 f1.open()
来调用好,这就体现出了只使用基本类型的局限性:无法从更高的抽象层次去简化代码。
接下来,我们将引入一个高级数据结构 —— 结构体 struct
,来看看复合类型是怎样更好的解决这类问题。 开始之前,先来看看 Rust 的重点也是难点:字符串 String
和 &str
。
在其他语言中,字符串往往是送分题,因为实在是太简单了,例如 "hello, world"
就是字符串章节的几乎全部内容了,但是如果你带着同样的想法来学 Rust,我保证,绝对会栽跟头,因此这一章大家一定要重视,仔细阅读,这里有很多其它 Rust 书籍中没有的内容。
首先来看段很简单的代码:
--fn main() { - let my_name = "Pascal"; - greet(my_name); -} - -fn greet(name: String) { - println!("Hello, {}!", name); -} -
greet
函数接受一个字符串类型的 name
参数,然后打印到终端控制台中,非常好理解,你们猜猜,这段代码能否通过编译?
error[E0308]: mismatched types
- --> src/main.rs:3:11
- |
-3 | greet(my_name);
- | ^^^^^^^
- | |
- | expected struct `std::string::String`, found `&str`
- | help: try using a conversion method: `my_name.to_string()`
-
-error: aborting due to previous error
-
-Bingo,果然报错了,编译器提示 greet
函数需要一个 String
类型的字符串,却传入了一个 &str
类型的字符串,相信读者心中现在一定有几头草泥马呼啸而过,怎么字符串也能整出这么多花活?
在讲解字符串之前,先来看看什么是切片?
-切片并不是 Rust 独有的概念,在 Go 语言中就非常流行,它允许你引用集合中部分连续的元素序列,而不是引用整个集合。
-对于字符串而言,切片就是对 String
类型中某一部分的引用,它看起来像这样:
--#![allow(unused)] -fn main() { -let s = String::from("hello world"); - -let hello = &s[0..5]; -let world = &s[6..11]; -} -
hello
没有引用整个 String s
,而是引用了 s
的一部分内容,通过 [0..5]
的方式来指定。
这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,也就是这是一个 右半开区间
。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引
- 开始索引
的方式计算得来的。
对于 let world = &s[6..11];
来说,world
是一个切片,该切片的指针指向 s
的第 7 个字节(索引从 0 开始, 6 是第 7 个字节),且该切片的长度是 5
个字节。
在使用 Rust 的 ..
range 序列语法时,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:
--#![allow(unused)] -fn main() { -let s = String::from("hello"); - -let slice = &s[0..2]; -let slice = &s[..2]; -} -
同样的,如果你的切片想要包含 String
的最后一个字节,则可以这样使用:
--#![allow(unused)] -fn main() { -let s = String::from("hello"); - -let len = s.len(); - -let slice = &s[4..len]; -let slice = &s[4..]; -} -
你也可以截取完整的 String
切片:
--#![allow(unused)] -fn main() { -let s = String::from("hello"); - -let len = s.len(); - -let slice = &s[0..len]; -let slice = &s[..]; -} -
--在对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界,例如中文在 UTF-8 中占用三个字节,下面的代码就会崩溃:
---#![allow(unused)] -fn main() { - let s = "中国人"; - let a = &s[0..2]; - println!("{}",a); -} -
因为我们只取
-s
字符串的前两个字节,但是本例中每个汉字占用三个字节,因此没有落在边界处,也就是连中
字都取不完整,此时程序会直接崩溃退出,如果改成&s[0..3]
,则可以正常通过编译。 -因此,当你需要对字符串做切片索引操作时,需要格外小心这一点, 关于该如何操作 UTF-8 字符串,参见这里。
字符串切片的类型标识是 &str
,因此我们可以这样声明一个函数,输入 String
类型,返回它的切片: fn first_word(s: &String) -> &str
。
有了切片就可以写出这样的代码:
--fn main() { - let mut s = String::from("hello world"); - - let word = first_word(&s); - - s.clear(); // error! - - println!("the first word is: {}", word); -} -fn first_word(s: &String) -> &str { - &s[..1] -} -
编译器报错如下:
-error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
- --> src/main.rs:18:5
- |
-16 | let word = first_word(&s);
- | -- immutable borrow occurs here
-17 |
-18 | s.clear(); // error!
- | ^^^^^^^^^ mutable borrow occurs here
-19 |
-20 | println!("the first word is: {}", word);
- | ---- immutable borrow later used here
-
-回忆一下借用的规则:当我们已经有了可变借用时,就无法再拥有不可变的借用。因为 clear
需要清空改变 String
,因此它需要一个可变借用(利用 VSCode 可以看到该方法的声明是 pub fn clear(&mut self)
,参数是对自身的可变借用 );而之后的 println!
又使用了不可变借用,也就是在 s.clear()
处可变借用与不可变借用试图同时生效,因此编译无法通过。
从上述代码可以看出,Rust 不仅让我们的 API
更加容易使用,而且也在编译期就消除了大量错误!
因为切片是对集合的部分引用,因此不仅仅字符串有切片,其它集合类型也有,例如数组:
---#![allow(unused)] -fn main() { -let a = [1, 2, 3, 4, 5]; - -let slice = &a[1..3]; - -assert_eq!(slice, &[2, 3]); -} -
该数组切片的类型是 &[i32]
,数组切片和字符串切片的工作方式是一样的,例如持有一个引用指向原始数组的某个元素和长度。
之前提到过字符串字面量,但是没有提到它的类型:
---#![allow(unused)] -fn main() { -let s = "Hello, world!"; -} -
实际上,s
的类型是 &str
,因此你也可以这样声明:
--#![allow(unused)] -fn main() { -let s: &str = "Hello, world!"; -} -
该切片指向了程序可执行文件中的某个点,这也是为什么字符串字面量是不可变的,因为 &str
是一个不可变引用。
了解完切片,可以进入本节的正题了。
-顾名思义,字符串是由字符组成的连续集合,但是在上一节中我们提到过,Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间。
-Rust 在语言级别,只有一种字符串类型: str
,它通常是以引用类型出现 &str
,也就是上文提到的字符串切片。虽然语言级别只有上述的 str
类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String
类型。
str
类型是硬编码进可执行文件,也无法被修改,但是 String
则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String
类型和 &str
字符串切片类型,这两个类型都是 UTF-8 编码。
除了 String
类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString
, OsStr
, CsString
和 CsStr
等,注意到这些名字都以 String
或者 Str
结尾了吗?它们分别对应的是具有所有权和被借用的变量。
在之前的代码中,已经见到好几种从 &str
类型生成 String
类型的操作:
String::from("hello,world")
"hello,world".to_string()
那么如何将 String
类型转为 &str
类型呢?答案很简单,取引用即可:
-fn main() { - let s = String::from("hello,world!"); - say_hello(&s); - say_hello(&s[..]); - say_hello(s.as_str()); -} - -fn say_hello(s: &str) { - println!("{}",s); -} -
实际上这种灵活用法是因为 deref
隐式强制转换,具体我们会在 Deref
特征进行详细讲解。
在其它语言中,使用索引的方式访问字符串的某个字符或者子串是很正常的行为,但是在 Rust 中就会报错:
---#![allow(unused)] -fn main() { - let s1 = String::from("hello"); - let h = s1[0]; -} -
该代码会产生如下错误:
-3 | let h = s1[0];
- | ^^^^^ `String` cannot be indexed by `{integer}`
- |
- = help: the trait `Index<{integer}>` is not implemented for `String`
-
-字符串的底层的数据存储格式实际上是[ u8
],一个字节数组。对于 let hello = String::from("Hola");
这行代码来说,Hola
的长度是 4
个字节,因为 "Hola"
中的每个字母在 UTF-8 编码中仅占用 1 个字节,但是对于下面的代码呢?
--#![allow(unused)] -fn main() { -let hello = String::from("中国人"); -} -
如果问你该字符串多长,你可能会说 3
,但是实际上是 9
个字节的长度,因为大部分常用汉字在 UTF-8 中的长度是 3
个字节,因此这种情况下对 hello
进行索引,访问 &hello[0]
没有任何意义,因为你取不到 中
这个字符,而是取到了这个字符三个字节中的第一个字节,这是一个非常奇怪而且难以理解的返回值。
现在看一下用梵文写的字符串 “नमस्ते”
, 它底层的字节数组如下形式:
--#![allow(unused)] -fn main() { -[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, -224, 165, 135] -} -
长度是 18 个字节,这也是计算机最终存储该字符串的形式。如果从字符的形式去看,则是:
---#![allow(unused)] -fn main() { -['न', 'म', 'स', '्', 'त', 'े'] -} -
但是这种形式下,第四和六两个字母根本就不存在,没有任何意义,接着再从字母串的形式去看:
---#![allow(unused)] -fn main() { -["न", "म", "स्", "ते"] -} -
所以,可以看出来 Rust 提供了不同的字符串展现方式,这样程序可以挑选自己想要的方式去使用,而无需去管字符串从人类语言角度看长什么样。
-还有一个原因导致了 Rust 不允许去索引字符串:因为索引操作,我们总是期望它的性能表现是 O(1),然而对于 String
类型来说,无法保证这一点,因为 Rust 可能需要从 0 开始去遍历字符串来定位合法的字符。
前文提到过,字符串切片是非常危险的操作,因为切片的索引是通过字节来进行,但是字符串又是 UTF-8 编码,因此你无法保证索引的字节刚好落在字符的边界上,例如:
---#![allow(unused)] -fn main() { -let hello = "中国人"; - -let s = &hello[0..2]; -} -
运行上面的程序,会直接造成崩溃:
-thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '中' (bytes 0..3) of `中国人`', src/main.rs:4:14
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-这里提示的很清楚,我们索引的字节落在了 中
字符的内部,这种返回没有任何意义。
因此在通过索引区间来访问字符串时,需要格外的小心,一不注意,就会导致你程序的崩溃!
-由于 String
是可变字符串,下面介绍 Rust 字符串的修改,添加,删除等常用方法:
在字符串尾部可以使用 push()
方法追加字符 char
,也可以使用 push_str()
方法追加字符串字面量。这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut
关键字修饰。
示例代码如下:
--fn main() { - let mut s = String::from("Hello "); - - s.push_str("rust"); - println!("追加字符串 push_str() -> {}", s); - - s.push('!'); - println!("追加字符 push() -> {}", s); -} -
代码运行结果:
-追加字符串 push_str() -> Hello rust
-追加字符 push() -> Hello rust!
-
-可以使用 insert()
方法插入单个字符 char
,也可以使用 insert_str()
方法插入字符串字面量,与 push()
方法不同,这俩方法需要传入两个参数,第一个参数是字符(串)插入位置的索引,第二个参数是要插入的字符(串),索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut
关键字修饰。
示例代码如下:
--fn main() { - let mut s = String::from("Hello rust!"); - s.insert(5, ','); - println!("插入字符 insert() -> {}", s); - s.insert_str(6, " I like"); - println!("插入字符串 insert_str() -> {}", s); -} -
代码运行结果:
-插入字符 insert() -> Hello, rust!
-插入字符串 insert_str() -> Hello, I like rust!
-
-如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace()
方法。与替换有关的方法有三个。
1、replace
该方法可适用于 String
和 &str
类型。replace()
方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
--fn main() { - let string_replace = String::from("I like rust. Learning rust is my favorite!"); - let new_string_replace = string_replace.replace("rust", "RUST"); - dbg!(new_string_replace); -} -
代码运行结果:
-new_string_replace = "I like RUST. Learning RUST is my favorite!"
-
-2、replacen
该方法可适用于 String
和 &str
类型。replacen()
方法接收三个参数,前两个参数与 replace()
方法一样,第三个参数则表示替换的个数。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
--fn main() { - let string_replace = "I like rust. Learning rust is my favorite!"; - let new_string_replacen = string_replace.replacen("rust", "RUST", 1); - dbg!(new_string_replacen); -} -
代码运行结果:
-new_string_replacen = "I like RUST. Learning rust is my favorite!"
-
-3、replace_range
该方法仅适用于 String
类型。replace_range
接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。该方法需要使用 mut
关键字修饰。
示例代码如下:
--fn main() { - let mut string_replace_range = String::from("I like rust!"); - string_replace_range.replace_range(7..8, "R"); - dbg!(string_replace_range); -} -
代码运行结果:
-string_replace_range = "I like Rust!"
-
-与字符串删除相关的方法有 4 个,他们分别是 pop()
,remove()
,truncate()
,clear()
。这四个方法仅适用于 String
类型。
1、 pop
—— 删除并返回字符串的最后一个字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是一个 Option
类型,如果字符串为空,则返回 None
。
-示例代码如下:
-fn main() { - let mut string_pop = String::from("rust pop 中文!"); - let p1 = string_pop.pop(); - let p2 = string_pop.pop(); - dbg!(p1); - dbg!(p2); - dbg!(string_pop); -} -
代码运行结果:
-p1 = Some(
- '!',
-)
-p2 = Some(
- '文',
-)
-string_pop = "rust pop 中"
-
-2、 remove
—— 删除并返回字符串中指定位置的字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是删除位置的字符串,只接收一个参数,表示该字符起始索引位置。remove()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
--fn main() { - let mut string_remove = String::from("测试remove方法"); - println!( - "string_remove 占 {} 个字节", - std::mem::size_of_val(string_remove.as_str()) - ); - // 删除第一个汉字 - string_remove.remove(0); - // 下面代码会发生错误 - // string_remove.remove(1); - // 直接删除第二个汉字 - // string_remove.remove(3); - dbg!(string_remove); -} -
代码运行结果:
-string_remove 占 18 个字节
-string_remove = "试remove方法"
-
-3、truncate
—— 删除字符串中从指定位置开始到结尾的全部字符
该方法是直接操作原来的字符串。无返回值。该方法 truncate()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
--fn main() { - let mut string_truncate = String::from("测试truncate"); - string_truncate.truncate(3); - dbg!(string_truncate); -} -
代码运行结果:
-string_truncate = "测"
-
-4、clear
—— 清空字符串
该方法是直接操作原来的字符串。调用后,删除字符串中的所有字符,相当于 truncate()
方法参数为 0 的时候。
示例代码如下:
--fn main() { - let mut string_clear = String::from("string clear"); - string_clear.clear(); - dbg!(string_clear); -} -
代码运行结果:
-string_clear = ""
-
-1、使用 +
或者 +=
连接字符串
使用 +
或者 +=
连接字符串,要求右边的参数必须为字符串的切片引用(Slice)类型。其实当调用 +
的操作符时,相当于调用了 std::string
标准库中的 add()
方法,这里 add()
方法的第二个参数是一个引用的类型。因此我们在使用 +
, 必须传递切片引用类型。不能直接传递 String
类型。+
是返回一个新的字符串,所以变量声明可以不需要 mut
关键字修饰。
示例代码如下:
--fn main() { - let string_append = String::from("hello "); - let string_rust = String::from("rust"); - // &string_rust会自动解引用为&str - let result = string_append + &string_rust; - let mut result = result + "!"; // `result + "!"` 中的 `result` 是不可变的 - result += "!!!"; - - println!("连接字符串 + -> {}", result); -} -
代码运行结果:
-连接字符串 + -> hello rust!!!!
-
-add()
方法的定义:
--#![allow(unused)] -fn main() { -fn add(self, s: &str) -> String -} -
因为该方法涉及到更复杂的特征功能,因此我们这里简单说明下:
--fn main() { - let s1 = String::from("hello,"); - let s2 = String::from("world!"); - // 在下句中,s1的所有权被转移走了,因此后面不能再使用s1 - let s3 = s1 + &s2; - assert_eq!(s3,"hello,world!"); - // 下面的语句如果去掉注释,就会报错 - // println!("{}",s1); -} -
self
是 String
类型的字符串 s1
,该函数说明,只能将 &str
类型的字符串切片添加到 String
类型的 s1
上,然后返回一个新的 String
类型,所以 let s3 = s1 + &s2;
就很好解释了,将 String
类型的 s1
与 &str
类型的 s2
进行相加,最终得到 String
类型的 s3
。
由此可推,以下代码也是合法的:
---#![allow(unused)] -fn main() { -let s1 = String::from("tic"); -let s2 = String::from("tac"); -let s3 = String::from("toe"); - -// String = String + &str + &str + &str + &str -let s = s1 + "-" + &s2 + "-" + &s3; -} -
String + &str
返回一个 String
,然后再继续跟一个 &str
进行 +
操作,返回一个 String
类型,不断循环,最终生成一个 s
,也是 String
类型。
s1
这个变量通过调用 add()
方法后,所有权被转移到 add()
方法里面, add()
方法调用后就被释放了,同时 s1
也被释放了。再使用 s1
就会发生错误。这里涉及到所有权转移(Move)的相关知识。
2、使用 format!
连接字符串
format!
这种方式适用于 String
和 &str
。format!
的用法与 print!
的用法类似,详见格式化输出。
示例代码如下:
--fn main() { - let s1 = "hello"; - let s2 = String::from("rust"); - let s = format!("{} {}!", s1, s2); - println!("{}", s); -} - -
代码运行结果:
-hello rust!
-
-我们可以通过转义的方式 \
输出 ASCII 和 Unicode 字符。
-fn main() { - // 通过 \ + 字符的十六进制表示,转义输出一个字符 - let byte_escape = "I'm writing \x52\x75\x73\x74!"; - println!("What are you doing\x3F (\\x3F means ?) {}", byte_escape); - - // \u 可以输出一个 unicode 字符 - let unicode_codepoint = "\u{211D}"; - let character_name = "\"DOUBLE-STRUCK CAPITAL R\""; - - println!( - "Unicode character {} (U+211D) is called {}", - unicode_codepoint, character_name - ); - - // 换行了也会保持之前的字符串格式 - let long_string = "String literals - can span multiple lines. - The linebreak and indentation here ->\ - <- can be escaped too!"; - println!("{}", long_string); -} -
当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:
--fn main() { - println!("{}", "hello \\x52\\x75\\x73\\x74"); - let raw_str = r"Escapes don't work here: \x3F \u{211D}"; - println!("{}", raw_str); - - // 如果字符串包含双引号,可以在开头和结尾加 # - let quotes = r#"And then I said: "There is no escape!""#; - println!("{}", quotes); - - // 如果还是有歧义,可以继续增加,没有限制 - let longer_delimiter = r###"A string with "# in it. And even "##!"###; - println!("{}", longer_delimiter); -} -
前文提到了几种使用 UTF-8 字符串的方式,下面来一一说明。
-如果你想要以 Unicode 字符的方式遍历字符串,最好的办法是使用 chars
方法,例如:
--#![allow(unused)] -fn main() { -for c in "中国人".chars() { - println!("{}", c); -} -} -
输出如下
-中
-国
-人
-
-这种方式是返回字符串的底层字节数组表现形式:
---#![allow(unused)] -fn main() { -for b in "中国人".bytes() { - println!("{}", b); -} -} -
输出如下:
-228
-184
-173
-229
-155
-189
-228
-186
-186
-
-想要准确的从 UTF-8 字符串中获取子串是较为复杂的事情,例如想要从 holla中国人नमस्ते
这种变长的字符串中取出某一个子串,使用标准库你是做不到的。
-你需要在 crates.io
上搜索 utf8
来寻找想要的功能。
可以考虑尝试下这个库:utf8_slice。
-那么问题来了,为啥 String
可变,而字符串字面值 str
却不可以?
就字符串字面值来说,我们在编译时就知道其内容,最终字面值文本被直接硬编码进可执行文件中,这使得字符串字面值快速且高效,这主要得益于字符串字面值的不可变性。不幸的是,我们不能为了获得这种性能,而把每一个在编译时大小未知的文本都放进内存中(你也做不到!),因为有的字符串是在程序运行得过程中动态生成的。
-对于 String
类型,为了支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容,这些都是在程序运行时完成的:
String
对象其中第一部分由 String::from
完成,它创建了一个全新的 String
。
重点来了,到了第二部分,就是百家齐放的环节,在有垃圾回收 GC 的语言中,GC 来负责标记并清除这些不再使用的内存对象,这个过程都是自动完成,无需开发者关心,非常简单好用;但是在无 GC 的语言中,需要开发者手动去释放这些内存对象,就像创建对象需要通过编写代码来完成一样,未能正确释放对象造成的后果简直不可估量。
-对于 Rust 而言,安全和性能是写到骨子里的核心特性,如果使用 GC,那么会牺牲性能;如果使用手动管理内存,那么会牺牲安全,这该怎么办?为此,Rust 的开发者想出了一个无比惊艳的办法:变量在离开作用域后,就自动释放其占用的内存:
---#![allow(unused)] -fn main() { -{ - let s = String::from("hello"); // 从此处起,s 是有效的 - - // 使用 s -} // 此作用域已结束, - // s 不再有效,内存被释放 -} -
与其它系统编程语言的 free
函数相同,Rust 也提供了一个释放内存的函数: drop
,但是不同的是,其它语言要手动调用 free
来释放每一个变量占用的内存,而 Rust 则在变量离开作用域时,自动调用 drop
函数: 上面代码中,Rust 在结尾的 }
处自动调用 drop
。
--其实,在 C++ 中,也有这种概念: Resource Acquisition Is Initialization (RAII)。如果你使用过 RAII 模式的话应该对 Rust 的
-drop
函数并不陌生。
这个模式对编写 Rust 代码的方式有着深远的影响,在后面章节我们会进行更深入的介绍。
---Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
- -
上一节中提到需要一个更高级的数据结构来帮助我们更好的抽象问题,结构体 struct
恰恰就是这样的复合数据结构,它是由其它数据类型组合而来。 其它语言也有类似的数据结构,不过可能有不同的名称,例如 object
、 record
等。
结构体跟之前讲过的元组有些相像:都是由多种类型组合而成。但是与元组不同的是,结构体可以为内部的每个字段起一个富有含义的名称。因此结构体更加灵活更加强大,你无需依赖这些字段的顺序来访问和解析它们。
-天下无敌的剑士往往也因为他有一柄无双之剑,既然结构体这么强大,那么我们就需要给它配套一套强大的语法,让用户能更好的驾驭。
-一个结构体由几部分组成:
-struct
定义名称
字段
例如, 以下结构体定义了某网站的用户:
---#![allow(unused)] -fn main() { -struct User { - active: bool, - username: String, - email: String, - sign_in_count: u64, -} -} -
该结构体名称是 User
,拥有 4 个字段,且每个字段都有对应的字段名及类型声明,例如 username
代表了用户名,是一个可变的 String
类型。
为了使用上述结构体,我们需要创建 User
结构体的实例:
--#![allow(unused)] -fn main() { - let user1 = User { - email: String::from("someone@example.com"), - username: String::from("someusername123"), - active: true, - sign_in_count: 1, - }; -} -
有几点值得注意:
-通过 .
操作符即可访问结构体实例内部的字段值,也可以修改它们:
--#![allow(unused)] -fn main() { - let mut user1 = User { - email: String::from("someone@example.com"), - username: String::from("someusername123"), - active: true, - sign_in_count: 1, - }; - - user1.email = String::from("anotheremail@example.com"); -} -
需要注意的是,必须要将结构体实例声明为可变的,才能修改其中的字段,Rust 不支持将某个结构体某个字段标记为可变。
-下面的函数类似一个构建函数,返回了 User
结构体的实例:
--#![allow(unused)] -fn main() { -fn build_user(email: String, username: String) -> User { - User { - email: email, - username: username, - active: true, - sign_in_count: 1, - } -} -} -
它接收两个字符串参数: email
和 username
,然后使用它们来创建一个 User
结构体,并且返回。可以注意到这两行: email: email
和 username: username
,非常的扎眼,因为实在有些啰嗦,如果你从 TypeScript 过来,肯定会鄙视 Rust 一番,不过好在,它也不是无可救药:
--#![allow(unused)] -fn main() { -fn build_user(email: String, username: String) -> User { - User { - email, - username, - active: true, - sign_in_count: 1, - } -} -} -
如上所示,当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样。
-在实际场景中,有一种情况很常见:根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1
实例来构建 user2
:
--#![allow(unused)] -fn main() { - let user2 = User { - active: user1.active, - username: user1.username, - email: String::from("another@example.com"), - sign_in_count: user1.sign_in_count, - }; -} -
老话重提,如果你从 TypeScript 过来,肯定觉得啰嗦爆了:竟然手动把 user1
的三个字段逐个赋值给 user2
,好在 Rust 为我们提供了 结构体更新语法
:
--#![allow(unused)] -fn main() { - let user2 = User { - email: String::from("another@example.com"), - ..user1 - }; -} -
因为 user2
仅仅在 email
上与 user1
不同,因此我们只需要对 email
进行赋值,剩下的通过结构体更新语法 ..user1
即可完成。
..
语法表明凡是我们没有显式声明的字段,全部从 user1
中自动获取。需要注意的是 ..user1
必须在结构体的尾部使用。
--结构体更新语法跟赋值语句
-=
非常相像,因此在上面代码中,user1
的部分字段所有权被转移到user2
中:username
字段发生了所有权转移,作为结果,user1
无法再被使用。聪明的读者肯定要发问了:明明有三个字段进行了自动赋值,为何只有
-username
发生了所有权转移?仔细回想一下所有权那一节的内容,我们提到了
-Copy
特征:实现了Copy
特征的类型无需所有权转移,可以直接在赋值时进行 -数据拷贝,其中bool
和u64
类型就实现了Copy
特征,因此active
和sign_in_count
字段在赋值给user2
时,仅仅发生了拷贝,而不是所有权转移。值得注意的是:
-username
所有权被转移给了user2
,导致了user1
无法再被使用,但是并不代表user1
内部的其它字段不能被继续使用,例如:
-#[derive(Debug)] -struct User { - active: bool, - username: String, - email: String, - sign_in_count: u64, -} -fn main() { -let user1 = User { - email: String::from("someone@example.com"), - username: String::from("someusername123"), - active: true, - sign_in_count: 1, -}; -let user2 = User { - active: user1.active, - username: user1.username, - email: String::from("another@example.com"), - sign_in_count: user1.sign_in_count, -}; -println!("{}", user1.active); -// 下面这行会报错 -println!("{:?}", user1); -} -
先来看以下代码:
--#[derive(Debug)] - struct File { - name: String, - data: Vec<u8>, - } - - fn main() { - let f1 = File { - name: String::from("f1.txt"), - data: Vec::new(), - }; - - let f1_name = &f1.name; - let f1_length = &f1.data.len(); - - println!("{:?}", f1); - println!("{} is {} bytes long", f1_name, f1_length); - } -
上面定义的 File
结构体在内存中的排列如下图所示:
-
从图中可以清晰地看出 File
结构体两个字段 name
和 data
分别拥有底层两个 [u8]
数组的所有权(String
类型的底层也是 [u8]
数组),通过 ptr
指针指向底层数组的内存地址,这里你可以把 ptr
指针理解为 Rust 中的引用类型。
该图片也侧面印证了:把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段。
-结构体必须要有名称,但是结构体的字段可以没有名称,这种结构体长得很像元组,因此被称为元组结构体,例如:
---#![allow(unused)] -fn main() { - struct Color(i32, i32, i32); - struct Point(i32, i32, i32); - - let black = Color(0, 0, 0); - let origin = Point(0, 0, 0); -} -
元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。例如上面的 Point
元组结构体,众所周知 3D 点是 (x, y, z)
形式的坐标点,因此我们无需再为内部的字段逐一命名为:x
, y
, z
。
还记得之前讲过的基本没啥用的单元类型吧?单元结构体就跟它很像,没有任何字段和属性,但是好在,它还挺有用。
-如果你定义一个类型,但是不关心该类型的内容, 只关心它的行为时,就可以使用 单元结构体
:
--#![allow(unused)] -fn main() { -struct AlwaysEqual; - -let subject = AlwaysEqual; - -// 我们不关心 AlwaysEqual 的字段数据,只关心它的行为,因此将它声明为单元结构体,然后再为它实现某个特征 -impl SomeTrait for AlwaysEqual { - -} -} -
在之前的 User
结构体的定义中,有一处细节:我们使用了自身拥有所有权的 String
类型而不是基于引用的 &str
字符串切片类型。这是一个有意而为之的选择:因为我们想要这个结构体拥有它所有的数据,而不是从其它地方借用数据。
你也可以让 User
结构体从其它对象借用数据,不过这么做,就需要引入生命周期(lifetimes)这个新概念(也是一个复杂的概念),简而言之,生命周期能确保结构体的作用范围要比它所借用的数据的作用范围要小。
总之,如果你想在结构体中使用一个引用,就必须加上生命周期,否则就会报错:
--struct User { - username: &str, - email: &str, - sign_in_count: u64, - active: bool, -} - -fn main() { - let user1 = User { - email: "someone@example.com", - username: "someusername123", - active: true, - sign_in_count: 1, - }; -} -
编译器会抱怨它需要生命周期标识符:
-error[E0106]: missing lifetime specifier
- --> src/main.rs:2:15
- |
-2 | username: &str,
- | ^ expected named lifetime parameter // 需要一个生命周期
- |
-help: consider introducing a named lifetime parameter // 考虑像下面的代码这样引入一个生命周期
- |
-1 ~ struct User<'a> {
-2 ~ username: &'a str,
- |
-
-error[E0106]: missing lifetime specifier
- --> src/main.rs:3:12
- |
-3 | email: &str,
- | ^ expected named lifetime parameter
- |
-help: consider introducing a named lifetime parameter
- |
-1 ~ struct User<'a> {
-2 | username: &str,
-3 ~ email: &'a str,
- |
-
-未来在生命周期中会讲到如何修复这个问题以便在结构体中存储引用,不过在那之前,我们会避免在结构体中使用引用类型。
-#[derive(Debug)]
来打印结构体的信息在前面的代码中我们使用 #[derive(Debug)]
对结构体进行了标记,这样才能使用 println!("{:?}", s);
的方式对其进行打印输出,如果不加,看看会发生什么:
-struct Rectangle { - width: u32, - height: u32, -} - -fn main() { - let rect1 = Rectangle { - width: 30, - height: 50, - }; - - println!("rect1 is {}", rect1); -} -
首先可以观察到,上面使用了 {}
而不是之前的 {:?}
,运行后报错:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
-
-提示我们结构体 Rectangle
没有实现 Display
特征,这是因为如果我们使用 {}
来格式化输出,那对应的类型就必须实现 Display
特征,以前学习的基本类型,都默认实现了该特征:
-fn main() { - let v = 1; - let b = true; - - println!("{}, {}", v, b); -} -
上面代码不会报错,那么结构体为什么不默认实现 Display
特征呢?原因在于结构体较为复杂,例如考虑以下问题:你想要逗号对字段进行分割吗?需要括号吗?加在什么地方?所有的字段都应该显示?类似的还有很多,由于这种复杂性,Rust 不希望猜测我们想要的是什么,而是把选择权交给我们自己来实现:如果要用 {}
的方式打印结构体,那就自己实现 Display
特征。
接下来继续阅读报错:
-= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
-= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
-
-上面提示我们使用 {:?}
来试试,这个方式我们在本文的前面也见过,下面来试试:
--#![allow(unused)] -fn main() { -println!("rect1 is {:?}", rect1); -} -
可是依然无情报错了:
-error[E0277]: `Rectangle` doesn't implement `Debug`
-
-好在,聪明的编译器又一次给出了提示:
-= help: the trait `Debug` is not implemented for `Rectangle`
-= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
-
-让我们实现 Debug
特征,Oh No,就是不想实现 Display
特征,才用的 {:?}
,怎么又要实现 Debug
,但是仔细看,提示中有一行: add #[derive(Debug)] to Rectangle
, 哦?这不就是我们前文一直在使用的吗?
首先,Rust 默认不会为我们实现 Debug
,为了实现,有两种方式可以选择:
derive
派生实现后者简单的多,但是也有限制,具体见附录 D,这里我们就不再深入讲解,来看看该如何使用:
--#[derive(Debug)] -struct Rectangle { - width: u32, - height: u32, -} - -fn main() { - let rect1 = Rectangle { - width: 30, - height: 50, - }; - - println!("rect1 is {:?}", rect1); -} -
此时运行程序,就不再有错误,输出如下:
-$ cargo run
-rect1 is Rectangle { width: 30, height: 50 }
-
-这个输出格式看上去也不赖嘛,虽然未必是最好的。这种格式是 Rust 自动为我们提供的实现,看上基本就跟结构体的定义形式一样。
-当结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?}
来替代 {:?}
,输出如下:
rect1 is Rectangle {
- width: 30,
- height: 50,
-}
-
-此时结构体的输出跟我们创建时候的代码几乎一模一样了!当然,如果大家还是不满足,那最好还是自己实现 Display
特征,以向用户更美的展示你的私藏结构体。关于格式化输出的更多内容,我们强烈推荐看看这个章节。
还有一个简单的输出 debug 信息的方法,那就是使用 dbg!
宏,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!
---
dbg!
输出到标准错误输出stderr
,而println!
输出到标准输出stdout
。
下面的例子中清晰的展示了 dbg!
如何在打印出信息的同时,还把表达式的值赋给了 width
:
-#[derive(Debug)] -struct Rectangle { - width: u32, - height: u32, -} - -fn main() { - let scale = 2; - let rect1 = Rectangle { - width: dbg!(30 * scale), - height: 50, - }; - - dbg!(&rect1); -} -
最终的 debug 输出如下:
-$ cargo run
-[src/main.rs:10] 30 * scale = 60
-[src/main.rs:14] &rect1 = Rectangle {
- width: 60,
- height: 50,
-}
-
-可以看到,我们想要的 debug 信息几乎都有了:代码所在的文件名、行号、表达式以及表达式的值,简直完美!
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
元组是由多种类型组合到一起形成的,因此它是复合类型,元组的长度是固定的,元组中元素的顺序也是固定的。
-可以通过以下语法创建一个元组:
--fn main() { - let tup: (i32, f64, u8) = (500, 6.4, 1); -} -
变量 tup
被绑定了一个元组值 (500, 6.4, 1)
,该元组的类型是 (i32, f64, u8)
,看到没?元组是用括号将多个类型组合到一起,简单吧?
可以使用模式匹配或者 .
操作符来获取元组中的值。
-fn main() { - let tup = (500, 6.4, 1); - - let (x, y, z) = tup; - - println!("The value of y is: {}", y); -} -
上述代码首先创建一个元组,然后将其绑定到 tup
上,接着使用 let (x, y, z) = tup;
来完成一次模式匹配,因为元组是 (n1, n2, n3)
形式的,因此我们用一模一样的 (x, y, z)
形式来进行匹配,元组中对应的值会绑定到变量 x
, y
, z
上。这就是解构:用同样的形式把一个复杂对象中的值匹配出来。
.
来访问元组模式匹配可以让我们一次性把元组中的值全部或者部分获取出来,如果只想要访问某个特定元素,那模式匹配就略显繁琐,对此,Rust 提供了 .
的访问方式:
-fn main() { - let x: (i32, f64, u8) = (500, 6.4, 1); - - let five_hundred = x.0; - - let six_point_four = x.1; - - let one = x.2; -} -
和其它语言的数组、字符串一样,元组的索引从 0 开始。
-元组在函数返回值场景很常用,例如下面的代码,可以使用元组返回多个值:
--fn main() { - let s1 = String::from("hello"); - - let (s2, len) = calculate_length(s1); - - println!("The length of '{}' is {}.", s2, len); -} - -fn calculate_length(s: String) -> (String, usize) { - let length = s.len(); // len() 返回字符串的长度 - - (s, length) -} -
calculate_length
函数接收 s1
字符串的所有权,然后计算字符串的长度,接着把字符串所有权和字符串长度再返回给 s2
和 len
变量。
在其他语言中,可以用结构体来声明一个三维空间中的点,例如 Point(10, 20, 30)
,虽然使用 Rust 元组也可以做到:(10, 20, 30)
,但是这样写有个非常重大的缺陷:
不具备任何清晰的含义,在下一章节中,会提到一种与元组类似的结构体,元组结构体
,可以解决这个问题。
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
当读者按照章节顺序读到本章时,意味着你已经几乎具备了参与真实项目开发的能力。但是真实项目远比我们之前的 cargo new
的默认目录结构要复杂,好在,Rust 为我们提供了强大的包管理工具:
其实项目 Package
和包 Crate
很容易被搞混,甚至在很多书中,这两者都是不分的,但是由于官方对此做了明确的区分,因此我们会在本章节中试图(挣扎着)理清这个概念。
对于 Rust 而言,包是一个独立的可编译单元,它编译后会生成一个可执行文件或者一个库。
-一个包会将相关联的功能打包在一起,使得该功能可以很方便的在多个项目中分享。例如标准库中没有提供但是在三方库中提供的 rand
包,它提供了随机数生成的功能,我们只需要将该包通过 use rand;
引入到当前项目的作用域中,就可以在项目中使用 rand
的功能:rand::XXX
。
同一个包中不能有同名的类型,但是在不同包中就可以。例如,虽然 rand
包中,有一个 Rng
特征,可是我们依然可以在自己的项目中定义一个 Rng
,前者通过 rand::Rng
访问,后者通过 Rng
访问,对于编译器而言,这两者的边界非常清晰,不会存在引用歧义。
鉴于 Rust 团队标新立异的起名传统,以及包的名称被 crate
占用,库的名称被 library
占用,经过斟酌, 我们决定将 Package
翻译成项目,你也可以理解为工程、软件包。
由于 Package
就是一个项目,因此它包含有独立的 Cargo.toml
文件,以及因为功能性被组织在一起的一个或多个包。一个 Package
只能包含一个库(library)类型的包,但是可以包含多个二进制可执行类型的包。
让我们来创建一个二进制 Package
:
$ cargo new my-project
- Created binary (application) `my-project` package
-$ ls my-project
-Cargo.toml
-src
-$ ls my-project/src
-main.rs
-
-这里,Cargo 为我们创建了一个名称是 my-project
的 Package
,同时在其中创建了 Cargo.toml
文件,可以看一下该文件,里面并没有提到 src/main.rs
作为程序的入口,原因是 Cargo 有一个惯例:src/main.rs
是二进制包的根文件,该二进制包的包名跟所属 Package
相同,在这里都是 my-project
,所有的代码执行都从该文件中的 fn main()
函数开始。
使用 cargo run
可以运行该项目,输出:Hello, world!
。
再来创建一个库类型的 Package
:
$ cargo new my-lib --lib
- Created library `my-lib` package
-$ ls my-lib
-Cargo.toml
-src
-$ ls my-lib/src
-lib.rs
-
-首先,如果你试图运行 my-lib
,会报错:
$ cargo run
-error: a bin target must be available for `cargo run`
-
-原因是库类型的 Package
只能作为三方库被其它项目引用,而不能独立运行,只有之前的二进制 Package
才可以运行。
与 src/main.rs
一样,Cargo 知道,如果一个 Package
包含有 src/lib.rs
,意味它包含有一个库类型的同名包 my-lib
,该包的根文件是 src/lib.rs
。
看完上面,相信大家看出来为何 Package
和包容易被混淆了吧?因为你用 cargo new
创建的 Package
和它其中包含的包是同名的!
不过,只要你牢记 Package
是一个项目工程,而包只是一个编译单元,基本上也就不会混淆这个两个概念了:src/main.rs
和 src/lib.rs
都是编译单元,因此它们都是包。
Package
结构上面创建的 Package
中仅包含 src/main.rs
文件,意味着它仅包含一个二进制同名包 my-project
。如果一个 Package
同时拥有 src/main.rs
和 src/lib.rs
,那就意味着它包含两个包:库包和二进制包,这两个包名也都是 my-project
—— 都与 Package
同名。
一个真实项目中典型的 Package
,会包含多个二进制包,这些包文件被放在 src/bin
目录下,每一个文件都是独立的二进制包,同时也会包含一个库包,该包只能存在一个 src/lib.rs
:
.
-├── Cargo.toml
-├── Cargo.lock
-├── src
-│ ├── main.rs
-│ ├── lib.rs
-│ └── bin
-│ └── main1.rs
-│ └── main2.rs
-├── tests
-│ └── some_integration_tests.rs
-├── benches
-│ └── simple_bench.rs
-└── examples
- └── simple_example.rs
-
-src/lib.rs
src/main.rs
,编译后生成的可执行文件与 Package
同名src/bin/main1.rs
和 src/bin/main2.rs
,它们会分别生成一个文件同名的二进制可执行文件tests
目录下benchmark
文件:benches
目录下examples
目录下这种目录结构基本上是 Rust 的标准目录结构,在 GitHub
的大多数项目上,你都将看到它的身影。
理解了包的概念,我们再来看看构成包的基本单元:模块。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
当工程规模变大时,把代码写到一个甚至几个文件中,都是不太聪明的做法,可能存在以下问题:
-disaster
同时,将大的代码文件拆分成包和模块,还允许我们实现代码抽象和复用:将你的代码封装好后提供给用户,那么用户只需要调用公共接口即可,无需知道内部该如何实现。
-因此,跟其它语言一样,Rust 也提供了相应概念用于代码的组织管理:
-Cargo
提供的 feature
,可以用来构建、测试和分享包下面,让我们一一来学习这些概念以及如何在实践中运用。
- - -在本章节,我们将深入讲讲 Rust 的代码构成单元:模块。使用模块可以将包中的代码按照功能性进行重组,最终实现更好的可读性及易用性。同时,我们还能非常灵活地去控制代码的可见性,进一步强化 Rust 的安全性。
-小旅馆,sorry,是小餐馆,相信大家都挺熟悉的,学校外的估计也没少去,那么咱就用小餐馆为例,来看看 Rust 的模块该如何使用。
-使用 cargo new --lib restaurant
创建一个小餐馆,注意,这里创建的是一个库类型的 Package
,然后将以下代码放入 src/lib.rs
中:
--#![allow(unused)] -fn main() { -// 餐厅前厅,用于吃饭 -mod front_of_house { - mod hosting { - fn add_to_waitlist() {} - - fn seat_at_table() {} - } - - mod serving { - fn take_order() {} - - fn serve_order() {} - - fn take_payment() {} - } -} -} -
以上的代码创建了三个模块,有几点需要注意的:
-mod
关键字来创建新模块,后面紧跟着模块名称类似上述代码中所做的,使用模块,我们就能将功能相关的代码组织到一起,然后通过一个模块名称来说明这些代码为何被组织在一起。这样其它程序员在使用你的模块时,就可以更快地理解和上手。
-在上一节中,我们提到过 src/main.rs
和 src/lib.rs
被称为包根(crate root),这个奇葩名称的来源(我不想承认是自己翻译水平太烂-,-)是由于这两个文件的内容形成了一个模块 crate
,该模块位于包的树形结构(由模块组成的树形结构)的根部:
crate
- └── front_of_house
- ├── hosting
- │ ├── add_to_waitlist
- │ └── seat_at_table
- └── serving
- ├── take_order
- ├── serve_order
- └── take_payment
-
-这颗树展示了模块之间彼此的嵌套关系,因此被称为模块树。其中 crate
包根是 src/lib.rs
文件,包根文件中的三个模块分别形成了模块树的剩余部分。
如果模块 A
包含模块 B
,那么 A
是 B
的父模块,B
是 A
的子模块。在上例中,front_of_house
是 hosting
和 serving
的父模块,反之,后两者是前者的子模块。
聪明的读者,应该能联想到,模块树跟计算机上文件系统目录树的相似之处。不仅仅是组织结构上的相似,就连使用方式都很相似:每个文件都有自己的路径,用户可以通过这些路径使用它们,在 Rust 中,我们也通过路径的方式来引用模块。
-想要调用一个函数,就需要知道它的路径,在 Rust 中,这种路径有两种形式:
-crate
作为开头self
,super
或当前模块的标识符作为开头让我们继续经营那个惨淡的小餐馆,这次为它实现一个小功能: -文件名:src/lib.rs
---#![allow(unused)] -fn main() { -mod front_of_house { - mod hosting { - fn add_to_waitlist() {} - } -} - -pub fn eat_at_restaurant() { - // 绝对路径 - crate::front_of_house::hosting::add_to_waitlist(); - - // 相对路径 - front_of_house::hosting::add_to_waitlist(); -} -} -
上面的代码为了简化实现,省去了其余模块和函数,这样可以把关注点放在函数调用上。eat_at_restaurant
是一个定义在包根中的函数,在该函数中使用了两种方式对 add_to_waitlist
进行调用。
因为 eat_at_restaurant
和 add_to_waitlist
都定义在一个包中,因此在绝对路径引用时,可以直接以 crate
开头,然后逐层引用,每一层之间使用 ::
分隔:
--#![allow(unused)] -fn main() { -crate::front_of_house::hosting::add_to_waitlist(); -} -
对比下之前的模块树:
-crate
- └── eat_at_restaurant
- └── front_of_house
- ├── hosting
- │ ├── add_to_waitlist
- │ └── seat_at_table
- └── serving
- ├── take_order
- ├── serve_order
- └── take_payment
-
-可以看出,绝对路径的调用,完全符合了模块树的层级递进,非常符合直觉,如果类比文件系统,就跟使用绝对路径调用可执行程序差不多:/front_of_house/hosting/add_to_waitlist
,使用 crate
作为开始就和使用 /
作为开始一样。
再回到模块树中,因为 eat_at_restaurant
和 front_of_house
都处于包根 crate
中,因此相对路径可以使用 front_of_house
作为开头:
--#![allow(unused)] -fn main() { -front_of_house::hosting::add_to_waitlist(); -} -
如果类比文件系统,那么它类似于调用同一个目录下的程序,你可以这么做:front_of_house/hosting/add_to_waitlist
,嗯也很符合直觉。
如果只是为了引用到指定模块中的对象,那么两种都可以,但是在实际使用时,需要遵循一个原则:当代码被挪动位置时,尽量减少引用路径的修改,相信大家都遇到过,修改了某处代码,导致所有路径都要挨个替换,这显然不是好的路径选择。
-回到之前的例子,如果我们把 front_of_house
模块和 eat_at_restaurant
移动到一个模块中 customer_experience
,那么绝对路径的引用方式就必须进行修改:crate::customer_experience::front_of_house ...
,但是假设我们使用的相对路径,那么该路径就无需修改,因为它们两个的相对位置其实没有变:
crate
- └── customer_experience
- └── eat_at_restaurant
- └── front_of_house
- ├── hosting
- │ ├── add_to_waitlist
- │ └── seat_at_table
-
-从新的模块树中可以很清晰的看出这一点。
-再比如,其它的都不动,把 eat_at_restaurant
移动到模块 dining
中,如果使用相对路径,你需要修改该路径,但如果使用的是绝对路径,就无需修改:
crate
- └── dining
- └── eat_at_restaurant
- └── front_of_house
- ├── hosting
- │ ├── add_to_waitlist
-
-不过,如果不确定哪个好,你可以考虑优先使用绝对路径,因为调用的地方和定义的地方往往是分离的,而定义的地方较少会变动。
-让我们运行下面(之前)的代码:
---#![allow(unused)] -fn main() { -mod front_of_house { - mod hosting { - fn add_to_waitlist() {} - } -} - -pub fn eat_at_restaurant() { - // 绝对路径 - crate::front_of_house::hosting::add_to_waitlist(); - - // 相对路径 - front_of_house::hosting::add_to_waitlist(); -} -} -
意料之外的报错了,毕竟看上去确实很简单且没有任何问题:
-error[E0603]: module `hosting` is private
- --> src/lib.rs:9:28
- |
-9 | crate::front_of_house::hosting::add_to_waitlist();
- | ^^^^^^^ private module
-
-错误信息很清晰:hosting
模块是私有的,无法在包根进行访问,那么为何 front_of_house
模块就可以访问?因为它和 eat_at_restaurant
同属于一个包根作用域内,同一个模块内的代码自然不存在私有化问题(所以我们之前章节的代码都没有报过这个错误!)。
模块不仅仅对于组织代码很有用,它还能定义代码的私有化边界:在这个边界内,什么内容能让外界看到,什么内容不能,都有很明确的定义。因此,如果希望让函数或者结构体等类型变成私有化的,可以使用模块。
-Rust 出于安全的考虑,默认情况下,所有的类型都是私有化的,包括函数、方法、结构体、枚举、常量,是的,就连模块本身也是私有化的。在中国,父亲往往不希望孩子拥有小秘密,但是在 Rust 中,父模块完全无法访问子模块中的私有项,但是子模块却可以访问父模块、父父..模块的私有项。
-类似其它语言的 public
或者 Go 语言中的首字母大写,Rust 提供了 pub
关键字,通过它你可以控制模块和模块中指定项的可见性。
由于之前的解释,我们知道了只需要将 hosting
模块标记为对外可见即可:
--#![allow(unused)] -fn main() { -mod front_of_house { - pub mod hosting { - fn add_to_waitlist() {} - } -} - -/*--- snip ----*/ -} -
但是不幸的是,又报错了:
-error[E0603]: function `add_to_waitlist` is private
- --> src/lib.rs:12:30
- |
-12 | front_of_house::hosting::add_to_waitlist();
- | ^^^^^^^^^^^^^^^ private function
-
-哦?难道模块可见还不够,还需要将函数 add_to_waitlist
标记为可见的吗? 是的,没错,模块可见性不代表模块内部项的可见性,模块的可见性仅仅是允许其它模块去引用它,但是想要引用它内部的项,还得继续将对应的项标记为 pub
。
在实际项目中,一个模块需要对外暴露的数据和 API 往往就寥寥数个,如果将模块标记为可见代表着内部项也全部对外可见,那你是不是还得把那些不可见的,一个一个标记为 private
?反而是更麻烦的多。
既然知道了如何解决,那么我们为函数也标记上 pub
:
--#![allow(unused)] -fn main() { -mod front_of_house { - pub mod hosting { - pub fn add_to_waitlist() {} - } -} - -/*--- snip ----*/ -} -
Bang,顺利通过编译,感觉自己又变强了。
-super
引用模块在用路径引用模块中,我们提到了相对路径有三种方式开始:self
、super
和 crate
或者模块名,其中第三种在前面已经讲到过,现在来看看通过 super
的方式引用模块项。
super
代表的是父模块为开始的引用方式,非常类似于文件系统中的 ..
语法:../a/b
-文件名:src/lib.rs
--#![allow(unused)] -fn main() { -fn serve_order() {} - -// 厨房模块 -mod back_of_house { - fn fix_incorrect_order() { - cook_order(); - super::serve_order(); - } - - fn cook_order() {} -} -} -
嗯,我们的小餐馆又完善了,终于有厨房了!看来第一个客人也快可以有了。。。在厨房模块中,使用 super::serve_order
语法,调用了父模块(包根)中的 serve_order
函数。
那么你可能会问,为何不使用 crate::serve_order
的方式?额,其实也可以,不过如果你确定未来这种层级关系不会改变,那么 super::serve_order
的方式会更稳定,未来就算它们都不在包根了,依然无需修改引用路径。所以路径的选用,往往还是取决于场景,以及未来代码的可能走向。
self
引用模块self
其实就是引用自身模块中的项,也就是说和我们之前章节的代码类似,都调用同一模块中的内容,区别在于之前章节中直接通过名称调用即可,而 self
,你得多此一举:
--#![allow(unused)] -fn main() { -fn serve_order() { - self::back_of_house::cook_order() -} - -mod back_of_house { - fn fix_incorrect_order() { - cook_order(); - crate::serve_order(); - } - - pub fn cook_order() {} -} -} -
是的,多此一举,因为完全可以直接调用 back_of_house
,但是 self
还有一个大用处,在下一节中我们会讲。
为何要把结构体和枚举的可见性单独拎出来讲呢?因为这两个家伙的成员字段拥有完全不同的可见性:
-pub
,但它的所有字段依然是私有的pub
,它的所有字段也将对外可见原因在于,枚举和结构体的使用方式不一样。如果枚举的成员对外不可见,那该枚举将一点用都没有,因此枚举成员的可见性自动跟枚举可见性保持一致,这样可以简化用户的使用。
-而结构体的应用场景比较复杂,其中的字段也往往部分在 A 处被使用,部分在 B 处被使用,因此无法确定成员的可见性,那索性就设置为全部不可见,将选择权交给程序员。
-在之前的例子中,我们所有的模块都定义在 src/lib.rs
中,但是当模块变多或者变大时,需要将模块放入一个单独的文件中,让代码更好维护。
现在,把 front_of_house
前厅分离出来,放入一个单独的文件中 src/front_of_house.rs
:
--#![allow(unused)] -fn main() { -pub mod hosting { - pub fn add_to_waitlist() {} -} -} -
然后,将以下代码留在 src/lib.rs
中:
--#![allow(unused)] -fn main() { -mod front_of_house; - -pub use crate::front_of_house::hosting; - -pub fn eat_at_restaurant() { - hosting::add_to_waitlist(); - hosting::add_to_waitlist(); - hosting::add_to_waitlist(); -} -} -
so easy!其实跟之前在同一个文件中也没有太大的不同,但是有几点值得注意:
-mod front_of_house;
告诉 Rust 从另一个和模块 front_of_house
同名的文件中加载该模块的内容hosting
模块:crate::front_of_house::hosting;
需要注意的是,和之前代码中 mod front_of_house{..}
的完整模块不同,现在的代码中,模块的声明和实现是分离的,实现是在单独的 front_of_house.rs
文件中,然后通过 mod front_of_house;
这条声明语句从该文件中把模块内容加载进来。因此我们可以认为,模块 front_of_house
的定义还是在 src/lib.rs
中,只不过模块的具体内容被移动到了 src/front_of_house.rs
文件中。
在这里出现了一个新的关键字 use
,联想到其它章节我们见过的标准库引入 use std::fmt;
,可以大致猜测,该关键字用来将外部模块中的项引入到当前作用域中来,这样无需冗长的父模块前缀即可调用:hosting::add_to_waitlist();
,在下节中,我们将对 use
进行详细的讲解。
当一个模块有许多子模块时,我们也可以通过文件夹的方式来组织这些子模块。
-在上述例子中,我们可以创建一个目录 front_of_house
,然后在文件夹里创建一个 hosting.rs
文件,hosting.rs
文件现在就剩下:
--#![allow(unused)] -fn main() { -pub fn add_to_waitlist() {} -} -
现在,我们尝试编译程序,很遗憾,编译器报错:
-error[E0583]: file not found for module `front_of_house`
- --> src/lib.rs:3:1
- |
-1 | mod front_of_house;
- | ^^^^^^^^^^^^^^^^^^
- |
- = help: to create the module `front_of_house`, create file "src/front_of_house.rs" or "src/front_of_house/mod.rs"
-
-是的,如果需要将文件夹作为一个模块,我们需要进行显示指定暴露哪些子模块。按照上述的报错信息,我们有两种方法:
-front_of_house
目录里创建一个 mod.rs
,如果你使用的 rustc
版本 1.30
之前,这是唯一的方法。front_of_house
同级目录里创建一个与模块(目录)同名的 rs 文件 front_of_house.rs
,在新版本里,更建议使用这样的命名方式来避免项目中存在大量同名的 mod.rs
文件( Python 点了个 踩
)。而无论是上述哪个方式创建的文件,其内容都是一样的,你需要定义你的子模块(子模块名与文件名相同):
---#![allow(unused)] -fn main() { -pub mod hosting; -// pub mod serving; -} -
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
如果代码中,通篇都是 crate::front_of_house::hosting::add_to_waitlist
这样的函数调用形式,我不知道有谁会喜欢,也许靠代码行数赚工资的人会很喜欢,但是强迫症肯定受不了,悲伤的是程序员大多都有强迫症。。。
因此我们需要一个办法来简化这种使用方式,在 Rust 中,可以使用 use
关键字把路径提前引入到当前作用域中,随后的调用就可以省略该路径,极大地简化了代码。
在 Rust 中,引入模块中的项有两种方式:绝对路径和相对路径,这两者在前面章节都有讲过,就不再赘述,先来看看使用绝对路径的引入方式。
---#![allow(unused)] -fn main() { -mod front_of_house { - pub mod hosting { - pub fn add_to_waitlist() {} - } -} - -use crate::front_of_house::hosting; - -pub fn eat_at_restaurant() { - hosting::add_to_waitlist(); - hosting::add_to_waitlist(); - hosting::add_to_waitlist(); -} -} -
这里,我们使用 use
和绝对路径的方式,将 hosting
模块引入到当前作用域中,然后只需通过 hosting::add_to_waitlist
的方式,即可调用目标模块中的函数,相比 crate::front_of_house::hosting::add_to_waitlist()
的方式要简单的多,那么还能更简单吗?
在下面代码中,我们不仅要使用相对路径进行引入,而且与上面引入 hosting
模块不同,直接引入该模块中的 add_to_waitlist
函数:
--#![allow(unused)] -fn main() { -mod front_of_house { - pub mod hosting { - pub fn add_to_waitlist() {} - } -} - -use front_of_house::hosting::add_to_waitlist; - -pub fn eat_at_restaurant() { - add_to_waitlist(); - add_to_waitlist(); - add_to_waitlist(); -} -} -
很明显,三兄弟又变得更短了,不过,怎么觉得这句话怪怪的。。
-从使用简洁性来说,引入函数自然是更甚一筹,但是在某些时候,引入模块会更好:
-在以上两种情况中,使用 use front_of_house::hosting;
引入模块要比 use front_of_house::hosting::add_to_waitlist;
引入函数更好。
例如,如果想使用 HashMap
,那么直接引入该结构体是比引入模块更好的选择,因为在 collections
模块中,我们只需要使用一个 HashMap
结构体:
-use std::collections::HashMap; - -fn main() { - let mut map = HashMap::new(); - map.insert(1, 2); -} -
其实严格来说,对于引用方式并没有需要遵守的惯例,主要还是取决于你的喜好,不过我们建议:优先使用最细粒度(引入函数、结构体等)的引用方式,如果引起了某种麻烦(例如前面两种情况),再使用引入模块的方式。
-根据上一章节的内容,我们只要保证同一个模块中不存在同名项就行,模块之间、包之间的同名,谁管得着谁啊,话虽如此,一起看看,如果遇到同名的情况该如何处理。
---#![allow(unused)] -fn main() { -use std::fmt; -use std::io; - -fn function1() -> fmt::Result { - // --snip-- -} - -fn function2() -> io::Result<()> { - // --snip-- -} -} -
上面的例子给出了很好的解决方案,使用模块引入的方式,具体的 Result
通过 模块::Result
的方式进行调用。
可以看出,避免同名冲突的关键,就是使用父模块的方式来调用,除此之外,还可以给予引入的项起一个别名。
-as
别名引用对于同名冲突问题,还可以使用 as
关键字来解决,它可以赋予引入项一个全新的名称:
--#![allow(unused)] -fn main() { -use std::fmt::Result; -use std::io::Result as IoResult; - -fn function1() -> Result { - // --snip-- -} - -fn function2() -> IoResult<()> { - // --snip-- -} -} -
如上所示,首先通过 use std::io::Result
将 Result
引入到作用域,然后使用 as
给予它一个全新的名称 IoResult
,这样就不会再产生冲突:
Result
代表 std::fmt::Result
IoResult
代表 std:io::Result
当外部的模块项 A
被引入到当前模块中时,它的可见性自动被设置为私有的,如果你希望允许其它外部代码引用我们的模块项 A
,那么可以对它进行再导出:
--#![allow(unused)] -fn main() { -mod front_of_house { - pub mod hosting { - pub fn add_to_waitlist() {} - } -} - -pub use crate::front_of_house::hosting; - -pub fn eat_at_restaurant() { - hosting::add_to_waitlist(); - hosting::add_to_waitlist(); - hosting::add_to_waitlist(); -} -} -
如上,使用 pub use
即可实现。这里 use
代表引入 hosting
模块到当前作用域,pub
表示将该引入的内容再度设置为可见。
当你希望将内部的实现细节隐藏起来或者按照某个目的组织代码时,可以使用 pub use
再导出,例如统一使用一个模块来提供对外的 API,那该模块就可以引入其它模块中的 API,然后进行再导出,最终对于用户来说,所有的 API 都是由一个模块统一提供的。
之前我们一直在引入标准库模块或者自定义模块,现在来引入下第三方包中的模块,关于如何引入外部依赖,我们在 Cargo 入门中就有讲,这里直接给出操作步骤:
-Cargo.toml
文件,在 [dependencies]
区域添加一行:rand = "0.8.3"
VSCode
和 rust-analyzer
插件,该插件会自动拉取该库,你可能需要等它完成后,再进行下一步(VSCode 左下角有提示)好了,此时,rand
包已经被我们添加到依赖中,下一步就是在代码中使用:
-use rand::Rng; - -fn main() { - let secret_number = rand::thread_rng().gen_range(1..101); -} -
这里使用 use
引入了第三方包 rand
中的 Rng
特征,因为我们需要调用的 gen_range
方法定义在该特征中。
Rust 社区已经为我们贡献了大量高质量的第三方包,你可以在 crates.io
或者 lib.rs
中检索和使用,从目前来说查找包更推荐 lib.rs
,搜索功能更强大,内容展示也更加合理,但是下载依赖包还是得用crates.io
。
你可以在网站上搜索 rand
包,看看它的文档使用方式是否和我们之前引入方式相一致:在网上找到想要的包,然后将你想要的包和版本信息写入到 Cargo.toml
中。
{}
简化引入方式对于以下一行一行的引入方式:
---#![allow(unused)] -fn main() { -use std::collections::HashMap; -use std::collections::BTreeMap; -use std::collections::HashSet; - -use std::cmp::Ordering; -use std::io; -} -
可以使用 {}
来一起引入进来,在大型项目中,使用这种方式来引入,可以减少大量 use
的使用:
--#![allow(unused)] -fn main() { -use std::collections::{HashMap,BTreeMap,HashSet}; -use std::{cmp::Ordering, io}; -} -
对于下面的同时引入模块和模块中的项:
---#![allow(unused)] -fn main() { -use std::io; -use std::io::Write; -} -
可以使用 {}
的方式进行简化:
--#![allow(unused)] -fn main() { -use std::io::{self, Write}; -} -
上面使用到了模块章节提到的 self
关键字,用来替代模块自身,结合上一节中的 self
,可以得出它在模块中的两个用途:
use self::xxx
,表示加载当前模块中的 xxx
。此时 self
可省略use xxx::{self, yyy}
,表示,加载当前路径下模块 xxx
本身,以及模块 xxx
下的 yyy
*
引入模块下的所有项对于之前一行一行引入 std::collections
的方式,我们还可以使用
--#![allow(unused)] -fn main() { -use std::collections::*; -} -
以上这种方式来引入 std::collections
模块下的所有公共项,这些公共项自然包含了 HashMap
,HashSet
等想手动引入的集合类型。
当使用 *
来引入的时候要格外小心,因为你很难知道到底哪些被引入到了当前作用域中,有哪些会和你自己程序中的名称相冲突:
-use std::collections::*; - -struct HashMap; -fn main() { - let mut v = HashMap::new(); - v.insert("a", 1); -} -
以上代码中,std::collection::HashMap
被 *
引入到当前作用域,但是由于存在另一个同名的结构体,因此 HashMap::new
根本不存在,因为对于编译器来说,本地同名类型的优先级更高。
在实际项目中,这种引用方式往往用于快速写测试代码,它可以把所有东西一次性引入到 tests
模块中。
在上一节中,我们学习了可见性这个概念,这也是模块体系中最为核心的概念,控制了模块中哪些内容可以被外部看见,但是在实际使用时,光被外面看到还不行,我们还想控制哪些人能看,这就是 Rust 提供的受限可见性。
-例如,在 Rust 中,包是一个模块树,我们可以通过 pub(crate) item;
这种方式来实现:item
虽然是对外可见的,但是只在当前包内可见,外部包无法引用到该 item
。
所以,如果我们想要让某一项可以在整个包中都可以被使用,那么有两种办法:
-pub
类型的 X
(父模块的项对子模块都是可见的,因此包根中的项对模块树上的所有模块都可见)pub
类型的 Y
,同时通过 use
将其引入到包根--#![allow(unused)] -fn main() { -mod a { - pub mod b { - pub fn c() { - println!("{:?}",crate::X); - } - - #[derive(Debug)] - pub struct Y; - } -} - -#[derive(Debug)] -struct X; -use a::b::Y; -fn d() { - println!("{:?}",Y); -} -} -
以上代码充分说明了之前两种办法的使用方式,但是有时我们会遇到这两种方法都不太好用的时候。例如希望对于某些特定的模块可见,但是对于其他模块又不可见:
---#![allow(unused)] -fn main() { -// 目标:`a` 导出 `I`、`bar` and `foo`,其他的不导出 -pub mod a { - pub const I: i32 = 3; - - fn semisecret(x: i32) -> i32 { - use self::b::c::J; - x + J - } - - pub fn bar(z: i32) -> i32 { - semisecret(I) * z - } - pub fn foo(y: i32) -> i32 { - semisecret(I) + y - } - - mod b { - mod c { - const J: i32 = 4; - } - } -} -} -
这段代码会报错,因为与父模块中的项对子模块可见相反,子模块中的项对父模块是不可见的。这里 semisecret
方法中,a
-> b
-> c
形成了父子模块链,那 c
中的 J
自然对 a
模块不可见。
如果使用之前的可见性方式,那么想保持 J
私有,同时让 a
继续使用 semisecret
函数的办法是将该函数移动到 c
模块中,然后用 pub use
将 semisecret
函数进行再导出:
--#![allow(unused)] -fn main() { -pub mod a { - pub const I: i32 = 3; - - use self::b::semisecret; - - pub fn bar(z: i32) -> i32 { - semisecret(I) * z - } - pub fn foo(y: i32) -> i32 { - semisecret(I) + y - } - - mod b { - pub use self::c::semisecret; - mod c { - const J: i32 = 4; - pub fn semisecret(x: i32) -> i32 { - x + J - } - } - } -} -} -
这段代码说实话问题不大,但是有些破坏了我们之前的逻辑,如果想保持代码逻辑,同时又只让 J
在 a
内可见该怎么办?
--#![allow(unused)] -fn main() { -pub mod a { - pub const I: i32 = 3; - - fn semisecret(x: i32) -> i32 { - use self::b::c::J; - x + J - } - - pub fn bar(z: i32) -> i32 { - semisecret(I) * z - } - pub fn foo(y: i32) -> i32 { - semisecret(I) + y - } - - mod b { - pub(in crate::a) mod c { - pub(in crate::a) const J: i32 = 4; - } - } -} -} -
通过 pub(in crate::a)
的方式,我们指定了模块 c
和常量 J
的可见范围都只是 a
模块中,a
之外的模块是完全访问不到它们的。
pub(crate)
或 pub(in crate::a)
就是限制可见性语法,前者是限制在整个包内可见,后者是通过绝对路径,限制在包内的某个模块内可见,总结一下:
pub
意味着可见性无任何限制pub(crate)
表示在当前包可见pub(self)
在当前模块可见pub(super)
在父模块可见pub(in <path>)
表示在某个路径代表的模块中可见,其中 path
必须是父模块或者祖先模块-// 一个名为 `my_mod` 的模块 -mod my_mod { - // 模块中的项默认具有私有的可见性 - fn private_function() { - println!("called `my_mod::private_function()`"); - } - - // 使用 `pub` 修饰语来改变默认可见性。 - pub fn function() { - println!("called `my_mod::function()`"); - } - - // 在同一模块中,项可以访问其它项,即使它是私有的。 - pub fn indirect_access() { - print!("called `my_mod::indirect_access()`, that\n> "); - private_function(); - } - - // 模块也可以嵌套 - pub mod nested { - pub fn function() { - println!("called `my_mod::nested::function()`"); - } - - #[allow(dead_code)] - fn private_function() { - println!("called `my_mod::nested::private_function()`"); - } - - // 使用 `pub(in path)` 语法定义的函数只在给定的路径中可见。 - // `path` 必须是父模块(parent module)或祖先模块(ancestor module) - pub(in crate::my_mod) fn public_function_in_my_mod() { - print!("called `my_mod::nested::public_function_in_my_mod()`, that\n > "); - public_function_in_nested() - } - - // 使用 `pub(self)` 语法定义的函数则只在当前模块中可见。 - pub(self) fn public_function_in_nested() { - println!("called `my_mod::nested::public_function_in_nested"); - } - - // 使用 `pub(super)` 语法定义的函数只在父模块中可见。 - pub(super) fn public_function_in_super_mod() { - println!("called my_mod::nested::public_function_in_super_mod"); - } - } - - pub fn call_public_function_in_my_mod() { - print!("called `my_mod::call_public_funcion_in_my_mod()`, that\n> "); - nested::public_function_in_my_mod(); - print!("> "); - nested::public_function_in_super_mod(); - } - - // `pub(crate)` 使得函数只在当前包中可见 - pub(crate) fn public_function_in_crate() { - println!("called `my_mod::public_function_in_crate()"); - } - - // 嵌套模块的可见性遵循相同的规则 - mod private_nested { - #[allow(dead_code)] - pub fn function() { - println!("called `my_mod::private_nested::function()`"); - } - } -} - -fn function() { - println!("called `function()`"); -} - -fn main() { - // 模块机制消除了相同名字的项之间的歧义。 - function(); - my_mod::function(); - - // 公有项,包括嵌套模块内的,都可以在父模块外部访问。 - my_mod::indirect_access(); - my_mod::nested::function(); - my_mod::call_public_function_in_my_mod(); - - // pub(crate) 项可以在同一个 crate 中的任何地方访问 - my_mod::public_function_in_crate(); - - // pub(in path) 项只能在指定的模块中访问 - // 报错!函数 `public_function_in_my_mod` 是私有的 - //my_mod::nested::public_function_in_my_mod(); - // 试一试 ^ 取消该行的注释 - - // 模块的私有项不能直接访问,即便它是嵌套在公有模块内部的 - - // 报错!`private_function` 是私有的 - //my_mod::private_function(); - // 试一试 ^ 取消此行注释 - - // 报错!`private_function` 是私有的 - //my_mod::nested::private_function(); - // 试一试 ^ 取消此行的注释 - - // 报错! `private_nested` 是私有的 - //my_mod::private_nested::function(); - // 试一试 ^ 取消此行的注释 -} -
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
80 后应该都对学校的小混混记忆犹新,在那个时代,小混混们往往都认为自己是地下王者,管控着地下事务的流程,在我看来,他们就像代码中的流程控制一样,无处不在,很显眼,但是又让人懒得重视。
-言归正传,Rust 程序是从上而下顺序执行的,在此过程中,我们可以通过循环、分支等流程控制方式,更好的实现相应的功能。
---if else 无处不在 -- 鲁迅
-
但凡你能找到一门编程语言没有 if else
,那么一定更要反馈给鲁迅,反正不是我说的:) 总之,只要你拥有其它语言的编程经验,就一定会有以下认知:if else
表达式根据条件执行不同的代码分支:
--#![allow(unused)] -fn main() { -if condition == true { - // A... -} else { - // B... -} -} -
该代码读作:若 condition
的值为 true
,则执行 A
代码,否则执行 B
代码。
先看下面代码:
--fn main() { - let condition = true; - let number = if condition { - 5 - } else { - 6 - }; - - println!("The value of number is: {}", number); -} -
以上代码有以下几点要注意:
-if
语句块是表达式,这里我们使用 if
表达式的返回值来给 number
进行赋值:number
的值是 5
if
来赋值时,要保证每个分支返回的类型一样(事实上,这种说法不完全准确,见这里),此处返回的 5
和 6
就是同一个类型,如果返回类型不一致就会报错error[E0308]: if and else have incompatible types
- --> src/main.rs:4:18
- |
-4 | let number = if condition {
- | __________________^
-5 | | 5
-6 | | } else {
-7 | | "six"
-8 | | };
- | |_____^ expected integer, found &str // 期望整数类型,但却发现&str字符串切片
- |
- = note: expected type `{integer}`
- found type `&str`
-
-可以将 else if
与 if
、else
组合在一起实现更复杂的条件分支判断:
-fn main() { - let n = 6; - - if n % 4 == 0 { - println!("number is divisible by 4"); - } else if n % 3 == 0 { - println!("number is divisible by 3"); - } else if n % 2 == 0 { - println!("number is divisible by 2"); - } else { - println!("number is not divisible by 4, 3, or 2"); - } -} -
程序执行时,会按照自上至下的顺序执行每一个分支判断,一旦成功,则跳出 if
语句块,最终本程序会匹配执行 else if n % 3 == 0
的分支,输出 "number is divisible by 3"
。
有一点要注意,就算有多个分支能匹配,也只有第一个匹配的分支会被执行!
-如果代码中有大量的 else if
会让代码变得极其丑陋,不过不用担心,下一章的 match
专门用以解决多分支模式匹配的问题。
循环无处不在,上到数钱,下到数年,你能想象的很多场景都存在循环,因此它也是流程控制中最重要的组成部分之一。
-在 Rust 语言中有三种循环方式:for
、while
和 loop
,其中 for
循环是 Rust 循环王冠上的明珠。
for
循环是 Rust 的大杀器:
-fn main() { - for i in 1..=5 { - println!("{}", i); - } -} -
以上代码循环输出一个从 1 到 5 的序列,简单粗暴,核心就在于 for
和 in
的联动,语义表达如下:
--#![allow(unused)] -fn main() { -for 元素 in 集合 { - // 使用元素干一些你懂我不懂的事情 -} -} -
这个语法跟 JavaScript 还蛮像,应该挺好理解。
-注意,使用 for
时我们往往使用集合的引用形式,除非你不想在后面的代码中继续使用该集合(比如我们这里使用了 container
的引用)。如果不使用引用的话,所有权会被转移(move)到 for
语句块中,后面就无法再使用这个集合了):
--#![allow(unused)] -fn main() { -for item in &container { - // ... -} -} -
--对于实现了
-copy
特征的数组(例如 [i32; 10] )而言,for item in arr
并不会把arr
的所有权转移,而是直接对其进行了拷贝,因此循环之后仍然可以使用arr
。
如果想在循环中,修改该元素,可以使用 mut
关键字:
--#![allow(unused)] -fn main() { -for item in &mut collection { - // ... -} -} -
总结如下:
-使用方法 | 等价使用方式 | 所有权 |
---|---|---|
for item in collection | for item in IntoIterator::into_iter(collection) | 转移所有权 |
for item in &collection | for item in collection.iter() | 不可变借用 |
for item in &mut collection | for item in collection.iter_mut() | 可变借用 |
如果想在循环中获取元素的索引:
--fn main() { - let a = [4, 3, 2, 1]; - // `.iter()` 方法把 `a` 数组变成一个迭代器 - for (i, v) in a.iter().enumerate() { - println!("第{}个元素是{}", i + 1, v); - } -} -
有同学可能会想到,如果我们想用 for
循环控制某个过程执行 10 次,但是又不想单独声明一个变量来控制这个流程,该怎么写?
--#![allow(unused)] -fn main() { -for _ in 0..10 { - // ... -} -} -
可以用 _
来替代 i
用于 for
循环中,在 Rust 中 _
的含义是忽略该值或者类型的意思,如果不使用 _
,那么编译器会给你一个 变量未使用的
的警告。
两种循环方式优劣对比
-以下代码,使用了两种循环方式:
---#![allow(unused)] -fn main() { -// 第一种 -let collection = [1, 2, 3, 4, 5]; -for i in 0..collection.len() { - let item = collection[i]; - // ... -} - -// 第二种 -for item in collection { - -} -} -
第一种方式是循环索引,然后通过索引下标去访问集合,第二种方式是直接循环集合中的元素,优劣如下:
-collection[index]
的索引访问,会因为边界检查(Bounds Checking)导致运行时的性能损耗 —— Rust 会检查并确认 index
是否落在集合内,但是第二种直接迭代的方式就不会触发这种检查,因为编译器会在编译时就完成分析并证明这种访问是合法的collection
的索引访问是非连续的,存在一定可能性在两次访问之间,collection
发生了变化,导致脏数据产生。而第二种直接迭代的方式是连续访问,因此不存在这种风险( 由于所有权限制,在访问过程中,数据并不会发生变化)。由于 for
循环无需任何条件限制,也不需要通过索引来访问,因此是最安全也是最常用的,通过与下面的 while
的对比,我们能看到为什么 for
会更加安全。
continue
使用 continue
可以跳过当前当次的循环,开始下次的循环:
--#![allow(unused)] -fn main() { - for i in 1..4 { - if i == 2 { - continue; - } - println!("{}", i); - } -} -
上面代码对 1 到 3 的序列进行迭代,且跳过值为 2 时的循环,输出如下:
-1
-3
-
-break
使用 break
可以直接跳出当前整个循环:
--#![allow(unused)] -fn main() { - for i in 1..4 { - if i == 2 { - break; - } - println!("{}", i); - } -} -
上面代码对 1 到 3 的序列进行迭代,在遇到值为 2 时的跳出整个循环,后面的循环不再执行,输出如下:
-1
-
-如果你需要一个条件来循环,当该条件为 true
时,继续循环,条件为 false
,跳出循环,那么 while
就非常适用:
-fn main() { - let mut n = 0; - - while n <= 5 { - println!("{}!", n); - - n = n + 1; - } - - println!("我出来了!"); -} -
该 while
循环,只有当 n
小于等于 5
时,才执行,否则就立刻跳出循环,因此在上述代码中,它会先从 0
开始,满足条件,进行循环,然后是 1
,满足条件,进行循环,最终到 6
的时候,大于 5,不满足条件,跳出 while
循环,执行 我出来了
的打印,然后程序结束:
0!
-1!
-2!
-3!
-4!
-5!
-我出来了!
-
-当然,你也可以用其它方式组合实现,例如 loop
(无条件循环,将在下面介绍) + if
+ break
:
-fn main() { - let mut n = 0; - - loop { - if n > 5 { - break - } - println!("{}", n); - n+=1; - } - - println!("我出来了!"); -} -
可以看出,在这种循环场景下,while
要简洁的多。
while vs for
-我们也能用 while
来实现 for
的功能:
-fn main() { - let a = [10, 20, 30, 40, 50]; - let mut index = 0; - - while index < 5 { - println!("the value is: {}", a[index]); - - index = index + 1; - } -} -
这里,代码对数组中的元素进行计数。它从索引 0
开始,并接着循环直到遇到数组的最后一个索引(这时,index < 5
不再为真)。运行这段代码会打印出数组中的每一个元素:
the value is: 10
-the value is: 20
-the value is: 30
-the value is: 40
-the value is: 50
-
-数组中的所有五个元素都如期被打印出来。尽管 index
在某一时刻会到达值 5,不过循环在其尝试从数组获取第六个值(会越界)之前就停止了。
但这个过程很容易出错;如果索引长度不正确会导致程序 panic。这也使程序更慢,因为编译器增加了运行时代码来对每次循环的每个元素进行条件检查。
-for
循环代码如下:
-fn main() { - let a = [10, 20, 30, 40, 50]; - - for element in a.iter() { - println!("the value is: {}", element); - } -} -
可以看出,for
并不会使用索引去访问数组,因此更安全也更简洁,同时避免 运行时的边界检查
,性能更高。
对于循环而言,loop
循环毋庸置疑,是适用面最高的,它可以适用于所有循环场景(虽然能用,但是在很多场景下, for
和 while
才是最优选择),因为 loop
就是一个简单的无限循环,你可以在内部实现逻辑通过 break
关键字来控制循环何时结束。
使用 loop
循环一定要打起精神,否则你会写出下面的跑满你一个 CPU 核心的疯子代码:
fn main() {
- loop {
- println!("again!");
- }
-}
-
-该循环会不停的在终端打印输出,直到你使用 Ctrl-C
结束程序:
again!
-again!
-again!
-again!
-^Cagain!
-
-注意,不要轻易尝试上述代码,如果你电脑配置不行,可能会死机!!!
-因此,当使用 loop
时,必不可少的伙伴是 break
关键字,它能让循环在满足某个条件时跳出:
-fn main() { - let mut counter = 0; - - let result = loop { - counter += 1; - - if counter == 10 { - break counter * 2; - } - }; - - println!("The result is {}", result); -} -
以上代码当 counter
递增到 10
时,就会通过 break
返回一个 counter * 2
的值,最后赋给 result
并打印出来。
这里有几点值得注意:
-return
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
提到格式化输出,可能很多人立刻就想到 "{}"
,但是 Rust 能做到的远比这个多的多,本章节我们将深入讲解格式化输出的各个方面。
先来一段代码,看看格式化输出的初印象:
---#![allow(unused)] -fn main() { -println!("Hello"); // => "Hello" -println!("Hello, {}!", "world"); // => "Hello, world!" -println!("The number is {}", 1); // => "The number is 1" -println!("{:?}", (3, 4)); // => "(3, 4)" -println!("{value}", value=4); // => "4" -println!("{} {}", 1, 2); // => "1 2" -println!("{:04}", 42); // => "0042" with leading zeros -} -
可以看到 println!
宏接受的是可变参数,第一个参数是一个字符串常量,它表示最终输出字符串的格式,包含其中形如 {}
的符号是占位符,会被 println!
后面的参数依次替换。
print!
,println!
,format!
它们是 Rust 中用来格式化输出的三大金刚,用途如下:
-print!
将格式化文本输出到标准输出,不带换行符println!
同上,但是在行的末尾添加换行符format!
将格式化文本输出到 String
字符串在实际项目中,最常用的是 println!
及 format!
,前者常用来调试输出,后者常用来生成格式化的字符串:
-fn main() { - let s = "hello"; - println!("{}, world", s); - let s1 = format!("{}, world", s); - print!("{}", s1); - print!("{}\n", "!"); -} -
其中,s1
是通过 format!
生成的 String
字符串,最终输出如下:
hello, world
-hello, world!
-
-eprint!
,eprintln!
除了三大金刚外,还有两大护法,使用方式跟 print!
,println!
很像,但是它们输出到标准错误输出:
--#![allow(unused)] -fn main() { -eprintln!("Error: Could not complete task") -} -
它们仅应该被用于输出错误信息和进度信息,其它场景都应该使用 print!
系列。
与其它语言常用的 %d
,%s
不同,Rust 特立独行地选择了 {}
作为格式化占位符(说到这个,有点想吐槽下,Rust 中自创的概念其实还挺多的,真不知道该夸奖还是该吐槽-,-),事实证明,这种选择非常正确,它帮助用户减少了很多使用成本,你无需再为特定的类型选择特定的占位符,统一用 {}
来替代即可,剩下的类型推导等细节只要交给 Rust 去做。
与 {}
类似,{:?}
也是占位符:
{}
适用于实现了 std::fmt::Display
特征的类型,用来以更优雅、更友好的方式格式化文本,例如展示给用户{:?}
适用于实现了 std::fmt::Debug
特征的类型,用于调试场景其实两者的选择很简单,当你在写代码需要调试时,使用 {:?}
,剩下的场景,选择 {}
。
Debug
特征事实上,为了方便我们调试,大多数 Rust 类型都实现了 Debug
特征或者支持派生该特征:
-#[derive(Debug)] -struct Person { - name: String, - age: u8 -} - -fn main() { - let i = 3.1415926; - let s = String::from("hello"); - let v = vec![1, 2, 3]; - let p = Person{name: "sunface".to_string(), age: 18}; - println!("{:?}, {:?}, {:?}, {:?}", i, s, v, p); -} -
对于数值、字符串、数组,可以直接使用 {:?}
进行输出,但是对于结构体,需要派生Debug
特征后,才能进行输出,总之很简单。
Display
特征与大部分类型实现了 Debug
不同,实现了 Display
特征的 Rust 类型并没有那么多,往往需要我们自定义想要的格式化方式:
--#![allow(unused)] -fn main() { -let i = 3.1415926; -let s = String::from("hello"); -let v = vec![1, 2, 3]; -let p = Person { - name: "sunface".to_string(), - age: 18, -}; -println!("{}, {}, {}, {}", i, s, v, p); -} -
运行后可以看到 v
和 p
都无法通过编译,因为没有实现 Display
特征,但是你又不能像派生 Debug
一般派生 Display
,只能另寻他法:
{:?}
或 {:#?}
Display
特征newtype
为外部类型实现 Display
特征下面来一一看看这三种方式。
-{:#?}
与 {:?}
几乎一样,唯一的区别在于它能更优美地输出内容:
// {:?}
-[1, 2, 3], Person { name: "sunface", age: 18 }
-
-// {:#?}
-[
- 1,
- 2,
- 3,
-], Person {
- name: "sunface",
-}
-
-因此对于 Display
不支持的类型,可以考虑使用 {:#?}
进行格式化,虽然理论上它更适合进行调试输出。
Display
特征如果你的类型是定义在当前作用域中的,那么可以为其实现 Display
特征,即可用于格式化输出:
-struct Person { - name: String, - age: u8, -} - -use std::fmt; -impl fmt::Display for Person { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "大佬在上,请受我一拜,小弟姓名{},年芳{},家里无田又无车,生活苦哈哈", - self.name, self.age - ) - } -} -fn main() { - let p = Person { - name: "sunface".to_string(), - age: 18, - }; - println!("{}", p); -} -
如上所示,只要实现 Display
特征中的 fmt
方法,即可为自定义结构体 Person
添加自定义输出:
大佬在上,请受我一拜,小弟姓名sunface,年芳18,家里无田又无车,生活苦哈哈
-
-Display
特征在 Rust 中,无法直接为外部类型实现外部特征,但是可以使用newtype
解决此问题:
-struct Array(Vec<i32>); - -use std::fmt; -impl fmt::Display for Array { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "数组是:{:?}", self.0) - } -} -fn main() { - let arr = Array(vec![1, 2, 3]); - println!("{}", arr); -} -
Array
就是我们的 newtype
,它将想要格式化输出的 Vec
包裹在内,最后只要为 Array
实现 Display
特征,即可进行格式化输出:
数组是:[1, 2, 3]
-
-至此,关于 {}
与 {:?}
的内容已介绍完毕,下面让我们正式开始格式化输出的旅程。
除了按照依次顺序使用值去替换占位符之外,还能让指定位置的参数去替换某个占位符,例如 {1}
,表示用第二个参数替换该占位符(索引从 0 开始):
-fn main() { - println!("{}{}", 1, 2); // =>"12" - println!("{1}{0}", 1, 2); // =>"21" - // => Alice, this is Bob. Bob, this is Alice - println!("{0}, this is {1}. {1}, this is {0}", "Alice", "Bob"); - println!("{1}{}{0}{}", 1, 2); // => 2112 -} -
除了像上面那样指定位置外,我们还可以为参数指定名称:
--fn main() { - println!("{argument}", argument = "test"); // => "test" - println!("{name} {}", 1, name = 2); // => "2 1" - println!("{a} {c} {b}", a = "a", b = 'b', c = 3); // => "a 3 b" -} -
需要注意的是:带名称的参数必须放在不带名称参数的后面,例如下面代码将报错:
---#![allow(unused)] -fn main() { -println!("{abc} {1}", abc = "def", 2); -} -
--#![allow(unused)] -fn main() { -error: positional arguments cannot follow named arguments - --> src/main.rs:4:36 - | - 4 | println!("{abc} {1}", abc = "def", 2); - | ----- ^ positional arguments must be before named arguments - | | - | named argument -} -
格式化输出,意味着对输出格式会有更多的要求,例如只输出浮点数的小数点后两位:
--fn main() { - let v = 3.1415926; - // Display => 3.14 - println!("{:.2}", v); - // Debug => 3.14 - println!("{:.2?}", v); -} -
上面代码只输出小数点后两位。同时我们还展示了 {}
和 {:?}
的用法,后面如无特殊区别,就只针对 {}
提供格式化参数说明。
接下来,让我们一起来看看 Rust 中有哪些格式化参数。
-宽度用来指示输出目标的长度,如果长度不够,则进行填充和对齐:
-字符串格式化默认使用空格进行填充,并且进行左对齐。
--fn main() { - //----------------------------------- - // 以下全部输出 "Hello x !" - // 为"x"后面填充空格,补齐宽度5 - println!("Hello {:5}!", "x"); - // 使用参数5来指定宽度 - println!("Hello {:1$}!", "x", 5); - // 使用x作为占位符输出内容,同时使用5作为宽度 - println!("Hello {1:0$}!", 5, "x"); - // 使用有名称的参数作为宽度 - println!("Hello {:width$}!", "x", width = 5); - //----------------------------------- - - // 使用参数5为参数x指定宽度,同时在结尾输出参数5 => Hello x !5 - println!("Hello {:1$}!{}", "x", 5); -} -
数字格式化默认也是使用空格进行填充,但与字符串左对齐不同的是,数字是右对齐。
--fn main() { - // 宽度是5 => Hello 5! - println!("Hello {:5}!", 5); - // 显式的输出正号 => Hello +5! - println!("Hello {:+}!", 5); - // 宽度5,使用0进行填充 => Hello 00005! - println!("Hello {:05}!", 5); - // 负号也要占用一位宽度 => Hello -0005! - println!("Hello {:05}!", -5); -} -
-fn main() { - // 以下全部都会补齐5个字符的长度 - // 左对齐 => Hello x ! - println!("Hello {:<5}!", "x"); - // 右对齐 => Hello x! - println!("Hello {:>5}!", "x"); - // 居中对齐 => Hello x ! - println!("Hello {:^5}!", "x"); - - // 对齐并使用指定符号填充 => Hello x&&&&! - // 指定符号填充的前提条件是必须有对齐字符 - println!("Hello {:&<5}!", "x"); -} -
精度可以用于控制浮点数的精度或者字符串的长度
--fn main() { - let v = 3.1415926; - // 保留小数点后两位 => 3.14 - println!("{:.2}", v); - // 带符号保留小数点后两位 => +3.14 - println!("{:+.2}", v); - // 不带小数 => 3 - println!("{:.0}", v); - // 通过参数来设定精度 => 3.1416,相当于{:.4} - println!("{:.1$}", v, 4); - - let s = "hi我是Sunface孙飞"; - // 保留字符串前三个字符 => hi我 - println!("{:.3}", s); - // {:.*}接收两个参数,第一个是精度,第二个是被格式化的值 => Hello abc! - println!("Hello {:.*}!", 3, "abcdefg"); -} -
可以使用 #
号来控制数字的进制输出:
#b
, 二进制#o
, 八进制#x
, 小写十六进制#X
, 大写十六进制x
, 不带前缀的小写十六进制-fn main() { - // 二进制 => 0b11011! - println!("{:#b}!", 27); - // 八进制 => 0o33! - println!("{:#o}!", 27); - // 十进制 => 27! - println!("{}!", 27); - // 小写十六进制 => 0x1b! - println!("{:#x}!", 27); - // 大写十六进制 => 0x1B! - println!("{:#X}!", 27); - - // 不带前缀的十六进制 => 1b! - println!("{:x}!", 27); - - // 使用0填充二进制,宽度为10 => 0b00011011! - println!("{:#010b}!", 27); -} -
-fn main() { - println!("{:2e}", 1000000000); // => 1e9 - println!("{:2E}", 1000000000); // => 1E9 -} -
--#![allow(unused)] -fn main() { -let v= vec![1, 2, 3]; -println!("{:p}", v.as_ptr()) // => 0x600002324050 -} -
有时需要输出 {
和}
,但这两个字符是特殊字符,需要进行转义:
-fn main() { - // "{{" 转义为 '{' "}}" 转义为 '}' "\"" 转义为 '"' - // => Hello "{World}" - println!(" Hello \"{{World}}\" "); - - // 下面代码会报错,因为占位符{}只有一个右括号},左括号被转义成字符串的内容 - // println!(" {{ Hello } "); - // 也不可使用 '\' 来转义 "{}" - // println!(" \{ Hello \} ") -} -
在以前,想要输出一个函数的返回值,你需要这么做:
--fn get_person() -> String { - String::from("sunface") -} -fn main() { - let p = get_person(); - println!("Hello, {}!", p); // implicit position - println!("Hello, {0}!", p); // explicit index - println!("Hello, {person}!", person = p); -} -
问题倒也不大,但是一旦格式化字符串长了后,就会非常冗余,而在 1.58 后,我们可以这么写:
--fn get_person() -> String { - String::from("sunface") -} -fn main() { - let person = get_person(); - println!("Hello, {person}!"); -} -
是不是清晰、简洁了很多?甚至还可以将环境中的值用于格式化参数:
---#![allow(unused)] -fn main() { -let (width, precision) = get_format(); -for (name, score) in get_scores() { - println!("{name}: {score:width$.precision$}"); -} -} -
但也有局限,它只能捕获普通的变量,对于更复杂的类型(例如表达式),可以先将它赋值给一个变量或使用以前的 name = expression
形式的格式化参数。
-目前除了 panic!
外,其它接收格式化参数的宏,都可以使用新的特性。对于 panic!
而言,如果还在使用 2015版本
或 2018版本
,那 panic!("{ident}")
依然会被当成 正常的字符串来处理,同时编译器会给予 warn
提示。而对于 2021版本
,则可以正常使用:
-fn get_person() -> String { - String::from("sunface") -} -fn main() { - let person = get_person(); - panic!("Hello, {person}!"); -} -
输出:
-thread 'main' panicked at 'Hello, sunface!', src/main.rs:6:5
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
---Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
-
把这些格式化都牢记在脑中是不太现实的,也没必要,我们要做的就是知道 Rust 支持相应的格式化输出,在需要之时,读者再来查阅本文即可。
-还是那句话,<<Rust 语言圣经>>不仅仅是 Rust 学习书籍,还是一本厚重的工具书!
-至此,Rust 的基础内容学习已经全部完成,下面我们将学习 Rust 的高级进阶内容,正式开启你的高手之路。
- - -从现在开始,我们正式踏入了 Rust 大陆,这片广袤而神秘的世界,在这个世界中,将接触到很多之前都没有听过的概念:
-类似的还有很多,不过不用怕,引用武林外传一句话:咱上面有人。有本书在,一切虚妄终将烟消云散。
-本章主要介绍 Rust 的基础语法、数据类型、项目结构等,学完本章,你将对 Rust 代码有一个清晰、完整的认识。
-开始之前先通过一段代码来简单浏览下 Rust 的语法:
--// Rust 程序入口函数,跟其它语言一样,都是 main,该函数目前无返回值 -fn main() { - // 使用let来声明变量,进行绑定,a是不可变的 - // 此处没有指定a的类型,编译器会默认根据a的值为a推断类型:i32,有符号32位整数 - // 语句的末尾必须以分号结尾 - let a = 10; - // 主动指定b的类型为i32 - let b: i32 = 20; - // 这里有两点值得注意: - // 1. 可以在数值中带上类型:30i32表示数值是30,类型是i32 - // 2. c是可变的,mut是mutable的缩写 - let mut c = 30i32; - // 还能在数值和类型中间添加一个下划线,让可读性更好 - let d = 30_i32; - // 跟其它语言一样,可以使用一个函数的返回值来作为另一个函数的参数 - let e = add(add(a, b), add(c, d)); - - // println!是宏调用,看起来像是函数但是它返回的是宏定义的代码块 - // 该函数将指定的格式化字符串输出到标准输出中(控制台) - // {}是占位符,在具体执行过程中,会把e的值代入进来 - println!("( a + b ) + ( c + d ) = {}", e); -} - -// 定义一个函数,输入两个i32类型的32位有符号整数,返回它们的和 -fn add(i: i32, j: i32) -> i32 { - // 返回相加值,这里可以省略return - i + j -} -
--注意 -在上面的
-add
函数中,不要为i+j
添加;
,这会改变语法导致函数返回()
而不是i32
,具体参见语句和表达式。
有几点可以留意下:
-""
而不是单引号 ''
,Rust 中单引号是留给单个字符类型(char
)使用的{}
来作为格式化输出占位符,其它语言可能使用的是 %s
,%d
,%p
等,由于 println!
会自动推导出具体的类型,因此无需手动指定各位读者,之前的集合章节挺简单吧?是不是安逸了挺久了?要不咱们加点料?来试试 Rust 中令人闻风丧胆的生命周期?
-生命周期,简而言之就是引用的有效作用域。在大多数时候,我们无需手动的声明生命周期,因为编译器可以自动进行推导,用类型来类比下:
-Rust 生命周期之所以难,是因为这个概念对于我们来说是全新的,没有其它编程语言的经验可以借鉴。当你觉得难的时候,不用过于担心,这个难对于所有人都是平等的,多点付出就能早点解决此拦路虎,同时本书也会尽力帮助大家减少学习难度(生命周期很可能是 Rust 中最难的部分)。
-生命周期的主要作用是避免悬垂引用,它会导致程序引用了本不该引用的数据:
---#![allow(unused)] -fn main() { -{ - let r; - - { - let x = 5; - r = &x; - } - - println!("r: {}", r); -} -} -
这段代码有几点值得注意:
-let r;
的声明方式貌似存在使用 null
的风险,实际上,当我们不初始化它就使用时,编译器会给予报错r
引用了内部花括号中的 x
变量,但是 x
会在内部花括号 }
处被释放,因此回到外部花括号后,r
会引用一个无效的 x
此处 r
就是一个悬垂指针,它引用了提前被释放的变量 x
,可以预料到,这段代码会报错:
error[E0597]: `x` does not live long enough // `x` 活得不够久
- --> src/main.rs:7:17
- |
-7 | r = &x;
- | ^^ borrowed value does not live long enough // 被借用的 `x` 活得不够久
-8 | }
- | - `x` dropped here while still borrowed // `x` 在这里被丢弃,但是它依然还在被借用
-9 |
-10 | println!("r: {}", r);
- | - borrow later used here // 对 `x` 的借用在此处被使用
-
-在这里 r
拥有更大的作用域,或者说活得更久。如果 Rust 不阻止该悬垂引用的发生,那么当 x
被释放后,r
所引用的值就不再是合法的,会导致我们程序发生异常行为,且该异常行为有时候会很难被发现。
为了保证 Rust 的所有权和借用的正确性,Rust 使用了一个借用检查器(Borrow checker),来检查我们程序的借用正确性:
---#![allow(unused)] -fn main() { -{ - let r; // ---------+-- 'a - // | - { // | - let x = 5; // -+-- 'b | - r = &x; // | | - } // -+ | - // | - println!("r: {}", r); // | -} // ---------+ -} -
这段代码和之前的一模一样,唯一的区别在于增加了对变量生命周期的注释。这里,r
变量被赋予了生命周期 'a
,x
被赋予了生命周期 'b
,从图示上可以明显看出生命周期 'b
比 'a
小很多。
在编译期,Rust 会比较两个变量的生命周期,结果发现 r
明明拥有生命周期 'a
,但是却引用了一个小得多的生命周期 'b
,在这种情况下,编译器会认为我们的程序存在风险,因此拒绝运行。
如果想要编译通过,也很简单,只要 'b
比 'a
大就好。总之,x
变量只要比 r
活得久,那么 r
就能随意引用 x
且不会存在危险:
--#![allow(unused)] -fn main() { -{ - let x = 5; // ----------+-- 'b - // | - let r = &x; // --+-- 'a | - // | | - println!("r: {}", r); // | | - // --+ | -} // ----------+ -} -
根据之前的结论,我们重新实现了代码,现在 x
的生命周期 'b
大于 r
的生命周期 'a
,因此 r
对 x
的引用是安全的。
通过之前的内容,我们了解了何为生命周期,也了解了 Rust 如何利用生命周期来确保引用是合法的,下面来看看函数中的生命周期。
-先来考虑一个例子 - 返回两个字符串切片中较长的那个,该函数的参数是两个字符串切片,返回值也是字符串切片:
--fn main() { - let string1 = String::from("abcd"); - let string2 = "xyz"; - - let result = longest(string1.as_str(), string2); - println!("The longest string is {}", result); -} -
--#![allow(unused)] -fn main() { -fn longest(x: &str, y: &str) -> &str { - if x.len() > y.len() { - x - } else { - y - } -} -} -
这段 longest
实现,非常标准优美,就连多余的 return
和分号都没有,可是现实总是给我们重重一击:
error[E0106]: missing lifetime specifier
- --> src/main.rs:9:33
- |
-9 | fn longest(x: &str, y: &str) -> &str {
- | ---- ---- ^ expected named lifetime parameter // 参数需要一个生命周期
- |
- = help: this function's return type contains a borrowed value, but the signature does not say whether it is
- borrowed from `x` or `y`
- = 帮助: 该函数的返回值是一个引用类型,但是函数签名无法说明,该引用是借用自 `x` 还是 `y`
-help: consider introducing a named lifetime parameter // 考虑引入一个生命周期
- |
-9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
- | ^^^^ ^^^^^^^ ^^^^^^^ ^^^
-
-喔,这真是一个复杂的提示,那感觉就好像是生命周期去非诚勿扰相亲,结果在初印象环节就 23 盏灯全灭。等等,先别急,如果你仔细阅读,就会发现,其实主要是编译器无法知道该函数的返回值到底引用 x
还是 y
,因为编译器需要知道这些,来确保函数调用后的引用生命周期分析。
不过说来尴尬,就这个函数而言,我们也不知道返回值到底引用哪个,因为一个分支返回 x
,另一个分支返回 y
...这可咋办?先来分析下。
我们在定义该函数时,首先无法知道传递给函数的具体值,因此到底是 if
还是 else
被执行,无从得知。其次,传入引用的具体生命周期也无法知道,因此也不能像之前的例子那样通过分析生命周期来确定引用是否有效。同时,编译器的借用检查也无法推导出返回值的生命周期,因为它不知道 x
和 y
的生命周期跟返回值的生命周期之间的关系是怎样的(说实话,人都搞不清,何况编译器这个大聪明)。
因此,这时就回到了文章开头说的内容:在存在多个引用时,编译器有时会无法自动推导生命周期,此时就需要我们手动去标注,通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。
---生命周期标注并不会改变任何引用的实际作用域 -- 鲁迅
-
鲁迅说过的话,总是值得重点标注,当你未来更加理解生命周期时,你才会发现这句话的精髓和重要!现在先简单记住,标记的生命周期只是为了取悦编译器,让编译器不要难为我们,记住了吗?没记住,再回头看一遍,这对未来你遇到生命周期问题时会有很大的帮助!
-在很多时候编译器是很聪明的,但是总有些时候,它会化身大聪明,自以为什么都很懂,然后去拒绝我们代码的执行,此时,就需要我们通过生命周期标注来告诉这个大聪明:别自作聪明了,听我的就好。
-例如一个变量,只能活一个花括号,那么就算你给它标注一个活全局的生命周期,它还是会在前面的花括号结束处被释放掉,并不会真的全局存活。
-生命周期的语法也颇为与众不同,以 '
开头,名称往往是一个单独的小写字母,大多数人都用 'a
来作为生命周期的名称。 如果是引用类型的参数,那么生命周期会位于引用符号 &
之后,并用一个空格来将生命周期和引用参数分隔开:
--#![allow(unused)] -fn main() { -&i32 // 一个引用 -&'a i32 // 具有显式生命周期的引用 -&'a mut i32 // 具有显式生命周期的可变引用 -} -
一个生命周期标注,它自身并不具有什么意义,因为生命周期的作用就是告诉编译器多个引用之间的关系。例如,有一个函数,它的第一个参数 first
是一个指向 i32
类型的引用,具有生命周期 'a
,该函数还有另一个参数 second
,它也是指向 i32
类型的引用,并且同样具有生命周期 'a
。此处生命周期标注仅仅说明,这两个参数 first
和 second
至少活得和'a 一样久,至于到底活多久或者哪个活得更久,抱歉我们都无法得知:
--#![allow(unused)] -fn main() { -fn useless<'a>(first: &'a i32, second: &'a i32) {} -} -
继续之前的 longest
函数,从两个字符串切片中返回较长的那个:
--#![allow(unused)] -fn main() { -fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { - if x.len() > y.len() { - x - } else { - y - } -} -} -
需要注意的点如下:
-<'a>
x
、y
和返回值至少活得和 'a
一样久(因为返回值要么是 x
,要么是 y
)该函数签名表明对于某些生命周期 'a
,函数的两个参数都至少跟 'a
活得一样久,同时函数的返回引用也至少跟 'a
活得一样久。实际上,这意味着返回值的生命周期与参数生命周期中的较小值一致:虽然两个参数的生命周期都是标注了 'a
,但是实际上这两个参数的真实生命周期可能是不一样的(生命周期 'a
不代表生命周期等于 'a
,而是大于等于 'a
)。
回忆下“鲁迅”说的话,再参考上面的内容,可以得出:在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过。
-因此 longest
函数并不知道 x
和 y
具体会活多久,只要知道它们的作用域至少能持续 'a
这么长就行。
当把具体的引用传给 longest
时,那生命周期 'a
的大小就是 x
和 y
的作用域的重合部分,换句话说,'a
的大小将等于 x
和 y
中较小的那个。由于返回值的生命周期也被标记为 'a
,因此返回值的生命周期也是 x
和 y
中作用域较小的那个。
说实话,这段文字我写的都快崩溃了,不知道你们读起来如何,实在***太绕了。。那就干脆用一个例子来解释吧:
--fn main() { - let string1 = String::from("long string is long"); - - { - let string2 = String::from("xyz"); - let result = longest(string1.as_str(), string2.as_str()); - println!("The longest string is {}", result); - } -} -
在上例中,string1
的作用域直到 main
函数的结束,而 string2
的作用域到内部花括号的结束 }
,那么根据之前的理论,'a
是两者中作用域较小的那个,也就是 'a
的生命周期等于 string2
的生命周期,同理,由于函数返回的生命周期也是 'a
,可以得出函数返回的生命周期也等于 string2
的生命周期。
现在来验证下上面的结论:result
的生命周期等于参数中生命周期最小的,因此要等于 string2
的生命周期,也就是说,result
要活得和 string2
一样久,观察下代码的实现,可以发现这个结论是正确的!
因此,在这种情况下,通过生命周期标注,编译器得出了和我们肉眼观察一样的结论,而不再是一个蒙圈的大聪明。
-再来看一个例子,该例子证明了 result
的生命周期必须等于两个参数中生命周期较小的那个:
-fn main() { - let string1 = String::from("long string is long"); - let result; - { - let string2 = String::from("xyz"); - result = longest(string1.as_str(), string2.as_str()); - } - println!("The longest string is {}", result); -} -
Bang,错误冒头了:
-error[E0597]: `string2` does not live long enough
- --> src/main.rs:6:44
- |
-6 | result = longest(string1.as_str(), string2.as_str());
- | ^^^^^^^ borrowed value does not live long enough
-7 | }
- | - `string2` dropped here while still borrowed
-8 | println!("The longest string is {}", result);
- | ------ borrow later used here
-
-在上述代码中,result
必须要活到 println!
处,因为 result
的生命周期是 'a
,因此 'a
必须持续到 println!
。
在 longest
函数中,string2
的生命周期也是 'a
,由此说明 string2
也必须活到 println!
处,可是 string2
在代码中实际上只能活到内部语句块的花括号处 }
,小于它应该具备的生命周期 'a
,因此编译出错。
作为人类,我们可以很清晰的看出 result
实际上引用了 string1
,因为 string1
的长度明显要比 string2
长,既然如此,编译器不该如此矫情才对,它应该能认识到 result
没有引用 string2
,让我们这段代码通过。只能说,作为尊贵的人类,编译器的发明者,你高估了这个工具的能力,它真的做不到!而且 Rust 编译器在调教上是非常保守的:当可能出错也可能不出错时,它会选择前者,抛出编译错误。
总之,显式的使用生命周期,可以让编译器正确的认识到多个引用之间的关系,最终帮我们提前规避可能存在的代码风险。
-小练习:尝试着去更改 longest
函数,例如修改参数、生命周期或者返回值,然后推测结果如何,最后再跟编译器的输出进行印证。
使用生命周期的方式往往取决于函数的功能,例如之前的 longest
函数,如果它永远只返回第一个参数 x
,生命周期的标注该如何修改(该例子就是上面的小练习结果之一)?
--#![allow(unused)] -fn main() { -fn longest<'a>(x: &'a str, y: &str) -> &'a str { - x -} -} -
在此例中,y
完全没有被使用,因此 y
的生命周期与 x
和返回值的生命周期没有任何关系,意味着我们也不必再为 y
标注生命周期,只需要标注 x
参数和返回值即可。
函数的返回值如果是一个引用类型,那么它的生命周期只会来源于:
-若是后者情况,就是典型的悬垂引用场景:
---#![allow(unused)] -fn main() { -fn longest<'a>(x: &str, y: &str) -> &'a str { - let result = String::from("really long string"); - result.as_str() -} -} -
上面的函数的返回值就和参数 x
,y
没有任何关系,而是引用了函数体内创建的字符串,那么很显然,该函数会报错:
error[E0515]: cannot return value referencing local variable `result` // 返回值result引用了本地的变量
- --> src/main.rs:11:5
- |
-11 | result.as_str()
- | ------^^^^^^^^^
- | |
- | returns a value referencing data owned by the current function
- | `result` is borrowed here
-
-主要问题就在于,result
在函数结束后就被释放,但是在函数结束后,对 result
的引用依然在继续。在这种情况下,没有办法指定合适的生命周期来让编译通过,因此我们也就在 Rust 中避免了悬垂引用。
那遇到这种情况该怎么办?最好的办法就是返回内部字符串的所有权,然后把字符串的所有权转移给调用者:
--fn longest<'a>(_x: &str, _y: &str) -> String { - String::from("really long string") -} - -fn main() { - let s = longest("not", "important"); -} -
至此,可以对生命周期进行下总结:生命周期语法用来将函数的多个引用参数和返回值的作用域关联到一起,一旦关联到一起后,Rust 就拥有充分的信息来确保我们的操作是内存安全的。
-不仅仅函数具有生命周期,结构体其实也有这个概念,只不过我们之前对结构体的使用都停留在非引用类型字段上。细心的同学应该能回想起来,之前为什么不在结构体中使用字符串字面量或者字符串切片,而是统一使用 String
类型?原因很简单,后者在结构体初始化时,只要转移所有权即可,而前者,抱歉,它们是引用,它们不能为所欲为。
既然之前已经理解了生命周期,那么意味着在结构体中使用引用也变得可能:只要为结构体中的每一个引用标注上生命周期即可:
--struct ImportantExcerpt<'a> { - part: &'a str, -} - -fn main() { - let novel = String::from("Call me Ishmael. Some years ago..."); - let first_sentence = novel.split('.').next().expect("Could not find a '.'"); - let i = ImportantExcerpt { - part: first_sentence, - }; -} -
ImportantExcerpt
结构体中有一个引用类型的字段 part
,因此需要为它标注上生命周期。结构体的生命周期标注语法跟泛型参数语法很像,需要对生命周期参数进行声明 <'a>
。该生命周期标注说明,结构体 ImportantExcerpt
所引用的字符串 str
必须比该结构体活得更久。
从 main
函数实现来看,ImportantExcerpt
的生命周期从第 4 行开始,到 main
函数末尾结束,而该结构体引用的字符串从第一行开始,也是到 main
函数末尾结束,可以得出结论结构体引用的字符串活得比结构体久,这符合了编译器对生命周期的要求,因此编译通过。
与之相反,下面的代码就无法通过编译:
--#[derive(Debug)] -struct ImportantExcerpt<'a> { - part: &'a str, -} - -fn main() { - let i; - { - let novel = String::from("Call me Ishmael. Some years ago..."); - let first_sentence = novel.split('.').next().expect("Could not find a '.'"); - i = ImportantExcerpt { - part: first_sentence, - }; - } - println!("{:?}",i); -} -
观察代码,可以看出结构体比它引用的字符串活得更久,引用字符串在内部语句块末尾 }
被释放后,println!
依然在外面使用了该结构体,因此会导致无效的引用,不出所料,编译报错:
error[E0597]: `novel` does not live long enough
- --> src/main.rs:10:30
- |
-10 | let first_sentence = novel.split('.').next().expect("Could not find a '.'");
- | ^^^^^^^^^^^^^^^^ borrowed value does not live long enough
-...
-14 | }
- | - `novel` dropped here while still borrowed
-15 | println!("{:?}",i);
- | - borrow later used here
-
-实际上,对于编译器来说,每一个引用类型都有一个生命周期,那么为什么我们在使用过程中,很多时候无需标注生命周期?例如:
---#![allow(unused)] -fn main() { -fn first_word(s: &str) -> &str { - let bytes = s.as_bytes(); - - for (i, &item) in bytes.iter().enumerate() { - if item == b' ' { - return &s[0..i]; - } - } - - &s[..] -} -} -
该函数的参数和返回值都是引用类型,尽管我们没有显式的为其标注生命周期,编译依然可以通过。其实原因不复杂,编译器为了简化用户的使用,运用了生命周期消除大法。
-对于 first_word
函数,它的返回值是一个引用类型,那么该引用只有两种情况:
如果是后者,就会出现悬垂引用,最终被编译器拒绝,因此只剩一种情况:返回值的引用是获取自参数,这就意味着参数和返回值的生命周期是一样的。道理很简单,我们能看出来,编译器自然也能看出来,因此,就算我们不标注生命周期,也不会产生歧义。
-实际上,在 Rust 1.0 版本之前,这种代码果断不给通过,因为 Rust 要求必须显式的为所有引用标注生命周期:
---#![allow(unused)] -fn main() { -fn first_word<'a>(s: &'a str) -> &'a str { -} -
在写了大量的类似代码后,Rust 社区抱怨声四起,包括开发者自己都忍不了了,最终揭锅而起,这才有了我们今日的幸福。
-生命周期消除的规则不是一蹴而就,而是伴随着 总结-改善
流程的周而复始,一步一步走到今天,这也意味着,该规则以后可能也会进一步增加,我们需要手动标注生命周期的时候也会越来越少,hooray!
在开始之前有几点需要注意:
-输入生命周期
,返回值的生命周期被称为 输出生命周期
编译器使用三条消除规则来确定哪些场景不需要显式地去标注生命周期。其中第一条规则应用在输入生命周期上,第二、三条应用在输出生命周期上。若编译器发现三条规则都不适用时,就会报错,提示你需要手动标注生命周期。
-每一个引用参数都会获得独自的生命周期
-例如一个引用参数的函数就有一个生命周期标注: fn foo<'a>(x: &'a i32)
,两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
, 依此类推。
若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期
-例如函数 fn foo(x: &i32) -> &i32
,x
参数的生命周期会被自动赋给返回值 &i32
,因此该函数等同于 fn foo<'a>(x: &'a i32) -> &'a i32
若存在多个输入生命周期,且其中一个是 &self
或 &mut self
,则 &self
的生命周期被赋给所有的输出生命周期
拥有 &self
形式的参数,说明该函数是一个 方法
,该规则让方法的使用便利度大幅提升。
规则其实很好理解,但是,爱思考的读者肯定要发问了,例如第三条规则,若一个方法,它的返回值的生命周期就是跟参数 &self
的不一样怎么办?总不能强迫我返回的值总是和 &self
活得一样久吧?! 问得好,答案很简单:手动标注生命周期,因为这些规则只是编译器发现你没有标注生命周期时默认去使用的,当你标注生命周期后,编译器自然会乖乖听你的话。
让我们假装自己是编译器,然后看下以下的函数该如何应用这些规则:
-例子 1
---#![allow(unused)] -fn main() { -fn first_word(s: &str) -> &str { // 实际项目中的手写代码 -} -
首先,我们手写的代码如上所示时,编译器会先应用第一条规则,为每个参数标注一个生命周期:
---#![allow(unused)] -fn main() { -fn first_word<'a>(s: &'a str) -> &str { // 编译器自动为参数添加生命周期 -} -
此时,第二条规则就可以进行应用,因为函数只有一个输入生命周期,因此该生命周期会被赋予所有的输出生命周期:
---#![allow(unused)] -fn main() { -fn first_word<'a>(s: &'a str) -> &'a str { // 编译器自动为返回值添加生命周期 -} -
此时,编译器为函数签名中的所有引用都自动添加了具体的生命周期,因此编译通过,且用户无需手动去标注生命周期,只要按照 fn first_word(s: &str) -> &str {
的形式写代码即可。
例子 2 -再来看一个例子:
---#![allow(unused)] -fn main() { -fn longest(x: &str, y: &str) -> &str { // 实际项目中的手写代码 -} -
首先,编译器会应用第一条规则,为每个参数都标注生命周期:
---#![allow(unused)] -fn main() { -fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str { -} -
但是此时,第二条规则却无法被使用,因为输入生命周期有两个,第三条规则也不符合,因为它是函数,不是方法,因此没有 &self
参数。在套用所有规则后,编译器依然无法为返回值标注合适的生命周期,因此,编译器就会报错,提示我们需要手动标注生命周期:
error[E0106]: missing lifetime specifier
- --> src/main.rs:1:47
- |
-1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
- | ------- ------- ^ expected named lifetime parameter
- |
- = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
-note: these named lifetimes are available to use
- --> src/main.rs:1:12
- |
-1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
- | ^^ ^^
-help: consider using one of the available lifetimes here
- |
-1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &'lifetime str {
- | +++++++++
-
-不得不说,Rust 编译器真的很强大,还贴心的给我们提示了该如何修改,虽然。。。好像。。。。它的提示貌似不太准确。这里我们更希望参数和返回值都是 'a
生命周期。
先来回忆下泛型的语法:
---#![allow(unused)] -fn main() { -struct Point<T> { - x: T, - y: T, -} - -impl<T> Point<T> { - fn x(&self) -> &T { - &self.x - } -} -} -
实际上,为具有生命周期的结构体实现方法时,我们使用的语法跟泛型参数语法很相似:
---#![allow(unused)] -fn main() { -struct ImportantExcerpt<'a> { - part: &'a str, -} - -impl<'a> ImportantExcerpt<'a> { - fn level(&self) -> i32 { - 3 - } -} -} -
其中有几点需要注意的:
-impl
中必须使用结构体的完整名称,包括 <'a>
,因为生命周期标注也是结构体类型的一部分!下面的例子展示了第三规则应用的场景:
---#![allow(unused)] -fn main() { -impl<'a> ImportantExcerpt<'a> { - fn announce_and_return_part(&self, announcement: &str) -> &str { - println!("Attention please: {}", announcement); - self.part - } -} -} -
首先,编译器应用第一规则,给予每个输入参数一个生命周期:
---#![allow(unused)] -fn main() { -impl<'a> ImportantExcerpt<'a> { - fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &str { - println!("Attention please: {}", announcement); - self.part - } -} -} -
需要注意的是,编译器不知道 announcement
的生命周期到底多长,因此它无法简单的给予它生命周期 'a
,而是重新声明了一个全新的生命周期 'b
。
接着,编译器应用第三规则,将 &self
的生命周期赋给返回值 &str
:
--#![allow(unused)] -fn main() { -impl<'a> ImportantExcerpt<'a> { - fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str { - println!("Attention please: {}", announcement); - self.part - } -} -} -
Bingo,最开始的代码,尽管我们没有给方法标注生命周期,但是在第一和第三规则的配合下,编译器依然完美的为我们亮起了绿灯。
-在结束这块儿内容之前,再来做一个有趣的修改,将方法返回的生命周期改为'b
:
--#![allow(unused)] -fn main() { -impl<'a> ImportantExcerpt<'a> { - fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str { - println!("Attention please: {}", announcement); - self.part - } -} -} -
此时,编译器会报错,因为编译器无法知道 'a
和 'b
的关系。 &self
生命周期是 'a
,那么 self.part
的生命周期也是 'a
,但是好巧不巧的是,我们手动为返回值 self.part
标注了生命周期 'b
,因此编译器需要知道 'a
和 'b
的关系。
有一点很容易推理出来:由于 &'a self
是被引用的一方,因此引用它的 &'b str
必须要活得比它短,否则会出现悬垂引用。因此说明生命周期 'b
必须要比 'a
小,只要满足了这一点,编译器就不会再报错:
--#![allow(unused)] -fn main() { -impl<'a: 'b, 'b> ImportantExcerpt<'a> { - fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str { - println!("Attention please: {}", announcement); - self.part - } -} -} -
Bang,一个复杂的玩意儿被甩到了你面前,就问怕不怕?
-就关键点稍微解释下:
-'a: 'b
,是生命周期约束语法,跟泛型约束非常相似,用于说明 'a
必须比 'b
活得久'a
和 'b
都在同一个地方声明(如上),或者分开声明但通过 where 'a: 'b
约束生命周期关系,如下:--#![allow(unused)] -fn main() { -impl<'a> ImportantExcerpt<'a> { - fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str - where - 'a: 'b, - { - println!("Attention please: {}", announcement); - self.part - } -} -} -
总之,实现方法比想象中简单:加一个约束,就能暗示编译器,尽管引用吧,反正我想引用的内容比我活得久,爱咋咋地,我怎么都不会引用到无效的内容!
-在 Rust 中有一个非常特殊的生命周期,那就是 'static
,拥有该生命周期的引用可以和整个程序活得一样久。
在之前我们学过字符串字面量,提到过它是被硬编码进 Rust 的二进制文件中,因此这些字符串变量全部具有 'static
的生命周期:
--#![allow(unused)] -fn main() { -let s: &'static str = "我没啥优点,就是活得久,嘿嘿"; -} -
这时候,有些聪明的小脑瓜就开始开动了:当生命周期不知道怎么标时,对类型施加一个静态生命周期的约束 T: 'static
是不是很爽?这样我和编译器再也不用操心它到底活多久了。
嗯,只能说,这个想法是对的,在不少情况下,'static
约束确实可以解决生命周期编译不通过的问题,但是问题来了:本来该引用没有活那么久,但是你非要说它活那么久,万一引入了潜在的 BUG 怎么办?
因此,遇到因为生命周期导致的编译不通过问题,首先想的应该是:是否是我们试图创建一个悬垂引用,或者是试图匹配不一致的生命周期,而不是简单粗暴的用 'static
来解决问题。
但是,话说回来,存在即合理,有时候,'static
确实可以帮助我们解决非常复杂的生命周期问题甚至是无法被手动解决的生命周期问题,那么此时就应该放心大胆的用,只要你确定:你的所有引用的生命周期都是正确的,只是编译器太笨不懂罢了。
总结下:
-'static
意味着能和程序活得一样久,例如字符串字面量和特征对象T: 'static
,有时候它会给你奇迹--事实上,关于
-'static
, 有两种用法:&'static
和T: 'static
,详细内容请参见此处。
手指已经疲软无力,我好想停止,但是华丽的开场都要有与之匹配的谢幕,那我们就用一个稍微复杂点的例子来结束:
---#![allow(unused)] -fn main() { -use std::fmt::Display; - -fn longest_with_an_announcement<'a, T>( - x: &'a str, - y: &'a str, - ann: T, -) -> &'a str -where - T: Display, -{ - println!("Announcement! {}", ann); - if x.len() > y.len() { - x - } else { - y - } -} -} -
依然是熟悉的配方 longest
,但是多了一段废话: ann
,因为要用格式化 {}
来输出 ann
,因此需要它实现 Display
特征。
--Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
-
我不知道支撑我一口气写完的勇气是什么,也许是不做完不爽夫斯基,也许是一些读者对本书的期待,不管如何,这章足足写了 17000 字,可惜不是写小说,不然肯定可以获取很多月票 :)
-从本章开始,最大的收获就是可以在结构体中使用引用类型了,说实话,为了引入这个特性,我已经憋了足足 N 个章节……
-但是,还没完,是的,就算是将近两万字,生命周期的旅程依然没有完结,在本书的进阶部分,我们将介绍一些关于生命周期的高级特性,这些特性你在其它中文书中目前还看不到的。
- - -在本书中我们已领略过许多不同类型模式的例子,本节的目标就是把这些模式语法都罗列出来,方便大家检索查阅(模式匹配在我们的开发中会经常用到)。
---#![allow(unused)] -fn main() { -let x = 1; - -match x { - 1 => println!("one"), - 2 => println!("two"), - 3 => println!("three"), - _ => println!("anything"), -} -} -
这段代码会打印 one
因为 x
的值是 1,如果希望代码获得特定的具体值,那么这种语法很有用。
在 match 中,我们有讲过变量遮蔽的问题,这个在匹配命名变量时会遇到:
--fn main() { - let x = Some(5); - let y = 10; - - match x { - Some(50) => println!("Got 50"), - Some(y) => println!("Matched, y = {:?}", y), - _ => println!("Default case, x = {:?}", x), - } - - println!("at the end: x = {:?}, y = {:?}", x, y); -} -
让我们看看当 match
语句运行的时候发生了什么。第一个匹配分支的模式并不匹配 x
中定义的值,所以代码继续执行。
第二个匹配分支中的模式引入了一个新变量 y
,它会匹配任何 Some
中的值。因为这里的 y
在 match
表达式的作用域中,而不是之前 main
作用域中,所以这是一个新变量,不是开头声明为值 10 的那个 y
。这个新的 y
绑定会匹配任何 Some
中的值,在这里是 x
中的值。因此这个 y
绑定了 x
中 Some
内部的值。这个值是 5,所以这个分支的表达式将会执行并打印出 Matched,y = 5
。
如果 x
的值是 None
而不是 Some(5)
,头两个分支的模式不会匹配,所以会匹配模式 _
。这个分支的模式中没有引入变量 x
,所以此时表达式中的 x
会是外部没有被遮蔽的 x
,也就是 None
。
一旦 match
表达式执行完毕,其作用域也就结束了,同理内部 y
的作用域也结束了。最后的 println!
会打印 at the end: x = Some(5), y = 10
。
如果你不想引入变量遮蔽,可以使用另一个变量名而非 y
,或者使用匹配守卫(match guard)的方式,稍后在匹配守卫提供的额外条件中会讲解。
在 match
表达式中,可以使用 |
语法匹配多个模式,它代表 或的意思。例如,如下代码将 x
的值与匹配分支相比较,第一个分支有 或 选项,意味着如果 x
的值匹配此分支的任何一个模式,它就会运行:
--#![allow(unused)] -fn main() { -let x = 1; - -match x { - 1 | 2 => println!("one or two"), - 3 => println!("three"), - _ => println!("anything"), -} -} -
上面的代码会打印 one or two
。
..=
匹配值的范围在数值类型中我们有讲到一个序列语法,该语法不仅可以用于循环中,还能用于匹配模式。
-..=
语法允许你匹配一个闭区间序列内的值。在如下代码中,当模式匹配任何在此序列内的值时,该分支会执行:
--#![allow(unused)] -fn main() { -let x = 5; - -match x { - 1..=5 => println!("one through five"), - _ => println!("something else"), -} -} -
如果 x
是 1、2、3、4 或 5,第一个分支就会匹配。这相比使用 |
运算符表达相同的意思更为方便;相比 1..=5
,使用 |
则不得不指定 1 | 2 | 3 | 4 | 5
这五个值,而使用 ..=
指定序列就简短的多,比如希望匹配比如从 1 到 1000 的数字的时候!
序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。
-如下是一个使用字符类型序列的例子:
---#![allow(unused)] -fn main() { -let x = 'c'; - -match x { - 'a'..='j' => println!("early ASCII letter"), - 'k'..='z' => println!("late ASCII letter"), - _ => println!("something else"), -} -} -
Rust 知道 'c'
位于第一个模式的序列内,所以会打印出 early ASCII letter
。
也可以使用模式来解构结构体、枚举、元组、数组和引用。
-下面代码展示了如何用 let
解构一个带有两个字段 x
和 y
的结构体 Point
:
-struct Point { - x: i32, - y: i32, -} - -fn main() { - let p = Point { x: 0, y: 7 }; - - let Point { x: a, y: b } = p; - assert_eq!(0, a); - assert_eq!(7, b); -} -
这段代码创建了变量 a
和 b
来匹配结构体 p
中的 x
和 y
字段,这个例子展示了模式中的变量名不必与结构体中的字段名一致。不过通常希望变量名与字段名一致以便于理解变量来自于哪些字段。
因为变量名匹配字段名是常见的,同时因为 let Point { x: x, y: y } = p;
中 x
和 y
重复了,所以对于匹配结构体字段的模式存在简写:只需列出结构体字段的名称,则模式创建的变量会有相同的名称。下例与上例有着相同行为的代码,不过 let
模式创建的变量为 x
和 y
而不是 a
和 b
:
-struct Point { - x: i32, - y: i32, -} - -fn main() { - let p = Point { x: 0, y: 7 }; - - let Point { x, y } = p; - assert_eq!(0, x); - assert_eq!(7, y); -} -
这段代码创建了变量 x
和 y
,与结构体 p
中的 x
和 y
字段相匹配。其结果是变量 x
和 y
包含结构体 p
中的值。
也可以使用字面值作为结构体模式的一部分进行解构,而不是为所有的字段创建变量。这允许我们测试一些字段为特定值的同时创建其他字段的变量。
-下文展示了固定某个字段的匹配方式:
--struct Point { - x: i32, - y: i32, -} - -fn main() { - let p = Point { x: 0, y: 7 }; - - match p { - Point { x, y: 0 } => println!("On the x axis at {}", x), - Point { x: 0, y } => println!("On the y axis at {}", y), - Point { x, y } => println!("On neither axis: ({}, {})", x, y), - } -} -
首先是 match
第一个分支,指定匹配 y
为 0
的 Point
;
-然后第二个分支在第一个分支之后,匹配 y
不为 0
,x
为 0
的 Point
;
-最后一个分支匹配 x
不为 0
,y
也不为 0
的 Point
。
在这个例子中,值 p
因为其 x
包含 0 而匹配第二个分支,因此会打印出 On the y axis at 7
。
下面代码以 Message
枚举为例,编写一个 match
使用模式解构每一个内部值:
-enum Message { - Quit, - Move { x: i32, y: i32 }, - Write(String), - ChangeColor(i32, i32, i32), -} - -fn main() { - let msg = Message::ChangeColor(0, 160, 255); - - match msg { - Message::Quit => { - println!("The Quit variant has no data to destructure.") - } - Message::Move { x, y } => { - println!( - "Move in the x direction {} and in the y direction {}", - x, - y - ); - } - Message::Write(text) => println!("Text message: {}", text), - Message::ChangeColor(r, g, b) => { - println!( - "Change the color to red {}, green {}, and blue {}", - r, - g, - b - ) - } - } -} -
这里老生常谈一句话,模式匹配一样要类型相同,因此匹配 Message::Move{1,2}
这样的枚举值,就必须要用 Message::Move{x,y}
这样的同类型模式才行。
这段代码会打印出 Change the color to red 0, green 160, and blue 255
。尝试改变 msg
的值来观察其他分支代码的运行。
对于像 Message::Quit
这样没有任何数据的枚举成员,不能进一步解构其值。只能匹配其字面值 Message::Quit
,因此模式中没有任何变量。
对于另外两个枚举成员,就用相同类型的模式去匹配出对应的值即可。
-目前为止,所有的例子都只匹配了深度为一级的结构体或枚举。 match
也可以匹配嵌套的项!
例如使用下面的代码来同时支持 RGB 和 HSV 色彩模式:
--enum Color { - Rgb(i32, i32, i32), - Hsv(i32, i32, i32), -} - -enum Message { - Quit, - Move { x: i32, y: i32 }, - Write(String), - ChangeColor(Color), -} - -fn main() { - let msg = Message::ChangeColor(Color::Hsv(0, 160, 255)); - - match msg { - Message::ChangeColor(Color::Rgb(r, g, b)) => { - println!( - "Change the color to red {}, green {}, and blue {}", - r, - g, - b - ) - } - Message::ChangeColor(Color::Hsv(h, s, v)) => { - println!( - "Change the color to hue {}, saturation {}, and value {}", - h, - s, - v - ) - } - _ => () - } -} -
match
第一个分支的模式匹配一个 Message::ChangeColor
枚举成员,该枚举成员又包含了一个 Color::Rgb
的枚举成员,最终绑定了 3 个内部的 i32
值。第二个,就交给亲爱的读者来思考完成。
我们甚至可以用复杂的方式来混合、匹配和嵌套解构模式。如下是一个复杂结构体的例子,其中结构体和元组嵌套在元组中,并将所有的原始类型解构出来:
---#![allow(unused)] -fn main() { -struct Point { - x: i32, - y: i32, - } - -let ((feet, inches), Point {x, y}) = ((3, 10), Point { x: 3, y: -10 }); -} -
这种将复杂类型分解匹配的方式,可以让我们单独得到感兴趣的某个值。
-对于数组,我们可以用类似元组的方式解构,分为两种情况:
-定长数组
---#![allow(unused)] -fn main() { -let arr: [u16; 2] = [114, 514]; -let [x, y] = arr; - -assert_eq!(x, 114); -assert_eq!(y, 514); -} -
不定长数组
---#![allow(unused)] -fn main() { -let arr: &[u16] = &[114, 514]; - -if let [x, ..] = arr { - assert_eq!(x, &114); -} - -if let &[.., y] = arr { - assert_eq!(y, 514); -} - -let arr: &[u16] = &[]; - -assert!(matches!(arr, [..])); -assert!(!matches!(arr, [x, ..])); -} -
有时忽略模式中的一些值是很有用的,比如在 match
中的最后一个分支使用 _
模式匹配所有剩余的值。 你也可以在另一个模式中使用 _
模式,使用一个以下划线开始的名称,或者使用 ..
忽略所剩部分的值。
_
忽略整个值虽然 _
模式作为 match
表达式最后的分支特别有用,但是它的作用还不限于此。例如可以将其用于函数参数中:
-fn foo(_: i32, y: i32) { - println!("This code only uses the y parameter: {}", y); -} - -fn main() { - foo(3, 4); -} -
这段代码会完全忽略作为第一个参数传递的值 3
,并会打印出 This code only uses the y parameter: 4
。
大部分情况当你不再需要特定函数参数时,最好修改签名不再包含无用的参数。在一些情况下忽略函数参数会变得特别有用,比如实现特征时,当你需要特定类型签名但是函数实现并不需要某个参数时。此时编译器就不会警告说存在未使用的函数参数,就跟使用命名参数一样。
-_
忽略部分值可以在一个模式内部使用 _
忽略部分值:
--#![allow(unused)] -fn main() { -let mut setting_value = Some(5); -let new_setting_value = Some(10); - -match (setting_value, new_setting_value) { - (Some(_), Some(_)) => { - println!("Can't overwrite an existing customized value"); - } - _ => { - setting_value = new_setting_value; - } -} - -println!("setting is {:?}", setting_value); -} -
这段代码会打印出 Can't overwrite an existing customized value
接着是 setting is Some(5)
。
第一个匹配分支,我们不关心里面的值,只关心元组中两个元素的类型,因此对于 Some
中的值,直接进行忽略。
-剩下的形如 (Some(_),None)
,(None, Some(_))
, (None,None)
形式,都由第二个分支 _
进行分配。
还可以在一个模式中的多处使用下划线来忽略特定值,如下所示,这里忽略了一个五元元组中的第二和第四个值:
---#![allow(unused)] -fn main() { -let numbers = (2, 4, 8, 16, 32); - -match numbers { - (first, _, third, _, fifth) => { - println!("Some numbers: {}, {}, {}", first, third, fifth) - }, -} -} -
老生常谈:模式匹配一定要类型相同,因此匹配 numbers
元组的模式,也必须有五个值(元组中元素的数量也属于元组类型的一部分)。
这会打印出 Some numbers: 2, 8, 32
, 值 4 和 16 会被忽略。
如果你创建了一个变量却不在任何地方使用它,Rust 通常会给你一个警告,因为这可能会是个 BUG。但是有时创建一个不会被使用的变量是有用的,比如你正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头:
--fn main() { - let _x = 5; - let y = 10; -} -
这里得到了警告说未使用变量 y
,至于 x
则没有警告。
注意, 只使用 _
和使用以下划线开头的名称有些微妙的不同:比如 _x
仍会将值绑定到变量,而 _
则完全不会绑定。
--#![allow(unused)] -fn main() { -let s = Some(String::from("Hello!")); - -if let Some(_s) = s { - println!("found a string"); -} - -println!("{:?}", s); -} -
s
是一个拥有所有权的动态字符串,在上面代码中,我们会得到一个错误,因为 s
的值会被转移给 _s
,在 println!
中再次使用 s
会报错:
error[E0382]: borrow of partially moved value: `s`
- --> src/main.rs:8:22
- |
-4 | if let Some(_s) = s {
- | -- value partially moved here
-...
-8 | println!("{:?}", s);
- | ^ value borrowed here after partial move
-
-只使用下划线本身,则并不会绑定值,因为 s
没有被移动进 _
:
--#![allow(unused)] -fn main() { -let s = Some(String::from("Hello!")); - -if let Some(_) = s { - println!("found a string"); -} - -println!("{:?}", s); -} -
..
忽略剩余值对于有多个部分的值,可以使用 ..
语法来只使用部分值而忽略其它值,这样也不用再为每一个被忽略的值都单独列出下划线。..
模式会忽略模式中剩余的任何没有显式匹配的值部分。
--#![allow(unused)] -fn main() { -struct Point { - x: i32, - y: i32, - z: i32, -} - -let origin = Point { x: 0, y: 0, z: 0 }; - -match origin { - Point { x, .. } => println!("x is {}", x), -} -} -
这里列出了 x
值,接着使用了 ..
模式来忽略其它字段,这样的写法要比一一列出其它字段,然后用 _
忽略简洁的多。
还可以用 ..
来忽略元组中间的某些值:
-fn main() { - let numbers = (2, 4, 8, 16, 32); - - match numbers { - (first, .., last) => { - println!("Some numbers: {}, {}", first, last); - }, - } -} -
这里用 first
和 last
来匹配第一个和最后一个值。..
将匹配并忽略中间的所有值。
然而使用 ..
必须是无歧义的。如果期望匹配和忽略的值是不明确的,Rust 会报错。下面代码展示了一个带有歧义的 ..
例子,因此不能编译:
-fn main() { - let numbers = (2, 4, 8, 16, 32); - - match numbers { - (.., second, ..) => { - println!("Some numbers: {}", second) - }, - } -} -
如果编译上面的例子,会得到下面的错误:
-error: `..` can only be used once per tuple pattern // 每个元组模式只能使用一个 `..`
- --> src/main.rs:5:22
- |
-5 | (.., second, ..) => {
- | -- ^^ can only be used once per tuple pattern
- | |
- | previously used here // 上一次使用在这里
-
-error: could not compile `world_hello` due to previous error ^^
-
-Rust 无法判断,second
应该匹配 numbers
中的第几个元素,因此这里使用两个 ..
模式,是有很大歧义的!
匹配守卫(match guard)是一个位于 match
分支模式之后的额外 if
条件,它能为分支模式提供更进一步的匹配条件。
这个条件可以使用模式中创建的变量:
---#![allow(unused)] -fn main() { -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,所以第一个分支被选择。
相反如果 num
为 Some(10)
,因为 10 不小于 5 ,所以第一个分支的匹配守卫为假。接着 Rust 会前往第二个分支,因为这里没有匹配守卫所以会匹配任何 Some
成员。
模式中无法提供类如 if x < 5
的表达能力,我们可以通过匹配守卫的方式来实现。
在之前,我们提到可以使用匹配守卫来解决模式中变量覆盖的问题,那里 match
表达式的模式中新建了一个变量而不是使用 match
之外的同名变量。内部变量覆盖了外部变量,意味着此时不能够使用外部变量的值,下面代码展示了如何使用匹配守卫修复这个问题。
-fn main() { - let x = Some(5); - let y = 10; - - match x { - Some(50) => println!("Got 50"), - Some(n) if n == y => println!("Matched, n = {}", n), - _ => println!("Default case, x = {:?}", x), - } - - println!("at the end: x = {:?}, y = {}", x, y); -} -
现在这会打印出 Default case, x = Some(5)
。现在第二个匹配分支中的模式不会引入一个覆盖外部 y
的新变量 y
,这意味着可以在匹配守卫中使用外部的 y
。相比指定会覆盖外部 y
的模式 Some(y)
,这里指定为 Some(n)
。此新建的变量 n
并没有覆盖任何值,因为 match
外部没有变量 n
。
匹配守卫 if n == y
并不是一个模式所以没有引入新变量。这个 y
正是 外部的 y
而不是新的覆盖变量 y
,这样就可以通过比较 n
和 y
来表达寻找一个与外部 y
相同的值的概念了。
也可以在匹配守卫中使用 或 运算符 |
来指定多个模式,同时匹配守卫的条件会作用于所有的模式。下面代码展示了匹配守卫与 |
的优先级。这个例子中看起来好像 if y
只作用于 6
,但实际上匹配守卫 if y
作用于 4
、5
和 6
,在满足 x
属于 4 | 5 | 6
后才会判断 y
是否为 true
:
--#![allow(unused)] -fn main() { -let x = 4; -let y = false; - -match x { - 4 | 5 | 6 if y => println!("yes"), - _ => println!("no"), -} -} -
这个匹配条件表明此分支只匹配 x
值为 4
、5
或 6
同时 y
为 true
的情况。
虽然在第一个分支中,x
匹配了模式 4
,但是对于匹配守卫 if y
来说,因为 y
是 false
,因此该守卫条件的值永远是 false
,也意味着第一个分支永远无法被匹配。
下面的文字图解释了匹配守卫作用于多个模式时的优先级规则,第一张是正确的:
-(4 | 5 | 6) if y => ...
-
-而第二张图是错误的
-4 | 5 | (6 if y) => ...
-
-可以通过运行代码时的情况看出这一点:如果匹配守卫只作用于由 |
运算符指定的值列表的最后一个值,这个分支就会匹配且程序会打印出 yes
。
@
(读作 at)运算符允许为一个字段绑定另外一个变量。下面例子中,我们希望测试 Message::Hello
的 id
字段是否位于 3..=7
范围内,同时也希望能将其值绑定到 id_variable
变量中以便此分支中相关的代码可以使用它。我们可以将 id_variable
命名为 id
,与字段同名,不过出于示例的目的这里选择了不同的名称。
--#![allow(unused)] -fn main() { -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
字段的值进行测试:任何值都会匹配此分支。
当你既想要限定分支范围,又想要使用分支的变量时,就可以用 @
来绑定到一个新的变量上,实现想要的功能。
使用 @
还可以在绑定新变量的同时,对目标进行解构:
-#[derive(Debug)] -struct Point { - x: i32, - y: i32, -} - -fn main() { - // 绑定新变量 `p`,同时对 `Point` 进行解构 - let p @ Point {x: px, y: py } = Point {x: 10, y: 23}; - println!("x: {}, y: {}", px, py); - println!("{:?}", p); - - - let point = Point {x: 10, y: 5}; - if let p @ Point {x: 10, y} = point { - println!("x is 10 and y is {} in {:?}", y, p); - } else { - println!("x was not 10 :("); - } -} -
考虑下面一段代码:
--fn main() { - match 1 { - num @ 1 | 2 => { - println!("{}", num); - } - _ => {} - } -} -
编译不通过,是因为 num
没有绑定到所有的模式上,只绑定了模式 1
,你可能会试图通过这个方式来解决:
--#![allow(unused)] -fn main() { -num @ (1 | 2) -} -
但是,如果你用的是 Rust 1.53 之前的版本,那这种写法会报错,因为编译器不支持。
-至此,模式匹配的内容已经全部完结,复杂但是详尽,想要一次性全部记住属实不易,因此读者可以先留一个印象,等未来需要时,再来翻阅寻找具体的模式实现方式。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
模式匹配,这个词,对于非函数语言编程来说,真的还蛮少听到,因为它经常出现在函数式编程里,用于为复杂的类型系统提供一个轻松的解构能力。
-曾记否?在枚举和流程控制那章,我们遗留了两个问题,都是关于 match
的,第一个是如何对 Option
枚举进行进一步处理,另外一个是如何用 match
来替代 else if
这种丑陋的多重分支使用方式。那么让我们先一起来揭开 match
的神秘面纱。
在 Rust 中,模式匹配最常用的就是 match
和 if let
,本章节将对两者及相关的概念进行详尽介绍。
先来看一个关于 match
的简单例子:
-enum Direction { - East, - West, - North, - South, -} - -fn main() { - let dire = Direction::South; - match dire { - Direction::East => println!("East"), - Direction::North | Direction::South => { - println!("South or North"); - }, - _ => println!("West"), - }; -} -
这里我们想去匹配 dire
对应的枚举类型,因此在 match
中用三个匹配分支来完全覆盖枚举变量 Direction
的所有成员类型,有以下几点值得注意:
match
的匹配必须要穷举出所有可能,因此这里用 _
来代表未列出的所有可能性match
的每一个分支都必须是一个表达式,且所有分支的表达式最终返回值的类型必须相同或
,代表该分支可以匹配 X
也可以匹配 Y
,只要满足一个即可其实 match
跟其他语言中的 switch
非常像,_
类似于 switch
中的 default
。
match
匹配首先来看看 match
的通用形式:
--#![allow(unused)] -fn main() { -match target { - 模式1 => 表达式1, - 模式2 => { - 语句1; - 语句2; - 表达式2 - }, - _ => 表达式3 -} -} -
该形式清晰的说明了何为模式,何为模式匹配:将模式与 target
进行匹配,即为模式匹配,而模式匹配不仅仅局限于 match
,后面我们会详细阐述。
match
允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行对应的代码,下面让我们来一一详解,先看一个例子:
--#![allow(unused)] -fn main() { -enum Coin { - Penny, - Nickel, - Dime, - Quarter, -} - -fn value_in_cents(coin: Coin) -> u8 { - match coin { - Coin::Penny => { - println!("Lucky penny!"); - 1 - }, - Coin::Nickel => 5, - Coin::Dime => 10, - Coin::Quarter => 25, - } -} -} -
value_in_cents
函数根据匹配到的硬币,返回对应的美分数值。match
后紧跟着的是一个表达式,跟 if
很像,但是 if
后的表达式必须是一个布尔值,而 match
后的表达式返回值可以是任意类型,只要能跟后面的分支中的模式匹配起来即可,这里的 coin
是枚举 Coin
类型。
接下来是 match
的分支。一个分支有两个部分:一个模式和针对该模式的处理代码。第一个分支的模式是 Coin::Penny
,其后的 =>
运算符将模式和将要运行的代码分开。这里的代码就仅仅是表达式 1
,不同分支之间使用逗号分隔。
当 match
表达式执行时,它将目标值 coin
按顺序依次与每一个分支的模式相比较,如果模式匹配了这个值,那么模式之后的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支。
每个分支相关联的代码是一个表达式,而表达式的结果值将作为整个 match
表达式的返回值。如果分支有多行代码,那么需要用 {}
包裹,同时最后一行代码需要是一个表达式。
match
表达式赋值还有一点很重要,match
本身也是一个表达式,因此可以用它来赋值:
-enum IpAddr { - Ipv4, - Ipv6 -} - -fn main() { - let ip1 = IpAddr::Ipv6; - let ip_str = match ip1 { - IpAddr::Ipv4 => "127.0.0.1", - _ => "::1", - }; - - println!("{}", ip_str); -} -
因为这里匹配到 _
分支,所以将 "::1"
赋值给了 ip_str
。
模式匹配的另外一个重要功能是从模式中取出绑定的值,例如:
---#![allow(unused)] -fn main() { -#[derive(Debug)] -enum UsState { - Alabama, - Alaska, - // --snip-- -} - -enum Coin { - Penny, - Nickel, - Dime, - Quarter(UsState), // 25美分硬币 -} -} -
其中 Coin::Quarter
成员还存放了一个值:美国的某个州(因为在 1999 年到 2008 年间,美国在 25 美分(Quarter)硬币的背后为 50 个州印刷了不同的标记,其它硬币都没有这样的设计)。
接下来,我们希望在模式匹配中,获取到 25 美分硬币上刻印的州的名称:
---#![allow(unused)] -fn main() { -fn value_in_cents(coin: Coin) -> u8 { - match coin { - Coin::Penny => 1, - Coin::Nickel => 5, - Coin::Dime => 10, - Coin::Quarter(state) => { - println!("State quarter from {:?}!", state); - 25 - }, - } -} -} -
上面代码中,在匹配 Coin::Quarter(state)
模式时,我们把它内部存储的值绑定到了 state
变量上,因此 state
变量就是对应的 UsState
枚举类型。
例如有一个印了阿拉斯加州标记的 25 分硬币:Coin::Quarter(UsState::Alaska)
, 它在匹配时,state
变量将被绑定 UsState::Alaska
的枚举值。
再来看一个更复杂的例子:
--enum Action { - Say(String), - MoveTo(i32, i32), - ChangeColorRGB(u16, u16, u16), -} - -fn main() { - let actions = [ - Action::Say("Hello Rust".to_string()), - Action::MoveTo(1,2), - Action::ChangeColorRGB(255,255,0), - ]; - for action in actions { - match action { - Action::Say(s) => { - println!("{}", s); - }, - Action::MoveTo(x, y) => { - println!("point from (0, 0) move to ({}, {})", x, y); - }, - Action::ChangeColorRGB(r, g, _) => { - println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored", - r, g, - ); - } - } - } -} -
运行后输出:
-$ cargo run
- Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
- Finished dev [unoptimized + debuginfo] target(s) in 0.16s
- Running `target/debug/world_hello`
-Hello Rust
-point from (0, 0) move to (1, 2)
-change color into '(r:255, g:255, b:0)', 'b' has been ignored
-
-在文章的开头,我们简单总结过 match
的匹配必须穷尽所有情况,下面来举例说明,例如:
-enum Direction { - East, - West, - North, - South, -} - -fn main() { - let dire = Direction::South; - match dire { - Direction::East => println!("East"), - Direction::North | Direction::South => { - println!("South or North"); - }, - }; -} -
我们没有处理 Direction::West
的情况,因此会报错:
error[E0004]: non-exhaustive patterns: `West` not covered // 非穷尽匹配,`West` 没有被覆盖
- --> src/main.rs:10:11
- |
-1 | / enum Direction {
-2 | | East,
-3 | | West,
- | | ---- not covered
-4 | | North,
-5 | | South,
-6 | | }
- | |_- `Direction` defined here
-...
-10 | match dire {
- | ^^^^ pattern `West` not covered // 模式 `West` 没有被覆盖
- |
- = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
- = note: the matched value is of type `Direction`
-
-不禁想感叹,Rust 的编译器真强大,忍不住想爆粗口了,sorry,如果你以后进一步深入使用 Rust 也会像我这样感叹的。Rust 编译器清晰地知道 match
中有哪些分支没有被覆盖, 这种行为能强制我们处理所有的可能性,有效避免传说中价值十亿美金的 null
陷阱。
_
通配符当我们不想在匹配时列出所有值的时候,可以使用 Rust 提供的一个特殊模式,例如,u8
可以拥有 0 到 255 的有效的值,但是我们只关心 1、3、5 和 7
这几个值,不想列出其它的 0、2、4、6、8、9 一直到 255
的值。那么, 我们不必一个一个列出所有值, 因为可以使用特殊的模式 _
替代:
--#![allow(unused)] -fn main() { -let some_u8_value = 0u8; -match some_u8_value { - 1 => println!("one"), - 3 => println!("three"), - 5 => println!("five"), - 7 => println!("seven"), - _ => (), -} -} -
通过将 _
其放置于其他分支后,_
将会匹配所有遗漏的值。()
表示返回单元类型与所有分支返回值的类型相同,所以当匹配到 _
后,什么也不会发生。
除了_
通配符,用一个变量来承载其他情况也是可以的。
-#[derive(Debug)] -enum Direction { - East, - West, - North, - South, -} - -fn main() { - let dire = Direction::South; - match dire { - Direction::East => println!("East"), - other => println!("other direction: {:?}", other), - }; -} -
然而,在某些场景下,我们其实只关心某一个值是否存在,此时 match
就显得过于啰嗦。
if let
匹配有时会遇到只有一个模式的值需要被处理,其它值直接忽略的场景,如果用 match
来处理就要写成下面这样:
--#![allow(unused)] -fn main() { - let v = Some(3u8); - match v { - Some(3) => println!("three"), - _ => (), - } -} -
我们只想要对 Some(3)
模式进行匹配, 不想处理任何其他 Some<u8>
值或 None
值。但是为了满足 match
表达式(穷尽性)的要求,写代码时必须在处理完这唯一的成员后加上 _ => ()
,这样会增加不少无用的代码。
俗话说“杀鸡焉用牛刀”,我们完全可以用 if let
的方式来实现:
--#![allow(unused)] -fn main() { -if let Some(3) = v { - println!("three"); -} -} -
这两种匹配对于新手来说,可能有些难以抉择,但是只要记住一点就好:当你只要匹配一个条件,且忽略其他条件时就用 if let
,否则都用 match
。
Rust 标准库中提供了一个非常实用的宏:matches!
,它可以将一个表达式跟模式进行匹配,然后返回匹配的结果 true
or false
。
例如,有一个动态数组,里面存有以下枚举:
--enum MyEnum { - Foo, - Bar -} - -fn main() { - let v = vec![MyEnum::Foo,MyEnum::Bar,MyEnum::Foo]; -} -
现在如果想对 v
进行过滤,只保留类型是 MyEnum::Foo
的元素,你可能想这么写:
--#![allow(unused)] -fn main() { -v.iter().filter(|x| x == MyEnum::Foo); -} -
但是,实际上这行代码会报错,因为你无法将 x
直接跟一个枚举成员进行比较。好在,你可以使用 match
来完成,但是会导致代码更为啰嗦,是否有更简洁的方式?答案是使用 matches!
:
--#![allow(unused)] -fn main() { -v.iter().filter(|x| matches!(x, MyEnum::Foo)); -} -
很简单也很简洁,再来看看更多的例子:
---#![allow(unused)] -fn main() { -let foo = 'f'; -assert!(matches!(foo, 'A'..='Z' | 'a'..='z')); - -let bar = Some(4); -assert!(matches!(bar, Some(x) if x > 2)); -} -
无论是 match
还是 if let
,这里都是一个新的代码块,而且这里的绑定相当于新变量,如果你使用同名变量,会发生变量遮蔽:
-fn main() { - let age = Some(30); - println!("在匹配前,age是{:?}",age); - if let Some(age) = age { - println!("匹配出来的age是{}",age); - } - - println!("在匹配后,age是{:?}",age); -} -
cargo run
运行后输出如下:
在匹配前,age是Some(30)
-匹配出来的age是30
-在匹配后,age是Some(30)
-
-可以看出在 if let
中,=
右边 Some(i32)
类型的 age
被左边 i32
类型的新 age
遮蔽了,该遮蔽一直持续到 if let
语句块的结束。因此第三个 println!
输出的 age
依然是 Some(i32)
类型。
对于 match
类型也是如此:
-fn main() { - let age = Some(30); - println!("在匹配前,age是{:?}",age); - match age { - Some(age) => println!("匹配出来的age是{}",age), - _ => () - } - println!("在匹配后,age是{:?}",age); -} -
需要注意的是,match
中的变量遮蔽其实不是那么的容易看出,因此要小心!其实这里最好不要使用同名,避免难以理解,如下。
-fn main() { - let age = Some(30); - println!("在匹配前,age是{:?}", age); - match age { - Some(x) => println!("匹配出来的age是{}", x), - _ => () - } - println!("在匹配后,age是{:?}", age); -} -
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
在枚举那章,提到过 Option
枚举,它用来解决 Rust 中变量是否有值的问题,定义如下:
--#![allow(unused)] -fn main() { -enum Option<T> { - Some(T), - None, -} -} -
简单解释就是:一个变量要么有值:Some(T)
, 要么为空:None
。
那么现在的问题就是该如何去使用这个 Option
枚举类型,根据我们上一节的经验,可以通过 match
来实现。
--因为
-Option
,Some
,None
都包含在prelude
中,因此你可以直接通过名称来使用它们,而无需以Option::Some
这种形式去使用,总之,千万不要因为调用路径变短了,就忘记Some
和None
也是Option
底下的枚举成员!
Option<T>
使用 Option<T>
,是为了从 Some
中取出其内部的 T
值以及处理没有值的情况,为了演示这一点,下面一起来编写一个函数,它获取一个 Option<i32>
,如果其中含有一个值,将其加一;如果其中没有值,则函数返回 None
值:
--#![allow(unused)] -fn main() { -fn plus_one(x: Option<i32>) -> Option<i32> { - match x { - None => None, - Some(i) => Some(i + 1), - } -} - -let five = Some(5); -let six = plus_one(five); -let none = plus_one(None); -} -
plus_one
接受一个 Option<i32>
类型的参数,同时返回一个 Option<i32>
类型的值(这种形式的函数在标准库内随处所见),在该函数的内部处理中,如果传入的是一个 None
,则返回一个 None
且不做任何处理;如果传入的是一个 Some(i32)
,则通过模式绑定,把其中的值绑定到变量 i
上,然后返回 i+1
的值,同时用 Some
进行包裹。
为了进一步说明,假设 plus_one
函数接受的参数值 x 是 Some(5)
,来看看具体的分支匹配情况:
Some(5)
None => None,
-
-首先是匹配 None
分支,因为值 Some(5)
并不匹配模式 None
,所以继续匹配下一个分支。
Some(i) => Some(i + 1),
-
-Some(5)
与 Some(i)
匹配吗?当然匹配!它们是相同的成员。i
绑定了 Some
中包含的值,因此 i
的值是 5
。接着匹配分支的代码被执行,最后将 i
的值加一并返回一个含有值 6
的新 Some
。
接着考虑下 plus_one
的第二个调用,这次传入的 x
是 None
, 我们进入 match
并与第一个分支相比较。
None => None,
-
-匹配上了!接着程序继续执行该分支后的代码:返回表达式 None
的值,也就是返回一个 None
,因为第一个分支就匹配到了,其他的分支将不再比较。
模式是 Rust 中的特殊语法,它用来匹配类型中的结构和数据,它往往和 match
表达式联用,以实现强大的模式匹配能力。模式一般由以下内容组合而成:
--#![allow(unused)] -fn main() { -match VALUE { - PATTERN => EXPRESSION, - PATTERN => EXPRESSION, - PATTERN => EXPRESSION, -} -} -
如上所示,match
的每个分支就是一个模式,因为 match
匹配是穷尽式的,因此我们往往需要一个特殊的模式 _
,来匹配剩余的所有情况:
--#![allow(unused)] -fn main() { -match VALUE { - PATTERN => EXPRESSION, - PATTERN => EXPRESSION, - _ => EXPRESSION, -} -} -
if let
往往用于匹配一个模式,而忽略剩下的所有模式的场景:
--#![allow(unused)] -fn main() { -if let PATTERN = SOME_VALUE { - -} -} -
一个与 if let
类似的结构是 while let
条件循环,它允许只要模式匹配就一直进行 while
循环。下面展示了一个使用 while let
的例子:
--#![allow(unused)] -fn main() { -// Vec是动态数组 -let mut stack = Vec::new(); - -// 向数组尾部插入元素 -stack.push(1); -stack.push(2); -stack.push(3); - -// stack.pop从数组尾部弹出元素 -while let Some(top) = stack.pop() { - println!("{}", top); -} -} -
这个例子会打印出 3
、2
接着是 1
。pop
方法取出动态数组的最后一个元素并返回 Some(value)
,如果动态数组是空的,将返回 None
,对于 while
来说,只要 pop
返回 Some
就会一直不停的循环。一旦其返回 None
,while
循环停止。我们可以使用 while let
来弹出栈中的每一个元素。
你也可以用 loop
+ if let
或者 match
来实现这个功能,但是会更加啰嗦。
--#![allow(unused)] -fn main() { -let v = vec!['a', 'b', 'c']; - -for (index, value) in v.iter().enumerate() { - println!("{} is at index {}", value, index); -} -} -
这里使用 enumerate
方法产生一个迭代器,该迭代器每次迭代会返回一个 (索引,值)
形式的元组,然后用 (index,value)
来匹配。
--#![allow(unused)] -fn main() { -let PATTERN = EXPRESSION; -} -
是的, 该语句我们已经用了无数次了,它也是一种模式匹配:
---#![allow(unused)] -fn main() { -let x = 5; -} -
这其中,x
也是一种模式绑定,代表将匹配的值绑定到变量 x 上。因此,在 Rust 中,变量名也是一种模式,只不过它比较朴素很不起眼罢了。
--#![allow(unused)] -fn main() { -let (x, y, z) = (1, 2, 3); -} -
上面将一个元组与模式进行匹配(模式和值的类型必需相同!),然后把 1, 2, 3
分别绑定到 x, y, z
上。
模式匹配要求两边的类型必须相同,否则就会导致下面的报错:
---#![allow(unused)] -fn main() { -let (x, y) = (1, 2, 3); -} -
--#![allow(unused)] -fn main() { -error[E0308]: mismatched types - --> src/main.rs:4:5 - | -4 | let (x, y) = (1, 2, 3); - | ^^^^^^ --------- this expression has type `({integer}, {integer}, {integer})` - | | - | expected a tuple with 3 elements, found one with 2 elements - | - = note: expected tuple `({integer}, {integer}, {integer})` - found tuple `(_, _)` -For more information about this error, try `rustc --explain E0308`. -error: could not compile `playground` due to previous error -} -
对于元组来说,元素个数也是类型的一部分!
-函数参数也是模式:
---#![allow(unused)] -fn main() { -fn foo(x: i32) { - // 代码 -} -} -
其中 x
就是一个模式,你还可以在参数中匹配元组:
-fn print_coordinates(&(x, y): &(i32, i32)) { - println!("Current location: ({}, {})", x, y); -} - -fn main() { - let point = (3, 5); - print_coordinates(&point); -} -
&(3, 5)
会匹配模式 &(x, y)
,因此 x
得到了 3
,y
得到了 5
。
对于以下代码,编译器会报错:
---#![allow(unused)] -fn main() { -let Some(x) = some_option_value; -} -
因为右边的值可能不为 Some
,而是 None
,这种时候就不能进行匹配,也就是上面的代码遗漏了 None
的匹配。
类似 let
, for
和match
都必须要求完全覆盖匹配,才能通过编译( 不可驳模式匹配 )。
但是对于 if let
,就可以这样使用:
--#![allow(unused)] -fn main() { -if let Some(x) = some_option_value { - println!("{}", x); -} -} -
因为 if let
允许匹配一种模式,而忽略其余的模式( 可驳模式匹配 )。
从面向对象语言过来的同学对于方法肯定不陌生,class
里面就充斥着方法的概念。在 Rust 中,方法的概念也大差不差,往往和对象成对出现:
--#![allow(unused)] -fn main() { -object.method() -} -
例如读取一个文件写入缓冲区,如果用函数的写法 read(f, buffer)
,用方法的写法 f.read(buffer)
。不过与其它语言 class
跟方法的联动使用不同(这里可能要修改下),Rust 的方法往往跟结构体、枚举、特征(Trait)一起使用,特征将在后面几章进行介绍。
Rust 使用 impl
来定义方法,例如以下代码:
--#![allow(unused)] -fn main() { -struct Circle { - x: f64, - y: f64, - radius: f64, -} - -impl Circle { - // new是Circle的关联函数,因为它的第一个参数不是self,且new并不是关键字 - // 这种方法往往用于初始化当前结构体的实例 - fn new(x: f64, y: f64, radius: f64) -> Circle { - Circle { - x: x, - y: y, - radius: radius, - } - } - - // Circle的方法,&self表示借用当前的Circle结构体 - fn area(&self) -> f64 { - std::f64::consts::PI * (self.radius * self.radius) - } -} -} -
我们这里先不详细展开讲解,只是先建立对方法定义的大致印象。下面的图片将 Rust 方法定义与其它语言的方法定义做了对比:
-可以看出,其它语言中所有定义都在 class
中,但是 Rust 的对象定义和方法定义是分离的,这种数据和使用分离的方式,会给予使用者极高的灵活度。
再来看一个例子:
--#[derive(Debug)] -struct Rectangle { - width: u32, - height: u32, -} - -impl Rectangle { - fn area(&self) -> u32 { - self.width * self.height - } -} - -fn main() { - let rect1 = Rectangle { width: 30, height: 50 }; - - println!( - "The area of the rectangle is {} square pixels.", - rect1.area() - ); -} -
该例子定义了一个 Rectangle
结构体,并且在其上定义了一个 area
方法,用于计算该矩形的面积。
impl Rectangle {}
表示为 Rectangle
实现方法(impl
是实现 implementation 的缩写),这样的写法表明 impl
语句块中的一切都是跟 Rectangle
相关联的。
接下来的内容非常重要,请大家仔细看。在 area
的签名中,我们使用 &self
替代 rectangle: &Rectangle
,&self
其实是 self: &Self
的简写(注意大小写)。在一个 impl
块内,Self
指代被实现方法的结构体类型,self
指代此类型的实例,换句话说,self
指代的是 Rectangle
结构体实例,这样的写法会让我们的代码简洁很多,而且非常便于理解:我们为哪个结构体实现方法,那么 self
就是指代哪个结构体的实例。
需要注意的是,self
依然有所有权的概念:
self
表示 Rectangle
的所有权转移到该方法中,这种形式用的较少&self
表示该方法对 Rectangle
的不可变借用&mut self
表示可变借用总之,self
的使用就跟函数参数一样,要严格遵守 Rust 的所有权规则。
回到上面的例子中,选择 &self
的理由跟在函数中使用 &Rectangle
是相同的:我们并不想获取所有权,也无需去改变它,只是希望能够读取结构体中的数据。如果想要在方法中去改变当前的结构体,需要将第一个参数改为 &mut self
。仅仅通过使用 self
作为第一个参数来使方法获取实例的所有权是很少见的,这种使用方式往往用于把当前的对象转成另外一个对象时使用,转换完后,就不再关注之前的对象,且可以防止对之前对象的误调用。
简单总结下,使用方法代替函数有以下好处:
-self
对应的类型在 Rust 中,允许方法名跟结构体的字段名相同:
--impl Rectangle { - fn width(&self) -> bool { - self.width > 0 - } -} - -fn main() { - let rect1 = Rectangle { - width: 30, - height: 50, - }; - - if rect1.width() { - println!("The rectangle has a nonzero width; it is {}", rect1.width); - } -} -
当我们使用 rect1.width()
时,Rust 知道我们调用的是它的方法,如果使用 rect1.width
,则是访问它的字段。
一般来说,方法跟字段同名,往往适用于实现 getter
访问器,例如:
-pub struct Rectangle { - width: u32, - height: u32, -} - -impl Rectangle { - pub fn new(width: u32, height: u32) -> Self { - Rectangle { width, height } - } - pub fn width(&self) -> u32 { - return self.width; - } -} - -fn main() { - let rect1 = Rectangle::new(30, 50); - - println!("{}", rect1.width()); -} -
用这种方式,我们可以把 Rectangle
的字段设置为私有属性,只需把它的 new
和 width
方法设置为公开可见,那么用户就可以创建一个矩形,同时通过访问器 rect1.width()
方法来获取矩形的宽度,因为 width
字段是私有的,当用户访问 rect1.width
字段时,就会报错。注意在此例中,Self
指代的就是被实现方法的结构体 Rectangle
。
---
->
运算符到哪去了?在 C/C++ 语言中,有两个不同的运算符来调用方法:
-.
直接在对象上调用方法,而->
在一个对象的指针上调用方法,这时需要先解引用指针。换句话说,如果object
是一个指针,那么object->something()
和(*object).something()
是一样的。Rust 并没有一个与
-->
等效的运算符;相反,Rust 有一个叫 自动引用和解引用的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。他是这样工作的:当使用
-object.something()
调用方法时,Rust 会自动为object
添加&
、&mut
或*
以便使object
与方法签名匹配。也就是说,这些代码是等价的:--#![allow(unused)] -fn main() { -#[derive(Debug,Copy,Clone)] -struct Point { - x: f64, - y: f64, -} - -impl Point { - fn distance(&self, other: &Point) -> f64 { - let x_squared = f64::powi(other.x - self.x, 2); - let y_squared = f64::powi(other.y - self.y, 2); - - f64::sqrt(x_squared + y_squared) - } -} -let p1 = Point { x: 0.0, y: 0.0 }; -let p2 = Point { x: 5.0, y: 6.5 }; -p1.distance(&p2); -(&p1).distance(&p2); -} -
第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者————
-self
的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self
),做出修改(&mut self
)或者是获取所有权(self
)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。
方法和函数一样,可以使用多个参数:
--impl Rectangle { - fn area(&self) -> u32 { - self.width * self.height - } - - fn can_hold(&self, other: &Rectangle) -> bool { - self.width > other.width && self.height > other.height - } -} - -fn main() { - let rect1 = Rectangle { width: 30, height: 50 }; - let rect2 = Rectangle { width: 10, height: 40 }; - let rect3 = Rectangle { width: 60, height: 45 }; - - println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2)); - println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3)); -} -
现在大家可以思考一个问题,如何为一个结构体定义一个构造器方法?也就是接受几个参数,然后构造并返回该结构体的实例。其实答案在开头的代码片段中就给出了,很简单,参数中不包含 self
即可。
这种定义在 impl
中且没有 self
的函数被称之为关联函数: 因为它没有 self
,不能用 f.read()
的形式调用,因此它是一个函数而不是方法,它又在 impl
中,与结构体紧密关联,因此称为关联函数。
在之前的代码中,我们已经多次使用过关联函数,例如 String::from
,用于创建一个动态字符串。
--#![allow(unused)] -fn main() { -#[derive(Debug)] -struct Rectangle { - width: u32, - height: u32, -} - -impl Rectangle { - fn new(w: u32, h: u32) -> Rectangle { - Rectangle { width: w, height: h } - } -} -} -
--Rust 中有一个约定俗成的规则,使用
-new
来作为构造器的名称,出于设计上的考虑,Rust 特地没有用new
作为关键字。
因为是函数,所以不能用 .
的方式来调用,我们需要用 ::
来调用,例如 let sq = Rectangle::new(3, 3);
。这个方法位于结构体的命名空间中:::
语法用于关联函数和模块创建的命名空间。
Rust 允许我们为一个结构体定义多个 impl
块,目的是提供更多的灵活性和代码组织性,例如当方法多了后,可以把相关的方法组织在同一个 impl
块中,那么就可以形成多个 impl
块,各自完成一块儿目标:
--#![allow(unused)] -fn main() { -#[derive(Debug)] -struct Rectangle { - width: u32, - height: u32, -} - -impl Rectangle { - fn area(&self) -> u32 { - self.width * self.height - } -} - -impl Rectangle { - fn can_hold(&self, other: &Rectangle) -> bool { - self.width > other.width && self.height > other.height - } -} -} -
当然,就这个例子而言,我们没必要使用两个 impl
块,这里只是为了演示方便。
枚举类型之所以强大,不仅仅在于它好用、可以同一化类型,还在于,我们可以像结构体一样,为枚举实现方法:
--#![allow(unused)] -enum Message { - Quit, - Move { x: i32, y: i32 }, - Write(String), - ChangeColor(i32, i32, i32), -} - -impl Message { - fn call(&self) { - // 在这里定义方法体 - } -} - -fn main() { - let m = Message::Write(String::from("hello")); - m.call(); -} -
除了结构体和枚举,我们还能为特征(trait)实现方法,这将在下一章进行讲解,在此之前,先来看看泛型。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
上节中提到,如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。 Rust 能否像其它编程语言一样,使用某个变量的指针或者引用呢?答案是可以。
-Rust 通过 借用(Borrowing)
这个概念来达成上述的目的,获取变量的引用,称之为借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来,当使用完毕后,也必须要物归原主。
常规引用是一个指针类型,指向了对象存储的内存地址。在下面代码中,我们创建一个 i32
值的引用 y
,然后使用解引用运算符来解出 y
所使用的值:
-fn main() { - let x = 5; - let y = &x; - - assert_eq!(5, x); - assert_eq!(5, *y); -} -
变量 x
存放了一个 i32
值 5
。y
是 x
的一个引用。可以断言 x
等于 5
。然而,如果希望对 y
的值做出断言,必须使用 *y
来解出引用所指向的值(也就是解引用)。一旦解引用了 y
,就可以访问 y
所指向的整型值并可以与 5
做比较。
相反如果尝试编写 assert_eq!(5, y);
,则会得到如下编译错误:
error[E0277]: can't compare `{integer}` with `&{integer}`
- --> src/main.rs:6:5
- |
-6 | assert_eq!(5, y);
- | ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}` // 无法比较整数类型和引用类型
- |
- = help: the trait `std::cmp::PartialEq<&{integer}>` is not implemented for
- `{integer}`
-
-不允许比较整数与引用,因为它们是不同的类型。必须使用解引用运算符解出引用所指向的值。
-下面的代码,我们用 s1
的引用作为参数传递给 calculate_length
函数,而不是把 s1
的所有权转移给该函数:
-fn main() { - let s1 = String::from("hello"); - - let len = calculate_length(&s1); - - println!("The length of '{}' is {}.", s1, len); -} - -fn calculate_length(s: &String) -> usize { - s.len() -} -
能注意到两点:
-calculate_length
的参数 s
类型从 String
变为 &String
这里,&
符号即是引用,它们允许你使用值,但是不获取所有权,如图所示:
-
通过 &s1
语法,我们创建了一个指向 s1
的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。
同理,函数 calculate_length
使用 &
来表明参数 s
的类型是一个引用:
--#![allow(unused)] -fn main() { -fn calculate_length(s: &String) -> usize { // s 是对 String 的引用 - s.len() -} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权, - // 所以什么也不会发生 -} -
人总是贪心的,可以拉女孩小手了,就想着抱抱柔软的身子(读者中的某老司机表示,这个流程完全不对),因此光借用已经满足不了我们了,如果尝试修改借用的变量呢?
--fn main() { - let s = String::from("hello"); - - change(&s); -} - -fn change(some_string: &String) { - some_string.push_str(", world"); -} -
很不幸,妹子你没抱到,哦口误,你修改错了:
-error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
- --> src/main.rs:8:5
- |
-7 | fn change(some_string: &String) {
- | ------- help: consider changing this to be a mutable reference: `&mut String`
- ------- 帮助:考虑将该参数类型修改为可变的引用: `&mut String`
-8 | some_string.push_str(", world");
- | ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
- `some_string`是一个`&`类型的引用,因此它指向的数据无法进行修改
-
-正如变量默认不可变一样,引用指向的值默认也是不可变的,没事,来一起看看如何解决这个问题。
-只需要一个小调整,即可修复上面代码的错误:
--fn main() { - let mut s = String::from("hello"); - - change(&mut s); -} - -fn change(some_string: &mut String) { - some_string.push_str(", world"); -} -
首先,声明 s
是可变类型,其次创建一个可变的引用 &mut s
和接受可变引用参数 some_string: &mut String
的函数。
不过可变引用并不是随心所欲、想用就用的,它有一个很大的限制: 同一作用域,特定数据只能有一个可变引用:
---#![allow(unused)] -fn main() { -let mut s = String::from("hello"); - -let r1 = &mut s; -let r2 = &mut s; - -println!("{}, {}", r1, r2); -} -
以上代码会报错:
-error[E0499]: cannot borrow `s` as mutable more than once at a time 同一时间无法对 `s` 进行两次可变借用
- --> src/main.rs:5:14
- |
-4 | let r1 = &mut s;
- | ------ first mutable borrow occurs here 首个可变引用在这里借用
-5 | let r2 = &mut s;
- | ^^^^^^ second mutable borrow occurs here 第二个可变引用在这里借用
-6 |
-7 | println!("{}, {}", r1, r2);
- | -- first borrow later used here 第一个借用在这里使用
-
-这段代码出错的原因在于,第一个可变借用 r1
必须要持续到最后一次使用的位置 println!
,在 r1
创建和最后一次使用之间,我们又尝试创建第二个可变借用 r2
。
对于新手来说,这个特性绝对是一大拦路虎,也是新人们谈之色变的编译器 borrow checker
特性之一,不过各行各业都一样,限制往往是出于安全的考虑,Rust 也一样。
这种限制的好处就是使 Rust 在编译期就避免数据竞争,数据竞争可由以下行为造成:
-数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。而 Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
-很多时候,大括号可以帮我们解决一些编译不通过的问题,通过手动限制变量的作用域:
---#![allow(unused)] -fn main() { -let mut s = String::from("hello"); - -{ - let r1 = &mut s; - -} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用 - -let r2 = &mut s; -} -
下面的代码会导致一个错误:
---#![allow(unused)] -fn main() { -let mut s = String::from("hello"); - -let r1 = &s; // 没问题 -let r2 = &s; // 没问题 -let r3 = &mut s; // 大问题 - -println!("{}, {}, and {}", r1, r2, r3); -} -
错误如下:
-error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
- // 无法借用可变 `s` 因为它已经被借用了不可变
- --> src/main.rs:6:14
- |
-4 | let r1 = &s; // 没问题
- | -- immutable borrow occurs here 不可变借用发生在这里
-5 | let r2 = &s; // 没问题
-6 | let r3 = &mut s; // 大问题
- | ^^^^^^ mutable borrow occurs here 可变借用发生在这里
-7 |
-8 | println!("{}, {}, and {}", r1, r2, r3);
- | -- immutable borrow later used here 不可变借用在这里使用
-
-其实这个也很好理解,正在借用不可变引用的用户,肯定不希望他借用的东西,被另外一个人莫名其妙改变了。多个不可变借用被允许是因为没有人会去试图修改数据,每个人都只读这一份数据而不做修改,因此不用担心数据被污染。
---注意,引用的作用域
-s
从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号}
Rust 的编译器一直在优化,早期的时候,引用的作用域跟变量作用域是一致的,这对日常使用带来了很大的困扰,你必须非常小心的去安排可变、不可变变量的借用,免得无法通过编译,例如以下代码:
--fn main() { - let mut s = String::from("hello"); - - let r1 = &s; - let r2 = &s; - println!("{} and {}", r1, r2); - // 新编译器中,r1,r2作用域在这里结束 - - let r3 = &mut s; - println!("{}", r3); -} // 老编译器中,r1、r2、r3作用域在这里结束 - // 新编译器中,r3作用域在这里结束 -
在老版本的编译器中(Rust 1.31 前),将会报错,因为 r1
和 r2
的作用域在花括号 }
处结束,那么 r3
的借用就会触发 无法同时借用可变和不可变的规则。
但是在新的编译器中,该代码将顺利通过,因为 引用作用域的结束位置从花括号变成最后一次使用的位置,因此 r1
借用和 r2
借用在 println!
后,就结束了,此时 r3
可以顺利借用到可变引用。
对于这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(}
)结束前就不再被使用的代码位置。
虽然这种借用错误有的时候会让我们很郁闷,但是你只要想想这是 Rust 提前帮你发现了潜在的 BUG,其实就开心了,虽然减慢了开发速度,但是从长期来看,大幅减少了后续开发和运维成本。
-悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。
-让我们尝试创建一个悬垂引用,Rust 会抛出一个编译时错误:
--fn main() { - let reference_to_nothing = dangle(); -} - -fn dangle() -> &String { - let s = String::from("hello"); - - &s -} -
这里是错误:
-error[E0106]: missing lifetime specifier
- --> src/main.rs:5:16
- |
-5 | fn dangle() -> &String {
- | ^ expected named lifetime parameter
- |
- = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
-help: consider using the `'static` lifetime
- |
-5 | fn dangle() -> &'static String {
- | ~~~~~~~~
-
-
-错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。不过,即使你不理解生命周期,也可以通过错误信息知道这段代码错误的关键信息:
-this function's return type contains a borrowed value, but there is no value for it to be borrowed from.
-该函数返回了一个借用的值,但是已经找不到它所借用值的来源
-
-仔细看看 dangle
代码的每一步到底发生了什么:
--#![allow(unused)] -fn main() { -fn dangle() -> &String { // dangle 返回一个字符串的引用 - - let s = String::from("hello"); // s 是一个新字符串 - - &s // 返回字符串 s 的引用 -} // 这里 s 离开作用域并被丢弃。其内存被释放。 - // 危险! -} -
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放,但是此时我们又尝试去返回它的引用。这意味着这个引用会指向一个无效的 String
,这可不对!
其中一个很好的解决方法是直接返回 String
:
--#![allow(unused)] -fn main() { -fn no_dangle() -> String { - let s = String::from("hello"); - - s -} -} -
这样就没有任何错误了,最终 String
的 所有权被转移给外面的调用者。
总的来说,借用规则如下:
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
Rust 之所以能成为万众瞩目的语言,就是因为其内存安全性。在以往,内存安全几乎都是通过 GC 的方式实现,但是 GC 会引来性能、内存占用以及 Stop the world 等问题,在高性能场景和系统编程上是不可接受的,因此 Rust 采用了与(错)众(误)不(之)同(源)的方式:所有权系统。
-理解所有权和借用,对于 Rust 学习是至关重要的,因此我们把本章提到了非常靠前的位置,So,骚年们,准备好迎接狂风暴雨了嘛?
-从现在开始,鉴于大家已经掌握了非常基本的语法,有些时候,在示例代码中,将省略 fn main() {}
的模版代码,只要将相应的示例放在 fn main() {}
中,即可运行。
所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。在计算机语言不断演变过程中,出现了三种流派:
-其中 Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。
-由于所有权是一个新概念,因此读者需要花费一些时间来掌握它,一旦掌握,海阔天空任你飞跃,在本章,我们将通过 字符串
来引导讲解所有权的相关知识。
先来看看一段来自 C 语言的糟糕代码:
-int* foo() {
- int a; // 变量a的作用域开始
- a = 100;
- char *c = "xyz"; // 变量c的作用域开始
- return &a;
-} // 变量a和c的作用域结束
-
-这段代码虽然可以编译通过,但是其实非常糟糕,变量 a
和 c
都是局部变量,函数结束后将局部变量 a
的地址返回,但局部变量 a
存在栈中,在离开作用域后,a
所申请的栈上内存都会被系统回收,从而造成了 悬空指针(Dangling Pointer)
的问题。这是一个非常典型的内存安全问题,虽然编译可以通过,但是运行的时候会出现错误, 很多编程语言都存在。
再来看变量 c
,c
的值是常量字符串,存储于常量区,可能这个函数我们只调用了一次,也可能我们不再会使用这个字符串,但 "xyz"
只有当整个程序结束后系统才能回收这片内存。
所以内存安全问题,一直都是程序员非常头疼的问题,好在, 在 Rust 中这些问题即将成为历史,因为 Rust 在编译的时候就可以帮助我们发现内存不安全的问题,那 Rust 如何做到这一点呢?
-在正式进入主题前,先来一个预热知识。
-栈和堆是编程语言最核心的数据结构,但是在很多语言中,你并不需要深入了解栈与堆。 但对于 Rust 这样的系统编程语言,值是位于栈上还是堆上非常重要, 因为这会影响程序的行为和性能。
-栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。
-栈按照顺序存储值并以相反顺序取出值,这也被称作后进先出。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,再从顶部拿走。不能从中间也不能从底部增加或拿走盘子!
-增加数据叫做进栈,移出数据则叫做出栈。
-因为上述的实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,你将无法取到你想要的数据。
-与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。
-当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针, 该过程被称为在堆上分配内存,有时简称为 “分配”(allocating)。
-接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。
-由上可知,堆是一种缺乏组织的数据结构。想象一下去餐馆就座吃饭: 进入餐馆,告知服务员有几个人,然后服务员找到一个够大的空桌子(堆上分配的内存空间)并领你们过去。如果有人来迟了,他们也可以通过桌号(栈上的指针)来找到你们坐在哪。
-写入方面:入栈比在堆上分配内存要快,因为入栈时操作系统无需分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备。
-读取方面:得益于 CPU 高速缓存,使得处理器可以减少对内存的访问,高速缓存和内存的访问速度差异在 10 倍以上!栈数据往往可以直接存储在 CPU 高速缓存中,而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存。
-因此,处理器处理分配在栈上数据会比在堆上的数据更加高效。
-当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。
-因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。
-对于其他很多编程语言,你确实无需理解堆栈的原理,但是在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助。
-理解了堆栈,接下来看一下关于所有权的规则,首先请谨记以下规则:
----
-- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
-- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
-- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
-
作用域是一个变量在程序中有效的范围, 假如有这样一个变量:
---#![allow(unused)] -fn main() { -let s = "hello"; -} -
变量 s
绑定到了一个字符串字面值,该字符串字面值是硬编码到程序代码中的。s
变量从声明的点开始直到当前作用域的结束都是有效的:
--#![allow(unused)] -fn main() { -{ // s 在这里无效,它尚未声明 - let s = "hello"; // 从此处起,s 是有效的 - - // 使用 s -} // 此作用域已结束,s不再有效 -} -
简而言之,s
从创建伊始就开始有效,然后有效期持续到它离开作用域为止,可以看出,就作用域来说,Rust 语言跟其他编程语言没有区别。
之前提到过,本章会用 String
作为例子,因此这里会进行一下简单的介绍,具体的 String
学习请参见 String 类型。
我们已经见过字符串字面值 let s ="hello"
,s
是被硬编码进程序里的字符串值(类型为 &str
)。字符串字面值是很方便的,但是它并不适用于所有场景。原因有二:
例如,字符串是需要程序运行时,通过用户动态输入然后存储在内存中的,这种情况,字符串字面值就完全无用武之地。 为此,Rust 为我们提供动态字符串类型: String
, 该类型被分配到堆上,因此可以动态伸缩,也就能存储在编译时大小未知的文本。
可以使用下面的方法基于字符串字面量来创建 String
类型:
--#![allow(unused)] -fn main() { -let s = String::from("hello"); -} -
::
是一种调用操作符,这里表示调用 String
中的 from
方法,因为 String
存储在堆上是动态的,你可以这样修改它:
--#![allow(unused)] -fn main() { -let mut s = String::from("hello"); - -s.push_str(", world!"); // push_str() 在字符串后追加字面值 - -println!("{}", s); // 将打印 `hello, world!` -} -
言归正传,了解 String
内容后,一起来看看关于所有权的交互。
先来看一段代码:
---#![allow(unused)] -fn main() { -let x = 5; -let y = x; -} -
代码背后的逻辑很简单, 将 5
绑定到变量 x
;接着拷贝 x
的值赋给 y
,最终 x
和 y
都等于 5
,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。
可能有同学会有疑问:这种拷贝不消耗性能吗?实际上,这种栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32
,4 个字节)的内存即可,因此在这种情况下,拷贝的速度远比在堆上创建内存来得快的多。实际上,上一章我们讲到的 Rust 基本类型都是通过自动拷贝的方式来赋值的,就像上面代码一样。
然后再来看一段代码:
---#![allow(unused)] -fn main() { -let s1 = String::from("hello"); -let s2 = s1; -} -
此时,可能某个大聪明(善意昵称)已经想到了:嗯,把 s1
的内容拷贝一份赋值给 s2
,实际上,并不是这样。之前也提到了,对于基本类型(存储在栈上),Rust 会自动拷贝,但是 String
不是基本类型,而且是存储在堆上的,因此不能自动拷贝。
实际上, String
类型是一个复杂类型,由存储在栈中的堆指针、字符串长度、字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存,至于长度和容量,如果你有 Go 语言的经验,这里就很好理解:容量是堆内存分配空间的大小,长度是目前已经使用的大小。
总之 String
类型指向了一个堆上的空间,这里存储着它的真实数据, 下面对上面代码中的 let s2 = s1
分成两种情况讨论:
拷贝 String
和存储在堆上的字节数组
-如果该语句是拷贝所有数据(深拷贝),那么无论是 String
本身还是底层的堆上数据,都会被全部拷贝,这对于性能而言会造成非常大的影响
只拷贝 String
本身
-这样的拷贝非常快,因为在 64 位机器上就拷贝了 8字节的指针
、8字节的长度
、8字节的容量
,总计 24 字节,但是带来了新的问题,还记得我们之前提到的所有权规则吧?其中有一条就是:一个值只允许有一个所有者,而现在这个值(堆上的真实字符串数据)有了两个所有者:s1
和 s2
。
好吧,就假定一个值可以拥有两个所有者,会发生什么呢?
-当变量离开作用域后,Rust 会自动调用 drop
函数并清理变量的堆内存。不过由于两个 String
变量指向了同一位置。这就有了一个问题:当 s1
和 s2
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
因此,Rust 这样解决问题:当 s1
赋予 s2
后,Rust 认为 s1
不再有效,因此也无需在 s1
离开作用域后 drop
任何东西,这就是把所有权从 s1
转移给了 s2
,s1
在被赋予 s2
后就马上失效了。
再来看看,在所有权转移后再来使用旧的所有者,会发生什么:
---#![allow(unused)] -fn main() { -let s1 = String::from("hello"); -let s2 = s1; - -println!("{}, world!", s1); -} -
由于 Rust 禁止你使用无效的引用,你会看到以下的错误:
-error[E0382]: use of moved value: `s1`
- --> src/main.rs:5:28
- |
-3 | let s2 = s1;
- | -- value moved here
-4 |
-5 | println!("{}, world!", s1);
- | ^^ value used here after move
- |
- = note: move occurs because `s1` has type `std::string::String`, which does
- not implement the `Copy` trait
-
-现在再回头看看之前的规则,相信大家已经有了更深刻的理解:
----
-- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
-- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
-- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
-
如果你在其他语言中听说过术语 浅拷贝(shallow copy) 和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据听起来就像浅拷贝,但是又因为 Rust 同时使第一个变量 s1
无效了,因此这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1
被移动到了 s2
中。那么具体发生了什么,用一张图简单说明:
这样就解决了我们之前的问题,s1
不再指向任何数据,只有 s2
是有效的,当 s2
离开作用域,它就会释放内存。 相信此刻,你应该明白了,为什么 Rust 称呼 let a = b
为变量绑定了吧?
再来看一段代码:
--fn main() { - let x: &str = "hello, world"; - let y = x; - println!("{},{}",x,y); -} -
这段代码,大家觉得会否报错?如果参考之前的 String
所有权转移的例子,那这段代码也应该报错才是,但是实际上呢?
这段代码和之前的 String
有一个本质上的区别:在 String
的例子中 s1
持有了通过String::from("hello")
创建的值的所有权,而这个例子中,x
只是引用了存储在二进制中的字符串 "hello, world"
,并没有持有所有权。
因此 let y = x
中,仅仅是对该引用进行了拷贝,此时 y
和 x
都引用了同一个字符串。如果还不理解也没关系,当学习了下一章节 "引用与借用" 后,大家自然而言就会理解。
首先,Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。
-如果我们确实需要深度复制 String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone
的方法。
--#![allow(unused)] -fn main() { -let s1 = String::from("hello"); -let s2 = s1.clone(); - -println!("s1 = {}, s2 = {}", s1, s2); -} -
这段代码能够正常运行,因此说明 s2
确实完整的复制了 s1
的数据。
如果代码性能无关紧要,例如初始化程序时,或者在某段时间只会执行一次时,你可以使用 clone
来简化编程。但是对于执行较为频繁的代码(热点路径),使用 clone
会极大的降低程序性能,需要小心使用!
浅拷贝只发生在栈上,因此性能很高,在日常编程中,浅拷贝无处不在。
-再回到之前看过的例子:
---#![allow(unused)] -fn main() { -let x = 5; -let y = x; - -println!("x = {}, y = {}", x, y); -} -
但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone
,不过依然实现了类似深拷贝的效果 —— 没有报所有权的错误。
原因是像整型这样的基本类型在编译时是已知大小的,会被存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y
后使 x
无效(x
、y
都仍然有效)。换句话说,这里没有深浅拷贝的区别,因此这里调用 clone
并不会与通常的浅拷贝有什么不同,我们可以不用管它(可以理解成在栈上做了深拷贝)。
Rust 有一个叫做 Copy
的特征,可以用在类似整型这样在栈中存储的类型。如果一个类型拥有 Copy
特征,一个旧的变量在被赋值给其他变量后仍然可用。
那么什么类型是可 Copy
的呢?可以查看给定类型的文档来确认,不过作为一个通用的规则: 任何基本类型的组合可以 Copy
,不需要分配内存或某种形式资源的类型是可以 Copy
的。如下是一些 Copy
的类型:
u32
bool
,它的值是 true
和 false
f64
char
Copy
的时候。比如,(i32, i32)
是 Copy
的,但 (i32, String)
就不是&T
,例如转移所有权中的最后一个例子,但是注意: 可变引用 &mut T
是不可以 Copy的将值传递给函数,一样会发生 移动
或者 复制
,就跟 let
语句一样,下面的代码展示了所有权、作用域的规则:
-fn main() { - let s = String::from("hello"); // s 进入作用域 - - takes_ownership(s); // s 的值移动到函数里 ... - // ... 所以到这里不再有效 - - let x = 5; // x 进入作用域 - - makes_copy(x); // x 应该移动函数里, - // 但 i32 是 Copy 的,所以在后面可继续使用 x - -} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走, - // 所以不会有特殊操作 - -fn takes_ownership(some_string: String) { // some_string 进入作用域 - println!("{}", some_string); -} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放 - -fn makes_copy(some_integer: i32) { // some_integer 进入作用域 - println!("{}", some_integer); -} // 这里,some_integer 移出作用域。不会有特殊操作 -
你可以尝试在 takes_ownership
之后,再使用 s
,看看如何报错?例如添加一行 println!("在move进函数后继续使用s: {}",s);
。
同样的,函数返回值也有所有权,例如:
--fn main() { - let s1 = gives_ownership(); // gives_ownership 将返回值 - // 移给 s1 - - let s2 = String::from("hello"); // s2 进入作用域 - - let s3 = takes_and_gives_back(s2); // s2 被移动到 - // takes_and_gives_back 中, - // 它也将返回值移给 s3 -} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走, - // 所以什么也不会发生。s1 移出作用域并被丢弃 - -fn gives_ownership() -> String { // gives_ownership 将返回值移动给 - // 调用它的函数 - - let some_string = String::from("hello"); // some_string 进入作用域. - - some_string // 返回 some_string 并移出给调用的函数 -} - -// takes_and_gives_back 将传入字符串并返回该值 -fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域 - - a_string // 返回 a_string 并移出给调用的函数 -} -
所有权很强大,避免了内存的不安全性,但是也带来了一个新麻烦: 总是把一个值传来传去来使用它。 传入一个函数,很可能还要从该函数传出去,结果就是语言表达变得非常啰嗦,幸运的是,Rust 提供了新功能解决这个问题。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
飞鸽传书、八百里加急,自古以来,掌权者最需要的就是及时获得对某个事物的信息反馈,在此过程中,也定义了相应的应急处理措施。
-社会演变至今,这种思想依然没变,甚至来到计算中的微观世界,也是如此。及时、准确的获知系统在发生什么,是程序设计的重中之重。因此能够准确的分辨函数返回值是正确的还是错误的、以及在发生错误时该怎么快速处理,成了程序设计语言的必备功能。
-Go 语言为人诟病的其中一点就是 if err != nil {} 的大量使用,缺乏一些程序设计的美感,不过我倒是觉得这种简单的方式也有其好处,就是阅读代码时的流畅感很强,你不需要过多的思考各种语法是什么意思。与 Go 语言不同,Rust 博采众家之长,实现了颇具自身色彩的返回值和错误处理体系,本章我们就高屋建瓴地来学习,更加深入的讲解见错误处理。
-错误对于软件来说是不可避免的,因此一门优秀的编程语言必须有其完整的错误处理哲学。在很多情况下,Rust 需要你承认自己的代码可能会出错,并提前采取行动,来处理这些错误。
-Rust 中的错误主要分为两类:
-很多编程语言,并不会区分这些错误,而是直接采用异常的方式去处理。Rust 没有异常,但是 Rust 也有自己的卧龙凤雏:Result<T, E>
用于可恢复错误,panic!
用于不可恢复错误。
在正式开始之前,先来思考一个问题:假设我们想要从文件读取数据,如果失败,你有没有好的办法通知调用者为何失败?如果成功,你有没有好的办法把读取的结果返还给调用者?
-上面的问题在真实场景会经常遇到,其实处理起来挺复杂的,让我们先做一个假设:文件读取操作发生在系统启动阶段。那么可以轻易得出一个结论,一旦文件读取失败,那么系统启动也将失败,这意味着该失败是不可恢复的错误,无论是因为文件不存在还是操作系统硬盘的问题,这些只是错误的原因不同,但是归根到底都是不可恢复的错误(梳理清楚当前场景的错误类型非常重要)。
-对于这些严重到影响程序运行的错误,触发 panic
是很好的解决方式。在 Rust 中触发 panic
有两种方式:被动触发和主动调用,下面依次来看看。
先来看一段简单又熟悉的代码:
--fn main() { - let v = vec![1, 2, 3]; - - v[99]; -} -
心明眼亮的同学立马就能看出这里发生了严重的错误 —— 数组访问越界,在其它编程语言中无一例外,都会报出严重的异常,甚至导致程序直接崩溃关闭。
-而 Rust 也不例外,运行后将看到如下报错:
-$ cargo run
- Compiling panic v0.1.0 (file:///projects/panic)
- Finished dev [unoptimized + debuginfo] target(s) in 0.27s
- Running `target/debug/panic`
-thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-上面给出了非常详细的报错信息,包含了具体的异常描述以及发生的位置,甚至你还可以加入额外的命令来看到异常发生时的堆栈信息,这个会在后面详细展开。
-总之,类似的 panic
还有很多,而被动触发的 panic
是我们日常开发中最常遇到的,这也是 Rust 给我们的一种保护,毕竟错误只有抛出来,才有可能被处理,否则只会偷偷隐藏起来,寻觅时机给你致命一击。
在某些特殊场景中,开发者想要主动抛出一个异常,例如开头提到的在系统启动阶段读取文件失败。
-对此,Rust 为我们提供了 panic!
宏,当调用执行该宏时,程序会打印出一个错误信息,展开报错点往前的函数调用堆栈,最后退出程序。
--切记,一定是不可恢复的错误,才调用
-panic!
处理,你总不想系统仅仅因为用户随便传入一个非法参数就崩溃吧?所以,只有当你不知道该如何处理时,再去调用 panic!.
首先,来调用一下 panic!
,这里使用了最简单的代码实现,实际上你在程序的任何地方都可以这样调用:
-fn main() { - panic!("crash and burn"); -} -
运行后输出:
-thread 'main' panicked at 'crash and burn', src/main.rs:2:5
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-以上信息包含了两条重要信息:
-main
函数所在的线程崩溃了,发生的代码位置是 src/main.rs
中的第 2 行第 5 个字符(去除该行前面的空字符)RUST_BACKTRACE=1 cargo run
$env:RUST_BACKTRACE=1 ; cargo run
下面让我们针对第二点进行详细展开讲解。
-在真实场景中,错误往往涉及到很长的调用链甚至会深入第三方库,如果没有栈展开技术,错误将难以跟踪处理,下面我们来看一个真实的崩溃例子:
--fn main() { - let v = vec![1, 2, 3]; - - v[99]; -} -
上面的代码很简单,数组只有 3
个元素,我们却尝试去访问它的第 100
号元素(数组索引从 0
开始),那自然会崩溃。
我们的读者里不乏正义之士,此时肯定要质疑,一个简单的数组越界访问,为何要直接让程序崩溃?是不是有些小题大作了?
-如果有过 C 语言的经验,即使你越界了,问题不大,我依然尝试去访问,至于这个值是不是你想要的(100
号内存地址也有可能有值,只不过是其它变量或者程序的!),抱歉,不归我管,我只负责取,你要负责管理好自己的索引访问范围。上面这种情况被称为缓冲区溢出,并可能会导致安全漏洞,例如攻击者可以通过索引来访问到数组后面不被允许的数据。
说实话,我宁愿程序崩溃,为什么?当你取到了一个不属于你的值,这在很多时候会导致程序上的逻辑 BUG! 有编程经验的人都知道这种逻辑上的 BUG 是多么难被发现和修复!因此程序直接崩溃,然后告诉我们问题发生的位置,最后我们对此进行修复,这才是最合理的软件开发流程,而不是把问题藏着掖着:
-thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-好的,现在成功知道问题发生的位置,但是如果我们想知道该问题之前经过了哪些调用环节,该怎么办?那就按照提示使用 RUST_BACKTRACE=1 cargo run
或 $env:RUST_BACKTRACE=1 ; cargo run
来再一次运行程序:
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
-stack backtrace:
- 0: rust_begin_unwind
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/std/src/panicking.rs:517:5
- 1: core::panicking::panic_fmt
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/panicking.rs:101:14
- 2: core::panicking::panic_bounds_check
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/panicking.rs:77:5
- 3: <usize as core::slice::index::SliceIndex<[T]>>::index
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/slice/index.rs:184:10
- 4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/slice/index.rs:15:9
- 5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/alloc/src/vec/mod.rs:2465:9
- 6: world_hello::main
- at ./src/main.rs:4:5
- 7: core::ops::function::FnOnce::call_once
- at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/ops/function.rs:227:5
-note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
-
-上面的代码就是一次栈展开(也称栈回溯),它包含了函数调用的顺序,当然按照逆序排列:最近调用的函数排在列表的最上方。因为咱们的 main
函数基本是最先调用的函数了,所以排在了倒数第二位,还有一个关注点,排在最顶部最后一个调用的函数是 rust_begin_unwind
,该函数的目的就是进行栈展开,呈现这些列表信息给我们。
要获取到栈回溯信息,你还需要开启 debug
标志,该标志在使用 cargo run
或者 cargo build
时自动开启(这两个操作默认是 Debug
运行方式)。同时,栈展开信息在不同操作系统或者 Rust 版本上也有所不同。
当出现 panic!
时,程序提供了两种方式来处理终止流程:栈展开和直接终止。
其中,默认的方式就是 栈展开
,这意味着 Rust 会回溯栈上数据和函数调用,因此也意味着更多的善后工作,好处是可以给出充分的报错信息和栈调用信息,便于事后的问题复盘。直接终止
,顾名思义,不清理数据就直接退出程序,善后工作交与操作系统来负责。
对于绝大多数用户,使用默认选择是最好的,但是当你关心最终编译出的二进制可执行文件大小时,那么可以尝试去使用直接终止的方式,例如下面的配置修改 Cargo.toml
文件,实现在 release
模式下遇到 panic
直接终止:
--#![allow(unused)] -fn main() { -[profile.release] -panic = 'abort' -} -
panic
后,程序是否会终止?长话短说,如果是 main
线程,则程序会终止,如果是其它子线程,该线程会终止,但是不会影响 main
线程。因此,尽量不要在 main
线程中做太多任务,将这些任务交由子线程去做,就算子线程 panic
也不会导致整个程序的结束。
具体解析见 panic 原理剖析。
-下面让我们大概罗列下何时适合使用 panic
,也许经过之前的学习,你已经能够对 panic
的使用有了自己的看法,但是我们还是会罗列一些常见的用法来加深你的理解。
先来一点背景知识,在前面章节我们粗略讲过 Result<T, E>
这个枚举类型,它是用来表示函数的返回结果:
--#![allow(unused)] -fn main() { -enum Result<T, E> { - Ok(T), - Err(E), -} -} -
当没有错误发生时,函数返回一个用 Result
类型包裹的值 Ok(T)
,当错误时,返回一个 Err(E)
。对于 Result
返回我们有很多处理方法,最简单粗暴的就是 unwrap
和 expect
,这两个函数非常类似,我们以 unwrap
举例:
--#![allow(unused)] -fn main() { -use std::net::IpAddr; -let home: IpAddr = "127.0.0.1".parse().unwrap(); -} -
上面的 parse
方法试图将字符串 "127.0.0.1"
解析为一个 IP 地址类型 IpAddr
,它返回一个 Result<IpAddr, E>
类型,如果解析成功,则把 Ok(IpAddr)
中的值赋给 home
,如果失败,则不处理 Err(E)
,而是直接 panic
。
因此 unwrap
简而言之:成功则返回值,失败则 panic
,总之不进行任何错误处理。
这几个场景下,需要快速地搭建代码,错误处理会拖慢编码的速度,也不是特别有必要,因此通过 unwrap
、expect
等方法来处理是最快的。
同时,当我们回头准备做错误处理时,可以全局搜索这些方法,不遗漏地进行替换。
-因为 panic
的触发方式比错误处理要简单,因此可以让代码更清晰,可读性也更加好,当我们的代码注定是正确时,你可以用 unwrap
等方法直接进行处理,反正也不可能 panic
:
--#![allow(unused)] -fn main() { -use std::net::IpAddr; -let home: IpAddr = "127.0.0.1".parse().unwrap(); -} -
例如上面的例子,"127.0.0.1"
就是 ip
地址,因此我们知道 parse
方法一定会成功,那么就可以直接用 unwrap
方法进行处理。
当然,如果该字符串是来自于用户输入,那在实际项目中,就必须用错误处理的方式,而不是 unwrap
,否则你的程序一天要崩溃几十万次吧!
有害状态大概分为几类:
-当错误预期会出现时,返回一个错误较为合适,例如解析器接收到格式错误的数据,HTTP 请求接收到错误的参数甚至该请求内的任何错误(不会导致整个程序有问题,只影响该次请求)。因为错误是可预期的,因此也是可以处理的。
-当启动时某个流程发生了错误,对后续代码的运行造成了影响,那么就应该使用 panic
,而不是处理错误后继续运行,当然你可以通过重试的方式来继续。
上面提到过,数组访问越界,就要 panic
的原因,这个就是属于内存安全的范畴,一旦内存访问不安全,那么我们就无法保证自己的程序会怎么运行下去,也无法保证逻辑和数据的正确性。
本来不想写这块儿内容,因为真的难写,但是转念一想,既然号称圣经,那么本书就得与众不同,避重就轻显然不是该有的态度。
-当调用 panic!
宏时,它会
panic
信息,然后使用该信息作为参数,调用 std::panic::panic_any()
函数panic_any
会检查应用是否使用了 panic hook
,如果使用了,该 hook
函数就会被调用(hook
是一个钩子函数,是外部代码设置的,用于在 panic
触发时,执行外部代码所需的功能)hook
函数返回后,当前的线程就开始进行栈展开:从 panic_any
开始,如果寄存器或者栈因为某些原因信息错乱了,那很可能该展开会发生异常,最终线程会直接停止,展开也无法继续进行catching
的帧(通过 std::panic::catch_unwind()
函数标记),此时用户提供的 catch
函数会被调用,展开也随之停止:当然,如果 catch
选择在内部调用 std::panic::resume_unwind()
函数,则展开还会继续。还有一种情况,在展开过程中,如果展开本身 panic
了,那展开线程会终止,展开也随之停止。
一旦线程展开被终止或者完成,最终的输出结果是取决于哪个线程 panic
:对于 main
线程,操作系统提供的终止功能 core::intrinsics::abort()
会被调用,最终结束当前的 panic
进程;如果是其它子线程,那么子线程就会简单的终止,同时信息会在稍后通过 std::thread::join()
进行收集。
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
还记得上一节中,提到的关于文件读取的思考题吧?当时我们解决了读取文件时遇到不可恢复错误该怎么处理的问题,现在来看看,读取过程中,正常返回和遇到可以恢复的错误时该如何处理。
-假设,我们有一台消息服务器,每个用户都通过 websocket 连接到该服务器来接收和发送消息,该过程就涉及到 socket 文件的读写,那么此时,如果一个用户的读写发生了错误,显然不能直接 panic
,否则服务器会直接崩溃,所有用户都会断开连接,因此我们需要一种更温和的错误处理方式:Result<T, E>
。
之前章节有提到过,Result<T, E>
是一个枚举类型,定义如下:
--#![allow(unused)] -fn main() { -enum Result<T, E> { - Ok(T), - Err(E), -} -} -
泛型参数 T
代表成功时存入的正确值的类型,存放方式是 Ok(T)
,E
代表错误时存入的错误值,存放方式是 Err(E)
,枯燥的讲解永远不及代码生动准确,因此先来看下打开文件的例子:
-use std::fs::File; - -fn main() { - let f = File::open("hello.txt"); -} -
以上 File::open
返回一个 Result
类型,那么问题来了:
--如何获知变量类型或者函数的返回类型
-有几种常用的方式,此处更推荐第二种方法:
--
-- 第一种是查询标准库或者三方库文档,搜索
-File
,然后找到它的open
方法- 在 Rust IDE 章节,我们推荐了
-VSCode
IDE 和rust-analyzer
插件,如果你成功安装的话,那么就可以在VSCode
中很方便的通过代码跳转的方式查看代码,同时rust-analyzer
插件还会对代码中的类型进行标注,非常方便好用!- 你还可以尝试故意标记一个错误的类型,然后让编译器告诉你:
-
--#![allow(unused)] -fn main() { -let f: u32 = File::open("hello.txt"); -} -
错误提示如下:
-error[E0308]: mismatched types
- --> src/main.rs:4:18
- |
-4 | let f: u32 = File::open("hello.txt");
- | ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
-`std::result::Result`
- |
- = note: expected type `u32`
- found type `std::result::Result<std::fs::File, std::io::Error>`
-
-上面代码,故意将 f
类型标记成整形,编译器立刻不乐意了,你是在忽悠我吗?打开文件操作返回一个整形?来,大哥来告诉你返回什么:std::result::Result<std::fs::File, std::io::Error>
,我的天呐,怎么这么长的类型!
别慌,其实很简单,首先 Result
本身是定义在 std::result
中的,但是因为 Result
很常用,所以就被包含在了 prelude
中(将常用的东东提前引入到当前作用域内),因此无需手动引入 std::result::Result
,那么返回类型可以简化为 Result<std::fs::File,std::io::Error>
,你看看是不是很像标准的 Result<T, E>
枚举定义?只不过 T
被替换成了具体的类型 std::fs::File
,是一个文件句柄类型,E
被替换成 std::io::Error
,是一个 IO 错误类型。
这个返回值类型说明 File::open
调用如果成功则返回一个可以进行读写的文件句柄,如果失败,则返回一个 IO 错误:文件不存在或者没有访问文件的权限等。总之 File::open
需要一个方式告知调用者是成功还是失败,并同时返回具体的文件句柄(成功)或错误信息(失败),万幸的是,这些信息可以通过 Result
枚举提供:
-use std::fs::File; - -fn main() { - let f = File::open("hello.txt"); - - let f = match f { - Ok(file) => file, - Err(error) => { - panic!("Problem opening the file: {:?}", error) - }, - }; -} -
代码很清晰,对打开文件后的 Result<T, E>
类型进行匹配取值,如果是成功,则将 Ok(file)
中存放的的文件句柄 file
赋值给 f
,如果失败,则将 Err(error)
中存放的错误信息 error
使用 panic
抛出来,进而结束程序,这非常符合上文提到过的 panic
使用场景。
好吧,也没有那么合理 :)
-直接 panic
还是过于粗暴,因为实际上 IO 的错误有很多种,我们需要对部分错误进行特殊处理,而不是所有错误都直接崩溃:
-use std::fs::File; -use std::io::ErrorKind; - -fn main() { - let f = File::open("hello.txt"); - - let f = match f { - Ok(file) => file, - Err(error) => match error.kind() { - ErrorKind::NotFound => match File::create("hello.txt") { - Ok(fc) => fc, - Err(e) => panic!("Problem creating the file: {:?}", e), - }, - other_error => panic!("Problem opening the file: {:?}", other_error), - }, - }; -} -
上面代码在匹配出 error
后,又对 error
进行了详细的匹配解析,最终结果:
ErrorKind::NotFound
,就创建文件,这里创建文件File::create
也是返回 Result
,因此继续用 match
对其结果进行处理:创建成功,将新的文件句柄赋值给 f
,如果失败,则 panic
panic
虽然很清晰,但是代码还是有些啰嗦,我们会在简化错误处理一章重点讲述如何写出更优雅的错误。
-上一节中,已经看到过这两兄弟的简单介绍,这里再来回顾下。
-在不需要处理错误的场景,例如写原型、示例时,我们不想使用 match
去匹配 Result<T, E>
以获取其中的 T
值,因为 match
的穷尽匹配特性,你总要去处理下 Err
分支。那么有没有办法简化这个过程?有,答案就是 unwrap
和 expect
。
它们的作用就是,如果返回成功,就将 Ok(T)
中的值取出来,如果失败,就直接 panic
,真的勇士绝不多 BB,直接崩溃。
-use std::fs::File; - -fn main() { - let f = File::open("hello.txt").unwrap(); -} -
如果调用这段代码时 hello.txt 文件不存在,那么 unwrap
就将直接 panic
:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-expect
跟 unwrap
很像,也是遇到错误直接 panic
, 但是会带上自定义的错误提示信息,相当于重载了错误打印的函数:
-use std::fs::File; - -fn main() { - let f = File::open("hello.txt").expect("Failed to open hello.txt"); -} -
报错如下:
-thread 'main' panicked at 'Failed to open hello.txt: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
-note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
-
-可以看出,expect
相比 unwrap
能提供更精确的错误信息,在有些场景也会更加实用。
咱们的程序几乎不太可能只有 A->B
形式的函数调用,一个设计良好的程序,一个功能涉及十几层的函数调用都有可能。而错误处理也往往不是哪里调用出错,就在哪里处理,实际应用中,大概率会把错误层层上传然后交给调用链的上游函数进行处理,错误传播将极为常见。
例如以下函数从文件中读取用户名,然后将结果进行返回:
---#![allow(unused)] -fn main() { -use std::fs::File; -use std::io::{self, Read}; - -fn read_username_from_file() -> Result<String, io::Error> { - // 打开文件,f是`Result<文件句柄,io::Error>` - let f = File::open("hello.txt"); - - let mut f = match f { - // 打开文件成功,将file句柄赋值给f - Ok(file) => file, - // 打开文件失败,将错误返回(向上传播) - Err(e) => return Err(e), - }; - // 创建动态字符串s - let mut s = String::new(); - // 从f文件句柄读取数据并写入s中 - match f.read_to_string(&mut s) { - // 读取成功,返回Ok封装的字符串 - Ok(_) => Ok(s), - // 将错误向上传播 - Err(e) => Err(e), - } -} -} -
有几点值得注意:
-Result<String, io::Error>
类型,当读取用户名成功时,返回 Ok(String)
,失败时,返回 Err(io:Error)
File::open
和 f.read_to_string
返回的 Result<T, E>
中的 E
就是 io::Error
由此可见,该函数将 io::Error
的错误往上进行传播,该函数的调用者最终会对 Result<String,io::Error>
进行再处理,至于怎么处理就是调用者的事,如果是错误,它可以选择继续向上传播错误,也可以直接 panic
,亦或将具体的错误原因包装后写入 socket 中呈现给终端用户。
但是上面的代码也有自己的问题,那就是太长了(优秀的程序员身上的优点极多,其中最大的优点就是懒),我自认为也有那么一点点优秀,因此见不得这么啰嗦的代码,下面咱们来讲讲如何简化它。
-大明星出场,必须得有排面,来看看 ?
的排面:
--#![allow(unused)] -fn main() { -use std::fs::File; -use std::io; -use std::io::Read; - -fn read_username_from_file() -> Result<String, io::Error> { - let mut f = File::open("hello.txt")?; - let mut s = String::new(); - f.read_to_string(&mut s)?; - Ok(s) -} -} -
看到没,这就是排面,相比前面的 match
处理错误的函数,代码直接减少了一半不止,但是,一山更比一山难,看不懂啊!
其实 ?
就是一个宏,它的作用跟上面的 match
几乎一模一样:
--#![allow(unused)] -fn main() { -let mut f = match f { - // 打开文件成功,将file句柄赋值给f - Ok(file) => file, - // 打开文件失败,将错误返回(向上传播) - Err(e) => return Err(e), -}; -} -
如果结果是 Ok(T)
,则把 T
赋值给 f
,如果结果是 Err(E)
,则返回该错误,所以 ?
特别适合用来传播错误。
虽然 ?
和 match
功能一致,但是事实上 ?
会更胜一筹。何解?
想象一下,一个设计良好的系统中,肯定有自定义的错误特征,错误之间很可能会存在上下级关系,例如标准库中的 std::io::Error
和 std::error::Error
,前者是 IO 相关的错误结构体,后者是一个最最通用的标准错误特征,同时前者实现了后者,因此 std::io::Error
可以转换为 std:error::Error
。
明白了以上的错误转换,?
的更胜一筹就很好理解了,它可以自动进行类型提升(转换):
--#![allow(unused)] -fn main() { -fn open_file() -> Result<File, Box<dyn std::error::Error>> { - let mut f = File::open("hello.txt")?; - Ok(f) -} -} -
上面代码中 File::open
报错时返回的错误是 std::io::Error
类型,但是 open_file
函数返回的错误类型是 std::error::Error
的特征对象,可以看到一个错误类型通过 ?
返回后,变成了另一个错误类型,这就是 ?
的神奇之处。
根本原因是在于标准库中定义的 From
特征,该特征有一个方法 from
,用于把一个类型转成另外一个类型,?
可以自动调用该方法,然后进行隐式类型转换。因此只要函数返回的错误 ReturnError
实现了 From<OtherError>
特征,那么 ?
就会自动把 OtherError
转换为 ReturnError
。
这种转换非常好用,意味着你可以用一个大而全的 ReturnError
来覆盖所有错误类型,只需要为各种子错误类型实现这种转换即可。
强中自有强中手,一码更比一码短:
---#![allow(unused)] -fn main() { -use std::fs::File; -use std::io; -use std::io::Read; - -fn read_username_from_file() -> Result<String, io::Error> { - let mut s = String::new(); - - File::open("hello.txt")?.read_to_string(&mut s)?; - - Ok(s) -} -} -
瞧见没? ?
还能实现链式调用,File::open
遇到错误就返回,没有错误就将 Ok
中的值取出来用于下一个方法调用,简直太精妙了,从 Go 语言过来的我,内心狂喜(其实学 Rust 的苦和痛我才不会告诉你们)。
不仅有更强,还要有最强,我不信还有人比我更短(不要误解):
---#![allow(unused)] -fn main() { -use std::fs; -use std::io; - -fn read_username_from_file() -> Result<String, io::Error> { - // read_to_string是定义在std::io中的方法,因此需要在上面进行引用 - fs::read_to_string("hello.txt") -} -} -
从文件读取数据到字符串中,是比较常见的操作,因此 Rust 标准库为我们提供了 fs::read_to_string
函数,该函数内部会打开一个文件、创建 String
、读取文件内容最后写入字符串并返回,因为该函数其实与本章讲的内容关系不大,因此放在最后来讲,其实只是我想震你们一下 :)
?
不仅仅可以用于 Result
的传播,还能用于 Option
的传播,再来回忆下 Option
的定义:
--#![allow(unused)] -fn main() { -pub enum Option<T> { - Some(T), - None -} -} -
Result
通过 ?
返回错误,那么 Option
就通过 ?
返回 None
:
--#![allow(unused)] -fn main() { -fn first(arr: &[i32]) -> Option<&i32> { - let v = arr.get(0)?; - Some(v) -} -} -
上面的函数中,arr.get
返回一个 Option<&i32>
类型,因为 ?
的使用,如果 get
的结果是 None
,则直接返回 None
,如果是 Some(&i32)
,则把里面的值赋给 v
。
其实这个函数有些画蛇添足,我们完全可以写出更简单的版本:
---#![allow(unused)] -fn main() { -fn first(arr: &[i32]) -> Option<&i32> { - arr.get(0) -} -} -
有一句话怎么说?没有需求,制造需求也要上……大家别跟我学习,这是软件开发大忌。只能用代码洗洗眼了:
---#![allow(unused)] -fn main() { -fn last_char_of_first_line(text: &str) -> Option<char> { - text.lines().next()?.chars().last() -} -} -
上面代码展示了在链式调用中使用 ?
提前返回 None
的用法, .next
方法返回的是 Option
类型:如果返回 Some(&str)
,那么继续调用 chars
方法,如果返回 None
,则直接从整个函数中返回 None
,不再继续进行链式调用。
初学者在用 ?
时,老是会犯错,例如写出这样的代码:
--#![allow(unused)] -fn main() { -fn first(arr: &[i32]) -> Option<&i32> { - arr.get(0)? -} -} -
这段代码无法通过编译,切记:?
操作符需要一个变量来承载正确的值,这个函数只会返回 Some(&i32)
或者 None
,只有错误值能直接返回,正确的值不行,所以如果数组中存在 0 号元素,那么函数第二行使用 ?
后的返回类型为 &i32
而不是 Some(&i32)
。因此 ?
只能用于以下形式:
let v = xxx()?;
xxx()?.yyy()?;
在了解了 ?
的使用限制后,这段代码你很容易看出它无法编译:
-use std::fs::File; - -fn main() { - let f = File::open("hello.txt")?; -} -
运行后会报错:
-$ cargo run
- ...
- the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
- --> src/main.rs:4:48
- |
-3 | fn main() {
- | --------- this function should return `Result` or `Option` to accept `?`
-4 | let greeting_file = File::open("hello.txt")?;
- | ^ cannot use the `?` operator in a function that returns `()`
- |
- = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
-
-因为 ?
要求 Result<T, E>
形式的返回值,而 main
函数的返回是 ()
,因此无法满足,那是不是就无解了呢?
实际上 Rust 还支持另外一种形式的 main
函数:
-use std::error::Error; -use std::fs::File; - -fn main() -> Result<(), Box<dyn Error>> { - let f = File::open("hello.txt")?; - - Ok(()) -} -
这样就能使用 ?
提前返回了,同时我们又一次看到了Box<dyn Error>
特征对象,因为 std::error:Error
是 Rust 中抽象层次最高的错误,其它标准库中的错误都实现了该特征,因此我们可以用该特征对象代表一切错误,就算 main
函数中调用任何标准库函数发生错误,都可以通过 Box<dyn Error>
这个特征对象进行返回。
至于 main
函数可以有多种返回值,那是因为实现了 std::process::Termination 特征,目前为止该特征还没进入稳定版 Rust 中,也许未来你可以为自己的类型实现该特征!
在 ?
横空出世之前( Rust 1.13 ),Rust 开发者还可以使用 try!
来处理错误,该宏的大致定义如下:
--#![allow(unused)] -fn main() { -macro_rules! try { - ($e:expr) => (match $e { - Ok(val) => val, - Err(err) => return Err(::std::convert::From::from(err)), - }); -} -} -
简单看一下与 ?
的对比:
--#![allow(unused)] -fn main() { -// `?` -let x = function_with_error()?; // 若返回 Err, 则立刻返回;若返回 Ok(255),则将 x 的值设置为 255 - -// `try!()` -let x = try!(function_with_error()); -} -
可以看出 ?
的优势非常明显,何况 ?
还能做链式调用。
总之,try!
作为前浪已经死在了沙滩上,在当前版本中,我们要尽量避免使用 try!。
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
特征之于 Rust 更甚于接口之于其他语言,因此特征在 Rust 中很重要也相对较为复杂,我们决定把特征分为两篇进行介绍,第一篇在之前已经讲过,现在就是第二篇:关于特征的进阶篇,会讲述一些不常用到但是你该了解的特性。
-在方法一章中,我们讲到了关联函数,但是实际上关联类型和关联函数并没有任何交集,虽然它们的名字有一半的交集。
-关联类型是在特征定义的语句块中,申明一个自定义类型,这样就可以在特征的方法签名中使用该类型:
---#![allow(unused)] -fn main() { -pub trait Iterator { - type Item; - - fn next(&mut self) -> Option<Self::Item>; -} -} -
以上是标准库中的迭代器特征 Iterator
,它有一个 Item
关联类型,用于替代遍历的值的类型。
同时,next
方法也返回了一个 Item
类型,不过使用 Option
枚举进行了包裹,假如迭代器中的值是 i32
类型,那么调用 next
方法就将获取一个 Option<i32>
的值。
还记得 Self
吧?在之前的章节提到过, Self
用来指代当前调用者的具体类型,那么 Self::Item
就用来指代该类型实现中定义的 Item
类型:
-impl Iterator for Counter { - type Item = u32; - - fn next(&mut self) -> Option<Self::Item> { - // --snip-- - } -} - -fn main() { - let c = Counter{..} - c.next() -} -
在上述代码中,我们为 Counter
类型实现了 Iterator
特征,变量 c
是特征 Iterator
的实例,也是 next
方法的调用者。 结合之前的黑体内容可以得出:对于 next
方法而言,Self
是调用者 c
的具体类型: Counter
,而 Self::Item
是 Counter
中定义的 Item
类型: u32
。
聪明的读者之所以聪明,是因为你们喜欢联想和举一反三,同时你们也喜欢提问:为何不用泛型,例如如下代码:
---#![allow(unused)] -fn main() { -pub trait Iterator<Item> { - fn next(&mut self) -> Option<Item>; -} -} -
答案其实很简单,为了代码的可读性,当你使用了泛型后,你需要在所有地方都写 Iterator<Item>
,而使用了关联类型,你只需要写 Iterator
,当类型定义复杂时,这种写法可以极大的增加可读性:
--#![allow(unused)] -fn main() { -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
要简单的多,而且含义清晰。
再例如,如果使用泛型,你将得到以下的代码:
---#![allow(unused)] -fn main() { -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> {...} -} -
可以看到,由于使用了泛型,导致函数头部也必须增加泛型的声明,而使用关联类型,将得到可读性好得多的代码:
---#![allow(unused)] -fn main() { -trait Container{ - type A; - type B; - fn contains(&self, a: &Self::A, b: &Self::B) -> bool; -} - -fn difference<C: Container>(container: &C) {} -} -
当使用泛型类型参数时,可以为其指定一个默认的具体类型,例如标准库中的 std::ops::Add
特征:
--#![allow(unused)] -fn main() { -trait Add<RHS=Self> { - type Output; - - fn add(self, rhs: RHS) -> Self::Output; -} -} -
它有一个泛型参数 RHS
,但是与我们以往的用法不同,这里它给 RHS
一个默认值,也就是当用户不指定 RHS
时,默认使用两个同样类型的值进行相加,然后返回一个关联类型 Output
。
可能上面那段不太好理解,下面我们用代码来举例:
--use std::ops::Add; - -#[derive(Debug, PartialEq)] -struct Point { - x: i32, - y: i32, -} - -impl Add for Point { - type Output = Point; - - fn add(self, other: Point) -> Point { - Point { - x: self.x + other.x, - y: self.y + other.y, - } - } -} - -fn main() { - assert_eq!(Point { x: 1, y: 0 } + Point { x: 2, y: 3 }, - Point { x: 3, y: 3 }); -} -
上面的代码主要干了一件事,就是为 Point
结构体提供 +
的能力,这就是运算符重载,不过 Rust 并不支持创建自定义运算符,你也无法为所有运算符进行重载,目前来说,只有定义在 std::ops
中的运算符才能进行重载。
跟 +
对应的特征是 std::ops::Add
,我们在之前也看过它的定义 trait Add<RHS=Self>
,但是上面的例子中并没有为 Point
实现 Add<RHS>
特征,而是实现了 Add
特征(没有默认泛型类型参数),这意味着我们使用了 RHS
的默认类型,也就是 Self
。换句话说,我们这里定义的是两个相同的 Point
类型相加,因此无需指定 RHS
。
与上面的例子相反,下面的例子,我们来创建两个不同类型的相加:
---#![allow(unused)] -fn main() { -use std::ops::Add; - -struct Millimeters(u32); -struct Meters(u32); - -impl Add<Meters> for Millimeters { - type Output = Millimeters; - - fn add(self, other: Meters) -> Millimeters { - Millimeters(self.0 + (other.0 * 1000)) - } -} -} -
这里,是进行 Millimeters + Meters
两种数据类型的 +
操作,因此此时不能再使用默认的 RHS
,否则就会变成 Millimeters + Millimeters
的形式。使用 Add<Meters>
可以将 RHS
指定为 Meters
,那么 fn add(self, rhs: RHS)
自然而言的变成了 Millimeters
和 Meters
的相加。
默认类型参数主要用于两个方面:
-之前的例子就是第一点,虽然效果也就那样。在 +
左右两边都是同样类型时,只需要 impl Add
即可,否则你需要 impl Add<SOME_TYPE>
,嗯,会多写几个字:)
对于第二点,也很好理解,如果你在一个复杂类型的基础上,新引入一个泛型参数,可能需要修改很多地方,但是如果新引入的泛型参数有了默认类型,情况就会好很多,添加泛型参数后,使用这个类型的代码需要逐个在类型提示部分添加泛型参数,就很麻烦;但是有了默认参数(且默认参数取之前的实现里假设的值的情况下)之后,原有的使用这个类型的代码就不需要做改动了。
-归根到底,默认泛型参数,是有用的,但是大多数情况下,咱们确实用不到,当需要用到时,大家再回头来查阅本章即可,手上有剑,心中不慌。
-不同特征拥有同名的方法是很正常的事情,你没有任何办法阻止这一点;甚至除了特征上的同名方法外,在你的类型上,也有同名方法:
---#![allow(unused)] -fn main() { -trait Pilot { - fn fly(&self); -} - -trait Wizard { - fn fly(&self); -} - -struct Human; - -impl Pilot for Human { - fn fly(&self) { - println!("This is your captain speaking."); - } -} - -impl Wizard for Human { - fn fly(&self) { - println!("Up!"); - } -} - -impl Human { - fn fly(&self) { - println!("*waving arms furiously*"); - } -} -} -
这里,不仅仅两个特征 Pilot
和 Wizard
有 fly
方法,就连实现那两个特征的 Human
单元结构体,也拥有一个同名方法 fly
(这世界怎么了,非要这么卷吗?程序员何苦难为程序员,哎)。
既然代码已经不可更改,那下面我们来讲讲该如何调用这些 fly
方法。
当调用 Human
实例的 fly
时,编译器默认调用该类型中定义的方法:
-fn main() { - let person = Human; - person.fly(); -} -
这段代码会打印 *waving arms furiously*
,说明直接调用了类型上定义的方法。
为了能够调用两个特征的方法,需要使用显式调用的语法:
--fn main() { - let person = Human; - Pilot::fly(&person); // 调用Pilot特征上的方法 - Wizard::fly(&person); // 调用Wizard特征上的方法 - person.fly(); // 调用Human类型自身的方法 -} -
运行后依次输出:
-This is your captain speaking.
-Up!
-*waving arms furiously*
-
-因为 fly
方法的参数是 self
,当显式调用时,编译器就可以根据调用的类型( self
的类型)决定具体调用哪个方法。
这个时候问题又来了,如果方法没有 self
参数呢?稍等,估计有读者会问:还有方法没有 self
参数?看到这个疑问,作者的眼泪不禁流了下来,大明湖畔的关联函数,你还记得嘛?
但是成年人的世界,就算再伤心,事还得做,咱们继续:
--trait Animal { - fn baby_name() -> String; -} - -struct Dog; - -impl Dog { - fn baby_name() -> String { - String::from("Spot") - } -} - -impl Animal for Dog { - fn baby_name() -> String { - String::from("puppy") - } -} - -fn main() { - println!("A baby dog is called a {}", Dog::baby_name()); -} -
就像人类妈妈会给自己的宝宝起爱称一样,狗妈妈也会。狗妈妈称呼自己的宝宝为Spot,其它动物称呼狗宝宝为puppy,这个时候假如有动物不知道该如何称呼狗宝宝,它需要查询一下。
-Dog::baby_name()
的调用方式显然不行,因为这只是狗妈妈对宝宝的爱称,可能你会想到通过下面的方式查询其他动物对狗狗的称呼:
-fn main() { - println!("A baby dog is called a {}", Animal::baby_name()); -} -
铛铛,无情报错了:
---#![allow(unused)] -fn main() { -error[E0283]: type annotations needed // 需要类型注释 - --> src/main.rs:20:43 - | -20 | println!("A baby dog is called a {}", Animal::baby_name()); - | ^^^^^^^^^^^^^^^^^ cannot infer type // 无法推断类型 - | - = note: cannot satisfy `_: Animal` -} -
因为单纯从 Animal::baby_name()
上,编译器无法得到任何有效的信息:实现 Animal
特征的类型可能有很多,你究竟是想获取哪个动物宝宝的名称?狗宝宝?猪宝宝?还是熊宝宝?
此时,就需要使用完全限定语法。
-完全限定语法是调用函数最为明确的方式:
--fn main() { - println!("A baby dog is called a {}", <Dog as Animal>::baby_name()); -} -
在尖括号中,通过 as
关键字,我们向 Rust 编译器提供了类型注解,也就是 Animal
就是 Dog
,而不是其他动物,因此最终会调用 impl Animal for Dog
中的方法,获取到其它动物对狗宝宝的称呼:puppy。
言归正题,完全限定语法定义为:
---#![allow(unused)] -fn main() { -<Type as Trait>::function(receiver_if_method, next_arg, ...); -} -
上面定义中,第一个参数是方法接收器 receiver
(三种 self
),只有方法才拥有,例如关联函数就没有 receiver
。
完全限定语法可以用于任何函数或方法调用,那么我们为何很少用到这个语法?原因是 Rust 编译器能根据上下文自动推导出调用的路径,因此大多数时候,我们都无需使用完全限定语法。只有当存在多个同名函数或方法,且 Rust 无法区分出你想调用的目标函数时,该用法才能真正有用武之地。
-有时,我们会需要让某个特征 A 能使用另一个特征 B 的功能(另一种形式的特征约束),这种情况下,不仅仅要为类型实现特征 A,还要为类型实现特征 B 才行,这就是 supertrait
(实在不知道该如何翻译,有大佬指导下嘛?)
例如有一个特征 OutlinePrint
,它有一个方法,能够对当前的实现类型进行格式化输出:
--#![allow(unused)] -fn main() { -use std::fmt::Display; - -trait OutlinePrint: Display { - fn outline_print(&self) { - let output = self.to_string(); - let len = output.len(); - println!("{}", "*".repeat(len + 4)); - println!("*{}*", " ".repeat(len + 2)); - println!("* {} *", output); - println!("*{}*", " ".repeat(len + 2)); - println!("{}", "*".repeat(len + 4)); - } -} -} -
等等,这里有一个眼熟的语法: OutlinePrint: Display
,感觉很像之前讲过的特征约束,只不过用在了特征定义中而不是函数的参数中,是的,在某种意义上来说,这和特征约束非常类似,都用来说明一个特征需要实现另一个特征,这里就是:如果你想要实现 OutlinePrint
特征,首先你需要实现 Display
特征。
想象一下,假如没有这个特征约束,那么 self.to_string
还能够调用吗( to_string
方法会为实现 Display
特征的类型自动实现)?编译器肯定是不愿意的,会报错说当前作用域中找不到用于 &Self
类型的方法 to_string
:
--#![allow(unused)] -fn main() { -struct Point { - x: i32, - y: i32, -} - -impl OutlinePrint for Point {} -} -
因为 Point
没有实现 Display
特征,会得到下面的报错:
error[E0277]: the trait bound `Point: std::fmt::Display` is not satisfied
- --> src/main.rs:20:6
- |
-20 | impl OutlinePrint for Point {}
- | ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter;
-try using `:?` instead if you are using a format string
- |
- = help: the trait `std::fmt::Display` is not implemented for `Point`
-
-既然我们有求于编译器,那只能选择满足它咯:
---#![allow(unused)] -fn main() { -use std::fmt; - -impl fmt::Display for Point { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} -} -
上面代码为 Point
实现了 Display
特征,那么 to_string
方法也将自动实现:最终获得字符串是通过这里的 fmt
方法获得的。
在特征章节中,有提到孤儿规则,简单来说,就是特征或者类型必需至少有一个是本地的,才能在此类型上定义特征。
-这里提供一个办法来绕过孤儿规则,那就是使用newtype 模式,简而言之:就是为一个元组结构体创建新类型。该元组结构体封装有一个字段,该字段就是希望实现特征的具体类型。
-该封装类型是本地的,因此我们可以为此类型实现外部的特征。
-newtype
不仅仅能实现以上的功能,而且它在运行时没有任何性能损耗,因为在编译期,该类型会被自动忽略。
下面来看一个例子,我们有一个动态数组类型: Vec<T>
,它定义在标准库中,还有一个特征 Display
,它也定义在标准库中,如果没有 newtype
,我们是无法为 Vec<T>
实现 Display
的:
error[E0117]: only traits defined in the current crate can be implemented for arbitrary types
---> src/main.rs:5:1
-|
-5 | impl<T> std::fmt::Display for Vec<T> {
-| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^------
-| | |
-| | Vec is not defined in the current crate
-| impl doesn't use only types from inside the current crate
-|
-= note: define and implement a trait or new type instead
-
-编译器给了我们提示: define and implement a trait or new type instead
,重新定义一个特征,或者使用 new type
,前者当然不可行,那么来试试后者:
-use std::fmt; - -struct Wrapper(Vec<String>); - -impl fmt::Display for Wrapper { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "[{}]", self.0.join(", ")) - } -} - -fn main() { - let w = Wrapper(vec![String::from("hello"), String::from("world")]); - println!("w = {}", w); -} -
其中,struct Wrapper(Vec<String>)
就是一个元组结构体,它定义了一个新类型 Wrapper
,代码很简单,相信大家也很容易看懂。
既然 new type
有这么多好处,它有没有不好的地方呢?答案是肯定的。注意到我们怎么访问里面的数组吗?self.0.join(", ")
,是的,很啰嗦,因为需要先从 Wrapper
中取出数组: self.0
,然后才能执行 join
方法。
类似的,任何数组上的方法,你都无法直接调用,需要先用 self.0
取出数组,然后再进行调用。
当然,解决办法还是有的,要不怎么说 Rust 是极其强大灵活的编程语言!Rust 提供了一个特征叫 Deref
,实现该特征后,可以自动做一层类似类型转换的操作,可以将 Wrapper
变成 Vec<String>
来使用。这样就会像直接使用数组那样去使用 Wrapper
,而无需为每一个操作都添加上 self.0
。
同时,如果不想 Wrapper
暴露底层数组的所有方法,我们还可以为 Wrapper
去重载这些方法,实现隐藏的目的。
-- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
Go 语言在 2022 年,就要正式引入泛型,被视为在 1.0 版本后,语言特性发展迈出的一大步,为什么泛型这么重要?到底什么是泛型?Rust 的泛型有几种? -本章将一一为你讲解。
-我们在编程中,经常有这样的需求:用同一功能的函数处理不同类型的数据,例如两个数的加法,无论是整数还是浮点数,甚至是自定义类型,都能进行支持。在不支持泛型的编程语言中,通常需要为每一种类型编写一个函数:
--fn add_i8(a:i8, b:i8) -> i8 { - a + b -} -fn add_i32(a:i32, b:i32) -> i32 { - a + b -} -fn add_f64(a:f64, b:f64) -> f64 { - a + b -} - -fn main() { - println!("add i8: {}", add_i8(2i8, 3i8)); - println!("add i32: {}", add_i32(20, 30)); - println!("add f64: {}", add_f64(1.23, 1.23)); -} -
上述代码可以正常运行,但是很啰嗦,如果你要支持更多的类型,那么会更繁琐。程序员或多或少都有强迫症,一个好程序员的公认特征就是 —— 懒,这么勤快的写一大堆代码,显然不是咱们的优良传统,是不?
-在开始讲解 Rust 的泛型之前,先来看看什么是多态。
-在编程的时候,我们经常利用多态。通俗的讲,多态就是好比坦克的炮管,既可以发射普通弹药,也可以发射制导炮弹(导弹),也可以发射贫铀穿甲弹,甚至发射子母弹,没有必要为每一种炮弹都在坦克上分别安装一个专用炮管,即使生产商愿意,炮手也不愿意,累死人啊。所以在编程开发中,我们也需要这样“通用的炮管”,这个“通用的炮管”就是多态。
-实际上,泛型就是一种多态。泛型主要目的是为程序员提供编程的便利,减少代码的臃肿,同时可以极大地丰富语言本身的表达能力,为程序员提供了一个合适的炮管。想想,一个函数,可以代替几十个,甚至数百个函数,是一件多么让人兴奋的事情:
--fn add<T>(a:T, b:T) -> T { - a + b -} - -fn main() { - println!("add i8: {}", add(2i8, 3i8)); - println!("add i32: {}", add(20, 30)); - println!("add f64: {}", add(1.23, 1.23)); -} -
将之前的代码改成上面这样,就是 Rust 泛型的初印象,这段代码虽然很简洁,但是并不能编译通过,我们会在后面进行详细讲解,现在只要对泛型有个大概的印象即可。
-上面代码的 T
就是泛型参数,实际上在 Rust 中,泛型参数的名称你可以任意起,但是出于惯例,我们都用 T
( T
是 type
的首字母)来作为首选,这个名称越短越好,除非需要表达含义,否则一个字母是最完美的。
使用泛型参数,有一个先决条件,必需在使用前对其进行声明:
---#![allow(unused)] -fn main() { -fn largest<T>(list: &[T]) -> T { -} -
该泛型函数的作用是从列表中找出最大的值,其中列表中的元素类型为 T。首先 largest<T>
对泛型参数 T
进行了声明,然后才在函数参数中进行使用该泛型参数 list: &[T]
(还记得 &[T]
类型吧?这是数组切片)。
总之,我们可以这样理解这个函数定义:函数 largest
有泛型类型 T
,它有个参数 list
,其类型是元素为 T
的数组切片,最后,该函数返回值的类型也是 T
。
具体的泛型函数实现如下:
--fn largest<T>(list: &[T]) -> T { - let mut largest = list[0]; - - for &item in list.iter() { - if item > largest { - largest = item; - } - } - - largest -} - -fn main() { - let number_list = vec![34, 50, 25, 100, 65]; - - let result = largest(&number_list); - println!("The largest number is {}", result); - - let char_list = vec!['y', 'm', 'a', 'q']; - - let result = largest(&char_list); - println!("The largest char is {}", result); -} -
运行后报错:
-error[E0369]: binary operation `>` cannot be applied to type `T` // `>`操作符不能用于类型`T`
- --> src/main.rs:5:17
- |
-5 | if item > largest {
- | ---- ^ ------- T
- | |
- | T
- |
-help: consider restricting type parameter `T` // 考虑对T进行类型上的限制 :
- |
-1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
- | ++++++++++++++++++++++
-
-因为 T
可以是任何类型,但不是所有的类型都能进行比较,因此上面的错误中,编译器建议我们给 T
添加一个类型限制:使用 std::cmp::PartialOrd
特征(Trait)对 T
进行限制,特征在下一节会详细介绍,现在你只要理解,该特征的目的就是让类型实现可比较的功能。
还记得我们一开始的 add
泛型函数吗?如果你运行它,会得到以下的报错:
error[E0369]: cannot add `T` to `T` // 无法将 `T` 类型跟 `T` 类型进行相加
- --> src/main.rs:2:7
- |
-2 | a + b
- | - ^ - T
- | |
- | T
- |
-help: consider restricting type parameter `T`
- |
-1 | fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
- | +++++++++++++++++++++++++++
-
-同样的,不是所有 T
类型都能进行相加操作,因此我们需要用 std::ops::Add<Output = T>
对 T
进行限制:
--#![allow(unused)] -fn main() { -fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T { - a + b -} -} -
进行如上修改后,就可以正常运行。
-结构体中的字段类型也可以用泛型来定义,下面代码定义了一个坐标点 Point
,它可以存放任何类型的坐标值:
-struct Point<T> { - x: T, - y: T, -} - -fn main() { - let integer = Point { x: 5, y: 10 }; - let float = Point { x: 1.0, y: 4.0 }; -} -
这里有两点需要特别的注意:
-Point<T>
,接着就可以在结构体的字段类型中使用 T
来替代具体的类型第二点非常重要,如果使用不同的类型,那么它会导致下面代码的报错:
--struct Point<T> { - x: T, - y: T, -} - -fn main() { - let p = Point{x: 1, y :1.1}; -} -
错误如下:
-error[E0308]: mismatched types //类型不匹配
- --> src/main.rs:7:28
- |
-7 | let p = Point{x: 1, y :1.1};
- | ^^^ expected integer, found floating-point number //期望y是整数,但是却是浮点数
-
-
-当把 1
赋值给 x
时,变量 p
的 T
类型就被确定为整数类型,因此 y
也必须是整数类型,但是我们却给它赋予了浮点数,因此导致报错。
如果想让 x
和 y
既能类型相同,又能类型不同,就需要使用不同的泛型参数:
-struct Point<T,U> { - x: T, - y: U, -} -fn main() { - let p = Point{x: 1, y :1.1}; -} -
切记,所有的泛型参数都要提前声明:Point<T,U>
! 但是如果你的结构体变成这鬼样:struct Woo<T,U,V,W,X>
,那么你需要考虑拆分这个结构体,减少泛型参数的个数和代码复杂度。
提到枚举类型,Option
永远是第一个应该被想起来的,在之前的章节中,它也多次出现:
--#![allow(unused)] -fn main() { -enum Option<T> { - Some(T), - None, -} -} -
Option<T>
是一个拥有泛型 T
的枚举类型,它第一个成员是 Some(T)
,存放了一个类型为 T
的值。得益于泛型的引入,我们可以在任何一个需要返回值的函数中,去使用 Option<T>
枚举类型来做为返回值,用于返回一个任意类型的值 Some(T)
,或者没有值 None
。
对于枚举而言,卧龙凤雏永远是绕不过去的存在:如果是 Option
是卧龙,那么 Result
就一定是凤雏,得两者可得天下:
--#![allow(unused)] -fn main() { -enum Result<T, E> { - Ok(T), - Err(E), -} -} -
这个枚举和 Option
一样,主要用于函数返回值,与 Option
用于值的存在与否不同,Result
关注的主要是值的正确性。
如果函数正常运行,则最后返回一个 Ok(T)
,T
是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E)
,E
是错误类型。例如打开一个文件:如果成功打开文件,则返回 Ok(std::fs::File)
,因此 T
对应的是 std::fs::File
类型;而当打开文件时出现问题时,返回 Err(std::io::Error)
,E
对应的就是 std::io::Error
类型。
上一章中,我们讲到什么是方法以及如何在结构体和枚举上定义方法。方法上也可以使用泛型:
--struct Point<T> { - x: T, - y: T, -} - -impl<T> Point<T> { - fn x(&self) -> &T { - &self.x - } -} - -fn main() { - let p = Point { x: 5, y: 10 }; - - println!("p.x = {}", p.x()); -} -
使用泛型参数前,依然需要提前声明:impl<T>
,只有提前声明了,我们才能在Point<T>
中使用它,这样 Rust 就知道 Point
的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 Point<T>
不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T>
而不再是 Point
。
除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:
--struct Point<T, U> { - x: T, - y: U, -} - -impl<T, U> Point<T, U> { - fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> { - Point { - x: self.x, - y: other.y, - } - } -} - -fn main() { - let p1 = Point { x: 5, y: 10.4 }; - let p2 = Point { x: "Hello", y: 'c'}; - - let p3 = p1.mixup(p2); - - println!("p3.x = {}, p3.y = {}", p3.x, p3.y); -} -
这个例子中,T,U
是定义在结构体 Point
上的泛型参数,V,W
是单独定义在方法 mixup
上的泛型参数,它们并不冲突,说白了,你可以理解为,一个是结构体泛型,一个是函数泛型。
对于 Point<T>
类型,你不仅能定义基于 T
的方法,还能针对特定的具体类型,进行方法定义:
--#![allow(unused)] -fn main() { -impl Point<f32> { - fn distance_from_origin(&self) -> f32 { - (self.x.powi(2) + self.y.powi(2)).sqrt() - } -} -} -
这段代码意味着 Point<f32>
类型会有一个方法 distance_from_origin
,而其他 T
不是 f32
类型的 Point<T>
实例则没有定义此方法。这个方法计算点实例与坐标(0.0, 0.0)
之间的距离,并使用了只能用于浮点型的数学运算符。
这样我们就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法。
-在之前的泛型中,可以抽象为一句话:针对类型实现的泛型,所有的泛型都是为了抽象不同的类型,那有没有针对值的泛型?可能很多同学感觉很难理解,值怎么使用泛型?不急,我们先从数组讲起。
-在数组那节,有提到过很重要的一点:[i32; 2]
和 [i32; 3]
是不同的数组类型,比如下面的代码:
-fn display_array(arr: [i32; 3]) { - println!("{:?}", arr); -} -fn main() { - let arr: [i32; 3] = [1, 2, 3]; - display_array(arr); - - let arr: [i32;2] = [1,2]; - display_array(arr); -} -
运行后报错:
-error[E0308]: mismatched types // 类型不匹配
- --> src/main.rs:10:19
- |
-10 | display_array(arr);
- | ^^^ expected an array with a fixed size of 3 elements, found one with 2 elements
- // 期望一个长度为3的数组,却发现一个长度为2的
-
-结合代码和报错,可以很清楚的看出,[i32; 3]
和 [i32; 2]
确实是两个完全不同的类型,因此无法用同一个函数调用。
首先,让我们修改代码,让 display_array
能打印任意长度的 i32
数组:
-fn display_array(arr: &[i32]) { - println!("{:?}", arr); -} -fn main() { - let arr: [i32; 3] = [1, 2, 3]; - display_array(&arr); - - let arr: [i32;2] = [1,2]; - display_array(&arr); -} -
很简单,只要使用数组切片,然后传入 arr
的不可变引用即可。
接着,将 i32
改成所有类型的数组:
-fn display_array<T: std::fmt::Debug>(arr: &[T]) { - println!("{:?}", arr); -} -fn main() { - let arr: [i32; 3] = [1, 2, 3]; - display_array(&arr); - - let arr: [i32;2] = [1,2]; - display_array(&arr); -} -
也不难,唯一要注意的是需要对 T
加一个限制 std::fmt::Debug
,该限制表明 T
可以用在 println!("{:?}", arr)
中,因为 {:?}
形式的格式化输出需要 arr
实现该特征。
通过引用,我们可以很轻松的解决处理任何类型数组的问题,但是如果在某些场景下引用不适宜用或者干脆不能用呢?你们知道为什么以前 Rust 的一些数组库,在使用的时候都限定长度不超过 32 吗?因为它们会为每个长度都单独实现一个函数,简直。。。毫无人性。难道没有什么办法可以解决这个问题吗?
-好在,现在咱们有了 const 泛型,也就是针对值的泛型,正好可以用于处理数组长度的问题:
--fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { - println!("{:?}", arr); -} -fn main() { - let arr: [i32; 3] = [1, 2, 3]; - display_array(arr); - - let arr: [i32; 2] = [1, 2]; - display_array(arr); -} -
如上所示,我们定义了一个类型为 [T; N]
的数组,其中 T
是一个基于类型的泛型参数,这个和之前讲的泛型没有区别,而重点在于 N
这个泛型参数,它是一个基于值的泛型参数!因为它用来替代的是数组的长度。
N
就是 const 泛型,定义的语法是 const N: usize
,表示 const 泛型 N
,它基于的值类型是 usize
。
在泛型参数之前,Rust 完全不适合复杂矩阵的运算,自从有了 const 泛型,一切即将改变。
-假设我们某段代码需要在内存很小的平台上工作,因此需要限制函数参数占用的内存大小,此时就可以使用 const 泛型表达式来实现:
--// 目前只能在nightly版本下使用 -#![allow(incomplete_features)] -#![feature(generic_const_exprs)] - -fn something<T>(val: T) -where - Assert<{ core::mem::size_of::<T>() < 768 }>: IsTrue, - // ^-----------------------------^ 这里是一个 const 表达式,换成其它的 const 表达式也可以 -{ - // -} - -fn main() { - something([0u8; 0]); // ok - something([0u8; 512]); // ok - something([0u8; 1024]); // 编译错误,数组长度是1024字节,超过了768字节的参数长度限制 -} - -// --- - -pub enum Assert<const CHECK: bool> { - // -} - -pub trait IsTrue { - // -} - -impl IsTrue for Assert<true> { - // -} -
@todo
-在 Rust 中泛型是零成本的抽象,意味着你在使用泛型时,完全不用担心性能上的问题。
-但是任何选择都是权衡得失的,既然我们获得了性能上的巨大优势,那么又失去了什么呢?Rust 是在编译期为泛型对应的多个类型,生成各自的代码,因此损失了编译速度和增大了最终生成文件的大小。
-具体来说:
-Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
-编译器所做的工作正好与我们创建泛型函数的步骤相反,编译器寻找所有泛型代码被调用的位置并针对具体类型生成代码。
-让我们看看一个使用标准库中 Option
枚举的例子:
--#![allow(unused)] -fn main() { -let integer = Some(5); -let float = Some(5.0); -} -
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T>
的值并发现有两种 Option<T>
:一种对应 i32
另一种对应 f64
。为此,它会将泛型定义 Option<T>
展开为 Option_i32
和 Option_f64
,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样:
--enum Option_i32 { - Some(i32), - None, -} - -enum Option_f64 { - Some(f64), - None, -} - -fn main() { - let integer = Option_i32::Some(5); - let float = Option_f64::Some(5.0); -} -
我们可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
- -
泛型和特征是 Rust 中最最重要的抽象类型,也是你在学习 Rust 路上的拦路虎,但是挑战往往与乐趣并存,一旦学会,在后面学习 Rust 的路上,你将一往无前。
- - -在上一节中有一段代码无法通过编译:
---#![allow(unused)] -fn main() { -fn returns_summarizable(switch: bool) -> impl Summary { - if switch { - Post { - // ... - } - } else { - Weibo { - // ... - } - } -} -} -
其中 Post
和 Weibo
都实现了 Summary
特征,因此上面的函数试图通过返回 impl Summary
来返回这两个类型,但是编译器却无情地报错了,原因是 impl Trait
的返回值类型并不支持多种不同的类型返回,那如果我们想返回多种类型,该怎么办?
再来考虑一个问题:现在在做一款游戏,需要将多个对象渲染在屏幕上,这些对象属于不同的类型,存储在列表中,渲染的时候,需要循环该列表并顺序渲染每个对象,在 Rust 中该怎么实现?
-聪明的同学可能已经能想到一个办法,利用枚举:
--#[derive(Debug)] -enum UiObject { - Button, - SelectBox, -} - -fn main() { - let objects = [ - UiObject::Button, - UiObject::SelectBox - ]; - - for o in objects { - draw(o) - } -} - -fn draw(o: UiObject) { - println!("{:?}",o); -} -
Bingo,这个确实是一个办法,但是问题来了,如果你的对象集合并不能事先明确地知道呢?或者别人想要实现一个 UI 组件呢?此时枚举中的类型是有些缺少的,是不是还要修改你的代码增加一个枚举成员?
-总之,在编写这个 UI 库时,我们无法知道所有的 UI 对象类型,只知道的是:
-draw
方法在拥有继承的语言中,可以定义一个名为 Component
的类,该类上有一个 draw
方法。其他的类比如 Button
、Image
和 SelectBox
会从 Component
派生并因此继承 draw
方法。它们各自都可以覆盖 draw
方法来定义自己的行为,但是框架会把所有这些类型当作是 Component
的实例,并在其上调用 draw
。不过 Rust 并没有继承,我们得另寻出路。
为了解决上面的所有问题,Rust 引入了一个概念 —— 特征对象。
-在介绍特征对象之前,先来为之前的 UI 组件定义一个特征:
---#![allow(unused)] -fn main() { -pub trait Draw { - fn draw(&self); -} -} -
只要组件实现了 Draw
特征,就可以调用 draw
方法来进行渲染。假设有一个 Button
和 SelectBox
组件实现了 Draw
特征:
--#![allow(unused)] -fn main() { -pub struct Button { - pub width: u32, - pub height: u32, - pub label: String, -} - -impl Draw for Button { - fn draw(&self) { - // 绘制按钮的代码 - } -} - -struct SelectBox { - width: u32, - height: u32, - options: Vec<String>, -} - -impl Draw for SelectBox { - fn draw(&self) { - // 绘制SelectBox的代码 - } -} - -} -
此时,还需要一个动态数组来存储这些 UI 对象:
---#![allow(unused)] -fn main() { -pub struct Screen { - pub components: Vec<?>, -} -} -
注意到上面代码中的 ?
吗?它的意思是:我们应该填入什么类型,可以说就之前学过的内容里,你找不到哪个类型可以填入这里,但是因为 Button
和 SelectBox
都实现了 Draw
特征,那我们是不是可以把 Draw
特征的对象作为类型,填入到数组中呢?答案是肯定的。
特征对象指向实现了 Draw
特征的类型的实例,也就是指向了 Button
或者 SelectBox
的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体调用的类型方法。
可以通过 &
引用或者 Box<T>
智能指针的方式来创建特征对象。
---
Box<T>
在后面章节会详细讲解,大家现在把它当成一个引用即可,只不过它包裹的值会被强制分配在堆上。
-trait Draw { - fn draw(&self) -> String; -} - -impl Draw for u8 { - fn draw(&self) -> String { - format!("u8: {}", *self) - } -} - -impl Draw for f64 { - fn draw(&self) -> String { - format!("f64: {}", *self) - } -} - -// 若 T 实现了 Draw 特征, 则调用该函数时传入的 Box<T> 可以被隐式转换成函数参数签名中的 Box<dyn Draw> -fn draw1(x: Box<dyn Draw>) { - // 由于实现了 Deref 特征,Box 智能指针会自动解引用为它所包裹的值,然后调用该值对应的类型上定义的 `draw` 方法 - x.draw(); -} - -fn draw2(x: &dyn Draw) { - x.draw(); -} - -fn main() { - let x = 1.1f64; - // do_something(&x); - let y = 8u8; - - // x 和 y 的类型 T 都实现了 `Draw` 特征,因为 Box<T> 可以在函数调用时隐式地被转换为特征对象 Box<dyn Draw> - // 基于 x 的值创建一个 Box<f64> 类型的智能指针,指针指向的数据被放置在了堆上 - draw1(Box::new(x)); - // 基于 y 的值创建一个 Box<u8> 类型的智能指针 - draw1(Box::new(y)); - draw2(&x); - draw2(&y); -} -
上面代码,有几个非常重要的点:
-draw1
函数的参数是 Box<dyn Draw>
形式的特征对象,该特征对象是通过 Box::new(x)
的方式创建的draw2
函数的参数是 &dyn Draw
形式的特征对象,该特征对象是通过 &x
的方式创建的dyn
关键字只用在特征对象的类型声明上,在创建时无需使用 dyn
因此,可以使用特征对象来代表泛型或具体的类型。
-继续来完善之前的 UI 组件代码,首先来实现 Screen
:
--#![allow(unused)] -fn main() { -pub struct Screen { - pub components: Vec<Box<dyn Draw>>, -} -} -
其中存储了一个动态数组,里面元素的类型是 Draw
特征对象:Box<dyn Draw>
,任何实现了 Draw
特征的类型,都可以存放其中。
再来为 Screen
定义 run
方法,用于将列表中的 UI 组件渲染在屏幕上:
--#![allow(unused)] -fn main() { -impl Screen { - pub fn run(&self) { - for component in self.components.iter() { - component.draw(); - } - } -} -} -
至此,我们就完成了之前的目标:在列表中存储多种不同类型的实例,然后将它们使用同一个方法逐一渲染在屏幕上!
-再来看看,如果通过泛型实现,会如何:
---#![allow(unused)] -fn main() { -pub struct Screen<T: Draw> { - pub components: Vec<T>, -} - -impl<T> Screen<T> - where T: Draw { - pub fn run(&self) { - for component in self.components.iter() { - component.draw(); - } - } -} -} -
上面的 Screen
的列表中,存储了类型为 T
的元素,然后在 Screen
中使用特征约束让 T
实现了 Draw
特征,进而可以调用 draw
方法。
但是这种写法限制了 Screen
实例的 Vec<T>
中的每个元素必须是 Button
类型或者全是 SelectBox
类型。如果只需要同质(相同类型)集合,更倾向于这种写法:使用泛型和 特征约束,因为实现更清晰,且性能更好(特征对象,需要在运行时从 vtable
动态查找需要调用的方法)。
现在来运行渲染下咱们精心设计的 UI 组件列表:
--fn main() { - let screen = Screen { - components: vec![ - Box::new(SelectBox { - width: 75, - height: 10, - options: vec![ - String::from("Yes"), - String::from("Maybe"), - String::from("No") - ], - }), - Box::new(Button { - width: 50, - height: 10, - label: String::from("OK"), - }), - ], - }; - - screen.run(); -} -
上面使用 Box::new(T)
的方式来创建了两个 Box<dyn Draw>
特征对象,如果以后还需要增加一个 UI 组件,那么让该组件实现 Draw
特征,则可以很轻松的将其渲染在屏幕上,甚至用户可以引入我们的库作为三方库,然后在自己的库中为自己的类型实现 Draw
特征,然后进行渲染。
在动态类型语言中,有一个很重要的概念:鸭子类型(duck typing),简单来说,就是只关心值长啥样,而不关心它实际是什么。当一个东西走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子,就算它实际上是一个奥特曼,也不重要,我们就当它是鸭子。
-在上例中,Screen
在 run
的时候,我们并不需要知道各个组件的具体类型是什么。它也不检查组件到底是 Button
还是 SelectBox
的实例,只要它实现了 Draw
特征,就能通过 Box::new
包装成 Box<dyn Draw>
特征对象,然后被渲染在屏幕上。
使用特征对象和 Rust 类型系统来进行类似鸭子类型操作的优势是,无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征, 那么 Rust 根本就不会编译这些代码:
--fn main() { - let screen = Screen { - components: vec![ - Box::new(String::from("Hi")), - ], - }; - - screen.run(); -} -
因为 String
类型没有实现 Draw
特征,编译器直接就会报错,不会让上述代码运行。如果想要 String
类型被渲染在屏幕上,那么只需要为其实现 Draw
特征即可,非常容易。
注意 dyn
不能单独作为特征对象的定义,例如下面的代码编译器会报错,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的。
而 &dyn
和 Box<dyn>
在编译期都是已知大小,所以可以用作特征对象的定义。
--#![allow(unused)] -fn main() { -fn draw2(x: dyn Draw) { - x.draw(); -} -} -
10 | fn draw2(x: dyn Draw) {
- | ^ doesn't have a size known at compile-time
- |
- = help: the trait `Sized` is not implemented for `(dyn Draw + 'static)`
-help: function arguments must have a statically known size, borrowed types always have a known size
-
-回忆一下泛型章节我们提到过的,泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。
-与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字 dyn
正是在强调这一“动态”的特点。
当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。
-下面这张图很好的解释了静态分发 Box<T>
和动态分发 Box<dyn Trait>
的区别:
结合上文的内容和这张图可以了解:
-Draw
,类型 Button
可以实现特征 Draw
,类型 SelectBox
也可以实现特征 Draw
,因此特征没有固定大小&dyn Draw
、Box<dyn Draw>
-ptr
和 vptr
),因此占用两个指针大小ptr
指向实现了特征 Draw
的具体类型的实例,也就是当作特征 Draw
来用的类型的实例,比如类型 Button
的实例、类型 SelectBox
的实例vptr
指向一个虚表 vtable
,vtable
中保存了类型 Button
或类型 SelectBox
的实例对于可以调用的实现于特征 Draw
的方法。当调用方法时,直接从 vtable
中找到方法并调用。之所以要使用一个 vtable
来保存各实例的方法,是因为实现了特征 Draw
的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作特征 Draw
来使用时(此时,它们全都看作是特征 Draw
类型的实例),有必要区分这些实例各自有哪些方法可调用简而言之,当类型 Button
实现了特征 Draw
时,类型 Button
的实例对象 btn
可以当作特征 Draw
的特征对象类型来使用,btn
中保存了作为特征对象的数据指针(指向类型 Button
的实例数据)和行为指针(指向 vtable
)。
一定要注意,此时的 btn
是 Draw
的特征对象的实例,而不再是具体类型 Button
的实例,而且 btn
的 vtable
只包含了实现自特征 Draw
的那些方法(比如 draw
),因此 btn
只能调用实现于特征 Draw
的 draw
方法,而不能调用类型 Button
本身实现的方法和类型 Button
实现于其他特征的方法。也就是说,btn
是哪个特征对象的实例,它的 vtable
中就包含了该特征的方法。
在 Rust 中,有两个self
,一个指代当前的实例对象,一个指代特征或者方法类型的别名:
-trait Draw { - fn draw(&self) -> Self; -} - -#[derive(Clone)] -struct Button; -impl Draw for Button { - fn draw(&self) -> Self { - return self.clone() - } -} - -fn main() { - let button = Button; - let newb = button.draw(); -} -
上述代码中,self
指代的就是当前的实例对象,也就是 button.draw()
中的 button
实例,Self
则指代的是 Button
类型。
当理解了 self
与 Self
的区别后,我们再来看看何为对象安全。
不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,它的对象才是安全的:
-Self
对象安全对于特征对象是必须的,因为一旦有了特征对象,就不再需要知道实现该特征的具体类型是什么了。如果特征方法返回了具体的 Self
类型,但是特征对象忘记了其真正的类型,那这个 Self
就非常尴尬,因为没人知道它是谁了。但是对于泛型类型参数来说,当使用特征时其会放入具体的类型参数:此具体类型变成了实现该特征的类型的一部分。而当使用特征对象时其具体类型被抹去了,故而无从得知放入泛型参数类型到底是什么。
标准库中的 Clone
特征就不符合对象安全的要求:
--#![allow(unused)] -fn main() { -pub trait Clone { - fn clone(&self) -> Self; -} -} -
因为它的其中一个方法,返回了 Self
类型,因此它是对象不安全的。
String
类型实现了 Clone
特征, String
实例上调用 clone
方法时会得到一个 String
实例。类似的,当调用 Vec<T>
实例的 clone
方法会得到一个 Vec<T>
实例。clone
的签名需要知道什么类型会代替 Self
,因为这是它的返回值。
如果违反了对象安全的规则,编译器会提示你。例如,如果尝试使用之前的 Screen
结构体来存放实现了 Clone
特征的类型:
--#![allow(unused)] -fn main() { -pub struct Screen { - pub components: Vec<Box<dyn Clone>>, -} -} -
将会得到如下错误:
-error[E0038]: the trait `std::clone::Clone` cannot be made into an object
- --> src/lib.rs:2:5
- |
-2 | pub components: Vec<Box<dyn Clone>>,
- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone`
- cannot be made into an object
- |
- = note: the trait cannot require that `Self : Sized`
-
-这意味着不能以这种方式使用此特征作为特征对象。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
如果我们想定义一个文件系统,那么把该系统跟底层存储解耦是很重要的。文件操作主要包含四个:open
、write
、read
、close
,这些操作可以发生在硬盘,可以发生在内存,还可以发生在网络IO甚至(...我实在编不下去了,大家来帮帮我)。总之如果你要为每一种情况都单独实现一套代码,那这种实现将过于繁杂,而且也没那个必要。
要解决上述问题,需要把这些行为抽象出来,就要使用 Rust 中的特征 trait
概念。可能你是第一次听说这个名词,但是不要怕,如果学过其他语言,那么大概率你听说过接口,没错,特征跟接口很类似。
在之前的代码中,我们也多次见过特征的使用,例如 #[derive(Debug)]
,它在我们定义的类型(struct
)上自动派生 Debug
特征,接着可以使用 println!("{:?}", x)
打印这个类型;再例如:
--#![allow(unused)] -fn main() { -fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T { - a + b -} -} -
通过 std::ops::Add
特征来限制 T
,只有 T
实现了 std::ops::Add
才能进行合法的加法操作,毕竟不是所有的类型都能进行相加。
这些都说明一个道理,特征定义了一组可以被共享的行为,只要实现了特征,你就能使用这组行为。
-如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
-例如,我们现在有文章 Post
和微博 Weibo
两种内容载体,而我们想对相应的内容进行总结,也就是无论是文章内容,还是微博内容,都可以在某个时间点进行总结,那么总结这个行为就是共享的,因此可以用特征来定义:
--#![allow(unused)] -fn main() { -pub trait Summary { - fn summarize(&self) -> String; -} -} -
这里使用 trait
关键字来声明一个特征,Summary
是特征名。在大括号中定义了该特征的所有方法,在这个例子中是: fn summarize(&self) -> String
。
特征只定义行为看起来是什么样的,而不定义行为具体是怎么样的。因此,我们只定义特征方法的签名,而不进行实现,此时方法签名结尾是 ;
,而不是一个 {}
。
接下来,每一个实现这个特征的类型都需要具体实现该特征的相应方法,编译器也会确保任何实现 Summary
特征的类型都拥有与这个签名的定义完全一致的 summarize
方法。
因为特征只定义行为看起来是什么样的,因此我们需要为类型实现具体的特征,定义行为具体是怎么样的。
-首先来为 Post
和 Weibo
实现 Summary
特征:
--#![allow(unused)] -fn main() { -pub trait Summary { - fn summarize(&self) -> String; -} -pub struct Post { - pub title: String, // 标题 - pub author: String, // 作者 - pub content: String, // 内容 -} - -impl Summary for Post { - fn summarize(&self) -> String { - format!("文章{}, 作者是{}", self.title, self.author) - } -} - -pub struct Weibo { - pub username: String, - pub content: String -} - -impl Summary for Weibo { - fn summarize(&self) -> String { - format!("{}发表了微博{}", self.username, self.content) - } -} -} -
实现特征的语法与为结构体、枚举实现方法很像:impl Summary for Post
,读作“为 Post
类型实现 Summary
特征”,然后在 impl
的花括号中实现该特征的具体方法。
接下来就可以在这个类型上调用特征的方法:
--fn main() { - let post = Post{title: "Rust语言简介".to_string(),author: "Sunface".to_string(), content: "Rust棒极了!".to_string()}; - let weibo = Weibo{username: "sunface".to_string(),content: "好像微博没Tweet好用".to_string()}; - - println!("{}",post.summarize()); - println!("{}",weibo.summarize()); -} -
运行输出:
-文章 Rust 语言简介, 作者是Sunface
-sunface发表了微博好像微博没Tweet好用
-
-说实话,如果特征仅仅如此,你可能会觉得花里胡哨没啥用,接下来就让你见识下 trait
真正的威力。
上面我们将 Summary
定义成了 pub
公开的。这样,如果他人想要使用我们的 Summary
特征,则可以引入到他们的包中,然后再进行实现。
关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A
实现特征 T
,那么 A
或者 T
至少有一个是在当前作用域中定义的! 例如我们可以为上面的 Post
类型实现标准库中的 Display
特征,这是因为 Post
类型定义在当前的作用域中。同时,我们也可以在当前包中为 String
类型实现 Summary
特征,因为 Summary
定义在当前作用域中。
但是你无法在当前作用域中,为 String
类型实现 Display
特征,因为它们俩都定义在标准库中,其定义所在的位置都不在当前作用域,跟你半毛钱关系都没有,看看就行了。
该规则被称为孤儿规则,可以确保其它人编写的代码不会破坏你的代码,也确保了你不会莫名其妙就破坏了风马牛不相及的代码。
-你可以在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法:
---#![allow(unused)] -fn main() { -pub trait Summary { - fn summarize(&self) -> String { - String::from("(Read more...)") - } -} -} -
上面为 Summary
定义了一个默认实现,下面我们编写段代码来测试下:
--#![allow(unused)] -fn main() { -impl Summary for Post {} - -impl Summary for Weibo { - fn summarize(&self) -> String { - format!("{}发表了微博{}", self.username, self.content) - } -} -} -
可以看到,Post
选择了默认实现,而 Weibo
重载了该方法,调用和输出如下:
--#![allow(unused)] -fn main() { - println!("{}",post.summarize()); - println!("{}",weibo.summarize()); -} -
(Read more...)
-sunface发表了微博好像微博没Tweet好用
-
-默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现。如此,特征可以提供很多有用的功能而只需要实现指定的一小部分内容。例如,我们可以定义 Summary
特征,使其具有一个需要实现的 summarize_author
方法,然后定义一个 summarize
方法,此方法的默认实现调用 summarize_author
方法:
--#![allow(unused)] -fn main() { -pub trait Summary { - fn summarize_author(&self) -> String; - - fn summarize(&self) -> String { - format!("(Read more from {}...)", self.summarize_author()) - } -} -} -
为了使用 Summary
,只需要实现 summarize_author
方法即可:
--#![allow(unused)] -fn main() { -impl Summary for Weibo { - fn summarize_author(&self) -> String { - format!("@{}", self.username) - } -} -println!("1 new weibo: {}", weibo.summarize()); - -} -
weibo.summarize()
会先调用 Summary
特征默认实现的 summarize
方法,通过该方法进而调用 Weibo
为 Summary
实现的 summarize_author
方法,最终输出:1 new weibo: (Read more from @horse_ebooks...)
。
之前提到过,特征如果仅仅是用来实现方法,那真的有些大材小用,现在我们来讲下,真正可以让特征大放光彩的地方。
-现在,先定义一个函数,使用特征作为函数参数:
---#![allow(unused)] -fn main() { -pub fn notify(item: &impl Summary) { - println!("Breaking news! {}", item.summarize()); -} -} -
impl Summary
,只能说想出这个类型的人真的是起名鬼才,简直太贴切了,顾名思义,它的意思是 实现了Summary
特征 的 item
参数。
你可以使用任何实现了 Summary
特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法,例如 summarize
方法。具体的说,可以传递 Post
或 Weibo
的实例来作为参数,而其它类如 String
或者 i32
的类型则不能用做该函数的参数,因为它们没有实现 Summary
特征。
虽然 impl Trait
这种语法非常好理解,但是实际上它只是一个语法糖:
--#![allow(unused)] -fn main() { -pub fn notify<T: Summary>(item: &T) { - println!("Breaking news! {}", item.summarize()); -} -} -
真正的完整书写形式如上所述,形如 T: Summary
被称为特征约束。
在简单的场景下 impl Trait
这种语法糖就足够使用,但是对于复杂的场景,特征约束可以让我们拥有更大的灵活性和语法表现能力,例如一个函数接受两个 impl Summary
的参数:
--#![allow(unused)] -fn main() { -pub fn notify(item1: &impl Summary, item2: &impl Summary) {} -} -
如果函数两个参数是不同的类型,那么上面的方法很好,只要这两个类型都实现了 Summary
特征即可。但是如果我们想要强制函数的两个参数是同一类型呢?上面的语法就无法做到这种限制,此时我们只能使特征约束来实现:
--#![allow(unused)] -fn main() { -pub fn notify<T: Summary>(item1: &T, item2: &T) {} -} -
泛型类型 T
说明了 item1
和 item2
必须拥有同样的类型,同时 T: Summary
说明了 T
必须实现 Summary
特征。
除了单个约束条件,我们还可以指定多个约束条件,例如除了让参数实现 Summary
特征外,还可以让参数实现 Display
特征以控制它的格式化输出:
--#![allow(unused)] -fn main() { -pub fn notify(item: &(impl Summary + Display)) {} -} -
除了上述的语法糖形式,还能使用特征约束的形式:
---#![allow(unused)] -fn main() { -pub fn notify<T: Summary + Display>(item: &T) {} -} -
通过这两个特征,就可以使用 item.summarize
方法,以及通过 println!("{}", item)
来格式化输出 item
。
当特征约束变得很多时,函数的签名将变得很复杂:
---#![allow(unused)] -fn main() { -fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {} -} -
严格来说,上面的例子还是不够复杂,但是我们还是能对其做一些形式上的改进,通过 where
:
--#![allow(unused)] -fn main() { -fn some_function<T, U>(t: &T, u: &U) -> i32 - where T: Display + Clone, - U: Clone + Debug -{} -} -
特征约束,可以让我们在指定类型 + 指定特征的条件下去实现方法,例如:
---#![allow(unused)] -fn main() { -use std::fmt::Display; - -struct Pair<T> { - x: T, - y: T, -} - -impl<T> Pair<T> { - fn new(x: T, y: T) -> Self { - Self { - x, - y, - } - } -} - -impl<T: Display + PartialOrd> Pair<T> { - fn cmp_display(&self) { - if self.x >= self.y { - println!("The largest member is x = {}", self.x); - } else { - println!("The largest member is y = {}", self.y); - } - } -} -} -
cmp_display
方法,并不是所有的 Pair<T>
结构体对象都可以拥有,只有 T
同时实现了 Display + PartialOrd
的 Pair<T>
才可以拥有此方法。
-该函数可读性会更好,因为泛型参数、参数、返回值都在一起,可以快速的阅读,同时每个泛型参数的特征也在新的代码行中通过特征约束进行了约束。
也可以有条件地实现特征, 例如,标准库为任何实现了 Display
特征的类型实现了 ToString
特征:
--#![allow(unused)] -fn main() { -impl<T: Display> ToString for T { - // --snip-- -} -} -
我们可以对任何实现了 Display
特征的类型调用由 ToString
定义的 to_string
方法。例如,可以将整型转换为对应的 String
值,因为整型实现了 Display
:
--#![allow(unused)] -fn main() { -let s = 3.to_string(); -} -
impl Trait
可以通过 impl Trait
来说明一个函数返回了一个类型,该类型实现了某个特征:
--#![allow(unused)] -fn main() { -fn returns_summarizable() -> impl Summary { - Weibo { - username: String::from("sunface"), - content: String::from( - "m1 max太厉害了,电脑再也不会卡", - ) - } -} -} -
因为 Weibo
实现了 Summary
,因此这里可以用它来作为返回值。要注意的是,虽然我们知道这里是一个 Weibo
类型,但是对于 returns_summarizable
的调用者而言,他只知道返回了一个实现了 Summary
特征的对象,但是并不知道返回了一个 Weibo
类型。
这种 impl Trait
形式的返回值,在一种场景下非常非常有用,那就是返回的真实类型非常复杂,你不知道该怎么声明时(毕竟 Rust 要求你必须标出所有的类型),此时就可以用 impl Trait
的方式简单返回。例如,闭包和迭代器就是很复杂,只有编译器才知道那玩意的真实类型,如果让你写出来它们的具体类型,估计内心有一万只草泥马奔腾,好在你可以用 impl Iterator
来告诉调用者,返回了一个迭代器,因为所有迭代器都会实现 Iterator
特征。
但是这种返回值方式有一个很大的限制:只能有一个具体的类型,例如:
---#![allow(unused)] -fn main() { -fn returns_summarizable(switch: bool) -> impl Summary { - if switch { - Post { - title: String::from( - "Penguins win the Stanley Cup Championship!", - ), - author: String::from("Iceburgh"), - content: String::from( - "The Pittsburgh Penguins once again are the best \ - hockey team in the NHL.", - ), - } - } else { - Weibo { - username: String::from("horse_ebooks"), - content: String::from( - "of course, as you probably already know, people", - ), - } - } -} -} -
以上的代码就无法通过编译,因为它返回了两个不同的类型 Post
和 Weibo
。
`if` and `else` have incompatible types
-expected struct `Post`, found struct `Weibo`
-
-报错提示我们 if
和 else
返回了不同的类型。如果想要实现返回不同的类型,需要使用下一章节中的特征对象。
largest
函数还记得上一节中的例子吧,当时留下一个疑问,该如何解决编译报错:
---#![allow(unused)] -fn main() { -error[E0369]: binary operation `>` cannot be applied to type `T` // 无法在 `T` 类型上应用`>`运算符 - --> src/main.rs:5:17 - | -5 | if item > largest { - | ---- ^ ------- T - | | - | T - | -help: consider restricting type parameter `T` // 考虑使用以下的特征来约束 `T` - | -1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T { - | ^^^^^^^^^^^^^^^^^^^^^^ -} -
在 largest
函数体中我们想要使用大于运算符(>
)比较两个 T
类型的值。这个运算符是标准库中特征 std::cmp::PartialOrd
的一个默认方法。所以需要在 T
的特征约束中指定 PartialOrd
,这样 largest
函数可以用于内部元素类型可比较大小的数组切片。
由于 PartialOrd
位于 prelude
中所以并不需要通过 std::cmp
手动将其引入作用域。所以可以将 largest
的签名修改为如下:
--#![allow(unused)] -fn main() { -fn largest<T: PartialOrd>(list: &[T]) -> T {} -} -
但是此时编译,又会出现新的错误:
---#![allow(unused)] -fn main() { -error[E0508]: cannot move out of type `[T]`, a non-copy slice - --> src/main.rs:2:23 - | -2 | let mut largest = list[0]; - | ^^^^^^^ - | | - | cannot move out of here - | help: consider using a reference instead: `&list[0]` - -error[E0507]: cannot move out of borrowed content - --> src/main.rs:4:9 - | -4 | for &item in list.iter() { - | ^---- - | || - | |hint: to prevent move, use `ref item` or `ref mut item` - | cannot move out of borrowed content -} -
错误的核心是 cannot move out of type [T], a non-copy slice
,原因是 T
没有实现 Copy
特性,因此我们只能把所有权进行转移,毕竟只有 i32
等基础类型才实现了 Copy
特性,可以存储在栈上,而 T
可以指代任何类型(严格来说是实现了 PartialOrd
特征的所有类型)。
因此,为了让 T
拥有 Copy
特性,我们可以增加特征约束:
-fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { - let mut largest = list[0]; - - for &item in list.iter() { - if item > largest { - largest = item; - } - } - - largest -} - -fn main() { - let number_list = vec![34, 50, 25, 100, 65]; - - let result = largest(&number_list); - println!("The largest number is {}", result); - - let char_list = vec!['y', 'm', 'a', 'q']; - - let result = largest(&char_list); - println!("The largest char is {}", result); -} -
如果并不希望限制 largest
函数只能用于实现了 Copy
特征的类型,我们可以在 T
的特征约束中指定 Clone
特征 而不是 Copy
特征。并克隆 list
中的每一个值使得 largest
函数拥有其所有权。使用 clone
函数意味着对于类似 String
这样拥有堆上数据的类型,会潜在地分配更多堆上空间,而堆分配在涉及大量数据时可能会相当缓慢。
另一种 largest
的实现方式是返回在 list
中 T
值的引用。如果我们将函数返回值从 T
改为 &T
并改变函数体使其能够返回一个引用,我们将不需要任何 Clone
或 Copy
的特征约束而且也不会有任何的堆分配。尝试自己实现这种替代解决方式吧!
derive
派生特征在本书中,形如 #[derive(Debug)]
的代码已经出现了很多次,这种是一种特征派生语法,被 derive
标记的对象会自动实现对应的默认特征代码,继承相应的功能。
例如 Debug
特征,它有一套自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s)
的形式打印该结构体的对象。
再如 Copy
特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy
特征,进而可以调用 copy
方法,进行自我复制。
总之,derive
派生出来的是 Rust 默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。
详细的 derive
列表参见附录-派生特征。
在一些场景中,使用 as
关键字做类型转换会有比较大的限制,因为你想要在类型转换上拥有完全的控制,例如处理转换错误,那么你将需要 TryInto
:
-use std::convert::TryInto; - -fn main() { - let a: i32 = 10; - let b: u16 = 100; - - let b_ = b.try_into() - .unwrap(); - - if a < b_ { - println!("Ten is less than one hundred."); - } -} -
上面代码中引入了 std::convert::TryInto
特征,但是却没有使用它,可能有些同学会为此困惑,主要原因在于如果你要使用一个特征的方法,那么你需要将该特征引入当前的作用域中,我们在上面用到了 try_into
方法,因此需要引入对应的特征。
但是 Rust 又提供了一个非常便利的办法,即把最常用的标准库中的特征通过 std::prelude
模块提前引入到当前作用域中,其中包括了 std::convert::TryInto
,你可以尝试删除第一行的代码 use ...
,看看是否会报错。
+
操作在 Rust 中除了数值类型的加法,String
也可以做加法,因为 Rust 为该类型实现了 std::ops::Add
特征,同理,如果我们为自定义类型实现了该特征,那就可以自己实现 Point1 + Point2
的操作:
-use std::ops::Add; - -// 为Point结构体派生Debug特征,用于格式化输出 -#[derive(Debug)] -struct Point<T: Add<T, Output = T>> { //限制类型T必须实现了Add特征,否则无法进行+操作。 - x: T, - y: T, -} - -impl<T: Add<T, Output = T>> Add for Point<T> { - type Output = Point<T>; - - fn add(self, p: Point<T>) -> Point<T> { - Point{ - x: self.x + p.x, - y: self.y + p.y, - } - } -} - -fn add<T: Add<T, Output=T>>(a:T, b:T) -> T { - a + b -} - -fn main() { - let p1 = Point{x: 1.1f32, y: 1.1f32}; - let p2 = Point{x: 2.1f32, y: 2.1f32}; - println!("{:?}", add(p1, p2)); - - let p3 = Point{x: 1i32, y: 1i32}; - let p4 = Point{x: 2i32, y: 2i32}; - println!("{:?}", add(p3, p4)); -} -
在开发过程中,往往只要使用 #[derive(Debug)]
对我们的自定义类型进行标注,即可实现打印输出的功能:
-#[derive(Debug)] -struct Point{ - x: i32, - y: i32 -} -fn main() { - let p = Point{x:3,y:3}; - println!("{:?}",p); -} -
但是在实际项目中,往往需要对我们的自定义类型进行自定义的格式化输出,以让用户更好的阅读理解我们的类型,此时就要为自定义类型实现 std::fmt::Display
特征:
-#![allow(dead_code)] - -use std::fmt; -use std::fmt::{Display}; - -#[derive(Debug,PartialEq)] -enum FileState { - Open, - Closed, -} - -#[derive(Debug)] -struct File { - name: String, - data: Vec<u8>, - state: FileState, -} - -impl Display for FileState { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - FileState::Open => write!(f, "OPEN"), - FileState::Closed => write!(f, "CLOSED"), - } - } -} - -impl Display for File { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "<{} ({})>", - self.name, self.state) - } -} - -impl File { - fn new(name: &str) -> File { - File { - name: String::from(name), - data: Vec::new(), - state: FileState::Closed, - } - } -} - -fn main() { - let f6 = File::new("f6.txt"); - //... - println!("{:?}", f6); - println!("{}", f6); -} -
以上两个例子较为复杂,目的是为读者展示下真实的使用场景长什么样,因此需要读者细细阅读,最终消化这些知识对于你的 Rust 之路会有莫大的帮助。
-最后,特征和特征约束,是 Rust 中极其重要的概念,如果你还是没搞懂,强烈建议回头再看一遍,或者寻找相关的资料进行补充学习。如果已经觉得掌握了,那么就可以进入下一节的学习。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
鉴于本书的目标读者(别慌,来到这里就说明你就是目标读者)已经熟练掌握其它任意一门编程语言,因此这里就不再对何为变量进行赘述,让我们开门见山来谈谈,为何 Rust 选择了手动设定变量的可变性。
-在其它大多数语言中,要么只支持声明可变的变量,要么只支持声明不可变的变量( 例如函数式语言 ),前者为编程提供了灵活性,后者为编程提供了安全性,而 Rust 比较野,选择了两者我都要,既要灵活性又要安全性。
-能想要学习 Rust,说明我们的读者都是相当有水平的程序员了,你们应该能理解一切选择皆是权衡,那么两者都要的权衡是什么呢?这就是 Rust 开发团队为我们做出的贡献,两者都要意味着 Rust 语言底层代码的实现复杂度大幅提升,因此 Salute to The Rust Team!
-除了以上两个优点,还有一个很大的优点,那就是运行性能上的提升,因为将本身无需改变的变量声明为不可变在运行期会避免一些多余的 runtime
检查。
在命名方面,和其它语言没有区别,不过当给变量命名时,需要遵循 Rust 命名规范。
---Rust 语言有一些关键字(keywords),和其他语言一样,这些关键字都是被保留给 Rust 语言使用的,因此,它们不能被用作变量或函数的名称。在 附录 A 中可找到关键字列表。
-
在其它语言中,我们用 var a = "hello world"
的方式给 a
赋值,也就是把等式右边的 "hello world"
字符串赋值给变量 a
,而在 Rust 中,我们这样写: let a = "hello world"
,同时给这个过程起了另一个名字:变量绑定。
为何不用赋值而用绑定呢(其实你也可以称之为赋值,但是绑定的含义更清晰准确)?这里就涉及 Rust 最核心的原则——所有权,简单来讲,任何内存对象都是有主人的,而且一般情况下完全属于它的主人,绑定就是把这个对象绑定给一个变量,让这个变量成为它的主人(聪明的读者应该能猜到,在这种情况下,该对象之前的主人就会丧失对该对象的所有权),像极了我们的现实世界,不是吗?
-那为什么要引进“所有权”这个新的概念呢?请稍安勿躁,时机一旦成熟,我们就回来继续讨论这个话题。
-Rust 的变量在默认情况下是不可变的。前文提到,这是 Rust 团队为我们精心设计的语言特性之一,让我们编写的代码更安全,性能也更好。当然你可以通过 mut
关键字让变量变为可变的,让设计更灵活。
如果变量 a
不可变,那么一旦为它绑定值,就不能再修改 a
。举个例子,在我们的工程目录下使用 cargo new variables
新建一个项目,叫做 variables 。
然后在新建的 variables 目录下,编辑 src/main.rs ,改为下面代码:
--fn main() { - let x = 5; - println!("The value of x is: {}", x); - x = 6; - println!("The value of x is: {}", x); -} -
保存文件,再使用 cargo run
运行它,迎面而来的是一条错误提示:
$ cargo run
- Compiling variables v0.1.0 (file:///projects/variables)
-error[E0384]: cannot assign twice to immutable variable `x`
- --> src/main.rs:4:5
- |
-2 | let x = 5;
- | -
- | |
- | first assignment to `x`
- | help: consider making this binding mutable: `mut x`
-3 | println!("The value of x is: {}", x);
-4 | x = 6;
- | ^^^^^ cannot assign twice to immutable variable
-
-error: aborting due to previous error
-
-具体的错误原因是 cannot assign twice to immutable variable x
(无法对不可变的变量进行重复赋值),因为我们想为不可变的 x
变量再次赋值。
这种错误是为了避免无法预期的错误发生在我们的变量上:一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。
-这种规则让我们的代码变得非常清晰,只有你想让你的变量改变时,它才能改变,这样就不会造成心智上的负担,也给别人阅读代码带来便利。
-但是可变性也非常重要,否则我们就要像 ClojureScript 那样,每次要改变,就要重新生成一个对象,在拥有大量对象的场景,性能会变得非常低下,内存拷贝的成本异常的高。
-在 Rust 中,可变性很简单,只要在变量名前加一个 mut
即可, 而且这种显式的声明方式还会给后来人传达这样的信息:嗯,这个变量在后面代码部分会发生改变。
为了让变量声明为可变,将 src/main.rs 改为以下内容:
--fn main() { - let mut x = 5; - println!("The value of x is: {}", x); - x = 6; - println!("The value of x is: {}", x); -} -
运行程序将得到下面结果:
-$ cargo run
- Compiling variables v0.1.0 (file:///projects/variables)
- Finished dev [unoptimized + debuginfo] target(s) in 0.30s
- Running `target/debug/variables`
-The value of x is: 5
-The value of x is: 6
-
-选择可变还是不可变,更多的还是取决于你的使用场景,例如不可变可以带来安全性,但是丧失了灵活性和性能(如果你要改变,就要重新创建一个新的变量,这里涉及到内存对象的再分配)。而可变变量最大的好处就是使用上的灵活性和性能上的提升。
-例如,在使用大型数据结构或者热点代码路径(被大量频繁调用)的情形下,在同一内存位置更新实例可能比复制并返回新分配的实例要更快。使用较小的数据结构时,通常创建新的实例并以更具函数式的风格来编写程序,可能会更容易理解,所以值得以较低的性能开销来确保代码清晰。
-如果你创建了一个变量却不在任何地方使用它,Rust 通常会给你一个警告,因为这可能会是个 BUG。但是有时创建一个不会被使用的变量是有用的,比如你正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头:
--fn main() { - let _x = 5; - let y = 10; -} -
使用 cargo run
运行下试试:
warning: unused variable: `y`
- --> src/main.rs:3:9
- |
-3 | let y = 10;
- | ^ help: 如果 y 故意不被使用,请添加一个下划线前缀: `_y`
- |
- = note: `#[warn(unused_variables)]` on by default
-
-可以看到,两个变量都是只有声明,没有使用,但是编译器却独独给出了 y
未被使用的警告,充分说明了 _
变量名前缀在这里发挥的作用。
值得注意的是,这里编译器还很善意的给出了提示( Rust 的编译器非常强大,这里的提示只是小意思 ): 将 y
修改 _y
即可。这里就不再给出代码,留给大家手动尝试并观察下运行结果。
更多关于 _x
的使用信息,请阅读后面的模式匹配章节。
let
表达式不仅仅用于变量的绑定,还能进行复杂变量的解构:从一个相对复杂的变量中,匹配出该变量的一部分内容:
-fn main() { - let (a, mut b): (bool,bool) = (true, false); - // a = true,不可变; b = false,可变 - println!("a = {:?}, b = {:?}", a, b); - - b = true; - assert_eq!(a, b); -} -
在 Rust 1.59 版本后,我们可以在赋值语句的左式中使用元组、切片和结构体模式了。
--struct Struct { - e: i32 -} - -fn main() { - let (a, b, c, d, e); - - (a, b) = (1, 2); - // _ 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 _ - [c, .., d, _] = [1, 2, 3, 4, 5]; - Struct { e, .. } = Struct { e: 5 }; - - assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e]); -} -
这种使用方式跟之前的 let
保持了一致性,但是 let
会重新绑定,而这里仅仅是对之前绑定的变量进行再赋值。
需要注意的是,使用 +=
的赋值语句还不支持解构式赋值。
--这里用到了模式匹配的一些语法,如果大家看不懂没关系,可以在学完模式匹配章节后,再回头来看。
-
变量的值不能更改可能让你想起其他另一个很多语言都有的编程概念:常量(constant)。与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:
-mut
。常量不仅仅默认不可变,而且自始至终不可变,因为常量在编译完成后,已经确定它的值。const
关键字而不是 let
关键字来声明,并且值的类型必须标注。我们将在下一节数据类型中介绍,因此现在暂时无需关心细节。
-下面是一个常量声明的例子,其常量名为 MAX_POINTS
,值设置为 100,000
。(Rust 常量的命名约定是全部字母都使用大写,并使用下划线分隔单词,另外对数字字面量可插入下划线以提高可读性):
--#![allow(unused)] -fn main() { -const MAX_POINTS: u32 = 100_000; -} -
常量可以在任意作用域内声明,包括全局作用域,在声明的作用域内,常量在程序运行的整个过程中都有效。对于需要在多处代码共享一个不可变的值时非常有用,例如游戏中允许玩家赚取的最大点数或光速。
---在实际使用中,最好将程序中用到的硬编码值都声明为常量,对于代码后续的维护有莫大的帮助。如果将来需要更改硬编码的值,你也只需要在代码中更改一处即可。
-
Rust 允许声明相同的变量名,在后面声明的变量会遮蔽掉前面声明的,如下所示:
--fn main() { - let x = 5; - // 在main函数的作用域内对之前的x进行遮蔽 - let x = x + 1; - - { - // 在当前的花括号作用域内,对之前的x进行遮蔽 - let x = x * 2; - println!("The value of x in the inner scope is: {}", x); - } - - println!("The value of x is: {}", x); -} -
这个程序首先将数值 5
绑定到 x
,然后通过重复使用 let x =
来遮蔽之前的 x
,并取原来的值加上 1
,所以 x
的值变成了 6
。第三个 let
语句同样遮蔽前面的 x
,取之前的值并乘上 2
,得到的 x
最终值为 12
。当运行此程序,将输出以下内容:
$ cargo run
- Compiling variables v0.1.0 (file:///projects/variables)
- ...
-The value of x in the inner scope is: 12
-The value of x is: 6
-
-这和 mut
变量的使用是不同的,第二个 let
生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配
-,而 mut
声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。
变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。
-例如,假设有一个程序要统计一个空格字符串的空格数量:
---#![allow(unused)] -fn main() { -// 字符串类型 -let spaces = " "; -// usize数值类型 -let spaces = spaces.len(); -} -
这种结构是允许的,因为第一个 spaces
变量是一个字符串类型,第二个 spaces
变量是一个全新的变量且和第一个具有相同的变量名,且是一个数值类型。所以变量遮蔽可以帮我们节省些脑细胞,不用去想如 spaces_str
和 spaces_num
此类的变量名;相反我们可以重复使用更简单的 spaces
变量名。如果你不用 let
:
--#![allow(unused)] -fn main() { -let mut spaces = " "; -spaces = spaces.len(); -} -
运行一下,你就会发现编译器报错:
-$ cargo run
- Compiling variables v0.1.0 (file:///projects/variables)
-error[E0308]: mismatched types
- --> src/main.rs:3:14
- |
-3 | spaces = spaces.len();
- | ^^^^^^^^^^^^ expected `&str`, found `usize`
-
-error: aborting due to previous error
-
-显然,Rust 对类型的要求很严格,不允许将整数类型 usize
赋值给字符串类型。usize
是一种 CPU 相关的整数类型,在数值类型中有详细介绍。
万事开头难,到目前为止,都进展很顺利,那下面开始,咱们正式进入 Rust 的类型世界,看看有哪些挑战在前面等着大家。
--- - -Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
-
Cargo 会在安装 Rust 的时候一并进行安装,无需我们手动的操作执行,安装 Rust 参见这里。
-在开始之前,先来明确一个名词: Package
,由于 Crate
被翻译成包,因此 Package
再被翻译成包就很不合适,经过斟酌,我们决定翻译成项目,你也可以理解为工程、软件包,总之,在本书中Package
意味着项目,而项目也意味着 Package
。
安装完成后,接下来使用 Cargo
来创建一个新的二进制项目,二进制意味着该项目可以作为一个服务运行或被编译成可执行文件运行。
--#![allow(unused)] -fn main() { -$ cargo new hello_world -} -
这里我们使用 cargo new
创建一个新的项目 ,事实上该命令等价于 cargo new hello_world --bin
,bin
是 binary
的简写,代表着二进制程序,由于 --bin
是默认参数,因此可以对其进行省略。
创建成功后,先来看看项目的基本目录结构长啥样:
-$ cd hello_world
-$ tree .
-.
-├── Cargo.toml
-└── src
- └── main.rs
-
-1 directory, 2 files
-
-这里有一个很显眼的文件 Cargo.toml
,一看就知道它是 Cargo
使用的配置文件,这个关系类似于: package.json
是 npm
的配置文件。
[package]
-name = "hello_world"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-
-以上就是 Cargo.toml
的全部内容,它被称之为清单( manifest ),包含了 Cargo
编译程序所需的所有元数据。
下面是 src/main.rs
的内容 :
-fn main() { - println!("Hello, world!"); -} -
可以看出 Cargo
还为我们自动生成了一个 hello world
程序,或者说二进制包,对程序进行编译构建:
$ cargo build
- Compiling hello_world v0.1.0 (file:///path/to/package/hello_world)
-
-然后再运行编译出的二进制可执行文件:
-$ ./target/debug/hello_world
-Hello, world!
-
-注意到路径中的 debug
了吗?它说明我们刚才的编译是 Debug
模式,该模式主要用于测试目的,如果想要进行生产编译,我们需要使用 Release
模式 cargo build --release
,然后通过 ./target/release/hello_world
运行。
除了上面的编译 + 运行方式外,在日常开发中,我们还可以使用一个简单的命令直接运行:
-$ cargo run
- Fresh hello_world v0.1.0 (file:///path/to/package/hello_world)
- Running `target/hello_world`
-Hello, world!
-
-cargo run
会帮我们自动完成编译、运行的过程,当然,该命令也支持 Release
模式: cargo run --release
。
--如果你的程序在跑性能测试 benchmark,一定要使用
-Release
模式,因为该模式下,程序会做大量性能优化
在快速了解 Cargo
的使用方式后,下面,我们将正式进入 Cargo 的学习之旅。
cargo build
的结果会被放入项目根目录下的 target
文件夹中,当然,这个位置可以三种方式更改:设置 CARGO_TARGET_DIR
环境变量、build.target-dir
配置项以及 --target-dir
命令行参数。
target
目录的结构取决于是否使用 --target
标志为特定的平台构建。
若 --target
标志没有指定,Cargo
会根据宿主机架构进行构建,构建结果会放入项目根目录下的 target
目录中,target
下每个子目录中包含了相应的 发布配置profile
的构建结果,例如 release
、debug
是自带的profile
,前者往往用于生产环境,因为会做大量的性能优化,而后者则用于开发环境,此时的编译效率和报错信息是最好的。
除此之外我们还可以定义自己想要的 profile
,例如用于测试环境的 profile
: test
,用于预发环境的 profile
:pre-prod
等。
目录 | 描述 |
---|---|
target/debug/ | 包含了 dev profile 的构建输出(cargo build 或 cargo build --debug ) |
target/release/ | release profile 的构建输出,cargo build --release |
target/foo/ | 自定义 foo profile 的构建输出,cargo build --profile=foo |
出于历史原因:
-dev
和 test
profile 的构建结果都存放在 debug
目录下release
和 bench
profile 则存放在 release
目录下当使用 --target XXX
为特定的平台编译后,输出会放在 target/XXX/
目录下:
目录 | 示例 |
---|---|
target/<triple>/debug/ | target/thumbv7em-none-eabihf/debug/ |
target/<triple>/release/ | target/thumbv7em-none-eabihf/release/ |
--注意:,当没有使用
---target
时,Cargo
会与构建脚本和过程宏一起共享你的依赖包,对于每个rustc
命令调用而言,RUSTFLAGS
也将被共享。而使用
---target
后,构建脚本、过程宏会针对宿主机的 CPU 架构进行各自构建,且不会共享RUSTFLAGS
。
在 profile 文件夹中(例如 debug
或 release
),包含编译后的最终成果:
目录 | 描述 |
---|---|
target/debug/ | 包含编译后的输出,例如二进制可执行文件、库对象( library target ) |
target/debug/examples/ | 包含示例对象( example target ) |
还有一些命令会在 target
下生成自己的独立目录:
目录 | 描述 |
---|---|
target/doc/ | 包含通过 cargo doc 生成的文档 |
target/package/ | 包含 cargo package 或 cargo publish 生成的输出 |
Cargo 还会创建几个用于构建过程的其它类型目录,它们的目录结构只应该被 Cargo 自身使用,因此可能会在未来发生变化:
-目录 | 描述 |
---|---|
target/debug/deps | 依赖和其它输出成果 |
target/debug/incremental | rustc 增量编译的输出,该缓存可以用于提升后续的编译速度 |
target/debug/build/ | 构建脚本的输出 |
在每一个编译成果的旁边,都有一个依赖信息文件,文件后缀是 .d
。该文件的语法类似于 Makefile
,用于说明构建编译成果所需的所有依赖包。
该文件往往用于提供给外部的构建系统,这样它们就可以判断 Cargo
命令是否需要再次被执行。
文件中的路径默认是绝对路径,你可以通过 build.dep-info-basedir
配置项来修改为相对路径。
# 关于 `.d` 文件的一个示例 : target/debug/foo.d
-/path/to/myproj/target/debug/foo: /path/to/myproj/src/lib.rs /path/to/myproj/src/main.rs
-
-sccache 是一个三方工具,可以用于在不同的工作空间中共享已经构建好的依赖包。
-为了设置 sccache
,首先需要使用 cargo install sccache
进行安装,然后在调用 Cargo
之前将 RUSTC_WRAPPER
环境变量设置为 sccache
。
bash
,可以将 export RUSTC_WRAPPER=sccache
添加到 .bashrc
中build.rustc-wrapper
配置项Cargo 使用了缓存的方式提升构建效率,当构建时,Cargo 会将已下载的依赖包放在 CARGO_HOME
目录下,下面一起来看看。
默认情况下,Cargo Home 所在的目录是 $HOME/.cargo/
,例如在 macos
,对应的目录是:
$ echo $HOME/.cargo/
-/Users/sunfei/.cargo/
-
-我们也可以通过修改 CARGO_HOME
环境变量的方式来重新设定该目录的位置。若你需要在项目中通过代码的方式来获取 CARGO_HOME
,home
包提供了相应的 API。
--注意! Cargo Home 目录的内部结构并没有稳定化,在未来可能会发生变化
-
config.toml
是 Cargo 的全局配置文件,具体请查看这里credentials.toml
为 cargo login
提供私有化登录证书,用于登录 package
注册中心,例如 crates.io
.crates.toml
, .crates2.json
这两个是隐藏文件,包含了通过 cargo install
安装的包的 package
信息,请不要手动修改!bin
目录包含了通过 cargo install
或 rustup
下载的包编译出的可执行文件。你可以将该目录加入到 $PATH
环境变量中,以实现对这些可执行文件的直接访问git
中存储了 Git
的资源文件:
-git/db
,当一个包依赖某个 git
仓库时,Cargo
会将该仓库克隆到 git/db
目录下,如果未来需要还会对其进行更新git/checkouts
,若指定了 git
源和 commit
,那相应的仓库就会从 git/db
中 checkout
到该目录下,因此同一个仓库的不同 checkout
共存成为了可能性registry
包含了注册中心( 例如 crates.io
)的元数据 和 packages
-registry/index
是一个 git 仓库,包含了注册中心中所有可用包的元数据( 版本、依赖等 )registry/cache
中保存了已下载的依赖,这些依赖包以 gzip
的压缩档案形式保存,后缀名为 .crate
registry/src
,若一个已下载的 .crate
档案被一个 package
所需要,该档案会被解压缩到 registry/src
文件夹下,最终 rustc
可以在其中找到所需的 .rs
文件为了避免持续集成时重复下载所有的包依赖,我们可以将 $CARGO_HOME
目录进行缓存,但缓存整个目录是效率低下的,原因是源文件可能会被缓存两次。
例如我们依赖一个包 serde 1.0.92
,如果将整个 $CACHE_HOME
目录缓存,那么serde
的源文件就会被缓存两次:在 registry/cache
中的 serde-1.0.92.crate
以及 registry/src
下被解压缩的 .rs
文件。
因此,在 CI 构建时,出于效率的考虑,我们仅应该缓存以下目录:
-bin/
registry/index/
registry/cache/
git/db/
理论上,我们可以手动移除缓存中的任何一部分,当后续有包需要时 Cargo
会尽可能去恢复这些资源:
registry/cache
下的 .crate
档案.git
中 checkout
缓存的仓库你也可以使用 cargo-cache 包来选择性的清除 cache
中指定的部分,当然,它还可以用来查看缓存中的组件大小。
在开发过程中,或多或少我们都会碰到这种问题,例如你同时打开了 VSCode IDE 和终端,然后在 Cargo.toml
中刚添加了一个新的依赖。
此时 IDE 会捕捉到这个修改然后自动去重新下载依赖(这个过程可能还会更新 crates.io
使用的索引列表),在此过程中, Cargo 会将相关信息写入到 $HOME/.cargo/.package_cache
下,并将其锁住。
如果你试图在另一个地方(例如终端)对同一个项目进行构建,就会报错: Blocking waiting for file lock on package cache
。
解决办法很简单:
-$HOME/.cargo/.package_cache
目录Cargo.toml
和 Cargo.lock
是 Cargo
的两个元配置文件,但是它们拥有不同的目的:
Cargo
自行维护,因此不要去手动修改它们的关系跟 package.json
和 package-lock.json
非常相似,从 JavaScript 过来的同学应该会比较好理解。
Cargo.lock
当本地开发时,Cargo.lock
自然是非常重要的,但是当你要把项目上传到 Git
时,例如 GitHub
,那是否上传 Cargo.lock
就成了一个问题。
关于是否上传,有如下经验准则:
-Cargo.lock
加入到 .gitignore
中。Cargo.lock
上传到源代码目录中。例如 axum
是 web 开发框架,它属于三方库类型的服务,因此源码目录中不应该出现 Cargo.lock
的身影,它的归宿是 .gitignore
。而 ripgrep
则恰恰相反,因为它是一个面向终端的产品,可以直接运行提供服务。
那么问题来了,为何会有这种选择?
-原因是 Cargo.lock
会详尽描述上一次成功构建的各种信息:环境状态、依赖、版本等等,Cargo 可以使用它提供确定性的构建环境和流程,无论何时何地。这种特性对于终端服务是非常重要的:能确定、稳定的在用户环境中运行起来是终端服务最重要的特性之一。
而对于三方库来说,情况就有些不同。它不仅仅被库的开发者所使用,还会间接影响依赖链下游的使用者。用户引入了三方库是不会去看它的 Cargo.lock
信息的,也不应该受这个库的确定性运行条件所限制。
还有个原因,在项目中,可能会有几个依赖库引用同一个三方库的同一个版本,那如果该三方库使用了 Cargo.lock
文件,那可能三方库的多个版本会被引入使用,这时就会造成版本冲突。换句话说,通过指定版本的方式引用一个依赖库是无法看到该依赖库的完整情况的,而只有终端的产品才会看到这些完整的情况。
Cargo.lock
Cargo.toml
是一个清单文件( manifest
)包含了我们 package
的描述元数据。例如,通过以下内容可以说明对另一个 package
的依赖 :
--#![allow(unused)] -fn main() { -[package] -name = "hello_world" -version = "0.1.0" - -[dependencies] -regex = { git = "https://github.com/rust-lang/regex.git" } -} -
可以看到,只有一个依赖,且该依赖的来源是 GitHub
上一个特定的仓库。由于我们没有指定任何版本信息,Cargo
会自动拉取该依赖库的最新版本( master
或 main
分支上的最新 commit
)。
这种使用方式,其实就错失了包管理工具的最大的优点:版本管理。例如你在今天构建使用了版本 A
,然后过了一段时间后,由于依赖包的升级,新的构建却使用了大更新版本 B
,结果因为版本不兼容,导致了构建失败。
可以看出,确保依赖版本的确定性是非常重要的:
---#![allow(unused)] -fn main() { -[dependencies] -regex = { git = "https://github.com/rust-lang/regex.git", rev = "9f9f693" } -} -
这次,我们使用了指定 rev
( revision
) 的方式来构建,那么不管未来何时再次构建,使用的依赖库都会是该 rev
,而不是最新的 commit
。
但是,这里还有一个问题:rev
需要手动的管理,你需要在每次更新包的时候都思考下 SHA-1
,这显然非常麻烦。
Cargo.lock
后当有了 Cargo.lock
后,我们无需手动追踪依赖库的 rev
,Cargo
会自动帮我们完成,还是之前的清单:
--#![allow(unused)] -fn main() { -[package] -name = "hello_world" -version = "0.1.0" - -[dependencies] -regex = { git = "https://github.com/rust-lang/regex.git" } -} -
第一次构建时,Cargo
依然会拉取最新的 master commit
,然后将以下信息写到 Cargo.lock
文件中:
--#![allow(unused)] -fn main() { -[[package]] -name = "hello_world" -version = "0.1.0" -dependencies = [ - "regex 1.5.0 (git+https://github.com/rust-lang/regex.git#9f9f693768c584971a4d53bc3c586c33ed3a6831)", -] - -[[package]] -name = "regex" -version = "1.5.0" -source = "git+https://github.com/rust-lang/regex.git#9f9f693768c584971a4d53bc3c586c33ed3a6831" -} -
可以看出,其中包含了依赖库的准确 rev
信息。当未来再次构建时,只要项目中还有该 Cargo.lock
文件,那构建依然会拉取同一个版本的依赖库,并且再也无需我们手动去管理 rev
的 SHA
信息!
由于 Cargo.lock
会锁住依赖的版本,你需要通过手动的方式将依赖更新到新的版本:
--#![allow(unused)] -fn main() { -$ cargo update # 更新所有依赖 -$ cargo update -p regex # 只更新 “regex” -} -
以上命令将使用新的版本信息重新生成 Cargo.lock
,需要注意的是 cargo update -p regex
传递的参数实际上是一个 Package ID
, regex
只是一个简写形式。
crates.io
是 Rust 社区维护的中心化注册服务,用户可以在其中寻找和下载所需的包。对于 cargo
来说,默认就是从这里下载依赖。
下面我们来添加一个 time
依赖包,若你的 Cargo.toml
文件中没有 [dependencies]
部分,就手动添加一个,并添加目标包名和版本号:
[dependencies]
-time = "0.1.12"
-
-可以看到我们指定了 time
包的版本号 "0.1.12",关于版本号,实际上还有其它的指定方式,具体参见指定依赖项章节。
如果想继续添加 regexp
包,只需在 time
包后面添加即可 :
[package]
-name = "hello_world"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-time = "0.1.12"
-regex = "0.1.41"
-
-此时,再通过运行 cargo build
来重新构建,首先 Cargo
会获取新的依赖以及依赖的依赖, 接着对它们进行编译并更新 Cargo.lock
:
$ cargo build
- Updating crates.io index
- Downloading memchr v0.1.5
- Downloading libc v0.1.10
- Downloading regex-syntax v0.2.1
- Downloading memchr v0.1.5
- Downloading aho-corasick v0.3.0
- Downloading regex v0.1.41
- Compiling memchr v0.1.5
- Compiling libc v0.1.10
- Compiling regex-syntax v0.2.1
- Compiling memchr v0.1.5
- Compiling aho-corasick v0.3.0
- Compiling regex v0.1.41
- Compiling hello_world v0.1.0 (file:///path/to/package/hello_world)
-
-在 Cargo.lock
中包含了我们项目使用的所有依赖的准确版本信息。这个非常重要,未来就算 regexp
的作者升级了该包,我们依然会下载 Cargo.lock
中的版本,而不是最新的版本,只有这样,才能保证项目依赖包不会莫名其妙的因为更新升级导致无法编译。 当然,你还可以使用 cargo update
来手动更新包的版本。
此时,就可以在 src/main.rs
中使用新引入的 regexp
包:
-use regex::Regex; - -fn main() { - let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); - println!("Did our date match? {}", re.is_match("2014-01-01")); -} -
运行后输出:
-$ cargo run
- Running `target/hello_world`
-Did our date match? true
-
-
-
- 如果看中 GitHub
上的某个开源 Rust 项目,那下载并构建它将是非常简单的。
$ git clone https://github.com/rust-lang/regex.git
-$ cd regex
-
-如上所示,直接从 GitHub
上克隆下来想要的项目,然后使用 cargo build
进行构建即可:
$ cargo build
- Compiling regex v1.5.0 (file:///path/to/package/regex)
-
-该命令将下载相关的依赖库,等下载成功后,再对 package
和下载的依赖进行一同的编译构建。
这就是包管理工具的强大之处,cargo build
搞定一切,而背后隐藏的复杂配置、参数你都无需关心。
在本章中,我们将学习 Cargo
的详细使用方式,例如 Package
的创建与管理、依赖拉取、Package
结构描述等。
一个典型的 Package
目录结构如下:
.
-├── Cargo.lock
-├── Cargo.toml
-├── src/
-│ ├── lib.rs
-│ ├── main.rs
-│ └── bin/
-│ ├── named-executable.rs
-│ ├── another-executable.rs
-│ └── multi-file-executable/
-│ ├── main.rs
-│ └── some_module.rs
-├── benches/
-│ ├── large-input.rs
-│ └── multi-file-bench/
-│ ├── main.rs
-│ └── bench_module.rs
-├── examples/
-│ ├── simple.rs
-│ └── multi-file-example/
-│ ├── main.rs
-│ └── ex_module.rs
-└── tests/
- ├── some-integration-tests.rs
- └── multi-file-test/
- ├── main.rs
- └── test_module.rs
-
-这也是 Cargo
推荐的目录结构,解释如下:
Cargo.toml
和 Cargo.lock
保存在 package
根目录下src
目录下lib
包根是 src/lib.rs
src/main.rs
-src/bin/
目录下benches
目录下examples
目录下tests
目录下关于 Rust 中的包和模块,之前的章节有更详细的解释。
-此外,bin
、tests
、examples
等目录路径都可以通过配置文件进行配置,它们被统一称之为 Cargo Target。
Cargo 可以通过 cargo test
命令运行项目中的测试文件:它会在 src/
底下的文件寻找单元测试,也会在 tests/
目录下寻找集成测试。
--#![allow(unused)] -fn main() { -$ cargo test - Compiling regex v1.5.0 (https://github.com/rust-lang/regex.git#9f9f693) - Compiling hello_world v0.1.0 (file:///path/to/package/hello_world) - Running target/test/hello_world-9c2b65bbb79eabce - -running 0 tests - -test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out -} -
从上面结果可以看出,项目中实际上还没有任何测试代码。
-事实上,除了单元测试、集成测试,cargo test
还会编译 examples/
下的示例文件以及文档中的示例。
如果希望深入学习如何在 Rust 编写及运行测试,请查阅该章节。
-持续集成是软件开发中异常重要的一环,大家应该都听说过 Jenkins,它就是一个拥有悠久历史的持续集成工具。简单来说,持续集成会定期拉取同一个项目中所有成员的相关代码,对其进行自动化构建。
-在没有持续集成前,首先开发者需要手动编译代码并运行单元测试、集成测试等基础测试,然后启动项目相关的所有服务,接着测试人员开始介入对整个项目进行回归测试、黑盒测试等系统化的测试,当测试通过后,最后再手动发布到指定的环境中运行,这个过程是非常冗长,且所有成员都需要同时参与的。
-在有了持续集成后,只要编写好相应的编译、测试、发布配置文件,那持续集成平台会自动帮助我们完成整个相关的流程,期间无需任何人介入,高效且可靠。
-关于如何使用 GitHub Actions
进行持续集成,在之前的章节已经有过详细的介绍,这里就不再赘述。
以下是 Travis CI
需要的一个简单的示例配置文件:
language: rust
-rust:
- - stable
- - beta
- - nightly
-matrix:
- allow_failures:
- - rust: nightly
-
-以上配置将测试所有的 Rust 发布版本,但是 nightly
版本的构建失败不会导致全局测试的失败,可以查看 Travis CI Rust 文档 获取更详细的说明。
以下是一个示例 .gitlab-ci.yml
文件:
stages:
- - build
-
-rust-latest:
- stage: build
- image: rust:latest
- script:
- - cargo build --verbose
- - cargo test --verbose
-
-rust-nightly:
- stage: build
- image: rustlang/rust:nightly
- script:
- - cargo build --verbose
- - cargo test --verbose
- allow_failure: true
-
-这里将测试 stable
和 nightly
发布版本,同样的,nightly
下的测试失败不会导致全局测试的失败。查看 Gitlab CI 文档 获取更详细的说明。
根据之前学习的知识,Rust 有两种类型的包: 库包和二进制包,前者是我们经常使用的依赖包,用于被其它包所引入,而后者是一个应用服务,可以编译成二进制可执行文件进行运行。
-包是通过 Rust 编译器 rustc
进行编译的:
--#![allow(unused)] -fn main() { -$ rustc hello.rs -$ ./hello -Hello, world! -} -
上面我们直接使用 rustc
对二进制包 hello.rs
进行编译,生成二进制可执行文件 hello
,并对其进行运行。
该方式虽然简单,但有几个问题:
-最关键的是,外部依赖库的引入也将是一个大问题。大部分实际的项目都有不少依赖包,而这些依赖包又间接的依赖了新的依赖包,在这种复杂情况下,如何管理依赖包及其版本也成为一个相当棘手的问题。
-正是因为这些原因,与其使用 rustc
,我们可以使用一个强大的包管理工具来解决问题:欢迎 Cargo
闪亮登场。
Cargo
解决了之前描述的所有问题,同时它保证了每次重复的构建都不会改变上一次构建的结果,这背后是通过完善且强大的依赖包版本管理来实现的。
总之,Cargo
为了实现目标,做了四件事:
Cargo.toml
和 Cargo.lock
Cargo.toml
中的依赖包版本描述,以及从 crates.io
下载包rustc
(或其它编译器) 并使用的正确的参数来构建项目,例如 cargo build
毫不夸张的说,得益于 Cargo
的标准化,只要你使用它构建过一个项目,那构建其它使用 Cargo
的项目,也将不存在任何困难。
Rust 语言的名气之所以这么大,保守估计 Cargo
的贡献就占了三分之一。
Cargo
是包管理工具,可以用于依赖包的下载、编译、更新、分发等,与 Cargo
一样有名的还有 crates.io
,它是社区提供的包注册中心:用户可以将自己的包发布到该注册中心,然后其它用户通过注册中心引入该包。
--本章内容是基于 Cargo Book 翻译,并做了一些内容优化和目录组织上的调整
-
下面我们通过一些例子来说明构建脚本该如何使用。社区中也提供了一些构建脚本的常用功能,例如:
-pkg-config
工具检测系统库cmake
来构建一个本地库rustc
的当前版本来实现条件编译的方法一些项目需要在编译开始前先生成一些代码,下面我们来看看如何在构建脚本中生成一个库调用。
-先来看看项目的目录结构:
-.
-├── Cargo.toml
-├── build.rs
-└── src
- └── main.rs
-
-1 directory, 3 files
-
-Cargo.toml
内容如下:
# Cargo.toml
-
-[package]
-name = "hello-from-generated-code"
-version = "0.1.0"
-
-接下来,再来看看构建脚本的内容:
--// build.rs - -use std::env; -use std::fs; -use std::path::Path; - -fn main() { - let out_dir = env::var_os("OUT_DIR").unwrap(); - let dest_path = Path::new(&out_dir).join("hello.rs"); - fs::write( - &dest_path, - "pub fn message() -> &'static str { - \"Hello, World!\" - } - " - ).unwrap(); - println!("cargo:rerun-if-changed=build.rs"); -} -
以上代码中有几点值得注意:
-OUT_DIR
环境变量说明了构建脚本的输出目录,也就是最终生成的代码文件的存放地址OUT_DIR
之外的任何文件return-if-changed
指令告诉 Cargo 只有在脚本内容发生变化时,才能重新编译和运行构建脚本。如果没有这一行,项目的任何文件发生变化都会导致 Cargo 重新编译运行该构建脚本下面,我们来看看 main.rs
:
-// src/main.rs - -include!(concat!(env!("OUT_DIR"), "/hello.rs")); - -fn main() { - println!("{}", message()); -} -
这里才是体现真正技术的地方,我们联合使用 rustc 定义的 include!
以及 concat!
和 env!
宏,将生成的代码文件( hello.rs
) 纳入到我们项目的编译流程中。
例子虽然很简单,但是它清晰地告诉了我们该如何生成代码文件以及将这些代码文件纳入到编译中来,大家以后有需要只要回头看看即可。
-有时,我们需要在项目中使用基于 C 或 C++ 的本地库,而这种使用场景恰恰是构建脚本非常擅长的。
-例如,下面来看看该如何在 Rust 中调用 C 并打印 Hello, World
。首先,来看看项目结构和 Cargo.toml
:
.
-├── Cargo.toml
-├── build.rs
-└── src
- ├── hello.c
- └── main.rs
-
-1 directory, 4 files
-
-# Cargo.toml
-
-[package]
-name = "hello-world-from-c"
-version = "0.1.0"
-edition = "2021"
-
-现在,我们还不会使用任何构建依赖,先来看看构建脚本:
--// build.rs - -use std::process::Command; -use std::env; -use std::path::Path; - -fn main() { - let out_dir = env::var("OUT_DIR").unwrap(); - - Command::new("gcc").args(&["src/hello.c", "-c", "-fPIC", "-o"]) - .arg(&format!("{}/hello.o", out_dir)) - .status().unwrap(); - Command::new("ar").args(&["crus", "libhello.a", "hello.o"]) - .current_dir(&Path::new(&out_dir)) - .status().unwrap(); - - println!("cargo:rustc-link-search=native={}", out_dir); - println!("cargo:rustc-link-lib=static=hello"); - println!("cargo:rerun-if-changed=src/hello.c"); -} -
首先,构建脚本将我们的 C 文件通过 gcc
编译成目标文件,然后使用 ar
将该文件转换成一个静态库,最后告诉 Cargo 我们的输出内容在 out_dir
中,编译器要在这里搜索相应的静态库,最终通过 -l static-hello
标志将我们的项目跟 libhello.a
进行静态链接。
但是这种硬编码的解决方式有几个问题:
-gcc
命令的跨平台性是受限的,例如 Windows 下就难以使用它,甚至于有些 Unix 系统也没有 gcc
命令,同样,ar
也有这个问题gcc
很可能无法输出一个 ARM 的可执行文件但是别怕,构建依赖 [build-dependencies]
解君忧:社区中已经有现成的解决方案,可以让这种任务得到更容易的解决。例如文章开头提到的 cc
包。首先在 Cargo.toml
中为构建脚本引入 cc
依赖:
[build-dependencies]
-cc = "1.0"
-
-然后重写构建脚本使用 cc
:
-// build.rs - -fn main() { - cc::Build::new() - .file("src/hello.c") - .compile("hello"); - println!("cargo:rerun-if-changed=src/hello.c"); -} -
不得不说,Rust 社区的大腿就是粗,代码立刻简洁了很多,最重要的是:可移植性、稳定性等头疼的问题也得到了一并解决。
-简单来说,cc
包将构建脚本使用 C
的需求进行了抽象:
cc
会针对不同的平台调用合适的编译器:windows 下调用 MSVC, MinGW 下调用 gcc, Unix 平台调用 cc 等OPT_LEVEL
、DEBUG
等会自动帮我们处理OUT_DIR
的位置也会被 cc
所处理如上所示,与其在每个构建脚本中复制粘贴相同的代码,将尽可能多的功能通过构建依赖来完成是好得多的选择。
-再回到例子中,我们来看看 src
下的项目文件:
// src/hello.c
-
-#include <stdio.h>
-
-void hello() {
- printf("Hello, World!\n");
-}
-
--// src/main.rs - -// 注意,这里没有再使用 `#[link]` 属性。我们把选择使用哪个 link 的责任交给了构建脚本,而不是在这里进行硬编码 -extern { fn hello(); } - -fn main() { - unsafe { hello(); } -} -
至此,这个简单的例子已经完成,我们学到了该如何使用构建脚本来构建 C 代码,当然又一次被构建脚本和构建依赖的强大所震撼!但控制下情绪,因为构建脚本还能做到更多。
-当一个 Rust 包想要链接一个本地系统库时,如何实现平台透明化,就成了一个难题。
-例如,我们想使用在 Unix 系统中的 zlib
库,用于数据压缩的目的。实际上,社区中的 libz-sys
包已经这么做了,但是出于演示的目的,我们来看看该如何手动完成,当然,这里只是简化版的,想要看完整代码,见这里。
为了更简单的定位到目标库的位置,可以使用 pkg-config
包,该包使用系统提供的 pkg-config
工具来查询库的信息。它会自动告诉 Cargo 该如何链接到目标库。
先修改 Cargo.toml
:
# Cargo.toml
-
-[package]
-name = "libz-sys"
-version = "0.1.0"
-edition = "2021"
-links = "z"
-
-[build-dependencies]
-pkg-config = "0.3.16"
-
-这里的 links = "z"
用于告诉 Cargo 我们想要链接到 libz
库,在下文还有更多的示例。
构建脚本也很简单:
--// build.rs - -fn main() { - pkg_config::Config::new().probe("zlib").unwrap(); - println!("cargo:rerun-if-changed=build.rs"); -} -
下面再在代码中使用:
---#![allow(unused)] -fn main() { -// src/lib.rs - -use std::os::raw::{c_uint, c_ulong}; - -extern "C" { - pub fn crc32(crc: c_ulong, buf: *const u8, len: c_uint) -> c_ulong; -} - -#[test] -fn test_crc32() { - let s = "hello"; - unsafe { - assert_eq!(crc32(0, s.as_ptr(), s.len() as c_uint), 0x3610a686); - } -} -} -
代码很清晰,也很简洁,这里就不再过多介绍,运行 cargo build --vv
来看看部分结果( 系统中需要已经安装 libz
库):
[libz-sys 0.1.0] cargo:rustc-link-search=native=/usr/lib
-[libz-sys 0.1.0] cargo:rustc-link-lib=z
-[libz-sys 0.1.0] cargo:rerun-if-changed=build.rs
-
-非常棒,pkg-config
帮助我们找到了目标库,并且还告知了 Cargo 所有需要的信息!
实际使用中,我们需要做的比上面的代码更多,例如 libz-sys
包会先检查环境变量 LIBZ_SYS_STATIC
或者 static
feature,然后基于源码去构建 libz
,而不是直接去使用系统库。
本例中,一起来看看该如何使用 libz-sys
包中的 zlib
来创建一个 C 依赖库。
若你有一个依赖于 zlib
的库,那可以使用 libz-sys
来自动发现或构建该库。这个功能对于交叉编译非常有用,例如 Windows 下往往不会安装 zlib
。
libz-sys
通过设置 include
元数据来告知其它包去哪里找到 zlib
的头文件,然后我们的构建脚本可以通过 DEP_Z_INCLUDE
环境变量来读取 include
元数据( 关于元数据的传递,见这里 )。
# Cargo.toml
-
-[package]
-name = "zuser"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-libz-sys = "1.0.25"
-
-[build-dependencies]
-cc = "1.0.46"
-
-通过包含 libz-sys
,确保了最终只会使用一个 libz
库,并且给了我们在构建脚本中使用的途径:
-// build.rs - -fn main() { - let mut cfg = cc::Build::new(); - cfg.file("src/zuser.c"); - if let Some(include) = std::env::var_os("DEP_Z_INCLUDE") { - cfg.include(include); - } - cfg.compile("zuser"); - println!("cargo:rerun-if-changed=src/zuser.c"); -} -
由于 libz-sys
帮我们完成了繁重的相关任务,C 代码只需要包含 zlib
的头文件即可,甚至于它还能在没有安装 zlib
的系统上找到头文件:
// src/zuser.c
-
-#include "zlib.h"
-
-// … 在剩余的代码中使用 zlib
-
-构建脚本可以通过发出 rustc-cfg
指令来开启编译时的条件检查。在本例中,一起来看看 openssl 包是如何支持多版本的 OpenSSL 库的。
openssl-sys
包对 OpenSSL 库进行了构建和链接,支持多个不同的实现(例如 LibreSSL )和多个不同的版本。它也使用了 links
配置,这样就可以给其它构建脚本传递所需的信息。例如 version_number
,包含了检测到的 OpenSSL 库的版本号信息。openssl-sys
自己的构建脚本中有类似于如下的代码:
--#![allow(unused)] -fn main() { -println!("cargo:version_number={:x}", openssl_version); -} -
该指令将 version_number
的信息通过环境变量 DEP_OPENSSL_VERSION_NUMBER
的方式传递给直接使用 openssl-sys
的项目。例如 openssl
包提供了更高级的抽象接口,并且它使用了 openssl-sys
作为依赖。openssl
的构建脚本会通过环境变量读取 openssl-sys
提供的版本号的信息,然后使用该版本号来生成一些 cfg
:
--#![allow(unused)] -fn main() { -// (portion of build.rs) - -if let Ok(version) = env::var("DEP_OPENSSL_VERSION_NUMBER") { - let version = u64::from_str_radix(&version, 16).unwrap(); - - if version >= 0x1_00_01_00_0 { - println!("cargo:rustc-cfg=ossl101"); - } - if version >= 0x1_00_02_00_0 { - println!("cargo:rustc-cfg=ossl102"); - } - if version >= 0x1_01_00_00_0 { - println!("cargo:rustc-cfg=ossl110"); - } - if version >= 0x1_01_00_07_0 { - println!("cargo:rustc-cfg=ossl110g"); - } - if version >= 0x1_01_01_00_0 { - println!("cargo:rustc-cfg=ossl111"); - } -} -} -
这些 cfg
可以跟 cfg
属性 或 cfg
宏一起使用以实现条件编译。例如,在 OpenSSL 1.1 中引入了 SHA3 的支持,那么我们就可以指定只有当版本号为 1.1 时,才包含并编译相关的代码:
--#![allow(unused)] -fn main() { -// (portion of openssl crate) - -#[cfg(ossl111)] -pub fn sha3_224() -> MessageDigest { - unsafe { MessageDigest(ffi::EVP_sha3_224()) } -} -} -
当然,大家在使用时一定要小心,因为这可能会导致生成的二进制文件进一步依赖当前的构建环境。例如,当二进制可执行文件需要在另一个操作系统中分发运行时,那它依赖的信息对于该操作系统可能是不存在的!
- - -一些项目希望编译第三方的非 Rust 代码,例如 C 依赖库;一些希望链接本地或者基于源码构建的 C 依赖库;还有一些项目需要功能性的工具,例如在构建之间执行一些代码生成的工作等。
-对于这些目标,社区已经提供了一些工具来很好的解决,Cargo 并不想替代它们,但是为了给用户带来一些便利,Cargo 提供了自定义构建脚本的方式,来帮助用户更好的解决类似的问题。
-若要创建构建脚本,我们只需在项目的根目录下添加一个 build.rs
文件即可。这样一来, Cargo 就会先编译和执行该构建脚本,然后再去构建整个项目。
以下是一个非常简单的脚本示例:
--fn main() { - // 以下代码告诉 Cargo ,一旦指定的文件 `src/hello.c` 发生了改变,就重新运行当前的构建脚本 - println!("cargo:rerun-if-changed=src/hello.c"); - // 使用 `cc` 来构建一个 C 文件,然后进行静态链接 - cc::Build::new() - .file("src/hello.c") - .compile("hello"); -} -
关于构建脚本的一些使用场景如下:
-下面的部分我们一起来看看构建脚本具体是如何工作的,然后在下个章节中还提供了一些关于如何编写构建脚本的示例。
---Note:
-package.build
可以用于改变构建脚本的名称,或者直接禁用该功能
在项目被构建之前,Cargo 会将构建脚本编译成一个可执行文件,然后运行该文件并执行相应的任务。
-在运行的过程中,脚本可以使用之前 println
的方式跟 Cargo 进行通信:通信内容是以 cargo:
开头的格式化字符串。
需要注意的是,Cargo 也不是每次都会重新编译构建脚本,只有当脚本的内容或依赖发生变化时才会。默认情况下,任何文件变化都会触发重新编译,如果你希望对其进行定制,可以使用 rerun-if
命令,后文会讲。
在构建脚本成功执行后,我们的项目就会开始进行编译。如果构建脚本的运行过程中发生错误,脚本应该通过返回一个非 0 码来立刻退出,在这种情况下,构建脚本的输出会被打印到终端中。
-我们可以通过环境变量的方式给构建脚本提供一些输入值,除此之外,构建脚本所在的当前目录也可以。
-构建脚本如果会产出文件,那么这些文件需要放在统一的目录中,该目录可以通过 OUT_DIR
环境变量来指定,构建脚本不应该修改该目录之外的任何文件!
在之前提到过,构建脚本可以通过 println!
输出内容跟 Cargo 进行通信:Cargo 会将每一行带有 cargo:
前缀的输出解析为一条指令,其它的输出内容会自动被忽略。
通过 println!
输出的内容在构建过程中默认是隐藏的,如果大家想要在终端中看到这些内容,你可以使用 -vv
来调用,以下 build.rs
:
-fn main() { - println!("hello, build.rs"); -} -
将输出:
-$ cargo run -vv
-[study_cargo 0.1.0] hello, build.rs
-
-构建脚本打印到标准输出 stdout
的所有内容将保存在文件 target/debug/build/<pkg>/output
中 (具体的位置可能取决于你的配置),stderr
的输出内容也将保存在同一个目录中。
以下是 Cargo 能识别的通信指令以及简介,如果大家希望深入了解每个命令,可以点击具体的链接查看官方文档的说明。
-cargo:rerun-if-changed=PATH
— 当指定路径的文件发生变化时,Cargo 会重新运行脚本cargo:rerun-if-env-changed=VAR
— 当指定的环境变量发生变化时,Cargo 会重新运行脚本cargo:rustc-link-arg=FLAG
– 将自定义的 flags 传给 linker,用于后续的基准性能测试 benchmark、 可执行文件 binary,、cdylib
包、示例和测试cargo:rustc-link-arg-bin=BIN=FLAG
– 自定义的 flags 传给 linker,用于可执行文件 BIN
cargo:rustc-link-arg-bins=FLAG
– 自定义的 flags 传给 linker,用于可执行文件cargo:rustc-link-arg-tests=FLAG
– 自定义的 flags 传给 linker,用于测试cargo:rustc-link-arg-examples=FLAG
– 自定义的 flags 传给 linker,用于示例cargo:rustc-link-arg-benches=FLAG
– 自定义的 flags 传给 linker,用于基准性能测试 benchmarkcargo:rustc-cdylib-link-arg=FLAG
— 自定义的 flags 传给 linker,用于 cdylib
包cargo:rustc-link-lib=[KIND=]NAME
— 告知 Cargo 通过 -l
去链接一个指定的库,往往用于链接一个本地库,通过 FFIcargo:rustc-link-search=[KIND=]PATH
— 告知 Cargo 通过 -L
将一个目录添加到依赖库的搜索路径中cargo:rustc-flags=FLAGS
— 将特定的 flags 传给编译器cargo:rustc-cfg=KEY[="VALUE"]
— 开启编译时 cfg
设置cargo:rustc-env=VAR=VALUE
— 设置一个环境变量cargo:warning=MESSAGE
— 在终端打印一条 warning 信息cargo:KEY=VALUE
— links
脚本使用的元数据构建脚本也可以引入其它基于 Cargo 的依赖包,只需要在 Cargo.toml
中添加或修改以下内容:
[build-dependencies]
-cc = "1.0.46"
-
-需要这么配置的原因在于构建脚本无法使用通过 [dependencies]
或 [dev-dependencies]
引入的依赖包,因为构建脚本的编译运行过程跟项目本身的编译过程是分离的的,且前者先于后者发生。同样的,我们项目也无法使用 [build-dependencies]
中的依赖包。
大家在引入依赖的时候,需要仔细考虑它会给编译时间、开源协议和维护性等方面带来什么样的影响。如果你在 [build-dependencies]
和 [dependencies]
引入了同样的包,这种情况下 Cargo 也许会对依赖进行复用,也许不会,例如在交叉编译时,如果不会,那编译速度自然会受到不小的影响。
在 Cargo.toml
中可以配置 package.links
选项,它的目的是告诉 Cargo 当前项目所链接的本地库,同时提供了一种方式可以在项目构建脚本之间传递元信息。
[package]
-# ...
-links = "foo"
-
-以上配置表明项目链接到一个 libfoo
本地库,当使用 links
时,项目必须拥有一个构建脚本,并且该脚本需要使用 rustc-link-lib
指令来链接目标库。
Cargo 要求一个本地库最多只能被一个项目所链接,换而言之,你无法让两个项目链接到同一个本地库,但是有一种方法可以降低这种限制,感兴趣的同学可以看看官方文档。
-假设 A 项目的构建脚本生成任意数量的 kv 形式的元数据,那这些元数据将传递给 A 用作依赖包的项目的构建脚本。例如,如果包 bar
依赖于 foo
,当 foo
生成 key=value
形式的构建脚本元数据时,那么 bar
的构建脚本就可以通过环境变量的形式使用该元数据:DEP_FOO_KEY=value
。
需要注意的是,该元数据只能传给直接相关者,对于间接的,例如依赖的依赖,就无能为力了。
-当 Cargo.toml
设置了 links
时, Cargo 就允许我们使用自定义库对现有的构建脚本进行覆盖。在 Cargo 使用的配置文件中添加以下内容:
[target.x86_64-unknown-linux-gnu.foo]
-rustc-link-lib = ["foo"]
-rustc-link-search = ["/path/to/foo"]
-rustc-flags = "-L /some/path"
-rustc-cfg = ['key="value"']
-rustc-env = {key = "value"}
-rustc-cdylib-link-arg = ["…"]
-metadata_key1 = "value"
-metadata_key2 = "value"
-
-增加这个配置后,在未来,一旦我们的某个项目声明了它链接到 foo
,那项目的构建脚本将不会被编译和运行,替代的是这里的配置将被使用。
warning
, rerun-if-changed
和 rerun-if-env-changed
这三个 key 在这里不应该被使用,就算用了也会被忽略。
Cargo 项目中包含有一些对象,它们包含的源代码文件可以被编译成相应的包,这些对象被称之为 Cargo Target。例如之前章节提到的库对象 Library
、二进制对象 Binary
、示例对象 Examples
、测试对象 Tests
和 基准性能对象 Benches
都是 Cargo Target。
本章节我们一起来看看该如何在 Cargo.toml
清单中配置这些对象,当然,大部分时候都无需手动配置,因为默认的配置通常由项目目录的布局自动推断出来。
在开始讲解如何配置对象前,我们先来看看这些对象究竟是什么,估计还有些同学对此有些迷糊 :)
-库对象用于定义一个库,该库可以被其它的库或者可执行文件所链接。该对象包含的默认文件名是 src/lib.rs
,且默认情况下,库对象的名称跟项目名是一致的,
一个工程只能有一个库对象,因此也只能有一个 src/lib.rs
文件,以下是一种自定义配置:
# 一个简单的例子:在 Cargo.toml 中定制化库对象
-[lib]
-crate-type = ["cdylib"]
-bench = false
-
-二进制对象在被编译后可以生成可执行的文件,默认的文件名是 src/main.rs
,二进制对象的名称跟项目名也是相同的。
大家应该还记得,一个项目拥有多个二进制文件,因此一个项目可以拥有多个二进制对象。当拥有多个对象时,对象的文件默认会被放在 src/bin/
目录下。
二进制对象可以使用库对象提供的公共 API,也可以通过 [dependencies]
来引入外部的依赖库。
我们可以使用 cargo run --bin <bin-name>
的方式来运行指定的二进制对象,以下是二进制对象的配置示例:
# Example of customizing binaries in Cargo.toml.
-[[bin]]
-name = "cool-tool"
-test = false
-bench = false
-
-[[bin]]
-name = "frobnicator"
-required-features = ["frobnicate"]
-
-示例对象的文件在根目录下的 examples
目录中。既然是示例,自然是使用项目中的库对象的功能进行演示。示例对象编译后的文件会存储在 target/debug/examples
目录下。
如上所示,示例对象可以使用库对象的公共 API,也可以通过 [dependencies]
来引入外部的依赖库。
默认情况下,示例对象都是可执行的二进制文件( 带有 fn main()
函数入口),毕竟例子是用来测试和演示我们的库对象,是用来运行的。而你完全可以将示例对象改成库的类型:
[[example]]
-name = "foo"
-crate-type = ["staticlib"]
-
-如果想要指定运行某个示例对象,可以使用 cargo run --example <example-name>
命令。如果是库类型的示例对象,则可以使用 cargo build --example <example-name>
进行构建。
与此类似,还可以使用 cargo install --example <example-name>
来将示例对象编译出的可执行文件安装到默认的目录中,将该目录添加到 $PATH
环境变量中,就可以直接全局运行安装的可执行文件。
最后,cargo test
命令默认会对示例对象进行编译,以防止示例代码因为长久没运行,导致严重过期以至于无法运行。
测试对象的文件位于根目录下的 tests
目录中,如果大家还有印象的话,就知道该目录是集成测试所使用的。
当运行 cargo test
时,里面的每个文件都会被编译成独立的包,然后被执行。
测试对象可以使用库对象提供的公共 API,也可以通过 [dependencies]
来引入外部的依赖库。
该对象的文件位于 benches
目录下,可以通过 cargo bench
命令来运行,关于基准测试,可以通过这篇文章了解更多。
我们可以通过 Cargo.toml
中的 [lib]
、[[bin]]
、[[example]]
、[[test]]
和 [[bench]]
部分对以上对象进行配置。
--大家可能会疑惑
-[lib]
和[[bin]]
的写法为何不一致,原因是这种语法是TOML
提供的数组特性,[[bin]]
这种写法意味着我们可以在 Cargo.toml 中创建多个[[bin]]
,每一个对应一个二进制文件上文提到过,我们只能指定一个库对象,因此这里只能使用
-[lib]
形式
由于它们的配置内容都是相似的,因此我们以 [lib]
为例来说明相应的配置项:
[lib]
-name = "foo" # 对象名称: 库对象、`src/main.rs` 二进制对象的名称默认是项目名
-path = "src/lib.rs" # 对象的源文件路径
-test = true # 能否被测试,默认是 true
-doctest = true # 文档测试是否开启,默认是 true
-bench = true # 基准测试是否开启
-doc = true # 文档功能是否开启
-plugin = false # 是否可以用于编译器插件(deprecated).
-proc-macro = false # 是否是过程宏类型的库
-harness = true # 是否使用libtest harness : https://doc.rust-lang.org/stable/rustc/tests/index.html
-edition = "2015" # 对象使用的 Rust Edition
-crate-type = ["lib"] # 生成的包类型
-required-features = [] # 构建对象所需的 Cargo Features (N/A for lib).
-
-对于库对象和默认的二进制对象( src/main.rs
),默认的名称是项目的名称( package.name
)。
对于其它类型的对象,默认是目录或文件名。
-除了 [lib]
外,name
字段对于其他对象都是必须的。
该字段的使用方式在过程宏章节有详细的介绍。
-对使用的 Rust Edition 版本进行设置。
-如果没有设置,则默认使用 [package]
中配置的 package.edition
,通常来说,这个字段不应该被单独设置,只有在一些特殊场景中才可能用到:例如将一个大型项目逐步升级为新的 edition 版本。
该字段定义了对象生成的包类型。它是一个数组,因此为同一个对象指定多个包类型。
-需要注意的是,只有库对象和示例对象可以被指定,因为其他的二进制、测试和基准测试对象只能是 bin
这个包类型。
默认的包类型如下:
-对象 | 包类型 |
---|---|
正常的库对象 | "lib" |
过程宏的库对象 | "proc-macro" |
示例对象 | "bin" |
可用的选项包括 bin
、lib
、rlib
、dylib
、cdylib
、staticlib
和 proc-macro
,如果大家想了解更多,可以看下官方的参考手册。
该字段用于指定在构建对象时所需的 features
列表。
该字段只对 [[bin]]
、 [[bench]]
、 [[test]]
和 [[example]]
有效,对于 [lib]
没有任何效果。
[features]
-# ...
-postgres = []
-sqlite = []
-tools = []
-
-[[bin]]
-name = "my-pg-tool"
-required-features = ["postgres", "tools"]
-
-默认情况下,Cargo
会基于项目的目录文件布局自动发现和确定对象,而之前的配置项则允许我们对其进行手动的配置修改(若项目布局跟标准的不一样时)。
而这种自动发现对象的设定可以通过以下配置来禁用:
-[package]
-# ...
-autobins = false
-autoexamples = false
-autotests = false
-autobenches = false
-
-只有在特定场景下才应该禁用自动对象发现。例如,你有一个模块想要命名为 bin
,目录结构如下:
├── Cargo.toml
-└── src
- ├── lib.rs
- └── bin
- └── mod.rs
-
-这在默认情况下会导致问题,因为 Cargo
会使用 src/bin
作为存放二进制对象的地方。
为了阻止这一点,可以设置 autobins = false
:
├── Cargo.toml
-└── src
- ├── lib.rs
- └── bin
- └── mod.rs
-
-
-
- Cargo 相关的配置有两种,第一种是对自身进行配置,第二种是对指定的项目进行配置,关于后者请查看 Cargo.toml 清单。对于普通用户而言第二种才是我们最常使用的。
-本文讲述的是如何对 Cargo 相关的工具进行配置,该配置中的部分内容可能会覆盖掉 Cargo.toml
中对应的部分,例如关于 profile
的内容。
在前面我们已经见识过如何为 Cargo 进行全局配置:$HOME/.cargo/config.toml
,事实上,还支持在一个 package
内对它进行配置。
总体原则是:Cargo
会顺着当前目录往上查找,直到找到目标配置文件。例如我们在目录 /projects/foo/bar/baz
下调用 Cargo 命令,那查找路径如下所示:
/projects/foo/bar/baz/.cargo/config.toml
/projects/foo/bar/.cargo/config.toml
/projects/foo/.cargo/config.toml
/projects/.cargo/config.toml
/.cargo/config.toml
$CARGO_HOME/config.toml
默认是 :
-%USERPROFILE%\.cargo\config.toml
$HOME/.cargo/config.toml
有了这种机制,我们既可以在全局中设置默认的配置,又可以每个包都设定独立的配置,甚至还能做版本控制。
-如果一个 key
在多个配置中出现,那这些 key
只会保留一个:最靠近 Cargo 执行目录的配置文件中的 key 的值将被最终使用(因此, $HOME 下的都是最低优先级)。需要注意的是,如果 key
的值是数组,那相应的值将被合并( join )。
对于工作空间而言,Cargo
的搜索策略是从 root 开始,对于内部成员中包含的 .cargo.toml
会自动忽略。例如一个工作空间拥有两个成员,每个成员都有配置文件: /projects/foo/bar/baz/mylib/.cargo/config.toml
和 /projects/foo/bar/baz/mybin/.cargo/config.toml
,但是 Cargo
并不会读取它们而是从工作空间的根( /projects/foo/bar/baz/
)开始往上查找。
--注意:Cargo 还支持没有
-.toml
后缀的.cargo/config
文件。对于.toml
的支持是从 Rust 1.39 版本开始,同时也是目前最推荐的方式。但若同时存在有后缀和无后缀的文件,Cargo 将使用无后缀的!
下面是一个完整的配置文件,并对常用的选项进行了翻译,大家可以参考下:
-paths = ["/path/to/override"] # 覆盖 `Cargo.toml` 中通过 path 引入的本地依赖
-
-[alias] # 命令别名
-b = "build"
-c = "check"
-t = "test"
-r = "run"
-rr = "run --release"
-space_example = ["run", "--release", "--", "\"command list\""]
-
-[build]
-jobs = 1 # 并行构建任务的数量,默认等于 CPU 的核心数
-rustc = "rustc" # rust 编译器
-rustc-wrapper = "…" # 使用该 wrapper 来替代 rustc
-rustc-workspace-wrapper = "…" # 为工作空间的成员使用 该 wrapper 来替代 rustc
-rustdoc = "rustdoc" # 文档生成工具
-target = "triple" # 为 target triple 构建 ( `cargo install` 会忽略该选项)
-target-dir = "target" # 存放编译输出结果的目录
-rustflags = ["…", "…"] # 自定义flags,会传递给所有的编译器命令调用
-rustdocflags = ["…", "…"] # 自定义flags,传递给 rustdoc
-incremental = true # 是否开启增量编译
-dep-info-basedir = "…" # path for the base directory for targets in depfiles
-pipelining = true # rustc pipelining
-
-[doc]
-browser = "chromium" # `cargo doc --open` 使用的浏览器,
- # 可以通过 `BROWSER` 环境变量进行重写
-
-[env]
-# Set ENV_VAR_NAME=value for any process run by Cargo
-ENV_VAR_NAME = "value"
-# Set even if already present in environment
-ENV_VAR_NAME_2 = { value = "value", force = true }
-# Value is relative to .cargo directory containing `config.toml`, make absolute
-ENV_VAR_NAME_3 = { value = "relative/path", relative = true }
-
-[cargo-new]
-vcs = "none" # 所使用的 VCS ('git', 'hg', 'pijul', 'fossil', 'none')
-
-[http]
-debug = false # HTTP debugging
-proxy = "host:port" # HTTP 代理,libcurl 格式
-ssl-version = "tlsv1.3" # TLS version to use
-ssl-version.max = "tlsv1.3" # 最高支持的 TLS 版本
-ssl-version.min = "tlsv1.1" # 最小支持的 TLS 版本
-timeout = 30 # HTTP 请求的超时时间,秒
-low-speed-limit = 10 # 网络超时阈值 (bytes/sec)
-cainfo = "cert.pem" # path to Certificate Authority (CA) bundle
-check-revoke = true # check for SSL certificate revocation
-multiplexing = true # HTTP/2 multiplexing
-user-agent = "…" # the user-agent header
-
-[install]
-root = "/some/path" # `cargo install` 安装到的目标目录
-
-[net]
-retry = 2 # 网络重试次数
-git-fetch-with-cli = true # 是否使用 `git` 命令来执行 git 操作
-offline = true # 不能访问网络
-
-[patch.<registry>]
-# Same keys as for [patch] in Cargo.toml
-
-[profile.<name>] # profile 配置,详情见"如何在 Cargo.toml 中配置 profile" : https://course.rs/cargo/reference/profiles.html#profile设置
-opt-level = 0
-debug = true
-split-debuginfo = '...'
-debug-assertions = true
-overflow-checks = true
-lto = false
-panic = 'unwind'
-incremental = true
-codegen-units = 16
-rpath = false
-[profile.<name>.build-override]
-[profile.<name>.package.<name>]
-
-[registries.<name>] # 设置其它的注册服务: https://course.rs/cargo/reference/specify-deps.html#从其它注册服务引入依赖包
-index = "…" # 注册服务索引列表的 URL
-token = "…" # 连接注册服务所需的鉴权 token
-
-[registry]
-default = "…" # 默认的注册服务名称: crates.io
-token = "…"
-
-[source.<name>] # 注册服务源和替换source definition and replacement
-replace-with = "…" # 使用给定的 source 来替换当前的 source,例如使用科大源来替换crates.io源以提升国内的下载速度:[source.crates-io] replace-with = 'ustc'
-directory = "…" # path to a directory source
-registry = "…" # 注册源的 URL ,例如科大源: [source.ustc] registry = "git://mirrors.ustc.edu.cn/crates.io-index"
-local-registry = "…" # path to a local registry source
-git = "…" # URL of a git repository source
-branch = "…" # branch name for the git repository
-tag = "…" # tag name for the git repository
-rev = "…" # revision for the git repository
-
-[target.<triple>]
-linker = "…" # linker to use
-runner = "…" # wrapper to run executables
-rustflags = ["…", "…"] # custom flags for `rustc`
-
-[target.<cfg>]
-runner = "…" # wrapper to run executables
-rustflags = ["…", "…"] # custom flags for `rustc`
-
-[target.<triple>.<links>] # `links` build script override
-rustc-link-lib = ["foo"]
-rustc-link-search = ["/path/to/foo"]
-rustc-flags = ["-L", "/some/path"]
-rustc-cfg = ['key="value"']
-rustc-env = {key = "value"}
-rustc-cdylib-link-arg = ["…"]
-metadata_key1 = "value"
-metadata_key2 = "value"
-
-[term]
-verbose = false # whether cargo provides verbose output
-color = 'auto' # whether cargo colorizes output
-progress.when = 'auto' # whether cargo shows progress bar
-progress.width = 80 # width of progress bar
-
-除了 config.toml
配置文件,我们还可以使用环境变量的方式对 Cargo 进行配置。
配置文件的中的 key foo.bar
对应的环境变量形式为 CARGO_FOO_BAR
,其中的.
、-
被转换成 _
,且字母都变成大写的。例如,target.x86_64-unknown-linux-gnu.runner
key 转换成环境变量后变成 CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER
。
就优先级而言,环境变量是比配置文件更高的。除了上面的机制,Cargo 还支持一些预定义的环境变量。
--- - -官方 Cargo Book 中本文的内容还有很多,但是剩余内容对于绝大多数用户都用不到,因此我们并没有涵盖其中。
-
依赖覆盖对于本地开发来说,是很常见的,大部分原因都是我们希望在某个包发布到 crates.io
之前使用它,例如:
master
分支发布了新的代码,恰好修复了某个 bug,因此你希望能单独对该分支进行下测试master
分支,但是时间不等人,因此你决定先使用自己修改的版本,等未来合并后,再继续使用官方版本下面我们来具体看看类似的问题该如何解决。
---上一章节中我们讲了如果通过多种引用方式来引入一个包,其实这也是一种依赖覆盖。
-
假设我们有一个项目正在使用 uuid
依赖包,但是却不幸地发现了一个 bug,由于这个 bug 影响了使用,没办法等到官方提交新版本,因此还是自己修复为好。
我们项目的 Cargo.toml
内容如下:
[package]
-name = "my-library"
-version = "0.1.0"
-
-[dependencies]
-uuid = "0.8.2"
-
-为了修复 bug
,首先需要将 uuid
的源码克隆到本地,笔者是克隆到和项目同级的目录下:
git clone https://github.com/uuid-rs/uuid
-
-下面,修改项目的 Cargo.toml
添加以下内容以引入本地克隆的版本:
[patch.crates-io]
-uuid = { path = "../uuid" }
-
-这里我们使用自己修改过的 patch
来覆盖来自 crates.io
的版本,由于克隆下来的 uuid
目录和我们的项目同级,因此通过相对路径 "../uuid" 即可定位到。
在成功为 uuid
打了本地补丁后,现在尝试在项目下运行 cargo build
,但是却报错了,而且报错内容有一些看不太懂:
$ cargo build
- Updating crates.io index
-warning: Patch `uuid v1.0.0-alpha.1 (/Users/sunfei/development/rust/demos/uuid)` was not used in the crate graph.
-Check that the patched package version and available features are compatible
-with the dependency requirements. If the patch has a different version from
-what is locked in the Cargo.lock file, run `cargo update` to use the new
-version. This may also occur with an optional dependency that is not enabled.
-
-具体原因比较复杂,但是仔细观察,会发现克隆下来的 uuid
的版本是 v1.0.0-alpha.1
(在 "../uuid/Cargo.toml"
中可以查看),然后我们本地引入的 uuid
版本是 0.8.2
,根据之前讲过的 crates.io
的版本规则,这两者是不兼容的,0.8.2
只能升级到 0.8.z
,例如 0.8.3
。
既然如此,我们先将 "../uuid/Cargo.toml" 中的 version = "1.0.0-alpha.1"
修改为 version = "0.8.3"
,然后看看结果先:
$ cargo build
- Updating crates.io index
- Compiling uuid v0.8.3 (/Users/sunfei/development/rust/demos/uuid)
-
-大家注意到最后一行了吗?我们成功使用本地的 0.8.3
版本的 uuid
作为最新的依赖,因此也侧面证明了,补丁 patch
的版本也必须遵循相应的版本兼容规则!
如果修改后还是有问题,大家可以试试以下命令,指定版本进行更新:
-% cargo update -p uuid --precise 0.8.3
- Updating crates.io index
- Updating uuid v0.8.3 (/Users/sunfei/development/rust/demos/uuid) -> v0.8.3
-
-修复 bug 后,我们可以提交 pr 给 uuid
,一旦 pr 被合并到了 master
分支,你可以直接通过以下方式来使用补丁:
[patch.crates-io]
-uuid = { git = 'https://github.com/uuid-rs/uuid' }
-
-等未来新的内容更新到 crates.io
后,大家就可以移除这个补丁,直接更新 [dependencies]
中的 uuid
版本即可!
还是 uuid
包,这次假设我们要为它新增一个特性,同时我们已经修改完毕,在本地测试过,并提交了相应的 pr,下面一起来看看该如何在它发布到 crates.io
之前继续使用。
再做一个假设,对于 uuid
来说,目前 crates.io
上的版本是 1.0.0
,在我们提交了 pr 并合并到 master
分支后,master
上的版本变成了 1.0.1
,这意味着未来 crates.io
上的版本也将变成 1.0.1
。
为了使用新加的特性,同时当该包在未来发布到 crates.io
后,我们可以自动使用 crates.io
上的新版本,而无需再使用 patch
补丁,可以这样修改 Cargo.toml
:
[package]
-name = "my-library"
-version = "0.1.0"
-
-[dependencies]
-uuid = "1.0.1"
-
-[patch.crates-io]
-uuid = { git = 'https://github.com/uuid-rs/uuid' }
-
-注意,我们将 [dependencies]
中的 uuid
版本提前修改为 1.0.1
,由于该版本在 crates.io
尚未发布,因此 patch
版本会被使用。
现在,我们的项目是基于 patch
版本的 uuid
来构建,也就是从 gihtub
的 master
分支中拉取最新的 commit
来构建。一旦未来 crates.io
上有了 1.0.1
版本,那项目就会继续基于 crates.io
来构建,此时,patch
就可以删除了。
patch
现在假设项目 A
的依赖是 B
和 uuid
,而 B
的依赖也是 uuid
,此时我们可以让 A
和 B
都使用来自 GitHub
的 patch
版本,配置如下:
[package]
-name = "my-binary"
-version = "0.1.0"
-
-[dependencies]
-my-library = { git = 'https://example.com/git/my-library' }
-uuid = "1.0.1"
-
-[patch.crates-io]
-uuid = { git = 'https://github.com/uuid-rs/uuid' }
-
-如上所示,patch
不仅仅对于 my-binary
项目有用,对于 my-binary
的依赖 my-library
来说,一样可以间接生效。
若我们想要覆盖的依赖并不是来自 crates.io
,就需要对 [patch]
做一些修改。例如依赖是 git
仓库,然后使用本地路径来覆盖它:
[patch."https://github.com/your/repository"]
-my-library = { path = "../my-library/path" }
-
-easy,轻松搞定!
-现在假设我们要发布一个大版本 2.0.0
,与之前类似,可以将 Cargo.toml
修改如下:
[dependencies]
-uuid = "2.0"
-
-[patch.crates-io]
-uuid = { git = "https://github.com/uuid-rs/uuid", branch = "2.0.0" }
-
-此时 2.0
版本在 crates.io
上还不存在,因此我们使用了 patch
版本且指定了 branch = "2.0.0"
。
patch
这里需要注意,与之前的小版本不同,大版本的 patch
不会发生间接的传递!,例如:
[package]
-name = "my-binary"
-version = "0.1.0"
-
-[dependencies]
-my-library = { git = 'https://example.com/git/my-library' }
-uuid = "1.0"
-
-[patch.crates-io]
-uuid = { git = 'https://github.com/uuid-rs/uuid', branch = '2.0.0' }
-
-以上配置中, my-binary
将继续使用 1.x.y
系列的版本,而 my-library
将使用最新的 2.0.0
patch。
原因是,大版本更新往往会带来破坏性的功能,Rust 为了让我们平稳的升级,采用了滚动的方式:在依赖图中逐步推进更新,而不是一次性全部更新。
-在之前章节,我们介绍过如何使用 package key
来重命名依赖包,现在来看看如何使用它同时引入多个 patch
。
假设,我们对 serde
有两个新的 patch
需求:
serde
官方解决了一个 bug
但是还没发布到 crates.io
,我们想直接从 git
仓库的最新 commit
拉取版本 1.*
serde
添加了新的功能,命名为 2.0.0
版本,并将该版本上传到自己的 git
仓库中为了满足这两个 patch
,可以使用如下内容的 Cargo.toml
:
[patch.crates-io]
-serde = { git = 'https://github.com/serde-rs/serde' }
-serde2 = { git = 'https://github.com/example/serde', package = 'serde', branch = 'v2' }
-
-第一行说明,第一个 patch
从官方仓库 main
分支的最新 commit
拉取,而第二个则从我们自己的仓库拉取 v2
分支,同时将其重命名为 serde2
。
这样,在代码中就可以分别通过 serde
和 serde2
引用不同版本的依赖库了。
有时我们只是临时性地对一个项目进行处理,因此并不想去修改它的 Cargo.toml
。此时可以使用 Cargo
提供的路径覆盖方法: 注意,这个方法限制较多,如果可以,还是要使用 [patch]。
与 [patch]
修改 Cargo.toml
不同,路径覆盖修改的是 Cargo
自身的配置文件 $Home/.cargo/config.toml
:
paths = ["/path/to/uuid"]
-
-paths
数组中的元素是一个包含 Cargo.toml
的目录(依赖包),在当前例子中,由于我们只有一个 uuid
,因此只需要覆盖它即可。目标路径可以是相对的,也是绝对的,需要注意,如果是相对路径,那是相对包含 .cargo
的 $Home
来说的。
---
[replace]
已经被标记为deprecated
,并将在未来被移除,请使用[patch]
替代
虽然不建议使用,但是如果大家阅读其它项目时依然可能会碰到这种用法:
-[replace]
-"foo:0.1.0" = { git = 'https://github.com/example/foo' }
-"bar:1.0.2" = { path = 'my/local/bar' }
-
-语法看上去还是很清晰的,[replace]
中的每一个 key
都是 Package ID
格式,通过这种写法可以在依赖图中任意挑选一个节点进行覆盖。
以下我们一起来看看一些来自真实世界的示例。
-如果一些包的部分特性不再启用,就可以减少该包占用的大小以及编译时间:
-syn
包可以用来解析 Rust 代码,由于它很受欢迎,大量的项目都在引用,因此它给出了非常清晰的文档关于如何最小化使用它包含的 features
regex
也有关于 features 的描述文档,例如移除 Unicode 支持的 feature 可以降低最终生成可执行文件的大小winapi
拥有众多 features,这些 feature
对用了各种 Windows API,你可以只引入代码中用到的 API 所对应的 feature.serde_json
拥有一个 preserve_order
feature,可以用于在序列化时保留 JSON 键值对的顺序。同时,该 feature 还会启用一个可选依赖 indexmap。
当这么做时,一定要小心不要破坏了 SemVer 的版本兼容性,也就是说:启用 feature 后,代码依然要能正常工作。
-一些包希望能同时支持 no_std
和 std
环境,例如该包希望支持嵌入式系统或资源紧张的系统,且又希望能支持其它的平台,此时这种做法是非常有用的,因为标准库 std
会大幅增加编译出来的文件的大小,对于资源紧张的系统来说,no_std
才是最合适的。
wasm-bindgen 定义了一个 std feature,它是默认启用的。首先,在库的顶部,它无条件的启用了 no_std
属性,它可以确保 std
和 std prelude
不会自动引入到作用域中来。其次,在不同的地方(示例 1,示例 2),它通过 #[cfg(feature = "std")]
启用 std
feature 来添加 std
标准库支持。
从依赖库再导出 features 在有些场景中会相当有用,这样用户就可以通过依赖包的 features 来控制功能而不是自己去手动定义。
-例如 regex
将 regex_syntax
包的 features 进行了再导出,这样 regex
的用户无需知道 regex_syntax
包,但是依然可以访问后者包含的 features。
一些包可能会拥有彼此互斥的 features(无法共存,上一章节中有讲到),其中一个办法就是为 feature 定义优先级,这样其中一个就会优于另一个被启用。
-例如 log
包,它有几个 features 可以用于在编译期选择最大的日志级别,这里,它就使用了 cfg-if
的方式来设置优先级。一旦多个 features
被启用,那更高优先级的就会优先被启用。
一些包拥有过程宏,这些宏必须定义在一个独立的包中。但是不是所有的用户都需要过程宏的,因此也无需引入该包。
-在这种情况下,将过程宏所在的包定义为可选依赖,是很不错的选择。这样做还有一个好处:有时过程宏的版本必须要跟父包进行同步,但是我们又不希望所有的用户都进行同步。
-其中一个例子就是 serde ,它有一个 derive feature 可以启用 serde_derive 过程宏。由于 serde_derive
包跟 serde
的关系非常紧密,因此它使用了版本相同的需求来保证两者的版本同步性。
Rust 有些实验性的 API 或语言特性只能在 nightly 版本下使用,但某些使用了这些 API 的包并不想强制他们的用户也使用 nightly
版本,因此他们会通过 feature 的方式来控制。
若用户希望使用这些 API 时,需要启用相应的 feature ,而这些 feature 只能在 nightly 下使用。若用户不需要使用这些 API,就无需开启 相应的 feature,自然也不需要使用 nightly 版本。
-例如 rand
包有一个 simd_support feature 就只能在 nightly 下使用,若我们不使用该 feature,则在 stable 下依然可以使用 rand
。
有一些包会提前将一些实验性的 API 放出去,既然是实验性的,自然无法保证其稳定性。在这种情况下,通常会在文档中将相应的 features 标记为实验性,意味着它们在未来可能会发生大的改变(甚至 minor 版本都可能发生)。
-其中一个例子是 async-std 包,它拥有一个 unstable feature,用来标记一些新的 API,表示人们已经可以选择性的使用但是还没有准备好去依赖它。
- - -Cargo Feature
是非常强大的机制,可以为大家提供条件编译和可选依赖的高级特性。
Feature
可以通过 Cargo.toml
中的 [features]
部分来定义:其中每个 feature
通过列表的方式指定了它所能启用的其他 feature
或可选依赖。
假设我们有一个 2D 图像处理库,然后该库所支持的图片格式可以通过以下方式启用:
-[features]
-# 定义一个 feature : webp, 但它并没有启用其它 feature
-webp = []
-
-当定义了 webp
后,我们就可以在代码中通过 cfg
表达式来进行条件编译。例如项目中的 lib.rs
可以使用以下代码对 webp
模块进行条件引入:
#[cfg(feature = "webp")]
-pub mod webp;
-
-#[cfg(feature = "webp")]
的含义是:只有在 webp
feature 被定义后,以下的 webp
模块才能被引入进来。由于我们之前在 [features]
里定义了 webp
,因此以上代码的 webp
模块会被成功引入。
在 Cargo.toml
中定义的 feature
会被 Cargo
通过命令行参数 --cfg
传给 rustc
,最终由后者完成编译:rustc --cfg ...
。若项目中的代码想要测试 feature
是否存在,可以使用 cfg
属性或 cfg
宏。
之前我们提到了一个 feature
还可以开启其他 feature
,举个例子,例如 ICO 图片格式包含 BMP 和 PNG 格式,因此当 ico
被启用后,它还得确保启用 bmp
和 png
:
[features]
-bmp = []
-png = []
-ico = ["bmp", "png"]
-webp = []
-
-对此,我们可以理解为: bmp
和 png
是开启 ico
的先决条件(注:开启 ico
,会自动开启 bmp
, png
)。
Feature 名称可以包含来自 Unicode XID standard 定义的字母,允许使用 _
或 0-9
的数字作为起始字符,在起始字符后,还可以使用 -
、+
或 .
。
但是我们还是推荐按照 crates.io 的方式来设置 Feature 名称 : crate.io
要求名称只能由 ASCII 字母数字、_
、-
或 +
组成。
默认情况下,所有的 feature
都会被自动禁用,可以通过 default
来启用它们:
[features]
-default = ["ico", "webp"]
-bmp = []
-png = []
-ico = ["bmp", "png"]
-webp = []
-
-使用如上配置的项目被构建时,default
feature 首先会被启用,然后它接着启用了 ico
和 webp
feature,当然我们还可以关闭 default
:
--no-default-features
命令行参数可以禁用 default
featuredefault-features = false
选项可以在依赖声明中指定--当你要去改变某个依赖库的
-default
启用的 feature 列表时(例如觉得该库引入的 feature 过多,导致最终编译出的文件过大),需要格外的小心,因为这可能会导致某些功能的缺失
当依赖被标记为 "可选 optional" 时,意味着它默认不会被编译。假设我们的 2D 图片处理库需要用到一个外部的包来处理 GIF 图片:
-[dependencies]
-gif = { version = "0.11.1", optional = true }
-
-这种可选依赖的写法会自动定义一个与依赖同名的 feature,也就是 gif
feature,这样一来,当我们启用 gif
feature 时,该依赖库也会被自动引入并启用:例如通过 --feature gif
的方式启用 feature 。
--注意:目前来说,
-[feature]
中定义的 feature 还不能与已引入的依赖库同名。但是在nightly
中已经提供了实验性的功能用于改变这一点: namespaced features
当然,我们还可以通过显式定义 feature 的方式来启用这些可选依赖库,例如为了支持 AVIF 图片格式,我们需要引入两个依赖包,由于 avif
是通过 feature 引入的可选格式,因此它依赖的两个包也必须声明为可选的:
[dependencies]
-ravif = { version = "0.6.3", optional = true }
-rgb = { version = "0.8.25", optional = true }
-
-[features]
-avif = ["ravif", "rgb"]
-
-之后,avif
feature 一旦被启用,那这两个依赖库也将自动被引入。
--注意:我们之前也讲过条件引入依赖的方法,那就是使用平台相关的依赖,与基于 feature 的可选依赖不同,它们是基于特定平台的可选依赖
-
就像我们的项目可以定义 feature
一样,依赖库也可以定义它自己的 feature
,也有需要启用的 feature
列表,当引入该依赖库时,我们可以通过以下方式为其启用相关的 features
:
[dependencies]
-serde = { version = "1.0.118", features = ["derive"] }
-
-以上配置为 serde
依赖开启了 derive
feature,还可以通过 default-features = false
来禁用依赖库的 default
feature :
[dependencies]
-flate2 = { version = "1.0.3", default-features = false, features = ["zlib"] }
-
-这里我们禁用了 flate2
的 default
feature,但又手动为它启用了 zlib
feature。
--注意:这种方式未必能成功禁用
-default
,原因是可能会有其它依赖也引入了flate2
,并且没有对default
进行禁用,那此时default
依然会被启用。查看下文的 feature 同一化 获取更多信息
-
除此之外,还能通过下面的方式来间接开启依赖库的 feature :
-[dependencies]
-jpeg-decoder = { version = "0.1.20", default-features = false }
-
-[features]
-# Enables parallel processing support by enabling the "rayon" feature of jpeg-decoder.
-parallel = ["jpeg-decoder/rayon"]
-
-如上所示,我们定义了一个 parallel
feature,同时为其启用了 jpeg-decoder
依赖的 rayon
feature。
--注意: 上面的 "package-name/feature-name" 语法形式不仅会开启指定依赖的指定 feature,若该依赖是可选依赖,那还会自动将其引入
-在
-nightly
版本中,可以对这种行为进行禁用:weak dependency features
以下的命令行参数可以启用指定的 feature
:
--features FEATURES
: 启用给出的 feature 列表,可以使用逗号或空格进行分隔,若你是在终端中使用,还需要加上双引号,例如 --features "foo bar"
。 若在工作空间中构建多个 package
,可以使用 package-name/feature-name
为特定的成员启用 features--all-features
: 启用命令行上所选择的所有包的所有 features--no-default-features
: 对选择的包禁用 default
featurefeature
只有在定义的包中才是唯一的,不同包之间的 feature
允许同名。因此,在一个包上启用 feature
不会导致另一个包的同名 feature
被误启用。
当一个依赖被多个包所使用时,这些包对该依赖所设置的 feature
将被进行合并,这样才能确保该依赖只有一个拷贝存在,这个过程就被称之为同一化。大家可以查看这里了解下解析器如何对 feature 进行解析处理。
这里,我们使用 winapi
为例来说明这个过程。首先,winapi
使用了大量的 features
;然后我们有两个包 foo
和 bar
分别使用了它的两个 features,那么在合并后,最终 winapi
将同时启四个 features :
由于这种不可控性,我们需要让 启用feature = 添加特性
这个等式成立,换而言之,启用一个 feature 不应该导致某个功能被禁止。这样才能的让多个包启用同一个依赖的不同 features。
例如,如果我们想可选的支持 no_std
环境(不使用标准库),那么有两种做法:
no_std
feature 启用时,禁用相关的标准库代码std
feature 启用时,才使用标准库的代码前者就是功能削减,与之相对,后者是功能添加,根据之前的内容,我们应该选择后者的做法:
---#![allow(unused)] -#![no_std] - -fn main() { -#[cfg(feature = "std")] -extern crate std; - -#[cfg(feature = "std")] -pub fn function_that_requires_std() { - // ... -} -} -
某极少数情况下,features 之间可能会互相不兼容。我们应该避免这种设计,因为如果一旦这么设计了,那你可能需要修改依赖图的很多地方才能避免两个不兼容 feature 的同时启用。
-如果实在没有办法,可以考虑增加一个编译错误来让报错更清晰:
-#[cfg(all(feature = "foo", feature = "bar"))]
-compile_error!("feature \"foo\" and feature \"bar\" cannot be enabled at the same time");
-
-当同时启用 foo
和 bar
时,编译器就会爆出一个更清晰的错误:feature foo
和 bar
无法同时启用。
总之,我们还是应该在设计上避免这种情况的发生,例如:
-cfg
表达式在复杂的依赖图中,如果想要了解不同的 features 是如何被多个包多启用的,这是相当困难的。好在 cargo tree
命令提供了几个选项可以帮组我们更好的检视哪些 features 被启用了:
cargo tree -e features
,该命令以依赖图的方式来展示已启用的 features,包含了每个依赖包所启用的特性:
$ cargo tree -e features
-test_cargo v0.1.0 (/Users/sunfei/development/rust/demos/test_cargo)
-└── uuid feature "default"
- ├── uuid v0.8.2
- └── uuid feature "std"
- └── uuid v0.8.2
-
-cargo tree -f "{p} {f}"
命令会提供一个更加紧凑的视图:
$ cargo tree -f "{p} {f}"
-test_cargo v0.1.0 (/Users/sunfei/development/rust/demos/test_cargo)
-└── uuid v0.8.2 default,std
-
-cargo tree -e features -i foo
,该命令会显示 features
会如何"流入"指定的包 foo
中:
$ cargo tree -e features -i uuid
-uuid v0.8.2
-├── uuid feature "default"
-│ └── test_cargo v0.1.0 (/Users/sunfei/development/rust/demos/test_cargo)
-│ └── test_cargo feature "default" (command-line)
-└── uuid feature "std"
- └── uuid feature "default" (*)
-
-该命令在依赖图较为复杂时非常有用,使用它可以让你了解某个依赖包上开启了哪些 features
以及其中的原因。
大家可以查看官方的 cargo tree
文档获取更加详细的使用信息。
我们还能通过以下配置指定使用 V2 版本的解析器( resolver ):
-[package]
-name = "my-package"
-version = "1.0.0"
-resolver = "2"
-
-V2 版本的解析器可以在某些情况下避免 feature 同一化的发生,具体的情况在这里有描述,下面做下简单的总结:
-features
且此时并没有被构建,会被忽略build-dependencies
和 proc-macros
不再跟普通的依赖共享 features
dev-dependencies
的 features
不会被启用,除非正在构建的对象需要它们(例如测试对象、示例对象等)对于部分场景而言,feature 同一化确实是需要避免的,例如,一个构建依赖开启了 std
feature,而同一个依赖又被用于 no_std
环境,很明显,开启 std
将导致错误的发生。
说完优点,我们再来看看 V2 的缺点,其中增加编译构建时间就是其中之一,原因是同一个依赖会被构建多次(每个都拥有不同的 feature 列表)。
---由于此部分内容可能只有极少数的用户需要,因此我们并没有对其进行扩展,如果大家希望了解更多关于 V2 的内容,可以查看官方文档
-
构建脚本可以通过 CARGO_FEATURE_<name>
环境变量获取启用的 feature
列表,其中 <name>
是 feature 的名称,该名称被转换成大全写字母,且 -
被转换为 _
。
该字段可以用于禁用特定的 Cargo Target:当某个 feature 没有被启用时,查看这里获取更多信息。
-启用一个 feature 不应该引入一个不兼容 SemVer 的改变。例如,启用的 feature 不应该改变现有的 API,因为这会给用户造成不兼容的破坏性变更。 如果大家想知道哪些变化是兼容的,可以参见官方文档。
-总之,在新增/移除 feature 或可选依赖时,你需要小心,因此这些可能会造成向后不兼容性。更多信息参见这里,简单总结如下:
-minor
版本时,以下通常是安全的:
-
-minor
版本时,以下操作应该避免:
-
-将你的项目支持的 feature 信息写入到文档中是非常好的选择:
-lib.rs
的顶部添加文档注释的方式来实现。例如 regex
就是这么做的。fn main
入口),可以将说明放在 README
文件或其他文档中,例如 sccache。特别是对于不稳定的或者不该再被使用的 feature 而言,它们更应该被放在文档中进行清晰的说明。
-当构建发布到 docs.rs
上的文档时,会使用 Cargo.toml
中的元数据来控制哪些 features 会被启用。查看 docs.rs 文档获取更多信息。
若依赖库的文档中对其使用的 features
做了详细描述,那你会更容易知道他们使用了哪些 features
以及该如何使用。
当依赖库的文档没有相关信息时,你也可以通过源码仓库的 Cargo.toml
文件来获取,但是有些时候,使用这种方式来跟踪并获取全部相关的信息是相当困难的。
进阶指南包含了 Cargo 的参考级内容,大家可以先看一遍了解下大概有什么,然后在后面需要时,再回来查询如何使用。
- - -Cargo.toml
又被称为清单( manifest
),文件格式是 TOML
,每一个清单文件都由以下部分组成:
cargo-features
— 只能用于 nightly
版本的 feature
[package]
— 定义项目( package
)的元信息
-name
— 名称version
— 版本authors
— 开发作者edition
— Rust edition.rust-version
— 支持的最小化 Rust 版本description
— 描述documentation
— 文档 URLreadme
— README 文件的路径homepage
- 主页 URLrepository
— 源代码仓库的 URLlicense
— 开源协议 License.license-file
— License 文件的路径.keywords
— 项目的关键词categories
— 项目分类workspace
— 工作空间 workspace 的路径build
— 构建脚本的路径links
— 本地链接库的名称exclude
— 发布时排除的文件include
— 发布时包含的文件publish
— 用于阻止项目的发布metadata
— 额外的配置信息,用于提供给外部工具default-run
— [cargo run
] 所使用的默认可执行文件( binary )autobins
— 禁止可执行文件的自动发现autoexamples
— 禁止示例文件的自动发现autotests
— 禁止测试文件的自动发现autobenches
— 禁止 bench 文件的自动发现resolver
— 设置依赖解析器( dependency resolver)[lib]
— Library target 设置.[[bin]]
— Binary target 设置.[[example]]
— Example target 设置.[[test]]
— Test target 设置.[[bench]]
— Benchmark target 设置.[dependencies]
— 项目依赖包[dev-dependencies]
— 用于 examples、tests 和 benchmarks 的依赖包[build-dependencies]
— 用于构建脚本的依赖包[target]
— 平台特定的依赖包[badges]
— 用于在注册服务(例如 crates.io ) 上显示项目的一些状态信息,例如当前的维护状态:活跃中、寻找维护者、deprecated[features]
— features
可以用于条件编译[patch]
— 推荐使用的依赖覆盖方式[replace]
— 不推荐使用的依赖覆盖方式 (deprecated).[profile]
— 编译器设置和优化[workspace]
— 工作空间的定义下面,我们将对其中一些部分进行详细讲解。
-Cargo.toml
中第一个部分就是 package
,用于设置项目的相关信息:
[package]
-name = "hello_world" # the name of the package
-version = "0.1.0" # the current version, obeying semver
-authors = ["Alice <a@example.com>", "Bob <b@example.com>"]
-
-其中,只有 name
和 version
字段是必须填写的。当发布到注册服务时,可能会有额外的字段要求,具体参见发布到 crates.io。
项目名用于引用一个项目( package
),它有几个用途:
package
时,会使用该 name
name
只能使用 alphanumeric
字符、 -
和 _
,并且不能为空。
事实上,name
的限制不止如此,例如:
cargo new
或 cargo init
创建时,name
还会被施加额外的限制,例如不能使用 Rust 关键字名称作为 name
crates.io
,那还有更多的限制: name
使用 ASCII
码,不能使用已经被使用的名称,例如 uuid
已经在 crates.io
上被使用,因此我们只能使用类如 uuid_v1
的名称,才能将项目发布到 crates.io
上Cargo 使用了语义化版本控制的概念,例如字符串 "0.1.12"
是一个 semver
格式的版本号,符合 "x.y.z"
的形式,其中 x
被称为主版本major
, y
被称为小版本 minor
,而 z
被称为补丁 patch
,可以看出从左到右,版本的影响范围逐步降低,补丁的更新是无关痛痒的,并不会造成 API 的兼容性被破坏。
使用该规则,你还需要遵循一些基本规则:
-x.y.z
形式的版本号,例如 1.0.0
而不是 1.0
1.0.0
之前,怎么都行,但是如果有破坏性变更( breaking changes ),需要增加 minor
版本号。例如,为结构体新增字段或为枚举新增成员就是一种破坏性变更1.0.0
之后,如果发生破坏性变更,需要增加 major
版本号1.0.0
之后不要去破坏构建流程1.0.0
之后,不要在 patch
更新中添加新的 api
( pub
声明),如果要添加新的 pub
结构体、特征、类型、函数、方法等对象时,增加 minor
版本号如果大家想知道 Rust 如何使用版本号来解析依赖,可以查看这里。同时 SemVer 兼容性 提供了更为详尽的破坏性变更列表。
-[package]
-authors = ["Sunfei <contact@im.dev>"]
-
-该字段仅用于项目的元信息描述和 build.rs
用到的 CARGO_PKG_AUTHORS
环境变量,它并不会显示在 crates.io
界面上。
--警告:清单中的
-[package]
部分一旦发布到crates.io
就无法进行更改,因此对于已发布的包来说,authors
字段是无法修改的
可选字段,用于指定项目所使用的 Rust Edition。
-该配置将影响项目中的所有 Cargo Target
和包,前者包含测试用例、benchmark、可执行文件、示例等。
[package]
-# ...
-edition = '2021'
-
-大多数时候,我们都无需手动指定,因为 cargo new
的时候,会自动帮我们添加。若 edition
配置不存在,那 2015 Edition
会被默认使用。
可选字段,用于说明你的项目支持的最低 Rust 版本(编译器能顺利完成编译)。一旦你使用的 Rust 版本比这个字段设置的要低,Cargo
就会报错,然后告诉用户所需的最低版本。
该字段是在 Rust 1.56 引入的,若大家使用的 Rust 版本低于该版本,则该字段会被自动忽略时。
-[package]
-# ...
-edition = '2021'
-rust-version = "1.56"
-
-还有一点,rust-version
必须比第一个引入 edition
的 Rust 版本要新。例如 Rust Edition 2021 是在 Rust 1.56 版本引入的,若你使用了 edition = '2021'
的 [package]
配置,则指定的 rust version
字段必须要要大于等于 1.56
版本。
还可以使用 --ignore-rust-version
命令行参数来忽略 rust-version
。
该字段将影响项目中的所有 Cargo Target
和包,前者包含测试用例、benchmark、可执行文件、示例等。
该字段是项目的简介,crates.io
会在项目首页使用该字段包含的内容,不支持 Markdown
格式。
[package]
-# ...
-description = "A short description of my package"
-
---注意: 若发布
-crates.io
,则该字段是必须的
该字段用于说明项目文档的地址,若没有设置,crates.io
会自动链接到 docs.rs
上的相应页面。
[package]
-# ...
-documentation = "https://docs.rs/bitflags"
-
-readme
字段指向项目的 README.md
文件,该文件应该存在项目的根目录下(跟 Cargo.toml
同级),用于向用户描述项目的详细信息,支持 Markdown
格式。大家看到的 crates.io
上的项目首页就是基于该文件的内容进行渲染的。
[package]
-# ...
-readme = "README.md"
-
-若该字段未设置且项目根目录下存在 README.md
、README.txt
或 README
文件,则该文件的名称将被默认使用。
你也可以通过将 readme
设置为 false
来禁止该功能,若设置为 true
,则默认值 README.md
将被使用。
该字段用于设置项目主页的 URL:
-[package]
-# ...
-homepage = "https://serde.rs/"
-
-设置项目的源代码仓库地址,例如 GitHub
链接:
[package]
-# ...
-repository = "https://github.com/rust-lang/cargo/"
-
-license
字段用于描述项目所遵循的开源协议。而 license-file
则用于指定包含开源协议的文件所在的路径(相对于 Cargo.toml
)。
如果要发布到 crates.io
,则该协议必须是 SPDX2.1 协议表达式。同时 license
名称必须是来自于 SPDX 协议列表 3.11。
SPDX 只支持使用 AND
、OR
来组合多个开源协议:
[package]
-# ...
-license = "MIT OR Apache-2.0"
-
-OR
代表用户可以任选一个协议进行遵循,而 AND
表示用户必须要同时遵循两个协议。还可以通过 WITH
来在指定协议之外添加额外的要求:
MIT OR Apache-2.0
LGPL-2.1-only AND MIT AND BSD-2-Clause
GPL-2.0-or-later WITH Bison-exception-2.2
若项目使用了非标准的协议,你可以通过指定 license-file
字段来替代 license
的使用:
[package]
-# ...
-license-file = "LICENSE.txt"
-
---注意:crates.io 要求必须设置
-license
或license-file
该字段使用字符串数组的方式来指定项目的关键字列表,当用户在 crates.io
上搜索时,这些关键字可以提供索引的功能。
[package]
-# ...
-keywords = ["gamedev", "graphics"]
-
---注意:
-crates.io
最多只支持 5 个关键字,每个关键字都必须是合法的ASCII
文本,且需要使用字母作为开头,只能包含字母、数字、_
和-
,最多支持 20 个字符长度
categories
用于描述项目所属的类别:
categories = ["command-line-utilities", "development-tools::cargo-plugins"]
-
---注意:
-crates.io
最多只支持 5 个类别,目前不支持用户随意自定义类别,你所使用的类别需要跟 https://crates.io/category_slugs 上的类别精准匹配。
该字段用于配置当前项目所属的工作空间。
-若没有设置,则将沿着文件目录向上寻找,直至找到第一个 设置了 [workspace]
的Cargo.toml
。因此,当一个成员不在工作空间的子目录时,设置该字段将非常有用。
[package]
-# ...
-workspace = "path/to/workspace/root"
-
-需要注意的是 Cargo.toml
清单还有一个 [workspace]
部分专门用于设置工作空间,若它被设置了,则 package
中的 workspace
字段将无法被指定。这是因为一个包无法同时满足两个角色:
[workspace]
指定)package.workspace
指定若要了解工作空间的更多信息,请参见这里。
-build
用于指定位于项目根目录中的构建脚本,关于构建脚本的更多信息,可以阅读 构建脚本 一章。
[package]
-# ...
-build = "build.rs"
-
-还可以使用 build = false
来禁止构建脚本的自动检测。
用于指定项目链接的本地库的名称,更多的信息请看构建脚本章节的 links
-[package]
-# ...
-links = "foo"
-
-这两个字段可以用于显式地指定想要包含在外或在内的文件列表,往往用于发布到注册服务时。你可以使用 cargo package --list
来检查哪些文件被包含在项目中。
[package]
-# ...
-exclude = ["/ci", "images/", ".*"]
-
-[package]
-# ...
-include = ["/src", "COPYRIGHT", "/examples", "!/examples/big_example"]
-
-尽管大家可能没有指定 include
或 exclude
,但是任然会有些规则自动被应用,一起来看看。
若 include
没有被指定,则以下文件将被排除在外:
.
开头的隐藏文件会被排除.gitignore
配置的文件会被排除无论 include
或 exclude
是否被指定,以下文件都会被排除在外:
Cargo.toml
的子目录会被排除target
目录会被排除以下文件会永远被 include
,你无需显式地指定:
Cargo.toml
Cargo.lock
会自动被包含license-file
指定的协议文件--这两个字段很强大,但是对于生产实践而言,我们还是推荐通过
-.gitignore
来控制,因为这样协作者更容易看懂。如果大家希望更深入的了解include/exclude
,可以参考下官方的Cargo
文档
该字段常常用于防止项目因为失误被发布到 crates.io
等注册服务上,例如如果希望项目在公司内部私有化,你应该设置:
[package]
-# ...
-publish = false
-
-也可以通过字符串数组的方式来指定允许发布到的注册服务名称:
-[package]
-# ...
-publish = ["some-registry-name"]
-
-若 publish
数组中包含了一个注册服务名称,则 cargo publish
命令会使用该注册服务,除非你通过 --registry
来设定额外的规则。
Cargo 默认情况下会对 Cargo.toml
中未使用的 key
进行警告,以帮助大家提前发现风险。但是 package.metadata
并不在其中,因为它是由用户自定义的提供给外部工具的配置文件。例如:
[package]
-name = "..."
-# ...
-
-# 以下配置元数据可以在生成安卓 APK 时使用
-[package.metadata.android]
-package-name = "my-awesome-android-app"
-assets = "path/to/static"
-
-与其相似的还有 [workspace.metadata]
,都可以作为外部工具的配置信息来使用。
当大家使用 cargo run
来运行项目时,该命令会使用默认的二进制可执行文件作为程序启动入口。
我们可以通过 default-run
来修改默认的入口,例如现在有两个二进制文件 src/bin/a.rs
和 src/bin/b.rs
,通过以下配置可以将入口设置为前者:
[package]
-default-run = "a"
-
-该部分用于指定项目当前的状态,该状态会展示在 crates.io
的项目主页中,例如以下配置可以设置项目的维护状态:
[badges]
-# `maintenance` 是项目的当前维护状态,它可能会被其它注册服务所使用,但是目前还没有被 `crates.io` 使用: https://github.com/rust-lang/crates.io/issues/2437
-#
-# `status` 字段时必须的,以下是可用的选项:
-# - `actively-developed`: 新特性正在积极添加中,bug 在持续修复中
-# - `passively-maintained`: 目前没有计划去支持新的特性,但是项目维护者可能会回答你提出的 issue
-# - `as-is`: 该项目的功能已经完结,维护者不准备继续开发和提供支持了,但是它的功能已经达到了预期
-# - `experimental`: 作者希望同大家分享,但是还不准备满足任何人的特殊要求
-# - `looking-for-maintainer`: 当前维护者希望将项目转移给新的维护者
-# - `deprecated`: 不再推荐使用该项目,需要说明原因以及推荐的替代项目
-# - `none`: 不显示任何 badge ,因此维护者没有说明他们的状态,用户需要自己去调查发生了什么
-maintenance = { status = "..." }
-
-在之前章节中,我们已经详细介绍过 [dependencies]
、 [dev-dependencies]
和 [build-dependencies]
,这里就不再赘述。
该部分可以对编译器进行配置,例如 debug 和优化,在后续的编译器优化章节有详细介绍。
- - -细心的同学可能发现了迄今为止我们已经为 Cargo 引入了不少新的名词,而且这些名词有一个共同的特点,不容易或不适合翻译成中文,因为难以表达的很准确,例如 Cargo Target, Feature 等,这不现在又多了一个 Profile。
-Profile 其实是一种发布配置,例如它默认包含四种: dev
、 release
、 test
和 bench
,正常情况下,我们无需去指定,Cargo
会根据我们使用的命令来自动进行选择
cargo build
自动选择 dev
profile,而 cargo test
则是 test
profile, 出于历史原因,这两个 profile 输出的结果都存放在项目根目录下的 target/debug
目录中,结果往往用于开发/测试环境cargo build --release
自动选择 release
profile,并将输出结果存放在 target/release
目录中,结果往往用于生产环境可以看出 Profile 跟 Nodejs 的 dev
和 prod
很像,都是通过不同的配置来为目标环境构建最终编译后的结果: dev
编译输出的结果用于开发环境,prod
则用于生产环境。
针对不同的 profile,编译器还会提供不同的优化级别,例如 dev
用于开发环境,因此构建速度是最重要的:此时,我们可以牺牲运行性能来换取编译性能,那么优化级别就会使用最低的。而 release
则相反,优化级别会使用最高,导致的结果就是运行得非常快,但是编译速度大幅降低。
--初学者一个常见的错误,就是使用非
-release
profile 去测试性能,例如cargo run
,这种方式显然无法得到正确的结果,我们应该使用cargo run --release
的方式测试性能
profile 可以通过 Cargo.toml
中的 [profile]
部分进行设置和改变:
[profile.dev]
-opt-level = 1 # 使用稍高一些的优化级别,最低是0,最高是3
-overflow-checks = false # 关闭整数溢出检查
-
-需要注意的是,每一种 profile 都可以单独的进行设置,例如上面的 [profile.dev]
。
如果是工作空间的话,只有根 package 的 Cargo.toml
中的 [profile]
设置才会被使用,其它成员或依赖包中的设置会被自动忽略。
另外,profile 还能在 Cargo 自身的配置文件中进行覆盖,总之,通过 .cargo/config.toml
或环境变量的方式所指定的 profile
配置会覆盖项目的 Cargo.toml
中相应的配置。
除了默认的四种 profile,我们还可以定义自己的。对于大公司来说,这个可能会非常有用,自定义的 profile 可以帮助我们建立更灵活的工作发布流和构建模型。
-当定义 profile 时,你必须指定 inherits
用于说明当配置缺失时,该 profile 要从哪个 profile 那里继承配置。
例如,我们想在 release profile 的基础上增加 LTO 优化,那么可以在 Cargo.toml
中添加如下内容:
[profile.release-lto]
-inherits = "release"
-lto = true
-
-然后在构建时使用 --profile
来指定想要选择的自定义 profile :
$ cargo build --profile release-lto
-
-与默认的 profile 相同,自定义 profile 的编译结果也存放在 target/
下的同名目录中,例如 --profile release-lto
的输出结果存储在 target/release-lto
中。
dev
: cargo build
, cargo rustc
, cargo check
, 和 cargo run
test
: cargo test
bench
: cargo bench
release
: cargo install
, cargo build --release
, cargo run --release
cargo build --profile release-lto
下面我们来看看 profile 中可以进行哪些优化设置。
-该字段用于控制 -C opt-level
标志的优化级别。更高的优化级别往往意味着运行更快的代码,但是也意味着更慢的编译速度。
同时,更高的编译级别甚至会造成编译代码的改变和再排列,这会为 debug 带来更高的复杂度。
-opt-level
支持的选项包括:
0
: 无优化1
: 基本优化2
: 一些优化3
: 全部优化我们非常推荐你根据自己的需求来找到最适合的优化级别(例如,平衡运行和编译速度)。而且有一点值得注意,有的时候优化级别和性能的关系可能会出乎你的意料之外,例如 3
比 2
更慢,再比如 "s"
并没有让你的二进制文件变得更小。
而且随着 rustc
版本的更新,你之前的配置也可能要随之变化,总之,为项目的热点路径做好基准性能测试是不错的选择,不然总不能每次都手动重写代码来测试吧 :)
如果想要了解更多,可以参考 rustc 文档,这里有更高级的优化技巧。
-debug
控制 -C debuginfo
标志,而后者用于控制最终二进制文件输出的 debug
信息量。
支持的选项包括:
-0
或 false
:不输出任何 debug 信息1
: 行信息2
: 完整的 debug 信息split-debuginfo
控制 -C split-debuginfo 标志,用于决定输出的 debug 信息是存放在二进制可执行文件里还是邻近的文件中。
该字段控制 -C debug-assertions 标志,可以开启或关闭其中一个条件编译选项: cfg(debug_assertions)
。
debug-assertion
会提供运行时的检查,该检查只能用于 debug
模式,原因是对于 release
来说,这种检查的成本较为高昂。
大家熟悉的 debug_assert!
宏也是通过该标志开启的。
支持的选项包括 :
-true
: 开启false
: 关闭用于控制 -C overflow-checks 标志,该标志可以控制运行时的整数溢出行为。当开启后,整数溢出会导致 panic
。
支持的选项包括 :
-true
: 开启false
: 关闭lto
用于控制 -C lto
标志,而后者可以控制 LLVM 的链接时优化( link time optimizations )。通过对整个程序进行分析,并以增加链接时间为代价,LTO 可以生成更加优化的代码。
支持的选项包括:
-false
: 只会对代码生成单元中的本地包进行 "thin" LTO
优化,若代码生成单元数为 1 或者 opt-level
为 0,则不会进行任何 LTO 优化true
或 "fat"
:对依赖图中的所有包进行 "fat" LTO
优化"thin"
:对依赖图的所有包进行 "thin" LTO
,相比 "fat"
来说,它仅牺牲了一点性能,但是换来了链接时间的可观减少off
: 禁用 LTO如果大家想了解跨语言 LTO,可以看下 -C linker-plugin-lto 标志。
-panic
控制 -C panic 标志,它可以控制 panic
策略的选择。
支持的选项包括:
-"unwind"
: 遇到 panic 后对栈进行展开( unwind )"abort"
: 遇到 panic 后直接停止程序当设置为 "unwind"
时,具体的栈展开信息取决于特定的平台,例如 NVPTX
不支持 unwind
,因此程序只能 "abort"。
测试、基准性能测试、构建脚本和过程宏会忽略 panic
设置,目前来说它们要求是 "unwind"
,如果大家希望修改成 "abort"
,可以看看 panic-abort-tests 。
另外,当你使用 "abort"
策略且在执行测试时,由于上述的要求,除了测试代码外,所有的依赖库也会忽略该 "abort"
设置而使用 "unwind"
策略。
incremental
控制 -C incremental 标志,用于开启或关闭增量编译。开启增量编译时,rustc
会将必要的信息存放到硬盘中( target
目录中 ),当下次编译时,这些信息可以被复用以改善编译时间。
支持的选项包括:
-true
: 启用false
: 关闭增量编译只能用于工作空间的成员和通过 path
引入的本地依赖。
大家还可以通过环境变量 CARGO_INCREMENTAL
或 Cargo 配置 build.incremental 在全局对 incremental
进行覆盖。
codegen-units
控制 -C codegen-units 标志,可以指定一个包会被分隔为多少个代码生成单元。更多的代码生成单元会提升代码的并行编译速度,但是可能会降低运行速度。
对于增量编译,默认值是 256,非增量编译是 16。
-用于控制 -C rpath标志,可以控制 rpath
的启用与关闭。
rpath
代表硬编码到二进制可执行文件或库文件中的运行时代码搜索(runtime search path),动态链接库的加载器就通过它来搜索所需的库。
dev
profile 往往用于开发和 debug,cargo build
或 cargo run
默认使用的就是 dev
profile,cargo build --debug
也是。
--注意:
-dev
profile 的结果并没有输出到target/dev
同名目录下,而是target/debug
,这是历史遗留问题
默认的 dev
profile 设置如下:
[profile.dev]
-opt-level = 0
-debug = true
-split-debuginfo = '...' # Platform-specific.
-debug-assertions = true
-overflow-checks = true
-lto = false
-panic = 'unwind'
-incremental = true
-codegen-units = 256
-rpath = false
-
-release
往往用于预发/生产环境或性能测试,以下命令使用的就是 release
profile:
cargo build --release
cargo run --release
cargo install
默认的 release
profile 设置如下:
[profile.release]
-opt-level = 3
-debug = false
-split-debuginfo = '...' # Platform-specific.
-debug-assertions = false
-overflow-checks = false
-lto = false
-panic = 'unwind'
-incremental = false
-codegen-units = 16
-rpath = false
-
-该 profile 用于构建测试,它的设置是继承自 dev
bench
profile 用于构建基准测试 benchmark,它的设计默认继承自 release
默认情况下,所有的 profile 都不会对构建过程本身所需的依赖进行优化,构建过程本身包括构建脚本、过程宏。
-默认的设置是:
-[profile.dev.build-override]
-opt-level = 0
-codegen-units = 256
-
-[profile.release.build-override]
-opt-level = 0
-codegen-units = 256
-
-如果是自定义 profile,那它会自动从当前正在使用的 profile 继承相应的设置,但不会修改。
-我们还可以对特定的包使用的 profile 进行重写(override):
-# `foo` package 将使用 -Copt-level=3 标志.
-[profile.dev.package.foo]
-opt-level = 3
-
-这里的 package
名称实际上是一个 Package ID
,因此我们还可以通过版本号来选择: [profile.dev.package."foo:2.1.0"]
。
如果要为所有依赖包重写(不包括工作空间的成员):
-[profile.dev.package."*"]
-opt-level = 2
-
-为构建脚本、过程宏和它们的依赖重写:
-[profile.dev.build-override]
-opt-level = 3
-
---注意:如果一个依赖同时被正常代码和构建脚本所使用,当
---target
没有指定时,Cargo 只会构建该依赖一次。但是当使用了
-build-override
后,该依赖会被构建两次,一次为正常代码,一次为构建脚本,因此会增加一些编译时间
重写的优先级按以下顺序执行(第一个匹配获胜):
-[profile.dev.package.name]
,指定名称进行重写[profile.dev.package."*"]
,对所有非工作空间成员的 package 进行重写[profile.dev.build-override]
,对构建脚本、过程宏及它们的依赖进行重写[profile.dev]
重写无法使用 panic
、lto
或 rpath
设置。
如果你想要把自己的开源项目分享给全世界,那最好的办法自然是 GitHub。但如果是 Rust 的库,那除了发布到 GitHub 外,我们还可以将其发布到 crates.io 上,然后其它用户就可以很简单的对其进行引用。
---注意:发布包到
-crates.io
后,特定的版本无法被覆盖,要发布就必须使用新的版本号,代码也无法被删除!
首先,我们需要一个账号:访问 crates.io 的主页,然后在右上角使用 GitHub 账户登陆,接着访问你的账户设置页面,进入到 API Tokens 标签页下,生成新的 Token,并使用该 Token 在终端中进行登录:
-$ cargo login abcdefghijklmnopqrstuvwxyz012345
-
-该命令将告诉 Cargo 你的 API Token,然后将其存储在本地的 ~/.cargo/credentials.toml
文件中。
--注意:你需要妥善保管好 API Token,并且不要告诉任何人,一旦泄漏,请撤销( Revoke )并重新生成。
-
crates.io
上的包名遵循先到先得的方式:一旦你想要的包名已经被使用,那么你就得换一个不同的包名。
在发布之前,确保 Cargo.toml
中以下字段已经被设置:
你还可以设置关键字和类别等元信息,让包更容易被其他人搜索发现,虽然它们不是必须的。
-如果你发布的是一个依赖库,那么你可能需要遵循相关的命名规范和 API Guidlines.
-下一步就是将你的项目进行打包,然后上传到 crates.io
。为了实现这个目的,我们可以使用 cargo publish
命令,该命令执行了以下步骤:
.crate
文件中.crate
文件解压并放入到临时的目录中,并验证解压出的代码可以顺利编译.crate
文件到 crates.io
在发布之前,我们推荐你先运行 cargo publish --dry-run
(或 cargo package
) 命令来确保代码没有 warning 或错误。
$ cargo publish --dry-run
-
-你可以在 target/package
目录下观察生成的 .crate
文件。例如,目前 crates.io
要求该文件的大小不能超过 10MB,你可以通过手动检查该文件的大小来确保不会无意间打包进一些较大的资源文件,比如测试数据、网站文档或生成的代码等。我们还可以使用以下命令来检查其中包含的文件:
$ cargo package --list
-
-当打包时,Cargo 会自动根据版本控制系统的配置来忽略指定的文件,例如 .gitignore
。除此之外,你还可以通过 exclude
来排除指定的文件:
[package]
-# ...
-exclude = [
- "public/assets/*",
- "videos/*",
-]
-
-如果想要显式地将某些文件包含其中,可以使用 include
,但是需要注意的是,这个 key 一旦设置,那 exclude
就将失效:
[package]
-# ...
-include = [
- "**/*.rs",
- "Cargo.toml",
-]
-
-准备好后,我们就可以正式来上传指定的包了,在根目录中运行:
-$ cargo publish
-
-就是这么简单,恭喜你,完成了第一个包的发布!
-绝大多数时候,我们并不是在发布新包,而是发布已经上传过的包的新版本。
-为了实现这一点,只需修改 Cargo.toml
中的 version
字段 ,但需要注意:版本号需要遵循 semver
规则。
然后再次使用 cargo publish
就可以上传新的版本了。
目前来说,管理包更多地是通过 cargo
命令而不是在线管理,下面是一些你可以使用的命令。
有的时候你会遇到发布的包版本实际上并不可用(例如语法错误,或者忘记包含一个文件等),对于这种情况,Cargo 提供了 yank 命令:
-$ cargo yank --vers 1.0.1
-$ cargo yank --vers 1.0.1 --undo
-
-该命令并不能删除任何代码,例如如果你上传了一段隐私内容,你需要的是立刻重置它们,而不是使用 cargo yank
。
yank
能做到的就是让其它人不能再使用这个版本作为依赖,但是现存的依赖依然可以继续工作。crates.io
的一个主要目标就是作为一个不会随着时间变化的永久性包存档,但删除某个版本显然违背了这个目标。
一个包可能会有多个主要开发者,甚至维护者 maintainer 都会发生变更。目前来说,只有包的 owner 才能发布新的版本,但是一个 owner 可以指定其它的用户为 owner:
-$ cargo owner --add github-handle
-$ cargo owner --remove github-handle
-$ cargo owner --add github:rust-lang:owners
-$ cargo owner --remove github:rust-lang:owners
-
-命令中使用的 ownerID 必须是 GitHub 用户名或 Team 名。
-一旦一个用户 B
通过 --add
被加入到 owner
列表中,他将拥有该包相关的所有权利。例如发布新版本、yank 一个版本,还能增加和移除 owner,包含添加 B
为 owner 的 A
都可以被移除!
因此,我们必须严肃的指出:不要将你不信任的人添加为 owner ! 免得哪天反目成仇后,他把你移除了 - , -
-但是对于 Team 又有所不同,通过 -add
添加的 GitHub Team owner,只拥有受限的权利。它们可以发布或 yank 某个版本,但是他们不能添加或移除 owner!总之,Team 除了可以很方便的管理所有者分组的同时,还能防止一些未知的恶意。
如果大家在添加 team 时遇到问题,可以看看官方的相关文档,由于绝大多数人都无需此功能,因此这里不再详细展开。
- - -我们的项目可以引用在 crates.io
或 GitHub
上的依赖包,也可以引用存放在本地文件系统中的依赖包。
大家可能会想,直接从前两个引用即可,为何还提供了本地方式?可以设想下,如果你要有一个正处于开发中的包,然后需要在本地的另一个项目中引用测试,那是将该包先传到网上,然后再引用简单,还是直接从本地路径的方式引用简单呢?答案显然不言而喻。
-本章节,我们一起来看看有哪些方式可以指定和引用三方依赖包。
-crates.io
引入依赖包默认设置下,Cargo
就从 crates.io 上下载依赖包,只需要一个包名和版本号即可:
[dependencies]
-time = "0.1.12"
-
-字符串 "0.1.12"
是一个 semver
格式的版本号,符合 "x.y.z"
的形式,其中 x
被称为主版本major
, y
被称为小版本 minor
,而 z
被称为补丁 patch
,可以看出从左到右,版本的影响范围逐步降低,补丁的更新是无关痛痒的,并不会造成 API 的兼容性被破坏。
"0.1.12"
中并没有任何额外的符号,在版本语义上,它跟使用了 ^
的 "^0.1.12"
是相同的,都是指定非常具体的版本进行引入。
但是 ^
能做的更多。
--npm 使用的就是
-semver
版本号,从JavaScript
过来的同学应该非常熟悉。
^
指定版本与之前的 "0.1.12"
不同, ^
可以指定一个版本号范围,然后会使用该范围内的最大版本号来引用对应的包。
只要新的版本号没有修改最左边的非零数字,那该版本号就在允许的版本号范围中。例如 "^0.1.12"
最左边的非零数字是 1
,因此,只要新的版本号是 "0.1.z"
就可以落在范围内,而0.2.0
显然就没有落在范围内,因此通过 "^0.1.12"
引入的依赖包是无法被升级到 0.2.0
版本的。
同理,若是 "^1.0"
,则 1.1
在范围中,2.0
则不在。 大家思考下,"^0.0.1"
与哪些版本兼容?答案是:无,因为它最左边的数字是 1
,而该数字已经退无可退,我们又不能修改 1
,因此没有版本落在范围中。
^1.2.3 := >=1.2.3, <2.0.0
-^1.2 := >=1.2.0, <2.0.0
-^1 := >=1.0.0, <2.0.0
-^0.2.3 := >=0.2.3, <0.3.0
-^0.2 := >=0.2.0, <0.3.0
-^0.0.3 := >=0.0.3, <0.0.4
-^0.0 := >=0.0.0, <0.1.0
-^0 := >=0.0.0, <1.0.0
-
-以上是更多的例子,事实上,这个规则跟 SemVer
还有所不同,因为对于 SemVer
而言,0.x.y
的版本是没有其它版本与其兼容的,而对于 Rust,只要版本号 0.x.y
满足 : z>=y
且 x>0
的条件,那它就能更新到 0.x.z
版本。
~
指定版本~
指定了最小化版本 :
--#![allow(unused)] -fn main() { -~1.2.3 := >=1.2.3, <1.3.0 -~1.2 := >=1.2.0, <1.3.0 -~1 := >=1.0.0, <2.0.0 -} -
*
通配符这种方式允许将 *
所在的位置替换成任何数字:
--#![allow(unused)] -fn main() { -* := >=0.0.0 -1.* := >=1.0.0, <2.0.0 -1.2.* := >=1.2.0, <1.3.0 -} -
不过 crates.io
并不允许我们只使用孤零零一个 *
来指定版本号 : *
。
可以使用比较符的方式来指定一个版本号范围或一个精确的版本号:
---#![allow(unused)] -fn main() { ->= 1.2.0 -> 1 -< 2 -= 1.2.3 -} -
同时还能使用比较符进行组合,并通过逗号分隔:
---#![allow(unused)] -fn main() { ->= 1.2, < 1.5 -} -
需要注意,以上的版本号规则仅仅针对 crate.io
和基于它搭建的注册服务(例如科大服务源) ,其它注册服务(例如 GitHub )有自己相应的规则。
为了使用 crates.io
之外的注册服务,我们需要对 $HOME/.cargo/config.toml
($CARGO_HOME 下) 文件进行配置,添加新的服务提供商,有两种方式可以实现。
--由于国内访问国外注册服务的不稳定性,我们可以使用科大的注册服务来提升下载速度,以下注册服务的链接都是科大的
-
首先是在 crates.io
之外添加新的注册服务,修改 .cargo/config.toml
添加以下内容:
[registries]
-ustc = { index = "https://mirrors.ustc.edu.cn/crates.io-index/" }
-
-对于这种方式,我们的项目的 Cargo.toml
中的依赖包引入方式也有所不同:
[dependencies]
-time = { registry = "ustc" }
-
-在重新配置后,初次构建可能要较久的时间,因为要下载更新 ustc
注册服务的索引文件,还挺大的...
注意,这一种使用方式最大的缺点就是在引用依赖包时要指定注册服务: time = { registry = "ustc" }
。
而第二种方式就不需要,因为它是直接使用新注册服务来替代默认的 crates.io
。
[source.crates-io]
-replace-with = 'ustc'
-
-[source.ustc]
-registry = "git://mirrors.ustc.edu.cn/crates.io-index"
-
-上面配置中的第一个部分,首先将源 source.crates-io
替换为 ustc
,然后在第二部分指定了 ustc
源的地址。
--注意,如果你要发布包到
-crates.io
上,那该包的依赖也必须在crates.io
上
若要引入 git 仓库中的库作为依赖包,你至少需要提供一个仓库的地址:
-[dependencies]
-regex = { git = "https://github.com/rust-lang/regex" }
-
-由于没有指定版本,Cargo 会假定我们使用 master
或 main
分支的最新 commit
。你可以使用 rev
、tag
或 branch
来指定想要拉取的版本。例如下面代码拉取了 next
分支上的最新 commit
:
[dependencies]
-regex = { git = "https://github.com/rust-lang/regex", branch = "next" }
-
-任何非 tag
和 branch
的类型都可以通过 rev
来引入,例如通过最近一次 commit
的哈希值引入: rev = "4c59b707"
,再比如远程仓库提供的的具名引用: rev = "refs/pull/493/head"
。
一旦 git
依赖被拉取下来,该版本就会被记录到 Cargo.lock
中进行锁定。因此 git
仓库中后续新的提交不再会被自动拉取,除非你通过 cargo update
来升级。需要注意的是锁定一旦被删除,那 Cargo 依然会按照 Cargo.toml
中配置的地址和版本去拉取新的版本,如果你配置的版本不正确,那可能会拉取下来一个不兼容的新版本!
因此不要依赖锁定来完成版本的控制,而应该老老实实的在 Cargo.toml
小心配置你希望使用的版本。
如果访问的是私有仓库,你可能需要授权来访问该仓库,可以查看这里了解授权的方式。
-Cargo 支持通过路径的方式来引入本地的依赖包:一般来说,本地依赖包都是同一个项目内的内部包,例如假设我们有一个 hello_world
项目( package ),现在在其根目录下新建一个包:
# 在 hello_world/ 目录下
-$ cargo new hello_utils
-
-新建的 hello_utils
文件夹跟 src
、Cargo.toml
同级,现在修改 Cargo.toml
让 hello_world
项目引入新建的包:
[dependencies]
-hello_utils = { path = "hello_utils" }
-# 以下路径也可以
-# hello_utils = { path = "./hello_utils" }
-# hello_utils = { path = "../hello_world/hello_utils" }
-
-但是,此时的 hello_world
是无法发布到 crates.io
上的。想要发布,需要先将 hello_utils
先发布到 crates.io
上,然后再通过 crates.io
的方式来引入:
[dependencies]
-hello_utils = { path = "hello_utils", version = "0.1.0" }
-
---注意!使用
-path
指定依赖的 package 将无法发布到crates.io
,除非path
存在于 [dev-dependencies] 中。当然,你还可以使用多种引用混合的方式来解决这个问题,下面将进行介绍
实际上,我们可以同时使用多种方式来引入同一个包,例如本地引入和 crates.io
:
[dependencies]
-# 本地使用时,通过 path 引入,
-# 发布到 `crates.io` 时,通过 `crates.io` 的方式引入: version = "1.0"
-bitflags = { path = "my-bitflags", version = "1.0" }
-
-# 本地使用时,通过 git 仓库引入
-# 当发布时,通过 `crates.io` 引入: version = "1.0"
-smallvec = { git = "https://github.com/servo/rust-smallvec", version = "1.0" }
-
-# N.B. 若 version 无法匹配,Cargo 将无法编译
-
-这种方式跟下章节将要讲述的依赖覆盖类似,但是前者只会应用到当前声明的依赖包上。
-我们还可以根据特定的平台来引入依赖:
-[target.'cfg(windows)'.dependencies]
-winhttp = "0.4.0"
-
-[target.'cfg(unix)'.dependencies]
-openssl = "1.0.1"
-
-[target.'cfg(target_arch = "x86")'.dependencies]
-native = { path = "native/i686" }
-
-[target.'cfg(target_arch = "x86_64")'.dependencies]
-native = { path = "native/x86_64" }
-
-此处的语法跟 Rust 的 #[cfg]
语法非常相像,因此我们还能使用逻辑操作符进行控制:
[target.'cfg(not(unix))'.dependencies]
-openssl = "1.0.1"
-
-这里的意思是,当不是 unix
操作系统时,才对 openssl
进行引入。
如果你想要知道 cfg
能够作用的目标,可以在终端中运行 rustc --print=cfg
进行查询。当然,你可以指定平台查询: rustc --print=cfg --target=x86_64-pc-windows-msvc
,该命令将对 64bit
的 Windows 进行查询。
聪明的同学已经发现,这非常类似于条件依赖引入,那我们是不是可以根据自定义的条件来决定是否引入某个依赖呢?具体答案参见后续的 feature 章节。这里是一个简单的示例:
-[dependencies]
-foo = { version = "1.0", optional = true }
-bar = { version = "1.0", optional = true }
-
-[features]
-fancy-feature = ["foo", "bar"]
-
-但是需要注意的是,你如果妄图通过 cfg(feature)
、cfg(debug_assertions)
, cfg(test)
和 cfg(proc_macro)
的方式来条件引入依赖,那是不可行的。
Cargo
还允许通过下面的方式来引入平台特定的依赖:
[target.x86_64-pc-windows-gnu.dependencies]
-winhttp = "0.4.0"
-
-[target.i686-unknown-linux-gnu.dependencies]
-openssl = "1.0.1"
-
-如果你在使用自定义的 target
:例如 --target bar.json
,那么可以通过下面方式来引入依赖:
[target.bar.dependencies]
-winhttp = "0.4.0"
-
-[target.my-special-i686-platform.dependencies]
-openssl = "1.0.1"
-native = { path = "native/i686" }
-
---需要注意,这种使用方式在
-stable
版本的 Rust 中无法被使用,建议大家如果没有特别的需求,还是使用之前提到的 feature 方式
你还可以为项目添加只在测试时需要的依赖库,类似于 package.json
( Nodejs )文件中的 devDependencies
,可以在 Cargo.toml
中添加 [dev-dependencies]
来实现:
[dev-dependencies]
-tempdir = "0.3"
-
-这里的依赖只会在运行测试、示例和 benchmark 时才会被引入。并且,假设A
包引用了 B
,而 B
通过 [dev-dependencies]
的方式引用了 C
包, 那 A
是不会引用 C
包的。
当然,我们还可以指定平台特定的测试依赖包:
-[target.'cfg(unix)'.dev-dependencies]
-mio = "0.0.1"
-
---注意,当发布包到 crates.io 时,
-[dev-dependencies]
中的依赖只有指定了version
的才会被包含在发布包中。况且,再加上测试稳定性的考虑,我们建议为[dev-dependencies]
中的包指定相应的版本号
我们还可以指定某些依赖仅用于构建脚本:
-[build-dependencies]
-cc = "1.0.3"
-
-当然,平台特定的依然可以使用:
-[target.'cfg(unix)'.build-dependencies]
-cc = "1.0.3"
-
-有一点需要注意:构建脚本( build.rs
)和项目的正常代码是彼此独立,因此它们的依赖不能互通: 构建脚本无法使用 [dependencies]
或 [dev-dependencies]
中的依赖,而 [build-dependencies]
中的依赖也无法被构建脚本之外的代码所使用。
如果你依赖的包提供了条件性的 features
,你可以指定使用哪一个:
[dependencies.awesome]
-version = "1.3.5"
-default-features = false # 不要包含默认的 features,而是通过下面的方式来指定
-features = ["secure-password", "civet"]
-
-更多的信息参见 Features 章节
-如果你想要实现以下目标:
-use foo as bar
那可以使用 Cargo 提供的 package key
:
[package]
-name = "mypackage"
-version = "0.0.1"
-
-[dependencies]
-foo = "0.1"
-bar = { git = "https://github.com/example/project", package = "foo" }
-baz = { version = "0.1", registry = "custom", package = "foo" }
-
-此时,你的代码中可以使用三个包:
---#![allow(unused)] -fn main() { -extern crate foo; // 来自 crates.io -extern crate bar; // 来自 git repository -extern crate baz; // 来自 registry `custom` -} -
有趣的是,由于这三个 package
的名称都是 foo
(在各自的 Cargo.toml
中定义),因此我们显式的通过 package = "foo"
的方式告诉 Cargo:我们需要的就是这个 foo package
,虽然它被重命名为 bar
或 baz
。
有一点需要注意,当使用可选依赖时,如果你将 foo
包重命名为 bar
包,那引用前者的 feature 时的路径名也要做相应的修改:
[dependencies]
-bar = { version = "0.1", package = 'foo', optional = true }
-
-[features]
-log-debug = ['bar/log-debug'] # 若使用 'foo/log-debug' 会导致报错
-
-
-
- 一个工作空间是由多个 package
组成的集合,它们共享同一个 Cargo.lock
文件、输出目录和一些设置(例如 profiles : 编译器设置和优化)。组成工作空间的 packages
被称之为工作空间的成员。
工作空间有两种类型:root package
和虚拟清单( virtual manifest )。
若一个 package
的 Cargo.toml
包含了[package]
的同时又包含了 [workspace]
部分,则该 package
被称为工作空间的根 package
。
换而言之,一个工作空间的根( root )是该工作空间的 Cargo.toml
文件所在的目录。
举个例子,我们现在有多个 package
,它们的目录是嵌套关系,然后我们在最外层的 package
,也就是最外层目录中的 Cargo.toml
中定义一个 [workspace]
,此时这个最外层的 package
就是工作空间的根。
再举个例子,大名鼎鼎的 ripgrep 就在最外层的 package
中定义了 [workspace]
:
[workspace]
-members = [
- "crates/globset",
- "crates/grep",
- "crates/cli",
- "crates/matcher",
- "crates/pcre2",
- "crates/printer",
- "crates/regex",
- "crates/searcher",
- "crates/ignore",
-]
-
-那么最外层的目录就是 ripgrep
的工作空间的根。
若一个 Cargo.toml
有 [workspace]
但是没有 [package]
部分,则它是虚拟清单类型的工作空间。
对于没有主 package
的场景或你希望将所有的 package
组织在单独的目录中时,这种方式就非常适合。
例如 rust-analyzer 就是这样的项目,它的根目录中的 Cargo.toml
中并没有 [package]
,说明该根目录不是一个 package
,但是却有 [workspace]
:
[workspace]
-members = ["xtask/", "lib/*", "crates/*"]
-exclude = ["crates/proc_macro_test/imp"]
-
-结合 rust-analyzer 的目录布局可以看出,该工作空间的所有成员 package
都在单独的目录中,因此这种方式很适合虚拟清单的工作空间。
工作空间的几个关键点在于:
-package
共享同一个 Cargo.lock
文件,该文件位于工作空间的根目录中package
共享同一个输出目录,该目录默认的名称是 target
,位于工作空间根目录下Cargo.toml
才能包含 [patch]
, [replace]
和 [profile.*]
,而成员的 Cargo.toml
中的相应部分将被自动忽略Cargo.toml
中的 [workspace]
部分用于定义哪些 packages
属于工作空间的成员:
[workspace]
-members = ["member1", "path/to/member2", "crates/*"]
-exclude = ["crates/foo", "path/to/other"]
-
-若某个本地依赖包是通过 path
引入,且该包位于工作空间的目录中,则该包自动成为工作空间的成员。
剩余的成员需要通过 workspace.members
来指定,里面包含了各个成员所在的目录(成员目录中包含了 Cargo.toml )。
members
还支持使用 glob
来匹配多个路径,例如上面的例子中使用 crates/*
匹配 crates
目录下的所有包。
exclude
可以将指定的目录排除在工作空间之外,例如还是上面的例子,crates/*
在包含了 crates
目录下的所有包后,又通过 exclude
中 crates/foo
将 crates
下的 foo
目录排除在外。
你也可以将一个空的 [workspace]
直接联合 [package]
使用,例如:
[package]
-name = "hello"
-version = "0.1.0"
-
-[workspace]
-
-此时的工作空间的成员包含:
-package
: "hello"path
引入的本地依赖(位于工作空间目录下)选择工作空间有两种方式:Cargo
自动查找、手动指定 package.workspace
字段。
当位于工作空间的子目录中时,Cargo
会自动在该目录的父目录中寻找带有 [workspace]
定义的 Cargo.toml
,然后再决定使用哪个工作空间。
我们还可以使用下面的方法来覆盖 Cargo
自动查找功能:将成员包中的 package.workspace
字段修改为工作区间根目录的位置,这样就能显式地让一个成员使用指定的工作空间。
当成员不在工作空间的子目录下时,这种手动选择工作空间的方法就非常适用。毕竟 Cargo
的自动搜索是沿着父目录往上查找,而成员并不在工作空间的子目录下,这意味着顺着成员的父目录往上找是无法找到该工作空间的 Cargo.toml
的,此时就只能手动指定了。
在工作空间中,package
相关的 Cargo
命令(例如 cargo build
)可以使用 -p
、 --package
或 --workspace
命令行参数来指定想要操作的 package
。
若没有指定任何参数,则 Cargo
将使用当前工作目录的中的 package
。若工作目录是虚拟清单类型的工作空间,则该命令将作用在所有成员上(就好像是使用了 --workspace
命令行参数)。而 default-members
可以在命令行参数没有被提供时,手动指定操作的成员:
[workspace]
-members = ["path/to/member1", "path/to/member2", "path/to/member3/*"]
-default-members = ["path/to/member2", "path/to/member3/foo"]
-
-这样一来, cargo build
就不会应用到虚拟清单工作空间的所有成员,而是指定的成员上。
与 package.metadata 非常类似,workspace.metadata
会被 Cargo
自动忽略,就算没有被使用也不会发出警告。
这个部分可以用于让工具在 Cargo.toml
中存储一些工作空间的配置元信息。例如:
[workspace]
-members = ["member1", "member2"]
-
-[workspace.metadata.webcontents]
-root = "path/to/webproject"
-tool = ["npm", "run", "build"]
-# ...
-
-
-
- 这个社区与其它 Rust 社区有点不一样: 我们聚焦于 Rust 语言的学习研究和实战应用上,不搞花活!
-以下是社区的部分 Rust 项目:
- -此外,社区还提供了一个优质的同名公众号:Rust语言中文网
,里面的文章是由 Rustt 翻译组提供,搬运自国外优秀的 Rust 技术文章、学习资料、新闻资讯等。
在 Rust 元宇宙,最优秀的项目可以称之为 rusty
,用咱中国话来说,就是够锈( 秀 )。
如果你有以下需求,可以来看看锈书,它绝对不会让你失望:
-想要知道现在优秀的、关注度高的 Rust 项目有哪些
-发现一些好玩、有趣、酷炫的开源库
-需要寻找某个类型的库,例如,一个 HTTP 客户端或 ProtoBuffer 编码库,要求是好用、更新活跃、高质量
-想要寻找常用操作的代码片段,用于熟悉 Rust 或者直接复制粘贴到自己的项目中,例如文件操作、数据库操作、HTTP 请求、排序算法、正则等
-在线阅读锈书:Github地址
- - -本文将彻底解决一个困扰广大 Rust 用户已久的常见错误: 当智能指针和结构体一起使用时导致的借用错误: cannot borrow
mut_s as mutable because it is also borrowed as immutable
.
相信看过<<对抗 Rust 编译检查系列>>的读者都知道结构体中的不同字段可以独立借用吧?
-不知道也没关系,我们这里再简单回顾一下:
---#![allow(unused)] -fn main() { -struct Test { - a : u32, - b : u32 -} - -impl Test { - fn increase(&mut self) { - let mut a = &mut self.a; - let mut b = &mut self.b; - *b += 1; - *a += 1; - } -} -} -
这段代码看上去像是重复借用了&mut self
,违反了 Rust 的借用规则,实际上在聪明的 Rust 编译器面前,这都不是事。它能发现我们其实借用了目标结构体的不同字段,因此完全可以将其借用权分离开来。
因此,虽然我们不能同时对整个结构体进行多次可变借用,但是我们可以分别对结构体中的不同字段进行可变借用,当然,一个字段至多也只能存在一个可变借用,这个最基本的所有权规则还是不能违反的。变量a
引用结构体字段a
,变量b
引用结构体字段b
,从底层来说,这种方式也不会造成两个可变引用指向了同一块内存。
如果你还不知道 RefCell,可以看看这篇文章,当然不看也行,简而言之,RefCell 能够实现:
-panic
既然了解了结构体的借用规则和RefCell
, 我们来看一段结合了两者的代码:
--#![allow(unused)] -fn main() { -use std::cell::RefCell; -use std::io::Write; - -struct Data { - string: String, -} - -struct S { - data: Data, - writer: Vec<u8>, -} - -fn write(s: RefCell<S>) { - let mut mut_s = s.borrow_mut(); - let str = &mut_s.data.string; - mut_s.writer.write(str.as_bytes()); -} -} -
以上代码从s
中可变借用出结构体S
,随后又对结构体中的两个字段进行了分别借用,按照之前的规则这段代码应该顺利通过编译:
error[E0502]: cannot borrow `mut_s` as mutable because it is also borrowed as immutable
- --> src/main.rs:16:5
- |
-15 | let str = &mut_s.data.string;
- | ----- immutable borrow occurs here
-16 | mut_s.writer.write(str.as_bytes());
- | ^^^^^ --- immutable borrow later used here
- | |
- | mutable borrow occurs here
-
-只能说,还好它报错了,否则本篇文章已经可以结束。。。错误很简单,首先对结构体S
的data
字段进行了不可变借用,其次又对writer
字段进行了可变借用,这个符合之前的规则:对结构体不同字段分开借用,为何报错了?
第一感觉,问题是出在borrow_mut
方法返回的类型上,先来看看:
--#![allow(unused)] -fn main() { -pub fn borrow_mut(&self) -> RefMut<'_, T> -} -
可以看出,该方法并没有直接返回我们的结构体,而是一个RefMut
类型,而要使用该类型,需要经过编译器为我们做一次隐式的Deref
转换,编译器展开后的代码大概如下:
--#![allow(unused)] -fn main() { -use std::cell::RefMut; -use std::ops::{Deref, DerefMut}; - -fn write(s: RefCell<S>) { - let mut mut_s: RefMut<S> = s.borrow_mut(); - let str = &Deref::deref(&mut_s).data.string; - DerefMut::deref_mut(&mut mut_s).writer.write(str.as_bytes()); -} -} -
可以看出,对结构体字段的调用,实际上经过一层函数,一层函数!?我相信你应该想起了什么,是的,在上一篇文章中讲过类似的问题, 大意就是编译器对于函数往往只会分析签名,并不关心内部到底如何使用结构体。
-而上面的&Deref::deref(&mut_s)
和DerefMut::deref_mut(&mut mut_s)
函数,签名全部使用的是结构体,并不是结构体中的某一个字段,因此对于编译器来说,该结构体明显是被重复借用了!
因此要解决这个问题,我们得把之前的展开形式中的Deref::deref
消除掉,这样没有了函数签名,编译器也将不再懒政。
既然两次Deref::deref
调用都是对智能指针的自动Deref
,那么可以提前手动的把它Deref
了,只做一次!
--#![allow(unused)] -fn main() { -fn write(s: RefCell<S>) { - let mut mut_s = s.borrow_mut(); - let mut tmp = &mut *mut_s; // Here - let str = &tmp.data.string; - tmp.writer.write(str.as_bytes()); -} -} -
以上代码通过*
对mut_s
进行了解引用,获得结构体,然后又对结构体进行了可变借用&mut
,最终赋予tmp
变量,那么该变量就持有了我们的结构体的可变引用,而不再是持有一个智能指针。
此后对tmp
的使用就回归到文章开头的那段代码:分别借用结构体的不同字段,成功通过编译!
我们再来模拟编译器对正确的代码进行一次展开:
---#![allow(unused)] -fn main() { -use std::cell::RefMut; -use std::ops::DerefMut; - -fn write(s: RefCell<S>) { - let mut mut_s: RefMut<S> = s.borrow_mut(); - let tmp: &mut S = DerefMut::deref_mut(&mut mut_s); - let str = &tmp.data.string; - tmp.writer.write(str.as_bytes()); -} -} -
可以看出,此时对结构体的使用不再有DerefMut::deref
的身影,我们成功消除了函数边界对编译器的影响!
事实上,除了 RefCell 外,还有不少会导致这种问题的智能指针,当然原理都是互通的,我们这里就不再进行一一深入讲解,只简单列举下:
-Box
MutexGuard
(来源于 Mutex)PeekMut
(来源于 BinaryHeap)RwLockWriteGuard
(来源于 RwLock)String
Vec
Pin
下面再来一个练习巩固一下,强烈建议大家按照文章的思路进行分析和解决:
--use std::rc::Rc; -use std::cell::RefCell; - -pub struct Foo { - pub foo1: Vec<bool>, - pub foo2: Vec<i32>, -} -fn main() { - let foo_cell = Rc::new(RefCell::new(Foo { - foo1: vec![true, false], - foo2: vec![1, 2] - - })); - - let borrow = foo_cell.borrow_mut(); - let foo1 = &borrow.foo1; - // 下面代码会报错,因为`foo1`和`foo2`发生了重复借用 - borrow.foo2.iter_mut().enumerate().for_each(|(idx, foo2)| { - if foo1[idx] { - *foo2 *= -1; - } - }); -} -
当结构体的引用穿越函数边界时,我们要格外小心,因为编译器只会对函数签名进行检查,并不关心内部到底用了结构体的哪个字段,当签名都使用了结构体时,会立即报错。
-而智能指针由于隐式解引用Deref
的存在,导致了两次Deref
时都让结构体穿越了函数边界Deref::deref
,结果造成了重复借用的错误。
解决办法就是提前对智能指针进行手动解引用,然后对内部的值进行借用后,再行使用。
- - -本章讲述如何解决类似cannot borrow *self as mutable because it is also borrowed as immutable
这种重复借用的错误。
本文将彻底解决一个困扰广大 Rust 用户已久的常见错误:因为在函数内外同时借用一个引用,导致了重复借用错误cannot borrow *self as mutable because it is also borrowed as immutable
.
--本文大部分内容节选自Rust 常见陷阱专题,由于借用是新手绕不过去的坎,因此将其提取出来形成一个新的系列
-
--#![allow(unused)] -fn main() { -struct Test { - a : u32, - b : u32 -} - -impl Test { - fn increase(&mut self) { - let mut a = &mut self.a; - let mut b = &mut self.b; - *b += 1; - *a += 1; - } -} -} -
这段代码是可以正常编译的,也许有读者会有疑问,self
在这里被两个变量以可变的方式借用了,明明违反了 Rust 的所有权规则,为何它不会报错?
答案要从很久很久之前开始(啊哒~~~由于我太啰嗦,被正义群众来了一下,那咱现在开始长话短说,直接进入主题)。
-虽然从表面来看,a
和b
都可变引用了self
,但是 Rust 的编译器在很多时候都足够聪明,它发现我们其实仅仅引用了同一个结构体中的不同字段,因此完全可以将其的借用权分离开来。
因此,虽然我们不能同时对整个结构体进行可变引用,但是我们可以分别对结构体中的不同字段进行可变引用,当然,一个字段至多也只能存在一个可变引用,这个最基本的所有权规则还是不能违反的。变量a
引用结构体字段a
,变量b
引用结构体字段b
,从底层来说,这种方式也不会造成两个可变引用指向了同一块内存。
至此,正确代码我们已经挖掘完毕,再来看看重构后的错误代码。
---#![allow(unused)] -fn main() { -struct Test { - a : u32, - b : u32 -} - -impl Test { - - fn increase_a (&mut self) { - self.a += 1; - } - - fn increase(&mut self) { - let b = &mut self.b; - self.increase_a(); - *b += 1; - } -} -} -
果然不正义的代码就是不好看,但是邪恶的它更强了吗?
-error[E0499]: cannot borrow `*self` as mutable more than once at a time
- --> src/main.rs:14:9
- |
-13 | let b = &mut self.b;
- | ----------- first mutable borrow occurs here
-14 | self.increase_a();
- | ^^^^ second mutable borrow occurs here
-15 | *b += 1;
- | ------- first borrow later used here
-
-嗯,最开始提到的错误,它终于出现了。
-为什么?明明之前还是正确的代码,就因为放入函数中就报错了?我们先从一个简单的理解谈起,当然这个理解也是浮于表面的,等会会深入分析真实的原因。
-之前讲到 Rust 编译器挺聪明,可以识别到引用到不同的结构体字段,因此不会报错。但是现在这种情况下,编译器又不够聪明了,一旦放入函数中,编译器将无法理解我们对self
的使用:它仅仅用到了一个字段,而不是整个结构体。
因此它会简单的认为,这个结构体作为一个整体被可变借用了,产生两个可变引用,一个引用整个结构体,一个引用了结构体字段b
,这两个引用存在重叠的部分,最终导致编译错误。
在工作生活中,我们无法理解甚至错误的理解一件事,有时是因为层次不够导致的。同样,对于本文来说,也是因为我们对编译器的所知不够,才冤枉了它,还给它起了一个屈辱的“大聪明”外号。
---如果只改变相关函数的实现而不改变它的签名,那么不会影响编译的结果
-
何为相关函数?当函数a
调用了函数b
,那么b
就是a
的相关函数。
上面这句是一条非常重要的编译准则,意思是,对于编译器来说,只要函数签名没有变,那么任何函数实现的修改都不会影响已有的编译结果(前提是函数实现没有错误- , -)。
-以前面的代码为例:
---#![allow(unused)] -fn main() { -fn increase_a (&mut self) { - self.a += 1; -} - -fn increase(&mut self) { - let b = &mut self.b; - self.increase_a(); - *b += 1; -} -} -
虽然increase_a
在函数实现中没有访问self.b
字段,但是它的签名允许它访问b
,因此违背了借用规则。事实上,该函数有没有访问b
不重要,因为编译器在这里只关心签名,签名存在可能性,那么就立刻报出错误。
为何会有这种编译器行为,主要有两个原因:
-然后,我们的借用类型这么简单,编译器有没有可能针对这种场景,在现有的借用规则之外增加特殊规则?答案是否定的,由于 Rust 语言的设计哲学:特殊规则的加入需要慎之又慎,而我们的这种情况其实还蛮好解决的,因此编译器不会为此新增规则。
-在深入分析中,我们提到一条重要的规则,要影响编译行为,就需要更改相关函数的签名,因此可以修改increase_a
的签名:
--#![allow(unused)] -fn main() { -fn increase_a (a :&mut u32) { - *a += 1; -} - -fn increase(&mut self) { - let b = &mut self.b; - Test::increase_a(&mut self.a); - *b += 1; -} -} -
此时,increase_a
这个相关函数,不再使用&mut self
作为签名,而是获取了结构体中的字段a
,此时编译器又可以清晰的知道:函数increase_a
和变量b
分别引用了结构体中的不同字段,因此可以编译通过。
当然,除了修改相关函数的签名,你还可以修改调用者的实现:
---#![allow(unused)] -fn main() { -fn increase(&mut self) { - self.increase_a(); - self.b += 1; -} -} -
在这里,我们不再单独声明变量b
,而是直接调用self.b+=1
进行递增,根据借用生命周期NLL的规则,第一个可变借用self.increase_a()
的生命周期随着方法调用的结束而结束,那么就不会影响self.b += 1
中的借用。
我们再来看一个例子:
--use std::collections::HashMap; - -struct Cpu { - pc: u16, - cycles: u32, - opcodes: HashMap<u8, Opcode>, -} - -struct Opcode { - size: u16, - cycles: u32, -} - -impl Cpu { - fn new() -> Cpu { - Cpu { - pc: 0, - cycles: 0, - opcodes: HashMap::from([ - (0x00, Opcode::new(1, 7)), - (0x01, Opcode::new(2, 6)) - ]), - } - } - - fn tick(&mut self) { - let address = self.pc as u8; - let opcode = &self.opcodes[&address]; - - step(&mut self, opcode); - } - - -} - -fn step(cpu : &mut Cpu, opcode: &Opcode) { - -} - -impl Opcode { - fn new(size: u16, cycles: u32) -> Opcode { - Opcode { size, cycles } - } -} - -fn main() { - let mut cpu = Cpu::new(); - cpu.tick(); -} -
知其然知其所以然,要彻底解决借用导致的编译错误,我们就必须深入了解其原理,心中有剑则手中无"贱"。
-上面的例子就留给读者朋友自己去解决,相信你以后在遇到这种常见问题时,会更加游刃有余。
- - -特征对象是一个好东西,闭包也是一个好东西,但是如果两者你都想要时,可能就会火星撞地球,boom! 至于这两者为何会勾搭到一起?考虑一个常用场景:使用闭包作为回调函数.
-如何使用闭包作为特征对象,并解决以下错误:the parameter type
`impl Fn(&str) -> Res` may not live long enough
在下面代码中,我们通过闭包实现了一个简单的回调函数(错误代码已经标注):
--pub struct Res<'a> { - value: &'a str, -} - -impl<'a> Res<'a> { - pub fn new(value: &str) -> Res { - Res { value } - } -} - -pub struct Container<'a> { - name: &'a str, - callback: Option<Box<dyn Fn(&str) -> Res>>, -} - -impl<'a> Container<'a> { - pub fn new(name: &str) -> Container { - Container { - name, - callback: None, - } - } - - pub fn set(&mut self, cb: impl Fn(&str) -> Res) { - self.callback = Some(Box::new(cb)); - } -} - -fn main() { - let mut inl = Container::new("Inline"); - - inl.set(|val| { - println!("Inline: {}", val); - Res::new("inline") - }); - - if let Some(cb) = inl.callback { - cb("hello, world"); - } -} -
error[E0310]: the parameter type `impl Fn(&str) -> Res` may not live long enough
- --> src/main.rs:25:30
- |
-24 | pub fn set(&mut self, cb: impl Fn(&str) -> Res) {
- | -------------------- help: consider adding an explicit lifetime bound...: `impl Fn(&str) -> Res + 'static`
-25 | self.callback = Some(Box::new(cb));
- | ^^^^^^^^^^^^ ...so that the type `impl Fn(&str) -> Res` will meet its required lifetime bounds
-
-从第一感觉来说,报错属实不应该,因为我们连引用都没有用,生命周期都不涉及,怎么就报错了?在继续深入之前,先来观察下该闭包是如何被使用的:
---#![allow(unused)] -fn main() { -callback: Option<Box<dyn Fn(&str) -> Res>>, -} -
众所周知,闭包跟哈姆雷特一样,每一个都有自己的类型,因此我们无法通过类型标注的方式来声明一个闭包,那么只有一个办法,就是使用特征对象,因此上面代码中,通过Box<dyn Trait>
的方式把闭包特征封装成一个特征对象。
事出诡异必有妖,那接下来我们一起去会会这只妖。
-首先编译器报错提示我们闭包活得不够久,那可以大胆推测,正因为使用了闭包作为特征对象,所以才活得不够久。因此首先需要调查下特征对象的生命周期。
-首先给出结论:特征对象隐式的具有'static
生命周期。
其实在 Rust 中,'static
生命周期很常见,例如一个没有引用字段的结构体它其实也是'static
。当'static
用于一个类型时,该类型不能包含任何非'static
引用字段,例如以下结构体:
--#![allow(unused)] -fn main() { -struct Foo<'a> { - x : &'a [u8] -}; -} -
除非x
字段借用了'static
的引用,否则'a
肯定比'static
要小,那么该结构体实例的生命周期肯定不是'static
: 'a: 'static
的限制不会被满足(HRTB)。
对于特征对象来说,它没有包含非'static
的引用,因此它隐式的具有'static
生命周期, Box<dyn Trait>
就跟Box<dyn Trait + 'static>
是等价的。
其实以上代码的错误很好解决,甚至编译器也提示了我们:
-help: consider adding an explicit lifetime bound...: `impl Fn(&str) -> Res + 'static`
-
-但是解决问题不是本文的目标,我们还是要继续深挖一下,如果闭包使用了'static
会造成什么问题。
--#![allow(unused)] -fn main() { -inl.set(|val| { - println!("Inline: {}", val); - Res::new("inline") -}); -} -
以上代码只使用了闭包中传入的参数,并没有本地变量被捕获,因此'static
闭包一切 OK。
--#![allow(unused)] -fn main() { -let local = "hello".to_string(); - -// 编译错误: 闭包不是'static! -inl.set(|val| { - println!("Inline: {}", val); - println!("{}", local); - Res::new("inline") -}); -} -
这里我们在闭包中捕获了本地环境变量local
,因为local
不是'static
,那么闭包也不再是'static
。
--#![allow(unused)] -fn main() { -let local = "hello".to_string(); - -inl.set(move |val| { - println!("Inline: {}", val); - println!("{}", local); - Res::new("inline") -}); - -// 编译错误: local已经被移动到闭包中,这里无法再被借用 -// println!("{}", local); -} -
如上所示,你也可以选择将本地变量的所有权move
进闭包中,此时闭包再次具有'static
生命周期
对于第 2 种情况,如果非要这么干,那'static
肯定是没办法了,我们只能给予闭包一个新的生命周期:
--#![allow(unused)] -fn main() { -pub struct Container<'a, 'b> { - name: &'a str, - callback: Option<Box<dyn Fn(&str) -> Res + 'b>>, -} - -impl<'a, 'b> Container<'a, 'b> { - pub fn new(name: &str) -> Container { - Container { - name, - callback: None, - } - } - - pub fn set(&mut self, cb: impl Fn(&str) -> Res + 'b) { - self.callback = Some(Box::new(cb)); - } -} -} -
肉眼可见,代码复杂度哐哐哐提升,不得不说'static
真香!
友情提示:由此修改引发的一系列错误,需要你自行修复: ) (再次友情小提示,可以考虑把main
中的local
变量声明位置挪到inl
声明位置之前)
其实,大家应该都知道该如何修改了,不过出于严谨,我们还是继续给出完整的正确代码:
---#![allow(unused)] -fn main() { -pub fn set(&mut self, cb: impl Fn(&str) -> Res + 'static) { -} -
可能大家觉得我重新定义了完整
两个字,其实是我不想水篇幅:)
闭包和特征对象的相爱相杀主要原因就在于特征对象默认具备'static
的生命周期,同时我们还对什么样的类型具备'static
进行了简单的分析。
同时,如果一个闭包拥有'static
生命周期,那闭包无法通过引用的方式来捕获本地环境中的变量。如果你想要非要捕获,只能使用非'static
。
本章并不讲太多的概念,主要是用例子来引导大家去思考该如何对抗编译检查。
- - -当涉及生命周期时,Rust 编译器有时会变得不太聪明,如果再配合循环,蠢笨都不足以形容它,不信?那继续跟着我一起看看。
-Talk is cheap, 一起来看个例子:
---#![allow(unused)] -fn main() { -use rand::{thread_rng, Rng}; - -#[derive(Debug, PartialEq)] -enum Tile { - Empty, -} - -fn random_empty_tile(arr: &mut [Tile]) -> &mut Tile { - loop { - let i = thread_rng().gen_range(0..arr.len()); - let tile = &mut arr[i]; - if Tile::Empty == *tile{ - return tile; - } - } -} -} -
我们来看看上面的代码中,loop
循环有几个引用:
arr.len()
, 一个不可变引用,生命周期随着函数调用的结束而结束tile
是可变引用,生命周期在下次循环开始前会结束根据以上的分析,可以得出个初步结论:在同一次循环间各个引用生命周期互不影响,在两次循环间,引用也互不影响。
-那就简单了,开心运行,开心。。。报错:
-error[E0502]: cannot borrow `*arr` as immutable because it is also borrowed as mutable
- --> src/main.rs:10:43
- |
-8 | fn random_empty_tile(arr: &mut [Tile]) -> &mut Tile {
- | - let's call the lifetime of this reference `'1`
-9 | loop {
-10 | let i = thread_rng().gen_range(0..arr.len());
- | ^^^ immutable borrow occurs here
-11 | let tile = &mut arr[i];
- | ----------- mutable borrow occurs here
-12 | if Tile::Empty == *tile{
-13 | return tile;
- | ---- returning this value requires that `arr[_]` is borrowed for `'1`
-
-error[E0499]: cannot borrow `arr[_]` as mutable more than once at a time
- --> src/main.rs:11:20
- |
-8 | fn random_empty_tile(arr: &mut [Tile]) -> &mut Tile {
- | - let's call the lifetime of this reference `'1`
-...
-11 | let tile = &mut arr[i];
- | ^^^^^^^^^^^ `arr[_]` was mutably borrowed here in the previous iteration of the loop
-12 | if Tile::Empty == *tile{
-13 | return tile;
- | ---- returning this value requires that `arr[_]` is borrowed for `'1`
-
-不仅是错误,还是史诗级别的错误!无情刷屏了!只能想办法梳理下:
-arr.len()
报错,原因是它借用了不可变引用,但是在紧跟着的&mut arr[i]
中又借用了可变引用&mut arr[i]
报错,因为在上一次循环中,已经借用过同样的可变引用&mut arr[i]
tile
的生命周期跟arr
不一致奇了怪了,跟我们之前的分析完全背道而驰,按理来说arr.len()
的借用应该在调用后立刻结束,而不是持续到后面的代码行;同时可变借用&mut arr[i]
也应该随着每次循环的结束而结束,为什么会前后两次循环会因为同一处的引用而报错?
虽然报错复杂,不过可以看出,所有的错误都跟tile
这个中间变量有关,我们试着移除它看看:
--#![allow(unused)] -fn main() { -use rand::{thread_rng, Rng}; - -#[derive(Debug, PartialEq)] -enum Tile { - Empty, -} - -fn random_empty_tile(arr: &mut [Tile]) -> &mut Tile { - loop { - let i = thread_rng().gen_range(0..arr.len()); - if Tile::Empty == arr[i] { - return &mut arr[i]; - } - } -} -} -
见证奇迹的时刻,竟然编译通过了!到底发什么了什么?仅仅移除了中间变量,就编译通过了?是否可以大胆的猜测,因为中间变量,导致编译器变蠢了,因此无法正确的识别引用的生命周期。
-如果不使用循环呢?会不会也有这样的错误?咱们试着把循环展开:
---#![allow(unused)] -fn main() { -use rand::{thread_rng, Rng}; - -#[derive(Debug, PartialEq)] -enum Tile { - Empty, -} - -fn random_empty_tile_2<'arr>(arr: &'arr mut [Tile]) -> &'arr mut Tile { - let len = arr.len(); - - // First loop iteration - { - let i = thread_rng().gen_range(0..len); - let tile = &mut arr[i]; // Lifetime: 'arr - if Tile::Empty == *tile { - return tile; - } - } - - // Second loop iteration - { - let i = thread_rng().gen_range(0..len); - let tile = &mut arr[i]; // Lifetime: 'arr - if Tile::Empty == *tile { - return tile; - } - } - - unreachable!() -} -} -
结果,编译器还是不给通过,报的错误几乎一样
-令人沮丧的是,我找遍了网上,也没有具体的原因,大家都说这是编译器太笨导致的问题,但是关于深层的原因,也没人能说出个所有然。
-因此,我无法在本文中给出为什么编译器会这么笨的真实原因,如果以后有结果,会在这里进行更新。
-------2022 年 1 月 13 日更新------- -兄弟们,我带着挖掘出的一些内容回来了,再来看段错误代码先:
---#![allow(unused)] -fn main() { -struct A { - a: i32 -} - -impl A { - fn one(&mut self) -> &i32{ - self.a = 10; - &self.a - } - fn two(&mut self) -> &i32 { - loop { - let k = self.one(); - if *k > 10i32 { - return k; - } - - // 可能存在的剩余代码 - // ... - } - } -} -} -
我们来逐步深入分析下:
-two
方法增加一下生命周期标识: fn two<'a>(&'a mut self) -> &'a i32 { .. }
, 这里根据生命周期的消除规则添加的two
中返回的k
的生命周期必须是'a
let k = self.one();
中对self
的借用生命周期也是'a
k
的借用发生在loop
循环内,因此它需要小于等于循环的生命周期,但是根据之前的推断,它又要大于等于函数的生命周期'a
,而函数的生命周期又大于等于循环生命周期,由上可以推出:let k = self.one();
中k
的生命周期要大于等于循环的生命周期,又要小于等于循环的生命周期, 唯一满足条件的就是:k
的生命周期等于循环生命周期。
但是我们的two
方法在循环中对k
进行了提前返回,编译器自然会认为存在其它代码,这会导致k
的生命周期小于循环的生命周期。
怎么办呢?很简单:
---#![allow(unused)] -fn main() { -fn two(&mut self) -> &i32 { - loop { - let k = self.one(); - return k; - } -} -} -
不要在if
分支中返回k
,而是直接返回,这样就让它们的生命周期相等了,最终可以顺利编译通过。
--如果一个引用值从函数的某个路径提前返回了,那么该借用必须要在函数的所有返回路径都合法
-
虽然不能给出原因,但是我们可以看看解决办法,在上面,移除中间变量和消除代码分支都是可行的方法,还有一种方法就是将部分引用移到循环外面.
---#![allow(unused)] -fn main() { -fn random_empty_tile(arr: &mut [Tile]) -> &mut Tile { - let len = arr.len(); - let mut the_chosen_i = 0; - loop { - let i = rand::thread_rng().gen_range(0..len); - let tile = &mut arr[i]; - if Tile::Empty == *tile { - the_chosen_i = i; - break; - } - } - &mut arr[the_chosen_i] -} -} -
在上面代码中,我们只在循环中保留一个可变引用,剩下的arr.len
和返回值引用,都移到循环外面,顺利通过编译.
再来看一个例子,代码会更复杂,但是原因几乎相同:
---#![allow(unused)] -fn main() { -use std::collections::HashMap; - -enum Symbol { - A, -} - -pub struct SymbolTable { - scopes: Vec<Scope>, - current: usize, -} - -struct Scope { - parent: Option<usize>, - symbols: HashMap<String, Symbol>, -} - -impl SymbolTable { - pub fn get_mut(&mut self, name: &String) -> &mut Symbol { - let mut current = Some(self.current); - - while let Some(id) = current { - let scope = self.scopes.get_mut(id).unwrap(); - if let Some(symbol) = scope.symbols.get_mut(name) { - return symbol; - } - - current = scope.parent; - } - - panic!("Value not found: {}", name); - } -} -} -
运行后报错如下:
-error[E0499]: cannot borrow `self.scopes` as mutable more than once at a time
- --> src/main.rs:22:25
- |
-18 | pub fn get_mut(&mut self, name: &String) -> &mut Symbol {
- | - let's call the lifetime of this reference `'1`
-...
-22 | let scope = self.scopes.get_mut(id).unwrap();
- | ^^^^^^^^^^^ `self.scopes` was mutably borrowed here in the previous iteration of the loop
-23 | if let Some(symbol) = scope.symbols.get_mut(name) {
-24 | return symbol;
- | ------ returning this value requires that `self.scopes` is borrowed for `'1`
-
-对于上述代码,只需要将返回值修改下,即可通过编译:
---#![allow(unused)] -fn main() { -fn get_mut(&mut self, name: &String) -> &mut Symbol { - let mut current = Some(self.current); - - while let Some(id) = current { - let scope = self.scopes.get_mut(id).unwrap(); - if scope.symbols.contains_key(name) { - return self.scopes.get_mut(id).unwrap().symbols.get_mut(name).unwrap(); - } - - current = scope.parent; - } - - panic!("Value not found: {}", name); -} -} -
其中的关键就在于返回的时候,新建一个引用,而不是使用中间状态的引用。
-针对现有编译器存在的各种问题,Rust 团队正在研发一个全新的编译器,名曰polonius
,但是目前它仍然处在开发阶段,如果想在自己项目中使用,需要在rustc/RUSTFLAGS
中增加标志-Zpolonius
,但是可能会导致编译速度变慢,或者引入一些新的编译错误。
编译器不是万能的,它也会迷茫,也会犯错。
-因此我们在循环中使用引用类型时要格外小心,特别是涉及可变引用,这种情况下,最好的办法就是避免中间状态,或者在返回时避免使用中间状态。
- - -在大多时候,Rust 的生命周期你只要标识了,即可以通过编译,但是总是存在一些情况,会导致编译无法通过,本文就讲述这样一种情况:因为生命周期声明的范围过大,导致了编译无法通过,希望大家喜欢
--struct Interface<'a> { - manager: &'a mut Manager<'a> -} - -impl<'a> Interface<'a> { - pub fn noop(self) { - println!("interface consumed"); - } -} - -struct Manager<'a> { - text: &'a str -} - -struct List<'a> { - manager: Manager<'a>, -} - -impl<'a> List<'a> { - pub fn get_interface(&'a mut self) -> Interface { - Interface { - manager: &mut self.manager - } - } -} - -fn main() { - let mut list = List { - manager: Manager { - text: "hello" - } - }; - - list.get_interface().noop(); - - println!("Interface should be dropped here and the borrow released"); - - // this fails because inmutable/mutable borrow - // but Interface should be already dropped here and the borrow released - use_list(&list); -} - -fn use_list(list: &List) { - println!("{}", list.manager.text); -} -
运行后报错:
-error[E0502]: cannot borrow `list` as immutable because it is also borrowed as mutable // `list`无法被借用,因为已经被可变借用
- --> src/main.rs:40:14
- |
-34 | list.get_interface().noop();
- | ---- mutable borrow occurs here // 可变借用发生在这里
-...
-40 | use_list(&list);
- | ^^^^^
- | |
- | immutable borrow occurs here // 新的不可变借用发生在这
- | mutable borrow later used here // 可变借用在这里结束
-
-这段代码看上去并不复杂,实际上难度挺高的,首先在直觉上,list.get_interface()
借用的可变引用,按理来说应该在这行代码结束后,就归还了,为何能持续到use_list(&list)
后面呢?
这是因为我们在get_interface
方法中声明的lifetime
有问题,该方法的参数的生明周期是'a
,而List
的生命周期也是'a
,说明该方法至少活得跟List
一样久,再回到main
函数中,list
可以活到main
函数的结束,因此list.get_interface()
借用的可变引用也会活到main
函数的结束,在此期间,自然无法再进行借用了。
要解决这个问题,我们需要为get_interface
方法的参数给予一个不同于List<'a>
的生命周期'b
,最终代码如下:
-struct Interface<'b, 'a: 'b> { - manager: &'b mut Manager<'a> -} - -impl<'b, 'a: 'b> Interface<'b, 'a> { - pub fn noop(self) { - println!("interface consumed"); - } -} - -struct Manager<'a> { - text: &'a str -} - -struct List<'a> { - manager: Manager<'a>, -} - -impl<'a> List<'a> { - pub fn get_interface<'b>(&'b mut self) -> Interface<'b, 'a> - where 'a: 'b { - Interface { - manager: &mut self.manager - } - } -} - -fn main() { - - let mut list = List { - manager: Manager { - text: "hello" - } - }; - - list.get_interface().noop(); - - println!("Interface should be dropped here and the borrow released"); - - // this fails because inmutable/mutable borrow - // but Interface should be already dropped here and the borrow released - use_list(&list); -} - -fn use_list(list: &List) { - println!("{}", list.manager.text); -} -
当然,咱还可以给生命周期给予更有意义的名称:
-- - -struct Interface<'text, 'manager> { - manager: &'manager mut Manager<'text> -} - -impl<'text, 'manager> Interface<'text, 'manager> { - pub fn noop(self) { - println!("interface consumed"); - } -} - -struct Manager<'text> { - text: &'text str -} - -struct List<'text> { - manager: Manager<'text>, -} - -impl<'text> List<'text> { - pub fn get_interface<'manager>(&'manager mut self) -> Interface<'text, 'manager> - where 'text: 'manager { - Interface { - manager: &mut self.manager - } - } -} - -fn main() { - let mut list = List { - manager: Manager { - text: "hello" - } - }; - - list.get_interface().noop(); - - println!("Interface should be dropped here and the borrow released"); - - // this fails because inmutable/mutable borrow - // but Interface should be already dropped here and the borrow released - use_list(&list); -} - -fn use_list(list: &List) { - println!("{}", list.manager.text); -} -
继上篇文章后,我们再来看一段可能涉及生命周期过大导致的无法编译问题:
--fn bar(writer: &mut Writer) { - baz(writer.indent()); - writer.write("world"); -} - -fn baz(writer: &mut Writer) { - writer.write("hello"); -} - -pub struct Writer<'a> { - target: &'a mut String, - indent: usize, -} - -impl<'a> Writer<'a> { - fn indent(&'a mut self) -> &'a mut Self { - &mut Self { - target: self.target, - indent: self.indent + 1, - } - } - - fn write(&mut self, s: &str) { - for _ in 0..self.indent { - self.target.push(' '); - } - self.target.push_str(s); - self.target.push('\n'); - } -} - -fn main() {} -
报错如下:
-error[E0623]: lifetime mismatch
- --> src/main.rs:2:16
- |
-1 | fn bar(writer: &mut Writer) {
- | -----------
- | |
- | these two types are declared with different lifetimes...
-2 | baz(writer.indent());
- | ^^^^^^ ...but data from `writer` flows into `writer` here
-
-WTF,这什么报错,之前都没有见过,而且很难理解,什么叫writer
滑入了另一个writer
?
别急,我们先来仔细看下代码,注意这一段:
---#![allow(unused)] -fn main() { -impl<'a> Writer<'a> { - fn indent(&'a mut self) -> &'a mut Self { - &mut Self { - target: self.target, - indent: self.indent + 1, - } - } -} -
这里的生命周期定义说明indent
方法使用的。。。等等!你的代码错了,你怎么能在一个函数中返回一个新创建实例的引用?!!最重要的是,编译器不提示这个错误,竟然提示一个莫名其妙看不懂的东东。
行,那我们先解决这个问题,将该方法修改为:
---#![allow(unused)] -fn main() { -fn indent(&'a mut self) -> Writer<'a> { - Writer { - target: self.target, - indent: self.indent + 1, - } -} -} -
怀着惴惴这心,再一次运行程序,果不其然,编译器又朝我们扔了一坨错误:
-error[E0308]: mismatched types
- --> src/main.rs:2:9
- |
-2 | baz(writer.indent());
- | ^^^^^^^^^^^^^^^
- | |
- | expected `&mut Writer<'_>`, found struct `Writer`
- | help: consider mutably borrowing here: `&mut writer.indent()`
-
-哦,这次错误很明显,因为baz
需要&mut Writer
,但是咱们writer.indent
返回了一个Writer
,因此修改下即可:
--#![allow(unused)] -fn main() { -fn bar(writer: &mut Writer) { - baz(&mut writer.indent()); - writer.write("world"); -} -} -
这次总该成功了吧?再次心慌慌的运行编译器,哐:
-error[E0623]: lifetime mismatch
- --> src/main.rs:2:21
- |
-1 | fn bar(writer: &mut Writer) {
- | -----------
- | |
- | these two types are declared with different lifetimes...
-2 | baz(&mut writer.indent());
- | ^^^^^^ ...but data from `writer` flows into `writer` here
-
-可恶,还是这个看不懂的错误,仔细检查了下代码,这次真的没有其他错误了,只能硬着头皮上。
-大概的意思可以分析,生命周期范围不匹配,说明一个大一个小,然后一个writer
中流入到另一个writer
说明,两个writer
的生命周期定义错了,既然这里提到了indent
方法调用,那么我们再去仔细看一眼:
--#![allow(unused)] -fn main() { -impl<'a> Writer<'a> { - fn indent(&'a mut self) -> Writer<'a> { - Writer { - target: self.target, - indent: self.indent + 1, - } - } - ... -} -} -
好像有点问题,indent
返回的Writer
的生命周期和外面调用者的Writer
的生命周期一模一样,这很不合理,一眼就能看出前者远小于后者。
这里稍微展开以下,为何indent
方法返回值的生命周期不能与参数中的self
相同。首先,我们假设它们可以相同,也就是上面的代码可以编译通过,由于此时在返回值中借用了self
的可变引用,意味着如果你在返回值被使用后,还继续使用self
会导致重复借用的错误,因为返回值的生命周期将持续到 self
结束。
既然不能相同,那我们尝试着修改下indent
:
--#![allow(unused)] -fn main() { - fn indent<'b>(&'b mut self) -> Writer<'b> { - Writer { - target: self.target, - indent: self.indent + 1, - } -} -} -
Bang! 编译成功,不过稍等,回想下生命周期消除的规则,我们还可以实现的更优雅:
--fn bar(writer: &mut Writer) { - baz(&mut writer.indent()); - writer.write("world"); -} - -fn baz(writer: &mut Writer) { - writer.write("hello"); -} - -pub struct Writer<'a> { - target: &'a mut String, - indent: usize, -} - -impl<'a> Writer<'a> { - fn indent(&mut self) -> Writer { - Writer { - target: self.target, - indent: self.indent + 1, - } - } - - fn write(&mut self, s: &str) { - for _ in 0..self.indent { - self.target.push(' '); - } - self.target.push_str(s); - self.target.push('\n'); - } -} - -fn main() {} -
至此,问题彻底解决,太好了,我感觉我又变强了。可是默默看了眼自己的头发,只能以哎~
一声叹息结束本章内容。
在 Rust 中,溢出后的数值被截断是很正常的:
---#![allow(unused)] -fn main() { -let x: u16 = 65535; -let v = x as u8; -println!("{}", v) -} -
最终程序会输出255
, 因此大家可能会下意识地就觉得算数操作在 Rust 中只会导致结果的不正确,并不会导致异常。但是实际上,如果是因为算术操作符导致的溢出,就会让整个程序 panic:
-fn main() { - let x: u8 = 10; - - let v = x + u8::MAX; - println!("{}", v) -} -
输出结果如下:
-thread 'main' panicked at 'attempt to add with overflow', src/main.rs:5:13
-
-那么当我们确实有这种需求时,该如何做呢?可以使用 Rust 提供的checked_xxx
系列方法:
-fn main() { - let x: u8 = 10; - - let v = x.checked_add(u8::MAX).unwrap_or(0); - println!("{}", v) -} -
也许你会觉得本章内容其实算不上什么陷阱,但是在实际项目快速迭代中,越是不起眼的地方越是容易出错:
--fn main() { - let v = production_rate_per_hour(5); - println!("{}", v); -} - -pub fn production_rate_per_hour(speed: u8) -> f64 { - let cph: u8 = 221; - match speed { - 1..=4 => (speed * cph) as f64, - 5..=8 => (speed * cph) as f64 * 0.9, - 9..=10 => (speed * cph) as f64 * 0.77, - _ => 0 as f64, - } -} - -pub fn working_items_per_minute(speed: u8) -> u32 { - (production_rate_per_hour(speed) / 60 as f64) as u32 -} -
上述代码中,speed * cph
就会直接 panic:
thread 'main' panicked at 'attempt to multiply with overflow', src/main.rs:10:18
-
-是不是还藏的挺隐蔽的?因此大家在 Rust 中做数学运算时,要多留一个心眼,免得上了生产才发现问题所在。或者,你也可以做好单元测试:)
- - -Rust 一道独特的靓丽风景就是生命周期,也是反复折磨新手的最大黑手,就连老手,可能一不注意就会遇到一些生命周期上的陷阱,例如闭包上使用引用。
-先来看一段简单的代码:
---#![allow(unused)] -fn main() { -fn fn_elision(x: &i32) -> &i32 { x } -let closure_slision = |x: &i32| -> &i32 { x }; -} -
乍一看,这段代码比古天乐还平平无奇,能有什么问题呢?来,走两圈试试:
-error: lifetime may not live long enough
- --> src/main.rs:39:39
- |
-39 | let closure = |x: &i32| -> &i32 { x }; // fails
- | - - ^ returning this value requires that `'1` must outlive `'2`
- | | |
- | | let's call the lifetime of this reference `'2`
- | let's call the lifetime of this reference `'1`
-
-咦?竟然报错了,明明两个一模一样功能的函数,一个正常编译,一个却报错,错误原因是编译器无法推测返回的引用和传入的引用谁活得更久!
-真的是非常奇怪的错误,学过Rust 生命周期的读者应该都记得这样一条生命周期消除规则: 如果函数参数中只有一个引用类型,那该引用的生命周期会被自动分配给所有的返回引用。我们当前的情况完美符合,fn_elision
函数的顺利编译通过,就充分说明了问题。
那为何闭包就出问题了?
-为了验证闭包无法应用生命周期消除规则,再来看一个复杂一些的例子:
--use std::marker::PhantomData; - -trait Parser<'a>: Sized + Copy { - fn parse(&self, tail: &'a str) -> &'a str { - tail - } - fn wrap(self) -> Wrapper<'a, Self> { - Wrapper { - parser: self, - marker: PhantomData, - } - } -} - -#[derive(Copy, Clone)] -struct T<'x> { - int: &'x i32, -} - -impl<'a, 'x> Parser<'a> for T<'x> {} - -struct Wrapper<'a, P> -where - P: Parser<'a>, -{ - parser: P, - marker: PhantomData<&'a ()>, -} - -fn main() { - // Error. - let closure_wrap = |parser: T| parser.wrap(); - - // No error. - fn parser_wrap(parser: T<'_>) -> Wrapper<'_, T<'_>> { - parser.wrap() - } -} -
该例子之所以这么复杂,纯粹是为了证明闭包上生命周期会失效,读者大大轻拍:) 编译后,不出所料的报错了:
-error: lifetime may not live long enough
- --> src/main.rs:32:36
- |
-32 | let closure_wrap = |parser: T| parser.wrap();
- | ------ - ^^^^^^^^^^^^^ returning this value requires that `'1` must outlive `'2`
- | | |
- | | return type of closure is Wrapper<'_, T<'2>>
- | has type `T<'1>`
-
-一模一样的报错,说明在这种情况下,生命周期的消除规则也没有生效,看来事情确实不简单,我眉头一皱,决定深入调查,最后还真翻到了一些讨论,经过整理后,大概分享给大家。
-首先给出一个结论:这个问题,可能很难被解决,建议大家遇到后,还是老老实实用正常的函数,不要秀闭包了。
-对于函数的生命周期而言,它的消除规则之所以能生效是因为它的生命周期完全体现在签名的引用类型上,在函数体中无需任何体现:
---#![allow(unused)] -fn main() { -fn fn_elision(x: &i32) -> &i32 {..} -} -
因此编译器可以做各种编译优化,也很容易根据参数和返回值进行生命周期的分析,最终得出消除规则。
-可是闭包,并没有函数那么简单,它的生命周期分散在参数和闭包函数体中(主要是它没有确切的返回值签名):
---#![allow(unused)] -fn main() { -let closure_slision = |x: &i32| -> &i32 { x }; -} -
编译器就必须深入到闭包函数体中,去分析和推测生命周期,复杂度因此极具提升:试想一下,编译器该如何从复杂的上下文中分析出参数引用的生命周期和闭包体中生命周期的关系?
-由于上述原因(当然,实际情况复杂的多), Rust 语言开发者其实目前是有意为之,针对函数和闭包实现了两种不同的生命周期消除规则。
-虽然我言之凿凿,闭包的生命周期无法解决,但是未来谁又知道呢。最大的可能性就是之前开头那种简单的场景,可以被自动识别和消除。
-总之,如果有这种需求,还是像古天乐一样做一个平平无奇的男人,老老实实使用函数吧。
- - -本章收录一些 Rust 常见的陷阱,一不小心就会坑你的那种(当然,这不是 Rust 语言的问题,而是一些边边角角的知识点)。
- - -Rust 的迭代器无处不在,直至你在它上面栽了跟头,经过深入调查才发现:哦,原来是迭代器的锅。不信的话,看看这个报错你能想到是迭代器的问题吗: borrow of moved value: words
.
以下的代码非常简单,用来统计文本中字词的数量,并打印出来:
--fn main() { - let s = "hello world"; - let mut words = s.split(" "); - let n = words.count(); - println!("{:?}",words); -} -
四行代码,行云流水,一气呵成,且看成效:
-error[E0382]: borrow of moved value: `words`
- --> src/main.rs:5:21
- |
-3 | let mut words = s.split(" ");
- | --------- move occurs because `words` has type `std::str::Split<'_, &str>`, which does not implement the `Copy` trait
-4 | let n = words.count();
- | ------- `words` moved due to this method call
-5 | println!("{:?}",words);
- | ^^^^^ value borrowed here after move
-
-世事难料,我以为只有的生命周期、闭包才容易背叛革命,没想到一个你浓眉大眼的count
方法也背叛革命。从报错来看,是因为count
方法拿走了words
的所有权,来看看签名:
--#![allow(unused)] -fn main() { -fn count(self) -> usize -} -
从签名来看,编译器的报错是正确的,但是为什么?为什么一个简单的标准库count
方法就敢拿走所有权?
在迭代器章节中,我们曾经学习过两个概念:迭代器适配器和消费者适配器,前者用于对迭代器中的元素进行操作,最终生成一个新的迭代器,例如map
、filter
等方法;而后者用于消费掉迭代器,最终产生一个结果,例如collect
方法, 一个典型的示例如下:
--#![allow(unused)] -fn main() { -let v1: Vec<i32> = vec![1, 2, 3]; - -let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); - -assert_eq!(v2, vec![2, 3, 4]); -} -
在其中,我们还提到一个细节,消费者适配器会拿走迭代器的所有权,那么这个是否与我们最开始碰到的问题有关系?
-要解释这个问题,必须要找到words
是消费者适配器的证据,因此我们需要深入源码进行查看。
其实。。也不需要多深,只要进入words
的源码,就能看出它属于Iterator
特征,那说明split
方法产生了一个迭代器?再来看看:
--#![allow(unused)] -fn main() { -pub fn split<'a, P>(&'a self, pat: P) -> Split<'a, P> -where - P: Pattern<'a>, -//An iterator over substrings of this string slice, separated by characters matched by a pattern. -} -
还真是,从代码注释来看,Split
就是一个迭代器类型,用来迭代被分隔符隔开的子字符串集合。
真相大白了,split
产生一个迭代器,而count
方法是一个消费者适配器,用于消耗掉前者产生的迭代器,最终生成字词统计的结果。
本身问题不复杂,但是在解决方法上,可能还有点在各位客官的意料之外,且看下文。
-你可能会想用collect
来解决这个问题,先收集成一个集合,然后进行统计。当然此方法完全可行,但是很不rusty
(很符合 rust 规范、潮流的意思),以下给出最rusty
的解决方案:
--#![allow(unused)] -fn main() { -let words = s.split(","); -let n = words.clone().count(); -} -
在继续之前,我得先找一个地方藏好,因为俺有一个感觉,烂西红柿正在铺天盖地的呼啸而来,伴随而来的是读者的正义呵斥:
-你管clone
叫最好、最rusty
的解决方法??
大家且听我慢慢道来,事实上,在 Rust 中clone
不总是性能低下的代名词,因为clone
的行为完全取决于它的具体实现。
clone
代价对于迭代器而言,它其实并不需要持有数据才能进行迭代,事实上它包含一个引用,该引用指向了保存在堆上的数据,而迭代器自身的结构是保存在栈上。
-因此对迭代器的clone
仅仅是复制了一份栈上的简单结构,性能非常高效,例如:
--#![allow(unused)] -fn main() { -pub struct Split<'a, T: 'a, P> -where - P: FnMut(&T) -> bool, -{ - // Used for `SplitWhitespace` and `SplitAsciiWhitespace` `as_str` methods - pub(crate) v: &'a [T], - pred: P, - // Used for `SplitAsciiWhitespace` `as_str` method - pub(crate) finished: bool, -} - -impl<T, P> Clone for Split<'_, T, P> -where - P: Clone + FnMut(&T) -> bool, -{ - fn clone(&self) -> Self { - Split { v: self.v, pred: self.pred.clone(), finished: self.finished } - } -} -} -
以上代码实现了对Split
迭代器的克隆,可以看出,底层的的数组self.v
并没有被克隆而是简单的复制了一个引用,依然指向了底层的数组&[T]
,因此这个克隆非常高效。
看起来是无效借用导致的错误,实际上是迭代器被消费了导致的问题,这说明 Rust 编译器虽然会告诉你错误原因,但是这个原因不总是根本原因。我们需要一双慧眼和勤劳的手,来挖掘出这个宝藏,最后为己所用。
-同时,克隆在 Rust 中也并不总是bad guy的代名词,有的时候我们可以大胆去使用,当然前提是了解你的代码场景和具体的clone
实现,这样你也能像文中那样作出非常rusty
的选择。
迭代器,在 Rust 中是一个非常耀眼的存在,它光鲜亮丽,它让 Rust 大道至简,它备受用户的喜爱。可是,它也是懒惰的,不信?一起来看看。
-在迭代器学习中,我们提到过迭代器在功能上可以替代循环,性能上略微优于循环(避免边界检查),安全性上优于循环,因此在 Rust 中,迭代器往往都是更优的选择,前提是迭代器得发挥作用。
-在下面代码中,分别是使用for
循环和迭代器去生成一个HashMap
。
使用循环:
--use std::collections::HashMap; -#[derive(Debug)] -struct Account { - id: u32, -} - -fn main() { - let accounts = [Account { id: 1 }, Account { id: 2 }, Account { id: 3 }]; - - let mut resolvers = HashMap::new(); - for a in accounts { - resolvers.entry(a.id).or_insert(Vec::new()).push(a); - } - - println!("{:?}",resolvers); -} -
使用迭代器:
---#![allow(unused)] -fn main() { -let mut resolvers = HashMap::new(); -accounts.into_iter().map(|a| { - resolvers - .entry(a.id) - .or_insert(Vec::new()) - .push(a); -}); -println!("{:?}",resolvers); -} -
两端代码乍一看(很多时候我们快速浏览代码的时候,不会去细看)都很正常, 运行下试试:
-for
循环很正常,输出{2: [Account { id: 2 }], 1: [Account { id: 1 }], 3: [Account { id: 3 }]}
{}
, 黑人问号? ?
?在继续深挖之前,我们先来简单回顾下迭代器。
-在迭代器章节中,我们曾经提到过,迭代器的适配器分为两种:消费者适配器和迭代器适配器,前者用来将一个迭代器变为指定的集合类型,往往通过collect
实现;后者用于生成一个新的迭代器,例如上例中的map
。
还提到过非常重要的一点: 迭代器适配器都是懒惰的,只有配合消费者适配器使用时,才会进行求值.
-在我们之前的迭代器示例中,只有一个迭代器适配器map
:
--#![allow(unused)] -fn main() { -accounts.into_iter().map(|a| { - resolvers - .entry(a.id) - .or_insert(Vec::new()) - .push(a); -}); -} -
首先, accounts
被拿走所有权后转换成一个迭代器,其次该迭代器通过map
方法生成一个新的迭代器,最后,在此过程中没有以类如collect
的消费者适配器收尾。
因此在上述过程中,map
完全是懒惰的,它没有做任何事情,它在等一个消费者适配器告诉它:赶紧起床,任务可以开始了,它才会开始行动。
自然,我们的插值计划也失败了。
---事实上,IDE 和编译器都会对这种代码给出警告:iterators are lazy and do nothing unless consumed
-
原因非常清晰,如果读者还有疑惑,建议深度了解下上面给出的迭代器链接,我们这里就不再赘述。
-下面列出三种合理的解决办法:
-map
,改成for_each
:--#![allow(unused)] -fn main() { -let mut resolvers = HashMap::new(); -accounts.into_iter().for_each(|a| { - resolvers - .entry(a.id) - .or_insert(Vec::new()) - .push(a); -}); -} -
但是,相关的文档也友善的提示了我们,除非作为链式调用的收尾,否则更建议使用for
循环来处理这种情况。哎,忙忙碌碌,又回到了原点,不禁让人感叹:天道有轮回。
collect
来收尾,将map
产生的迭代器收集成一个集合类型:--#![allow(unused)] -fn main() { -let resolvers: HashMap<_, _> = accounts -.into_iter() -.map(|a| (a.id, a)) -.collect(); -} -
嗯,还挺简洁,挺rusty
.
fold
,语义表达更强:--#![allow(unused)] -fn main() { -let resolvers = account.into_iter().fold(HashMap::new(), |mut resolvers, a|{ - resolvers.entry(a.id).or_insert(Vec::new()).push(a); - resolvers -}); -} -
在使用迭代器时,要清晰的认识到需要用到的方法是迭代型还是消费型适配器,如果一个调用链中没有以消费型适配器结尾,就需要打起精神了,也许,不远处就是一个陷阱在等你跳:)
- - -本篇陷阱较短,主要解决新手在多线程间传递消息时可能会遇到的一个问题:主线程会一直阻塞,无法结束。
-Rust 标准库中提供了一个消息通道,非常好用,也相当简单明了,但是但是在使用起来还是可能存在坑:
--use std::sync::mpsc; -fn main() { - - use std::thread; - - let (send, recv) = mpsc::channel(); - let num_threads = 3; - for i in 0..num_threads { - let thread_send = send.clone(); - thread::spawn(move || { - thread_send.send(i).unwrap(); - println!("thread {:?} finished", i); - }); - } - - for x in recv { - println!("Got: {}", x); - } - println!("finished iterating"); -} -
以上代码看起来非常正常,运行下试试:
-thread 0 finished
-thread 1 finished
-Got: 0
-Got: 1
-thread 2 finished
-Got: 2
-
-奇怪,主线程竟然卡死了,最后一行 println!("finished iterating");
一直没有被输出。
其实,上面的描述有问题,主线程并不是卡死,而是for
循环并没有结束,至于for
循环不结束的原因是消息通道没有被关闭。
回忆一下 Rust 消息通道关闭的两个条件:所有发送者全部被drop
或接收者被drop
,由于for
循环还在使用接收者,因为后者条件无法被满足,那么只能发送者全部被drop
,才能让例子中的消息通道关闭。
来分析下代码,每一个子线程都从send
获取了一个拷贝,然后该拷贝在子线程结束时自动被drop
,看上去没问题啊。等等,好像send
本身并没有被drop
,因为send
要等到main
函数结束才会被drop
,那么代码就陷入了一个尴尬的境地:main
函数要结束需要for
循环结束,for
循环结束需要send
被drop
,而send
要被drop
需要main
函数结束。。。
破局点只有一个,那就是主动drop
掉send
,这个简单,使用std::mem::drop
函数即可,得益于prelude
,我们只需要使用drop
:
-use std::sync::mpsc; -fn main() { - - use std::thread; - - let (send, recv) = mpsc::channel(); - let num_threads = 3; - for i in 0..num_threads { - let thread_send = send.clone(); - thread::spawn(move || { - - thread_send.send(i).unwrap(); - println!("thread {:?} finished", i); - }); - } - - drop(send); - for x in recv { - println!("Got: {}", x); - } - println!("finished iterating"); -} -
此时再运行,主线程将顺利结束。
-本文总结了一个新手在使用消息通道时常见的错误,那就是忘记处理创建通道时得到的发送者,最后由于该发送者的存活导致通道无法被关闭,最终主线程阻塞,造成程序错误。
- - -相信大家都听说过重构一时爽,一直重构一直爽的说法,私以为这种说法是很有道理的,不然技术团队绩效从何而来?但是,在 Rust 中,重构可能就不是那么爽快的事了,不信?咱们来看看。
-很多时候,错误也是一种美,但是当这种错误每天都能见到时(呕):
-error[e0499]: cannot borrow ` * self` as mutable more than once at a time;
-
-虽然这一类错误长得一样,但是我这里的错误可能并不是大家常遇到的那些妖艳错误,废话不多说,一起来看看。
---#![allow(unused)] -fn main() { -struct Test { - a : u32, - b : u32 -} - -impl Test { - fn increase(&mut self) { - let mut a = &mut self.a; - let mut b = &mut self.b; - *b += 1; - *a += 1; - } -} -} -
这段代码是可以正常编译的,也许有读者会有疑问,self
在这里被两个变量以可变的方式借用了,明明违反了 Rust 的所有权规则,为何它不会报错?
答案要从很久很久之前开始(啊哒~~~由于我太啰嗦,被正义群众来了一下,那咱现在开始长话短说,直接进入主题)。
-虽然从表面来看,a
和b
都可变引用了self
,但是 Rust 的编译器在很多时候都足够聪明,它发现我们其实仅仅引用了同一个结构体中的不同字段,因此完全可以将其的借用权分离开来。
因此,虽然我们不能同时对整个结构体进行可变引用,但是我们可以分别对结构体中的不同字段进行可变引用,当然,一个字段至多也只能存在一个可变引用,这个最基本的所有权规则还是不能违反的。变量a
引用结构体字段a
,变量b
引用结构体字段b
,从底层来说,这种方式也不会造成两个可变引用指向了同一块内存。
至此,正确代码我们已经挖掘完毕,再来看看重构后的错误代码。
-由于领导说我们这个函数没办法复用,那就敷衍一下呗:
---#![allow(unused)] -fn main() { -struct Test { - a : u32, - b : u32 -} - -impl Test { - - fn increase_a (&mut self) { - self.a += 1; - } - - fn increase(&mut self) { - let b = &mut self.b; - self.increase_a(); - *b += 1; - } -} -} -
既然领导说了,咱照做,反正他也没说怎么个复用法,咱就来个简单的,把a
的递增部分复用下。
代码说实话。。。更丑了,但是更强了吗?
-error[E0499]: cannot borrow `*self` as mutable more than once at a time
- --> src/main.rs:14:9
- |
-13 | let b = &mut self.b;
- | ----------- first mutable borrow occurs here
-14 | self.increase_a();
- | ^^^^ second mutable borrow occurs here
-15 | *b += 1;
- | ------- first borrow later used here
-
-嗯,最开始提到的错误,它终于出现了。
-为什么?明明之前还是正确的代码,就因为放入函数中就报错了?我们先从一个简单的理解谈起,当然这个理解也是浮于表面的,等会会深入分析真实的原因。
-之前讲到 Rust 编译器挺聪明,可以识别到引用到不同的结构体字段,因此不会报错。但是现在这种情况下,编译器又不够聪明了,一旦放入函数中,编译器将无法理解我们对self
的使用:它仅仅用到了一个字段,而不是整个结构体。
因此它会简单的认为,这个结构体作为一个整体被可变借用了,产生两个可变引用,一个引用整个结构体,一个引用了结构体字段b
,这两个引用存在重叠的部分,最终导致编译错误。
在工作生活中,我们无法理解甚至错误的理解一件事,有时是因为层次不够导致的。同样,对于本文来说,也是因为我们对编译器的所知不够,才冤枉了它,还给它起了一个屈辱的“大聪明”外号。
---如果只改变相关函数的实现而不改变它的签名,那么不会影响编译的结果
-
何为相关函数?当函数a
调用了函数b
,那么b
就是a
的相关函数。
上面这句是一条非常重要的编译准则,意思是,对于编译器来说,只要函数签名没有变,那么任何函数实现的修改都不会影响已有的编译结果(前提是函数实现没有错误- , -)。
-以前面的代码为例:
---#![allow(unused)] -fn main() { -fn increase_a (&mut self) { - self.a += 1; -} - -fn increase(&mut self) { - let b = &mut self.b; - self.increase_a(); - *b += 1; -} -} -
虽然increase_a
在函数实现中没有访问self.b
字段,但是它的签名允许它访问b
,因此违背了借用规则。事实上,该函数有没有访问b
不重要,因为编译器在这里只关心签名,签名存在可能性,那么就立刻报出错误。
为何会有这种编译器行为,主要有两个原因:
-然后,我们的借用类型这么简单,编译器有没有可能针对这种场景,在现有的借用规则之外增加特殊规则?答案是否定的,由于 Rust 语言的设计哲学:特殊规则的加入需要慎之又慎,而我们的这种情况其实还蛮好解决的,因此编译器不会为此新增规则。
-在深入分析中,我们提到一条重要的规则,要影响编译行为,就需要更改相关函数的签名,因此可以修改increase_a
的签名:
--#![allow(unused)] -fn main() { -fn increase_a (a :&mut u32) { - *a += 1; -} - -fn increase(&mut self) { - let b = &mut self.b; - Test::increase_a(&mut self.a); - *b += 1; -} -} -
此时,increase_a
这个相关函数,不再使用&mut self
作为签名,而是获取了结构体中的字段a
,此时编译器又可以清晰的知道:函数increase_a
和变量b
分别引用了结构体中的不同字段,因此可以编译通过。
当然,除了修改相关函数的签名,你还可以修改调用者的实现:
---#![allow(unused)] -fn main() { -fn increase(&mut self) { - self.increase_a(); - self.b += 1; -} -} -
在这里,我们不再单独声明变量b
,而是直接调用self.b+=1
进行递增,根据借用生命周期NLL的规则,第一个可变借用self.increase_a()
的生命周期随着方法调用的结束而结束,那么就不会影响self.b += 1
中的借用。
再来看一个使用了闭包的例子:
---#![allow(unused)] -fn main() { -use tokio::runtime::Runtime; - -struct Server { - number_of_connections : u64 -} - -impl Server { - pub fn new() -> Self { - Server { number_of_connections : 0} - } - - pub fn increase_connections_count(&mut self) { - self.number_of_connections += 1; - } -} - -struct ServerRuntime { - runtime: Runtime, - server: Server -} - -impl ServerRuntime { - pub fn new(runtime: Runtime, server: Server) -> Self { - ServerRuntime { runtime, server } - } - - pub fn increase_connections_count(&mut self) { - self.runtime.block_on(async { - self.server.increase_connections_count() - }) - } -} -} -
代码中使用了tokio
,在increase_connections_count
函数中启动了一个异步任务,并且等待它的完成。这个函数中分别引用了self
中的不同字段:runtime
和server
,但是可能因为闭包的原因,编译器没有像本文最开始的例子中那样聪明,并不能识别这两个引用仅仅引用了同一个结构体的不同部分,因此报错了:
error[E0501]: cannot borrow `self.runtime` as mutable because previous closure requires unique access
- --> the_little_things\src\main.rs:28:9
- |
-28 | self.runtime.block_on(async {
- | __________^____________--------_______-
- | | | |
- | | _________| first borrow later used by call
- | ||
-29 | || self.server.increase_connections_count()
- | || ---- first borrow occurs due to use of `self` in generator
-30 | || })
- | ||_________-^ second borrow occurs here
- | |__________|
- | generator construction occurs here
-
-解决办法很粗暴,既然编译器不能理解闭包中的引用是不同的,那么我们就主动告诉它:
---#![allow(unused)] -fn main() { -pub fn increase_connections_count(&mut self) { - let runtime = &mut self.runtime; - let server = &mut self.server; - runtime.block_on(async { - server.increase_connections_count() - }) -} -} -
上面通过变量声明的方式,在闭包外声明了两个变量分别引用结构体self
的不同字段,这样一来,编译器就不会那么笨,编译顺利通过。
你也可以这么写:
---#![allow(unused)] -fn main() { -pub fn increase_connections_count(&mut self) { - let ServerRuntime { runtime, server } = self; - runtime.block_on(async { - server.increase_connections_count() - }) -} -} -
当然,如果难以解决,还有一个笨办法,那就是将server
和runtime
分离开来,不要放在一个结构体中。
心中有剑,手中无剑,是武学至高境界。
-本文列出的那条编译规则,在未来就将是大家心中的那把剑,当这些心剑招式足够多时,量变产生质变,终将天下无敌。
- - -在 Rust 中,我们不太容易遇到栈溢出,因为默认栈还挺大的,而且大的数据往往存在堆上(动态增长),但是一旦遇到该如何处理?先来看段代码:
---#![allow(unused)] -#![feature(test)] -fn main() { -extern crate test; - -#[cfg(test)] -mod tests { - use test::Bencher; - - #[bench] - fn it_works(b: &mut Bencher) { - b.iter(|| { let stack = [[[0.0; 2]; 512]; 512]; }); - } -} -} -
以上代码是一个测试模块,它在堆上生成了一个数组stack
,初步看起来数组挺大的,先尝试运行下cargo test
:
--你很可能会遇到
-#![feature(test)]
错误,因为该特性目前只存在Rust Nightly
版本上,具体解决方法见Rust 语言圣经
running 1 test
-
-thread 'tests::it_works' has overflowed its stack
-fatal runtime error: stack overflow
-
-Bang,很不幸,遇到了百年一遇的栈溢出错误,再来试试cargo bench
,竟然通过了测试,这是什么原因?为何cargo test
和cargo bench
拥有完全不同的行为?这就要从 Rust 的栈原理讲起。
首先看看stack
数组,它的大小是8 × 2 × 512 × 512 = 4 MiB
,嗯,很大,崩溃也正常(读者说,正常,只是作者你不太正常。。).
其次,cargo test
和cargo bench
,前者运行在一个新创建的线程上,而后者运行在main 线程上.
最后,main
线程由于是老大,所以资源比较多,拥有令其它兄弟艳羡不已的8MB
栈大小,而其它新线程只有区区2MB
栈大小(取决于操作系统,linux
是2MB
,其它的可能更小),再对比我们的stack
大小,不崩溃就奇怪了。
因此,你现在明白,为何cargo test
不能运行,而cargo bench
却可以欢快运行。
如果实在想要增大栈的默认大小,以通过该测试,你可以这样运行:RUST_MIN_STACK=8388608 cargo test
,结果如下:
running 1 test
-test tests::it_works ... ok
-
-test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
-
-Bingo, 成功了,最后再补充点测试的背景知识:
--- - --
cargo test
为何使用新线程?因为它需要并行的运行测试用例,与之相反,cargo bench
只需要顺序的执行,因此 main 线程足矣
众所周知 Rust 是一门安全性非常强的系统级语言,其中,显式的设置变量可变性,是安全性的重要组成部分。按理来说,变量可变不可变在设置时就已经决定了,但是你遇到过可变变量在某些情况失效,变成不可变吗?
-先来看段正确的代码:
--#[derive(Debug)] -struct A { - f1: u32, - f2: u32, - f3: u32 -} - -#[derive(Debug)] -struct B<'a> { - f1: u32, - a: &'a mut A, -} - -fn main() { - let mut a: A = A{ f1: 0, f2: 1, f3: 2 }; - // b不可变 - let b: B = B{ f1: 3, a: &mut a }; - // 但是b中的字段a可以变 - b.a.f1 += 1; - - println!("b is {:?} ", &b); -} -
在这里,虽然变量b
被设置为不可变,但是b
的其中一个字段a
被设置为可变的结构体,因此我们可以通过b.a.f1 += 1
来修改a
的值。
也许有人还不知道这种部分可变性的存在,不过没关系,因为马上就不可变了:)
-&mut a
b.a
在理解了上面两条简单规则后,来看看下面这段代码:
--#[derive(Debug)] -struct A { - f1: u32, - f2: u32, - f3: u32 -} - -#[derive(Debug)] -struct B<'a> { - f1: u32, - a: &'a mut A, -} - - -impl B<'_> { - // this will not work - pub fn changeme(&self) { - self.a.f1 += 1; - } -} - -fn main() { - let mut a: A = A{ f1: 0, f2: 1, f3: 2 }; - // b is immutable - let b: B = B{ f1: 3, a: &mut a }; - b.changeme(); - - println!("b is {:?} ", &b); -} -
这段代码,仅仅做了一个小改变,不再直接修改b.a
,而是通过调用b
上的方法去修改其中的a
,按理说不会有任何区别。因此我预言:通过方法调用跟直接调用不应该有任何区别,运行验证下:
error[E0594]: cannot assign to `self.a.f1`, which is behind a `&` reference
- --> src/main.rs:18:9
- |
-17 | pub fn changeme(&self) {
- | ----- help: consider changing this to be a mutable reference: `&mut self`
-18 | self.a.f1 += 1;
- | ^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be written
-
-啪,又被打脸了。我说我是大意了,没有闪,大家信不?反正马先生应该是信的:D
-观察第一个例子,我们调用的b.a
实际上是用b
的值直接调用的,在这种情况下,由于所有权规则,编译器可以认定,只有一个可变引用指向了a
,因此这种使用是非常安全的。
但是,在第二个例子中,b
被藏在了&
后面,根据所有权规则,同时可能存在多个b
的借用,那么就意味着可能会存在多个可变引用指向a
,因此编译器就拒绝了这段代码。
事实上如果你将第一段代码的调用改成:
---#![allow(unused)] -fn main() { -let b: &B = &B{ f1: 3, a: &mut a }; -b.a.f1 += 1; -} -
一样会报错!
-结束之前再来一个练习,稍微有点绕,大家品味品味:
--#[derive(Debug)] -struct A { - f1: u32, - f2: u32, - f3: u32 -} - -#[derive(Debug)] -struct B<'a> { - f1: u32, - a: &'a mut A, -} - -fn main() { - let mut a: A = A{ f1: 0, f2: 1, f3: 2 }; - let b: B = B{ f1: 3, a: &mut a }; - b.a.f1 += 1; - a.f1 = 10; - - println!("b is {:?} ", &b); -} -
小提示:这里b.a.f1 += 1
和a.f1 = 10
只能有一个存在,否则就会报错。
根据之前的观察和上面的小提示,可以得出一个结论:可变性的真正含义是你对目标对象的独占修改权。在实际项目中,偶尔会遇到比上述代码更复杂的可变性情况,记住这个结论,有助于我们拨云见日,直达本质。
-学习,就是不断接近和认识事物本质的过程,对于 Rust 语言的学习亦是如此。
- - -一般来说,for
循环能做到的,while
也可以,反之亦然,但是有一种情况,还真不行,先来看代码:
--#![allow(unused)] -fn main() { -let mut v = vec![1,2,3]; - -for i in 0..v.len() { - v.push(i); - println!("{:?}",v); -} -} -
我们的目的是创建一个无限增长的数组,往里面插入0..
(看不懂该表达式的同学请查阅流程控制)的数值序列。
看起来上面代码可以完成,因为随着数组不停增长,v.len()
也会不停变大,但是事实上真的如此吗?
[1, 2, 3, 0]
-[1, 2, 3, 0, 1]
-[1, 2, 3, 0, 1, 2]
-
-输出很清晰的表明,只新插入了三个元素:0..=2
,刚好是v
的初始长度。
这是因为:在 for 循环中,v.len
只会在循环伊始之时进行求值,之后就一直使用该值。
行,问题算是清楚了,那该如何解决呢,我们可以使用while
循环,该循环与for
相反,每次都会重新求值:
--#![allow(unused)] -fn main() { -let mut v = vec![1,2,3]; - -let mut i = 0; -while i < v.len() { - v.push(i); - i+=1; - println!("{:?}",v); -} -} -
友情提示,在你运行上述代码时,千万要及时停止,否则会Boom
- 炸翻控制台。
大家应该都知道, 虽然 Rust 的字符串 &str
、String
在底层是通过 Vec<u8>
实现的:字符串数据以字节数组的形式存在堆上,但在使用时,它们都是 UTF-8 编码的,例如:
-fn main() { - let s: &str = "中国人"; - for c in s.chars() { - println!("{}", c) // 依次输出:中 、 国 、 人 - } - - let c = &s[0..3]; // 1. "中" 在 UTF-8 中占用 3 个字节 2. Rust 不支持字符串索引,因此只能通过切片的方式获取 "中" - assert_eq!(c, "中"); -} -
从上述代码,可以很清晰看出,Rust 的字符串确实是 UTF-8 编码的,这就带来一个隐患:可能在某个转角,你就会遇到来自糟糕性能的示爱。
-例如我们尝试写一个词法解析器,里面用到了以下代码 self.source.chars().nth(self.index).unwrap();
去获取下一个需要处理的字符,大家可能会以为 .nth
的访问应该非常快吧?事实上它确实很快,但是并不妨碍这段代码在循环处理 70000 长度的字符串时,需要消耗 5s 才能完成!
这么看来,唯一的问题就在于 .chars()
上了。
其实原因很简单,简单到我们不需要用代码来说明,只需要文字描述即可传达足够的力量:每一次循环时,.chars().nth(index)
都需要对字符串进行一次 UTF-8 解析,这个解析实际上是相当昂贵的,特别是当配合循环时,算法的复杂度就是平方级的。
既然找到原因,那解决方法也很简单:只要将 self.source.chars()
的迭代器存储起来就行,这样每次 .nth
调用都会复用已经解析好的迭代器,而不是重新去解析一次 UTF-8 字符串。
当然,我们还可以使用三方库来解决这个问题,例如 str_indices。
-最终的优化结果如下:
-5s
-> 4ms
u8
字节数组来替换 char
,最后使用 String::from_utf8
来构建 UTF-8 字符串: 耗时 4ms
-> 400us
肉眼可见的巨大提升,12500 倍!
-总之,我们在热点路径中使用字符串做 UTF-8 的相关操作时,就算不提前优化,也要做到心里有数,这样才能在问题发生时,进退自如。
- - -@todo
-https://www.reddit.com/r/rust/comments/rrgxr0/a_critique_of_rusts_range_types/
- - -在 Rust 中,想要重载操作符,你就需要实现对应的特征。
-例如 <
、<=
、>
和 >=
需要实现 PartialOrd
特征:
--#![allow(unused)] -fn main() { -use std::fmt::Display; - -struct Pair<T> { - x: T, - y: T, -} - -impl<T> Pair<T> { - fn new(x: T, y: T) -> Self { - Self { x, y } - } -} - -impl<T: Display + PartialOrd> Pair<T> { - fn cmp_display(&self) { - if self.x >= self.y { - println!("The largest member is x = {}", self.x); - } else { - println!("The largest member is y = {}", self.y); - } - } -} -} -
再比如, +
号需要实现 std::ops::Add
特征,而本文的主角 Eq
和 PartialEq
正是 ==
和 !=
所需的特征,那么问题来了,这两个特征有何区别?
我相信很多同学都说不太清楚,包括一些老司机,而且就算是翻文档,可能也找不到特别明确的解释。如果大家看过标准库示例,可能会看过这个例子:
---#![allow(unused)] -fn main() { -enum BookFormat { Paperback, Hardback, Ebook } -struct Book { - isbn: i32, - format: BookFormat, -} -impl PartialEq for Book { - fn eq(&self, other: &Self) -> bool { - self.isbn == other.isbn - } -} -impl Eq for Book {} -} -
这里只实现了 PartialEq
,并没有实现 Eq
,而是直接使用了默认实现 impl Eq for Book {}
,奇了怪了,别急,还有呢:
--#![allow(unused)] -fn main() { -impl PartialEq<IpAddr> for Ipv4Addr { - #[inline] - fn eq(&self, other: &IpAddr) -> bool { - match other { - IpAddr::V4(v4) => self == v4, - IpAddr::V6(_) => false, - } - } -} - -impl Eq for Ipv4Addr {} -} -
以上代码来自 Rust 标准库,可以看到,依然是这样使用,类似的情况数不胜数。既然如此,是否说明如果要为我们的类型增加相等性比较,只要实现 PartialEq
即可?
其实,关键点就在于 partial
上,如果我们的类型只在部分情况下具有相等性,那你就只能实现 PartialEq
,否则可以实现 PartialEq
然后再默认实现 Eq
。
好的,问题逐步清晰起来,现在我们只需要搞清楚何为部分相等。
-首先我们需要找到一个类型,它实现了 PartialEq
但是没有实现 Eq
(你可能会想有没有反过来的情况?当然没有啦,部分相等肯定是全部相等的子集!)
在 HashMap
章节提到过 HashMap
的 key 要求实现 Eq
特征,也就是要能完全相等,而浮点数由于没有实现 Eq
,因此不能用于 HashMap
的 key。
当时由于一些知识点还没有介绍,因此就没有进一步展开,那么让我们考虑浮点数既然没有实现 Eq
为何还能进行比较呢?
-fn main() { - let f1 = 3.14; - let f2 = 3.14; - - if f1 == f2 { - println!("hello, world!"); - } -} -
以上代码是可以看到输出内容的,既然浮点数没有实现 Eq
那说明它实现了 PartialEq
,一起写个简单代码验证下:
-fn main() { - let f1 = 3.14; - is_eq(f1); - is_partial_eq(f1) -} - -fn is_eq<T: Eq>(f: T) {} -fn is_partial_eq<T: PartialEq>(f: T) {} -
上面的代码通过特征约束的方式验证了我们的结论:
-3 | is_eq(f1);
- | ----- ^^ the trait `Eq` is not implemented for `{float}`
-
-好的,既然我们成功找到了一个类型实现了 PartialEq
但没有实现 Eq
,那就通过它来看看何为部分相等性。
其实答案很简单,浮点数有一个特殊的值 NaN
,它是无法进行相等性比较的:
-fn main() { - let f1 = f32::NAN; - let f2 = f32::NAN; - - if f1 == f2 { - println!("NaN 竟然可以比较,这很不数学啊!") - } else { - println!("果然,虽然两个都是 NaN ,但是它们其实并不相等") - } -} -
大家猜猜哪一行会输出 :) 至于 NaN
为何不能比较,这个原因就比较复杂了( 有读者会说,其实就是你不知道,我只能义正严辞的说:咦?你怎么知道 :P )。
既然浮点数有一个值不可以比较相等性,那它自然只能实现 PartialEq
而不能实现 Eq
了,以此类推,如果我们的类型也有这种特殊要求,那也应该这么作。
事实上,还有一对与 Eq/PartialEq
非常类似的特征,它们可以用于 <
、<=
、>
和 >=
比较,至于哪个类型实现了 PartialOrd
却没有实现 Ord
就交给大家自己来思考了:)
-- - -小提示:Ord 意味着一个类型的所有值都可以进行排序,而 PartialOrd 则不然
-
当大家一路看到这里时,我敢说 90% 的人还是云里雾里的,例如你能说清楚:
-以及到底该用它们之中哪一个吗?
-如果不行,就跟随我一起来看看吧,本章的目标就是帮大家彻底理清这些概念,为后面的进一步学习和实战打好坚实的基础。
- - -关于 str
/ &str
,[u8]
/ &[u8]
区别,你能清晰的说出来嘛?如果答案是 No ,那就跟随我一起来看看切片和切片引用到底有何区别吧。
--在继续之前,查看这里了解何为切片
-
切片允许我们引用集合中部分连续的元素序列,而不是引用整个集合。例如,字符串切片就是一个子字符串,数组切片就是一个子数组。
-Rust 语言特性内置的 str
和 [u8]
类型都是切片,前者是字符串切片,后者是数组切片,下面我们来尝试下使用 str
:
--#![allow(unused)] -fn main() { -let string: str = "banana"; -} -
上面代码创建一个 str
类型的字符串,看起来很正常,但是编译就会报错:
error[E0277]: the size for values of type `str` cannot be known at compilation time
- --> src/main.rs:4:9
- |
-4 | let string: str = "banana";
- | ^^^^^^ doesn't have a size known at compile-time
-
-编译器准确的告诉了我们原因:str
字符串切片它是 DST
动态大小类型,这意味着编译器无法在编译期知道 str
类型的大小,只有到了运行期才能动态获知,这对于强类型、强安全的 Rust 语言来说是不可接受的。
也就是说,我们无法直接使用 str
,而对于 [u8]
也是类似的,大家可以自己动手试试。
总之,我们可以总结出一个结论:在 Rust 中,所有的切片都是动态大小类型,它们都无法直接被使用。
-原因在于底层的切片长度是可以动态变化的,而编译器无法在编译期得知它的具体的长度,因此该类型无法被分配在栈上,只能分配在堆上。
-既然切片只能分配到堆上,我们就无法直接使用它,大家可以想想,所有分配在堆上的数据,是不是都是通过一个在栈上的引用来访问的?切片也不例外。
-切片引用是一个宽指针,存储在栈上,指向了堆上的切片数据,该引用包含了切片的起始位置和长度,而且最重要的是,类似于指针,引用的大小是固定的(起始位置和长度都是整形),因此它才可以存储在栈上。
-有,使用固定长度的数组: let a: [i8;4] = [1,2,3,4];
,注意看,数组的类型与切片是不同的,前者的类型带有长度:[i8;4]
,而后者仅仅是 [i8]
。
那么问题来了,该如何使用切片呢?
-何以解忧,唯有引用。由于引用类型的大小在编译期是已知的,因此在 Rust 中,如果要使用切片,就必须要使用它的引用。
-str
切片的引用类型是 &str
,而 [i32]
的引用类型是 &[i32]
,相信聪明的读者已经看出来了,&str
和 &[i32]
都是我们非常常用的类型,例如:
--#![allow(unused)] -fn main() { -let s1: &str = "banana"; -let s2: &str = &String::from("banana"); - -let arr = [1, 2, 3, 4, 5]; - -let s3: &[i32] = &arr[1..3]; -} -
这段代码就可以正常通过,原因在于这些切片引用的大小在编译器都是已知的。
-我们常常说使用切片,实际上我们在用的是切片的引用,我们也在频繁说使用字符串,实际上我们在使用的也是字符串切片的引用。
-总之,切片在 Rust 中是动态大小类型 DST,是无法被我们直接使用的,而我们在使用的都是切片的引用。
-切片 | 切片引用 |
---|---|
str 字符串切片 | &str 字符串切片的引用 |
[u8] 数组切片 | &[u8] 数组切片的引用 |
但是出于方便,我们往往不会说使用切片引用,而是直接说使用字符串切片或数组切片,实际上,这时指代的都是切片的引用!
- - -字符串让人疯狂,这句话用在 Rust 中一点都不夸张,不信?那你能否清晰的说出 String
、str
、&str
、&String
、Box<str>
或 Box<&str>
的区别?
Rust 语言的类型可以大致分为两种:基本类型和标准库类型,前者是由语言特性直接提供的,而后者是在标准库中定义。即将登场的 str
类型就是唯一定义在语言特性中的字符串。
--在继续之前,大家需要先了解字符串的基本知识,本文主要在于概念对比,而不是字符串讲解
-
如上所述,str
是唯一定义在 Rust 语言特性中的字符串,但是也是我们几乎不会用到的字符串类型,为何?
原因在于 str
字符串它是 DST
动态大小类型,这意味着编译器无法在编译期知道 str
类型的大小,只有到了运行期才能动态获知,这对于强类型、强安全的 Rust 语言来说是不可接受的。
--#![allow(unused)] -fn main() { -let string: str = "banana"; -} -
上面代码创建一个 str
类型的字符串,看起来很正常,但是编译就会报错:
error[E0277]: the size for values of type `str` cannot be known at compilation time
- --> src/main.rs:4:9
- |
-4 | let string: str = "banana";
- | ^^^^^^ doesn't have a size known at compile-time
-
-如果追求更深层的原因,我们可以总结如下:所有的切片都是动态类型,它们都无法直接被使用,而 str
就是字符串切片,[u8]
是数组切片。
同时还是 String 和 &str 的底层数据类型。 由于 str 是动态
-str
类型是硬编码进可执行文件,也无法被修改,但是 String
则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String
类型和 &str
字符串切片类型,这两个类型都是 UTF-8 编码。
除了 String
类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString
, OsStr
, CsString
和 CsStr
等,注意到这些名字都以 String
或者 Str
结尾了吗?它们分别对应的是具有所有权和被借用的变量。
未完待续
-https://pic1.zhimg.com/80/v2-177bce575bfaf289ae12d677689a26f4_1440w.png -https://pic2.zhimg.com/80/v2-697ad53cb502ccec4b2e98c40975344f_1440w.png
-https://medium.com/@alisomay/strings-in-rust-28c08a2d3130
- - -但凡经历过 C/C++ 或 Go 语言 1.10 版本之前的用户都知道,一个好的包管理工具有多么的重要!!我那个时候是如此的渴望类似 nodejs
的 npm
包管理工具,但是却求而不得。
包管理工具最重要的意义就是任何用户拿到你的代码,都能运行起来,而不会因为各种包版本依赖焦头烂额。
-Go 语言在 1.10 版本之前,所有的包都是在 github.com
下存放,导致了所有的项目都公用一套依赖代码,在本地项目复杂后,这简直是一种灾难。
说多了都是泪,笔者目前还有一个早期 Go 的项目 (15 年写的),用到了 iris
(一个坑爹 HTTP 服务),结果现在运行不起来了,因为找不到 iris
当时的那个版本了!!
作为一门现代化语言,Rust
吸收了多个语言的包管理优点,为大家提供超级大杀器: cargo
,真的,再挑剔的开发者,都对它赞不绝口。👍
总而言之,cargo
提供了一系列的工具,从项目的建立、构建到测试、运行直至部署,为 Rust 项目的管理提供尽可能完整的手段。同时,与 Rust 语言及其编译器 rustc
紧密结合,可以说用了后就忘不掉,如同初恋般的感觉。
又见"你好,世界",肯定有读者在批评了:你就不能有点创意吗?"世界,你好"难道不配?你是读者,你说了算,那我们就来创建一个"世界,你好"。
-上文提到,Rust 语言的包管理工具是 cargo
。不过,我们无需再手动安装,之前安装 Rust 的时候,就已经一并安装了。
终于到了紧张刺激的 new new new 环节:
-$ cargo new world_hello
-$ cd world_hello
-
-上面的命令使用 cargo new
创建一个项目,项目名是 world_hello
(向读者势力低头的项目名称,泪奔),该项目的结构和配置文件都是由 cargo
生成,意味着我们的项目被 cargo
所管理。
--如果你在终端无法使用这个命令,考虑一下
-环境变量
是否正确的设置:把cargo
可执行文件所在的目录添加到环境变量中。如果是在 Windows 的 WLS2 子系统下,出现以下错误:
--
error: command failed: 'rustc'
-error: caused by: Permission denied (os error 13)
可尝试先卸载,再使用
-sudo
命令进行安装:$ sudo curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
早期的 cargo
在创建项目时,必须添加 --bin
的参数,如下所示:
$ cargo new world_hello --bin
-$ cd world_hello
-
-现在的版本,已经无需此参数,cargo
默认就创建 bin
类型的项目,顺便说一句,Rust 项目主要分为两个类型:bin
和 lib
,前者是一个可运行的项目,后者是一个依赖库项目。
下面来看看创建的项目结构:
-$ tree
-.
-├── .git
-├── .gitignore
-├── Cargo.toml
-└── src
- └── main.rs
-
-
-是的,连 git
都给你创建了,不禁令人感叹,不是女儿,胜似女儿,比小棉袄还体贴。
有两种方式可以运行项目:
-cargo run
手动编译和运行项目
-首先来看看第一种方式,一码胜似千言,在之前创建的 world_hello
目录下运行:
$ cargo run
- Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
- Finished dev [unoptimized + debuginfo] target(s) in 0.43s
- Running `target/debug/world_hello`
-Hello, world!
-
-好了,你已经看到程序的输出:"Hello, world"
。
如果你安装的 Rust 的 host triple
是 x86_64-pc-windows-msvc
并确认 Rust 已经正确安装,但在终端上运行上述命令时,出现类似如下的错误摘要 linking with `link.exe` failed: exit code: 1181
,请使用 Visual Studio Installer 安装 Windows SDK
。
可能有读者不愿意了,说好了"世界,你好"呢?别急,在下一节,我们再对代码进行修改。(认真想来,"你好,世界“强调的是我对世界说你好,而"世界,你好“是世界对我说你好,明显是后者更有包容性和国际范儿,读者真·好眼光。)
-上述代码,cargo run
首先对项目进行编译,然后再运行,因此它实际上等同于运行了两个指令,下面我们手动试一下编译和运行项目:
编译
-$ cargo build
- Finished dev [unoptimized + debuginfo] target(s) in 0.00s
-
-运行
-$ ./target/debug/world_hello
-Hello, world!
-
-行云流水,但谈不上一气呵成。 细心的读者可能已经发现,在调用的时候,路径 ./target/debug/world_hello
中有一个明晃晃的 debug
字段,没错我们运行的是 debug
模式,在这种模式下,代码的编译速度会非常快,可是福兮祸所伏,运行速度就慢了. 原因是,在 debug
模式下,Rust 编译器不会做任何的优化,只为了尽快的编译完成,让你的开发流程更加顺畅。
作为尊贵的读者,咱自然可以要求更多,比如你想要高性能的代码怎么办? 简单,添加 --release
来编译:
cargo run --release
cargo build --release
试着运行一下我们高性能的 release
程序:
$ ./target/release/world_hello
-Hello, world!
-
-当项目大了后,cargo run
和 cargo build
不可避免的会变慢,那么有没有更快的方式来验证代码的正确性呢?大杀器来了,接着!
cargo check
是我们在代码开发过程中最常用的命令,它的作用很简单:快速的检查一下代码能否编译通过。因此该命令速度会非常快,能节省大量的编译时间。
$ cargo check
- Checking world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
- Finished dev [unoptimized + debuginfo] target(s) in 0.06s
-
---Rust 虽然编译速度还行,但是还是不能与 Go 语言相提并论,因为 Rust 需要做很多复杂的编译优化和语言特性解析,甚至连如何优化编译速度都成了一门学问: 优化编译速度。
-
Cargo.toml
和 Cargo.lock
是 cargo
的核心文件,它的所有活动均基于此二者。
Cargo.toml
是 cargo
特有的项目数据描述文件。它存储了项目的所有元配置信息,如果 Rust 开发者希望 Rust 项目能够按照期望的方式进行构建、测试和运行,那么,必须按照合理的方式构建 Cargo.toml
。
Cargo.lock
文件是 cargo
工具根据同一项目的 toml
文件生成的项目依赖详细清单,因此我们一般不用修改它,只需要对着 Cargo.toml
文件撸就行了。
--什么情况下该把
-Cargo.lock
上传到 git 仓库里?很简单,当你的项目是一个可运行的程序时,就上传Cargo.lock
,如果是一个依赖库项目,那么请把它添加到.gitignore
中。
现在用 VSCode 打开上面创建的"世界,你好"项目,然后进入根目录的 Cargo.toml
文件,可以看到该文件包含不少信息:
package
中记录了项目的描述信息,典型的如下:
[package]
-name = "world_hello"
-version = "0.1.0"
-edition = "2021"
-
-name
字段定义了项目名称,version
字段定义当前版本,新项目默认是 0.1.0
,edition
字段定义了我们使用的 Rust 大版本。因为本书很新(不仅仅是现在新,未来也将及时修订,跟得上 Rust 的小步伐),所以使用的是 Rust edition 2021
大版本,详情见 Rust 版本详解
使用 cargo
工具的最大优势就在于,能够对该项目的各种依赖项进行方便、统一和灵活的管理。
在 Cargo.toml
中,主要通过各种依赖段落来描述该项目的各种依赖项:
crates.io
,通过版本说明来描述这三种形式具体写法如下:
-[dependencies]
-rand = "0.3"
-hammer = { version = "0.5.0"}
-color = { git = "https://github.com/bjz/color-rs" }
-geometry = { path = "crates/geometry" }
-
-相信聪明的读者已经能看懂该如何引入外部依赖库,这里就不再赘述。详细的说明参见此章:Cargo 依赖管理,但是不建议大家现在去看,只要按照目录浏览,拨云见日指日可待。
-前文有提到 cargo
默认生成的项目结构,真实的项目肯定会有所不同,但是在目前的学习阶段,还无需关注。感兴趣的同学可以移步:Cargo 项目结构
至此,大家对 Rust 项目的创建和管理已经有了初步的了解,那么来完善刚才的"世界,你好"
项目吧。
VSCode
从 15 年刚开始推出,我就在使用了。做为第一个吃螃蟹的人,可以说见证了它一路的快速发展,直到现在它已经成为开源世界最火的 IDE 之一(弱弱的说一句,之一也许可以去掉)。
顺便歪楼说一句:我预言过三件事:
-Golang
会火遍全世界。同时创建了 14-19 年最火的 Golang 隐修会社区,可惜因为某些原因被封停了,甚是遗憾。VSCode
会成为世界上最好的 IDE;同时我还是 jaeger tracing
项目的第一个 star 用户(是的,比作者还早),当时就很看好这个项目的后续发展。Rust
会成为主流编程语言之一,在几乎所有开发领域都将大放光彩。总之牛逼已吹下,希望不要被打脸。:(下面继续简单介绍下 VSCode,以下内容引用于官网:
---Visual Studio Code(VSCode) 是微软 2015 年推出的一个轻量但功能强大的源代码编辑器,基于 Electron 开发,支持 Windows、Linux 和 macOS 操作系统。它内置了对 JavaScript,TypeScript 和 Node.js 的支持并且具有丰富的其它语言和扩展的支持,功能超级强大。Visual Studio Code 是一款免费开源的现代化轻量级代码编辑器,支持几乎所有主流的开发语言的语法高亮、智能代码补全、自定义快捷键、括号匹配和颜色区分、代码片段、代码对比 Diff、GIT 命令等特性,支持插件扩展,并针对网页开发和云端应用开发做了优化。
-
在 VSCode 的左侧扩展目录里,搜索 rust
, 你能看到两个 Rust 插件,如果没有意外,这两个应该分别排名第一和第二:
Rust
,作者是 The Rust Programming Language
, 官方出品,牛逼就完了,但是……我们并不推荐(事实上已经不再维护了,官方收编了第二个插件,现在第二个插件的作者也是 The Rust Programming Language
),这个插件有几个问题:rust-analyzer
,非常推荐,上面说的所有问题,在这个插件上都得到了解决,不得不说,Rust 社区 yyds!所以,综上所述,我们选择 rust-analyzer
作为 Rust 语言的插件,具体的安装很简单,点击插件,选择安装即可,根据提示可能需要重新加载 IDE。
--在搜索 VSCode 插件时,报错:
-提取扩展出错,XHR failed
,这个报错是因为网络原因导致,很可能是你的网络不行或者翻墙工具阻拦你的访问,试着关掉翻墙,再进行尝试。
安装完成后,在第一次打开 Rust 项目时,需要安装一些依赖,具体的状态在左下角会进行提示,包括下载、代码构建、building 等。
-当插件使用默认设置时,每一次保存代码,都会出进行一次重新编译。
---如果你的电脑慢,有一点一定要注意:
-在编译器构建代码的同时,不要在终端再运行
-cargo run
等命令进行编译,不然会获得一个报错提示,大意是当前文件目录已经被锁定,等待其它使用者释放。如果等了很久 IDE 还是没有释放(虽然我没遇到过,但是存在这个可能性),你可以关掉 IDE,并手动kill
掉rust-analyzer
,然后重新尝试。
在此,再推荐大家几个好用的插件:
-Even Better TOML
,支持 .toml 文件完整特性Error Lens
, 更好的获得错误展示One Dark Pro
, 非常好看的 VSCode 主题CodeLLDB
, Debugger 程序好了,至此,VSCode 的配置就已经全部结束,是不是很简单?下面让我们来用 Cargo
创建一个 Rust 项目,然后用 VSCode 打开。
几乎所有教程中安装的最后一个环节都是 hello world
,我们也不能免俗。但是,在 hello world
之后,还有一个相亲,啊呸,Rust 初印象环节,希望大家喜欢。
还记得大明湖畔等你的 VSCode IDE 和通过 Cargo
创建的 世界,你好 工程吗?
现在使用 VSCode 打开 上一节 中创建的 world_hello
工程,然后进入 main.rs
文件。(此文件是当前 Rust 工程的入口文件,和其它语言几无区别。)
接下来,对世界友人给予热切的问候:
--fn greet_world() { - let southern_germany = "Grüß Gott!"; - let chinese = "世界,你好"; - let english = "World, hello"; - let regions = [southern_germany, chinese, english]; - for region in regions.iter() { - println!("{}", ®ion); - } -} - -fn main() { - greet_world(); -} -
打开终端,进入 world_hello
工程根目录,运行该程序。(你也可以在 VSCode 中打开终端,方法是点击 VSCode 上方菜单栏中的终端->新建终端,或者直接使用快捷键打开。)
$ cargo run
- Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
- Finished dev [unoptimized + debuginfo] target(s) in 0.21s
- Running `target/debug/world_hello`
-Grüß Gott!
-世界,你好
-World, hello
-
-你的热情,就像一把火,燃烧了整个世界~ 花点时间来看看上面的代码:
-首先,Rust 原生支持 UTF-8 编码的字符串,这意味着你可以很容易的使用世界各国文字作为字符串内容。
-其次,关注下 println
后面的 !
,如果你有 Ruby 编程经验,那么你可能会认为这是解构操作符,但是在 Rust 中,这是 宏
操作符,你目前可以认为宏是一种特殊类型函数。
对于 println
来说,我们没有使用其它语言惯用的 %s
、%d
来做输出占位符,而是使用 {}
,因为 Rust 在底层帮我们做了大量工作,会自动识别输出数据的类型,例如当前例子,会识别为 String
类型。
最后,和其它语言不同,Rust 的集合类型不能直接进行循环,需要变成迭代器(这里是通过 .iter()
方法),才能用于迭代循环。在目前来看,你会觉得这一点好像挺麻烦,不急,以后就知道这么做的好处所在。
--实际上这段代码可以简写,在 2021 edition 及以后,支持直接写
-for region in regions
,原因会在迭代器章节的开头提到,是因为 for 隐式地将 regions 转换成迭代器。
至于函数声明、调用、数组的使用,和其它语言没什么区别,So Easy!
-Rust 这门语言对于 Haskell 和 Java 开发者来说,可能会觉得很熟悉,因为它们在高阶表达方面都很优秀。简而言之,就是可以很简洁的写出原本需要一大堆代码才能表达的含义。但是,Rust 又有所不同:它的性能是底层语言级别的性能,可以跟 C/C++ 相媲美。
-上面的 So Easy
的余音仍在绕梁,我希望它能继续下去,可是… 人总是要面对现实,因此让我们来点狠活:
-fn main() { - let penguin_data = "\ - common name,length (cm) - Little penguin,33 - Yellow-eyed penguin,65 - Fiordland penguin,60 - Invalid,data - "; - - let records = penguin_data.lines(); - - for (i, record) in records.enumerate() { - if i == 0 || record.trim().len() == 0 { - continue; - } - - // 声明一个 fields 变量,类型是 Vec - // Vec 是 vector 的缩写,是一个可伸缩的集合类型,可以认为是一个动态数组 - // <_>表示 Vec 中的元素类型由编译器自行推断,在很多场景下,都会帮我们省却不少功夫 - let fields: Vec<_> = record - .split(',') - .map(|field| field.trim()) - .collect(); - if cfg!(debug_assertions) { - // 输出到标准错误输出 - eprintln!("debug: {:?} -> {:?}", - record, fields); - } - - let name = fields[0]; - // 1. 尝试把 fields[1] 的值转换为 f32 类型的浮点数,如果成功,则把 f32 值赋给 length 变量 - // - // 2. if let 是一个匹配表达式,用来从=右边的结果中,匹配出 length 的值: - // 1)当=右边的表达式执行成功,则会返回一个 Ok(f32) 的类型,若失败,则会返回一个 Err(e) 类型,if let 的作用就是仅匹配 Ok 也就是成功的情况,如果是错误,就直接忽略 - // 2)同时 if let 还会做一次解构匹配,通过 Ok(length) 去匹配右边的 Ok(f32),最终把相应的 f32 值赋给 length - // - // 3. 当然你也可以忽略成功的情况,用 if let Err(e) = fields[1].parse::<f32>() {...}匹配出错误,然后打印出来,但是没啥卵用 - if let Ok(length) = fields[1].parse::<f32>() { - // 输出到标准输出 - println!("{}, {}cm", name, length); - } - } - } -
看完这段代码,不知道你的余音有没有戛然而止,反正我已经在颤抖了。这就是传说中的下马威吗?😵
-上面代码中,值得注意的 Rust 特性有:
-for
和 continue
连在一起使用,实现循环控制。OO
语言那里偷师了方法的使用 record.trim()
,record.split(',')
等。.map(|field| field.trim())
,这里 map
方法中使用闭包函数作为参数,也可以称呼为 匿名函数
、lambda 函数
。if let Ok(length) = fields[1].parse::<f32>()
,通过 ::<f32>
的使用,告诉编译器 length
是一个 f32
类型的浮点数。这种类型标注不是很常用,但是在编译器无法推断出你的数据类型时,就很有用了。if cfg!(debug_assertions)
,说明紧跟其后的输出(打印)只在 debug
模式下生效。return
关键字用于函数返回,但是在很多时候,我们可以省略它。因为 Rust 是 基于表达式的语言。在终端中运行上述代码时,会看到很多 debug: ...
的输出,上面有讲,这些都是 条件编译
的输出,那么该怎么消除掉这些输出呢?
读者大大普遍冰雪聪明,肯定已经想到:是的,在 认识 Cargo中,曾经介绍过 --release
参数,因为 cargo run
默认是运行 debug
模式。因此想要消灭那些 debug:
输出,需要更改为其它模式,其中最常用的模式就是 --release
也就是生产发布的模式。
具体运行代码就不给了,留给大家作为一个小练习,建议亲自动手尝试下。
-至此,Rust 安装入门就已经结束。相信看到这里,你已经发现了本书与其它书的区别,其中最大的区别就是:这本书就像优秀的国外课本一样,不太枯燥。也希望这本不太枯燥的书,能伴你长行,犹如一杯奶茶,细细品之,唇齿留香。
- - -rustup
是 Rust 的安装程序,也是它的版本管理程序。
-强烈建议使用 rustup
来安装 Rust,当然如果你有异心,请寻找其它安装方式,然后再从下一节开始阅读。
--haha,开个玩笑。读者乃大大,怎么能弃之不顾。
-注意:如果你不想用或者不能用 rustup,请参见 Rust 其它安装方法。
-
至于版本,现在 Rust 稳定版特性越来越全了,因此下载最新稳定版本即可。由于你用的 Rust 版本可能跟本书写作时不一样,一些编译错误和警告可能也会有所不同。
-rustup
打开终端并输入下面命令:
-$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
-
-这个命令将下载一个脚本并开始安装 rustup
工具,此工具将安装 Rust 的最新稳定版本。可能会提示你输入管理员密码。
如果安装成功,将出现下面这行:
-Rust is installed now. Great!
-
-OK,这样就已经完成 Rust 安装啦。
-Rust 对运行环境的依赖和 Go 语言很像,几乎所有环境都可以无需安装任何依赖直接运行。但是,Rust 会依赖 libc
和链接器 linker
。所以如果遇到了提示链接器无法执行的错误,你需要再手动安装一个 C 语言编译器:
macOS 下:
-$ xcode-select --install
-
-Linux 下:
-Linux 用户一般应按照相应发行版的文档来安装 GCC
或 Clang
。
例如,如果你使用 Ubuntu,则可安装 build-essential
。
rustup
Windows 上安装 Rust 需要有 C++
环境,以下为安装的两种方式:
1. x86_64-pc-windows-msvc
(官方推荐)
先安装 Microsoft C++ Build Tools,勾选安装 C++ 环境即可。安装时可自行修改缓存路径与安装路径,避免占用过多 C 盘空间。安装完成后,Rust 所需的 msvc 命令行程序需要手动添加到环境变量中,否则安装 Rust 时 rustup-init
会提示未安装 Microsoft C++ Build Tools,其位于:%Visual Studio 安装位置%\VC\Tools\MSVC\%version%\bin\Hostx64\x64
(请自行替换其中的 %Visual Studio 安装位置%、%version% 字段)下。
如果你不想这么做,可以选择安装 Microsoft C++ Build Tools 新增的“定制”终端 Developer Command Prompt for %Visual Studio version%
或 Developer PowerShell for %Visual Studio version%
,在其中运行 rustup-init.exe
。
准备好 C++ 环境后开始安装 Rust:
-在 RUSTUP-INIT 下载系统相对应的 Rust 安装程序,一路默认即可。
-PS C:\Users\Hehongyuan> rustup-init.exe
-......
-Current installation options:
-
- default host triple: x86_64-pc-windows-msvc
- default toolchain: stable (default)
- profile: default
- modify PATH variable: yes
-
-1) Proceed with installation (default)
-2) Customize installation
-3) Cancel installation
-
-2、x86_64-pc-windows-gnu
相比于 MSVC 版本来说,GNU 版本具有更轻量,更靠近 Linux 的优势。
-首先,根据 MSYS2 官网 配置 MSYS。
-若您觉得下载太慢,可以试试由 Caviar-X 提供的 代理。
-在安装 mingw-toolchain
后,请将 %MSYS 安装路径%\mingw64\bin
添加到系统变量 PATH
中。
配置好后,在 MSYS 中输入下面的命令来安装 rustup。
-$ curl https://sh.rustup.rs -sSf | sh
-
-之后,根据以下输出进行配置。
-Current installation options:
-
- default host triple: x86_64-pc-windows-msvc
- default toolchain: stable (default)
- profile: default
- modify PATH variable: yes
-
-1) Proceed with installation (default)
-2) Customize installation
-3) Cancel installation
->2
-
-I'm going to ask you the value of each of these installation options.
-You may simply press the Enter key to leave unchanged.
-
-Default host triple? [x86_64-pc-windows-msvc]
-x86_64-pc-windows-gnu
-
-Default toolchain? (stable/beta/nightly/none) [stable]
-stable
-
-Profile (which tools and data to install)? (minimal/default/complete) [default]
-complete
-
-Modify PATH variable? (Y/n)
-Y
-
-Current installation options:
-
- default host triple: x86_64-pc-windows-gnu
- default toolchain: stable
- profile: complete
- modify PATH variable: yes
-
-1) Proceed with installation (default)
-2) Customize installation
-3) Cancel installation
->
-
-再之后,按下 1,等待。完成后,您就已经安装了 Rust 和 rustup
。
要卸载 Rust 和 rustup
,在终端执行以下命令即可卸载:
$ rustup self uninstall
-
-检查是否正确安装了 Rust,可打开终端并输入下面这行,此时能看到最新发布的稳定版本的版本号、提交哈希值和提交日期:
-$ rustc -V
-rustc 1.56.1 (59eed8a2a 2021-11-01)
-
-$ cargo -V
-cargo 1.57.0 (b2e52d7ca 2021-10-21)
-
---注:若发现版本号不同,以您的版本号为准
-
恭喜,你已成功安装 Rust!
-如果没看到此信息:
-%USERPROFILE%\.cargo\bin
是否在 %PATH%
系统变量中。如果都正确,但 Rust 仍然无法正常工作,那么你可以在很多地方获得帮助。最简单的是加入 Rust 编程学院这个大家庭,QQ 群:1009730433.
-安装 Rust 的同时也会在本地安装一个文档服务,方便我们离线阅读:运行 rustup doc
让浏览器打开本地文档。
每当遇到标准库提供的类型或函数不知道怎么用时,都可以在 API 文档中查找到!具体参见 在标准库寻找你想要的内容。
- - -其实对于写这种章节,我内心是拒绝的,因为真的很无趣。对于一本书而言,这也更像是一种浪费纸张的行为(好在咱无纸化:-D)。不过没有办法,如果不安装 Rust 环境,总不能让大家用空气运行吧,so,我恶趣味的起了一个这样的章节名。
-在本章中,你将学习以下内容:
-在目前,大家还不需要自己搭建的镜像下载服务,因此只需知道下载依赖库的地址是 crates.io,是由 Rust 官方搭建的镜像下载和管理服务。
-但悲剧的是,它的默认镜像地址是在国外,这就导致了某些时候难免会遇到下载缓慢或者卡住的情况,下面我们一起来看看。
-解决下载缓慢有两种方式:
-事实上,翻墙工具默认开启的仅仅是浏览器的翻墙代理,对于命令行或者软件中的访问,并不会代理流量,因此这些访问还是通过正常网络进行的,自然会失败。
-因此,大家需要做的是在你使用的翻墙工具中 复制终端代理命令
或者开启全局翻墙。由于每个翻墙软件的使用方式不同,因此具体的还是需要自己研究下。以我使用的 ClashX
为例,点击 复制终端代理命令
后,会自动复制一些 export
文本,将这些文本复制到命令行终端中,执行一下,就可以自动完成代理了。
export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7891
-
-为了使用 crates.io
之外的注册服务,我们需要对 $HOME/.cargo/config.toml
($CARGO_HOME 下) 文件进行配置,添加新的服务提供商,有两种方式可以实现:增加新的镜像地址和覆盖默认的镜像地址。
--这里推荐使用科大的注册服务来提升下载速度,以下注册服务的链接都是科大的。
-
首先是在 crates.io
之外添加新的注册服务,在 $HOME/.cargo/config.toml
(如果文件不存在则手动创建一个)中添加以下内容:
[registries]
-ustc = { index = "https://mirrors.ustc.edu.cn/crates.io-index/" }
-
-这种方式只会新增一个新的镜像地址,因此在引入依赖的时候,需要指定该地址,例如在项目中引入 time
包,你需要在 Cargo.toml
中使用以下方式引入:
[dependencies]
-time = { registry = "ustc" }
-
-在重新配置后,初次构建可能要较久的时间,因为要下载更新 ustc
注册服务的索引文件,还挺大的...
事实上,我们更推荐第二种方式,因为第一种方式在项目大了后,实在是很麻烦,全部修改后,万一以后不用这个镜像了,你又要全部修改成其它的。
-而第二种方式,则不需要修改 Cargo.toml
文件,因为它是直接使用新注册服务来替代默认的 crates.io
。
在 $HOME/.cargo/config.toml
添加以下内容:
[source.crates-io]
-replace-with = 'ustc'
-
-[source.ustc]
-registry = "git://mirrors.ustc.edu.cn/crates.io-index"
-
-首先,创建一个新的镜像源 [source.ustc]
,然后将默认的 crates-io
替换成新的镜像源: replace-with = 'ustc'
。
简单吧?只要这样配置后,以往需要去 crates.io
下载的包,会全部从科大的镜像地址下载,速度刷刷的.. 我的 300M 大刀( 宽带 )终于有了用武之地。
这里强烈推荐大家在学习完后面的基本章节后,看一下 Cargo 使用指南章节,对于你的 Rust 之旅会有莫大的帮助!
-下载卡住其实就一个原因:下载太慢了。
-根据经验来看,卡住不动往往发生在更新索引时。毕竟 Rust 的包越来越多,索引也越来越大,如果不使用国内镜像,卡住还蛮正常的,好在,我们也无需经常更新索引 :P
-不过这里有一个坑,需要大家注意,如果你同时打开了 VSCODE 和命令行,然后修改了 Cargo.toml
,此时 VSCODE 的 rust-analyzer 插件会自动检测到依赖的变更,去下载新的依赖。
在 VSCODE 下载的过程中( 特别是更新索引,可能会耗时很久),假如你又在命令行中运行类似 cargo run
或者 cargo build
的命令,就会提示一行有些看不太懂的内容:
$ cargo build
- Blocking waiting for file lock on package cache
- Blocking waiting for file lock on package cache
-
-其实这个报错就是因为 VSCODE 的下载太慢了,而且该下载构建还锁住了当前的项目,导致你无法在另一个地方再次进行构建。
-解决办法也很简单:
-$HOME/.cargo/.package_cache
目录很多人都在学 Rust ing,也有很多人在放弃 ing。想要顺利学完 Rust,大家需要谨记本文列出的内容,否则这极有可能是又双叒叕从入门到放弃之旅。
-Rust 是一门全新的语言,它会带给你前所未有的体验,提升你的通用编程水平,甚至于赋予你全新的编程思想。在此时此刻,大家可能还半信半疑,但是当学完它再回头看时,你肯定也会认同这些貌似浮夸的赞美。
-在学习 Go、Python 等编程语言时,你可能会一边工作、一边轻松愉快的学习它们,但是 Rust 不行。原因如文章开头所说,在学习 Rust 的同时你会收获很多语言之外的知识,因此 Rust 在入门阶段比很多编程语言要更难,但是一旦入门,你将收获一个全新的自己,成为一个更加优秀的程序员。
-在学习过程中,一开始可能会轻松愉快,但是在开始接触 Rust 核心概念时(所有权、借用、生命周期、智能指针等),难度会陡然提升,此时就需要认真对待起来,否则会为后面埋下难以填补的坑: 结果最后你可能只有两个选择 - 重新学 or 放弃。
-因此,在学习过程中,给大家三点建议:
-总之,Rust 入门难,但是在你一次次克服艰难险阻的同时,也一次次收获了与众不同的编程经验,最后历经九九八十一难,立地成大佬。 给自己一个机会,也给 Rust 一个机会 :)
-Rust 跟其它语言不同,你无法看了一遍语法,然后就能上手写代码,对,我说的就是对比 Go 语言,后者的简单易用是有目共睹的。
-这些年,我遇到过太多在网上看了一遍菜鸟教程(或其它简易教程)就上手写 demo 甚至项目的同学,无一例外,都各种碰壁、趟坑,最后要么放弃,要么回炉重造,之前的时间和精力基本等同浪费。
-因此,大家一定要舍得投入时间,沉下心去读一本好书,这本书会带你深入浅出地学习使用 Rust 所需的各种知识,还会带你提前趟坑,这些坑往往是需要大量的时间才能领悟的。
-在以前我可能会推荐看 Rust Book + async book + nomicon 这几本英文书的组合,但是现在有了一本更适合中国用户的书籍,那就是...你们猜,内容好坏大家一读即知,光就文字而言,那绝对是行云流水般的阅读体验,可以极大提升学习效率,也不再因为反复读也读不懂一句话而烦闷不堪。
-CS(Computer Science:计算机科学)课程中咱们会学习大量的常用数据结构和算法,因此大家都养成了一种好习惯:学习一门新语言,先用它写个链表或图试试。
-我的天,在 Rust 中千万别这么干,你是在扼杀自己之前的努力!因为不像其它语言,链表在 Rust 中简直是地狱一般的难度,我见过太多英雄好汉难过链表关,最终黯然退幕。我不希望正在阅读此文的你也成为其中一个 :(
-这些自引用类型(一种数据结构,它内部的某个字段又引用了其自身),它们堪称恶魔:不仅仅在蹂躏着新手,还在折磨着老手。有意思的是,它们的难恰恰是 Rust 的优点导致的:无 GC 也无手动内存管理还要做到内存安全。
-这些优点并不是凭空产生,而是来源于 Rust 那一套强大、优美的机制,这些机制一旦你学到,就会被它巧妙的构思和设计征服,进而被 Rust 深深吸引!但是一切选择都有利弊,这种机制的弊端就在于实现链表这类数据结构时,会变得非常非常复杂。
-你需要糅合各种知识,才能解决这个问题,但是这显然不是一个新手应该独自去面对的。总之,不会链表对于 Rust 的学习和写项目,真的没有任何影响,直接使用大神已经写好的数据结构就可以。
-如果想要练手,我们可以换个方向开始,例如书中的入门和进阶实战项目都是非常好的选择。当然如果你就是喜欢征服困难,那没问题,就从链表开始。但是无论选择哪个,本书都将给你莫大的帮助,包括如何实现一个链表!
-在一些编程语言中,你可能习惯了编译器给出的错误只看前面(或后面)几行,毕竟大部分是没啥大用的堆栈信息,在此过程中, how stupid the 编译器 is
的感想时不时会迸发出来。
但是 Rust 不是,它为我们提供了一个强大无比的编译器,而且会提示我们该如何修改代码以解决错误,简直就是一名优秀的老师!
-因此在使用 Rust 过程中,如果你不知该如何解决错误,不妨仔细阅读下编译器或者 IDE 给出的错误提示,绝大多数时候,都可以通过这些提示顺利的解决问题。
-同时也不要忽略编译器给出的警告信息(warnings),因为里面包含了 cargo clippy
给出的 lint
提示,这些提示不仅仅包含代码风格,甚至包含了一些隐藏很深的错误!至于这些错误为何不是 error
形式出现,随着学习的深入,你将逐渐理解 Rust 的各种设计选择,包括这个问题。
大多数其它编程语言适用的最佳实践在 Rust 中也可以很好的使用,但是 Rust 并不是一门专门的面向对象或者函数式语言,因此在使用自己喜欢的编程风格时,也要考虑遵循 Rust 应有的实践。
-例如纯面向对象或纯函数式编程,在 Rust 中就并不是一个很好的选择。如果你有过 Go 语言的编程经验,相信能更加理解我这里想表达的含义。
-不过大家也不用担心,在书中我们会以专题的形式专门讲解 Rust 的最佳实践,看完后自然就明白了。
-对于新手而言,最应该避免的就是从链表开始练手,最应该做的就是认真仔细地学习一本优秀而全面的书。
-总之,认真学 Rust,既然选择了,就相信自己,你的前方会是星辰大海!
- - -Rust 语言真的好:连续七年成为全世界最受欢迎的语言、没有 GC 也无需手动内存管理、性能比肩 C++/C 还能直接调用它们的代码、安全性极高 - 总有公司说使用 Rust 后以前的大部分 bug 都将自动消失、全世界最好的包管理工具 Cargo 等等。但...
-有人说: "Rust 太难了,学了也没用"。
-对于后面一句话我们持保留意见,如果以找工作为标准,那国内环境确实还不好,但如果你想成为更优秀的程序员或者是玩转开源,那 Rust 还真是不错的选择,具体原因见下一章。
-至于 Rust 难学,那正是本书要解决的问题,如果看完后,你觉得没有学会 Rust,可以找我们退款,哦抱歉,这是开源书,那就退 🌟 吧 :)
-如果看到这里,大家觉得这本书的介绍并没有吸引到你,不要立即放弃,强烈建议读一下进入 Rust 编程世界,那里会有不一样的精彩。
-对于学习编程而言,读一篇文章不如做几道练习题,此话虽然夸张,但是也不无道理。既然如此,即读书又做练习题,效果会不会更好?再加上练习题是书本的配套呢? :P
-截至目前,Rust 语言圣经已写了 170 余章,110 余万字,历经 1000 多个小时,每一个章节都是手动写就,没有任何机翻和质量上的妥协( 相信深入阅读过的读者都能体会到这一点 )。
-曾经有读者问过 "这么好的书为何要开源,而不是出版?",原因很简单:只有完全开源才能完美地呈现出我想要的教学效果。
-总之,Rust 要在国内真正发展起来,必须得有一些追逐梦想的人在做着不计付出的事情,而我希望自己能贡献一份微薄之力。
-但是要说完全无欲无求,那也是不可能的,看到项目多了一颗 🌟,那感觉...棒极了,因为它代表了读者的认可和称赞。
-你们用指尖绘制的星空,那里繁星点点,每一颗都在鼓励着怀揣着开源梦想的程序员披荆斩棘、不断前行,不夸张的说,没有你们,开源世界就没有星光,自然也就不会有今天的开源盛世。
-因此,我恳请大家,如果觉得书还可以,就在你的指尖星空绘制一颗新的 🌟,指引我们继续砥砺前行。这个人世间,因善意而美好。
-最后,能通过开源在茫茫人海中与大家相识,这感觉真好 :D
-非常感谢本教程的所有贡献者,正是有了你们,才有了现在的高质量 Rust 教程!
-🏆 贡献榜前三(根据难易度、贡献次数、活跃度综合评定):
-
-
- - Sunface 🥇 - - |
-
-
- - AllanDowney 🥈 - - |
-
-
- - Rustln - - |
-