從 map 的實作聊聊為什麼 Go 需要有泛型

Larry Lu
Starbugs Weekly 星巴哥技術專欄
8 min readOct 31, 2021

--

前言

這篇的程式碼比較多一點,手機可以存起來用電腦看比較好讀哦~

關於 Go 到底要不要支援泛型、以及泛型的語法該怎麼設計已經討論好久了(大概十年有吧XD),近幾年來官方也不斷修改泛型的設計草案,總算是在去年把泛型語法定下來,也承諾要在 Go 的下個版本 1.18 中正式支援泛型

那究竟為什麼需要泛型呢?現在的 Go 就算沒有泛型,不是照樣寫出 DockerKubernetesTerraform 這些超厲害的東西嗎?

所以今天就是想跟大家說說泛型到底解決了 Go 的哪些問題,也順便讓大家看看 Go 的泛型怎麼寫(非常簡單,看一下就會了),到時候明年 1.18 出來就可以馬上開始用了哦~

從 map 開始談起

寫過動態語言如 Python、JavaScript 的人應該都很熟悉語言內建的 map function,他讓你可以很輕易的對整個 array 做操作,譬如說整個 array 都乘二之類的。但在 Go 裡面卻沒有內建的 map,因此我們來自己寫一個

有了寫好的 map function 後,團隊內的其他成員就可以隨時拿去用,就算不知道 map 內部是怎麼實作的也沒關係,總之,用就對了

但如果今天突然來了個新的需求:老闆為了追隨 Elon Musk,希望以後大家的薪水都改用狗狗幣發放,因此要你寫個程式幫公司同仁們的薪水,以 8:1 的比例的換算成狗狗幣,譬如說月薪 40000 那就發給你 5000 顆

雖然這需求聽起來極度荒謬,但重點是這樣一來我們自己寫的 map function 就派不上用場了,因為他只能把 []int 對應到 []int,但狗狗幣的算出來會有小數點,也就是說我們會需要另一個可以給 []float64 用的 map function

Solution 1 — 缺什麼寫什麼

正所謂「頭痛醫頭,腳痛醫腳」,現在需要一個給 float 用的 map function,那我就另外寫一個 mapFloat,反正照抄過去改一下型別就好,打開 VSCode 十秒鐘就寫出來了

雖然這樣看似解決了問題,但這樣做有兩個非常明顯的缺點:

一來是這兩個 map function 的實作邏輯完全一模一樣,只不過是把接受的參數從 []int 改成 []float64 而已。從軟體工程的觀點來看這樣嚴重違反了 DRY(Don’t Repeat Yourself) 原則,如果哪天發現 map function 裡面有地方寫錯了,就又要一次改兩個地方(有時候甚至會發現一個有錯一個沒錯,可能是複製的時候改錯了,那就真的很慘),導致專案的維護成本提高

雖然 map function 很難寫錯 XD,不過這邊只是舉個例子,實務上很多落落長的 function 都會一再修改,因此有兩個功能一樣的 function 就會非常麻煩

二來是這樣並沒有解決根本問題,如果哪天新的需求又來了:「老闆為了展現他對加密貨幣的決心,突然決定把公司名字改叫 crypto,所以要你寫個程式把大家的信箱從 [Luka@abc.com, Andy@abc.com] 改成 [Luka@crypto.com, Andy@crypto.com]」,那就還要寫一個給 string 專用的 map function,這樣一直加新的 map function 也不是辦法

Solution 2 — 通用的 map function?

因為一直寫重複的程式碼實在是太糟糕了,有沒有什麼方法可以寫出一個同時支援 int 跟 float64 的 map function 呢?於是你去查了一下發現很多人是用 interface{} 來解的

因為在 Go 裡面任何變數都可以轉成 interface{} 型別,所以只要把 map function 的參數型別改成接受 []{}interface(看起來很複雜,但其實就是 array of interface{}),那就可以實作出一個通用版本的 map function

雖然這個 map 看起來非常通用(整個函數簽名有四個 interface{} XD),但其實用起來非常很麻煩。如果你現在手上已經有一個 int array,那你要先把它轉成 interface{} array,接著才能丟進去 Map 裡面做 map,寫完自己看很可能都看不太懂

而通用性方面,雖然這個版本真的有比較通用,而且整份 codebase 裡面也不會有多個 map function,但整體而言還是非常不知道在幹嘛,光是看到那堆 interface{} 頭就痛了,要我用這個的 map function 我寧願自己用 for loop 把 array 跑過一遍就好

而且除了麻煩之外,「型別轉換、確認」這些該由編譯器做的事情現在被拿到 runtime 來做(我寫的不是靜態語言嗎?),所以效能不用說當然是會慢非常多,所以這種做法雖然通用但也是被很多人嫌棄

Solution — Type Parameter

但 Go 身為新一代的靜態語言,如果想要走在潮流的尖端,總不能連簡單的 map 都寫不出來吧?因此 Go 決定從 1.18 起開始支援 Type Parameter,讓你在宣告一個 function 時,可以不用太過在乎參數的型別,就像你在寫 JS 時一樣

而 Type Parameter 的寫法就像這樣:你在宣告 Map 時可以先說「我假設有一個型別叫做 T,這個 T 可以是任何(any)型別」,接著後面就可以把 T 當成已經存在的型別來使用

如果仔細跟之前寫的 MapFloat 比較一下的話,就會發現 Type Parameter 其實就是在函數名之後加上 [T any],然後把後面的 float64 都改成 T 而已

那要使用這個泛型後的 Map[T any] 函數會很麻煩嗎?一點都不會,就直接把他的參數 (arr []T, fn func(T) T) 看成 (arr []int, fn func(int) int) 就好了(其實就是把 T 換成 int 啦XD)

因爲 Go 的編譯器會自動推斷這邊的 T 是什麼,像上面的例子中 T 就分別是 int 跟 float32,因此你完全不需要多做什麼事,就很像在寫 JS 一樣,使用上完全沒有障礙

到目前為止,我們這個 map 已經可以用來做任何 []T[]T 的 mapping 了,不管那個 T 是 int、float32 還是什麼型別,都可以直接使用這個泛型的 map function

更通用的 map function

但現在另外一個需求又來了,老闆不知道為什麼,又希望你寫個程式計算一下大家英文名字的字數,因此你會需要輸入 [Larry, Luka, Andy] 陣列、然後輸出 [5, 4, 4],於是你便這樣寫

原本以為可以一帆風順,想不到編譯時竟然噴出了錯誤,因為我們現有的 Map 只能把 []T 對應到 []T 型別,而且這兩個 T 必須是一樣的。但我們現在做的事情是把 []string 對應到 []int,所以編譯器當然會噴錯

為了解決這個問題,我們要把 map 寫得更加通用,讓他可以把 []T1 對應到 []T2而且 T1 跟 T2 可以是兩個不一樣的型別,那要怎麼寫呢?

首先是把 [T any] 改成 [T1, T2 any]代表我們允許有 T1、T2 兩個任意型別,接著再把後面的輸入改成 []T1、回傳值改成 []T2、還有最重要的 fn 改成 func(T1) T2(輸入 T1 型別,回傳 T2 型別),這樣就大功告成囉

實際用的時候也很簡單,因為 Go 編譯器會看到你的輸入型別是 []string,而回傳值型別是 []int因此他會知道 T1 是 string、T2 是 int,執行出來的結果當然也跟我們預期的一樣是 [5 4 4]而且這個版本兼具了通用性以及型別的嚴謹度,自己寫完都覺得很漂亮XD

小結

自從知道 Go 要以 Type Parameter 的方式支援泛型之後就一直很期待後續的發展,不過也有聽到一些想法覺得加入泛型後會讓 Go 的學習門檻變高,所以還是維持現狀比較好

但我個人覺得泛型可以讓邏輯跟型別不必綁在一起,帶來的效益實在是太大了,而且有了泛型之後可以少寫、少維護很多程式碼,所以才想寫這篇文章讓大家了解為什麼 Go 需要泛型,也希望今天這些講的這些例子可以讓大家覺得 Go 的泛型其實很好學,那我就心滿意足了~

另外雖然今天講的 map function 例子很簡單,針對不同型別寫好幾個 mapInt、mapFloat、mapString 好像也沒關係,但在實際開發的時候一直 copy/paste 也滿浪費時間的,而且 function 一多又要花時間維護,所以最好一開始就把 function 寫得足夠通用(泛型只是其中一種方法),那後面同事要拿去用也會輕鬆很多~

還有還有,雖然 Go 1.18 要明年才會發佈,但想先玩玩看泛型的話可以把今天的範例貼到 go2go Playground 跑跑看,是真的可以跑哦~

--

--

Larry Lu
Starbugs Weekly 星巴哥技術專欄

我是 Larry 盧承億,傳說中的 0.1 倍工程師。我熱愛技術、喜歡與人分享,專長是 JS 跟 Go,平常會寫寫技術文章還有參加各種技術活動,歡迎大家來找我聊聊~