Go language 36 talk notes: esos contenedores en el paquete 08container (List\Ring)

Lista enlazada en idioma Go

La lista vinculada del lenguaje Go se implementa en el container/listpaquete de código de la biblioteca estándar. Hay dos entidades de programa público en este paquete de código: ListList Elementimplementa una lista doblemente enlazada (en lo sucesivo, lista enlazada), y Element representa la estructura de los elementos en la lista enlazada.

Pregunta: ¿Puedo Elementpasar el valor de tipo generado por mí mismo a la lista vinculada?

concreción del problema

ListLos cuatro métodos que usamos aquí .

MoveBeforemétodo y MoveAftermétodo, que se utilizan para mover un elemento dado antes y después de otro elemento, respectivamente .

func (l *List) MoveBefore(e, mark *Element)
func (l *List) MoveAfter(e, mark *Element)
 
func (l *List) MoveToFront(e *Element)
func (l *List) MoveToBack(e *Element)
复制代码

MoveToFrontmethod y MoveToBackmethod, que se utilizan para mover un elemento dado al frente y al final de la lista enlazada , respectivamente .

En estos métodos, el " elemento dado " es de *Elementtipo, el *Elementtipo es Elementun puntero al tipo y *Elementel valor es un puntero al elemento.

La pregunta específica es, ¿qué sucede si generamos dicho valor nosotros mismos y lo pasamos al método de lista enlazada como "el elemento dado"? ¿La lista enlazada lo aceptará?

antecedentes para hacer esta pregunta

比如你用 list.New 函数创建了一个 List 类型的双向链表,然后通过它的一些方法往里面塞了一些元素。可以往里面塞的元素的方法有 PushFront、PushBack、InsertAfter、InsertBefore 等。

但是你发现没有,这些方法接受的新元素的类型都是 interface{} 的。也就是说,这个 List 类型的链表只接受 interface{} 类型的新元素值。

而当新元素值进入链表之后,链表会把它们再包装成 list.Element 类型的值。你看,那些往里塞元素值的方法返回的都是被包装后的 *list.Element 类型的元素值。

在浏览了 container/list.List 类型的相关 API 之后,就应该可以明白这个问题的背景了。

这个 List 类型只会接受 interface{} 类型的新元素值,并且只会吐出 *list.Element 类型的已有元素值。显然,任何移动已有元素值或者删除已有元素值的方法,都只会接受该链表自己吐出来的“Element 值”。因此,对于我们自己生成的“Element 值”,这个链表的任何方法都是不会接受的。

典型回答

不会接受,这些方法将不会对链表做出任何改动。因为我们自己生成的Element值并不在链表中,所以也就谈不上“在链表中移动元素”。更何况链表不允许我们把自己生成的Element值插入其中。

问题解析

List包含的方法中,用于插入新元素的那些方法都只接受interface{}类型的值。这些方法在内部会使用Element值,包装接收到的新元素。

这样做正是为了避免直接使用我们自己生成的元素,主要原因是避免链表的内部关联,遭到外界破坏,这对于链表本身以及我们这些使用者来说都是有益的。

List的方法还有下面这几种:

FrontBack方法分别用于获取链表中最前端和最后端的元素,
InsertBeforeInsertAfter方法分别用于在指定的元素之前和之后插入新元素,PushFrontPushBack方法则分别用于在链表的最前端和最后端插入新元素

func (l *List) Front() *Element
func (l *List) Back() *Element
 
func (l *List) InsertBefore(v interface{}, mark *Element) *Element
func (l *List) InsertAfter(v interface{}, mark *Element) *Element
 
func (l *List) PushFront(v interface{}) *Element
func (l *List) PushBack(v interface{}) *Element
复制代码

这些方法都会把一个Element值的指针作为结果返回,它们就是链表留给我们的安全“接口”。拿到这些内部元素的指针,我们就可以去调用前面提到的用于移动元素的方法了。


知识扩展

1. 为什么链表可以做到开箱即用?

开箱即用:指刚声明的变量,元素只会包含缺省的内容,此时可以被直接拿来使用。

举例
ListElement都是结构体类型。结构体类型有一个特点,那就是它们的零值都会是拥有特定结构,但是没有任何定制化内容的值,相当于一个空壳。值中的字段也都会被分别赋予各自类型的零值

讲讲这个零值
广义来讲,所谓的零值就是只做了声明,但还未做初始化的变量被给予的缺省值。每个类型的零值都会依据该类型的特性而被设定。
比如,经过语句var a [2]int声明的变量a的值,将会是一个包含了两个0的整数数组。又比如,经过语句var s []int声明的变量s的值将会是一个[]int类型的、值为nil的切片。

var l list.List声明的变量l的值是什么?--零值
这个零值将会是一个长度为0的链表。链表的根元素也将会是一个空壳,其中只包含缺省的内容。这样的链表可以直接拿来使用吗? 答案是可以的。这被称为“开箱即用”。

Go 语言标准库中很多结构体类型的程序实体都做到了开箱即用。这也是在编写可供别人使用的代码包(或者说程序库)时,我们推荐遵循的最佳实践之一

how

那么,语句var l list.List声明的链表l可以直接使用是怎么做到的呢

1.在于它的“延迟初始化”机制。

what(延迟初始化)

