一個 Java 程式設計師眼中的 Go 語言

alvendarthy發表於2016-06-01

首先,我想做個免責宣告,我不是 Go 語言專家。幾周前我才開始學習,所以本文是我對 Go 的第一印象。文中我的一些主觀看法可能是錯的。以後我可能會發文再探討本文的一些觀點。在此之前,先看看本文吧。如果你是一個 Java 開發者,很高興與你分享我的感受和經歷,更期待你的留言評論,如果我有一些錯誤闡述,請不吝指教。

Go 語言令人印象深刻

不同於 Java,Go 編譯生成機器碼,並被直接執行,非常類似 C。因為它不是一個虛擬機器,這與 Java 有著天壤之別。Go 支援物件導向,並在一定程度上支援函數語言程式設計,因此它不僅僅是一種具備自動垃圾回收機制的類 C 語言。如果我們將程式語言發展看作線性的話(事實上不是),Go 介於 C 和 C++ 之間的某種狀態。在 Java 開發者看來,Go 是如此的與眾不同,以至於學習它本身就是一種挑戰。通過對 Go 的學習,可以更深入理解程式語言的構造,物件及類等等都是如何實現的。這些知識在 Java 中同樣適用。

我相信,如果你知道 Go 是如何實現物件導向的,你也會明白 Java 以不同的途徑實現的一些原因。

免得你覺得我絮絮叨叨,簡言之吧:不要被 Go 中看起來怪異的結構嚇到,即便你沒有專案要用 Go 開發,也去了解它,這會增加你的知識和理解。

GC 還是不 GC,這是個問題

記憶體管理對於程式語言至關重要。彙編允許你操作所有東西,或者說要求你必須全權處理所有細節更合適。C 語言中雖然標準庫函式提供一些記憶體管理支援,但是對於之前呼叫 malloc 申請的記憶體,還是依賴於你親自 free 掉。從C++、Python、Swift 和 Java 開始,才在不同程度上支援記憶體管理,Go 語言也是他們中的一員。

Python 和 Swift 採用引用計數方案。當存在一個物件引用時,物件自身持有一個計數器,用於統計有多少個引用指向當前物件。物件中並沒有反向引用或指標。當一個引用獲取物件的值,並指向這個物件時,計數器自增;當一個引用變為 null/nil/其他值 時,計數器自減。很顯然,當計數器為0時,這個物件就沒有被引用,可以被作廢了。這種方法的問題是,計數器大於0,但是物件卻可能已失效。當物件彼此形成環形引用時,通過靜態、區域性或者其他有效引用釋放環中最後一個物件時,整個引用環就懸在記憶體中,就像氣泡懸浮在水中:所有物件的計數器都大於 0,但是所有物件都已失效。Swift 教程對這種情況做了很好的解釋,並說明了避免的方法。可惜,結論還是那樣:你始終需要在某種程度上關心記憶體管理。

對於 Java 和其他語言的 JVM (包括 JVM 的Python實現),記憶體是完全由 JVM 管理的。與工作執行緒同時執行著 1 個或者多個執行緒,週期性的執行全域性垃圾回收,或者暫停所有執行緒(眾所周知的 stop the world),標記所有失效物件,清理它們,並壓縮可能存在的記憶體碎片。你唯一需要操心的是效能問題。

Go 語言與上述情況大同,又有點小異。Go 中沒有引用,只有指標,這是非常重要的區別。Go 語言可以被外部 C 程式碼整合,出於效能考慮,Go 執行時中也沒有類似引用表之類的東西。真實的指標對呼叫者是不可知的。申請到的記憶體依然被分析,以獲得物件有效性相關資訊,無用“物件”依然可被標記和清理,但是記憶體不能通過移動實現壓縮。我在文件中沒有找到太多相關資訊,由於我理解指標的處理機制,我一直期待 Go 語言存在某種實現記憶體壓縮的天才魔法。我很失望的瞭解到,它根本沒有記憶體壓縮。畢竟,魔法不常有。

Go 包含垃圾回收機制,但是不是跟 Java 一樣完整的垃圾回收機制,它不能進行記憶體壓縮。這也未嘗是一件壞事。它可以持續執行服務很長一段時間,而且不會產生記憶體碎片。某些 JVM 垃圾回收器也會跳過記憶體壓縮,以減少垃圾回收造成的服務停頓,直到必要時才執行。Go 語言中,必要時才進行的這一步沒有了,在個別情況下可能會引起一些問題。不過在你學習該語言時,不大可能需要考慮這個問題。

區域性變數

Java 語言中,區域性變數(新版本中,有時候物件也是)被儲存在棧中。C、C++等等其他類似實現呼叫棧的語言也是如此。Go 語言也差不多,除了… …

