Why Are Golang Heaps So Complicated
原文链接:www.dolthub.com/blog/2023-1...
原文作者:Max Hoffman
译者:Regan Yue
P.S. 原文作者没有审校过本译文,且译者在翻译本内容时带有个人对原文的理解。可能理解有误,麻烦请在评论区指出!
近年来,Golang作为一门新兴编程语言越来越受欢迎,但其中的一些设计选择也引起了热议,比如Heap的实现就被指责过于复杂难用。
我们今天为大家带来的这篇文章,作者的核心观点是Golang中的切片机制给堆结构的实现带来了额外的复杂性。
作者详细论证了以下几个要点:(1)Golang标准库中的heap包为了保证通用性,需要编写大量模板代码,增加了使用难度; (2)切片本质上是一个隐式指针,传递和修改会对用户代码产生让人难以预料的副作用;(3)切片指针在做堆操作时需要额外小心,否则会破坏原始数据结构;(4)几种常见的堆实现方案各有优劣,都受制于Golang的切片机制;(5)最新的泛型实现提供了更优雅的解决方案,但与标准库不完全兼容。
作者通过深入探讨Golang中的堆机制,剖析了不同实现方案背后的考量,这对于我们在实际项目中正确高效地使用堆结构提供了很好的指导。尽管当前的标准库存在一定不便,但随着泛型的引入,相信Golang的堆实现会变得更加简洁高效。我们期待Golang核心团队能在后续版本中进一步完善相关功能。
堆(heaps)通常用于对集合进行部分排序。每次从集合中插入/删除数据后,都会进行一次 "修正"操作来恢复最小堆或最大堆,使之仍然满足最小堆或最大堆的要求。例如,最大堆(max-heap)可以表示为一个二叉树,其中每个父节点都"大于"其子节点。通常,在插入或删除后,只需要进行少量的交换操作就可以"修复"这棵二叉树,使之恢复最大堆的性质。尽管集合中的所有元素并非全局有序的,但"最大值"总是位于最大堆的顶端。因此,堆可以有多种实际应用场景。
一、container/heap
堆可以用带有节点和指针的二叉树来实现,但大多数编程语言(包括但不限于Python和Golang)都提供了内置的堆实现。这些堆实现通常是针对列表(list)数据结构而设计的,使得我们可以使用列表来表示堆,并且可以方便地进行插入、删除和其他操作。
在我刚开始使用 Golang 时,我觉得 Golang 标准库的堆让我感到十分困惑。
这是我使用 Python 时堆的习惯写法:
python
h = []
>>> heappush(h, (5, 'write code'))
>>> heappush(h, (7, 'release product'))
>>> heappush(h, (1, 'write spec'))
>>> heappush(h, (3, 'create tests'))
>>> heappop(h)
(1, 'write spec')
作为对比,这是从Go的 container/heap 文档中改编的代码(run here):
go
import (
"container/heap"
"fmt"
)
type Tuple struct {
i int
s string
}
// An TupleHeap is a min-heap of ints.
type TupleHeap []Tuple
func (h TupleHeap) Len() int { return len(h) }
func (h TupleHeap) Less(i, j int) bool {
if h[i].i != h[j].i {
return h[i].i < h[j].i
}
return h[i].s < h[j].s
}
func (h TupleHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *TupleHeap) Push(x any) {
// Push and Pop use pointer receivers because they modify the slice's length,
// not just its contents.
*h = append(*h, x.(Tuple))
}
func (h *TupleHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
// This example inserts several ints into an TupleHeap, checks
// the minimum, and removes them in order of priority.
func main() {
h := &TupleHeap{}
heap.Init(h)
heap.Push(h, Tuple{i: 5, s: "write code"})
heap.Push(h, Tuple{i: 7, s: "release product"})
heap.Push(h, Tuple{i: 1, s: "write spec"})
heap.Push(h, Tuple{i: 3, s: "create tests"})
fmt.Printf("%d ", heap.Pop(h))
// main.Tuple{i:1, s:"write spec"}
// Program exited.
}
最近,我在几次引擎优化中使用了堆,3 年过去了,我仍然觉得 Go 的堆让我困惑。于是我花了些时间研究了一下原因。
二、默认的 []int 类型堆
collections/heap是Go语言标准库中的一个包,它提供了一个通用的接口heap.Interface,可以用于实现各种类型的堆,包括二叉堆、数组堆、将数据存储在磁盘的堆(disk-backed heaps)等。这个接口的设计使得用户可以使用任何数据类型来实现自己的堆数据结构。
通常情况下,使用heap.Interface来实现堆需要编写一些样板代码,包括实现Len、Less、Swap、Push和Pop等方法,以及定义堆的具体类型。通过这些样板代码,我们可以根据不同的需求和数据结构来创建自定义的堆。
这种设计使得collections/heap非常灵活和通用,可以适用于各种堆的实现。无论是二叉堆、数组堆还是将数据存储在磁盘的堆(disk-backed heaps),都可以通过实现heap.Interface来进行操作和管理。这种通用性使得collections/heap非常强大,可以满足各种不同类型的堆需求。
Golang 的大部分标准库都以最大程度的简单为目标,因此这一设计特点也就不足为奇了。例如,sort 也是一个类似的标准库软件包,其接口也是类似的通用接口。不过,sort包提供了sort.Ints和sort.Strings等函数,用于对整数切片和字符串切片进行排序。这样简化了在最常见情况下的排序操作,从而减少了编写样板代码的工作量。
将最大程度的灵活和简单的默认方法实现或配置结合起来应当是挺好的。然而,为什么heap包不针对大多数常见的堆操作场景实现一些默认的方法或配置呢?
三、切片指针 Slice Pointers
切片在使用过程中可能会引发一些难以预测或容易出错的问题。分片之所以令人感到困惑,部分原因在于它们是指向底层存储数组的隐式指针。Nick 在这篇博客中对 Golang 发表的主要批评之一就是对数组进行修改操作可能会导致内存访问错误、内存泄漏、增加内存管理的开销等问题。类似的问题也困扰着堆的实现。
为了说明这一点,这里有两个不同的 append 函数(译者注:指的是用于向切片中添加元素的函数)。一个使用默认的切片指针,另一个使用切片指针的指针(run here)
go
func main() {
arr := []int{1, 2}
append1(arr, 3)
fmt.Printf("#%v\n", arr) // prints: [1 2]
append2(&arr, 3)
fmt.Printf("#%v\n", arr) // prints: [1 2 3]
}
func append1(arr []int, v int) {
arr = append(arr, v)
}
func append2(arr *[]int, v int) {
*arr = append(*arr, v)
}
在不深入探讨细节的情况下,切片指针在传递给函数时的行为与其他值类型一样。对底层数组进行修改会创建一个新的切片指针。如果切片指针是通过值传递的,那么所有的更改都被限制在函数内部(相比之下,通过切片指针原地修改数组可以保持原始引用的完整性)。如果按引用传递同一个切片指针,外部切片的地址会更新为新的数组。
这对于堆实现非常重要,因为它限制了设计空间。我们可以选择以下几种方式来处理:
- 使用一个间接层,在修改内存时保持最新的指针。
- 在修改内存时返回新的引用。
- 显式地操作切片的指针,使原始引用保持有效。
四、通过对象指针间接使用切片
第一种方案是Go标准库采用的方案,该方案提供了一个最简洁的接口,抽象了不必要的排序细节,使用户可以更简洁地使用切片。但也需要用户在操作中注意切片的间接引用:
go
type IntHeap []int
type Interface interface {
sort.Interface
Push(x any) // add x as element Len()
Pop() any // remove and return element Len() - 1.
}
我们没有传递指针的指针,而是添加了一个具体类型,使额外的引用显式化。这样,我们可以更方便地确定切片的开始和结束位置,从而更好地进行操作。具体类型的引入可以使我们在实现过程中更加灵活地处理切片,从而满足不同的需求。
五、不可变指针
第二种方案是为每次堆操作返回并跟踪更新的切片指针,这是一种实现处理 []int 类型堆的方案之一:
go
var h []int
h = heap.Push(h, x)
h, y := heap.Pop(h)
Immutable updates 强制我们不断跟踪最新的堆引用。(译者注:Immutable updates 是指对数据进行更新时,创建一个新的副本而不是直接修改原始数据。这意味着原始数据保持不变,而更新操作会生成一个新的不可变对象。)我们也失去了在生产环境中通常使用的上下文管理、锁定和并发控制的能力。鉴于 Go 经常围绕原地更新中心对象进行设计,我对标准库避免使用Immutable updates 并不感到惊讶。
六、切片指针
第三种方案是将切片指针传递给堆函数,类似于标准库 encoding/json 在 JSON 反序列化时传递字符串指针的方式。为了验证这种设计的可行性,我 fork 了标准库并进行了实现。
go
h := []int{5, 7, 1, 3}
Init(&h)
Push(&h, 3)
min := Pop(&h).
这个包同时支持 heap.Interface 和 内置的Comparable slices(比如[]int和[]string)。不过,缺点是不支持的类型会引发panic(或者,堆函数可能偏离标准库的接口,返回一个error)。
可以说,最后一种方案的最大缺点是将动态接口和静态接口合并为一个包。如果包装器出了问题,我会比之前更加困惑!
七、泛型
在我发布这篇文章之后,r/golang的moderator之一 jerf 介绍了一个很好的软件包,可以方便地对内置类型进行堆排序。
go
h := binheap.EmptyMaxHeap[int]()
h.Push(5)
h.Push(7)
h.Push(1)
h.Push(3)
min := h.Pop() // 1
另一位名为 twek 的用户找到了一份 2021 年的旧提案,其中有一个类似的接口设计。这个用户还好心地为其他人创建了一个代码示例的沙盒,供其他人尝试运行。
通过将与堆排序相关的一些额外的方法或函数和 heap.Interface 合二为一,这个设计与当前的标准库略有不同。这意味着堆的实现可以为所有切片的Push()、Pop()和Swap()方法进行标准化。泛型对象静态地捕获了可比较类型的Less()方法。这样一来,对于所有内置类型,实现堆排序就变得非常简单。
只需要一个类似sort.Slice (run here):的初始化回调函数,就可以对任意切片进行堆排序。
go
import (
"fmt"
"github.com/lispad/go-generics-tools/binheap"
)
type Struct struct {
Idx int
Description string
}
func (s Struct) LessThan(r Struct) bool {
if s.Idx < r.Idx {
return true
}
if s.Idx > r.Idx {
return false
}
return s.Description < r.Description
}
func main() {
h := binheap.EmptyHeap[Struct](func(a, b Struct) bool { return a.LessThan(b) })
h.Push(Struct{5, "write code"})
h.Push(Struct{7, "release product"})
h.Push(Struct{1, "write spec"})
h.Push(Struct{3, "create tests"})
fmt.Println(h.Pop())
}
八、总结
说到底,Golang中切片的工作方式使得堆的使用比其他编程语言更令人困惑。通过对 Goalng 一些设计细节的了解,我开始理解了在 Golang 语言特性限制下可供选择的多种设计方案和策略。r/golang 的社区帮我获得了更多的背景信息和更好的实现方式。使用泛型的解决方案使用了与标准库略有不同的设计,但它是一种简单的抽象,相比于当前的 container/heap,它消除了大部分样板代码。如果你对Go核心团队的想法感兴趣,可以阅读这个公开的提案。