lang

LangBook

5.数据类型

复合类型

光有砖和瓦还不够,我们还需要墙、门、板这些模型,才能快速构建起我们的大楼。将基本类型组合起来,这就是复合类型。

1. 结构体

使用struct来定义一个结构体类型。结构体是多个相关属性的集合,比如,人有年龄, 体重这些属性。

type person struct {
	age    int32
	height int32
}

type 定义类型

你可以使用type关键字创建一个新的类型,例如给int32类型起一个别名。

type myint int32

myint 类型的底层是int32, 但这两个不是一个类型。

1.1 声明(创建)一个结构体

  1. 声明一个结构体,指定它各个字段的值:
p1 := person{age: 1, height: 160} // p: {age: 1, height: 160}
  1. 如果有的字段不指定,会赋予该字段零值:
p1 := person{} // p: {age: 0, height: 0}
  1. 也可以使用 var 声明:
var p1 person 	  // 默认初始化为零值, p1: {age: 0, height: 0}
var p2 = person{  // p2: {age: 1, height: 16}
	age: 1,
	height: 16
}
  1. 通过.来读取和修改结构体的字段
main.go
package main
import (
	"fmt"
)
type person struct {
	age    int32
	height int32
}
func main() {
	p := person{age: 18, height: 160}
 
	// 读取
	age := p.age // age = 18
	fmt.Println(age)
 
	// 修改
	p.age = 35
	fmt.Println(p) // {age: 35, height: 160}
}

运行,输出

18
{35 160}

1.2 结构体进行比较

结构体是可比较的,要求:

  • 相同的结构体类型才能比较。
  • 其所有字段都是可比的(不是切片,map, 函数等引用类型)。

比较的规则是,两个结构体的各个字段都相等,它们才相等。例如下列代码p1 == p2:

main.go
package main
 
import "fmt"
 
type person struct {
	age    int32
	height int32
}
 
func main() {
	p1 := person{age: 11, height: 160} 
	p2 := person{age: 11, height: 160} 
	fmt.Println(p1 == p2)
}

输出

true

1.3 结构体占多少内存?

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
type person struct {
	age    int32 // 4 byte
	height int32 // 4 byte
}
 
func main() {
	p := person{
		age:    18,
		height: 160,
	}
	fmt.Println(unsafe.Sizeof(p)) // 标准库unsafe包里的Sizeof函数返回p变量在内存中占多少字节
}

运行,输出(64 位系统):

8

person结构体占用 8 个字节。我们知道ageheightint32类型,都占 4 个字节, 加起来正好 8 个字节。

我们可以设想,不包含字段的结构体即 空结构体不占内存,验证一下

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
type empty struct { // 空结构体
}
 
func main() {
	p := empty{}
	fmt.Println(unsafe.Sizeof(p)) // 打印p变量占多少字节的内存
}

运行,输出

0

果然,空结构体不占内存!, 这在并发编程传递取消信号时很有用,因为不占内存,就减少了复制开销。我们在传递取消信号时,不关心传递了什么类型的变量,只关心有东西传递过来了。

1.4 结构体内存对齐规则

如果把 person 结构体的age的类型改为int8, 结构体会占用多少内存呢? 根据上面的经验,应该占 1+4=5 byte

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
type person struct {
	age    int8  // 1 byte
	height int32 // 4 byte
}
 
func main() {
	p := person{age: 18, height: 160}
	fmt.Println(unsafe.Sizeof(p)) // 打印p变量占多少字节的内存
}

运行,输出

8

然而更改后的结构体占 8 个字节,而不是 5 个字节。因为这里涉及到内存对齐。

规则 1: 整个结构体的大小必须是其最大字段大小的整数倍。 这里的最大字段必须是基本类型(如整数,布尔, 浮点数等,不包括string)

person结构体占内存最大的是height, 4 个字节,整体必须是 4 的倍数,又要加 1 个字节,所以是 8 个字节。

