函數式+泛型編程:編寫簡潔可複用的代碼

Write Less Do More.

引子

我個人比較信奉的一句編程箴言: Write Less and Do More。無論是出於懶,還是出於酷炫的編程技藝,或者是一種編程樂趣。

函數式和泛型編程是編寫簡潔可複用代碼的兩大編程技藝,組合起來威力更加強大。另一項技藝是元編程。本文主要來講講函數式和泛型編程。

泛型編程

所謂泛型函數,就是一個函數適用於多種類型。有很多流程算法,都是可以適配多種類型的,比如加減之於整數/實數/複數、排序之於不同類型的數組。這些很適合用泛型來表達。

泛型和普通函數和很相似,只有一點不同。

一個簡單的例子

比如 如下代碼:

func add(a int, b int) int {
	return a + b
}

func addInt8(a int8, b int8) int8 {
	return a + b
}

func main() {
	fmt.Println(add(1, 2))
	fmt.Println(addInt8(1, 2))
}

add 和 addInt8 只是類型不同,實際上算法一模一樣。這時候就適合用泛型改造一下:

func addGeneric[T int | int8 | int32 | int64](a T, b T) T {
	return a + b
}

func main() {
	fmt.Println(addGeneric(1, 2))
	fmt.Println(addGeneric(int8(1), int8(2)))
	fmt.Println(addGeneric(int32(1), int32(2)))
	fmt.Println(addGeneric(int64(1), int64(2)))
}

注意到普通函數和泛型函數只有一點點差別。就是方法名和參數列表之間多了個類型形參 [T int|int8|int32|int64]。 這個類型形參可以替代實參裏的參數類型,返回值如有必要也替換 T。

如果你不太適應寫泛型函數,可以先寫個普通函數,然後再加上類型形參,再把實參裏的類型替換成類型形參即可。就這麼簡單!多寫幾次就會了。

封裝庫函數

泛型函數很適合封裝庫函數。比如如下代碼,在 byte[] 和對象之間轉換。

package util

import (
	"bytes"
	"encoding/gob"
)

/*
 * 使用 gob 進行 struct{} 與 byte[] 之間轉換
 * 只適用於 Go, 不支持函數和通道
 */
func ConvertToBytes[T any](t *T) []byte {
	var buf bytes.Buffer
	e := gob.NewEncoder(&buf)
	err := e.Encode(*t)
	if err != nil {
		panic(err)
	}
	return buf.Bytes()
}

func ConvertFromBytes[T any](buf []byte) *T {
	var obj T
	d := gob.NewDecoder(bytes.NewReader(buf))
	err := d.Decode(&obj)
	if err != nil {
		panic(err)
	}
	return &obj
}

測試用例如下:

type HostCacheInfo2 struct {
	TenantId     string
	AgentId      string
	OsType       string
	DisplayIp    string
	GroupId      string
	Hostname     string
	PlatformType string
}

func TestConversion(t *testing.T) {
	hc := HostCacheInfo2{AgentId: "agentId", TenantId: "tenantId", PlatformType: "SERVER", Hostname: "qin"}
	bytes := util.ConvertToBytes(&hc)
	fmt.Println(bytes)

	hc2 := util.ConvertFromBytes[HostCacheInfo2](bytes)
	fmt.Println(hc2)

	str := "abcde"
	bytes2 := util.ConvertToBytes(&str)
	str2 := util.ConvertFromBytes[string](bytes2)
	fmt.Println(str2)
}

如下代碼所示,基於 golang BigCache 庫實現一個易用的對象本地緩存。BigCache 底層是用字節數組來存儲的,對於存儲對象不太友好。

package util

import (
	"github.com/allegro/bigcache"
)

type LocalCache[T any] struct {
	LocalCache *bigcache.BigCache
}

func NewLocalCache[T any](config bigcache.Config) *LocalCache[T] {
	bigCache, _ := bigcache.NewBigCache(config)
	return &LocalCache[T]{LocalCache: bigCache}
}

func (c *LocalCache[T]) Set(key string, value T) {
	c.LocalCache.Set(key, ConvertToBytes(&value))
}

func (c *LocalCache[T]) Get(key string) *T {
	bytes, _ := c.LocalCache.Get(key)
	return ConvertFromBytes[T](bytes)
}

測試用例如下:

package test

import (
	"fmt"
	"testing"
	"time"

	"util"
	"github.com/allegro/bigcache"
)

func TestLocalCache(t *testing.T) {
	lc := util.NewLocalCache[HostCacheInfo2](bigcache.DefaultConfig(10 * time.Minute))
	lc.Set("abc", HostCacheInfo2{AgentId: "agentId", TenantId: "tenantId"})

	host := lc.Get("abc")
	fmt.Println(*host)
}

func TestLocalCache2(t *testing.T) {
	lc := util.NewLocalCache[string](bigcache.DefaultConfig(10 * time.Minute))
	lc.Set("abc", "a dream")

	astring := lc.Get("abc")
	fmt.Println(*astring)
}


掌握泛型編程,你的編程技藝會更上一層樓。

函數式編程

函數式編程看上去很神祕,但其實很簡單。有一定編程經驗的人都知道,函數可以接受參數進行計算。大多數時候,參數可能就是普通的具體的值,按照一定的規則進行計算。函數式編程,不過就是把函數地址傳給函數,然後可以調用傳入的函數而已。

小試牛刀

我比較喜歡用的例子是,取出某個對象列表裏的某個元素。你不知道這個對象的類型是什麼,只知道如何從對象裏取這個元素。

