Golang Generics: The Future of Go Read it later

5/5 - (4 votes)

The wait is finally over as Go has introduced its much-anticipated feature, Golang Generics, with the release of version 1.18. We’re thrilled to dive into this fascinating addition to the language and explore how Go has implemented generics. Join us on this journey as we unravel the inner workings of Golang Generics and witness the impact it can have on your code. So without further ado, let’s embark on this adventure together!

Understanding The Problem

To properly understand the concept of Golang Generics, it is essential to understand the problem they aim to solve. Let’s explore this by considering a simple example.

Imagine we have a simple Add() function that takes two integers and returns their sum:

func Add(a, b int) int {
    return a + b
}

func main() {
    fmt.Println(Add(1, 2))    // Output: 3
}

This function works perfectly fine for integers, but what if we want it to accept float values as well? If we try to use a float value with our Add() function, we encounter an error:

func main() {
    fmt.Println(Add(1.1, 2))    // Error: cannot use 1.1 (untyped float constant) as int value in argument to Add
}

Now, you might suggest using interfaces to solve this problem, but there’s a catch. We only want the Add() function to accept integer and float values, not any other types. Unfortunately, achieving this level of specificity is not possible with interfaces alone.

This is where Golang generics come into play.

What’s New in Go 1.18?

In the highly anticipated release of Go version 1.18, which followed seven months after the previous version 1.17, the language introduced a game-changing feature: generics. Fulfilling its compatibility promise, Go 1.18 ensures backward compatibility with Go 1, ensuring a smooth transition for developers.

So, what exactly do these generics bring to the table? Let’s delve into the three major advancements that accompany this release:

  1. Defining Interface Types as Sets of Types
  2. Type Parameters for Functions and Types
  3. Type Inference

Exciting, isn’t it? So, fasten your seatbelts and get ready to witness the power of generics in Go 1.18!

What is a Generic Type?

In programming, a generic type is a type that can be used with multiple other types, allowing for code reusability and flexibility.

It serves as a template, enabling you to write functions and data structures that work seamlessly with various data types without duplicating code.

Generics Overview

In order to grasp the concept of generics in Go more effectively, let’s take a moment to gain an overview of how other programming languages, such as C++ and Java, implement generics. By comparing and contrasting these implementations, we can better understand the unique approach that Golang brings to the table.

In C++, generics are achieved through templates, which allow generic functions and classes to be created. Templates enable the definition of algorithms and data structures that can work with multiple types without sacrificing type safety.

template <typename T>
void swap(T& a, T& b) {
    T temp = a;
    a = b;
    b = temp;
}

Here, T represents the generic type parameter, and it allows us to write a single swap function that works for various data types.

Java, on the other hand, introduced generics with the release of Java 5. Generics in Java are implemented using type erasure, a technique where type information is removed at runtime, making generics a compile-time feature.

public class Box<T> {
    private T value;
    
    public void setValue(T value) {
        this.value = value;
    }
    
    public T getValue() {
        return value;
    }
}

In this example, T is the type parameter that allows the Box class to work with different types of values.

Now, let’s shift our focus back to Go and explore its take on generics.

Prerequisites

To get started with this tutorial, you’ll need:

  1. Go version 1.18 or greater installed: Make sure you have Go installed on your system, preferably version 1.18 or a newer release.
  2. Solid understanding of Go basics: Ensure that you have a good understanding of Go’s fundamental concepts, including struct types, for loops, slices, and interfaces.

With these prerequisites fulfilled, we’re all set to dive into the fascinating world of Golang generics. Let’s begin our coding journey together!

Golang Generics Syntax

In the world of Go, generics have made their grand entrance starting from version 1.18, adding a whole new level of flexibility and power to the language. Now, let’s dive into the syntax of Golang generics, exploring how they can be used with functions and types.

Golang Generic Function Syntax

To define a generic function in Go, we utilize the following syntax:

func FunctionName[T Constraint](a, b T) T {
    // Function Body
}

In the above syntax, FunctionName represents the name of our generic function. The square brackets [T Constraint] indicate the use of a type parameter T, which can be any type constraint.

Inside the function parentheses (a, b T), we define the input parameters a and b of type T. The return type of the function is also T, denoted after the parentheses. You can replace T with any valid identifier that fits your needs.

To gain a clearer understanding of each component of the Golang generics syntax, refer to the accompanying image that breaks down each element visually.

Golang Generics Function Syntax

Golang Generic Type Syntax

Similarly, we can use generics when defining types in Go. The syntax for a generic type declaration is as follows:

type Number[T Constraint] interface {
    Method(T) T
}

type Student[T Constraint] struct {
    Name T
}

In the syntax above, we showcase two types: Number[T Constraint] and Student[T Constraint]. Here, Number is an interface that specifies a method Method which takes a parameter of type T and returns a value of the same type. The Student struct type, on the other hand, has a single field Name of type T.

Golang Generics Type Syntax

Working with Golang Generics

Now that we have familiarized ourselves with the syntax of Golang generics, it’s time to dive into their practical usage. Let’s explore how we can employ Golang generics to solve a common problem.

Let’s build upon the example we discussed in the “Understanding The Problem” section. Imagine we want to create an Add() function that can handle both integer and float values, providing the sum as the result.

func Add[T int | float64](a T, b T) T {
	return a + b
}

func main() {
	fmt.Println(Add(3, 4))       // Output: 7
	fmt.Println(Add(3.1, 4.1))   // Output: 7.199999999999999
}

In the above example, we define the Add function using generics. The function signature includes a type parameter T, which represents the type of the operands and the return value. We specify that T can be either int or float64 using the constraint [T int | float64].

Now that we have examined a complete working example of Go generics, let’s dive into each component and explore in-depth knowledge about this topic.

How Golang Generics Work?

Working with practical examples is fun, but understanding the underlying concepts is even more exciting. In this section, we will delve into the inner workings of Golang generics. We will dissect each component and explore how they are composed and function together.

In the previous section, we encountered terms like type parameters and type constraints. However, there are a few more concepts to grasp, such as type argument, type instantiation, type sets, and type inference. This section will provide a detailed explanation, and it’s possible that some aspects may not immediately make sense. But fear not! We will cover these topics comprehensively in upcoming sections, and you can always revisit this section for a complete understanding.

Go Generics Example

To better understand how Golang generics work, let’s continue with the example from the previous section:

// Generic Function
func Add[T int | float64](a T, b T) T {
	return a + b
}

func main() {
	add := Add[int]    // Type instantiation
	fmt.Println(reflect.TypeOf(add)) // Output: func(int, int) int
	fmt.Println(add(1, 2))           // Output: 3
}

In this updated example, the Add() function remains the same, but the way we call the function has changed. Now, pay close attention:

When we write Add[int], this part is known as type instantiation, where we assign a specific type as a type argument. It instructs the compiler to generate a concrete version of the generic function for the specified type.

Next, we use the reflect package to check the type of the type-instantiated function variable. As you can see from the output, the type instantiation converts the generic function into a normal Go function. In this case, the type of add is func(int, int) int, which is then used for function calls as usual.

But you might wonder, what about the earlier example where we skipped the type assignment and instantiation part, how did it work? The answer lies in type inference. In that scenario, the types of the type arguments were automatically inferred by the compiler, allowing the generic function to work without explicit type instantiation. Don’t worry if this concept is still a bit confusing; we will dive into type inference in detail later.

Golang Interface as Type Sets

In the latest release of Go 1.18, there have been some changes to the definition of interfaces. After Go 1.18, interfaces in Golang now can define a set of types as well as a set of methods. These changes were made to support type constraints in Golang Generics. Let’s dive into understanding these changes and what we need to grasp in order to master Golang Generics.

