31.7 Generic Data Structures: Stack, Queue, and Set
Alright, let’s get our hands dirty. Generic data structures are where this whole “type parameter” thing stops being an academic exercise and starts paying rent. You’ve probably hand-rolled a Stack of ints or a Queue of SomeStruct a dozen times. It’s boring, it’s error-prone, and it’s exactly the kind of mind-numbing repetition generics are meant to erase.
We’re going to build three classics: a Stack, a Queue, and a Set. But we’re going to build them once, for any type (well, any comparable type, in the Set’s case). You’ll see where the power lies, and also where Go’s designers, in their infinite wisdom, decided to put some frustrating little speed bumps.
The Humble, Yet Mighty, Stack
A stack is last-in, first-out (LIFO). Think of it like a stack of plates. You add to the top, you take from the top. The implementation is stupidly simple, which makes it a perfect starting point.
The key insight here is that our underlying data structure can be a slice of some type T. The Push method takes a value v T. The Pop method returns a value T. That’s it. That’s the whole song and dance.
package main
import "fmt"
// Stack is a last-in, first-out (LIFO) collection of some type T.
type Stack[T any] struct {
items []T
}
// Push adds a value to the top of the stack.
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
// Pop removes and returns the value from the top of the stack.
// It returns the zero value of T and false if the stack is empty.
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // We have to declare a zero value to return
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func main() {
// Let's make a stack of strings. Note how we specify the type.
var stack Stack[string]
stack.Push("Hello")
stack.Push("Gopher")
for {
if item, ok := stack.Pop(); ok {
fmt.Println(item) // Prints "Gopher", then "Hello"
} else {
break
}
}
}
Why this works: The [T any] is our type parameter clause. It says “for some type T that I don’t care about at all (any), define this struct.” The methods then use this concrete T that will be determined when the stack is declared. The Pop method has a slight wart: because we can’t return nil for a value type like int, we have to return the zero value and a boolean. It’s a bit clunky, but it’s honest.
The Slightly More Interesting Queue
A queue is first-in, first-out (FIFO). Like a line for a concert. The implementation is nearly identical to the stack, but we remove from the front instead of the back. This introduces our first performance gotcha.
type Queue[T any] struct {
items []T
}
// Enqueue adds a value to the back of the queue.
func (q *Queue[T]) Enqueue(v T) {
q.items = append(q.items, v)
}
// Dequeue removes and returns the value from the front of the queue.
// It returns the zero value of T and false if the queue is empty.
func (q *Queue[T]) Dequeue() (T, bool) {
if len(q.items) == 0 {
var zero T
return zero, false
}
item := q.items[0]
q.items = q.items[1:] // This is the critical line.
return item, true
}
The problem? That q.items = q.items[1:] operation. It’s a slice reslicing. It doesn’t actually reclaim the memory at the front of the underlying array; it just moves the pointer forward. If you enqueue and dequeue in a loop, your slice’s underlying array will forever grow, silently hoarding memory like a digital dragon. The fix is to periodically check if the slice is wasting too much space and copy it to a new one. This is a classic Go gotta, not a generics one, but it’s something you absolutely must know when building real data structures.
The Set, and the Constraint That Actually Does Something
A set is a collection of unique items. This is where we finally can’t use any anymore. To check if an item is already in the set, we need to be able to compare items for equality. Enter the comparable built-in constraint. It’s a predeclared constraint that includes all types that can be compared with == and != (so, not slices, maps, or funcs).
// Set is a collection of unique items of a comparable type T.
type Set[T comparable] struct {
items map[T]struct{} // The classic Go map-as-set pattern, now generic!
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{items: make(map[T]struct{})}
}
// Add inserts a value into the set. Returns true if it was added (new).
func (s *Set[T]) Add(v T) bool {
if _, exists := s.items[v]; exists {
return false
}
s.items[v] = struct{}{} // The value is irrelevant; the key is everything.
return true
}
// Contains checks for the existence of a value.
func (s *Set[T]) Contains(v T) bool {
_, exists := s.items[v]
return exists
}
func main() {
intSet := NewSet[int]()
intSet.Add(42)
intSet.Add(10)
fmt.Println(intSet.Contains(42)) // true
fmt.Println(intSet.Contains(99)) // false
// This won't compile! []int is not comparable.
// sliceSet := NewSet[[]int]()
}
Why this is brilliant: We’ve generalized the entire concept of a set without losing any of the type safety. The compiler will stop you from trying to make a Set[[]string], which is exactly what you want. The comparable constraint is the bouncer at the door, checking types at compile time. The implementation using map[T]struct{} is as efficient as it’s always been, we just don’t have to rewrite it for every type anymore. This is generics at their absolute best.