An intro to Go for non-Go developers

Ben Hoyt

給非 Go 開發者的 Go 入門介紹

原文由 Ben Hoyt 發布,訂閱此部落格

摘要:我曾多次為剛接觸 Go 的開發者介紹 Go——本文即是將該演講整理而成的技術文章。內容探討你可能會想使用 Go 的原因,並簡要概述標準函式庫與語言本身。

幾年前,我透過將副業專案 Gifty Weddings 的伺服器從 Python 移植到 Go 來 學習 Go。這是一種學習語言的有趣方式,我大約花了「兩週的公車通勤時間」就掌握了 Go 的基礎並完成程式碼移植。

從那之後,我非常享受使用這個語言,不僅在工作上大量使用,也用在 GoAWKzztgo 等副業專案中。在我目前任職的 Compass.com,Go 的使用量在我任職期間大幅成長——我們 200 多個服務中,約有一半是用 Go 寫的。

本文將介紹我認為 Go 的一些優點,簡要概述標準函式庫,然後深入探討核心語言。不過,如果你只是想感受一下真正的 Go 程式碼長什麼樣子,可以直接跳到 HTTP 伺服器範例

為什麼選擇 Go?

如下方的 Google Trends 圖表所示,Go 在過去幾年變得非常熱門,部分原因是語言本身的簡潔,但或許更重要的是其優異的工具鏈。

2010 年至 2020 年「golang」的 Google Trends 數據

以下是我喜歡用 Go 寫程式的一些原因(或許你也會喜歡):

  • 核心語言小巧而簡潔。 Go 的體感大小與 C 相近,語言規格非常易讀,只有大約 50 頁(相比之下 Java 規格有 770 頁)。這讓它很容易學習,也很容易教給別人。
  • 高品質的標準函式庫,尤其適合伺服器與網路相關工作。更多細節請見下文
  • 一流的並行支援,透過 goroutine(類似 thread,但更輕量)與用 go 關鍵字啟動 goroutine、channel 讓它們彼此溝通,以及由排程器協調這一切的 runtime。
  • 編譯成原生碼,在所有主流平台上都能產生易於部署的執行檔。
  • 不需繁瑣調校的垃圾回收(針對低延遲最佳化)。
  • 靜態型別,但具備型別推論,能避免大量重複宣告型別的冗贅。
  • 精簡出色的文件,並包含大量可直接執行的範例。
  • 優異的工具鏈。 只要輸入 go build 就能建置專案,輸入 go test 就能尋找並執行測試等等。還內建 CPU 與記憶體效能分析、程式碼覆蓋率、跨平台編譯——全都不需要外部工具。
  • 極快的編譯速度。 這個語言從一開始就以快速編譯為設計目標。事實上,共同創作者 Rob Pike 還 開玩笑說:「Go 是在等待大型 [C++] 編譯時構思出來的。」
  • 非常穩定的語言與函式庫,並有嚴格的相容性承諾,保證所有 Go 1 程式都能在後續的 Go 1.x 版本上不經修改直接執行。
  • 備受青睞。 根據 Stack Overflow 2019 年的調查,它是第三大最受渴望的程式語言,因此很容易招募到想使用它的開發者。
  • 在雲端工具中被廣泛使用。 Docker 與 Kubernetes 都是用 Go 寫的,Dropbox、Digital Ocean、Cloudflare 等許多公司也大量採用。

標準函式庫

Go 的標準函式庫豐富、跨平台且文件完善。跟 Python 一樣,Go 也是「內建電池」,讓你不需要任何第三方依賴就能立刻打造實用的伺服器與 CLI 工具。以下是一些亮點(偏向我個人常用的部分):

至於第三方套件,Go 的哲學幾乎與 JavaScript 動輒拉一堆 npm 套件的做法相反。Google Go 團隊的技術負責人 Russ Cox 曾談及我們的軟體依賴問題,而 Go 的共同創作者 Rob Pike 也常說:「少量複製勝過少量依賴。」所以可以說,大多數 Gopher 在使用第三方函式庫時都相當保守。

話雖如此,自從我最初撰寫這個演講以來,Go 團隊已經設計並推出了modules,也就是 Go 團隊對於如何管理與鎖定依賴版本的官方解答。我覺得用起來很順手,而且可以和所有常見的 go 子指令搭配使用。

語言特色

