【Go语言圣经】第四节:复合数据类型
第四章:复合数据类型
本节主要讨论四种类型——数组、slice、map和结构体。
数组和结构体都是有固定内存大小的数据结构。相比之下,slice 和 map 则是动态的数据结构,它们可以根据需要动态增长。
4.1 数组
数组是一个定长的由特定类型元素构成的序列。由于数组定长,因此 Golang 当中很少直接使用数组,而是使用 slice。一个使用数组的例子如下:
package main
import "fmt"
func main() {
var a [3]int // 初始化一个空的数组
fmt.Println(a)
var q [3]int = [3]int{1, 2, 3} // 使用数组字面值语法用一组值来初始化数组
var p [3]int = [3]int{4, 5} // 如果花括号当中给定的元素个数少于数组长度, 将使用零值填充
fmt.Println(q)
fmt.Println(p)
t := [...]int{1, 2, 3} // 可以使用 ... 省略长度
fmt.Println(t)
}
如果在数组长度位置出现的是...
,则表示数组的长度根据初始值的个数来计算,...
只能用于声明阶段。
数组的长度是数组类型的一个组成部分,因此[3]int
和[4]int
是不同的数组类型。
我们不难发现,数组、slice、map 和 结构体字面值的写法很相似。上面的形式是直接提供顺序初始化值序列,但是也可以指定一个索引和对应值列表的方式来初始化:
package main
import "fmt"
func main() {
type Currency int
const (
USD Currency = iota
EUR
GBP
RMB
)
symbol := [...]string{USD: "$", EUR: "€", GBP: "£", RMB: "¥"}
fmt.Println(symbol)
}
未指定的元素将用零值初始化,例如:
r := [...]int{99: -1} // 99 个 0 和 1 个 -1
类型相同的数组可以相互比较,完全相等时,==
为 true。
4.2 Slice
Slice 代表变长的序列,序列中每个元素都有相同的类型。一个 slice 一般写作 []T
,其中 T 代表 slice 种元素的类型;slice 的语法和数组很像,一个明显的区别是 slice 没有固定的长度。
数组和 slice 的关系紧密,一个 slice 是一个轻量级的数据结构,提供了访问数组子序列(或全部数组)的能力,并且 slice 的底层确实引用了一个数组对象(一说 slice 是数组对象的视图,当 slice 显式地和数组绑定时,修改 slice 也将修改数组)。
一个 slice 由三部分构成,分别是:指针、长度和容量。
- 指针指向第一个 slice 元素对应地底层元素的地址,要注意的是 slice 的第一个元素未必是数组的第一个元素,因为 slice 可能是数组局部的视图;
- 长度对应 slice 种元素的数目,长度不能超过容量;
- 容量一般是从 slice 的开始位置到底层数据的结束位置。
内置的 cap 和 len 函数分别返回 slice 的长度和容量,但是要注意它们不是 slice 的方法。
多个 slice 之间可以共享底层数据,引用的数组部分区间可能重叠:
如果切片操作超出cap(s)
的上限将导致一个 panic 异常,但超出len(s)
意味着拓展了 slice(这里指的是切片操作仍然在底层数组的上限范围内的时候,如果切片操作超出了底层数组的上限,将会导致 panic 异常)。
和数组不同的是,slice 之间不能比较,因此不能使用==
操作符来判断 slice 是否含有全部相等的元素,slice 唯一合法的比较操作是和 nil 进行比较。一个零值的 slice 等于 nil。一个 nil 值的 slice 并没有底层数组。nil 值的 slice 长度和容量都是 0(但是也有非 nil 的 slice 长度和容量为 0,例如[]int{}
或make([]int, 3)[3:]
)。
内置的 make 函数可以创建一个指定元素类型、长度和容量的 slice:
make([]T, len) // cap 可以省略, 此时 len 和 cap 相等
make([]T, len, cap)
4.2.1 append 函数
内置的 append 函数用于向 slice 追加元素:
package main
import "fmt"
func main() {
var runes []rune
for _, r := range "Happy New Year" {
runes = append(runes, r)
}
fmt.Printf("%q\n", runes)
}
// OUTPUT
['H' 'a' 'p' 'p' 'y' ' ' 'N' 'e' 'w' ' ' 'Y' 'e' 'a' 'r']
注意,append 仍然是内置的函数,而非 slice 的方法。append 函数对于理解 slice 底层是如何工作的非常重要。当调用 append 追加元素时,必须先检测 slice 底层数组是否有足够的容量来保存新添加的元素。如果空间足够,直接拓展 slice,并返回 slice。如果没有足够的增长空间,append 则先分配一个足够大的 slice 同于保存新的结果,先将输入的 slice 复制到新空间,再追加元素。(与 C++ 当中 vector 的容量扩张策略类似,首先检测是否超过 capacity,如果超过了,则将 capacity 扩大两倍)
4.2.2 Slice 内存技巧
一个 slice 可以用于模拟 stack(实际上 Golang 没有内置的 stack 和 queue,但可以直接使用 slice 来模拟)。
可以使用 slice 模拟一个 stack,最初给定的空 slice 对应一个空的 stack,可以使用 append 将元素入栈:
stack = append(stack, v)
使用 slice 切片操作返回顶部元素:
top := stack[len(stack) - 1]
收缩 stack 完成出栈:
stack = stack[:len(stack)-1]
一个完整的代码片段如下:
package main
import "fmt"
func main() {
stack := make([]int, 0)
for i := 0; i < 10; i++ {
stack = append(stack, i)
}
var top int
for i := 0; i < 10; i++ {
top = stack[len(stack)-1]
stack = stack[:len(stack)-1]
fmt.Println(top)
}
}
4.3 Map
Map (哈希表)是一个无序的 key/value 对的集合,其中所有的 key 都是不同的。通过 key 可以在常数时间内检索、更新或删除对应的 value。
在 Golang 当中,一个 map 就是一个哈希表的引用,map 类型可以写为 map[K]V
,其中 K 和 V 分别对应 Key 和 Value。和其它语言类似,map 中所有的 key 都有相同的类型,所有 vakue 也有着相同的类型,但 key 和 value 之间可以是不同类型。其中 K 对应的 key 必须是支持==
比较运算符的数据类型(所以 map 可以通过测试 key 是否相等来判断是否存在,因为相同的原因,使用浮点数作为 key 是一个糟糕的想法)。
与 slice 类似,可以使用内建的 make 创造一个 map:
ages := make(map[string]int)
// OR
ages := map[string]int{}
// 实际上可以使用相同的策略创建一个空的 slice
names := []string{}
可以用 map 字面值的语法创建 map,同时还可以指定一些最初的 key/value:
ages := map[string]int {
"alice": 31,
"charlie": 34,
}
可以使用下标访问 map 当中 key 的 value:
ages["alice"] = 32
fmt.Println(ages["alice"])
可以使用内置的 delete 删除元素:
delete(ages, "alice")
所有这些操作都是安全的,如果访问到一个不存在的 key,将会直接放回 value 类型对应的零值。此外,+=
、++
这些操作都可以应用在 map 上。
但由于 map 中的元素并不是一个变量,因此我们不能对 map 中的元素进行取地址操作。禁止对 map 取地址的原因是 map 可能随着元素数量的增长而重新分配个更大的内存空间,从而导致之前的地址失效。
遍历 map 当中的元素可以采用 range 风格的方法:
for name, age := range ages {
fmt.Println("%s\t%d\n", name, age)
}
map 的迭代顺序不确定,并且不同的哈希函数可能导致不同的遍历顺序。如果要按顺序遍历 key/value 对,必须显式地对 key 进行排序:
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 一样,map 之间不能进行相等比较。唯一的例外是和 nil 比较。要判断两个 map 是否含有相同的 key 和 value,比如通过循环实现。
Golang 并没有 set 的实现,但 map 中的 key 也是不同的,可以用 map 实现类似 set 的效果。
package main
import (
"bufio"
"fmt"
"os"
)
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)
}
}
有时候我们需要 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 类型。
使用同样的技术可以处理任何不可比较的 key 类型,而不仅仅是 slice 类型。
4.4 结构体
结构体是一种聚合的数据类型,由零个或多个任意类型的值聚合而成,每个值称为成员。
下面两个语句声明了一个叫 Employee 的结构体类型,且声明了一个 Employee 类型的变量 dilbert:
type Employee struct {
ID int
Name string
Address string
DoB time.Time
Position string
Salary int
ManageID int
}
var dilbert Employee
dilbert 结构体变量的成员可以通过点操作符访问。比如dilbert.Name
和dilbert.DoB
。由于 dilbert 是一个变量,其所有成员都是变量,可以直接对每个成员赋值:
dilbert.Salary = 5000
或是取地址,然后通过指针访问:
position := &dilbert.Position
*position = "Senior " + *position
上述代码可运行的片段如下:
package main
import (
"fmt"
"time"
)
type Employee struct {
ID int
Name string
Address string
DoB time.Time
Position string
Salary int
ManageID int
}
func main() {
var dilbert Employee
dilbert.Salary = 5000
dilbert.Position = "Dalian"
position := &dilbert.Position
*position = "Senior " + *position
fmt.Println(dilbert)
}
点操作符也可以和指向结构体的指针一起工作:
var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proactive team player)"
// 等价于
(*employeeOfTheMonth).Position += " (proactive team player)"
(意思是,可以直接对指向结构体的指针使用点操作符,并对指针指向的结构体的成员进行修改)
下面的 EmployeeByID 函数将根据给定的员工 ID 返回对应的员工信息结构体的指针。可以直接对函数使用点操作符来访问结构体当中的成员:
func EmployeeByID(id int) *Employee {/* ... ... ... */}
id := dilbert.ID
EmployeeByID(id).Salary = 0
后面的语句通过 EmployeeByID 返回的结构体指针更新了 Employee 结构体的成员。如果将函数的返回值从指针改为值,编译将不能通过。
在声明结构体时,通常应该一行对应一个成员,不过如果向量的成员类型相同,可以它们合并:
type Employee struct {
ID int
Name, Address, Position string
DoB time.Time
Salary int
ManageID int
}
结构体成员的输入顺序有重要的意义,如果在声明时成员名称出现的顺序不同,对应的将是不同的结构体。
如果结构体成员名字是以大写字母开头的,那么该成员就是导出的;这是Go语言导出规则决定的。一个结构体可能同时包含导出和未导出的成员。
4.4.1 结构体字面值
结构体值可以用结构体的字面值表示:
type Point struct { X, Y int }
var p Point = Point{1, 2}
// OR
p := Point{1, 2}
另一种更常见的写法是,以成员名字和对应的值来初始化,可以包含部分或全部的成员:
anim := gif.GIF{LoopCount: nframes}
两种形式不能混用,并且我们不能在另一个包中用第一种赋值来初始化结构体中未导出的成员。
结构体可以作为函数的返回值:
func Scale(p Point, factor int) Point {
return Point{p.X * factor, p.Y * factor}
}
fmt.Println(Scale(Point{1, 2}, 5))
考虑效率的话,传输较大的结构体应该使用指针。
4.4.2 结构体比较
如果结构体当中全部成员都是可比较的,那么结构体也是可比较的。可比较的结构体和其它可比较的类型一样,可以用于 map 和 key 类型。
4.4.3 结构体嵌入和匿名成员
一个例子如下:
type Circle struct {
X, Y, Radius int
}
type Wheel struct {
X, Y, Radius, Spokes int
}
可以将相同的属性独立出来:
type Point struct {
X, Y int
}
type Circle struct {
Center Point
Radius int
}
type Wheel struct {
Circle Circle
Spokes int
}
Golang 的一个特性允许我们只声明一个成员对应的数据类型而不指明成员的名字,这样的成员称为匿名成员。匿名成员的数据类型必须是命名的类型或是指向一个命名的类型的指针。一个例子如下:
type Circle struct {
Point
Radius int
}
type Wheel struct {
Circle
Spokes int
}
我们称类型 Point 嵌入到了 Circle 结构体中,同理 Circle 嵌入到了 Wheel 当中。
得益于匿名嵌入的特性,我们可以直接访问叶子属性而不给出完整路径:
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
不幸的是,结构体字面值没有简短表示匿名成员的语法,因此下面的语句不能编译通过:
w = Wheel{8, 8, 5, 20} // compile error
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20} // compile error
我们只能用以下两种语法:
w = Wheel{Circle{Point{8, 8}, 5}, 20}
// 等价于
w = Wheel {
Circle: Circle{
Point: Point{X:8, Y:8},
Radius: 5,
},
Spokes: 20,
}