使用 Go 泛型的函數語言程式設計

jokerbugs發表於2021-06-23

使用 Go 泛型的函數語言程式設計

時間:2021年5月25日

函數語言程式設計是很多語言正在支援或已經支援的日漸流行的程式設計正規化。Go 已經支援了其中一部分的特性,比如頭等函式和更高階功能的支援,使函數語言程式設計成為可能。

Go 缺失的一個關鍵特性是泛型。缺少這個特性,Go 的函式庫和應用不得不從下面的兩種方法中選擇一種:型別安全 + 特定使用場景或型別不安全 + 未知使用場景。在 2022 年初即將釋出的 Go 1.18 版本,泛型將被加進來,從而使 Go 支援新型的函數語言程式設計形式。

在本篇文章中,我將介紹一些函數語言程式設計的背景,Go 函數語言程式設計的現狀調查,並討論 Go 1.18 計劃的特性以及如何將它們用於函數語言程式設計。

背景

什麼是函數語言程式設計?

維基百科中定義的函數語言程式設計是:

通過應用組合函式的程式設計正規化。

更具體的說,函數語言程式設計有以下幾個關鍵特徵:

  • 純函式 - 使用相同的輸入總是返回無共享狀態、可變資料或副作用的相同輸出的函式
  • 不可變資料 - 資料建立後不會再被分配或修改
  • 函式組合 - 組合多個函式對資料進行處理邏輯
  • 宣告式而非指令式 - 表示的是函式的處理方式而無需定義 如何完成

對於函數語言程式設計更詳細的資訊,可以參考這兩篇有詳細描述例子的文章:函數語言程式設計是什麼?函式式的 Go

函數語言程式設計的優勢是什麼?

函數語言程式設計是讓開發者提升程式碼質量的一些模式。這些質量提升的模式並非函數語言程式設計獨有,而是一些 “免費” 的優勢。

  • 可測性 - 測試純函式更加簡單,因為函式永遠不會產生超出作用範圍的影響(比如,終端輸出、資料庫的讀取),並總會得到可預測的結果
  • 可表達性 - 函數語言程式設計/庫使用宣告式的基礎可以更高效地表達函式的原始意圖,儘管需要額外學習這些基礎
  • 可理解性 - 閱讀和理解沒有副作用、全域性或可變的純函式主觀來看更簡單

正如多數開發者從經驗中學到的,如 Robert C. Martin 在程式碼整潔之道中所說:

確實,相對於寫程式碼,花費在讀程式碼上的時間超過 10 倍。為了寫出新程式碼,我們一直在讀舊程式碼。…[因此,] 讓程式碼更易讀,可以讓程式碼更易寫。

根據團隊的經驗或學習函數語言程式設計的意願,這些優勢會產生很大的影響。相反,對於缺乏經驗和足夠時間投入學習的團隊,或維護大型的程式碼倉庫時,函數語言程式設計將會產生相反的作用,上下文切換的引入或顯著的重構工作將無法產生相應的價值。

Go 函數語言程式設計的現狀

Go 不是一門函式語言,但確實提供了一些允許函數語言程式設計的特性。有大量的 Go 開源庫提供函式特性。我們將會討論泛型的缺失導致這些庫只能折衷選擇。

語言特性

函數語言程式設計的語言支援包括一系列從僅支援函式正規化(比如 Haskell)到多正規化和頭等函式的支援(比如 Scale、Elixir),還包括多正規化和部分支援(如 Javascript、Go)。在後面的語言中,函數語言程式設計的支援一般是通過使用社群建立的庫,它們複製了前面兩個語言的部分或全部的標準庫的特性。

屬於後一種類別的 Go 要使用函數語言程式設計需要下面這些特性:

語言特性 支援情況
頭等函式和高階函式
閉包
泛型 ✓†
尾部呼叫優化
可變引數函式 + 可變型別引數
柯里化

† 將在 Go 1.18 中可用(2022 年初)

現有的庫

