十、GO程式設計模式 : 泛型程式設計

zhaocrazy發表於2022-02-08

Go語言的1.17版本釋出了,其中開始正式支援泛型了。雖然還有一些限制(比如,不能把泛型函式export),但是,可以體驗了。我的這個《Go程式設計模式》的系列終於有了真正的泛型程式設計了,再也不需要使用反射或是go generation這些難用的技術了。週末的時候,我把Go 1.17下載下來,然後,體驗了一下泛型程式設計,還是很不錯的。下面,就讓我們來看一下Go的泛型程式設計。(注:不過,如果你對泛型程式設計的重要性還不是很瞭解的話,你可以先看一下之前的這篇文章《Go程式設計模式:Go Generation》,然後再讀一下《Go程式設計模式:MapReduce》)

十、GO程式設計模式 : 泛型程式設計

初探

我們先來看一個簡單的示例:

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)
}

上面這個例子中,有一個 print() 函式,這個函式就是想輸出陣列的值,如果沒有泛型的話,這個函式需要寫出 int 版,float版,string 版,以及我們的自定義型別(struct)的版本。現在好了,有了泛型的支援後,我們可以使用 [T any] 這樣的方式來宣告一個泛型型別(有點像C++的 typename T),然後面都使用 T 來宣告變數就好。

上面這個示例中,我們泛型的 print() 支援了三種型別的適配—— int型,float64型,和 string型。要讓這段程式跑起來需要在編譯行上加上 -gcflags=-G=3編譯引數(這個編譯引數會在1.18版上成為預設引數),如下所示:

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

有了個操作以後,我們就可以寫一些標準的演算法了,比如,一個查詢的演算法

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

我們注意到,我們沒有使用 [T any]的形式,而是使用 [T comparable]的形式,comparable是一個介面型別,其約束了我們的型別需要支援 == 的操作, 不然就會有型別不對的編譯錯誤。上面的這個 find() 函式同樣可以使用於 int, float64或是string型別。

從上面的這兩個小程式來看,Go語言的泛型已基本可用了,只不過,還有三個問題:

  • 一個是 fmt.Printf()中的泛型型別是 %v 還不夠好,不能像c++ iostream過載 >> 來獲得程式自定義的輸出。
  • 另外一個是,go不支援操作符過載,所以,你也很難在泛型演算法中使用“泛型操作符”如:==
  • 最後一個是,上面的 find() 演算法依賴於“陣列”,對於hash-table、tree、graph、link等資料結構還要重寫。也就是說,沒有一個像C++ STL那樣的一個泛型迭代器(這其中的一部分工作當然也需要通過過載操作符(如:++ 來實現)

不過,這個已經很好了,讓我們來看一下,可以幹哪些事了。

資料結構

Stack 棧

程式設計支援泛型最大的優勢就是可以實現型別無關的資料結構了。下面,我們用Slices這個結構體來實現一個Stack的數結構。

type stack [T any] []T

看上去很簡單,還是 [T any] ,然後 []T 就是一個陣列,接下來就是實現這個資料結構的各種方法了。下面的程式碼實現了 push()pop()top()len()print()這幾個方法,這幾個方法和 C++的STL中的 Stack很類似。(注:目前Go的泛型函式不支援 export,所以只能使用第一個字元是小寫的函式名)

首先,我們可以定義一個泛型的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("")
}

上面的這個例子還是比較簡單的,不過在實現的過程中,對於一個如果棧為空,那麼 top()要麼返回error要麼返回空值,在這個地方卡了一下。因為,之前,我們返回的“空”值,要麼是 int 的0,要麼是 string 的 “”,然而在泛型的T下,這個值就不容易搞了。也就是說,除了型別泛型後,還需要有一些“值的泛型”(注:在C++中,如果你要用一個空棧進行 top() 操作,你會得到一個 segmentation fault),所以,這裡我們返回的是一個指標,這樣可以判斷一下指標是否為空。

下面是如何使用這個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 雙向連結串列

下面我們再來看一個雙向連結串列的實現。下面這個實現中實現了 這幾個方法:

  • add() – 從頭插入一個資料結點
  • push() – 從尾插入一個資料結點
  • del() – 刪除一個結點(因為需要比較,所以使用了 compareable 的泛型)
  • print() – 從頭遍歷一個連結串列,並輸出值。
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")
}

