Meet Rajesh Gor

Golang: Generics

Introduction

In the 29th post of the series, we will be looking into generics in Golang. Generics were added in Golang version 1.18, so they are quite new in the world of Golang but the concept is quite old in other programming languages.

Generics provide a powerful toolset for writing more expressive and concise code that can handle a wide range of data types. With generics, we can write reusable algorithms, data structures, and functions that work seamlessly with various types, without sacrificing type safety.

We will learn how to create generic functions and work with generic types. Additionally, we will cover type constraints and interfaces, which allow us to specify requirements for the types used with our generics.

Generic Type in Functions

We can define a generic type with the keyword any that is going to replace the type T i.e. any data type with the inferred type at compilation. This makes the code reusable to any relevant data type to be used for that operation/task.

We can provide the type any after the name of the function/struct to make it generic like func Name[T any](x T). Here, the Name is a function that takes in a generic type T it could be any type and the variable is named as x that could be used inside the function.

We could also make the function take specific types instead of any but we will eventually move into that. However, for now, let’s ease the process of learning and then move on to the optimizations and adding constraints.

package main

import (
	"fmt"
)

func Print[T any](stuff T) {
	fmt.Println(stuff)
}

func main() {
	Print("hello")
	Print(123)
	Print(3.148)
}

GO Playground Link

$ go run main.go
hello
123
3.148

The above is the simplest example that could be used to demonstrate a generic function. The function Print takes a parameter stuff of a generic type denoted by a type parameter T. The type parameter T serves as a placeholder that represents a specific type determined at compile time when the function is invoked.

This means, if in my code I do not call the function with the type []int it won’t have the function with the signature as Print(stuff []int) rather only the types which the function is called with are inferred and written with that specific types.

Creating a Generic Slice

We can even create a slice with a generic type and allow any valid processing on the elements or the slice as a whole.

package main

import (
	"fmt"
)

func ForEach[T any](arr []T, f func(T)) {
	for _, v := range arr {
		f(v)
	}
}

func main() {

	strSlice := []string{"b", "e", "a"}
	ForEach(strSlice, func(v string) {
		fmt.Println(v)
	})

	slice := []int{10, 25, 33, 42, 50}
	var evenSlice []int
	ForEach(slice, func(v int) {
		isEven := v%2 == 0
		if isEven {
			evenSlice = append(evenSlice, v)
		}
	})
	fmt.Println(evenSlice)

}
$ go run main.go

b
e
a
[10 42 50]

Go Playground Link

The ForEach is a generic function that iterates over a slice of any type and calls a function on each element. Let’s break it down:

  • ForEach[T any] declares this as a generic function that works on a slice of any type T.

  • arr []T is the slice of elements we want to iterate over. It can be a slice of any type, ints, strings, T in general, etc. So it is a generic slice parameter.

  • f func(T) is the function that will be called on each element. It takes a single parameter of type T which will be each element. So, this is a function parameter with a generic type as its parameter.

In the body, we range over the slice arr:

for _, v := range arr {

}

On each iteration, v will be the next element. The underscore ignores the index. We call the provided function f, passing the element v: f(v)

So in summary:

  • It allows iterating over a slice of any type

  • This avoids having to know the specific slice type in the loop

  • The provided function f is called on each element

  • So it provides a reusable abstraction for processing slices generically.

Now, we will discuss the example used in the main function. First, we create a slice of strings as strSlice := []string{"b", "e", "a"}. Then we call the generic ForEach function, passing the string slice and a function to handle each element.

ForEach(strSlice, func(v string) {
  fmt.Println(v) 
})

Here, the func(v string) is the invocation of the ForEach function with the typed string and the variable name as v. The v is the element of the slice, so inside the function body(this is an anonymous function), we call the fmt.Println(v), which will print each string in the slice.

slice := []int{10, 25, 33, 42, 50}
var evenSlice []int
ForEach(slice, func(v int) {
    isEven := v%2 == 0
    if isEven {
        evenSlice = append(evenSlice, v)
    }
})
fmt.Println(evenSlice)

