10, GO programming mode: Generic Programming

Version 1.17 of the Go language has been released, which officially supports generics. Although there are some limitations (for example, you can't export generic functions), you can experience it. My "Go programming mode" series finally has real generic programming, and there is no need to use difficult technologies such as reflection or go generation. At the weekend, I downloaded Go 1.17 and experienced generic programming, which was very good. Next, let's take a look at Go's generic programming. Note: however, if you don't understand the importance of generic programming, you can take a look at the previous article first< Go programming mode: Go Generation >, and then read it again< Go programming mode: MapReduce>)

Probe into

Let's start with a simple example:

package main

import "fmt"

func print[T any] (arr []T) {
  for _, v := range arr {
    fmt.Print(v)
    fmt.Print(" ")
  }
  fmt.Println("")
}

func main() {
  strs := []string{"Hello", "World",  "Generics"}
  decs := []float64{3.14, 1.14, 1.618, 2.718 }
  nums := []int{2,4,6,8}

  print(strs)
  print(decs)
  print(nums)
}

In the above example, there is a print() function, which wants to output the value of the array. If there is no generic type, this function needs to write the int version, float version, string version, and our custom type (struct) version. Now, with the support of generics, we can use [T any] to declare a generic type (a bit like typename T in C + +), and then use T to declare variables.

In the above example, our generic print() supports three types of adaptations - int, float64, and string. To make this program run, you need to add - gcflags=-G=3 compilation parameter on the compilation line (this compilation parameter will become the default parameter in version 1.18), as follows:

$ go run -gcflags=-G=3 ./main.go

With an operation, we can write some standard algorithms, such as a search algorithm

func find[T comparable] (arr []T, elem T) int {
  for i, v := range arr {
    if  v == elem {
      return i
    }
  }
  return -1
}

We have noticed that instead of using the form of [T any], we use the form of [T comparable]. Comparable is an interface type, which restricts the operation of = = that our type needs to support. Otherwise, there will be compilation errors of wrong type. The find() function above can also be used for int, float64 or string types.

