Go程序设计语言 学习笔记 第四章 复合数据类型

复合数据类型是由基本数据类型以各种方式组合而构成的。我们将介绍四种复合数据类型:数组、slice、map、结构体。

数组和结构体都是聚合类型,它们的值由内存中的一组变量构成。数组的元素具有相同类型,而结构体中的元素类型可以不同。数组和结构体的长度都是固定的。反之,slice和map都是动态数据结构,它们的长度在元素添加到结构中时可以动态增长。

4.1 数组

数组是具有固定长度且拥有零个或多个相同数据类型元素的序列。由于数组的长度固定,所以Go里很少直接使用。slice的长度可以增长和缩短,很多场合下使用的很多。

数组中的每个元素是通过索引访问的,索引从0到数组长度减1。Go的内置函数len可返回数组中的元素个数。

go 复制代码
var a [3]int // 3个整数的数组
fmt.Println(a[0]) // 输出数组的第一个元素
fmt.Println(a[len(a)-1]) // 输出数组的最后一个元素,即[2]

// 输出索引和元素
for i, v := range a {
    fmt.Printf("%d %d\n", i, v)
}

// 仅输出元素
for _, v := range a {
    fmt.Printf("%d\n", v)
}

默认情况,一个新数组中的元素初始值为元素类型的零值,对于数字来说,就是0。也可以使用数组字面量根据一组值来初始化一个数组:

go 复制代码
var q [3]int = [3]int{1, 2, 3}
var r [3]int = [3]int{1, 2}
fmt.Println(r[2]) // "0"

在数组字面量中,如果数组长度是省略号,那么数组的长度由初始化数组的元素个数决定。以上数组q可简化为:

go 复制代码
q := [...]int{1, 2, 3}
fmt.Println("%T\n", q) // "[3]int"

数组的长度是数组类型的一部分,所以[3]int和[4]int是两种不同的数组类型。数组的长度必须是常量表达式,即,这个表达式的值在程序编译时就可以确定。

go 复制代码
q := [3]int{1, 2, 3}
q = [4]int{1, 2, 3, 4} // 编译错误:不可以将[4]int赋值给[3]int

数组、slice、map、结构体的字面语法都是相似的。创建数组时可以指定key:

go 复制代码
type Currency int

const (
    USD Currency = iota
    EUR
    GBP
    RMB
)

symbol := []string{USD: "$", EUR: "€", GBP: "£", RMB: "¥"}

fmt.Println(RMB, symbol[RMB]) // "3 ¥"

上例中,创建数组时的有些key可以省略,省略的元素被赋予元素类型的零值,例如:

go 复制代码
r := [...]int{99: -1}

它定义了一个大小为100个元素的数组r,最后一个元素值为-1,其他元素值为0。

如果数组的元素类型是可比较的,那么这个数组也是可比较的,我们可用==操作符来比较两个相同类型的数组,比较的结果是两边元素的值是否完全相同。使用!=来比较两个数组是否不同:

go 复制代码
a := [2]int{1, 2}
b := [...]int{1, 2}
c := [2]int{1, 3}
fmt.Println(a == b, a == c, b == c) // "true false false"
d := [3]int{1, 2}
fmt.Println(a == d) // 编译错误:无法比较[2]int == [3]int

举一个更有意义的例子,crypto/sha256包里的函数Sum256用来为存储在任意字节slice中的消息使用SHA256加密散列算法生成一个摘要。摘要的信息是256位,即[32]byte。如果两个摘要信息相同,那么很有可能这两条原始消息就是相同的;如果这两个摘要信息不同,那么这两条原始消息就是不同的。以下程序输出并比较了"x"和"X"的SHA256散列值:

go 复制代码
package main

import (
	"crypto/sha256"
	"fmt"
)

func main() {
	c1 := sha256.Sum256([]byte("x"))
	c2 := sha256.Sum256([]byte("X"))
	fmt.Printf("%x\n%x\n%t\n%T\n", c1, c2, c1 == c2, c1)
}

运行它:

注意这两个原始消息仅有一位(bit)之差,但它们生成的摘要消息有近一半的位不同。上面的格式化字符串%x表示将一个数组或者slice里面的字节按照十六进制方式输出,%t表示输出一个布尔值,%T表示输出一个值的类型。

当调用一个函数的时候,每个传入的参数都会创建一个副本,然后赋值给对应的函数变量。这种方式传递大数组会变得很低。Go把数组和其他类型都看成值传递,而在其他语言中,数组是隐式地使用引用传递。

也可以显式传递一个数组的指针给函数:

go 复制代码
// 将一个[32]byte数组清零
func zero(ptr *[32]byte) {
    for i := range ptr {
        ptr[i] = 0
    }
}

数组字面量[32]byte{}是含有32个值为0的字节类型的数组,可用它来重写上例:

go 复制代码
func zero(ptr *[32]byte) {
    *ptr = [32]byte{}
}

使用数组指针更高效,且允许被调用函数修改调用方数组中的元素。但由于数组长度是固定的,我们不能向其中删除或添加元素。除了上例SHA256例子中,摘要的结果拥有固定长度,我们一般使用slice,而不是数组。

练习4.1:编写一个函数,用于统计SHA256散列中不同的位数(2.6.2的PopCount):

go 复制代码
package main

func main() {
	// c1 := sha256.Sum256([]byte{'x'})
	// c2 := sha256.Sum256([]byte("X"))

	c1 := [32]byte{0: 1}
	c2 := [32]byte{0: 1, 1: 15}

	println(getDiffBitNum(c1, c2))
}

func getDiffBitNum(a1, a2 [32]byte) int {
	ans := 0
	for i := range a1 {
		i1 := a1[i]
		i2 := a2[i]

		for j := 0; j < 8; j++ {
			if (i1 & (1 << j)) != (i2 & (1 << j)) {
				ans++
			}
		}
	}

	return ans
}

练习4.2:编写一个程序,用于在默认情况下输出其标准输入的SHA256散列,但也支持一个输出SHA384或SHA512散列的命令行标记:

go 复制代码
package main

import (
	"crypto/sha256"
	"crypto/sha512"
	"flag"
	"fmt"
)

func main() {
	SHAType := flag.String("t", "SHA256", "SHA256 OR SHA384 OR SHA512")
	flag.Parse()

	for _, v := range flag.Args() {
		switch *SHAType {
		case "SHA256":
			shaRes := sha256.Sum256([]byte(v))
			fmt.Printf("%s SHA256: %x\n", v, shaRes)
		case "SHA384":
			hash := sha512.New384()
			shaRes := hash.Sum([]byte(v))
			fmt.Printf("%s SHA384: %x\n", v, shaRes)
		case "SHA512":
			hash := sha512.New()
			shaRes := hash.Sum([]byte(v))
			fmt.Printf("%s SHA512: %x\n", v, shaRes)
		}
	}
}

运行它:

4.2 slice

slice表示一个拥有相同类型元素的可变长度的序列。slice通常写成[]T,其中元素的类型都是T;它看上去像没有长度的数组类型。

数组和slice是紧密关联的。slice是一种轻量级的数据结构,可以用来访问数组的部分或者全部的元素,而这个数组称为slice的底层数组。slice有三个属性:指针、长度、容量。指针指向数组的第一个可以从slice中访问的元素,这个元素并不一定是数组的第一个元素。长度指slice中的元素个数,它不能超过slice的容量。容量的大小通常是从slice的起始元素到底层数组的最后一个元素间元素的个数。Go的内置函数len和cap用来返回slice的长度。

一个底层数组可以对应多个slice,这些slice可以引用数组的任何位置,彼此之间的元素还可以重叠。图4-1显示了一个月份名称的字符串数组和两个元素存在重叠的slice。数组的声明是:

所以January就是months[1],December是months[12]。一般来讲,数组中索引0的位置存放数组的第一个元素,但是由于月份总是从1开始,因此我们可以不设置索引为0的元素,这样它的值就是空字符串。

slice操作符s[i:j](其中0<=i<=j<=cap(s))创建了一个新slice,这个新的slice引用了序列s中从i到j-1索引位置的所有元素,这里的s既可以是数组或指向数组的指针,也可以是slice。新slice的元素个数是j-i。如果上面的表达式中省略了i,那么新clice的起始索引位置就是0,即i=0;如果省略了j,那么新slice的结束索引位置是len(s)-1,即j=len(s)。因此slice months[1:13]引用了所有的有效月份,同样的写法可以是months[1:]。slice months[:]引用了整个数组。接下来,我们定义元素重叠的slice,分别用来表示第二季度的月份和北半球的夏季月份:

go 复制代码
Q2 := month2[4:7]
summer := months[6:9]
fmt.Println(Q2) // "[April May June]"
fmt.Println(summer) // "[June July August]"