You might be wondering why these changes were made. To understand the reason, let’s consider an example. Suppose we want to create a generic comparison function. The code might look something like this:

func Compare[T any](a, b T) bool {
	return a == b      // invalid operation: a == b (incomparable types in type set)
}

func main() {
	println(Compare(1, 2))
}

But hold on a second! Do you notice any problem here? The type parameter’s constraint is bound with the any type, which means the Compare function can accept any type as its argument. However, if we pass a slice, can it be compared using the == operator? No, because slices do not support the == operator. So, here’s the reason we need to find a way to create a set of types that will be used as constraints, and that’s where type sets come into play.

Type sets are simply interfaces that contain one or more sets of types using the | (Union) operator.

Let’s take an example to illustrate this concept:

// Type Set
type Num interface {
	int | float32 | float64 | string | bool
}

func Compare[T Num](a, b T) bool {
	return a == b
}

func main() {
	println(Compare(1, 2))         // false
	println(Compare(1, 1))         // true
	println(Compare(1.1, 1.1))     // true
	println(Compare(1.1, 2.2))     // false
	println(Compare("1", "2"))     // false
}

In this example, we created a type set called Num using the | (Union) operator to combine multiple types. Now, our generic function works properly because we have ensured that the type set only contains types that support the == operator.

The Underlying Type Element

In Go 1.18, a new operator called ~ (tilde) has been introduced. This operator plays a significant role and is known as the underlying type operator. Now, let’s delve deeper into understanding the underlying type operator and its relevance in Golang generics.

You might wonder, what is the need for the underlying type? To answer this question, let’s consider an example that builds upon our previous discussion:

type Num interface {
	int | float32 | float64 | string | bool
}

func Compare[T Num](a, b T) bool {
	return a == b
}

func main() {

	type Integer int

	var a Integer = 1
	var b Integer = 2

	println(Compare(a, b))
}
Explanation

While the core logic of the example remains the same, the way we pass the values as arguments has changed. Now, the question arises: will it work correctly? The output we get is:

Integer does not satisfy Num (possibly missing ~ for int in Num)

Why did we encounter this error? After all, Integer is a type of int, and the Num type set contains int. So, why do we get this error?

The answer lies in the fact that the type parameter of the Compare function is bound to the Num type set. Consequently, it only allows types that are explicitly defined within the type set. However, you might argue that since Integer has an underlying type of int, it should be allowed. This is where the underlying operator (~) comes into play.

To accept any type as an argument whose underlying type matches the set of types defined within the type set, we need to use the ~ operator. It allows us to explicitly look for underlying types that correspond to the types defined in the type set.

Using Underlying Type Element

Let’s improve our example by incorporating the underlying operator:

// Type Set
type Num interface {
	~int | ~float32 | ~float64 | ~string | ~bool
}

func Compare[T Num](a, b T) bool {
	return a == b
}

func main() {
	type Integer int
	type Str string
	type Double float64

	var a Integer = 1
	var b Integer = 2

	var s1 Str = "Hello"
	var s2 Str = "Hello"

	var d1 Double = 1.1
	var d2 Double = 1.2

	println(Compare(a, b))   // false
	println(Compare(s1, s2)) // true
	println(Compare(d1, d2)) // false
}

By utilizing the underlying operator (~), we have modified the Num type set to consider the underlying types of the specified types. This improvement enables us to pass arguments whose underlying types match the types defined within the type set.

In this improved example, we see that the Compare function can now correctly compare values of types like Integer, Str, and Double. The underlying operator (~) allows the flexibility to work with underlying types, expanding the capabilities of generics in Go.

Type Parameters

In the earlier sections, we touched upon the concept of type parameters in Golang generics. Now, let’s dive a little deeper to enhance our understanding of this crucial aspect.

Type parameter serves as an abstract data type in generic code. It allows us to write code that can work with different types without sacrificing reusability and flexibility. You can think of type parameters as placeholders that will be replaced with actual types during the type instantiation of the generic code.

