Java程式設計師不喜歡Golang的地方 - Gavin

banq發表於2022-06-25

我愛Go。從我開始使用這種語言的第一天起,我就迅速愛上了它。它提供了令人難以置信的簡單性,同時保持了出色的型別安全和快如閃電的編譯。它的執行速度非常快,併發性是一流的(這是一種輕描淡寫的說法),標準庫有大量的高階介面,可以用很少的依賴性來啟動任何應用程式,它可以直接編譯成可執行檔案,我可以繼續說下去。儘管與其他C語言相比,Go的語法文字主義需要一些時間來適應,但在使用了一段時間後,感覺非常直觀。

我的背景是Java,但對C、C++、JavaScript、TypeScript和Python都有豐富的經驗。Go是我學習的第一種語言,我希望在任何地方都能用它來做任何事情。雖然我討厭 "產品殺手 "這種陳詞濫調的概念,但作為一個曾經是專業的Java開發者的人,Go感覺就像是一個Java殺手。我認為Java會消失嗎?可能不會。我認為Go會在流行程度上超過Java嗎?不太可能。然而,對我個人來說,我無法想象在任何情況下(除了維護大到無法重寫的傳統產品),我寧願使用Java而不是Go。

說到這裡,你可能會想,"這篇文章不是應該講你不喜歡Go的地方嗎?" 這是一個完全公平的問題,我正要回答這個問題,但重要的是要了解我有多喜歡Go,才能理解我為什麼要抱怨它。那麼就不多說了,到底有什麼可批評的呢?

庫函式會修改其引數
我對Go的第一個抱怨是我馬上就注意到的。許多內建的庫函式修改它們的引數而不是返回新的結果。修改函式引數模糊了輸入和輸出之間的界限,最終破壞了程式碼的表現力。一個函式的表現力是指它透過其簽名清楚地傳達意義和意圖的能力;意義傳達得越清楚,這個函式的表現力就越強。表達性是可維護程式碼的最重要方面。顯然,有些時候效能比表現力更有利於程式,但從可維護性的角度來看,表現力應該始終是優先考慮的。

那為什麼Go會這樣做呢?透過修改引數而不是返回新資料,Go編譯器可以更好地跟蹤特定變數的生命週期。Go是一種垃圾收集(GC)語言,所以任何時候函式返回一個指標或由指標支援的型別(例如一個片斷),都會增加記憶體需要在堆而不是棧上分配的可能性。堆分配需要垃圾收集,而垃圾收集會佔用你程式中寶貴的CPU週期。

避免堆分配--從而減少垃圾收集--絕對可以提高一個應用程式的效能。這些最佳化可能有利於渲染高FPS圖形的軟體,但對於大多數企業應用和日常服務來說,很可能對終端使用者的好處幾乎是無法察覺的。
一個合理的論點是,一種語言應該儘量減少其自身庫的開銷,但是當速度和易用性是相互競爭的目標時,需要優先考慮一個目標。已經有很多語言為高效能的使用場景提供了顯式記憶體管理(C、C++、Rust等),那麼Go真的應該為了提供更少的GC週期而犧牲其最大的優勢之一(易用性)嗎?

作為一個開發者,我希望至少能有一些更有表現力、更直觀的替代品,在功能上與最佳化的API相當。儘管有這樣的煩惱,Go遠不是唯一犯了這樣錯誤的語言,事實上,許多違規的Go函式都有幾乎相同的Java和C++對應物。然而,僅僅因為有其他語言的先例,並不意味著Go應該無可指責,最終,這是我不喜歡這種語言的地方。

為了挽回一些分數,可以說要求用指標作為引數是Go語言請求修改的一種表達方式。對於這一點,我將承認幾點,儘管我仍然沒有找到一個令人信服的方法來用slice這麼做(而slice往往是這個模式用的最多的)。

