開放出版:為什麼我們需要一門新語言?許式偉《Go語言程式設計》序

暘谷發表於2012-03-23

程式語言的選擇已經非常之多。偏系統級的語言有 C,C++,Java,C#,Delphi,Objective-C等;偏快速業務開發的語言有PHP,Python,Perl,Ruby,JavaScript,Lua等;面向特定領域的,有 R,Matlab等。那麼,為什麼我們需要 Go這樣一門新語言呢?

在2000年前的單機時代,C語言是程式設計之王。隨著機器效能的提升,軟體規模與複雜度的提高,Java逐步取代了C的位置。

儘管看起來Java已經深獲人心,但Java程式設計的體驗並未盡如人意。歷年來的程式語言排行榜顯示,Java語言的市場份額在逐步下跌,並趨近於C語言的水平,顯示了這門語言的後勁不足。如下圖所示:

Go語言官方自稱Go是一門系統級語言。之所以開發Go 語言,是因為“近10年左右開發程式之難讓我們有點沮喪”。Go語言這一定位暗示了Go希望取代C和Java地位,成為最流行的語言。

Go希望成為網際網路時代的C語言。多數的系統級語言包括Java、C#,其根本的程式設計哲學來源於C++,將C++的物件導向進一步發揚光大。但是Go語言的作者們很清楚,C++ 真的沒啥好學的,他們要學的是C語言。C語言經久不衰的根源是它足夠簡單。Go語言也要足夠得簡單。

那麼,網際網路時代的C語言需要具備哪些特性呢?

首先,並行與分散式支援。多核化和叢集化是網際網路時代的典型特徵。作為一個網際網路時代的C語言,必須要讓這門語言操作多核與計算機叢集如單機一樣容易。

其次,軟體工程支援。工程規模不斷擴大是產業發展的必然趨勢。單機時代語言可以只關心問題本身的解決。網際網路時代的C語言還需要考慮軟體品質保障、團隊協作相關的話題。

最後,程式設計哲學的重塑。計算機軟體發展經歷了數十年的發展,形成了物件導向等多種學術流派 。 什麼才是最佳的程式設計實踐?作為網際網路時代的C語言,需要回答這個問題。

接下來我們來聊聊Go語言是如何來同時做到這些特性的。

併發與分散式

多核化和叢集化是網際網路時代的典型特徵。語言需要哪些特性來應對多核化和叢集化呢?

第一個話題是併發執行的“執行體”。執行體是個抽象的概念,在作業系統層面有多個概念與之對應,比如作業系統自己掌管的程式(Process)、程式內的執行緒(Thread)、以及程式內的協程(Coroutine),它也叫輕量級執行緒 。 多數語言在語法層面並不直接支援Coroutine,而通過庫的方式支援的Coroutine功能也並不完整,比如僅僅提供Coroutine的建立、銷燬與切換等能力。如果在這樣的Coroutine 中呼叫一個同步IO操作,比如網路通訊、本地檔案讀寫,都會阻塞其他的併發執行Coroutine 。從而無法真正達到Coroutine本身期望達到的目標。

Go 語言在語言級別支援 Coroutine, 叫goroutine。 Go語言標準庫提供的所有系統呼叫(Syscall)操作,當然也就包括了所有同步IO操作,都會出讓CPU給其他goroutine。這讓事情變得非常簡單。 我們以Java和Go做對比,近距離觀摩下兩者對“執行體”的支援。

為了簡化,在樣例中用的是Java標準庫中的Thread,而不是Coroutine。如下:

public class MyThread implements Runnable {

    String arg;

    public MyThread(String a) {
        arg = a;
    }

public void run() {
    ...
}

public static void main(String[] args) {
    new Thread(new MyThread("test")).start();
    ...
}
}

相同功能的程式碼,在Go語言中是這樣的:

func run(arg string) {
    ...
}

func main() {
    go run("test")
...
}

我相信你已經明白為什麼Go語言會叫Go語言了:Go語言獻給這個時代最好的禮物,就是加了go這個關鍵字。當然也有人會說叫Go語言是因為它是Google出的。好吧,這也是個不錯的閒聊主題。

第二個話題是“執行體間的通訊”。執行體間的通訊包含幾個方式:
• 執行體之間的互斥與同步
• 執行體之間的訊息傳遞

先說“執行體之間的互斥與同步”。當執行體之間存在共享資源(一般是共享記憶體)時,為保證記憶體訪問邏輯的確定性,需要對訪問該共享資源的相關執行體進行互斥。當多個執行體之間的邏輯存在時序上的依賴時,也往往需要對執行體之間進行同步。互斥與同步是執行體間最基礎的互動方式。

多數語言在庫層面提供了Thread間的互斥與同步支援。那麼Coroutine之間的互斥與同步呢?呃,不好意思,沒有。事實上多數語言標準庫中連Coroutine都是看不到的。