Union Constraint Elements

In some cases, we may need to impose multiple constraints on the type parameters. Golang allows us to achieve this by using the union | operator. For instance, when defining generic functions or types, we can specify multiple constraints using the | operator:

func F[T int | float64 | string](a, b T) T {...}

Similarly, when working with types, we can define a type interface or a struct with multiple constraints:

type Number interface {
    int | float64 | string
}

type Num[T int | float64 | string] interface{}

type Student[T int | float64 | string] struct {
    Name T
}

Multiple Type Parameters

Just as a regular function can have multiple parameters, type parameters can also be defined in multiples. This allows us to work with multiple types simultaneously.

Let’s take a look at an example:

func Add[T int32 | float32, S int64 | float64](a T, b S) S {
	return S(a) + b
}

func main() {
	fmt.Println(Add[int32, float64](3, 5.5))   // Output: 8.5
}

In this example, the Add function takes two type parameters T and S, which can be either int32 or float32 for T, and int64 or float64 for S. The function performs addition and returns the result of type S.

Type Parameters Reference

A fascinating concept of type parameters in Go generics is the ability to reference any type parameter that has not been defined yet. This feature adds flexibility and enables you to create generic functions that can work with multiple types, even if they are related to each other within the same function.

Let’s consider an example to illustrate this concept:

func PrintList[S []T, T any](list S) {
    for _, v := range list {
        println(v)
    }
}

func main() {
    PrintList([]int{1, 2, 3})
    PrintList([]string{"a", "b", "c"})
}

In the above example, we have a generic function called PrintList. It takes two type parameters: S and T. The type parameter S represents a slice ([]) of elements of type T. The second type parameter, T, is defined as any, indicating that it can be any type.

What’s interesting here is that the first type parameter, S []T, references the second type parameter, T any. This reference allows the function to determine the type of the elements in the slice based on the provided type parameter T.

Using type parameter reference, the PrintList function can be called with different types of slices. In the main function, we demonstrate its usage by calling PrintList with both []int and []string slices.

This dynamic referencing of type parameters showcases the flexibility and versatility of generics in Go. It enables you to create generic functions that adapt to various types and provides a concise and reusable solution.

Type Constraints

In the previous section, we explored type parameters, but our understanding wouldn’t be complete without delving into the world of type constraints. So, let’s dive right in!

What is a type constraint?

Type constraint also known as meta-type, is an interface that defines a set of rules and what type of values can be used as arguments for a specific type parameter.

If that definition seemed a bit overwhelming, don’t worry! Sometimes concepts become clearer when we dig deeper and understand the underlying concepts. And that’s precisely what we’ll do here.

To illustrate the concept of type constraints, let’s consider the following example:

func Add[T int](a, b T) T {
    return a + b
}

func main() {
    println(Add(1, 2))
}

In this example, we’ve used T int as the constraint for the type parameter. However, according to the definition we just discussed, a type constraint is actually an interface. So, it can also be written as:

func Add[T interface{ int }](a, b T) T {}

Using Union Operator:

func Add[T interface{ int | float64 }](a, b T) T {}

But hold on! While these alternative ways of expressing constraints are technically valid, they can compromise the readability of your code. So, it’s generally recommended to stick with the first form for the sake of clarity.

The Comparable Constraint

In the “Type Set” section, we discussed the need to define a set of types that support comparison operators when working with generics in Go. However, the good news is that the Go developers have introduced a convenient built-in type parameter constraint called “comparable“.

This constraint allows developers to utilize existing Go types that inherently support comparison operators without the need for additional type sets.

To illustrate its usage, let’s consider the same example and using comparable type constraint:

func F[T comparable](a, b T) bool {
	return a == b
}

func main() {
	fmt.Println(F(1, 2))            // false
	fmt.Println(F("a", "a"))     // true
	fmt.Println(F([]int{1, 2, 3}, []int{1, 2, 3}))   // Error: []int does not satisfy comparable)
}

