[Golang] 參數化類型 — 泛型(Generic)

Tipsy (Jun)
16 min readJul 26, 2023

--

閱讀程度 : ★★★★☆

適合族群: 增強編碼能力、學習程式語言高階用法、物件導向真正精神

泛型: 某對象接受『所有類型的輸入,並參數化輸入的所有類型』(包含客製化)

★ 筆者經驗: 物件導向重視類型結構導向重視一階函數 (類型較少),結構導向目的將解構類型成不同型態

目的: 透過參數化類型高效『抽象化物件』,將物件轉變成通用型元件。快速整合物件形成具有韌性的(向後兼容)結構

參數化類型: 將變數、結構、容器等對象所需定義的『類型』視為一種參數並透過抹除參數的存在,使規劃與設計架構變得安全

抹除參數 — 意義: 約束/忽略變數、結構、容器等輸入對象的類型。無須強制輸入對象轉型,使規劃與設計變得安全

通用 — 意義: 類型抽離編程、算法,專注規劃設計功能。對類型(包含客製化)需要有一定程度的掌握

  • 抽象化 (Abstraction)

抽象經常與泛型一起討論,泛型常使用於抽象化過程,所以底下列出抽象化的意義。目的為廣義化某對象

1. 對象、流程、目的(概念性的意象) 擇一成為抽象目標(達成具體描述,透過行為、屬性敘述目標)

2. 過程: 壓縮邏輯 — 廣義化(Generalize) → 提煉邏輯

3. 壓縮邏輯: 原本邏輯進行坍塌過程,通常視為「模組化」(一般稱為歸納)

4. 提煉邏輯: 結構(模組化邏輯)進行萃取過程,通常使對象具有「韌性」

5. 抽象化過程結果可能擁有「無序性」

6. 物件導向的建構子為基礎的抽象化 (泛型也為物件導向隱含的真正精神)

Photo by Daniel Chen on Unsplash

● 簡介

底下為語言中泛型主要具備的意義 (目前大多數開發者只有單純使用泛型的表面意義,尚未系統性使用泛型。程式的泛型技巧充滿未知數未來性,原因為其中泛型相關開發知識甚少!!)

  1. 編譯檢查: 降低程式開發中,編譯無異常,執行時發生異常機率 (幫助檢測異常)
  2. 類型邊界: 透過上限下限隱藏實現機制控制類型範圍 (EX: Java 的 extends、Super),有些語言不支援
  3. 向後兼容: 擁有共通基礎功能,並解隅物件之間的型態(類型)

※題外話: Golang 的泛型 一定程度取代 Java 的 abstract 關鍵字使用方法

● 開發

介紹泛型之前,筆者先介紹筆者習慣定義的函數結構,將函數拆解成元件。目的為系統性理解語言結構。

函數擁有三種元件: 函數所有者函數接受者函數回傳者

函數所有者: 透過「擁有」函數而實作介面,擁有為 is a,其意義為鴨子型態

函數接受者: 透過 「類型限定」強化輸入型態,其意義使函數具有向後兼容

函數回傳者: 透過「依賴反轉」轉移物件控制權,其意義為解耦物件

#函數所有者、接受者、回傳者都可以具備「向後兼容」,其中接受者可以發揮「向後兼容」最大效用。

#開發 Golang 時,開發者容易基於函數所有者設計程式。如果開發設計重心多以函數接受者將使系統設計、模組化能力大幅優化改進。

※模組化: 壓縮邏輯

※抽象 : 表示尚未實現的介面功能 (只要實現可以完成轉移物件的控制權)。可以想像成藝術畫作的畫布框架,框架可以是不同的情境,畫布內容則是實現的功能

轉移: 透過第三方控制物件中的其他物件。當要使用物件時,其中可能會使用其他物件的功能,此功能主要由第三方管理。以此將額外功能轉移(委託)給第三方進行處理

(依賴注入 ( Dependency Injection): 物件擁有的物件委託給第三方保管。物件可以呼叫第三方藉此物件可以使用其擁有的物件。第三方視為 控制反轉 ( Inversion of Control ) IoC 容器)

建構子: 處理類別中屬性基礎方法方法物件導向函數尊稱盡量不撰寫與基礎方法不相關的額外功能,額外功能通常寫在物件內並且搭配對象狀態 (public,private,protected,package(不寫))。另外額外功能通常不建議寫在建構子內

package main

// 函數所有者
type A struct{}

// 函數接收者
type B struct{}

// 函數回傳者
type C struct{}

// 函數結構 (此部分採用 naked return)
func (a A) useFunc(b B) (c C) {
return
}

func main() {
a := A{}
b := B{}
a.useFunc(b)
}

■ 開發方向

開發者可以選擇不同方式(函數所有者、函數接受者、函數回傳者)進行參數化型態(泛型)。

  1. 基礎 — 泛型

第一種: 透過 inline 設定函數泛型,達成重用函數。一定程度取代 Golang 介面Java 關鍵字 abstract 功能。函數接收者、函數回傳者透過泛型擴展輸入型態同時降低執行期的編譯錯誤

