Beginner's Guide to Generics in Golang
Generics in Go refers to a language feature that allows creating functions, data structures, and interfaces that can work with different types. In other words, generics enable the creation of code that is not limited to a specific type or data structure.
Before the introduction of generics in Go, developers had to write multiple functions to handle different types of data. This approach was often cumbersome and led to code duplication. With generics, developers can write more concise and reusable code that can handle different types of data.
Generics in Go were introduced in version 1.18, which was released in February 2021. The implementation of generics in Go is based on the concept of type parameters. Type parameters are placeholders for types that are passed as arguments to a function or a data structure, allowing them to work with different types of data.
Table Of Contents
- What are Generic types in Go?
- How Generics works in Go?
- What are Type Parameters?
- Using Type Parameters in Generics
- Type Constraints
- Examples of Using Generics in Golang
- Limitations of Generics
What are Generic types in Go?
Generic types (simply called Generics) are codes that allow us to use them for various functions by just altering the function types. Generics were created to make code independent of types and functions.
The main aim of generics is to achieve greater flexibility in terms of writing code with the addition of fewer lines.
To understand better, look at this example below. We make a function for printing any type parameter like this:
func Print(s[] string) {
for _, v: = range s {
fmt.Print(v)
}
}
Now, instead of printing a string, we suddenly wish to print an integer, so we change the code accordingly.
func Print(s[] int) {
for _, v: = range s {
fmt.Print(v)
}
}
But changing the code every time like this may seem daunting and this is where Generics come into play. By assigning any type in its generic form, we can make use of the same code for different functions. Take a look at this:
func Print[T any](s[] T) {
for _, v: = range s {
fmt.Print(v)
}
}
Here we have assigned "T" as an any
keyword. This any type allows us to parse different types of variables within the same function. S is the corresponding variable, which is a slice of T. Now, calling the method we can print a string and an integer together in the same function.
func main() {
str := []string{"Hello", "Again Hello"]
intArray := []int {1, 2, 3}
Print(str)
Print(intArray)
}
How Generics works in Go?
Generics in Go are implemented using type parameters, which allow for the creation of generic functions and data structures that can operate on different types without the need for explicit type conversions.
Consider this example where the type parameter "T" is defined using the "any" keyword, which specifies that any type can be used with this function.
func Swap[T any](a, b * T) { * a, * b = * b, * a
}
The function body then performs a simple swap of the values pointed to by the two pointers passed in as arguments.
When the function is called, the compiler generates a specific version of the function for the type that is used with the function. For example, if the function is called with two pointers to integers, the compiler generates a version of the function that operates on integers.
Generics in Go also support type constraints, which allow for more specific types to be used with generic functions and data structures. Type constraints are specified using the "interface" keyword, followed by the name of the interface and the methods that the type must implement.
For example, the following generic function defines a type constraint that requires the type parameter to be a slice of integers:
func Sum[T Slice[Int]](slice T) int {
sum: = 0
for _,
v: = range slice {
sum += v
}
return sum
}
Here, the Slice[Int]
constraint ensures that only slices of integers can be used with the "Sum" function.
We have seen how important Type Parameters and Type Constraints are in Generics. In the following sections, we will take a deeper look into their definition and working.
What are Type Parameters?
In Go, type parameters are specified using a type parameter list enclosed in square brackets immediately following the name of the function, data structure, or interface. The type parameters are represented by a single uppercase letter or a sequence of uppercase letters, enclosed in angle brackets.
Type parameters are used to create generic functions, data structures, and interfaces in Go. Type parameters are placeholders for types that are determined at compile-time.
For example, consider the above example that shows a function declaration that uses a type parameter. In this function, the type parameter is represented by the uppercase letter "T". The "any" keyword indicates that the function can work with any type. When this function is called, the type parameter is replaced with the actual type of the argument passed to the function.
Type parameters enable the creation of more generic and reusable code in Go by allowing functions and data structures to work with different types of data.
Using Type Parameters in Generics
In the example that we saw above, we saw how to incorporate more than one type of variable under the same function.
In the example, the function is declared with a type parameter "T" using the "any" keyword. The "any" keyword indicates that the function can work with any type. The function takes a slice of type "T" as an argument and prints its contents.
To use this function, you can call it with a slice of any type like what’s given below:
intSlice: = [] int {
1, 2, 3, 4, 5
}
stringSlice: = [] string {
"apple", "banana", "cherry"
}
PrintSlice(intSlice) // prints 1 2 3 4 5
PrintSlice(stringSlice) // prints apple banana cherry
In this example, the PrintSlice
function is called with a slice of integers and a slice of strings. The type parameter "T" is replaced with the actual types of the arguments passed to the function.
You can also create generic data structures and interfaces in Go using type parameters. Here's an example of a generic data structure, a stack, that uses a type parameter:
type Stack[T any] struct {
items[] 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")
}
item: = s.items[len(s.items) - 1]
s.items = s.items[: len(s.items) - 1]
return item
}
- Here, the Stack data structure is declared with a type parameter "T" using the "any" keyword.
- The Push method takes an item of type "T" as an argument and adds it to the stack.
- The Pop method returns an item of type "T" from the top of the stack.
To use this data structure, you can create a stack of any type:
intStack: = & Stack[int] {}
stringStack: = & Stack[string] {}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
stringStack.Push("apple")
stringStack.Push("banana")
stringStack.Push("cherry")
fmt.Println(intStack.Pop()) // prints 3
fmt.Println(stringStack.Pop()) // prints cherry
In this example, two stacks are created, one of type int and one of type string. The type parameter "T" is replaced with the actual types of the stacks create
Type Constraints
Type constraints in generics define the set of types that can be used with a generic function or data structure. Type constraints allow the compiler to enforce type safety and ensure that only compatible types are used with a generic construct.
Type constraints are specified using the "interface" keyword, followed by the name of the interface and the methods that the type must implement. For example, consider the following generic function that uses a type constraint:
func Max[T comparable](a, b T) T {
if a > b {
return a
}
return b
}
In this example, the type parameter "T" is constrained by the "comparable" interface, which requires that the type implements the comparison operators (>, <, >=, <=). This ensures that the function can only be called with types that support comparison.
comparable
is a built-in interface that is used to constrain generic type parameters to only those types that support comparison operators (<
, <=
, >
, >=
, and ==
).
The comparable
interface is implicitly defined by the Go language specification and is not required to be explicitly defined in code. This means that any type that supports comparison operators can be used as a type parameter for the Max
function without any additional declaration of the comparable
interface.
Type constraints can also be user-defined interfaces, which allow for more specific constraints on the types that can be used with a generic function or data structure. For example, consider the following user-defined interface:
type Number interface {
Add(other Number) Number
Sub(other Number) Number
Mul(other Number) Number
Div(other Number) Number
}
This interface defines a set of methods that a type must implement in order to be considered a "Number". A generic function or data structure that uses this interface as a type constraint can only be used with types that implement these methods, ensuring type safety and compatibility.
Type constraints in generics in Go provide a way to ensure type safety and limit the set of types that can be used with a generic construct, while still allowing for the flexibility and reusability that generics provide.
Examples of Using Generics in Golang
Here are some examples of using generics in Go:
1. Generic functions:
This function takes a slice of any type T and a value of type T and returns the index of the value in the slice. The any
keyword in the type parameter specifies that any type can be used.
func findIndex[T any](slice []T, value T) int {
for i, v := range slice {
if reflect.DeepEqual(v, value) {
return i
}
}
return -1
}
2. Generic types:
This defines a generic stack type that can hold elements of any type T. The any
keyword specifies that any type can be used as the element type.
type Stack[T any] []T
func (s *Stack[T]) Push(value T) {
*s = append(*s, value)
}
func (s *Stack[T]) Pop() T {
if len(*s) == 0 {
panic("Stack is empty")
}
value := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return value
}
3. Constraints on type parameters:
This defines a type constraint on the type parameter T that requires it to implement the Equatable
interface. This allows the findIndex
function to use the Equals
method to compare values of type T.
type Equatable interface {
Equals(other interface{}) bool
}
func findIndex[T Equatable](slice []T, value T) int {
for i, v := range slice {
if v.Equals(value) {
return i
}
}
return -1
}
These are just a few examples of how generics can be used in Go to write more flexible and reusable code.
Limitations of Generics
Although generics in Go have brought many benefits and new possibilities to the language, there are still some limitations and challenges that come with their implementation. Here are some of the main limitations of generics in Go:
- Performance: One of the main concerns with generics in Go is the potential impact on performance. With the introduction of generics, the Go compiler needs to generate code for different types at compile time, which can lead to larger binaries and slower compilation times.
- Type constraints: Go's implementation of generics relies on type constraints to ensure type safety. However, these constraints can be restrictive and limit the types that can be used with generic functions and data structures.
- Syntax complexity: The syntax for declaring and using generic functions and data structures can be complex and difficult to understand, especially for beginners.
- Error messages: The error messages generated by the Go compiler for issues related to generics can be difficult to understand, making debugging and troubleshooting more challenging.
- Code readability: Generics in Go can sometimes make code less readable and harder to understand, especially if type constraints and type parameters are used extensively.
- No switching possible: When you want to switch from one underlying generic type to another, it is not possible using generics. The only way to go about this is to use an interface and run the type switch function at runtime.
func is64Bit[T Float](v T) T {
switch (interface {})(v).(type) {
case float32:
return false
case float64:
return true
}
}
Final Thoughts
Generics provide a powerful yet simple method for creating generic interfaces, methods for structs, and functions.
They enable a reduction in redundant information and, at least in some instances, provide a superior alternative to reflection. Of course, the primary reason generics were vehemently opposed for a considerable amount of time was that they could make code more difficult to read and parse, which would appear to go against the simplicity of Go.
Generics, on the other hand, are an excellent and necessary addition to the language if used wisely and where it makes sense.
Monitor Your Go Applications with Atatus
Atatus provides developers with insights into the performance of their Golang applications. By tracking requests and backend performance, Atatus helps identify bottlenecks in the application and enables developers to diagnose and fix errors more efficiently.
Golang monitoring captures errors and exceptions that occur in Golang applications and provides a detailed stack trace, enabling developers to quickly pinpoint the exact location of the error and address it.
We provide flexible alerting options, such as email, Slack, PagerDuty, or webhooks, to notify developers of Golang errors and exceptions in real-time. This enables developers to address issues promptly and minimize any negative impact on the end-user experience.