In the next example, we create a new slice of int as slice := []int{10, 25, 33, 42, 50}. Then we call the generic ForEach function again, passing the slice and a function to handle each element just as before, however, we are processing some things and then appending to an external slice.

First, the slice := []int{10, 25, 33, 42, 50} is created with some numbers, we also initialize another slice as evenSlice := []int{} which is empty. Then we call the generic ForEach function again, passing the slice and a function to handle each element.Here, the ForEach is called with the slice slice and not the evenSlice slice, so we are going to access each element in the slice array. We first create a isEven boolean that checks if the element is even or odd by v%2 == 0. This expression will result in true if v is even and false otherwise. So, only if the isEven is true, we append the element v into the evenSlice slice.

So, that’s how generic slices can be handy for doing type-specific processing without writing functions for those individual types. This avoids needing to write type-specific functions for each slice type.

NOTE: Make sure to only use generic functions with generic slice types with the appropriate and valid conditions and use it only when it looks legible to do so.

Creating a Generic Map

We can also create a generic map with the generic type of map[K]T where K is a generic type and T is the type of the key.

package main

import (
	"fmt"
)

func GetValue[K comparable, V any](m map[K]V, key K, defaultVal V) V {
	if v, ok := m[key]; ok {
		return v
	}
	return defaultVal
}

func main() {

	serverStats := map[string]int{
		"port":      8000,
		"pings":     47,
		"status":    1,
		"endpoints": 13,
	}
	v := GetValue(serverStats, "status", -1)
	fmt.Println(v)
	v = GetValue(serverStats, "cpu", 4)
	fmt.Println(v)

}
$ go run main.go
1
4

Go Playground Link

GetValue is a generic function that takes three type parameters: The map itself, the key to find the value for, and a default value if the key doesn’t exist.

The m is a map with keys of type K and values of type V, the key is of type K, and defaultVal is of type V. So, we have two generics here, as the key and value need not be of the same type, hence we have distinct generics here. K has added a constraint of comparable and V as any type. The type constraint comparable restricts K to be a comparable type, and the type constraint any allows V to be any type.

  • Inside the function, we use the ok variable to check if the given key exists in the map m.

  • If the key is present in the map (ok is true), we retrieve the corresponding value from the map and return it as m[key] which is stored in the variable v.

  • If the key is not present in the map (ok is false), we return the provided defaultVal.

So, this is how we can use any type of map to retrieve the value of a key, the data type of key and value could be anything. It allows us to retrieve a value from a map irrespective of the pair type in the map.

NOTE: The type of defaultVal and the type of v should be the same since the map will need to have the value for the given key as the same type as defined in the map type(here map[string]int so v is int and so should be the defaultVal).

Moving into the main function, we create a map of [string]int i.e. the key is of type string and the value of type int. The map serverStats has a few keys like port, pings, endpoints, and status. We call the GetValue method on the map serverStats with the key status and provide a default value of -1. The function will readily return 1 since the key is present in the provided map. However, if we try to access the key cpu, the key is not present and the value is returned as the defaultVal which we passed as 4.

So, this was a simple generic getter method on a map. We can get a value of a key in a map of any pair and even provide a default value if doesn’t exist. However, it won’t add it to the map, we will just return the value from the function that’s it. We have to see that returned default value manually.

We can make another function to get or set the value of a key in a map. The function will take in a reference to the map rather than a copy of the map, we can then use that reference to set the key with the provided default value.

package main

import (
    "fmt"
)

func GetOrSetValue[K comparable, V any](m *map[K]V, key K, defaultVal V) V {
    // reference the original map
	ref := *m
	if v, ok := ref[key]; ok {
		return v
	} else {
        //mutate the original map
        ref[key] = defaultVal

        return defaultVal
    }
}

func main() {
    serverStats := map[string]int{
        "port":      8000,
        "pings":     47,
        "status":    1,
        "endpoints": 13,
    }
    fmt.Println(serverStats)
    v := GetOrSetValue(&serverStats, "cpu", 4)
    fmt.Println(v)
    fmt.Println(serverStats)
}
$ go run main.go

