Lesson 15/41 · Composite Data Types & Object-Oriented Design Basics m02-l06

Generics: Type Parameters, Constraints and Practice

Module 2: Composite Data Types & Object-Oriented Design | Lesson: m02-l06 | Level: Beginner After this lesson you will be able to: write Go generic syntax, understand type parameters and constraints, and decide when generics are the right tool for a given scenario.

Learning Objectives

  • Write generic functions and generic structs that take type parameters.
  • Define and use constraints, including ~T, union types and comparable.
  • Explain the type inference mechanism and understand when to reach for generics versus interfaces.
  • Recognize common generic pitfalls such as memory retention and unnecessary abstraction.

Core Concepts

2.1 Type Parameters and Type Inference

Go 1.18 introduced generics, letting us abstract concrete types into "type parameters" when writing functions or types.

What: A generic function declares its type parameters in square brackets [] in the signature. When called, the compiler infers the type arguments from the actual arguments passed in; this process is called type inference.

Why this design: Before generics, to run the same logic on different types (such as summing), you had to write a separate function for each type (like SumInt, SumFloat64), or use interface{} with type assertions — redundant and lacking type safety. Generics generate type-specific code at compile time, giving both type safety and avoiding the runtime cost of type assertions.

How:

func Sum[T int | float64](a, b T) T {
    return a + b
}

When you call Sum(1, 2), the compiler infers T as int; when you call Sum(1.0, 2.0), it infers T as float64.

2.2 Defining Constraints and comparable

A constraint is the condition a type parameter must satisfy. A constraint is usually an interface type.

~T and union types: - ~int means all types whose underlying type is int (including int itself and its derived types). - int | float64 means the type must be either int or float64.

comparable: comparable is a built-in Go constraint meaning the type supports == and !=. - Version note: in Go 1.18/1.19, comparable only accepts strictly comparable types; as of Go 1.20 it is relaxed to also accept comparable interface types (such as any). If your go.mod declares go below 1.20, using any to satisfy comparable fails to compile. - Note: comparable only guarantees == works at compile time; when T = any and the dynamic type at runtime is not comparable (slice, map, func, etc.), a == b panics — keep this in mind in generic code.

Interface constraints are also structural: a type satisfies a constraint as long as it has the methods or the underlying type the constraint requires, with no explicit declaration. ~T and union types let constraints match by underlying type — an extra layer of expressiveness that ordinary interfaces do not have.

2.3 Generic Data Structures

Generics are not only for functions but also for structs. When a generic struct is instantiated, its field types are fixed accordingly.

2.4 Choosing Between Generics and Interfaces

  • When to use generics: when the operation depends on the underlying behavior of a concrete type (numeric arithmetic, comparable comparisons) and performance matters.
  • When to use interfaces: when behavior is polymorphic (different types implement the same method) and you do not care about the underlying type. Interfaces are more flexible; generics are more precise.

Example Code

Example 1: Generic Functions and Constraints

This shows how to write a generic max function using a union constraint and the comparable constraint.

package main

import (
    "fmt"
)

// Number constraint: types whose underlying type is int or float64
type Number interface {
    ~int | ~float64
}

// Max returns the larger of two values of the same Number type
func Max[T Number](a, b T) T {
    if a > b {
        return a
    }
    return b
}

func main() {
    // Type inference: T inferred as int
    maxInt := Max(3, 5)
    fmt.Printf("Max(3, 5) = %d\n", maxInt)

    // Type inference: T inferred as float64
    maxFloat := Max(3.14, 2.71)
    fmt.Printf("Max(3.14, 2.71) = %.2f\n", maxFloat)

    // Since Go 1.21, built-in min/max exist for ordered types and can be used
    // directly (max(3, 4)); this example's Max[T Number] mainly demonstrates
    // how to define a constraint. Note the built-in functions are lowercase
    // max/min and do not conflict with the uppercase Max here.
}

Expected output:

Max(3, 5) = 5
Max(3.14, 2.71) = 3.14

Example 2: A Generic Map and comparable

This shows how to use the comparable constraint to implement a generic counter, and how to handle memory retention.

package main

import (
    "fmt"
)

// CountMap is a generic structure that counts element occurrences
type CountMap[T comparable] struct {
    counts map[T]int
}

// NewCountMap creates a new CountMap
func NewCountMap[T comparable]() *CountMap[T] {
    return &CountMap[T]{
        counts: make(map[T]int),
    }
}

// Increment increments the count for an element
func (cm *CountMap[T]) Increment(key T) {
    cm.counts[key]++
}

// GetCount returns the count for an element
func (cm *CountMap[T]) GetCount(key T) int {
    return cm.counts[key]
}

