這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
1. Rob,你建立了Google Go這門語言。什麼是Google Go?能簡明扼要的介紹一下Google Go嗎?
我還是講講為什麼要建立這門語言吧,和你的問題稍有些不同。我在Google做了一個有關程式設計語言的系列講座,在Youtube上有,談及了我早期所寫的一個語言,叫做Newsqueak,那是八十年代的事,非常早。在做講座期間,我開始思考為什麼Newsqueak中的一些想法在我現在以C++為主的工作環境中無法使用。而且在Google我們經常要構建非常大的程式,光構建就要花很多時間,對依賴的管理也有問題,由於連結了本來並不需要的東西,二進位程式包變得很大,連結時間很長,編譯時間也很長,而且C++的工作方式有點古老,其底層實際上C,C++已經有三十年的曆史了,而C則更是有四十年了。用現今的硬體做計算,有很多新東西需要考慮:多核機器、網路化、分布式系統、雲端運算等等。
2. Go的主要特點是什嗎?有什麼重要功能?
對於大多數人來說,他們對Go的第一印象是該語言將並發性作為語言原語,這對我們處理分散式運算和多核這類東西來說非常好、也非常重要。我猜許多人會認為Go是一門簡單無趣的語言,沒有什麼特別的東西,因為其構想看起來一目瞭然。但實際上不能用第一印象來判斷Go。很多用過Go的人會發現它是一門非常高產而且有表現力的語言,能夠解決我們編寫這門語言時期望其所能解決的所有問題。
Go的編譯過程很快,二進位程式包又比較小,它管理依賴的方式如同管理語言本身的東西一樣。這裡還有一個故事呢,但是在這裡就不再展開討論了,但是這門語言的並發性使其能夠以非常簡單的模式來處理非常複雜的操作及分散式運算環境。我想最重要的功能可能就是並發性了,後面我們可以談談該語言的類型系統,其與C++、Java這類傳統物件導向類型系統的差異很大。
3. 在我們繼續話題之前,能否解釋一下為什麼Go編譯器能達到那麼快的編譯速度呢?有什麼法寶?
它之所以快,有兩個原因。首先Go有兩個編譯器——兩個單獨的實現。一個是按照Plan 9(http://plan9.bell-labs.com/wiki/plan9/1/) 風格新寫的編譯器,它有自己獨特的工作方式,是個全新的編譯器。另一個編譯器叫做GCC Go,它擁有GCC前端,這個編譯器是Ian Taylor後來寫的。所以Go有兩個編譯器,速度快是二者的共同特點,但是Plan 9風格編譯器的速度是GCC Go的5倍,因為它從頭到腳都是全新的,沒有GCC後端,那些東西會花很多時間來產生真正的好代碼。
GCC Go編譯器要產生更好的代碼,所以速度慢些。不過真正重要的一點是Go編譯器的依賴管理特性才是其編譯速度快的真正原因。如果你去看一個C或C++程式,便會發現其標頭檔描述了函數庫、對象代碼等等東西。語言本身並不強制檢查依賴,每一次你都必須分析代碼以便清楚你的函數是怎樣的。如果你編譯過程中想用另一個類的C++程式,你必須先編譯它所依賴的類和標頭檔等等等等。如果你所編譯的C++程式有許多類,並且內部相關,你可能會把同一個標頭檔編譯數百次甚至上千次。當然,你可以用先行編譯標頭檔及其他技巧來迴避之一問題。
但是語言本身並不能幫上你的忙,工具可能會讓這一問題得到改善,可是最大的問題是並沒有什麼能保證你所編譯的東西就是程式真正需要的東西。有可能你的程式包含了一個並不真正需要的標頭檔,但是你沒辦法知道,因為語言並沒有強制檢查。而Go有一個更加嚴格的依賴模型,它有一些叫做包(packages)的東西,你可以把它想象成Java類檔案或著類似的東西,或者函數庫什麼的,雖然他們並不相同,但基本思路是一樣的。關鍵問題是,如果這個東西依賴那個東西,而那個東西又依賴另外一個東西,比如A依賴於B,B又依賴於C,那麼你必須首先編譯最內層的依賴:即,你先編譯C,然後編譯B,最後編譯A。
但是如果A依賴B,但是A並不直接依賴於C,而是存在依賴傳遞,那麼該怎麼辦呢?這時所有B需要從C拿到的資訊都會被放在B的對象代碼裡。這樣,當我編譯A的時候,我不需要再管C了。於是事情就非常簡單了:在你編譯器時,你只需將類型資訊沿著相依樹狀結構向上遍曆即可,如果你到達樹的頂端,則只需編譯緊鄰的依賴,而不用管其它層級的依賴了。如果你要做算術運算,你會發現在Objective-C或C++或類似的語言裡,雖然只包含了一個簡單的標頭檔,但由於依賴傳遞的存在,你可能會編譯數十萬行程式。然而在Go中,你開啟一個檔案,裡面或許只有20行,因為其中只描述了公用介面。
如果一個依賴鏈裡只有三個檔案,Go的優勢可能並不明顯,但是如果你有成千上萬個檔案的時候,Go的速度優勢會成指數增長。我們相信,如果用Go的話,我們應該能夠在數秒內就編譯完數百萬行代碼。然而如果是等量的用C++編寫的程式,由於依賴管理問題,編譯的開銷會大得多,編譯的時間將會長達若干分鐘。因此,Go速度快的根源主要歸功於對依賴的管理。
4. 讓我們開始聊聊Go裡的類型系統吧。Go裡面有結構(struct)、有類型(type),那麼Go裡的類型是什嗎?
Go裡的類型與其它傳統程式設計語言裡的類型是類似的。Go裡的類型有整數、字串、struct資料結構、以及數組(array),我們稱之為切片(slice),它們類似於C的數組,但更便於使用,更加固定一些。你可以聲明本地類型並予以命名,然後按照通常的方式來使用。Go和物件導向方式的不同之處在於,類型只是書寫資料的一種方式,方法則是一個完全獨立的概念。你可以把方法放在struct上,在Go裡沒有類的概念,取而代之的是結構,以及為此結構聲明的一些方法。
結構不能與類混為一談。但是你也可以把方法放在數組、整數、浮點數或字串上,實際上任何類型都可以有方法。因此,這裡方法的概念比Java的方法更加泛化,在Java裡方法是類的一部分,僅此而已。例如,你的整數上可以有方法,聽上去似乎沒什麼用,但是如果你想在一個叫做Tuesday的整數常量上附加上to_string方法來列印出漂亮的星期格式;或者,你想重新格式化字串使其能夠以不同的方式列印出自己,這時你就會意識到它的作用。為什麼非要把所有方法或者其它好東西都塞進類裡面呢,為什麼不讓它們提供更廣泛的服務呢?
5. 那麼這些方法只是在包內部可見嘍?
非也,實際上是這樣,Go只允許你在包內為你所實現的類型定義方法。我不能引入你的類型然後直接把我的方法增加進去,但是我可以使用匿名屬性(anonymous field)將其包裹起來,方法可不是你想加到哪就加到哪的,你要定義類型,然後才能把方法放在上面。正因為如此,我們在包裡提供了另一種封裝——介面(interface),但是如果你不明白誰能為對象增加方法的嚴格界限,就很難理解介面。
6. 你的意思是,我可以給int增加方法,但是必須先使用typedef嗎?
你要typedef一個整數類型,起個名字,如果你正在處理一星期中的七天,可以就叫它“Day”,你可以給你所聲明的類型——Day增加方法,但是你不能直接給int增加方法。因為整數類型不是你定義的,不在你的包裡,它是引入的但並不在你的包中定義,這就意味著你不能給其增加方法。你不能給不在你包裡定義的類型增加方法。
7. 你們借鑒了Ruby裡開放類的思想,這很有意思。Ruby的開放類實際上是可以修改類並增加新的方法,這是有破壞性的,但是你們的方法本質上是安全的,因為建立了新的東西。
它是安全可控的,而且很容易理解。最初我們覺得類型用起來可能不太方便,我們也希望像Ruby那樣添加方法,但這又讓介面比較難以理解。所以,我們只把方法取出來,而不是放進去,我們想不出有什麼更好的辦法,於是限制方法只能在本地類型上,不過這種思路確實很容易理解和使用。
8. 你還提到了typedef,是叫typedef吧?
應該叫“type”,你所說的類型——Day的定義方式是這樣“type Day int”,這樣你就有一個新類型了,你可以在其上增加方法、聲明變數,但這個類型不同於int,不像C那樣,只是同一事物另起了個名字而已,在Go裡實際上你建立了一個不同於int的新類型,叫做“Day”,它擁有int的結構特性,但卻有自己的方法集。
9. Typedef在C裡是一種預先處理指令嗎?【編輯注/免責申明:C語言裡的typedef與預先處理無關】
那實際上就是個別名,但在Go裡不是別名,是新類型。
10. 我們從底層說起吧,在Go裡最小的類型是什嗎?
最小的類型應該是布爾類型(bool)吧。bool、int和float,然後是int32、float64之類有尺寸的類型、字串、複雜類型,可能有遺漏,但這就是基本類型集了。你可以由這些類型構建結構、數組、映射(map),映射在Go裡是內建類型不是函數庫。然後我想就該是介面了,到了介面,有趣的東西才真正開始。
11. 但是,int這樣的類型是實值型別對吧.
Int是實值型別。在Go裡,任何類型都是實值型別,和C一樣,所有東西都是按值調用,但是你也可以用指標。如果你想引用某樣東西,可以擷取其地址,這樣你就有了一個指標。Go也有指標但是比C指標有更多限制,Go裡的指標是安全的,因為他們是型別安全的,所以你沒法欺騙編譯器,而且也沒有指標運算,因此,如果你有個指向某物的指標,你無法將其移到對象外,也無法欺騙編譯器。
12. 它們類似C++的引用嗎?
是的,很像引用,但是你可以按照你預期的方式對它們進行寫操作。而且你可以使用結構內部(如緩衝區)中間的某個地址,它和Java的引用不一樣。在Java中,你必須在旁邊分配一個緩衝區,這是額外的開銷。在Go中,你實際上把該對象分配為結構的一部分,在同一記憶體塊中,這對效能是非常重要的。
13. 它是結構內部一個綜合物件。
是的,如果它是值而不是指標的話,是這樣。當然你也可以把指標放在結構內部和外部,但是如果你有struct A,而把struct B放在struct A裡,那麼stuct B就是一塊記憶體,而不像Java那樣,這也是Java效能問題的原因之一。
14. 你提到過介面比較有趣,那下面咱們就談談這一部分。
Go裡的介面真的非常、非常地簡單。介面指明了兩個不同事情:其一,它表明了類型的構思,介面類型是一個羅列了一組方法的類型,因此如果你要抽象一組方法來定義一個行為,那麼就定義一個介面並聲明這些方法。現在你就有了一個類型,我們就叫它介面類型吧,那麼從現在起所有實現了介面中這些方法的類型——包括基本類型、結構、映射(map)或其它什麼類型,都隱含符合該介面要求。其二,也是真正有意思的是,和大多數語言中的介面不同的是,Go裡面沒有“implements”聲明。
你無須說明“我的對象實現了這個介面”,只要你定義了介面中的那些方法,它就自動實現了該介面。有些人對此感到非常擔憂,依我看他們想說的是:知道自己實現(Implement)了什麼介面真的很重要。如果你真想確定自己實現了什麼介面,還是有技巧可以做到這一點的。但是我們的想法與此截然不同,我們的想法是你不應該考慮實現什麼介面,而是應該寫下要做的東西,因為你不必事前就決定要實現哪個介面。可能後來你實際上實現了某個現在你尚不知曉的介面,因為該介面還未設計出來,但是現在你已經在實現它。
後來你可能發現兩個原先未曾考慮過相關性的類具有了相關性——我又用了類這個詞,我思考Java太多了——兩個structs都實現了一些非常有用的小子集中的相關方法,這時有辦法能夠操作這兩個structs中的任意一個就顯得非常有用了。這樣你就可以聲明一個介面,然後什麼都不用管了,即使這些方法是在別人的代碼中實現的也沒問題,雖然你不能編輯這些代碼。如果是Java,這些代碼必須要聲明實現你的介面,在某種意義上,實現是單向的。然而在Go裡,實現是雙向的。對於介面實際上有不少漂亮而簡單的例子。
我最愛用的一個真執行個體子就是“Reader”,Go裡有個包叫做IO,IO包裡有個Reader介面,它只有一個方法,該方法是read方法的標準聲明,比如從作業系統或檔案中讀取內容。這個介面可以被系統中任何做read系統調用的東西所實現。顯然,檔案、網路、緩衝、解壓器、解密機、管道,甚至任何想訪問資料的東西,都可以給其資料提供一個Reader介面,然後想從這些資源中讀取資料的任何程式都可以通過該介面達到目的。這有點像我們前面說過的Plan 9,但是用不同的方式泛化的。
與之類似,Writer也是比較好理解的另一個例子,Writer 由那些要做寫操作的人來實現。那麼在做格式化列印時,fpringf的第一參數不是file了,而是Writer。這樣,fprintf可以給任何實現了write方法的東西做IO格式化的工作。有很多很好的例子:比如HTTP,如果你正在實現一個HTTP伺服器,你僅須對connection做fprintf,便可將資料傳遞到用戶端,不需要任何花哨的操作。你可以通過壓縮器來進行寫操作,你可以通過我所提到的任何東西來進行寫操作:壓縮器、加密機、緩衝、網路連接、管道、檔案,你都可以通過fprintf直接操作,因為它們都實現了write方法,因此,隱含都隱含符合writer介面要求。
15. 某種程度上有點類似結構化類型系統(structural typing)
不考慮它的行為的話,它是有點像結構化類型系統。不過它是完全抽象的,其意並不在擁有什麼,而是能做什麼。有了結構(struct)之後,就規定了其記憶體的樣子,然後方法說明了結構的行為,再之後,介面則抽象了該結構及其它實現了相同方法的其他結構中的這些方法。這是一種鴨子類型系統(duck typing,一種動態類型系統,http://en.wikipedia.org/wiki/Duck_typing),而不是結構化類型系統。
16. 你提到過類,但Go沒有類,對吧。
Go沒有類。
17. 但是沒有類怎麼去寫代碼?
帶方法的結構(stuct)很像是類。比較有意思的不同之處是,Go沒有子類型繼承,你必須學習Go的另類寫法,Go有更強大、更有表現力的東西。不過Java程式員和C++程式員剛開始使用Go的時候會感到意外,因為他們實際上在用Go去編寫Java程式或C++程式,這樣的代碼工作得並不好,你可以這樣做,但這樣就略顯笨拙了。但是如果你退一步,對自己說“我該怎樣用Go去編寫這些東西呢?”,你會發現模式其實是不同的,用Go你可以用更短的程式來表達類似的想法,因為你不需要在所有子類裡重複實現行為。這是個非常不同的環境,比你第一眼看上去的還要不同。
18. 如果我有一些行為要實現,而且想放在多個structs裡,怎麼去共用這些行為?
有一個叫做匿名域的概念,也就是所謂的嵌入。其工作方式是這樣:如果你有一個結構(struct),而又有一些其它東西實現了你想要的行為,你可以把這些東西嵌入到你的結構(struct)裡,這樣,這個結構(struct)不僅僅可以獲得被嵌入者的資料還可以獲得它的方法。如果你有一些公用行為,比如某些類型裡都有一個name方法,在Java裡的話你會認為這是一組子類(繼承來的方法),在Go裡,你只需拿到一個擁有name方法的類型,放在所有你要實現這個方法的結構裡,它們就會自動獲得name方法,而不用在每個結構裡都去寫這個方法。這是個很簡單的例子,但有不少有趣的結構化的東西使用到了嵌入。
而且,你還可以把多個東西嵌入到一個單一結構中,你可以把它想象成多重繼承,不過這會讓人更加迷惑,實際在Go裡它是很簡單的,它只是一個集合,你可以放任何東西在裡面,基本上聯合了所有的方法,對每個方法集合,你只需寫一行代碼就可以擁有其所有行為。
19. 如果有多重繼承命名衝突的問題該怎麼辦?
命名衝突實際上並沒什麼,Go是靜態處理這一問題的。其規則是,如果有多層嵌入,則最高層優先;如果同一層有兩個相同的名字或相同的方法,Go會給出一個簡單的靜態錯誤。你不用自己檢查,只需留意這個錯誤即可。命名衝突是靜態檢查的,而且規則非常簡單,在實踐中命名衝突發生的也並不多。
20. 因為系統中沒有根對象或根類,如果我想得到一個擁有不同類型的結構的列表,應該怎麼辦?
介面一個有意思的地方是他們只是集合,方法的集合,那麼就會有空集合,沒有任何方法的介面,我們稱之為空白介面。系統中任何東西都符合空介面的要求。空介面有點類似於Java的Object,不同之處在於,int、float和string也符合空介面,Go並不需要一個實際的類,因為Go裡沒有類的概念,所有東西都是統一的,這有點像void*,只不過void*是針對指標而不是值。
但是一個空介面值可以代表系統中的任何東西,非常具有普遍性。所以,如果建立一個空介面數組,實際上你就有了一個多態性容器,如果你想再把它拿出來,Go裡面有類型開關,你可以在解包的時候詢問裡面的類型,因此可以安全的進行解包操作。
21. Go裡有叫做Goroutines的東西,它們和coroutines有什麼區別?不一樣嗎?
Coroutines和Goroutines是不同的,它們的名字反應了這一點。我們給它起了個新名,因為有太多術語了,進程(processes)、線程(threads)、輕量級線程、弦(chords),這些東西有數不清的名字,而Goroutines也並不新鮮,同樣的概念在其它系統裡已經都有了。但是這個概念和前面那些名字有很大不同,我希望我們自己起名字來命名它們。Goroutine背後的含義是:它是一個coroutine,但是它在阻塞之後會轉移到其它coroutine,同一線程上的其它coroutines也會轉移,因此它們不會阻塞。
因此,從根本上講Goroutines是coroutines的一個分支,可在足夠多的操作線程上獲得多路特性,不會有Goroutines會被其他coroutine阻塞。如果它們只是協作的話,只需一個線程即可。但是如果有很多IO操作的話,就會有許多作業系統動作,也就會有許多許多線程。但是Goroutines還是非常廉價的,它們可以有數十萬之眾,總體運行良好並只佔用合理數量的記憶體,它們建立起來很廉價並有記憶體回收功能,一切都非常簡單。
22. 你提到你們使用了m:n執行緒模式,即m個coroutines映射到n個線程上?
對的,但是coroutines的數量和線程的數量是按照程式所做工作動態決定的。
23. Goroutines有用於通訊的通道嗎?
是的,一旦有兩個獨立執行的功能,如果Goroutine們要相互協作它們就需要相互對話。所以就有了通道這個概念,它實際上是一個類型訊息佇列,你可以用它來發送值,如果你在Goroutine中持有通道的一端,那麼你可以發送類型值給另外一端,那一端則會得到想要的東西。通道有同步和非同步之分,我們儘可能使用同步通道,因為同步通道的構思非常好,你可以同時進行同步和通訊,所有東西運行起來都步調一致。
但是有時由於效率原因或調度原因,對訊息進行緩衝也是有意義的。你可以向通道發送整型訊息、字串、結構、指向結構的指標等任何東西,非常有意思的事,你可以在通道上發送另一個通道。這樣,我就能夠把與他人的通訊發送給你,這是非常有意思的概念。
24. 你提到你們有緩衝的同步通道和非同步通道。
不對,同步是沒有緩衝的;非同步和緩衝是一個意思,因為有了緩衝,我才能把值放在緩衝的空間裡進行儲存。但是如果沒有緩衝,我必須等著別人把值拿走,因此無緩衝和同步是一個意思。
25. 每個Goroutine就像是一個小的線程,可以這麼給讀者解釋吧。
對,但是輕量級的。
26. 它們是輕量級的。但是每個線程同樣都預分配棧空間,因而它們非常耗費資,Goroutines是怎麼處理的呢?
沒錯,Goroutines在被建立的時候,只有非常小的一個棧——4K,可能有點小吧,這個棧是在堆中的,當然,你知道如果在C語言裡有這麼一個小棧會發生什麼,當你調用函數或分配數組之類的東西時,程式會馬上溢出。在Go裡則不會發生這樣的事情,每個函數的開頭都會有若干指令以檢查棧指標是否達到其界限,如果到達界限,它會連結到其它塊上,這種串連的棧叫做分段棧,如果你使用了比剛開始啟動時更多的棧,你就有了這種棧塊連結串,我們稱之為分段棧。
由於只有若干指令,這種機制非常廉價。當然,你可以分配多個棧塊,但是Go編譯器更傾向於將大的東西移到堆上,因此實際上典型的用法是,你必須在達到4K邊界之前調用幾個方法,雖然這並不經常發生。但是有一點很重要:它們建立起來很廉價,因為僅有一次記憶體配置,而且分配的記憶體非常小,在建立一個新的Goroutine時你不用指明棧的尺寸,這是很好的一種抽象,你根本不用擔心棧的大小問題。之後,棧會隨需求增長或縮小,你不用擔心遞迴會有問題,你也不用擔心大的緩衝或任何對程式員完全不可見的東西,一切由Go語言來打理,這是一門語言的整體構思。
27. 我們再來談談自動化方面的東西,最初你們是將Go語言作為系統級語言來推廣的,一個有趣的選擇是使用了記憶體回收行程,但是它速度並不快或者說有記憶體回收間歇問題,如果用它寫一個作業系統的話,這是非常煩人的。你們是怎麼看這一問題的?
我認為這是個非常難的問題,我們也還沒有解決它,我們的記憶體回收行程可以工作,但是有一些延遲問題,記憶體回收行程可能會停頓,但是我們的看法是,我們相信儘管這是一個研究課題,雖還沒解決但是我們正在努力。對於現今的並行機,通過把機器核心的一些片段專門分給作為背景工作的記憶體回收來進行並行回收是可行的。在這一領域有很多工作要做,也取得了不少成功,但這是個很微妙的問題,我不認為而我們會把延遲降為0,但是我相信我們可以讓延遲儘可能低,這樣對於絕大多數系統軟體來講它不再是個問題。我不保證每個程式都不會有顯著延遲,但是我想我們可以獲得成功,而且這是Go語言中一個比較活躍的領域。
28. 有沒有方法能夠避免直面記憶體回收行程,比如用一些大容量緩衝,我們可以把資料扔進去。
Go可以讓你深入到記憶體布局,你可以分配自己的空間,如果你想的話可以自己做記憶體管理。雖然沒有alloc和free方法,但是你可以聲明一個緩衝把東西放進去,這個技巧可用來避免產生不必要的垃圾。就像在C語言一樣,在C裡,如果你老是malloc和free,代價很大。因此,你分配一個對象數組並把它們連結在一起,形成一個鏈表,管理你自己的空間,而且還不用malloc和free,那麼速度會很快。你可以做與Go所做相同的事情,因為Go賦予你與底層事物安全打交道的能力,因此不用欺騙類型系統來達到目的,你實際上可以自己來做。
前面我表達了這樣的觀點,在Java裡,無論何時你在結構裡嵌入其它東西,都是通過指標來實現的,但在Go裡你可以把它放在一個單一結構中。因此如果你有一些需要若干緩衝的資料結構,你可以把緩衝放在結構的記憶體裡,這不僅意味著高效(因為你不用間接得到緩衝),而且還意味著單一結構可以在一步之內進行記憶體配置與記憶體回收。這樣開銷就會減少。因此,如果你考慮一下記憶體回收的實際情況,當你正在設計效能要求不高的東西時,你不應該總是考慮這個問題。但如果是高效能要求的,考慮到記憶體布局,儘管Go是具有真正記憶體回收特性的語言,它還是給了你工具,讓你自己來控制有多少記憶體和產生了的垃圾。我想這是很多人容易忽略的。
29. 最後一個問題:Go是系統級語言還是應用級語言?
我們是把他設計為一種系統級語言,因為我們在Google所做的工作是系統級的,對吧?Web伺服器和資料庫系統、以及儲存系統等,這些都是系統。但不是作業系統,我不知道Go是否能成為一個好的作業系統語言,但是也不能說它不會成為這樣的語言。有趣的是由於我們設計語言時所採用的方法,Go最終成為了一個非常好的通用語言,這有點出乎我們意料。我想大多數使用者並沒有實際從系統觀點來考慮過它,儘管很多人做過一點Web伺服器或類似東西。
Go用來做很多應用類的東西也非常不錯,它將會有更好的函數庫,越來越多的工具以及一些Go更有用的東西,Go是一個非常好的通用語言,它是我用過的最高產的語言。