Rust惑点启示系列(五):工具类型太多了

发布时间:2024-10-14 22:33
最后更新:2024-10-15 23:20
所属分类:
Rust

Rust中的泛型套娃也是严重影响Rust代码阅读的罪魁祸首之一,大量的泛型套娃类型也带来了很多疑惑,让我们在编码的时候会产生不知所措的感觉,不知道该如何正确的选择这些类型去使用。本文的目标就是对常见的工具泛型类型和工具特征进行一个记录,方便在日常的选择使用。

本文是《Rust惑点启示笔记》系列文章中的第五篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。

本系列专题还有以下文章:

  1. Rust惑点启示系列(一):避免随意使用Clone
  2. Rust惑点启示系列(二):从函数中返回一些东西
  3. Rust惑点启示系列(三):引用的生命期从来就不够长
  4. Rust惑点启示系列(四):到处都是的大括号
  5. Rust惑点启示系列(五):工具类型太多了
  6. Rust惑点启示系列(六):如何下手编写一个函数
  7. Rust惑点启示系列(七):使用全局变量和单例
  8. Rust惑点启示系列(八):奇形怪状的Rust闭包

令人头疼的嵌套尖括号

Rust是个强类型的语言,虽然有自动类型推断,但是像函数参数和返回值、结构体字段这些类型还是不可省略的,一旦碰上复杂的类型,那一层层的尖括号绝对让人看的眼花缭乱,比如:Pin<Box<dyn Future<Output = Result<Option<T>, E>> + Send>>

看到上面这个例子是不是很仔细的数了数两边的尖括号数量是不是一致?其实这样的类型在实际使用中,都是会数一数的。

这种复杂的泛型使用在Rust中无法避免,但是却有一定的方法来让我们看的舒心一些。在了解如何应对这些套娃一样的泛型之前,应该先了解一下为什么会出现这么多的工具类型。

常见工具类型

其实工具类型在每一种语言都非常常见,而且它们也是哥哥语言标准库的组成。这种工具类型的特点是每一种工具类型只解决一个问题。这个特点就导致了如果我们所需的类型需要更多的功能,就只能把工具类型一层一层的组装起来。

不过Rust里的基础工具类型是要更多一些的,比如上面那个用来表示异步任务的类型描述在其他语言中可能就是一个:Future<T>。归根结底还是语言做了多少事情的问题,Rust语言在这方面实际上并没有帮我们干什么活,一个内容的具体类型还是需要我们一个个的详细去定义。

那么在日常Rust代码里常见的工具类型都解决了什么问题呢?

解决内容放置的Box

Box这个类型已经在很多文章中见的多了,也已经是一个老生常谈的类型了。这个含义为装箱的类型,主要功能就是将其包装的内容转移到堆上存储,然后再栈上提供一个指向堆内存的指针。Box类型是Rust中智能指针的一种。

创建一个Box实例通常使用以下两种方法:

  1. Box::new,可以将一个给定的值分配到堆上,通常接受一个具体的值。
  2. Box::from,能够利用不同的类型构建Box,通常用来将一个类型转换为Box,而不仅仅是将一个值分配到堆上。

解决内容传递的RcArc

RcArc也是经常在各种文章中见到的类型了,也是我们代码中最经常出现的工具类型之一。作为实现了引用计数的工具类,RcArc在我们编码的过程中可以降低许多心智负担,放心大胆的使用引用。RcArc也是Rust中智能指针的一员。

Box一样,RcArc也提供了new方法和from方法,它们的使用区别也是将一个具体值分配到堆上,和完成类型转换功能。

Box::fromRc::fromArc::from实际上都是依靠被处理实例类型实现的From特征的。

RcArc也会将其包装的内容放置在堆上,所以如果使用了RcArc,那么就没有必要再使用Box了。而且使用Rc<Box<T>>这种嵌套的形式除了增加凝视层级数量以外,也是没有什么太大意义的。

特殊的Weak

RcArc都是强引用类型,当创建一个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

