数据类型
从本篇文章开始,记录Golang数据类型相关的内容,数据类型相关的知识点包括slice、map、string三个部分。
1. 切片
1.1. 从数组说起
数组是具有固定长度具有零个或者多个相同数据类型元素的序列。
由于数组长度固定,在Go里很少直接使用。
- 定义数组
// 3种方式,声明,初始,省略号
// 变量arr1类型为[5]int
var arr1 [5]int
// 变量arr2类型为[3]int,同时初始化赋值
var arr2 [5]int = [5]int{1,2,3}
// 让编译器自己数,结果为[3]int
arr3 := [...]int{1,2,3}
// 错误例子,因为[3]int和[4]int是两种类型
arr3 := [4]int{1,2,3,4}
- 数组长度是数组类型的一部分,
[3]int
和[5]int
就是两种类型。 - 数组长度必须是常量表达式,即这个表达式的值在编译阶段就可以确定。
- 查看数组
arr1 := [...]{1,2,3}
lenth := len(arr1)
- 数组遍历
arr1 := [3]{1,2,3}
for i,val := range arr1 {
}
- Go中的数组是值类型,换句话说,如果你将一个数组赋值给另外一个数组,那么,实际上就是将整个数组拷贝一份。
- 如果Go中的数组作为函数的参数,那么实际传递的参数是一份数组的拷贝,而不是数组的指针。
- 注意!!!在其他语言中,数组是隐式的使用引用传递;Golang传参时,传入的参数会创建一个副本,使用这种方式传递大的数组会变的很低效。
1.2. slice底层数组
切片用于表示一个拥有相同类型元素的可变长的序列,看上去像是没有长度的数组类型。
- 切片是引用类型,因此在当传递切片时将引用同一指针,修改值将会影响其他的对象。
- 切片可能会在堆上分配内存,本身不是动态数组或者数组指针,内部是通过指针引用底层数组,切片本身是一个只读对象,本身没有数据,底层数组才有数据,类似于数组指针的一种封装,是引用类型
- 当使用for range 遍历slice的时候,拿到的value其实是切片的值拷贝,每次打印出来的value地址不变
- slice扩容时,当cap小于1024的时候,每次扩容都会变成原来容量的2倍;当大于1024的时候,每次变为之前的1.25倍
- 所谓切片是引用类型,是指切片作为函数的参数,操作的是同一个底层数组。(由于引用类型数据存放在堆上,值类型数据存放在栈上,因此切片数据存放在堆上,栈上保存堆在内存中的地址)
切片拷贝:
- 对于切片直接使用
=
拷贝,实际上是浅拷贝,只拷贝了切片在堆上的内存地址;sliceA = sliceB
。- 对于切片深拷贝的需求,可以使用
copy
内置函数完成;copy(sliceA,sliceB)
。
func nonempty(strings []string) []string {
i := 0
for _, s := range strings {
if s != "" {
strings[i] = s
i++
}
}
return strings[:i]
}
- golang中函数传递都是值传递,map、channel、slice都是引用传递,会传递指针值。参数传递时,切片进行浅拷贝(指针、cap、len),在函数内部发生append扩容等操作时,会进行深拷贝,会为切片分配新的内存。https://zhuanlan.zhihu.com/p/111796041
//定义一个函数,给切片添加一个元素
func addOne(s []int) {
s[0] = 4 // 可以改变原切片值
s = append(s, 1) // 扩容后分配了新的地址,原切片将不再受影响
s[0] = 8
}
var s1 = []int{2} // 初始化一个切片
addOne(s1) // 调用函数添加一个切片
fmt.Println(s1) // 输出一个值 [4]
1.3. 切片扩容
- 预估扩容后的newCap
// 预估规则:
if oldCap * 2 < newCap
直接分配内存
else
if oldLen < 1024 newCap = oldCap * 2
if oldLen > 1024 newCap = oldCap *1.25
- newCap个元素需要多大的内存?
// 预估到的newCap只是扩容后元素的个数,具体分配多大的内存呢?
// newCap * sizeof(T)吗?
// 事实上,许多编程语言中,申请分配内存,并不是直接和操作系统交涉,而是和语言自身实现的内存管理模块。内存管理模块会提前申请一批常用的内存,管理起来,需要申请 内存时内存管理模块会帮我们匹配到最接近的规格
- 匹配到合适的内存规格
2. map
2.1. hash表
键值对的存储,我们首先会想到hash表,hash表通常会由一堆桶来存储键值对。一个键值对来了,如何确定哪个桶呢?
- 首先通过hash函数把“键”处理一下,得到一个hash值,根据这个hash值从m个桶中选择一个,桶编号区间为[0,m-1]
- 两个方法确定将这个值存到哪个桶。1. 取模法 h a s h hash hash % m m m 2. h a s h hash hash& ( m − 1 ) (m-1) (m−1),注意一定是2的整数次幂,否则一定会出现有些桶不会被选中的情况。
- 如果两个hash选择了同一个桶,称发生了hash冲突。解决hash冲突的办法有两种:顺位后移即开放地址法;添加链表即拉链法。
2.2. hash冲突
hash冲突的发生会影响hash表的读写效率,选择散列均匀的哈希函数可以减少哈希冲突的发生,适时的对hash表进行扩容也是保障读写效率的有效手段。
扩容时通常会把 c o u n t / m count/m count/m作为是否需要扩容的判断依据, l o a d = c o u n t / m load = count/m load=count/m 称为“负载因子”。需要扩容时,就要分配更多的桶,它们就是新桶,需要把旧桶里存储的键值对都迁移到新桶里。
如果需要迁移的数据过多,通常会在哈希表扩容时,先分配足够多的新桶,然后用一个字段记录旧桶的位置,再增加一个字段记录旧桶的迁移进度,直到所有数据全部迁移。
把键值对迁移的时间分摊到多次哈希表中操作的方式,就是“渐进式扩容”,可以避免一次性扩容带来的性能瞬间抖动。
扩容规则:
- Golang中负载因子loadFactor > 6.5 时,就会发生翻倍扩容,分配新桶的数目是旧桶的2倍。每个旧桶的键值都会分流到两个新桶中。
- 如果loadFactor没有超标,但使用的溢出桶较多,也会发生扩容,即等量扩容。即创建和旧桶数目一样多的新桶,然后把原来的键值对,迁移到新桶中。
- 既然是等量为什么还要迁移呢?先思考什么情况下会导致负载因子没有超标,而使用溢出桶较多?答案是很多键值对被删除的情况下。同样数目的键值对,迁移到新桶中能够排列的更紧凑,从而减少溢出桶的使用,这便是等量扩容的意义所在。
2.3. Golang中map
Golang中map类型的底层实现就是哈希表。
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
hmap结构体最后有一个extra字段,执行一个mapextra结构体,里面记录的都是溢出桶相关的信息。如编号为2的桶满了,就会在后面链一个溢出桶,nextoverflow就执行下一个空闲桶。
type mapextra struct {
// If both key and elem do not contain pointers and are inline, then we mark bucket
// type as containing no pointers. This avoids scanning such maps.
// However, bmap.overflow is a pointer. In order to keep overflow buckets
// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
// overflow and oldoverflow are only used if key and elem do not contain pointers.
// overflow contains overflow buckets for hmap.buckets.
// oldoverflow contains overflow buckets for hmap.oldbuckets.
// The indirection allows to store a pointer to the slice in hiter.
overflow *[]*bmap
oldoverflow *[]*bmap
// nextOverflow holds a pointer to a free overflow bucket.
nextOverflow *bmap
}
每个桶里可以放8个键值对,但是为了让内存排列更加紧凑,8个key放一起,8个value放一起,8个key前是8个top hash,每个top hash对应hash值的高8位。
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}
溢出桶: 为了减少扩容次数而引入溢出桶,当一个桶存满了,还有可用的溢出桶时,就会在后面链一个溢出桶。实际上,如果hash表要分配的桶的数目大于
2
4
2^{4}
24,就认为使用到溢出桶的几率比较大,就会分配
2
B
−
4
2^{B-4}
2B−4个溢出桶备用,这些溢出桶和常规桶在内存中是连续的。
3. 字符串
Golang中字符串类型用关键字string来标识。首先从数值型说起,计算机中数值表示的最小单位是bit,1bit可以表示0或1两个数字;1个字节可以表示0到255共256个数字;2个字节可以表示0到65535共65536个数字。那么一堆二进制0和1,怎么表示成字母A呢?
3.1. 字符
为了用一堆二进制0和1,表示字符。一种直接的方法就是给字符编号,于是有了字符集的概念。ASCII码字符集收录了128个字符,其扩展字符也只有256个,GB2312增加了汉字,BIG5增加繁体字。
上述字符集都有一定的局限性,不同国家语言存在差异性,于是,便有了Unicode全球化编码字符集,Unicode字符集自1990年开始研发,1994年正式公布,实现了跨语言,跨平台的文本转换与处理。字符集使字符与二进制之间可以互相转换
3.2. UTF8编码
单个字符容易转化,那如何表示字符串呢?如要把“eggo 世界”这个字符串用二进制表示,最直接的方法是直接转换。
问题是,我怎么知道这么长的二进制数字,如何进行划分?于是,主要问题变成了,划分字符边界。由于Unicode字符集中,一个字符最多占4字节,一种直接的方法是,4字节表示一个字符,高位补0,这种方法固然可以,但是会浪费内存,称为定长编码。
于是,有了传说中的UTF8编码方式,因此Unicode只是一个字符集,UTF8是一种编解码方式,主要为了解决如何划分Unicode二进制码的字符边界问题,同时保证了内存的合理利用。
3.3. 字符串
var str string = "hello"
在内存中结构如下,问题是如何知道字符串结束的位置呢?C语言的做法是,在结尾处放一个特定标识符,这就限制了内容中不能再出现这个标识符,Golang中没有采用了这种做法。
- Golang中字符串
s1 := "eggo世界"
2. 字符串读写
- 可以使用
fmt.Printf("%c\n",s1[2])
这样的方式来读取,但是不可以使用s1[2] = 'b'
这样的方式修改。 - Golang规定,字符串的内容不能够进行修改,于是把字符串内容分配到只读内存段,这是由于字符串变量可以共用底层字符串内容,如果允许修改只读内存段的话,会影响到其他的字符串。
如果真要修改字符串某个字符,可以强制转换为字节类型的slice后,再进行修改,从而脱离只读内存的限制。
bs := ([]byte)(s1)
bs[2] = 'b'
如上代码,会为bs重新分配一段内存,并拷贝s1中的内容。
4. 参考文献
[1] https://www.bilibili.com/video/BV1Sp4y1U7dJ?from=search&seid=1014416151836055424