元素June同时包含在两个slice中。用下面的代码来输出两个slice的共同元素(虽然效率不高):

go 复制代码
for _, s := range summer {
    for _, q := range Q2 {
        if s == q {
            fmt.Println("%s appears in both\n", s)
        }
    }
}

如果slice的引用超过了被引用对象的容量,即cap(s),那么会导致程序宕机;但是如果slice的引用超出了被引用对象的长度,即len(s),那么最终slice会比原slice长:

go 复制代码
fmt.Println(summer[:20]) // 宕机:超过了被引用对象的边界

endlessSummer := summer[:5] // 在slice容量范围内扩展了slice
fmt.Println(endlessSummer) // "[June July August September October]"

另外,注意求字符串(string)子串操作和对字节slice([]byte)做slice操作这两者的相似性。它们都写作x[m:n],并且都返回原始字节的一个子序列,同时它们的底层引用方式也是相同的,所以两个操作都消耗常量时间。如果x是字符串,那么x[m:n]返回的是一个字符串;如果x是字节slice,那么返回的结果是字节slice。

因为slice包含了指向数组元素的指针,所以将一个slice传递给函数的时候,可以在函数内部修改底层数组的元素。换言之,创建一个数组的slice等于为数组创建了一个别名(2.3.2)。下面的函数就地反转整型slice中的元素,它适用于任意长度的整型slice:

go 复制代码
// 就地反转一个整型slice中的元素
func reverse(s []int) {
    for i, j := 0, len(s) - 1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}

反转整个数组:

将一个slice左移n个元素的简单方法是连续调用reverse函数三次。第一次反转前n个元素,第二次反转剩下的元素,最后对整个slice再做一次反转(如果将元素右移n个元素,那么先做第三次调用)。

注意初始化slice s的表达式和初始化数组a的表达式的区别。slice字面量看上去和数组字面量很像,都是用逗号分隔并用花括号括起来的一个元素序列,但是slice没有指定长度。和数组一样,slice也按顺序指定元素,也可以通过索引来指定元素,或者两者结合。

和数组不同的是,slice无法做比较,也不能用==来测试两个slice是否拥有相同的元素。标准库里面提供了高度优化的函数bytes.Equal来比较两个字节slice([]byte)。但对于其他类型的slice,我们必须自己写函数来比较:

go 复制代码
func equal(x, y []string) bool {
    if len(x) != len(y) {
        return false
    }
    for i := range x {
        if x[i] != y[i] {
            return false
        }
    }
    return true
}

上例看起来很简单,且运行时并不比字符串数组使用==做比较多耗费时间。为什么slice不可以直接使用==操作符作比较?有两个原因,首先,和数组不同,slice的元素是间接的,有可能slice可以包含它自身(当slice声明为[]interface{}时,slice的元素可以是自身),虽然有办法处理这种特殊情况,但没有一种是简单、高效、直观的。

其次,因为slice的元素是间接的,所以如果底层数组元素改变,同一个slice在不同的时间会拥有不同的元素。由于散列表(如Go的map类型)仅对元素的键做浅拷贝,这就要求散列表里面键在散列表的整个生命周期内必须保持不变。因为slice需要深度比较,所以就不能用slice作为map的键(可能会出现浅不变,深却变了的情况)。对于引用类型,例如指针和通道,操作符==检查的是引用相等性,即它们是否指向相同的元素。如果有一个相似的slice相等性比较功能,它或许会比较有用,也能解决slice作为map键的问题,但是如果操作符==对slice和数组的行为不一致,会带来困扰。所以最安全的方法就是不允许直接比较slice。

slice唯一允许的比较操作是和nil做比较,例如:

go 复制代码
if summer == nil { /* ... */ }

slice类型的零值是nil。值为nil的slice没有对应的底层数组。值为nil的slice长度和容量都是零,但是也有非nil的slice长度和容量是零,例如[]int{}或make([]int, 3)[3:]。对于任何类型,如果它们的值可以是nil,那么这个类型的nil值可以使用一种转换表达式,例如[]int(nil)。

go 复制代码
var s []int // len(s) == 0, s == nil
s = nil // len(s) == 0, s == nil
s = []int(nil) // len(s) == 0, s == nil
s = []]int{} // len(s) == 0, s != nil

所以,如果想检查一个slice是否是空,那么使用len(s) == 0,而非s == nil,因为s != nil的情况下,slice也可能是空。除了可以和nil做比较之外,值为nil的slice表现和其他长度为零的slice一样。例如,reverse函数调用reverse(nil)也是安全的。除了文档已经明确说明的地方,所有的Go语言函数应该以相同的方式对待nil值的slice和0长度的slice。

内置函数make可以创建一个具有指定元素类型、长度和容量的slice。其中容量参数可以省略,此时,slice的长度和容量相等:

go 复制代码
make([]T, len)
make([]T, len, cap) // 和make([[]T, cap)[:len]功能相同

深入研究下,其实make创建了一个无名数组并返回了它的一个slice;这个数组仅可以通过这个slice来访问。上面的第一行代码中,返回的slice引用了整个数组。第二行代码中,slice只引用了数组的前len个元素,但是它的容量是数组的长度,这为未来的slice元素留出空间。

4.2.1 append函数

内置函数append用来将元素追加到slice的后面。

go 复制代码
var runes []rune
for _, r := range "Hello, 世界" {
    runes = append(runes, r)
}
// 相比%s,%q会输出转义字符,如原字符串中有个\n,用%q输出的字符串中也会有\n,而%s输出的字符串中会有换行符
fmt.Printf("%q\n", runes) // ['H' 'e' 'l' 'l' 'o' ',' ' ' '世' '界']

虽然最方便的用法是[]rune("Hello, 世界"),但上面的循环演示了如何使用append来为一个rune类型的slice添加元素。

append函数对理解slice的工作原理很重要,接下来看一个为[]int数组slice定义的方法appendInt:

go 复制代码
func appendInt(x []int, y int) []int {
    var z []int
    zlen := len(x) + 1
    if zlen <= cap(x) {
        // slice 仍有增长空间,扩展slice内容
        z = x[:zlen]
    } else {
        // slice已无空间,为它分配一个新的底层数组
        // 为了达到分摊线性复杂性,容量扩展一倍
        zcap := zlen
        // x中有0或1个元素时,不会乘2,而是直接创建一个长为1或2的slice
        if zcap < 2*len(x) {
			zcap = 2 * len(x)
		}
		z = make([]int, zlen, zcap)
		copy(z, y) // 内置copy函数
    }
    z[len(x)] = y
    return z;
}

每次appInt调用都必须检查slice是否仍有足够容量来存储数组中的新元素。如果slice容量足够,那么它会定义一个新slice(仍引用原始底层数组)。否则,创建一个拥有足够容量的新底层数组,此时返回的slice和输入slice引用不同的底层数组。

内置copy函数用来为两个拥有相同类型元素的slice复制元素。copy函数的第一个参数是目标slice,第二个参数是源slice,copy函数将源slice中的元素复制到目标slice中,和一般的元素赋值有点像。不同slice可能对应相同底层数组,甚至可能存在元素重叠。copy函数有返回值,它返回实际上复制的元素个数,这个值是两个slice长度的较小值。所以这里不存在由于元素复制而导致的索引越界问题。

对于重叠copy:

go 复制代码
	a := []int{1, 2, 3, 4, 5, 6}
	b := a[0:3]
	c := a[1:4]
	fmt.Println(b)
	fmt.Println(c)
	copy(b, c)
	fmt.Println(b)
	fmt.Println(c)
	fmt.Println(a)

如果把b复制到c,如果复制方式是按顺序执行:b[0]=c[0]、b[1]=c[1]、b[2]=c[2],那么b的结果应该是三个1,但结果是:

因此复制时是先把c复制了一份,赋值号右边,即c[1]、c[2]、c[3],引用的不是底层数组,而是复制品,最终,底层数组a只修改了b的部分。

每次的数组容量扩展时,通过扩展一倍来减少内存分配次数,这样可以保证追加一个元素所消耗的是固定时间,下面的程序演示了这个效果:

go 复制代码
func main() {
    var x, y []int
    for i := 0; i < 10; i++ {
        y = appendInt(x, i)
        fmt.Printf("%d cap=%d\t%v\n", i, cap(y), y)
        x = y
    }
}

每次slice容量的改变都意味着一次底层数组重新分配和元素复制:

我们来仔细看一下当i=3时的情况。这个时候slice x拥有三个元素[0 1 2],但是容量是4,此时slice最后还有一个空位置,所以调用appendInt追加元素3的时候,没有发生底层数组重新分配。调用的结果是slice的长度和容量都是4,并且这个结果slice和x一样拥有相同的底层数组,如图4-2所示。