第二種: 函數接受者、回傳者沒有使用函數指定泛型。其意義為未來實現向後兼容(compatible)、自由解耦。兩種功能幫助開發者增加開發能力、拓展思維。

第三種: 透過介面實現泛型,介面視為泛型控制器第三種為第一種實際程式展開意義,開發者使用引數實現介面訂制的函數參數類型。

觀察第一種至第三種作法,Golang 特別針對結構導向設計泛型。尤其第三種延續 Golang 開發者透過介面開發泛型的習慣,並且從物件導向設計優勢引出結構導向精神,使結構導向容易達成系統制度思維設計

package main

import "fmt"

// 第一種
func say1[T int32 | float32](n []T) T {
var word T
for _, item := range n {
word += item
}
return word
}

// 第二種
func say2[T int32](n float32) float32 {
var word float32 = n
return word
}

// 第三種
type funcType interface {
int32 | float32
}

func say3[T funcType](n T) T {
var word T = n
return word
}

func main() {
fmt.Println("Hello, 世界")

// 第一種
data1 := []int32{10, 20, 30}
word1 := say1[int32](data1)
fmt.Printf("word1: %v (%T)\n", word1, word1)

// 第二種
data2 := float32(40)
word2 := say2[int32](data2)
fmt.Printf("word2: %v (%T)\n", word2, word2)

// 第三種
word3 := say3[float32](data2)
fmt.Printf("word3: %v (%T)\n", word3, word3)

}
  • 一階函數 — 泛型 (泛型另一種特殊形態)

一階函數做為函數的參數宣告,引數需要符合一階函數的定義型態。開發者將此做法視為模組化一階函數,由此可以輔助開發設計函數。

package main

import "fmt"

type chosenType func(int) int

func transfer(in int) (out int) {
out = in + 1
return out
}

func takeType(ct chosenType, inValue int) {
switch ct(inValue) {
case 1:
fmt.Println(ct(inValue))
case 2:
fmt.Println(ct(inValue))
case 3:
fmt.Println(ct(inValue))
default:
fmt.Println(ct(inValue))
}
}

func main() {
fmt.Println("Hello, 世界")
takeType(transfer, 0)
}

泛型設計替換一階函數的型態,並且保留原有一階函數的型態,幫助未來維護與開發。

package main

import "fmt"

type A struct{}
type B struct{}
type valueType interface{ A | B }
type chosenType[T valueType] func(T) int

func takeType(ct chosenType[A]) { // 定義函數參數、泛型類型
tmpA := A{}
fmt.Println(ct(tmpA))
}
func input(a A) int {
return 1
}

func main() {
fmt.Println("Hello, 世界")
takeType(input)
}

2. 結構 — 泛型

函數所有者無法直接使用 inline 呈現泛型,所以採取介面定義類型,此方法降低程式耦合性並且讓整體程式更為簡潔

函數所有者使用泛型時,先透過介面定義泛型,再將泛型塞入結構。其意義為函數所有者透過泛型解放鴨子型態 (函數所有者的類型定義不能是 pointer、interface type)

package main

import "fmt"

type A struct {
word int
}

type B struct {
word int
}

type attrType interface {
A | B
}

type C[T attrType] struct {
word int
}

func (c C[T]) testFunc1() {
fmt.Println("Hello from the testFunc1")
}

func main() {
fmt.Println("Hello, 世界")
tmpC := C[A]{}
tmpC.testFunc1()
}

3. 結構 — 泛型 & 一般介面

  • 結構定義: 函數所有者函數接受者使用介面實現泛型、模組化功能介面幫助開發通用性類型、功能,同時也複雜化開發流程。主要原因為介面主要處理抽象形式,抽象形式與開發流程兩者通常需要取捨。
  • 函數定義: 函數所有者使用介面實現泛型,函數接受者使用函數定義宣告參數類型。以此平衡抽象形式與開發流程兩者比重,降低系統的複雜性
package main

import "fmt"

type A struct{ word int }

type B struct{ word int }

type attrType interface{ A | B }

type C[T attrType] struct{ word A }

type D interface{ test() }

// 結構定義
type E struct{}

func (e E) test() {}
func (c C[T]) testFunc1(d D) T {
fmt.Println("Hello from the testFunc1")
return T(c.word)
}

// 函數定義
type TypeFunc func(int)

func showFunc(int) {}
func (c C[T]) testFunc2(tf TypeFunc) {
fmt.Println("Hello from the testFunc2")
}

func main() {
fmt.Println("Hello, 世界")
tmpC := C[A]{}
tmpE := E{}
tmpC.testFunc1(tmpE)
tmpC.testFunc2(showFunc)
}

4. 常數 — 泛型 (功能設定)

開發系統時透過常數定義系統設置,並且需要設計設置擁有功能。此時開發者透過泛型輔助函數接受者的向後兼容

此程式透過函數接受者開發泛型,如果開發者選擇函數接受者通常代表輸入內容具有通用性

package main

import "fmt"

type testInt int

type testStr string

type SetType interface{ testInt | testStr } // 不同的系統設置

