Generic Data Structures — Senior Level¶
Table of Contents¶
- The hard ones — trees, heaps, graphs
- Tree[T] — generic n-ary tree
- BST[T cmp.Ordered] — binary search tree
- Heap[T] — priority queue
- Graph[V, E] — vertices and edges
- Method-set constraints on T
- Architectural choices
- Summary
The hard ones — trees, heaps, graphs¶
Trees and graphs are where generic data structures stop being a syntactic exercise and start asking real design questions:
- What constraint does
Tneed?cmp.Ordered?comparable? A method? - Should the comparator be a parameter, a constraint, or a hard-coded
<? - Where do node types live — exported, unexported, internal?
- How do I expose iteration without leaking the node type?
A senior engineer answers these questions consciously, not by accident.
Tree[T] — generic n-ary tree¶
A general tree has a value and a list of children. No order, no constraint.
type Tree[T any] struct {
Value T
Children []*Tree[T]
}
func (t *Tree[T]) AddChild(v T) *Tree[T] {
child := &Tree[T]{Value: v}
t.Children = append(t.Children, child)
return child
}
func (t *Tree[T]) Walk(f func(T)) {
if t == nil {
return
}
f(t.Value)
for _, c := range t.Children {
c.Walk(f)
}
}
Notes:
[T any]— no requirement onTbecause we never compare or hash.*Tree[T]everywhere as the child type, neverTree[T], because trees are usually mutated and shared.AddChildreturns the new child so callers can chain construction.
Use Tree[T] for filesystem-like structures, DOM-like trees, scene graphs, expression trees.
Recursive construction is awkward¶
A natural-looking recursive constructor:
func MakeTree[T any](v T, children ...*Tree[T]) *Tree[T] {
return &Tree[T]{Value: v, Children: children}
}
…lets you build trees declaratively:
This pattern scales well for static trees but, like all variadic-recursive builders, gets awkward when nodes need parent pointers.
BST[T cmp.Ordered] — binary search tree¶
A BST imposes order: left children are smaller, right children are larger. The natural constraint is cmp.Ordered so we can use < directly.
import "cmp"
type bnode[T cmp.Ordered] struct {
value T
left *bnode[T]
right *bnode[T]
}
type BST[T cmp.Ordered] struct {
root *bnode[T]
size int
}
func (t *BST[T]) Insert(v T) {
t.root, _ = bstInsert(t.root, v)
// Note: bstInsert returns (newRoot, inserted)
}
func bstInsert[T cmp.Ordered](n *bnode[T], v T) (*bnode[T], bool) {
if n == nil {
return &bnode[T]{value: v}, true
}
switch {
case v < n.value:
var ins bool
n.left, ins = bstInsert(n.left, v)
return n, ins
case v > n.value:
var ins bool
n.right, ins = bstInsert(n.right, v)
return n, ins
default:
return n, false // already present
}
}
func (t *BST[T]) Contains(v T) bool {
n := t.root
for n != nil {
switch {
case v < n.value:
n = n.left
case v > n.value:
n = n.right
default:
return true
}
}
return false
}
func (t *BST[T]) InOrder(f func(T)) {
inOrder(t.root, f)
}
func inOrder[T cmp.Ordered](n *bnode[T], f func(T)) {
if n == nil {
return
}
inOrder(n.left, f)
f(n.value)
inOrder(n.right, f)
}
Why cmp.Ordered and not a custom Less¶
Two design choices for ordered containers:
| Choice | Pros | Cons |
|---|---|---|
[T cmp.Ordered] | Zero ceremony, < works | Cannot order custom struct types |
[T any] + less func(a, b T) bool | Works for any T including structs | Caller must remember to pass less |
A senior engineer often offers both: a default BST[T cmp.Ordered] and a BSTFunc[T any] that takes a comparator. This is the same pattern slices.Sort and slices.SortFunc follow.
(Inside, replace v < n.value with t.less(v, n.value) and so on.)
Why cmp.Ordered vs comparable¶
comparable only guarantees ==/!=. cmp.Ordered guarantees <, <=, >, >=. A BST needs the second.
cmp.Ordered is roughly:
Custom struct types do not satisfy cmp.Ordered. They satisfy comparable (if their fields are comparable) and need a custom comparator function for ordering.
Heap[T] — priority queue¶
A heap is a tree where every parent is less than its children (min-heap) or greater (max-heap). Implemented over a slice with index arithmetic.
type Heap[T any] struct {
data []T
less func(a, b T) bool
}
func NewHeap[T any](less func(a, b T) bool) *Heap[T] {
return &Heap[T]{less: less}
}
func (h *Heap[T]) Len() int { return len(h.data) }
func (h *Heap[T]) Push(v T) {
h.data = append(h.data, v)
h.up(len(h.data) - 1)
}
func (h *Heap[T]) Pop() (T, bool) {
var zero T
if len(h.data) == 0 {
return zero, false
}
top := h.data[0]
n := len(h.data) - 1
h.data[0] = h.data[n]
h.data[n] = zero
h.data = h.data[:n]
if n > 0 {
h.down(0)
}
return top, true
}
func (h *Heap[T]) up(i int) {
for i > 0 {
parent := (i - 1) / 2
if !h.less(h.data[i], h.data[parent]) {
break
}
h.data[i], h.data[parent] = h.data[parent], h.data[i]
i = parent
}
}
func (h *Heap[T]) down(i int) {
n := len(h.data)
for {
l := 2*i + 1
r := 2*i + 2
smallest := i
if l < n && h.less(h.data[l], h.data[smallest]) {
smallest = l
}
if r < n && h.less(h.data[r], h.data[smallest]) {
smallest = r
}
if smallest == i {
return
}
h.data[i], h.data[smallest] = h.data[smallest], h.data[i]
i = smallest
}
}
Usage:
import "cmp"
minHeap := NewHeap[int](cmp.Less[int])
minHeap.Push(3); minHeap.Push(1); minHeap.Push(2)
v, _ := minHeap.Pop() // 1
A cmp.Ordered-only version:
The function-based version is more flexible (supports max-heap, custom struct ordering); the constraint-based version is shorter for simple cases.
Comparison with container/heap¶
container/heap works through an interface:
Every user must define a wrapper type that implements five methods. With generics, the user just supplies a less function. The generic version is dramatically more ergonomic but slightly slower (we explore why in optimize.md and professional.md).
Graph[V, E] — vertices and edges¶
A graph has two type parameters: vertex labels V and edge weights E.
type Graph[V comparable, E any] struct {
edges map[V]map[V]E
}
func NewGraph[V comparable, E any]() *Graph[V, E] {
return &Graph[V, E]{edges: make(map[V]map[V]E)}
}
func (g *Graph[V, E]) AddVertex(v V) {
if _, ok := g.edges[v]; !ok {
g.edges[v] = make(map[V]E)
}
}
func (g *Graph[V, E]) AddEdge(from, to V, weight E) {
g.AddVertex(from)
g.AddVertex(to)
g.edges[from][to] = weight
}
func (g *Graph[V, E]) Neighbors(v V) map[V]E {
return g.edges[v]
}
func (g *Graph[V, E]) HasEdge(from, to V) bool {
if m, ok := g.edges[from]; ok {
_, ok2 := m[to]
return ok2
}
return false
}
Why V comparable and E any¶
V is a map key — it must be comparable. E only stores weight data — it can be any (an int for weights, a struct for typed edges, etc.).
BFS over the generic graph¶
func (g *Graph[V, E]) BFS(start V, visit func(V)) {
seen := map[V]struct{}{}
queue := []V{start}
for len(queue) > 0 {
v := queue[0]
queue = queue[1:]
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
visit(v)
for n := range g.edges[v] {
if _, ok := seen[n]; !ok {
queue = append(queue, n)
}
}
}
}
Note that V comparable propagated automatically: seen is map[V]struct{} and the queue-based BFS uses == implicitly.
When to add a third type parameter¶
If your graph also carries vertex data (not just labels), you need three parameters:
Three parameters is the practical limit. Beyond that, the type is doing too much — split it.
Method-set constraints on T¶
Some containers want elements that have specific methods. The constraint becomes a custom interface.
Container with String() string¶
type Printable interface {
String() string
}
type Diary[T Printable] struct {
entries []T
}
func (d *Diary[T]) Add(v T) { d.entries = append(d.entries, v) }
func (d *Diary[T]) PrintAll() {
for _, e := range d.entries {
fmt.Println(e.String())
}
}
Now only types with String() string are accepted:
type Note struct{ msg string }
func (n Note) String() string { return n.msg }
d := &Diary[Note]{}
d.Add(Note{"first entry"})
d.PrintAll()
Mixing method-set and type-set constraints¶
Any type whose underlying type is []T and which has a Less method satisfies Sortable[T]. This pattern is rare in practice but useful for sort-style helpers.
The trade-off¶
Method-set constraints push the design back toward interfaces. If every operation goes through a method, you may not need generics at all — a plain interface is simpler.
A useful rule: use a method-set constraint only when the container also performs type-set operations (like == or <). Otherwise pick an interface and write a non-generic helper.
Architectural choices¶
A senior engineer designing a generic data structure thinks about five axes.
1. Constraint scope¶
[T any] is the most permissive. Each tightening (comparable, cmp.Ordered, custom interface) restricts the user. Choose the loosest constraint that lets the body compile.
2. Receiver style¶
Pointer receivers (*Container[T]) are conventional for mutable containers. Value receivers (Container[T]) for tiny immutable types like Pair[K, V]. Mixing the two on one type is confusing.
3. Comparator: constraint or function?¶
| Option | When to pick |
|---|---|
[T cmp.Ordered] | Built-in ordered types only |
[T any] + less func(a, b T) bool | Custom orderings on structs |
| Both as parallel APIs | Library aimed at broad audience |
slices.Sort and slices.SortFunc is the canonical example.
4. Iteration¶
Pick one or two of: - ToSlice() []T — simplest, allocates - ForEach(func(T)) — no allocation - iter.Seq[T] — modern (Go 1.23+)
Document the iteration order if it is not obvious.
5. Concurrency¶
Most generic containers are not safe for concurrent use. Document this explicitly. If you provide a thread-safe variant, do so as a separate type:
Composing the simple Set[T] keeps responsibilities clean.
Anti-pattern: the "god container"¶
Five jobs in one type. Split into smaller types and let the user compose them.
Summary¶
Senior-level generic data structures are about architectural clarity, not syntax:
- Pick the constraint deliberately —
any,comparable,cmp.Ordered, or a method set. - Default to pointer receivers for mutable containers; value receivers for tiny tuples.
- Offer comparators when
cmp.Orderedis too restrictive — and follow theSort/SortFuncpairing. - Bound generic surface — three type parameters is the practical limit.
- Don't ship a god type — small, composable structures beat one monster.
The hardest call is constraint scope. Stack[T any] is permissive but cannot offer Contains. Stack[T comparable] can offer Contains but excludes types with slice fields. Picking the right tradeoff is the senior skill.
Move on to professional.md for the comparison with the stdlib container/* packages and the question of when to ship a generic library at all.