go語言的初體驗

小二十七 發表於 2021-03-28
Go

分享最近學習 Go 語言的心得和體會,適合有程式設計基礎的人,因為這裡只做經驗性的總結概述,不做基礎教學的入門知識講解,如果想要學習程式語言的基礎知識,請出門左轉進入官方文件,檢視基礎教學文件。

Go 概覽

Go 的特徵

我經常說要學一樣東西,首先要搞清楚它為什麼會出現? 解決了什麼問題 ?

只要瞭解這些底層的根本問題,你才會有持續的動力深入學習,而不是盲目跟風和三分鐘熱度。

Go 語言是 google 在 2009年11月釋出的專案,在程式語言裡面算是非常年輕的小夥子。

至於 Go 語言的誕生和歷史,大家可以看看這篇文章:三分鐘瞭解 Go 語言的前世今生

我個人認為 Go 的誕生是有時代的必要性,因為她主要解決主要是解決了:

  • 動態語言的效能和弱型別問題
  • 靜態語言的開發效率和複雜度,還有併發問題

我們都知道 Google 是世界上資料量最大的公司,Go 語言的輕量級執行緒設計,也幫助 Google 降低運算和併發計算的成本,這也是 Go 語言能誕生的一個重要目的。

在資料爆炸的今天,Go 語言重新平衡了開發效率和執行效能,所以 Go 會在未來十年,都是最重要的程式語言

關於 go 的定位,大家看下圖可能會更清晰:

Go的定位
Go 的設計理念

剛接觸這門語言的時候,能感受到它的設計者是經過認真思考的,從不同語言遷移過來的開發者,可以從 Go 身上看到很多其他語言的影子,因為設計者借鑑了許多其他語言的設計,但是它也非常的剋制,不是完全照搬,而且非常精準的把優秀並且使用的設計融入到 Go 當中,將很多不實用且複雜的設計直接剔除。

雖然 Go 出自名門,你從 Go 身上看不到什麼學院派的影子,沒有多餘的設計,沒有複雜的概念,處處可見的 簡單,實用 的設計理念,因為它的創造者的理念是:

只有通過設計上的簡單性,系統才能在增長的過程中保持穩定和自洽

Go 另外還有一個特點區別於其他語言的就是,Go 語言為了追求程式碼可讀性,可能是第一個將程式碼風格在編譯器層面做出強制要求的語言。例如:

  • 首字母大寫代表 public,代表匯出型別,外部可訪問
  • 首字母小寫程式碼 private,代表非匯出型別,僅內部可訪問
  • 還有對 {} 換行的限制,
  • 編譯層面就不允許出現無用變數
  • 等等&……

Go 對於動態語言:

寫過動態語言類似 Ruby,Python 的開發者,最頭痛的應該就是型別問題,因為不確定型別,導致很多問題在編譯期無法被發現,直接 runtime 的時候才能暴露出現,處理成本極高。Go 語言提供簡單夠用的型別系統,對於動態語言開發者不會有太大的手上成本,也幫助了動態語言的開發者解決大多數型別問題。

Go 對於靜態語言:

Go 語言並沒有去照搬 C++ 和 Java 那套超級複雜的型別系統,Go 放棄了大量的 OOP 特性,不支援繼承和過載,對於 Java/C++ 等主流 OOP 程式語言,Go 可能也是一個徹頭徹尾的異類,但是不要懷疑 Go 也是一門物件導向的程式語言,只是他在用自己理解方法,一種不同尋常的方式來解釋物件導向,它的特徵如下:

  • 它沒有繼承、甚至沒有類
  • 沒有繼承,通過組合來完成繼承
  • 型別和介面都是非侵入式(無需宣告介面的實現)

至於 Go 其他語言的區別,可以單獨列出一篇文章,這裡暫時不深入討論了……

Go 語法簡介

短賦值語句、遞增語句

符合 Java 程式設計師的習慣、Go 支援短賦值語句、遞增語句,下面簡單看一個示例即可:

x := 0
x += 5
fmt.Print(x)			// x = 5

x++
fmt.Print(x)			// x = 6

x--
fmt.Print(x)			// x = 5

Go 雖然是靜態編譯型語言,但是擁有很多動態語言才有的語法特性,比如批量賦值、變數交換,示例:

// 批量賦值
x, y, z := 1, 2, 3
// 交換變數
x, y = y ,x

Java 程式設計師應該很羨慕這種交換變數的寫法,因為在 Java 中想要交換變數必須要宣告一個很彆扭的 tmp 臨時變數才能實現變數的交換

Go 只支援 for 一種迴圈語句(減少心智負擔)

// for 格式
for init; condition; post{
  // 迴圈邏輯
}

for i := range ary {
	// for range 用於遍歷 slice 的快捷方法
}

初體驗

Go 命名規範

不同於其他語言,Go 中的函式、變數、常量、型別和包都遵循一個簡單和統一的原則:

  • 名稱開始是一個 Unicode 字元即可,區分大小寫
  • 例如:HeapSort 和 heapSort 是不同的名稱

還有就是上面說到的,通過大小寫的命名規範,直接把 privatepublic 許可權宣告的關鍵字這種並無很大作用的關鍵字給移除了,這種在不改變功能的前提下做減法,可謂是刀法快準狠

另外在 Go 官方的 Demo 和文件來看, Go 是比較推崇簡短的命名原則,有以下兩點:

  • 如果作用域越長,那麼命名就應該越清晰(也就是越長)
  • Go 是推崇駝峰命名法的,而不是 C 語言裡面的下劃線分割法
關鍵字

我們先看一組資料對比:

  • C++ 關鍵字數量 62 個
  • Java 關鍵字數量 53 個
  • Go 關鍵字數量 25 個

從關鍵字的數量上,也可以看得出 Go 語言的設計者的剋制,對簡單設計哲學的踐行。也降低學習成本和學習 Go 語言的心智負擔,是一門對於初學者非常友好的語言

變數表示式

總結一下 Go 其實只有 4種可宣告的型別,主要如下:

  • 變數:通過 var 或者 := 宣告
  • 常量:通過關鍵字 const 宣告
  • 型別:通過關鍵字 type 宣告
  • 函式:通過關鍵字 func 宣告

變數的標準宣告格式是:

var name type = expression
// 上面宣告方式很清晰,但是很囉嗦,平時很少用,通常使用短變數的宣告格式
// 如下:
name := expression		// 短變數可以通過 expression 自動推導 name 的型別

短變數宣告格式短小,靈活,所以是平時很常用的宣告方式。

另外在 Go 語言中,變數,常量都可以通過以下方式進行批量宣告:

var (
  ...
)

const (
	...
)

如果變數沒有初始化表示式,例如 var name int,那麼會觸發 Go 語言的零值機制(Default Value),具體每種型別對應的零值,大家可以自行 Google,這裡就不長篇大論了。

通過零值其實可以明白:Go 裡面不存在沒有初始化的變數,這也保證了 Go 語言的健壯性,不容易出現低階錯誤

引用傳遞和值傳遞

熟悉 Go 語言基礎的都知道 Go 的引用傳遞在不加任何修飾符的情況下,預設是值傳遞,為什麼要這樣設計呢 ?

因為這樣的設計會為 Go 語言的垃圾回收帶來效能上的提升,值傳遞可以最大化的減少變數的逃逸行為,變數會最大概率的被分配到棧上,棧上分配的變數是無需等待 GC 的回收,還可以減少堆記憶體的佔用和 GC 的壓力,倒不是要大家去學習垃圾回收的工作原理,或者特別去關心變數的逃逸行為,但是對於變數的生命週期還是要搞清楚的

在 Go 裡面通過表示式的 &variable 可以獲取該變數的指標,通過 *pointer 可以獲取該指標變數的值,這是眾所周知的事情,所以在 Go 裡面想要傳遞引用也是很簡單的事情,並且使用指標可以在無需知道變數名字的情況下,讀取和更新變數。

指標是可以比較的,相同值的指標必然相同,我們看一段程式碼:

p := 0			// 宣告型別
&p != nil		// true, 比較指標,說明 p 當前指向一個變數

var x, y int 		// 宣告型別, default value 0
&x == &x 				// true, 相同指標結果必然相等
&x == &y				// false,指標不同,結果不相等

函式引數也可以通過 * 表示當前引數的傳遞型別,例如函式: func incr(p *int) 表示當前 p 引數是指標傳遞,不過多年程式設計經驗來看,這樣引用傳遞過多的話,可能你的程式庫龐大後,或者你想找到一個被經常傳遞的引用變數在哪裡被修改的,你可能會很難找到和定位,這可能是傳遞指標所帶來的一個副作用吧