在下一次循环中i=4,这个时候原来的slice已经没有空间了,所以appendInt函数分配了一个长度为8的新数组。然后将x中的4个元素[0 1 2 3]都复制到新的数组中,最后再追加新元素i。这样slice的长度就是5,而容量是8。多分配的三个位置就留给接下来的循环添加值使用,在接下来的三次循环中,就不需要再分配空间。i=4的迭代中,未给x复制前,y和x是不同数组的slice。这个操作过程如图4-3所示:

内置的append函数使用了比这里的appendInt更复杂的增长策略。通常,我们并不清楚一次append调用会不会导致一次新的内存分配,所以我们不能假设原始的slice和调用append后的结果slice指向同一个底层数组,也无法证明它们是否指向不同的底层数组。同样,我们也无法假设旧slice上对元素的操作会不会影响新的slice元素。因此,通常我们将append调用的结果再次赋值给传入append函数的slice:

go 复制代码
runes = append(runes, r)

不仅仅是在调用append函数的情况下需要更新slice变量。只要函数有可能改变slice的长度或容量,抑或使得slice指向不同的底层数组,都需要更新slice变量。为了正确使用slice,必须记住,虽然底层数组的元素是间接引用的,但slice的指针、长度和容量不是。要更新一个slice的指针,长度或容量必须使用如上所示的显式赋值。从这个角度看,slice并不是纯引用类型,而是像下面这种聚合类型:

go 复制代码
type IntSlice struct {
    ptr *int
    len, cap int
}

(暂时感觉设计得不如C++的vector,如果append是slice的一个方法,而非一个全局函数,那就可以直接改slice本身内部的指针,就不用append后再赋值一次了)

appendInt函数只能给slice添加一个元素,但是内置的append函数可以同时给slice添加多个元素,甚至添加另一个slice里的所有元素:

go 复制代码
var x[]int
x = append(x, 1)
x = append(x, 2, 3)
x = append(x, 4, 5, 6)
x = append(x, x...) // 追加x中的所有元素
fmt.Println(x) // "[1 2 3 4 5 6 1 2 3 4 5 6]"

可以简单修改一下appendInt函数来匹配append的功能。函数appendInt参数声明中的省略号"..."表示该函数可以接受可变长度参数列表。上面例子中append函数的参数后面的省略号表示将一个slice转换为参数列表。5.7节会详细解释这种机制。

go 复制代码
func appendInt(x []int, y ...int) []int {
    var z []int
    zlen := len(x) + len(y)
    // ...扩展slice z的长度至少到zlen
    copy(z[len(x):], y)
    return z
}

扩展slice z底层数组的逻辑和上面一样,所以就不重复了。

4.2.2 slice就地修改

我们多看一些就地使用slice的例子,比如rotate和reverse这种可以就地修改slice元素的函数。下面的函数nonempty可以从给定的一个字符串列表中去除空字符串并返回一个新的slice。

go 复制代码
// Nonempty演示了slice的就地修改算法
package main

// nonempty返回一个新的slice,slice中的元素都是非空字符串
// 在函数的调用过程中,底层数组的元素发生了改变
func nonempty(strings []string) []string {
	i := 0
	for _, s := range strings {
		if s != "" {
			strings[i] = s
			i++
		}
	}
	return strings[:i]
}

这里有一点是输入的slice和输出的slice拥有相同的底层数组,这样就避免在函数内部重新分配一个数组。当然,这种情况下,底层数组的元素只是部分被修改,示例如下:

go 复制代码
data := []string{"one", "", "three"}
fmt.Printf("%q\n", nonempty(data)) // 输出:["one", "three"]
fmt.Printf("%q\n", data) // 输出:["one", "three", "three"]

因此,通常我们会这样来写:data = nonempty(data)。

函数nonempty还可以利用append函数来写:

go 复制代码
func nonempty2(strings []string) []string {
    out := strings[:0] // 引用原始slice的新的零长度的slice
    for _, s := range strings {
        if s != "" {
            out.append(out, s)
        }
    }
    return out
}

无论使用哪种方式,重用底层数组的结果是要为每一个输入的slice产生一个对应的返回值slice,很多从序列中过滤元素再组合结果的算法都是这样做的。这种精细的slice使用方式只是一个特例,并不是规则,但是偶尔这样做可以让实现更清晰、高效、有用。

slice可以用来实现栈。给定一个空的slice元素stack,可以使用append向slice尾部追加值:

go 复制代码
stack = append(stack, v) // push v

栈的顶部是最后一个元素:

go 复制代码
top := stack[len(stack)-1] // 栈顶

通过弹出最后一个元素来缩减栈:

go 复制代码
stack = stack[:len(stack)-1] // pop

为了从slice的中间移除一个元素,并保留剩余元素的顺序,可以使用copy来将高位索引的元素向前移动来覆盖被移除元素所在位置:

go 复制代码
func remove(slice []int, i int) []int {
    copy(slice[i:], slice[i+1:])
    return slice[:len(slice)-1]
}

func main() {
    s := []int{5, 6, 7, 8, 9}
    fmt.Println(remove(s, 2)) // 输出:[5 6 8 9]
}

如果不需要维持slice中剩余元素的顺序,可以简单地将slice的最后一个元素赋值给被移除元素所在的索引位置:

go 复制代码
func remove(slice []int, i int) []int {
    slice[i] = slice[len(slice)-1]
    return slice[:len(slice)-1]
}

func main() {
    s := []int{5, 6, 7, 8, 9}
    fmt.Println(remove(s, 2)) // 
}

练习4.3:重写函数reverse,使用数组指针作为参数而不是slice:

go 复制代码
package main

import "fmt"

func main() {
	intS := [6]int{1, 2, 3, 4, 5, 6}
	fmt.Println(*reverse(&intS))
}

func reverse(sptr *[6]int) *[6]int {
	length := len(*sptr)
	for i, j := 0, length-1; i < j; i, j = i+1, j-1 {
		(*sptr)[i], (*sptr)[j] = (*sptr)[j], (*sptr)[i]
	}

	return sptr
}

运行它:

练习4.4:编写一个函数rotate,实现一次遍历就可以完成元素旋转:

go 复制代码
// 此方式不是原地修改
package main

import "fmt"

func main() {
	is := []int{1, 2, 3, 4, 5}
	fmt.Println(rotate(is, 2))
}

func rotate(is []int, r int) []int {
	ans := make([]int, len(is))
	for i := 0; i < len(is); i++ {
		ans[i] = is[(i+r)%len(is)]
	}

	return ans
}

运行它:

练习4.5:编写一个就地处理函数,用于去除[]string slice中相邻的重复字符串元素:

go 复制代码
package main

import "fmt"

func main() {
	ss := []string{"1", "1", "2", "3", "3", "4"}
	fmt.Println(uniq(ss))
}

func uniq(ss []string) []string {
	resIdx := 0
	for i := 1; i < len(ss); {
		for i < len(ss) && ss[resIdx] == ss[i] {
			i++
		}

		if i == len(ss) {
			break
		}

		resIdx++
		ss[resIdx] = ss[i]
	}

	return ss[:resIdx+1]
}

运行它:

练习4.6:编写一个就地处理函数,用于将一个UTF-8编码的字节slice中所有相邻的Unicode空白字符(查看unicode.IsSpace)缩减为一个ASCII空白字符:

go 复制代码
package main

import (
	"fmt"
	"unicode"
	"unicode/utf8"
)

func main() {
	bs := []byte("你好 \t 世界,  aaa")
	fmt.Printf("%q", shortSpace(bs))
}

func shortSpace(bs []byte) []byte {
	resIdx := 0
	beforeIsSpace := false
	for i := 0; i < len(bs); {
		r, size := utf8.DecodeRune(bs[i:])
		if unicode.IsSpace(r) {
			if !beforeIsSpace {
				bs[resIdx] = ' '
				resIdx++
				beforeIsSpace = true
			}
		} else {
			for j := 0; j < size; j++ {
				bs[resIdx] = bs[i+j]
				resIdx++
			}
			beforeIsSpace = false
		}
		i += size
	}
	return bs[:resIdx]
}

运行它:

练习4.7:修改函数reverse,来翻转一个UTF-8编码的字符串中的字符元素,传入参数是该字符串对应的字节slice类型([]byte)。你可以做到不需要重新分配内存就实现该功能吗?

go 复制代码
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
    // s是string,存的实际是utf8编码
	s := "你好,世界. aaa"
	// 把string转成[]byte后,slice里存的也是utf8编码
	fmt.Printf("%q\n", reverseUTF8([]byte(s)))
}

