從 Java 的角度初識 Go 語言 | 青訓營筆記

語言: CN / TW / HK

theme: cyanosis

這是我參與「第五屆青訓營」伴學筆記創作活動的第 1 天

前言

本系列文章試圖從一名 Java 開發者(有時也會穿插其他語言)的角度窺探 Go 語言,並以註釋的方式提及 Go 與 Java 的一些區別,方便 Java 開發者迅速入門 Go 語言。

什麼是 Go 語言?

與 Java 相同,Go 是一門高效能,高併發,語法簡單,學習曲線平緩的強型別和靜態型別語言,其擁有豐富的標準庫,完善的工具鏈,支援快速編譯,跨平臺且支援垃圾回收(GC)

與 Java 不同的是,其並不是一門虛擬機器語言,不需要通過中間程式碼表示(例如 JVM Bytecode)和虛擬機器(VM)支援程式碼執行,其以直接將目的碼靜態連結並編譯到目標平臺的形式跨平臺。

雖然 Go 和 C/C++ 類似,人們也經常講 Go 講述為“更好的 C/C++”,但 Go 的競爭領域並不是 C/C++ 所適合的領域,相反,Go 更適合 Java 所適合的 Web 工程等領域。理論上,Go 可以提供比 Java 更好的效能和吞吐量

Go 是一門由 Google 主導開發的語言,目前已經更新至 1.19 版本。

入門 Go 語言

選擇 IDE

要想開發 Go 程式,則需要 Go 開發環境,可以前往 Go 官網 並遵循 安裝文件 安裝對應平臺的 Go 開發環境。這些開發環境包括 Go 編譯器,工具和庫。和 Java 不同的是,不存在類似於 JRE(Java Runtime Environment)一樣的東西,使用者可以直接執行編譯後對應平臺的可執行檔案,無須執行時支援

接下來,我們當然還需要 IDE 來便捷我們的開發。有兩種主流 IDE 可選:VSCodeGoLand。前者是由微軟開發的開原始碼編輯器,後者則是由 Jetbrains 公司開發,基於著名 Java IDE IntelliJ IDEA 構建的功能強大的 IDE。

此兩種 IDE 的區別是,前者更像手動擋,後者則是自動擋。對於進階需求,VSCode 為你帶來的可自定義性會更強;但是對於新手,個人還是推薦使用 GoLand

值得一提的是,GoLand 是一款付費軟體,在購買前,你有機會進行 30 天的使用;或者,如果你是一名在校大學生,你可以向 Jetbrains 申請一份免費的教育許可證,其允許你在學業期間免費使用 Jetbrains 的全套工具鏈;如果你已申請並通過 GitHub Education 學生包,那麼你也可以通過此學生包獲得 Jetbrains 教育許可證。

學習基礎語法

Hello World

go package main ​ import ( "fmt" ) ​ func main(){ fmt.Println("hello world") }

以上是使用 Go 語言輸出 Hello World 的程式碼。可以看出,Go 語言的入口點是 main 函式(注意 Go 語言同時存在函式和方法,前者可以認為是 Java 的靜態方法或者 Rust 的關聯函式,後者可以認為是非靜態方法);除此之外,fmt.Println 類似於 System.out.println,可將一段資料列印在標準輸出流中。

應當注意到,在 Go 語言中,;不是必要的,當一行中只存在一個語句時,則不必顯式的為語句末新增 ;

你可能注意到,Println 中的 P 是大寫的,你可能會主觀的認為這是 Go 語言的命名習慣,就像 C# 開發者那樣。但實際上,在 Go 語言中,函式/方法首字母大寫意味著可被其他包呼叫,否則只能在該包被呼叫,這就類似於 Java 中 publicprotected 訪問修飾符的區別。

變數

與 Java 不同,Go 語言的變數是型別後置的,你可以這樣建立一個型別為 int 的變數:

go var a int = 1

當然,允許在同一行宣告多個變數:

go var b,c int = 1, 2

Go 支援變數型別自動推斷,也就是說,當我們立即為一個變數進行初始化時,其型別是可以省略的:

go var d = true

相反,如果我們未為一個變數初始化,則必須顯式指定變數型別,此時,變數會被以初始值自動初始化:

go var e float64 // got 0

可以通過 := 符號以一種簡單的方式(也是實際上最常用的方式)宣告一個變數:

go f := 3.2 // 等價於 var f = 3.2

最後,可以使用 const 關鍵字代替 var 關鍵字來建立一個常量(不可變變數):

go const h string = "constant"

流程控制

對於流程控制這一部分,其實各語言都大差不差,所以就簡略講講。

選擇語句

Go 支援 ifelse ifelse, switch 進行選擇控制。

go if 7%2 == 0 { fmt.Println("7 is even") } else { fmt.Println("7 is odd") } ​ if num := 9; num < 0 { fmt,Println(num,"is negative") } else if num < 10 {    fmt.Println(num, "has 1 digit") } else {    fmt.Println(num, "has mutiple digits") }

你可能會注意到,其他語言中,if(其他類似)後應當緊跟一個括號,括號內才是表示式,但是在 Go 中,這個括號是可選的,我們也建議不要使用括號。

要注意的是,if 表示式後面的括號是必需的,即使是對於單行語句塊,您也必須新增括號,而不能像其他語言那樣直接省略。

go a := 2 switch a {    case 0, 1:   fmt.Println("zero or one") case 2:   fmt.Println("two")    default:   fmt.Println("other") }

這便是最簡單,也是和其他語言最相似的 switch 語句,對一個 a 變數進行掃描,並根據不同的值輸出不同的字串。

當然,你也可以直接省略 switch 後的變數,來獲得一個更加寬鬆的 switch 語句:

go t := time.Now() switch { case t.Hour() < 12:   fmt.Println("It's before noon")    default:   fmt.Println("It's after noon") }

需要注意的是,與其他語言恰好相反,switch 語句中每個 casebreak 是隱式存在的,也就是說,每個 case 的邏輯會在執行完畢後立刻退出,而不是跳轉到下一個 case

要想跳轉到下一個 case,則應該使用 fallthrough 關鍵字:

go v := 42 switch v { case 100: fmt.Println(100) fallthrough case 42: fmt.Println(42) fallthrough case 1: fmt.Println(1) fallthrough default: fmt.Println("default") } // Output: // 42 // 1 // default

需要注意的是,fallthrough 關鍵字只能存在於 case 的末尾,也就是說,如下做法是錯誤的:

go switch { case f(): if g() { fallthrough // Does not work! } h() default: error() }

但是,你可以使用 goto + 標籤的方式來變相的解決這個問題。但是由於 goto 無論在任何語言的任何地方都應當是不被推薦使用的語法,因此此處不作繼續探討。想要繼續瞭解的可以前往 Go Wiki 檢視。

迴圈語句

在 Go 語言中不區分 forwhile。你可以通過這樣的方式建立一個最普遍的 for 語句:

go for j := 7; j < 9; j++ {    fmt.Println(j) }

或者,將 for 語句中的三段表示式改為一個布林值表示式,即可得到一個類似於其它語言的 while 語句:

go i := 1 for i <= 3 {    fmt.Println(i)    i = i + 1 }

又或者,不為 for 語句填寫任何表示式,你將得到一個無限迴圈,除非使用 break 關鍵字跳出迴圈,否則這個迴圈永遠也不會停止,這看起來有些類似於 Java 的 while(true) {} 或是 Rust 的 loop {}

go for { fmt.Println("loop") }

當然,我們也可以使用 for range 迴圈的方式來遍歷一個數組,切片,集合乃至對映(Map)。

當我們使用 for range 語句遍歷一個數組,切片或是集合的時候,我們將得到該集合元素的索引(idx)和對應值(num):

go nums := []int{2, 3, 4} sum := 0 for idx, num := range nums {    fmt.Println("range to index:", idx)    sum += num } // Will got following output: // range to index: 0 // range to index: 1 // range to index: 2 // sum: 9 fmt.Println("sum:", sum)

或者,當我們遍歷一個 Map 時,將得到鍵(k)和值(v):

go m := make(map[string]int) m["hello"] = 0 m["world"] = 1 // If key and value both needed for k, v := range m {        // Will got following output:        // key: hello, value: 0        // key: world, value: 1 fmt.Printf("key: %v, value: %v\n", k, v) } // Or only need key for k := range m {        // Will got following output:        // key: hello        // key: world fmt.Printf("key: %v", k) }

如果我們不需要迴圈中的某個值,則可以使用 _ 符號代替變數名來遮蔽該變數(其他語言也有類似的做法,但是在 Go 中,此操作是必須的,因為未被使用的變數或匯入會被 Go 編譯器認為是一個 error):

go // When only `v` variable needed for _, v := range m {    //... }

Go 語言沒有 do-while 迴圈或其平替。可以通過這種方式手動編寫一個近似的 do-while 迴圈:

go for { work() if !condition { break } }

很顯然,breakcontinue 都是支援的,其用法和其他語言完全相同,在此直接略過。

陣列,切片和對映

陣列

可以使用以下方式宣告一個指定長度的陣列:

go var a [5]int a[4] = 100

聲明瞭一個名為 a ,大小為 5 的 int 陣列,並將其最後一個元素的值設定為 100

直接使用 := 進行聲明當然也是可行的:

go b := [5]int{1, 2, 3, 4, 5}

聲明瞭一個名為 b,大小為 5,陣列內元素初始值為 1,2,3,4,5int 陣列。

當然,多維陣列也是可以的:

go var twoD [2][3]int

建立了一個名為 twoD 的二維陣列。

值得一提的是,當一個數組未被顯式初始化元素值時,將採用元素預設值填充陣列。

可以這樣使用索引從陣列中取出一個值:

go fmt.Println(b[4]) // 5

當我們試圖訪問一個超出陣列長度的索引,編譯器將會拒絕為我們編譯,並返回一個編譯錯誤:

go fmt.Println(b[5]) // error: invalid argument: index 5 out of bounds [0:5]

切片

陣列是定長的,因此在實際業務中使用的並不是很多,因此,更多情況下我們會使用切片代替陣列。

就像它的名字一樣,切片(slice)某個陣列或集合的一部分,切片是可變容量的,其工作原理類似於 Java 的 ArrayList,當切片容量不足時,便會自動擴容然後返回一個新的切片給我們。

可以使用如下方式宣告一個切片:

go s := make([]string, 3)

聲明瞭一個長度為 3,容量為 3 的 string 切片。

切片的型別標識看起來和陣列很像,但是實際上他們是不同的東西。切片並不需要在 [] 內指定一個長度,而陣列是需要的。

需要注意的是,切片的 長度(length)容量(capacity) 是兩個完全不同的東西,前者才是切片實際的長度,後者則是一個閾值,當切片長度達到該閾值時才會對切片進行擴容。

當然,也可以直接指定一個切片的長度和容量:

go s2 := make([]string, 0, 10)

建立了一個長度為 0 ,容量為 10 的 string 切片。

可以直接像陣列一樣為切片元素賦值:

go s[0] = "a" s[1] = "b" s[2] = "c"

也可以使用 append 方法為陣列新增新的元素:

go s = append(s, "d") s = append(s, "e", "f")

並返回更新後的切片。

可以使用 copy 方法將一個切片內的元素複製到另一個切片中:

go c := make([]string, len(s)) copy(c, s)

使用 len 方法獲得一個數組,切片的長度。

可以使用和陣列相同的方式從切片中獲得一個值:

go fmt.Println(s[5])

但是不同的是,當我們試圖越界訪問一個切片時,編譯器並不會給我們一個錯誤(因為切片的長度是不確定的),然而,這會得到一個 panic,並使程式直接結束執行:

go fmt.Println(s[6]) // panic: runtime error: index out of range [6] with length 6

可以使用以下切片操作從陣列和切片中擷取元素:

go fmt.Println(s[2:5]) // [c d e]

將返回一個新的切片,該切片的元素是 s 切片的第 2 個元素到第 4 個值(左閉右開)。

注意,在這種切片操作中,: 左邊和右邊的數字均可被省略,也就是說:

go fmt.Println(s[:5]) // [a b c d e]

將返回切片第 0 個元素到第 4 個元素的切片。

go fmt.Println(s[2:]) // [c d e f]

將返回切片第 2 個元素到最後一個元素的切片。

go fmt.Println(s[:]) // [a b c d e f]

將返回切片的整個切片(副本)。

對映

對映(Map)是一個無序 1 對 1 鍵值對。可以使用如下方式宣告一個 Map:

go m := make(map[string]int)

聲明瞭一個鍵(key)為 string 型別,值(value)為 int 型別的 Map。

當然,也可以提前初始化 Map 內的值:

go m2 := map[string]int{"one" : 1, "two" : 2}

可以使用類似於陣列和切片的賦值語法為 Map 賦值,只不過,將索引換成了 key,目標值換為了 value

go m["one"] = 1 m["two"] = 2

使用 len 方法獲得一個 Map 內包含鍵值對的長度。

go fmt.Println(len(m)) // 2

可以使用和陣列和切片類似的方式從切片中獲得一個值,只不過,將索引換成了 key

go fmt.Println(m["one"]) // 1

但實際上,這種寫法是非常不好的,因為,當我們試圖訪問一個不存在的 key,那麼 Map 會給我們返回一個初始值:

go fmt.Println(m["unknown"]) // 0, wtf?

因此,我們需要接收第二個值 —— 一個布林值,來判斷該鍵是否在 Map 中存在:

go r, ok := m["unknown"] fmt.Println(r, ok) // 0 false

最後,使用 delete 函式從一個 Map 中移除指定的鍵:

go delete(m, "one")

函式,指標,結構體與結構體方法

函式

可以通過這種語法宣告一個帶參有返回值函式:

go func add(a int, b int) int {    return a + b }

聲明瞭一個名為 add,擁有兩個型別為 int,名稱分別為 ab 的形參,返回值為 int 的函式。

如果不需要返回值,則可以直接省略,就像 main 函式那樣:

go func main() {    // ... }

指標

Go 語言支援指標操作,但預設情況下(不考慮 unsafe),指標必須指向一個合法物件,而不是一個可能不存在的記憶體地址,你也不能使用指標進行地址運算(因此,與其說指標,不如稱之為引用更加合適):

go func add2(n int) { n += 2 } ​ func add2ptr(n *int) {    *n += 2 } ​ func main() {    n := 5    add2(n) // not working    fmt.Println(n) // 5    add2ptr(&n)    fmt.Println(n) // 7 }

使用 *type 宣告一個指標變數,使用 * 對一個變數進行解引用,使用 & 獲取一個變數的指標(引用)。

支援指標的 Go 也側面印證了,預設情況下,Go 的方法傳參均為傳值,而不是傳引用,如果不傳入指標而直接傳入一個值的話,則方法實參會被複制一份再傳入。

結構體

Go 不是一門面向物件(OO)的語言,因此,Go 並沒有類(Class)或是其他類似概念,取而代之的,是同類語言中均擁有的結構體(Struct)

使用如下方式來宣告一個結構體:

go type user struct { name string    password string }

然後,使用如下方式初始化一個結構體:

go a := user{name: "wang", password: "1024"} fmt.Printf("%+v\n", a) // {name:wang password:1024}

如果未對一個結構體進行初始化,則結構體成員將採用預設值:

go var b user fmt.Printf("%+v\n", b) // {name: password:}

可以使用 . 來訪問結構體成員

go fmt.Println(a.name) // wang fmt.Println(a.password) // 1024

結構體方法

如果將函式類比為 Java 中的靜態方法,那麼結構體方法則可以類比為 Java 中的非靜態方法(類成員函式)。

使用如下方式宣告一個用於檢查使用者密碼是否匹配的方法:

go func (u user) checkPassword(password string) bool { return u.password == password }

使用如下方式宣告一個用於重置使用者密碼為指定值的方法(注意此處結構體是一個指標,只有這樣才可以避免值拷貝,修改原結構體):

go func (u *user) resetPassword(password string) { u.password = password }

然後即可直接呼叫:

go a.resetPassword("2048") fmt.Println(a.checkPassword("2048")) // true

Go 錯誤處理

與 Java 不同,Go 語言並不支援 throwtry-catch 這樣的操作,與 Rust 比較類似,Go 通過跟隨返回值返回返回錯誤物件來代表方法執行中是否出現了錯誤 —— 如果返回的值錯誤物件為 nil,則代表沒有發生錯誤,函式正常執行。

但是,由於 Go 並沒有 Rust 那麼強大的模式識別,因此,其錯誤處理並不能像 Rust 那樣便捷有效,並時常飽受詬病(經典的if err != nil

以下方法試圖從一個 user 切片中查詢是否存在指定名稱的 user,如果存在,則返回其指標,否則,返回一個錯誤。

要實現此功能,需要匯入 errors 包:

go import ( "errors" )

宣告函式:

go func findUser(users []user, name string) (v *user, err error){    for _,u := range users {        if u.name == name {            return &u, nil       }   }    return nil, errors.New("not found") }

findUser 函式返回了多個值,這樣,我們便可以建立兩個變數直接接收它們(類似於 ES6 或 Kotlin 的 解構賦值 語法)。

呼叫函式:

go func main(){    u, err := findUser([]user{{"wang", "1024"}}, "wang")    if err != nil {        fmt.Println(err)        return   }    fmt.Println(u.name) // wang        if u, err := findUser([]user{{"wang", 1024}}, "li"); err != nil {        fmt.Println(err) // not found        return   } else {        fmt.Println(u.name)   } }

當函式執行完畢後,我們便可通過判斷 err 是否為 nil 來得知錯誤是否發生,然後進行下一步操作。

Go 標準庫

與 Java 相同,Go 擁有一個非常強大的標準庫,包含了字串操作,字串格式化,日期與時間處理,JSON 解析,數字解析,程序資訊等功能,此處略過不提。

值得一提的是,對於日期和時間處理,Go 使用 2006-01-02 15:04:05 來表達日期和時間模板,而不是傳統的 yyyy-MM-dd HH:mm:ss

Go 語言實戰

在這一部分,位元組內部課:Go 語言上手 - 基礎語法通過三個簡單的小專案帶領學生學習了 Go 語言語法及其標準庫使用:一個經典的猜數字遊戲,給定一個隨機數,讓使用者猜測這個數並給出與這個數相比是大了還是小了;一個線上詞典,通過 HTTP 爬蟲爬取其他線上詞典網站的結果並返回;一個 SOCKS5 代理,簡單的實現了 SOCKS 5 的握手流程,並給予回答。

引用

該文章部分內容來自於以下課程或網頁:

分發

This work is licensed under CC BY-SA 4.0