本章知识点:

  • 数组与切片
  • map(字典)
  • struct(结构体)
  • 指针
  • 泛型

数组与切片

在 Go 中,数组是固定长度的,而切片是动态的、是对数组的抽象。

数组

不管在Java还是Go似乎都不太常用,比较常用List或者Go中的切片。其语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import "testing"

func Test_Array(t *testing.T) {

// 数组时值类型,当一个数组被赋值给另一个数组时,实际上是创建了一个新的数组,并将原数组的值复制到新数组中。这意味着对新数组的修改不会影响原数组,反之亦然。
// 定义一个长度为5的int数组,其默认值为0
var arr1 [5]int
t.Log(arr1)
// 定义一个长度为3的字符串数组,默认值为""
var arr2 [3]string
t.Log(arr2)

// 初始化

//字面量初始化
arr3 := [3]string{"a", "b", "c"}
t.Log(arr3)

//省略长度,让编译器自动推断
arr4 := []string{"a", "b", "c"}
t.Log(len(arr4))

//指定下标初始化
arr5 := [5]int{1: 100, 2: 200}
t.Log(arr5)
}

访问数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func Test_ArrayOpera(t *testing.T) {
// 定义一个长度为5的int数组,其默认值为0
var arr1 [5]int
// 给指定下表赋值/或者是修改
arr1[0] = 10
arr1[3] = 88
t.Log(arr1)
// 获取数组指定下标的值
t.Log(arr1[3])
//获取数组长度
t.Log(len(arr1))

//二维数组
var arrab [2][3]string
arrab[0][0] = "a"
arrab[0][1] = "b"
arrab[0][2] = "c"
// invalid argument: index 3 out of bounds [0:3]
// arrab[0][3] = "c"
arrab[1][0] = "d"
t.Log(arrab)

//二维数组初始化
arrab1 := [2][3]string{
{"a", "b", "c"},
// 必须有,结尾,否则会报错:syntax error: unexpected newline, expecting }
{"a1", "b1", "c1"},
}
t.Log(arrab1)

//获取指定下标
t.Log(arrab1[0][1])
}

对于数组的便利,下一章会有介绍。

关于数组,还有一个最主要的特性:

Go 中数组是值类型,赋值或传参会复制整个数组,处理大数据时非常低效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Test_ArrayCopy(t *testing.T) {
arr := [3]int{1, 2, 3}
// 完整的拷贝
b := arr
b[0] = 6
t.Log(arr, b)
//调用后,不影响arr的值
modifyArray(arr)
t.Log(arr)
}

func modifyArray(arr [3]int) {
//这里修改的是副本,不影响原来的数组
arr[0] = 6
}

数组的长度必须是常量,且是类型的一部分。[3]int[5]int 在编译器看来是完全不同的两种类型,不能互相赋值或比较。

1
2
3
4
5
6
7
8
9
10
11
func Test_ArrayCompare(t *testing.T) {
arr1 := [3]int{1, 2, 3}
arr2 := [3]int{1, 2, 3}
arr3 := [3]int{1, 2, 4}
t.Log(arr1 == arr2)
t.Log(arr1 == arr3)

//arr4 := [4]int{1, 2, 3, 4}
// 无效运算: arr1 == arr4(类型 [3]int 和 [4]int 不匹配)
//t.Log(arr1 == arr4)
}

切片(重点)

切片是 Go 最核心的设计之一。它不是动态数组,而是对底层数组的描述符

切片三要素:

  • 指针(指向底层数组的起始位置)
  • 长度(len,当前可见的元素个数)
  • 容量(cap,从起始位置到底层数组末尾的元素个数)

它的底层结构体runtime/slice.go:

1
2
3
4
5
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 长度:当前切片内的元素个数
cap int // 容量:从切片指针开始到数组末尾的元素个数
}

当你传递切片时,实际复制的是这个 header,底层数组是共享的。

下面是切片的初始化方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func Test_Slice(t *testing.T) {
//nil切片
var s1 []int
// 空切片
s2 := []int{}
//字面量创建
s3 := []int{1, 2, 3}
//[] [] [1 2 3]
t.Log(s1, s2, s3)

// 使用make创建 长度为3,容量为10
s4 := make([]int, 3, 10)
// 长度为3,容量为3
s5 := make([]int, 3)
t.Log(s4, s5)

//从数组中创建
arr := [4]int{1, 2, 3, 4}
s6 := arr[1:3]
t.Log(s6)

//使用new创建 返回 *[]int,需要解引用
s7 := new([]int)
//&[]
t.Log(s7)

//俄罗斯套娃,从切片中创建切片
s8 := s3[1:3]
t.Log(s8)
}

