首页 Go的复合数据类型
文章
取消

Go的复合数据类型

之前讨论了Go程序中的基础数据类型;它们就像宇宙中的原子一样。这部分介绍复合数据类型,复合数据类型是由基本数据类型以各种方式组合而构成的,就像分子由原子构成一样。接下来将重点讲解四种复合数据类型,分别是数组、slicemap和结构体。

另外末尾部分将演示如何将使用这些数据类型构成的结构化数据编码为JSON数据,从JSON数据转换为结构化数据,以及从模板生成HTML页面。

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

数组

数组是具有固定长度且拥有零个或者多个相同数据类型元素的序列。由于数组的长度固定,所以在Go里面很少直接使用。slice的长度可以增长和缩短,在很多场合下使用得更多。然而,在理解slice之前,我们必须先理解数组。

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

1
2
3
4
5
6
7
8
9
10
11
var a [3]int        // 3个整数的数组
fmt.Println(a[0])   // 输出数组的第一个元素
fmt.Print1n(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。也可以使用数组字面量根据一组值来初始化一个数组。

1
2
3
var q [3]int = [3]int{1, 2, 3}
var r [3]int = [3]int{1, 2}
fmt.Println(r[2])   // 只有前两位初始化了,未被显式初始化的执行隐式初始化,初始化为"0"

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

1
2
q := [..]int{1,2,3}
fmt.Printf("%T\n", q)   // "[3]int"

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

1
2
q := [3]int{1, 2, 3}
q := [4]int{1, 2, 3, 4} // 编译错误,q已经是一个长度为3的整型数组

如我们所见,数组、slicemap和结构体的字面语法都是相似的。上面的例子是按顺序给出一组值;也可以像这样给出一组值,这一组值同样具有索引和索引对应的值:

1
2
3
4
5
6
7
8
9
type Currency int
const (
    USD Currency = iota
    EUR
    GBP
    RMB
)
symbol := [...]string{USD: "$", EUR: "E", GBP: "E", RMB: "¥"}   // 这里应该是map结构吧,学习到这里的时候是猜测
fmt.Println(RMB,symbol[RMB])    // "3 ¥"

在这种情况下,索引可以按照任意顺序出现,并且有的时候还可以省略。和上面一样,没有指定值的索引位置的元素默认被赋予数组元素类型的零值。例如,

1
r := [...]int{99 : -1}  // 其实就是数组结构,只不过下标表现的形式改变了,这里表示下标99对应的元素为-1

定义了一个拥有100个元素的数组r,除了最后一个元素值是-1外,该数组中的其他元素值都是0。

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

1
2
3
4
5
6
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.Print1n(a == d) // 编译错误:无法比较[2]int == [3]int

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

1
2
3
4
5
6
7
8
9
10
11
12
import "crypto/sha256"

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)
// Output:
// 2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
// 4b68ab3847feda7d6c62c1fbcbeebfa35eab7351ed5e78f4ddadea5df64b8015
// false
// [32]uint8
}

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

当调用一个函数的时候,每个传入的参数都会创建一个副本,然后赋值给对应的函数变量,所以函数接受的是一个副本,而不是原始的参数。使用这种方式传递大的数组会变得很低效,并且在函数内部对数组的任何修改都仅影响副本,而不是原始数组。这种情况下,Go把数组和其他的类型都看成值传递。而在其他的语言中,数组是隐式地使用引用传递。

当然,也可以显式地传递一个数组的指针给函数,这样在函数内部对数组的任何修改都会反映到原始数组上面。下面的程序演示如何将一个数组[32]byte的元素清零:

1
2
3
4
5
func zero(ptr *[32]byte) {
    for i := range ptr {
        ptr[i] = 0
    }
}

数组字面量[32]byte{}可以生成一个拥有32个字节元素的数组。数组中每个元素的值都是字节类型的零值,即0。可以利用这一点来写另一个版本的数组清零程序:

1
2
3
func zero(ptr *[32]byte) {
    *ptr = [32]byte{}
}

使用数组指针是高效的,同时允许被调函数修改调用方数组中的元素,但是因为数组长度是固定的,所以数组本身是不可变的。例如上面的zero函数不能接受一个[16]byte这样的数组指针,同样,也无法为数组添加或者删除元素。由于数组的长度不可变的特性,除了在特殊的情况下之外,我们很少使用数组。上面关于SHA256的例子中,摘要的结果拥有固定的长度,我们可以使用数组作为函数参数或结果,但是更多的情况下,我们使用slice

