坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.3.12

前言

昨天我们实现了一个有瑕疵的单栈链表,今天我们来优化下


一个还行的单链栈

我们刚搞了一个很小的链表,但是从头到尾一直有一种变扭的妥协,现在我们来优化下,绝不妥协!天王老子来了也不行!(难说)

接下来的流程:

  1. 造轮子
  2. 支持泛型,目前仅支持
  3. 支持数据查看(peeking)
  4. 让我们的链表可以迭代(难点)

在这个过程中,我们将对下面的知识有些许深入理解:

  • Option<T>的使用
  • 泛型
  • 生命周期
  • 迭代器

如果你对上面的四种知识存在至少一种不了解或者感到陌生,请出门左拐:rust基础学习--final: 章节索引 - 知乎 (zhihu.com)

然后我们新建一个second.rs文件并将它添加到lib.rs中。


使用Option<T>

回到我们之前设计的链表结构:

前面我们说过了,空和非空实际上可以变成有和没有。没有我们直接用None表示即可。

所以我们第一步先把可以用Option<T>替换的都给替换了,甭管好不好,不好再优化嘛。

其实也没多大变化,只是少了一些违和感。

接下来我们来用Option<T>提供给我们的一些方法处理掉另外一些变扭的地方:mem::replace,早看它不爽了!

我们这里有俩法宝可以替代mem::replace

  • take:它相当于replace(None),也就是将Some里的数据拿出来,往里塞入一个None
  • insert:有拿自然就有放,将数据塞入Some里面(注意,当且只当insert的对象是None,如果原来就有数据则无效)

不过目前我们只需要用到take,我们来替换掉mem::replace

虽然差不多,但变扭感有所下降,用了Option<T>,自然要用它的api才对胃。

其中pop的那块代码还可以简化:

这段代码其实可以用map来实现,简单的说就是可以帮你Option<T>变成Option<U>,看个例子:

那我们这块代码也可以改成

ok,一阶段我们使用Option<T>替换掉所有变扭的方法和结构,接下来我们来继续优化下我们的代码


泛型

现在我们的链表只支持i32类型的,我们来让它通用一点。得益于Box的特性,我们的数据都是在堆上的,所以可以很舒服的实现泛型。

然后再来调整下impl部分

很基础,就不多说了。


peek(瞥一眼)

我们目前只能通过pop拿到node保存的数据,但很多时候我们只是想看下数据,拿出来对比,并不想改动链表,这个时候pop就不行了,所以我们需要设计一个方法来获取数据。

不过需要注意,我们只是返回引用,不能直接拿出来。

你第一想法可能是下面这样:

但是你的第二想法应该就会立刻把它否定,或者你的编译器帮你否定

我们前面看过map的代码,它会直接把原来的数据也就是head拿出来,而不是take出来,这对于&self来说是不可饶恕的。

我们只是想要一个引用,所以这个时候我们可以使用as_ref拿到head的引用。

这样就行了,因为这里的|node|变成引用类型&Box<Node<T>>,所以不用担心所有权问题了。

或者as_mut,这货一样会返回Option<&T>,不过是&mut T

这俩在刷链表的算法题中非常常见。

然后我们来搞点测试用例:

但是这个测试用例遇到了问题,无法过编译:

我们明明在map中声明了|&mut value|了,为啥还是不能修改?

实际上这是个误区闭包的参数是一个模式匹配,也就是说它会匹配符合&mut value的模式,那么value就是i32,整体组成一个&mut i32,我们自然是不能修改i32的,因为它immutable!

ok,了解了这个误区之后,我们回到代码中,现在我们移除&mut就行了,这就代表着这是个可以修改的i32引用。

不过我们还是不能直接value = 42,为什么呢?因为这是个可变引用,直接修改的修改的只是指针,我们需要修改的是指针指向的那个空间的数据,这个时候我们就可以使用deref,也就是解引用,直接指向内存空间

根据我们之前学到的知识,我们直接使用*就好啦。

这样就没问题了。


intoIter

从这里开始,复杂度上升!

迭代器是必要的,虽然链表在索引这块没有优势,但是我们还是不可避免的会用到,总不能每次都在pop吧。。。

要实现迭代的功能,我们需要给我们的List实现[Iterator](Iterator in std::iter - Rust (rust-lang.org))这个trait

其中[Item](Iterator in std::iter - Rust (rust-lang.org))是我们迭代器中项的类型,我们一般称它们为关联类型(associated type),可自定义,写法为type Item = xxx(比如i32)

这么做的好处是我们只需要写一处类型声明,其它地方全都是默认的Self::Item即可。

[next](Iterator in std::iter - Rust (rust-lang.org))是实现Iterator必须实现的方法,它定义了如何返回一个项。