再說“執行體之間的訊息傳遞”。在併發程式設計模型的選擇上,有兩個流派,一個是共享記憶體模型,一個是訊息傳遞模型。多數傳統語言選擇了前者,少數語言選擇後者,其中最典型的代表是Erlang語言。業界有專門的術語叫“Erlang風格的併發模型”,其主體思想是兩點:一是“輕量級的程式(Erlang中的程式這個術語就是我們上文說的執行體)”,二是“訊息乃程式間通訊的唯一方式”。當執行體之間需要相互傳遞訊息時,通常需要基於一個訊息佇列(Message Queue),或者程式郵箱(Process Mail Box)這樣的設施進行通訊。

Go語言推薦採用“Erlang風格的併發模型”的程式設計正規化,儘管傳統的“共享記憶體模型”仍然被保留,允許適度地使用。在Go中內建了訊息佇列(Message Queue)的支援,只不過它叫channel。兩個goroutine之間可以通過channel來進行互動。

軟體工程

單機時代語言可以只關心問題本身的解決,但是隨著工程規模不斷擴大,軟體複雜度不斷增加,軟體工程也是語言設計層面要考慮的重要課題。多數軟體需要一個團隊共同去完成。在團隊協作的過程中,人們需要建立統一的互動語言來降低溝通的成本。規範化體現在多個層面,如:

• 程式碼風格的規範。
• 錯誤處理規範。
• 包管理。
• 契約的規範(介面)。 • 單元測試規範。
• 功能開發的流程規範。
• ... Go語言很可能是第一個將程式碼風格強制統一的語言。例如Go語言要求public的變數必須大寫開頭,private變數則小寫開頭。這種做法不僅免除了 public、private關鍵字,更重要的是統一了命名風格。 另外,Go語言對 { } 應該怎麼寫進行了強制,比如以下風格是正確的:

if expression {
    ...
}

但下面這個寫法就是錯誤的:

if expression 
{
    ...
}    

而C/Java語言中則對花括號的位置沒有任何要求。這個見仁見智。但很顯然的是,所有的Go程式碼的花括號位置肯定是非常統一了。

最有意思的其實還是 Go 語言首創的錯誤處理規範:

f, err := os.Open(filename)
if err != nil {
    log.Println("Open file failed:", err)
    return
   }
    defer f.Close() 
  ... // 操作f這個開啟的檔案

這裡有兩個關鍵點。其一是defer關鍵字。defer語句的含義是不管程式是否出現異常,均在函式退出時自動執行相關程式碼。在上面的例子,正是有了這個defer才使得無論後續是否會出現異常都可以確保檔案被正確關閉。其二是Go語言的函式允許返回多個值。所有可能出錯的函式,建議最後一個返回值為error型別。error型別只是一個系統內建的interface。如下:

type error interface {
    Error() string
}

有了error型別,程式出現錯誤的邏輯就看起來相當統一。

在Java中你可能這樣寫程式碼來保證資源的正確釋放:

Connection conn = ...;
try {
    Statement stmt = ...;
    try {
        ResultSet rset = ...;
        try {
            ... // 正常程式碼
        }
        finally {
            rset.close();
        }
    }
    finally {
        stmt.close();
    }
}
finally {
    conn.close();
}

完成同樣的功能,相應的Go程式碼只需要寫成這樣:

conn := ...  
defer conn.Close()

    stmt := ...  
    defer stmt.Close()
rset := ...  
defer rset.Close()
... // 正常程式碼

對比兩段程式碼,Go語言錯誤處理的優勢顯而易見。當然其實Go帶給我們的驚喜還有很多。後續有機會我們可以就某個更具體的話題詳細展開來談一談。

程式設計哲學

計算機軟體發展經歷了數十年的發展,形成了多種學術流派,有程式導向程式設計、物件導向程式設計,函數語言程式設計、面向訊息程式設計等等。這些思想究竟孰優孰劣,眾說紛紜。

C語言是純過程式的,這和它產生的歷史背景有關。Java語言則是激進的物件導向主義推崇者,典型表現是它不能容忍體系裡中存在孤立的函式。而Go語言沒有去否認任何一方,而是用批判吸收的眼光,將所有程式設計思想做了一次梳理,融合眾家之長,但時刻警惕特性複雜化,極力維持語言特性的簡潔,力求小而精。

從程式設計正規化角度來說,Go語言是變革派,而不是改良派。

對於C++、Java、C# 等語言為代表的物件導向思想體系,Go語言總體來說持保守態度,有限吸收。 首先,Go語言反對函式和操作符過載(overload)。而C++、Java、C# 都允許出現同名函式或操作符,只要他們引數列表不同。雖然過載解決了一小部分OOP的問題,但同樣給這些語言帶來了極大的負擔。Go語言相比有著完全不同的設計哲學。既然函式過載帶來了負擔,並且這個特性並不對解決任何問題有顯著的價值,那麼Go就不提供它。

