yocto-queue 源码学习

392 阅读6分钟

Tiny queue data structure

这个包使用了 链表 的模拟的 队列 数据结构,队列是遵循先进先出 FIFO 的原则,其中在这个包的 README.md 中也有写到什么时候应该去使用队列的这种数据结构:

You should use this package instead of an array if you do a lot of Array#push() and Array#shift() on large arrays, since Array#shift() has linear time complexity O(n) while Queue#dequeue() has constant time complexity O(1). That makes a huge difference for large arrays.

译:

如果你在大型数组上做很多事情,你应该使用这个包而不是Array#push()数组Array#shift(),因为Array#shift()它具有 线性时间复杂度 _O(n)_而Queue#dequeue()具有 恒定时间复杂度 O(1)。这对大型队列产生了巨大的影响。

说是队列,但其实是用链表模拟出来的,而内部使用了 Symbol.iterator 来使得这个实例对象可迭代,使用 Symbol.iterator 创建迭代器的教程可以看阮一峰老师的《ECMAScript 6 入门》中的 Iterator 章节

我在数据结构方面的知识比较薄弱,所以理解不了这种链表形式模拟的队列操作起来为什么会比数组的效率高,在请教了群友后,了解到这其中的操作数据的原理。链表在进行数据操作的时候是设置数据的指针,每次进行修改的时候只需要赋值目标项,而数据在进行删除的时候,后面的数据位置都会变化,所以差距就体现了出来。但链表在查找目标的元素却非常慢,因为它是使用迭代器模拟的,同时内部有一个使用 while 不断查询的过程,所以应该尽量避免去迭代它。

通篇源码的代码不多,加上注释和换行也只有 67 行而已,而且代码的结构也很简单,只有两个构造函数,分别为:Node``Queue

Node

Node的构造函数很简单,目的是为了生命一个包含了 value``next属性的实例对象,这个实例对象就是 queue 中的每一个队列元素,非常重要,但代码不多:

class Node {
	value
	next

	constructor(value) {
		this.value = value
	}
}

Queue

Queue则是整个队列的具体实现,构造出来的实例对象拥有三个属性,三个方法,和一个迭代器接口。先看一下其源码实现:

export default class Queue {
	#head
	#tail
	#size

	constructor() {
		this.clear()
	}

	enqueue(value) {
		const node = new Node(value)

		if (this.#head) {
			this.#tail.next = node
			this.#tail = node
		} else {
			this.#head = node
			this.#tail = node
		}

		this.#size++
	}

	dequeue() {
		const current = this.#head
		if (!current) {
			return
		}

		this.#head = this.#head.next
		this.#size--
		return current.value
	}

	clear() {
		this.#head = undefined
		this.#tail = undefined
		this.#size = 0
	}

	get size() {
		return this.#size
	}

	*[Symbol.iterator]() {
		let current = this.#head

		while (current) {
			yield current.value
			current = current.next
		}
	}
}

先不着急看代码,看它的测试用例,用调试结合测试用例来看每一个方法的作用。调试的办法很简单,使用 vscode启动一个调试终端: image.png 这样在执行某个文件的时候就能识别在每行代码行号旁边的断点了。打开终端后,输入 node test.js 回车。这时候会发现终端提示:

Debugger attached.

Test files must be run with the AVA CLI:

    $ ava test.js

Waiting for the debugger to disconnect...

这个提示出自于 ava模块下的 lib/worker/ensure-forked.js 的第 12行。意思是如果想调试这个文件则必须使用 ava 模块来调用,但是我们在 package.json 文件中并没有看到有使用 ava test.js 的命令,只有一条:"//test":"xo && ava && tsd",和一条 "test":"ava && tsd",怎么办?选最短!如果是第一次看 sindresorhus大佬的包,都会很疑惑,因为它的包大多都不是直接调用 test.js 文件的,为什么?原因是他写的 ava模块在没有传入参数的时候,默认就会去找 test.js文件,所以我们只需要在 JavaScript Debug Terminal 中运行 npm run test即可。但前提是需要像这样在行号前打上对应的断点image.png 在运行以后,在 运行和调试 面板,会有着一些这样的信息: image.png 我们只需要关注 queue 变量即可,因为 tava 测试框架的回调函数的参数,不用过多关注。接下来就可以跟着自己的节奏去按下 F5 同时查看变量值的情况来知道每个方法做了什么,发生了什么变化。

说完调试,我们回到代码本身,探讨一下,代码为什么会这样实现。

constructor

constructor() {
  this.clear()
}

首先是 constructor,里面执行了 this.clear()

clear

clear() {
  this.#head = undefined
  this.#tail = undefined
  this.#size = 0
}

找到 clear方法的定义会发现它执行了三个赋值操作,结合之前的调试信息可以知道这是一个初始化的过程,也就是清空任务队列,同时设置初始值的操作。同时还重置了一下 #head #tail元素为 undefined将值的引用计数清空,这样就方便了 JS 的 GC 进行垃圾回收。

enqueue

enqueue(value) {
  const node = new Node(value)

  if (this.#head) {
    this.#tail.next = node
    this.#tail = node
  } else {
    this.#head = node
    this.#tail = node
  }

  this.#size++
}

enqueue是一个进栈的操作,会将进栈的值以 Node 构造函数包装一次,方便设置整个链条元素的指针。而整个操作需要区分一下是否是队首,因为队首就已经没有上一个元素了,所以将 #head#tail设置为自身即可,如果不是则正常串联这个链条元素。

dequeue

dequeue() {
  const current = this.#head
  if (!current) {
    return
  }

  this.#head = this.#head.next
  this.#size--
  return current.value
}

dequeue则是出栈操作,会先判断是否有队首的元素,如果没有则表示这个队列已经空了。如果有则移交 #head.next的控制权,然后成为新的表头,然后将队列的长度 -1,最后返回这个。可能一开始会理解不了,我们可以依然可以结合断点来查看数据,只需要稍微修改一下测试用例:

test('.dequeue()', t => {
	const queue = new Queue()
	t.is(queue.dequeue(), undefined)
	t.is(queue.dequeue(), undefined)
	queue.enqueue('🦄')
	queue.enqueue('🦄')
	queue.enqueue('🦄')
	queue.enqueue('🦄')
	queue.enqueue('🦄')
	t.is(queue.dequeue(), '🦄')
	t.is(queue.dequeue(), undefined)
})

截屏2022-05-15 16.22.33.png 这里可以看出这个数据是一个无限嵌套的结构,看上去有点像 JS 中的原型链: image.png

*Symbol.iterator

*[Symbol.iterator]() {
  let current = this.#head

  while (current) {
    yield current.value
    current = current.next
  }
}

这里就是上文中提到的迭代器的实现,也是为什么它可以像一个数组一样使用 ... 扩展运算符去展开整个队列的愿意,既然可以被展开,那么也就可以被 for...of所迭代使用。

最后

其实这个源码代码很少,但是却非常实用,同时功能也很强大,实现方式也与往常不同。如果是让我去设计一个类似这样的功能,我肯定想不到使用链表 + ES6 迭代器来实现这个功能。学习源码也不一定就是要全部学习它的写法,更多得还是扩展自己的思维,一个人的能接触到的知识点很有限,但是大家在一起学习,这个面就是无限的。

以上。