看样子,切片很像Java中的List,顺序存储、动态扩容。它和数组的区别是:

特性 数组 切片
长度 固定,是类型的一部分 可变
类型 值类型 引用类型(底层是结构体)
传参开销 复制整个数组 复制头信息(24字节)
动态扩展 不支持 支持 append
比较 支持 == 不支持 ==(只能和 nil 比)

切片表达式

规则:

  • 0 <= low <= high <= max <= cap(底层数组)
  • 省略 low 默认为 0
  • 省略 high 默认为 len
  • 完整形式 [low:high:max] 限制新切片的容量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Test_SliceReg(t *testing.T) {
//切片是引用类型,当一个切片被赋值给另一个切片时,实际上是创建了一个新的切片,但它们指向同一个底层数组。这意味着对新切片的修改会影响原切片,反之亦然。
// 语法 slice[low:high:max]
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

s1 := arr[2:5]
// 2 3 4 len=3 cap=8
t.Log(s1)

s2 := arr[2:5:7]
// 2, 3, 4 len=3 cap=5 max-low
t.Log(s2)

s3 := arr[:5] //省略 low
// 0,1,2,3,4
t.Log(s3)

s4 := arr[5:] //省略 high
// 5,6,7,8,9
t.Log(s4)

s5 := arr[:] // 完整拷贝
// 0,1,2,3,4,5,6,7,8,9
t.Log(s5)
}

切片Append

append函数会返回一个新的切片,新的切片可能指向同一个底层数组,也可能指向一个新的底层数组,这取决于原切片的容量和追加元素的数量。

1
2
3
4
5
6
7
8
9
10
func Test_SliceAppend(t *testing.T) {
s1 := []int{1, 2, 3}
// 追加一个
s2 := append(s1, 4)
// 追加多个
s3 := append(s1, 5, 6)
// 追加另一个切片
s4 := append(s1, []int{7, 8, 9}...)
t.Log(s1, s2, s3, s4)
}

如果容量足够,就会出现共享底层数组的情况:

1
2
3
4
5
6
func Test_ShareArray(t *testing.T) {
a := []int{1, 2, 3, 4, 5}
b := a[:3]
b = append(b, 99)
t.Log(a, b)
}

输出如下:

1
2
=== RUN   Test_ShareArray
array_test.go:206: [1 2 3 99 5] [1 2 3 99]

解决方案:使用完整切片表达式限制容量

1
2
3
4
5
6
7
8
func Test_ShareArray(t *testing.T) {
a := []int{1, 2, 3, 4, 5}
//b := a[:3]
//b = append(b, 99)
b := a[:3:3] // len=3,cap=3,append会创建新底层数组,不影响a
b = append(b, 99)
t.Log(a, b)
}

此时,输出如下:

1
2
=== RUN   Test_ShareArray
array_test.go:208: [1 2 3 4 5] [1 2 3 99]

还可能出现append以后,原切片可能被覆盖的情况

1
2
3
4
5
6
func Test_OverwriteSlice(t *testing.T) {
s := make([]int, 3, 5)
s1 := append(s, 1)
s2 := append(s, 2)
t.Log(s, s1, s2)
}

输出如下:

1
2
=== RUN   Test_OverwriteSlice
array_test.go:215: [0 0 0] [0 0 0 2] [0 0 0 2]

此时可以看到,s2覆盖了s1的最后一个元素。原因就是s1和s2指向了同一个底层数组。

解决方案:在 append 前对原切片做一次完整拷贝,让每次 append 独占底层数组

1
2
3
4
5
6
7
8
func Test_OverwriteSlice(t *testing.T) {
s := make([]int, 3, 5)
//s1 := append(s, 1)
//s2 := append(s, 2)
s1 := append(slices.Clone(s), 1)
s2 := append(slices.Clone(s), 2)
t.Log(s, s1, s2)
}

切片扩容

既然涉及到自动扩容,那就要了解下扩容的原理:

当执行 s = append(s, val) 时,Go 的扩容策略在不同版本有所微调(以 Go 1.25 为准):

  1. 如果新长度大于容量的 2 倍,直接使用新长度作为新容量。
  2. 否则,如果旧容量 < 256,翻倍。
  3. 如果旧容量 >= 256,则增加 (old_cap + 3*256) / 4,直到满足要求。
  4. 注意:扩容会触发内存重新分配和数据拷贝,旧底层数组将被 GC 回收。

扩容可能导致底层数组重新分配!此时原切片和新切片不再共享数据。

例如:

1
2
3
4
5
a := []int{1, 2, 3}
b := append(a, 4)