ResultOption一样,都是在Rust代码中非常常见的类型,而且Result在函数返回值中有着更加广泛的使用。不同于C++、Java这些语言中的try/catch语法结构,把一个异常作为语言中的一种常规值来对待,实际上可以使得程序更加健壮,因为编码者会被强制去处理可能会发生的事情。

纵使你很烦Result,或者你非常确定函数返回的Result一定不会存在错误,也请不要掉以轻心,凡事没有绝对。

因为Result类型的特点,很多Module和库中都提供了自己的Result类型,但是它们基本上都是从标准库std::result::Result定义来的,所以其使用方法基本上没有区别。

Result也是一个枚举类型,变体组成形式跟Option十分相似,所以在Option中可以使用的方法,大多在Result中也可以使用,仅有部分方法会有些区别。

注意要慎用很多示例中常用的expect()unwrap()方法,这两个方法在Result包含错误的时候会抛出panic,从而导致程序终止执行。正确的错误处理方法是使用map_error_elseunwrap_or等方法来对可能存在的错误进行处理。

解决不可变引用的修改问题的CellRefCell

刚开始学习Rust的时候,我们接触的到一个概念就是不可变性。相信大部分已经熟悉和习惯在其他语言中自由的给变量赋值的人,都会很不适应。这个不可变性的概念就是在默认情况下,所有的引用都是不可变引用(&T),是无法修改其指向的数据的,如果要修改指向的数据,就只能显式使用可变引用(&mut T)。但是可变引用的获取和使用都是由很大限制的。CellRefCell提供的内不可变性就提供了在获得了不可变引用的情况下,修改引用指向数据的方法。

Cell类型用于在单线程环境中的使用,其中只能承载实现了Copy特征的类型。所以Cell提供的功能也是最简单的,它可以使用复制的方式获取和修改数据。对其中内容的操作也主要通过setgetreplace三个方法来完成。

RefCell相对就要复杂的多了,它允许在运行时借用其内部的数据,并使用借用检查机制来确保不违反Rust的可变性规则。RefCell提供的运行时借用检查可以绕过编译时的借用规则检查,所以使用了RefCell的代码在编译时一般不会报出违反借用规则的错误。从RefCell的名字可以看出来,与Cell不同,RefCell以引用为主,所以其中可以保存任意类型的数据。RefCell的使用就相对要简单多了,只有borrow()borrow_mut()两个方法,其中一个返回Ref<T>类型的不可变引用,一个返回RefMut<T>类型的可变引用。

解决内存地址固定问题的Pin

在Rust中,值是可以随意移动的,而且绝大部分情况下值在内存中的移动是不受开发人员控制的。Pin就是一个与内存地址固定相关的类型,可以用来辅助处理不允许被移动的值。

Pin的最主要用途就是处理自引用类型和防止异步任务中因为数据移动造成的潜在错误问题。Pin的实际工作需要依赖于Rust中的一个相关特征UnpinUnpin特征用来标识一个可以被安全移动的类,大部分的简单类型都已经由Rust默认实现了Unpin,只有复杂类型需要由开发者决定是否为其实现Unpin特征。如果一个类型实现了Unpin特征,那么即便将其放在Pin中,它也依旧可以安全的被移动,反之则一定不能被移动。

其实异步任务对于不可移动数据的要求也是来自于自引用的情况。所以整体总结起来,Pin的存在实际上还主要是为了解决自引用的问题。

以下是一个自引用结构体的示例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct CustomGenerator {
  value: String,
  reference: Option<*const String>, // 这个引用将指向value,是不能移动的。
}

impl CustomGenerator {
  fn pin_self(self: Pin<&mut self>) {
    let self_ptr = &self.value as *const String;
    unsafe {
      // 这里将引用指向自身。
      self.get_unchecked_mut().reference = Some(self_ptr)
    };
  }
}
*const T是一个指向类型T的裸指针,不是一个引用。*const T不保证安全性,所以使用的时候需要unsafe*const T是一个不可变指针,特性类似于&T,如果需要使用可变裸指针,则要使用*mut T*const T*mut T主要在比较低级的场景中使用,比如与C语言进行交互或者绕过Rust安全性检查。

Pin使用在保存在栈上的数据没有任何意义,所以一般都是搭配BoxRc来固定堆上的内容,而且可以通过Box::pinRc::pin快速的创建一个Pin<Box<T>>Pin<Rc<T>>类型的实例。

解决泛型类型标记问题的PhantomData

PhantomData是一个零尺寸类型,是Rust在标准库中提供用来产生类型标记的。PhantomData产生的类型标记主要用来确保类型参与了泛型生命期推断和类型推断,在实际数据存储和使用中,并没有太多的影响。

例如在结构体中使用裸指针的时候,就可以使用PhantomData来辅助确定泛型参数的生命期,例如:

1
2
3
4
struct CustomStruct<'a, T> {
  data: *const T, // Rust不会跟踪裸指针的生命期
  marker: PhantomData<&'a T>, // 此时需要利用PhantomData来将结构体与T关联起来
}

解决反复Clone的Cow

Cow主要提供的是写时复制的功能,这个功能在实际使用中延迟了clone()的使用,只有在被包装的引用需要进行修改操作的时候,才会发生clone()操作。所以在实际编码过程中,如果不确定引用资源的使用和控制,那么就可以使用Cow来辅助管理。

Cow也是一个枚举类型,其中的两个变体BorrowedOwned分别代表引用和拥有所有权的两种状态。Cow可以使用into_owned()to_mut()来将其中原本使用Borrowed持有的引用转换为拥有所有权的状态。

Cow::Owned状态是不能变回Cow::Borrowed状态的,不过可以通过获取一个引用并初始化一个新的Cow::Borrowed实例来转换回去。

解决多线程共享的MutexRwLock

多线程条件下的资源共享一直是多线程编程里老生常谈的问题。MutexRwlock就是针对常见的资源共享控制结构实现的,其使用跟其他语言中的互斥锁和读写锁是一样的。

Mutex在上锁以后,会返回一个MutexGuard<'_, T>类型的实例,用来代表已经获得的锁。这个类型已经实现了Deref特征,所以可以直接访问其中T类型的所有方法。当MutexGuard实例被释放,即表示锁已经释放。这是Rust中的锁操作相较其他语言中更加便利的一点。RwLock也是一样,只不过支持分别获取只读锁和可写锁。

在标准库的std::sync中其实还有很多其他的工具类可以在多线程编程中用来实现更多的资源控制和线程控制功能,这里不再一一列举。如果使用的是tokio这一类异步库,其中所提供的工具类基本上也跟标准库大同小异,只是实现上会有一些区别。

常见的工具特征

还有一些常见的功能实际上是特征,并不是现成的结构体。但是根据Rust的特点,实现了这些特征的结构体在一些操作中将会默认的实现和应用一些快捷的操作。这其实也是Rust代码会出现难以读懂的原因之一。

解决类型转换的FromInto

FromInto是两个紧密相关的特征,其中From主要用于实现从一种类型转换成另一种类型的逻辑,而Into则表示允许当前类型转换成另一种类型的逻辑。Rust自动为每一个实现了From<T>的类型实现了Into<U>,所以只要一个类型实现了From特征,就可以使用Into::into来进行逆向的转换。

如果类型只实现了Into,那么Rust是不会自动实现From的。

如果一个函数声明其参数的泛型类型为T: Into<P>,那么任何可以通过Into特征转换为P类型的实例都是可以传递给函数的,函数在调用前会自动的进行类型转换。此外在一些构造函数和方法中,FromInto也是会自动调用的;还有使用?处理错误时等等。这种隐式调用FromInto的情形其实很多,而且在实际使用中非常常见,在遇到的时候需要留意。

相似的特征还有两个:TryFromTryInto,与FromInto不同,带有Try前缀的特征主要用于可能失败的类型转换,所以不同于需要始终保证转换成功的FromIntoTryFromTryInto在实现的时候需要额外提供一个Error伴随泛型来声明在转换操作失败后会返回的错误,也就是说TryFromTryInto会始终返回一个Result<T, Self::Error>类型的值。

FromInto一样,如果为一个类型T实现了TryFrom<U>,那么Rust也会相应的为类型U实现TryInto<T>

