Skip to main content
Go: Generics
  1. Posts/

Go: Generics

Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

Following the Learn Go with Tests guide, exploring Go generics.

Any vs Generics
#

Test Examples
#

Functions to demonstrate how generics work with different data types:

func TestAssertFunctions(t *testing.T) {
	t.Run("assert an integer", func(t *testing.T) {
		AssertEquals(t, 1, 1)
		AssertNotEqual(t, 1, 2)
	})

	t.Run("assert on strings", func(t *testing.T) {
		AssertEquals(t, "hello", "hello")
		AssertNotEqual(t, "hello", "privet")
	})

	t.Run("asserting different types", func(t *testing.T) {
		AssertEquals(t, 1, "1")
		// This could cause an issue as this is missing types
	})
}

Using Any Interface
#

The traditional approach before generics was to use the empty interface (now called any):

func AssertEquals(t *testing.T, got, want any) {
	t.Helper()
	if got != want {
		t.Errorf("got %+v, want %+v", got, want)
	}
}

func AssertNotEqual(t *testing.T, got, want any) {
	t.Helper()
	if got == want {
		t.Errorf("got %+v, want %+v", got, want)
	}
}

How it handles mixed types:

AssertEquals(t, 1, "1") // Test output: "got 1, want 1"

Compiles without issues, but it lacks type safety. When comparing different types like int(1) and string("1"), the test fails but the error message shows both values as 1, which can be misleading.

Using Generics
#

Comparable Interface
#

The comparable interface is a type parameter in Go that represents all types that can be compared using == and != operators. This includes:

  • Booleans
  • Numbers
  • Strings
  • Pointers

The comparable interface may only be used as a type parameter constraint, not as the type of a variable.

type comparable interface{ comparable }

Generic Assert Functions
#

func AssertEquals[T comparable](t *testing.T, got, want T) {
	t.Helper()
	if got != want {
		t.Errorf("got %+v, want %+v", got, want)
	}
}

func AssertNotEqual[T comparable](t *testing.T, got, want T) {
	t.Helper()
	if got == want {
		t.Errorf("got %+v, want %+v", got, want)
	}
}

The comparable constraint lets the compiler know we want to use == and != operators. If we used any instead, we would get a compilation error:

“cannot compare got != want (operator != not defined for T)”.

How it handles mixed types:

AssertEquals(t, 1, "1") // Compilation error: Cannot infer T

With generics, attempting to compare different types like int(1) and string("1") results in a compilation error:

“Cannot infer T”

because the compiler cannot determine what type T should be when the arguments are of different types. This prevents the bug at compile time rather than runtime.


Difference:

  • any: Runtime type checking, accepts mixed types, potential runtime errors
  • generics: Compile-time type checking, enforces type consistency

Stack Example
#

Test Helpers
#

func AssertTrue(t *testing.T, got bool) {
	t.Helper()
	if !got {
		t.Errorf("got %v, want true", got)
	}
}

func AssertFalse(t *testing.T, got bool) {
	t.Helper()
	if got {
		t.Errorf("got %v, want false", got)
	}
}

Stack Tests
#

func TestStack(t *testing.T) {
	t.Run("integer stack", func(t *testing.T) {
		myStackOfInts := NewStack[int]() // Explicit type parameter

		// check stack is empty
		AssertTrue(t, myStackOfInts.IsEmpty())

		// add a thing, then check it's not empty
		myStackOfInts.Push(42)
		AssertFalse(t, myStackOfInts.IsEmpty())

		// add another thing, pop it back again
		myStackOfInts.Push(456)
		value, _ := myStackOfInts.Pop()
		AssertEqual(t, value, 456)
		value, _ = myStackOfInts.Pop()
		AssertEqual(t, value, 42)
		AssertTrue(t, myStackOfInts.IsEmpty())

		myStackOfInts.Push(1)
		myStackOfInts.Push(2)
		firstNum, _ := myStackOfInts.Pop()
		secondNum, _ := myStackOfInts.Pop()
		AssertEqual(t, firstNum+secondNum, 3)
	})
}

Implementation
#

Generic stack data structure using Go generics:

Structure & Constructor
#

The generic stack struct uses T any to accept any type:

type Stack[T any] struct {
	values []T
}

func NewStack[T any]() *Stack[T] {
	return new(Stack[T])
}

Methods
#

func (s *Stack[T]) Push(value T) {
	s.values = append(s.values, value)
}

func (s *Stack[T]) IsEmpty() bool {
	return len(s.values) == 0
}

func (s *Stack[T]) Pop() (T, bool) {
	if s.IsEmpty() {
		var zero T
		return zero, false
	}

	index := len(s.values) - 1
	el := s.values[index]
	s.values = s.values[:index]
	return el, true
}
  • Push adds a value of type T to the stack
  • IsEmpty checks if the stack has any elements
  • Pop returns the last element and a boolean indicating success. If empty, it returns the zero value of type T (e.g., 0 for int, "" for string)