Read Go generic design and usage scenarios

preface

2021.12. On the 14th, Go officially released Go 1.18beta1, which supports generics, which is the most significant functional change of Go language since its birth in 2007.

There are three concepts at the core of generics:

  1. Type parameters for functions and types

    Type parameters, which can be used for generic functions and generic types

  2. Type sets defined by interfaces

    Before Go 1.18, interface was used to define a set of methods.

    Starting from Go 1.18, you can also use the interface to define a set of types as the type constraint of the type parameter

  3. Type inference

    Type derivation can help us write code without passing type arguments, which can be deduced by the compiler itself.

    Note: type derivation is not always feasible.

Type parameters

[P, Q constraint1, R constraint2]

A type parameter list is defined here, which can contain one or more type parameters.

P. Q and R are both type parameters, and containt1 and containt2 are type constraints.

  • The type parameter list uses square brackets []
  • Type parameters are recommended to be capitalized to indicate that they are types

Let's start with a simple example:

func min(x, y float64) float64 {
    if x < y {
        return x
    }
    return y
}

In this example, only the smaller of the two float64 can be calculated. Before generics, if we want to support computing the smaller of two ints or other numeric types, we need to implement new functions, or use interface {}, or use reference.

For this scenario, using generic code is simpler and more efficient. Generic min functions that support comparing different numeric types are implemented as follows:

func min(T constraints.Ordered) (x, y T) T {
    if x < y {
        return x
    }
    return y
}

// Call generic function
m := min[int](2, 3)

be careful:

  1. Use constraints Ordered type, import constraints required.
  2. min[int](2, 3) instantiates the generic function min and replaces the type parameter T in the generic function with int at compile time.

Instantiation

The instantiation of generic functions does two things

  1. Replace the type parameter of the generic function with the type argument.

    For example, in the above example, the type argument passed by the min function call is int, which will replace the type parameter T of the generic function with int

  2. Check whether the type arguments meet the type restrictions in the generic function definition.

    For the above example, check whether the type argument int satisfies the type restriction constraints Ordered.

If any step fails, the instantiation of the generic function fails, that is, the generic function call fails.

After the generic function is instantiated, a non generic function is generated for real function execution.

The above min[int](2, 3) call can also be replaced by the following code:

func min(T constraints.Ordered) (x, y T) T {
    if x < y {
        return x
    }
    return y
}

// Mode 1
m := min[int](2, 3)
// Mode 2
fmin := min[int]
m2 := fmin(2, 3)

min[int](2, 3) will be parsed by the compiler into (min[int])(2, 3), that is

  1. First instantiate a non generic function
  2. Then do the real function execution.

Generic types

In addition to generic functions, type parameters can also be used for the type definition of Go to implement generic types.

Look at the following code example to implement a generic binary tree structure

type Tree[T interface{}] struct {
    left, right *Tree[T]
    data T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] 

var stringTree Tree[string]

Binary tree nodes may store a variety of data types. Some binary trees store int, some store string, and so on.

Using generics, you can make the Tree structure type support binary Tree nodes to store different data types.

For a method of generic type, the corresponding type parameter needs to be declared in the method receiver. For example, the Lookup method in the above example declares the type parameter T in the pointer receiver * Tree[T].

Type sets

The type restriction of a type parameter specifies the specific types allowed by the type parameter.

Type restrictions often contain multiple concrete types, which constitute a type set.

func min(T constraints.Ordered) (x, y T) T {
    if x < y {
        return x
    }
    return y
}

For example, in the above example, the type limit of the type parameter T is constraints Ordered,contraints.Ordered contains many specific types, which are defined as follows:

// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
  Integer | Float | ~string
}

Integer and Float are also type restrictions defined in the constraints package,

The type parameter list cannot be used for methods, but only for functions.

type Foo struct {}

func (Foo) bar[T any](t T) {}

The above example uses the type parameter list in the method bar of structure type Foo, and an error will be reported during compilation:

./example1.go:30:15: methods cannot have type parameters
./example1.go:30:16: invalid AST: method must have no type parameters

Personally, I think the compilation hint of Go: methods cannot have type parameters is not particularly accurate.

For example, the following example uses the type parameter T in the method bar. It feels better to change it to methods cannot have type parameter list.

type Foo[T any] struct {}

func (Foo[T]) bar(t T) {}

Note: type restriction must be of interface type. For example, constraints Ordered is an interface type.

|And~

|: indicates a union set. For example, the Number interface in the following example can be used as a type restriction to limit that the type parameters must be int, int32 and int64.

type Number interface{
    int | int32 | int64
}

~T: ~ is a new symbol added to Go 1.18, ~ t means that the underlying type is all types of T~ English reads tilde.

  • Example 1: for example, the interface of AnyString in the following example can be used as a type restriction. The underlying type used to limit type parameters must be string. The string itself and the following MyString satisfy the AnyString type limit.

    type AnyString interface{
       ~string
    }
    type MyString string
  • Example 2: for another example, we define a new type restriction called customConstraint, which is used to restrict the underlying type to int and implement all types of String() string method. The following customInt satisfies this type constraint.

    type customConstraint interface {
       ~int
       String() string
    }
    
    type customInt int
    
    func (i customInt) String() string {
       return strconv.Itoa(int(i))
    }

Type restrictions have two functions:

  1. It is used to specify valid type arguments. Type arguments that do not meet the type limit will be reported as errors by the compiler.
  2. If all types in the type restriction support an operation, the corresponding type parameter can use this operation in the code.

Constraint literals (type limit literals)

Type constraints can be defined in advance or directly in the type parameter list, which is called constraint literals.

[S interface{~[]E}, E interface{}]