// 如果 a 的容量足够,b 和 a 共享底层数组
// 如果容量不够,b 指向全新数组,a 不受影响

切片的拷贝

copy函数会将源切片中的元素复制到目标切片中,复制的元素数量取决于源切片和目标切片的长度。copy函数返回实际复制的元素数量。

1
2
3
4
5
6
7
func Test_SliceCopy(t *testing.T) {
ss := []int{1, 2, 3, 4, 5}
sd := make([]int, 3)
// 将ss(src)切片中的前3个元素复制到sd(dst)切片中
n := copy(sd, ss)
t.Log(sd, n)
}

特点:

  • 复制个数 = min(len(src), len(dst))
  • 两个切片可以重叠
  • 可用于切片截断:copy(s, s[i:])

常用技巧:

1
2
3
4
5
6
7
8
9
10
11
12
// 删除索引 i 的元素
copy(s[i:], s[i+1:])
s = s[:len(s)-1]

// 在开头插入元素(效率低,需要移动所有元素)
s = append([]int{x}, s...)

//解决大切片不释放内存的问题
// 假设 data是一个超级大的切片
small := make([]int, 10)
//只copy出需要的部分,否则只要small存在,data的整个底层数组都不会被GC
copy(small, data)

切片是引用类型,函数内修改会影响原切片(注意区分修改元素和改变切片本身):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func Test_SliceArgs(t *testing.T) {
s := []int{1, 2, 3, 4, 5}
modifySlice(s)
t.Log(s)

modifySlice1(s)
t.Log(s)

appendValue(&s, 8)
t.Log(s)
}

func modifySlice(s []int) {
// 会改变原始切片的值
s[0] = 99
}

func modifySlice1(s []int) {
// 不会改变原始切片的值
s = append(s, 6)
}

// 如果想要实现切片的append,需要传递指针
func appendValue(s *[]int, v int) {
if s == nil {
return
}
*s = append(*s, v)
}

多维切片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func Test_MultiSlice(t *testing.T) {
// 多维切片
var s [][]int
// 切片的每行长度可以不同(俗称锯齿数组)
rows, cols := 3, 4
s = make([][]int, rows, cols)
for i := range s {
s[i] = make([]int, cols)
}
s[1][2] = 99

t.Log(s)
// [[0 0 0 0] [0 0 99 0] [0 0 0 0]]
// 切片的每行长度可以不同(俗称锯齿数组)
s2 := [][]int{
{1},
{1, 2},
{1, 2, 3},
}
t.Log(s2)
// [[1] [1 2] [1 2 3]]
}

map(字典)

map 是 Go 内置的哈希表类型,用于存储 键值对。其语法结构为

1
map[KeyType]ValueType

申明与初始化

nil map

1
2
// nil map,不能写入
var m1 map[string]int

map 的零值是 nil

1
2
3
var m1 map[string]int
t.Logf("m1 == nil 的结果是 : %v", m1 == nil)
// m1 == nil 的结果是 : true

nil不能写入,但可以有以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Test_NilMap(t *testing.T) {
var m map[string]int
// nil map可以获取值
v := m["x"]
t.Logf("nil map get key : %v", v)
// nil map 可以作为判断
_, ok := m["x"]
t.Logf("nil map get status : %v", ok)
// nil map可以删除元素
delete(m, "x")
// nil map可以clear
clear(m)
// nil map可以遍历
for i := range m {
t.Log(i)
}
}

通过make创建map

1
2
3
m := make(map[string]int)
// 或者指定容量
m1:= make(map[string]int,1000)

注意:这个 1000容量提示,不是最大容量限制。map会自动扩容。

通过字面量创建map

1
2
3
4
m := map[string]int {
"a":1,
"b":2
}

当然,也可以创建一个空的map(注意:空map和nil map是两码事),空map可以写入数据。

1
m := map[string]int{}

此处附上nil map空map对比:

nil map 空map map[k]v{}
读取 返回零值 返回零值
写入 panic 正常
删除 panic 正常
len 0 0

map的CRUD

增加/修改

1
2
3
4
5
m := map[string]int{}
// 新增一条数据
m["age"] = 18
// 将原有的数据修改为28
m["age"] = 28

总结下来就是:

  • 如果key不存在,就是新增。
  • 如果key存在,就是修改。

读取

1
2
3
4
5
6
7
v := m["age"] 
// 此时输出28
t.Log(v)
// 如果key不存在,则返回value类型零值
v1 := m["age1"]
// 输出0
t.Log(v)

判断key是否存在(核心写法)

1
2
3
4
5
6
v,ok := m["age"]
if ok {
t.Log("存在",v)
} else {
t.Log("不存在")
}

删除元素

1
2
3
delete(m,"age")
// 即使key不存在也不会报错,所以nil map使用delete也不会报错。
delete(m, "not-exist")

清空map

此特性是Go 1.21之后内置的函数clear()

1
clear(m)

删除map中的所有元素,对于nil map调用也安全的。

获取长度

1
2
// 返回的是当前key-value对的数量。
len(m)

遍历map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
m := map[string]int{
"key1":100,
"key2":150,
"key3":200,
}

for k,v := range m {
t.Logf("key = %v, value = %v",k,v)
}

// 只遍历key
for k:= range m {
t.Logf("key = %v",k)
}

// 只便利value
for _,v := range m {
t.Logf("value = %v",v)
}

注意:map 遍历顺序是不确定

那么如何确保输出顺序是稳定的呢?那就是先取key,在排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func Test_LoopMapSort(t *testing.T) {
m := map[string]int{
"key1": 100,
"key2": 150,
"key3": 200,
}

for k, v := range m {
t.Log(k, v)
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
t.Logf("key = %v, value = %v", k, m[k])
}
}

多次执行,观察输出:

1
2
3
4
5
6
7
=== RUN   Test_LoopMapSort
map_test.go:71: key3 200
map_test.go:71: key1 100
map_test.go:71: key2 150
map_test.go:80: key = key1, value = 100
map_test.go:80: key = key2, value = 150
map_test.go:80: key = key3, value = 200

对于Go语言的map,它对key还是略微有一些要求的,那就是map的key必须是可比较的类型,也就是说可以使用==或者!=比较的类型,以下类型都可以作为map的key:

  • string
  • int
  • bool
  • float64
  • complex64
  • pointer
  • channel
  • interface
  • array
  • struct

其中arraystruct的元素/字段也必须为可比较。

下面三个是不能作为key的类型:

  • slice
  • map
  • func
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func Test_MapKeyType(t *testing.T) {
m := map[[2]int]string{
{1, 2}: "1",
{3, 4}: "2",
}

m1 := map[struct {
Name string
Age int
}]string{
{"zhangsan", 18}: "1",
{"lisi", 18}: "2",
}

t.Log(m, m1)

//无效的映射键类型: 必须为键类型完全定义比较运算符 == 和 !=
// m2 := map[func()]string{}
}

至于slice不能作为map的key,主要原因是slice不能使用==比较

1
2
3
4
a := []int{1, 2, 3}
b := []int{1, 2, 3}
//无效运算: a == b (在 []int 中未定义运算符 ==)
t.Log(a == b)

因为slice底层有三个属性指针长度容量,Go语言没有定义两个slice内容相等的比较语义。所以slice不能作为map的key。如果想用 slice 内容作为 key,可以转成 string 或 array。

1
2
3
4
5
6
7
8
m3 := map[string]int{}
key := fmt.Sprint([]int{1, 2, 3})
m3[key] = 11
t.Log(m3)
// 或者固定长度
m4 := map[[3]int]string{}
m4[[3]int{1, 2, 3}] = "ok"
t.Log(m4)

还需要注意的是,两个map不能直接比较,但任意一个map可以和nil map比较,如果需要比较两个map,可以使用maps.Equal(m1,m2)来实现(注意,此时需要valuie也是可比较的才行),或者根据业务需求,自己定义比较逻辑。

map 是引用类型吗?

Go 语言规范并没有把 map 正式定义为“引用类型”这一类别,但规范明确说明:非 nil 的 map 值包含对底层数据的引用;具体来说,map 值是对实现相关数据结构的引用。因此从使用效果看,map 赋值或传参会复制这个引用,而不会复制整张 map。

1
2
3
4
5
6
7
8
9
func Test_Map1(t *testing.T) {
m1 := map[string]int{
"a": 1,
}
m2 := m1
m2["a"] = 100
// map[a:100]
t.Log(m1)
}

或者传参也是类似:

1
2
3
4
5
6
7
8
9
func TestMapArg(t *testing.T) {
m := make(map[string]int)
changeMap(m)
t.Log(m)
}

func changeMap(m map[string]int) {
m["x"] = 1
}

但是如果在函数里让参数指向一个新 map,不会影响外部变量本身:

1
2
3
4
5
6
7
8
9
10
11
func TestMapArg(t *testing.T) {
m := make(map[string]int)
changeMap1(m)
t.Log(m)
}

func changeMap1(m map[string]int) {
// 新的map
m = make(map[string]int)
m["y"] = 1
}

如果想替换外部 map,需要返回新 map,或传 *map[...]...

