Recently, the Go team announced an updated draft design for their Generics in Go proposal. It goes into a lot of details about why certain decisions were made, implementation details, etc.
In this article, my goal is to summarise the major upcoming changes, as the whole draft design can be a mouthful for many.
I will provide some code snippets to demonstrate the major features as well as give you the chance to experiment yourself with them, thanks to the new Go playground with support for generics.
Type Constraints in Generic Functions
Here's how a generic function looks like without any constraints on the type parameter:
// package decls, imports... | |
func arrayOf(type T)(elems ...T) []T { | |
arr := []T{} | |
for _, el := range elems { | |
arr = append(arr, el) | |
} | |
return arr | |
} | |
func main() { | |
strs := arrayOf("one", "two", "three", "four", "five") | |
fmt.Println(strs) | |
nums := arrayOf(1, 2, 3, 4, 5) | |
fmt.Println(nums) | |
} |
To add constraints on the generic type, you can demand that it implements a given interface:
// package decls, imports... | |
// Person, who implements fmt.Stringer... | |
func join(type T fmt.Stringer)(tokens []T, delim string) string { | |
res := "" | |
for _, token := range tokens { | |
if res != "" { | |
res += delim | |
} | |
res += token.String() | |
} | |
return res | |
} | |
func main() { | |
joined := join([]Person{Person{"Mike", "Jordan"}, Person{"Dave", "Stevens"}, Person{"John", "Doe"}}, ", ") | |
fmt.Println(joined) | |
} |
To specify multiple type parameters, separate them by commas:
// package decls, imports... | |
func mapAll(type T, R)(arr []T, mapFunc func(T) R) []R { | |
res := []R{} | |
for _, el := range arr { | |
res = append(res, mapFunc(el)) | |
} | |
return res | |
} | |
func main() { | |
strs := mapAll([]int{1, 2, 3}, func(n int) string { | |
return strconv.Itoa(n) | |
}) | |
fmt.Println(strs) | |
} |
Constraints on multiple type parameters are written the same way you write function argument types:
// package decls, imports... | |
// Person & Animal structs, which implement fmt.Stringer... | |
func Concat(type T1, T2 fmt.Stringer)(f T1, s T2, delim string) string { | |
return f.String() + delim + s.String() | |
} | |
func main() { | |
res := Concat(Person{"John", "Doe"}, Animal{"Dog", "Richie"}, " loves his ") | |
fmt.Println(res) | |
} |
And here's how to specify different types for both parameters:
// package decls, imports... | |
// Hooman & Pet interfaces... | |
// Person & Dog structs... | |
func PlayAround(type H Hooman, P Pet)(human H, pet P) { | |
fmt.Println("The human says:", human.Speak()) | |
fmt.Println("And the pet responds:", pet.ProduceSound()) | |
} | |
func main() { | |
PlayAround(Person{}, Dog{}) | |
} |
Type Lists & comparable
Instead of constraining types based on a set of methods, you can constraint them based on a set of supported types. For example, you could specify that you accept a generic type which can only be an int or a long.
This allows you to e.g. leverage operators like "less than", "greater than", which are only available for basic types in Go:
// package decls, imports... | |
// Ordered is a type constraint that matches any ordered type. | |
// An ordered type is one that supports the <, <=, >, and >= operators. | |
type Ordered interface { | |
type int, int8, int16, int32, int64, | |
uint, uint8, uint16, uint32, uint64, uintptr, | |
float32, float64, | |
string | |
} | |
func Max(type T Ordered)(elems []T) T { | |
if len(elems) == 0 { | |
var zero T | |
return zero | |
} | |
max := elems[0] | |
for _, el := range elems { | |
if el > max { | |
max = el | |
} | |
} | |
return max | |
} | |
func main() { | |
res := Max([]int{1, 5, 3, 10, 4}) | |
fmt.Println(res) | |
} |
You also have an out-of-the-box constraint called comparable, which constraints types to those which support the == and != operators.
// package decls, imports... | |
func Contains(type T comparable)(elems []T, target T) bool { | |
for _, elem := range elems { | |
if elem == target { | |
return true | |
} | |
} | |
return false | |
} | |
func main() { | |
fmt.Println(Contains([]int{1, 2, 3, 4, 5}, 4)) | |
} |
Interfaces using these constraints - type lists and/or comparable, cannot be implemented by a struct.
They can only be used for type constraint definitions.
Generic Types
Structs can be defined using a generic type. Once specified, in the type declaration, there is no need to specify the type for all the functions of the type:
// package decls, imports... | |
type Stack(type T) struct { | |
buffer []T | |
} | |
func (v *Stack(T)) Push(elem T) { | |
v.buffer = append(v.buffer, elem) | |
} | |
func (v *Stack(T)) Pop() T { | |
res := v.buffer[len(v.buffer)-1] | |
v.buffer = v.buffer[:len(v.buffer)-1] | |
return res | |
} | |
func main() { | |
st := &Stack(int){} | |
st.Push(1) | |
st.Push(2) | |
st.Push(3) | |
fmt.Println(st.Pop()) | |
fmt.Println(st.Pop()) | |
fmt.Println(st.Pop()) | |
} |
You can also do that in interfaces. This is especially useful when a type constraint depends on itself.
E.g. you have a type constraint for T, which requires an Equal method, which accepts a T parameter:
// package decls, imports... | |
// Person, who implements Equaler... | |
type Equaler(type T) interface { | |
Equal(other T) bool | |
} | |
func Contains(type T Equaler)(elems []T, target T) bool { | |
for _, elem := range elems { | |
if elem.Equal(target) { | |
return true | |
} | |
} | |
return false | |
} | |
func main() { | |
people := []Person{Person{"Dave"}, Person{"Bob"}, Person{"Steve"}} | |
fmt.Println(Contains(people, Person{"Dave"})) | |
} |
In case you need to specify a type parameter, who has a state-modifying function (e.g. a setter), then you can specify a pointer type constraint:
// package decls, imports... | |
type Setter interface { | |
Set(string) | |
} | |
type Settable int | |
// Set sets the value of *p from a string. | |
func (p *Settable) Set(s string) { | |
i, _ := strconv.Atoi(s) | |
*p = Settable(i) | |
} | |
func FromStrings(type *T Setter)(s []string) []T { | |
result := make([]T, len(s)) | |
for i, v := range s { | |
// result[i] is an addressable value of type T, | |
// so it's OK to call Set. | |
result[i].Set(v) | |
} | |
return result | |
} | |
func main() { | |
nums := FromStrings(Settable)([]string{"1", "2", "3"}) | |
fmt.Println(nums) | |
} |
Notice how this example above requires that you explicitly specify the type you'll use in the function - FromStrings(Settable)...
This is because whenever the type is not present as a function argument, the compiler cannot infer the actual type once the code is compiled. Hence, you need to explicitly specify it.
Conclusion
This article was purposefully succinct and straightforward. Hopefully, it will help you quickly get up-to-date on the latest generics draft design, while you're drinking your morning coffee.
However, there is a lot of rationale behind any single design choice around generics in Go. If you are interested to dive deeper into this subject, check out the official draft design doc.
So, do you like the latest Go Generics draft design?
Let me know in the comments section below.
Top comments (2)
thanks a lot for this ...
and i think the design of generics in go is very upfront... as always rationale and practical implementation is very well done by the go team...
so i recommend everyone study the draft and contribute where appropriate.
mmaedel
That explains it all. I don't think there can be a good explanation then this post.
Thank you.