其次,Go語言支援類、類成員方法、類的組合。但反對繼承,反對虛擬函式(virtual function)和虛擬函式過載(override)。確切地說,Go也提供了繼承,但是採用了組合的文法:

type Foo struct {
    Base
    ...
}

func (foo *Foo) Bar() {
    ...
}

再次,Go語言也放棄了建構函式(constructor)和解構函式(destructor)。由於Go語言中沒有虛擬函式,也就沒有 vptr,支援建構函式、解構函式就沒有太大的價值。本著“如果一個特性並不對解決任何問題有顯著的價值,那麼Go就不提供它”的原則,建構函式和解構函式就這樣被Go語言的作者們幹掉了。 在放棄了大量的OOP特性後,Go語言送上了一份非常棒的禮物:介面(interface)。你可能會說,除了C這麼原始的語言外,還有什麼語言沒有介面呢?是的,多數語言都有提供介面(interface)。但它們的介面都不同於Go語言的介面。

Go語言中的介面與其他語言最大的一點區別是它的非侵入性。在C++/Java/C# 中,為了實現一個介面,你需要從該介面繼承:

class Foo implements IFoo { // Java 文法
    ...
}

class Foo : public IFoo { // C++ 文法

    ...
}

IFoo* foo = new Foo;

在Go語言中,實現類的時候無需從介面派生:

type Foo struct { // Go 文法
    ...
}

 var foo IFoo = new(Foo)

只要 Foo 實現了介面 IFoo 要求的所有方法,就實現了該介面,可以進行賦值。

Go語言的非侵入式介面,看似只是做了很小的文法調整,但實則影響深遠。

其一,Go語言的標準庫,再也不需要繪製類庫的繼承樹圖。你只需要知道這個類實現了哪些方法,每個方法是啥含義就足夠了。

其二,不用再糾結介面需要拆得多細才合理。比如我們實現了File類,它有這些方法:

Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
Seek(off int64, whence int) (pos int64, err error)
Close() error

那麼,到底是應該定義一個 IFile 介面,還是應該定義一系列的 IReader, IWriter, ISeeker, ICloser 介面,然後讓 File 從他們派生好呢?事實上,脫離了實際的使用者場景,討論這兩個設計哪個更好並無意義。問題在於,實現File類的時候,我怎麼知道外部會如何用它呢?

其三,不用為了實現一個介面而import一個包,目的僅僅是引用其中的某個interface的定義。在Go語言中,只要兩個介面擁有相同的方法列表,那麼他們就是等同的,可以相互賦值。例如:

package one

    type ReadWriter interface {
        Read(buf []byte) (n int, err error)
        Write(buf []byte) (n int, err error)
    }

package two

  type IStream interface {
  Write(buf []byte) (n int, err error)
  Read(buf []byte) (n int, err error)
  }

這裡我們定義了兩個介面,一個叫 one.ReadWriter,一個叫 two.IStream。兩者都定義了Read、Write方法,只是定義的次序相反。one.ReadWriter先定義了Read再定義Write,而two.IStream反之。

在Go語言中,這兩個介面實際上並無區別。因為:
• 任何實現了 one.ReadWriter 介面的類,均實現了 two.IStream。
• 任何 one.ReadWriter 介面物件可賦值給 two.IStream,反之亦然。
• 在任何地方使用one.ReadWriter介面,和使用two.IStream並無差異。

所以在Go語言中,為了引用另一個package中的interface而import它,這是不被推薦的。因為多引用一個外部的package,就意味著更多的耦合。

除了物件導向程式設計(OOP)外,近年出現了一些小眾的程式設計哲學。Go語言對這些思想亦有所吸收。例如,Go語言接受了函數語言程式設計的一些想法,支援匿名函式與閉包。再如,Go語言接受了Erlang語言為代表的面向訊息程式設計思想,支援goroutine和channel,並推薦使用訊息而不是共享記憶體來進行併發程式設計。總體來說,Go語言是一個非常現代化的語言,精小但卻非常強大。

總結

在十餘年的技術生涯中,我接觸過,使用過,喜愛過不同的語言,但總體而言,Go語言的出現是最讓我興奮的事情。我個人對未來10年程式語言排行榜的趨勢判斷如下:

• Java語言的份額繼續下滑,並最終被C和Go語言超越。
• C語言將長居程式設計榜第二的位置,並有望在Go取代Java前重獲得語言榜第一的寶座。
• Go語言最終會取代Java,居於程式設計榜之首。

本書將盡可能的展現出Go語言的迷人魅力,讓更多人能夠理解這門語言,熱愛這門語言, 讓這門優秀的語言能夠落到實處,把程式設計師從以往繁複的語言細節中解放出來,集中精力於開發更加優秀的系統軟體。

相關文章