泛型
Go 1.18引入了泛型,所以我有點被寵壞了,因為我只需要等待幾個月就可以得到這個功能¹,而許多資深的Go開發者已經等待了好幾年。Go的泛型實現感覺有點像TypeScript的,在大多數情況下,這是件好事。與TS一樣,開發者可以很容易地約束泛型,使其符合幾種可能的已知型別或介面。但與TS不同的是,Go不必處理JavaScript的包袱,特別是圍繞著未定義和null。

說白了,我喜歡泛型作為一種語言特性。如果使用得當,它們可以提高程式碼的可重用性,從而提高一致性並降低錯誤的風險。當我說我不喜歡Go中的泛型時,我的意思有兩點:第一,Go對泛型的實現還有很多需要改進的地方;第二,長期以來語言中缺乏泛型,導致許多醜陋的反模式埋藏在許多庫的表面,包括Go的標準庫。

解讀第一點,Go目前不支援方法或結構域的泛型。對方法的不支援有點令人費解,因為在引擎蓋下,Go將方法視為以接收者為第一引數的函式。如果函式支援泛型,為什麼方法不支援呢?Go對嵌入的支援降低了泛型結構欄位的關鍵性,因為很多泛型的用途都可以用嵌入來模仿:只要把 "非泛型 "欄位放在一個單獨的結構中,然後把同一個結構嵌入幾次就好了。然而,嵌入並不是一個完美的替代品,因為操作和方法需要針對外部結構的每一種變化進行重新實現。Go可以透過允許泛型結構欄位來將這一責任轉移給編譯器,而不是開發人員,但現在我們還只能複製和貼上。

由於Go中目前缺少泛型的地方,許多資料結構的實現不得不使用反射、型別檢查和鑄造等駭客的變通方法,以提供對不同型別的廣泛支援。這讓我想到了第二個抱怨。Go作出了型別安全的承諾,然後立即在其標準庫中透過使用偽通用的變通方法:interface{}來破壞它。Go的空介面用法不僅是一種反模式的縮影,而且型別檢查和反射往往是較慢的操作(諷刺的是,這與我之前抱怨的表達能力對速度的權衡是不一致的)。最糟糕的是,第三方庫也大量採用了空介面的反模式,所以即使Go最終將其所有庫遷移到泛型,這種模式也可能在許多程式碼庫中存在相當長一段時間。

make()函式
make()函式是Go的 "原始型別 "初始化解決方案。大多數基元都有一個合理的零值,但在Go中,map、slices和channel都是受益於動態初始化的基元型別。使用map和slices的零值是完全可能的,有時甚至是合理的(例如JSON操作和避免nil返回),但對於大多數情況,make()是最好的選擇。我對make()有異議的地方是,它存在我已經說過的兩個問題。

首先,make()沒有表現力。它的完整簽名是func make(t Type, size ...IntegerSize) Type,這讓我對如何正確使用它知之甚少。儘管它在技術上只是一個函式,但在Go編譯器對它的特殊處理以及它對建立通道的必要性之間,make()就像for-loop一樣是Go的一個重要組成部分。採用這種思路可以部分地原諒它的簽名,但是提供NewMap()、NewSlice()和NewChan()函式也同樣容易,甚至更容易,這樣就不會產生歧義了。我不打算深入討論這些替代方案,因為我相信對於這些選擇為什麼會有問題,有很多強烈的意見。但我要深入探討的是,make()的錯誤是多麼容易發生(看到我做了什麼了嗎)。

m := make(map[int]int, 10) 建立一個空的地圖,分配足夠的空間來儲存10個條目;len(m) 返回0。看到問題所在了嗎?無論是在寫程式碼時,還是在審查程式碼時,都很容易不小心忽略這個重要的區別。要獲得你所期望的切片行為,需要一個額外的引數:s := make([]int, 0, 10)。在這種情況下,len(s)實際上會返回0。因此,Go並沒有為這些資料結構提供更具表現力的、不同的初始化器,而是提供了一個具有更大模糊性的單一函式,因此具有更大的誤用風險。

