Golang learning journal — (5) A clear guide to when to use Generics

Shelly
9 min readAug 21, 2023

--

Photo by Chinmay B on Unsplash

Introduction

Hi there! I am Shelly, a non-CS background developer since 2020. This article belongs to my Golang learning journal series. For all the related articles, you can find the links in the table of contents at the very first article of this series.

In Go, functions, interfaces, and the newly introduced generics enable polymorphism and code reusability. However, how can we know when to use what? Below chart is a simple guideline.

P.S. “Implementation” means the code logic defined in the function.

P.S. When the signature is not the same, we simply can not combine two functions. Hence, in the table, we only discuss cases, when signatures are the same.

Before we dive into the comparison, let’s first look at the syntax of the generics.

Syntax with Example

The “generic” type should be defined in square brackets [] right after the function name.

Afterwards, we can use T a placeholder type to represent the concrete/interface input type or concrete/interface output type.

First Example

[T any] : here, T is so-called type parameter , and T can be ofany type. At the example of a, b , T represents int type.
At the example of Hello World , T represents string type.

By the way, if you are confused about this question:

Isn’t any equal to interface{} ? Well, it’s not exactly the same. Any is more powerful than interface{} when used in combination of generics .

The any after the T is a type. any type was introduced alongside generics in Go 1.18, providing a more flexible alternative to interface{} in the context of generic. Unlike interface{}, generic functions using any can operate on type parameters without the need for runtime type assertions, leading to safer and more efficient code."

func Swap[T any] (a, b T) (T, T) {
return b, a
}
func main() {
a, b := 1, 2
x, y := "Hello", "World"

a, b = Swap(a, b)
x, y = Swap(x, y)

fmt.Println(a, b) // Outputs: 2 1
fmt.Println(x, y) // Outputs: World Hello
}

Second Example

[T canShout] : here, T is still a type parameter , and it’s of an interface type canShout .

type canShout interface{
Shout() string
}
type crazyPerson struct {
words string
}
func (p crazyPerson) Shout() string {
return p.words
}
type crazyDog struct {
sounds string
}
func (p crazyDog) Shout() string {
return p.sounds
}
func PrintShoutingSounds[T canShout](individual T) {
fmt.Println(individual.Shout())
}
func main() {
a := crazyPerson{words: "haha"}
PrintShoutingSounds(a) // haha
d := crazyDog{sounds: "wonwon"}
PrintShoutingSounds(d) // wonwon
}

When to use what

Now that we understand the syntax and usage of the generics type, let’s come back to the core question: when do we use generics?

This table gives you a clear answer:

As we can see from the above table, interface allows developer to decide what happens in the function (the implementation , i.e. the behaviour) freely, as long as the signatures of the functions is the same as the interface’s required functions’ signatures.

Generics are to be used when implementation is exactly the same but with different input or output types. However, these different input or output types can be of the same “interface” type.

Hence, the combination of interface and generics is also common. As we see from the example above, PrintShoutingSounds() is a generic function that accepts a type parameter T, which must satisfy the canShout interface.

Before Generics is released in Go 1.18 in 2022, developer often used interfaces in combination with type assertions or reflect package, to achieve similar outcome as Generics. E.g.

func PrintLength(items interface{}) {
nums, ok := items.([]int)
if ok {
fmt.Println("asserted successfully:", nums)
} else {
fmt.Println("Failed to assert")
}
fmt.Println(len(list))
}

However, as we see above, we need type assertion (the line nums, ok := items.([]int)is the type assertion) to make sure that the items are a list of integers, or it will panic.

With generics, we can now build the same function without needing to assert the types:

func PrintLength[T any] (items []T) {
fmt.Println(len(items))
}

Hence, generics assure a more type-safe way.

Types asserted from interface are checked at runtime, wherease types used with generics are checked at compile-time. This means that when using generics, type mismatches or violations will be caught by the compiler, making our code more type-safe. When we use go run, which first compiles and then runs our program, any type-related issues with generics will be identified immediately during the compilation phase, enabling us to catch and address errors early in the local development process.

Four scenarios with examples

  1. Signature’s variable types are all the same concrete types, but with different implementations. -> we use interface , as we need to define cal() twice to meet different implementation logics.

type calculator interface {
cal(int) int
}
type plus1Calculator struct{}
type plus2Calculator struct{}

func (plus1Calculator) cal(input int) int {
return input + 1 // different from the implementation of next cal() definition
}

func (plus2Calculator) cal(input int) int {
return input + 2
}

func main() {
c1 := plus1Calculator{}
c2 := plus2Calculator{}
fmt.Printf("%d", c1.cal(0)) // print out 1
fmt.Printf("%d", c2.cal(0)) // print out 2
}

2. Signature’s variable types are all the same concrete types, and with the same implementations. -> we use shared function , as below c1 and c2 both use a shared function cal().

type calculator interface {
cal(int) int
}
type times10Calculator struct{}

func (times10Calculator) cal(input int) int {
return input * 10
}

func main() {
c1 := times10Calculator{}
c2 := times10Calculator{}
fmt.Printf("%d", c1.cal(1)) // print out 10
fmt.Printf("%d", c2.cal(1)) // print out 10
}

