光有砖和瓦还不够,我们还需要墙、门、板这些模型,才能快速构建起我们的大楼。将基本类型组合起来,这就是复合类型。
使用struct
来定义一个结构体类型。结构体是多个相关属性的集合,比如,人有年龄, 体重这些属性。
type person struct {
age int32
height int32
}
type 定义类型
你可以使用type
关键字创建一个新的类型,例如给int32
类型起一个别名。
myint
类型的底层是int32
, 但这两个不是一个类型。
声明一个结构体,指定它各个字段的值:
p1 := person {age: 1 , height: 160 } // p: {age: 1, height: 160}
如果有的字段不指定,会赋予该字段零值:
p1 := person {} // p: {age: 0, height: 0}
也可以使用 var 声明:
var p1 person // 默认初始化为零值, p1: {age: 0, height: 0}
var p2 = person { // p2: {age: 1, height: 16}
age: 1 ,
height: 16
}
通过.
来读取和修改结构体的字段
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}
}
运行,输出
结构体是可比较的,要求:
相同的结构体类型才能比较。
其所有字段都是可比的(不是切片,map, 函数等引用类型)。
比较的规则是,两个结构体的各个字段都相等,它们才相等。例如下列代码p1 == p2
:
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)
}
输出
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 位系统):
person
结构体占用 8 个字节。我们知道age
和height
是int32类型
,都占 4 个字节, 加起来正好 8 个字节。
我们可以设想,不包含字段的结构体即 空结构体不占内存,验证一下
package main
import (
" fmt "
" unsafe "
)
type empty struct { // 空结构体
}
func main () {
p := empty {}
fmt. Println (unsafe. Sizeof (p)) // 打印p变量占多少字节的内存
}
运行,输出
果然,空结构体不占内存! , 这在并发编程传递取消信号时很有用,因为不占内存,就减少了复制开销。我们在传递取消信号时,不关心传递了什么类型的变量,只关心有东西传递过来了。
如果把 person 结构体的age
的类型改为int8
, 结构体会占用多少内存呢? 根据上面的经验,应该占 1+4=5 byte
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 个字节,而不是 5 个字节。因为这里涉及到内存对齐。
规则 1 : 整个结构体的大小必须是其最大字段大小的整数倍。 这里的最大字段必须是基本类型(如整数,布尔, 浮点数等,不包括string
)
person结构体
占内存最大的是height
, 4 个字节,整体必须是 4 的倍数,又要加 1 个字节,所以是 8 个字节。
规则 2:
我们看两个有趣的现象,看看结构体的字段不同的排列方式如何影响占用内存:
按照规则1
, BadLayout
结构体应该占 8 的倍数,即 16 个字节,但实际上却占 24 个字节。
原因是还有规则 2:每个字段的起始位置必须是其类型大小的整数倍 。 BadLayout
结构体中, b
字段内存的起始位置必须是8
的整数倍,即不能紧挨着a
字段,需要填充 7 个字节,如上图。
结构体内存对齐受到两个规则的约束,这启示我们,把占内存大的字段排在前面,(其实咋排都没关系,影响不大,我们知道原理就好。)
为什么会有数值类型?当我们需要一组有顺序的元素,同时能快速地读取和修改第几个元素时 (例如书架上的书), 就需要用到数组类型。
数组的类型是[len]T
, len 为数组的长度,T 为包含元素的类型:
package main
import " fmt "
func main () {
// 创建一个长度为5,类型为int的数组
arr := [ 5 ] int { 1 , 2 , 3 , 4 , 5 }
fmt. Println (arr)
}
输出
也可以省略长度,用...
代替,go 会帮我们自动推断出长度
arr := [ ... ] int { 1 , 2 , 3 , 4 , 5 }
等价于
arr := [ 5 ] int { 1 , 2 , 3 , 4 , 5 }
声明空数组会默认初始化为它的零值
package main
import " fmt "
func main () {
var arr [ 5 ] int
fmt. Println (arr)
}
输出
使用arr[索引]
的方式读取和修改数组的元素。
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)
}
输出
假设数组的长度是len
, 那么它的第一个元素的索引是 0, 最后一个元素的索引是len-1
; 如果索引不在[0, len-1]这个范围,就会发生索引越界的错误。 ,如下图:
package main
import " fmt "
func main () {
arr := [ 5 ] int { 1 , 2 , 3 , 4 , 5 }
a1 := arr[ 5 ] // 5越界,最大的索引为4
fmt. Println (a1)
}
运行, 编译期 panic:
下面将[2]int
类型的数组arr2
赋值给[5]int
类型的数组arr
,会发送编译期错误,因为这两个不是相同的类型(虽然都是int
类型的数组,但是长度不同)。
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
package main
import (
" fmt "
" unsafe "
)
func main () {
arr := [ 5 ] int { 1 , 2 , 3 , 4 , 5 }
fmt. Println (unsafe. Sizeof (arr)) // 打印arr变量占多少字节的内存
}
运行,输出
每个 int 类型占 8 个字节,一共有 5 个,共占 5*8=40 个字节。数组在内存中是连续分布的,方便根据索引来快速访问和修改元素。
所以数组占用内存 = 元素占用的内存 * 数组长度。
有意思的部分来了。因为数组的长度就是它类型的一部分,我们不能增加和删除元素,所以 Go 在数组的基础上 创造了切片。我们使用最多的就是切片,而不是数组。
切片的类型是[]T
, T 可以是任何类型。和数组不同,切片的长度不是类型的一部分。
声明一个切片但没初始化,go 会赋予切片的零值 nil
, nil
就是没有的意思。
package main
import " fmt "
func main () {
var s [] int // s = nil
fmt. Println (s == nil )
}
运行,输出
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
}
输出
[]int{1, 2, 3}
就是切片的字面量,它定义了切片的类型和包含的元素。切片有两个属性,长度和容量。长度就是切片的元素的个数,容量是切片的底层数组的长度。
使用make([]T, len, cap)
声明并初始化
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
}
输出
make
会初始化对应长度的零值。
也可以省略容量,make([]T, len)
这时容量等于长度。
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
}
输出
切片的读取、修改和索引越界与数组相同,这里不再赘述。(切片的底层就是数组)
append
函数接收切片和要添加的元素,返回添加后的新切片。
package main
import (
" fmt "
)
func main () {
// 定义一个长度为0的空切片
s := make ([] int , 0 )
fmt. Println (s) // 打印增加前的切片
s = append (s, 1 ) // 在切片尾部增加元素1
fmt. Println (s) // 打印增加后的切片
}
运行,输出
你可以一次性增加多个元素:
package main
import " fmt "
func main () {
ls := [] int { 1 , 2 , 3 }
ls = append (ls, 4 , 5 , 6 )
fmt. Println (ls)
}
输出
记得把append
返回的新切片赋值给原切片ls
也可以把一个切片的元素添加到另一个切片,使用...
操作符解开
package main
import " fmt "
func main () {
ls1 := [] int { 1 , 2 , 3 }
ls2 := [] int { 4 , 5 , 6 }
ls1 = append (ls1, ls2 ... ) // ...把切片解开
fmt. Println (ls1)
}
输出
使用unsafe.Sizeof()
查看切片占多少字节的内存
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
个字节 。为什么呢? 我们看看切片是怎么实现的
type slice struct {
array unsafe . Pointer // 指针: 8 byte, 指向底层数组
len int // 8byte
cap int // 8byte
}
切片是一个结构体,有三个字段:
array: 是一个指针,存储底层数组的内存地址,即指向底层数组。
len: 切片的长度,即切片的元素的个数。
cap: 切片的容量,即切片的底层数组的长度。
根据结构体内存对齐的规则,切片就占8+8+8=24
个字节。
已下面的切片ls
为例
它在内存中是这样的:
使用append
函数每次添加一个元素,添加完后打印新切片的长度和容量。
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
扩容前切片的容量 扩容后切片的容量 小于 256 max(len0+n, 2 * cap0)
中间地带 2*cap0 -> 1.25*cap0
过渡大容量数组 约等于 1.25*cap0
简单来说,小容量切片扩容容量翻倍,大容量切片扩容容量翻 1.25 倍。中间地带是平滑过渡的曲线。有runtime
包为证:
// 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 倍。
可以通过切片操作符[start:end]
来创建新的切片(子切片), 其中end
取不到,只取到end-1
从数组中取子切片
array := [ 4 ] int8 { 1 , 2 , 3 , 4 }
slice := array[ 1 : 3 ]
从切片中取子切片
ls1 := [] int8 { 1 , 2 , 3 , 4 }
ls2 := array[ 1 : 3 ]
子切片的长度 = end-start
, 容量 = 底层数组容量-start
思考一个问题,下面的切片ls
会改变吗
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。
运行, 输出
ls 改变了,为什么会改变呢,我们并没有传入ls
的指针*[]int
呀。原因是切片的实现就包含有指针指向底层数组。我们知道,Go 中一切传输都是进行复制:
聪明的你,下面的ls
会改变吗?
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)
}
输出
为什么 ls 不会改变呢。首先append
函数会先判断ls
底层还有没有足够的容量来存4
。显然是没有的,因为ls
的长度和容量都是3
。那么append
就会新建一个底层数组,如图:
底层数组没有改变,所以 main 函数里的ls
不会改变。
为了使外面的ls
改变,我们让add
函数返回ls
即可,像append
函数一样:
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)
}
输出
将接片作为参数传入函数要特别小心,函数有没有append
操作,如果有,切片是否有足够的容量来存放新的切片。如果没有,那么append
会进行扩容,返回一个新的切片,外面的切片就不会改变。
字符串虽然是基本类型,但它的实现和切片差不多,我们来看看吧。
package main
import (
" fmt "
" unsafe "
)
func main () {
var str1 string // 空字符串 ""
str2 := "hello world"
fmt. Println (unsafe. Sizeof (str1))
fmt. Println (unsafe. Sizeof (str2))
}
运行,输出
我们发现,所以的string
都占 16 个字节,为什么呢?我们来看它的实现:
type string struct {
array unsafe . Pointer // 指针 8byte
len int // 8 byte
}
string 是一个结构体,根据结构体内存对齐的规则,它占8+8=16
个字节。
已下面的str
为例:
它在内存中是这样的:
和切片不同的是,string
是没有容量的。为什么呢,Go 在设计时就让string
不能变:
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
聪明的你,两个字符串相加会发生什么?
package main
import (
" fmt "
)
func main () {
str1 := "hello"
str2 := "world"
str := str1 + str2
fmt. Println (str)
}
字符串是不能变的,相加会创建新的底层数组:
最后的重头戏map
来了, map
就像和切片一样。切片可以根据索引拿出值,而map
更强大,可以通过自定义的键拿出对应的值。
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
切片类型
bool
map
chan
函数类型(func)
数值类型: int, float64等
结构体类型,其字段至少有一个为不可比较的类型
数组类型
接口类型
结构体类型,但其字段必须全部为可比较的类型
指针类型
如果你提前知道键值对的数量,可以在make
中指定创建的map
的初始容量。如果不指定,则默认分配一个初始容量较小的map
。
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
package main
import " fmt "
func main () {
m := map [ string ] string {
"hello" : "world" ,
}
fmt. Println (m[ "hello" ]) // 通过键"hello" 取出值"world"
}
可以在初始化时就指明map
中的键值对。
我们传入一个不在map
中的key
,看看会发送什么
package main
import " fmt "
func main () {
m := map [ string ] int {
"k1" : 100 ,
}
val := m[ "k2" ] // k2 并不在m中
fmt. Println (val)
}
输出
会返回其值类型的零值, int类型
的零值是0
。
使用val, ok := map[key]
的方式,(接收两个变量), ok
表示key
在不在map
中:
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使用默认的格式化
}
输出:
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
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)
}
}
输出
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))
}
输出
可以发现,所有的map
类型的变量都占 8 个字节。该变量本质上是一个指针,指向底层的hmap
, hmap
存放实际的键值对数据。