slice

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

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

  • 有点像vector

一个底层数组可以对应多个slice,这些slice可以引用数组的任何位置,彼此之间的元素还可以重叠。来一个数组声明:

1
months := [...]string{1: "January", /*...*/, 12: "December"}

在这个数组里,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,那么新slice的起始索引位置就是0,即i = 0;如果省略了j,那么新slice的结束索引位置1en(s) - 1,即j = len(s)。因此slice对象months[1:13]引用了所有的有效月份,同样的写法可以是months[1:]

  • 注意:仍然是常规的[ )形式,可以理解为一个约定俗称的规则;

slice对象months[:]引用了整个数组。接下来,我们定义元素重叠的slice,分别用来表示第二季度的月份和北半球的夏季月份:

1
2
3
4
Q2 := months[4:7]
summer := months[6:9]
fmt.Println(Q2)     // ["April""May""June"]
fmt.Println(summer) // ["June""July""August"]

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

1
2
3
4
5
6
7
    for _, s := range summer {
        for _, q := range Q2 {
            if s ==q {
                fmt.Printf("%s appears in both\n", s)
            }
        }
    }

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

1
2
3
fmt.Println(summer[:20])    // 宕机:超过了被引用对象的边界
endlessSummer := summer[:5] // 在slice容量范围内扩展了s1ice
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节)。下面的函数reverse就地反转了整型slice中的元素,它适用于任意长度的整型slice

1
2
3
4
5
6
//就地反转一个整型slice中的元素
func reverse(s []int) {
    for i,j := 0, len(s) - 1; 1 < j; i, j = i + 1, j - 1 {
        s[i], s[j] = s[j], s[i] // 交换
    }
}

这里,反转整个数组a:

1
2
3
a := [...]int{0, 1, 2, 3, 4, 5}
reverse(a[:])
fmt.Println(a)  // "[5 4 3 2 1 0]"

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

1
2
3
4
5
6
s := []int{0, 1, 2, 3, 4, 5}
// 向左移动两个元素
reverse(s[:2])
reverse(s[2:])
reverse(s)
fmt.Print1n(s)  // "[2 3 4 5 0 1]"

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

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

1
2
3
4
5
6
7
8
9
10
11
12
// 仔细一看,实现逻辑其实极其严格
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的元素不是直接的,所以如果底层数组元素改变,同一个slice在不同的时间会拥有不同的元素。由于散列表(例如Go的map类型)仅对元素的键做浅拷贝,这就要求散列表里面键在散列表的整个生命周期内必须保持不变。因为slice需要深度比较,所以就不能用slice作为map的键。对于引用类型,例如指针和通道,操作符==检查的是引用相等性,即它们是否指向相同的元素。如果有一个相似的slice相等性比较功能,它或许会比较有用,也能解决slice作为map键的问题,但是如果操作符==slice和数组的行为不一致,会带来困扰。所以最安全的方法就是不允许直接比较slice

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

1
if summer == nil { /* ... */ }

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

1
2
3
4
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 == ni1,因为s != nil的情况下,slice也有可能是空。除了可以和nil做比较之外,值为nilslice表现和其他长度为零的slice一样。例如,reverse函数调用reverse(nil)也是安全的。除非文档上面写明了与此相反,否则无论值是否为nil,Go的函数都应该以相同的方式对待所有长度为零的slice

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

1
2
make([]T, len)
make([]T, len, cap) // 和make([]T, cap)[:len]功能相同

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

append函数

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

1
2
3
4
var _, r := range "Hello, 世界" {
    runes = append(runes, r)
}
fmt.Printf("%q\n", runes)

看一下简易的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func appendInt(x []int, y int) []int {
    var z []int
    zlen := len(x) + 1
    if zlen <= cap(x) {
        // slice仍然有增长空间,那么可以就地拓展
        z = x[:zlen]
    } else {
        // 如果slice没有了增长空间,那么需要分配一个新的底层数组,一般而言,容量翻倍
        zcap := zlen
        if zcap < 2 * len(x) {
            zcap = 2 * len(x)
        }
        z = make([]int, zlen, zcap)
        copy(z, x)  // 内置copy函数,把x的内容给z
    }
    z[len(x)] = y
    return z
}

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

