掌握一門語言Go


摘要:Go語言的優勢不必多說,通過本篇文章,讓我們花時間來掌握一門外語,Let's Go!

關鍵字:Go語言,閉包,基本語法,函數與方法,指針,slice,defer,channel,goroutine,select

Go開發環境

針對Go語言,有眾多老牌新廠的IDE。本地需要下載Go安裝包,無論Windows還是Linux,安裝差不多。這里推薦手動安裝方式,

  • 安裝包下載地址:https://www.golangtc.com/download
  • 解壓縮存放相應位置(linux可選位置usr/local),設置環境變量GOROOT指向Go安裝目錄,並將GOROOT/bin目錄放置PATH路徑下
  • 設置環境變量GOPATH,這個目錄就是告訴Go你的workspace,一個Go工程對應一個workspace。每個workspace內的結構一般包含src,pkg,bin三個目錄,其實是仿照Go安裝目錄,建立了一個獨立的Go環境,可以執行bin中我們自己構建的命令。
  • Go語言開發相當於堆積木,Go安裝目錄下已有的內容為積木的底座,為了防止我們構建的包與標准庫有區分,我們需要獨立的命名空間,例如普遍采用開發者本人github賬戶的命名空間。

GOPATH和GOROOT

為了避免混淆加深印象,這里再針對GOPATH和GOROOT進行一個區分詳解。

  • GOROOT是GO的安裝目錄,存放GO的源碼文件。

  • GOPATH是我們使用GO開發的工作空間,類似於workspace的概念,但由於GO是高復用型,就像疊積木那樣,我們開發的Go程序與GO標准庫中的無異,我們編譯的Go命令也與GOROOT/bin中的源碼命令同級,因此對於一個GO工程,我們就要創建一個工作間添加到GOPATH中去,這個工程中的新開發的包都在該工作間目錄結構下。

一個GO工程工作間的目錄結構包括:bin,src,pkg。

先說src目錄,該目錄是我們開發的Go代碼的所在地,bin是我們通過go install 將Go源碼編譯生成的一個可執行文件的存放地,pkg是go get獲取的第三方依賴庫,源碼中使用到的第三方依賴包都會從pkg中去尋找,當然了也會在$GOROOT/pkg標准庫中尋找。對了,在我看來,庫和包的概念沒有什么差異。

我們還可以直接使用go build編譯我們的源碼,那將會直接在源碼位置生成一個可執行文件,而不是像go install那樣將該可執行文件安裝在$GOPATH/bin目錄下。

我們應該將GOROOT和GOPATH均放到\(HOME/.profile中去作為環境變量,同時要將\)GOROOT/bin以及$GOPATH/bin均放到PATH中,以方便我們在任何位置直接訪問go的命令以及我們自己生成的go命令。

hello, world

你的helloworld一定要交給我 ✿◡‿◡

可以來 https://play.golang.org/ 玩一玩,但我不推薦,下面我們來搞一個完整helloworld。

我們已完成上面介紹的開發環境的搭建,然后我們進入到GOPATH目錄下,並進入src下我們設置的命名空間目錄,

liuwenbin@ubuntu1604:~/workspace/src/github.com$ mkdir hello
liuwenbin@ubuntu1604:~/workspace/src/github.com$ cd hello/
liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ vi hello.go

我們創建了一個包hello,在包內又創建了一個hello.go文件,下面是具體helloworld代碼編寫內容:

package main

import "fmt"

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

這里簡單啰嗦兩句。

每個可執行的Go程序都需要滿足:1、有一個main函數,2、程序第一行引入了package main。

我們的代碼滿足可執行條件,下面繼續:

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ go install

執行go install將Go程序代碼打包為可執行文件,保存在GOPATH/bin下,

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ ls ~/workspace/bin
hello

經過檢查,證實了可執行文件hello已被自動install成功。

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ ~/workspace/bin/hello 
hello,world

執行成功。

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ export PATH=$HOME/workspace/bin:$PATH
liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ cd
liuwenbin@ubuntu1604:~$ hello
hello,world

將GOPATH/bin加入到PATH當中,然后在任何位置鍵入hello都會執行我們的程序。

IDE

IDE就選擇官方主推的JetBrains家的goLand吧,親測好用,至於激活碼什么的,谷歌百度你懂的。

goLand可以幫助我們:

  • 時刻管理Go工程目錄結構:包括源碼位置、包管理一目了然,SDK或第三方依賴顯而易見。
  • 統一管理環境變量,作用域可以是全局、工程以及模塊。
  • 代碼開發語法高亮,自動補全,代碼候選項,源碼搜索,文件對比,函數跳轉,初步代碼結構審查,格式化,根據你的習慣設置更方面的快捷鍵,設置TODO,任務列表。
  • 代碼編譯執行可視化,斷點調試bug易於追蹤。
  • IDE內部直接調取終端,不用切換。
  • 可集成各種插件擴展功能,例如版本控制Git,github客戶端,REST客戶端等。
  • 多種數據庫連接客戶端可視化。
  • 更炫酷的界面,多種配色主題可選。
  • 自定義宏小工具集成到IDE,更加方便擴展。

Go基本語法

每個Go程序都是由包組成,程序的入口為main包,bin中的自定義命令就是一個Go程序,入口為main包的main函數,該入口程序文件還會依賴其他庫的內容,可以是標准庫,第三方庫或者自己編寫的庫。這時要通過關鍵字import導入。而導入的庫的程序文件的包名一定是導入路徑的最后一個目錄,例如import "math/rand","math/rand"包一定是由package rand開始。

package main

import (
	"fmt"
	"math/rand"
)

func main() {
	fmt.Println("rand number ", rand.Intn(10))
}

package聲明當錢包,import導入依賴包,這與Java很相似。另外這里的rand.Intn方法也與其他語言一樣是一個偽隨機數,根據種子的變化而變化,如果種子相同,則生成的“隨機數”也相同,這其實就是一種哈希算法。