除了函式可以返回區域性變數的指標。這種做法在 C 語言中絕對是致命錯誤。當 Go 編譯器發現被建立的“物件”(晚點晚再解釋用引號的原因)將會脫離函式作用域,它會妥善處理這種情況,保證該物件在函式返回後繼續存活,其指標不會指向廢棄的記憶體地址,獲得不確定的資料。

像這樣是絕對合法的:

閉包

你可以實現一個函式中的函式,然後返回這個函式本身,就像函式式語言一樣(Go 也是一種函式式語言),所有的區域性變數都將成為閉包中的變數。

函式返回值

函式可以返回多個返回值。如果沒有謹慎使用,該特性貌似一種糟糕的實踐。python 也這麼做的,perl 也是,其實是可以善用的。最主要的用法是返回一個值,外加 nil 或者 錯誤資訊。如此,將錯誤資訊編碼為無意義的負值這種傳統(比如 C 標準庫中的做法,通常返回 -1 作為錯誤碼,非負值則表示有意義的返回值),轉換為一種更加可讀的方式。

多賦值不只用在函式上。可以如下完成一對值的交換。

物件導向

由於支援閉包,而且函式是第一類值,Go 語言至少可以做到接近 Javascript 程度的物件導向支援,然而事實遠不止如此。Go 語言支援介面和結構體,但是它們不是真正意義上的類,而是值型別。他們通過值傳遞,資料在記憶體中儲存時,只包含純粹的資料,沒有類頭部之類的資訊。Go 中的結構體非常像 C——可以包含域(fields),但不能互相擴充套件,也不能包含函式方法。Go 另闢蹊徑支援物件導向。

不同於在類定義中包含方法定義,你可以在定義方法自身時定義結構體。結構體中也可以包含其他結構體,當內部結構體匿名時,其型別隱式的變為名稱,你可以直接用其型別名引用內部結構體。或者你可以直接引用內部結構體的一個域或者方法,因為它們都是頂級結構體的成員。

例如

這就是一個介面了。

當明確哪些方法可以通過結構體呼叫時,你可以用值或者指標表示結構體。如果通過結構體呼叫方法,方法將訪問結構體的副本(值傳遞);如果通過結構體指標呼叫方法,方法將獲得結構體的指標(引用傳遞)。後一種情況下,方法可以操作結構體(此時,結構就不能被認為是一種型別,因為值型別應當是不可變的)。上述方法都可以完整的實現介面。在上面的示例中,通過結構體 A 的指標呼叫了 Printa 方法,Go 表述為:A 是 Printa 方法的接收者(reviver)。

Go 對結構體和指標的語法也很寬鬆。在C中,通過結構體時,可以用 b.a 來訪問結構體成員;通過結構體指標時,可以用 b->a 訪問結構體中同一成員。對於指標,試圖用 b.a 訪問則是語法錯誤。Go 認為 b->a 是無意義的(你可以字面意思理解)。為什麼要用 -> 使得程式碼混亂不堪呢,明明可以用 “點” 操作符過載它啊。通過結構體訪問成員,與通過指標訪問結構體成員等同對待,非常符合邏輯啊。

因為結構體自身與其指標是等效的,你可以這樣

是的,這就是指標——作為一個 Java 開發者,你應該不會覺得奇怪。我們通過一個 nil 指標呼叫了方法!這是什麼情況?

鍵入值型別,而非物件。

這就是我為什麼用引號的“物件”。Go儲存的結構體,其實是記憶體中的一小片區域。其中不存在物件頭資訊(確實有可能存在,這與具體的實現有關,而非語言本身的規定,通常是沒有類頭資訊的)。變數本身就儲存著值的型別資訊。如果變數型別是一個結構體,那麼在編譯階段這些資訊就是已知的。如果變數型別是介面,那麼它就成為值的指標,與此同時引用該值真正的型別。

如果變數即不是介面也不是結構體的指標,你無法完成同樣的功能:只會得到一個執行時錯誤。

介面的實現

Go 中的介面實現非常簡單,同時也有非常複雜(換言之,至少與 Java 的實現差別很大)。介面定義了一組函式,如果希望結構體可以使用介面,結構體就應當實現這些函式。繼承的實現與結構體類似。比較奇特的是,你不需要明確定義即將實現介面的結構體。從根本上講,與其說結構體實現了介面,不如說介面中的函式將結構體或結構體指標當作接受者(reciver)。如果介面中所有函式都被實現了,那麼結構體就實現了這個介面。如果部分函式沒有實現,介面的實現就是不完整的。

為什麼我們在 Go 中不需要 “implements” 關鍵字,而 Java 需要呢?Go 不需要它是因為 Go 完全編譯的,其中不存在執行時載入獨立編譯的程式碼的類載入器。如果一個本來要實現介面的結構體沒有實現介面,這個錯誤會在編譯階段就被發現,不需要明確說明這個結構體會實現介面。如果你使用反射技術(Go 是支援的),你就可以繞過這一點,並引發執行時錯誤,“implements” 宣告對這種做法無能為力。