规则 2:

我们看两个有趣的现象,看看结构体的字段不同的排列方式如何影响占用内存:

按照规则1, BadLayout结构体应该占 8 的倍数,即 16 个字节,但实际上却占 24 个字节。

原因是还有规则 2:每个字段的起始位置必须是其类型大小的整数倍BadLayout结构体中, b字段内存的起始位置必须是8的整数倍,即不能紧挨着a字段,需要填充 7 个字节,如上图。

结构体内存对齐受到两个规则的约束,这启示我们,把占内存大的字段排在前面,(其实咋排都没关系,影响不大,我们知道原理就好。)

2. 数组

为什么会有数值类型?当我们需要一组有顺序的元素,同时能快速地读取和修改第几个元素时 (例如书架上的书), 就需要用到数组类型。

2.1 数组的定义

  1. 数组的类型是[len]T, len 为数组的长度,T 为包含元素的类型:
main.go
package main
 
import "fmt"
 
func main() {
    // 创建一个长度为5,类型为int的数组
	arr := [5]int{1, 2, 3, 4, 5} 
	fmt.Println(arr)
}

输出

[1 2 3 4 5]
  1. 也可以省略长度,用...代替,go 会帮我们自动推断出长度
arr := [...]int{1, 2, 3, 4, 5}

等价于

arr := [5]int{1, 2, 3, 4, 5}
  1. 声明空数组会默认初始化为它的零值
main.go
package main
 
import "fmt"
 
func main() {
	var arr [5]int
	fmt.Println(arr)
}

输出

[0 0 0 0 0]

2.2 数组的读取和修改

使用arr[索引]的方式读取和修改数组的元素。

main.go
package main
 
import "fmt"
 
func main() {
	arr := [5]int{1, 2, 3, 4, 5}
    a1 := arr[0] // a1 = 1, 读取第一个元素
	fmt.Println(a1)
 
    arr[0] = 9 // 修改arr的第一个元素为9
    fmt.Println(arr)
}

输出

1
[9 2 3 4 5]

2.3 数组的索引越界

假设数组的长度是len, 那么它的第一个元素的索引是 0, 最后一个元素的索引是len-1; 如果索引不在[0, len-1]这个范围,就会发生索引越界的错误。 ,如下图:

main.go
package main
 
import "fmt"
 
func main() {
	arr := [5]int{1, 2, 3, 4, 5}
	a1 := arr[5] // 5越界,最大的索引为4
	fmt.Println(a1)
}

运行, 编译期 panic:

index 5 out of bounds

2.4 数组的长度是其类型的一部分

下面将[2]int类型的数组arr2 赋值给[5]int类型的数组arr,会发送编译期错误,因为这两个不是相同的类型(虽然都是int类型的数组,但是长度不同)。

main.go
package main
 
import "fmt"
 
func main() {
	var arr [5]int
	arr2 := [2]int{1, 2}
	arr = arr2 // 不同长度的数组是不同的类型
	fmt.Println(arr)
}

运行,编译期 panic:

cannot use arr2 (variable of type [2]int) as [5]int value in assignment

2.5 数组占多少内存

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
func main() {
	arr := [5]int{1, 2, 3, 4, 5}
	fmt.Println(unsafe.Sizeof(arr)) // 打印arr变量占多少字节的内存
}

运行,输出

40

每个 int 类型占 8 个字节,一共有 5 个,共占 5*8=40 个字节。数组在内存中是连续分布的,方便根据索引来快速访问和修改元素。

所以数组占用内存 = 元素占用的内存 * 数组长度。

3. 切片

有意思的部分来了。因为数组的长度就是它类型的一部分,我们不能增加和删除元素,所以 Go 在数组的基础上 创造了切片。我们使用最多的就是切片,而不是数组。

3.1 切片的定义

切片的类型是[]T, T 可以是任何类型。和数组不同,切片的长度不是类型的一部分。

  • 声明一个切片但没初始化,go 会赋予切片的零值 nilnil就是没有的意思。