在 Go 生態中,有大量函數語言程式設計的庫,區別在於流行度、特性和工效。由於缺少泛型,它們全部只能從下面兩種選擇中取一個:

  1. 型別安全和特定使用場景 - 選擇這個方法的庫實現的設計是型別安全,但只能處理特定的預定義型別。因為無法應用於自適應的型別或結構體,這些庫的應用範圍將受限制。
    • 比如,func UniqString(data []string) []stringfunc UniqInt(data []int) []int 都是型別安全的,但只能應用在預定義的型別
  2. 型別不安全和未知的應用場景 - 選擇這個方法的庫實現的是型別不安全但可以應用在任意使用場景的方法。這些庫可以處理自定義型別和結構體,但折衷點在於必須使用型別斷言,這讓應用在不合理的實現時有執行時崩潰的風險。
    • 比如,一個通用的函式可能有這樣的命名:func Uniq(data interface{}) interface{}

這兩種設計選擇顯示了兩種相似的不吸引人的選項:有限的使用或執行時崩潰的風險。最簡單也許最常見的選擇是不使用 Go 的函數語言程式設計庫,堅持指令式的風格。

使用泛型的函式式 Go

在2021年3月19日,泛型的設計提案通過並定為 Go 1.18 發行版的一部分。有了泛型之後,函數語言程式設計庫就不再需要在可用性和型別安全之間進行折衷。

Go 1.18 實驗

Go 開發組釋出了一個 go 1.18 遊樂場,便於大家嚐鮮泛型。同時也有一個實驗性的編譯器,在 go 程式碼倉庫的一個分支上實現了泛型特性的最小集合。這兩個都是在 Go 1.18 上嚐鮮泛型的不錯選擇。

一個使用場景的探索

在前面說到的那個 unique 函式使用了兩種可能的設計方法。有了泛型,它可以重寫為 func Uniq[T](data []T) []T,並可以使用任意型別來呼叫,比如 Uniq[string any](data []string) []stringUniq[MyStruct any](data []MyStruct) []MyStruct。為了進一步闡述這個概念,下面是一個具體的例子,展示了在 Go 1.18 中如何使用函式式單元來解決實際問題。

背景

一個在網路世界常見的案例是 HTTP 的請求響應,其中 API 介面返回的 JSON 資料一般會被消費應用轉換為一些有用的結構。

問題 & 輸入資料

考慮下這個從 API 返回使用者、得分和朋友資訊的響應:

[
  {
    "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": "ramirez.gillespie@sealoud.com",
    "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"
  },
  ...
]

假設目標是獲取各個等級的高分使用者。我們將看下函式式和指令式風格的樣子。

指令式

// 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}]

樣例的完整程式碼

函式式

// 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}]

樣例的完整程式碼

從上面的樣例中可以看出一些特性:

  1. 指令式的實現在 Go 1.16 下是有效的(本文編寫時的最新版本),而函式式的實現只在使用 Go 1.18(go2go)編譯才有效
  2. 函式式例子中的型別引數的泛型函式(如,Compose3Head 等)僅 Go 1.18 支援
  3. 兩個實現在各自對應的風格下,使用了不同的邏輯來解決同樣的問題
  4. 指令式的實現相比使用及早求值(即本例中的pneumatic)的函式來說,計算更加高效

使用 Go 1.18 函式式庫的實驗

在上面的例子中,兩個使用場景使用了 go2go 編譯器和一個叫做 pneumatic 的 Go 1.18 庫,它提供了與Ramda (JavaScript), Elixir 標準庫以及其他相似的常見函式式單元。鑑於 go2go 編譯器有限的特性集,在本文釋出時 pneumatic 只能用於實驗目的,但從長期看,隨著 Go 1.18 編譯器的逐漸成熟,它會包含常見的函式式 Go 庫。設定 pneumatic 和使用 Go 1.18 進行函數語言程式設計的指導參見 pneumatic readme

結論

Go 增加泛型將會支援新型的方案、方法和正規化,從而成為眾多支援函數語言程式設計的語言之一。隨著函數語言程式設計的逐漸流行,函數語言程式設計的支援也會越來越好,從而有機會吸引那些現在還沒考慮學習 Go 的開發者並讓社群持續發展——這是在我看來比較積極的一面。非常期待看到在後續支援泛型之後和它帶來新的解決方法後,Go 社群和生態將會發展成什麼樣。

參考資料

更多原創文章乾貨分享,請關注公眾號
  • 使用 Go 泛型的函數語言程式設計
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章