Go 很清爽

Go 程式碼非常清爽,令人過目難忘。在其他語言中,存在一些不太常用的字元。 C 發明出來之後的 40 年裡,我們逐漸適應了它們,眾多語言都在跟隨這種語法,但是這並不能說明這種設計是最好的。通過 C 我們都瞭解,在 “if 表示式” 中使用 “{” 和 “}” 將各程式碼分支括起來,很好的解決了 “長尾else” (trailing else)問題。(可能 Perl 是第一個使用這種特性的主流類 C 語法的語言)既然如此,如果我們必須有花括號,那就沒必要用圓括號將條件語句括起來了。就像你看到下面的程式碼:

Go 中即不需要,也不允許用圓括號包含條件語句。也許你也發現了,語句中沒有分號。你可以使用分號,但是不是必須的。在預編譯階段,它們會被自動插入程式碼中,非常高效。通常額外書寫它們都會帶來一些干擾。你可以用  ‘:=’ 宣告一個新變數,同時為之賦值。等式的右值通常就可以定義型別,因此沒必要編寫 ‘var x typeOfX = expression‘。另一方面講,如果你 import 一個不被使用的包或者定義了一個未用變數,這被認為是個bug。這些在編譯階段就會被檢測為程式碼錯誤,還是非常智慧的(雖然有時候挺鬧心,我會 import 一個晚點用到的包,但是在我引用這個包之前,每當我儲存程式碼時, IntelliJ  就會自動幫我刪掉這個包)。

執行緒和佇列

執行緒和佇列是 Go 的內建功能。它們被稱為 go協程(goroutines) 和 管道(channels)。只要你編寫 go functioncall(),這個函式就會以不同的執行緒執行。 雖然在 Go 庫中有對 “物件” 加鎖的方法/函式,但是 Go 原生的多執行緒程式設計是利用 channels 實現的。channel 是 Go 的內建型別—— 適用於任何型別的固定大小先進先出(FIFO)管道。你可以向 channel 中 push 一個新值,goroutine 則從中 pull 出此值。如果 channel 已滿,push 操作阻塞;如果 channel 已空,則 pull 操作阻塞。

只有錯誤,沒有異常。Panic!

Go 有異常處理機制,但是與 Java 中的用法不同。異常被稱為 ‘panic’ ,當程式碼中出現問題的時候會被呼叫。在 Java 中異常實現以丟擲類似  ‘…Error’ 之類的資訊實現。當出現可被處理的異常情況或者錯誤時,錯誤狀態由系統呼叫返回,然後程式中的函式以如下模式處理。例如

‘Open’ 函式要麼返回檔案控制程式碼和nil,要麼返回nil和錯誤碼。如果你在 Go Playground 中執行上面的程式碼(猛戳上面的連線),就會看到錯誤提示。這種方式跟我們習慣的 Java 編碼實踐不太匹配。我們可以簡單的略去一些錯誤測試語句,這樣寫

直接忽略錯誤就好。對所有的系統或者函式呼叫,都檢查其所有可能的錯誤是非常繁瑣而不必要的,尤其是我們關注很長的呼叫鏈時(如果其中任何一個環節出現錯誤,我們並不關心具體是哪個環節)。

沒有 finally,用 Defer。

Java 通過 try/catch/finally 特性實現了緊密耦合的異常處理機制。在 Java 中你可以有一段絕對會在最後執行的程式碼。Go 通過 ‘defer’ 關鍵字實現了這個特性,它允許你指定一個函式呼叫,該函式會在當前方法返回前呼叫,即使在出現 panic 的情況下也是。這在解決問題的同時,幾乎不會給你濫用的機會。你不能在函式裡隨便寫點程式碼,然後延遲呼叫該函式。在 Java 中你甚至可以讓 finally 程式碼塊返回狀態碼,或者為了處理 finally 程式碼塊中可能出現的異常,把一切搞得一團混亂。Go 把一切處理的很簡潔,我喜歡!

多說幾句 …

還有一些第一次看到會覺得詭異的事:

  • 公共函式和變數是首字母大寫的,Go 沒有類似 ‘public’, ‘private’ 的關鍵字。
  • 庫的原始碼會被匯入到工程程式碼中(我不是很確定我真的明白這個特性)。
  • 不支援泛型
  • 程式碼生成特性的支援是語言內建的,以註釋指令方式實現。(簡直 Bee 了狗)

總而言之,Go 是個有意思的語言。即便在語言層面,Go 也不是 Java 的替代品。Java 和 Go 本不是服務於相同任務的 —— Java 是企業開發語言, Go 則是系統開發語言。Go 和 Java 一樣,都在不斷的開發中,相信在未來我們會看到更多變化。

相關文章