打包:觀察代碼可以發現,這里的import結構與上面編寫helloworld的 import "fmt"相比發生了變化。這里導入了兩個包,用圓括號組合了導入,這種方式稱為“打包”。

它等同於

import "fmt"
import "math"

但是仍舊提倡使用打包的方式來導入多個包。

下面貼一個官方包的api地址: https://go-zh.org/pkg/ ,這里面的包除了標准庫,還有一些其他的附加包,新增包等,我們都可以通過上面提到的方式進行導入,在我們自己的代碼中復用他們。

函數

這里面最大的不同之處在於函數的參數類型是在變量名的后面的,相應的,返回值的類型也在參數列表的后面。

package main

import "fmt"

func swap(x, y string) (string, string) {
	return y, x
}

func main() {
	a, b := swap("hello", "world")
	fmt.Println(a, b)
}

相同類型的變量可以只在最后用一個類型表示。這里聲明了返回值為兩個string類型數據(string, string)。我們可以在這里通過聲明返回值的類型返回任意數量的值。

Go語言的函數與其他語言最大的不同除了數據類型在變量名后面進行聲明以外,函數的返回值也是可以被命名的。上面講到了可以直接定義返回值的數量以及數據類型,除此之外,還可以進一步對返回值進行定義。

package main

import "fmt"

func split(sum int) (x, y int) {
	x = sum * 4 / 9
	y = sum - x
	return
}

func main() {
	fmt.Println(split(17))
}

(x, y int)定義了返回值的數量,類型,以及變量名,這些變量名在方法體內部處理進行賦值,方法在返回時return后面無需任何內容即可返回x和y的值。

變量

變量的聲明定義創建以及初始化需要關鍵字var

package main

import "fmt"

var i, j int = 1, 2

func main() {
	var c, python, java = true, false, "no!"
	fmt.Println(i, j, c, python, java)
}

注意,var后面加變量名,然后是變量類型,后面還可以直接等號加入初始化內容。var定義的變量的作用域可以在函數內也可在函數外。

函數內部的短聲明變量 :=

在函數內部,在明確類型的情況下,也即變量聲明即初始化情況下,可以使用短聲明變量的方式,省略了var關鍵字以及變量類型,例如k := 3,與var k int = 3等同,但要注意同一個變量不能被var或者:=聲明兩次,也即var或:=只能作用於新變量上。但是要注意只有函數內部才可以使用。函數外每條語句必須是var func等關鍵字為開頭。

基本類型

Go語言的數據基本類型包括bool,string,int,uint,float,complex。其中bool是布爾類型不多介紹,string是字符串,注意開頭s是小寫。int根據長度不同包括int8 int16 int32(rune) int64。uint為無符號整型類型,無符號整型就代表只能為正整數,根據長度也分為uint8(byte) uint16 uint32 uint64 uintptr。float浮點型包括float32 float64,復數類型包括complex64 complex128,Go語言支持了復數類型,這是java所不具備的。

布爾類型 字符串 整型 無符號整型 浮點型 復數類型
關鍵字 bool string int uint float complex
包含 bool string int8/16/32(rune)/64 uint8(byte)/16/32/64 uintptr float32/64 complex64/128
true, false 字符串 正負整數 正整數 小數 復數
零值 false "" 0 0 0(注意浮點的零值也為0,而不是0.0) (0+0i)

注意:int,uint,uintptr類型在32位系統上的長度是32位,在64位系統上是64位,經過測試可知,Go Playground后台是32位系統,因為int溢出了。另外,復數的運算一般都是與數學運算相關聯,與業務處理關系較少。所以常用的類型就是bool,string,int,float四種。

Go數據類型的轉換直接采用以類型關鍵字為函數名,參數為待轉換變量的方式即可。

var i int = 42
var f float64 = float64(i)
var u uint = uint(f)

函數內,直接使用短聲明變量的方式

i := 42
f := float64(i)
u := uint(f)

Go作為功能強大的新型編程語言,也具備自動類型推導的功能。

package main

import "fmt"

func main() {
	var v = 1
	k:=3.1
	fmt.Printf("v is of type %T\n", v)
	fmt.Printf("k is of type %T\n", k)
}

v is of type int
k is of type float64

類型推導:即在未顯示指定變量類型的時候,可以根據賦值情況來自動推導出變量類型。然而,當你在前面已經通過賦值推導出某變量的類型以后,再改變其值為其他類型就會報錯。

另外,在Println表達式中,%v代表值的默認形式,%T代表值的類型。

常量

使用關鍵字const聲明,可以是字符、字符串、布爾或數字類型的值,不能使用 := 語法定義。

package main

import "fmt"

const Pi = 3.14

func main() {
	const World = "世界"
	fmt.Println("Hello", World)
	fmt.Println("Happy", Pi, "Day")

	const Truth = true
	fmt.Println("Go rules?", Truth)
}

Hello 世界
Happy 3.14 Day
Go rules? true

此外,還有數值常量,數值常量往往是高精度的值,例如

const (
	Big   = 1 << 100
	Small = Big >> 99
)

我們看到了位運算符<<和>>,這里再復習一些位運算的知識。首先定義運算符左側為原值,右側為操作位數,運算符“<<”代表左移,即將原值用二進制方式表示,然后將其中的值左移相應位數,再還原回十進制表示結果,反之則為運算符“>>”。那么用一種更加容易理解的方式來講是左移即為乘以2的n次方,n=操作位數,右移即除以2的n次方,n=操作位數。

循環

同樣的,關鍵字也為for,for的循環結構也與java相似,有初始化語句,循環終止條件,以及后置變化語句(例如自增自減)。不一樣的地方是,這個循環結構沒有圓括號,初始化變量的作用域在整個循環體內,另外,for也可以相當於其他語言的while使用,即去掉初始化語句和后置變化語句,只有一個循環終止條件,同樣沒有圓括號,但是循環體必須用花括號包圍{}。下面看例子。

    for i := 0; i < 10; i++ {
		sum += i
	}
	// 如果初始化語句和后置變化都去掉的話,則省略分號;
	for ; sum < 10; {
		sum += sum
	}
	// 相當於while
	for sum < 10 {
		sum += sum
	}

