關于Go語言,你可能會討厭的五件事
關于 Go 語言,你有什么要吐槽的?
近年來,Go 從新出現的編程語言中脫穎而出。不過要把 Go 稱為“新晉者”似乎并不合適,因為谷歌早在 2009 年就推出了 Go,并于 2012 年發布了***個最終版(Go 1.0)。到現在為止,Go 已經發展到了 1.10 版本,這個版本令人印象深刻,而且還在不斷添加新的特性。
為什么它被稱為 eGOtistic(自大狂)……
大家都知道,Go 在實現或語法方面喜歡“我行我素”。在英語中,這種情況被描述為“自以為是”。很多來自其他編程語言的概念在 Go 中并不存在,或者即使存在,它們的行為也變得“面目全非”。后一種情況可能會導致意想不到的錯誤,甚至讓開發人員感到疑惑。
嚴格的 Go 語法通常會讓開發人員感到疲倦。Go 編譯器不允許出現未使用的導入和變量,并竭盡所能將它們攔截下來,甚至讓花括號另起一行都不行。Go 強制使用相對固定且幾乎統一的編程風格。只要 Go 編譯器不喜歡某些東西,到***都變成了編譯錯誤。
Go 提供了非常嚴格的類型安全。因為太過嚴格,我們甚至可以通過它來實現一些特殊效果和編程錯誤,其中一些我們稍后會在文中討論。不過,我們很少有必要在 Go 中顯式地聲明類型,因為類型通常可以從賦值中獲得,也就是類型推斷。
我不是要提供問答!
一年多以前,我開始在工作中大量使用 Go。Go 算不上是我最喜歡的編程語言,但我承認,Go 在提升開發效率方面起到了一定作用。事實上,我已經使用 Go 完成了幾個小項目,主要是一些嵌入式應用。Go Toolchain 的跨平臺編譯功能(編譯后可用于其他操作系統或 CPU 平臺)非常棒,已經***于它的競爭對手。
現在讓我們來看看 Go 的一些比較特別的特性。入門 Go 其實很容易,可能只需要一個周末來了解它的基礎知識。但當你開始用 Go 做一些更復雜的事情時,各種奇奇怪怪的事件開始浮出水面。
有時候,這些特性非常奇怪,谷歌為此提供了問題解答,用于解釋類似“為什么 X 的行為是這樣或者那樣的”這類問題。Go 在很多方面都表現得與其他語言不太一樣,感覺好像程序員在某個時候一定會被某些陷阱絆倒一樣。gopher Slack 頻道已經證實了這種情況的存在,其中就有這樣的描述:“現在你真的應該好好了解一下 Go 了,因為每個開發人員在他們的 Go 職業生涯中都會問到這個問題”。通常情況下,我們的直覺與 Go 的特性并不相符。例如,在谷歌的 C 語言變種中,公開類型、函數、常量等都以大寫字母作為開頭來表示它們是公開的,而標識符開頭的小寫字母表示它們是私有的。
盡管如此,有關 Go 的很多決策都是在郵件列表或提案文件中經過了長時間的討論,因此還是得到了肯定。然而,討論所使用的用例都非常特殊,以至于很多開發人員仍然不清楚這與他們要解決的問題究竟有什么關系。
我個人最喜歡的部分是 Go 沒有提供可重入鎖,即同一線程或 Goroutine(Coroutine 或 Green Thread 的變體)可遞歸獲取的鎖。如果不通過 hack 的方式就無法自行實現這樣的功能,因為線程在 Go 中不可用,而 Goroutine 也并沒有提供可用于遞歸識別相同 Coroutine 的標識符。
在這篇文章中,我想介紹 Go 的五個特性及其語法,這些特性都很隱晦。
1. 瘋狂的影子
讓我們從最簡單的事情開始:每個優秀的開發人員都聽說過 Shadowing,它通常會發生在變量的上下文中。下面是只包含兩個作用域的簡單示例:
- foo("foo")
- func foo(var1 string) {
- for {
- var1 := "bar"
- fmt.Println(var1)
- break
- }
我們通過:=賦值符號創建了一個變量,并通過所賦的值(類型引用)來推斷變量的類型。在這里,它是一個字符串。因此,我們在內部作用域(for 循環)中創建了一個與函數參數名稱相同的變量。我們覆蓋(shadow)了輸入參數,并輸出“bar”。
到現在為止還挺好。但是,在 Go 中,需要為其他包的屬性指定包名(即結構體、方法、函數等),這個可以在提供 Println 函數的 fmt 包中看到。
所以我們對之前的例子稍微做一下重構:
- foo("foo")
- func foo(var1 string) {
- for {
- fmt := "bar"
- fmt.Println(var1)
- break
- }
- }
這一次,我們遇到了編譯錯誤,我們試圖在一個字符串上調用 Println 函數。但這種情況并不總是這么明顯。當代碼突然停止編譯時,即使只有幾行代碼也會給我們帶來“驚喜”。
如果結構體發生重疊,就會很麻煩。讓我們舉一個奇怪的例子:
- type task struct {
- }
- func main() {
- task := &task{}
- }
我們創建了一個叫作 task 的結構體和它的一個實例。我們有意使用小寫 task 作為結構體的名稱,因為如前所述,Go 使用***個字母來確定可見性,所以 task 在這里是私有的。
到目前為止,它看起來很不錯,Go 編譯了我們創建的 task。但是,當我們嘗試添加另一行代碼時,情況突然發生了變化。
- type task struct {
- }
- func main() {
- task := &task{}
- task = &task{}
- }
現在無法通過編譯,并顯示 task 不是一個類型。此時,Go 分不清類型和變量之間的區別。也許有人會說,在 JavaScript 中,變量 task 可以是對類型的引用,但這在 Go 中是不可能的,因為類型不可以作為值賦給變量。
現在的問題是:這算不算是悲劇?一般來說不算,但它卻經常在我沒有意識到的情況下發生。后面可能還會有一些代碼嘗試訪問相同名稱的結構體或包,而每次都需要花幾分鐘時間才能找到問題所在。
說到類型問題,讓我們看看另外一個例子。
2. 類型還是無類型,這是個問題!
我們已經知道如何創建結構體和函數。有時候,我們會偶爾“重命名”一下類型,比如:type handle int ,這將創建一個叫作 handle 的類型,它的行為類似 int。通常,這個特性被稱為類型別名。你可能也想到過這個特性,但不是在 Go 中。不過從 Go 1.9 開始,已經完全支持這個特性了。
讓我們看看可以用 Go 做哪些好玩的事情:
- type handle int
- func main() {
- var var1 int = 1
- var var2 handle = 2
- types(var1)
- types(var2)
- }
- func types(val interface{}) {
- switch v := val.(type) {
- case int:
- fmt.Println(fmt.Sprintf("I am an int: %d", v))
- case handle:
- fmt.Println(fmt.Sprintf("I am an handle: %d", v))
- }
- }
- I am an int: 1
- I am an handle: 2
在這個例子中,我們使用了 Go 的幾個非常酷的特性。switch-type-case 語句是一種類型模式匹配,類似于 Java 的 instanceof 或 JavaScript 的 typeof。我們把 interface{}與 Java 中的 Object 等同起來,因為它是一個空的接口,每個 Go 類都會自動實現它。
有趣的是,Java 開發人員希望handle也是一個int,這樣就會匹配到***個 case。但事實并非如此,因為面向對象中的類型繼承在 Go 中并不適用。
另一種可能的情況是,handle 是 int 的別名,就像 C/C++ 中的typedef一樣,但事實也并非如此。Go 編譯器會創建一個新的 TypeSpec,可以說是原始類型的克隆。因此,它們之間是完全獨立的。
不過,從 Go 1.9 開始,支持真正的類型別名。下面的例子只稍微做了點修改。
- type handle = int
- func main() {
- var var1 int = 1
- var var2 handle = 2
- types(var1)
- types(var2)
- }
- func types(val interface{}) {
- switch v := val.(type) {
- case int:
- fmt.Println(fmt.Sprintf("I am an int: %d", v))
- }
- switch v := val.(type) {
- case handle:
- fmt.Println(fmt.Sprintf("I am an handle: %d", v))
- }
- }
- I am an int: 1
- I am an int: 2
- I am an handle: 1
- I am an handle: 2
你有沒有注意到它們的區別?實際上,我們現在不使用type handle int,而是使用type handle=int為int創建一個額外的名稱(別名),即handle。這意味著 switch 語句也必須做出修改,因為這個時候,int 和 handle 對于編譯器來說是完全相同的類型,除非你有另一個 double case,否則會出現編譯錯誤。由于類型別名實在 Go 1.9 中引入的,很多人會認為上述的類型克隆就是類別別名。
為了方便演示,讓我們定義一個名為Callable的類型,它由一個沒有參數和返回值的簡單函數組成。
type Callable func()
現在創建一個相應的函數。
- func main() {
- myCallable := func() {
- fmt.Println("callable")
- }
- test(myCallable)
- }
- func test(callable Callable) {
- callable()
- }
看,很簡單。由于 Go 的類型推斷機制,編譯器自動識別出myCallable應該對應Callable的函數簽名。編譯器因此能夠隱式地將myCallable轉換為Callable。隨后,myCallable被傳遞給test函數。這是執行隱式轉換的少數例外之一,通常情況下,所有形式的轉換必須全部明確地指出。
現在我們已經到了不得不使用Reflection的地步。與其他語言一樣,Reflection提供了在運行時分析或改變行為的能力。類型信息通常被用于根據值的數據類型來改變運行時行為。
- type Callable func()
- func main() {
- callable1 := func() {
- fmt.Println("callable1")
- }
- var callable2 Callable
- callable2 = func() {
- fmt.Println("callable2")
- }
- test(callable1)
- test(callable2)
- }
- func test(val interface{}) {
- switch v := val.(type) {
- case func():
- v()
- default:
- fmt.Println("wrong type")
- }
- }
- callable1
- wrong type
callable1現在是函數類型func(),而callable2被顯式聲明為Callable。 Callable是一個單獨的TypeSpec,因此與func()的類型不一樣。這兩種情況現在都必須由我們的Reflection處理程序單獨攔截處理。不過這些問題可以通過在 Go 1.9 中引入的類型別名來解決。
type Callable=func()
3. 懶惰是囊地鼠的天性!
Go 語言萌萌噠的 logo 囊地鼠生性懶散,選這個 logo 也是有一定的代表意義的。
我最喜歡的 Go 特性之一是惰性求值(Lazy Evaluation),即延遲執行代碼。自從 Java 推出 Stream API 以來,Java 開發人員對該特性也所了解。
我們來看看下面的代碼片段:
- func main() {
- functions := make([]func(), 3)
- for i := 0; i < 3; i++ {
- functions[i] = func() {
- fmt.Println(fmt.Sprintf("iterator value: %d", i))
- }
- }
- functions[0]()
- functions[1]()
- functions[2]()
- }
這里有一個包含三個元素的數組、一個循環和閉包,而結果會是什么?
- iterator value: 3
- iterator value: 3
- iterator value: 3
我們會認為是 0,1,2,但實際上卻是 3,3,3。沒錯!
在其他編程語言(如 Java)中,在創建閉包時會捕獲變量的值,而 Go 僅捕獲指向變量本身的指針。問題是,在迭代期間,變量的值不斷變化。循環完成后,我們執行閉包,只看到***的值。我們知道我們只擁有指針,所以也就可以理解這種行為,但確實不是很直觀。
如果我們想保存這個值,需要知道在創建閉包時如何計算這個值。
- func main() {
- functions := make([]func(), 3)
- for i := 0; i < 3; i++ {
- functions[i] = func(y int) func() {
- return func() {
- fmt.Println(fmt.Sprintf("iterator value: %d", y))
- }
- }(i)
- }
- functions[0]()
- functions[1]()
- functions[2]()
- }
我們創建了一個臨時函數,它將變量作為參數并返回閉包。我們立即調用這個函數。由于在調用外部函數時必須先計算變量的值,所以內部閉包就可以捕獲到正確的值。我們得到的是 0,1,2。
在寫這篇文章不久之前,我找到了另一種方式。我們可以在循環中創建一個具有相同名稱的變量,并為其分配實際值。這樣也可以捕獲到變量的值,因為這個方法在循環的每次迭代中都會創建一個新的變量(因此是一個新的指針)。
- func main() {
- functions := make([]func(), 3)
- for i := 0; i < 3; i++ {
- i := i // Trick mit neuer Variable
- functions[i] = func() {
- fmt.Println(fmt.Sprintf("iterator value: %d", i))
- }
- }
- functions[0]()
- functions[1]()
- functions[2]()
- }
從執行速度來看,懶求值通常是一個有趣的話題。畢竟,我可以在不使用它的情況下創建閉包。既然這樣,為什么還要求值?在我看來,這也是非常不直觀的。
4. 我們是不是都有點像囊地鼠?
我們已經知道,Go 中的interface{}就像 Java 中的Object——Go 中的每個類型都會自動實現這個空接口。不過,自動實現接口不僅適用于空接口,每一個實現了某個接口所有方法的結構體或類型也會自動實現這個接口。
為了更好地說明這個問題,讓我們來看看下面的例子:
- type Sortable interface {
- Sort(other Sortable)
- }
定義了這個方法的結構體會自動成為 Sortable。
- type MyStruct struct{}
- func (m MyStruct) Sort(other Sortable){}
除了接收器類型的語法,它用于將函數綁定到類型(在本例中為結構體),我們已經實現了Sortable接口的所有方法。我們現在是一個 Sortable!
var sortable Sortable = &MyStruct{}
自動實現接口乍一看似乎很有用,但這樣會讓事情變得復雜,特別是在大型應用中,如果有幾個接口擁有相同的方法,那么就會點讓人摸不著頭腦。開發者實際想要實現哪個接口?或許他們應該在代碼的注釋中寫清楚!
Go 還有一個解決方案用于確保一個類型實現了一個接口,就像 Java 的implements關鍵字一樣,這實在是太簡單了。
- type MyStruct struct{}
- func (m MyStruct) Sort(other Sortable){}
- var _ Sortable = MyStruct{}
- var _ Sortable = (*MyStruct)(nil)
5. nil 和 nothing
現在我們都知道,“null”和“nil”之間有很大的差別,但可能不是所有人都知道,“nothing”并不總是意味著“什么都沒有”。為了證明這點,我們定義了自己的錯誤類型(異常)。
- type MyError string
- func (m MyError) Error() string {
- return string(m)
- }
我們創建了一個新的類型,它是從字符串類型克隆過來的。我們只是想要一個錯誤消息,所以這樣做就足夠了。要實現error接口(是的,小寫,理論上它不應該是公開的,但 Go 無所不能),就必須實現Error方法。
接下來,我們需要另一個總是返回 Nil 的函數。
- func test(v bool) error {
- var e *MyError = nil
- if v {
- return nil
- }
- return e
- }
無論我們傳進去的是true還是false,這個函數總是返回nil,是這樣的嗎?c`
- func main() {
- fmt.Println(nil == test(true))
- fmt.Println(nil == test(false))
- }
- true
- false
在返回e時,*MyError指針指向接口error的一個實例,它不是nil!這樣合邏輯嗎?當你知道接口在 Go 中的表示方式,你就會知道這是合乎邏輯的。
在 Go 內部,接口是一個結構體,包含了實際目標實例(這里為nil)和接口類型(在這里是error),而且根據 Go 語言規范,只有在這個結構體的兩個值都為nil時,接口實例才為nil。因此,如果真想要返回nil,那就顯式地返回吧。
特別之處
還有一點是值得一提的,如前所述,Go 根據名稱來推斷出類型和功能的可見性。如果***個字母是大寫字母(如Foo),則該函數或類型是公開的,如果***個字母是小寫字母(如foo),那么就是私有的。不過,在 Java 中有 private,而在 Go 中只有package-private。
一般來說,除了在 Go 中使用駝峰式命名法,我們都可以使用這種可見性規則,無論是函數、結構體還是常量,但我們的 IDE 有語法突出顯示,所以誰會在乎這個!
有趣的是,Go 支持 Unicode 的標識符。因此,日本語(Nihongo 是日語的意思)是完全合法的標識符,但通常被認為是私有的。為什么?因為日文字符沒有大寫字母。
“GO 斯拉”發來問候
某種程度上,Go 是一門非常獨特的語言。在日常工作中,你可以享受 Go 帶來的樂趣。如果你已經知道我們在這里所提到的陷阱(還有更多),那么即使開發再大型的應用程序也不成問題。盡管如此,還是會不斷出現各種提醒,說這門語言有問題。
Go 在近幾年發生了很多事情,除了增加新特性,Go 2 中還列出了很多需要改進的地方,包括一些語法和運行時行為的不一致性。不過 Go 2 的推出時間還不得而知,還沒有清晰的路線圖。
如果你想要用 Go,那么就用吧,盡管存在很多坑。不過你要為此做好準備:有時候你會感到困惑,需要長時間的調試,或通過閱讀 FAQ 或訪問 Gopher Slack 頻道來解決問題。