不过这里有个问题,我们不仅仅需要实现T(IntoIter)一种类型,我们还需要&T(Iter)&mut T(IterMut)

我们先来实现IntoIter的,其实我们已经实现了类似next的方法,那就是pop,所以我们直接在里面使用pop即可。

简单的说下这里面的几个点:

  1. 我们定义了一个tuple struct也就是元组结构体,作用是给List<T>实现Iterator
  2. 我们的List<T>实现一个into_iter的方法,用来返回一个迭代对象。

你可能会疑惑:为什么要绕一圈,不直接给List<T>实现Iterator

因为**List<T>不能自身就是迭代器!**

我们不能让它自己本身就是迭代器,这是一种越界。这算是一种规范。

但最主要的是我们还得实现Iter和IterMut(笑)

然后我们来搞几个测试用例:

ok,最简单的迭代器就搞定了,我们来实现剩下俩。


Iter

这货的难度比IntoIter高一个度,因为我们保留一个引用,也就是一个指针,这就意味着我们需要声明生命周期

我们先来照猫画虎一下:

很明显有问题,我们来看下问题的具体:

作者:Oh god. Lifetimes. I've heard of these things. I hear they're a nightmare.

自然就是需要声明生命周期,因为编译器无法推导放在类型上的数据的引用会在什么时候挂掉,所以只能我们手动去声明。

也存在不需要声明的场景,比如:

这些都是编译器可以推导出来的,因为它遵守输入的生命周期最少都要比输出的活得久

换句话说就是输出按输入中生命周期最短那个算,这样必定安全。

你也可以在终端输入rustc --explain E0106

这里会告诉你怎么做是对的。

对于我们的这个代码,改起来也还算简单:

可以看到,生命周期的传染性async/.await有的一拼,代码改完可读性减低一半。但这是必要的,因为我们没有**gc**!

类似的C或者C++对于处理空指针(悬浮引用)的问题上采用的是放任,有问题自行解决。

rust则是选择能管就管,毕竟对标的是它俩,对外宣传的重点就是安全,安全,还TM的安全!

那么古尔丹,代价是什么呢?

自然就是开发的成本和复杂度。

扯远了,回到代码中。

上面的代码还是有问题:

简单地说就是我们写太多'a了,都在同一个函数里我们写它干啥,吃饱了撑着。

调整下,涉及到Iter里的T才需要声明,比如List<T>是没有必要声明的:

这回去掉没用的生命周期之后,

居然还有问题!!!

(╯°□°)╯︵ ┻━┻

问题也简单,类型问题,我们返回的是一个Box::<Node>,我们要的是Node,所以需要*一下,因为Box本身返回的是一个指针。

cargo,启动!

(ノಥ益ಥ)ノ ┻━┻

原来忘了as_ref

cargo, 启..启动!

😭

又多了个Box,因为*解的是as_ref这一层,Box的没解到。

我们直接改用as_deref,返回解引用的引用,也就是数据的引用,即&Node,相当于as_ref.map(|node| &**node)

另外我们还可以通过类型声明的方式让编译器帮我们解引用,比如:

因为map自身接收泛型,所以需要我们声明。这样编译器会自动帮我们*

cargo,再带我们冲一次吧!!

这回终于可以了(流下心酸的眼泪)

然后我们来写测试用例:


IterMut

这货难度自然和Iter差不多,但是难一点,因为是支持可变的,可变引用的限制比不可变引用的大很多。

我们先来写类型:

其中实现Iterator这部分相当于

然后根据上一小节复习的知识,next方法中可以省略掉生命周期。

然后我们来实现剩下的方法:

不过这里有个问题

因为next自身保管的就是&mut,如果你再分享一个&mut出去,那么就会同时存在两个&mut,这是不允许的。

另外这里补充一个知识:&是Copy的,连带着Option<&>也是Copy的,但是&mut不是,所以Option<&mut>也不是Copy的

存放在堆上的数据一般都是Copy的,比如i32类型,它可以直接复制,为什么呢?因为它会自动调用Copy,复制一个也放到上(注意,如果你是&i32,那就是搞了一个指针也放在栈上指向这个数据)。

回到我们的问题中,既然不能同时存在俩&mut,那我们只能将next里面的take出来了

注意,这个时候self.nexttake之后变成 None了,我们之所以这么做一方面是没办法很好解决所有权问题(要么用unsafe),另一方面是我们没办法拿到next的上一个,所以可以大胆一点,将next直接take出来。

现在我们可以直接通过引用去修改数据,这样就舒服很多了。


代码整合

其实这段代码还是有瑕疵,Iter里面我们只能存一次和拿一次!


总结

今天我们优化了昨天的代码,但是还存在一些问题,我们下面来处理这个问题。