1
2
3
4
5
6
7
8
// 更推荐这种写法
func reset() map[string]int {
return make(map[string]int)
}
// 只有在必须修改调用者持有的那个变量时,才考虑 *map[...]...
func reset1(m *map[string]int) {
*m = make(map[string]int)
}

其他关于map线程安全其他map常用api或编码技巧等在后续学习中逐渐补充。

struct(结构体)

struct 是 Go 中用于组合多个字段的数据类型,类似于Java中的classs。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type User struct {
//同类型的可以合并写
Id, Age int
Name string
}

func TestStruct(t *testing.T) {
u := User{}
u.Age = 18
u.Name = "John"
u.Id = 1
t.Log(u)

u1 := User{
Id: 2,
Age: 25,
Name: "Tom",
}
t.Log(u1)
}

struct 的零值是:所有字段都是各自类型的零值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type User struct {
//同类型的可以合并写
Id, Age int
Name string
Active bool
Tags []string
}

func TestStruct(t *testing.T) {
u2 := User{}
// {0 0 false []}
t.Log(u2.Id) // 0
t.Log(u2.Age) //0
t.Log(u2.Name) // “”
t.Log(u2.Tags) // []
t.Log(u2.Active)// false
t.Log(u2.Tags == nil) // true
}

struct 本身通常不为 nil,除非你使用的是 *Struct 指针。

初始化

对于struct,初始化方式有两种方式:

按照字段名初始化(推荐)

1
2
3
4
5
u1 := User{
Id: 2,
Age: 25,
Name: "Tom",
}

日常编码应当使用这种方式进行初始化。

按照字段顺序初始化

1
u2:= User{3,30,"Jerry"}

这种方式似乎看起来简单,但可读性差,一旦后期修改了struct的字段顺序,这样的代码就会出现错位或者报错。所以日常开发尽量不要使用这种方式。

个人觉得似乎还有一种,不过我是以Java程序员的视角似乎也是一种初始化,回头查查这样的初始化是不是有啥坑。

1
2
3
4
5
u := User{}
u.Age = 18
u.Name = "John"
u.Id = 1
t.Log(u)

访问和修改字段

1
2
3
4
5
6
7
func TestStructRU(t *testing.T) {
u := User{Id: 1, Name: "zhangsan", Active: true, Tags: []string{"Java", "Go"}, Age: 28}
t.Log(u.Tags)

u.Age = 32
t.Log(u.Age)
}

struct是值类型

struct 赋值时会拷贝整个值,不像 map、slice 那样共享整体结构。

1
2
3
4
5
6
7
func TestStructType(t *testing.T) {
u := User{Id: 1, Name: "zhangsan", Active: true, Tags: []string{"Java", "Go"}, Age: 28}
u1 := u
u1.Name = "Jack"
t.Log(u.Name)
t.Log(u1.Name)
}

当然,struct中的字段如果是引用类型,那么字段内部引用的数据仍然可能是共享的。

拷贝

如果struct中包含slice/map/pointer时,拷贝是浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Person struct {
Name string
Tags []string
}

func TestStructCopy(t *testing.T) {
p1 := Person{
Name: "Jack",
Tags: []string{"Java", "Go"},
}
p2 := p1
p2.Tags[0] = "Python"
// [Python Go]
t.Log(p1.Tags)
// [Python Go]
t.Log(p2.Tags)
}

原因:

  • struct 本身被拷贝了
  • Tags 这个 slice 头部被拷贝
  • slice底层数组仍然共享

函数传struct默认也是值拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Person struct {
Name string
Tags []string
Age int
}

func grow(p Person) {
p.Age++
}

func TestStructCopyArg(t *testing.T) {
p1 := Person{
Name: "Jack",
Tags: []string{"Java", "Go"},
Age: 28,
}
grow(p1)
// 28
t.Log(p1.Age)
}

如果想要修改原来的对象,那么就需要传指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func grow1(p *Person) {
p.Age++
}

func TestStructCopyArg(t *testing.T) {
p1 := Person{
Name: "Jack",
Tags: []string{"Java", "Go"},
Age: 28,
}
grow1(&p1)
// 29
t.Log(p1.Age)
}

struct 指针访问字段可以省略解引用

Go 允许自动解引用。

1
2
3
4
5
6
func TestStruct2(t *testing.T) {
p := &Person{Name: "Jack", Tags: []string{"Java", "Go"}, Age: 28}
t.Log(p.Name) // 等价于(*p).Name
p.Age = 20
t.Log(p.Age)
}

如何判断使用struct还是*struct

用 struct 值的情况

适合:

  • 数据很小

  • 不需要修改原对象

  • 想避免共享状态

  • 临时值、配置值、小对象

1
2
3
4
5
6
7
8
9
10
type Point struct {
X, Y int
}