接下來,我們來看看 Go 本身長什麼樣子,一步步走過這個語言的核心。

Hello world

Go 擁有類似 C 的語法、強制使用大括號,且沒有分號(除了在形式語法中)。專案透過 import 與 package 來組織——編譯單元是由包含一個或多個 .go 檔案的目錄所構成。以下是「hello world」的樣子:

package main

import "fmt"

func main() {
    fmt.Println("Hello, world!")
}

有點具爭議性的特色

Go 有一些乍看之下會讓人卻步,但習慣之後其實相當不錯的設計。

首先是程式碼格式化:只要執行 go fmt,它就會把大括號和空白(還有 tab!)放到該放的位置。這是避免風格之爭、讓大家專注於維持一致格式的好方法。

大寫開頭的名稱是公開的(「exported」),小寫開頭的則是 package 私有的。這個規則乍看很奇怪,但其實很容易理解,也省去了像 Java 那樣 public static void 一堆關鍵字的雜訊。根本不需要 public 關鍵字——寫起來像這樣:

package people

type Person struct {
    Name      string // fields Name and Age are exported ("public")
    Age       int
    hairColor color  // hairColor is not exported ("private")
}

func New() *Person { ... } // New is exported

func doThing() { ... } // doThing is not exported

另一個讓一些開發者不太習慣的地方:警告就是錯誤!Go 內建工具的選項很少,在其他編譯器中屬於警告的項目,在 Go 中就是錯誤(換句話說,沒有警告這回事)。

所以就連未使用的區域變數或未使用的 import 也會造成編譯錯誤——這在開發過程中可能有點煩人,但能保持程式碼乾淨,也避免開發者為了該開啟哪些編譯器警告而爭論不休。

更具爭議性的特色

有幾個特色——或者說缺少的特色——更具爭議性,那就是:Go 沒有例外,以及沒有使用者自定的泛型。

Go 沒有傳統意義上的例外。從一開始,其信條就是錯誤即是值,應該像其他值一樣被明確地傳遞、回傳與處理。因此,與其拋出 FileNotFound 例外,你會去檢查一個錯誤值:

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open file f

這確實會讓程式碼變得比較冗長(把 if err != nil 設成快速鍵就對了),但好處是讓每一層的錯誤處理都變得明確。你可以選擇加上脈絡、記錄錯誤、轉成更高層級的錯誤物件,甚至直接忽略——但你必須明確地處理它。

第二個常被批評缺少的功能是使用者自定的泛型。所以你無法定義自己型別安全的 OrderedMap<int>。不過因為內建的 slicemap 型別本身就具備靜態型別的泛型,你在實務上還是能走得很遠,不太會感到痛苦。

另一點值得注意的是,泛型正在開發中:Go 團隊只是想用一種非常 Go 風格、且經過成本考量的方式來加入它,而不是硬加上去的附加功能。目前已有草案提案實驗性實作,甚至還有一篇近期關於此主題的型別理論論文《Featherweight Go》。所以,如果在未來 12 到 18 個月內看到 Go 推出某種形式的泛型,我一點也不會意外。

好了,關於 Go 沒有什麼就談到這裡。來看看它擁有哪些特色(其中不少是獨特的)。

簡潔的型別推論

Go 透過 := 運算子提供簡潔的型別推論,稱為「短變數宣告」。型別推論讓它用起來有點像腳本語言,因為需要(咳)打的字更少了。寫起來像這樣:

package main

import "fmt"

// Output: 3 4 hello 5
func main() {
    var i int = 3
    j := 4          // j is an int
    s := "hello"    // s is a string
    a := add(2, 3)  // a is an int
    fmt.Println(i, j, s, a)
}

func add(x, y int) int {
    return x + y
}

另一方面,Go 不會做任何自動型別轉換,即使是不同寬度或有無號的整數之間也不會。引用常見問題解答中比較 Go 與 C 做法的回答:

C 語言中數值型別自動轉換的便利性,被它所造成的混淆所抵銷。什麼時候一個表達式是無號的?值有多大?會溢位嗎?結果是否可攜、與執行的機器無關?

For 迴圈與 range

Go 只有一個迴圈關鍵字 for,用來寫 while 迴圈、傳統的 C 風格迴圈,以及 range 迴圈(Go 的「for each」)。當用 range 遍歷 slice 或 map 時,Go 會把索引(或 map 的鍵)當作第一個值,值本身當作第二個值。

