本节包括 Go 的数组、切片、映射、结构体、指针和值传递与引用传递等内容。
1 数组h2
Go 中的数组(Array)和其他静态类型语言的数组相似,都是具有相同唯一类型的一组长度固定的数据**序列。**因此,数组在声明时需要同时指定数组长度和元素类型:
numbers := [5]int{1, 2, 3, 4, 5}其中,[ ]里是数组的长度,而{ }是用于初始化的数组。初始化数组会按照索引依次为声明的新数组赋值,如果初始化数组的长度小于新数组的长度,则新数组中未被初始化的元素将被赋予零值。注意,初始化数组的长度不能超过新数组的长度。
当然,正如编译器能自动推断类型一样,它也能自动推断数组长度:
numbers := [...]int{1, 2, 3, 4, 5}Go 也支持多维数组,n维数组需要n个[]来声明,并指定它们的形状:
twoDim := [2][3]int{ {1, 2, 3}, {4, 5, 6},}
threeDim := [2][3][4]int{ { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}, }, { {13, 14, 15, 16}, {17, 18, 19, 20}, {21, 22, 23, 24}, },}和其他语言一样,Go 数组中的元素同样通过**下标(索引/偏移量,从0开始)**访问 :
secendNumber := numbers[1]需要注意的是,数组的长度是类型的一部分。例如,虽然都是整型数组,但是长度为 3 的[3]int和和长度为 5 的[5]int之间并不兼容。
也因此,如果需要把数组作为函数参数传递,参数类型也需要指明:
func foo(arr [5]int) { // existing code}Go 数组是值类型因此将数组传递给函数作为参数时,实际上是在传递数组的副本。
如果你想要在函数内修改原始数组,可以通过传递数组的指针来实现。
2 切片h2
切片(Slice)是对数组的抽象,或者说,动态数组。
你可以使用字面量来定义切片:
numbers := []int{}numbers := []int{1, 2, 3, 4, 5}或使用make()函数:
numbers := make([]int, 5, 10)make()函数的三个参数分别是切片类型、切片长度、切片容量(可选)。
也可以使用数组来创建切片:
numbers := [5]int{1, 2, 3, 4, 5}numSlice := numbers[1:4] // [2 3 4]形如 arr[startIndex:endIndex] 的语法表示截取数组或切片从 startIndex 到 endIndex - 1 的元素。例如上述的 [1:4] 就表示截取索引为 1、2、3 的元素。
切片长度不是类型的一部分,所有的[]int都是同一种类型。当然,访问元素的方法和数组相同。
切片本质上是一个封装了数组的结构体,包含三个字段:
- 指针:指向底层数组中切片开始的内存地址;
- 长度:切片中当前的元素个数;
- 容量:从切片的起始位置到底层数组末尾的元素个数。
这也就能解释为什么切片是动态数组了:当切片中的元素数量超过容量时,Go 会自动创建一个更大的新底层数组,并将原底层数组拷贝过去。
对了,既然切片是“动态”数组,自然就能添加一个或多个元素:
numbers = append(numbers, 6)numbers = append(numbers, 7, 8, 9)以及拷贝切片中的内容到另一个切片:
originSlice := []int{1, 2, 3, 4, 5}copiedSlice := make([]int, len(originSlice))copy(copiedSlice, originSlice)copy() 函数的踩坑点copy() 的拷贝数量是两切片长度的较小值,即 min(len(origin), len(copied))。所以如果想完整拷贝原切片的内容,新切片的长度至少等于原切片的长度。
3 映射h2
映射(Map)是 Go 提供的用于存储无序键值对的集合。Go 的映射使用哈希表实现,这使其平均时间复杂度达到了 O(1)。
你可以使用字面量定义映射:
me := map[string]string{ "name": "YOAKE", "age": "23", "gender": "male",}在定义映射时,必须同时指定唯一相同的键的类型和唯一相同的值的类型,即 map[KeyType]ValueType。键的类型在 [ ] 中,值的类型紧跟在 [ ] 之后。
键也不一定非要是字符串类型:
aMap := map[int]string{ 1: "A", 2: "B", 3: "C",}此外,make() 函数也能用来创建映射:
m := make(map[string]int, 10)这里 make() 函数的两个参数分别是映射的类型和容量(可选)。容量指的是映射中可保存键值对的数量,当超过容量时,映射会自动扩容。如果不指定容量,编译器会自动选择一个合适的值。
我们可以使用两种方式获取键值对的值:
myName := me["name"]myName, ok := me["name"]第一种方式不必多说。在第二种方式中,如果键不存在,ok 会被赋值为 false,且 myName 为零值。
此外,还能修改键值对:
me["age"] = "24"当修改一个不存在的键值对时,就会变成添加操作:
me["location"] = "China"映射是可迭代的,因此它有长度,并可被 for-each 遍历:
length := len(me)
for key, value := range me { fmt.Printf("%s: %s\n", key, value)}使用 delete() 函数删除某个键值对:
delete(me, "location")4 结构体h2
数组、切片、映射都只能存储同一类型的数据,但在结构体(Structure)中可以为不同的项定义不同的数据类型。
type Member struct { name string age int isStudent bool gender string}事实上,结构体本身就是一种自定义的数据类型,因此能被用于变量的声明:
me := Member{ name: "YOAKE", age: 23, isStudent: true, gender: "Male",}访问结构体成员需要用 . 操作符:
myName := me.name结构体也能作为函数参数类型:
func print(m Member) { fmt.Printf("Name: %s\n", m.name) fmt.Printf("Age: %d\n", m.age) fmt.Printf("Is Student: %t\n", m.isStudent) fmt.Printf("Gender: %s\n", m.gender)}5 指针h2
Go 的指针是很简单的。我们都知道,变量的值存放在计算机的内存中,并且有一个唯一的内存地址。
我们可以使用取地址运算符 & 获取到这个变量的内存地址:
a := 10fmt.Println("Address of a:", &a) // Address of a: 0xc00000a088好,现在我们拿到了内存地址,接下来就该介绍指针(Pointer)了。
一个指针指向一个值的内存地址。
指针也是一个变量(指针变量),因此在使用指针之前需要先声明:
var iptr *intiptr 是这个指针变量的变量名,代表的是“整型”(i)和“指针”(ptr)。当然这只是个约定俗成的命名规范,你完全可以不遵守,但也不建议不遵守。而 *int 声明这是个专门指向整型变量的指针。同理,指向 32 位浮点型的就是 *float32,指向字符串型的就是 *string。结构体也能被指针指向,即 *Member。
和普通变量不同的是,普通变量在声明后如果不初始化,就会被默认赋予零值,它们永远存在。但是指针如果不初始化,就是 nil ,即不存在,也就是空指针。
你当然可以在声明时初始化指针:
var iptr *int = &a或者使用 := 简写:
iptr := &a编译器会自动推断 iptr 的类型。
使用指针拿到内存地址后,就可以访问到内存地址所代表的变量:
fmt.Println("Value at address iptr:", *iptr) // Value at address iptr: 10取值运算符 * 用于获取指针所指向的变量。
打个比方来说,变量 a 住在小区的二号楼三单元四层,取地址运算符 & 就能拿到变量 a 的住址,而指针变量 iptr 是变量 a 的专门对接人,它存储着变量 a 的住址。当需要访问变量 a 的时候,通过询问变量 iptr 得到 a 的住址,然后根据住址找到(取值运算符 *)变量 a。
因此简单归纳一下指针的使用:
- 定义指针
- 为指针赋值
- 访问指针中指向地址的值
有人可能会问:明明可以直接访问 a,为什么还要通过指针变量访问。这涉及到 Go 中函数参数的传递。
6 值传递与引用传递h2
我们在数组中提到:Go 数组是值类型因此将数组传递给函数作为参数时,实际上是在传递数组的副本。事实上,不只是数组,当你把基本数据类型、数组、结构体这三种传递给函数时, Go 都会完整地把这个变量复制一份传给函数:
func main() { a := 10 fmt.Println("Before calling add function:", a) // Before calling add function: 10
add(a) fmt.Println("After calling add function:", a) // After calling add function: 10}
func add(x int) int { x = x + 5 fmt.Println("Inside add function:", x) // Inside add function: 15 return x}所以我们看到,进行了 add() 操作之后,并没有改变 a 的值。传进 add() 函数中的是 a 的值而不是 a 本身的内存地址(引用)。
值传递固然有其好处,即无论函数内部怎么折腾,都不会影响外部变量。
但是也有一下缺点:
- 如果变量很大,例如一个 100 万元素的数组,Go 需要先开辟同样大的一块内存空间,把 100 万个元素一一复制过去,这非常耗费时间和内存;
- 在复杂的程序中,可能需要在多个地方处理同一份数据。例如一个购物订单,它的状态可能被金额结算、用户信息、物流状态等多个子程序同时共享。如果没有指针,当其中一个地方更新了数据,其他地方的代码根本看不到。而有了指针,这些子程序随时都能看到最新的结果。
而如果使用引用传递,只需要 8 个字节(64 位系统)的空间,就能解决值引用的问题。
特别的是,切片和映射不是值传递,因为它们本质上是一个包含指针的结构体。例如切片,它实际上是这个样子:
type slice struct { array unsafe.Pointer // 指向底层数组的指针 len int // 长度 cap int // 容量}当你传递一个切片时,Go 复制了这个结构体,而结构体里的 array 指针没变,因此你才能修改底层数组的值。
如果你在函数内部用 append 导致切片扩容了(换了新数组),那么函数内部的 array 指针就变了,而外部切片的 array 指针还指向旧地址。这就是为什么 append 之后必须把新切片返回来,或者干脆传切片的指针。
上边说过切片本身是一个结构体,当你把切片传给函数时,Go 复制了这个结构体。也就是说,现在函数内外的两个切片指向同一个底层数组。
如果切片的容量足够,使用 append() 向底层数组添加新的元素,就会出现:
- 函数内:修改了底层数组,长度加 1;
- 函数外:底层数组确实变了,但是函数外的那个切片的长度依然是原来的长度,它看不到新添加的元素。
而如果在 append() 的过程中触发了扩容, 切片就会为新的底层数组开辟一块更大的内存,并且把全部数据搬到新的底层数组中,并且把内部的指向底层数组的指针从旧数组指向新数组。
这就直接导致内外切片彻底断开联系。
为了避免这个陷阱,Go 社区通常会有两种做法:
- 返回新切片并覆盖原切片
func doSomething(s []int) []int { return append(s, 4)}
s = doSomething(s) // 覆盖原切片- 传递切片指针
func doSomething(s *[]int) { *s = append(*s, 4) // 直接修改外部切片}
doSomething(&s)不过,除非你有极高的性能追求,否则永远通过返回值来更新切片,这会让你的代码逻辑更清晰,避免莫名其妙的数据不一致。
而映射实际上就是一个指向底层哈希表结构的指针。
在 Go 中,应该尽量克制地使用指针。
如果数据很小(比如就两个字段的结构体),直接传值往往比传指针更快,因为这能减轻垃圾回收(GC)的压力。
只有当需要修改原数据或者数据大到拷贝成本太高时,才考虑使用指针。