Skip to Content
Course content

174: Generic Data Structures: Stack, Queue, LinkedList

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been thinking about how we handle state in our applications. Often, we need to store things in a specific order—last-in first-out for undo buffers, or first-in first-out for background task processing. In the past, I'd just use a slice and call it a day, but as soon as you need a "proper" data structure that enforces these rules across different types of data, you hit a wall.

The redundancy trap

Let's say I'm building a simple Undo system for a text editor. I only need to store strings for now. I'll write a quick Stack.

type StringStack []string

func (s *StringStack) Push(v string) {
    *s = append(*s, v)
}

func (s *StringStack) Pop() string {
    if len(*s) == 0 {
        return ""
    }
    res := (*s)[len(*s)-1]
    *s = (*s)[:len(*s)-1]
    return res
}

That works perfectly for strings. But then my teammate tells me the Undo system also needs to track "Action" objects—complex structs containing coordinates and timestamps. Now I'm staring at the code, realizing I have to copy-paste that entire logic just to change string to Action. It's tedious and feels wrong. I don't want two stacks; I want one idea of a stack that works for anything.

Generalizing the Stack

This is where generics come in. Instead of pinning the stack to a string, I'll use a type parameter. I'll call it T, which is the convention, and tell Go that T can be any type.

type Stack[T any] []T

func (s *Stack[T]) Push(v T) {
    *s = append(*s, v)
}

func (s *Stack[T]) Pop() T {
    if len(*s) == 0 {
        var zero T // I need a way to return the "zero value" of T
        return zero
    }
    res := (*s)[len(*s)-1]
    *s = (*s)[:len(*s)-1]
    return res
}

Now I can do s := Stack[string]{} or s := Stack[Action]{}. Notice that var zero T trick? Since I don't know what T is, I can't just return "" or 0. Declaring a variable without initializing it gives me the default zero value for whatever type T ends up being. It's a clean way to handle empty stacks.

The Queue performance wall

Next, I need a Queue for a print job processor. A Queue is just a Stack's mirror: first-in, first-out. I tried using a slice again, like this:

type Queue[T any] []T

func (q *Queue[T]) Enqueue(v T) {
    *q = append(*q, v)
}

func (q *Queue[T]) Dequeue() T {
    if len(*q) == 0 {
        var zero T
        return zero
    }
    res := (*q)[0]
    *q = (*q)[1:] // Here's the problem
    return res
}

It looks fine, right? But I remember a performance profile from a previous project. When you slice the front of a very large slice ((*q)[1:]), you aren't actually freeing the memory of the first element immediately, and if you do this millions of times, you can end up with a memory leak or unnecessary overhead. For a small tool, it's fine. For a production-grade queue, I want something more stable. I want a Linked List.

Wiring up a Generic Linked List

A Linked List is the foundation for a more efficient Queue because adding and removing from the ends is just a matter of updating pointers. But I can't just have a "List"; I need "Nodes".

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

type LinkedList[T any] struct {
    Head *Node[T]
    Tail *Node[T]
    Size int
}

func (ll *LinkedList[T]) Append(v T) {
    newNode := &Node[T]{Value: v}
    if ll.Head == nil {
        ll.Head = newNode
        ll.Tail = newNode
    } else {
        ll.Tail.Next = newNode
        ll.Tail = newNode
    }
    ll.Size++
}

Now, if I want to implement my Queue using this LinkedList, the Dequeue operation becomes incredibly cheap. I just move the Head pointer to Head.Next. No slicing, no shifting elements in memory. I'm just updating a reference.

By combining these, I've moved from a rigid, type-specific implementation to a set of tools. I have a Stack[T] for my undo buffers, and a LinkedList[T] that I can use to build a high-performance Queue[T] for my task processor. The beauty of generics here isn't just avoiding copy-pasting; it's that the logic for "how a list works" is now completely decoupled from "what the list holds."




📋 Practical Task

Implement a Generic Double-Ended Queue (Deque)

You need to expand the LinkedList[T] concept to create a Deque (Double-Ended Queue). A Deque allows you to push and pop from both the front and the back.

  • Modify the Node[T] struct to be doubly-linked (add a Prev *Node[T] field).
  • Implement a Deque[T] struct that tracks both the Head and Tail.
  • Write four methods: PushFront(v T), PushBack(v T), PopFront() T, and PopBack() T.
  • Ensure that PopFront and PopBack correctly handle cases where the Deque is empty or contains only one element.

Test your Deque by using it to store a sequence of int values and then a sequence of string values to verify the generics are working correctly.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.