func reverseUTF8(bs []byte) []byte {
	for i, j := 0, len(bs); i < j; {
	    // 解析第一个UTF8编码,解码成rune类型,即unicode码点
		r1, size1 := utf8.DecodeRune(bs[i:])
		// 解析最后一个UTF8编码,解码成rune类型,即unicode码点
		r2, size2 := utf8.DecodeLastRune(bs[:j])

        // 假如size1是1,size2是4,那么需要把除首尾外的编码右移3位
		if size1 != size2 {
			copy(bs[i+size2:j-size1], bs[i+size1:j-size2])
		}

        // buf用于存放把rune中的unicode码点编码成utf8编码后的结果
		buf := make([]byte, utf8.UTFMax)
		utf8.EncodeRune(buf, r2)
		// 把最后一个utf8编码放到第一个位置
		copy(bs[i:i+size2], buf)

		utf8.EncodeRune(buf, r1)
		// 把第一个utf编码放到最后一个位置
		copy(bs[j-size1:j], buf)

        // 由于互换了首尾的编码,因此i需要跳过最后一个utf8编码的长度
		i += size2
		// 同理,j需要前移第一个utf8编码的长度
		j -= size1
	}

	return bs
}

另一种方法是先reverse每个utf8编码,最后再reverse整个slice:

go 复制代码
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "你好,世界. aaa"
	fmt.Printf("%q\n", []byte(s))
	fmt.Printf("%q\n", reverseBytes([]byte(s)))
}

func reverseBytes(slice []byte) []byte {
	var idx = 0
	for idx < len(slice) {
		_, size := utf8.DecodeRune(slice[idx:])
		for i, j := idx, idx+size-1; i < j; i, j = i+1, j-1 {
			slice[i], slice[j] = slice[j], slice[i]
		}
		idx += size
	}
	for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
		slice[i], slice[j] = slice[j], slice[i]
	}
	return slice
}

运行它:

4.3 map

散列表是设计精妙、用途广泛的数据结构之一。它是一个拥有键值对元素的无序集合。在这个集合中,键的值是唯一的,键对应的值可以通过键来获取、更新、移除。无论这个散列表有多大,这些操作基本上是通过常量时间的键比较就可以完成的。

在Go语言中,map是散列表的引用,map的类型是map[K]V,其中K和V是字典的键和值对应的数据类型。map中所有的键都拥有相同的数据类型,同时所有的值也都拥有相同的数据类型,但是键的类型和值的类型不一定相同。键的类型K,必须是可以通过操作符==来进行比较的数据类型,所以map可以通过相等判断来检测某一个键是否已经存在。虽然浮点型是可以比较的,但比较浮点型的相等性不是一个好主意,如第3章所述,尤其是在NaN可以是浮点值的时候(NAN和任意值都不相等)。值类型V没有任何限制。

内置函数make可用来创建一个map:

go 复制代码
ages := make(map[string]int) // 创建一个从string到int的map

也可以使用map的字面量来新建一个带初始化键值对元素的字典:

go 复制代码
ages := map[string]int {
    "alice": 31,
    "charlie": 34,
}

这等价于:

go 复制代码
ages := make(map[string]int)
ages["alice"] = 31
ages["charlie"] = 34

因此,新的空map的另一种表达式是:map[string]int{}。

map的元素访问也是通过下标的方式:

go 复制代码
ages["alice"] = 32
fmt.Println(ages["alice"]) // 32

可以使用内置函数delete来从字典中根据键移除一个元素:

go 复制代码
delete(ages, "alice") // 移除元素ages["alice"]

即使键不在map中,上面的操作也是安全的。map使用给定键来查找元素,如果对应的元素不存在,就返回值类型的零值。例如,下面的代码同样可以工作,尽管"bob"还不是map的键,ages["bob"]的值是0:

go 复制代码
ages["bob"] = ages["bob"] + 1

快捷赋值方式(如x+=y和x++)对map中的元素同样适用,所以上面的代码还可以写成:

go 复制代码
ages["bob"] += 1

或者更简洁的:

go 复制代码
ages["bob"]++

但map元素不是一个变量,不能获取它的地址,比如这样是错的:

go 复制代码
_ = &ages["bob"] // 编译错误,无法获取map元素的地址

我们无法获取map元素的地址的一个原因是map的增长可能会导致已有元素被重新散列到新的存储位置,这样就可能使得获取的地址无效。

可以使用for循环(结合关键字range)来遍历map中所有的键和对应的值,就像上面遍历slice一样。循环语句的连续迭代将会使得变量name和age被赋予map中的下一对键和值:

go 复制代码
for name, age := range ages {
    fmt.Printf("%s\t%d\n", name, age)
}

map中元素的迭代顺序是不固定的,不同的实现方法会使用不同的散列算法,得到不同的元素顺序。实践中,我们认为这种顺序是随机的,每一次遍历的顺序都不相同,这是有意为之的,每次都使用随机的遍历顺序可以强制要求程序不会依赖具体的哈希函数实现。如果需要按某种顺序遍历map中元素,我们必须显式给键排序(不如C++的map,它是有序的)。例如,如果键是字符串类型,可以使用sort包中的Strings函数来进行键的排序,这是一种常见的模式:

go 复制代码
import "sort"

var names []string
for name := range ages {
    names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
    fmt.Printf("%s\t%d\n", name, ages[name])
}

因为我们一开始就知道slice names的长度,所以直接指定一个slice的长度会更高效。下面的语句创建了一个初始元素为空但容量足够容纳ages map中所有键的slice:

go 复制代码
names := make([]string, 0, len(ages))

在上面的第一个循环中,我们忽略了循环中的第二个变量age。在第二个循环中,我们只需使用slice names中的元素值,所以我们使用空白标识符_来忽略第一个变量,即元素索引。

map类型的零值是nil,即没有引用任何散列表。

go 复制代码
var ages map[string]int
fmt.Println(ages == nil) // true
fmt.Println(len(ages) == 0) // true

大多map操作都可以安全地在map的零值nil上执行,包括查找元素,删除元素,获取map元素个数(len),执行range循环,因为这和空map的行为一致。但是向零值map中设置元素会导致错误:

go 复制代码
ages["carol"] = 21 // 宕机:为零值map中的项赋值

设置元素前,必须初始化。

有时候你需要知道一个元素是否在map中,例如,如果元素类型是数值类型,你需要能够辨别一个不存在的元素或者恰好这个元素的值是0,可以这样做:

go 复制代码
age, ok := ages["bob"]
if !ok { /* "bob"不是字典中的键,age == 0 */ }

通常这两条语句合并成一条语句:

go 复制代码
if age, ok := ages["bob"]; !ok { /* ... */ }

通过下标访问map中的元素输出两个值,第二个值是一个布尔值,用来报告该元素是否存在。这个布尔变量一般叫做ok,尤其是它立即用在if条件判断中的时候。

map不可比较,唯一合法的比较就是和nil做比较,这和slice一样。为了判断两个map是否拥有相同的键和值,必须写一个循环:

go 复制代码
func equal(x, y map[string]int) bool {
    if (len(x) != len(y)) {
        return false
    }

	for k, xv := range x {
	    if yv, ok := y[k]; !ok || yv != xv {
	    	return false
	    }
	}
	return true
}

注意我们用了!ok来区分"元素不存在"和"元素存在但值为零"的情况。如果我们简单地写成了xv != y[k],那么下面的调用将错误地报告两个map是相等的:

go 复制代码
// 如果equal函数写法错误,结果为True
equal(map[string]int{"A": 0}, map[string]int{"B": 42})

Go没有提供集合类型,但是既然map的键都是唯一的,就可以用map来实现这个功能。为了模拟这个功能,程序dedup读取一系列的行,并且只输出每个不同行一次。这个是1.3节演示过的dup程序的变体,它使用map的键来存储已经出现过的行,来确保接下来出现的相同行不会输出。

go 复制代码
func main() {
    seen := make(map[string]bool) // 字符串集合
    input := bufio.NewScanner(os.Stdin)
    for input.Scan() {
        line := input.Text()
        if !seen[line] {
            seen[line] = true
            fmt.Println(line)
        }
    }
    if err := input.Err(); err != nil {
        fmt.Fprintf(os.Stderr, "dedup: %v\n", err)
        os.Exit(1)
    }
}

Go程序员将这种忽略value的map当作一个字符串集合,但并非所有map[string]bool类型的value都是无关紧要的。

有时,我们需要一个map并且需求它的键是slice,但是因为map的键必须是可比较的,所以这个功能无法直接实现。但我们可以分两步来做。首先,定义一个帮助函数k将每一个键都映射到字符串,当且仅当x和y相等的时候,我们才认为k(x) == k(y)。然后,就可以创建一个map,map的键是字符串类型,在每个键元素被访问的时候,调用这个帮助函数。