1
runes append(runes, r)

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

1
2
3
4
type IntSlice struct {
    ptr *int
    len, cap int
}

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

1
2
3
4
5
6
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转换为参数列表。后续会详细解释这种机制。

1
2
3
4
5
6
7
func appendInt(x []int, y ...int) []int {
    var z []int
    zlen := len(x) + len(y)
    // ...扩展slice z的长度至少到z1en...
    copy(z[len(x):], y)
    return z
}

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

slice就地修改

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

1
2
3
4
5
6
7
8
9
10
func noempty(strings []string) []string {
    i := 0
    for _, s := range strings {
        if s != "" {
            strings[i] = s
            i++
        }
    }
    return strings[:i]  // 返回的是所需要的那部分slice,使用的是同一个底层数组
}

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

1
2
3
data := []string{"one", "", "three"}
fmt.Printf("%q\n", nonempty(data))  // ["one" "three"]
fmt.Printf("%q\n", data)    // ["one" "three" "three"],这个输出可以很好的看出,共享了一个数组

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

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

1
2
3
4
5
6
7
8
9
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,可以使用appendslice尾部追加值:

1
stack = append(stack, v)    // push v

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

1
top := stack[len(stack) - 1]    // 栈顶

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

1
stack = stack[:len(stack) - 1]  // pop,这里要注意优先级细节信息

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

1
2
3
4
5
6
7
8
9
10
// i代表的是索引的位置
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的最后一个元素赋值给被移除元素所在的索引位置。

map

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

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

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

1
ages := make(map[string]int)    // 创建-个从string到int的map

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

1
2
3
4
ages := map[string]int{
    "alice":    31,
    "charlie":  34,
}

这个等价于:

1
2
3
ages := make(map[string]int)
ages["alice"] = 31
ages["charlie"] = 34

因此,新的空map的另外一种表达式是:map[string]int{}map的元素访问也是通过下标的方式:

1
2
ages["alice"] = 32
fmt.Println(ages["alice"])  // "32"

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

1
delete(ages, "alice")   // 通过键移除元素ages["alice"]

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

1
ages["bob"] = ages["bob"] + 1   // 生日快乐

快捷赋值方式(如x += yx++)对map中的元素同样适用,所以上面的代码还可以写成:ages["bob"]+1或者更简洁的:ages["bob"]++

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

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

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

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

1
2
3
for name,age := range ages {
    fmt.Printf("%s\t%d\n", name, age)
}

map中元素的迭代顺序是不固定的,不同的实现方法会使用不同的散列算法,得到不同的元素顺序。实践中,我们认为这种顺序是随机的,从一个元素开始到后一个元素,依次执行。这个是有意为之的,这样可以使得程序在不同的散列算法实现下变得健壮。如果需要按照某种顺序来遍历map中的元素,我们必须显式地来给键排序。例如,如果键是字符串类型,可以使用sort包中的Strings函数来进行键的排序,这是一种常见的模式:

1
2
3
4
5
6
7
8
9
10
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])
}

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

1
names := make([]string, 0, len(ages))

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

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

1
2
3
var ages map[string]int
fmt.Println(ages == nil)    // "true"
fmt.Println(len(ages) == 0) // "true"

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

1
ages["caro1"] = 21  // 宕机:为零值map中的项赋值,这里就不像CPP的策略了

设置元素之前,必须初始化map

通过下标的方式访问map中的元素总是会有值。如果键在map中,你将得到键对应的值;如果键不在map中,你将得到map值类型的零值,如同对于ages["bob"]的操作结果。很多情况下,这个没有问题,但是有时候你需要知道一个元素是否在map中。例如,如果元素类型是数值类型,你需要能够辨别一个不存在的元素或者恰好这个元素的值是0,可以这样做:

1
2
age, ok := ages["bob"]
if !ok { /* "bob"不是字典中的键,age == 0 */ }

通常这两条语句合并成一条语句,如下所示:

1
if age, ok := ages["bob"]; !ok { /*...*/ }

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

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

1
2
3
4
5
6
7
8
9
10
11
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来区分元素不存在元素存在但值为零的情况。如果我们简单地写成了xw! = y[k],那么下面的调用将错误地报告两个map是相等的。