如果在链式操作的map方法中使用了TryFrom::try_from或者TryInto::try_into的方法,而获得了嵌套的类型,那么可以使用and_thentranspose来处理和转换Result类型或者使用?Result类型中的错误抛到链式操作之外。

提供深拷贝能力的Clone

Clone特征带来的深拷贝能力在本系列文章的第一篇中就已经讨论过了。Clone特征一般不会手动去实现,而是使用过程宏#[derive(Clone)]来让Rust自动实现。

提供引用转换能力的AsRefAsMut

AsRefAsMut从字面上来看是进行引用提取的。实际上它们的功能也的确是获得被包装类型的引用。但是它们所提供的功能远远不止获得引用那么简单。AsRefAsMut可以在获取引用的时候,对数据类型做一个转换,甚至可以将当前的类型转换成另一个类型的引用,比如文件操作中常见的AsRef<Path>,可以用来在函数中声明接受任何可以转换成&Path的参数。相比AsRef用来提取不可变引用,AsMut是用来提取可变引用的。就像前面举的示例一样,AsRefAsMut在实际使用中,往往是用在泛型约束上,用来设计更加通用的接口。

AsRef的类型转换和Into的类型转换的不同在于,AsRef是转换为引用,一般没什么开销,而Into则是转换为值,往往会涉及所有权的转移。

提供引用获取能力的BorrowBorrowMut

BorrowBorrowMut提供了一种抽象引用的机制,允许类型在一些场景下表现的像是其他类型的引用。BorrowBorrowMut通常用在容器和数据结构里,以便在使用引用的时候不影响所有权模型。

在实际代码中的使用,BorrowAsRef都是以获取引用的形式出现的,而且它们也都可以实现类型的转换。它们之间的不同主要是用途。Borrow更加强调所借用的类型与原始类型之间的等价关系,而AsRef更强调引用的获取。这两者之间的具体区别需要在实际编码过程中仔细体会。

提供解引用能力的Deref

Deref在日常编码中使用的比较少,但这并不代表它就不重要。Deref定义的是当执行解引用*操作的时候会发生的行为。任何实现了Deref特征的类型都可以在使用的时候表现的与引用类型一样。例如BoxRc等就实现了Deref特征,这使得它们可以“透明”的访问和调用其内部内容。

针对Deref,Rust还提供了一个非常强大的功能,称为“Deref强制转换”(或者称为自动引用与解引用),能够自动将某个类型的引用通过Deref转换为另一个类型的引用。这种转换经常出现在函数参数中,例如一个函数需要&str类型的参数,你可以传一个&String类型的实参进去,Rust会自动通过Deref特征将&String转换为&str。同时,多层的Deref也会自动完全解引用,例如&Rc<Box<String>>也是可以直接自动转换为&str的。

利用DerefDeref强制转换,还可以实现对于类型T,如果其实现了Deref<Target = U>,那么在任何需要&U的地方,都可以提供&T,并自动的转换成&U。Rust提供的这种特征在实际编码和阅读代码的时候需要仔细注意,这往往会影响我们对于代码中实际使用类型的判断。

提供迭代能力的IteratorIntoIterator

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的内容。所以以下两个函数的定义是同义的。

1
2
3
4
5
6
7
fn get_comment() -> Pin<Box<dyn Future<Output = Result<String, std::io::Error>> + Send>> {
  // 函数定义
}

async fn get_comment() -> Result<String, std::io::Error> {
  // 函数定义
}
虽然在有了async关键字以后,可以不必书写复杂的异步任务类型了,但是还是建议熟悉这种写法,因为根据对象安全性,自定义特征中是不能使用async关键字的,所以如果在特征中声明异步函数,还是需要写这一长串的类型定义的。

用于多线程和异步的SendSync

SendSync是两个标记类型,一个类型在实现它们的时候不需要实现什么方法,只需要使用unsafe impl Send for T {}unsafe impl Sync for T {}即可。

Send特征表示这个类型的值可以安全的在线程之间传递,也就是可以从一个线程移动到另一个线程,这种移动往往是涉及到所有权的移动的。在Rust里,几乎所有的基本类型和标准库类型都是Send的。如果一个结构体的所有字段都支持Send,那么这个结构体也同样是Send的。