main.go
package main
 
import "fmt"
 
func main() {
	var s []int // s = nil
	fmt.Println(s == nil)
}

运行,输出

true
  • 使用切片的字面量声明并初始化:
main.go
package main
 
import "fmt"
 
func main() {
	s := []int{1, 2, 3} 
	fmt.Println(s)
    fmt.Println(len(s)) // len(s) 返回切片的长度 3
	fmt.Println(cap(s)) // cap(s) 返回切片的容量 3
}

输出

[1 2 3]
3
3

[]int{1, 2, 3}就是切片的字面量,它定义了切片的类型和包含的元素。切片有两个属性,长度和容量。长度就是切片的元素的个数,容量是切片的底层数组的长度。

  • 使用make([]T, len, cap)声明并初始化
main.go
package main
 
import "fmt"
 
func main() {
	// 创建一个长度为2,容量为4的[]int类型的切片
	s := make([]int, 2, 4) 
	fmt.Println(s) // [0, 0]
	fmt.Println(len(s)) // len(s) 返回切片的长度 2
	fmt.Println(cap(s)) // cap(s) 返回切片的容量 4
}

输出

[0 0]
2
4

make会初始化对应长度的零值。

  • 也可以省略容量,make([]T, len)这时容量等于长度。
main.go
package main
 
import "fmt"
 
func main() {
	// 创建一个长度为2,容量为2的[]int类型的切片
	s := make([]int, 2) 
	fmt.Println(s) // [0, 0]
	fmt.Println(len(s)) // len(s) 返回切片的长度 2
	fmt.Println(cap(s)) // cap(s) 返回切片的容量 2
}

输出

[0 0]
2
2

切片的读取、修改和索引越界与数组相同,这里不再赘述。(切片的底层就是数组)

3.2 增加元素

  1. append函数接收切片和要添加的元素,返回添加后的新切片。
main.go
package main
 
import (
	"fmt"
)
 
func main() {
	// 定义一个长度为0的空切片
	s := make([]int, 0)
    fmt.Println(s) 	 // 打印增加前的切片
    s = append(s, 1) // 在切片尾部增加元素1
    fmt.Println(s) 	 // 打印增加后的切片
}

运行,输出

[]
[1]
  1. 你可以一次性增加多个元素:
main.go
package main
 
import "fmt"
 
func main() {
	ls := []int{1, 2, 3}
	ls = append(ls, 4, 5, 6) 
	fmt.Println(ls)
}

输出

[1 2 3 4 5 6]

记得把append返回的新切片赋值给原切片ls

  1. 也可以把一个切片的元素添加到另一个切片,使用...操作符解开
main.go
package main
 
import "fmt"
 
func main() {
	ls1 := []int{1, 2, 3}
	ls2 := []int{4, 5, 6}
 
	ls1 = append(ls1, ls2...) // ...把切片解开
	fmt.Println(ls1)
}

输出

[1 2 3 4 5 6]

3.3 切片占多少内存

使用unsafe.Sizeof()查看切片占多少字节的内存

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
func main() {
	ls1 := []int{1, 2, 3}
	ls2 := []int{}
 
	fmt.Println(unsafe.Sizeof(ls1)) 
	fmt.Println(unsafe.Sizeof(ls2)) 
}

输出

24
24

其实,所有的切片都占24个字节。为什么呢? 我们看看切片是怎么实现的

runtime/slice.go
type slice struct {
	array unsafe.Pointer // 指针: 8 byte, 指向底层数组
	len   int            // 8byte
	cap   int            // 8byte
}

切片是一个结构体,有三个字段:

  • array: 是一个指针,存储底层数组的内存地址,即指向底层数组。
  • len: 切片的长度,即切片的元素的个数。
  • cap: 切片的容量,即切片的底层数组的长度。

根据结构体内存对齐的规则,切片就占8+8+8=24个字节。

已下面的切片ls为例

ls := []int8{1,2,3}

它在内存中是这样的:

3.4 切片的扩容策略

使用append函数每次添加一个元素,添加完后打印新切片的长度和容量。

main.go
package main
 
import (
	"fmt"
)
 
func main() {
	// 定义一个长度为0的空切片
	s := make([]int, 0)
	fmt.Printf("开始s: len=%d, cap=%d\n", len(s), cap(s)) // %d打印整数。
 
    // 依次增加10个元素,i从0到9
	for i := range 10 {
		s = append(s, i) 
		fmt.Printf("增加第%d个元素: len=%d, cap=%d\n", i+1, len(s), cap(s))
	}
}

输出

开始s: len=0, cap=0
增加第1个元素: len=1, cap=1
增加第2个元素: len=2, cap=2
增加第3个元素: len=3, cap=4
增加第4个元素: len=4, cap=4
增加第5个元素: len=5, cap=8
增加第6个元素: len=6, cap=8
增加第7个元素: len=7, cap=8
增加第8个元素: len=8, cap=8
增加第9个元素: len=9, cap=16
增加第10个元素: len=10, cap=16

可以发现,append函数会首先检查切片的容量是不是还够放进去一个元素,如果不够放,就进行扩容,每次容量都是扩大两倍。

切片的扩容策略是怎样的呢?

对应扩容前的切片(ls), 设

  • 它的长度len0 = len(ls)

  • 容量cap0 = cap(ls)

  • append()函数要添加的元素个数为n

扩容前切片的容量扩容后切片的容量
小于 256max(len0+n, 2 * cap0)
中间地带2*cap0 -> 1.25*cap0 过渡
大容量数组约等于 1.25*cap0

简单来说,小容量切片扩容容量翻倍,大容量切片扩容容量翻 1.25 倍。中间地带是平滑过渡的曲线。有runtime包为证:

runtime/slice.go
// nextslicecap 计算下一个适当的切片长度。
func nextslicecap(newLen, oldCap int) int {
    newcap := oldCap
    doublecap := newcap + newcap
    if newLen > doublecap {
        return newLen
    }
 
    const threshold = 256
    if oldCap < threshold {
        return doublecap
    }
    for {
        // 从小切片的2倍增长过渡到大切片的1.25倍增长。
        // 这个公式在两者之间提供了一个平滑的过渡。
        newcap += (newcap + 3*threshold) >> 2
 
        // 我们需要检查 `newcap >= newLen` 以及 `newcap` 是否溢出。
        // newLen 保证大于零,因此当 newcap 溢出时,
        // `uint(newcap) > uint(newLen)` 。
        // 这使得我们可以通过相同的比较来检查这两者。
        if uint(newcap) >= uint(newLen) {
            break
        }
    }
 
    // 当 newcap 计算溢出时,设置 newcap 为请求的 cap 值。
    if newcap <= 0 {
        return newLen
    }
    return newcap
}

为什么要设置这样的扩容策略?切片的底层是数组,每次扩容都要新建一个数组,把旧的数组复制到新的数组,所以要尽量扩容大一些。但当容量很大时,扩容两倍的数组又很浪费空间,所以扩容 1.25 倍。

3.5 子切片

可以通过切片操作符[start:end]来创建新的切片(子切片), 其中end取不到,只取到end-1

  1. 从数组中取子切片
main.go
array := [4]int8{1, 2, 3, 4}
slice := array[1:3]

  1. 从切片中取子切片
ls1 := []int8{1, 2, 3, 4}
ls2 := array[1:3]

子切片的长度 = end-start, 容量 = 底层数组容量-start

3.6 切片的常见错误

思考一个问题,下面的切片ls会改变吗

main.go
package main
 
import "fmt"
 
// x2 将传进来的切片元素都乘2
func x2(ls []int) {
	for i, v := range ls { // i 是ls 的索引,v 是ls的值
		ls[i] = v * 2      // 每个元素乘2。
	}
}
 
