兄弟連教育攜手清華系尹成團隊成立區塊鏈學院並開設Go全棧與區塊鏈課程。該課程旨在助力使用者認知並跟隨清華微軟Google區塊鏈專家級名師學習相關技術,並通過海量的企業級實戰項目深度掌握顛覆性區塊鏈技術,並為國內區塊鏈孵化更多優質的專業型人才。
1. Go簡介
Go是由Google於2007年9月21日開始開發,2009年11月10日開放源碼,2012年
3月28日推出第一個正式版本的通用型程式設計語言。它為系統編程而7設計,是強型別化的
語言,具有記憶體回收機制,並顯式支援並發編程。Go程式由包構造,以此來提供高效的
依賴管理功能。當前的編譯器實現使用傳統的“編譯-連結”模型來產生可執行檔二進位文
件。
十年以來,主流的系統級程式設計語言並未出現過,但在這期間,計算環境已經發生了巨大的
變化。以下是一些變化趨勢:
相電腦的速度變得極快,但軟體開發還不夠快。
在今天,依賴管理已然成為了軟體開發中當重要的部分,但傳統的C家族語言以“頭文
件”的方式組織源碼,這與清晰的依賴分析以及快速編譯背道而馳。
Java和C++等語言的類型系統比較笨重,人們的反抗越來越多,因此他們轉向了
Python和JavaScript之類的動態類型語言。
目前流行的系統語言對於像記憶體回收及並行計算等基本思想的支援並不算好。
多核電腦的出現產生了一些麻煩與混亂。
隨著系統的龐大,分布式的要求越來越高,現有的語言開發分布式系統越來越笨重以及難以維護。
而Go則是一種新的語言,一種並發的、帶記憶體回收的、快速編譯的語言。它滿足了以下特點:
它可以在一台電腦上用幾秒鐘的時間編譯一個大型的Go程式。
Go為軟體構造提供了一種模型,它使依賴分析更加容易,且避免了大部分C風格
include檔案與庫的開頭。
Go是靜態類型的語言,它的類型系統沒有層級。因此使用者不需要在定義類型間的關係上
花費時間,這樣感覺起來比典型的物件導向語言更輕量級。
Go完全是記憶體回收型的語言,並為並發執行與通訊提供了基本的支援。
按照其設計,Go打算為多核機器上系統軟體的構造提供一種方法。
Go試圖成為結合解釋型編程的輕鬆、動態類型語言的高效以及靜態類型語言的安全的編譯型語
言。它也打算成為現代的,支援網路與多核計算的語言。要滿足這些目標,需要解決一些語言上
的問題:一個富有表達能力但輕量級的類型系統,並發與記憶體回收機制,嚴格的依賴規範等等。
這些無法通過庫或工具解決好,因此Go也就應運而生了。
2. C/C++的缺陷
a. 全域變數的初始化順序
由於在C/C++中,全域變數的初始化順序並不確定,因此依賴於全域變數初始化
順序的操作,可能會給程式帶來不可預知的問題。
b. 變數預設不會被初始化
由於變數預設不會被初始化,因此如果在程式中忘記初始化某個變數,就有可能
造成一些奇怪的細節性錯誤,以至於在Coding Standard中都為之專門加以強調。
c. 字元集的支援1
C/C++最先支援的字元集是ANSI。雖然在C99/C++98之後提供了對Unicode的支
持,但在日常的編碼工作中卻要在ANSI與Unicode之間來迴轉換,相當地繁瑣。
d. 複雜的繼承模式
C++提供了單/多繼承,而多繼承則引入了大量的複雜性,比如“鑽石型繼承”等。
細節請參閱《深度探索C++物件模型》。
e. 對並發的支援
在C++中,並發更多的是通過建立線程來啟用,而線程間的通訊則是通過加鎖共
享變數來實現,很容易死結。雖然在C++11中添加了並發處理機制,但這給本來就十分複
雜的類型系統又添加了更重的負擔。
f. 不支援自動記憶體回收
關於這一點存在爭議。記憶體流失是C/C++程式員經常遭遇到的問題,但隨著垃圾
回收演算法的成熟,對於大多數開發人員來說,自動回收帶來的便利已經超過手工操作提高的
效率。而在C++中雖然可使用智能指標來減少原生指標的使用,但不能杜絕它,因此這個
問題仍然存在。
g. 落後的包管理機制
C/C++中採用.h/.c(pp)檔案來組織代碼,這種方式使編譯時間變得過於漫長
;C++中的模板更讓這個問題雪上加霜。
h. C++編譯器總會私自產生一些代碼
比如: 建構函式/解構函式/new/delete等。如果是動態庫,當include不同版本的頭
檔案時,容易產生版本不相容的代碼。
i. 不加約束的指標使用是導致C/C++軟體BUG的重要根源之一
3. Go的優勢
1
C/C++編譯時間的編碼方式也不確定。main.cpp如果是UTF-8編碼, 在gcc和VC下的行為也
會不一樣。——柴樹杉
正如語言的設計者之一Rob Pike所說:
“我們——Ken,Robert和我自己曾經是C++程式員,我們設計新的語言是為瞭解
決那些我們在編寫軟體時遇到的問題。”
這些問題中的大部分,就是在第2節中列舉的內容。這一小節就是Go針對這些缺陷提出的
解決方案。
a. Init
每個包都可以定義一個或多個init函數2(原型為 func init()),init函數在包初次
被匯入時調用,同一個包內的多個init函數的執行的順序是不定的,而如果這個包又匯入
了其他的包,則級連調用,所有包import完成,所有init函數執行完後,則開始main的執
行。
而對於全域變數,以一個簡單的例子來說明:
// package p
var gInt int
…
// package a
import "p"
…
// package b
import "p"
…
// package main
import (
"a"
"b"
)
…
在package p中,我們定義了一個全域變數gInt,而p被package a,b所import,接
著package main又按序import了a,b,即a在b前被import。a先import了p,所以此時gInt被
初始化,這樣就解決了C/C++中全域變數初始化順序不一致的問題。
b. 預設自動初始化
Go引入了零值的概念,即每個對象被建立的時候,預設初始化為它相應類型的零
值。例如,string為””,指標為nil,int為0等等,這樣就保證了變數在使用時,不會因為忘
記初始化而出現一些莫名其妙的問題。此外,由於零值的引入,也方便了代碼的編寫。比
如說sync包的mutex類型,在引入零值後,就能以如下方式使用:
2
每個源檔案也可包含多個init函數。多個init之間的調用順序不確定。init函數本身不能被其它變數或函數引
用(調用或取函數地址)。——柴樹杉
3
var locker sync.Mutex
locker.Lock()
defer locker.Unlock()
…
而相應的C/C++代碼,可能就要這樣寫了:
CRITICAL_SECTION locker
InitializeCriticalSection(&locker)
EnterCriticalSection(&locker)
…
LeaveCriticalSection(&locker)
DeleteCriticalSection(&locker)
忘記任何一步操作,都將造成死結(dead lock)或者其他的問題。
c. UTF-8
Go語言原生支援UTF-8編碼格式3。同時Go涉及到字串的各種包,也直接為
UTF-8提供了支援,比如:
str := "樣本"
if str == "樣本" {...}
d. 只支援組合不支援繼承
OOP在Go中是通過組合而非繼承來實現的,因為“繼承”存在一些弊端,比如
:“不適應變化”,“會繼承到不適用的功能”。所以在編碼實踐中一般建議優先使用組合而
非繼承。在Go中則更進一步,直接去掉了繼承,只支援組合。在定義struct時,採用匿名
組合的方式,也更好地實現了C++中的“實現”繼承,而在定義interface時,也可以實現接
口繼承。比如:
type A struct{}
func (a A) HelloA() {
…
}
type B struct{}
func (b B) HelloB() {
…
}
type C struct {
A
B
}
c := &C{}
c.HelloA()
c.HelloB()
此時c就擁有了HelloA、HelloB兩個方法,即我們很容易地實現了“實現繼承”。
同時已經支援帶BOM的UTF-8了。——柴樹杉
e. Go程(goroutine)與通道(channel)
Go對並發的支援,採用的是CSP模型,即在代碼編寫的時候遵循“通過通訊來共
享記憶體,而非通過共用記憶體來通訊”的原則。為此,Go提供了一種名為“Go程”的抽象。由
於Go程是一種高於線程的抽象,因此它使用起來也就更加輕量方便。而當多個Go程需要
通訊的時候,通道就成為了它們之間的橋樑。例如:
func goroutine(pass chan bool) {
fmt.Println("hello, i'm in the goroutine")
pass <- true
}
func main() {
pass := make(chan bool)
go goroutine(pass)
<-pass
fmt.Println("passed")
}
代碼中通過關鍵字chan來聲明一個通道,在函數前加上關鍵字go來開啟一個新的
Go程。此Go程在執行完成後,會自動銷毀。而在通訊過程中,可通過<-操作符向通道中
放入或從中取出資料。
f. 自動記憶體回收
與C#、Java等語言類似,為了將程式員從記憶體流失的泥沼中解救出來,Go提供
了自動記憶體回收機制,同時不再區分對象是來自於棧(stack)還是堆(heap)。
g. 介面(interface)
除Go程外,Go語言的最大特色就是介面的設計,Go的介面與Java的介面,
C++的虛基類是不同的,它是非侵入式的,即我們在定義一個struct的時候,不需要顯
式的說明它實現了哪一/幾個interface,而只要某個struct定義了某個interface所聲明的
所有方法,則它就隱式的實現了那個interface,即所謂的Structural-Typing(關於Duck-
Typing與Structural-Typing的區別,請參考minux.ma的相關注釋)。
假設我要定義一個叫Shape的interface,它有Circle、Square、Triangle等實現
類。
在java等語言中,我們是先在大腦中從多個實現中抽象出一個interface,即:
在定義Shape的時候,我們會先從實作類別中得出共性。比如它們都可以計算面
積,都可以被繪製出來,即Shape擁有Area與Show方法。在定義出了Shape過後,再定
義Circle、Square、Triangle等實作類別,這些類都顯式的從Shape派生,即我們先實現了
介面再實現了“實現”。在實現“實現”的過程中,如果發現定義的介面不合適,因為“實現”顯
式地指定了它派生自哪個基類,所以此時我們需要重構:
public interface Shape {
public float Area();
public void Show();
}
public class Circle : implements Shape {
public float Area() { return …}
public void Show() {…}
}
// (同理Square和Triangle)
而在Go中,由於interface是隱式的,非侵入式的,我們就可以先實現Circle、
Square、Triangle等子類。在實現這些“實作類別”的過程中,由於知識的增加,我們可以更
好地瞭解哪些方法應該放到interface中,即在抽象的過程中完成了重構。
type Circle struct {}
func (c Circle) Area() float32 {}
func (c Circle) Show() {}
// (同理Square和Triangle)
type Shape interface {
Area() float32
Show()
}
這樣Circle、Square和Triangle就實現了Shape。
對於一個模組來說,只有模組的使用者才能最清楚地知道,它需要使用由其它被
使用模組提供的哪些方法,即interface應該由使用者定義。而被使用者在實現時,並不知
道它會被哪些模組使用,所以它只需要實現自己就好了,不需要去關心介面的粒度是多細
才合適這一類的瑣碎問題。interface是由使用方按需定義,而不用事前規劃。
Go的interface與Java等的interface相比優勢在於:
1. 按需定義,最小化重構的代價。
2. 先實現後抽象,搭配結構嵌入,在編寫大型軟體的時候,我們的模組可
以組織得耦合度更低。
h. Go命令
在Unix/Linux下為了編譯器的方便,都可能需要編寫makefile或者各種進階的自
動構建工具(Windows也存在類似的工具,只不過被各種強大的IDE給隱藏在背後了)。
而Rob Pike等人當初發明Go的動機之一就是:“Google的大型的C++程式的編譯時間過
長”。所以為了達到:“編譯Go程式時,作為程式員除開編寫代碼外,不需要編寫任何配
置檔案或類似額外的東西。”這個目標,引入了Go命令族。通過Go命令族,你可以很容
易從實現的線上repository上獲得開原始碼,編譯並執行代碼,測試代碼等功能。這與C/
C++的處理方式相比,前進了一大步。
i. 自動類型推導
Go雖然是一門編譯型語言,但是在編寫代碼的時候,卻可以給你提供動態語言的
靈活性。在定義一個變數的時候,你可以省略類型,而讓編譯器自動為之推導類型,這樣
減少了程式員的輸入字數。比如:
i := 0 ⇔ var i int
s := "hello world" ⇔ var s string = "hello world"
j. 強制編碼風格規範
在C/C++中,大家為大括弧的位置採用K&R還是ANSI,是使用tab還是
whitespace,whitespace是2個字元還是4個字元等瑣碎的問題而爭論不休。每個公司內
部都定義了自己的Coding Standard來強制限制式。而隨著互連網的蓬勃發展,開源項目的
越發增多,這些小問題卻影響了大家的工作效率。而有一條編程準則是“less is more”。為
了一致性,Go提供了專門的格式化命令go fmt,用以統一大家的編碼風格。
作為程式員,你在編寫代碼的時候,可以按你喜歡的風格編寫。編寫完成後,
執行一下go fmt命令,就可以將你的代碼統一成Go的標準風格。這樣你在接觸到陌生的
Go代碼時,減少了因為編碼風格差異帶來的陌生感,強調了一致性。
k. 內建單元測試及效能測試工具
C/C++雖未提供官方的單元測試與效能測試工具,但有大量第三方的相關工具。
而由於每個人接觸的,喜歡的工具可能不一樣,就造成了在交流時的負擔。有鑒於此,
Go提供了官方測試載入器go test,你可以很方便地編寫出單元測試用例。比如這樣就完成
了一個單元測試的編寫:
package test
// example.go
func Add(a, b int) int {
return a + b
}
…
// example_test.go
func TestAdd(t *testing.T) {
//定義一個表格,以展示 table-driven 測試
table := []struct {
a, b, result int
}{
{1, 0, 1},
{1, 2, 3},
{-1, -2, 0},
}
for _, row := range table {
if row.result != Add(row.a, row.b) {
t.Fatalf("failed")
}
}
}
同理效能測試。
編寫完成後執行go test就可完成測試。
l. 雲平台的支援
最近幾年雲端運算發展得如火如荼,Go被稱為“21世紀的C語言”,當然它也不能忽
視這一塊的需求。現在有大量的雲端運算平台支援Go語言開發,比如由官方維護的GAE,
第三方的AWS等。
m. 簡化的指標
這一條,可能不算優勢,在C/C++中指標運算的功能非常強大,但是帶來的危害也很
突出,所以在Go中指標取消了運算功能,只保留了“引用/解引用”功能 。
n. 簡單的文法,入門快速,對於新成員很容易上手
Go本質上是一個C家族的語言,所以如果有C家族語言的經驗,很容易上手。
4. Go的劣勢4
a. 調度器的不完善
b. 原生庫太少/弱
c. 32bit上的-記憶體流失
關於這一點,Go的貢獻者minux.ma在Golang-China討論群組上有詳細解釋:
“目前Go使用的GC是個保守的GC,換句通俗的話說就是寧可少釋放垃圾,也不
可誤釋放還在用的記憶體;這一點反映在設計上就是會從堆棧、全域變數開始,把所有可能
是指標的uintptr全部當作指標,遍曆,找到所有還能訪問到的記憶體中的對象,然後把剩下
的釋放。
那麼如何判斷一個uintptr可能是指標呢?大家知道Go的記憶體配置是參考的
tcmalloc,並做了一些改動。原先tcmalloc是使用類似頁表的樹形結構儲存已經從操作
系統中獲得的記憶體頁面,Go使用了另外一個辦法。由於Go需要維護每個記憶體字的一些
狀態(比如是否包含指標?是否有finalizer?是否是結構體的開始?還有上面提到的是
否還能訪問到的狀態),綜合在一起是每個字需要4bit資訊;於是Go就先找一片地區
(arena),以不可訪問的許可權從作業系統那裡申請過來(mmap
的prot參數是PROT_NONE),然後根據每一個uintptr對應4位申請一片RW的內
存(bitmap)與前面的arena對應;這樣已知heap上記憶體的地址想獲得對應的bitmap地址
就很簡單了,不需要像tcmalloc似的尋找,直接簡單的右移和加法就能獲得;同時呢,操
4
可以歸納為: 效能/生態/Bug/工具等。——柴樹杉
作系統的demand paging會自動處理還沒有使用到的bitmap。
這裡大家就明白了為啥Go用了那麼大的虛擬記憶體(arena)並且知道為啥經常在
記憶體不足的時候panic說申請到的記憶體不在範圍了(因為記憶體不在bitmap所能映射的範圍
裡,當然多個bitmap是可以解決這個問題的,不過目前還不支援);回到開始的那個問
題,既然arena有個位址範圍,判斷一個uintptr是否可能是指標就是判斷是否在這個範圍
裡了。
這樣的問題就來了。如果我有一個int32,他的內容恰巧在那個範圍裡,更碰巧的
是如果把它當作指標,它恰巧指向一個大的資料結構,那麼GC只能認為那個資料結構還
在使用中。這樣就造成了泄露。這個問題在32位/64位平台上都是存在的。但是在32位上
問題更嚴重些,主要是32位表示的地址空間有768MB是Arena,也就是說一個均勻分布的
uintptr是指標的機率是768/4096,這個遠比64位系統的16GiB/(2^64B)的機率大得多。
Go 1.1不出意外的話會使用記錄每個heap上分配的對象的類型的方式來幾乎完整
地解決這個問題;說幾乎完整是因為,堆棧上的資料還是沒有類型的,所以這裡面還是有
前面說的問題的,只不過會影響會小很多了。”
d. 無強大IDE支援
e. 最大可用記憶體16G限制
因為今年3月28日Go才推出Go 1,所以目前Go還存在不足。a、c、e這幾個缺陷在
2013年初的Go 1.1中會得到解決,而b、d則需要等時間的積累才能完善。
5. Go的爭議
a. 錯誤處理機制
在錯誤處理上,Go不像C++、Java等提供了異常機制,而是採取檢查傳回值的方
案,這是目前Go最大爭議所在。
反對者的理由:
1. 每一步,都得做檢查繁瑣,原始。
2. 返回的error類型可以通過 _ 給忽略掉。
3. 返回的官方error介面只有一個Error()方法來輸出字串,無法用來判斷
複雜的錯誤,比我定義一個方法OpenJsonFile(name string) (jFile JFile,
err Error), 這個方法可能引法的錯誤有兩種1.檔案沒找到,2,檔案解析
錯誤,這時我希望返回的錯誤中帶有兩個資訊,1,錯誤碼,2錯誤的提
示, 錯誤碼,用於程式中的判斷,錯誤提示用於快速瞭解這個錯誤,現
在官方的error介面只有錯誤提示,而沒有錯誤碼。
支援的理由:
1. 在離錯誤發生最近的地方,可以用最佳的方式處理錯誤。
2. 異常在crash後拋出的stack資訊,對於別有用心者,會泄漏關鍵資訊;而
對於終端使用者,他將看不明白究竟發生了什麼情況。而使用錯誤機制能
讓你有機會將stack資訊替換為更有意義的資訊,這樣就能提高安全性和
方便使用性。
3. 異常也可以預設處理。
b. new與變數初始化
在Go中,new與delete和在C++中的含義是不一樣的。delete用以刪除一個
map項,而new用以獲得一個指向某種類型對象的指標,而因為Go支援類似如下的文法
:
type T struct {
…
}
obj := &T{} ⇔ obj = new(T)
同時Go提供另一個關鍵字make用以建立內建的對象,所以&T{}這種文法與
make合起來,就基本可以替代new(但目前new(int)這類基本類型指標的建立,則無法用
&T{}的寫法),因此new看起來有點冗餘了,這與Go的簡單原則有點不一致。
c. For…range不能用於自訂類型
為了遍曆的方便,Go提供了for-range文法,但是這種構造只能用於built-in類型,
如slice、map和chan;而對於非built-in類型,即使官方包container中的相關資料結構也
不行,這降低了for-range的易用性。而目前在不支援泛型的前提下,要實現一個很友好的
for-range看起來還是很不容易的。
d. 不支援動態連結
目前Go只支援靜態連結(但gccgo支援動態連結,Go 1.1可能會支援部分動態鏈
接),這又是另一個引起爭論的地方。爭論雙方的論據就是動態連結/靜態連結的優、缺
點,在此不再贅述。
e. 無泛型
現代的大多數程式設計語言都提供了對泛型的支援,而在Go 1中則沒有提供對泛型
的支援。按官方團隊成員Russ Cox的說法,支援泛型要麼降低編譯效率,要麼降低程式
員效率,要麼降低運行效率。而這三個恰好與Go的快速、高效、易編寫的目標是相衝突
的。同時Go提供的interface{}可以降低對泛型的期望和需求,因此是否需要泛型也成了爭
論的焦點。
f. 首字母大寫表示可見度
Go中只支援包層級的可見度,即無論變數、結構、方法、還是函數等,如果以大
寫字母開頭,則它的可見度是公用的,在其它包中可加以引用;如果以小寫字母開頭,
則其可見度為其所在的包。由於Go支援UTF-8,而對於像中文這種沒有大小寫分別的字
符在需要匯出時,就會出現問題。關於這個問題,支援者的理由是:既然語言本身支援
UTF-8,那麼在變數命名上就應該是一致的;不支援者的理由是,中國人用中文命名,日
本人用日語命名…而且非要用類似中文這類符號編寫的話,可以在中文符號前加一個英文
符號.比如:
var 不可匯出 int = 0
var E可匯出 int = 0
6. 替代方案
a. Cgo
在前邊的劣勢部分有講過,Go缺乏原生包,而現在世面上已經有大量的C實現的
高品質的第三方庫,比如OpenAL、OpenCL、OpenGL等。為瞭解決這個問題,Go引入
一個叫做cgo的命令,通過遵守簡單的約定,就可以將一個C庫wrapper成一個Go包,這
也是為何在短短几年Go擁有了大量高品質包的原因。cgo相關樣本在此不再展示。
b. B/S
因為到目前為止,Go尚未提供GUI相關的支援。同時在雲端運算時代,越來越多的
程式採用了B/S結構,而Go對Web編程提供了最完善的支援,所以如果程式需要提供介面
,無論是本地程式,還是伺服器程式,在當下建議使用B/S架構來替代。