初始化語句和后置變化語句都可以被省略,如果終止條件語句也被省略,循環就成了死循環。簡潔的表示為

for {
	}

判斷語句

Go的if語句也不要用圓括號括起來,但方法體還是要用花括號{}的。與for一樣,if語句也可以包含一個初始化語句,然后再接判斷表達式,這個初始化變量的作用域僅在if語句內,包括與其成對的else語句。

package main

import (
	"fmt"
	"math"
)

func pow(x, n, lim float64) float64 {
	if v := math.Pow(x, n); v < lim {
		return v
	}
	return lim
}

func main() {
	fmt.Println(
		pow(3, 2, 10),
		pow(3, 3, 20),
	)
}

pow函數復寫了math.pow(x,y float64),加入了一個限制lim參數,當加冪值結果超過lim的時候,返回lim,未超過則返回結果。通過這個例子,可以看到if語句在判斷表達式中加入了初始化語句。此外,main函數中的Println輸出多條信息的方式,與前面介紹的import打包,const數值常量集都很相似,可以說明這種形式是Go的一種編程習慣。

switch的邏輯同其他語言並沒有太多出入。

package main

import (
	"fmt"
	"time"
)

func main() {
	t := time.Now()
	switch {
	case t.Hour() < 12:
		fmt.Println("Good morning!")
	case t.Hour() < 17:
		fmt.Println("Good afternoon.")
	default:
		fmt.Println("Good evening.")
	}
}

輸出:Good evening.

由於現在已經17:37,確實過了17點,所以輸出為晚上好是合理的。此外,這里面引用到了time包,獲取了當前時間,time.Now(),同樣的,這可以通過上面給出的標准包文檔查看。

延遲執行

這是一個Go語言獨特的內容,關鍵字為defer,意思是defer聲明的一行代碼要它的上層函數執行完畢返回的時候再執行。

package main

import "fmt"

func main() {
	defer fmt.Println("world")

	fmt.Println("hello")
}
hello
world

defer關鍵字的聲明使得world的輸出雖然寫在hello輸出的上方,但必須等待hello輸出完畢以后再輸出。

  • defer下壓棧

當defer關鍵字聲明的代碼不止一行的時候,就引入了defer下壓棧的特性,這也是Go比驕強大的地方,根據下壓棧的特點,后壓入的那行代碼會在上層函數執行完畢后先執行。

package main

import "fmt"

func main() {
	fmt.Println("counting")

	for i := 0; i < 10; i++ {
		defer fmt.Println(i)
	}

	fmt.Println("done")
}

輸出:

counting
done
9
8
7
6
5
4
3
2
1
0

指針

指針我們都熟悉,大學時期學習C語言的時候折磨我們好久,Go語言中也支持指針,但它並不像我們印象中那么恐怖,因為它並不包含C的指針運算。

指針保存了變量的內存地址。

  • & 符號會【生成】一個指向其作用對象的指針。
  • * 符號表示指針指向的【底層的值】。
package main

import "fmt"

func main() {
	i, j := 42, 2701

	p := &i         // p為i的指針
	fmt.Println(*p) // 通過指針顯示的是i的值
	*p = 21         // 通過指針修改的是i的值
	fmt.Println(i) 

	p = &j         // 沒有:了,因為是第二次修改值,不是初始化,將p改為j的指針
	*p = *p / 37   // 通過指針操作的是j的值,除以37的結果重新通過指針賦給j
	fmt.Println(j)
}

42
21
73

結構體struct

package main

import "fmt"

type Vertex struct {
	X int
	Y int
}

func main() {
	fmt.Println(Vertex{1, 2})
}

  • 以type開頭,用來聲明創建一種類型,創建以后可以被var聲明該類型的變量。
  • 關鍵字struct,它相當於一個字段的集合,使用方式與基本類型相似,也是寫在變量名后面。
    v := Vertex{1, 2}
	fmt.Println(v.X)
	// 輸出1

可以用短聲明方式定義一個變量,通過點獲得相關字段的內容。

    p := &v
	p.X = 1e9
	fmt.Println(v)
	// 輸出{1000000000 2}

結構體同樣可以像一個普通變量那樣有指針,通過指針可以操作結構體字段。

package main

import "fmt"

type Vertex struct {
	X, Y int
}

var (
	v1 = Vertex{1, 2}  // 類型為 Vertex
	v2 = Vertex{X: 1}  // Y:0 被省略
	v3 = Vertex{}      // X:0 和 Y:0
	p  = &Vertex{1, 2} // 類型為 *Vertex
)

func main() {
	fmt.Println(v1, v2, v3, p)
}
// 輸出:{1 2} {1 0} {0 0} &{1 2}

通過對結構體的字段操作,用一個變量來接受,可以重新組裝新的結構體。以上代碼中,使用var圓括號列表的方式,分別定義了v1,v2,v3和p四個變量,前三個對原結構體的數據進行了不同的賦值,p為結構體的指針,輸出也是帶有&符號的結果。

數組和slice

類型 [n]T 是一個有 n 個類型為 T 的值的數組。

數組的聲明方式:

var a [10]int

定義一個數組變量,變量名為a,長度為10,數據類型為int。Go的數組與其他語言一樣,都是定長的,一旦聲明無法自動伸縮,但Go提供了更好的解決方案,就是slice。

[]T 是一個元素類型為 T 的 slice。

slice與數組最大的區別就是不必定義數組的長度,它可以根據賦值的長度來設定自己的長度,而不是提前設定。

package main

import "fmt"

func main() {
	s := []int{2, 3, 5, 7, 11, 13}
	fmt.Println(len(s))
	fmt.Println("s ==", s)

	for i := 0; i < len(s); i++ {
		fmt.Printf("s[%d] == %d\n", i, s[i])
	}
}

6
s == [2 3 5 7 11 13]
s[0] == 2
s[1] == 3
s[2] == 5
s[3] == 7
s[4] == 11
s[5] == 13

通過len(s)方法可以獲得slice當前的長度。此外,上面代碼中Printf中的格式化字符串的&d,與C和java相同,都代表是整型數字。

數組和slice都可以是二維的。

slice可以內部重新切片s[lo:hi],lo是低位,hi是高位,hi>lo,若hi=lo則為空。

slice除了上面的直接字面量賦值以外,還可以通過make創建。func make([]T, len, cap) []T

a := make([]int, 5)  // len(a)=5 cap(a)=5
b := make([]int, 0, 5) // len(b)=0, cap(b)=5
// slice內部繼續切片,空為從頭,這不是下標的概念,而是個數的概念。
b = b[:cap(b)] // len(b)=5, cap(b)=5,b原來的容量為5,重切以后的切片是b[:5]意思是b的前五個數組成的切片,順序不變。
b = b[1:]      // len(b)=4, cap(b)=4,b原來的容量為5,重切以后的切片是b[1:]意思是除去前一個數(即第一個數)剩余的數組成的切片,順序不變。

slice的零值是nil。Go語言中的空值用nil來表示。一個 nil 的 slice 的長度和容量是 0。

slice是通過append函數來添加元素。

range遍歷slice和map

package main

import "fmt"

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}

func main() {
	for i, v := range pow {
		fmt.Printf("2^%d = %d\n", i, v)
	}
}

2^0 = 1
2^1 = 2
2^2 = 4
2^3 = 8
2^4 = 16
2^5 = 32
2^6 = 64
2^7 = 128

注意使用range的格式,返回值i,v分別代表了當前下標,下標對應元素的拷貝。

利用下划線_作占位符

當我們不需要當前下標的時候,可以將i用下划線_代替。然而如果只想要下標,可以把, v直接去掉,不必加占位符。

map

與其他語言一樣,map也是一個映射鍵值對的數據類型。在Go中,與slice相同的是,它也需要使用make來創建,零值為nil,注意值為nil的map不可被賦值。

package main

import "fmt"

type Vertex struct {
	Lat, Long float64
}

// []中的為key數據類型,[]外面緊跟着的是value的數據類型,這里value的數據類型是上面type新創建的struct類型。
var m map[string]Vertex

func main() {
	m = make(map[string]Vertex)
	m["Bell Labs"] = Vertex{
		40.68433, -74.39967,
	}
	fmt.Println(m["Bell Labs"])
}

注意,map在賦值時必須有鍵名。賦值的時候可以省略類型名

var m = map[string]Vertex{
	"Bell Labs": {40.68433, -74.39967},
	"Google":    {37.42202, -122.08408},
}
  • 修改map中一個元素的內容:m[key]=elem
  • 獲得map中的一個元素:elem=m[key]
  • 刪除元素:delete(m,key)
  • elem, ok = m[key]判斷key是否存在m中,如果沒有ok為false,elem=map零值,如果有ok為true,elem為key對應的value
package main

import "fmt"

func main() {
	m := make(map[string]int)
    // 賦值key為"Answer",值為42。
	m["Answer"] = 42
    // 檢查key是否存在,此時是存在的。那么v=42。
	v, ok := m["Answer"]
	fmt.Println("The value:", v, "Present?", ok)
    // 刪除key
	delete(m, "Answer")
	fmt.Println("The value:", m["Answer"])
	
	// 注意下面沒有冒號了,因為是第二次賦值,再次檢查key是否存在,此時是不存在的。那么v=0,整型的零值是0。
	v, ok = m["Answer"]
	fmt.Println("The value:", v, "Present?", ok)
}

輸出:

The value: 42 Present? true
The value: 0
The value: 0 Present? false

與JavaScript似曾相識?

函數值概念:函數也是值,可以像其他值一樣被傳遞和操作,例如可以當作函數的參數和返回值,這非常強大,跟JavaScript的思想如出一轍。

閉包:當時學習JavaScript閉包概念的時候也是蒙圈,沒想到Go語言也支持,下面我們再來復習一下。閉包是基於函數值概念的,它引用了函數體之外的變量,可以對該變量進行訪問和賦值。


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

adder函數的返回值類型為func(int) int,好叼哦。也就是說返回的是另一個函數,它沒有函數名(有點匿名內部類的意思哈),該函數只有一個int型的參數,返回值為int。繼續我們來看函數體,聲明並賦值給sum變量為0,然后是return階段的確返回了符合上面定義的一個函數。在這個返回函數的函數體內,我們直接使用到了外部的sum變量,對其進行了操作並返回,這就是閉包的概念(一個無名函數小子和一個大家閨秀變量的感情糾葛)。

func main() {
	pos, neg := adder(), adder()
	for i := 0; i < 10; i++ {
		fmt.Println(
			pos(i),
			neg(-2*i),
		)
	}
}

接着,我們在main函數中調用這個adder函數。首先聲明並初始化變量pos和neg為adder函數值,然后定義一個循環,直接調用pos和neg變量並傳參,相當於調用了adder函數。

以上就是函數值和閉包的概念的學習,根據以上知識完成斐波那契數列的練習:

package main

import "fmt"

// fibonacci 函數會返回一個返回 int 的函數。
func fibonacci() func() int {
	back1,back2 := 0,1
	return func() int{
		temp := back1
		// 重新賦值back1和back2的值,下面是關鍵代碼
		back1,back2 = back2, (back2+back1)
		return temp
	}
}

func main() {
	f := fibonacci()
	for i := 0; i < 10; i++ {
		fmt.Println(f())
	}
}

類的概念?Go的方法

Go中是沒有類的概念的,但是可以實現與類相同的功能。在java中,如果一個方法屬於一個類,我們是如何做的?直接在類文件中加入方法即可,外部調用的時候,我們可以通過類的實例來調用該方法,如果是靜態類的話,可以直接通過類名調用。