In this example, the generic function F uses the comparable constraint by specifying [T comparable] as a type parameter. This constraint ensures that the types passed to the function must support comparison operators, such as ==, >, < etc.

Type Argument and Type Instantiation

Cool, so now that we have a good understanding of type parameters and type constraints, it’s time to dive deeper into the exciting world of type argument and instantiation. Understanding how the type instantiation works is essential to prepare yourself for the thrilling battle of Golang Generics.

What is Type Instantiation?

Type instantiation is the process of substituting type arguments for the type parameters in a generic function or type. It is the process that transforms generic code into specific, non-generic code specifically tailored to our needs.

Let’s take a look at an example to make things clearer:

add = Add[float64]
fmt.Println(reflect.TypeOf(add)) // Output: func Add(a, b float64) float64 {}

In this example, we define the “type argument” as float64, which replaces the “type parameter” in the generic function. As a result, the generic function becomes a non-generic function, as indicated by the output of the reflect package.

Type Instantiation Steps

Type instantiation involves two crucial steps:

  1. Substituting Type Arguments: Each type argument is substituted for its corresponding type parameter throughout the generic declaration. This substitution encompasses the type parameter list itself and any other types associated with it.
  2. Satisfying Type Constraints: After substitution, each type argument must satisfy the constraint of the corresponding type parameter. If a type argument fails to satisfy the constraint, instantiation fails.

Rules of Type Instantiation

To make things even more interesting, let’s explore some rules regarding type instantiation in Golang generics:

  1. Type Argument Required: For a generic function that isn’t called, you need to provide a type argument list for successful instantiation. It’s a necessary ingredient to unlock the power of generics.
  2. Partial Type Arguments are Allowed: In some cases, you can provide a partial type argument list, leaving some arguments unspecified. However, any remaining “type argument” must be inferrable based on the context.
  3. Partial Type Argument List Cannot be Empty: Remember, a partial type argument list cannot be empty. You need to include at least the first argument to initiate the process.
  4. Right to Left Type Argument Omission: Here’s an interesting twist – when omitting type arguments, you have the freedom to do it from “right to left“. In other words, you can omit type arguments in reverse order, starting from the rightmost parameter.

Let’s take a look at an example to illustrate this rule:

func F[T []V, S []T, V int](list T, matrix S, size V) {
    // Function body here
}

func main() {
    // Explicitly defined Type Argument for Type Instantiation 
    f := F[[]int]       // Partial Type Argument
    // Rest of the code
}

In this example, we’ve omitted two “type arguments” in the “right to left” order. It’s just one of the many intriguing possibilities that Golang generics offer.

Type Inference

In the previous section, we briefly touched upon the concept of type inference. Now, let’s take a deeper dive into this topic.

What is Type Inference?

Type inference is the process of determining the correct types of type arguments when they are not explicitly provided by the user. It plays a crucial role in working with generic functions or types. Without proper type inference, the user will have to do extra work of explicitly assigning type arguments for type instantiation.

Key Components of Type Inference

In Go, type inference involves several components that work together to deduce types in a concise manner. Let’s explore these components briefly:

  1. Type Parameter List: This list defines the unknown types as placeholders for inference. They represent the types that need to be determined based on the context.
  2. Substitution Map: The substitution map (M) keeps track of known type arguments for the type parameters. It is updated as type arguments are inferred.
  3. Ordinary Function Arguments: For function calls, typed ordinary function arguments undergo function argument type inference to determine their types based on given values.
  4. Constraint Type Inference: This step analyzes constraints on generic types to narrow down the possible types assignable to type parameters.
  5. Default Type Inference: For untyped ordinary function arguments, default types are used to infer their types during function argument type inference.

The process continues until the substitution map has a type argument for each type parameter or until an inference step fails. Failed steps or incomplete substitution maps indicate unsuccessful type inference.

Types of Type Inference

