1. 程式人生 > >【轉】對 Go 語言的綜合評價

【轉】對 Go 語言的綜合評價

ask sch 專業 cps epo 語言 允許 出現 嚴重

以前寫過一些對 Go 語言的負面評價。現在看來,雖然那些評價大部分屬實,然而卻由於言辭激烈,沒有點明具體問題,難以讓某些人信服。在經過幾個月實際使用 Go 來構造網站之後,我覺得現在是時候對它作一些更加“客觀”的評價了。

定位和優點

Go 比起 C 和 C++ 確實有它的優點,這是很顯然的事情。它比起 Java 也有少數優點,然而相對而言更多是不足之處。所以我對 Go 的偏好在比 Java 稍低一點的位置。

Go 語言比起 C,C++ 的強項,當然是它的簡單性和垃圾回收。由於 C 和 C++ 的設計有很多歷史遺留問題,所以 Go 看起來確實更加優雅和簡單。比起那些大量使用設計模式的 Java 代碼,Go 語言的代碼也似乎更簡單一些。另外,Go 的垃圾回收機制比起 C 和 C++ 的全手動內存管理來說,大大降低了程序員的頭腦負擔。

但是請註意,這裏的所謂“優點”都是相對於 C 之類的語言而言的。如果比起另外的一些語言,Go 的這種優點也許就很微不足道,甚至是歷史的倒退了。

語法

Go 的簡單性體現在它的語法和語義的某些方面。Go 的語法比 C 要稍好一些,有少數比 Java 更加方便的設計,然而卻也有“倒退”的地方。而且這些倒退還不被很多人認為是倒退,反而認為是進步。我現在舉出暫時能想得起來的幾個方面:

  • 進步:Go 有語法支持一種類似 struct literal 的構造,比如你可以寫這樣的代碼來構造一個 S struct:

    S { x: 1, y: 2, }
    

    這比起 Java 只能用構造函數來創建對象是一個不錯的方便性上的改進。這些東西可能借鑒於 JavaScript 等語言的設計。

  • 倒退:類型放在變量後面,卻沒有分隔符。如果變量和它的類型寫成像 Pascal 那樣的,比如 x : int,那也許還好。然而 Go 的寫法卻是 x int,沒有那個冒號,而且允許使用 x, y int 這樣的寫法。這種語法跟 var,函數參數組合在一起之後,就產生了擾亂視線的效果。比如你可以寫一個函數是這樣開頭的:

      func foo(s string, x, y, z int, c bool) {
        ...
      }
    

    註意 x, y, z 那個位置,其實是很混淆的。因為看見 x 的時候我不能立即從後面那個符號(, y)看到它是什麽類型。所以在 Go 裏面我推薦的寫法是把 x

    y 完全分開,就像 C 和 Java 那樣,不過類型寫在後面:

      func foo(s string, x int, y int, z int, c bool) {
        ...
      }
    

    這樣一來就比較清晰了,雖然我願意再多寫一些冒號。每一個參數都是“名字 類型”的格式,所以我一眼就看到 x 是 int。雖然多打幾個字,然而節省的是“眼球 parse 代碼”的開銷。

  • 倒退:類型語法。Go 使用像 []string 這樣的語法來表示類型。很多人說這種語法非常“一致”,但經過一段時間我卻沒有發現他們所謂的一致性在哪裏。其實這樣的語法很難讀,因為類型的各部分之間沒有明確的分隔標識符,如果和其他一些符號,比如 * 搭配在一起,你就需要知道一些優先級規則,然後費比較大的功夫去做“眼球 parse”。比如,在 Go 代碼裏你經常看到 []*Struct 這樣的類型,註意 *Struct 要先結合在一起,再作為 [] 的“類型參數”。這種語法缺乏足夠的分隔符作為閱讀的“邊界信號”,一旦後面的類型變得復雜,就很難閱讀了。比如,你可以有 *[]*Struct 或者 *[]*pkg.Struct 這樣的類型。所以這其實還不如像 C++ 的 vector<struct*> 這樣的寫法,也就更不如 Java 或者 Typed Racket 的類型寫法來得清晰和簡單。

  • 倒退:過度地“語法重載”,比如 switch, for 等關鍵字。Go 的 switch 關鍵字其實包含了兩種不同的東西。它可以是 C 裏面的普通的 switch(Scheme 的 case),也可以是像 Scheme 的 cond 那樣的嵌套分支語句。這兩種語句其實是語義完全不同的,然而 Go 的設計者為了顯得簡單,把它們合二為一,而其實引起了更大的混淆。這是因為,就算你把它們合二為一,它們仍然是兩種不同的語義結構。把它們合並的結果是,每次看到 switch 你都需要從它們“頭部”的不同點把這兩種不同的結構區分開來,增加了人腦的開銷。正確的作法是把它們分開,就像 Scheme 那樣。其實我設計語言的時候有時候也犯同樣的錯誤,以為兩個東西“本質”上是一樣的,所以合二為一,結果經過一段時間,發現其實是不一樣的。所以不要小看了 Scheme,很多你認為是“新想法”的東西,其實早就被它那非常嚴謹的委員會給拋棄在了歷史的長河中。