func Move(p Point, dx, dy int) Point {
p.X += dx
p.Y += dy
return p
}

用 *struct 指针的情况

适合:

  • struct 较大

  • 需要修改原对象

  • 避免频繁拷贝

  • 方法需要改变 receiver(相当于Java的this)

  • 包含锁、缓存、连接等状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Counter struct {
N int
}

func (c *Counter) Inc() {
c.N++
}

func main() {
c := &Counter{}
c.Inc()
c.Inc()

fmt.Println(c.N) // 2
}

Go 没有 class,但可以给类型定义方法。

参考上面的例子就可以说明问题。Inc()方法可以被Counter调用。

receiver 选择原则:如果一个类型的方法中,有任意一个方法需要指针 receiver,通常所有方法都用指针 receiver,保持一致。

1
2
3
4
5
6
7
8
9
10
11
12
type User struct {
Name string
Age int
}

func(u *User) Rename(name string){
u.Name = name
}

func(u *User) IsAdult() bool {
return u.Age >= 18
}

指针

指针保存的是一个变量的内存地址。其语法如下:

1
var p *int
  • p 是一个指针变量
  • 它指向一个 int 类型的变量
  • *int 表示“指向 int 的指针类型”

取地址

在Go语言中,使用&获取变量地址。

1
2
3
4
5
6
7
func TestGetAddress(t *testing.T) {
x := 10
// x 的内存地址,例如 0xc0000120a
p := &x
// x = 10, p = 44087471899344
t.Logf("x = %d, p = %d", x, p)
}

解引用

以上一个例子为例,使用*p访问指针指向的值。

1
2
3
4
5
6
7
8
9
10
func TestDeref(t *testing.T) {
x := 10
p := &x
// *p = 10
t.Logf("*p = %v", *p)
// 表示修改 p 指向的变量的值。
*p = 20
// x = 20, *p = 20
t.Logf("x = %d, *p = %d", x, *p)
}

指针的零值

指针类型的零值是 nil

1
2
3
4
5
6
7
8
9
func TestZeroValue(t *testing.T) {
var p *int
//p == nil 的值是 true
t.Logf("p == nil 的值是 %v", p == nil)
// 潜在的 nil 解引用
// 第 26 行: 'p == nil' 被假定等于 'nil'
// 第 27 行: 'p' 被解引用
// t.Log(*p)
}

注意:不能解引用nil 指针。

通常为了避免这个问题,使用如下写法:

1
2
3
4
5
6
7
8
9
10
11
func TestZeroValue1(t *testing.T) {
// 假设任意一个指针
x := 10
p := &x
if p != nil {
// 执行正常的逻辑
t.Log(*p)
} else {
t.Log("p is nil")
}
}

函数传值和传指针

其实前面的学习中,已经使用过几次。这里再介绍一下。

Go 默认是值传递。

如果传普通变量,函数内部修改的是副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func change(x int) {
// 如果传普通变量,函数内部修改的是副本。
x = 20
}

func TestArgsValue(t *testing.T) {
n := 10
change(n)
// n = 10
t.Logf("n = %d", n)
}

// 如果想让函数修改外部变量,需要传指针。
func changePoint(p *int) {
*p = 20
}

func TestArgsPoint(t *testing.T) {
n := 10
changePoint(&n)
// n = 20
t.Logf("n = %d", n)
}

struct指针(重点)

如果要修改struct的值,需要传递指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type UserInfo struct {
Name string
Age int
}

// 方法里的指针 receiver,通常修改struct时,需要这么定义。如果传值,那么修改不会影响元对象。
func birthday(u *UserInfo) {
// 等价于 (*u).Age++,Go 会自动解引用 struct 指针,所以一般不用写 (*u).Age。
u.Age++
}

func TestStructPointer(t *testing.T) {
u := UserInfo{
Name: "Jerry",
Age: 18,
}
birthday(&u)
// Name = Jerry Age = 19
t.Logf("Name = %v Age = %d", u.Name, u.Age)
}

new函数

new(T)会分配一个T类型的值,并返回*T

1
2
3
4
5
6
7
8
9
10
11
func TestNew(t *testing.T) {
p := new(int)
t.Logf("p = %v, *p = %v", p, *p)
*p = 10
t.Logf(" *p = %v", *p)
// 对于struct,以下方式等价于u := &User{},后者更常用
u := new(UserInfo)
u.Name = "Tom"
u.Age = 18
t.Logf("Name = %v Age = %d", u.Name, u.Age)
}

指针不能做算数运算

Go 指针不能像 C/C++ 那样做指针偏移。

