Go 语言笔记 - 语法和数据类型

一、基础语法

一个标准 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
  • print
  • println
  • real
  • recover
  • string
  • true
  • uint
  • uint8
  • uintptr

二、数据类型

本部分的内容基本来自 《Go 语言设计与实现》 - draveness.me ,这里只是一些关键内容摘录,为了保持上下文内容连贯,还是推荐查看原文。

  • 布尔型 常量 true 或者 false。一个简单的例子:var b bool = true

  • 数字类型 整型 int 和浮点型 float,并且原生支持复数,其中位的运算采用补码。更详细的信息看文章末尾。

  • 字符串类型 使用UTF-8编码标识Unicode文本

  • 派生类型:

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 语言中包含三种初始化切片的方式:

  1. 通过下标的方式获得数组或者切片的一部分,编译器会将 arr[0:3] 或者 slice[0:3] 等语句转换成 OpSliceMake 操作;
  2. 使用字面量初始化新的切片(编译时处理大部分工作);
  3. 使用关键字 make 创建切片(运行时处理相对多):

如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成。但是当我们使用 make 关键字创建切片时,很多工作都需要运行时的参与。

arr[0:3] or slice[0:3] 
slice := []int{1, 2, 3}
slice := make([]int, 10)

使用 lencap 获取长度、容量是切片最常见的操作,编译器将这它们看成两种特殊操作,即 OLENOCAP

切片的长度是它所包含的元素个数。 切片的容量是从它的第一个元素开始数,到其底层数组元素末尾的个数

切片的操作(init、查、改、长度、容量)基本都是在编译期间完成的,编译期间也会将包含 range 关键字的遍历转换成形式更简单的循环。

2.2.2 切片扩容

切片追加和扩容时,会先解构切片结构体获取它的数组指针、大小和容量,切片容量足够时向切片中追加元素,切片容量不足时会调用 runtime.growslice 对切片进行扩容,扩容根据切片的当前容量选择不同的策略:

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于 1024 就会将容量翻倍;
  3. 如果当前切片的长度大于 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)。

img

黑客攻击的一种方法,就是设法制造”哈希碰撞”,然后入侵系统,窃取信息。防止哈希碰撞的最有效方法,就是扩大哈希值的取值空间。

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 函数会在以下两种情况发生时触发哈希的扩容:

  1. 装载因子已经超过 6.5;
  2. 哈希使用了太多溢出桶;

不过因为 Go 语言哈希的扩容不是一个原子的过程,所以 runtime.mapassign 还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。

2.3.4 map初始化
  1. 字面量

    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]
    }
    
  2. 运行时

    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 无符号整型,用于存放一个指针

参考资料


Mac 使用 smb 协议与小米电视共享硬盘 Go 语言笔记 - 复合数据类型