DEV Community

Sadanand Dodawadakar
Sadanand Dodawadakar

Posted on

Generics in Go: Transforming Code Reusability

Generics, introduced in Go 1.18, have revolutionised the way of writing reusable and type-safe code. Generics bring flexibility and power while maintaining Go’s philosophy of simplicity. However, understanding nuances, benefits, and how generics compare to traditional approaches (like interface{} ) requires a closer look.

Let’s explore the intricacies of generics, delve into constraints, compare generics to interface{}, and demonstrate their practical applications. We’ll also touch upon performance considerations and binary size implications. Let’s dive in!

What is Generics?

Generics allow developers to write functions and data structures that can operate on any type while maintaining type safety. Instead of relying on interface{}, which involves type assertions in runtime, generics let you specify a set of constraints that dictate the permissible operations on the types.

Syntax

func FunctionName[T TypeConstraint](parameterName T) ReturnType {
    // Function body using T
}
Enter fullscreen mode Exit fullscreen mode

T: A type parameter, representing a placeholder for the type.

TypeConstraint: Restricts the type of T to a specific type or a set of types.

parameterName T: The parameter uses the generic type T.

ReturnType: The function can also return a value of type T.

Example

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

func Sum: Declares the name of the function, Sum

[T int | float64]: Specifies a type parameter list that introduces T as a type parameter, constrained to specific types (int or float64). Sum function can take only parameters either int or float64, not in combination, both have to either int or float64. We will explore this further in below sections.

(a, b T): Declares two parameters, a and b, both of type T (the generic type).

T: Specifies the return type of the function, which matches the type parameter T.

Constraints: Building blocks of Generics

Constraints define what operations are valid for a generic type. Go provides powerful tools for constraints, including the experimental constraints package(golang.org/x/exp/constraints).

Built-in Constraints

Go introduced built-in constraints with generics to provide type safety while allowing flexibility in defining reusable and generic code. These constraints enable developers to enforce rules on the types used in generic functions or types.

Go has below built-in constraints

  1. any: Represents any type. It’s an alias for interface{}. This is used when no constraints are needed