map[endpoints:13 pings:47 port:8000 status:1]
4
map[cpu:4 endpoints:13 pings:47 port:8000 status:1]

Go Playground Link

In the above code, we take a reference of the map as *map[K]V, this will give access to the actual place(memory address where the map is located so we could mutate/change it). The rest of the parameters are kept as is, the key will be taken as it was before, and so will the defaultVal. The only difference is that we will set the key doesn’t exist, we set the ref[key] to the defaultVal and return the defaultVal.

For example, the cpu key is not present in the initial map serverStats so, when we call the GetOrSetValue with the reference to the map serverStats, key as cpu and the default value as 4, the function returns 4 and the map is mutated with the key cpu with value 4.

The takeaway is you can even use references to access the original data and mutate it based on your needs.

Generic Type in Struct

We can define custom structs with generic type as the field values. The name of the struct is followed by the [T any] which is the type parameter to be used in the struct fields, again this type could have multiple types(since a struct can have many fields), not necessary want a single type parameter, you could have multiple type parameters bunched up just like we saw in the map example.

package main

import (
	"fmt"
)

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

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

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

func (s *Stack[T]) Pop() T {
	if len(s.items) == 0 {
		panic("Stack is empty")
	}
	index := len(s.items) - 1
	item := s.items[index]
	s.items = s.items[:index]
	return item
}

func main() {
	intStack := NewStack[int]()
	intStack.Push(10)
	intStack.Push(20)
	intStack.Push(30)
	fmt.Println("Integer Stack")
	fmt.Println(intStack)
	intStack.Pop()
	intStack.Pop()
	fmt.Println(intStack)

	// without the NewStack method
	strStack := Stack[string]{}
	strStack.Push("c")
	strStack.Push("python")
	strStack.Push("mojo")
	fmt.Println("String Stack:")
	fmt.Println(strStack)
	strStack.Pop()
	fmt.Println(strStack)
}
$ go run main.go

Integer Stack
&{[10 20 30]}
&{[10]}

String Stack:
{[c python mojo]}
{[c python]}

Go Playground Link

In this example, we have used the Stack example for doing a basic Push and Pop operation on the elements. Here the type of the underlying stack elements could be anything, hence the type parameter is defined for the items which is a list/slice of the type T as []T. We have to specify the type before initializing the

We have created the NewStack method, it is not needed, it could be just used as Stack[int]{} to initialize a empty stack with int type(here int could be any other type you wish). I have just created it so that it shows the abstraction that could be built upon in real applications. The NewStack simply replaces the T with the provided type in the initialization.

The Push method is associated with the Stack struct, as we refer to the *Stack[T] indicating a reference to the Stack object with the type T. The method takes in the parameter T which would be the element to be added to the Stack. Since the function is tied to the Stack struct, we can simply mutate the underlying items by appending the provided value item in the parameter using s.items = append(s.items, item). This appends the item to the underlying items list in the Stack object s

Similarly, we can create Pop method as well, which will first check if the Stack is not empty(i.e. the s.items slice has a length greater than 0), then get the index of the last element using len(s.items) - 1 and then slice the items at index [:last_index]. This will basically get you all the elements except the last. Before we remove the element from the slice, we also store the item in item variable and return it from the method.

You could see the case of generics here, you could build complex data structures without creating a separate implementation for each type.

Adding Constraints to Generics

We can add constraints to the generics to restrict the type of the generic parameter. For example, we can add a constraint for the generic type to be a slice of a specific type or we have seen in the map example the comparable constraint.

The comparable constraint is an interface that allows two instances of the same type to be compared i.e. support comparison operators like ==, <, >, !=, >=, <=, etc. It could be any numeric type like int, float, uint and variants, booleans, time duration, and any other struct that implements the comparable interface.

package main

import (
	"fmt"
)

func FindIndex[T comparable](arr []T, value T) int {
	for i, v := range arr {
		if v == value {
			return i
		}
	}
	return -1
}