1
2
// 如果equal函数写法错误,结果为True,因为不存在的键会自动返回0值
equal(map[string]int{"A": 0}, map[string]int{"B": 42})

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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程序员通常把这种使用map的方式描述成字符串集合,但是请注意,并不是所有的map[string]bool都是简单的集合,有一些map的值会同时包含truefalse的情况。

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

下面的例子通过一个字符串列表使用一个map来记录Add函数被调用的次数。帮助函数使用fmt.Sprintf来将一个字符串slice转换为一个适合做map键的字符串,使用%q来格式化slice并记录每个字符串的边界。

1
2
3
4
5
6
7
8
var m = make(map[string]int)

// 帮助函数k
func k(list []string) string return { fmt.Sprintf("%q", list) }

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

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

同样的方法适用于任何不可直接比较的键类型,不仅仅局限于slice。甚至有的时候,你不想让键通过==来比较相等性,而是自定义一种比较方法,例如字符串不区分大小写的比较。同样k(x)的类型不一定是字符串类型,任何能够得到想要的比较结果的可比较类型都可以,例如整数、数组或者结构体。

这里还有一个关于map的例子,一个统计输入中Unicode代码点出现次数的程序。虽然存在着大量可能的字符,但是在一篇文档中仅会有这个巨大字符集的一部分,所以很自然地使用map来追踪每个字符出现的次数。

map的值类型可以是复合数据类型,例如mapslice,如代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var graph = make(map[string]map[string]bool)    // 键是string类型,值是map类型

// 建立一个从字符串到字符串集合的映射
// form以及to都是string类型
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的方法,当map中的每个键第一次出现的时候初始化。函数hasEdge演示了在map中值不存在的情况下,也可以直接使用。即使fromto都不存在,graph[from][to]也始终可以给出一个有意义的值。

结构体

结构体是将零个或者多个任意类型的命名变量组合在一起的聚合数据类型。每个变量都叫作结构体的成员。在数据处理领域,结构体使用的经典实例是员工信息记录,记录中有唯一ID、姓名、地址、出生日期、职位、薪水、直属领导等信息。所有的这些员工信息成员都作为一个整体组合在一个结构体里面,可以复制一个结构体,将它传递给函数,作为函数的返回值,将结构体存储到数组中,等等。

下面的语句定义了一个叫Employee的结构体和一个结构体变量dilbert:

1
2
3
4
5
6
7
8
9
10
11
type Employee struct {
    ID          int
    Name        string
    Address     string
    DoB         time.Time
    Position    string
    Salary      int
    ManagerID   int
}

var dilbert Employee

dilbert的每一个成员都通过点号方式来访问,就像dilbert.Namedilbert.DoB这样。由于dilbert是一个变量,它的所有成员都是变量,因此可以给结构体的成员赋值:

1
di1bert.Salary -= 5000  // 写的代码太少了,降薪

或者获取成员变量的地址,然后通过指针来访问它:

1
2
position := dilbert.Position
*position = "Senior " + *position   // 工作外包给Elbonia,所以升职

点号同样可以用在结构体指针上:

1
2
var employeeofTheMonth *Employee = dilbert
employeeOfTheMonth.Position += " (proactive team player)"

后面一条语句等价于:

1
(*employeeOfTheMonth).Position += " (proactive team player)"

函数EmployeeID通过给定的参数ID来返回一个指向Employee结构体的指针。可以用点号来访问它的成员变量:

1
2
3
4
func EmployeeByID(id int) *Employee { /* ... */ }
fmt.Printin(EmployeeByID(dilbert.ManagerID).Position)   // 尖头发的老板
id := dilbert.ID
EmployeeByID(id).Salary = 0 // 被开除了…不知道为什么

最后一条语句更新了函数EmployeeID返回的指针指向的结构体Employee。如果函数EmployeeID的返回值类型变成了Employee而不是*Employee,那么代码将无法通过编译,因为赋值表达式的左侧无法识别出一个变量。

  • 这句话没太看懂
  • 2024年6月13日更新:因为函数返回的是一个值,而不是变量,类似于10=5这一类的逻辑。
  • 可以先用一个变量接住这个值,再处理。

结构体的成员变量通常一行写一个,变量的名称在类型的前面,但是相同类型的连续成员变量可以写在一行上,就像这里的NameAddress:

1
2
3
4
5
6
7
8
type Employee struct {
    ID              int
    Name,Address    string
    DoB             time.Time
    Position        string
    Salary          int
    ManagerID       int
}

成员变量的顺序对于结构体同一性很重要。如果我们将也是字符串类型的PositionNameAddress组合在一起或者互换了NameAddress的顺序,那么我们就在定义一个不同的结构体类型。一般来讲,我们只组合相关的成员变量。

如果一个结构体的成员变量名称是首字母大写的,那么这个变量是可导出的,这个是Go最主要的访问控制机制。一个结构体可以同时包含可导出和不可导出的成员变量。

因为结构体类型一个成员变量占据一行,所以通常它的定义比较长。虽然可以在每次需要它的时候写出整个结构体类型定义,即匿名结构体类型,但是重复的工作会比较累,所以通常会定义命名结构体类型,比如Employee

一个聚合类型不可以包含他自己,否则陷入了递归定义的怪圈,但是可以定义一个指向自己的指针类型,如一个二叉树的定义:

1
2
3
4
type tree struct {
    value       int
    left, right *tree
}

结构体的零值由结构体成员的零值组成。通常情况下,我们希望零值是一个默认自然的、合理的值。例如,在bytes.Buffer中,结构体的初始值就是一个可以直接使用的空缓存。另外,后续讲到的sync.Mutex也是一个可以直接使用且未锁定状态的互斥锁。有时候,这种合理的初始值实现简单,但是有时候也需要类型的设计者花费时间来进行设计。

没有任何成员变量的结构体称为空结构体,写作struct{}。它没有长度,也不携带任何信息,但是有的时候会很有用。有一些Go程序员用它来替代被当作集合使用的map中的布尔值,来强调只有键是有用的,但由于这种方式节约的内存很少并且语法复杂,所以一般尽量避免这样用。

1
2
3
4
5
6
seen := make(map[string]struct())   // 字符串集合
// ...
if _, ok := seen[s]; !ok {
    seen[s] = struct{}{}    // struct{}是一种类型,因此存在两个{}
    // ..首次出现s...
}

结构体字面量

结构体类型的值可以通过结构体字面量来设置,即通过设置结构体的成员变量来设置。

1
2
type Point struct{ X, Y int }
p := Point{1, 2}

结构体字面量有两种格式:

  • 按顺序为每个成员变量指定一个值,这种方式容易给开发和阅读代码的人增加负担。
  • 指定部分或者全部成员变量的名称和值来初始化结构体变量。
    1
    
      anim = git.GIF{LoopCount: nframes}
    

    这种方式里,如果某个成员变量没有指定,那么它的值就是该成员变量类型的零值。

上述两种初始化方式不能混合使用。也无法使用第一种初始化方式来绕过不可导出变量无法在其他包中使用的规则(即首字母小写变量)。

1
2
3
4
5
6
7
package p
type T struct{ a, b int }   // a 和 b都是不可导出的

package q
import "p"
var _ = p.T{a: 1, b: 2} // 编译错误,无法引用a、b
var _ = p.T{1, 2} // 编译错误,无法引用a、b

结构体类型的值可以作为参数传递给函数或者作为函数的返回值。例如,下面的函数将Point缩放了一个比率:

1
2
3
4
func Scale(p Point, factor int) Point {
    return Point{p.X * factor, p.Y * factor}
}
fmt.Println(Scale(Point{1, 2}, 5))   // "{5 10}"

出于效率的考虑,大型的结构体通常都使用结构体指针的方式直接传递给函数或者从函数中返回。

1
2
3
func Bonus(e *Employee, percent int) int {
    return e.Salary * percent / 100
}

这种方式在函数需要修改结构体内容的时候也是必需的,在Go这种按值调用的语言中,调用的函数接收到的是实参的一个副本,并不是实参的引用。

1
2
3
func AwardAnnualRaise(e *Employee) {
    e.Salary = e.Salary * 105 / 100
}

由于通常结构体都通过指针的方式使用,因此可以使用一种简单的方式来创建、初始化一个struct类型的变量并获取它的地址:

1
2
3
4
pp := &Point{1, 2}
// 这个等价于:
pp := new(Point)
*pp = Point{1, 2}

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

结构体比较