func main() {
    cm := NewCountMap[string]()
    cm.Increment("apple")
    cm.Increment("banana")
    cm.Increment("apple")

    fmt.Printf("apple: %d\n", cm.GetCount("apple"))
    fmt.Printf("banana: %d\n", cm.GetCount("banana"))
}

Expected output:

apple: 2
banana: 1

Common Mistakes & Pitfalls

  • Wrong assumption: Go supports function overloading
  • Why: Go has never supported function overloading (no two functions with the same name in the same scope).
  • Correct approach: use generics when you need to handle different types, or give each type a different function name (like SumInt, SumFloat64).

  • Bad code: using interface{} instead of comparable in generics

  • Why: interface{} (or any) does not guarantee the type is comparable. If you use == directly on any and the dynamic type is slice/map/func, it panics.
  • Correct approach: if you need to compare, you must use the comparable constraint.

  • Bad code: memory leak when removing elements from a generic struct slice

  • Why: q.items = q.items[1:] only moves the slice header's start pointer; the backing array is still referenced by the new slice and is not freed by the "truncation". What actually helps is zeroing out the removed slot beforehand.
  • Correct approach: before truncating, zero the removed element (e.g. var zero T; q.items[0] = zero) so the element is no longer referenced by the backing array and can be garbage-collected.

Best Practices

  1. Prefer type inference: when calling a generic function, let the compiler infer the type arguments instead of specifying them explicitly (like Max[int](1, 2)), unless inference fails or you need to disambiguate.
  2. Keep constraints specific: do not overuse any or interface{}. Specific constraints (like ~int, comparable) give stronger type safety and better error messages.
  3. Use generics judiciously: if an interface clearly expresses polymorphic behavior, prefer interfaces. Generics fit scenarios that need underlying-type operations (numeric computation, hash keys).
  4. Watch version compatibility: if the project must support Go 1.19 and earlier, avoid using any inside a comparable constraint.

Exercises

  1. Concept question: explain the difference between ~int and int in a constraint, and describe what the comparable constraint does.
  2. Coding exercise: write a generic function Reverse[T any](s []T) []T that reverses a slice, and write a main that tests it on int and string slices.
  3. Advanced/debugging exercise: the generic queue below compiles and runs. Analyze the memory retention problem that q.items = q.items[1:] in Dequeue causes when elements are pointers or large objects, and give an improved version. ```go package main

type Queue[T any] struct { items []T }

func (q *Queue[T]) Enqueue(item T) { q.items = append(q.items, item) }

func (q *Queue[T]) Dequeue() (T, bool) { if len(q.items) == 0 { var zero T return zero, false } item := q.items[0] // Problematic code: truncate directly q.items = q.items[1:] return item, true } ```

Reference Answers

Reference Answers (click to expand / collapse)

Question 1

~int means all types whose underlying type is int, including int and its derived types (like type MyInt int). int means only the int type itself. The comparable constraint means the type supports == and !=; it ensures a generic type can be used as a map key or compared.

Question 2

package main

import "fmt"

func Reverse[T any](s []T) []T {
    reversed := make([]T, len(s))
    for i, v := range s {
        reversed[len(s)-1-i] = v
    }
    return reversed
}

func main() {
    ints := []int{1, 2, 3}
    fmt.Println(Reverse(ints)) // [3 2 1]

    strs := []string{"a", "b", "c"}
    fmt.Println(Reverse(strs)) // [c b a]
}

Question 3

Problem analysis: q.items = q.items[1:] only moves the slice header; the backing array still holds a reference to the removed element, so it cannot be collected (retention).

Fixed complete code:

package main

import "fmt"

type Queue[T any] struct {
    items []T
}

func (q *Queue[T]) Enqueue(item T) {
    q.items = append(q.items, item)
}

func (q *Queue[T]) Dequeue() (T, bool) {
    if len(q.items) == 0 {
        var zero T
        return zero, false
    }
    item := q.items[0]
    // Fix: zero the removed slot to release the backing array reference
    var zero T
    q.items[0] = zero
    // Truncate the slice
    q.items = q.items[1:]
    return item, true
}

func main() {
    q := &Queue[string]{}
    q.Enqueue("hello")
    q.Enqueue("world")

    item, _ := q.Dequeue()
    fmt.Println(item) // hello
}

Lesson Summary

  • Generics reuse code through type parameters, and type inference simplifies the call sites.
  • Constraints (~T, union types, comparable) define the valid range of a type parameter.
  • When using generics, watch for memory retention, especially in slice operations.
  • Interfaces fit polymorphic behavior; generics fit underlying-type operations; each has its place.

Next lesson preview: starting Module 3 — the concurrency model: goroutines and the GMP scheduler.

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also:Browse existing issues · Email feedback (no GitHub account needed):278975598@qq.com