上面這個程式碼都是一些比較常規的連結串列操作,學過連結串列資料結構的同學應該都不陌生,使用的程式碼也不難,如下所示,都很簡單,看程式碼就好了。

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

}

函式式範型

接下來,我們就要來看一下我們函數語言程式設計的三大件 map() 、 reduce() 和 filter() 在之前的《Go程式設計模式:Map-Reduce》文章中,我們可以看到要實現這樣的泛型,需要用到反射,程式碼複雜到完全讀不懂。下面來看一下真正的泛型版本。

泛型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
}

在上面的這個 map函式中我使用了兩個型別 – T1 和 T2 ,

  • T1 – 是需要處理資料的型別
  • T2 – 是處理後的資料型別
    T1 和 T2 可以一樣,也可以不一樣。

我們還有一個函式引數 – func(T1) T2 意味著,進入的是 T1 型別的,出來的是 T2 型別的。

然後,整個函式返回的是一個 []T2

好的,我們來看一下怎麼使用這個map函式:

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{"零", "壹", "貳", "叄", "肆", "伍", "陸", "柒", "捌", "玖"}
strs =  gMap(nums, func (elem int) string  {
  return  dict[elem]
})
print(strs) // 零 壹 貳 叄 肆 伍 陸 柒 捌 玖
泛型 Reduce

接下來,我們再來看一下我們的Reduce函式,reduce函式是把一堆資料合成一個。

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
}

函式實現起來很簡單,但是感覺不是很優雅。

  • 也是有兩個型別 T1 和 T2,前者是輸出資料的型別,後者是佃出資料的型別。
  • 因為要合成一個資料,所以需要有這個資料的初始值 init,是 T2 型別
  • 而自定義函式 func(T2, T1) T2,會把這個init值傳給使用者,然後使用者處理完後再返回出來。
    下面是一個使用上的示例——求一個陣列的和
    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)
    泛型 filter

filter函式主要是用來做過濾的,把資料中一些符合條件(filter in)或是不符合條件(filter out)的資料過濾出來,下面是相關的程式碼示例

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)
}

其中,使用者需要提從一個 bool 的函式,我們會把資料傳給使用者,然後使用者只需要告訴我行還是不行,於是我們就會返回一個過濾好的陣列給使用者。

比如,我們想把陣列中所有的奇數過濾出來

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)

業務示例

正如《Go程式設計模式:Map-Reduce》中的那個業務示例,我們在這裡再做一遍。

首先,我們先宣告一個員工物件和相關的資料

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},
}

然後,我們想統一下所有員工的薪水,我們就可以使用前面的reduce函式

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

我們函式這個 gReduce 函式有點囉嗦,還需要傳一個初始值,在使用者自己的函式中,還要關心 result 我們還是來定義一個更好的版本。

一般來說,我們用 reduce 函式大多時候基本上是統計求和或是數個數,所以,是不是我們可以定義的更為直接一些?比如下面的這個 CountIf(),就比上面的 Reduce 乾淨了很多。

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

我們做求和,我們也可以寫一個Sum的泛型。

  • 處理 T 型別的資料,返回 U型別的結果
  • 然後,使用者只需要給我一個需要統計的 T 的 U 型別的資料就可以了。
    程式碼如下所示:
    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
}

上面的程式碼我們動用了一個叫 Sumable 的介面,其限定了 U 型別,只能是 Sumable裡的那些型別,也就是整型或浮點型,這個支援可以讓我們的泛型程式碼更健壯一些。

於是,我們就可以完成下面的事了。

1)統計年齡大於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)統計薪水超過 6000元的員工數

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)統計年齡小於30歲的員工的薪水

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)統計全員的休假天數

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)把沒有休假的員工過濾出來

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}

怎麼樣,你大概瞭解了泛型程式設計的意義了吧。

(全文完)本文非本人所作,轉載左耳朵耗子部落格和出處 酷 殼 – CoolShell

本作品採用《CC 協議》,轉載必須註明作者和本文連結
滴水穿石,石破天驚----馬乂

相關文章