Generic Priority Queue in Go
Go 1.18 introduced generics to the language, a highly requested feature. In this post we implement a priority queue in Go using generics to accept any element type in the queue without having to re-write boilerplate code.
Priority Queue
First, let's take a look at how we can actually implement a priority queue in Go. We can create a priority queue by implementing the heap interface that is present in the standard library (documentation):
As you can see, it is not exactly trivial to implement the heap interface. The heap interface is defined as such:
And sort.Interface is defined as such:
We have to implement a total of 5 functions just to create a priority queue for one custom type. That is a lot of boilerplate, especially if you need multiple priority queues for different custom types! This is where generics come in.
Generics
One way to understand Generics is to think of it as something that enables type-agnostic operations. For example, in non-generic settings, when we define functions, we expect our parameters to be of a certain explicitly declared type, but with generics, as one might suspect from the term "generic", the parameter can be of any type*, and we can perform operations in these parameters without consideration for the parameter type. See this blog post for some quick examples and the official design document for more in-depth details.
In the case of our priority queue, we want to create a single priority queue structure that can accept any type of object in it. In order for our priority queue to work properly, we need to define certain things about the nature of our objects. In our priority queue, how do we know if one custom object is "prioritized" over another object?
* Subject to user defined constraints. See the "Constraints" section in the official design document for more information.
Natural Order
This leads us to the concept of Natural Order. The natural order of a domain of objects defines what is considered "smaller" or "bigger" for all objects in that domain. For example, we define the natural order of all natural numbers as 1 < 2 < 3 ... etc.
This allows us to also establish a priority for all objects in that domain; we can consider a smaller/bigger object in the natural order has having a bigger priority.
Comparator
How do we define the natural order of custom objects? We can use what is called a comparator function, which in Go is also just commonly called the "Less" function. A comparator function takes in two custom objects and computes and/or compares certain internal values, determining which object is "smaller". In a sense, this function is used to define the natural order. This is also the Less function that is defined in the heap Interface.
How the Comparator function is defined is up to your own object and use case; it can be as simple as comparing certain primitive values in our custom objects (this is probably the most common usage) or it can do certain pre-processing on various interal values of the object before comparing the final result.
Creating the Generic Priority Queue
Now we have all the ingredients needed for a generic priority queue. We need to implement the heap interface, and we also need a comparator function for the generic type which will be passed in by the user. The Generic Priority Queue is defined as such:
A priority queue of type T can be initialized with a comparator function for objects of type T:
This will return a Priority Queue structure that will compare its items based on the comparator function we passed in.
Next, we want to implement the heap interface as defined by Go's standard library:
Note that these functions are only for implementing the heap interface, we should not be using them. By implementing these functions, we ensure that the first item in our PQ structure's internal slice of items is always of the highest priority.
With a non-generic priority queue, we would call heap.Push(pq, &item)
and heap.Pop(pq)
to perform Push and Pop operations. However, since we are creating a generic wrapper, we can encapsulate these in their own functions:
I have named them differently (Put and Get) in order to differentiate them from the functions implementing the heap interface. We can also add a Peek() function for completeness:
And with that, our generic priority queue is now usable:
This is definitely a much more succinct alternative to having to define all the functions ourselves to use the heap interface!
Closing Thoughts
Generics are extremely useful, especially if we know that we will be reusing similar data structures for different types of objects. The issue described here about the heap interface being too verbose to implement for different types was one I faced myself, and that led me to searching for answers for a more generic priority queue, which led me to discover Generics in Go (haha).
I'm definitely looking forward to using this new item in my toolbox in the future with similar problems!
Comments