那么在Go中是如何實現“類的方法”的?

方法,屬於一個“東西”的函數被稱為這個“東西”的方法。Go中是通過綁定的方式來處理的。我們先type定義一個結構體類型

type Vertex struct {
	X, Y float64
}

這段代碼我們上面已經學習過了,應該沒有任何疑問。接着,我們要創建一個Vertex類型的變量(java中稱為對象的實例,就看你怎么解讀了),並且讓這個變量擁有一個方法。

func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

我們慢慢來看,

  • 正常的函數是func Abs() float64, 但我們在func和函數名之間加入了一個東西,定義了一個Vertex指針類型的變量v。
  • 在該函數體中,我們可以直接把v當作參數來使用,因為v是一個結構體類型的對象指針,所以v可以調用結構體中的各個字段。(TODO:Go語言聖經繼續深入研究這一部分)
func main() {
	v := &Vertex{3, 4}
	fmt.Println(v.Abs())
}

我們在main函數中可以直接通過變量v調用上面的函數Abs,此時函數Abs就是v的方法了。

注意,type關鍵字可以創建任意類型

type MyFloat float64

MyFloat就是一個類型,我們在下面可以直接使用,該類型仍然可以與函數綁定。

下面我們再來重申區分一下函數和方法:

  • 函數是func后面直接跟函數名,跟任何類型都無關系。
  • 方法是func后面加入類型的變量,然后再加函數名,這個類型的變量本身也是該方法的參數,同時該方法是屬於該類型的,但要用類型的對象來調用(Go沒有靜態方法)。

上面我們分別使用了結構體指針和自定義類型,使用指針的好處就是可以避免在每個方法調用中拷貝值同時可以修改接收者指向的值。

package main

import (
	"fmt"
	"math"
)

type Vertex struct {
	X, Y float64
}

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
	v := &Vertex{3, 4}
	fmt.Printf("Before scaling: %+v, Abs: %v\n", v, v.Abs())
	v.Scale(5)
	fmt.Printf("After scaling: %+v, Abs: %v\n", v, v.Abs())
}
/* 輸出:
    Before scaling: &{X:3 Y:4}, Abs: 5
    After scaling: &{X:15 Y:20}, Abs: 25
*/

當Scale使用Vertex而不是*Vertex的時候,main函數中調用v.Scale(5)沒有任何作用,此時輸出結果應該毫無變化“After scaling: &{X:3 Y:4}, Abs: 5”。因為接收者為值類型時,修改的是Vertex的副本而不是原始值。

當我們修改Abs的函數接收者為Vertex的時候,並不會影響函數執行結果,原因是這里只是讀取v而沒有修改v,讀取的話無論是指針還是值的副本都不受影響,但是修改的話就只會修改值的副本,然而打印程序打印的是原始值。

接口

Go的接口定義了一組方法,同樣的沒有實現方法體。接口類型的值可以存放任意實現這些方法的值。

首先,我們來看一個接口是如何定義的:

type Abser interface {
	Abs() float64
}

然后我們再type創建兩個類型,並綁定與接口相同方法名,參數,返回值的方法。

type MyFloat float64

func (f MyFloat) Abs() float64 {
	if f < 0 {
		return float64(-f)
	}
	return float64(f)
}

type Vertex struct {
	X, Y float64
}

func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

上面我們定義了一個MyFloat類型和Vertex類型,在他們類型定義的下方都綁定了方法Abs() float64,並有各自具體的實現。

func main() {
	var a Abser
	f := MyFloat(-math.Sqrt2)
    a=f
	fmt.Println(a.Abs())
}

//輸出:1.4142135623730951

最后我們在main函數中去做具體操作,首先定義一個接口類型的值var a Abser,然后我們先定義一個剛剛我們創建的MyFloat類型的值f,將f賦值給a,接口類型值a就存放了實現了接口方法Abs的MyFloat類型的值f,最后我們去用a調用Abs方法,實際上調用的是f的Abs方法。

func main() {
	var a Abser
	v := Vertex{3, 4}
	a = &v // a *Vertex 實現了 Abser
	//a = v //等於v為什么不行,而必須是v的指針?
	fmt.Println(a.Abs())
}
// 輸出:5

然后我們來測試上面創建的另一個類型Vertex,創建並初始化Vertex的值變量v,將v的指針賦值為接口類型值a,用a調用Abs方法,實際上調用的是*Vertex的Abs方法。

解答代碼注釋里的問題:因為我們實現接口方法的時候,綁定的是*Vertex而不是Vertex,所以必須是Vertex的指針類型才擁有該方法,如果使用Vertex的值類型而不是指針,則會報錯“Vertex does not implement Abser”。

Go的接口屬於隱式接口,類型通過實現接口方法來實現接口,方法也不必像java那樣必須全部實現,沒有顯示聲明,也就沒有“implements”關鍵字。隱式接口解耦了實現接口的包和定義接口的包:互不依賴。

  • Stringer接口
type Stringer interface {
    String() string
}

常用的一個接口就是Stringer接口,它就如同java復寫toString方法,輸出對象的時候不必顯示調用toString,而是直接輸出該接口的實現方法。

package main

import "fmt"

type Person struct {
	Name string
	Age  int
}

func (p Person) String() string {
    // Sprintf 根據於格式說明符進行格式化並返回其結果字符串。
	return fmt.Sprintf("%v (%v years)", p.Name, p.Age)
}

func main() {
	a := Person{"Arthur Dent", 42}
	z := Person{"Zaphod Beeblebrox", 9001}
	fmt.Println(a, z)
}
// 輸出:Arthur Dent (42 years) Zaphod Beeblebrox (9001 years)

我們看到新創建的類型Person,它實現了Stringer的String方法(注意這里開頭S是大寫,與基本類型string不同)。我們在fmt.Println的時候會默認調用該方法輸出。

發現一個問題:Person的實現接口方法以及main函數初始化Person調用String方法輸出,這整個過程都沒有出現真正的原接口名稱“Stringer”!這是非常有意思的部分,我們日后要注意。