[S ~[]E, E interface{}]

[S ~[]E, E any]

Several points for attention:

  • You can directly define type restrictions in square brackets [], that is, use type to limit literal values, such as the above example.
  • In the position of type restriction, interface{E} can also be written as e directly. Therefore, it can be understood that interface{~[]E} can be written as ~ [] E.
  • any is a pre declared identifier added to Go 1.18 and is an alias of interface {}.

constraints package

The constraints package defines some common type restrictions. In addition to the test code, there is only one constraints in the whole package Go file, 50 lines of code, source code address:

https://github.com/golang/go/...

The types included are limited as follows:

  • constraints.Signed

    type Signed interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64
    }
  • constraints.Unsigned

    type Unsigned interface {
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
    }
  • constraints.Integer

    type Integer interface {
        Signed | Unsigned
    }
  • constraints.Float

    type Float interface {
        ~float32 | ~float64
    }
  • constraints.Complex

    type Complex interface {
        ~complex64 | ~complex128
    }
  • constraints.Ordered

    type Ordered interface {
        Integer | Float | ~string
    }

Type inference

Let's look at the following code example:

func min(T constraints.Ordered) (x, y T) T {
    if x < y {
        return x
    }
    return y
}

var a, b, m1, m2 float64
// Method 1: display the specified type argument
m1 = min[float64](a, b)
// Method 2: do not specify type argument and let the compiler deduce it by itself
m2 = min(a, b)

Mode 2 does not pass type arguments. The compiler deduces type arguments based on function arguments a and b.

Type derivation can make our code more concise and readable.

There are two types of derivation for Go generics:

  1. function argument type inference: deduce type arguments from the types of the non-type arguments.

    The specific type is derived from the arguments of the function. For example, m2 = min(a, b) in the above example is based on the two function arguments a and B

    It is deduced that T is float64.

  2. constraint type inference: inferring a type argument from another type argument, based on type parameter constraints.

    Derive unknown type arguments from determined type arguments. In the following code example, the type of E cannot be determined according to function argument 2, but it can be determined that S is [] int32. Combined with the fact that the underlying type of S in type restrictions is [] E, it can be deduced that E is int32 and int32 meets constraints Integer limit, so the derivation was successful.

    type Point []int32
    
    func ScaleAndPrint(p Point) {
      r := Scale(p, 2)
      fmt.Println(r)
    }
    
    func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
      r := make(S, len(s))
      for i, v := range s {
        r[i] = v * c
      }
      return r
    }

Type derivation is not necessarily successful. For example, type parameters are used in the return value of a function or in the body of a function. In this case, type arguments must be specified.

func test[T any] () (result T) {...}
func test[T any] () {
  fmt.Println(T)
}

For more in-depth understanding of type information, please refer to: https://go.googlesource.com/p...

Usage scenario

Proverb

Write code, don't design types.

When writing Go code, Ian Lance Taylor, the designer of Go generics, suggested not to define type parameter s and type constraint s at first. If you do so at first, you will make a mistake about the best practice of generics.

Write specific code logic first. When you realize that you need to use type parameter or define a new type constraint, add type parameter and type constraint.

When to use generics?

  • Slice, map and channel types need to be used, but there may be multiple element types in slice, map and channel.
  • General data structures, such as linked list, binary tree, etc. The following code implements a binary tree that supports any data type.

    type Tree[T any] struct {
      cmp func(T, T) int
      root *node[T]
    }
    
    type node[T any] struct {
      left, right *node[T]
      data T
    }
    
    func (bt *Tree[T]) find(val T) **node[T] {
      pl := &bt.root
      for *pl != nil {
        switch cmp := bt.cmp(val, (*pl).data); {
          case cmp < 0 : pl = &(*pl).left
          case cmp > 0 : pl = &(*pl).right
        default: return pl
        }
      }
      return pl
    }
  • When the implementation of a method is the same for all types.

    type SliceFn[T any] struct {
      s []T
      cmp func(T, T) bool
    }
    
    func (s SliceFn[T]) Len() int{return len(s.s)}
    func (s SliceFn[T]) Swap(i, j int) {
      s.s[i], s.s[j] = s.s[j], s.s[i]
    }
    func (s SliceFn[T]) Less(i, j int) bool {
      return s.cmp(s.s[i], s.s[j])
    }

When not to use generics?

  1. Do not use generics when simply calling methods with arguments.

    // good
    func foo(w io.Writer) {
       b := getBytes()
       _, _ = w.Write(b)
    }
    
    // bad
    func foo[T io.Writer](w T) {
       b := getBytes()
       _, _ = w.Write(b)
    }

    For example, the above example simply calls io The Write method of the writer writes the content to the specified place. Using interface as a parameter is more appropriate and more readable.

  2. When functions or methods or specific implementation logic are different for different types, do not use generics. For example, the encoding/json package uses reflect, but it is not appropriate to use generics.

summary

Avoid boilerplate.

Corollary: Don't use type parameters prematurely; wait until you are about to write boilerplate code.

Don't use generics casually. Ian's advice is: use generics only when you find that you can write the same code logic for different types. that is

Avoid boilerplate code.

interface and reference in Go language can realize generics to some extent. When dealing with multiple types, we should consider specific use scenarios and do not blindly use generics.

For more in-depth understanding of the Go generic design principle, please refer to the Go Proposal written by Ian and Robert, the authors of Go generic design:

https://go.googlesource.com/p...

Open source address

The open source address of the article and code is in GitHub: https://github.com/jincheng9/...

Official account: coding advanced

Personal website: https://jincheng9.github.io/

References

Keywords: Go Programming

Added by NoReason on Tue, 04 Jan 2022 11:32:46 +0200