编写一个函数是Rust中再普通不过的事情了,但是Rust里的函数在编写的时候确始终感觉不像是其他语言中那么丝滑,有的时候甚至会有一些手足无措的感觉,不知道该如何下手。本文尝试从大部分函数常见的处理流程出发,对编写一个Rust函数的过程和可能遇到的问题做一个简单的讨论。
本文是《Rust惑点启示笔记》系列文章中的第六篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。
本系列专题还有以下文章:
- Rust惑点启示系列(一):避免随意使用Clone
- Rust惑点启示系列(二):从函数中返回一些东西
- Rust惑点启示系列(三):引用的生命期从来就不够长
- Rust惑点启示系列(四):到处都是的大括号
- Rust惑点启示系列(五):工具类型太多了
- Rust惑点启示系列(六):如何下手编写一个函数
- Rust惑点启示系列(七):使用全局变量和单例
- Rust惑点启示系列(八):奇形怪状的Rust闭包
准备函数需要的资源
函数的核心功能就是降将一些输入的参数,经过计算和处理,转换成输出的内容。所以准备编写一个函数的时候,首先要计划和准备的是这个函数在完成它功能时所需要的资源。
这第一步其实并不复杂,函数的输入参数是已经确定的,所以剩下的就是要确定函数执行还需要哪些资源。一般来说函数不应该使用程序里的所有资源,但这个资源使用的划分界限在哪?一个比较好的原则就是采用最小权限原则。也就是说,尽可能的让函数拥有更少的所有权,也要使用最少的资源使用权限。
接下来就是要区分一下哪些资源是一次性资源,哪些是需要重复使用的资源。这决定了这个函数在运行的时候需要使用多少内存。内存也是资源,也要尽可能的要利用引用来减少内存的使用。
对于会运行在多个线程中的函数,还需要注意所需共享资源的锁定。一般建议在函数开始运行的时候就要先尝试锁定这些资源,否则如果在函数内部仅在需要的时候才锁定资源,在多线程的资源抢夺下,容易产生不易察觉的死锁。另外在多线程编程的状态下就是要计划好共享资源是否需要读写分开,以及是否需要更高级的共享控制。
当所有权被移来移去
在一个函数中,资源的所有权被移来移去是最正常不过的事情了。但是这里要说的并不是这种正常的所有权转移。而是如何来减少所有权的转移。其实在操作大型结构体的情况下,转移所有权可能并不是一个很好的选择,因为伴随所有权转移的,可能还有内存复制。对小型数据结构来说,内存复制是没有什么代价的,但对大型数据结构或者复杂数据结构就不一样了。
一个函数一般都是运行在一个线程里的,所以对于这些大型结构体和复杂结构体,甚至是需要经常进行clone
的数据,使用Rc
进行一个包装是一个不错的选择。
如果不想在处理中影响函数中原有变量的生命期,或者不想进行所有权的转移,那么可以新建一个语句块,在这个语句块中利用变量遮蔽来避免操作函数中原有内容。这里依靠的Rust特性是一个语句块会以引用的方式捕获其所所在作用域的变量,而作用域不同的同名变量之间,最内层的变量会遮蔽对外层同名变量的访问。
Rc<T>
转换为Arc<T>
,是没有一个直接转换的方法的,只能通过Arc::new((*rc).clone())
来将Rc<T>
的底层数据重新包装来形成Arc<T>
的实例。反之亦然。
if
嵌套了太多层
Rust里的if
提供了if let
按条件赋值的功能。如果大量的使用这个功能来处理Option
或者Result
之类的判断,可能会使得if
越套层数越多。
其实要解决这个问题并不是很复杂,只是需要改变一下从其他语言中带来的习惯。
用match
来代替复杂的if
嵌套
比如我们经常会碰到的对Result<Opiton<T>, E>
进行拆分判断的情况。如果使用if
的话,可能就需要需要两层if
。例如:
|
|
这个嵌套的if
使用match
来代替就省事多了。
|
|
if
和match
是可以返回值的
还有一些情况下嵌套if
是为了使用if let
取得的内容,因为这些内容只在获取它的if
区块里起效。所以为了能够一直使用这些内容,就不得不一层一层的套下去。
遇到这种情况的时候,我们其实也可以换一个思路,在if
里返回一个后续处理所需要的值。同样的match
也是可以这样返回值的,不过需要注意的是,如果在if
和match
的各个分支里返回值的话,它们的类型需要一致,这在语句设计的时候要仔细考量一下。
使用的函数返回太多类型的错误
这里所说的函数是我们正在编写的函数里所使用的其他函数。我们所编写的函数在允许的时候能够产生哪些异常在大多数情况下是可以预见的,但我们不能预见的是我们所使用的其他函数会抛出什么样的错误。这些其他函数中抛出的错误一般都各自成一个体系,聚集在一个函数中就编程了一个大杂烩。
要解决这个异常类型过多的问题,可以有两种方式。
- 依靠
thiserror
库定义一套自己的的Error枚举,使用其中#[from]
过程宏来包装第三方的错误。在函数中统一为自己编写的Error枚举。 - 使用
map_err
搭配anyhow
库中的anyhow!
宏来捕获并抛出一个动态的Error。但是这要求函数返回anyhow::Result
类型。
要不要处理异常
在一个函数中使用?
来处理Result
类型简直不要太爽。但是在使用?
的时候,有没有想过被?
抛出的Error都去哪里了?
在函数中抛出的Error自然是去到了函数的调用方那里,这也是为什么使用了?
以后,当前函数的返回值一定是Result
类型。如果在每一个函数中都使用?
吧错误抛向上面一层,那么这个错误最终会抵达程序的主函数main()
。如果main()
里再使用?
抛出错误,那整个程序就要终止运行了。
所以?
的使用并不是处理异常,只是推迟了异常的处理而已。
有一句话叫“出来混的总是要还的”,换到这里可以改成“扔出来的异常,总归还是要解决的”。那么这些在函数中产生的异常,到底要不要处理,要怎么处理呢?
其实这个问题并不用纠结,可以这样来界定一下:看看函数本身有没有对这个异常做主的权力。如果函数可以做主处理掉这个异常,那就不要把这个异常抛给上一级了。
在同步函数中调用异步函数
Rust在最开始设计是只有同步功能的,异步功能是后来逐渐引入的。所以直接定义async fn
实际上是需要异步运行时支持的,直接定义是不太能直接使用的。
在同步函数中可以初始化一个异步运行时再搭配block_on
来执行一个异步函数并取得异步函数的返回值。
|
|
在这个示例中,main()
没有使用#[tokio::main]
来创建一个异步运行时,所以它还是一个同步函数,在其中调用异步函数async_function
就需要运行在一个即时初始化的异步运行时里了。block_on
接受一个异步任务作为参数,而不是一个闭包或者函数,所以要注意传入的内容。
tokio::runtime::Runtime::new()
一般是不会出错的,只有在系统资源不足或者系统限制多线程和异步的情况下,才有可能会报出错误,所以获取异步运行时的表达式Runtime::new().unwrap()
可以放心使用。
函数需要返回什么值
函数返回什么值其实在刚开始设计函数的时候就已经确定了,但是如何返回这个值,可能会随着函数设计的深入发生一些调整。
函数返回值类型的调整一般可以按照以下顺序来考虑。
- 函数是否有可能出错或者抛出错误?
- 如果函数会产生或者抛出错误,那么一定需要使用
Result
了。 - 如果函数可以自行消化所有错误,那么应该可以直接返回一个确定类型的值,或者用
Option
返回可空值。
- 如果函数会产生或者抛出错误,那么一定需要使用
- 函数里抛出的错误是否是各式各样的?
- 如果是的话,那可能需要借助
anyhow
库或者自定义一套Error枚举了。
- 如果是的话,那可能需要借助
- 函数是否需要返回输入的参数?
- 函数如果需要返回修改以后的参数,那么就需要尝试获取这个参数的唯一可变引用,甚至可以考虑要求传入一个引用作为参数,然后再返回这个引用。
- 没有特殊要求时,可以返回参数clone以后的新实例。
- 函数是否需要返回再函数内部生成的值?
- 需要输出函数内部生成的值,就需要提升这个值的生命期,一般可以选择使用
Box
或者Rc
包装。
- 需要输出函数内部生成的值,就需要提升这个值的生命期,一般可以选择使用