目錄
基礎語法部分相關概念
基礎語法部分概念詳解
可見性
導包
內部包
運算符
轉義字符
函數
風格
函數花括號換行
代碼縮進
代碼間隔
花括號省略
三元表達式
數據類型部分相關概念
數據類型部分概念詳解
布爾類型
整型
浮點型
復數類型
字符類型
派生類型
零值
nil
常量
初始化
iota
枚舉
變量
聲明
賦值
匿名
交換
比較
代碼塊
輸入輸出
輸出
stdout
fmt
bufio
格式化
輸入
read
fmt
bufio
bufio.Reader
bufio.Scanner
條件控制
if else
else if
switch
label
goto
循環控制
for
打印九九乘法表
for range
break
continue
切片
數組
初始化
使用
切割
切片
初始化
使用
插入元素
刪除元素
拷貝
遍歷
clear
字符串
字面量
普通字符串
原生字符串
訪問
轉換
長度
拷貝
拼接
遍歷
映射表
初始化
訪問
存值
刪除
遍歷
清空
Set
注意
指針
創建
禁止指針運算
new和make
函數
聲明
匿名函數
閉包
延遲調用
循環
結構體
聲明
實例化
選項模式
組合
指針
標簽
空結構體
方法
Go學習的相關資料
// 學習使用的文檔
// 中文文檔:golang.halfiisland.com/essential/base/0.ready.html
// 官方文檔:go.dev/doc
案例一:使用Go打印Hello World
// 打印Hello World
// 需要導包,使用輸出函數,輸出Hello World// package是當前文件屬于那個包,入口文件都必須聲明為main包,入口函數是main函數,在自定義包和函數時命名應盡量避免與之重復
package main// import是導入關鍵字,后面跟著的是被導入的包名
import "fmt"// func是函數聲明關鍵字,用于聲明一個函數
func main() {// 調用fmt包下的Println函數進行輸出fmt.Println("Hello World")
}
基礎語法部分相關概念
包 | ????????在Go中,程序是通過將包鏈接在一起來構建的。在Go中進行導入的最小單位是包而不是.go文件; ????????包其實就是一個文件夾,英文就是package,包內共享所有變量,常量,以及所有定義的類型; ? ? ? ? 包的命名風格建議都是小寫,并且要盡量簡短。 |
可見性 | ????????包內共享所有變量,常量,以及所有定義的類型,但對于包外而言并不是這樣,有時候你并不想讓別人訪問某一個類型,所以就需要控制可見性; ? ? ? ? 如C語言中公共變量為Public,私有變量是Pravite等關鍵字進行定義; ? ? ? ? 但是在Go中沒有這些,在Go中控制可見性的方式非常簡單,規則為: ? ? ? ? ? ? ? ? 名稱大寫字母開頭,即為公有類型/變量/常量 ? ? ? ? ? ? ? ? 名稱小寫字母或下劃線開頭,即為私有類型/變量/常量 |
導入 | ????????導入一個包,就是導入這個包的所有共有的類型/變量/常量; ????????導入的語法: ? ? ? ? ? ? ? ? import "包名"(導入一個包) ? ? ? ? ? ? ? ? import ( ? ? ? ? ? ? ? ? ? ? ? ? “包1” ? ? ? ? ? ? ? ? ? ? ? ? “包2” ????????????????)? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? (導入多個包) |
內部包 | Go中約定,一個包內名為internal ?包為內部包,外部包將無法訪問內部包中的任何內容,否則的話編譯不通過 |
標識符 | 標識符就是一個名稱,用于包命名,函數命名,變量命名等等,命名規則如下:
|
字面量 | ? ? ? ? 字面量,就是用于表達源代碼中一個固定值的符號,也叫字面值 ? ? ? ? 整型字面量,允許使用下劃線 ? ? ? ? 浮點數字面量,通過不同的前綴可以表達不同進制的浮點數 ? ? ? ? 復數字面值 ? ? ? ? 字符字面量,字符字面量必須使用單引號括起來 ? ? ? ? 轉移字符 ? ? ? ? 字符串字面量,字符串字面量必須使用雙引號""括起來或者反引號(反引號不允許轉義) |
轉義字符 | Go中允許使用轉義字符 |
函數 | ? ? ? ? Go中的函數聲明方式通過func關鍵字來進行; ? ? ? ? 在Go中的函數參數類型后置,如func hello(name string); ? ? ? ? 函數允許多返回值的情況,而且可以帶有名字 |
風格 | ????????關于編碼風格這一塊 Go 是強制所有人統一同一種風格,Go 官方提供了一個格式化工具 ? ? ? ? 在詳解部分,有關于Go代碼風格的詳解。 |
基礎語法部分概念詳解
可見性
// Go中變量的定義,用const定義常量,用var定義變量,變量類型可以省略,默認是int類型
// 定義一個公共常量MyName和一個私有常量myName
// 我理解的這里就像電視劇臺詞,在家叫呂子喬,出門叫呂小布
const MyName = "呂小布"
const myName = "呂子喬"
導包
// 導包
// 導入一個包時,包名必須是唯一的,不能與當前文件中的變量名沖突
import "fmt"// 導入多個包
import ("fmt""os"
)// 如果導入的包名與當前文件中的變量名沖突,可以使用別名
import (f "fmt"o "os"
)// 匿名導入,另一種特殊的使用方法就是匿名導入包,匿名導入的包無法被使用,這么做通常是為了加載包下的init函數,但又不需要用到包中的類型,例如一個常見的場景就是注冊數據庫驅動
import (f "fmt"_ "mysql-driver" // 假設這是一個MySQL驅動包,導入后可以使用其init函數進行注冊
)
內部包
文件結構中可知,crash
包無法訪問baz
包中的類型,baz包為內部包
/home/user/go/src/crash/bang/ (go code in package bang)b.gofoo/ (go code in package foo)f.gobar/ (go code in package bar)x.gointernal/baz/ (go code in package baz)z.goquux/ (go code in package main)y.go
運算符
下面是 Go 語言中支持的運算符號的優先級排列,也可以前往參考手冊-運算符查看更多細節。
Precedence Operator5 * / % << >> & &^4 + - | ^3 == != < <= > >=2 &&1 ||
有一點需要稍微注意下,go 語言中沒有選擇將~
作為取反運算符,而是復用了^符號,當兩個數字使用^
時,例如a^b
,它就是異或運算符,只對一個數字使用時,例如^a
,那么它就是取反運算符。go 也支持增強賦值運算符,如下。
a += 1
a /= 2
a &^= 2
自增、自減問題
Go 語言中沒有自增與自減運算符,它們被降級為了語句statement
,并且規定了只能位于操作數的后方,所以不用再去糾結i++
和++i
這樣的問題。
a++ // 正確
++a // 錯誤
a-- // 正確
還有一點就是,它們不再具有返回值,因此a = b++
這類語句的寫法是錯誤的。
自增、自減不具有返回值。
轉義字符
\a U+0007 響鈴符號
\b U+0008 回退符號
\f U+000C 換頁符號
\n U+000A 換行符號
\r U+000D 回車符號
\t U+0009 橫向制表符號
\v U+000B 縱向制表符號
\\ U+005C 反斜杠轉義
\' U+0027 單引號轉義 (該轉義僅在字符內有效)
\" U+0022 雙引號轉義 (該轉義僅在字符串內有效)
函數
Go 中的函數聲明方式通過func
關鍵字來進行,跟大多數語言類似
func main() {println(1)
}
不過 Go 中的函數有兩個不同的點,第一個是參數類型后置,像下面這樣
func Hello(name string) {fmt.Println(name)
}
第二個不同的點就是多返回值,而且可以帶名字
func Pos() () (x, y float64) {...
}
風格
函數花括號換行
關于函數后的花括號到底該不該換行,幾乎每個程序員都能說出屬于自己的理由,在 Go 中所有的花括號都不應該換行
// 正確示例
func main() {fmt.Println("Hello 世界!")
}
如果你真的這么做了,像下面這樣
// 錯誤示例
func main()
{fmt.Println("Hello 世界!")
}
這樣的代碼連編譯都過不了,所以 Go 強制所有程序員花函數后的括號不換行。
代碼縮進
Go 默認使用Tab
也就是制表符進行縮進,僅在一些特殊情況會使用空格。
代碼間隔
Go 中大部分間隔都是有意義的,從某種程度上來說,這也代表了編譯器是如何看待你的代碼的,例如下方的數學運算
2*9 + 1/3*2
眾所周知,乘法的優先級比加法要高,在格式化后,*
符號之間的間隔會顯得更緊湊,意味著優先進行運算,而+
符號附近的間隔則較大,代表著較后進行運算。
花括號省略
在其它語言中的 if 和 for 語句通常可以簡寫,像下面這樣
for (int i=0; i < 10; i++) printf("%d", i)
但在 Go 中不行,你可以只寫一行,但必須加上花括號
for i := 0; i < 10; i++ {fmt.Println(i)}
三元表達式
Go 中沒有三元表達式,所以像下面的代碼是無法通過編譯的
var c = a > b ? a : b
數據類型部分相關概念
布爾類型 | ? ? ? ? 布爾類型只有真值和假值,bool類型只有true(真值)、false(假值) ? ? ? ? 數值無法替代布爾值進行邏輯判斷,bool和數值是兩種不同類型 |
整型 | ????????Go 中為不同位數的整數分配了不同的類型,主要分為無符號整型與有符號整型 |
浮點型 | ??IEEE-754 浮點數,主要分為單精度浮點數與雙精度浮點數 |
復數類型 | |
字符類型 | ? ? ? ? Go語言字符類型完全兼容UTF-8 |
派生類型 | |
零值 | ????????官方文檔中零值稱為zero value ,零值并不僅僅只是字面上的數字零,而是一個類型的空值或者說默認值更為準確 |
nil | ??nil 類似于其它語言中的none 或者null ,但并不等同。nil 僅僅只是一些引用類型的零值,并且不屬于任何類型,從源代碼中的nil 可以看出它僅僅只是一個變量 |
數據類型部分概念詳解
布爾類型
布爾類型只有真值和假值。
類型 | 描述 |
---|---|
bool | true 為真值,false 為假值 |
提示,在 Go 中,整數 0 并不代表假值,非零整數也不能代表真值,即數字無法代替布爾值進行邏輯判斷,兩者是完全不同的類型。
整型
Go 中為不同位數的整數分配了不同的類型,主要分為無符號整型與有符號整型。
序號 | 類型和描述 |
---|---|
uint8 | 無符號 8 位整型 |
uint16 | 無符號 16 位整型 |
uint32 | 無符號 32 位整型 |
uint64 | 無符號 64 位整型 |
int8 | 有符號 8 位整型 |
int16 | 有符號 16 位整型 |
int32 | 有符號 32 位整型 |
int64 | 有符號 64 位整型 |
uint | 無符號整型 至少 32 位 |
int | 整型 至少 32 位 |
uintptr | 等價于無符號 64 位整型,但是專用于存放指針運算,用于存放死的指針地址。 |
浮點型
IEEE-754
浮點數,主要分為單精度浮點數與雙精度浮點數。
類型 | 類型和描述 |
---|---|
float32 | IEEE-754 32 位浮點數 |
float64 | IEEE-754 64 位浮點數 |
復數類型
類型 | 描述 |
---|---|
complex128 | 64 位實數和虛數 |
complex64 | 32 位實數和虛數 |
字符類型
go 語言字符串完全兼容 UTF-8
類型 | 描述 |
---|---|
byte | 等價?uint8 ?可以表達 ANSCII 字符 |
rune | 等價?int32 ?可以表達 Unicode 字符 |
string | 字符串即字節序列,可以轉換為[]byte 類型即字節切片 |
派生類型
類型 | 例子 |
---|---|
數組 | [5]int ,長度為 5 的整型數組 |
切片 | []float64 ,64 位浮點數切片 |
映射表 | map[string]int ,鍵為字符串類型,值為整型的映射表 |
結構體 | type Gopher struct{} ,Gopher 結構體 |
指針 | *int ,一個整型指針。 |
函數 | type f func() ,一個沒有參數,沒有返回值的函數類型 |
接口 | type Gopher interface{} ,Gopher 接口 |
通道 | chan int ,整型通道 |
零值
官方文檔中零值稱為zero value
,零值并不僅僅只是字面上的數字零,而是一個類型的空值或者說默認值更為準確。
類型 | 零值 |
---|---|
數字類型 | 0 |
布爾類型 | false |
字符串類型 | "" |
數組 | 固定長度的對應類型的零值集合 |
結構體 | 內部字段都是零值的結構體 |
切片,映射表,函數,接口,通道,指針 | nil |
nil
nil
類似于其它語言中的none
或者null
,但并不等同。nil
僅僅只是一些引用類型的零值,并且不屬于任何類型,從源代碼中的nil
可以看出它僅僅只是一個變量。
var nil Type
并且nil == nil
這樣的語句是無法通過編譯的。
常量
常量的值,在定義后無法進行修改,其值來源于:
- 字面量
- 其他常量標識符
- 常量表達式
- 結果是常量的類型轉換
- iota
常量只能是基本數據類型,不能是
- 除基本類型以外的其它類型,如結構體,接口,切片,數組等
- 函數的返回值
常量的值無法被修改,否則無法通過編譯
初始化
常量的聲明需要用到const
關鍵字,常量在聲明時就必須初始化一個值,并且常量的類型可以省略,例如
const name string = "Jack" // 字面量const msg = "hello world" // 字面量const num = 1 // 字面量const numExpression = (1+2+3) / 2 % 100 + num // 常量表達式
如果僅僅只是聲明而不指定值,將會無法通過編譯(常量定義時就要進行賦值)
const name string
編譯器報錯
missing init expr for name
批量聲明常量可以用()
括起來以提升可讀性,可以存在多個()
達到分組的效果。
const (Count = 1Name = "Jack"
)const (Size = 16Len = 25
)
在同一個常量分組中,在已經賦值的常量后面的常量可以不用賦值,其值默認就是前一個的值,比如
const (A = 1B // 1C // 1D // 1E // 1
)
iota
內置的常量標識符,用于表示一個常量聲明的無類型整數序數,一般都是在括號中使用。
iota
是一個內置的常量標識符,通常用于表示一個常量聲明中的無類型整數序數,一般都是在括號中使用。
const iota = 0
看幾個使用案例
const (Num = iota // 0Num1 // 1Num2 // 2Num3 // 3Num4 // 4
)
也可以這么寫
const (Num = iota*2 // 0Num1 // 2Num2 // 4Num3 // 6Num4 // 8
)
還可以
const (Num = iota << 2*3 + 1 // 1Num1 // 13Num2 // 25Num3 = iota // 3Num4 // 4
)
通過上面幾個例子可以發現,iota
是遞增的,第一個常量使用iota
值的表達式,根據序號值的變化會自動的賦值給后續的常量,直到用新的const
重置,這個序號其實就是代碼的相對行號,是相對于當前分組的起始行號,看下面的例子
const (Num = iota<<2*3 + 1 // 1 第一行Num2 = iota<<2*3 + 1 // 13 第二行_ // 25 第三行Num3 //37 第四行Num4 = iota // 4 第五行_ // 5 第六行Num5 // 6 第七行
)
例子中使用了匿名標識符_
占了一行的位置,可以看到iota
的值本質上就是iota
所在行相對于當前const
分組的第一行的差值。而不同的const
分組則相互不會影響
枚舉
Go 語言沒有為枚舉單獨設計一個數據類型,不像其它語言通常會有一個enum
來表示。一般在 Go 中,都是通過自定義類型 + const + iota 來實現枚舉,下面是一個簡單的例子
type Season uint8const (Spring Season = iotaSummerAutumnWinter
)
這些枚舉實際上就是數字,Go 也不支持直接將其轉換為字符串,但我們可以通過給自定義類型添加方法來返回其字符串表現形式,實現Stringer
接口即可。
func (s Season) String() string {switch s {case Spring:return "spring"case Summer:return "summer"case Autumn:return "autumn"case Winter:return "winter"}return ""
}
這樣一來就是一個簡單的枚舉實現了。你也可以通過官方工具Stringer來自動生成枚舉。
不過它有以下缺點:
-
類型不安全,因為
Season
是自定義類型,可以通過強制類型轉換將其他數字也轉換成該類型Season(6)
-
繁瑣,字符串表現形式需要自己實現
-
表達能力弱,因為
const
僅支持基本數據類型,所以這些枚舉值也只能用字符串和數字來進行表示
變量
變量是用于保存一個值的存儲位置,變量的聲明會用到var關鍵字,允許其存儲的值在運行時動態的變化。每聲明一個變量,都會為其分配一塊內存以存儲對應類型的值
聲明
?go 中的類型聲明是后置的,變量的聲明會用到var
關鍵字,格式為var 變量名 類型名
,變量名的命名規則必須遵守標識符的命名規則。
Go語言中變量定義時,變量類型名在變量名的后面。
var intNum int
var str string
var char byte
當要聲明多個相同類型的變量時,可以只寫一次類型
var numA, numB, numC int
當要聲明多個不同類型的變量時,可以使用 () 進行包裹,可以存在多個()
var (name stringage intaddress string
)var (school stringclass int
)
一個變量如果只是聲明而不是賦值,那么變量存儲的值就是對應類型的零值。
賦值
賦值會用到運算符 =?
var name string
name = "jack"
也可以聲明的時候直接賦值
var name string = "jack"
或者這樣也可以
var name string
var age int
name, age = "jack", 1
第二種方式每次都要指定類型,可以使用官方提供的語法糖:短變量初始化,可以省略掉var
關鍵字和后置類型,具體是什么類型交給編譯器自行推斷。
name := "jack" // 字符串類型的變量。
雖然可以不用指定類型,但是在后續賦值時,類型必須保持一致,下面這種代碼無法通過編譯。
a := 1
a = "1"
還需要注意的是,短變量初始化不能使用nil
,因為nil
不屬于任何類型,編譯器無法推斷其類型。
name := nil // 無法通過編譯
短變量聲明可以批量初始化
name, age := "jack", 1
短變量聲明方式無法對一個已存在的變量使用,比如
// 錯誤示例
var a int
a := 1// 錯誤示例
a := 1
a := 2
但是有一種情況除外,那就是在賦值舊變量的同時聲明一個新的變量,比如
a := 1
a, b := 2, 2
這種代碼是可以通過編譯的,變量a
被重新賦值,而b
是新聲明的。
在 go 語言中,有一個規則,那就是所有在函數中的變量都必須要被使用,比如下面的代碼只是聲明了變量,但沒有使用它
func main() {a := 1
}
那么在編譯時就會報錯,提示你這個變量聲明了但沒有使用
a declared and not used
這個規則僅適用于函數內的變量,對于函數外的包級變量則沒有這個限制,下面這個代碼就可以通過編譯。
var a = 1func main() {}
匿名
用下劃線可以表示不需要某一個變量
比如os.Open
函數有兩個返回值,我們只想要第一個,不想要第二個,可以按照下面這樣寫
file, _ := os.Open("readme.txt")
未使用的變量是無法通過編譯的,當你不需要某一個變量時,就可以使用下劃線_
代替
交換
在Go中,如果想要交換兩個變量的值,不需要使用指針,可以使用賦值運算符直接進行交換,語法上看起來非常直觀
num1, num2 := 25, 36
num1, num2 = num2, num1
三個變量也是同樣如此
num1, num2, num3 := 25, 36, 49
num1, num2, num3 = num3, num2, num1
比較
變量之間的比較有一個大前提,那就是它們之間的類型必須相同,go 語言中不存在隱式類型轉換,像下面這樣的代碼是無法通過編譯的
func main() {var a uint64var b int64fmt.Println(a == b)
}
編譯器會告訴你兩者之間類型并不相同
invalid operation: a == b (mismatched types uint64 and int64)
所以必須使用強制類型轉換
func main() {var a uint64var b int64fmt.Println(int64(a) == b)
}
在沒有泛型之前,早期 go 提供的內置min
,max
函數只支持浮點數,到了 1.21 版本,go 才終于將這兩個內置函數用泛型重寫,現在可以使用min
函數比較最小值
minVal := min(1, 2, -1, 1.2)
使用max
函數比較最大值
maxVal := max(100, 22, -1, 1.12)
它們的參數支持所有的可比較類型,go 中的可比較類型有
- 布爾
- 數字
- 字符串
- 指針
- 通道 (僅支持判斷是否相等)
- 元素是可比較類型的數組(切片不可比較)(僅支持判斷是否相等)(僅支持相同長度的數組間的比較,因為數組長度也是類型的一部分,而不同類型不可比較)
- 字段類型都是可比較類型的結構體(僅支持判斷是否相等)
除此之外,還可以通過導入標準庫cmp
來判斷,不過僅支持有序類型的參數,在 go 中內置的有序類型只有數字和字符串。
import "cmp"func main() {cmp.Compare(1, 2)cmp.Less(1, 2)
}
代碼塊
在函數內部,可以通過花括號建立一個代碼塊,代碼塊彼此之間的變量作用域是相互獨立的。例如下面的代碼
func main() {a := 1{a := 2fmt.Println(a)}{a := 3fmt.Println(a)}fmt.Println(a)
}
它的輸出是
2
3
1
塊與塊之間的變量相互獨立,不受干擾,無法訪問,但是會受到父塊中的影響。
func main() {a := 1{a := 2fmt.Println(a)}{fmt.Println(a)}fmt.Println(a)
}
它的輸出是
2
1
1
輸入輸出
輸出
文件描述符 | 在
Go 中的輸入輸出都離不開它們 |
stdout | |
stdout
因為標準輸出本身就是一個文件,所以你可以直接將字符串寫入到標準輸出中
package mainimport "os"func main() {os.Stdout.WriteString("hello world!")
}
Go 有兩個內置的函數print
,println
,他們會將參數輸出到標準錯誤中,僅做調試用,一般不推薦使用。
package mainfunc main() {print("hello world!\n")println("hello world")
}
fmt
最常見的用法是使用fmt
包,它提供了fmt.Println
函數,該函數默認會將參數輸出到標準輸出中。
package mainimport "fmt"func main() {fmt.Println("hello world!")
}
它的參數支持任意類型,如果類型實現了String
接口也會調用String
方法來獲取其字符串表現形式,所以它輸出的內容可讀性比較高,適用于大部分情況,不過由于內部用到了反射,在性能敏感的場景不建議大量使用。
bufio
bufio
提供了可緩沖的輸出方法,它會先將數據寫入到內存中,積累到了一定閾值再輸出到指定的Writer
中,默認緩沖區大小是4KB
。在文件 IO,網絡 IO 的時候建議使用這個包。
func main() {writer := bufio.NewWriter(os.Stdout)defer writer.Flush()writer.WriteString("hello world!")
}
你也可以把它和fmt
包結合起來用
func main() {writer := bufio.NewWriter(os.Stdout)defer writer.Flush()fmt.Fprintln(writer, "hello world!")
}
格式化
Go 中的格式化輸出功能基本上由fmt.Printf
函數提供,如果你學過 C 系語言,一定會覺得很熟悉,下面是一個簡單的例子。
func main() {fmt.Printf("hello world, %s!", "jack")
}
下面是 Go 目前所有的格式化動詞。
0 | 格式化 | 描述 | 接收類型 |
---|---|---|---|
1 | %% | 輸出百分號% | 任意 |
2 | %s | 輸出string /[] byte 值 | string ,[] byte |
3 | %q | 格式化字符串,輸出的字符串兩端有雙引號"" | string ,[] byte |
4 | %d | 輸出十進制整型值 | 整型 |
5 | %f | 輸出浮點數 | 浮點 |
6 | %e | 輸出科學計數法形式 ,也可以用于復數 | 浮點 |
7 | %E | 與%e 相同 | 浮點 |
8 | %g | 根據實際情況判斷輸出%f 或者%e ,會去掉多余的 0 | 浮點 |
9 | %b | 輸出整型的二進制表現形式 | 數字 |
10 | %#b | 輸出二進制完整的表現形式 | 數字 |
11 | %o | 輸出整型的八進制表示 | 整型 |
12 | %#o | 輸出整型的完整八進制表示 | 整型 |
13 | %x | 輸出整型的小寫十六進制表示 | 數字 |
14 | %#x | 輸出整型的完整小寫十六進制表示 | 數字 |
15 | %X | 輸出整型的大寫十六進制表示 | 數字 |
16 | %#X | 輸出整型的完整大寫十六進制表示 | 數字 |
17 | %v | 輸出值原本的形式,多用于數據結構的輸出 | 任意 |
18 | %+v | 輸出結構體時將加上字段名 | 任意 |
19 | %#v | 輸出完整 Go 語法格式的值 | 任意 |
20 | %t | 輸出布爾值 | 布爾 |
21 | %T | 輸出值對應的 Go 語言類型值 | 任意 |
22 | %c | 輸出 Unicode 碼對應的字符 | int32 |
23 | %U | 輸出字符對應的 Unicode 碼 | rune ,byte |
24 | %p | 輸出指針所指向的地址 | 指針 |
使用fmt.Sprintf
或者fmt.Printf
來格式化字符串或者輸出格式化字符串,看幾個例子
fmt.Printf("%%%s\n", "hello world")fmt.Printf("%s\n", "hello world")
fmt.Printf("%q\n", "hello world")
fmt.Printf("%d\n", 2<<7-1)fmt.Printf("%f\n", 1e2)
fmt.Printf("%e\n", 1e2)
fmt.Printf("%E\n", 1e2)
fmt.Printf("%g\n", 1e2)fmt.Printf("%b\n", 2<<7-1)
fmt.Printf("%#b\n", 2<<7-1)
fmt.Printf("%o\n", 2<<7-1)
fmt.Printf("%#o\n", 2<<7-1)
fmt.Printf("%x\n", 2<<7-1)
fmt.Printf("%#x\n", 2<<7-1)
fmt.Printf("%X\n", 2<<7-1)
fmt.Printf("%#X\n", 2<<7-1)type person struct {name stringage intaddress string
}
fmt.Printf("%v\n", person{"lihua", 22, "beijing"})
fmt.Printf("%+v\n", person{"lihua", 22, "beijing"})
fmt.Printf("%#v\n", person{"lihua", 22, "beijing"})
fmt.Printf("%t\n", true)
fmt.Printf("%T\n", person{})
fmt.Printf("%c%c\n", 20050, 20051)
fmt.Printf("%U\n", '碼')
fmt.Printf("%p\n", &person{})
使用其它進制時,在%
與格式化動詞之間加上一個空格便可以達到分隔符的效果,例如
func main() {str := "abcdefg"fmt.Printf("%x\n", str)fmt.Printf("% x\n", str)
}
該例輸出的結果為
61626364656667
61 62 63 64 65 66 67
在使用數字時,還可以自動補零。比如
fmt.Printf("%09d", 1)
// 000000001
二進制同理
fmt.Printf("%09b", 1<<3)
// 000001000
錯誤情況
格式化字符數量 < 參數列表數量
fmt.Printf("", "") //%!(EXTRA string=)
格式化字符數量 > 參數列表數量
fmt.Printf("%s%s", "") //%!s(MISSING)
類型不匹配
fmt.Printf("%s", 1) //%!s(int=1)
缺少格式化動詞
fmt.Printf("%", 1) // %!(NOVERB)%!(EXTRA int=1)
輸入
read
你可以像直接讀文件一樣,讀取輸入內容,如下
func main() {var buf [1024]byten, _ := os.Stdin.Read(buf[:])os.Stdout.Write(buf[:n])
}
這樣用起來太麻煩了,一般不推薦使用
fmt
我們可以使用fmt
包提供的幾個函數,用起來跟 C 差不多。主要包括Scan、Scanln、Scanf
// 掃描從os.Stdin讀入的文本,根據空格分隔,換行也被當作空格
func Scan(a ...any) (n int, err error)// 與Scan類似,但是遇到換行停止掃描
func Scanln(a ...any) (n int, err error)// 根據格式化的字符串掃描
func Scanf(format string, a ...any) (n int, err error)
讀取兩個數字
func main() {var a, b intfmt.Scanln(&a, &b)fmt.Printf("%d + %d = %d\n", a, b, a+b)
}
讀取固定長度的數組
func main() {n := 10s := make([]int, n)for i := range n {fmt.Scan(&s[i])}fmt.Println(s)
}
1 2 3 4 5 6 7 8 9 10
[1 2 3 4 5 6 7 8 9 10]
bufio
bufio.Reader | 有大量輸入需要讀取時,使用bufio.Reader進行內容讀取 |
bufio.Scanner | 于Reader類似,但是Scanner是按行讀取 |
bufio.Reader
在有大量輸入需要讀取的時候,就建議使用bufio.Reader
來進行內容讀取
func main() {reader := bufio.NewReader(os.Stdin)var a, b intfmt.Fscanln(reader, &a, &b)fmt.Printf("%d + %d = %d\n", a, b, a+b)
}
bufio.Scanner
bufio.Scanner
與bufio.Reader
類似,不過它是按行讀取的。
func main() {scanner := bufio.NewScanner(os.Stdin)for scanner.Scan() {line := scanner.Text()if line == "exit" {break}fmt.Println("scan", line)}
}
結果如下
first line
scan first line
second line
scan second line
third line
scan third line
exit
條件控制
Go 中,條件控制語句總共有三種if
,switch
,select
if else
if else至多兩個判斷分支,語句格式如下
if expression {}
或者
if expression {}else {}
expression
必須是一個布爾表達式,即結果要么為真要么為假,必須是一個布爾值,例子如下:
func main() {a, b := 1, 2if a > b {b++} else {a++}
}
也可以把表達式寫的更復雜些,必要時為了提高可讀性,應當使用括號來顯式的表示誰應該優先計算。
func main() {a, b := 1, 2if a<<1%100+3 > b*100/20+6 { // (a<<1%100)+3 > (b*100/20)+6b++} else {a++}
}
同時if
語句也可以包含一些簡單的語句,例如:
func main() {if x := 1 + 1; x > 2 {fmt.Println(x)}
}
else if
else if
?語句可以在if else
的基礎上創建更多的判斷分支,語句格式如下:
if expression1 {}else if expression2 {}else if expression3 {}else {}
在執行的過程中每一個表達式的判斷是從左到右,整個if
語句的判斷是從上到下 。一個根據成績打分的例子如下,第一種寫法
func main() {score := 90var ans stringif score == 100 {ans = "S"} else if score >= 90 && score < 100 {ans = "A"} else if score >= 80 && score < 90 {ans = "B"} else if score >= 70 && score < 80 {ans = "C"} else if score >= 60 && score < 70 {ans = "E"} else if score >= 0 && score < 60 {ans = "F"} else {ans = "nil"}fmt.Println(ans)
}
第二種寫法利用了if
語句是從上到下的判斷的前提,所以代碼要更簡潔些。
func main() {score := 90var ans stringif score >= 0 && score < 60 {ans = "F"} else if score < 70 {ans = "D"} else if score < 80 {ans = "C"} else if score < 90 {ans = "B"} else if score < 100 {ans = "A"} else if score == 100 {ans = "S"}else {ans = "nil"}fmt.Println(ans)
}
switch
switch
語句也是一種多分支的判斷語句,語句格式如下:
switch expr {case case1:statement1case case2:statement2default:default statement
}
一個簡單的例子如下
func main() {str := "a"switch str {case "a":str += "a"str += "c"case "b":str += "bb"str += "aaaa"default: // 當所有case都不匹配后,就會執行default分支str += "CCCC"}fmt.Println(str)
}
還可以在表達式之前編寫一些簡單語句,例如聲明新變量
func main() {switch num := f(); { // 等價于 switch num := f(); true {case num >= 0 && num <= 1:num++case num > 1:num--fallthroughcase num < 0:num += num}
}func f() int {return 1
}
switch
語句也可以沒有入口處的表達式。
func main() {num := 2switch { // 等價于 switch true {case num >= 0 && num <= 1:num++case num > 1:num--case num < 0:num *= num}fmt.Println(num)
}
通過fallthrough
關鍵字來繼續執行相鄰的下一個分支。
func main() {num := 2switch {case num >= 0 && num <= 1:num++case num > 1:num--fallthrough // 執行完該分支后,會繼續執行下一個分支case num < 0:num += num}fmt.Println(num)
}
label
標簽語句,給一個代碼塊打上標簽,可以是goto
,break
,continue
的目標。例子如下:
func main() {A:a := 1B:b := 2
}
單純的使用標簽是沒有任何意義的,需要結合其他關鍵字goto來進行使用
goto
goto
將控制權傳遞給在同一函數中對應標簽的語句,示例如下:
func main() {a := 1if a == 1 {goto A} else {fmt.Println("b")}
A:fmt.Println("a")
}
在實際應用中goto
用的很少,跳來跳去的很降低代碼可讀性,性能消耗也是一個問題
循環控制
Go 中,有僅有一種循環語句:for
,Go 拋棄了while
語句,for
語句可以被當作while
來使用
for
語句格式如下
for init statement; expression; post statement {execute statement
}
當只保留循環條件時,就變成了while
。
for expression {execute statement
}
這是一個死循環,永遠也不會退出
for {execute statement
}
打印[0,20]之間的數字
/*打印[0,20]之間的數字
*/
import "fmt"var i intfunc main() {for i = 0; i <= 20; i++ {fmt.Println(i)}
}
打印九九乘法表
/*
打印九九乘法表
*/
var i, j intfunc main() {for i = 1; i <= 9; i++ {for j = 1; j <= 9; j++ {if i <= j {fmt.Printf("%d*%d =%d ", i, j, i*j)}}fmt.Println()}
}
for range
for range
可以更加方便的遍歷一些可迭代的數據結構,如數組,切片,字符串,映射表,通道。語句格式如下:
for index, value := range iterable {// body
}
index
為可迭代數據結構的索引,value
則是對應索引下的值,例如使用for range
遍歷一個字符串。
func main() {sequence := "hello world"for index, value := range sequence {fmt.Println(index, value)}
}
for range
也可以迭代一個整型值,字面量,常量,變量都是有效的。
for i := range 10 {fmt.Println(i)
}n := 10
for i := range n {fmt.Println(i)
}const n = 10
for i := range n {fmt.Println(i)
}
break
break
關鍵字會終止最內層的for
循環,結合標簽一起使用可以達到終止外層循環的效果,例子如下:這是一個雙循環
func main() {for i := 0; i < 10; i++ {for j := 0; j < 10; j++ {if i <= j {break}fmt.Println(i, j)}}
}
輸出
1 0
2 0
2 1
3 0
3 1
3 2
...
9 6
9 7
9 8
continue
continue
關鍵字會跳過最內層循環的本次迭代,直接進入下一次迭代,結合標簽使用可以達到跳過外層循環的效果,例子如下
func main() {for i := 0; i < 10; i++ {for j := 0; j < 10; j++ {if i > j {continue}fmt.Println(i, j)}}
}
輸出
0 0
0 1
0 2
0 3
0 4
0 5
0 6
0 7
0 8
0 9
...
7 7
7 8
7 9
8 8
8 9
9 9
切片
在Go中,切片和數組看起來長得一模一樣,但是在功能上有區別:
????????數組是固定長度的數據結構,長度被指定后是不能被改變的
? ? ? ? 切片不是定長的,切片在容量不夠時會自行擴容
數組
數組作為值類型,將數組作為參數傳遞給函數時,而Go語言中函數是值傳遞的,所以會將整個數組拷貝
如果事先已知要存放的數據的長度,且后續使用過程中不會有擴容的需求,就可以考慮使用數組,數組是值類型。而非引用,并不是指向頭部的指針
初始化
數組在聲明是長度只能是一個常量,不能是變量
// 正確示例
var a [5]int// 錯誤示例
l := 1
var b [l]int
先來初始化一個長度為 5 的整型數組
var nums [5]int
也可以用元素初始化
nums := [5]int{1, 2, 3}
可以讓編譯器自動推斷長度
nums := [...]int{1, 2, 3, 4, 5} //等價于nums := [5]int{1, 2, 3, 4, 5},省略號必須存在,否則生成的是切片,不是數組
還可以通過new
函數獲得一個指針
nums := new([5]int)
以上幾種方式都會給nums
分配一片固定大小的內存,區別只是最后一種得到的值是指針。
在數組初始化時,需要注意的是,長度必須為一個常量表達式,否則將無法通過編譯,常量表達式即表達式的最終結果是一個常量,錯誤例子如下:
length := 5 // 這是一個變量
var nums [length]int
length
是一個變量,因此無法用于初始化數組長度,如下是正確示例:
const length = 5
var nums [length]int // 常量
var nums2 [length + 1]int // 常量表達式
var nums3 [(1 + 2 + 3) * 5]int // 常量表達式
var nums4 [5]int // 最常用的
使用
只要有數組名和下標,就可以訪問數組中對應的元素。
fmt.Println(nums[0])
同樣的也可以修改數組元素
nums[0] = 1
還可以通過內置函數len
來訪問數組元素的數量
len(nums)
內置函數cap
來訪問數組容量,數組的容量等于數組長度,容量對于切片才有意義。
cap(nums)
切割
切割數組的格式為arr[startIndex:endIndex]
,切割的區間為左閉右開,例子如下:
nums := [5]int{1, 2, 3, 4, 5}
nums[1:] // 子數組范圍[1,5) -> [2 3 4 5]
nums[:5] // 子數組范圍[0,5) -> [1 2 3 4 5]
nums[2:3] // 子數組范圍[2,3) -> [3]
nums[1:3] // 子數組范圍[1,3) -> [2 3]
數組在切割后,就會變為切片類型
func main() {arr := [5]int{1, 2, 3, 4, 5}fmt.Printf("%T\n", arr)fmt.Printf("%T\n", arr[1:2])
}
輸出
[5]int
[]int
若要將數組轉換為切片類型,不帶參數進行切片即可,轉換后的切片與原數組指向的是同一片內存,修改切片會導致原數組內容的變化
func main() {arr := [5]int{1, 2, 3, 4, 5}slice := arr[:]slice[0] = 0fmt.Printf("array: %v\n", arr)fmt.Printf("slice: %v\n", slice)
}
輸出
array: [0 2 3 4 5]
slice: [0 2 3 4 5]
如果要對轉換后的切片進行修改,建議使用下面這種方式進行轉換
func main() {arr := [5]int{1, 2, 3, 4, 5}slice := slices.Clone(arr[:])slice[0] = 0fmt.Printf("array: %v\n", arr)fmt.Printf("slice: %v\n", slice)
}
輸出
array: [1 2 3 4 5]
slice: [0 2 3 4 5]
切片
切片在Go中應用的范圍比數組廣泛的多,用于存放不知道長度的數據,且后續使用過程中可能會頻繁插入和刪除元素
初始化
切片的初始化方式有以下集中
var nums []int // 值
nums := []int{1, 2, 3} // 值
nums := make([]int, 0, 0) // 值
nums := new([]int) // 指針
切片在外貌上與數組的區別僅僅只是初始化長度的區別。
數組在定義時,就要使用常量對數組長度進行固定,但是切片在初始化的時候不需要對長度進行限定,可以根據使用進行擴充
通常情況下,推薦使用make
來創建一個空切片,只是對于切片而言,make
函數接收三個參數:類型,長度,容量
切片在底層邏輯上的實現依舊是數組,是引用類型,可以簡單理解為指向底層數組的指針。
通過var nums []int
這種方式聲明的切片,默認值為nil
,所以不會為其分配內存,而在使用make
進行初始化時,建議預分配一個足夠的容量,可以有效減少后續擴容的內存消耗
使用
切片的基本使用與數組完全一致,區別只是切片可以動態變化長度
切片可以通過append
函數實現許多操作,函數簽名如下,slice
是要添加元素的目標切片,elems
是待添加的元素,返回值是添加后的切片。
func append(slice []Type, elems ...Type) []Type
首先創建一個長度為 0,容量為 0 的空切片,然后在尾部插入一些元素,最后輸出長度和容量。
nums := make([]int, 0, 0)// 用法: nums = append(要添加元素的初始切片, 要添加的元素)
nums = append(nums, 1, 2, 3, 4, 5, 6, 7)fmt.Println(len(nums), cap(nums)) // 7 8 可以看到長度與容量并不一致。
新 slice 預留的 buffer 容量 大小是有一定規律的。 在 golang1.18 版本更新之前網上大多數的文章都是這樣描述 slice 的擴容策略的: 當原 slice 容量小于 1024 的時候,新 slice 容量變成原來的 2 倍;原 slice 容量超過 1024,新 slice 容量變成原來的 1.25 倍。 在 1.18 版本更新之后,slice 的擴容策略變為了: 當原 slice 容量(oldcap)小于 256 的時候,新 slice(newcap)容量為原來的 2 倍;原 slice 容量超過 256,新 slice 容量 newcap = oldcap+(oldcap+3*256)/4
插入元素
切片添加元素是需要配合append進行插入的
現有切片如下,
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
從頭部插入元素
nums = append([]int{-1, 0}, nums...)
fmt.Println(nums) // [-1 0 1 2 3 4 5 6 7 8 9 10]
從中間下標 i 插入元素
nums = append(nums[:i+1], append([]int{999, 999}, nums[i+1:]...)...)
fmt.Println(nums) // i=3,[1 2 3 4 999 999 5 6 7 8 9 10]
從尾部插入元素,就是append
最原始的用法
nums = append(nums, 99, 100)
fmt.Println(nums) // [1 2 3 4 5 6 7 8 9 10 99 100]
刪除元素
切片元素的刪除需要結合append
函數來使用,現有如下切片
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
從頭部刪除 n 個元素
nums = nums[n:]
fmt.Println(nums) //n=3 [4 5 6 7 8 9 10]
從尾部刪除 n 個元素
nums = nums[:len(nums)-n]
fmt.Println(nums) //n=3 [1 2 3 4 5 6 7]
從中間指定下標 i 位置開始刪除 n 個元素
nums = append(nums[:i], nums[i+n:]...)
fmt.Println(nums)// i=2,n=3,[1 2 6 7 8 9 10]
刪除所有元素
nums = nums[:0]
fmt.Println(nums) // []
拷貝
切片在拷貝時需要確保目標切片有足夠的長度,例如
func main() {dest := make([]int, 0)src := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}fmt.Println(src, dest)fmt.Println(copy(dest, src))fmt.Println(src, dest)
}
[1 2 3 4 5 6 7 8 9] []
0
[1 2 3 4 5 6 7 8 9] []
將長度修改為 10,輸出如下
[1 2 3 4 5 6 7 8 9] [0 0 0 0 0 0 0 0 0 0]
9
[1 2 3 4 5 6 7 8 9] [1 2 3 4 5 6 7 8 9 0]
遍歷
切片的遍歷與數組完全一致,for
循環
func main() {slice := []int{1, 2, 3, 4, 5, 7, 8, 9}for i := 0; i < len(slice); i++ {fmt.Println(slice[i])}
}
for range
循環
func main() {slice := []int{1, 2, 3, 4, 5, 7, 8, 9}for index, val := range slice {fmt.Println(index, val)}
}
clear
Go1.21新增clear內置函數,clear會將切片內的所有值置為零值
package mainimport ("fmt"
)func main() {s := []int{1, 2, 3, 4}clear(s)fmt.Println(s)
}
輸出
[0 0 0 0]
如果想要清空切片,可以
func main() {s := []int{1, 2, 3, 4}// 清空切片s = s[:0:0]fmt.Println(s)
}
限制了切割后的容量,這樣可以避免覆蓋原切片的后續元素。
字符串
字面量
字符串有兩種字面表達方式,分別為普通字符串、原生字符串
普通字符串
普通字符串由 " " 雙引號表示,支持轉義,但是不支持多行書寫
"這是一個普通字符串\n"
"abcdefghijlmn\nopqrst\t\\uvwxyz"
這是一個普通字符串
abcdefghijlmn
opqrst \uvwxyz
原生字符串
原生字符串由? ` `??反引號表示,不支持轉義,支持多行書寫,原生字符串里面所有的字符都會原封不動的輸出,包括換行和縮進
`這是一個原生字符串,換行tab縮進,\t制表符但是無效,換行"這是一個普通字符串"結束
`
這是一個原生字符串,換行tab縮進,\t制表符但是無效,換行"這是一個普通字符串"結束
訪問
字符串本質上就是字節數組,所以字符串的訪問形式跟數組切片完全一致,例如訪問第一個元素
func main() {str := "this is a string"fmt.Println(str[0])
}
輸出是字節而不是字符
116
切割字符串
func main() {str := "this is a string"fmt.Println(string(str[0:4]))
}
this
嘗試修改字符串元素
func main() {str := "this is a string"str[0] = 'a' // 無法通過編譯fmt.Println(str)
}
main.go:7:2: cannot assign to str[0] (value of type byte)
雖然沒法修改字符串,但是可以覆蓋
func main() {str := "this is a string"str = "that is a string"fmt.Println(str)
}
that is a string
轉換
字符串可以轉換為字節切片,而字節切片或字節數組也可以轉換為字符串,例子如下:
func main() {str := "this is a string"// 顯式類型轉換為字節切片bytes := []byte(str)fmt.Println(bytes)// 顯式類型轉換為字符串fmt.Println(string(bytes))
}
字符串的內容是只讀的不可變的,無法修改,但是字節切片是可以修改的。
func main() {str := "this is a string"fmt.Println(&str)bytes := []byte(str)// 修改字節切片bytes = append(bytes, 96, 97, 98, 99)// 賦值給原字符串str = string(bytes)fmt.Println(str)
}
將字符串轉換成字節切片以后,兩者之間毫無關聯,因為 Go 會新分配一片內存空間給字節切片,再將字符串的內存復制過去,對字節切片進行修改不會對原字符串產生任何影響,這么做是為了內存安全。
在這種情況下,如果要轉換的字符串或字節切片很大,那么性能開銷就會很高。不過你也可以通過unsafe
庫來實現無復制轉換,不過背后的安全問題需要自己承擔,比如下面的例子,b1 和 s1 的地址是一樣的。
func main() {s1 := "hello world"b1 := unsafe.Slice(unsafe.StringData(s1), len(s1))fmt.Printf("%p %p", unsafe.StringData(s1), unsafe.SliceData(b1))
}
0xe27bb2 0xe27bb2
長度
字符串的長度是字節數組的長度,而不是字面量的長度,只是大多數時候都是ANSCII字符,剛好用一個字節表示,所以恰好與字面量長度相等,求字符串長度使用內置函數len
func main() {str := "this is a string" // 看起來長度是16str2 := "這是一個字符串" // 看起來長度是7fmt.Println(len(str), len(str2))
}
16 21
看起來中文字符串比英文字符串短,但是實際求得的長度卻比英文字符串長。這是因為在unicode
編碼中,一個漢字在大多數情況下占 3 個字節,一個英文字符只占一個字節,通過輸出字符串第一個元素可以看出結果:
func main() {str := "this is a string"str2 := "這是一個字符串"fmt.Println(string(str[0]))fmt.Println(string(str2[0]))fmt.Println(string(str2[0:3]))
}
t // 字母t
è // 意大利語
這 // 中文漢字
拷貝
類似數組切片的拷貝方式,字符串拷貝其實是字節切片拷貝,使用內置函數copy
func main() {var dst, src stringsrc = "this is a string"desBytes := make([]byte, len(src))copy(desBytes, src)dst = string(desBytes)fmt.Println(src, dst)
}
也可以使用strings.clone
函數,但其實內部實現都差不多
func main() {var dst, src stringsrc = "this is a string"dst = strings.Clone(src)fmt.Println(src, dst)
}
拼接
字符串的拼接使用+
操作符
func main() {str := "this is a string"str = str + " that is a int"fmt.Println(str)
}
也可以轉換為字節切片再進行添加元素
func main() {str := "this is a string"bytes := []byte(str)bytes = append(bytes, "that is a int"...)str = string(bytes)fmt.Println(str)
}
以上兩種拼接方式性能都很差,一般情況下可以使用,但如果對應性能有更高要求,可以使用strings.Builder
func main() {builder := strings.Builder{}builder.WriteString("this is a string ")builder.WriteString("that is a int")fmt.Println(builder.String())
}
this is a string that is a int
遍歷
在本文開頭就已經提到過,Go 中的字符串就是一個只讀的字節切片,也就是說字符串的組成單位是字節而不是字符。這種情況經常會在遍歷字符串時遇到,例如下方的代碼
func main() {str := "hello world!"for i := 0; i < len(str); i++ {fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))}
}
例子中分別輸出了字節的十進制形式和十六進制形式。
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
119,77,w
111,6f,o
114,72,r
108,6c,l
100,64,d
33,21,!
由于例子中的字符都是屬于 ASCII 字符,只需要一個字節就能表示,所以結果恰巧每一個字節對應一個字符。但如果包含非 ASCII 字符結果就不同了,如下
func main() {str := "hello 世界!"for i := 0; i < len(str); i++ {fmt.Printf("%d,%x,%s\n", str[i], str[i], string(str[i]))}
}
通常情況下,一個中文字符會占用 3 個字節,所以就可能會看到以下結果
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
228,e4,?
184,b8,?
150,96,?
231,e7,?
149,95,?
140,8c,?
33,21,!
按照字節來遍歷會把中文字符拆開,這顯然會出現亂碼。Go 字符串是明確支持 utf8 的,應對這種情況就需要用到rune
類型,在使用for range
進行遍歷時,其默認的遍歷單位類型就是一個rune
,例如下方代碼
func main() {str := "hello 世界!"for _, r := range str {fmt.Printf("%d,%x,%s\n", r, r, string(r))}
}
輸出如下
104,68,h
101,65,e
108,6c,l
108,6c,l
111,6f,o
32,20,
19990,4e16,世
30028,754c,界
33,21,!
rune
本質上是int32
的類型別名,unicode 字符集的范圍位于 0x0000 - 0x10FFFF 之間,最大也只有三個字節,合法的 UTF8 編碼最大字節數只有 4 個字節,所以使用int32
來存儲是理所當然,上述例子中將字符串轉換成[]rune
再遍歷也是一樣的道理,如下
func main() {str := "hello 世界!"runes := []rune(str)for i := 0; i < len(runes); i++ {fmt.Println(string(runes[i]))}
}
還可以使用uft8
包下的工具,例如
func main() {str := "hello 世界!"for i, w := 0, 0; i < len(str); i += w {r, width := utf8.DecodeRuneInString(str[i:])fmt.Println(string(r))w = width}
}
這兩個例子的輸出都是相同的。
映射表
映射表數據結構實現通常有兩種,哈希表(hash table)和搜索樹(search tree),區別在于前者無序,后者有序。在 Go 中,map的實現是基于哈希桶(也是一種哈希表),所以也是無序的
初始化
在 Go 中,map 的鍵類型必須是可比較的,比如string?
,int
是可比較的,而[]int
是不可比較的,也就無法作為 map 的鍵。初始化一個 map 有兩種方法,第一種是字面量,格式如下:
map[keyType]valueType{}
舉幾個例子
mp := map[int]string{0: "a",1: "a",2: "a",3: "a",4: "a",
}mp := map[string]int{"a": 0,"b": 22,"c": 33,
}
第二種方法是使用內置函數make
,對于 map 而言,接收兩個參數,分別是類型與初始容量,例子如下:
mp := make(map[string]int, 8)mp := make(map[string][]int, 10)
map 是引用類型,零值或未初始化的 map 可以訪問,但是無法存放元素,所以必須要為其分配內存。
func main() {var mp map[string]intmp["a"] = 1fmt.Println(mp)
}
panic: assignment to entry in nil map
訪問
訪問map的方式就像通過索引訪問一個數組一樣
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}fmt.Println(mp["a"])fmt.Println(mp["b"])fmt.Println(mp["d"])fmt.Println(mp["f"])
}
0
1
3
0
通過代碼可以觀察到,即使 map 中不存在"f"
這一鍵值對,但依舊有返回值。map 對于不存的鍵其返回值是對應類型的零值,并且在訪問 map 的時候其實有兩個返回值,第一個返回值對應類型的值,第二個返回值一個布爾值,代表鍵是否存在,例如:
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}if val, exist := mp["f"]; exist {fmt.Println(val)} else {fmt.Println("key不存在")}
}
對 map 求長度
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}fmt.Println(len(mp))
}
存值
map 存值的方式也類似數組存值一樣,例如:
func main() {mp := make(map[string]int, 10)mp["a"] = 1mp["b"] = 2fmt.Println(mp)
}
存值時使用已存在的鍵會覆蓋原有的值
func main() {mp := make(map[string]int, 10)mp["a"] = 1mp["b"] = 2if _, exist := mp["b"]; exist {mp["b"] = 3}fmt.Println(mp)
}
但是也存在一個特殊情況,那就是鍵為math.NaN()
時
func main() {mp := make(map[float64]string, 10)mp[math.NaN()] = "a"mp[math.NaN()] = "b"mp[math.NaN()] = "c"_, exist := mp[math.NaN()]fmt.Println(exist)fmt.Println(mp)
}
false
map[NaN:c NaN:a NaN:b]
通過結果可以觀察到相同的鍵值并沒有覆蓋,反而還可以存在多個,也無法判斷其是否存在,也就無法正常取值。因為 NaN 是 IEE754 標準所定義的,其實現是由底層的匯編指令UCOMISD
完成,這是一個無序比較雙精度浮點數的指令,該指令會考慮到 NaN 的情況,因此結果就是任何數字都不等于 NaN,NaN 也不等于自身,這也造成了每次哈希值都不相同。關于這一點社區也曾激烈討論過,但是官方認為沒有必要去修改,所以應當盡量避免使用 NaN 作為 map 的鍵。
刪除
func delete(m map[Type]Type1, key Type)
刪除一個鍵值對需要用到內置函數delete
,例如
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}fmt.Println(mp)delete(mp, "a")fmt.Println(mp)
}
map[a:0 b:1 c:2 d:3]
map[b:1 c:2 d:3]
需要注意的是,如果值為 NaN,甚至沒法刪除該鍵值對。
func main() {mp := make(map[float64]string, 10)mp[math.NaN()] = "a"mp[math.NaN()] = "b"mp[math.NaN()] = "c"fmt.Println(mp)delete(mp, math.NaN())fmt.Println(mp)
}
map[NaN:c NaN:a NaN:b]
map[NaN:c NaN:a NaN:b]
遍歷
通過for range
可以遍歷 map,例如
func main() {mp := map[string]int{"a": 0,"b": 1,"c": 2,"d": 3,}for key, val := range mp {fmt.Println(key, val)}
}
c 2
d 3
a 0
b 1
可以看到結果并不是有序的,也印證了 map 是無序存儲。值得一提的是,NaN 雖然沒法正常獲取,但是可以通過遍歷訪問到,例如
func main() {mp := make(map[float64]string, 10)mp[math.NaN()] = "a"mp[math.NaN()] = "b"mp[math.NaN()] = "c"for key, val := range mp {fmt.Println(key, val)}
}
NaN a
NaN c
NaN b
清空
在 go1.21 之前,想要清空 map,就只能對每一個 map 的 key 進行 delete
func main() {m := map[string]int{"a": 1,"b": 2,}for k, _ := range m {delete(m, k)}fmt.Println(m)
}
但是 go1.21 更新了 clear 函數,就不用再進行之前的操作了,只需要一個 clear 就可以清空
func main() {m := map[string]int{"a": 1,"b": 2,}clear(m)fmt.Println(m)
}
輸出
map[]
Set
Set 是一種無序的,不包含重復元素的集合,Go 中并沒有提供類似的數據結構實現,但是 map 的鍵正是無序且不能重復的,所以也可以使用 map 來替代 set。
func main() {set := make(map[int]struct{}, 10)for i := 0; i < 10; i++ {set[rand.Intn(100)] = struct{}{}}fmt.Println(set)
}
map[0:{} 18:{} 25:{} 40:{} 47:{} 56:{} 59:{} 81:{} 87:{}]
提示
一個空的結構體不會占用內存
注意
map 并不是一個并發安全的數據結構,Go 團隊認為大多數情況下 map 的使用并不涉及高并發的場景,引入互斥鎖會極大的降低性能,map 內部有讀寫檢測機制,如果沖突會觸發fatal error
。例如下列情況有非常大的可能性會觸發fatal
。
func main() {group.Add(10)// mapmp := make(map[string]int, 10)for i := 0; i < 10; i++ {go func() {// 寫操作for i := 0; i < 100; i++ {mp["helloworld"] = 1}// 讀操作for i := 0; i < 10; i++ {fmt.Println(mp["helloworld"])}group.Done()}()}group.Wait()
}
fatal error: concurrent map writes
在這種情況下,需要使用sync.Map
來替代。
指針
Go 保留了指針,在一定程度上保證了性能,同時為了更好的 GC 和安全考慮,又限制了指針的使用
創建
關于指針的兩個常用操作符,一個是取地址符 & ,另一個是解引用符 * 。
對一個變量進行取地址,會返回對應類型的指針。
func main() {num := 2p := &numfmt.Println(p)
}
指針存儲的是變量num
的地址
0xc00001c088
解引用符則有兩個用途,第一個是訪問指針所指向的元素,也就是解引用,例如
func main() {num := 2p := &numrawNum := *pfmt.Println(rawNum)
}
p
是一個指針,對指針類型解引用就能訪問到指針所指向的元素。還有一個用途就是聲明一個指針,例如:
func main() {var numPtr *intfmt.Println(numPtr)
}
<nil>
*int
即代表該變量的類型是一個int
類型的指針,不過指針不能光聲明,還得初始化,需要為其分配內存,否則就是一個空指針,無法正常使用。要么使用取地址符將其他變量的地址賦值給該指針,要么就使用內置函數new
手動分配,例如:
func main() {var numPtr *intnumPtr = new(int)fmt.Println(numPtr)
}
更多的是使用短變量
func main() {numPtr := new(int)fmt.Println(numPtr)
}
new
函數只有一個參數那就是類型,并返回一個對應類型的指針,函數會為該指針分配內存,并且指針指向對應類型的零值,例如:
func main() {fmt.Println(*new(string))fmt.Println(*new(int))fmt.Println(*new([5]int))fmt.Println(*new([]float64))
}
0
[0 0 0 0 0]
[]
禁止指針運算
在 Go 中是不支持指針運算的,也就是說指針無法偏移,先來看一段 C++代碼:
int main() {int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};int *p = &arr[0];cout << &arr << endl<< p << endl<< p + 1 << endl<< &arr[1] << endl;
}
0x31d99ff880
0x31d99ff880
0x31d99ff884
0x31d99ff884
可以看出數組的地址與數字第一個元素的地址一致,并且對指針加一運算后,其指向的元素為數組第二個元素。Go 中的數組也是如此,不過區別在于指針無法偏移,例如
func main() {arr := [5]int{0, 1, 2, 3, 4}p := &arrprintln(&arr[0])println(p)// 試圖進行指針運算p++fmt.Println(p)
}
這樣的程序將無法通過編譯,報錯如下
main.go:10:2: invalid operation: p++ (non-numeric type *[5]int)
提示
標準庫unsafe
提供了許多用于低級編程的操作,其中就包括指針運算,前往標準庫-unsafe了解細節。
new和make
在前面的幾節已經很多次提到過內置函數new
和make
,兩者有點類似,但也有不同,下面復習下。
func new(Type) *Type
- 返回值是類型指針
- 接收參數是類型
- 專用于給指針分配內存空間
func make(t Type, size ...IntegerType) Type
- 返回值是值,不是指針
- 接收的第一個參數是類型,不定長參數根據傳入類型的不同而不同
- 專用于給切片,映射表,通道分配內存。
下面是一些例子:
new(int) // int指針
new(string) // string指針
new([]int) // 整型切片指針
make([]int, 10, 100) // 長度為10,容量100的整型切片
make(map[string]int, 10) // 容量為10的映射表
make(chan int, 10) // 緩沖區大小為10的通道
函數
在Go中,函數是Go的基礎組成部分,也是核心
聲明
func 函數名([參數列表]) [返回值] {函數體
}
聲明函數有兩種辦法,一種是通過func
關鍵字直接聲明,另一種就是通過var
關鍵字來聲明,如下所示
func sum(a int, b int) int {return a + b
}var sum = func(a int, b int) int {return a + b
}
函數簽名由函數名稱,參數列表,返回值組成,下面是一個完整的例子,函數名稱為Sum
,有兩個int
類型的參數a
,b
,返回值類型為int
。
func Sum(a int, b int) int {return a + b
}
Go中的函數不支持重載。Go 的理念便是:如果簽名不一樣那就是兩個完全不同的函數,那么就不應該取一樣的名字,函數重載會讓代碼變得混淆和難以理解。這種理念是否正確見仁見智,至少在 Go 中你可以僅通過函數名就知道它是干什么的,而不需要去找它到底是哪一個重載
匿名函數
匿名函數就是沒有簽名的函數,例如下面的函數func (a,b int) int,這個函數是沒有名稱的,所以我們只能在它的函數體后緊跟括號來進行調用
func main() {func(a, b int) int {return a + b}(1, 2)
}
閉包
閉包(Closure)在一些語言里被稱為Lambda表達式,與匿名函數一起使用,閉包=函數+環境引用
利用閉包,可以非常簡單的實現一個求費波那契數列的函數,代碼如下
func main() {// 10個斐波那契數fib := Fib(10)for n, next := fib(); next; n, next = fib() {fmt.Println(n)}
}func Fib(n int) func() (int, bool) {a, b, c := 1, 1, 2i := 0return func() (int, bool) {if i >= n {return 0, false} else if i < 2 {f := ii++return f, true}a, b = b, cc = a + bi++return a, true}
}
輸出為
0
1
1
2
3
5
8
13
21
34
延遲調用
defer
關鍵字可以使得一個函數延遲一段時間調用,在函數返回之前這些 defer 描述的函數最后都會被逐個執行,看下面一個例子
func main() {Do()
}func Do() {defer func() {fmt.Println("1")}()fmt.Println("2")
}
輸出
2
1
因為 defer 是在函數返回前執行的,你也可以在 defer 中修改函數的返回值
func main() {fmt.Println(sum(3, 5))
}func sum(a, b int) (s int) {defer func() {s -= 10}()s = a + breturn
}
當有多個 defer 描述的函數時,就會像棧一樣先進后出的順序執行。
func main() {fmt.Println(0)Do()
}func Do() {defer fmt.Println(1)fmt.Println(2)defer fmt.Println(3)defer fmt.Println(4)fmt.Println(5)
}
0
2
5
4
3
1
延遲調用通常用于釋放文件資源,關閉網絡連接等操作,還有一個用法是捕獲panic
,不過這是錯誤處理一節中才會涉及到的東西
循環
一般建議不要在 for 循環中使用 defer
在 Go 中,每創建一個 defer,就需要在當前協程申請一片內存空間。假設在上面例子中不是簡單的 for n 循環,而是一個較為復雜的數據處理流程,當外部請求數突然激增時,那么在短時間內就會創建大量的 defer,在循環次數很大或次數不確定時,就可能會導致內存占用突然暴漲,這種我們一般稱之為內存泄漏
結構體
結構體可以存儲一組不同類型的數據,是一種復合類型。Go 拋棄了類與繼承,同時也拋棄了構造方法,刻意弱化了面向對象的功能,Go 并非是一個傳統 OOP 的語言,但是 Go 依舊有著 OOP 的影子,通過結構體和方法也可以模擬出一個類。下面是一個簡單的結構體的例子:
type Programmer struct {Name stringAge intJob stringLanguage []string
}
聲明
結構體聲明代碼:
type Person struct {name stringage int
}
結構體本身以及其內部的字段都遵守大小寫命名的暴露方式。對于一些類型相同的相鄰字段,可以不需要重復聲明類型,如下:
type Rectangle struct {height, width, area intcolor string
}
提示:在聲明結構體字段時,字段名不能與方法名重復
實例化
Go中不存在構造方法,大多數情況下采用如下的方式進行實例化結構體,初始化的時候像 map 一樣指定字段名稱再初始化字段值
programmer := Programmer{Name: "jack",Age: 19,Job: "coder",Language: []string{"Go", "C++"},
}
不過也可以省略字段名稱,當省略字段名稱時,就必須初始化所有字段,通常不建議使用這種方式,因為可讀性很糟糕。
programmer := Programmer{"jack",19,"coder",[]string{"Go", "C++"}}
如果實例化過程比較復雜,你也可以編寫一個函數來實例化結構體,就像下面這樣,你也可以把它理解為一個構造函數
type Person struct {Name stringAge intAddress stringSalary float64
}func NewPerson(name string, age int, address string, salary float64) *Person {return &Person{Name: name, Age: age, Address: address, Salary: salary}
}
不過 Go 并不支持函數與方法重載,所以你無法為同一個函數或方法定義不同的參數。如果你想以多種方式實例化結構體,要么創建多個構造函數,要么建議使用 options 模式
選項模式
選項模式是 Go 語言中一種很常見的設計模式,可以更為靈活的實例化結構體,拓展性強,并且不需要改變構造函數的函數簽名。假設有下面這樣一個結構體
type Person struct {Name stringAge intAddress stringSalary float64Birthday string
}
聲明一個PersonOptions
類型,它接受一個*Person
類型的參數,它必須是指針,因為我們要在閉包中對 Person 賦值。
type PersonOptions func(p *Person)
接下來創建選項函數,它們一般是With
開頭,它們的返回值就是一個閉包函數。
func WithName(name string) PersonOptions {return func(p *Person) {p.Name = name}
}func WithAge(age int) PersonOptions {return func(p *Person) {p.Age = age}
}func WithAddress(address string) PersonOptions {return func(p *Person) {p.Address = address}
}func WithSalary(salary float64) PersonOptions {return func(p *Person) {p.Salary = salary}
}
實際聲明的構造函數簽名如下,它接受一個可變長PersonOptions
類型的參數。
func NewPerson(options ...PersonOptions) *Person {// 優先應用optionsp := &Person{}for _, option := range options {option(p)}// 默認值處理if p.Age < 0 {p.Age = 0}......return p
}
這樣一來對于不同實例化的需求只需要一個構造函數即可完成,只需要傳入不同的 Options 函數即可
func main() {pl := NewPerson(WithName("John Doe"),WithAge(25),WithAddress("123 Main St"),WithSalary(10000.00),)p2 := NewPerson(WithName("Mike jane"),WithAge(30),)
}
函數式選項模式在很多開源項目中都能看見,gRPC Server 的實例化方式也是采用了該設計模式。函數式選項模式只適合于復雜的實例化,如果參數只有簡單幾個,建議還是用普通的構造函數來解決
組合
在 Go 中,結構體之間的關系是通過組合來表示的,可以顯式組合,也可以匿名組合,后者使用起來更類似于繼承,但本質上沒有任何變化。例如:
顯式組合的方式
type Person struct {name stringage int
}type Student struct {p Personschool string
}type Employee struct {p Personjob string
}
在使用時需要顯式的指定字段p
student := Student{p: Person{name: "jack", age: 18},school: "lili school",
}
fmt.Println(student.p.name)
而匿名組合可以不用顯式的指定字段
type Person struct {name stringage int
}type Student struct {Personschool string
}type Employee struct {Personjob string
}
匿名字段的名稱默認為類型名,調用者可以直接訪問該類型的字段和方法,但除了更加方便以外與第一種方式沒有任何的區別。
student := Student{Person: Person{name: "jack",age: 18},school: "lili school",
}
fmt.Println(student.name)
指針
對于結構體指針而言,不需要解引用就可以直接訪問結構體的內容,例子如下:
p := &Person{name: "jack",age: 18,
}
fmt.Println(p.age,p.name)
在編譯的時候會轉換為(*p).name
?,(*p).age
,其實還是需要解引用,不過在編碼的時候可以省去,算是一種語法糖
標簽
結構體標簽是一種元編程的形式,結合反射可以做出很多奇妙的功能,格式如下
`key1:"val1" key2:"val2"`
標簽是一種鍵值對的形式,使用空格進行分隔。結構體標簽的容錯性很低,如果沒能按照正確的格式書寫結構體,那么將會導致無法正常讀取,但是在編譯時卻不會有任何的報錯,下方是一個使用示例。
type Programmer struct {Name string `json:"name"`Age int `yaml:"age"`Job string `toml:"job"`Language []string `properties:"language"`
}
結構體標簽最廣泛的應用就是在各種序列化格式中的別名定義,標簽的使用需要結合反射才能完整發揮出其功能
空結構體
空結構體沒有字段,不占用內存空間,我們可以通過unsafe.SizeOf
函數來計算占用的字節大小
func main() {type Empty struct {}fmt.Println(unsafe.Sizeof(Empty{}))
}
輸出
0
空結構體的使用場景有很多,比如之前提到過的,作為map
的值類型,可以將map
作為set
來進行使用,又或者是作為通道的類型,表示僅做通知類型的通道
方法
方法與函數的區別在于,方法擁有接收者,而函數沒有,且只有自定義類型能夠擁有方法。先來看一個例子。
type IntSlice []intfunc (i IntSlice) Get(index int) int {return i[index]
}
func (i IntSlice) Set(index, val int) {i[index] = val
}func (i IntSlice) Len() int {return len(i)
}
先聲明了一個類型IntSlice
,其底層類型為[]int
,再聲明了三個方法Get
,Set
和Len
,方法的長相與函數并無太大的區別,只是多了一小段(i IntSlice)
?。i
就是接收者,IntSlice
就是接收者的類型,接收者就類似于其他語言中的this
或self
,只不過在 Go 中需要顯示的指明。
func main() {var intSlice IntSliceintSlice = []int{1, 2, 3, 4, 5}fmt.Println(intSlice.Get(0))intSlice.Set(0, 2)fmt.Println(intSlice)fmt.Println(intSlice.Len())
}
方法的使用就類似于調用一個類的成員方法,先聲明,再初始化,再調用
接收者分為兩種類型:值接收者 和 指針接收者