Go编程基础-4. 复合数据类型
复合数据类型
Go语言中四种主要的数据类型:数组、slice、map和结构体。
1. 数组
数组是一个具有固定长度且包含零个或多个相同数据类型元素的序列。由于数组的长度是固定的,因此在Go中很少直接使用。相比之下,切片的长度可以增长和缩短,在许多情况下更为常用。数组的特点如下:
- 数组中每个元素通过索引来访问,从0到len(num)-1
// 默认位零值
var a [3]int
// 下标遍历
for i := 0; i < len(a); i++ {
a[i] = 1 << i
}
// range遍历
for i, v := range a {
fmt.Printf("%d\t%d\n", i, v)
}
- 数组的长度是类型的一部分, 所以[3]int 和[4]int 是两种不同的类型;数组的长度必须是常量表达式,在编译过程中就已经确定。
// (1)我们可以在数组初始化时,显式指定数组长度
symbol := [4]string{"$", "€", "£", "¥"}
// (2)也可以由编译器根据元素个数自动确定数组大小——必须赋初值
symbol := [...]string{"$", "€", "£", "¥"}
// (3)如果赋初值时,使用了索引的方式对部分元素进行赋值,那么数组大小等于最大索引下标+1
test := [...]string{1: "1", 3: "3", 5: "5"}
fmt.Println(reflect.TypeOf(test)) // 6
-
基本类型+数组作为参数传递时,都会创建一个副本,然后赋值给对应的变量,而不是操作原本的参数 ———— 这在传递大数组时,会变得非常低效。因此,如果想要在函数中修改传入的参数数组,应该使用指针的形式 *[32]int
-
如果一个数组的元素是可以比较的,那么这个数组也是可以比较的(前提是数组类型——元素类型+元素个数——相同),可以使用==直接比较两个数组的元素是否完全相同,!=同理
a := [2]int{1,2}
b := [...]int{1,2}
fmt.Println(a == b) // true
数组配合枚举类型的特殊用法
数组的常规初始化方法如下:
symbol := [4]string{"$", "€", "£", "¥"}
这种方法是按顺序给出一组值,但是同样我们可以乱序赋初值:
symbol := [4]string{2:"€", 4:"¥"}
这样我们可以只对一部分数组元素进行赋值,结合枚举可以进一步实现类似索引的效果,极大增加代码的可读性:
type Currency int
const (
USD Currency = iota
EUR
GBP
RMB
)
symbol := [4]string{USD: "$", EUR: "€", GBP: "£", RMB: "¥"}
fmt.Println(RMB, symbol[RMB])
2. slice
slice表示一个拥有相同类型元素的可变长度序列,通常写成[]T。
- slice是一种轻量级的数据结构,其底层存储通过数组实现,有三个属性:指针、长度和容量。
- 指针:指向slice可以访问的第一个数组元素。slice并非一直指向数组的开头,可能从数组的中间开始,例如:
// 1.底层数组定义
months := [...]string{1:"January", /.../ , 12:"December"}
// 2.切片
summer := months[6:9]
// 3. 一个底层数组可以被多个切片所指向,切片之间可以相互重叠、影响,共用同一个底层数组
Q2 := months[4:7]
- 长度:slice中元素的个数,不能超过slice的容量;
- 容量:从slice的起始元素起到底层数组最后一个可访问元素之间的距离/元素个数。slice的引用可以超过长度,但是不能超过容量大小,例如:
// 续接上例
fmt.Println(summer[:5]) // 超出了summer的长度,但是并没有超过容量,是允许的
fmt.Println(summer[:20]) // 宕机,超过了所引用对象-底层数组的边界
- 由于slice本质其实是引用/别名,因此在函数传递参数时,我们可以直接传递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(s[:n]) + reverse(s[n:]) + reverse(s)
- slice无法直接进行比较,因此无法通过 == 来测试两个slice是否拥有相同的元素,标准库中提供了 bytes.Equal 来比较两个字节slice。 但是对于其余类型而言,我们必须自己写工具来实现比较:
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的零值是nil, 因此slice唯一允许比较的就是nil。但是需要注意slice != nil时并不意味着不为空, 在任何情况下都应该使用len(slice)进行判断:
var s[]int // len(s) == 0, s == nil
s = []int(nil) // len(s) == 0, s == nil
s = []int{} // len(s) == 0, s != nil
- slice的创建可以通过make函数进行:
// make函数可以创建一个具有指定元素类型、长度和容量的slice。
make([]T, len) // 忽略容量参数的情况下,cap==len
make([]T, len, cap) // 与 make([]T, cap)[:len] 功能相同
appned函数
内置函数append用来将元素追加到slice的后面,其工作原理如下:
- 每次append都会检查是否有足够的容量来存储新元素
- 如果容量足够:那么会定义一个新的slice(长度增加但仍引用原底层数组),然后将新元素复制到对应的位置;
- 如果容量不够:那么会创建一个新的底层数组(容量翻倍),然后通过copy方法,来将原始的数据复制过去,最后将新元素复制到对应位置上并返回切片
- 注意:上述仅为简化过程,append实际使用了更加复杂的增长策略。
- 我们并不清楚哪一次调用append会导致新的内存分配,所以不能确定原始slice和结果slice指向同一个底层数组。因此我们通常会将返回结果再次赋值给传入的slice:
runes := append(runes, r)
对slice就地修改,避免分配新的数组
// 1. 保留非空字符串
func nonempty(strings []string) []string {
out := strings[:0]
for _, s := range strings {
if s != "" {
out = append(out, s)
}
}
return out
}
// 2. 通过copy替代循环,实现更加简洁的数组移位、元素删除
func remove(slice []int, i int) []int {
copy(slice[i:], slice[i+1:])
return slice[:len(slice)-1]
}
使用slice实现栈
// 1. 定义一个空的slice作为栈
stack := make([]int, 0, 10)
// 2. 往栈中添加新的元素
stack = append(stack, v)
// 3. 获取栈顶元素
top := stack[len(stack)-1]
// 4. 移除栈顶元素
stack = stack[:len(stack)-1]
3. map
散列表,拥有键值对元素的无序集合。
- Go语言中,map是散列表的引用,类型为 map[K]V;
- map的键的类型为k,必须是可以通过操作符==来进行比较的数据类型(因此slice不能为键)。
- 注意:浮点型虽然可以比较,但是会存在精度问题,因此最好不要使用浮点数据作为键值,可以先转化为string或其它高精度数据类型再作为key。
- map的零值是nil。在往map中添加元素之前,必须先初始化,否则会报错。
基本操作
// 1. 字面量赋值&初始化
// ages := map[string]int{} // 空map
ages := map[string]int {
"alice": 31,
"charlie": 34,
}
// 2. make创建+手动赋值
ages := make(map[string]int)
ages["alice"] = 31
ages["charlie"] = 34
// 3. 删除元素
delete(ages, "alice")
// 4. 修改元素
ages["charlie"] += 1
ages["charlie"]++
// 5. map的元素不是一个变量,因此不可以直接获取它的地址
_ = &ages["charlie"] // 编译错误
无法直接获取map中元素地址的一个原因是,随着map的增长,可能会导致已有的元素被重新打散到新的存储位置,导致原有地址失效。
判断一个元素是否存在map中
// 1. 标准形式
age, ok := ages["charlie"]
if !ok {
// ...
}
// 2. 简化形式
if age, ok := ages["charlie"]; !ok {
// ...
}
通过下标访问map中的元素时,会返回两个值,第二个是一个布尔值,表示该元素是否存在。注意:最好不要直接用 A[k] == B[k]来判断map中是否存在相同的元素,因为如果(1)A[k] == 0; (2)B中不存在k对应元素,这样B会返回一个int的零值也为0; 这种情况下两者会相等,但是并不意味着存在相同的元素。正确写法如下:
func equal(x, y map[string]int) bool {
if len(x) != len(y) {
return false
}
for k, xv := range x {
// 1. 先判断是否存在,然后再判断值是否相等
if yv, ok := y[k]; !ok || xv != yv {
return false
}
}
return true
}
将slice作为key的间接实现方法
// 1. 将slice转为字符串
func tranfer(list []string) string {
return fmt.Sprintf("%q", list)
}
// 2. 以字符串为key进行操作
func Add(list []string) {
mapT[transfer(list)]++
}
map的使用示例:字符统计
package main
import (
"bufio"
"fmt"
"os"
"unicode"
"unicode/utf8"
)
func main() {
counts := make(map[rune]int) // unicode字符出现次数
var utflen [utf8.UTFMax + 1]int // UTF-8编码长度
invalid := 0 // replace character -- 无法通过标准表示的符号
in := bufio.NewReader(os.Stdin)
for {
r, size, err := in.ReadRune()
if err != nil {
fmt.Fprintf(os.Stderr, "charcount: %v\n", err)
os.Exit(1)
}
if r == unicode.ReplacementChar && size == 1 {
invalid++
continue
}
if r == '*' { // 终止标识
break
}
counts[r]++
utflen[size]++
}
// 通过map的形式记录字符出现次数
fmt.Print("rune\tcount\n")
for k, v := range counts {
fmt.Printf("%q\t%d\n", k, v)
}
// 通过数组的方式记录不同字节长度的utf-8字符出现的次数
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)
}
}
4. 结构体
结构体是将零个或多个任意类型的命名变量组合在一起的聚合数据类型。每个变量都被称为结构体的成员。以员工信息为例:
// 1. 结构体定义
type Employee struct {
ID int
Name, Address string // 相同类型的成员变量可以定义在同一行
DoB time.Time
Position string
Salary int
ManagerID int
}
// 2. 结构体使用
var alice Employee
// 2.1 通过'.'访问成员
alice.Salary = 5000
// 2.2 获取成员变量地址,然后通过指针进行访问
postion := &alice.Salary
*position = 6000
// PS: 点号同样可以用在结构体的指针上
var employeePointer *Employee = &alice
employeePointer.Salary = 7000
注意:
- 结构体作为函数参数时,是值传递 - 因此,涉及修改操作需要传递指针
- 成员变量的顺序也是结构体的一部分,如果我们将Name、Address的顺序互换,那么就是在定义一个新的结构体类型
- 如果成员变量的名称首字母大写,那么该变量是可导出/可在包外使用的,则是Go最主要的访问/权限控制机制。
- 如果不加上type XXX, 只有struct {…},那么着依然是一个完整的结构体定义,只不过没有名称 —— 匿名结构体类型,每次使用时都需要写出整个结构体定义。
结构体定义中的限制:
- 命名结构体类型S不可以拥有一个相同结构体类型的成员变量,即:不可以包含自身(同样的限制数组也有效)。
- 但是可以包含自身类型的指针,从而实现一些递归数据结构,比如链表和树。
// 示例:使用结构体实现tree-sort, 待补充
结构体字面量
结构体类型的变量, 可以通过结构体字面量来设置。
type Point struct {
x, y int
}
// 1. 按照正确的顺序,为每个成员变量指定值 - 在写有明确顺序约束的小结构体中使用,例如color(rgb)
p := Point{1, 2}
// 2. 指定部分,或全部成员变量的名称和值来初始化
p2 := Point{y: 2}
注意事项:
- 两种初始化方法无法混合使用
- 对于无法导出的变量,无法在被外部包所引用时初始化
// 1. 包p
package p
// 定义结构体
type T struct {
a, b int
}
// 2. 包q
package q
// 引用包p
import "p"
// 2.1 显式初始化 - 编译错误
var _ = p.T{a:1, b:2}
// 2.2 隐式初始化 - 编译错误
var _ = p.T{1, 2}
结构体的函数传递和修改
- 对于小型结构体,我们可以新建一个结构体对象返回+赋值的形式实现修改;
func Scale(p Point, factor int) Point {
return Point{p.X * factor, p.Y * factor}
}
p := Point{1, 2}
p = Scale(p, 3)
- 对于大型结构体,我们一般通过指针的方式在函数中直接进行修改
func Scale(p *Point, factor int) {
p.X *= factor
p.Y *= factor
}
// 1. 先声明对象,再取地址
p := Point{1, 2}
Scale(&p, 3)
// 2. 声明对象时直接取地址 —— 更加简单的创建和使用结构体的方法
p := &Point{1, 2}
Scale(p, 3)
结构体比较
如果所有成员变量都可以比较,那么结构体就是可以比较的
- 可以使用 == 或 !=
- 按照顺序逐个比较两个结构体的成员变量
// 即:下述两式等价 (X、Y均为int类型)
p.X == q.X && p.Y == q.Y
p == q
结构体嵌套 + 匿名成员
由于Go语言中没有类型的概念,只有结构体。那么很显然,我们在一些情况下可能会遇到结构体的包含关系(对应类型的父子关系),例如:Wheel包含Circle的基本属性,Circle包含Point-圆心的基本属性。这个时候有两种方案:
- 每个类型都包含完整的属性————但是如果Circle发生了修改,这种修改不会自动同步到Wheel中,不是真正的包含关系
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
}
// 访问时需要带上一大串名称前缀
var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
w.Circle.Radius = 5
w.Spokes = 20
- 结构体嵌套+匿名成员
// 1. Go允许我们定义不带名称的结构体成员,只需要指定类型即可;这种结构体成员称为“匿名成员”。这个结构体成员的类型必须是一个命名类型或者指向命名类型的指针,示例如下:
type Circle struct {
Point
Radius int
}
type Wheel struct {
Circle
Spokes int
}
// 2. 正因为有了这种结构体嵌套+匿名成员的功能,我们才能直接访问到我们需要的变量而不是指定一大串中间变量:
var w Wheel
w.X = 8
w.Y = 8
w.Radius = 5
w.Spokes = 20
// 3. 等价于:
w.Circle.Point.X = 8
w.Circle.Point.Y = 8
w.Circle.Radius = 5
// PS: 使用“匿名成员”的说法或许不合适。上面的结构体成员Circle和Point是有名字的,就是对应类型的名字,只是这些名字在点号访问变量时是可选的。当我们访问最终需要的变量的时候可以省略中间所有的匿名成员。
- 虽然访问成员变量时可以直接访问,但是结构体赋值时————字面量比较遵循类型的定义,例如:Wheel比较传入一个Circle和int,而不是4个int:
// 1. 方式一
w = Wheel{Circle{Point{8, 8}, 5}, 20}
// 2. 方式二(推荐)
w = Wheel{
Circle: Ciecle{
Point: Point{X:8, Y:8},
Radius: 5,
},
Spokes: 20, // 尾部逗号是必须的
}
- 副词 # 使得 Printf的格式化符号 %v 以类似 Go 语法的方式输出对象,包含成员变量的名字:
// 格式化输出:
fmt.Printf("%#v\n", w)
// 效果:Wheel{Circle:Circle{Point :Point{X:8, Y:8}, Radius”}, Spokes: 20}
注意事项:
- 由于"匿名成员"拥有隐式的名字,因此在一个结构体中不能定义两个相同类型的匿名成员,否则会引起冲突。
- 由于匿名成员的名字是由它们的类型决定的,因此它们的可导出性也由它们的类型决定。在上面的例子中,Point和Circle这两个匿名成员是可导出的。
- 即使这两个结构体是不可导出的(例如改为:point和circle),我们在包外仍然可以使用快捷方式:
w.X = 8
的方式进行访问。但是,显式指定中间匿名成员的方式w.circle.point.X = 8
在声明circle和point的包之外是不允许的,因为它们是不可导出的。 - 以快捷方式访问匿名成员的内部变量同样适用于访问匿名成员的内部方法.
5. JSON
JavaScript对象表示法(JSON)是一种发送和接收格式化信息的标准,因为简单、可读性强并且支持广泛,所以使用最多。其基本数据类型如下:
Go通过标准库 encoding/json 对JSON格式的编解码提供了良好的支持。
- 通过JSON数组编码slice和数组;
- 通过JSON对象编码map - “键为字符串类型”;
- Go的额结构体和JSON对象非常类似,因此两者之间的转换非常容易。Go数据类型转化为JSON称为marshal,使用json.Marshal来实现:
// Go->JSON示例:
data, err := json.Marshal(movies) // 生成不带有任何空白字符的字符串,在生成环境下使用
// data, err := json.MarshalIndent(movies) // 生成格式化后的字符串,带有标准的字符缩进,在开发、阅读过程中使用
if err != nil {
log.Fatalf("JSON marshaling failed: %s", err)
}
fmt.Printf("%s\n", data)
- marshal使用Go结构体成员的名称作为JSON对象中字段的名称(通过反射的方式获取),同时只有可导出的成员会转化为JSON字段 - 隐藏一些内部变量、中间变量。
- 一些情况下,我们会对结构体成员的名称进行改变,但是不希望这种改变影响到已有服务(兼容),这种情况下字段的名称就需要手动指定:
type Movie struct {
Title string
Year int `json:"released"`
Color bool `json:"color, omitempty"`
Actors []string
}
- 上述加在Year|Color类型后面的内容称为成员标签定义(field tag),是结构体成员在编译期间关联的一些元信息。成员标签类型可以是任意字符串,但是一般由一串空格分开的键值对key:"value"组成,可以通过反射的方式获取并据此实现一些拓展功能。
- 以
json:"color, omitempty"
为例,其中标签值的第一部分指定了Go结构体成员对应JSON字段中的名字,第二部分(可选)omitempty表示如果该成员的值为零值或空的话,就不输出到JSON中。
### 小总结:
Go结构体成员转化为JSON字段的名字存在三种情况:
1. 不可导出的成员变量(开头小写),不会转化
2. 可导出的成员变量:
* 带omiempty标签,非零值、非空情况下会被转化
* 其余,任何情况下均会被转化为JSON字段
- marshal的逆操作负责键JSON字符串解码为GO的数据结构,这个过程叫做unmarshal.
- 及时对应的JSON字段的名称不是首字母大写,结构体的成员名称也必须是首字符大写的。unmarshal阶段,JSON字段名称关联到Go结构体成员的过程是忽略大小写的。
- 同理,unmarshal过程也可以使用field tag进行映射,一般用于不同变量名标准之间的转化,例如:json->created_at : go->CreateAt(驼峰式)。
// 示例:
// 注意:json.Unmarshal需要完整的byte slice作为输入。
// 但是HTML响应一般是流式的,且可能会比较大,如果等待接收完才进行处理:(1) 内存占用比较大 (2) 效率偏低;
// 因此一般使用json.NewDecode(resp.Body).Decode(&res)流式解码器进行处理,同理还有流失编码器。
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const IssuesURL = "https://api.github.com/search/issues"
type IssuesSearchResult struct {
TotalCount int `json:"total_count"`
Items []*Issues
}
// 只提取和issues相关的部分成员变量
type Issues struct {
URL string
RepositoryURL string `json:"repository_url"`
Number int
Title string
States string
User *User
CreateAt time.Time `json:"created_at"`
Body string // markdown格式
}
type User struct {
Login string
Id int
URL string
}
func SearchIssues(terms []string) (*IssuesSearchResult, error) {
// 1. 将输入字符串进行url编码
q := url.QueryEscape(strings.Join(terms, " "))
// 2. 进行请求
resp, err := http.Get(IssuesURL + "?q=" + q)
if err != nil {
return nil, err
}
// 3. 必须在所有可能分支上关闭resp.Body, 后续可以用defer进行优化
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("search query failed: %s", resp.Status)
}
// 4. 解析结果 - 注意这里使用了NewDecode.Decode而不是unmarshal
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
}
func main() {
res, err := SearchIssues(os.Args[1:])
// 1. 处理异常
if err != nil {
log.Fatal(err)
}
// 2. 处理结果
fmt.Printf("%d issues:\n", res.TotalCount)
for _, item := range res.Items {
// 2.1 %-5d --> '-'表示左对齐,5表示最小宽度
// 2.2 %9.9s --> 最小和最大宽度都为9
// 2.3 %.55s --> 最大宽度为55个字符
fmt.Printf("#%-5d %9.9s %.55s\n",
item.Number, item.User.Login, item.Title)
}
}
6. 文本和HTML模板
到目前为止的代码中,我们都是用Printf函数进行格式化输出,但是实际工作场景中,我们会:
- 遇到更加复杂的格式化操作
- 要求格式和代码彻底分离(避免硬编码)
这种情况下,可以通过text/template和html/template来实现,这两个包提供了一种机制,可以将程序变量的值带入到文本或者HTML模板中。
模板
- 模板是一个字符串或者文件,包含一个或者多个双边用大括号包围的单元{{…}}。
- 每一个大括号包围的单位称之为操作,对应一个表达式,用来进行:输出值、选择结构体成员、调用函数或方法、描述控制逻辑(if-else|range)、实例化其它模板等行为。
// 示例:text/template
const templ = `{{.TotalCount}} issues:
{{range .Items}}---------------------------
Number: {{.Number}}
User: {{.User.Login}}
Title: {{.Title | printf "%.64s"}}
Age: {{.CreateAt | daysAgo }} days
{{end}}`
如上例中所示:
- 常规字符,直接输出
.
号表示当前值,用来从结构体中获取成员并进行输出|
号类似Unix中的管道,将前一个操作的结果当作下一个操作的输入
- {{.Title | printf “%.64s”}},对字符串最大长度进行限制
- {{.CreateAt | daysAgo }},作为参数传入daysAgo函数中,获取已经过去的时间
- {{range.Items}}和{{end}}表示创建一个循环
使用示例如下:
func main() {
res, err := SearchIssues(os.Args[1:])
// 1. 处理异常
if err != nil {
log.Fatal(err)
}
// 2. 处理结果
const templ = `{{.TotalCount}} issues:
{{range .Items}}---------------------------
Number: {{.Number}}
User: {{.User.Login}}
Title: {{.Title | printf "%.64s"}}
Age: {{.CreateAt | daysAgo }} days
{{end}}`
report := template.Must(template.New("report").
Funcs(template.FuncMap{"daysAgo": daysAgo}).
Parse(templ))
if err := report.Execute(os.Stdout, res); err != nil {
log.Fatal(err)
}
}
func daysAgo(t time.Time) int {
return int(time.Since(t).Hours() / 24)
}
- html/template同理,不过需要在其中添加一些html代码而非文本。html/template会对出现的字符串进行自动转义,避免出现一些注入问题。
PS:
- 一个类型可以通过关联String方法来改变格式化输出的内容(%v和%s情况下,如果直接输出%d或%g不会变):
func (c Celsius) String() string {
return fmt.Sprintf("%g 摄氏度", c)
}
- 同理json.Marshaler 接口定义了一个 MarshalJSON 方法,当你的类型需要转换成 JSON 时,MarshalJSON 方法会被调用。可以在这个方法中自定义类型的 JSON 编码行为。