1
2
3
4
5
6
7
8
9
10
func TestPointCompute(t *testing.T) {
x := 10
p := &x
// 无效运算: p++(非数值类型 *int)
// p++
}

// 同样也不支持下列操作:
//p + 1
//p--

指针和(数组、slice、map)

数组是值类型,传参会拷贝整个数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 传数组:会拷贝
func changeArray(a [3]int) {
a[0] = 99
}

// 传数组指针:可以修改原数组
func chageArrayPoint(a *[3]int) {
a[0] = 99
}

func TestArray(t *testing.T) {
arr := [3]int{1, 2, 3}
changeArray(arr)
// arr = [1 2 3]
t.Logf("arr = %v", arr)

chageArrayPoint(&arr)
// arr = [99 2 3]
t.Logf("arr = %v", arr)
}

slice 本身包含指向底层数组的指针,所以传 slice 通常就能修改底层元素。

对于Go语言,slice的使用频率要远高于数组。下面通过一个例子来说明指针与切片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func changeSlice(nums []int) {
nums[0] = 99
}

func changeSliceAppend(nums []int) {
nums = append(nums, 4)
}

func changeSliceReturnNew(nums []int) []int {
nums = append(nums, 5)
return nums
}

func TestSlice(t *testing.T) {
nums := []int{1, 2, 3}
// 修改第一值
changeSlice(nums)
// 修改第一个值:nums = [99 2 3]
t.Logf("修改第一个值:nums = %v", nums)
changeSliceAppend(nums)
//追加一个新值:nums = [99 2 3]
t.Logf("追加一个新值:nums = %v", nums)
nums = changeSliceReturnNew(nums)
// 追加一个新值,并返回新的slice: nums = [99 2 3 5]
t.Logf("追加一个新值,并返回新的slice: nums = %v", nums)
}

一般不推荐用 *[]T,除非你真的需要修改 slice 变量本身。

对于map来说,map 本身也是引用语义的数据结构,所以通常不需要传 *map。其他方式和slice基本一致。

泛型

与Java的泛型类似,Go语言泛型允许在定义函数、类型或方法时,暂时不指定具体类型,而是在使用时再传入具体类型。

它的核心作用是:

  • 减少重复代码

  • 提高类型安全

  • 编写更通用的函数和数据结构

下面是一个最直观的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func maxInt(x int, y int) int {
if x > y {
return x
} else {
return y
}
}

func maxFloat(x float64, y float64) float64 {
if x > y {
return x
} else {
return y
}
}

// 使用泛型
func Max[T int | float64](a, b T) T {
if a > b {
return a
} else {
return b
}
}

func TestGetMax(t *testing.T) {
// 不使用泛型
t.Logf("maxInt(1, 2) = %d", maxInt(1, 2))
t.Logf("maxFloat(1.1, 2.2) = %f", maxFloat(1.1, 2.2))
// 使用泛型
t.Logf("Max[int](1, 2) = %d", Max[int](1, 2))
t.Logf("Max[float64](1.1, 2.2) = %f", Max[float64](1.1, 2.2))
}

使用泛型可以减少重复代码。

泛型的语法格式

1
func 函数名[T 约束](参数 T) T
  • T是类型参数
  • 约束限制T可以是什么类型
1
2
3
4
5
6
7
8
9
10
func Identity[T any](value T) T {
return value
}

func TestGenerics(t *testing.T) {
t.Logf("Identity[int](1) = %d", Identity[int](1))
t.Logf("Identity[string]('hello') = %v", Identity[string]("Hello"))
// 也可以让Go取自动类型推导
t.Logf("Identity(2) = %v", Identity(2))
}

any约束

any 是 Go 1.18 引入的内置类型别名,本质上等价于 interface{}。它表示可以接受任何类型。

1
2
3
4
5
6
7
8
9
10
func PrintValue[T any](value T) {
fmt.Println(value)
}

func TestAny(t *testing.T) {
PrintValue(123)
PrintValue("Hello World!")
PrintValue(true)
PrintValue(3.1415)
}

comparable约束

comparable 表示该类型可以使用 ==!= 比较。

常见可比较类型:

  • int

  • string

  • bool

  • 指针

  • channel

  • 由可比较字段组成的 struct

不可比较类型:

  • slice

  • map

  • function

这一块和map的key比较像。

1
2
3
4
5
6
7
8
9
10
11
func Equal[T comparable](a, b T) bool {
return a == b
}

func TestComparable(t *testing.T) {
t.Logf("Equal(1, 1) = %t", Equal(1, 1))
t.Logf("Equal('hello', 'hello') = %t", Equal("hello", "hello"))
t.Logf("Equal(1.1, 1.1) = %t", Equal(1.1, 1.1))
// 下面的代码会报错,因为slice类型不支持比较
// t.Logf("Equal([]int{1, 2}, []int{1, 2}) = %t", Equal([]int{1, 2}, []int{1, 2}))
}