const (
tmp1 testInt = iota + 1
tmp2
tmp3
tmp4
)

func (testInt) getRet1() {
fmt.Println("Hello from the getRet1")
}

func getRet2[T SetType](T) {
fmt.Println("Hello from the getRet2")

}

func main() {
fmt.Println("Hello, 世界")
tmp1.getRet1() // 因為 tmp1 為常數,所以可以直接使用
getRet2[testInt](tmp1) // getRet2(tmp1) 也可以,Golang 會自行從函數接收者推斷類型
}

5. 通道(Channel) — 泛型

Golang 通道是一種獨有傳輸資訊方法。開發者可能擁有其他語言開發、看過相關程式經驗,不過以目前整體趨勢發展,Golang 通道設計相對完善

  • 通道 — 簡介
  1. 資訊交換: 併發/並行傳輸資料
  2. 資訊鎖: 資料存取順序 (權限等級: 以順序為設計切入點)
  3. 通道 & 函數: 結構導向併行化 (函數所有者一般以結構開發為主,函數接受者通常是輸入函數回傳者通常是輸出)
  4. 通道 & 函數 & 泛型: 系統性併行化 (因為參數化類型,開發者易於操控類型變化(EX: 類型上下限),並且使開發成本降低(如果開發者熟悉))

※ 結構導向在意資訊順序設計

底下為基礎的通道-泛型 (無順序)

實務上,通道經常需要大量測試與修改,所以透過泛型有效降低開發成本。

package main

import "fmt"

type A struct{}
type B struct{}
type C struct{ A B }

type valueType interface{ A | C }

func chanValue[T valueType](inCH chan T, outCH chan T) {
var result T
result = <-inCH
outCH <- result
}

func main() {
fmt.Println("Hello, 世界")
ch1 := make(chan A)
ch2 := make(chan A)
go chanValue(ch1, ch2) // 泛型的型態推斷,所以不需要寫泛型[]
}

底下為基礎的通道-泛型 (有順序)

筆者根據開發經驗將函數區分成兩種特性

  • 無函數所有者,只有函數接受者,此函數特性為通用性
  • 有函數所有者,只有函數接受者,此函數特性為順序性

筆者盡量將此程式的函數接受者 inCH、outCH 使用 類型 A 擁有的函數,函數所有者 d 使用 類型 C 擁有的函數。在未來開發時,比較不容易出錯。

※另外,如果開發者需要開發有順序性,可以新增一個函數 chanValue2 處理不同類型的 Channel。通常順序性與通用性的函數功能不同,這也是一種判斷方式。

package main

import "fmt"

type A struct{}
type B struct{}
type C struct{ A B }
type valueType interface{ A | C }
type D[T valueType] struct{} // 定義函數所有者

func (d D[T]) chanValue(inCH chan T, outCH chan T) { // 藉由函數所有者的泛型進行推斷
var result T
result = <-inCH
outCH <- result
}

func main() {
fmt.Println("Hello, 世界")
ch1 := make(chan C)
ch2 := make(chan C)
tmpD := D[C]{}
go tmpD.chanValue(ch1, ch2) // 泛型的型態推斷,所以不需要寫泛型[]
}
  • 泛型的型態推斷 (開發者需注意!!)

型態推斷基於函數的第一個參數推論型態,執行時會發現底下程式出錯。因為在編譯時期泛型會檢查錯誤,此程式的錯誤發生在執行時期。

※ 這部分可能與其他語言的泛型規定不太相同

實務開發上,型態轉換問題相對棘手,因為程式可能由不同人開發新功能,開發過程容易觀察到程式的變數、結構等型態相互轉換,此時使用泛型較容易除錯

package main

import "fmt"

type A struct{}
type B struct{}
type C struct{ A B }

type chanValueType interface{ A | B | C }

func chanValue[T chanValueType](inCH chan T, outCH chan T) {
var result T = <-inCH
switch t := any(result).(type) { // 泛型轉型方法
case A:
_ = t
outCH <- result
case B:
_ = t
case C:
_ = t
}
}

func main() {
fmt.Println("Hello, 世界")
ch1 := make(chan A)
ch2 := make(chan C)
go chanValue(ch1, ch2) // 執行時,型態推斷出現錯誤
}

● 結論/心得

筆者較常看到此篇文章的一階函數、常數的泛型設計,這兩種都較為高階,它們可以改善系統開發過程增強測試錯誤能力

以往的物件導向開發者較少使用泛型,因為物件導向對類型要求相對較高,所以開發者經常在開發當下直接使用轉型,但是也造成系統的隱患。隱患通常為開發系統的新功能時難以判斷以前程式多餘的物件舊功能,最後導致系統變得冗餘、龐雜

所以如果開發允許,開發者不妨使用泛型進行開發。

另外,泛型思維物件導向開發者可能會比結構導向開發者更為吃力,這點需要注意!!

--

--

Tipsy (Jun)

Chill & Cozy Coding City ☕️(Software Engineer , Decentralized Engineer(Blockchain) ) | Research systematic field for 5 years.| Coding Tour