下例演示了如何使用map来记录提交相同的字符串列表的次数,它使用fmt.Sprintf函数将字符串列表转换为一个字符串以用于map的key,通过%q参数记录每个字符串元素的信息:

go 复制代码
var m = make(map[string]int)

func k(list []string) string {
    return fmt.Sprintf("%q", list)
}

func Add(list []string) {
    m[k(list)]++
}

func Count(list []string) int {
    return m[k(list)]
}

同样的技术可用于任何不可比较的key类型,而不仅仅是slice类型。这种技术对于想使用自定义key比较函数的时候也很有用,例如在比较字符串时忽略大小写。同时,辅助函数k(x)也不一定返回string类型,它可以返回任何可比较的类型,例如整数、数组、结构体等。

这是map的另一个例子,用于统计输出中每个Unicode码点出现的次数。虽然Unicode全部码点的数量巨大,但出现在特定文档中的字符种类并没有多少,使用map可以用比较自然的方式追踪那些出现过的字符的次数。

go 复制代码
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, err
		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(powershell中,是Ctrl+z)。如果输入的是无效的UTF-8编码的字符,返回的将是unicode.ReplacementChar(表示无效字符),并且编码的长度是1。

charcount程序同时打印不同UTF-8编码长度的字符数目。对此,map不是一个合适的数据结构;因为UTF-8编码的长度总是从1到utf8.UTFMax(最大是4个字节),使用数组将更有效。

作为一个实验,我们用charcount程序对本书英文原稿进行了统计。虽然大部分是英语,但也有一些非ASCII字符。下面是排名前10的非ASCII字符:

下面是不同UTF-8编码长度的字符的数目:

map的value类型也可以是一个聚合类型,比如一个map或slice。以下代码中,graph这个map的key类型是一个字符串,value类型是map[string]bool,代表一个字符串集合。从概念上讲,graph将一个字符串类型的key映射到一组相关的字符串集合,它们指向新的graph的key。

go 复制代码
package main

var graph = make(map[string]map[string]bool)

func addEdge(from, to string) {
	edges := graph[from]
	if edges == nil {
		edges = make(map[string]bool)
		graph[from] = edges
	}
	edges[to] = true
}

func hasEdge(from, to string) bool {
	return graph[from][to]
}

其中addEdge函数惰性初始化map是一个惯用方式,也就是说在每个值首次作为key时才初始化。hasEdge函数显示了如何让map的零值也能正常工作;即使from到to的边不存在,graph[from][to]依然可以返回一个有意义的结果。

练习4.8:修改charcount程序,使用unicode.IsLetter等相关的函数,统计字母、数字等Unicode中不同的字符类别:

go 复制代码
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
	letter := 0                     // count of letter
	number := 0                     // count of number

	in := bufio.NewReader(os.Stdin)
	for {
		r, n, err := in.ReadRune() // returns rune, nbytes, err
		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]++

    	// unicode.IsLetter返回是否是文字符号,如字母、中文,数字(如1)和符号(如$)都不是
		if unicode.IsLetter(r) {
			letter++
		}
		if unicode.IsDigit(r) {
			number++
		}
	}
	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)
	}

	fmt.Printf("\n%d UTF-8 letters\n", letter)
	fmt.Printf("\n%d UTF-8 number\n", number)
}

练习4.9:编写一个wordfreq程序,报告输入文本中每个单词出现的频率。在第一次调用Scan前先调用input.Split(bufio.ScanWords)函数,这样可以按单词而不是按行输入:

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	in := bufio.NewScanner(os.Stdin)
	in.Split(bufio.ScanWords)
	ans := map[string]int{}
	for in.Scan() {
		text := in.Text()
		ans[text]++
	}

	fmt.Println(ans)
}

运行它:

4.4 结构体

结构体是一种聚合的数据类型,由零个或多个任意类型的值聚合成的实体。每个值称为结构体的成员。用结构体的经典案例是处理公司的员工信息,每个员工信息包含一个唯一的员工编号、员工的名字、家庭住址、出生日期、工作岗位、薪资、上级领导等。所有这些信息都需要绑定到一个实体中,可以作为一个整体单元被复制,作为函数的参数或返回值,或者是被存储到数组中。

下面两个语句声明了一个叫Employee的命名的结构体类型,并且声明了一个Employee类型的变量dilbert:

go 复制代码
type Employee struct {
	ID        int
	Name      string
	Address   string
	DoB       time.Time
	Position  string
	Salary    int
	ManagerID int
}

var dilbert Employee

dilbert结构体变量的成员可以通过点操作符访问,比如dilbert.Name和dilbert.DoB。因为dilbert是一个变量,它所有的成员也同样是变量,我们可以直接对每个成员赋值:

go 复制代码
dilbert.Salary -= 5000 // demoted, for writing too few lines of code

或者是对成员取地址,然后通过指针访问:

go 复制代码
position := &dilbert.Position
*position = "Senior" + *position // promoted, for outsourcing to Elbonia

点操作符也可以和指向结构体的指针一起工作:

go 复制代码
var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proactive team player)"

相当于下面语句:

go 复制代码
(*employeeOfTheMonth).Position += " (proactive team player)"

下面的EmployeeByID函数将根据给定的员工ID返回对应的员工信息结构体的指针。我们可以使用点操作符来访问它里面的成员:

go 复制代码
func EmployeeByID(id int) *Employee { /* ... */ }

fmt.Println(EmployeeByID(dilbert.ManagerID).Position) // "Pointy-haired boss"

id := dilbert.ID
EmployeeByID(id).Salary = 0 // fired for... no real reason

后面的语句通过EmployeeByID返回的结构体指针更新了Employee结构体的成员。如果将EmployeeByID函数的返回值从*Employee指针类型改为Employee值类型,那么更新语句将不能编译通过,因为在赋值语句的左边并不确定是一个变量(即右值)。

通常一行对应一个结构体成员,成员的名字在前类型在后,不过如果相邻的成员类型如果相同的话可以被合并到一行,就像下面的Name和Address成员那样:

go 复制代码
type Employee struct {
    ID            int
    Name, Address string
    DoB           time.Time
    Position      string
    Salary        int
    ManagerID     int
}

结构体成员的输入顺序也有重要的意义。我们可以将Position成员合并(因为也是字符串类型),或者是交换Name和Address的先后顺序,那样的话就是定义了不同的结构体类型。通常,我们只是将相关的成员写到一起。

如果结构体成员名字是以大写字母开头的,那么该成员就是导出的;这是Go语言导出规则决定的。一个结构体可能同时包含导出和未导出的成员。

结构体类型往往是冗长的,因为它的每个成员可能都会占一行。虽然我们每次都可以重写整个结构体成员,但是重复会令人厌烦。因此,完整的结构体写法通常只在类型声明语句的地方出现,就像Employee类型声明语句那样。

一个命名为S的结构体类型将不能再包含S类型的成员:因为一个聚合的值不能包含它自身(该限制同样适用于数组)。但S类型的结构体可以包含*S指针类型成员,这可以让我们创建递归的数据结构,比如链表或树结构等。以下代码中,我们使用一个二叉树来实现插入排序:

go 复制代码
package main

import "fmt"

type tree struct {
	value       int
	left, right *tree
}

// 就地排序
func Sort(values []int) {
	var root *tree
	for _, v := range values {
		root = add(root, v)
	}
	appendValues(values[:0], root)
}

// appendValues将元素按照顺序追加到values里面,然后返回结果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
}

