『每周譯Go』使用 Go 泛型的函數(shù)式編程
函數(shù)式編程是很多語言正在支持或已經(jīng)支持的日漸流行的編程范式。Go 已經(jīng)支持了其中一部分的特性,比如頭等函數(shù)和更高階功能的支持,使函數(shù)式編程成為可能。
Go 缺失的一個關(guān)鍵特性是泛型。缺少這個特性,Go 的函數(shù)庫和應(yīng)用不得不從下面的兩種方法中選擇一種:類型安全 + 特定使用場景或類型不安全 + 未知使用場景。在 2022 年初即將發(fā)布的 Go 1.18 版本,泛型將被加進(jìn)來,從而使 Go 支持新型的函數(shù)式編程形式。
在本篇文章中,我將介紹一些函數(shù)式編程的背景,Go 函數(shù)式編程的現(xiàn)狀調(diào)查,并討論 Go 1.18 計劃的特性以及如何將它們用于函數(shù)式編程。
背景
什么是函數(shù)式編程?
維基百科中定義的函數(shù)式編程是:
通過應(yīng)用組合函數(shù)的編程范式。
更具體的說,函數(shù)式編程有以下幾個關(guān)鍵特征:
純函數(shù) - 使用相同的輸入總是返回?zé)o共享狀態(tài)、可變數(shù)據(jù)或副作用的相同輸出的函數(shù) 不可變數(shù)據(jù) - 數(shù)據(jù)創(chuàng)建后不會再被分配或修改 函數(shù)組合 - 組合多個函數(shù)對數(shù)據(jù)進(jìn)行處理邏輯 聲明式而非指令式 - 表示的是函數(shù)的處理方式而無需定義 如何完成
對于函數(shù)式編程更詳細(xì)的信息,可以參考這兩篇有詳細(xì)描述例子的文章:函數(shù)式編程是什么?和函數(shù)式的 Go
函數(shù)式編程的優(yōu)勢是什么?
函數(shù)式編程是讓開發(fā)者提升代碼質(zhì)量的一些模式。這些質(zhì)量提升的模式并非函數(shù)式編程獨有,而是一些 “免費(fèi)” 的優(yōu)勢。
可測性 - 測試純函數(shù)更加簡單,因為函數(shù)永遠(yuǎn)不會產(chǎn)生超出作用范圍的影響(比如,終端輸出、數(shù)據(jù)庫的讀?。?,并總會得到可預(yù)測的結(jié)果 可表達(dá)性 - 函數(shù)式編程/庫使用聲明式的基礎(chǔ)可以更高效地表達(dá)函數(shù)的原始意圖,盡管需要額外學(xué)習(xí)這些基礎(chǔ) 可理解性 - 閱讀和理解沒有副作用、全局或可變的純函數(shù)主觀來看更簡單
正如多數(shù)開發(fā)者從經(jīng)驗中學(xué)到的,如 Robert C. Martin 在代碼整潔之道中所說:
確實,相對于寫代碼,花費(fèi)在讀代碼上的時間超過 10 倍。為了寫出新代碼,我們一直在讀舊代碼?!璠因此,] 讓代碼更易讀,可以讓代碼更易寫。
根據(jù)團(tuán)隊的經(jīng)驗或?qū)W習(xí)函數(shù)式編程的意愿,這些優(yōu)勢會產(chǎn)生很大的影響。相反,對于缺乏經(jīng)驗和足夠時間投入學(xué)習(xí)的團(tuán)隊,或維護(hù)大型的代碼倉庫時,函數(shù)式編程將會產(chǎn)生相反的作用,上下文切換的引入或顯著的重構(gòu)工作將無法產(chǎn)生相應(yīng)的價值。
Go 函數(shù)式編程的現(xiàn)狀
Go 不是一門函數(shù)語言,但確實提供了一些允許函數(shù)式編程的特性。有大量的 Go 開源庫提供函數(shù)特性。我們將會討論泛型的缺失導(dǎo)致這些庫只能折衷選擇。
語言特性
函數(shù)式編程的語言支持包括一系列從僅支持函數(shù)范式(比如 Haskell)到多范式和頭等函數(shù)的支持(比如 Scale、Elixir),還包括多范式和部分支持(如 Javascript、Go)。在后面的語言中,函數(shù)式編程的支持一般是通過使用社區(qū)創(chuàng)建的庫,它們復(fù)制了前面兩個語言的部分或全部的標(biāo)準(zhǔn)庫的特性。
屬于后一種類別的 Go 要使用函數(shù)式編程需要下面這些特性:

? 將在 Go 1.18 中可用(2022 年初)
現(xiàn)有的庫
在 Go 生態(tài)中,有大量函數(shù)式編程的庫,區(qū)別在于流行度、特性和工效。由于缺少泛型,它們?nèi)恐荒軓南旅鎯煞N選擇中取一個:
類型安全和特定使用場景 - 選擇這個方法的庫實現(xiàn)的設(shè)計是類型安全,但只能處理特定的預(yù)定義類型。因為無法應(yīng)用于自適應(yīng)的類型或結(jié)構(gòu)體,這些庫的應(yīng)用范圍將受限制。 - 比如, func UniqString(data []string) []string和func UniqInt(data []int) []int都是類型安全的,但只能應(yīng)用在預(yù)定義的類型類型不安全和未知的應(yīng)用場景 - 選擇這個方法的庫實現(xiàn)的是類型不安全但可以應(yīng)用在任意使用場景的方法。這些庫可以處理自定義類型和結(jié)構(gòu)體,但折衷點在于必須使用類型斷言,這讓應(yīng)用在不合理的實現(xiàn)時有運(yùn)行時崩潰的風(fēng)險。 - 比如,一個通用的函數(shù)可能有這樣的命名: func Uniq(data interface{}) interface{}
這兩種設(shè)計選擇顯示了兩種相似的不吸引人的選項:有限的使用或運(yùn)行時崩潰的風(fēng)險。最簡單也許最常見的選擇是不使用 Go 的函數(shù)式編程庫,堅持指令式的風(fēng)格。
使用泛型的函數(shù)式 Go
在2021年3月19日,泛型的設(shè)計提案通過并定為 Go 1.18 發(fā)行版的一部分。有了泛型之后,函數(shù)式編程庫就不再需要在可用性和類型安全之間進(jìn)行折衷。
Go 1.18 實驗
Go 開發(fā)組發(fā)布了一個 go 1.18 游樂場,便于大家嘗鮮泛型。同時也有一個實驗性的編譯器,在 go 代碼倉庫的一個分支上實現(xiàn)了泛型特性的最小集合。這兩個都是在 Go 1.18 上嘗鮮泛型的不錯選擇。
一個使用場景的探索
在前面說到的那個 unique 函數(shù)使用了兩種可能的設(shè)計方法。有了泛型,它可以重寫為 func Uniq[T](data []T) []T,并可以使用任意類型來調(diào)用,比如 Uniq[string any](data []string) []string或 Uniq[MyStruct any](data []MyStruct) []MyStruct。為了進(jìn)一步闡述這個概念,下面是一個具體的例子,展示了在 Go 1.18 中如何使用函數(shù)式單元來解決實際問題。
背景
一個在網(wǎng)絡(luò)世界常見的案例是 HTTP 的請求響應(yīng),其中 API 接口返回的 JSON 數(shù)據(jù)一般會被消費(fèi)應(yīng)用轉(zhuǎn)換為一些有用的結(jié)構(gòu)。
問題 & 輸入數(shù)據(jù)
考慮下這個從 API 返回用戶、得分和朋友信息的響應(yīng):
[
{
"id": "6096abc445dbb831decde62f",
"index": 0,
"isActive": true,
"isVerified": false,
"user": {
"points": 7521,
"name": {
"first": "Ramirez",
"last": "Gillespie"
},
"friends": [
{
"id": "6096abc46573cedd17fb0201",
"name": "Crawford Arnold"
},
...
],
"company": "SEALOUD"
},
"level": "gold",
"email": "[email protected]",
"text": "Consequat pariatur aliquip pariatur mollit mollit cillum sint. Elit est nisi velit cillum. Ex mollit dolor qui velit Lorem proident ullamco magna velit nulla qui. Elit duis non ad laborum ullamco irure nulla culpa. Proident culpa esse deserunt minim sint nisi duis culpa nostrud in incididunt ad. Amet qui laborum deserunt proident adipisicing exercitation quis.",
"created_at": "Saturday, August 3, 2019 8:12 AM",
"greeting": "Hello, Ramirez! You have 9 unread messages.",
"favoriteFruit": "banana"
},
...
]
假設(shè)目標(biāo)是獲取各個等級的高分用戶。我們將看下函數(shù)式和指令式風(fēng)格的樣子。
指令式
// imperative
func getTopUsers(posts []Post) []UserLevelPoints {
postsByLevel := map[string]Post{}
userLevelPoints := make([]UserLevelPoints, 0)
for _, post := range posts {
// Set post for group when group does not already exist
if _, ok := postsByLevel[post.Level]; !ok {
postsByLevel[post.Level] = post
continue
}
// Replace post for group if points are higher for current post
if postsByLevel[post.Level].User.Points < post.User.Points {
postsByLevel[post.Level] = post
}
}
// Summarize user from post
for _, post := range postsByLevel {
userLevelPoints = append(userLevelPoints, UserLevelPoints{
FirstName: post.User.Name.First,
LastName: post.User.Name.Last,
Level: post.Level,
Points: post.User.Points,
FriendCount: len(post.User.Friends),
})
}
return userLevelPoints
}
posts, _ := getPosts("data.json")
topUsers := getTopUsers(posts)
fmt.Printf("%+v\n", topUsers)
// [{FirstName:Ferguson LastName:Bryant Level:gold Points:9294 FriendCount:3} {FirstName:Ava LastName:Becker Level:silver Points:9797 FriendCount:2} {FirstName:Hahn LastName:Olsen Level:bronze Points:9534 FriendCount:2}]
樣例的完整代碼
函數(shù)式
// functional
var getTopUser = Compose3[[]Post, []Post, Post, UserLevelPoints](
// Sort users by points
SortBy(func (prevPost Post, nextPost Post) bool {
return prevPost.User.Points > nextPost.User.Points
}),
// Get top user by points
Head[Post],
// Summarize user from post
func(post Post) UserLevelPoints {
return UserLevelPoints{
FirstName: post.User.Name.First,
LastName: post.User.Name.Last,
Level: post.Level,
Points: post.User.Points,
FriendCount: len(post.User.Friends),
}
},
)
var getTopUsers = Compose3[[]Post, map[string][]Post, [][]Post, []UserLevelPoints](
// Group posts by level
GroupBy(func (v Post) string { return v.Level }),
// Covert map to values only
Values[[]Post, string],
// Iterate over each nested group of posts
Map(getTopUser),
)
posts, _ := getPosts("data.json")
topUsers := getTopUsers(posts)
fmt.Printf("%+v\n", topUsers)
// [{FirstName:Ferguson LastName:Bryant Level:gold Points:9294 FriendCount:3} {FirstName:Ava LastName:Becker Level:silver Points:9797 FriendCount:2} {FirstName:Hahn LastName:Olsen Level:bronze Points:9534 FriendCount:2}]
樣例的完整代碼
從上面的樣例中可以看出一些特性:
指令式的實現(xiàn)在 Go 1.16 下是有效的(本文編寫時的最新版本),而函數(shù)式的實現(xiàn)只在使用 Go 1.18(go2go)編譯才有效 函數(shù)式例子中的類型參數(shù)的泛型函數(shù)(如,Compose3、Head 等)僅 Go 1.18 支持 兩個實現(xiàn)在各自對應(yīng)的風(fēng)格下,使用了不同的邏輯來解決同樣的問題 指令式的實現(xiàn)相比使用及早求值(即本例中的pneumatic)的函數(shù)來說,計算更加高效
使用 Go 1.18 函數(shù)式庫的實驗
在上面的例子中,兩個使用場景使用了 go2go 編譯器和一個叫做 pneumatic 的 Go 1.18 庫,它提供了與Ramda (JavaScript), Elixir 標(biāo)準(zhǔn)庫以及其他相似的常見函數(shù)式單元。鑒于 go2go 編譯器有限的特性集,在本文發(fā)布時 pneumatic 只能用于實驗?zāi)康?,但從長期看,隨著 Go 1.18 編譯器的逐漸成熟,它會包含常見的函數(shù)式 Go 庫。設(shè)置 pneumatic 和使用 Go 1.18 進(jìn)行函數(shù)式編程的指導(dǎo)參見 pneumatic readme。
結(jié)論
Go 增加泛型將會支持新型的方案、方法和范式,從而成為眾多支持函數(shù)式編程的語言之一。隨著函數(shù)式編程的逐漸流行,函數(shù)式編程的支持也會越來越好,從而有機(jī)會吸引那些現(xiàn)在還沒考慮學(xué)習(xí) Go 的開發(fā)者并讓社區(qū)持續(xù)發(fā)展——這是在我看來比較積極的一面。非常期待看到在后續(xù)支持泛型之后和它帶來新的解決方法后,Go 社區(qū)和生態(tài)將會發(fā)展成什么樣。
參考資料
Go 函數(shù)庫調(diào)研
go-funk [2.5k stars, type-safe or generic, active]
go-underscore [1.2k stars, generic, abandoned]
gubrak [336 stars, generic, active]
fpGo [167 stars, generic, active]
functional-go [92 stars, type-safe, active]
文章
Go 泛型的過去、現(xiàn)在和將來
原文地址:https://ani.dev/2021/05/25/functional-programming-in-go-with-generics/
原文作者:
Ani Channarasappa本文永久鏈接:https://github.com/gocn/translator/blob/master/2021/w24_functional_programming_in_go_with_generics.md
譯者:cvley
別忘了還有 Gopher China 2021 大會在文末等著你哦~
想和各位技術(shù)大佬們同臺見面嘛?
那就趕快點擊下方「閱讀原文」報名參加呀!