以下是一些迴圈範例——到目前為止都沒什麼特別的:

// C style "for"
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// Like "while"
for safe.IsLocked() {
    time.Sleep(5 * time.Second)
}

// Loop through elements of array or slice
for index, person := range people {
    fmt.Println(index, person)
}

// If you don't care about the index
for _, person := range people { ... }

// Loop through keys/values of a map
for word, count := range counts { ... }

Slice 非常好用

在 Go 中,slice 是對陣列其中一部分的參照——其內部表示非常簡單:一個資料指標、一個長度與一個容量。Slice 是泛型的,所以你可以有 float64 的 slice,寫作 []float64,或是 Person 結構的 slice,寫作 []Person

你可以用類似 Python 的語法來「切片」slice,例如 slice[:5] 會回傳一個檢視前五個元素的新 slice。新的 slice 仍然參照同一個底層陣列,因此效率和直接操作指標一樣高,但具備記憶體安全——runtime 會防止你超出 slice 範圍或做其他危險操作。

還有一個內建的泛型函式 append(),會將單一元素附加到 slice 並回傳新的 slice。如果底層陣列(容量)不夠大,它會配置一個兩倍大的新陣列並把元素複製過去。

Slice 用起來像這樣:

// Create array and slice pointing into it
nums := []int{3, 4, 5, 6}

// Slice the slice
fmt.Println(nums[1:3]) // Output: [4 5]
fmt.Println(nums[:2]) // Output: [3 4]
fmt.Println(nums[2:]) // Output: [5 6]

// Append: may reallocate underlying array
nums = append(nums, 7, 8)
fmt.Println(nums) // Output: [3 4 5 6 7 8]

Slice 的功能相當精簡,而我(從 Python 過來的)懷念的一項功能是 list comprehension。為什麼僅僅為了從清單中篩選幾個東西,就得寫一個 for 迴圈加一個 if 判斷?我曾問過 Go 團隊的一位成員為何缺少這類功能,他說因為 Go 是「系統程式語言」,他們希望讓你能掌控記憶體配置。舉例來說,你可以用 make() 預先配置 slice 的底層陣列以提升效率。

Map

Go 的 map 是將鍵對應到值的無序雜湊表。跟 slice 一樣,它們是泛型且具型別安全的,所以你可以有(例如)map[string]int,意思是「鍵為 string、值為 int 的 map」。

map 資料型別提供取值、設值、刪除、存在性檢查與迭代。跟 slice 一樣,你也可以用 make() 加上「大小提示」來控制記憶體配置。

關於 map 還有很多可以說,你也可以閱讀其 Pub63 實作方式,不過這裡先用程式碼小試一下:

phrase := "the foo foo bar the foo"

counts := make(map[string]int)
for _, word := range strings.Fields(phrase) {
    counts[word]++
}

fmt.Println(counts)
// Output: map[foo:3 bar:1 the:2]

// map literal
maths := map[string]float64{
    "pi":  3.14,
    "tau": 6.28,
}

指標,但很安全

Go 有指標,但不像 C 和 C++,它們是安全的。你無法指向不存在的記憶體,runtime 也會防止你對 nil 指標解參照。事實上,根本沒有指標運算——如果你想對某個東西做索引,就必須使用安全的 slice,或退而求其次使用底層的 unsafe 套件(我從來沒需要用到它)。

指標使用和 C 一樣的 *& 語法,* 用來取得指標位址上的值,& 則用來取得變數的位址。

語法上一個不錯的地方是,沒有 C 風格的 -> 運算子:要對結構指標解參照並取得欄位,同樣使用 . 即可。以下是一些範例:

p := new(Person) // p is a "pointer to Person"
p.Name = "Joe Bloggs"
p.Age = 42
pers := *p // dereference p back to Person

// More succinct alternatives
p = &Person{"Joe Bloggs", 42}
p = &Person{Name: "Joe Bloggs", Age: 42}
pers = Person{"Joe Bloggs", 42}
p = &pers

Defer

Go 有一個獨特的關鍵字 defer,會在目前函式回傳前(或因 runtime「panic」而結束前)執行指定的函式呼叫。如果多次呼叫 defer,函式會以後呼叫先執行的順序被呼叫。它用於資源清理,用來取代像 C++ 中的 RAII 或 Python 中的 with 陳述式。