func add(t *tree, value int) *tree {
	if t == nil {
		// 等价于返回&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
}

func main() {
	is := []int{4, 5, 2, 6, 1}
	Sort(is)
	fmt.Println(is)
}

运行它:

结构体类型的零值是每个成员的零值。通常会将零值作为最合理的默认值。例如,对于bytes.Buffer类型,结构体初始值就是一个随时可用的空缓冲,还有第9章将会讲到的sync.Mutex的零值也是有效的未锁定状态。有时候这种零值可用的特性是自然获得的,但也有些类型需要一些额外的工作。

如果结构体没有任何成员的话就是空结构体,写作struct{},它的大小是0,也不包含任何信息,但是有时候依然是有价值的。有些Go程序员用map来模拟set数据结构时,用它来代替map中布尔类型的value,只是强调key的重要性,但是因为节约的空间有限,而且语法比较复杂,所以我们通常会避免这样的用法:

go 复制代码
seen := make(map[string]struct{}) // set of strings
// ...
if _, ok := seen[s]; !ok {
    seen[s] = struct{}{}
    // ...first time seeing s...
}

4.4.1 结构体字面值

结构体值也可以用结构体字面值表示,结构体字面值可以指定每个成员的值:

go 复制代码
type Point struct { X, Y int }

p := Point{1, 2}

这里有两种形式的结构体字面值语法,上面的是第一种写法,要求以结构体成员定义的顺序为每个结构体成员指定一个字面值。它要求写代码和读代码的人要记住结构体的每个成员的类型和顺序,不过结构体成员有细微的调整就可能导致上述代码不能编译。因此,上述的语法一般只在定义结构体的包内部使用,或者是在较小的结构体中使用,这些结构体的成员排列比较规则,比如image.Point{x, y}或color.RGBA{red, green, blue, alpha}。

其实更常用的是第二种写法,以成员名字和相应的值来初始化,可以包含部分或全部的成员,如1.4节的Lissajous程序的写法:

go 复制代码
anim := gif.GIF{LoopCount: nframes}

在这种形式的结构体字面值写法中,如果成员被忽略的话将默认用零值。因为提供了成员的名字,所以成员出现的顺序并不重要。

两种形式的写法不能混合使用。而且,你不能企图在外部包中用第一种顺序赋值的技巧来偷偷地初始化结构体中未导出的成员:

go 复制代码
package p
type T struct { a, b int } // a and b are not exported

package q
import "p"
var _ = p.T{a:1, b:2} // compile error: can't reference a, b
var _ = p.T{1, 2}     // compile error: can't reference a, b

虽然上面最后一行代码的编译错误信息中并没有显式提到a和b是未导出的成员,但是这样企图隐式使用未导出成员的行为也是不允许的。

结构体可以作为函数的参数和返回值。例如,这个Scale函数将Point类型的值缩放后返回:

go 复制代码
func Scale(p Point, factor int) Point {
    return Point{p.X * factor, p.Y * factor}
}

fmt.Println(Scale(Point{1, 2}, 5)) // "{5 10}"

如果考虑效率的话,较大的结构体通常会用指针的方式传入和返回:

go 复制代码
func Bonus(e *Employee, percent int) int {
    return e.Salary * percent / 100
}

如果要在函数内部修改结构体成员的话,用指针传入是必须的;因为在Go语言中,所有的函数参数都是值拷贝传入的,函数参数将不再是函数调用时的原始变量。

go 复制代码
func AwardAnnualRaise(e *Employee) {
    e.Salary = e.Salary * 105 / 100
}

因为结构体通常通过指针处理,可以用下面写法来创建并初始化一个结构体变量,并返回结构体的地址:

go 复制代码
pp := &Point{1, 2}

它和下面的语句是等价的:

go 复制代码
pp := new(point)
*pp = Point{1, 2}

不过&Point{1, 2}写法可以直接在表达式中使用,比如一个函数调用。

4.4.2 结构体比较

如果结构体的全部成员都是可以比较的,那么结构体也是可以比较的,那样的话两个结构体将可以使用==或!=运算符进行比较。相等比较运算符==将比较两个结构体的每个成员,因此下面两个比较的表达式是等价的:

go 复制代码
type Point struct {X, Y int}

p := Point{1, 2}
q := Point{2, 1}
fmt.Println(p.X == q.X && p.Y == q.Y) // false
fmt.Println(p == q)                   // false

可比较的结构体类型和其他可比较的类型一样,可以用于map的key类型。

go 复制代码
type address struct {
    hostname string 
    port     int
}

hits := make(map[address]int)
hits[address{"golang.org", 443}]++

4.4.3 结构体嵌入和匿名成员

本节将讨论Go中不同寻常的结构体嵌套机制,这个机制可以让我们将一个命名结构体当作另一个结构体类型的匿名成员使用;并提供了一种方便的语法,使用简单的表达式(如x.f)就可以访问匿名成员链中嵌套的成员(比如x.d.e.f)。

考虑一个二维的绘图程序,提供了一个各种图形的库,例如矩形、椭圆形、星形和轮形等几何形状。这里是其中两个的定义:

go 复制代码
type Circle struct {
    X, Y, Radius int
}

type Wheel struct {
    X, Y, Radius, Spokes int
}

一个Circle代表的圆形类型包含了标准圆心的X和Y坐标信息,和一个Radius表示的半径信息。一个Wheel轮形除了包含Circle类型的全部成员外,还增加了Spokes表示径向辐条的数量。我们可以这样创建一个wheel变量:

go 复制代码
var w Wheel
w.X = 8
w.Y = 8
w.Radius = 5
w.Spokes = 20

随着库中几何形状数量的增多,我们一定会注意到它们之间的相似和重复之处,所以我们可能为了便于维护而将相同的属性独立出来:

go 复制代码
type Point struct {
    X, Y int
}

type Circle struct {
    Center Point
    Radius int
}

type Wheel struct {
    Circle Circle
    Spokes int
}

这样改动之后结构体类型变得清晰了,但是这种修改同时也导致了访问每个成员变得繁琐:

go 复制代码
var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
w.Circle.Radius = 5
w.Spokes = 20

Go语言有一个特性让我们只声明一个成员对应的数据类型而不指名成员的名字;这类成员叫匿名成员。匿名成员的数据类型必须是命名的类型或指向一个命名的类型的指针。下面的代码中,Circle和Wheel各自都有一个匿名成员。我们可以说Point类型被嵌入到了Circle结构体,同时Circle类型被嵌入到了Wheel结构体。

go 复制代码
type Circle struct {
    Point
    Radius int
}

type Wheel struct {
    Circle
    Spokes int
}

得益于匿名嵌入的特性,我们可以直接访问叶子属性而不需要给出完整的路径:

go 复制代码
var w Wheel
w.X = 8            // equivalent to w.Circle.Point.X = 8
w.Y = 8            // equivalent to w.Circle.Point.Y = 8
w.Radius = 5       // equivalent to w.Circle.Radius = 5
w.Spokes = 20

在右边的注释中给出的显式形式访问这些叶子成员的语法依然有效,因此匿名成员并不是真的无法访问了。其中匿名成员Circle和Point都有自己的名字------就是命名的类型名字------但是这些名字在点操作符中是可选的。我们在访问子成员的时候可以忽略任何匿名成员部分。

不幸的是,结构体字面值并没有简短表示匿名成员的语法,因此下面的语句都不能编译通过:

go 复制代码
w = Wheel{8, 8, 5, 20} // compile error: unknown fields
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20} // compile error: unknown fields

结构体字面值必须遵循形状类型声明时的结构,所以我们只能用下面的两种语法,它们彼此是等价的:

go 复制代码
w = Wheel{Circle{Point{8, 8}, 5}, 20}

w = Wheel{
    Circle: Circle{
        Point: Point{X: 8, Y: 8},
        Radius: 5,
    },
    Spokes: 20 // NOTE: trailing comma nescessary here (and at Radius)
}

// %#v相比%v,还会打印出变量类型
fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:8, Y:8}, Radius:5}, Spokes:20}

w.X = 42

fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:42, Y:8}, Radius:5}, Spokes:20}

需要注意的是Printf函数中%v参数包含的#副词,它表示用和Go语言类似的语法来打印值。对于结构体类型来说,将包含每个成员的名字。

因为匿名成员也有一个隐式的名字,因此不能包含两个类型相同的匿名成员,这会导致名字冲突。在上面的例子中,Point和Circle匿名成员都是导出的。即使它们不导出(比如改成小写字母开头的point和circle),我们依然可以在包内用简短形式访问匿名成员嵌套的成员:

go 复制代码
w.X = 8 // equivalent to w.circle.point.X = 8

但是在包外部,因为circle和point没有导出,不能访问它们的成员,因此简短的匿名成员访问语法也是禁止的。

到目前为止,我们看到匿名成员特性只是对访问嵌套成员的点运算符提供了简短的语法糖。稍后,我们将会看到匿名成员并不要求是结构体类型;其实任何命名的类型都可以作为结构体的匿名成员。但是为什么要嵌入一个没有任何子成员类型的匿名成员类型呢?

答案是匿名类型的方法集。简短的点运算符语法可以用于选择匿名成员嵌套的成员,也可以用于访问它们的方法。实际上,外层的结构体不仅仅是获得了匿名成员类型的所有成员,而且也获得了该类型导出的全部的方法。这个机制可以用于将一些有简单行为的对象组合成有复杂行为的对象。组合是Go语言中面向对象编程的核心,我们将在6.3节专门讨论。

4.5 JSON

JavaScript对象表示法(JSON)是一种用于发送和接收结构化信息的标准协议。在类似的协议中,JSON并不是唯一的一个标准协议。XML(7.14)、ASN.1和Google的Protocol Buffers都是类似的协议,并且有各自的特色,但是由于简洁性、可读性和流行程度等原因,JSON是应用最广泛的一个。

Go语言对于这些标准格式的编码和解码都有良好的支持,有标准库中的encoding/json、encoding/xml、encoding/asn1等包提供支持(Protocol Buffers的支持由github.com/golang/protobuf包提供),并且这类包都有着相似的API接口。本书,我们将对重要的encoing/json包的用法做个概述。