func main() {
	ls := []int{1, 2, 3}
	x2(ls)
	fmt.Println(ls)
}

我们将ls传入x2函数,这个函数作用是将ls的每个元素乘 2。

运行, 输出

[2 4 6]

ls 改变了,为什么会改变呢,我们并没有传入ls的指针*[]int呀。原因是切片的实现就包含有指针指向底层数组。我们知道,Go 中一切传输都是进行复制:

聪明的你,下面的ls会改变吗?

main.go
package main
 
import "fmt"
 
// add 在传入的切片中增加一个元素4
func add(ls []int) {
	ls = append(ls, 4) 
}
 
func main() {
	ls := []int{1, 2, 3}
	add(ls) 
	fmt.Println(ls)
}

输出

[1 2 3]

为什么 ls 不会改变呢。首先append函数会先判断ls底层还有没有足够的容量来存4。显然是没有的,因为ls的长度和容量都是3。那么append就会新建一个底层数组,如图:

底层数组没有改变,所以 main 函数里的ls不会改变。

为了使外面的ls改变,我们让add函数返回ls即可,像append函数一样:

main.go
package main
 
import "fmt"
 
// add 把增加后的切片返回
func add(ls []int) []int { 
	ls = append(ls, 4)
    return ls 
}
 
func main() {
	ls := []int{1, 2, 3}
	ls = add(ls) 
	fmt.Println(ls)
}

输出

[1 2 3 4]

将接片作为参数传入函数要特别小心,函数有没有append操作,如果有,切片是否有足够的容量来存放新的切片。如果没有,那么append会进行扩容,返回一个新的切片,外面的切片就不会改变。

4. 字符串

字符串虽然是基本类型,但它的实现和切片差不多,我们来看看吧。

4.1 字符串占多少内存呢?

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
func main() {
	var str1 string // 空字符串 ""
	str2 := "hello world"
 
	fmt.Println(unsafe.Sizeof(str1))
	fmt.Println(unsafe.Sizeof(str2))
}

运行,输出

16
16

我们发现,所以的string都占 16 个字节,为什么呢?我们来看它的实现:

type string struct {
	array unsafe.Pointer // 指针 8byte
	len   int            // 8 byte
}

string 是一个结构体,根据结构体内存对齐的规则,它占8+8=16个字节。

已下面的str为例:

str := "hello"

它在内存中是这样的:

和切片不同的是,string是没有容量的。为什么呢,Go 在设计时就让string不能变:

main.go
package main
 
import (
	"fmt"
)
 
func main() {
	str := "hello world"
	str[0] = 'o' // 编译期报错
	fmt.Println(str)
}

输出,编译期错误 panic:

cannot assign to str[0] (neither addressable nor a map index expression

4.2 字符串相加?

聪明的你,两个字符串相加会发生什么?

package main
 
import (
	"fmt"
)
 
func main() {
	str1 := "hello"
	str2 := "world"
	str := str1 + str2
	fmt.Println(str)
}

字符串是不能变的,相加会创建新的底层数组:

5. map

最后的重头戏map来了, map就像和切片一样。切片可以根据索引拿出值,而map更强大,可以通过自定义的键拿出对应的值。

5.1 map 的声明

  • 通过make创建一个map
main.go
package main
 
import "fmt"
 
func main() {
	m := make(map[string]string) // 声明并初始化一个map
 
	m["hello"] = "world"    // 存入键值对: "hello" : "world"
	fmt.Println(m["hello"]) // 通过键"hello" 取出值"world"
}

map在声明时指定键和值的类型: map[K]V , K是键的类型,V是值的类型。键和值的类型是map类型的一部分:

var m1 map[string]string
m2 := make(map[string]int)
// 把m2 赋值给m1
m1 = m2 // 编译期报错, m1和m2是不同的类型。

map的键必须是可比较的

“可比较”的意思是可以通过比较运算符(如 ==!=)进行比较。

为什么键必须是可比较的? 因为map是用哈希表和溢出链表实现的,如果键是不可比较的,就无法通过键拿出对应的值。

可比较的类型不可比较的类型
string切片类型
boolmap
chan函数类型(func)
数值类型: int, float64等结构体类型,其字段至少有一个为不可比较的类型
数组类型
接口类型
结构体类型,但其字段必须全部为可比较的类型
指针类型

如果你提前知道键值对的数量,可以在make中指定创建的map的初始容量。如果不指定,则默认分配一个初始容量较小的map

main.go
package main
 
import "fmt"
 
func main() {
    // 创建一个 map,指定初始容量为 10
    m1 := make(map[string]string, 10)
    fmt.Println(len(m1)) // 输出 0(map 为空,但有足够空间存储 10 个元素)
 
    // 创建一个 map,不指定容量
    m2 := make(map[string]string)
    fmt.Println(len(m2)) // 输出 0(map 为空,初始容量较小)
}

为什么要指定初始容量?

指定初始容量可以提高性能,避免 map 随着数据增加频繁地扩容(扩容涉及内存分配和数据迁移)。如果你大概知道要存储的键值对数量,指定容量是一个好的实践。

map和切片一样都是引用类型。它的零值是nil,还没有分配底层的hmap内存,不能直接使用。

var m map[string]string
m["k1"] = "v1" // 编译期panic: assignment to entry in nil map
  • 通过字面量创建
main.go
package main
 
import "fmt"
 
func main() {
	m := map[string]string{
		"hello": "world",
	}
 
	fmt.Println(m["hello"]) // 通过键"hello" 取出值"world"
}

可以在初始化时就指明map中的键值对。

5.2 如何判断一个 key 在不在 map 中

我们传入一个不在map中的key,看看会发送什么

main.go
package main
 
import "fmt"
 
func main() {
	m := map[string]int{
		"k1": 100,
	}
	val := m["k2"] // k2 并不在m中
	fmt.Println(val)
}

输出

0

会返回其值类型的零值, int类型的零值是0

使用val, ok := map[key]的方式,(接收两个变量), ok表示key在不在map中:

main.go
package main
 
import "fmt"
 
func main() {
	m := map[string]int{
		"k1": 100,
	}
	val, ok := m["k2"] // k2 并不在m中
	fmt.Printf("val = %d, ok = %v\n", val, ok) // %d 格式化整数类型的变量,%v使用默认的格式化
}

输出:

val = 0, ok = false

5.3 for-range 循环 map

main.go
package main
 
import "fmt"
 
func main() {
	m := map[string]string{
		"k1": "v1",
		"k2": "v2",
		"k3": "v3",
	}
 
	for k, v := range m { // k是键, v是值
		fmt.Printf("key: %s, val: %s", k, v)
	}
}

输出:

key: k3, val: v3
key: k1, val: v1
key: k2, val: v2

注意,map中键值对是无序的,每次循环时键值对的顺序都不同。

for-range 循环中也可以缺省值v, 只需要键k

main.go
package main
 
import "fmt"
 
func main() {
	m := map[string]string{
		"k1": "v1",
		"k2": "v2",
		"k3": "v3",
	}
 
	for k := range m { 
		fmt.Printf("key: %s\n", k)
	}
}

输出

key: k2
key: k3
key: k1

5.4 map 占多少内存

main.go
package main
 
import (
	"fmt"
	"unsafe"
)
 
func main() {
	m1 := map[string]int{
		"k1": 100,
	}
	var m2 map[string]string // m2 == nil
 
	fmt.Println(unsafe.Sizeof(m1))
	fmt.Println(unsafe.Sizeof(m2))
}

输出

8
8

可以发现,所有的map类型的变量都占 8 个字节。该变量本质上是一个指针,指向底层的hmap, hmap存放实际的键值对数据。

On this page