就我所知,defer 是 Go 獨有的流程控制陳述式,與其明確處理錯誤的方式非常契合。你可以閱讀更多相關內容,不過這裡有一個常見任務的簡單範例——開啟並關閉檔案:

f, err := os.Open("file")
if err != nil {
    log.Fatal(err)
}
defer f.Close()
// read from f

Goroutine

Goroutine 是 Go 的並行機制:它們像 thread,但輕量得多——你可以輕鬆同時擁有 10 萬甚至 100 萬個存活的 goroutine。Go 的 runtime 會排程這些 goroutine,在需要時(例如 I/O 就緒時)喚醒它們並在作業系統的 thread 上執行。

Go 並行模型一個巧妙之處在於,所有標準函式庫函式都提供簡單、同步的 API——而如果你需要並行,就明確地使用 goroutine。這避免了「有色函式」的問題——也就是某些語言中分成非同步與同步兩套 API 的情況。

要啟動一個 goroutine,只要寫 go backgroundFunc(),Go 的 runtime 就會在新的 goroutine 上啟動 backgroundFunc。以下是一個簡單的處理函式範例,它會記錄使用者註冊,接著在背景寄送電子郵件(這和我在副業專案中使用的真實程式碼類似):

func ProcessSignup(u *User) {
    u.SignedUpAt = time.Now()
    u.Save(db)
    go SendEmail(u.email, "Thanks for signing up!", "signup.html")
}

Channel

啟動 goroutine 不會回傳 promise 或 goroutine ID——如果你想在 goroutine 之間溝通或發出工作完成的信號,就必須明確使用 channel。Channel 是 Go 中主要的 goroutine 間溝通機制,正如 Go 諺語所說:「不要透過共享記憶體來溝通,要透過溝通來共享記憶體。」

Channel 是一個型別安全且執行緒安全的佇列,可以傳遞資料,也能用來同步——從 channel 讀取的 goroutine 會一直等待,直到另一個 goroutine 寫入為止。

以下是將簡單的「陣列加總」任務平行化的範例——這個例子在實務上幾乎肯定不會因使用 goroutine 而受益,但可以讓你了解其概念:

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c) // first half
    go sum(s[len(s)/2:], c) // second half

    // Receive both results from channel
    x, y := <-c, <-c

    fmt.Println(x, y, x+y)
}

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // Send sum back to main
}

Channel 是非常強大的機制,關於它們還有很多可說的(有緩衝與無緩衝、已關閉的 channel 等),不過這部分就留給 Effective Go 吧。

型別與方法

Go 支援使用者自定的型別,型別也可以擁有方法,但沒有類別(有人會說 Go 不是個 classy 的語言)。也有 struct 和介面(下面會討論),但沒有繼承。所有物件導向的好處都是透過組合來實現——不過也有像內嵌這樣的工具提供另一種作法。

在型別上定義的方法會接收一個「receiver」參數,類似 Python 中的 self 或其他語言中的 this。但它們有幾個獨特的性質(例如,receiver 可以是指標或值)。你也可以隨意命名 receiver,不過通常會用該型別的第一個字母來命名。

以下是一個包含兩個欄位的簡單 struct,以及一個 String 方法:

type Person struct {
    Name string
    Age  int
}

func (p *Person) String() string {
    return fmt.Sprintf("%s (%d years)", p.Name, p.Age)
}

// Output: Bob (42 years)
func main() {
    p := &Person{"Bob", 42}
    fmt.Println(p.String()) // but .String() is optional; see below
}

介面

介面和 Java 等其他語言中的介面有點不同,在那些語言中你必須明確寫出 class MyThing implements ThatInterface。在 Go 中,只要你在某個型別上定義了介面的所有方法,該型別就隱含地實作了該介面,你可以在任何需要該介面的地方使用它——完全不需要 implements 關鍵字。

Go 的這種做法常被稱為「靜態鴨子型別」,它是結構化型別的一種形式(TypeScript 是另一個也使用結構化型別的熱門語言)。

介面在標準函式庫和 Go 程式碼中無所不在。兩個最常見的例子是 Stringer 介面,它讓 Printf 等函式能產生值的字串版本,以及 io.Readerio.Writer 介面,它們讓你可以把檔案、HTTP 伺服器、gzip 檔案、字串緩衝區等都當作可讀或可寫的串流來處理。

以下是 StringerWriter 介面的定義——兩者都是非常簡單的單方法介面(小介面在 Go 中非常常見)。你實際上不需要自己定義它們,但這段程式碼展示了其語法:

// Defined in package "fmt"
type Stringer interface {
    String() string
}

// Defined in package "io"
type Writer interface {
    Write(p []byte) (n int, err error)
}

// ...

func main() {
    p := &Person{"Bob", 42}
    fmt.Println(p.String())
    // Equivalent (Person implements Stringer, which Println looks for)
    fmt.Println(p)
}

很難誇大介面在 Go 中的重要性。它們被用來讓演算法更通用、讓函式更容易測試。請在 Effective Go 中閱讀更多相關內容。

HTTP 伺服器範例

在結束之前,這裡有兩個小程式,展示用 Go 撰寫 HTTP 伺服器有多容易。而且這些可不只是玩具——Go 的 net/http 套件已經可用於正式環境(不像許多其他語言內建的網頁伺服器,總得在說明上加註「請勿用於正式環境」)。

以下是一個非常基本的 HTTP 伺服器,只有單一路徑,會回應 user 查詢字串參數。請注意這裡將 http.ResponseWriter 作為 io.Writer 傳給 fmt.Fprintf 的用法:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", handler)
    fmt.Println("listening on port 8080")
    http.ListenAndServe(":8080", nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
    user := r.URL.Query().Get("user")
    if user == "" {
        user = "world"
    }
    fmt.Fprintf(w, "Hello, %s", user)
}

作為一個稍微進階一點的範例,這裡我們用幾行程式碼打造一個帶有自定正則路由的 HTTP 伺服器。

更新:這類自定路由程式碼現在已經不需要了。Go 1.22 為 http.ServeMux 帶來了增強功能,讓你可以直接匹配像 /user/{userId} 這樣的路徑。

// NOTE: use the new http.ServeMux routing in Go 1.22!

package main

import (
    "fmt"
    "net/http"
    "regexp"
)

type route struct {
    pattern *regexp.Regexp
    handler func(w http.ResponseWriter, r *http.Request, matches []string)
}

func home(w http.ResponseWriter, r *http.Request, matches []string) {
    fmt.Fprintf(w, "Home")
}

func user(w http.ResponseWriter, r *http.Request, matches []string) {
    user := matches[1]
    fmt.Fprintf(w, "User ID: %s", user)
}

func main() {
    routes := []route{
        {regexp.MustCompile(`^/$`), home},
        {regexp.MustCompile(`^/user/(\w+)$`), user},
    }
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        for _, route := range routes {
            matches := route.pattern.FindStringSubmatch(r.URL.Path)
            if len(matches) >= 1 {
                route.handler(w, r, matches)
                return
            }
        }
        http.NotFound(w, r)
    })
    fmt.Println("listening on port 8080")
    http.ListenAndServe(":8080", nil)
}

go 工具

最近有人問我最喜歡的開發工具是什麼。一開始我說:「也許是 Sublime Text?」但後來我改口了:我想我(目前)最喜歡的開發工具是 go 指令。不需要 Makefile,它就能快速完成以下所有事情:

go build          # build your project, produce a static executable
go run            # quick way to build and run, for development
go fmt            # format your .go files in the standard way
go test           # find and run your tests
go test -bench=.  # run all your benchmarks too

go mod init                # initialize a "Go modules" project
go get github.com/foo/bar  # fetch and install the "bar" package

還有更多指令——請參閱完整文件

但對我來說最神奇的是,只要設定兩個環境變數 GOOSGOARCH,然後執行 go build,Go 就會為指定的作業系統與架構交叉編譯你的專案。以下是在 macOS 或 Windows 電腦上一行指令產生可部署 Linux 執行檔的範例:

GOOS=linux GOARCH=amd64 go build

是不是很酷?自從 Turbo Pascal 以來,開發還沒這麼輕鬆過……

總結

關於 Go 及其生態系還有很多可以談的,但希望這對來自其他語言的讀者來說是個有幫助的入門。若想入門,我非常推薦官方的 Go Tour。想更深入的話,請閱讀 Effective Go,接著再讀優秀的書籍 The Go Programming Language

哦,還有,趕快用 Go 來寫程式吧!

本文章由 muse-spark-1.2-contributor 進行翻譯

留言