如果结构体的所有成员变量都可以比较,那么这个结构体就是可比较的。两个结构体的比较可以使用==或者!=。其中==操作符按照顺序比较两个结构体变量的成员变量,所以下面的两个输出语句是等价的:

1
2
3
4
5
6
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的键类型。

1
2
3
4
5
6
7
type address struct {
    hostname    string
    port        int
}

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

结构体嵌套和匿名成员

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

想象一下2D绘图程序中会提供的关于形状的库,比如矩形、椭圆、星形和车轮形。这里定义了其中可能存在的两个类型:

1
2
3
4
5
6
type circle struct {
    X, Y, Radius int
}
type Wheel struct {
    X, Y, Radius, Spokes int
}

Circle类型定义了圆心的坐标XY,另外还有一个半径RadiusWheel类型拥有Circle类型的所有属性,另外还有一个Spokes属性,即车轮中条辐的数量。创建一个Wheel类型的对象:

1
2
3
4
5
var w Wheel
W.X = 8
w.Y = 8
w.Radius = 5
w.Spokes = 20

在需要支持的形状变多之后,我们将意识到它们之间的相似性和重复性。所以,很自然地,我们会重构相同的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Point struct {
    X,Y int
}

type Circle struct {
    Center Point
    Radius int
}

type Wheel struct {
    Circle  Circle
    Spokes  int
}

这个程序看上去变得更清晰了,但是访问wheel的成员变麻烦了:

1
2
3
4
5
var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
w.Circle.Radius = 5
w.Spokes = 20

Go允许我们定义不带名称的结构体成员,只需要指定类型即可;这种结构体成员称做匿名成员。这个结构体成员的类型必须是一个命名类型或者指向命名类型的指针。下面的CircleWheel都拥有一个匿名成员。这里称Point被嵌套到Circle中,Circle被嵌套到Wheel中。

1
2
3
4
5
6
7
8
9
type Circle struct {
    Point
    Radius int
}

type Wheel struct {
    Circle
    Spokes int
}

正因为有了这种结构体嵌套的功能,我们才能直接访问到我们需要的变量而不是指定一大串中间变量:

1
2
3
4
5
var w Wheel
w.X = 8     // 等价于w.Circle.Point.X = 8
w.Y = 8     // 等价于w.Circle.Point.Y = 8
w.Radius = 5  // 等价于w.Circle.Radius = 5
w.Spokes = 20

上面注释里面的方式也是正确的,但是使用匿名成员的说法或许不合适。上面的结构体成员CirclePoint是有名字的,就是对应类型的名字,只是这些名字在点号访问变量时是可选的。当我们访问最终需要的变量的时候可以省略中间所有的匿名成员。

遗憾的是,结构体字面量并没有什么快捷方式来初始化结构体,所以下面的语句是无法通过编译的:

1
2
w = Wheel{8, 8, 5, 20}  // 编译错误,未知成员变量
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20}    // 编译错误,未知成员变量

结构体字面量必须遵循形状类型的定义,所以我们使用下面的两种方式来初始化,这两种方式是等价的:

1
2
3
4
5
6
7
8
w = wheel{Circle{Point{8, 8}, 5}, 20}
w = Wheel{
    Circle:Circle{
        Point: Point{X: 8, Y: 8},
        Radius: 5,
    },
    Spokes: 20, // 注意,尾部的逗号是必需的(Radius后面的逗号也一样)》
}

因为匿名成员拥有隐式的名字,所以你不能在一个结构体里面定义两个相同类型的匿名成员,否则会引起冲突。由于匿名成员的名字是由它们的类型决定的,因此它们的可导出性也是由它们的类型决定的。在上面的例子中,PointCircle这两个匿名成员是可导出的。即使这两个结构体是不可导出的(pointcircle),我们仍然可以使用快捷方式:

1
w.X = 8 // 等价于w.circle.point.X = 8

但是注释中那种显式指定中间匿名成员的方式在声明circlepoint的包之外是不允许的,因为它们是不可导出的。

到目前为止,我们所看到关于结构体嵌套的使用,仅仅是关于点号访问匿名成员内部变量的语法糖。后面我们将了解到匿名成员不一定是结构体类型,任何命名类型或者指向命名类型的指针都可以。不过话说回来,嵌套一个没有子成员的类型有什么用呢?

