Go語言的美好和丑陋解析
這是一個“Go不好”系列的額外文章。Go確實有一些不錯的特性,也就是本文中“好的”部分,但是當我們不使用API或者網絡服務器(這是為它設計的)而且將它用于業務領域邏輯的時候,總體而言我發現它用起來笨重且糟糕。但是即使在用于網絡編程的時候,在設計和實現方面它也有很多缺陷,這導致它在顯而易見的簡單的表面之下是危險的。
促使我寫這篇文章的原因就是最近我又開始用Go做一個副項目。在我之前的工作我廣泛地使用Go來寫網絡代理(包括http和原生tcp)來做SaaS服務。網絡部分相當不錯(當時我也是初次嘗試這個語言),但是賬戶和賬單部分給我帶來了痛苦。由于我的副項目做的是一個簡單的API,我覺得Go應該是可以快速完成這個工作的合適的工具,但是就像我們知道的,很多項目會擴張并超過他們的初始范圍,因此我不得不寫一些數據處理來做統計,然后使用Go就又變得痛苦了。因此下面是我對Go的問題的看法。
一些背景:我喜歡靜態類型語言。我的第一個重要項目是用Pascal編寫的。在90年代初我開始工作之時,我使用了Ada和C/C ++。后來我遷移到了Java,最后又使用了Scala(在期間還用過Go),最近開始學習Rust。我還寫了大量的JavaScript代碼,因為直到最近它是Web瀏覽器中唯一可用的語言。對動態類型語言我感覺不牢靠,并嘗試將其應用限制在簡單腳本中。我對命令式、函數式和面向對象的方法感到很滿意。
這是篇長文,所以,這是讓你開胃口的菜單目錄:
好的
- Go易于學習
- 易于并發編程的協程(goroutines )和通道(channels)
- 強大的標準庫
- 高性能GO
- 程序語言定義的源代碼格式
- Defer聲明,避免忘記清理
- 新類型
不好之處
- GO忽略現代語言設計的進步
- 接口是結構類型
- 沒有枚舉
- := / var的困境
- (讓人)恐慌的零值
- Go沒有異常
爛的
- 依賴關系管理的噩夢
- 用語言硬編碼的可變性
- Slice陷阱
- 可變性和渠道:使競態條件(race conditions)很容易
- 混亂的錯誤管理
- Nil接口值
- Struct字段標記:字符串中的運行時DSL
- 沒有泛型…至少不是為了你
- Go在slice和map之外幾乎沒有什么數據結構
- go generate: ok-ish,但是…
結語
優點
Go容易學習
這是事實:如果你會任何一種編程語言,你可以通過“Go教程”在幾個小時之內學會Go的大部分語法,在幾天之內就可以寫出你的第一個程序。閱讀和消化Effective Go,徘徊在標準庫中,運用web工具包如Gorilla 或者Go kit,你就能成為一個相當不錯的Go開發者。
這是因為Go的首要目標就是簡單。當我開始學習Go的時候,它讓我回憶起了我初次接觸Java:一個豐富卻不臃腫的簡單語言。與現在的Java繁重的環境對比,學習Go是一個新鮮的體驗。由于Go的簡單,Go程序是非常易讀的,即使錯誤處理方面有不少麻煩(這下面更多)。
但是這可能并不是真的簡單。引用 Rob Pike的話,簡單即復雜,我們在下面可以看到在后面有很多的陷阱等著我們,簡潔和極簡主義阻止了我們編寫DRY原則的代碼。
使用goroutines 和 channels簡單的并發編程
Goroutines可能是Go的最好的特性。與操作系統線程不同,他們是輕量級的計算線程。
當一個Go程序執行阻塞I/O操作一類的工作時,實際上Go實時掛起了這個goroutine,而且在一個event表明一些結果已經可以訪問之后,會重新運行。在此期間,其他goroutines已經在為執行調度。因此我們在使用一個同步編程模型做異步編程的時候有可擴展性的優點。
Goroutines也是輕量級的:他們的棧按需增加或減少,也就是說有數百個甚至數千個goroutines都不是問題。
在一個應用中我曾經有一個goroutine泄露:在結束之前這些goroutines等待一個channel去關閉,但那個channel不會關閉(一個常見的死鎖問題)。這個進程平白占了90%的CPU,查看expvars顯示60萬個空的goroutine!我猜CPU都被goroutine調度占用了。
當然,一個像Akka的actor系統可以不費力氣就處理數百萬actors,一部分是因為actors沒有棧,但是他們在寫復雜并發request/response應用(如 http APIs)時不如goroutine簡單的多。
Channels是goroutines之間交互的通道:他們提供了一個方便的編程模型可以在goroutines之間發送和接收數據,而不用依賴脆弱的底層同步原語。Channels擁有他們自己的一套使用模式。
由于錯誤的channels數量(他們默認無緩沖)會導致死鎖,Channels必須要慎重考慮。我們在下面也會提到因為Go缺少不變性,使用channels并不能阻止爭搶資源。
強大的標準庫
Go標準庫真的很強大,特別是對網絡協議相關的所有東西或者API開發:http 客戶端和服務器,加密,壓縮格式,壓縮,發送郵件等等。甚至還有html解析器和相當強大的模板引擎,通過自動escaping可以用來產生文字&html來避免XSS(在Hugo 模板的示例中使用)。
大多數情況下APIs通常是簡單易懂的。盡管有時候他們看起來過于簡單:這當中,一部分是由于goroutine編程模式告訴我們只需要關心“看似同步”的操作。另一部分是因為少數通用的多功能函數能替代大量單一功能的函數,就像最近一段時間,我發現的那些用于時間計算的函數一樣。
GO是高性能的
Go編譯成一個本地可執行文件。許多Go的用戶來自于Python,Ruby或者Node.js。對他們來說,這是個令人興奮的體驗,因為他們發現服務器可以處理的并發請求數量大幅的增加。對于沒有并發的語言(Node.js)或者全局解釋器鎖(GIL)來說,這實際上是再正常不過的事情。結合語言的簡單性,這說明了Go語言令人興奮的一面。
然而相比Java,在原始性能基準測試中,情況并不是那么清晰。在內存使用和垃圾收集方面Go力壓Java。
Go的垃圾收集的設計目的是優先考慮延遲和避免stop-the-world停頓,這在服務器中尤其重要。這可能會帶來更高的CPU成本,但是在水平可伸縮的架構(horizontally scalable architecture)中通過添加更多的機器這是易于解決的。記住,Go是Google設計的,他們不缺資源。
相比于Java,Go的GC在要做的工作方面也更少的:slice的結構是一個連續的結構數組,而不是像Java這樣的指針數組。相似地,Go的maps出于同樣的目的使用像桶的小數組。這意味著在GC上工作量更少,并且還更有利于CPU的緩存位置。
Go也可以力壓Java的命令行實用程序:一個本地可執行的,Go程序對Java的首先必須加載和編譯字節碼來說沒有啟動成本。
語言所定義的源代碼格式
在我職業生涯中一些最激烈的爭論發生在團隊代碼格式的定義上。Go通過為Go代碼定義規范格式解決了這個問題。gofmt工具會重新格式化你的代碼,并且沒有選項。
不管喜不喜歡,gofmt都定義了Go代碼應該如何格式化,因此該問題得到一次性解決!
標準化的測試框架
Go在其標準庫中提供了一個很好的測試框架。它支持并行測試、基準測試,并且包含很多用于輕松測試網絡客戶端和服務器的使用程序。
Go程序非常適合運維
與Python、Ruby或Node.js相比,僅安裝單個可執行文件對于運維工程師來說是一個夢想。隨著越來越多的Docker投入使用,這個問題出現的越來越少,但獨立的可執行文件也意味著更小的Docker鏡像。
Go還具有一些內置的可觀察性功能,使用expvar包發布內部狀態和指標,并且可以輕松添加新內容。但要小心,因為它們在默認的http請求處理程序中自動暴露,變得不受保護。Java中JMX有類似的功能,但它更復雜。
Defer語句,用于避免遺忘清理
defer語句的作用類似于Java中的finally:在當前函數結束時執行一些清理代碼,并不管此函數是如何退出的。有關defer的有趣的事情是它沒有鏈接到一段代碼上,并可以隨時出現。這允許清理代碼盡可能靠近創建那些需要清理資源的代碼:
- file, err := os.Open(fileName)
- if err != nil {
- return
- }
- defer file.Close()
- // use file, we don't have to think about closing it anymore
當然,Java的“try-with-resource”不是那么冗長,同時Rust在資源的所有者被刪除時會自動聲明資源,但由于Go要求你對資源清理明確了解,因此讓它靠近資源分配的地方將其關閉是很不錯的。
自定義類型
我喜歡自定義類型,而且我惱怒/害怕一些情況,就好像當我們來回傳一個字符串型或者long型的持久化對象標識符的時候。我們經常對參數名為id的類型編碼,但是這就是一些產生小bug的原因,即當一個函數有多個標識符作為參數的時候,一些調用就會弄混參數順序。
Go的自定義類型支持first-class,例如那些分配給一個已有類型的獨立的標識符的類型,可以與原來的標識符區分開來。與封裝相反,自定義類型沒有運行時開銷。這使得編譯器能捕獲這種錯誤:
- type UserId string // <-- new type
- type ProductId string
- func AddProduct(userId string, productId string) {}
- func main() {
- userId := UserId("some-user-id")
- productId := ProductId("some-product-id")
- // Right order: all fine
- AddProduct(userId, productId)
- // Wrong order: would compile with raw strings
- AddProduct(productId, userId)
- // Compilation errors:
- // cannot use productId (type ProductId) as type UserId in argument to AddProduct
- // cannot use userId (type UserId) as type ProductId in argument to AddProduct
- }
不幸的是,對那些要求自定義類型與原始類型做轉換的人來說,由于不支持泛型,自定義類型在寫復用代碼的時候用起來比較累贅。
不好之處
GO忽略現代語言設計的進步
在大道至簡(Less is exponentially more)的演講上,Rob Pike解釋說Go是要取代C和C++的,它的前身是Newsqueak,這是他在80年代寫的一種語言。Go也有很多關于Plan9的參考,這是一個分布式操作系統,80年代在貝爾實驗室開發的。
甚至有一個Go組件直接從Plan9獲得靈感。為什么不使用LLVM來提供范圍廣泛的目標體系結構呢?我可能也在這里漏掉了什么,但為什么需要呢?如果你需要編寫程序集以充分利用CPU,那么你不是應該直接使用目標CPU匯編語言嗎?
Go的設計者很值得尊敬,但是他們就像在一個平行宇宙(或者他們的Plan9實驗室)設計的Go,在那里大多數編譯器和編程語言的設計都不是在90年代和2000年代。或者是那些能寫編譯器的系統編程人員設計了Go。
函數式編程?沒有提到它。泛型?你不需要它們,看看它們在C++里產生的混亂吧!哪怕slice,map和channels都是泛型類型,就像接下來我們會看到的。
Go的目標就是代替C和C++,但是很明顯它的設計者沒有多看看其他語言。他們避開了他們的目標,Google的C和C++開發者不采用它。我猜主要原因就是垃圾回收。低級C開發者十分抗拒管理內存,因為他們不了解管理什么,在什么時候管理。他們喜歡這種控制,即使會帶來額外的復雜,而且打開內存泄露和buffer溢出的大門。有趣的是,Rust在不使用GC的情況下使用另一種方法做自動內存管理。
相反的,在操作工具方面Go吸引了那些像使用Python和Ruby等腳本語言的人。他們在Go中發現一個方法,有很好的性能,而且減少了內存/cpu/硬盤的占用空間。而且也是更static的類型,這對他們來說是新穎的。對GO來說Docker是殺手級應用,這使得它在開發界開始被廣泛使用。Kubernetes的提出加強了這個趨勢。
Interfaces是結構化類型(structural types)
Go的interfaces就像Java的interfaces或者Scala和Rust的traits:他們定義行為,之后才會被一個type(我在這不把他們叫做“class”)實現。
不像Java的interfaces和Scala和Rust的traits,一個type不需要明確定義它實現了一個interface:它必須要實現所有定義在interfaces中的函數。因此Go的interfaces的確是structural types。
我們也許認為Go允許在其他的packages中實現interface,而不僅僅是在type所在的packages中申請,就像Scala、Kotlin的類擴展和Rust的trait一樣。但事實并非如此:與type相關的所有方法都必須在這個type的package中定義。
Go并不是唯一使用structural typing的語言,但我發現它存在幾個缺點:
- 尋找有哪些type實現了interface是困難的,因為它依賴于函數定義匹配。在Java或Scala中,我經常通過搜索實現了interface的類來尋找相關的實現。
- 當給interface添加一個方法時,你將會發現只有當那些types被用作interface type的值時,type才會被更新。很長一段時間內你會忽視這種問題。Go建議盡少使用有只有幾個方法的interfaces,以此來防止該問題的發生。
- 因為type中有一個方法與interface相同,這個type可能會無意中實現了一個interface。但是偶然的情況下,它所實現的功能可能與預想的interface協議不同。
更新:interface的一些丑陋的地方,請詳看后面的“interface空值”章節。
沒有枚舉類型
Go中沒有枚舉值,在我看來這是一個錯失的機會。
iota可以快速生成自增的數值,但它看起來更像是一種修改而非特性。而實際上,由于在一系列iota所生成的常量中插入一行會改變其后面的值是一個危險的操作。由于所生成的值是在整個代碼中使用的,因此這可能會觸發意外。
這也意味著在Go中沒有辦法讓編譯器檢查switch語句是否詳盡,并且無法描述給定類型所支持的值。
:= / var 的尷尬
Go提供了兩種方法聲明和分配給變量一個值:var x = “foo” 和 x := “foo”,為什么這樣?
主要區別是:var允許聲明而不初始化(那你就必須聲明類型),就像var x string,然而 :=要求分配一個值,而且這種方法可以同樣用于已有變量和新變量。我猜發明:=就是用來讓我們在捕獲錯誤的時候不那么痛苦的:
使用var:
- var x, err1 = SomeFunction()
- if (err1 != nil) {
- return nil
- }
- var y, err2 = SomeOtherFunction()
- if (err2 != nil) {
- return nil
- }
使用:= :
- x, err := SomeFunction()
- if (err != nil) {
- return nil
- }
- y, err := SomeOtherFunction()
- if (err != nil) {
- return nil
- }
:=語法也容易不小心對一個變量重新賦值。我曾經不止一次遇到這個問題,就像:=(聲明和分配)與=(分配)太像了,就像下面這樣:
- foo := "bar"
- if someCondition {
- foo := "baz"
- doSomething(foo)
- }
- // foo == "bar" even if "someCondition" is true
零值恐慌
Go里沒有構造函數。因此,它奉行“零值”應該可以隨時使用。這是一個有趣的方法,但在我看來,它所帶來的簡化化主要是針對語言實現者的。
在實踐中,如果沒有正確的初始化,許多類型都不能做有用的事情。讓我們來看一下在Effective Go中作為示例的io.Fileobject:
- type File struct {
- *file // os specific
- }
- func (f *File) Name() string {
- return f.name
- }
- func (f *File) Read(b []byte) (n int, err error) {
- if err := f.checkValid("read"); err != nil {
- return 0, err
- }
- n, e := f.read(b)
- return n, f.wrapErr("read", e)
- }
- func (f *File) checkValid(op string) error {
- if f == nil {
- return ErrInvalid
- }
- return nil
- }
我們在這里能看到什么呢?
- 在零值文件上調用Name()將會出現問題,因為它的file字段為nil。
- Read函數和File幾乎所有其他的方法都一樣,首先檢查文件是否已初始化。
所以基本上零值文件不僅沒用,而且會導致問題。你必須使用以下構造函數中的一個:如“Open”或“Create”。檢查是否正確的初始化是每次函數調用都必須承受的開銷。
標準庫中有無數類似這樣的類型,有些甚至不試圖使用它們的零值做一些有用的事情。在零值的html.Template上調用任何方法:它們都引起問題。
同時map的零值有個嚴重的缺陷:它可以查詢,但在map中存儲任何數據都有導致panic異常:
- var m1 = map[string]string{} // empty map
- var m0 map[string]string // zero map (nil)
- println(len(m1)) // outputs '0'
- println(len(m0)) // outputs '0'
- println(m1["foo"]) // outputs ''
- println(m0["foo"]) // outputs ''
- m1["foo"] = "bar" // ok
- m0["foo"] = "bar" // panics!
當結構具有map字段時,就要當心了,因為在向其添加條目之前必須對其進行初始化。
因此,身為一名開發人員,你必須經常檢查你要使用的結構體是否需要調用構造函數或者零值是否有用。為了一些語言上的簡化,這將給代碼編寫者帶來很大的負擔。
Go中沒有異常
博客文章“為何Go處理異常是正確的”中詳細解釋了為什么異常是很糟糕的,以及為什么Go中的方法需要返回錯誤是更好的作法。我同意這一點,并且在使用異步編程或像Java流這樣的函數式風格時,異常是很難處理的(讓我們暫且將之拋之腦后,因為前者在Go中是不需要的,這要歸功于goroutine;而后者根本不可能)。該博文中提到panic是“對你的程序總是致命的,游戲結束”,這是對的。
現在,“Defer, panic和recove”在這之前,解釋了如何從panic中恢復過來(實際上通過捕獲它們),并說:“對于一個真實世界的panic和恢復示例,請參閱Go標準庫中的json包。
事實上,json解碼器有一個會觸發panic的通用的錯誤處理函數,在最頂層的unmarshall函數中可恢復該panic,該函數將檢查panic類型,并在其是“local panic”時將其作為錯誤返回,或重新觸發panic錯誤(在此丟失了原來的panic堆棧跟蹤信息)。
對于任一Java開發人員來說,這看起來像try / catch (DecodingException ex)。所以Go確實有異常,僅在內部使用但建議你不要使用。
有趣的是:幾個星期前,一個非googler修復了json解碼器,其中使用常規錯誤冒泡。
丑陋的
依賴關系管理的噩夢
一位知名的Google Go開發者Jaana Dogan(又名JBD),最近在推特上發泄他的不滿:
如果依賴關系管理不能在一年解決,我會考慮棄用Go并且永遠不再回來。依賴性管理問題通常會改變我從語言中獲得的所有樂趣。
- 133
- 有44人在談論這件事
讓我們把它簡單化:Go中沒有依賴項管理,所有當前的解決方案都只是一些技巧和變通方法。
這可以追溯回谷歌的起源階段,眾所周知,谷歌使用了一個巨大的單塊存儲庫,用于所有源代碼。不需要模塊版本控制,不需要第三方模塊存儲庫,你在你當前分支上build任何(你想要的)東西。不幸的是,這在開放的互聯網上行不通。
為 Go 添加依賴就表示將依賴項的源代碼庫拷貝到 GOPATH 中。但是是什么版本呢?是克隆時的 master 分支,不管它是哪個版本。如果不同項目需要不同版本的依賴項怎么辦呢?沒辦法。版本的概念甚至不存在。
同時,自己的項目也要放在 GOPATH,否則編譯器就找不到它。你是否想讓項目整潔的組織在各自獨立的目錄中呢?那就必須想為每個項目設置 GOPATH 或恰當的建立符合連接。
社區中設計出來的方法帶來了大量工具。包管理工具引入了提供方和 lock 文件,它們包含的 Git ShA1 可以支持重復構建。
vendor 目錄最終在 Go 1.6 中得到了官方支持。但對于克隆的供應內容,仍然沒有合適的版本管理。也不能通過語義化版本解決混淆導入和依賴傳遞的問題。
不過情況正在好轉:dep,最近出現了這個官方依賴管理工具用于支持供應內容。它支持版本(git tags),同時具有支持語義化版本約定的版本解析器。這個工具尚未達到穩定版本,但它在做正確的事情。而且,它仍然需要你把項目放在 GOPATH 目錄下。
但dep可能不會存在太久,因為vgo,也來自Google,想在語言本身中引入版本信息并且近期一直在發起一些此類的浪潮。
所以Go中的依賴管理是噩夢般的存在。完成配置是很痛苦的,而你在開發過程中從沒有考慮過它,直到你添加一個新的導入或者簡單地想把你的一個團隊成員的一個分支拉到你的GOPATH中時…
現在讓我們回到代碼上吧。
可變性在語言中是硬編碼的
在Go中沒有辦法定義不可變的結構體:struct字段是可變的,而const關鍵詞不適用于它們。Go可以很容易地通過簡單的賦值來完成整個結構的復制,因此我們可能會認為按值傳參是以復制為代價以實現不變性的前提。
然而,毫不奇怪,這不會復制由指針引用的值。由于內置集合(map,slice和array)是引用并且是可變的,所以復制包含其中之一的結構體只會將復制指向同一后臺內存的指針。
下面的示例說明這一點:
- type S struct {
- A string
- B []string
- }
- func main() {
- x := S{"x-A", []string{"x-B"}}
- y := x // copy the struct
- y.A = "y-A"
- y.B[0] = "y-B"
- fmt.Println(x, y)
- // Outputs "{x-A [y-B]} {y-A [y-B]}" -- x was modified!
- }
所以你必須對此非常小心,并且如果你是通過傳值來傳遞參數的話,則不要假定它是不可變的。
有一些deepcopy庫試圖用(慢)反射來解決這個問題,但由于專有字段不能被反射訪問,所以它們存在不足之處。因此可避免競態條件的預防式復制將會是很困難的,需要大量的樣板代碼。Go甚至沒有可以標準化的Clone接口。
Slice陷阱
Slices帶來了很多陷阱:就像在“Go slices: usage and internals”中解釋的一樣,由于一些性能原因,re-slicing一個slice不會復制底層數組。它 的目的是好的,但這意味著一個slice中的子slice僅僅是繼承了原始slice的mutations的視圖。因此如果你想將子slice與原始的slice區分,不要忘了copy()這個slice。
因為append函數,忘記調用copy()會很危險:如果它沒有足夠的容量存儲新值,在一個slice中append一個值會改變底層數組的大小。這就意味著append的結果可能會也可能不會指向依賴初始化容量的原始的數組。這會導致很難找到不確定的bugs。
在下面的代碼我們看到一個函數將值append到一個子slice改變了使用容量初始化的原始slice產生的結果。
- func doStuff(value []string) {
- fmt.Printf("value=%v\n", value)
- value2 := value[:]
- value2 = append(value2, "b")
- fmt.Printf("value=%v, value2=%v\n", value, value2)
- value2[0] = "z"
- fmt.Printf("value=%v, value2=%v\n", value, value2)
- }
- func main() {
- slice1 := []string{"a"} // length 1, capacity 1
- doStuff(slice1)
- // Output:
- // value=[a] -- ok
- // value=[a], value2=[a b] -- ok: value unchanged, value2 updated
- // value=[a], value2=[z b] -- ok: value unchanged, value2 updated
- slice10 := make([]string, 1, 10) // length 1, capacity 10
- slice10[0] = "a"
- doStuff(slice10)
- // Output:
- // value=[a] -- ok
- // value=[a], value2=[a b] -- ok: value unchanged, value2 updated
- // value=[z], value2=[z b] -- WTF?!? value changed???
- }
Mutability 和 channels:更容易產生競爭條件
Go的并發是使用channels在CSP上建立的,這會使相應的goroutines比同步共享數據更加簡單和安全。這里的mantra是“不要通過共享內存來通信;相反,通過通信來共享內存”。然而這只是一廂情愿,實際上并不能安全的完成這個目標。
就像我們之前看到的,在Go中沒有方法使用不可變數據結構。這意味著我們使用channel發送一個指針,就玩完了:我們在并發進程共享了可變數據。當然structures(不是指針)的一個channel復制了在channel上發送的值,但是就像我們之前看到的,這不是深度復制引用,包括slices和maps,他們本質上都是可變的。一個interface type的結構字段也是一樣:他們是指針,interface定義的任何mutation方法都是通向競爭條件的大門。
因此雖然channels明顯讓并發編程更簡單,他們不阻止在共享數據里的競爭條件。而且slices和maps的本質可變性讓這種情況更容易發生。
來說一下競爭條件,Go包含了一個競爭條件的檢測模式,這些代碼工具是用來尋找未同步的共享訪問。它只能在他們出問題的時候檢測競爭問題,因此大多都是在集成或負載測試中使用,借此期望產生會引發競爭條件的問題。在生產中,實際上這并不可行,因為它的高運行時代價,除了臨時debug sessions。
雜亂的錯誤管理
在Go中你需要快速學習的是錯誤處理模式,因為反復出現:
- someData, err := SomeFunction()
- if err != nil {
- return err;
- }
由于Go聲稱不支持異常(雖然它支持異常),但每個可能以錯誤結尾的函數都必須有error作為其最終處理結果。 這尤其適用于執行一些I / O功能,因此這種冗長的模式在網絡應用程序中非常普遍,這是Go的主要領域。
你的眼睛會很快為這種模式開發一個可視化過濾器,并將其識別為“是的,錯誤處理”,但仍然有很多其他干擾,有時很難在錯誤處理過程中找到實際的代碼。
雖然有一些陷阱,因為錯誤結果實際上可能只是一個表面上的情況,例如從普遍存在的io.Reader讀取時:
- len, err := reader.Read(bytes)
- if err != nil {
- if err == io.EOF {
- // All good, end of file
- } else {
- return err
- }
- }
在“有價值錯誤”中,Rob Pike提出了一些減少冗長錯誤處理的策略。 我發現他們實際上是危險的救急:
- type errWriter struct {
- w io.Writer
- err error
- }
- func (ew *errWriter) write(buf []byte) {
- if ew.err != nil {
- return // Write nothing if we already errored-out
- }
- _, ew.err = ew.w.Write(buf)
- }
- func doIt(fd io.Writer) {
- ew := &errWriter{w: fd}
- ew.write(p0[a:b])
- ew.write(p1[c:d])
- ew.write(p2[e:f])
- // and so on
- if ew.err != nil {
- return ew.err
- }
- }
基本上,從以上認識到,檢查錯誤提供一種一直令人痛苦的模式,直到結束時才忽略寫入序列中的錯誤。 因此,即使我們知道它不應該執行,任何執行的操作都會在執行完錯誤后執行。 如果這些比分片更昂貴呢? 我們只是浪費資源,因為Go的錯誤處理是一件痛苦的事情。
Rust有一個類似的問題:沒有異常處理(與Go相反,沒有),函數可能失敗后返回Result ,并且需要對結果進行一些模式匹配。 所以Rust1.0帶有try! 宏指令認識到這種模式的普遍性,并做成一流的語言功能。 因此,您在保持正確的錯誤處理的同時保持上述代碼的簡潔。
不幸的是,將Rust的方法轉換為Go是不可能的,因為Go沒有泛型或宏。
無接口值
在一次更新后,出現redditor jmickeyd顯示nil和接口的奇怪行為,這十分丑陋。 我把它擴展了一點:
- type Explodes interface {
- Bang()
- Boom()
- }
- // Type Bomb implements Explodes
- type Bomb struct {}
- func (*Bomb) Bang() {}
- func (Bomb) Boom() {}
- func main() {
- var bomb *Bomb = nil
- var explodes Explodes = bomb
- println(bomb, explodes) // '0x0 (0x10a7060,0x0)'
- if explodes != nil {
- explodes.Bang() // works fine
- explodes.Boom() // panic: value method main.Bomb.Boom called using nil *Bomb pointer
- }
- }
上面的代碼驗證了explode不是nil,但是code在Boom中冒出來,但不在Bang中。 這是為什么? 解釋一下在println行中:bomb指針是0x0,實際上是nil,但explodes是非空值(0x10a7060,0x0)。
該pair的第一個元素是指向由Explodes類型所實現的Bomb接口的方法的調度表的指針,第二個元素是實際Explodes對象的地址,該地址為nil。
對Bang的調用成功了,因為它應用在指向Bomb的指針上:不需要解引用該指針來調用該方法。Boom方法操作一個值,因此一個調用導致指針被解引用,這會導致panic。
請注意,如果我們寫了var explode Explodes = nil,那么!= nil將不會成功。
那么我們應該如何以安全的方式編寫測試? 我們必須對接口值和非零值都進行nil-check,檢查接口對象指向的值…使用反射!
- if explodes != nil && !reflect.ValueOf(explodes).IsNil() {
- explodes.Bang() // works fine
- explodes.Boom() // works fine
- }
錯誤或功能? Tour of Go有一個專門的頁面來解釋這種行為,并明確指出:“請注意,一個具有nil值的接口值本身不是零”。
不過,這很丑陋,可能會導致很微小的錯誤。 它在語言設計中看起來像是一個很大的缺陷,使其實現更容易。
結構字段標簽:運行時字符串中的DSL
如果您在Go中使用過JSON,您肯定遇到過類似的情況:
- type User struct {
- Id string `json:"id"`
- Email string `json:"email"`
- Name string `json:"name,omitempty"`
- }
這些語言規范所說的結構標簽是一個字符串“通過反射接口可見并參與結構的類型標識,但是被忽略”。 所以基本上,寫上任何你想要的字符串,并在運行時使用反射來解析它。 如果語法不對,會在運行時會出現宕機。
這個字符串實際上是字段元數據,在許多語言中已經存在了數十年的“注釋”或“屬性”。 通過語言支持,它們的語法在編譯時被正式定義和檢查,同時仍然是可擴展的。
為什么Go決定使用原始字符串,并且任何庫都可以決定是否使用它想要的任何DSL,在運行時解析?
當您使用多個庫時,情況可能會變得尷尬:下面是從協議緩沖區的Go文檔中取出的一個例子:
- type Test struct {
- Label *string `protobuf:"bytes,1,req,name=label" json:"label,omitempty"`
- Type *int32 `protobuf:"varint,2,opt,name=type,def=77" json:"type,omitempty"`
- Reps []int64 `protobuf:"varint,3,rep,name=reps" json:"reps,omitempty"`
- Optionalgroup *Test_OptionalGroup `protobuf:"group,4,opt,name=OptionalGroup" json:"optionalgroup,omitempty"`
- }
邊注:為什么在使用JSON的時候有很多常見的標簽。因為在Go中,public的字段必須使用大駱駝命名法,或者至少以大寫字母開始。然而在JSON中,常見的字段命名習慣用小駱駝命名法或者蛇形命名法。因此需要很多冗長的標簽。
JSON編碼器和解碼器標準不允許提供命名策略來轉自動轉化,就像Java中的Jackson文檔。這就解釋了為什么在Docker APIs的所有的字段都是大駝峰命名法:避免他的開發人員為他們的大型API寫這些麻煩的標簽。
沒有泛型……至少不適合你
很難想象一個沒有泛型的現代靜態類型語言,但這就是你用Go得到的東西:它沒有泛型……或者更確切地說幾乎沒有泛型,正如我們將看到的那樣,這使得它比沒有泛型更糟糕。
內置切片,地圖,數組和通道是通用的。 聲明一個map [string] MyStruct清楚地顯示了使用具有兩個參數的泛型類型。 這很好,因為它允許類型安全編程捕捉各種錯誤。
然而,沒有用戶可定義的泛型數據結構。這意味著你無法以類型安全的方式定義可用于任何一個type的可復用abstractions。你必須使用untyped的interface{}并且需要將值轉成合適的type。任何錯誤都只會在運行時捕獲,并且產生了panic。作為一個Java開發者,這就像回到了之前2004年Java5時代。
在 “Less is exponentially more“中,Rob Pike驚人的將泛型和繼承放進了同一個“typed programming”包中,說他贊成組合替換繼承。不喜歡繼承是可以的(事實上,我寫Scala的時候很少使用繼承)但是泛型解決了另一個問題:在保持類型安全的同時有可復用性。
正如接下來我們將看到的,把內置的泛型與用戶定義的非泛型分隔開,對開發者的“舒適度”和編譯時的類型安全產生了影響:它影響了整個Go的生態系統。
Go除了分片和映射之外幾乎沒有數據結構
Go生態系統沒有很多數據結構,它們可以從內置切片和貼圖中提供額外的功能或不同的功能。 Go的最新版本添加了其中幾個的容器包。 他們都有同樣的說明:他們處理interface{}值,這意味著你失去了所有類型的安全機制。
我們來看看sync.Map的一個例子,它是一個具有較低線程爭用的并發映射,而不是使用互斥鎖來保護常規映射:
- type MetricValue struct {
- Value float64
- Time time.Time
- }
- func main() {
- metric := MetricValue{
- Value: 1.0,
- Time: time.Now(),
- }
- // Store a value
- m0 := map[string]MetricValue{}
- m0["foo"] = metric
- m1 := sync.Map{}
- m1.Store("foo", metric) // not type-checked
- // Load a value and print its square
- foo0 := m0["foo"].Value // rely on zero-value hack if not present
- fmt.Printf("Foo square = %f\n", math.Pow(foo0, 2))
- foo1 := 0.0
- if x, ok := m1.Load("foo"); ok { // have to make sure it's present (not bad, actually)
- foo1 = x.(MetricValue).Value // cast interface{} value
- }
- fmt.Printf("Foo square = %f\n", math.Pow(foo1, 2))
- // Sum all elements
- sum0 := 0.0
- for _, v := range m0 { // built-in range iteration on map
- sum0 += v.Value
- }
- fmt.Printf("Sum = %f\n", sum0)
- sum1 := 0.0
- m1.Range(func(key, value interface{}) bool { // no 'range' for you! Provide a function
- sum1 += value.(MetricValue).Value // with untyped interface{} parameters
- return true // continue iteration
- })
- fmt.Printf("Sum = %f\n", sum1)
- }
這是個很好的例子來解釋為什么Go的生態系統中沒有太多的數據結構:與內置的slice和map相比它們用起來很痛苦。出于一個簡單的原因:Go的數據結構中只有兩大類。
- aristocracy,內置的slice,map,array和channel:類型安全,通用且調用range方便,
- Go代碼寫的其他的類型:不提供類型安全,因為需要強制轉換所以用起來笨拙。
所以庫定義的數據結構必須為我們開發者提供很多實在的好處,讓我們愿意付出失去類型安全和額外冗長代碼的代價。
當我們想要編寫可重用的算法時,內置結構和Go代碼之間的雙重性更加微妙。 這是標準庫的排序包對排序片段的一個例子:
- import "sort"
- type Person struct {
- Name string
- Age int
- }
- // ByAge implements sort.Interface for []Person based on the Age field.
- type ByAge []Person
- func (a ByAge) Len() int { return len(a) }
- func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
- func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
- func SortPeople(people []Person) {
- sort.Sort(ByAge(people))
- }
等等…這是真的嗎? 我們必須定義一個新的ByAge類型,它必須實現3種方法來橋接泛型(“可重用”)排序算法和類型化片段。
對于我們開發者來說,唯一需要關注的一件事,就是用于比較兩個對象的Less函數,并且它是域依賴的。其他一切都是干擾,因為Go沒有泛型所以出現了模板。我們不得不一次次地重復使用它,包括我們想去排序的每個type和comparator。
更新:Michael Stapelberg指導我去看被我遺漏的sort.Slice。它在底層使用了反射,而且要求排序的時候,在slice上comparator函數得形成一個閉包。雖然這看起來會好些,但它依舊丑陋。
對于Go不需要泛型的所有解釋都是在告訴我們這就是“Go方式”,Go允許有可復用的算法來避免向下轉型成interface{}…
好了,現在來緩解一下痛苦,如果Go能用宏來生成這些無意義的模板將會變得美好一些,對嗎?
go generate:還行,但是…
Go 1.4引入了 go generate command命令來觸發源代碼中注釋的代碼生成。 那么,這里的“注釋”實際上意味著一個神奇的// go:generate,用嚴格的規則生成注釋:“注釋必須從行的開始處開始并且在//和go:generate之間沒有空格”。 弄錯了,增加一個空格,沒有空格工具會警告你。
這實際上涵蓋了兩種用例:
- 從其他來源生成Go代碼:ProtoBuf / Thrift / Swagger模式,語言文法等
- 生成補充現有代碼的Go代碼,例如作為示例給出的stringer,它為一系列類型常量生成一個String()方法。
第一個用例是可以正常使用的,附加的是你不必使用Makefiles,生成指令可以接近生成的代碼的用法。
對于第二種用例,許多語言(如Scala和Rust)都有宏(在設計文檔中提到)可在編譯期間訪問源代碼的AST。 Stringer實際上導入了Go編譯器的解析器來遍歷AST。 Java沒有宏,但注釋處理器扮演著相同的角色。
許多語言也不支持宏,因此除了這種脆弱的注釋驅動語法之外,沒有任何根本性的錯誤,除了這種脆弱的注釋驅動的語法之外,它看起來像是一種快速破解,它不知道怎么做了這個工作,而不是被認真考慮為連貫的語言設計。
哦,你知道Go編譯器實際上有許多注釋/雜注和條件編譯使用這種脆弱的注釋語法?
結論
正如你可能猜到的那樣,我與Go有著或愛或恨的關系。 Go有點像一個朋友,你喜歡和他在一起,因為他很有趣,很適合一起喝啤酒閑談,但是當你想要進行更深入的對話時,你會覺得無聊或痛苦,而且你不想與他去一起度假
我喜歡Go編寫高效的API及網絡工具的簡單性,這歸功于Goroutine,我討厭它在我必須實現業務邏輯時限制我的表現力,并且我討厭它的所有怪異和陷阱等著你踩進去。
直到最近,Go還沒有真正的替代品,它正在開發高效的本地可執行文件,而不會產生C或C ++的痛苦。Rust正在迅速發展,我越玩越多,我發現它越來越有趣和設計得非常好。我有一種感覺,Rust是需要一段時間才能相處的朋友之一,但是你最終會想要與他們建立長期合作關系。
回到更技術的層面,你會發現文章中說的Rust和Go并不是一個層面的,由于Rust沒有GC等原因,Rust是一個系統語言。我認為這越來越不符合實際。Rust在大型web框架和優秀的ORM中的地位正在逐漸升高。它也給你一種親切感:“如果它是編譯器,錯誤會出現在我寫的邏輯上,而不是我忘記注意的語言特性上”。
我們也從容器/服務網格領域上看到一些有趣的活動,包括使用Rust寫的高效Sozu代理,或者Buoyant(Likerd的開發者)開發的他們的新Kubernetes服務網格Conduit來作為Go和Rust的結合,其中Go作為控制層(我猜由于現有的 Kubernetes 庫),Rust作為數據層因為它的高效和健壯。
Swift也是可以替代C和C++語言的家族的一部分。盡管它的生態仍然太以Apple為中心,但是現在它在Linux是可以用的,而且出現了服務端API和Netty框架。
現在當然沒有萬能和完全通用的技術。但是了解你使用的這些工具的缺點是很重要的。我希望這個博客已經讓你了解到了一些關于Go的你曾經沒意識到的問題,這樣你就可以避免陷阱而不會被陷進去!