給非 Go 開發者的 Go 入門介紹
摘要:我曾多次為剛接觸 Go 的開發者介紹 Go——本文即是將該演講整理而成的技術文章。內容探討你可能會想使用 Go 的原因,並簡要概述標準函式庫與語言本身。
幾年前,我透過將副業專案 Gifty Weddings 的伺服器從 Python 移植到 Go 來 學習 Go。這是一種學習語言的有趣方式,我大約花了「兩週的公車通勤時間」就掌握了 Go 的基礎並完成程式碼移植。
從那之後,我非常享受使用這個語言,不僅在工作上大量使用,也用在 GoAWK 和 zztgo 等副業專案中。在我目前任職的 Compass.com,Go 的使用量在我任職期間大幅成長——我們 200 多個服務中,約有一半是用 Go 寫的。
本文將介紹我認為 Go 的一些優點,簡要概述標準函式庫,然後深入探討核心語言。不過,如果你只是想感受一下真正的 Go 程式碼長什麼樣子,可以直接跳到 HTTP 伺服器範例。
為什麼選擇 Go?
如下方的 Google Trends 圖表所示,Go 在過去幾年變得非常熱門,部分原因是語言本身的簡潔,但或許更重要的是其優異的工具鏈。

以下是我喜歡用 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 工具。以下是一些亮點(偏向我個人常用的部分):
- 輸入/輸出:OS 呼叫、檔案與目錄、緩衝 I/O。
- HTTP:可用於正式環境的客戶端與伺服器、TLS、HTTP/2、簡易路由、URL 與 Cookie 解析。
- 字串:各種基本操作、raw bytes 處理、unicode 轉換。
- 編碼:JSON、XML、CSV、base64、hex、binary 等。
- 樣板:簡潔卻強大的文字樣板與自動跳脫的 HTML 樣板。
- 時間:API 簡潔但設計周到的日期與時間函式。
- 正則表達式:非回溯的 regexp 函式庫。
- 排序:通用的集合排序函式。
- 資料庫:
database/sql介面,具體實作則交由第三方函式庫提供。 - 加密函式庫:安全且快速的 AES、區塊加密、密碼雜湊等實作。
- 影像:讀寫 JPEG、PNG 與 GIF,執行基本的圖像合成。
- 大數運算:任意精度的 int 與 float。
- 封存與壓縮:tar、zip、gzip、bzip2 等。
- 簡易的命令列 flag 函式庫。
- Go 原始碼工具:parser、AST、程式碼格式化。
- 反射:強大的執行時期反射支援。
至於第三方套件,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>。不過因為內建的 slice 與 map 型別本身就具備靜態型別的泛型,你在實務上還是能走得很遠,不太會感到痛苦。
另一點值得注意的是,泛型正在開發中: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 = &persDefer
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 fGoroutine
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.Reader 與 io.Writer 介面,它們讓你可以把檔案、HTTP 伺服器、gzip 檔案、字串緩衝區等都當作可讀或可寫的串流來處理。
以下是 Stringer 與 Writer 介面的定義——兩者都是非常簡單的單方法介面(小介面在 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還有更多指令——請參閱完整文件。
但對我來說最神奇的是,只要設定兩個環境變數 GOOS 與 GOARCH,然後執行 go build,Go 就會為指定的作業系統與架構交叉編譯你的專案。以下是在 macOS 或 Windows 電腦上一行指令產生可部署 Linux 執行檔的範例:
GOOS=linux GOARCH=amd64 go build是不是很酷?自從 Turbo Pascal 以來,開發還沒這麼輕鬆過……
總結
關於 Go 及其生態系還有很多可以談的,但希望這對來自其他語言的讀者來說是個有幫助的入門。若想入門,我非常推薦官方的 Go Tour。想更深入的話,請閱讀 Effective Go,接著再讀優秀的書籍 The Go Programming Language。
哦,還有,趕快用 Go 來寫程式吧!
隨機一篇部落格
留言
登入後參與討論