3. One of the signature’s variable type is of the different concrete types, and they also have different implementations.

This is however, by definition impossible, because when it has the same signature, but diff. concrete type, then it can only use “Generics”.
However, when using “Generics”, the implementation *should* be the same.
Hence, this category will simply belong to the “different signature” group, where we can not really combine the two functions together.

4.1. Same implementation, but different input/output concrete types who does not belong to the same interface-> usegeneric + concrete type
One of the signature’s variable type is of the different concrete types, but they have the same implementations. -> we use generics , like combine() function below.

func combine[T any](input1 T, input2 T) {
fmt.Println(input1)
fmt.Println(input2)
}

func main() {
combine("A", "B") // print out A B
combine(3, 4) // print out 3 4
}

4.2. Same implementation, but different input/output concrete types who belong to the same interface -> use generic + interface type
We have demonstrated with thePrintShoutingSounds() example above, where PrintShoutingSounds() is a generic function that accepts a type parameter T, which must satisfy the canShout interface.

Restrictions

Generics is powerful, but it is also not perfect. Below are the two restrictions I found during the journey using generics.

1. Can not access struct field on the type parameter

We have learned at, if a type is declared inside [] , it’s called type parameter . However, according to this stackoverflow answer which quotes the Go 1.18 release note. The Go compiler does not support accessing a struct field x.fwhere x is of type parameter type even if all types in the type parameter's type set have a field f. We may remove this restriction in Go 1.19.

In other words, below, we can not access x.f .

type x struct {
f string
}

func PrintF[T any](x T) {
fmt.Println(x.f) // x.f undefined (type T has no field or method f)
}
func main() {
xInstance := x{f: "Fly"}
fmt.Println(xInstance.f) // will print Fly
PrintF(xInstance) // error
}

And it seems like now at 1.21 (as of August, 2023)…this issue is still not fixed.

2. More need for manual type inference

In the previous examples, we only specify that the types need to be generic type, when we define the functions, because providing the type argument when calling the generic functions is often unnecessary due to type inference.

However, in some cases, we need to specify types when calling the functions. Otherwise, the go compiler can not know what type it is.

For example, in the below code, when calling LoadData() , if we write LoadData("user.json") , rather than LoadData[User]("user.json") , then go will have a compiler error.

However, please note that, the need to specify types or having the compiler infer types isn’t specific only to generics in Go. It’s a broader aspect of the Go language (and statically typed languages in general). However, the prominence of type specification becomes more evident when dealing with generics because of their dynamic nature in terms of type handling.

Below is an example:

// SaveData saves data of any type to a file in JSON format.
func SaveData[T any](fileName string, data T) error {
bytes, err := json.Marshal(data)
if err != nil {
return err
}
return ioutil.WriteFile(fileName, bytes, 0644)
}

// LoadData loads data from a JSON file into the given type.
func LoadData[T any](fileName string) (*T, error) {
bytes, err := ioutil.ReadFile(fileName)
if err != nil {
return nil, err
}

var result T
err = json.Unmarshal(bytes, &result)
if err != nil {
return nil, err
}
return &result, nil
}

type User struct {
Name string
Email string
}

func main() {
user := User{Name: "Alice", Email: "alice@example.com"}

// Save user data to a file
err := SaveData("user.json", user)
if err != nil {
fmt.Println("Error saving data:", err)
return
}

// Load user data from a file
loadedUser, err := LoadData[User]("user.json")
// loadedUser, err := LoadData("user.json")// ./prog.go:51:29: cannot infer T (prog.go:21:15)
if err != nil {
fmt.Println("Error loading data:", err)
return
}

fmt.Println("Loaded user:", *loadedUser)
}

To clarify further, why do we need to infer the type manually more when it’s generics:

  1. Standard Types: When dealing with built-in types or user-defined types (e.g., structs), the Go compiler can easily determine the type based on the provided value or context.
var x int = 10
user := User{Name: "Alice", Email: "alice@example.com"}

Here, x is explicitly typed as int, and user is inferred to be of type User.

2. Interfaces: With interfaces, the type is determined at runtime based on the concrete type that satisfies the interface. The Go compiler ensures at compile-time that any value assigned to an interface variable satisfies that interface, but the exact type is known only at runtime.

var w io.Writer
w = os.Stdout

Here, the type of w is io.Writer (an interface), but at runtime, its concrete type will be whatever os.Stdout is (in this case, *os.File).

3. Generics: Generics introduce a new dimension to type specification in Go. Since a generic function or type can operate on multiple types, you sometimes need to specify the type to resolve ambiguity, especially when the type cannot be inferred from the provided arguments.

result := LoadData[User]("user.json")

In this case, we’re telling the LoadData function to treat the content of "user.json" as a User type.

To summarise, while type inference and specification are concepts present throughout Go, generics introduce scenarios where type specification becomes more prominent because a single generic function or type can work with multiple types, and the compiler might need hints to determine the exact type to use.

Now, I hope this article can help you understand what is generics and when to use it compared to the interface and shared functions.

See you next time. :)

Thanks for reading till here. If you found the article helpful, feel free to clap multiple times on the article (one person can clap up to 50 times!), or ❤️ give me some tips following the below link ❤️. Thank you in advance :)

--

--