那么原因是什么?我們來分析一下,上面我們調用接口方法的時候是需要利用接口類型值來調用的(如var a Abser,a.Abs())。然而這里由於特殊原因(跟其他語言一樣,大家都不會顯示調用toString吧),並沒有顯式使用
接口類型變量,所以全文沒有出現接口名稱,這種情況在之后的Go工作中,應該不少見,還望注意。

error

error在Go中是一個接口類型,與Stringer一樣。

type error interface {
    Error() string// 注意接口方法為Error()
}

一般函數都會返回一個error值,調用函數的代碼要對這個error進行判斷,如果為空nil則說明成功,如果不為空則需要做相應處理,例如報告出來。

i, err := strconv.Atoi("42")
if err != nil {
    fmt.Printf("couldn't convert number: %v\n", err)
    return
}
fmt.Println("Converted integer:", i)

Go語言的io包中的Reader接口定義了從數據流結尾讀取的方法,標准庫中有很多包對Reader的這個接口方法進行了實現,包括文件、網絡連接、加密、壓縮等。該Read方法的聲明為:

func (T) Read(b []byte) (n int, err error)

下面使用strings包中的NewReader實現方法,它的介紹是:

func NewReader(s string) *Reader
NewReader returns a new Reader reading from s. It is similar to bytes.NewBufferString but more efficient and read-only.

會返回一個新的讀取參數字符串的Reader類型。

package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	r := strings.NewReader("Hello, Reader!")

	b := make([]byte, 8)//定義一個8位字節數組用來存上面的字符串
	for {// 無限循環here
		n, err := r.Read(b)
		fmt.Printf("n = %v err = %v b = %v\n", n, err, b)
		fmt.Printf("b[:n] = %q\n", b[:n])// b[:n]輸出字節數組b的所有值,n是最大長度
		if err == io.EOF {//隨着不斷循環,上面字符串已經讀取完畢,當前字節數組為空,返回EOF(已到結尾)錯誤
			break// 手動置頂break跳出循環
		}
	}
}

HTTP web服務器

主要通過調用標准庫的http包來實現。

package http

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

下面我們創建一個結構體類型,實現該接口方法:

package main

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

type Hello struct{}

func (h Hello) ServeHTTP(
	w http.ResponseWriter,
	r *http.Request) {
	fmt.Fprint(w, "hello, Go server!")
}
func main() {
	var h Hello
	err := http.ListenAndServe("localhost:4000", h)
	if err != nil {
		log.Fatal(err)
	}
}

在編寫這段代碼過程中,針對goland有兩點收獲:

  • import內容完全不必手寫,goland會全程幫助你自動補全代碼。
  • 每次goland自動補全的時候都會自動格式化你的代碼。

此外,我們發現對於Handler接口的ServeHTTP方法,我們自定義的結構體類型Hello全程並未見到Handler的字樣,這個問題我在前面已經研究過,這里的Hello的實例h直接作為參數傳給了http的ListenAndServe方法,可能在這個方法內部才會有Handler的出現。

因此,這種我實現了你的接口方法,但根本不知道你是誰的情況在Go中十分常見。

我想深層原因就是Go並沒有強連接的關系例如繼承,顯式implements關鍵字去實現,這是一種解耦的,松散的實現接口方法的方式,才會有這種情況的出現。這個特點稱不上好壞,但需要適應。

下面讓我們直接在goland中將該文件run起來,會發現在控制台中該程序處於監聽狀態。然后我們可以

  • 通過瀏覽器去訪問http://localhost:4000/
  • 通過終端curl http://localhost:4000/
  • 通過goLand的REST client輸入網址http://localhost:4000/,get的方式run

總之,最終會得到結果:hello, Go server!一個簡單的web服務器通過GO就搭建完成了。

並發

goroutine

goroutine 是由 Go 運行時runtime環境管理的輕量級線程。

goroutine 使用關鍵字go來執行,例如

go f(x, y, z)

意思為開啟一個新的goroutine來執行f(x,y,z)函數。

一般來講,多線程通信都需要處理共享內存同步的問題,Go也有sync功能,但是不常用,后面繼續研究。

package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 5; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	go say("world")
	say("hello")
}

我們定義了一個函數say,函數體為一個循環輸出,每隔100微秒輸出一遍參數值,共輸出5次。

Go的time包中定義了多個常量來表示時間,我們可以直接調用而不需要再自行計算。

package time

type Duration int64

const (
    Nanosecond  Duration = 1
    Microsecond          = 1000 * Nanosecond
    Millisecond          = 1000 * Microsecond
    Second               = 1000 * Millisecond
    Minute               = 60 * Second
    Hour                 = 60 * Minute
)

接着,我們在main函數中調用了兩遍say函數,不同的是第一行調用加入了關鍵字go,這就使得這兩行調用並不存在依次順序,而是兩個線程互不干擾的在跑。我們來看一下結果。

world
hello
hello
world
world
hello
hello
world
world
hello

可以發現,world和hello的輸出並沒有顯然順序,而是交替輸出。

然而經測試,這個輸出雖然是交替但順序不變,這說明了goroutine並不是“完全的多線程”,也就是說goroutine是通過程序控制內存資源的調配的,而不是真正意義的互不干擾獨立的並行地享用各自的內存空間運行。

channel

我們在之前學習java的nio的時候就介紹過channel,編程語言都是換湯不換葯,各取所需,所以本質上區別不大,下面我們來具體介紹一下Go的通道。

channel 是有類型的管道,使用chan關鍵字定義,可以用 channel 操作符 <- 對其發送或者接收值。

我們要使用通道,記住這個次操作符即可,箭頭就是數據流的方向,不過注意你只能調整箭頭左右的對象,這兩個對象至少有一個是channel類型的,而不能改變操作符的箭頭方向,操作符只有這一個,方向就是從右向左。

與map和slice一樣,channel創建也要使用make