JSON是对JavaScript中各种类型的值------字符串、数字、布尔值和对象------的Unicode文本编码。它可以用有效可读的方式表示第三章的基础数据类型和本章的数组、slice、结构体和map等聚合数据类型。

基本的JSON类型有数字(十进制或科学计数法)、布尔值(true或false)、字符串,其中字符串是以双引号包含的Unicode字符序列,支持和Go语言类似的反斜杠转义特性。但JSON里面的\uhhh转义数字得到的是UTF-16编码(UTF-16和UTF-8一样是一种变长的编码,有些Unicode码点较大的字符需要用4个字节来表示;而且UTF-16还有大端和小端的问题),而不是Go语言的rune类型。

这些基础类型可以通过JSON的数组和对象类型进行递归组合。一个JSON数组是一个有序的元素序列,写在一个方括号中并以逗号分隔。JSON的数组用来编码Go里面的数组和slice。JSON的对象是一串字符串到值的映射,写成name:value对的序列,每个元素之间用逗号分隔,两边用花括号括起来。JSON的对象用来编码Go里面的map(键为字符串类型)和结构体。例如:

go 复制代码
boolean         true
number          -273.15
string          "She said \"Hello, BF\""
array           ["gold", "silver", "bronze"]
object          {"year": 1980,
                 "event": "archery",
                 "medals": ["gold", "silver", "bronze"]}

考虑一个应用,它负责收集各种电影评论并提供反馈功能。它的Movie数据类型和一个典型的表示电影的值列表如下所示(在结构体声明中,Year和Color成员后面的字符串面值是结构体成员Tag;我们稍后会解释它的作用)。

go 复制代码
type Movie struct {
    Title  string
    Year   int  `json:"released"`
    Color  bool `json:"color,omitempty"`
    Actors []string
}

var movies = []Movie{
    {Title: "Casablanca", Year: 1942, Color: false,
        Actors: []string{"Humphrey Bogart", "Ingrid Bergman"}},
    {Title: "Cool Hand Luke", Year: 1967, Color: true,
        Actors: []string{"Paul Newman"}},
    {Title: "Bullitt", Year: 1968, Color: true,
        Actors: []string{"Steve McQueen", "Jacqueline Bisset"}},
    // ...
}

这样的数据结构特别适合JSON格式,并且在两者之间转换也很容易。将一个Go语言中类似movies的结构体slice转为JSON的过程叫编组(marshaling)。编组通过调用json.Marshal函数完成:

go 复制代码
data, err := json.Marshal(movies)
if err != nil {
    log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)

Marshal函数返回一个编码后的字节slice,包含很长的字符串,并且没有空白缩进;我们将它折行以便于显示:

这种紧凑的表示形式虽然包含了全部的信息,但很难阅读。为了生成便于阅读的格式,另一个json.MarshalIndent函数将产生整齐缩进的输出。该函数有两个额外的字符串参数用于表示每一行输出的前缀和每一个层级的缩进:

go 复制代码
data, err := json.MarshalIndent(movies, "", "    ")
if err != nil {
    log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)

上面的代码将产生这样的输出(最后一个对象成员后面没有逗号分隔符):

在编码时,默认使用Go语言结构体的名字作为JSON的对象(通过reflect技术,我们将在12.6节讨论)。只有导出的结构体成员才会被编码,这也就是我们为什么选择用大写字母开头的成员名称。

细心的读者可能已经注意到,其中名为Year的成员在编码后变成了released,还有Color成员编码后变成了小写字母color。这是因为结构体成员Tag导致的。一个结构体成员Tag是在编译阶段关联到该成员的元信息字符串:

go 复制代码
Year  int  `json:"released"`
Color bool `json:"color,omitempty"`

结构体的成员Tag可以是任意的字符串面值,但是通常是一系列用空格分隔的key:"value"键值对序列;标签的值用双引号括起来。键json控制包encoding/json的行为,同样其他的encoding/...包也遵循这个规则。标签值的第一部分指定了Go结构体成员对应JSON中字段的名字。Color成员的Tag还带了一个额外的omitempty选项,表示当Go语言结构体成员为空或零值时不生成该成员到JSON中。所以,对于《Cassablanca》这部黑白电影,就没有输出成员color到JSON中。

编码的逆操作是解码,对应将JSON数据解码为Go语言的数据结构,Go语言中一般叫unmarshaling,通过json.Unmarshal函数完成。下面的代码将JSON格式的电影数据解码为一个结构体slice,结构体中只有Title成员。通过定义合适的Go语言数据结构,我们可以选择性地解码JSON中感兴趣的成员。当Unmarshal函数调用返回,slice将被只含有Title信息的值填充,其他JSON成员将被忽略:

go 复制代码
var titles []struct{ Title string }
if err := json.Unmarshal(data, &titles); err != nil {
    log.Fatalf("JSON unmarshaling failed: %s", err)
}
fmt.Println(titles) // "[{Casablanca} {Cool Hand Luke} {Bullitt}]"

许多web服务都通过JSON接口,通过HTTP接口发送JSON格式请求并返回JSON格式的信息。为了说明这一点,我们通过Github的issue查询服务来演示类似的用法。首先,我们要定义合适的类型和常量:

go 复制代码
// Package github provides a Go API for the GitHub issue tracker.
// See https://developer.github.com/v3/search/#search-issues.
package github

import "time"

const IssuesURL = "https://api.github.com/search/issues"

type IssuesSearchResult struct {
	TotalCount int `json:"total_count"`
	Items      []*Issue
}

type Issue struct {
	Number    int
	HTMLURL   string `json:"html_url"`
	Title     string
	State     string
	User      *User
	CreatedAt time.Time `json:"created_at"`
	Body      string    // in Markdown format
}

type User struct {
	Login   string
	HTMLURL string `json:"html_url"`
}

和前面一样,即使对应的JSON对象名是小写字母,每个结构体的成员名也是声明为大写字母开头。因为有些JSON成员名字和Go结构体成员名字并不相同,因此需要Go语言结构体成员Tag来指定对应的JSON名字。同样,在解码的时候也需要做同样的处理,GitHub服务返回的信息比我们定义的要多很多。

SearchIssues函数发出一个HTTP请求,然后解码返回的JSON格式的结果。因为用于提供的查询条件可能包含类似?和&之类的特殊字符,为了避免对URL造成冲突,我们用url.QueryEscape来对查询中的特殊字符进行转义操作。

go 复制代码
// SearchIssues queries the GitHub issue tracker.
func SearchIssues(terms []string) (*IssuesSearchResult, error) {
	q := url.QueryEscape(strings.Join(terms, " "))
	resp, err := http.Get(IssuesURL + "?q=" + q)
	if err != nil {
		return nil, err
	}

	// We must close resp.Body on all execution paths.
	// (Chapter 5 presents 'defer', which makes this simpler.)
	if resp.StatusCode != http.StatusOK {
		resp.Body.Close()
		return nil, fmt.Errorf("search query failed: %s", resp.Status)
	}

	var result IssuesSearchResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		resp.Body.Close()
		return nil, err
	}
	resp.Body.Close()
	return &result, nil
}

在早些的例子中,我们使用了json.Unmarshal函数来将JSON格式的字符串解码为字节slice。但是这个例子中,我们使用了基于流式的解码器json.Decoder,它可以从一个输入流解码JSON数据,尽管这不是必须得。还有一个针对输出流的json.Encoder编码对象。

我们调用Decode方法来填充变量。这里有多种方法可以格式化结构。下面是最简单的一种,以一个固定宽度打印每个issue,但是在下一节我们将看到如何利用模板来输出复杂的格式。

go 复制代码
func main() {
	result, err := github.SearchIssues(os.Args[1:])
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d issues:\n", result.TotalCount)
	for _, item := range result.Items {
		fmt.Printf("#%-5d %9.9s %.55s\n",
			item.Number, item.User.Login, item.Title)
	}
}

通过命令行参数指定检索条件。下面的命令是查询Go语言项目中和JSON解码相关的问题(该命令搜索项目的issue跟踪接口,查找关于JSON编码的Open状态的bug列表),还有查询返回的结果:

上图中实际访问的url是:https://api.github.com/search/issues?q=repo%3Agolang%2Fgo+is%3Aopen+json+decoder。%3A代表`:`,%2F代表`/`,+代表空格` `。