There are two types of type inference that we’ll explore: function argument type inference and constraint type inference.

Function Argument Type Inference

As the name suggests, function argument type inference is concerned with inferring the types of a function’s arguments. This type of inference occurs when the “type arguments” are determined based on the types of the function’s arguments.

Let’s consider an example to illustrate this:

func Add[T int | float32](a, b T) T {
	return a + b
}

func main() {
	println(Add[int](1, 2)) // Explicit type argument
	println(Add(1, 2))      // Type inference (No type argument)
}

In the above code snippet, we have a generic function called Add. It takes two arguments, a and b, of type T, which can be either int or float32. In the main function, we demonstrate both explicit type argument usage and type inference. In the second println statement, type inference is used, as we don’t explicitly specify the type argument for Add.

Constraint Type Inference

Constraint type inference, on the other hand, involves inferring type arguments based on type constraints.

Let’s consider an example that demonstrates constraint-type inference:

type Num interface {
	int | float64
}

type IdConstraint interface {
	int | ~string
}

type Student[I IdConstraint, T Num] struct {
	ID         I
	TotalMarks T
}

func IncreaseMarks[S []Student[string, T], T Num](studentList S, marksToIncrease T) S {
	for index, _ := range studentList {
		studentList[index].TotalMarks += marksToIncrease
	}

	return studentList
}

func main() {
	student := []Student[string, int]{}
	student = append(student, Student[string, int]{"1", 90})
	student = append(student, Student[string, int]{"2", 80})
	student = append(student, Student[string, int]{"3", 70})

	s := IncreaseMarks(student, 10)
	for _, v := range s {
		println(v.ID, v.TotalMarks)
	}
}

In this example, we define two constraints: Num and IdConstraint. These constraints are utilized as type parameters in the Student struct type. We also define the IncreaseMarks function, which takes a list of Student structs, the marks to increase, and returns the updated slice of students. To make this function generic, we introduce type parameters.

In the main function, we create a slice of Student structs and append data for three students. We then call IncreaseMarks(student, 10) with the student slice and the marks to increase. Finally, we print the IDs and total marks of the students in the returned slice.

In this example, we haven’t explicitly used type arguments for type instantiation. However, the type is inferred from the constraints specified in the function and the constraints of the function arguments. This is known as type constraint inference.

Output:

1 100
2 90
3 80

Wrapping Up

In conclusion, we’ve explored the vast potential of Golang generics to enhance code flexibility and reusability. From mastering the basics to diving into advanced techniques and real-world use cases, you now have the tools to create adaptable and scalable code. Embrace the power of generics in your projects, and enjoy the benefits of concise, maintainable, and future-proof code. Happy coding and may your journey with Golang be filled with endless possibilities!

Frequently Asked Questions (FAQs)

Does Golang have generics?

Yes, Golang now has generics! Generics were introduced in Go 1.18 and have been eagerly awaited by the Go community. This new feature brings increased flexibility and reusability to the language, allowing developers to write more generic and concise code that can work with different types.

What are generics in Golang?

Generics in Golang refer to the ability to write code that is flexible and can operate on various types. With generics, you can create functions and data structures that are not tied to a specific type but can be used with multiple types.

How are generics implemented in Golang?

Generics in Golang are implemented through type parameters. Type parameters act as placeholders for specific types that will be determined when the generic code is used. These type parameters enable you to write code that can adapt to different types dynamically.

Is Go Generics slow?

No, Go Generics are not inherently slow. While there may be some performance considerations when using generics, such as type checking and code specialization overhead, the impact on performance is generally minimal.

Why do we need generics in Golang?

Generics are highly valuable in Golang because they provide increased code flexibility and reusability. By using generics, you can write more generic and concise code that adapts to different types. This eliminates code duplication and reduces the need for writing specialized functions or data structures for each specific type.

Reference

Was This Article Helpful?

Leave a Reply

Your email address will not be published. Required fields are marked *