以快捷方式访问匿名成员的内部变量同样适用于访问匿名成员的内部方法。因此,外围的结构体类型获取的不仅是匿名成员的内部变量,还有相关的方法。这个机制就是从简单类型对象组合成复杂的复合类型的主要方式。在Go中,组合是面向对象编程方式的核心。

JSON

JavaScript对象表示法(JSON)是一种发送和接收格式化信息的标准。JSON不是唯一的标准,XML、ASN.1和Google的Protocol Buffer都是相似的标准,各自有适用的场景。但是因为JSON的简单、可读性强并且支持广泛,所以使用得最多。

Go通过标准库encoding/jsonencoding.xmlencoding/asn1和其他的库对这些格式的编码和解码提供了非常好的支持,这些库都拥有相同的API。本节对使用最多的encoding/json做一个简要的描述。

JSON是JavaScript值的Unicode编码,这些值包括字符串、数字、布尔值、数组和对象。JSON是基本数据类型和复合数据类型的一种高效的、可读性强的表示方法。

JSON最基本的类型是数字(以十进制或者科学计数法表示)、布尔值(truefalse)和字符串。字符串是用双引号括起来的Unicode代码点的序列,使用反斜杠作为转义字符,通过和Go类似的方式访问成员。当然,JSON里面的\uhhh数字转义得到的是UTF-16编码,而不是Go里面的字符。

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

1
2
3
4
5
6
7
boolean     true
number      -273.15
string      "She said \"Hello, 世界\""
array       ["gold", "silver", "bronze"]
object      {"year": 1980,
             "event": "archery",
             "medals": ["gold", "silver", "bronze"]}

想象一个程序需要收集电影的观看次数并提供推荐。这个程序的Movie类型和典型的元素列表都在下面提供了。(结构体中成员YearColor后面的字符串字面量是成员的标签)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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对象转为JSON还是从JSON转换为Go对象都很容易。把Go的数据结构(movies)转换为JSON称为marshalmarshal是通过json.Marshal来实现的:

1
2
3
4
5
data, err := json.Marshal(movies)
if err != nil {
    log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)

Marshal生成了一个字节slice,其中包含一个不带有任何多余空白字符的很长的字符串。把生成的结果折叠一下放在这里:

1
2
3
4
[{"Title":"Casablanca","released":1942,"Actors":["Humphrey Bogart","Ingr
id Bergman"]},{"Title":"Cool Hand Luke","released":1967,"color":true,"Ac
tors":["Paul Newman"]},{"Title":"Bullitt","released":1968,"color":true,"
Actors":["Steve McQueen","Jacqueline Bisset"]}]

这种紧凑的表示方法包含了所有的信息但是难以阅读。为了方便阅读,有一个json.MarshalIndent的变体可以输出整齐格式化过的结果。该函数有两个参数,一个是定义每行输出的前缀字符串,另外一个是定义缩进的字符串。

1
2
3
4
5
data, err := json.MarshalIndent(movies, "", "  ")
if err != nil {
    log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)

上面的代码输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[
    {
        "Title": "Casablanca",
        "released": 1942,
        "Actors": [
            "Humphrey Bogart",
            "Ingrid Bergman"
        ]
    },
    {
        "Title": "Cool Hand Luke",
        "released": 1967,
        "color": true,
        "Actors": [
            "Paul Newman"
        ]
    },
    {
        "Title":"Bullitt",
        "released":1968,
        "color":true,
        "Actors": [
            "Steve McQueen",
            "Jacqueline Bisset"
        ]
    }
]

marshal使用Go结构体成员的名称作为JSON对象里面字段的名称(通过反射的方式)。只有可导出的成员可以转换为JS0N字段,这就是为什么我们将Go结构体里面的所有成员都定义为首字母大写的。

你或许注意到了,上面的结构体成员Year对应地转换为released,另外Color转换为color。这个是通过成员标签定义(field tag)实现的。成员标签定义是结构体成员在编译期间关联的一些元信息:

1
2
Year int    `json:"released"`
Color bool  `json:"color,omitempty"`

成员标签定义可以是任意字符串,但是按照习惯,是由一串由空格分开的标签键值对key:"va1ue"组成的;因为标签的值使用双引号括起来,所以一般标签都是原生的字符串字面量。键json控制包encoding/json的行为,同样其他的encoding/...包也遵循这个规则。标签值的第一部分指定了Go结构体成员对应JSON中字段的名字。成员的标签通常这样使用,比如total_count对应Go里面的TotalCount。Color的标签还有一个额外的选项,omitempty,它表示如果这个成员的值是零值或者为空,则不输出这个成员到JSON中。所以,对于《Casablanca》这部黑白电影,就没有输出成员ColorJSON中。

marshal的逆操作将JSON字符串解码为Go数据结构,这个过程叫作unmarshal,这个是由json.Unmarshal实现的。下面的代码将电影的JSON数据转换到结构体slice中,这个结构体唯一的成员就是Title。通过合理地定义Go的数据结构,我们可以选择将哪部分JSON数据解码到结构体对象中,哪些数据可以丢弃。当函数Unmarshal调用完成后,它将填充结构体sliceTitle的值,JSON中其他的字段就丢弃了。

1
2
3
4
5
var titles []struct{ Title string }
if err := json.Unmarshal(data, &titles); err != nil {
    log.Fatalf("JSON unmarshaling failed: %s", err)
}
fmt.Println(titles) // "填充了Title的值:[{Casablanca}{Cool Hand Luke}{Bullitt}]"

很多的Web服务都提供JSON接口,通过发送HTTP请求来获取想要得到的JSON信息。在unmarshal阶段,JSON字段的名称关联到Go结构体成员的名称是忽略大小写的。因此在JSON有下划线而Go里面没有下划线的时候使用一下成员标签定义。

文本和HTML模板

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 依次输出:
// 符合条件的issue数量
// 每个issue的序号
// 用户
// 标题
// 距离创建时间已过去的天数
// (.)号表示当前值
// {{.TotalCount}}代表TotalCount成员的值
// {{range .Items}}与{{end}}操作创建一个循环,所以内部的值会展开很多次
// 符号'|'将前一个操作的结果当作下一个操作的输入,类似管道的功能,printf就是fmt.Sprintf的同义词
// daysAgo也是一个函数,进行时间转换
const templ = `{{.TotalCount}} issues:
{{range .Items}}----------------------------------------
Number: {{.Number}}
User:   {{.User.Login}}
Title:  {{.Title | printf "%.64s"}}
Age:    {{.CreatedAt | daysAgo}} days
{{end}}`

func daysAgo(t time.Time) int { // 注意看,时间的参数类型
    return int(time.Since(t).Hours() / 24)
}

注意,一个类型可以定义方法来控制自己的字符串格式化方式,另外也可以定义方法来控制自身JSON序列化和反序列化的方式。time.Time的JSON序列化值就是该类型标准的字符串表示方法。

通过模板输出结果需要两个步骤。首先,需要解析模板并转换为内部的表示方法,然后在指定的输入上面执行。解析模板只需要执行一次。下面的代码创建并解析上面定义的文本模板templ。注意方法的链式调用:template.New创建并返回一个新的模板,Funcs添加daysAgo到模板内部可以访问的函数列表中,然后返回这个模板对象;最后调用Parse方法。

1
2
3
4
5
6
report, err := template.New("report").
    Funcs(template.FuncMap{"daysAgo": daysAgo}).
    Parse(templ)
if err != nil {
    log.Fatal(err)
}

由于模板通常是在编译期间就固定下来的,因此无法解析模板将是程序中的一个严重的bug。帮助函数template.Must提供了一种便捷的错误处理方式,它接受一个模板和错误作为参数,检查错误是否为nil(如果不是nil,则宕机),然后返回这个模板。

一旦创建了模板,添加了内部可调用的函数daysAgo,然后解析,再检查,就可以使用github.IssuesSearchResult作为数据源,使用os.Stdout作为输出目标执行这个模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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)
    }

    // 将result按report要求的形式从终端输出
    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(看看就行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import "html/template"

/*
这段代码,jekyll的解析出现了一点问题,通过加便签{% raw %}来
告诉Liquid引擎不要对其包含的内容进行解析和处理,而是原样输出。
解析出来了四列,列名分别为`#`,`State`,`User`,`Title`
好好看看内容是如何对应的
*/
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>
`))
本文由作者按照 CC BY 4.0 进行授权

Go的基本数据类型

Golang包(模块)的组织与管理