從 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