如下代碼所示,只需要短短 6 行代碼,你可以從任意對象列表中獲取對象的某個屬性的列表。酷不酷?

func GetElements[E any, R any](objlist []E, convertFunc func(e E) R) []R {
	result := make([]R, len(objlist))
	for _, e := range objlist {
		result = append(result, convertFunc(e))
	}
	return result
}

測試用例

type Person struct {
	Name string
	Age  int
}

func NewPerson(name string, age int) Person {
	return Person{Name: name, Age: age}
}

type Student struct {
	No string
}

func NewStudent(no string) Student {
	return Student{No: no}
}

func main() {

	persons := []Person{NewPerson("qin", 35), NewPerson("ni", 27)}
	fmt.Println(GetElements(persons, func(p Person) string {
		return p.Name
	}))

	students := []Student{NewStudent("S001"), NewStudent("S003")}
	fmt.Println(GetElements(students, func(s Student) string {
		return s.No
	}))
}

如果支持元編程(用反射也可以做到)的話,還可以把屬性名傳入函數,就能實現在任意列表取出對象的任意屬性來生成一個新的列表。

你還可以給這個函數添加更多的功能。比如過濾:

func GetElementsWithFilter[E any, R any](objlist []E,
	convertFunc func(e E) R,
	filterFunc func(r R) bool) []R {
	result := make([]R, 0)
	for _, e := range objlist {
		r := convertFunc(e)
		if (filterFunc(r)) {
			result = append(result, r)
		}
	}
	return result
}

測試用例

oldPersonAges := GetElementsWithFilter(persons, func(p Person) int { return p.Age }, func(age int) bool { return age > 35 })
fmt.Println(oldPersonAges)

文件處理

我們來實現一個通用文件讀寫工具。讀取文本文件的每一行,使用一個外部函數來處理。

func ScanFile(filename string) *bufio.Scanner {

	readFile, err := os.Open(filename)

	if err != nil {
		fmt.Println(err)
	}
	fileScanner := bufio.NewScanner(readFile)

	fileScanner.Split(bufio.ScanLines)

	return fileScanner
}

func ReadAndHandle(filename string, handle func(line string)) {

	fileScanner := ScanFile(filename)

	for fileScanner.Scan() {
		handle(fileScanner.Text())
	}
}

func main() {
    ReadAndHandle("/Users/qinshu/Development/ids_method_costs_20230729182500015.txt", func(line string) {
        fmt.Println(line + " chars: " + strconv.Itoa(len(line)))
    })
}

假設要讀取一批文件呢?這一批文件是通過不同方式來獲取的。只消這樣:

func BatchReadAndHandle(filenamesGenerator func() []string, handle func(line string)) {
	for _, filename := range filenamesGenerator() {
		ReadAndHandle(filename, handle)
	}
}

用法:

batchGetFilenameFunc := func() []string {
    cmd := exec.Command("/bin/bash", "-c", "ls -1 /Users/qinshu/Development/ids_method_costs_*.txt")
    out, err := cmd.CombinedOutput()
    if err != nil {
        fmt.Printf("combined out:\n%s\n", string(out))
        log.Fatalf("cmd.Run() failed with %s\n", err)
    }
    fmt.Printf("combined out:\n%s\n", string(out))
    return strings.Split(string(out), "\n")
}

BatchReadAndHandle(batchGetFilenameFunc, func(line string) {
    fmt.Println(line + " chars: " + strconv.Itoa(len(line)))
})

現在,我要寫一個文件處理的簡易框架,它的過程如下:

  • 第一步:拿到一個文件名列表;
  • 第二步:過濾文件名,拿到所需的文件名;
  • 第三步:對文件的每一行進行處理,輸出一個值;
  • 第四步:對第三步輸出的值,進行聚合,輸出一個最終聚合的值。
func ReadAndHandleWithReturn[T any](filename string, handle func(line string) T) []T {
	fileScanner := ScanFile(filename)

	result := make([]T, 0)
	for fileScanner.Scan() {
		result = append(result, handle(fileScanner.Text()))
	}
	return result
}

func handleFiles[T any, R any](
	filenamesGenerator func() []string,
	filenameFilter func(filename string) bool,
	handle func(line string) T,
	aggregate func(t []T) R) R {

	var r R
	for _, filename := range filenamesGenerator() {
		if filenameFilter(filename) {
			subresults := ReadAndHandleWithReturn[T](filename, handle)
			r = aggregate(subresults)
		}
	}
	return r
}

使用:

charsCount := func(line string) int {
    return len(line)
}

aggregate := func(numbers []int) int {
    total := 0
    for _, number := range numbers {
        total += number
    }
    return total
}

totalChars := handleFiles[int, int](batchGetFilenameFunc, filenameFilter, charsCount, aggregate)

fmt.Println("totalChars: " + strconv.Itoa(totalChars))

lineConcat := func(line string) string { return line }
lineAggregate := func(lines []string) string {
    return strings.Join(lines, "\n")
}

totalLines := handleFiles[string, string](batchGetFilenameFunc, filenameFilter, lineConcat, lineAggregate)
fmt.Println("totalLines: " + totalLines)

是不是很嗨!

可以看到,僅僅只是通過函數組合,可以構建出非常強大的功能,而且可以在這個基礎上不斷疊加組合。

當你熟諳函數式編程時,只要幾個函數,就可以構建出一個簡潔易用的處理框架。真是一項迷人的技藝啊!

小結

函數式 + 泛型編程,是一對強大的編程組合,威力極強,可謂重劍鈍鋒。使用函數式+泛型編程,編寫簡潔可複用的代碼,也是編程樂趣之一。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章