From the above two small programs, the generic type of Go language is basically available, but there are three problems:

  • One is FMT The generic type in printf () is% v, which is not good enough. You can't overload > > like c++ iostream to obtain program-defined output.
  • Another is that go does not support operator overloading, so it is difficult for you to use "generic operators" in generic algorithms, such as: = = and so on
  • Finally, the find() algorithm above relies on "arrays", and data structures such as hash table, tree, graph and link need to be rewritten. In other words, there is no generic iterator like C++ STL (part of this work also needs to be implemented by overloading operators (such as + +)

However, this is already very good. Let's see what we can do.

data structure

Stack stack

The biggest advantage of programming support for generics is that it can realize type independent data structures. Next, we use Slices to implement the number structure of a Stack.

type stack [T any] []T

It looks very simple, or [T any], and then [] T is an array. Next, there are various methods to implement this data structure. The following code implements the methods push(), pop(), top(), len(), print(), which are very similar to Stack in C + + STL. (Note: at present, the generic function of Go does not support export, so only the function name whose first character is lowercase can be used)

First, we can define a generic Stack

func (s *stack[T]) push(elem T) {
  *s = append(*s, elem)
}

func (s *stack[T]) pop() {
  if len(*s) > 0 {
    *s = (*s)[:len(*s)-1]
  } 
}
func (s *stack[T]) top() *T{
  if len(*s) > 0 {
    return &(*s)[len(*s)-1]
  } 
  return nil
}

func (s *stack[T]) len() int{
  return len(*s)
}

func (s *stack[T]) print() {
  for _, elem := range *s {
    fmt.Print(elem)
    fmt.Print(" ")
  }
  fmt.Println("")
}

The above example is relatively simple, but in the process of implementation, if the stack is empty, then top() either returns error or returns null value, which is stuck in this place. Because before, the "null" value we returned was either 0 of int or "" of string. However, under the generic T, this value is not easy to get. In other words, in addition to type generics, we also need some "value generics" (Note: in C + +, if you want to use an empty stack for top() operation, you will get a segmentation fault). Therefore, here we return a pointer, so we can judge whether the pointer is empty.

The following is the code of how to use this stack.

func main() {

  ss := stack[string]{}
  ss.push("Hello")
  ss.push("Hao")
  ss.push("Chen")
  ss.print()
  fmt.Printf("stack top is - %v\n", *(ss.top()))
  ss.pop()
  ss.pop()
  ss.print()


  ns := stack[int]{}
  ns.push(10)
  ns.push(20)
  ns.print()
  ns.pop()
  ns.print()
  *ns.top() += 1
  ns.print()
  ns.pop()
  fmt.Printf("stack top is - %v\n", ns.top())

}
LinkList bidirectional linked list

Now let's look at the implementation of a two-way linked list. These methods are implemented in the following implementation:

  • add() – insert a data node from the beginning
  • push() – insert a data node from the tail
  • del() – delete a node (because it needs to be compared, the generic type of comparable is used)
  • print() – traverse a linked list from scratch and output values.
type node[T comparable] struct {
  data T
  prev *node[T]
  next *node[T]
}

type list[T comparable] struct {
  head, tail *node[T]
  len int
}

func (l *list[T]) isEmpty() bool {
  return l.head == nil && l.tail == nil
}

func (l *list[T]) add(data T) {
  n := &node[T] {
    data : data,
    prev : nil,
    next : l.head,
  }
  if l.isEmpty() {
    l.head = n
    l.tail = n
  }
  l.head.prev = n
  l.head = n
}

func (l *list[T]) push(data T) { 
  n := &node[T] {
    data : data,
    prev : l.tail,
    next : nil,
  }
  if l.isEmpty() {
    l.head = n
    l.tail = n
  }
  l.tail.next = n
  l.tail = n
}

func (l *list[T]) del(data T) { 
  for p := l.head; p != nil; p = p.next {
    if data == p.data {

      if p == l.head {
        l.head = p.next
      }
      if p == l.tail {
        l.tail = p.prev
      }
      if p.prev != nil {
        p.prev.next = p.next
      }
      if p.next != nil {
        p.next.prev = p.prev
      }
      return 
    }
  } 
}

func (l *list[T]) print() {
  if l.isEmpty() {
    fmt.Println("the link list is empty.")
    return 
  }
  for p := l.head; p != nil; p = p.next {
    fmt.Printf("[%v] -> ", p.data)
  }
  fmt.Println("nil")
}

The above codes are relatively conventional linked list operations. Students who have learned the linked list data structure should be familiar with them, and the codes used are not difficult. As shown below, they are very simple. Just look at the codes.

func main(){
  var l = list[int]{}
  l.add(1)
  l.add(2)
  l.push(3)
  l.push(4)
  l.add(5)
  l.print() //[5] -> [2] -> [1] -> [3] -> [4] -> nil
  l.del(5)
  l.del(1)
  l.del(4)
  l.print() //[2] -> [3] -> nil

}

Functional paradigm

Next, let's take a look at the three major parts of our functional programming: map(), reduce() and filter(). In the previous article "Go programming mode: Map reduce", we can see that to realize such generics, reflection is needed, and the code is so complex that we can't understand it at all. Let's take a look at the real generic version.

Generic Map
func gMap[T1 any, T2 any] (arr []T1, f func(T1) T2) []T2 {
  result := make([]T2, len(arr))
  for i, elem := range arr {
    result[i] = f(elem)
  }
  return result
}

In the above map function, I use two types - T1 and T2,

  • T1 – is the type of data to be processed
  • T2 – is the processed data type
    T1 and T2 can be the same or different.

We also have a function parameter - func(T1) T2, which means that what comes in is T1 and what comes out is T2.

Then, the whole function returns a [] T2

OK, let's see how to use this map function:

nums := []int {0,1,2,3,4,5,6,7,8,9}
squares := gMap(nums, func (elem int) int {
  return elem * elem
})
print(squares)  //0 1 4 9 16 25 36 49 64 81 

strs := []string{"Hao", "Chen", "MegaEase"}
upstrs := gMap(strs, func(s string) string  {
  return strings.ToUpper(s)
})
print(upstrs) // HAO CHEN MEGAEASE 


dict := []string{"Fatal Frame", "one", "two", "three", "four", "five", "land", "seven", "eight", "nine"}
strs =  gMap(nums, func (elem int) string  {
  return  dict[elem]
})
print(strs) // Zero one two three four five six seven eight nine
Generic Reduce

Next, let's take a look at our reduce function, which combines a pile of data into one.

func gReduce[T1 any, T2 any] (arr []T1, init T2, f func(T2, T1) T2) T2 {
  result := init
  for _, elem := range arr {
    result = f(result, elem)
  }
  return result
}

The function is simple to implement, but it doesn't feel very elegant.

  • There are also two types T1 and T2. The former is the type of output data and the latter is the type of output data.
  • Because you want to synthesize a data, you need to have the initial value init of this data, which is of type T2
  • The user-defined function func(T2, T1) T2 will pass the init value to the user, and then return it after the user processes it.
    Here's an example of how to use -- sum an array
    nums := []int {0,1,2,3,4,5,6,7,8,9}
    sum := gReduce(nums, 0, func (result, elem int) int  {
      return result + elem
    })
    fmt.Printf("Sum = %d \n", sum)
    Generic filter

The filter function is mainly used for filtering. It filters out some data that meets the conditions (filter in) or does not meet the conditions (filter out). The following is a related code example

func gFilter[T any] (arr []T, in bool, f func(T) bool) []T {
  result := []T{}
  for _, elem := range arr {
    choose := f(elem)
    if (in && choose) || (!in && !choose) {
      result = append(result, elem)
    }
  }
  return result
}

func gFilterIn[T any] (arr []T, f func(T) bool) []T {
  return gFilter(arr, true, f)
}

func gFilterOut[T any] (arr []T, f func(T) bool) []T {
  return gFilter(arr, false, f)
}

Among them, the user needs to mention a bool function, and we will pass the data to the user. Then the user just needs to tell me whether it is OK or not, so we will return a filtered array to the user.

For example, we want to filter out all odd numbers in the array

nums := []int {0,1,2,3,4,5,6,7,8,9}
odds := gFilterIn(nums, func (elem int) bool  {
    return elem % 2 == 1
})
print(odds)

Business example

Just like the business example in Go programming mode: Map reduce, let's do it again here.

First, we state an employee object and related data

type Employee struct {
  Name     string
  Age      int
  Vacation int
  Salary   float32
}

var employees = []Employee{
  {"Hao", 44, 0, 8000.5},
  {"Bob", 34, 10, 5000.5},
  {"Alice", 23, 5, 9000.0},
  {"Jack", 26, 0, 4000.0},
  {"Tom", 48, 9, 7500.75},
  {"Marry", 29, 0, 6000.0},
  {"Mike", 32, 8, 4000.3},
}

Then, if we want to unify the salaries of all employees, we can use the previous reduce function

total_pay := gReduce(employees, 0.0, func(result float32, e Employee) float32 {
  return result + e.Salary
})
fmt.Printf("Total Salary: %0.2f\n", total_pay) // Total Salary: 43502.05

The gReduce function of our function is a bit verbose and needs to pass an initial value. In the user's own function, we should also care about the result. We'd better define a better version.

Generally speaking, most of the time when we use the reduce function, it is basically statistical summation or several numbers. Therefore, can we define it more directly? For example, CountIf() below is much cleaner than reduce above.

func gCountIf[T any](arr []T, f func(T) bool) int {
  cnt := 0
  for _, elem := range arr {
    if f(elem) {
      cnt += 1
    }
  }
  return cnt;
}

When we do summation, we can also write a generic type of Sum.

  • Process T-type data and return U-type results
  • Then, the user only needs to give me a T-U type data to be counted.
    The code is as follows:
    type Sumable interface {
    type int, int8, int16, int32, int64,
          uint, uint8, uint16, uint32, uint64,
          float32, float64
    }
    

func gSum[T any, U Sumable](arr []T, f func(T) U) U {
var sum U
for _, elem := range arr {
sum += f(elem)
}
return sum
}

We used a code called Sumable Interface that defines U Type, can only be Sumable For those types in, that is, integer or floating point, this support can make our generic code more robust.

So we can finish the following things.

1)Count the number of employees older than 40
```go
old := gCountIf(employees, func (e Employee) bool  {
    return e.Age > 40
})
fmt.Printf("old people(>40): %d\n", old) 
// ld people(>40): 2

2) Count the number of employees with a salary of more than 6000 yuan

high_pay := gCountIf(employees, func(e Employee) bool {
  return e.Salary >= 6000
})
fmt.Printf("High Salary people(>6k): %d\n", high_pay) 
//High Salary people(>6k): 4

3) Calculate the salary of employees younger than 30 years old

younger_pay := gSum(employees, func(e Employee) float32 {
  if e.Age < 30 {
      return e.Salary
  } 
  return 0
})
fmt.Printf("Total Salary of Young People: %0.2f\n", younger_pay)
//Total Salary of Young People: 19000.00

4) Count the leave days of all employees

total_vacation := gSum(employees, func(e Employee) int {
  return e.Vacation
})
fmt.Printf("Total Vacation: %d day(s)\n", total_vacation)
//Total Vacation: 32 day(s)

5) Filter out employees without vacation

no_vacation := gFilterIn(employees, func(e Employee) bool {
  return e.Vacation == 0
})
print(no_vacation)
//{Hao 44 0 8000.5} {Jack 26 0 4000} {Marry 29 0 6000}

You know the meaning of generic programming.

(end of the full text) this article is not made by myself. It reprints the left ear mouse blog and its source Cool shell – CoolShell

Keywords: Go

Added by mjahkoh on Tue, 08 Feb 2022 09:55:39 +0200