Rust中的泛型套娃也是严重影响Rust代码阅读的罪魁祸首之一,大量的泛型套娃类型也带来了很多疑惑,让我们在编码的时候会产生不知所措的感觉,不知道该如何正确的选择这些类型去使用。本文的目标就是对常见的工具泛型类型和工具特征进行一个记录,方便在日常的选择使用。
本文是《Rust惑点启示笔记》系列文章中的第五篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。
本系列专题还有以下文章:
- Rust惑点启示系列(一):避免随意使用Clone
- Rust惑点启示系列(二):从函数中返回一些东西
- Rust惑点启示系列(三):引用的生命期从来就不够长
- Rust惑点启示系列(四):到处都是的大括号
- Rust惑点启示系列(五):工具类型太多了
- Rust惑点启示系列(六):如何下手编写一个函数
- Rust惑点启示系列(七):使用全局变量和单例
- Rust惑点启示系列(八):奇形怪状的Rust闭包
令人头疼的嵌套尖括号
Rust是个强类型的语言,虽然有自动类型推断,但是像函数参数和返回值、结构体字段这些类型还是不可省略的,一旦碰上复杂的类型,那一层层的尖括号绝对让人看的眼花缭乱,比如:Pin<Box<dyn Future<Output = Result<Option<T>, E>> + Send>>
。
看到上面这个例子是不是很仔细的数了数两边的尖括号数量是不是一致?其实这样的类型在实际使用中,都是会数一数的。
这种复杂的泛型使用在Rust中无法避免,但是却有一定的方法来让我们看的舒心一些。在了解如何应对这些套娃一样的泛型之前,应该先了解一下为什么会出现这么多的工具类型。
常见工具类型
其实工具类型在每一种语言都非常常见,而且它们也是哥哥语言标准库的组成。这种工具类型的特点是每一种工具类型只解决一个问题。这个特点就导致了如果我们所需的类型需要更多的功能,就只能把工具类型一层一层的组装起来。
不过Rust里的基础工具类型是要更多一些的,比如上面那个用来表示异步任务的类型描述在其他语言中可能就是一个:Future<T>
。归根结底还是语言做了多少事情的问题,Rust语言在这方面实际上并没有帮我们干什么活,一个内容的具体类型还是需要我们一个个的详细去定义。
那么在日常Rust代码里常见的工具类型都解决了什么问题呢?
解决内容放置的Box
Box
这个类型已经在很多文章中见的多了,也已经是一个老生常谈的类型了。这个含义为装箱的类型,主要功能就是将其包装的内容转移到堆上存储,然后再栈上提供一个指向堆内存的指针。Box
类型是Rust中智能指针的一种。
创建一个Box
实例通常使用以下两种方法:
Box::new
,可以将一个给定的值分配到堆上,通常接受一个具体的值。Box::from
,能够利用不同的类型构建Box
,通常用来将一个类型转换为Box
,而不仅仅是将一个值分配到堆上。
解决内容传递的Rc
和Arc
Rc
和Arc
也是经常在各种文章中见到的类型了,也是我们代码中最经常出现的工具类型之一。作为实现了引用计数的工具类,Rc
和Arc
在我们编码的过程中可以降低许多心智负担,放心大胆的使用引用。Rc
和Arc
也是Rust中智能指针的一员。
与Box
一样,Rc
和Arc
也提供了new
方法和from
方法,它们的使用区别也是将一个具体值分配到堆上,和完成类型转换功能。
Box::from
、Rc::from
和Arc::from
实际上都是依靠被处理实例类型实现的From
特征的。
Rc
和Arc
也会将其包装的内容放置在堆上,所以如果使用了Rc
和Arc
,那么就没有必要再使用Box
了。而且使用Rc<Box<T>>
这种嵌套的形式除了增加凝视层级数量以外,也是没有什么太大意义的。
特殊的Weak
Rc
和Arc
都是强引用类型,当创建一个Rc
的克隆(也即引用)时,其中的引用计数会增加。但是如果被Rc
包装的类型中使用Rc
引用了自己,或者引用了一个使用Rc
引用自己的Rc
对象,那么就会形成循环引用。这种循环引用是不可能把其中的引用计数降低到0从而使内存得到释放的。为了解决这种问题,Rust引入了弱引用类型Weak
。
Weak
可以在不增加引用计数的前提下引用对象。Weak
引用一般是通过Rc
等强引用类型的downgrade()
方法获得,那么根据弱引用的特点,Weak
是不能保证其引用的值一定存在的。因为当作为Weak
来源的Rc
被释放以后,Weak
引用就会变成无效引用。所以弱引用Weak
在使用的时候,需要使用upgrade()
方法转换成Option<Rc<T>>
来安全的使用。
Weak
必须通过upgrade()
升级以后才可以使用的原因就是Weak
不能保证其引用的数据一定有效,这也是Rust避免悬垂指针的手段。
解决内容有无的Option
Option
类型在Rust程序中也是非常常见的,因为它提供了对于可能不存在值的处理。不存在的值在很多语言中的处理中,往往作为空指针来对待,这也就经常会引发类似于NullPointerException
这样的空指针错误。通过Option
来显式的处理值可能为空的情况,就会使得程序健壮的多了。Option
是一个功能类型,它并不决定其中的内容放置在什么位置上。
Option
类型是一个枚举类型,其中有两个变体:Some(T)
和None
。从它们的字面意思就可以看出来它们的功能。Option
在使用的时候,可以通过它提供的一系列方法来进行链式调用,方便对于其中包装内容的处理。
其中常见的操作方法有:
is_some()
和is_none()
,主要用于判断当前Option
内部的值是否存在。unwrap()
和unwrap_or()
,用于从Option
中取出值,unwrap_or
可以在Option
中没有值的时候使用默认值代替,而不是抛出panic。map()
用来对Option
内部的值进行一个转换操作,如果内部不存在值(为None
时),则不会做任何处理。map
同样会返回一个Option
类型的值。and_then()
接受一个返回Option<U>
的闭包,效果跟map
类似,但是可以实现的功能更加复杂。or_else()
允许在内部值为None
的情况下,返回一个默认的Option<T>
值。flatten()
可以用来将Option<Option<T>>
这种嵌套的Option
类型展平成Option<T>
类型。ok_or()
可以将Option<T>
类型转换为Result<T, E>
类型,如果Option
中内容为None
时,就会返回ok_or
的默认参数作为异常值。相似的方法ok_or_else()
可以接受一个闭包来生成异常值。transpose()
可以将Option<Result<T, E>>
转换成Result<Option<T>, E>
。
解决可能存在异常的Result
Result
和Option
一样,都是在Rust代码中非常常见的类型,而且Result
在函数返回值中有着更加广泛的使用。不同于C++、Java这些语言中的try/catch
语法结构,把一个异常作为语言中的一种常规值来对待,实际上可以使得程序更加健壮,因为编码者会被强制去处理可能会发生的事情。
Result
,或者你非常确定函数返回的Result
一定不会存在错误,也请不要掉以轻心,凡事没有绝对。
因为Result
类型的特点,很多Module和库中都提供了自己的Result
类型,但是它们基本上都是从标准库std::result::Result
定义来的,所以其使用方法基本上没有区别。
Result
也是一个枚举类型,变体组成形式跟Option
十分相似,所以在Option
中可以使用的方法,大多在Result
中也可以使用,仅有部分方法会有些区别。
expect()
和unwrap()
方法,这两个方法在Result
包含错误的时候会抛出panic,从而导致程序终止执行。正确的错误处理方法是使用map_err
、or_else
、unwrap_or
等方法来对可能存在的错误进行处理。
解决不可变引用的修改问题的Cell
和RefCell
刚开始学习Rust的时候,我们接触的到一个概念就是不可变性。相信大部分已经熟悉和习惯在其他语言中自由的给变量赋值的人,都会很不适应。这个不可变性的概念就是在默认情况下,所有的引用都是不可变引用(&T
),是无法修改其指向的数据的,如果要修改指向的数据,就只能显式使用可变引用(&mut T
)。但是可变引用的获取和使用都是由很大限制的。Cell
和RefCell
提供的内不可变性就提供了在获得了不可变引用的情况下,修改引用指向数据的方法。
Cell
类型用于在单线程环境中的使用,其中只能承载实现了Copy
特征的类型。所以Cell
提供的功能也是最简单的,它可以使用复制的方式获取和修改数据。对其中内容的操作也主要通过set
、get
和replace
三个方法来完成。
RefCell
相对就要复杂的多了,它允许在运行时借用其内部的数据,并使用借用检查机制来确保不违反Rust的可变性规则。RefCell
提供的运行时借用检查可以绕过编译时的借用规则检查,所以使用了RefCell
的代码在编译时一般不会报出违反借用规则的错误。从RefCell
的名字可以看出来,与Cell
不同,RefCell
以引用为主,所以其中可以保存任意类型的数据。RefCell
的使用就相对要简单多了,只有borrow()
和borrow_mut()
两个方法,其中一个返回Ref<T>
类型的不可变引用,一个返回RefMut<T>
类型的可变引用。
解决内存地址固定问题的Pin
在Rust中,值是可以随意移动的,而且绝大部分情况下值在内存中的移动是不受开发人员控制的。Pin
就是一个与内存地址固定相关的类型,可以用来辅助处理不允许被移动的值。
Pin
的最主要用途就是处理自引用类型和防止异步任务中因为数据移动造成的潜在错误问题。Pin
的实际工作需要依赖于Rust中的一个相关特征Unpin
。Unpin
特征用来标识一个可以被安全移动的类,大部分的简单类型都已经由Rust默认实现了Unpin
,只有复杂类型需要由开发者决定是否为其实现Unpin
特征。如果一个类型实现了Unpin
特征,那么即便将其放在Pin
中,它也依旧可以安全的被移动,反之则一定不能被移动。
Pin
的存在实际上还主要是为了解决自引用的问题。
以下是一个自引用结构体的示例。
|
|
*const T
是一个指向类型T
的裸指针,不是一个引用。*const T
不保证安全性,所以使用的时候需要unsafe
。*const T
是一个不可变指针,特性类似于&T
,如果需要使用可变裸指针,则要使用*mut T
。*const T
和*mut T
主要在比较低级的场景中使用,比如与C语言进行交互或者绕过Rust安全性检查。
Pin
使用在保存在栈上的数据没有任何意义,所以一般都是搭配Box
和Rc
来固定堆上的内容,而且可以通过Box::pin
和Rc::pin
快速的创建一个Pin<Box<T>>
和Pin<Rc<T>>
类型的实例。
解决泛型类型标记问题的PhantomData
PhantomData
是一个零尺寸类型,是Rust在标准库中提供用来产生类型标记的。PhantomData
产生的类型标记主要用来确保类型参与了泛型生命期推断和类型推断,在实际数据存储和使用中,并没有太多的影响。
例如在结构体中使用裸指针的时候,就可以使用PhantomData
来辅助确定泛型参数的生命期,例如:
|
|
解决反复Clone的Cow
Cow
主要提供的是写时复制的功能,这个功能在实际使用中延迟了clone()
的使用,只有在被包装的引用需要进行修改操作的时候,才会发生clone()
操作。所以在实际编码过程中,如果不确定引用资源的使用和控制,那么就可以使用Cow
来辅助管理。
Cow
也是一个枚举类型,其中的两个变体Borrowed
和Owned
分别代表引用和拥有所有权的两种状态。Cow
可以使用into_owned()
和to_mut()
来将其中原本使用Borrowed
持有的引用转换为拥有所有权的状态。
Cow::Owned
状态是不能变回Cow::Borrowed
状态的,不过可以通过获取一个引用并初始化一个新的Cow::Borrowed
实例来转换回去。
解决多线程共享的Mutex
和RwLock
多线程条件下的资源共享一直是多线程编程里老生常谈的问题。Mutex
和Rwlock
就是针对常见的资源共享控制结构实现的,其使用跟其他语言中的互斥锁和读写锁是一样的。
Mutex
在上锁以后,会返回一个MutexGuard<'_, T>
类型的实例,用来代表已经获得的锁。这个类型已经实现了Deref
特征,所以可以直接访问其中T
类型的所有方法。当MutexGuard
实例被释放,即表示锁已经释放。这是Rust中的锁操作相较其他语言中更加便利的一点。RwLock
也是一样,只不过支持分别获取只读锁和可写锁。
std::sync
中其实还有很多其他的工具类可以在多线程编程中用来实现更多的资源控制和线程控制功能,这里不再一一列举。如果使用的是tokio这一类异步库,其中所提供的工具类基本上也跟标准库大同小异,只是实现上会有一些区别。
常见的工具特征
还有一些常见的功能实际上是特征,并不是现成的结构体。但是根据Rust的特点,实现了这些特征的结构体在一些操作中将会默认的实现和应用一些快捷的操作。这其实也是Rust代码会出现难以读懂的原因之一。
解决类型转换的From
和Into
From
和Into
是两个紧密相关的特征,其中From
主要用于实现从一种类型转换成另一种类型的逻辑,而Into
则表示允许当前类型转换成另一种类型的逻辑。Rust自动为每一个实现了From<T>
的类型实现了Into<U>
,所以只要一个类型实现了From
特征,就可以使用Into::into
来进行逆向的转换。
Into
,那么Rust是不会自动实现From
的。
如果一个函数声明其参数的泛型类型为T: Into<P>
,那么任何可以通过Into
特征转换为P
类型的实例都是可以传递给函数的,函数在调用前会自动的进行类型转换。此外在一些构造函数和方法中,From
和Into
也是会自动调用的;还有使用?
处理错误时等等。这种隐式调用From
和Into
的情形其实很多,而且在实际使用中非常常见,在遇到的时候需要留意。
相似的特征还有两个:TryFrom
和TryInto
,与From
和Into
不同,带有Try
前缀的特征主要用于可能失败的类型转换,所以不同于需要始终保证转换成功的From
和Into
,TryFrom
和TryInto
在实现的时候需要额外提供一个Error
伴随泛型来声明在转换操作失败后会返回的错误,也就是说TryFrom
和TryInto
会始终返回一个Result<T, Self::Error>
类型的值。
与From
和Into
一样,如果为一个类型T
实现了TryFrom<U>
,那么Rust也会相应的为类型U
实现TryInto<T>
。
map
方法中使用了TryFrom::try_from
或者TryInto::try_into
的方法,而获得了嵌套的类型,那么可以使用and_then
、transpose
来处理和转换Result
类型或者使用?
将Result
类型中的错误抛到链式操作之外。
提供深拷贝能力的Clone
Clone
特征带来的深拷贝能力在本系列文章的第一篇中就已经讨论过了。Clone
特征一般不会手动去实现,而是使用过程宏#[derive(Clone)]
来让Rust自动实现。
提供引用转换能力的AsRef
和AsMut
AsRef
和AsMut
从字面上来看是进行引用提取的。实际上它们的功能也的确是获得被包装类型的引用。但是它们所提供的功能远远不止获得引用那么简单。AsRef
和AsMut
可以在获取引用的时候,对数据类型做一个转换,甚至可以将当前的类型转换成另一个类型的引用,比如文件操作中常见的AsRef<Path>
,可以用来在函数中声明接受任何可以转换成&Path
的参数。相比AsRef
用来提取不可变引用,AsMut
是用来提取可变引用的。就像前面举的示例一样,AsRef
和AsMut
在实际使用中,往往是用在泛型约束上,用来设计更加通用的接口。
AsRef
的类型转换和Into
的类型转换的不同在于,AsRef
是转换为引用,一般没什么开销,而Into
则是转换为值,往往会涉及所有权的转移。
提供引用获取能力的Borrow
和BorrowMut
Borrow
和BorrowMut
提供了一种抽象引用的机制,允许类型在一些场景下表现的像是其他类型的引用。Borrow
和BorrowMut
通常用在容器和数据结构里,以便在使用引用的时候不影响所有权模型。
在实际代码中的使用,Borrow
和AsRef
都是以获取引用的形式出现的,而且它们也都可以实现类型的转换。它们之间的不同主要是用途。Borrow
更加强调所借用的类型与原始类型之间的等价关系,而AsRef
更强调引用的获取。这两者之间的具体区别需要在实际编码过程中仔细体会。
提供解引用能力的Deref
Deref
在日常编码中使用的比较少,但这并不代表它就不重要。Deref
定义的是当执行解引用*
操作的时候会发生的行为。任何实现了Deref
特征的类型都可以在使用的时候表现的与引用类型一样。例如Box
、Rc
等就实现了Deref
特征,这使得它们可以“透明”的访问和调用其内部内容。
针对Deref
,Rust还提供了一个非常强大的功能,称为“Deref
强制转换”(或者称为自动引用与解引用),能够自动将某个类型的引用通过Deref
转换为另一个类型的引用。这种转换经常出现在函数参数中,例如一个函数需要&str
类型的参数,你可以传一个&String
类型的实参进去,Rust会自动通过Deref
特征将&String
转换为&str
。同时,多层的Deref
也会自动完全解引用,例如&Rc<Box<String>>
也是可以直接自动转换为&str
的。
利用Deref
和Deref
强制转换,还可以实现对于类型T
,如果其实现了Deref<Target = U>
,那么在任何需要&U
的地方,都可以提供&T
,并自动的转换成&U
。Rust提供的这种特征在实际编码和阅读代码的时候需要仔细注意,这往往会影响我们对于代码中实际使用类型的判断。
提供迭代能力的Iterator
和IntoIterator
Iterator
定义了迭代器的行为,负责逐步产生序列中的每一个值。常常用来定义如何迭代一个集合,或者将一个类型看做集合来操作。要给一个类型实现Iterator
特征只需要实现其中规定的next
方法即可,当next
方法返回None
的时候,即代表迭代的结束。
IntoInterator
则是定义了一个类型如何转换为一个迭代器。任何实现了IntoInterator
特征的类型都可以使用在for
循环中进行直接遍历而无需提前获得迭代器。在给一个类型实现IntoIterator
特征的时候,需要注意Intoiterator
一般会实现两次,一次是消耗所有权的迭代,例如Vec<T>
,一次是借用迭代,例如&Vec<T>
。
Iterator
特征变为迭代器,只要它可以逐步生成一个序列。
负责生成默认值的Default
Default
特征用在为类型提供一个默认实例的场景中,比如在需要一个空白实例或者具备初始值的实例。如果结构体或者枚举中的所有字段和变体都实现了Default
特征,那么就可以使用#[derive(Default)]
来给结构体和枚举自动生成Default
实现。
Default
特征的实现可以被视为一个无参构造函数,用来与new()
方法结合进行实例的初始化。
支持异步的Future
其实在Rust早期的版本里,对异步的支持是很差的,Future
这个特征甚至不是由标准库提供的。在tokio等异步库有了长足进步以后,Future
也终于进入到了标准库的支持。Future
表示的是一个异步计算过程,而这个过程还尚未执行结束,所以我们也可以认为Future
代表的是一个尚未执行完毕的异步任务。
Future
特征在使用的时候需要声明其伴随泛型Output
的值,这个伴随泛型代表的是异步任务执行完毕以后将会返回的结果。所以常见的异步任务的类型往往是dyn Future<Output = T>
。但是在有异步运行时支持,可以使用async
来定义异步函数的时候,函数的返回值不必写完整的异步任务类型,只需要书写函数的返回值即可,也就是Future
的伴随泛型Output
的内容。所以以下两个函数的定义是同义的。
|
|
async
关键字以后,可以不必书写复杂的异步任务类型了,但是还是建议熟悉这种写法,因为根据对象安全性,自定义特征中是不能使用async
关键字的,所以如果在特征中声明异步函数,还是需要写这一长串的类型定义的。
用于多线程和异步的Send
和Sync
Send
和Sync
是两个标记类型,一个类型在实现它们的时候不需要实现什么方法,只需要使用unsafe impl Send for T {}
和unsafe impl Sync for T {}
即可。
Send
特征表示这个类型的值可以安全的在线程之间传递,也就是可以从一个线程移动到另一个线程,这种移动往往是涉及到所有权的移动的。在Rust里,几乎所有的基本类型和标准库类型都是Send
的。如果一个结构体的所有字段都支持Send
,那么这个结构体也同样是Send
的。
Sync
特征表示这个类型可以安全的在多个线程之间共享引用,而不会出现数据竞争。在Rust里,大多数类型都默认是Sync
的,也就是&T
是可以在线程之间安全使用的。但是支持Sync
的类型,并一定是支持Send
的。
T
来说,如果T
是Sync
的,那么&T
就是Send
的。也就是说一个类型如果是Sync
的,那么他的共享引用就可以安全的传递到其他线程中。
Rust会对许多类型自动的实现Send
和Sync
,但是如果类型中包含了非线程安全的类型,那么Rust就会禁止为这些类型实现Send
或Sync
。例如类型中包含了Rc
,那么整个类型都不会实现Send
,因为Rc
是单线程的;而如果类型中包含了Cell
或者RefCell
,那么整个类型都不会实现Sync
,因为通过它们获取到的引用无法保证不会发生数据竞争。
如何使用工具类型
上面说了这么多的工具类型,那么这些工具类型要如何使用呢?前面已经提到过了,这些工具类型一般都是一个类型只完成一个功能的,所以我们在选择使用的时候只需要分析我们所需要构建的实例都需要哪些特征和功能。
按照这个原则来选择所需要的工具类,剩下的一个问题就是这些被选出的工具类要如何组合,哪些在内部,哪些在外部。这个问题一般没有什么固定的规律,只是需要注意的是,决定内容保存和使用方式的工具类一般应该放置在最外部。
依旧以上面这个复杂的表示异步任务的类型来说明。
- 异步任务首先可能会返回一个可能为空的值,所以核心类型应该是
Option<T>
。 - 异步任务有可能会出错,所以异步任务的返回值应该支持返回一个错误,从异步任务中返回的类型应该是
Result<Option<T>, Error>
。 - 由于这是一个异步任务,所以需要使用
Future
包装,但在编译时是不可能确定Future
的真正类型,所以需要使用动态分发,异步任务类型应该为dyn Future<Output = Result<Option<T>, Error>>
。 - 异步任务是需要跨越线程的,所以还需要
Send
特征的支持,异步任务类型继续扩展为dyn Future<Output = Result<Option<T>, Error>> + Send
。 - 动态分发的实例不能保存在栈上,所以还必须将其放置在堆上,类型继续扩展为
Box<dyn Future<Output = Result<Option<T>, Error>> + Send>
。 - 前面介绍
Pin
工具类型的时候提到了,用于异步和多线程任务的内容,在内存中应该是不可移动的,所以还需要将其固定在堆内存中,所以异步任务的最终类型形态就是Pin<Box<dyn Future<Output = Result<Option<T>, Error>> + Send>>
。
嵌套工具类型以后的拆包
嵌套了大量工具类型以后的类型在构建的时候痛苦,在使用的时候同样痛苦。不过好在Rust提供了不少可以用来简化类型使用的方法。
在遇到一个工具类型的时候,可以去看一下文档,一般情况下这个工具类型会实现Deref
或者AsRef
特征,这就允许我们通过获取这个工具类的引用来使用其内部的内容。如果连续的基层工具类都实现了Deref
,那么我们还可以无视这些工具类,直接使用它们所包装的核心内容。
尖括号太多了,优化一下吧
看到上面这个用来表示异步任务的完整类型写法,你可能已经感觉到了尖括号的恐怖。不过我们还可以庆幸一点,这个又来表示异步任务的类实际上有很多结构是固定的,可变的地方并不多。所以我们可以利用Rust提供的类型别名来精简一下它。
例如:
|
|
这下好了,之前复杂的类型在我们的代码里就可以直接使用AsyncOptionResult<T, E>
这个别名来使用了,看起来就清爽多了。
另外从类型的别名可以看出来,其实很多库中的看起来比较陌生的类型,实际上可能是一个类型别名。所以要对文档中的Type Aliases
内容多加留意。