GitHub的Web服务接口(https://developer.github.com/v3/)有很多功能,这里不再赘述。

练习4.10:修改issue实例,根据问题的时间进行分类,比如一个月以内,一年以内或者超过一年:

go 复制代码
func main() {
	timeLimitDay, err := strconv.ParseInt(os.Args[1], 10, 32)
	if err != nil {
		log.Fatal(err)
	}
	timeLimitSecond := time.Duration(timeLimitDay * 86400 * 1000000000)
	result, err := SearchIssues(os.Args[2:])
	if err != nil {
		log.Fatal(err)
	}
	now := time.Now()
	fmt.Printf("%d issues:\n", result.TotalCount)
	for _, item := range result.Items {
		timeDiff := now.Sub(item.CreatedAt)
		if timeDiff < timeLimitSecond {
		    // %-5d:最小宽度5,左对齐
		    // %9.9s:最小宽度9,最大宽度9(超了截断)
		    // %.55s:最小宽度无,最大宽度55(超了截断)
			fmt.Printf("#%-5d %9.9s %.55s\n",
				item.Number, item.User.Login, item.Title)
		}
	}
}

以上代码通过命令行指定要查看多少天内的issue。

4.6 文本和HTML模板

上例给仅仅给出了最简单的个哦实话,这种情况下,Printf函数足够用了。但是有的情况下格式化会比这个复杂很多,并且要求格式和代码彻底分离。这个可以通过text/template包和html/template包里面的方法来实现,这两个包提供了一种机制,可以将程序变量的值带入到文本或HTML模板中。

模板是一个字符串或者文件,它包含一个或者多个两边用双大括号包围的单元------{{...}},这称为操作。大多数的字符串是直接输出的,但是操作可以引发其他的行为。每个操作在模板语言里面都对应一个表达式,提供的简单但强大的功能包括:输出值,选择结构体成员,调用函数和方法,描述控制逻辑(比如if-else语句和range循环),实例化其他的模板等。一个简单的字符串模板如下所示:

go 复制代码
const templ = `{{.TotalCount}} issues:
{{range .Items}}-----------------------------
Number: {{.Number}}
User:   {{.User.Login}}
Title:  {{.Title | printf "%.64s"}}
Age:    {{.CreatedAt | daysAgo}} days
{{end}}`

模板先输出符合条件的issue数量,然后分别输出每个issue的序号、用户、标题和距离创建时间已过去的天数。在这个操作里面,有一个表示当前值的标记,用点号(.)表示。点号最开始的时候表示模板里面的参数,在这个例子中即是github.IssuesSearchResult。操作{{.TotalCount}}代表TotalCount成员的值,直接输出。{{range.Items}}和{{end}}操作创建一个循环,所以它们内部的值会展开很多次,这个时候点号(.)表示Items里面连续的元素。

在操作中,符号|会将前一个操作的结果当作下一个操作的输入,和UNIX的shell管道类似。在Title的例子中,第二个操作就是printf函数,它是一个基于内置函数fmt.Sprintf实现的内置函数,所有模板都可以直接使用。对于Age部分,第二个动作是一个叫daysAgo的函数,通过time.Since函数将CreatedAt成员转换为已经过去了多少天:

go 复制代码
func daysAgo(t time.Time) int {
    return int(time.Since(t).Hours() / 24)
}

需要注意的是CreatedAt的参数类型是time.Time,并不是字符串。我们可以通过定义一些方法来控制字符串的格式化(2.5)方式;另外也可以定义方法来控制自身JSON序列化和反序列化的方式。time.Time的JSON序列化值是一个标准时间格式的字符串。

生成模板的输出需要两个处理步骤。第一步是要分析模板并转为内部表示,然后基于指定的输入执行模板。分析模板部分一般只需要执行一次。下面的代码创建并分析上面定义的模板templ。注意方法调用链的顺序:template.New先创建并返回一个模板;Funcs方法将daysAgo等自定义函数注册到模板中,并返回模板;最后调用Parse函数分析模板。

go 复制代码
var report, err = template.New("report").
	Funcs(template.FuncMap{"daysAgo": daysAgo}).
	Parse(templ)
if err != nil {
	log.Fatal(err)
}

因为模板通常在编译时就测试好了,如果模板解析失败将是一个致命的错误。template.Must辅助函数可以简化这个致命错误的处理:它接受一个模板和一个error类型的参数,检测error是否为nil(如果不是nil则发出panic异常),然后返回传入的模板。我们将在5.9节再讨论这个话题。

一旦模板已经创建、注册了daysAgo函数、并通过分析和检测,我们就可以使用github.IssuesSearchResult作为输入源、os.Stdout作为输出源来执行模板:

go 复制代码
var report = template.Must(template.New("issuelist").
    Funcs(template.FuncMap{"daysAgo": daysAgo}).
    Parse(templ))

func main() {
    result, err := github.SearchIssues(os.Args[1:])
    if err != nil {
        log.Fatal(err)
    }
    if err := report.Execute(os.Stdout, result); err != nil {
        log.Fatal(err)
    }
}

程序输出一个纯文本报告:

我们再来看html/template包。它使用和text/template包里面一样的API和表达式语句,并且额外地对出现在HTML、JavaScript、CSS和URL中的字符串进行自动转义。这些功能可以避免生成的HTML引发长久以来都会有的安全问题,比如注入攻击,对方利用issue的标题来包含不安全的代码,在模板中如果没有合理地进行转义,会让它们能够控制整个页面。

下面的模板将issue输出为HTML的表格,注意导入不同的包:

go 复制代码
import "html/template"

var issueList = template.Must(template.New("issuelist").Parse(`
<h1>{{.TotalCount}} issues</h1>
<table>
<tr style='text-align: left'>
  <th>#</th>
  <th>State</th>
  <th>User</th>
  <th>Title</th>
</tr>
{{range .Items}}
<tr>
  <td><a href='{{.HTMLURL}}'>{{.Number}}</a></td>
  <td>{{.State}}</td>
  <td><a href='{{.User.HTMLURL}}'>{{.User.Login}}</a></td>
  <td><a href='{{.HTMLURL}}'>{{.Title}}</a></td>
</tr>
{{end}}
</table>
`))

下面的命令将在新模板上执行查询:

图4-4显示了生成的HTML表格在Web浏览器中的样子。链接指向GitHub上面对应的页面。

图4.4中的issue没有包含会对HTML格式产生冲突的特殊字符,但下面两个issue里包含&<字符:

图4.5显示了该查询的结果。注意,html/template包自动将HTML元字符转义,这样标题才能显示正常。如果我们错误地使用了text/template包,那么字符串&lt;将会被当作小于号<,而字符串<link>将变成一个link元素,这将改变HTML的文档结构,甚至有可能产生安全问题。

我们可以通过使用命名的字符串类型template.HTML类型而不是字符串类型避免模板自动转义受信任的HTML数据。同样的命名类型适用于受信任的JavaScript、CSS和URL。下面的程序演示了相同数据在不同类型下的效果,A是字符串类型而B是template.HTML类型。

go 复制代码
package main

import (
	"html/template"
	"log"
	"os"
)

func main() {
	const templ = `<p>A: {{.A}}</p><p>B: {{.B}}</p>`
	t := template.Must(template.New("escape").Parse(templ))
	var data struct {
		A string        // 不受信任的纯文本
		B template.HTML // 受信任的HTML
	}
	data.A = "<b>Hello!</b>"
	data.B = "<b>Hello!</b>"
	if err := t.Execute(os.Stdout, data); err != nil {
		log.Fatal(err)
	}
}

图4-6演示了这个模板在浏览器中的输出,我们可以看出来A转义了而B没有。

这里仅仅演示了模板系统最基本的功能。如果你希望获取更多信息,可以查询相关包的文档。

相关推荐
汤姆和杰瑞在瑞士吃糯米粑粑1 分钟前
【C++学习篇】AVL树
开发语言·c++·学习
虾球xz13 分钟前
游戏引擎学习第58天
学习·游戏引擎
LuH112423 分钟前
【论文阅读笔记】Scalable, Detailed and Mask-Free Universal Photometric Stereo
论文阅读·笔记
奶香臭豆腐1 小时前
C++ —— 模板类具体化
开发语言·c++·学习
波音彬要多做2 小时前
41 stack类与queue类
开发语言·数据结构·c++·学习·算法
m0_748256782 小时前
WebGIS实战开源项目:智慧机场三维可视化(学习笔记)
笔记·学习·开源
红色的山茶花2 小时前
YOLOv9-0.1部分代码阅读笔记-loss.py
笔记
南七澄江4 小时前
各种网站(学习资源及其他)
开发语言·网络·python·深度学习·学习·机器学习·ai
胡西风_foxww5 小时前
【es6复习笔记】Promise对象详解(12)
javascript·笔记·es6·promise·异步·回调·地狱
机智的叉烧9 小时前
前沿重器[57] | sigir24:大模型推荐系统的文本ID对齐学习
人工智能·学习·机器学习