func main() {

	strSlice := []string{"m", "e", "e", "t"}
	fmt.Println(FindIndex(strSlice, "e"))
	fmt.Println(FindIndex(strSlice, "t"))
	fmt.Println(FindIndex(strSlice, "a"))

	intSlice := []int{10, 25, 33, 42, 50}
	fmt.Println(FindIndex(intSlice, 33))
	fmt.Println(FindIndex(intSlice, 90))

}
$ go run main.go
1
3
-1

2
-1

Go Playground Link

In the above example, we have created the function FindIndex that takes in a generic slice, the type parameter [T comparable] indicates that the type used for calling this method needs to have a type that implements the comparable interface (for the elements of the slice). The method takes in two parameters, one the slice as []T and the other the value to find the index for as type T. The method returns a type int since the index of the slice has to be an integer value.

Inside the function body, we simply iterate over the slice arr and check if the element is equal to the provided value. If the element exists, we return that index, else we return -1

As we can see we have run a couple of slices with the function FindIndex with types int and string. The method returns an index value if the element exists, else it returns -1. The comparable is a built-in type constraint. We could even define custom constraints as interfaces that implement the types of the particular type(s).

Also, we could define custom constraints like numeric only, string only, etc.

package main

import (
	"fmt"
)

type numeric interface {
	uint | uint8 | uint16 | uint32 | uint64 |
		int | int8 | int16 | int32 | int64 |
		float32 | float64
}

func Sum[T numeric](nums []T) T {
	var s T
	for _, n := range nums {
		s += n
	}
	return s
}

func main() {

	intSlice := []int{10, 20, 30, 40, 50}
	fmt.Println(Sum(intSlice))

	floatSlice := []float64{1.1, 2.2, 3.3, 4.4, 5.5}
	fmt.Println(Sum(floatSlice))
}
$ go run main.go

150
16.5

Go Playground Link

In the above example, we have created the numeric constraints that allow the type int, float and their variants to be allowed in the numeric generic type. The function Sum is a generic function with the constraint of numeric type parameter. The method takes in the parameter as type []T and returns the type as T. The method will simply iterate over the slice and return the sum of its elements.

This will allow any numeric type which can be added and the sum can be obtained, so if we try to call the method with other types like string or maps, it won’t work, and give an error:

$ go run constraints.go

# command-line-arguments                                                                                                               
scripts/generics/constraints.go:46:20: 
string does not satisfy numeric (string missing in uint | uint8 | uint16 | uint32 | uint64 | int
 | int8 | int16 | int32 | int64 | float32 | float64)

shell returned 1

So, we can use the constraint to restrict the type of the generic type parameter which will allow us to restrict the usage and avoid any unsafe type to be used in the generic function.

Also, if we have a custom type with the base types, we need to use ~ before the type to accept it into the generic constraint. This will allow any approximate type to be allowed in the constraint. Let’s say we are implementing a custom string type, for that to work with a constraint, it won’t be satisfied in the constraint since its type is CustomString and not string. So to avoid this we use ~string that would approximate the type and allow abstracted base types.

package main

import (
	"fmt"
)

type string2 string

type strings interface {
	~string
}

func PrintEach[T strings](arr T) {
	for _, v := range arr {
		fmt.Printf("%c\n", v)
	}
}

func main() {
	var s string2
	s = "hello"
	PrintEach(s)

}
$ go run main.go

h
e
l
l
o

Go Playground Link

In the above example, we have used the type approximations in the type constraint strings, it allows any string type, whether a base string type or an abstract string type. If you try to remove the ~ in ~string, it will result in the error that string2 does not satisfy strings interface. So, by adding ~ to the string type the abstract type string2 can be satisfied in the generic constraint.

That’s it from the 29th part of the series, all the source code for the examples are linked in the GitHub on the 100 days of Golang repository.

References

Conclusion

From this section of the series, we have covered the basics of generics in Golang. By using generics in functions, slices, maps, and structs, and adding constraints to them the fundamental usage of generics was covered.

If you have any questions, feedback, or suggestions, please drop them in the comments/social handles or discuss them below. Thank you so much for reading. Happy Coding :)