func PrintValues[T any](values []T) {
    for _, v := range values {
        fmt.Println(v)
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. comparable: Allows types that support equality comparison(== and !=). Useful for maps keys, duplicate detection or equality checks. This can not be used for maps, slices and functions, since these types don’t support direct comparison.
func CheckDuplicates[T comparable](items []T) []T {
    seen := make(map[T]bool)
    duplicates := []T{}
    for _, item := range items {
        if seen[item] {
            duplicates = append(duplicates, item)
        } else {
            seen[item] = true
        }
    }
    return duplicates
}
Enter fullscreen mode Exit fullscreen mode

Experimental constraints

  1. constraints.Complex: Permits complex numeric types(complex64 and complex128).
  2. constraints.Float: Permits float numeric types(float32 and float64)
  3. constraints.Integer: Permits any integer both signed and unsigned (int8, int16, int32, int64, int, uint8, uint16, uint32, uint64 and uint)
  4. constraints.Signed: Permits any signed integer(int8, int16, int32, int64 and int)
  5. constraints.Unsigned: Permits any unsigned integer (uint8, uint16, uint32, uint64 and uint).
  6. constraint.Ordered: Permits types that allow comparison (<. <=, >, >=), all numeric types and string are supported(int, float64, string, etc).
import (
     "golang.org/x/exp/constraints"
     "fmt"
)

func SortSlice[T constraints.Ordered](items []T) []T {
    sorted := append([]T{}, items...) // Copy slice
    sort.Slice(sorted, func(i, j int) bool {
        return sorted[i] < sorted[j]
    })
    return sorted
}

func main() {
    nums := []int{5, 2, 9, 1}
    fmt.Println(SortSlice(nums)) // Output: [1 2 5 9]

    words := []string{"banana", "apple", "cherry"}
    fmt.Println(SortSlice(words)) // Output: [apple banana cherry]
}
Enter fullscreen mode Exit fullscreen mode

Custom Constraints

Custom constraints are interfaces that define a set of types or type behaviours that a generic type parameter must satisfy. By creating your own constraints, we can;

  • Restrict types to a specific subset, such as numeric types.

  • Require types to implement specific methods or behaviors.

  • Add more control and specificity to your generic functions and types.

Syntax

type Numeric interface {
    int | float64 | uint
}
Enter fullscreen mode Exit fullscreen mode

Example

type Number interface {
    int | int64 | float64
}

func Sum[T Number](a, b T) T {
    return a + b
}
Enter fullscreen mode Exit fullscreen mode

Sum function can be called using only int, int64 and float64 parameters.

Constraints by method

If you want to enforce a type must implement certain methods, you can define it using those methods.

type Formatter interface {
    Format() string
}

func PrintFormatted[T Formatter](value T) {
    fmt.Println(value.Format())
}
Enter fullscreen mode Exit fullscreen mode

The Formatter constraint requires that any type used as T must have a Format method that returns a string.

Combining Constraints

Custom constraints can combine type sets and method requirements

type AdvancedNumeric interface {
    int | float64
    Abs() float64
}

func Process[T AdvancedNumeric](val T) float64 {
    return val.Abs()
}
Enter fullscreen mode Exit fullscreen mode

This constraint includes both specific types (int, float54) and requires the presence of an abs method.

Generics vs interface{}

Before introduction of generics, interface{} was used to achieve flexibility. However, this approach has limitations.

Type Safety

  • interface{}: Relies on runtime type assertions, increasing the chance of errors at runtime.

  • Generics: Offers compile-time type safety, catching errors early during development.

Performance

  • interface{}: Slower due to additional runtime type checks.

  • Generics: Faster, as the compiler generates optimised code paths specific to types.

Code Readability

  • interface{}: Often verbose and less intuitive, making the code harder to maintain.

  • Generics: Cleaner syntax leads to more intuitive and maintainable code.

Binary Size

  • interface{}: Results in smaller binaries as it doesn’t duplicate code for different types.

  • Generics: Slightly increases binary size due to type specialisation for better performance.

Example

func Add(a, b interface{}) interface{} {
    return a.(int) + b.(int)
}
Enter fullscreen mode Exit fullscreen mode

Code works well, type assertion is overhead. Add function can called with any argument, both a and b parameters can be of different types, however code will crash in the runtime.

func AddGeneric[T int | float64](a, b T) T {
    return a + b
}
Enter fullscreen mode Exit fullscreen mode

Generics eliminate the risk of runtime panics caused by incorrect type assertions and improve clarity.

Performance

Generics produce specialised code for each type, leading to better runtime performance compared to interface{}.

Binary Size

A trade-off exists: generics increase binary size due to code duplication for each type, but this is often negligible compared to the benefits.

Limitations of Go Generics

Complexity in Constraints: While constraints like constraints.Ordered simplify common use cases, defining highly customized constraints can become verbose.

No Type Inference in Structs: Unlike functions, you must specify the type parameter explicitly for structs.

s := Stack[int]{} // Type parameter is required
Enter fullscreen mode Exit fullscreen mode

Limited to Compile-Time Constraints: Go generics focus on compile-time safety, whereas languages like Rust offer more powerful constraints using lifetimes and traits.

Let’s Benchmark — Better done than said

We will implement a simple Queue with both interface{} and generic and benchmark the results.

Interface{} Queue implementation

package main

import (
 "testing"
)

type QueueI struct {
 items []interface{}
}

func (q *QueueI) Enqueue(item interface{}) {
 q.items = append(q.items, item)
}

func (q *QueueI) Dequeue() interface{} {
 item := q.items[0]
 q.items = q.items[1:]
 return item
}

func BenchmarkInterfaceQueue(b *testing.B) {
 queue := QueueI{}
 for i := 0; i < b.N; i++ {
  queue.Enqueue(i)
  queue.Dequeue()
 }
}

Enter fullscreen mode Exit fullscreen mode

Generic Queue Implementation

package main

import (
 "testing"
)

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

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

func (q *QueueG[T]) Dequeue() T {
 item := q.items[0]
 q.items = q.items[1:]
 return item
}

func BenchmarkGenericQueue(b *testing.B) {
 queue := QueueG[int]{}
 for i := 0; i < b.N; i++ {
  queue.Enqueue(i)
  queue.Dequeue()
 }
}

Enter fullscreen mode Exit fullscreen mode
go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: becnh
cpu: VirtualApple @ 2.50GHz
BenchmarkGenericQueue-10        74805141                16.05 ns/op            8 B/op          1 allocs/op
BenchmarkInterfaceQueue-10      25719405                44.65 ns/op           24 B/op          1 allocs/op
PASS
ok      becnh   4.522s
Enter fullscreen mode Exit fullscreen mode

Analysis of Results

  • Execution Time:
    The generic implementation is approximately 63.64% faster than the interface{} version because it avoids runtime type assertions and operates directly on the given type.

  • Allocations:
    The interface{} version makes 3x more allocations, primarily due to boxing/unboxing when inserting and retrieving values. This adds overhead to garbage collection.

For larger workloads, such as 1 million enqueue/dequeue operations, the performance gap widens. Real-world applications with high-throughput requirements (e.g., message queues, job schedulers) benefit significantly from generics.

Final Thoughts

Generics in Go strike a balance between power and simplicity, offers a practical solution for writing reusable and type-safe code. While not as feature-rich as Rust or C++, align perfectly with Go’s minimalist philosophy. Understanding constraints like constraints.Ordered and leveraging generics effectively can greatly enhance code quality and maintainability.

As generics continue to evolve, they are destined to play a central role in Go’s ecosystem. So, dive in, experiment, and embrace the new era of type safety and flexibility in Go programming!

Checkout github repository for some samples on generics.

GitHub logo sadananddodawadakar / GoGenerics

Repository contains working examples of go generics

Go Generics: Comprehensive Examples Repository

Welcome to the Go Generics Repository! This repository is a one-stop resource for understanding, learning, and mastering generics in Go, introduced in version 1.18. Generics bring the power of type parameters to Go, enabling developers to write reusable and type-safe code without compromising on performance or readability.

This repository contains carefully curated examples that cover a wide range of topics, from basic syntax to advanced patterns and practical use cases. Whether you're a beginner or an experienced Go developer, this collection will help you leverage generics effectively in your projects.


🚀 What's Inside

🔰 Basic Generic Programs

These examples introduce the foundational concepts of generics, helping you grasp the syntax and core features:

  1. GenericMap: Demonstrates a generic map function to transform slices of any type.
  2. Swap: A simple yet powerful example of swapping two values generically.
  3. FilterSlice: Shows how to filter…




Top comments (0)