你可以理解为把初始化操作延后,仅在实际需要的时候才进行。延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗

例如,如果我们需要集中声明非常多的大容量切片的话,那么那时的 CPU 和内存空间的使用量肯定都会一个激增,并且只有设法让其中的切片及其底层数组被回收,内存使用量才会有所降低。

如果数组是可以被延迟初始化的,那么计算量和存储空间的压力就可以被分散到实际使用它们的时候。这些数组被实际使用的时间越分散,延迟初始化带来的优势就会越明显。(why延迟初始化)

分析List数据结构

List这个“结构体类型”(关键字struct)有两个字段:
一个是Element类型的字段root(根元素),
一个是int类型的字段len(链表长度),它们属于包级私有,使用者无法查看和修改。

例如:

var l list.List
复制代码

l有字段root和len,都被赋予相应的零值,len为0,表示该链表未包含任何元素;root的零值表示该类型的空壳,用字面量表示为:Element{}。

Element类型包含几个包级私有字段,分别用于存储前一个元素、后一个元素以及所属链表的指针值;

还有一个Value公开字段,该字段代表了持有元素的实际值,也是interface{}类型。在Element类型的零值中,这些字段的默认值都是nil。

Tips

实际上,Go 语言的切片就起到了延迟初始化其底层数组的作用,你可以想一想为什么会这么说的理由。

缺点:恰恰也在于“延后”。你可以想象一下,如果我在调用链表的每个方法的时候,它们都需要先去判断链表是否已经被初始化,那这也会是一个计算量上的浪费。在这些方法被非常频繁地调用的情况下,这种浪费的影响就开始显现了,程序的性能将会降低。

弥补一下在实际使用中,延迟初始化带来的负面影响

在这里的链表实现中,一些方法是无需对是否初始化做判断的。

比如Front方法和Back方法,一旦发现链表的长度为0, 直接返回nil就好了。

又比如,在用于删除元素、移动元素,以及一些用于插入元素的方法中,只要判断一下传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以了。
如果不相等,就一定说明传入的元素不是这个链表中的,后续的操作就不用做了。反之,就一定说明这个链表已经被初始化了。

原因在于,链表的PushFront方法、PushBack方法、PushBackList方法以及PushFrontList方法总会先判断链表的状态,并在必要时进行初始化,这就是延迟初始化。

而且,我们在向一个空的链表中添加新元素的时候,肯定会调用这四个方法中的一个,这时新元素中指向所属链表的指针,一定会被设定为当前链表的指针。所以,指针相等是链表已经初始化的充分必要条件。

List利用了自身以及Element在结构上的特点,巧妙地平衡了延迟初始化的优缺点使得链表可以开箱即用,并且在性能上可以达到最优

2:RingList的区别在哪儿?

container/ring包中的Ring类型实现的是一个循环链表,是环。

List在内部也是一个循环链表。它的根元素永远不会持有任何实际的元素值,而是为了连接这个循环链表的首尾两端。

So,List的零值是一个只包含了根元素,但不包含任何实际元素值的空链表。

那么,既然RingList在本质上都是循环链表,那它们到底有什么不同呢?

最主要的不同有下面几种。

  1. Ring类型的数据结构仅由它自身即可代表,而List类型则需要由它以及Element类型联合表示。这是表示方式上的不同,也是结构复杂度上的不同。
  2. 一个Ring类型的值严格来讲,只代表了其所属的循环链表中的一个元素,而一个List类型的值则代表了一个完整的链表。这是表示维度上的不同。
  3. 在创建并初始化一个Ring值的时候,我们可以指定它包含的元素的数量,但是对于一个List值来说却不能这样做(也没有必要这样做)。List循环链表一旦被创建,其长度是不可变的。这是两个代码包中的New函数在功能上的不同,也是两个类型在初始化值方面的第一个不同
  4. 仅通过var r ring.Ring语句声明的r将会是一个长度为1的循环链表,而List类型的零值则是一个长度为0的链表。别忘了List中的根元素不会持有实际元素值,因此计算长度时不会包含它。这是两个类型在初始化值方面的第二个不同
  5. Ring值的Len方法的算法复杂度是 O(N) 的,而List值的Len方法的算法复杂度则是 O(1) 的。这是两者在性能方面最显而易见的差别

其他的不同基本上都是方法方面的了。比如,循环链表也有用于插入、移动或删除元素的方法,不过用起来都显得更抽象一些,等等。


总结

我们今天主要讨论了container/list包中的链表实现。我们详细讲解了链表的一些主要的使用技巧和实现特点。由于此链表实现在内部就是一个循环链表,所以我们还把它与container/ring包中的循环链表实现做了一番比较,包括结构、初始化以及性能方面。


思考题

1.container/ring包中的循环链表的适用场景都有哪些?

Ring用于保持固定数量的元素,保存最近10天日志,最近5天登陆信息等。
比如:可重用的资源(缓存等)的存储,或者需要灵活组织的资源池,等等

2.你使用过container/heap包中的堆吗?它的适用场景又有哪些呢?

Heap用于排序。
它最重要的用途就是构建优先级队列,并且这里的“优先级”可以很灵活。所以,想象空间很大。

Supongo que te gusta

Origin juejin.im/post/7085350363984838687
Recomendado
Clasificación