Golang 1.18 introduced support for generics, allowing developers to write code that is independent of specific types. This means that functions and types can now be written to work with any set of types. In this article, we’ll explore how to use Golang generics in a struct and with interfaces.
Generic function to handle multiple types
Type parameters in programming have constraints that define which types they can accept. These constraints serve as guidelines. During compilation, the actual type provided must comply with the constraint; otherwise, you’ll get a compilation error.
func IsGreater[V float64 | int](a, b V) bool {
return a > b
}
Your generic code can only perform operations that are supported by the type parameter’s constraints. Attempting string operations on a numeric-only type parameter will cause a compilation error.
Using generics in a struct
type Model[T any] struct {
Data []T
}
A generic type must be instantiated when used, and instantiation requires a type parameter list. Here’s an example:
func main() {
// passing int as type parameter
modelInt := Model[int]{Data: []int{1, 2, 3}}
fmt.Println(modelInt.Data) // [1 2 3]
// passing string as type parameter
modelStr := Model[string]{Data: []string{"a", "b", "c"}}
fmt.Println(modelStr.Data) // [a b c]
}
Define methods on generic types
If you declare methods on a generic type, you must repeat the type parameter declaration on the receiver, even if the type parameters are not used in the method scope. For example:
type Model[T any] struct {
Data []T
}
func (m *Model[T]) Push(item T) {
m.Data = append(m.Data, item)
}
Using interfaces with Golang Generics
Types don’t actually implement generic interfaces, they implement instantiations of generic interfaces. You can’t use a generic type (including interfaces) without instantiation.
Here’s an example:
type Getter[T any] interface {
Get() T
}
type Model[T any] struct {
Data []T
}
// implements Getter
func (m *Model[T]) Get(i int) T {
return m.Data[i]
}
Declare a type constraint as interface
In the next section, we’ll extract the previously defined constraint into its own interface for reuse in multiple places. Using interfaces as type constraints allows any implementing type to satisfy the constraint. For instance, if an interface has three methods and is used as a type parameter in a generic function, all type arguments must have those methods. This section also explores constraint interfaces that refer to specific types. Example:
type Number interface {
int | float64
}
type Getter[T Number] interface {
Get() T
}
type Model[T Number] struct {
Data []T
}
func (m *Model[T]) Push(item T) {
m.Data = append(m.Data, item)
}
func (m *Model[T]) Get(i int) T {
return m.Data[i]
}
func main() {
// passing int as type parameter
modelInt := Model[int]{Data: []int{1, 2, 3}}
fmt.Println(modelInt.Data) // [1 2 3] // passing float64 as type parameter
modelFloat := Model[float64]{Data: []float64{1.1, 2.2, 0.02}}
fmt.Println(modelFloat.Data) // [1.1 2.2 0.02] modelInt.Push(4)
fmt.Println(modelInt.Data) // [1 2 3 4] itemAtOne := modelFloat.Get(1)
fmt.Println(itemAtOne) // 2.2
}
In this example, if we try to use a type that does not satisfy the Number
interface, you will get a compile error.
_ = Model[string]{Data: []string{"a", "b"}}
// string does not satisfy Number (string missing in int | float64)
Express ‘Family of Types’ constraints
To express a constraint covering all types with an underlying type we can use the `~` operator. Here is an example from the slices package Clip function:
func Clip[S ~[]E, E any](s S) S {
return s[:len(s):len(s)]
}
Essentially, this constraint ensures that S can only be a slice that can hold elements of any data type. This allows the Clip function to be generic and work with various slice types like []int, []string, or even slices of custom structs.
Here is a more straightforward example:
func Hello[S ~string](s S) S {
return fmt.Sprintf("Hello, %s", s)
}
Return zero (default) values
The *new(T)
idiom
The preferred option, as suggested in golang-nuts, involves using new(T)
. While potentially less readable, this approach simplifies finding and replacing instances if a zero-value builtin is introduced in the language. It also allows for concise one-line assignments.
The new
builtin function allocates memory for a variable of any type T
and returns a pointer to it. Dereferencing the result (*new(T)
) effectively yields the zero value for the type T
. This approach also works with type parameters.
func Zero[T any]() T {
return *new(T)
}
var
of type T
Straightforward and easier to read, though it always requires one line more:
func Zero[T any]() T {
var zero T
return zero
}
Named return types
You can use named returns to avoid explicitly declaring variables. While not everyone loves this style, it can be helpful in complex functions or when using defer
statements. Here’s a simple example:
func Zero[T any]() (ret T) {
return
}
In conclusion, Golang generics provide developers with the flexibility to write code that is independent of specific types. This allows for more reusable and maintainable code.
Leave a Reply