Introduction to Go Generics

Generics arrive!!!

Introduction to Go Generics

Photo by Tolga Ulkan on Unsplash

Go 1.18 arrived with the added support for "generics".

Okay. But what are generics?

So generics are a way of writing code that is not restricted with the specific types being used.

But we could already use interface{} and have type casting.

Yes, we could. But just think of a function which can accept and/or return interface{}. We need to cast the values to some type before we can do anything meaningful with it in most of the cases. Let's take a look at the following example which uses interface{} and type casting.

In the example we will have a function which will find the sum of all the elements of a slice of numbers. For keeping the example simple, we will consider only 2 data types int64 and float64.

func FindSum(arr interface{}) (interface{}, error) {
    switch v := arr.(type) {
    case []int64:
        arr := arr.([]int64)
        var sum int64
        for _, x := range arr {
            sum += x
        }
        return sum, nil
    case []float64:
        arr := arr.([]float64)
        var sum float64
        for _, x := range arr {
            sum += x
        }
        return sum, nil
    default:
        return nil, fmt.Errorf("invalid slice passed of type : %v", v)
    }
}

Our function is simply determining what type of slice has been passed and then it returns the result and an error. The error is just in case some unknown slice type is passed which our function doesn't knows how to handle.

By going through the above function, we can easily see that adding support for more data types is going to become another problem.

For the interested folks, it also violates the open-close principle and single responsibility principle.

Okay, got your point, but we can clean this up pretty easily by creating different functions for different data types.

Yes, we can do that but then our calling function needs to know which function to call depending on the data type. This does solves the open-close principle and single responsibility principle problems for our function which finds the sum.

But, just assume that you have a slice of type []int32 and now let's recall the famous saying "in this world, change is the only constant". Similarly, we might need to change the data type to []float32 or []int64 for some reason in the future. This one change will probably force us to make the change at each and every place where that value is being used anywhere in the whole project. Thus this simply raises different design pattern and maintainability issues with it.

Got your point, but you win some, you lose some. We have to make compromises somewhere.

Yes, we got to make compromises somewhere. But this problem can be solved without making a compromise.

Generics to the rescue.

Finally. How do we use generics here?

Patience my young padawan. First, let's see what do these "generics" add here.

Generics add three new big things to the language:

  • Type parameters for function and types.
    func FindSum[T int64 | float64] (arr []T) T { ... }
    

In the above snippet, T is the type set comprising of int64 and float64 types. This means that T can be of the types int64 or float64. Which means the function can accept types []int64 and []float64. The return type is of the same type set T, which will be the sum of the elements in the slice.

  • Defining interface types as sets of types, including types that don’t have methods.

We are now allowed to define interface types as sets of other types.

type Numbers interface {
  int64 | float64
}

Here we have basically created a new type called Numbers which is a set of types int64 and float64 types.

So now, our function declaration can be cleaned up in the following way:

func FindSum[T Numbers] (arr []T) T { ... }

The function means exactly the same thing as discussed earlier.

  • Type inference, which permits omitting type arguments in many cases when calling a function.

Now, generics say that a good way of calling a function which takes in the type parameter is to also pass the type, in this case T. Like in the example below:

sumInt := FindSum[int64]([]int64{1,2,3,4,5})
// or
sumFloat := FindSum[float64]([]float64{1.1,2.2})

//doing this will throw a compilation error
// sumFunky := FindSum[int64]([]float64{1.1,2.2})

but, go compiler is smart enough most of the times to figure out the type without needing the developers to pass the types every time the function is called. For example:

sum := FindSum(arrInt64OrFloat64)

Woah, generics seem pretty cool.

Oh they are pretty cool. But only if we use them wisely. Generics support doesn't means that you start rewriting your entire project and forcefully use generics everywhere. A good developer needs to understand where to use which design patterns.

Now, let's get into the function implementation and we will see how to call the function and get the sum.

func FindSum[T Numbers](arr []T) T {
    var sum T
    for _, x := range arr {
        sum += x
    }
    return sum
}

func main(){
    arr := []int64{1, 2, 3, 4, 5}
    // arr := []float64{1.1, 2.2}
    sum := FindSum(intArr)
    fmt.Println(sum)
}

The generic function is really very small and looks pretty clean and cool.

Yes. That's the coolness of generics for this particular purpose.

The sweet part is that the above code follows single responsibility principle and open-close principle.

Got it. But what if we need to add support for more data types, like int32 and float32.

So this is where the type Numbers help us keep our code cleaner. We just have to add the new types in the new type set we created.

type Numbers interface {
  int64 | float64 | int32 | float32
}

Ah, cool. We didn't have to make any change in the function declaration of implementation.

So this is a very basic introduction to Go Generics. For further reads, you can refer to the following articles