Golang: Generics
Learn how to apply generics to functions, slices, maps, and structs for enhanced reusability and type safety.
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 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]
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 typeT
.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 typeT
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 elementSo 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
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 mapm
.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 variablev
.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]
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]}
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
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
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
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 :)series: "['100-days-of-golang']"