ch := make(chan int)

ch是變量名,chan聲明了這是一個channel,int說明這個channel的數據類型是整型。chan有點像java的final或static關鍵字的用法,它是用來修飾變量的,與數據類型不沖突。判斷一個變量是不是通道值,就看它的定義中是否有關鍵字chan即可。

注意:在channel傳輸數據的操作中,只要另一端沒有准備好,發送和接收都會阻塞,這使得goroutine可以在沒有明確鎖或競態變量的情況下進行同步。

package main

import "fmt"

func sum(a []int, c chan int) {
	sum := 0
	for _, v := range a {
		sum += v
	}
	c <- sum // 將和送入 c
}

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

	ch := make(chan int)
	go sum(a[4:], ch)
	go sum(a[:1], ch)

	x, y := <-ch, <-ch // 從 ch 中獲取

	fmt.Println(x, y, x+y)
}
// 輸出:7 4 11

函數sum很好理解,我們就把channel c當做普通的整型值,意思就是將第一個參數的整型數組的值之和傳給c。

main函數中,先定義了一個整型切片a,根據初始化值可以確定它的長度和容量均為6。

然后借助make定義了一個通道變量ch,它的數據類型是整型。

下面我們使用goroutine來“多線程”調用sum函數,除了都傳入了通道變量ch以外,第一個調用傳入的是a的后6-4個數組成的新切片a[4:]等於{4,0},第二個調用傳入的是前1個數組成的新切片a[:1]等於{7}

因此可以得出,第一個調用sum以后,ch接收到值為4+0=4,第二個調用sum以后,ch接收到值為7。

那么下一行代碼是如何執行的? x和y應該如何分配ch的值。

x, y := <-ch, <-ch

這一行代碼我也比較confuse,首先來看x是先得到ch的值,y是后得到ch的值。

那么關於ch的值到底在傳給x和傳給y這之間發生了什么?

回到goroutine的特性,我們上面已經分析了一波,它不是真正的獨立多線程,而是有序的,有章法地通過語言底層邏輯來實現資源調配,那么經測試,我可以總結出來這兩個調用的執行順序是第一個調用的結果后傳給ch,第二個調用的結果先傳給ch。那么是否可以總結出來,第二個調用的結果給到了先接收的x,第一個調用的結果給到了后接收的y。

以上的分析完全是結果反推的,我不確定是否正確,但我是根據結果這么理解的。(TODO:參照其他書籍的解釋)

channel的緩沖區概念

package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	ch <- 3
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

可以看到,在正常的創建channel變量ch的結構ch := make(chan int)的make后面加入了第二個參數2。這個2代表了當前通道變量ch的緩沖區大小為2

2的意思是什么?

我們從長計議,再回到上面的channel的概念繼續分析,

package main

import "fmt"
func add(i int,c chan int){
	c<-i
}
func main() {
	ch := make(chan int)
	go add(1,ch)
	fmt.Println(<-ch)
}
// 輸出:1

channel這種類型的變量必須伴隨這goroutine的使用,而goroutine必須修飾的是函數,也就是說線程執行的一定是函數,而不能是一行代碼。

你寫 go c:=1 就是錯的,go只能用於函數不能用於一行代碼。

所以,我寫了一個add函數來做這行代碼相同的事,然后用go來修飾。這才能通過fmt.Println(<-ch)打印出ch的值。

我們再繼續測試。