自定义类型约束

可以通过接口定义自己的类型约束(TypeScript?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Number interface {
int | int64 | float64
}

func Add[T Number](a, b T) T {
return a + b
}

func TestCustomType(t *testing.T) {
num1 := Add(2, 3)
num2 := Add(1.9, 2.1)
t.Logf("Add(2, 3) = %v", num1)
t.Logf("Add(1.9, 2.1) = %v", num2)
}

定义Number类型的|称之为类型联合,意思是可以是这些类型中的任意一种。例如如下:

1
2
3
type Integer interface {
int | int8 | int16 | int32 | int64
}

表示满足该约束的类型是:

1
2
3
4
5
int
int8
int16
int32
int64

这样的方式,也可以叫做类型集合。

底层类型约束

~ 表示允许使用某个类型以及以该类型为底层类型的自定义类型。

下面通过两个例子作为对比:

不适用~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type MyInt int

type IntOnly interface {
int
}

func Double[T IntOnly](v T) T {
return v * 2
}

// 使用~

type IntLike interface {
~int
}

func DoubleIntLike[T IntLike](v T) T {
return v * 2
}

func Test1(t *testing.T) {
//var x MyInt = 10
/**
无法将 MyInt 用作类型 IntOnly
类型未实现约束 IntOnly,因为类型未包括在类型集(int)中
*/
//t.Logf("Double(x) = %v", Double(x))

var y MyInt = 20
t.Logf("Double(y) = %v", DoubleIntLike(y))
}

~int 的意思是: 只要底层类型是 int,都可以满足这个约束。

泛型函数

泛型函数是最常见的泛型用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

func TestGenericsFunc(t *testing.T) {
t.Logf("Contains([]int{1, 2, 3}, 2) = %t", Contains([]int{1, 2, 3}, 2))
t.Logf("Contains([]string{'a', 'b', 'c'}, 'd') = %t", Contains([]string{"a", "b", "c"}, "d"))
}

可以实现一个类似lambda中的map操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Map[T any, R any](items []T, fn func(T) R) []R {
result := make([]R, len(items))
for i, item := range items {
result[i] = fn(item)
}
return result
}

func TestMapFunc(t *testing.T) {
nums := []int{1, 2, 3}

strs := Map(nums, func(n int) string {
return fmt.Sprintf("Number: %d", n)
})

t.Log(strs)
}

同理,实现一个FilterReduce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func Filter[T any](items []T, fn func(T) bool) []T {
result := make([]T, 0, len(items))
for _, item := range items {
if fn(item) {
result = append(result, item)
}
}
return result
}

func Reduce[T any, R any](items []T, initial R, fn func(R, T) R) R {
result := initial

for _, item := range items {
result = fn(result, item)
}

return result
}

func TestLambdaFunc(t *testing.T) {
nums := []int{1, 2, 3, 4, 5}
evens := Filter(nums, func(n int) bool {
return n%2 == 0
})
t.Log(evens)

sum := Reduce(nums, 0, func(acc int, n int) int {
return acc + n
})

t.Logf("Sum of nums = %d", sum)

}

结构体泛型

不仅函数可以使用泛型,结构体也可以使用泛型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Box[T any] struct {
value T
}

type Pair[K comparable, V any] struct {
Key K
Value V
}

func TestGenericsStruct(t *testing.T) {
intBox := Box[int]{value: 28}
stringBox := Box[string]{value: "Hello Go"}
t.Logf("intBox = %v, stringBox = %v", intBox.value, stringBox.value)

p1 := Pair[string, int]{
Key: "age",
Value: 28,
}

p2 := Pair[string, string]{
Key: "Name",
Value: "Jerry",
}

t.Logf("p1 = {Key: %v, Value: %v}, p2 = {Key: %v, Value: %v}", p1.Key, p1.Value, p2.Key, p2.Value)
}

泛型与接口的区别

虽然自定义泛型使用的是interface,看起来像是接口,但和接口还有些区别。普通接口关注的是行为,泛型约束关注的是类型集合

1
2
3
type Writer interface {
Write([]byte) (int,error)
}

只要实现Write方法,就满足接口。

1
2
3
type Number interface{
int | int32 | float64
}

表示类型必须属于这个类型集合,也可以同时约束类型和方法。

1
2
3
4
type StringNumber interface {
~int | ~int64
String() string
}

但这种约束要求类型:

  1. 底层类型是 intint64
  2. 实现了 String() string