在我對make()的看法上,我對它的第二個問題是它的偽通用性。Go通常不允許函式過載,但make()得到了一個特殊的通行證來假裝過載。由於這個特殊的傳遞,make()的第一個引數可以是幾種型別中的一種。它的返回型別也是如此。對於一個十年來一直聲稱不需要泛型的語言來說,Go不得不打破很多自己的規則,讓它最核心的一個函式在沒有泛型的情況下工作。對我來說,這讓我感覺很草率。

扁平化的包結構
我來自Java的世界。Java應用程式往往有很多很多的包。在這個世界上,父包對於一個類的上下文來說往往和類的名字本身一樣重要,所以對於Go這個 "Java殺手 "來說,擁有這樣一個扁平的包結構是有點刺耳的。這並不是Go的獨特之處。許多面向指令碼的語言,如Python,傾向於採用更多的廣度而不是深度。儘管這是一種相對普遍的做法,但我想讓Go成為Java的 "直接替代品 "的夢想似乎已經破滅了。

扁平化的包結構本身並沒有什麼問題。一層層的空目錄(或包含單個檔案的目錄)在沒有明確設計的語言中很少提供價值--誠然,這適用於大多數不是物件導向的語言。然而,如果扁平語言聲稱要解決與巢狀語言相同的問題,那麼扁平語言應該提供語義上相等的機制來管理識別符號的可見性和範圍。

Go透過大寫字母匯出識別符號的簡單方法非常好。在我的團隊的風格公約會議上,我又少了一件要爭論的事情。玩笑歸玩笑,儘管我很喜歡Go開發者的這一選擇,但包的語義和扁平結構的慣例削弱了這一功能對應用程式程式碼的潛在價值。在庫程式碼中,匯出或不匯出的簡單概念對於定義公共API是完美的。對於大型應用,尤其是網路伺服器,這通常是不夠的。

網路伺服器必然會有一些從來沒有被其他程式碼明確消費的包(除了測試),而是被外部客戶和其他伺服器透過HTTP等協議呼叫。這些包中的程式碼將和其他程式碼一樣從抽象中受益,但在扁平化的包結構中,未匯出的抽象將不可避免地被包中無權使用的其他區域看到。這導致了一個難題:我們應該違反扁平化包結構的慣例,犧牲可讀性和重用性來換取更少的抽象性,還是乾脆讓未匯出的識別符號在它們不應該出現的地方被訪問?這個問題的存在就是承認Go有問題。
當然,扁平結構是慣例而不是法律,但慣例在Go的發展過程中具有巨大的影響力,它決定了許多新的功能,並將其納入語言。所以是的,這可能不是Go的一個明確特徵,但它仍然是我不喜歡的東西,因為Go社群把它作為一個最佳實踐而大力推動。

缺少lambda函式
這個問題絕對是吹毛求疵的,所以我就直奔主題了。Go並沒有λ函式的簡寫方式。我知道有人提議使用λ函式,也有人爭論為什麼不需要λ函式,但儘管有這些考慮,事實是我喜歡速記λ,而Go沒有。

Go的函式語法恰好是短而精的。此外,函式在Go中是型別,可以分配給變數,這對我這個喜歡濫用Java 8中引入的 "方法引用 "的人來說很熟悉。即使如此,當我用Go寫作時,有時內聯一個函式是解決一個問題的最合適的方法,然而即使是單行的,所產生的程式碼也往往是笨拙的,特別是當需要返回語句時。我不認為有人能說服我說func(x, y int) int { return x+y }比(x, y) => (x+y)更漂亮或更易讀。隨你怎麼爭論強型別或明確性,我還是會懷念速記的lambdas。

總結
對於那些還沒有嘗試過Go,但正在考慮使用它的人來說,不要讓這些勸阻你;它是一個神奇的工具,幾乎肯定會改善你的開發生活。對於現有的Gophers,我希望你能同情我的抱怨,但仍然像我一樣喜歡這門語言。

相關文章