func main() {
	ch := make(chan int)
	go add(3,ch)
	go add(2,ch)
	go add(5,ch)
	go add(11,ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

輸出情況如下:

11
3
2
5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
	/tmp/sandbox859149002/main.go:17 +0x360

通過輸出結果繼續分析,我寫了4個go修飾的add函數調用,然而下面使用<-ch了5次,第五次的輸出報錯了,

報錯信息為:所有的goroutine都睡眠了,死鎖,下面是通道接收報錯,main函數是在tmp臨時目錄下建立了一個沙盒sandbox加沙盒id的目錄,在這個目錄下執行main函數時,第17行出錯。

第17行對應的就是第五次輸出。

這說明了通道channel傳輸的次數一定要等於go調用函數的次數。

包含通道channel類型參數的函數必須要用goroutine來調用。

好,這種情況我們來評判一下是好是壞呢?我覺得這是一種規定,但是稍顯死板,必須是相等的才行。那么Go也提供了一種機制來變通,就是上面提到的channel的緩沖區概念。下面來看代碼,深入體驗一下緩沖區的“療效”。

func main() {
	ch := make(chan int,4)
	ch<-1
	ch<- 100
	go add(3,ch)
	go add(2,ch)
	go add(5,ch)
	go add(11,ch)
	add(12,ch)
	add(222,ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

輸出結果:

1
100
12
222
11
3
2
5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
	/tmp/sandbox119279221/main.go:25 +0x640

我們先來描述一下上面發生了什么,上面代碼中我們定義了四次goroutine調用add函數,四次針對通道channel變量ch進行的普通賦值操作,然后下方對通道變量的接受者輸出了九次。

下面我們來總結一下Go語言中通道channel緩沖區的特性。可以發現,如果沒有緩沖區的話,不能對通道進行非線程操作,也就是說不使用goroutine調用函數來操作通道的話,就會報錯。而有了緩沖區以后,通道可以按照你設定的緩沖區大小來做普通的非goroutine參與的非線程的同步操作,從上面的輸出結果我們也能看出來了,非goroutine參與的代碼都是按照先后順序執行的,只有goroutine參與的是無序的,但是所有的goroutine參與的操作一定是在所有普通操作結束以后再執行的,1,100,12,222就是普通操作的結果,后面的都是goroutine的操作結果。

最后一行的報錯信息是因為通道ch被接受值的次數多於通道ch被發送值的次數一次,所以有一次報錯,但這與緩沖區大小無關。

最后,讓我嘗試一下用精簡的一句話來總結一下通道緩沖的概念。

通道緩沖定義了通道變量允許被最多普通操作的次數。

那么它的意義是什么?

我好像又繞回來了,上面講過那么一大段通道緩沖的存在意義了。下面再來一句話解釋通道緩沖區。

緩沖區大小是可以發送到通道而沒有發送阻塞的元素數。

這樣的解釋更加清晰了,就是通道有了緩沖區可以存放(通道作為接受者)一定大小的數據而不是直接進入阻塞。

緩沖區的高級用法range,close

可以通過for range 語句來遍歷緩沖區,close是關閉通道的方法。

package main

import (
	"fmt"
)

func fibonacci(n int, c chan int) {
	x, y := 0, 1
	for i := 0; i < n; i++ {
		c <- x
		x, y = y, x+y
	}
	close(c)
}

func main() {
	c := make(chan int, 10)
	go fibonacci(cap(c), c)
	for i := range c {
		fmt.Println(i)
	}
}

斐波那契函數我們前面練習過,這里對函數做了修改,加入了通道參數,用通道來替代temp(不懂temp的來歷的請翻到上面斐波那契函數)。

temp是需要返回的,但通道不需要,通道可以與線程共享數據。

我們先來看上面代碼發生了什么?

通道變量c被定義了大小為10的緩沖區,goroutine調用斐波那契函數向通道發送值,每發送一次,會被下面的for range循環的循環體中接收通道的值並輸出,也就是說通道c接收到一個值就會立馬在goroutine線程發送出去,所以goroutine調用斐波那契函數和下面的for range循環是並行的。直到for range將通道c的緩沖區遍歷結束,通道c由於緩沖區大小的限制也不會繼續再接收值了,這時就會被close掉。

總結幾點注意:

  • close方法是放在了斐波那契函數內尾部而不是想當然的放在for循環后面。原因是Go規定了只有發送者可以關閉通道,作為發送者的只有斐波那契函數,for循環是作為接受者的,它無權對通道進行關閉操作。
  • 我們在斐波那契函數中的循環次數為手動輸入的通道的緩沖區大小,如果不是這樣的話,發送次數超過了緩沖區大小就會報錯。
  • for range循環在對通道c進行遍歷的時候,它並不會自動按照c的緩沖區大小來循環,而是通道c被關閉以后,觸發了for range循環的中止,而如果不是這樣的話,通道一般是不需要被close的。
  • 向一個已經關閉的 channel 發送數據會引起 panic(可以理解為一種error)。

select

select的用法有點像switch,基於上面我們對通道的深入了解,結合select我們可以做很多事,select可以根據判斷執行哪個分支,下面看代碼。

package main

import "fmt"

func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}

func main() {
	c := make(chan int)
	quit := make(chan int)
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Println(<-c)
		}
		quit <- 0
	}()
	fibonacci(c, quit)
}

輸出:

0
1
1
2
3
5
8
13
21
34
quit

先來看這段程序都發生了什么,我們又對斐波那契函數進行了改動,函數內有一個for死循環,這就要求我們在循環體中去設置中止辦法。循環體中用到了select關鍵字,它就像switch那樣,這里有兩個case判斷:

  • 第一個是判斷c是否可以接收值,如果可以就執行第一個分支,那么c在什么情況下不能接收值呢?上面我們研究過多次了,這是沒有緩沖區的通道,它的接收次數一定要與它的發送次數相等,當它的發送結束的時候,它也就不能再繼續接收值了。
  • 第二個判斷是quit通道是否可以發送值,同樣的道理,通道的發送次數一定與它的接收次數相等,當quit通道接收了值,這個判斷的分支就可以被執行。

只要有goroutine的函數,執行時一定會與普通函數並行,無論這個普通函數的調用是寫在它的前面還是后面。

沒有緩沖區的通道,在代碼中它的接收次數和發送次數一定是相等的,這個主動權當然是在main函數里,因為只有main函數才是真正開始執行的函數。

所以下面來看main函數。

main函數定義了一個go修飾的匿名函數,函數體內是一個循環10次發送通道c的循環,然后是一次quit通道的接收。上面說了主動權在main,所以main函數要求的這些次數必將在斐波那契函數中得以平衡(即發送次數與接收次數相等)。所以下面的斐波那契函數並行地執行了對應的10次通道c的接收和1次quit通道的發送,這些操作放到select的判斷中去就是執行10次的斐波那契數列,每次通道c接收到數列的一個值就會被go匿名函數發送打印出去,10次結束以后,會接收quit通道的值,return中止斐波那契函數內部的死循環。

select 操作很像switch,所以select也有default判斷的分支,當其他分支不滿足的時候,就會走default分支,一般default分支會執行一個時間的休眠等待,等待外部其他函數的通道操作能夠滿足select的某些分支。

sync.Mutex

sync.Mutex是一個互斥鎖類型,它有Lock和Unlock一個上鎖一個解鎖的方法,Lock和Unlock之間的代碼只會被一個goroutine訪問共享變量(共享變量不僅是通道,普通類型的實例也可以,例如struct類型),從而保證一段代碼的互斥執行。目前沒有什么太好的例子,以后有機會再學而時習之吧。

總結

總結里面依然不說Go的優勢,只對本篇文章做一個總結,本篇文章的目標是一次系統性的從零到一的學習Go語言。我本想多看基本書概況總結他們來放到這篇文章中去,但我覺得學習分為理論和實踐,比例約為2:8,不能再多了,也由於項目緊留給我搞理論的時間實在不多,因此我就順着官方文檔這一支完完整整地捋下來,對其中每一個特性,語法細節都做了仔細的研究,開發環境的逐步搭建,也對源碼進行了復現,甚至自己也開發了一些測試代碼。當然,這篇文章遠遠不能稱為Go語言的高級深入使用手冊,只是一個入門到了解的過程,未來還有着長久的實踐,在使用中會有更多的心得,到時有機會我再總結一篇深入版吧,可以基於《effective go》來寫。

參考資料

《A Tour of Go》

其他更多內容請轉到醒者呆的博客園


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM