Changes

The draft is a living document which means these posts will need to change over time. This section documents when changes have taken place to this post.

21/08/20 : Moving forward with the generics design draft

Series Index

Generics Part 01: Basic Syntax
Generics Part 02: Underlying Types
Generics Part 03: Struct Types and Data Semantics

Introduction

In the previous post, I showed you how to write a generic function in Go using the proposed syntax from the generics draft document. I did this through the progression of writing different versions of the same function using concrete types, the empty interface, and then finally generics. I also provided information on why the new syntax was needed and how the compiler can simplify the calling of generic functions by inferring type information at the call site.

In this post, I will share an example of declaring a type based on a generic underlying type and break down the new syntax further. I will use a similar progression as I did in the last post by declaring the same type in different ways. You can find the code for this post at this playground link.

Concrete Example

What if you needed to define a vector that worked with integers?

Listing 1

16 type vectorInt []int
17
18 func (v vectorInt) last() (int, error) {
19     if len(v) == 0 {
20         return 0, errors.New("empty")
21     }
22     return v[len(v)-1], nil
23 }

69 func main() {
70     fmt.Print("vectorInt : ")
71     vInt := vectorInt{10, -1}
72     i, err := vInt.last()
73     if i < 0 {
74         fmt.Print("negative integer: ")
75     }
76     fmt.Printf("value: %d error: %v\n", i, err)

Output:
vectorInt : negative integer: value: -1 error: <nil>

In listing 1, a type named vectorInt is defined on line 16 whose underlying type is a slice of int. On line 18, a method named last is defined with a value receiver that returns the integer stored at the highest index position in the vector. If the vector is empty, then the zero value for an integer is returned and an error.

On line 71 in the main function, you can see how to construct a value of type vectorInt initialized with two integer values. Then the call to last is performed on line 72 and the integer value of -1 is returned. On line 73, the returned value is checked to see if it’s a negative number and then on line 76 the value is displayed.

What if your app now needs to work with a vector of strings?

Listing 2

25 type vectorString []string
26
27 func (v vectorString) last() (string, error) {
28     if len(v) == 0 {
29         return "", errors.New("empty")
30     }
31     return v[len(v)-1], nil
32 }

69 func main() {
...
78     fmt.Print("vectorString : ")
79     vStr := vectorString{"A", "B", string([]byte{0xff})}
80     s, err := vStr.last()
81     if !utf8.ValidString(s) {
82         fmt.Print("non-valid string: ")
83     }
84     fmt.Printf("value: %q error: %v\n", s, err)

Output:
vectorString : non-valid string: value: "\xff" error: <nil>

In listing 2, a type named vectorString is defined on line 25 whose underlying type is a slice of string. On line 27, the last method is defined with a value receiver that returns the string stored at the highest index position in the vector. If the vector is empty, then the zero value for a string is returned and an error.

On line 79 in the main function, you can see how to construct a value of type vectorString initialized with three string values. Then the call to last is performed on line 80 and an invalid string is returned. On line 81, the returned value is checked to validate the string consists entirely of valid UTF-8-encoded runes and then on line 84 the value is displayed with quotes.

What if your app now needs to work with a vector of floats? At this point you would need to copy the integer implementation and replace int with float64 and change out the variable names.

The only real difference between these two implementations is each works exclusively with a different concrete type, and this difference requires new implementations. The positive thing is you can perform specific type based validation on the returned value since you are working directly with a value of a known and specific concrete type.

Empty Interface

Is there a way to define a single vector type that can work with integers, strings, and floats?

Listing 3

41 type vectorInterface []interface{}
42
43 func (v vectorInterface) last() (interface{}, error) {
44     if len(v) == 0 {
45         return nil, errors.New("empty")
46     }
47     return v[len(v)-1], nil
48 }

Listing 3 shows an implementation of a vector type that can work with integers, strings, floats and really any type of data. Once again, the only difference between this and the other two vector types is the use of the empty interface for the underlying slice.

Listing 4

 69 func main() {
...
 88     fmt.Print("vectorInterface : ")
 89     vItf := vectorInterface{10, "A", 20, "B", 3.14}
 90     itf, err := vItf.last()
 91     switch v := itf.(type) {
 92     case int:
 93         if v < 0 {
 94             fmt.Print("negative integer: ")
 95         }
 96     case string:
 97         if !utf8.ValidString(v) {
 98             fmt.Print("non-valid string: ")
 99         }
100     default:
101         fmt.Printf("unknown type %T: ", v)
102     }
103     fmt.Printf("value: %v error: %v\n", itf, err)

Output:
vectorInterface : unknow type float64: value: 3.14 error: <nil>

On line 89 in Listing 4, you can see how the empty interface version allows for the storage of multiple types of data during construction. In this example, I am mixing the storage of integers, strings, and floats. This makes the vector more flexible, but also more complex to work with.

Look at lines 91 through 102. If I want to detect if the last value returned is a negative integer, I need to perform a type assertion. The same goes for detecting if the last value returned is a valid string or not. I no longer have a guarantee that the vector is working with a specific type of data. This makes working with the vector more complex and error prone.

Generics

With generics, you can define a vector type that works with values of any type where each construction of a vector is restricted to a single type of data.

Listing 5

57 type vector[T any] []T
58
59 func (v vector[T]) last() (T, error) {
60     var zero T
61     if len(v) == 0 {
62         return zero, errors.New("empty")
63     }
64     return v[len(v)-1], nil
65 }

Listing 5 shows a generic vector type that restricts the construction of a vector to a single type of data. As described in the previous post, square brackets are used to declare that type T is a generic type to be determined at compile time. The use of the constraint any describes there is no constraint on what type T can become.

On line 59, the last method is declared with a value receiver of type vector[T] to represent a value of type vector with an underlying slice of some type T. The method returns a value of that same type T.

Listing 6

 68 func main() {
...
107    fmt.Print("vector[int] : ")
108    vGenInt := vector[int]{10, -1}
109    i, err = vGenInt.last()
110    if i < 0 {
111        fmt.Print("negative integer: ")
112    }
113    fmt.Printf("value: %d error: %v\n", i, err)
114
115    fmt.Print("vector[string] : ")
116    vGenStr := vector[string]{"A", "B", string([]byte{0xff})}
117    s, err = vGenStr.last()
118    if !utf8.ValidString(s) {
119        fmt.Print("non-valid string: ")
120    }
121    fmt.Printf("value: %q error: %v\n", s, err)

Output:
vector[int] : negative integer: value: -1 error: <nil>
vector[string] : non-valid string: value: "\xff" error: <nil>

Listing 6 shows how to construct a value of type vector with an underlying type of int on line 108 and a string on line 116. An important aspect of this code is the construction calls.

Listing 7

// Zero Value Construction
var vGenInt vector[int]
var vGenStr vector[string]

// Non-Zero Value Construction
vGenInt := vector[int]{10, -1}
vGenStr := vector[string]{"A", "B", string([]byte{0xff})}

Listing 7 shows how during construction, the type to be used for T is explicitly provided. When it comes to constructing these generic types to their zero value, it’s not possible for the compiler to infer the type. However, in cases where there is initialization during construction, the compiler could infer the type but the tooling currently doesn’t have this implemented.

Now if your app needs a vector of floats, you can declare that at construction without the need to write any new code.

Listing 8

func main() {
    fmt.Print("vector[float64] : ")
    vGenFlt := vector[float64]{10.45, -1.32}
    f, err := vGenFlt.last()
    if f < 0.0 {
        fmt.Print("negative float: ")
    }
    fmt.Printf("value: %f error: %v\n", f, err)

Listing 8 shows how to work with a vector of floats. All that is required is that type float64 is provided at construction to represent type T in the implementation. There is no need for type assertions when checking for negative values since all the data in this vector is restricted to the same type of float64.

Zero Value

There is an aspect of the spec that focuses on the construction of a generic type to its zero value state.

Listing 9

56 type vector[T any] []T
57
58 func (v vector[T]) last() (T, error) {
59     var zero T
60     if len(v) == 0 {
61         return zero, errors.New("empty")
62     }
63     return v[len(v)-1], nil
64 }

On line 58 in listing 9, focus on the method declaration for last and how the method returns a value of the generic type T. Now look at line 61 where the function needs to return a zero value. This is a situation where you need to return the zero value for type T.

The current draft provides two solutions to write this code. The first solution you see in listing 9. On line 59, a variable named zero is constructed to its zero value state of type T and then that variable is used for the return on line 61.

The other option is to use the built-in function new and deference the returned pointer within the return statement.

Listing 10

58 func (v vector[T]) last() (T, error) {
59
60     if len(v) == 0 {
61         return *new(T), errors.New("empty")
62     }
63     return v[len(v)-1], nil
64 }

Listing 10 shows the change to the return statement on line 61. This version of last is using the built-in function new for zero value construction and dereferencing of the returned pointer to satisfy return type T.

Note: You might think why not use T{} to perform zero value construction? The problem is this syntax does not work with all types, such as the scalar types (int, string, bool). So it’s not an option.

At some point the community will need to make a decision about which option should be considered the best practice. There is a possibility that other options will be presented before this first release.

Conclusion

After reading this post, you should have a better understanding of the basic syntax for user defined types in Go that are based on generic underlying types. You should see the need for the square brackets to form a generic type list when declaring a generic type. When constructing generic types, you saw how the type information had to be explicitly passed, though the draft does support the ability to infer the type for non-zero value construction calls.

In the next post, I will explore how generics can be used to define a user defined type with structs. I will also explore value and pointer semantics with this example. If you can’t wait, I recommend you check out the repo of code that these blog posts are based on and experiment for yourself. If you have any questions, please reach out to me over email, Slack, or Twitter.

Trusted by top technology companies

We've built our reputation as educators and bring that mentality to every project. When you partner with us, your team will learn best practices and grow along the way.

30,000+

Engineers Trained

1,000+

Companies Worldwide

12+

Years in Business