基本型別

Go 的基本型別也很少,常用的也就是:整型(int)、浮點(flora)、布林(bool)、字串(string)、複數(complex),和 Java 的不同之處在於,string 在 Go 裡面是內建的基本資料型別,在 Java 中確實一個實體類。不過我個人感受 String 本就應該是基本資料型別。用類組合 byte[] 來實現字串似乎還是有些彆扭。

整數

這裡主要區分有符號整數、無符號整數。

不過無符號因為無法表達負數,所以平時使用場景比較少,往往只用於資料庫自增 ID,位運算和特定算數,實現位集,解析二進位制等,這裡要了解平時還是使用 int 等有符號整數比較多就好,具體區分如下:

  • 有符號整數:int8、int16、int32、int64
  • 無符號整數:uint8、uint16、uint32、uint64

Int 後面的數字代表型別的大小,也就是 2N 次冪,使用明確的型別可以更好的利用記憶體空間,Go 語言的所有二元操作符和其他無言別無二致,另外 Go 不支援三元表示式,原因我也不知道為什麼,個人猜測可能是因為考慮函式多返回值的原因,但是 if/else 這樣的程式碼就要寫很多了,感覺還是挺嘔心的。

浮點數 float32、float64 也沒什麼好講的,都很簡單,只有一個原則,如果想要減少浮點運算誤差,儘量推薦使用 float64,因為 float64 有效數是 15 位,差不多是 float32 的 3倍

複數(complex)目前看上去很少用,後面用到再聊聊……

布林型別(bool)除了名字短點,基本和其他語言沒有區別,跳過

字串

可以簡單聊聊,string 是 Go 的基本資料型別,這點和 Java 的型別有些不同,但是相同點還是蠻多的,例如:

  • 都可以通過加號(+)拼接字串,但是返回新的字串(但效能敏感慎用)

不知道是不是 Go 語言設計者同時也是 UTF-8 編碼的設計者(Rob、Ken),所以 Go 語言原始檔預設就是 UTF8 編碼,可以預見到使用 Go 語言會大大減少亂碼問題。

另外介紹幾個 Go 常用處理字元的工具包,如下:

  • strings:提供搜尋、比較、替換等平時常用的字元操作函式
  • bytes:顧名思義,提供操作 byte[] 型別的函式
  • strconv:提供布林,整數,浮點等其他型別轉為 string 的服務
  • unicode:提供對於文字元號特性判斷的函式服務
命名返回值

Go 語言可以在返回型別中,給返回值命名,所以在 return 中就無需再顯示返回,程式碼如下

func split(sum int) (x, y int) {
  x = sum + 3
  y = sum + x
  return 	// 將變數直接返回
}

func main() {
  fmt.Println(split(50))		// res:53, 103
}

不過這種靈活的寫法,會對影響程式碼的可讀性,不利於團隊協作。不推薦使用。

從程式碼可讀性和團隊協作的角度來說,建議寫成如下方式,程式碼更可讀,如下:

func split(sum int) (int, int) {
  x := sum + 3
  y := sum + x
  return x, y
}

func main() {
  fmt.Println(split(50))		// res:53, 103
}
常量

值得注意的是,常量使用 const 關鍵字,任何基本資料型別都可以宣告為常量,但是不能使用 := 語法宣告,示例:

const Pi = 3.14
const World = "世界"
const Truth = true

import 類似可以批量宣告,這樣可以減少很多 const 重複宣告,,如下:

const (
  Pi = 3.14
  World = "世界"
  Truth = true
)
迴圈

只有 for 一種迴圈,簡單用法如下:

sum := 0
for i := 0; i < 10; i++ {
  sum += i
}

Go語言的迴圈和 Java、Javascript 的區別主要在於沒有小括號,但是大括號則是必須的

很多程式語言都有 while 語句,但是在 Go 裡面也是可以用 for 替代,如下:

sum := 1
for sum < 100 {
  sum += 1  // sum 累積 100 次
}

// out: 100
if

for 類似,if 也是沒有小括號的,其他方面和常見的語言差不多,如下:

if x < 0 {
  fmt.Println('x < 0')
}

比較有特色的是,Go語言的 if 可以在執行表示式之前,執行一段宣告語句,如下:

func conditon(x, n, lim float64) float64 {
  // 初始化 v 變數,在進行表示式判定
  // 值得注意的是:v 是 if 條件內的區域性變數,外部無法呼叫
  if v := x * n; v < lim {
    return v
  }
  return lim
}

condition(3, 5, 10)			// out: 10
switch

switch 是簡化一連串 if else 的利器,不過 Go 語言的 switch 和其他語言差別不大,這裡就不多說了。。

延遲函式 defer

算是 Go 語言的特色,Go 的語言執行機制保證它會在函式返回後執行,所以通常用於關閉資源(網路/檔案/IO)等操作,如下:

defer fmt.Println("end")		// 最先宣告,但會在最後執行

fmt.Println("hello")
fmt.Println("Phoenix")

//out: 
//hello
//Phoenix
//end

值得注意的是,在使用 defer 宣告函式被壓力棧中,所以有多個 defer 宣告會根據 FIFO 先進先出的順序執行,如下

defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")

fmt.Println("done")

// done
// 3
// 2
// 1
指標

Go 通過 & 可以直接操作指標,並且通過 * 操作符可以通過指標修改引用值,如下:

x, y = 100, 200
p := &x			// get i 指標
*p = 21		  // 通過指標修改引用值
fmt.Println(x)		//out x = 21
slice 切片

是 Go 語言比較常用的動態陣列,值得注意的是它的傳遞是引用的,任何對切出來的變數進行修改,都會影響到原本的值,程式碼如下:

names := []string{
  "金剛",
  "哥斯拉",
  "怪獸",
  "奧特曼"
}

a := names[0:2]		// out:[金剛,哥斯拉]
b := names[1:3]		// out:[哥斯拉,怪獸]
b[0] = "XXX"

fmt.Println(a)		// out:[金剛,XXX]
fmt.Println(b)		// out:[XXX,怪獸]
fmt.Println(names)// out:[金剛,XXX,怪獸,奧特曼]

備註:宣告一個 slice 就像宣告一個沒有長度的陣列

slice 的快捷切片寫法:

s := []int{2, 3, 5, 7, 11, 13}
s = s[1:4]		// out: 3, 5, 7
// s[0:2] 的簡寫
s = s[:2]			// out: 3, 5
s = s[1:]			// out: 5

在 slice 中 length 和 capacity 是分開儲存,例如上面改變長度,並不會改變容量,在 slice 中的長度和容量可以通過函式 len()cap() 獲取,參考以下幾行程式碼:

s := []int{2, 3, 5, 7, 11, 13}		// len=6, cap=6
s = s[:0]			// len=0, cap=6
s = s[:4]			// len=4, cap=6
Map

Go 語言 map 的簡單用法:

// 使用字面量,宣告並且初始化一個簡單的 map,[key:string,value:int]
s := map[string]int{"a": 123, "b": 456, "c":789}
// out: map[a:123 b:456 c:789]

// 插入和更新
s["d"] = 1001		// out: map[a:123 b:456 c:789, d:1001]

//刪除元素
delete(s, "d")		// out: map[a:123 b:456 c:789]

// 檢索元素
value = s["a"]		// out: 123

// 比較常用的快捷檢索
if v, ok := s["a"]; ok {
  fmt.Println("the value is >", v)			// out: 123
}
函式變數

在 Go 中函式可以作為變數複製,也可以作為引數被引用

// 宣告函式引數為函式變數,fn 則執行該函式
func compute(fn func(float64, float64) float64) float64 {
  return fn(3, 4)
}

// 宣告函式變數
hypot := func(x, y float64) float64 {
  return math.Sqrt(x*x + y*y)
}

// 傳遞函式變數
hypot(5, 12)		// out: 13
compute(hypot)	// out: 5
閉包

Go 的閉包是一段匿名函式,並且可以訪問外部的區域性變數,如下 adder 返回一個函式閉包:

func adder() func(int) int {
		sum := 5
		return func(x int) int {
			sum += x
			return sum
		}
}

// 宣告 pos 函式變數
pos := adder()
fmt.Println(pos(5))		// out: 10

後面還有很多內容。。。。有空再聊。。。