注:学习《Go语言圣经》笔记,PDF点击下载,建议看书。
Go语言小白学习笔记,书上的内容照搬,大佬看了勿喷,以后熟悉了会总结成自己的读书笔记。
数组是一个由固定长度的特定类型元素组成的序列, 一个数组可以由零个或多个元素组成。因为数组的长度是固定的, 因此在Go语言中很少直接使用数组。 和数组对应的类型是Slice( 切片) , 它是可以增长和收缩动态序列, slice功能也更灵活, 但是要理解slice工作原理的话需要先理解数组。
数组的每个元素可以通过索引下标来访问, 索引下标的范围是从0开始到数组长度减1的位置。 内置的len函数将返回数组中元素的个数。
默认情况下, 数组的每个元素都被初始化为元素类型对应的零值, 对于数字类型来说就是0。我们也可以使用数组字面值语法用一组值来初始化数组:
在数组字面值中, 如果在数组的长度位置出现的是“…”省略号, 则表示数组的长度是根据初始化值的个数来计算。 因此, 上面q数组的定义可以简化为
数组的长度是数组类型的一个组成部分, 因此[3]int和[4]int是两种不同的数组类型。 数组的长度必须是常量表达式, 因为数组的长度需要在编译阶段确定。
我们将会发现, 数组、 slice、 map和结构体字面值的写法都很相似。 上面的形式是直接提供顺序初始化值序列, 但是也可以指定一个索引和对应值列表的方式初始化, 就像下面这样:
在这种形式的数组字面值形式中, 初始化索引的顺序是无关紧要的, 而且没用到的索引可以省略, 和前面提到的规则一样, 未指定初始值的元素将用零值初始化。 例如,
定义了一个含有100个元素的数组r, 最后一个元素被初始化为-1, 其它元素都是用0初始化。
如果一个数组的元素类型是可以相互比较的, 那么数组类型也是可以相互比较的, 这时候我们可以直接通过==比较运算符来比较两个数组, 只有当两个数组的所有元素都是相等的时候数组才是相等的。 不相等比较运算符!=遵循同样的规则。
作为一个真实的例子, crypto/sha256包的Sum256函数对一个任意的字节slice类型的数据生成一个对应的消息摘要。 消息摘要有256bit大小, 因此对应[32]byte数组类型。 如果两个消息摘要是相同的, 那么可以认为两个消息本身也是相同( 译注: 理论上有HASH码碰撞的情况,但是实际应用可以基本忽略) ; 如果消息摘要不同, 那么消息本身必然也是不同的。 下面的例子用SHA256算法分别生成“x”和“X”两个信息的摘要:
gopl.io/ch4/sha256
上面例子中, 两个消息虽然只有一个字符的差异, 但是生成的消息摘要则几乎有一半的bit位是不相同的。 需要注意Printf函数的%x副词参数, 它用于指定以十六进制的格式打印数组或slice全部的元素, %t副词参数是用于打印布尔型数据, %T副词参数是用于显示一个值对应的数据类型。
当调用一个函数的时候, 函数的每个调用参数将会被赋值给函数内部的参数变量, 所以函数参数变量接收的是一个复制的副本, 并不是原始调用的变量。 因为函数参数传递的机制导致传递大的数组类型将是低效的, 并且对数组参数的任何的修改都是发生在复制的数组上, 并不能直接修改调用时原始的数组变量。 在这个方面, Go语言对待数组的方式和其它很多编程语言不同, 其它编程语言可能会隐式地将数组作为引用或指针对象传入被调用的函数。
当然, 我们可以显式地传入一个数组指针, 那样的话函数通过指针对数组的任何修改都可以直接反馈到调用者。 下面的函数用于给[32]byte类型的数组清零:
其实数组字面值[32]byte{}就可以生成一个32字节的数组。 而且每个数组的元素都是零值初始化, 也就是0。 因此, 我们可以将上面的zero函数写的更简洁一点:
虽然通过指针来传递数组参数是高效的, 而且也允许在函数内部修改数组的值, 但是数组依然是僵化的类型, 因为数组的类型包含了僵化的长度信息。 上面的zero函数并不能接收指向[16]byte类型数组的指针, 而且也没有任何添加或删除数组元素的方法。 由于这些原因, 除了像SHA256这类需要处理特定大小数组的特例外, 数组依然很少用作函数参数; 相反, 我们一般使用slice来替代数组。
==
相等测试可以判断两个是否是引用相同的对象。 一个针对slice的浅相等测试的==
操作符可能是有一定用处的, 也能临时解决map类型的key问题, 但是slice和数组不同的相等测试行为会让人困惑。 因此, 安全的做法是直接禁止slice之间的比较操作。==
0来判断, 而不应该用s ==
nil来判断。除了和nil相等比较外, 一个nil值的slice的行为和其它任意0长度的slice一样; 例如reverse(nil)也是安全的。 除了文档已经明确说明的地方, 所有的Go语言函数应该以相同的方式对待nil值的slice和0长度的slice。在循环中使用append函数构建一个由九个rune字符构成的slice, 当然对应这个特殊的问题我们可以通过Go语言内置的[]rune(“Hello, 世界”)转换操作完成。
append函数对于理解slice底层是如何工作的非常重要, 所以让我们仔细查看究竟是发生了什么。 下面是第一个版本的appendInt函数, 专门用于处理[]int类型的slice:
每次调用appendInt函数, 必须先检测slice底层数组是否有足够的容量来保存新添加的元素。如果有足够空间的话, 直接扩展slice( 依然在原有的底层数组之上) , 将新添加的y元素复制到新扩展的空间, 并返回slice。 因此, 输入的x和输出的z共享相同的底层数组.
如果没有足够的增长空间的话, appendInt函数则会先分配一个足够大的slice用于保存新的结果, 先将输入的x复制到新的空间, 然后添加y元素。 结果z和输入的x引用的将是不同的底层数组。
虽然通过循环复制元素更直接, 不过内置的copy函数可以方便地将一个slice复制另一个相同类型的slice。 copy函数的第一个参数是要复制的目标slice, 第二个参数是源slice, 目标和源的位置顺序和 dst = src 赋值语句是一致的。 两个slice可以共享同一个底层数组, 甚至有重叠也没有问题。 copy函数将返回成功复制的元素的个数( 我们这里没有用到) , 等于两个slice中较小的长度, 所以我们不用担心覆盖会超出目标slice的范围。
为了提高内存使用效率, 新分配的数组一般略大于保存x和y所需要的最低大小。 通过在每次扩展数组时直接将长度翻倍从而避免了多次内存分配, 也确保了添加单个元素操的平均时间是一个常数时间。 这个程序演示了效果:
让我们仔细查看i=3次的迭代。 当时x包含了[0 1 2]三个元素, 但是容量是4, 因此可以简单将新的元素添加到末尾, 不需要新的内存分配。 然后新的y的长度和容量都是4, 并且和x引用着相同的底层数组, 如图4.2所示。
在下一次迭代时i=4, 现在没有新的空余的空间了, 因此appendInt函数分配一个容量为8的底层数组, 将x的4个元素[0 1 2 3]复制到新空间的开头, 然后添加新的元素i, 新元素的值是4。新的y的长度是5, 容量是8; 后面有3个空闲的位置, 三次迭代都不需要分配新的空间。 当前迭代中, y和x是对应不同底层数组的view。 这次操作如图4.3所示。
内置的append函数可能使用比appendInt更复杂的内存扩展策略。 因此, 通常我们并不知道append调用是否导致了内存的重新分配, 因此我们也不能确认新的slice和原始的slice是否引用的是相同的底层数组空间。 同样, 我们不能确认在原先的slice上的操作是否会影响到新的slice。 因此, 通常是将append返回的结果直接赋值给输入的slice变量:
更新slice变量不仅对调用append函数是必要的, 实际上对应任何可能导致长度、 容量或底层数组变化的操作都是必要的。 要正确地使用slice, 需要记住尽管底层数组的元素是间接访问的, 但是slice对应结构体本身的指针、 长度和容量部分是直接访问的。 要更新这些信息需要像上面例子那样一个显式的赋值操作。 从这个角度看, slice并不是一个纯粹的引用类型, 它实际上是一个类似下面结构体的聚合类型:
我们的appendInt函数每次只能向slice追加一个元素, 但是内置的append函数则可以追加多个元素, 甚至追加一个slice。
通过下面的小修改, 我们可以可以达到append函数类似的功能。 其中在appendInt函数参数中的最后的“…”省略号表示接收变长的参数为slice。
为了避免重复, 和前面相同的代码并没有显示。
让我们看看更多的例子, 比如旋转slice、 反转slice或在slice原有内存空间修改元素。 给定一个字符串列表, 下面的nonempty函数将在原有slice内存空间之上返回不包含空字符串的列表:
比较微妙的地方是, 输入的slice和输出的slice共享一个底层数组。 这可以避免分配另一个数组, 不过原来的数据将可能会被覆盖, 正如下面两个打印语句看到的那样:
因此我们通常会这样使用nonempty函数: data = nonempty(data) 。
无论如何实现, 以这种方式重用一个slice一般都要求最多为每个输入值产生一个输出值, 事实上很多这类算法都是用来过滤或合并序列中相邻的元素。 这种slice用法是比较复杂的技巧, 虽然使用到了slice的一些技巧, 但是对于某些场合是比较清晰和有效的。
一个slice可以用来模拟一个stack。 最初给定的空slice对应一个空的stack, 然后可以使用append函数将新的值压入stack:
要删除slice中间的某个元素并保存原有的元素顺序, 可以通过内置的copy函数将后面的子slice向前依次移动一位完成:
哈希表是一种巧妙并且实用的数据结构。 它是一个无序的key/value对的集合, 其中所有的key都是不同的, 然后通过给定的key可以在常数时间复杂度内检索、 更新或删除对应的value。
在Go语言中, 一个map就是一个哈希表的引用, map类型可以写为map[K]V, 其中K和V分别对应key和value。 map中所有的key都有相同的类型, 所有的value也有着相同的类型, 但是key和value之间可以是不同的数据类型。 其中K对应的key必须是支持==比较运算符的数据类型, 所以map可以通过测试key是否相等来判断是否已经存在。 虽然浮点数类型也是支持相等运算符比较的, 但是将浮点数用做key类型则是一个坏的想法, 正如第三章提到的, 最坏的情况是可能出现的NaN和任何浮点数都不相等。 对于V对应的value数据类型则没有任何的限制。
因此, 另一种创建空的map的表达式是 map[string]int{} 。
所有这些操作是安全的, 即使这些元素不在map中也没有关系; 如果一个查找失败将返回value类型对应的零值, 例如, 即使map中不存在“bob”下面的代码也可以正常工作, 因为ages[“bob”]失败时将返回0。
更简单的写法
禁止对map元素取址的原因是map可能随着元素数量的增长而重新分配更大的内存空间, 从而可能导致之前的地址无效。
要想遍历map中全部的key/value对的话, 可以使用range风格的for循环实现, 和之前的slice遍历语法类似。 下面的迭代语句将在每次迭代时设置name和age变量, 它们对应下一个键/值对:
Map的迭代顺序是不确定的, 并且不同的哈希函数实现可能导致不同的遍历顺序。 在实践中, 遍历的顺序是随机的, 每一次遍历的顺序都不相同。 这是故意的, 每次都使用随机的遍历顺序可以强制要求程序不会依赖具体的哈希函数实现。 如果要按顺序遍历key/value对, 我们必须显式地对key进行排序, 可以使用sort包的Strings函数对字符串slice进行排序。 下面是常见的处理方式:
因为我们一开始就知道names的最终大小, 因此给slice分配一个合适的大小将会更有效。 下面的代码创建了一个空的slice, 但是slice的容量刚好可以放下map中全部的key:
在上面的第一个range循环中, 我们只关心map中的key, 所以我们忽略了第二个循环变量。在第二个循环中, 我们只关心names中的名字, 所以我们使用“_”空白标识符来忽略第一个循环变量, 也就是迭代slice时的索引。
map上的大部分操作, 包括查找、 删除、 len和range循环都可以安全工作在nil值的map上, 它们的行为和一个空的map类似。 但是向一个nil值的map存入元素将导致一个panic异常:
在向map存数据前必须先创建map。
通过key作为索引下标来访问map将产生一个value。 如果key在map中是存在的, 那么将得到与key对应的value; 如果key不存在, 那么将得到value对应类型的零值, 正如我们前面看到的ages[“bob”]那样。 这个规则很实用, 但是有时候可能需要知道对应的元素是否真的是在map之中。 例如, 如果元素类型是一个数字, 你可以需要区分一个已经存在的0, 和不存在而返回零值的0, 可以像下面这样测试:
在这种场景下, map的下标语法将产生两个值; 第二个是一个布尔值, 用于报告元素是否真的存在。 布尔变量一般命名为ok, 特别适合马上用于if条件判断部分。
和slice一样, map之间也不能进行相等比较; 唯一的例外是和nil进行比较。 要判断两个map是否包含相同的key和value, 我们必须通过一个循环实现:
要注意我们是如何用!ok来区分元素缺失和元素不同的。 我们不能简单地用xv != y[k]判断, 那样会导致在判断下面两个map时产生错误的结果:
Go语言中并没有提供一个set类型, 但是map中的key也是不相同的, 可以用map实现类似set的功能。 为了说明这一点, 下面的dedup程序读取多行输入, 但是只打印第一次出现的行。( 它是1.3节中出现的dup程序的变体。 ) dedup程序通过map来表示所有的输入行所对应的set集合, 以确保已经在集合存在的行不会被重复打印。
Go程序员将这种忽略value的map当作一个字符串集合, 并非所有 map[string]bool 类型value都是无关紧要的; 有一些则可能会同时包含true和false的值。
有时候我们需要一个map或set的key是slice类型, 但是map的key必须是可比较的类型, 但是slice并不满足这个条件。 不过, 我们可以通过两个步骤绕过这个限制。 第一步, 定义一个辅助函数k, 将slice转为map对应的string类型的key, 确保只有x和y相等时k(x) == k(y)才成立。然后创建一个key为string类型的map, 在每次对map操作时先用k辅助函数将slice转化为string类型。
下面的例子演示了如何使用map来记录提交相同的字符串列表的次数。 它使用了fmt.Sprintf函数将字符串列表转换为一个字符串以用于map的key, 通过%q参数忠实地记录每个字符串元素的信息:
使用同样的技术可以处理任何不可比较的key类型, 而不仅仅是slice类型。 这种技术对于想使用自定义key比较函数的时候也很有用, 例如在比较字符串的时候忽略大小写。 同时, 辅助函数k(x)也不一定是字符串类型, 它可以返回任何可比较的类型, 例如整数、 数组或结构体等。
这是map的另一个例子, 下面的程序用于统计输入中每个Unicode码点出现的次数。 虽然Unicode全部码点的数量巨大, 但是出现在特定文档中的字符种类并没有多少, 使用map可以用比较自然的方式来跟踪那些出现过字符的次数。
gopl.io/ch4/charcount
// Charcount computes counts of Unicode characters.
package main
import (
"bufio"
"fmt"
"io"
"os"
"unicode"
"unicode/utf8"
)
func main() {
counts := make(map[rune]int) // counts of Unicode characters
var utflen [utf8.UTFMax + 1]int // count of lengths of UTF-8 encodings
invalid := 0 // count of invalid UTF-8 characters
in := bufio.NewReader(os.Stdin)
for {
r, n, err := in.ReadRune() // returns rune, nbytes, error
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "charcount: %v\n", err)
os.Exit(1)
}
if r == unicode.ReplacementChar && n == 1 {
invalid++
continue
}
counts[r]++
utflen[n]++
}
fmt.Printf("rune\tcount\n")
for c, n := range counts {
fmt.Printf("%q\t%d\n", c, n)
}
fmt.Print("\nlen\tcount\n")
for i, n := range utflen {
if i > 0 {
fmt.Printf("%d\t%d\n", i, n)
}
}
if invalid > 0 {
fmt.Printf("\n%d invalid UTF-8 characters\n", invalid)
}
}
ReadRune方法执行UTF-8解码并返回三个值: 解码的rune字符的值, 字符UTF-8编码后的长度, 和一个错误值。 我们可预期的错误值只有对应文件结尾的io.EOF。 如果输入的是无效的UTF-8编码的字符, 返回的将是unicode.ReplacementChar表示无效字符, 并且编码长度是1。
charcount程序同时打印不同UTF-8编码长度的字符数目。 对此, map并不是一个合适的数据结构; 因为UTF-8编码的长度总是从1到utf8.UTFMax( 最大是4个字节) , 使用数组将更有效。
作为一个实验, 我们用charcount程序对英文版原稿的字符进行了统计。 虽然大部分是英语,但是也有一些非ASCII字符。 下面是排名前10的非ASCII字符:
Map的value类型也可以是一个聚合类型, 比如是一个map或slice。 在下面的代码中, 图graph的key类型是一个字符串, value类型map[string]bool代表一个字符串集合。 从概念上将,graph将一个字符串类型的key映射到一组相关的字符串集合, 它们指向新的graph的key。
其中addEdge函数惰性初始化map是一个惯用方式, 也就是说在每个值首次作为key时才初始化。 addEdge函数显示了如何让map的零值也能正常工作; 即使from到to的边不存在,graph[from][to]依然可以返回一个有意义的结果。
结构体是一种聚合的数据类型, 是由零个或多个任意类型的值聚合成的实体。 每个值称为结构体的成员。 用结构体的经典案例处理公司的员工信息, 每个员工信息包含一个唯一的员工编号、 员工的名字、 家庭住址、 出生日期、 工作岗位、 薪资、 上级领导等等。 所有的这些信息都需要绑定到一个实体中, 可以作为一个整体单元被复制, 作为函数的参数或返回值, 或者是被存储到数组中, 等等。
dilbert结构体变量的成员可以通过点操作符访问, 比如dilbert.Name和dilbert.DoB。 因为dilbert是一个变量, 它所有的成员也同样是变量, 我们可以直接对每个成员赋值:
下面的EmployeeByID函数将根据给定的员工ID返回对应的员工信息结构体的指针。 我们可以使用点操作符来访问它里面的成员:
后面的语句通过EmployeeByID返回的结构体指针更新了Employee结构体的成员。 如果将EmployeeByID函数的返回值从 *Employee 指针类型改为Employee值类型, 那么更新语句将不能编译通过, 因为在赋值语句的左边并不确定是一个变量( 译注: 调用函数返回的是值,并不是一个可取地址的变量) 。
通常一行对应一个结构体成员, 成员的名字在前类型在后, 不过如果相邻的成员类型如果相同的话可以被合并到一行, 就像下面的Name和Address成员那样:
结构体成员的输入顺序也有重要的意义。 我们也可以将Position成员合并( 因为也是字符串类型) , 或者是交换Name和Address出现的先后顺序, 那样的话就是定义了不同的结构体类型。 通常, 我们只是将相关的成员写到一起。
如果结构体成员名字是以大写字母开头的, 那么该成员就是导出的; 这是Go语言导出规则决定的。 一个结构体可能同时包含导出和未导出的成员。
结构体类型往往是冗长的, 因为它的每个成员可能都会占一行。 虽然我们每次都可以重写整个结构体成员, 但是重复会令人厌烦。 因此, 完整的结构体写法通常只在类型声明语句的地方出现, 就像Employee类型声明语句那样。
一个命名为S的结构体类型将不能再包含S类型的成员: 因为一个聚合的值不能包含它自身。( 该限制同样适应于数组。 ) 但是S类型的结构体可以包含 *S 指针类型的成员, 这可以让我们创建递归的数据结构, 比如链表和树结构等。 在下面的代码中, 我们使用一个二叉树来实现一个插入排序:
gopl.io/ch4/treesort
package main
type tree struct {
value int
left, right *tree
}
// Sort sorts values in place
func Sort(values []int) {
var root *tree
for _, v := range values {
root = add(root, v)
}
appendValues(values[:0], root)
}
func add(t *tree, value int) *tree {
if t == nil {
// Equivalent to return &tree{value: value}.
t = new(tree)
t.value = value
return t
}
if value < t.value {
t.left = add(t.left, value)
} else {
t.right = add(t.right, value)
}
return t
}
// appendValues appends the elements of t to values in order
// and returns the resulting slice.
func appendValues(values []int, t *tree) []int {
if t != nil {
values = appendValues(values, t.left)
values = append(values, t.value)
values = appendValues(values, t.right)
}
return values
}
结构体类型的零值是每个成员都对是零值。 通常会将零值作为最合理的默认值。 例如, 对于bytes.Buffer类型, 结构体初始值就是一个随时可用的空缓存, 还有在第9章将会讲到的sync.Mutex的零值也是有效的未锁定状态。 有时候这种零值可用的特性是自然获得的, 但是也有些类型需要一些额外的工作。
如果结构体没有任何成员的话就是空结构体, 写作struct{}。 它的大小为0, 也不包含任何信息, 但是有时候依然是有价值的。 有些Go语言程序员用map带模拟set数据结构时, 用它来代替map中布尔类型的value, 只是强调key的重要性, 但是因为节约的空间有限, 而且语法比较复杂, 所有我们通常避免避免这样的用法。
==
或!=运算符进行比较。 相等比较运算符==
将比较两个结构体的每个成员, 因此下面两个比较的表达式是等价的: