Go 语言笔记 - 语法和数据类型
2017-08-26 tech go 23 mins 1 图 8223 字
一、基础语法
一个标准 Go 语句如下:
fmt.Println("Hello, World!")
- 一行代表一个语句结束。每个语句不需以分号 ; 结尾,也不是 Python 纯靠缩进来表示内容,需要大括号{}进行包裹
- 标识符用来命名变量、类型等,第一个字符必须是字母或下划线而不能是数字。
Go 代码中会使用到的 25 个关键字或保留字:
- default
- func
- interface
- select
- defer
- go
- map
- struct
- chan
- if
- else
- switch
- case
- continue
- for
- break
- range
- goto
- package
- const
- fallthrough
- type
- import
- return
- var
36 个预定义标识符:
- append
- bool
- byte
- cap
- close
- complex
- complex64
- complex128
- uint16
- copy
- false
- float32
- float64
- imag
- int
- int8
- int16
- uint32
- int32
- int64
- iota
- len
- make
- new
- nil
- panic
- uint64
- println
- real
- recover
- string
- true
- uint
- uint8
- uintptr
二、数据类型
本部分的内容基本来自 《Go 语言设计与实现》 - draveness.me ,这里只是一些关键内容摘录,为了保持上下文内容连贯,还是推荐查看原文。
-
布尔型 常量 true 或者 false。一个简单的例子:var b bool = true
-
数字类型 整型 int 和浮点型 float,并且原生支持复数,其中位的运算采用补码。更详细的信息看文章末尾。
-
字符串类型 使用UTF-8编码标识Unicode文本
-
派生类型:
- (a) 指针类型(Pointer)
- (b) 数组类型
- (c) 结构化类型(struct)
- (d) Channel 类型
- (e) 函数类型
- (f) 切片类型
- (g) 接口类型(interface)
- (h) Map/hash 类型
2.1 数组
arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}
字面量初始化方式,元素的个数小于或者等于四个时,在编译时会转换成如下命令:
var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3
当前数组的元素大于四个,则会:
var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
arr = statictmp_0
数组在 Go 语言中没那么常用,更常用的数据结构是切片,即动态数组。
2.2 切片
[]int
[]interface{}
切片与数组的关系非常密切。切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,我们可以在运行区间可以修改它的长度和范围。当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,不过在上层看来切片是没有变化的.
2.2.1 基本操作
Go 语言中包含三种初始化切片的方式:
- 通过下标的方式获得数组或者切片的一部分,编译器会将 arr[0:3] 或者 slice[0:3] 等语句转换成 OpSliceMake 操作;
- 使用字面量初始化新的切片(编译时处理大部分工作);
- 使用关键字
make
创建切片(运行时处理相对多):
如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。但是当我们使用 make
关键字创建切片时,很多工作都需要运行时的参与。
arr[0:3] or slice[0:3]
slice := []int{1, 2, 3}
slice := make([]int, 10)
使用 len
和 cap
获取长度、容量是切片最常见的操作,编译器将这它们看成两种特殊操作,即 OLEN
和 OCAP
。
切片的长度是它所包含的元素个数。 切片的容量是从它的第一个元素开始数,到其底层数组元素末尾的个数。
切片的操作(init、查、改、长度、容量)基本都是在编译期间完成的,编译期间也会将包含 range
关键字的遍历转换成形式更简单的循环。
2.2.2 切片扩容
切片追加和扩容时,会先解构切片结构体获取它的数组指针、大小和容量,切片容量足够时向切片中追加元素,切片容量不足时会调用 runtime.growslice
对切片进行扩容,扩容根据切片的当前容量选择不同的策略:
- 如果期望容量大于当前容量的两倍就会使用期望容量;
- 如果当前切片的长度小于 1024 就会将容量翻倍;
- 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
确定了切片的大致容量,此外还需要根据切片中的元素大小对齐内存,runtime.roundupsize
函数会将待申请的内存向上取整,取整时会使用 runtime.class_to_size
数组(以提高内存的分配效率并减少碎片)
var arr []int64
arr = append(arr, 1, 2, 3, 4, 5)
简单总结一下扩容的过程,当我们执行上述代码时,会触发 runtime.growslice
函数扩容 arr
切片并传入期望的新容量 5,这时期望分配的内存大小为 40 字节;不过因为切片中的元素大小等于 sys.PtrSize
,所以运行时会调用 runtime.roundupsize
向上取整内存的大小到 48 字节,所以新切片的容量为 48 / 8 = 6。
2.2.3 切片拷贝
如果当前 copy
不是在运行时调用的, runtime.memmove
会负责拷贝内存。
而如果拷贝是在运行时发生的,例如:go copy(a, b)
,编译器会使用 runtime.slicecopy
替换运行期间调用的 copy
,再通过 runtime.memmove
将整块内存的内容拷贝到目标的内存区域中。
相比于依次拷贝元素,runtime.memmove
能够提供更好的性能。需要注意的是,整块拷贝内存仍然会占用非常多的资源,在大切片上执行拷贝操作时一定要注意对性能的影响。
2.3 map
哈希表/map是除了数组之外,最常见的数据结构。几乎所有的语言都会有数组和哈希表两种集合元素,有的语言将数组实现成列表,而有的语言将哈希称作字典或者映射。无论如何命名或者如何实现,数组和哈希是两种设计集合元素的思路,数组用于表示元素的序列,而哈希表示的是键值对之间映射关系。
所谓哈希(hash),就是将不同的输入映射成独一无二的、固定长度的值(又称”哈希值”)。如果不同的输入得到了同一个哈希值,就发生了”哈希碰撞”(collision)。
黑客攻击的一种方法,就是设法制造”哈希碰撞”,然后入侵系统,窃取信息。防止哈希碰撞的最有效方法,就是扩大哈希值的取值空间。
16个二进制位的哈希值,产生碰撞的可能性是 65536 分之一。也就是说,如果有65537个用户,就一定会产生碰撞。哈希值的长度扩大到32个二进制位,碰撞的可能性就会下降到 4,294,967,296 分之一。
更长的哈希值意味着更大的存储空间、更多的计算,将影响性能和成本。开发者必须做出抉择,在安全与成本之间找到平衡。
哈希表是计算机科学中的最重要数据结构之一,这不仅因为它 O(1)O(1) 的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点 —— 哈希函数和冲突解决方法。
2.3.1 哈希函数
实现哈希表的关键点在于哈希函数的选择,哈希函数的选择在很大程度上能够决定哈希表的读写性能。在理想情况下,哈希函数应该能够将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。
如果使用结果分布较为均匀的哈希函数,那么哈希的增删改查的时间复杂度为 O(1);但是如果哈希函数的结果分布不均匀,那么所有操作的时间复杂度可能会达到 O(n),由此看来,使用好的哈希函数是至关重要的。
比较实际的方式是让哈希函数的结果能够尽可能的均匀分布,然后通过工程上的手段解决哈希碰撞的问题。
2.3.2 冲突解决
-
开放寻址法 #
开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n)O(n) 的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
-
拉链法 #
拉链法是哈希表最常见的实现方法,大多数的编程语言都用拉链法实现哈希表,它的实现比较开放地址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间。
实现拉链法一般会使用数组加上链表,不过一些编程语言会在拉链法的哈希中引入红黑树以优化性能,拉链法会使用链表数组作为哈希底层的数据结构,我们可以将它看成可以扩展的二维数组
哈希表可能会在装载因子过高或者溢出桶过多时进行扩容,哈希表扩容并不是原子过程,在扩容的过程中保证哈希的访问是比较有意思的话题.
2.3.3 map的数据结构
Go 语言使用拉链法来解决哈希碰撞的问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或者方法。哈希在每一个桶中存储键对应哈希的前 8 位,当对哈希进行操作时,这些 tophash
就成为可以帮助哈希快速遍历桶中元素的缓存。
哈希表的每个桶都只能存储 8 个键值对,一旦当前哈希的某个桶超出 8 个,新的键值对就会存储到哈希的溢出桶中。随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。
Go 语言运行时同时使用了多个数据结构组合表示哈希表,其中 runtime.hmap
是最核心的结构体, runtime.hmap
的桶是 runtime.bmap
。每一个 runtime.bmap
都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow
中桶存储溢出的数据。
溢出桶是在 Go 语言还使用 C 语言实现时使用的设计,由于它能够减少扩容的频率所以一直使用至今。
随着哈希表存储的数据逐渐增多,我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8 个。
溢出桶也只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
runtime.mapassign
函数会在以下两种情况发生时触发哈希的扩容:
- 装载因子已经超过 6.5;
- 哈希使用了太多溢出桶;
不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 runtime.mapassign
还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。
2.3.4 map初始化
-
字面量
hashMap := map[string]int{ "1": 2, "3": 4, "5": 6, }
当哈希表中的元素数量少于或者等于 25 个时,编译器会将字面量初始化的结构体转换成以下的代码,将所有的键值对一次加入到哈希表中,初始化的方式与数组和切片几乎完全相同
hashMap := make(map[string]int, 3) hashMap["1"] = 2 hashMap["3"] = 4 hashMap["5"] = 6
一旦哈希表中元素的数量超过了 25 个,编译器会创建两个数组分别存储键和值,这些键值对会通过如下所示的 for 循环加入哈希:
hashMap := make(map[string]int, 26) vstatk := []string{"1", "2", "3", ... , "26"} vstatv := []int{1, 2, 3, ... , 26} for i := 0; i < len(vstak); i++ { hashMap[vstatk[i]] = vstatv[i] }
-
运行时
hashMap := make(map[string]int, 3)
当创建的哈希被分配到栈上并且其容量小于
BUCKETSIZE = 8
时,Go 语言在编译阶段会使用如下方式快速初始化哈希,这也是编译器对小容量的哈希做的优化:var h *hmap var hv hmap var bv bmap h := &hv b := &bv h.buckets = b h.hash0 = fashtrand0()
除了上述特定的优化之外,无论
make
是从哪里来的,只要我们使用make
创建哈希,Go 语言编译器都会在类型检查期间将它们转换成runtime.makemap
,使用字面量初始化哈希也只是语言提供的辅助工具,最后调用的都是runtime.makemap
2.3.5 一般使用
_ = hashMap[key]
for k, v := range hashMap {
// k, v
}
hashMap[key] = value
hashMap[key] = newValue
delete(hashMap, key)
2.4 字符串
变量
声明变量的一般形式是使用 var 关键字:
var identifier type
变量声明
操作符 := 可以高效地创建一个新的变量,是使用变量的首选形式,但是只能被用在函数体内,不可以用于全局变量的声明与赋值。
var a int = 10 // 指定变量类型,声明后若不赋值,使用默认值。
var b = 10 // 根据值自行判定变量类型。
c : = 10 // 注意 :=左侧的变量不应该是已经声明过的,否则会导致编译错误。
多变量声明
//类型相同多个变量, 非全局变量
var vname1, vname2, vname3 type
vname1, vname2, vname3 = v1, v2, v3
var vname1, vname2, vname3 = v1, v2, v3
vname1, vname2, vname3 := v1, v2, v3
// 这种因式分解关键字的写法一般用于声明全局变量
var (
vname1 v_type1
vname2 v_type2
)
常量
常量的定义格式:
const identifier [type] = value
多个相同类型的声明可以简写为:
const c_name1, c_name2 = value1, value2
常量还可以用作枚举:
const (
Unknown = 0
Female = 1
Male = 2
)
iota,特殊常量,可以认为是一个可以被编译器修改的常量。每当 iota 在新的一行被使用时,它的值都会自动加 1。
运算符
与 C/C++ 相当接近,不写了。速查手册
条件循环语句
条件语句
if 布尔表达式 {
/* 在布尔表达式为 true 时执行 */
} else {
/* 在布尔表达式为 false 时执行 */
}
switch var1 {
case val1: ...
case val2: ...
default: ...
}
var c1, c2, c3 chan int
var i1, i2 int
select {
case i1 = <-c1:
fmt.Printf("received ", i1, " from c1\n")
case c2 <- i2:
fmt.Printf("sent ", i2, " to c2\n")
default:
fmt.Printf("no communication\n")
}
循环语句
package main
import "fmt"
func main() {
var b int = 15
var a int
numbers := [6]int{1, 2, 3, 5}
/* for 循环 */
for a := 0; a < 10; a++ {
fmt.Printf("a 的值为: %d\n", a)
}
for a < b {
a++
fmt.Printf("a 的值为: %d\n", a)
}
for i,x:= range numbers {
fmt.Printf("第 %d 位 x 的值 = %d\n", i,x)
}
}
附录:数字类型
整型:
序号 | 类型和描述 |
---|---|
1 | uint8 无符号 8 位整型 (0 到 255) |
2 | uint16 无符号 16 位整型 (0 到 65535) |
3 | uint32 无符号 32 位整型 (0 到 4294967295) |
4 | uint64 无符号 64 位整型 (0 到 18446744073709551615) |
5 | int8 有符号 8 位整型 (-128 到 127) |
6 | int16 有符号 16 位整型 (-32768 到 32767) |
7 | int32 有符号 32 位整型 (-2147483648 到 2147483647) |
8 | int64 有符号 64 位整型 (-9223372036854775808 到 9223372036854775807) |
浮点型:
序号 | 类型和描述 |
---|---|
1 | float32 IEEE-754 32位浮点型数 |
2 | float64 IEEE-754 64位浮点型数 |
3 | complex64 32 位实数和虚数 |
4 | complex128 64 位实数和虚数 |
其他数字类型
序号 | 类型和描述 |
---|---|
1 | byte 类似 uint8 |
2 | rune 类似 int32 |
3 | uint 32 或 64 位 |
4 | int 与 uint 一样大小 |
5 | uintptr 无符号整型,用于存放一个指针 |