Go 語言裏面還有其他一些語法設計問題,比如強制把 { 放在一行之後而且不能換行,if 語句的判斷開頭可以嵌套賦值操作等等。這些試圖讓程序顯得短小的作法,其實反而降低了程序理解的流暢度。

所以總而言之,Go 的語法很難被叫做“簡單”或者“優雅”,它的簡單性其實在 Java 之下。

工具鏈

Go 提供了一些比較方便的工具。比如 gofmt,godef 等,使得 Go 代碼的編程比起單用 Emacs 或者 VIM 來編輯 C 和 C++ 來說是一個進步。使用 Emacs 編輯 Go 就已經能實現某些 IDE 才有的功能,比如精確的定義跳轉等等。

這些工具雖然好用,但比起像 Eclipse, IntelliJ 和 Visual Studio 這樣的 IDE,差距還是相當大的。比起 IDE,Go 的工具鏈缺乏各種最基本的功能,比如列出引用了某個變量的所有位置,重命名等 refactor 功能,好用的 debugger (GDB 不算好用)等等。

Go 的各種工具感覺都不大成熟,有時候你發現有好幾個不同的 package 用於解決同一個問題,搞不清楚哪一個好些。而且這些東西配置起來不是那麽的可靠和簡單,都需要折騰。每一個小功能你都得從各處去尋找 package 來配置。有些時候一個工具配置了之後其實沒有起作用,要等你摸索好半天才發現問題出現在哪裏。這種沒有組織,沒有計劃的工具設計,是很難超過專業 IDE 廠商的連貫性的。

Go 提供了方便的 package 機制,可以直接 import 某個 GitHub repository 裏的 Go 代碼。不過我發現很多時候這種 package 機制帶來的更多是麻煩事和依賴關系。所以 Go 的推崇者們又設計了一些像 godep 的工具,用來繞過這些問題,結果 godep 自己也引起一些稀奇古怪的問題,導致有時候新的代碼其實沒有被編譯,產生莫名其妙的錯誤信息(可能是由於 godep 的 bug)。

我發現很多人看到這些工具之後總是很狂熱的認為它們就能讓 Go 語言一統天下,其實還差得非常之遠。而且如此年輕的語言就已經出現這麽多的問題,我覺得所有這些麻煩事累積下來,多年以後恐怕夠嗆。

內存管理

比起 C 和 C++ 完全手動的內存管理方式,Go 有垃圾回收(GC)機制。這種機制大大減輕了程序員的頭腦負擔和程序出錯的機會,所以 Go 對於 C/C++ 是一個進步。

然而進步也是相對的。Go 的垃圾回收器是一個非常原始的 mark-and-sweep,這比起像 Java,OCaml 和 Chez Scheme 之類的語言實現,其實還處於起步階段。

當然如果真的遇到 GC 性能問題,通過大量的 tuning,你可以部分的改善內存回收的效率。我也看到有人寫過一些文章介紹他們如何做這些事情,然而這種文章的存在說明了 Go 的垃圾回收還非常不成熟。GC 這種事情我覺得大部分時候不應該是讓程序員來操心的,否則就失去了 GC 比起手動管理的很多優勢。所以 Go 代碼想要在實時性比較高的場合,還是有很長的路要走的。

由於缺乏先進的 GC,卻又帶有高級的抽象,所以 Go 其實沒法取代 C 和 C++ 來構造底層系統。Go 語言的定位對我來說越來越模糊。

沒有“generics”

比起 C++ 和 Java 來說,Go 缺乏 generics。雖然有人討厭 Java 的 generics,然而它本身卻不是個壞東西。Generics 其實就是 Haskell 等函數式語言裏面所謂的 parametric polymorphism,是一種非常有用的東西,不過被 Java 抄去之後有時候沒有做得全對。因為 generics 可以讓你用同一塊代碼來處理多種不同的數據類型,它為避免重復,方便替換復雜數據結構等提供了方便。

由於 Go 沒有 generics,所以你不得不重復寫很多函數,每一個只有類型不同。或者你可以用空 interface {},然而這個東西其實就相當於 C 的 void* 指針。使用它之後,代碼的類型無法被靜態的檢查,所以其實它並沒有 generics 來的嚴謹。

比起 Java,Go 的很多數據結構都是“hard code”進了語言裏面,甚至創造了特殊的關鍵字和語法來構造它們(比如哈希表)。一旦遇到用戶需要自己定義類似的數據結構,就需要把大量代碼重寫一遍。而且由於沒有類似 Java collections 的東西,無法方便的換掉復雜的數據結構。這對於構造像 PySonar 那樣需要大量實驗才能選擇正確的數據結構,需要實現特殊的哈希表等數據結構的程序來說,Go 語言的這些缺失會是一個非常大的障礙。

缺少 generics 是一個問題,然而更嚴重的問題是 Go 的設計者及其社區對於這類語言特性的盲目排斥。當你提到這些,Go 支持者就會以一種蔑視的態度告訴你:“我看不到 generics 有什麽用!”這種態度比起語言本身的缺點來說更加有害。在經過了很長一段時間之後 Go 語言的設計者們開始考慮加入 generics,然後由於 Go 的語法設計偷工減料,再加上由於缺乏 generics 而產生的特例(比如 Go 的 map 的語法設計)已經被大量使用,我覺得要加入 generics 的難度已經非常大。

Go 和 Unix 系統一樣,在出現的早期就已經因為不吸取前人的教訓,背上了沈重的歷史包袱。

多返回值

很多人都覺得 Go 的多返回值設計是一個進步,然而這裏面卻有很多蹊蹺的東西。且不說這根本不是什麽新東西(Scheme 很早就有了多返回值 let-values),Go 的多返回值卻被大量的用在了錯誤的地方—Go 利用多返回值來表示出錯信息。比如 Go 代碼裏最常見的結構就是:

ret, err := foo(x, y, z)
if err != nil {
	return err
}

如果 foo 的調用產生了錯誤,那麽 err 就不是 nil。Go 要求你在定義了變量之後必須使用它,否則報錯。這樣它“碰巧”避免了出現錯誤 err 而不檢查的情況。否則如果你想忽略錯誤,就必須寫成

ret, _ := foo(x, y, z)

這樣當 foo 出錯的時候,程序就會自動在那個位置當掉。

不得不說,這種“歪打正著”的做法雖然貌似可行,從類型系統角度看,卻是非常不嚴謹的。因為它根本不是為了這個目的而設計的,所以你可以比較容易的想出各種辦法讓它失效。而且由於編譯器只檢查 err 是否被“使用”,卻不檢查你是否檢查了“所有”可能出現的錯誤類型。比如,如果 foo 可能返回兩種錯誤 Error1 和 Error2,你沒法保證調用者完全排除了這兩種錯誤的可能性之後才使用數據。所以這種錯誤檢查機制其實還不如 Java 的 exception 來的嚴謹。

另外,reterr 同時被定義,而每次只有其中一個不是 nil,這種“或”的關系並不是靠編譯器來保障,而是靠程序員的“約定俗成”。這樣當 err 不是 nil 的時候,ret 其實也可以不是 nil。這些組合帶來了挺多的混淆,讓你每次看到 return 的地方都不確信它到底想返回一個錯誤還是一個有效值。如果你意識到這種“或”關系其實意味著你只應該用一個返回值來表示它們,你就知道其實 Go 誤用了多返回值來表示可能的錯誤。

其實如果一個語言有了像 Typed Racket 和 PySonar 所支持的 “union type”類型系統,這種多返回值就沒有意義了。因為如果有了 union type,你就可以只用一個返回值來表示有效數據或者錯誤。比如你可以寫一個類型叫做 {String, FileNotFound},用於表示一個值要麽是 String,要麽是 FileNotFound 錯誤。如果一個函數有可能返回錯誤,編譯器就強制程序員檢查所有可能出現的錯誤之後才能使用數據,從而可以完全避免以上的各種混淆情況。對 union type 有興趣的人可以看看 Typed Racket,它擁有我迄今為止見過最強大的類型系統(超越了 Haskell)。

所以可以說,Go 的這種多返回值,其實是“歪打”打著了一半,然後換著法子繼續歪打,而不是瞄準靶心。

接口

Go 采用了基於接口(interface)的面向對象設計,你可以使用接口來表達一些想要進行抽象的概念。

然而這種接口設計卻不是沒有問題的。首先跟 Java 不同,實現一個 Go 的接口不需要顯式的聲明(implements),所以你有可能“碰巧”實現了某個接口。這種不確定性對於理解程序來說是有反作用的。有時候你修改了一個函數之後就發現編譯不通過,抱怨某個位置傳遞的不是某個需要的接口,然而出錯信息卻不能告訴你準確的原因。要經過一番摸索你才發現你的 struct 為什麽不再實現之前定義的一個接口。

另外,有些人使用接口,很多時候不過是為了傳遞一些函數作為參數。我有時候不明白,這種對於函數式語言再簡單不過的事情,在 Go 語言裏面為什麽要另外定義一個接口來實現。這使得程序不如函數式語言那麽清晰明了,而且修改起來也很不方便。有很多冗余的名字要定義,冗余的工作要做。

舉一個相關的例子就是 Go 的 Sort 函數。每一次需要對某種類型 T 的數組排序,比如 []string,你都需要

  1. 定義另外一個類型,通常叫做 TSorter,比如 StringSorter
  2. 為這個 StringSorter 類型定義三個方法,分別叫做 Len, Swap, Less
  3. 把你的類型比如 []string cast 成 StringSorter
  4. 調用 sort.Sort 對這個數組排序

想想 sort 在函數式語言裏有多簡單吧?比如,Scheme 和 OCaml 都可以直接這樣寫:

(sort ‘(3 4 1 2) <)

這裏 Scheme 把函數 < 直接作為參數傳給 sort 函數,而沒有包裝在什麽接口裏面。你發現了嗎,Go 的那個 interface 裏面的三個方法,其實本來應該作為三個參數直接傳遞給 Sort,但由於受到 design pattern 等思想的局限,Go 的設計者把它們“打包”作為接口來傳遞。而且由於 Go 沒有 generics,你無法像函數式語言一樣寫這三個函數,接受比較的“元素”作為參數,而必須使用它們的“下標”。由於這些方法只接受下標作為參數,所以 Sort 只能對數組進行排序。另外由於 Go 的設計比較“底層”,所以你需要另外兩個參數: len 和 swap。

其實這種基於接口的設計其實比起函數式語言,差距是很大的。比起 Java 的接口設計,也可以說是一個倒退。

goroutine

Goroutine 可以說是 Go 的最重要的特色。很多人使用 Go 就是聽說 goroutine 能支持所謂的“大並發”。

首先這種大並發並不是什麽新鮮東西。每個理解程序語言理論的人都知道 goroutine 其實就是一些用戶級的 “continuation”。系統級的 continuation 通常被叫做“進程”或者“線程”。Continuation 是函數式語言專家們再了解不過的東西了,比如我的前導師 Amr Sabry 就是關於 continuation 的頂級專家之一。

Node.js 那種 “callback hell”,其實就是函數式語言裏面常用的一種手法,叫做 continuation passing style (CPS)。由於 Scheme 有 call/cc,所以從理論上講,它可以不通過 CPS 樣式的代碼而實現大並發。所以函數式語言只要支持 continuation,就會很容易的實現大並發,也許還會更高效,更好用一些。比如 Scheme 的一個實現 Gambit-C 就可以被用來實現大並發的東西。Chez Scheme 也許也可以,不過還有待確認。

當然具體實現上的效率也許有區別,然而我只是說,goroutine 其實並不是像很多人想象的那樣全新的,革命性的,獨一無二的東西。只要有足夠的動力,其它語言都能添加這個東西。

defer

Go 實現了 defer 函數,用於避免在函數出錯後忘了收拾殘局(cleanup)。然而我發現這種 defer 函數有被濫用的趨勢。比如,有些人把那種不是 cleanup 的動作也做成 defer,到後來累積幾個 defer 之後,你就不再能一眼看得清楚到底哪塊代碼先運行哪塊後運行了。位置處於前面的代碼居然可以在後來運行,違反了代碼的自然位置順序關系。

當然這可以怪程序員不明白 defer 的真正用途,然而一旦你有了這種東西就會有人想濫用它。那種急於試圖利用一個語言的每種 feature 的人,特別喜歡幹這種事情。這種問題恐怕需要很多年的經驗之後,才會有人寫成書來教育大家。在形成統一的“代碼規範”以前,我預測 defer 仍然會被大量的濫用。

所以我們應該想一下,為了避免可能出現的資源泄漏,defer 帶來的到底是利多還是弊多。

庫代碼

Go 的標準庫的設計裏面帶有濃郁的 Unix 氣息。比起 Java 之類的語言,它的庫代碼有很多不方便的地方。有時候引入了一些函數式語言的方式,但卻由於 Unix 思維的限制,不但沒能發揮函數式語言的優點,而且導致了很多理解的復雜性。

一個例子就是 Go 處理字符串的方式。在 Java 裏每個字符串裏包含的字符,缺省都是 Unicode 的“code point”。然而在 Go 裏面 string 類型裏面每個元素都是一個 byte,所以每次你都得把它 cast 成“rune”類型才能正確的遍歷每個字符,然後 cast 回去。這種把任何東西都看成 byte 的方式,就是 Unix 的思維方式,它引起過度底層和復雜的代碼。

HTML template 庫

我使用過 Go 的 template library 來生成一些網頁。這是一種“基本可用”的模板方式,然而比起很多其他成熟的技術,卻是相當的不足的。讓我比較驚訝的是,Go 的 template 裏面夾帶的代碼,居然不是 Go 語言自己,而是一種表達能力相當弱的語言,有點像一種退化的 Lisp,只不過把括號換成了 { {...} } 這樣的東西。

比如你可以寫這樣的網頁模板:

{ {define "Contents"} }
{ {if .Paragraph.Length} }
<p>{ {.Paragraph.Content} }</p>
{ {end} }
{ {end} }

由於每個模板接受一個 struct 作為填充的數據,你可以使用 .Paragraph.Content 這樣的代碼,然而這不但很醜陋,而且讓模板不靈活,不好理解。你需要把需要的數據全都放進同一個結構才能從模板裏面訪問它們。

任何超過一行的代碼,雖然也許這語言可以表達,一般人為了避免這語言的弱點,還是在 .go 文件裏面寫一些“幫助函數”。用它們產生數據放進結構,然後傳給模板,才能夠表達模板需要的一些信息。而這每個幫助函數又需要一定的“註冊”信息才能被模板庫找到。所以這些復雜性加起來,使得 Go 的 HTML 模板代碼相當的麻煩和混亂。

聽說有人在做一個新的 HTML 模板系統,可以支持直接的 Go 代碼嵌入。這些工作剛剛起步,而且難說最後會做成什麽樣子。所以要做網站,恐怕還是最好使用其他語言比較成熟的框架。

總結

優雅和簡單性都是相對而言的。雖然 Go 語言在很多方面超過了 C 和 C++,也在某些方面好於 Java,然而它其實是沒法和 Python 的優雅性相比的,而 Python 在很多方面卻又不如 Scheme 和 Haskell。所以總而言之,Go 的簡單性和優雅程度屬於中等偏下。

由於沒有明顯的優勢,卻又有各種其它語言裏沒有的問題,所以在實際工程中,我目前更傾向於使用 Java 這樣的語言。我不覺得 Go 語言和它的工具鏈能夠幫助我迅速的寫出 PySonar 那樣精密的代碼。另外我還聽說有人使用 Java 來實現大並發,並沒發現比起 Go 有什麽明顯的不足。

Alan Perlis 說,語言設計不應該是把功能堆積起來,而應該努力地減少弱點。從這種角度來看,Go 語言引入了一兩個新的功能,同時又引入了相當多的弱點。

Go 也許暫時在某些個別的情況有特殊的強項,可以單獨用於優化系統的某些部分,但我不推薦使用 Go 來實現復雜的算法和整個的系統。

【轉】對 Go 語言的綜合評價