Sync特征表示这个类型可以安全的在多个线程之间共享引用,而不会出现数据竞争。在Rust里,大多数类型都默认是Sync的,也就是&T是可以在线程之间安全使用的。但是支持Sync的类型,并一定是支持Send的。

对于一个类型T来说,如果TSync的,那么&T就是Send的。也就是说一个类型如果是Sync的,那么他的共享引用就可以安全的传递到其他线程中。

Rust会对许多类型自动的实现SendSync,但是如果类型中包含了非线程安全的类型,那么Rust就会禁止为这些类型实现SendSync。例如类型中包含了Rc,那么整个类型都不会实现Send,因为Rc是单线程的;而如果类型中包含了Cell或者RefCell,那么整个类型都不会实现Sync,因为通过它们获取到的引用无法保证不会发生数据竞争。

如何使用工具类型

上面说了这么多的工具类型,那么这些工具类型要如何使用呢?前面已经提到过了,这些工具类型一般都是一个类型只完成一个功能的,所以我们在选择使用的时候只需要分析我们所需要构建的实例都需要哪些特征和功能。

按照这个原则来选择所需要的工具类,剩下的一个问题就是这些被选出的工具类要如何组合,哪些在内部,哪些在外部。这个问题一般没有什么固定的规律,只是需要注意的是,决定内容保存和使用方式的工具类一般应该放置在最外部。

依旧以上面这个复杂的表示异步任务的类型来说明。

  1. 异步任务首先可能会返回一个可能为空的值,所以核心类型应该是Option<T>
  2. 异步任务有可能会出错,所以异步任务的返回值应该支持返回一个错误,从异步任务中返回的类型应该是Result<Option<T>, Error>
  3. 由于这是一个异步任务,所以需要使用Future包装,但在编译时是不可能确定Future的真正类型,所以需要使用动态分发,异步任务类型应该为dyn Future<Output = Result<Option<T>, Error>>
  4. 异步任务是需要跨越线程的,所以还需要Send特征的支持,异步任务类型继续扩展为dyn Future<Output = Result<Option<T>, Error>> + Send
  5. 动态分发的实例不能保存在栈上,所以还必须将其放置在堆上,类型继续扩展为Box<dyn Future<Output = Result<Option<T>, Error>> + Send>
  6. 前面介绍Pin工具类型的时候提到了,用于异步和多线程任务的内容,在内存中应该是不可移动的,所以还需要将其固定在堆内存中,所以异步任务的最终类型形态就是Pin<Box<dyn Future<Output = Result<Option<T>, Error>> + Send>>

嵌套工具类型以后的拆包

嵌套了大量工具类型以后的类型在构建的时候痛苦,在使用的时候同样痛苦。不过好在Rust提供了不少可以用来简化类型使用的方法。

在遇到一个工具类型的时候,可以去看一下文档,一般情况下这个工具类型会实现Deref或者AsRef特征,这就允许我们通过获取这个工具类的引用来使用其内部的内容。如果连续的基层工具类都实现了Deref,那么我们还可以无视这些工具类,直接使用它们所包装的核心内容。

尖括号太多了,优化一下吧

看到上面这个用来表示异步任务的完整类型写法,你可能已经感觉到了尖括号的恐怖。不过我们还可以庆幸一点,这个又来表示异步任务的类实际上有很多结构是固定的,可变的地方并不多。所以我们可以利用Rust提供的类型别名来精简一下它。

例如:

1
type AsyncOptionResult<T, E> = Pin<Box<dyn Future<Output = Result<Option<T>, E>> + Send>>;

这下好了,之前复杂的类型在我们的代码里就可以直接使用AsyncOptionResult<T, E>这个别名来使用了,看起来就清爽多了。

但是记得,类型别名不会影响实例的构建过程,构建的痛苦还是简化不了的。

另外从类型的别名可以看出来,其实很多库中的看起来比较陌生的类型,实际上可能是一个类型别名。所以要对文档中的Type Aliases内容多加留意。


索引标签
Rust
泛型
特征