Introduction
Generics are a way to write code that is independent of the specific types it uses. They allow you to write a single piece of code that can work with multiple types, rather than having to write separate code for each type. This can make your code more flexible, reusable, and maintainable.
Generics in Go were a controversial topic because the language did not support them at all. Many developers believed that the lack of generics was a significant limitation of Go, and there were numerous debates and discussions about whether or not the language should support them. One of the main arguments against generics was that they would make the language more complex and harder to learn. Go is known for its simplicity and minimalism, and some people feared that adding generics would compromise these virtues.
Others argued that generics were necessary for writing high-quality, reusable code, and that the lack of generics was a major drawback of Go. These developers believed that the benefits of generics outweighed any potential drawbacks, and that Go would be a better language with them.
We will be addressing generics gently in go, describing what have been used in the go community and what became available since generics landed in golang
Create a new project
mkdir golanggenrics && cd golanggenerics
Initialize the project
golanggenerics ~> go mod init codescalers/golanggenerics
go: creating new go.mod: module codescalers/golanggenerics
printing a slice of ints
What we want to do now is writing a simple function that only prints a slice of integers
let's create a main.go
file
package main
import "fmt"
func printInts(ints []int) {
for _, v := range ints {
fmt.Printf("value %v\n", v)
}
}
func main() {
ints := []int{1, 2, 3, 4}
printInts(ints)
}
run it with go run main.go
and the output should be something like
value 1
value 2
value 3
value 4
Very cool, so now we managed to write a function that prints the items in an int slice. What if we wanted to do the same for a slice of floats? We can write another function printFloats
as follows
package main
import "fmt"
func printInts(ints []int) {
for _, v := range ints {
fmt.Printf("value %v\n", v)
}
}
func printFloats(floats []float32) {
for _, v := range floats {
fmt.Printf("value %v\n", v)
}
}
func main() {
fmt.Println("printInts")
ints := []int{1, 2, 3, 4}
printInts(ints)
fmt.Println("printFloats")
floats := []float32{1.4, 2.1, 3.6, 4.21}
printFloats(floats)
}
the output
printInts
value 1
value 2
value 3
value 4
printFloats
value 1.4
value 2.1
value 3.6
value 4.21
so now we can print slices of ints and slices of floats! we managed to do so by some code duplication, code still simple, and maintainable,.. etc
Is there another way we can make it work using just one function, instead of defining a function per type? Well.. we can move to interfaces and type assertions as follows
func printAnything(aslice interface{}) {
switch slice := aslice.(type) {
case []int:
for _, v := range slice {
fmt.Printf("value: %v\n", v)
}
case []float32:
for _, v := range slice {
fmt.Printf("value: %v\n", v)
}
}
}
func main() {
ints := []int{1, 2, 3, 4}
floats:= []float32{4.11, 2.52, 3.29, 4.0}
fmt.Println("printAnything: ints")
printAnything(ints)
fmt.Println("printAnything: floats")
printAnything(floats)
}
while this function now accepts the empty interface, we have embedded the code from printInts
and printFloats
into it and handled each type case
the output will be
printAnything: ints
value: 1
value: 2
value: 3
value: 4
printAnything: floats
value: 4.11
value: 2.52
value: 3.29
value: 4
At least now the API seems to be smaller, just one function printAnything
can replace both printInts
and printFloats
Still one more problem though, we can pass other data types to printAnything
function without providing the right implementation, at least when we had printInts
or printFloats
the user had an idea what is actually supported! Fine! let's add a panic to it.
func printAnything(aslice interface{}) {
switch slice := aslice.(type) {
case []int:
for _, v := range slice {
fmt.Printf("value: %v\n", v)
}
case []float32:
for _, v := range slice {
fmt.Printf("value: %v\n", v)
}
default:
panic("what??")
}
}
One problem with our current code is we literally embedded the code of printInts
and printFloats
, we can do another improvement here by using reflection in golang
func printAnything(aslice interface{}) {
slice := reflect.ValueOf(aslice)
if slice.Kind() != reflect.Slice {
panic("what??")
}
for i := 0; i < slice.Len(); i++ {
fmt.Printf("value %v \n", slice.Index(i).Interface())
}
}
func main() {
fmt.Println("printAnything: ints")
printAnything([]int{1, 2, 3, 4})
fmt.Println("printAnything: floats")
printAnything([]float32{4.11, 2.52, 3.29, 4.0})
fmt.Println("printAnything: string")
printAnything("hello")
}
Here we removed the code duplication of the cases of []int
and []float
and now it even works for any slice e.g including strings and float32
as well, but will for any data type other than slices e.g printAnything("hello")
will cause a panic.
Output
printAnything: ints
value 1
value 2
value 3
value 4
printAnything: floats
value 4.11
value 2.52
value 3.29
value 4
panic: what??
Always remember you are sacrificing the typesafety when using the empty interface, and reflections always comes with an overhead.
There're other solutions including code generation, but it can get quite hairy
func printGeneric[T any](slice []T) {
for _, v := range slice {
fmt.Printf("value %v\n", v)
}
}
Here we defined a function printGeneric
that takes a slice of type T
, T is a type, that needs to be defined or your go project needs to know about, and here we immediately defined it after the function name between the brackets [T any]
Hint:
try removing the brackets and see go complaining with undeclared name T
. Another way is defining T separately with
type T any
func printGeneric(slice []T) {
for _, v := range slice {
fmt.Printf("value %v\n", v)
}
}
However, for convenience we can define T immediately after the function name to be func printGeneric[T any](slice []T)
update your main.go
to be
package main
import "fmt"
func main() {
printGeneric([]int{1, 2, 3, 4})
printGeneric([]float32{4.11, 2.52, 3.29, 4.0})
}
Now what happens if we decided to pass a string instead of the slice? (which was allowed in all of the previous versions)? the code won't compile
Building a Box
Imagine that we want to create a new type representing a container that has just one value. Let's call it Box
. With what we learned so far we can build customized types IntBox
, FloatBox
, .. etc
type IntBox struct {
obj int
}
func (b IntBox) GetObject() int {
return b.obj
}
type FloatBox struct {
obj float32
}
func (b FloatBox) GetObject() float32 {
return b.obj
}
func main() {
mybox5 := IntBox{obj: 5}
fmt.Println(mybox5)
fmt.Println(mybox5.GetObject())
mybox3dot5 := FloatBox{obj: 3.5}
fmt.Println(mybox3dot5)
fmt.Println(mybox3dot5.GetObject())
}
Output
{5}
5
{3.5}
3.5
Still too much manual work that can be exhausting if you needed to support more types, we can go back to use the empty interface
package main
import "fmt"
type Box struct {
obj interface{}
}
func (b Box) GetObject() interface{} {
return b.obj
}
func main() {
mybox5 := Box{obj: 5}
fmt.Println(mybox5)
fmt.Println(mybox5.GetObject())
myboxHello := Box{obj: "hello"}
fmt.Println(myboxHello)
fmt.Println(myboxHello.GetObject())
}
Output
{5}
5
{hello}
hello
Now our Box supports ints, floats and by luck also strings, that wasn't really intended. How can generics help us improving our code? and maybe adding some more constraints to make it work for certain types? e.g having NumberBox that can work for int32
, int64
, float32
, float64
only?
Let's do the GenericBox first and then do the constraints
type GenericBox[T any] struct {
obj T
}
func (gb GenericBox[T]) GetObject() T {
return gb.obj
}
func main() {
mygbox5 := GenericBox[int32]{obj: 5}
fmt.Println(mygbox5)
mygbox3dot2 := GenericBox[float32]{obj: 3.2}
fmt.Println(mygbox3dot2)
mygboxstring := GenericBox[string]{obj: "hello"}
fmt.Println(mygboxstring)
}
It helps a lot to reason about GenericBox
as a type awaiting an argument to construct a new type. So for instance in the case of GenericBox[int32]{obj:5}
as if GenericBox
was a function that generates a new type GenericBoxInt32
that can have more parameters to initialize
Output
{5}
{3.2}
{hello}
Let's add the constraints for numbers box only NumberBox
type NumberBox[T Number] struct {
obj T
}
func (nb NumberBox[T]) GetObject() T {
return nb.obj
}
but wait a second, where does that Number
type come from? Number
is an interface that we can declare as follows
type Number interface {
int32 | int64 | float32 | float64
}
Our main.go looks like this now
package main
import "fmt"
type Number interface {
int32 | int64 | float32 | float64
}
type NumberBox[T Number] struct {
obj T
}
func (nb NumberBox[T]) GetObject() T {
return nb.obj
}
func main() {
nbox5 := NumberBox[int32]{obj: 5}
fmt.Println(nbox5)
nbox3dot2 := NumberBox[float32]{obj: 3.2}
fmt.Println(nbox3dot2)
// nboxstring := NumberBox[string]{obj: "hello"}
// fmt.Println(nboxstring)
}
So now we have a generic code for a Box that works on int32, float32, int64, float64 only, and if you try to make it work against string
, you should see an error like string does not implement Number