本专题记载了 Go 语言的复合类型:数组,切片 Slice,结构体 ( 以及和 JSON 数据之间的相互转换),散列表 map
。其中,切片是一个比较难理解的部分,因此它占据了大部分章节。
第四章 复合数据类型
1. 数组
了解数组是了解 Go 语言切片的前提。数组是具备固定长度的,由 0 ~ 多个相同元素组成的连续序列。数组内的元素通过索引 ( 或者称之 "下标号" ) 访问。下面的代码演示了如何创建一个可容纳 3 个 int8
空间的数组:
var ints [3]int8
默认情况下,数组的零值就是指内部元素类型的零值。如果要在创建数组时赋初始值,则可以使用数组字面量来完成。( 如果字面量的数量不够,之后的元素仍采用零值。
var ints = [3]int8{1, 2, 3}
for index, value := range ints{
fmt.Printf("index %v is %v\n",index,value)
}
也可以不主动指定数组的长度,这样的话实际长度将由数组字面量的个数来决定。
var ints = [...]int8{1,2,3,4,5}
// len 函数可以返回该数组的长度。
print(len(ints))
注意,数组的长度也是数组类型的一部分。比如说 [3]int
和 [4]int
就是完全不同的两个类型。
var ints = [...]int8{1,3,4,4,5,6}
// %T 会打印该变量的数据类型。数组的数据类型包含数组长度。
fmt.Printf("%T",ints)
数组之间是可以比较的,前提是内部的元素类型也是可比较的。假定有表达式 arr1 == arr2
,其中两个变量均为数组类型。那么程序会依次判断两数组内部的元素是否都完全相等。若为真,则该表达式结果为 true
。
如果两个数组的长度不同,那么它们就必定是不同的。对不同类型的变量进行比较,Go 会直接给出一个编译错误。
var arr1 = [...]int{1,2,3}
var arr2 = [...]int{1,2,4}
var arr3 = [...]int{1,2,3}
var arr4 = [...]int{1,2,3,4,5}
//false
println(arr1==arr2)
//ture
println(arr1==arr3)
// 编译不通过,原因是 [3]int 和 [5]int 不是同一种类型。
println(arr1==arr4)
如果函数中出现了与数组相关的形参,它将是这样的:
// 这个函数将 ints 内的三个元素全部 + 1
func add_1(ints [3]int){for _,v := range ints{v++}}
但是当在主程序使用它时,会发现外部传入的数组并没有发生改变:
ints := [3]int{1,2,3}
add_1(ints)
// ints 并没有因为 add_1 函数的操作而发生改变。
for _,v := range ints{println(v)}
这是由于当调用 Go 函数时,传入的参数都是一个副本 ( 值拷贝 )。当传入的是一个大数组时,这种方式将变得十分低效。并且,函数内部修改的都是其数组的副本,而不是传入的那个数组本身。但是在其它语言当中,数组可能是隐式地引用方式传递。
行之有效的方法是令函数接收一个 [3]int
类型的指针,然后根据下标修改数组的值。
func add_1(pInt3 *[3]int){
for i := range pInt3 {
pInt3[i]++
}
}
值得一提的是,下面这种方式也不会改变原有的数组:
func add_1(pInt3 *[3]int){
for _,v := range pInt3 {
// 这种方式只是将数组内的元素拷贝给局部变量 v 并使 v 自增,但是并没有影响到数组内的元素本身。
v++
}
}
如果不采取指针的方式,也可以在函数运行结束之后将改动后的数组值传回到外部:
func add_1(ints [3]int) [3]int {
for i,v := range ints {
ints[i] = v+1
}
return ints
}
由于数组天然就是不可变的,因此它没有定义有关扩容,或者收缩的操作。在实际开发中,很少直接使用不可变的数组,而是其依附于数组的切片 Slice。
2. 切片
切片表示一个长度可变的,由同类元素组成的序列。切片的类型为 []T
,和数组类型的最大区别是,它不指派长度。切片和不可变的数组紧密相关,又或者称切片是依附于某个数组的。这个数组被称为底层数组。切片包含了三个内容:指针,长度,和容量。
[10]arr => [0] [1] [2] [3] [4] [5] [6] [7] [8] [9]
arr[1:6] => |===============|
len(arr[1:6]) => `-------v-------’
len
|===============|----------------|
cap(arr[1:6]) => `---------------v----------------’
cap
指针,记录了这个切片从数组的哪个位置 "切入",因此笔者习惯额外称它是 "头指针"。上述演示的代码块中,arr[1:6]
表示从 arr[1]
这个 "切点" 切入并得到了一个左闭 ( 含 arr[1]
) 右开 ( 不包含 arr[6]
) 区间的切片。
容量 cap
是一个和长度 len
不同的概念。这个数值通常等于切片指针到底层数组末端的这段距离,cap >= len
恒成立。 cap - len
则代表了 "这个切片的可拓展空间" 。比如,在通常情况下 cap(arr[1:6]) = len(arr) - 1 = 9
( 底层数组长 - 此切片 "头" 指针对应底层数组的下标 ) 。后文会通过例子介绍 cap
存在的意义。
var arr = [10]int8{0,1,2,3,4,5,6,7,8,9}
// 由于切片 slice[m:n] 的前闭后开区间,因此计算长度很容易:n - m.
// 5
println(len(arr[1:6]))
// 通常都是 len(arr) - m
// 9
println(cap(arr[1:6]))
注意,切片的可访问下标范围仅在 0
~ len-1
之间,这和 cap
没有关系。
var arr = [10]int8{0,1,2,3,4,5,6,7,8,9}
//len = 6 - 3 = 3
//cap = 10 - 3 = 7
//可用的下标号为 0 ~ 2,在本例中,值依次对应 3 ~ 5
slice := arr[3:6]
fmt.Print(slice[0])
fmt.Print(slice[1])
fmt.Print(slice[2])
// 编译通过,但是运行错误。
// panic: runtime error: index out of range [3] with length 3
fmt.Print(slice[3])
切片字面量的初始化方式和数组字面量非常相似,但是不会像数组那样声明长度。
// 得到的是切片
a := []int8{1,2,3,4,5}
// 得到的是数组
b := [5]int8{1,2,3,4,5}
fmt.Printf("%T\n",a)
fmt.Printf("%T\n",b)
格外要注意的点是,切片之间不允许相互比较。切片唯一能够比较的值是 nil
,用来判断它是否是一个空切片。
// a 和 b 相等吗? 或者说可比较吗?
a := []int8{1,2,3,4,5}
b := []int8{1,2,3,4,5}
// 提示 []int8 没有定义 == 操作符。
println(a == b)
println(a != nil)
一个底层数组可以覆盖多个切片。切片可以从底层数组的任意一点 "切入",并且切片之间可以相互重叠。
var arr = [10]int8{0,1,2,3,4,5,6,7,8,9}
slice1 := arr[1:5]
// 可以像访问数组元素那样,从切片中摘出某一个元素。
// 对于 slice1 而言,它的第一个元素相当于是底层数组的第二个元素,因此这个值是1.
println(slice1[3])
// 这个切片修改了底层数组
slice1[3] = 127
// 切片之间可以相互重叠。
slice2 := arr[2:6]
// 切片对底层数组的修改可能会导致另外的切片也随之变化。
// 打印结果将是 127,因为这个位置的元素被 slice1 修改过。
println(slice2[2])
切片之间相互重叠的情况下要格外小心:一个切片对底层数组的重叠位置做了修改,这个行为会影响到另一个切片。
var arr = [10]int8{0,1,2,3,4,5,6,7,8,9}
//arr[1] ~ arr[7],length = 6;
slice1 := arr[1:8]
slice2 := slice1[2:5]
for _,v := range slice2 {println(v)}
一个切片 B 可以切于令一个切片 A,切片 A,B 会共享底层数组。这种情况下必定会引起切片 A,B 发生重叠,且切片 B 的长度未必就比 A 短。
下面用图示方式举了一个例子:切片 B 的末端可以越过切片 A 的末端,这将导致切片 A 被 "暂时动态扩容"。有一点可以肯定的是:切片 B 的前端一定不会越过切片 A 的前端。
这时,切片 A 的 cap
就是一个关键量。在这里按默认情况计算 sliceA
的 cap = 10 - 3 = 7
。 也就是说如果 sliceB := sliceA[n:m]
,这个 m
的最大值可以为 7
。
下面的图示中,m == 6
。显然,如果 m
的取值大于 7
( cap(sliceA)
) ,那么就说明 sliceB
的末端已经延展到了底层数组的外界,这会引发程序错误。
[10]arr => [0] [1] [2] [3] [4] [5] [6] [7] [8] [9]
| | |
sliceA => |=============|~.~.~.~|
(arr[3:7]) [3] [4] [5] [6] [7] [8]
| |
sliceB => |=================|
(sliceA[1:6]) [4] [5] [6] [7] [8]
下面将用程序来演示这个过程。值得强调的一点是,上文使用了"暂时动态扩容" 的措辞,这是一种 "形象" 又便于理解的说法,同时还暗示了:在创建切片 B 的前后,切片 A 的长度没有因此发生任何变化。
var arr = [10]int8{0,1,2,3,4,5,6,7,8,9}
sliceA := arr[3:7]
//输出切片 A 的长: 7 - 3.
println(len(sliceA))
// 切片 B 截取自切片 A
sliceB := sliceA[1:6]
// 切片 A 的长度不变
println(len(sliceA))
// 查看切片 B 的最后一个元素:8
println(sliceB[len(sliceB)-1])
通过这个例子展现了 cap
的一般意义:它提供一种保证,以防止其它切片在某一个切片为基准 "切入" 时,不慎拓展到底层数组的 "外面" 。不过,cap
并不总是切片指针到数组末端的距离长,后文演示了如何主动设置 cap
值。
2.1 append 函数
append
函数可以为切片扩容。直观的用法是:
sliceA := []int8{1,2,3,4,5}
sliceB := append(sliceA,6,7)
//{1,2,3,4,5,6,7}
for _,v := range sliceB {fmt.Print(v," ")}
相比于用法,append
函数更值得探讨的地方在于:如果在一般的情形中使用了 append
函数,这是否会影响到原切片所引用的底层数组?答案是:不确定。前文已经介绍了一个切片具有的两个量值:len
长度和 cap
容量。下文为了方便叙述,会将 cap - len
这片空间称之为 "剩余空间" 。
第一种情况是:作为参数的切片 sliceA
的剩余空间足够容纳 appned
函数新添加的元素:( 比如下面的代码块中,append
只添加了一个新元素 10
) 。
i8s := [...]int8{1, 2, 3, 4, 5}
// len = 4 , cap = 5 , usable = 5 - 4 = 1.
sliceA := i8s[:4]
sliceB := append(sliceA,10)
// 显然 slice 序列里有 {1, 2, 3, 4, 10}
fmt.Println("sliceB:")
for _,v := range sliceB {fmt.Print(v," ")}
// 此时的底层数组也被改变了:{1, 2, 3, 4, 10}
fmt.Println("\n[after] i8s:")
for _,v := range i8s {fmt.Print(v," ")}
这种情况下,append
函数相当于是基于切片 sliceA
的底层数组创建了另一个切片 sliceB
。为了能够在原切片的基础上新增一个值 10
,append
函数的做法是修改了 sliceA
的底层数组 i8s[4]
,将原来的元素值 5
修改成了 10
。
i8s => [1] [2] [3] [4] [5]
sliceA => |=============| :
(i8s[:4]) [1] [2] [3] [4] :
| | :
append(sliceA,10) | :
↓ | | :
Does cap(sliceA) suffice? | :
↓ | | :
(ture) | | :
↓ | | :
i8s => [1] [2] [3] [4] [10]
sliceB <- |==================|
return
第二种情况是:append
函数添加的元素数量多于 sliceA
的剩余空间。下面的例子中,新增的元素数量有 3 个,但是 sliceA
的剩余空间只有 1 个。如果按照默认方式计算 cap(sliceA)
,这其实还表明了 sliceA
的底层数组没有办法装下多出来的那 2 个元素。由于数组本身又是不可变的,append
函数自然就无法再基于 sliceA
的底层数组 i8s
做处理了。
i8s := [...]int8{1, 2, 3, 4, 5}
// len = 4 , cap = 5 , usable = 5 - 4 = 1.
sliceA := i8s[:4]
// element Count = 3 > usable
sliceB := append(sliceA,10,11,12)
// {1, 2, 3, 4, 5, 10, 11, 12}
fmt.Println("sliceB:")
for _,v := range sliceB {fmt.Print(v," ")}
// 此时的 sliceA 的底层数组没有变化,{1, 2, 3, 4, 5}
fmt.Println("\n[after] i8s:")
for _,v := range i8s {fmt.Print(v," ")}
这时 append
的逻辑是:索性在内存空间中在原有底层数组的基础上复制出一个有更多空余空间的新数组,返回的新切片将以这个新数组作为底层数组。
说得再直白一些,此时得到的 sliceB
已经和 sliceA
及其底层数组 i8s
没有联系了。
i8s => [1] [2] [3] [4] [5] ←------------┐
sliceA => |=============| |
(i8s[:4]) [1] [2] [3] [4] |
↓ ↓
append(sliceA,10,11,12) ┌-→ copy a new longer array
↓ | ↑
Does cap(sliceA) suffice ? | |
↓┌----------------------------┘ |
(false) |
|
new base arr=> [1] [2] [3] [4] [5] [0 ] [0 ] ←┘
: : :
do append => [1] [2] [3] [4] [10] [11] [12]
sliceB <= |============================|
return
总之, append
函数可能会引发副作用,这取决于被放入的 sliceA
剩余空间 cap - len
是否足够容纳这些被添加的元素。
如果要强制令 append
为新切片 sliceB
分配一个新的底层数组 ( 如果你想实现这样的效果 ),这里有两个思路:
思路一:限定 sliceA
的剩余空间为 0,表明 "不可在 sliceA
之上做拓展"。这其实只要限制切片的 cap == len
即可。Go 提供这样的语法:
// index - start = len;
// max -start = cap
// start <= index <= max
// avaliable: [start,index)
array[start:index:max]
该语法在 array[m:n]
的基础上增添了第三项:max
。其中,start
可以被缺省,默认为 0,写法为 array[:index:max]
。
程序通过计算 max - start
来设置此切片的 cap
容量。如果该 max == len(array)
,则两者声明方式等价。本例则通过设置 max == index
来使切片的剩余空间为 0
。
var arr =[...]int8{1,2,3,4,5,6,7}
// start = 2, index = 5, max = 5
// len(sliceA) = 5 - 2 = 3
// cap(sliceA) = 5 - 2 = 3
// usable = cap - len = 0
// sliceA: {3, 4, 5}
// 使用 "arr[2:5]" 和 "arr[2:5:5]" 观察会对 arr 数组有何影响.
sliceA := arr[2:5:5]
// sliceB: {3, 4, 5, 16, 17}
sliceB := append(sliceA, 16, 17)
fmt.Println("arr...")
for _, v := range arr {fmt.Print(v," ")}
fmt.Println("\nsliceB...")
for _, v := range sliceB {fmt.Print(v," ")}
思路二:如果不想令某一个数组会随着切片而改变,那么只需要让切片在创建时引用另一个底层数组,且这两个数组通过值拷贝的方式共享数据,以此达到切片和原数组 "松耦合" 的效果。
var arr =[...]int8{1,2,3,4,5,6,7}
// make(slice type, len, cap),这个空切片引用的是一个长度为 cap 的零值数组。
sliceA := make([]int8, 3, 30)
// slice = {3, 4, 5}
copy(sliceA,arr[2:5])
// sliceB = {3, 4, 5, 100, 101}
sliceB := append(sliceA,100,101)
// arr is 1 2 3 4 5 6 7
fmt.Println("arr is...")
for _,v := range arr{
fmt.Print(v," ")
}
// sliceB is 3 4 5 100 101
fmt.Println("\nsliceB is...")
for _,v := range sliceB{
fmt.Print(v," ")
}
首先,这里的 sliceA
没有直接引用 arr[2:5]
片段 ,它通过 make
函数完成了初始化。这使切片 sliceA
引用一个新的零值数组作为底层数组,而不是 arr
本身。
sliceA
需要获取到 arr[2:5]
的内容,而这一步通过内置的 copy
函数将 arr[2:5]
片段上的值拷贝到了 sliceA
的底层数组。
arr => [1] [2] [3] [4] [5] [6] [7]
| | | ________________________
┌---┬---┼---┴---┘ <=| make([]int8,3,30) |
| | | | copy(sliceA,arr[2:5]) |
↓ ↓ ↓ ▔▔▔▔▔▔▔▔▔▔▔▔▔
[base arr] => [3] [4] [5] [000] [000] [0] [0] ... [0]
sliceA => |=========| : : _________________________
| | : : <= | append (sliceA,100,101) |
| | : : ▔▔▔▔▔▔▔▔▔▔▔▔▔▔
[base arr] [3] [4] [5] [100] [101] [0] [0] ... [0]
sliceB <= |=====================|
3. 散列表 map
散列表是一个拥有键值对元素的无序集合。散列表的键 ( key ) 总是唯一的。在 Go 程序中,需要借助内置的 make
函数来得到一个散列表 map
的引用:
// K: Key 的类型。
// V: Value 的类型。
// 通过 make 函数得到的是一个非 nil,但是内容为空的 map 散列表。
aMap := make(map[K]V)
从写法上来看,键占据了一个相对更重要的地位:Go 要求它的类型必须是可以通过 ==
符号比较的,这样 map
才能快速检测某个键是否已经存在了。可以使用 map
字面量在初始化时插入 K-V,不管这个键值对是不是列表中的最后一个,它总是需要以 ,
收尾 。
// 通过 map 字面量赋值的方式可以在初始化时就在内部插入键值对。
aMap := map[string]int8{
// 无论后面有没有 k-v 对,总是需要使用半角,符号收尾。
"age": 20,
"score": 100,
}
3.1 注意初始化问题
当如下方式声明一个 map
时,得到的是一个 nil
。
// 声明却不赋值
var aMap map[string]int8
这样的 map
是没有意义的,因为它还没有被初始化 ( 如果要得到一个内容为空的 map
引用,则应该使用 make
函数 ),除非在之后为它赋值了一个 map
字面量,这个字面量里面可以不包含任何内容:
var a map[string]int8
// 也可以这样赋值: a = map[string]int8{}
// 这将等价于 => make(map[string]int8)
a = map[string]int8{
"java":2017,
"scala":2020,
"go":2021,
}
fmt.Println(a["python"])
3.2 有关散列表的查找删除
在 map
中的查找,删除操作都是安全的。如果程序没有找到某个 Key,则会在访问时返回 Value 类型的零值;同样的,如果要删除某个不存在的 Key,那么这个 map
就不会发生任何变化。
散列表维持了 "下标" 方式访问元素的传统,因此在写法上保留了方括号 []
。不过,这里 "下标" 指代的是 Key 值。
aMap := map[string]int8{
"age": 20,
"score": 100,
}
//访问 key = "age" 的 value 值。
println(aMap["age"])
//修改 key = "score" 的 value 值。
aMap["score"] = 60
上述代码块的赋值语句要分两个情况考虑:如果这个 Key 之前并不存在,那么这条语句相当于插入了一个新的键值对。否则,由于 map
中 Key 不允许重复,这会修改原来这个 Key 所对应的 Value。
因此,下面的这一条语句包含了两条信息:
// aMap is a type of map[string]int8
aMap["age"] ++
如果这个 Key 未曾存在过,那么这条语句会在 aMap
表内插入一条 "age":0
,然后再做自增操作 ( 这样的话,该键的返回值就是 1 ) 。否则,就将 Value 在之前的基础上自增 1 。换句话说,aMap["age"]++
得到的值将至少为 1 。
无论一个 Key 是否存在于 map
中,Go 程序总是至少会返回一个零值,因此避免了 "NullPointerException
"。但是,这种 "贴心" 的做法也会带来混淆的情况,比如说用户程序恰好插入的就是 Value 为零值的键值对:
_3dPosition := map[string]int8{
"x":100,
"y":80,
"z":0,
}
_,isExist := _3dPosition["z"]
if(isExist){fmt.Println("value of z is 0")}
为了避免这种混淆,Go 语言做了如下设计:当访问 map
元素时,实际上会得到两个值,一个是 Value 本身,另一个则是布尔类型的值,它表达了程序是否在此 map
找到了预期的 Value。
_3dPosition := map[string]int8{
"x":100,
"y":80,
"z":0,
}
_,isExist := _3dPosition["z"]
if(isExist){fmt.Println("value of z is 0")}
_2dPosition:=map[string]int8{
"x":100,
"y":80,
}
_,isExist = _2dPosition["z"]
if(!isExist){fmt.Println("have no param of 'z'")}
注意,无法获取 map
元素的地址:
&aMap["age"]
原因是因为随着 map
散列表的增长,内部元素的地址可能会发生改变 ( 它们可能会被挪到一个新的地址 ) 。map
被视作下标为 "Key" 的特殊序列,因此 for
循环中的 range
关键字对它仍然适用:
for k,v := range aMap {
fmt.Printf("the key is %v, and the value is %v\n",k,v)
}
3.3 有序遍历 map 散列表的思路
值得一提的是,map
依赖足够随机的散列算法来避免碰撞,这也导致直接对 map
进行 for
循环遍历时,程序并不保证其迭代的顺序。如果要按照某种顺序遍历 map
,一种委婉的方法是:单独创建一个切片有序保管 Key 值,然后按照遍历顺序去访问 map[Key]
。
nameList := map[string]int8{
"Li Lei": 100,
"Wang Fang": 80,
"A Wei": 70,
}
//make(type of slice,len,cap)
names := make([]string, 0, len(nameList))
// 1.将 map 内的 key 全部插入到 names 切片
for key := range nameList{
names = append(names,key)
}
// 2.这个包的作用相当于对 names 切片就地进行了一步重排序。
// 字符串的排序是按首字母 A-Z 顺序排列的。
sort.Strings(names)
// 3.按照有序的 key 列表去访问 map.
for _,v := range names {
fmt.Println(nameList[v])
}
3.4 将不可比较的类型设置为 Key 的思路
前文提到作为 Key 的类型必须是可使用 ==
,!=
比较的类型,因此声明下面的 map
不会通过编译,因为 Key 是一个不可比较的字符串切片。
map[[]string]int8{
[]string{"hello","go"}:3,
//....
}
答案是适配器设计思想。我们只需要设计一个助手函数 k
,将不可变的 []string
映射成 string
,就可以通过一种 "委婉" 的方式实现这个需求。
import "fmt"
func main() {
slice := []string{"hello", "go"}
aMap := map[string]int8{}
Insert(aMap, slice, 3)
r, rExist := GetOrReturn(aMap, []string{"hello", "go"}, -1)
if rExist {
println(r)
}
}
func k(key []string) string {
return fmt.Sprintf("%q", key)
}
func Insert(thisMap map[string]int8, key []string, value int8) {
thisMap[k(key)] = value
}
func GetOrReturn(thisMap map[string]int8, key []string, ifNone int8) (int8, bool) {
result, ifExist := thisMap[k(key)]
if !ifExist {
result = ifNone
}
return result, ifExist
}
3.5 结构体
结构体是将 0 个或者多个类型的变量组合在一起的聚合数据类型,可以将它理解成是一张数据库的表。声明方式类似于 C 语言:
// type 和 struct 是两个关键字
// not 'Class'
type Employee struct {
name,address string
age int8
salary int32
boss *Employee
}
首先,结构体 S
内部的成员可以是基本数据类型,或者是其它类型的结构体 V
或其指针 *V
,异或是自身类型的指针 *S
,但是,不可以是自身类型 S
。
结构体内可以定义任意数量的成员。如果有多个成员属于同一种数据类型,则可以将它们写在一行,使用 ,
分割。总体来看,结构体内部成员的排列顺序是由左至右,由上到下的。
使用点操作符 .
来访问一个结构体变量的成员:
Bob.name
结构体可以通过指针方式来访问,如果对结构体指针使用 .
操作符,则等价于对指针指向的结构体变量使用 .
操作符:
var p2Employee *Employee = &Bob
// 等价于*(p2Employee).name
println(p2Employee.name)
3.5.1 结构体成员的可导出性
如果结构体内部定义的成员名是首字母大写的,则意味着该成员可以被跨包访问,或者称可以被导出 ( 相当于 public
成员 ) 。比如在 B 包定义一个这样的结构体,注意,info
成员为小写开头。
type Protocol struct {
info string
Host string
Port int32
}
在另一个 A 包中,可以导入 B 包并创建一个 Protocol
类型的变量,但是只能够为 Host
和 Port
成员赋值。在 A 包操作 Protocol
类型的结构体时,成员 info
会如同不存在一样。
import "B"
client := B.Protocol{
Host: "localhost",
Port: 8000,
}
同理,如果结构体本身以小写字母开头命名,则在其它包中就无法使用此结构体。除非要实现 "密封类" 的效果,一般情况下结构体总是大写字母开头,而需要被保护的个别成员则使用小写字母开头。
3.5.2 结构体赋值
结构体一般情况下通过两种方式赋值:调用函数并使用返回值赋值,或者是直接使用结构体字面量赋值。结构体字面量的完整写法类似于 json :
func main(){
// Employee{...} 部分又称之为结构体字面量,不需要使用诸如 new 之类的关键字。内部写法类似于 map.
Tim := Employee{
name: "Tim",
address: "UK"
age: 26,
salary: 10000,
boss: nil,
}
Bob := Employee{
name: "Bob",
address: "UK"
age: 18,
salary: 15000,
boss: &Tim,
}
}
在结构体的结构非常简单的场合,结构体字面量可以直接写值而忽略掉成员名:
type Position{X,Y int}
p := Position{3,4}
这意味着代码将不会显式地提示每个数值的意义,这时需要程序员自己保证赋值顺序要和成员声明的顺序严格一致,以保证每个值对应正确的语义。当结构体的结构比较复杂时,这种写法并不会推荐,因为这会为后续的维护工作带来难度。
结构体字面量的写法只能在上述两种取其一:要么显式地带上所有的成员名,要么就一个都不带。尤其对于第二种赋值方式而言,要格外注意结构体成员的排列顺序 ( 前文提到的:从上到下,从左到右 ) 。对于这样 "模棱两可" 的结构体字面量赋值, Go 会拒绝编译:
Bob := Employee{
name: "Bob",
address: "UK"
18,
15000,
boss: &Tim,
}
3.5.3 在函数中传递结构体
结构体及其指针都可以作为函数的参数或者返回值。首先假定有以下两组函数:
// 好耶
func PayRaise(employee Employee) {
employee.salary += 5000
}
func PayRaiseP(employee *Employee){
employee.salary += 5000
}
第一个函数 PayRaise
接收 Employee
类型,而第二个函数 PayRaiseP
接收的是其指针,两个函数的逻辑均是将传进来的员工信息 "涨薪" 5000。下面在主程序中调用这两个函数,并观察传入的值 Tim
是否真的被改变了:
Tim := Employee{
name: "Tim",
address: "EN",
age: 26,
salary: 10000,
boss: nil,
}
PayRaise(Tim)
fmt.Println(Tim.salary)
PayRaiseP(&Tim)
fmt.Println(Tim.salary)
通过运行代码可以观察到:调用 PayRaise
方法时, Tim
的实际工资并没有上涨;但是在调用了 PayRaiseP
方法之后,Tim
的实际工资真的被改变了。这表明当形参列表中出现的是结构体类型时,参数只是简单地值传递。然而,如果要在函数内部对外界的结构体做出修改时,此时传入的应该是结构体指针。
下面有另两组函数,它们分别返回结构体类型及其指针类型:
func NewEmployee() Employee{
return Employee{
name: "John",
address: "UK",
age: 30,
salary: 16000,
boss: nil,
}
}
func NewEmployeeP() *Employee{
return &Employee{
name: "John",
address: "UK",
age: 30,
salary: 16000,
boss: nil,
}
}
这两者返回的内容及其相似,只不过一个是结构体字面量的值,另一个则是结构体字面量的地址。出于效率的考虑,函数中接收,返回的通常都是结构体指针,因为这避免了一次 "值拷贝" 的过程。除非你不希望函数在原有的数据进行改动,而是返回一个新的结构体数据。
// 该变量将是一个普通的结构体类型
newEmployee := NewEmployee()
fmt.Printf("%T\n",newEmployee)
fmt.Printf("%v\n",newEmployee.name)
newEmployee = Tim
// 该变量是 *Employee 类型。
newEmployeeP := NewEmployeeP()
fmt.Printf("%T\n",newEmployeeP)
fmt.Printf("%v\n",newEmployeeP.name)
newEmployeeP = &Tim
3.5.4 结构体的可比较性
当且仅当一个结构体内部的成员全部可以比较时,这个结构体才是可以比较的。如果这个结构体是可比较的,那么它就可以作为 map
散列表的键 Key 。
p := Position{1,2}
q :=Position{3,4}
// 如果结构体内部全是可比较的,那么这个结构体本身就是可比较的。
println(p==q)
// 如果一个结构体是可以比较的,则它可以充当 map 的 key。
_ = make(map[Position]string)
3.5.5 结构体嵌套结构和匿名成员
结构体内部的成员可以是其它的结构体类型或指针。假设有一个名为 Item
的结构体,它内部成员由 ItemName
和嵌套的 Position
结构体组成:
type Position struct{ X, Y int }
type Item struct {
p Position
ItemName string
}
如果要访问某个 Item
的 X
坐标,则需要两次 .
操作符来完成:
item := Item{
P: Position{3, 4},
ItemName: "apple",
}
// 写起来很麻烦。
println(item.p.X)
从语法上来看,这不难理解:成员 X
属于 p
,而非 item
。因此如果要访问成员 X
,这需要首先访问 Item
得到 p
才行。如果有一种办法可以令 X
和 Y
能 "直属于" Item
,那么访问它们就会容易得多了。
Go 语言提供这样的语法糖,我们要做的是就是将那个 Position
类型的成员 p
改成 "匿名" 成员:
type Position struct{ X, Y int }
type Item struct {
Position
itemName string
}
从访问的逻辑上看,这相当于做了一步 flat
操作:
Item{Position{X,Y},ItemName} => Item{X,Y,ItemName}
这么做的好处是:仅仅需要一个 .
操作符就可以直接访问到原本不直接属于 Item
结构体的成员 X
:
// before:
// println(item.p.X)
println(item.X)
然而,"匿名成员" 的说法其实并不准确,因为 Go 其实仍然会为 "匿名成员" 隐式地指派一个名字,这个名字就是结构体类型。在这个例子中,仍然可以通过 item.Position.X
的方式来访问到 X 坐标,只是在此处没有必要这么做而已。
同时,这种做法其实还含蓄的表达了:"匿名成员" 的可导出性将由这个结构体的可导出性决定。
4. JSON
有关 JSON 本身在此处应该无需赘言了,它是目前使用最广泛的一种网络发送和接收格式化信息的标准 ( 它并不是唯一的标准,类似的还有 XML ) 。在 Go 语言中,无论是将结构体转换为 JSON ( 这个过程在 Go 程序里称之为 "marshal
",意为 "整理,排序" ),还是 JSON 转换回结构体数据都十分简单,不需要从外部导入一些 fastjson
或是 gson
包的依赖 ......
item := Item{
Position:Position{1,2},
ItemName: "apple",
}
marshal, err := json.Marshal(item)
if err == nil {
fmt.Printf("%s",marshal)
}else {
log.Fatal(err.Error())
}
Go to JSON
json.Marshal(...)
函数返回两个值:一个是 []uint8
( 或者称 []byte
) 类型的转换结果,另一个变量则是可能出现的错误情况。对于转换结果可以使用 fmt.Sprintf(...)
等方法将其转换成字符串形式。上述代码会在控制台打印以下内容:
{"X":1,"Y":2,"ItemName":"apple"}
当 JSON 数据结构比较复杂时,将其打印在一行可能难以阅读。json.MarshalIndent
函数可以输出更加整齐的格式化结果:
// 在第一个参数种传入要被转换的 Go 数据。
// 第二个参数是前缀符号。通常情况下它都是 "",不过在这里出于演示效果而使用了其它符号。
// 第一个参数是定义的缩进字符串,这里使用了制表符 `\t` 。
marshal, err := json.MarshalIndent(item,"|","\t")
if err == nil {
fmt.Printf("%s",marshal)
}else {
log.Fatal(err.Error())
}
上述代码会在控制台打印以下内容:
{
| "X": 1,
| "Y": 2,
| "ItemName": "apple"
|}
需要注意的是,json
包只针对可被导出 ( 首字母大写命名 ) 的成员,对于不可导出的成员,在处理过程中会被直接忽略。
成员标签定义
在定义结构体时,可以通过标记 成员标签定义( field flag) 令 json
在处理数据时做一些额外工作:比如说你希望将某个成员 s
导出成 JSON 数据时,能够换成另一个名字 v
。
type Item struct {
Position `json:"position"`
ItemName string
}
其中,json:"position"
就是一个成员标签定义。它本身其实可以是任何一个字符串,但是它通常都以 key:"value"
的写法来定义。重新运行这段代码,观察控制台的打印结果会发生什么变化:
item := Item{
Position:Position{1,2},
ItemName: "apple",
}
marshal, err := json.Marshal(item)
if err == nil {
fmt.Printf("%s",marshal)
}else {
log.Fatal(err.Error())
}
匿名成员 Position
转换为 JSON 输出时,它的名字从 Position
变成了小写开头的 position
:
{"position":{"X":1,"Y":2},"ItemName":"apple"}
json
还提供一个额外的标签 omitempty
,它表示:如果在转换为 JSON 的过程中发现这个成员是零值,那么就忽略它。
type Item struct {
Position 'json:"position,omitempty"'
ItemName string
}
JSON to Go
相对地,将 JSON 转回结构体数据使用的是 json.Unmarshal(...)
函数。它可能比 json.Marshal(...)
函数更容易出现错误,因为你需要预留一个合适的结构体指针装载数据。
item := Item{
Position:Position{1,2},
ItemName: "apple",
}
marshal, _ := json.Marshal(item)
var parsedItem Item
err := json.Unmarshal(marshal, &parsedItem)
if err == nil {
// "%#v" 会输出完整的成员名称,包括匿名成员。
fmt.Printf("%#v",parsedItem)
}
Unmarshal
函数需要两个参数,一个是符合 JSON 语法的字符串字节形式 []byte
,一个是合理的结构体指针。它会就地将数据存放到指定的指针,因此返回值只有一个 err
。这里额外给出一个提示,可以通过 []byte(".....")
得到 string
值的字节序列。比如:
bytes := []byte(`
{
"AInt":12
}
`)
如果只希望得到 Item
中的 position
数据,那只需要设置一个仅包含了 position
的匿名结构体变量 ( 其它数据会在转换过程中被丢弃 ) ,然后传递它的指针:
item := Item{
Position:Position{1,2},
ItemName: "apple",
}
marshal, _ := json.Marshal(item)
var parsedPosition struct{
// 由于结构体通过标签 `json:position` 将匿名成员 "重命名" 为了 position,
// 故这里也设置了一个同为小写字母开头的成员 position。
// 如果不这样做,Json.Unmarshal 就找不到合适的数据类型。
position Position
}
err := json.Unmarshal(marshal,&parsedPosition)
if err == nil {
fmt.Printf("%#v",Position{})
}
5. 文本模板
对于简单的格式化输出,或许使用 fmt.Fprint(...)
就足够了。但如果格式化的是一个足够复杂的段落,这种情况下要求格式和嵌入的数据相互分离。而 Go 的 text/template
包则提供了这个功能 ( 其实 Go 还提供了渲染 HTML 模板的包:text/html
,但是本章暂时不考虑它 ) 。
这里首先给出一个简单的示例,然后逐步通过代码分析 text/template
提供的功能。
// 模板字符串。内部含有占位符 {{.}}
const templ = `
ISSUE:{{.Issue}}
AUTHOR:{{.Author}}
`
// 生成模板。
parse, err := template.New("Hello").Parse(templ)
// 准备插入的数据,这里直接使用了一个匿名结构体。
var data = struct {
// 注意,这些数据必须是可导出的,否则会在创建模板的过程中出现错误。
Issue string
Author string
}{"Hello,go","me"}
if err == nil {
// 执行模板
err := parse.Execute(os.Stdout, data)
if err != nil {log.Fatal(err.Error())}
}
这个函数大体上分为三个步骤:定义一个字符串形式的模板,然后通过 text/template
的 Parse(...)
函数创建它,最终通过 Execute(...)
函数执行这个模板,同时将外部数据插入到了模板中。程序最终的执行结果是:
ISSUE:Hello,go
AUTHOR:me
在字符串模板中,形如 {{.XXX}}
的片段起到了 "占位符" 的作用。如 {{.Issue}}
表示:在这个位置,将来会从传入的结构体数据 data
中摘出 Issue
成员的值替代之,{{.Author}}
同理。
template.New("name").Parse(templ)
是一串链式调用,首先 New(...)
函数为这个新的模板指定了一个名字,随后 Parse(...)
函数接收刚才定义好的字符串模板,并返回了一个可以被执行的文本模板,还有可能发生的错误 err
。
下一步则是准备好要插值的数据。注意,模板接收的是一整个结构体变量,因此在这里需要将模板所需要的各种数值打包到一个结构体变量内。至于这个结构体是什么类型 parse.Execute(...)
函数并不关心,它可以是临时定义的匿名类型,也可以是在外部声明的具名类型。
这个结构体内部的成员名要和模板的占位符对应,而且要保证这些成员是可以被导出的,否则程序就不能正确地渲染数据。
上述流程全部就绪后,可以调用 parse.Execute(...)
函数指定输出流 ( 这里选择输出到控制台,标准输出流 os.Stdout
) 和刚才准备好的结构体变量 data
,然后得到预期的结果。
上面是使用文本模板的基本流程,下面补充细节:
5.1 将函数绑定到模板内
首先,如果某些函数具备返回值,则它们可以被 "安装" 到文本模板中用于渲染数值。这些函数需要被传递到 template.FuncMap
散列表内 ( 在 Go 语言中,函数是一等公民,我们很快就会提到它 ),然后通过调用 Funcs(...)
函数将这个散列表和文本模板绑定起来。
func main() {
// 这里的 {{greet}} 相当于调用了 FuncMap["greet"] 的那个函数。
const template_ = `
===========
{{greet}}
===========
`
parse, err := template.New("evokeFunction").Funcs(
template.FuncMap{"greet": greet},
).Parse(template_)
if err == nil {
err := parse.Execute(os.Stdout, nil)
if err != nil {
log.Fatal(err.Error())
}
}
}
// 这个函数只返回字符串,什么都不做。
func greet() string {return "hello,go"}
假定这个函数的执行需要参数,则可以借助管道符号 |
实现参数传递 ( 在 *nix 系统中我们曾经常这么做 ) :
func main() {
// 将访问到的 {{.Name}} 值传给了 FuncMap["greet"] 函数。
const template_ = `
===========
{{.Name | greet}}
===========
`
safeParse := template.Must(
template.New("newTemplate").
Funcs(template.FuncMap{"greet": greet}).
Parse(template_))
data := struct{
Name string
}{"John"}
if err := safeParse.Execute(os.Stdout, data);err != nil {
log.Fatal(err.Error())
}
}
如果一个函数需要更多的参数,笔者的做法是将多个参数绑定到一个结构体再进行整体传递。
func main() {
const template_ = `
===========
{{.GreetArgs | greet}}
===========
`
safeParse := template.Must(
template.New("evokeFunction").Funcs(
template.FuncMap{"greet": greet},
).Parse(template_),
)
data := struct {
GreetArgs struct {
BoyName string
GirlName string
}
}{struct {
BoyName string
GirlName string
}{BoyName: "John", GirlName: "River"}}
if err := safeParse.Execute(os.Stdout, data); err != nil {
log.Fatal(err.Error())
}
}
func greet(args struct {
BoyName string
GirlName string
}) string {
return fmt.Sprintf("hello,%s and %s", args.BoyName, args.GirlName)
}
在这个例子中,如果将频繁使用的匿名结构体提取出来并定义为具名结构体,则代码的可读性会更高。
5.2 “错误拦截器” template.Must
由于 Parse(...)
和 Execute(...)
函数都有可能抛出异常,这将迫使程序员通过写嵌套的 if
语句来处理:
// parse,err := .... Parse(...)
if err == nil {
if err := parse.Execute(os.Stdout, nil);err != nil {
log.Fatal(err.Error())
}
}
其中,由于文本模板通常在编译阶段就被固定下来了,如果 Parse(...)
函数执行出错将是一个严重的程序 BUG 。Go 提供了一个帮助函数 template.Must(...)
,它负责检查执行 Parse(...)
的过程中是否会出现异常,如果是,则程序会结束。如果一切正常,则过滤掉 err
,只返回一个创建成功的模板。
const template_ = `
===========
{{greet}}
===========
`
safeParse := template.Must(
template.New("evokeFunction").Funcs(
template.FuncMap{"greet": greet},
).Parse(template_),
)
if err := safeParse.Execute(os.Stdout, nil);err != nil {
log.Fatal(err.Error())
}
5.3 模板内的循环体
如果希望模板遍历一个切片数据并渲染每一个元素,需要在文本模板内插入循环语句,这种感觉有点像在 .jsp
中写 EL 表达式。在这个例子中,文本模板渲染的是 []Employee
类型的结构体切片:
func main() {
const temp = `
Author: {{.Name}}
{{range .Employees}}
====================
name : {{.Name}}
age : {{.Age}}
====================
{{end}}
`
employees := []Employee{
{"John", 25},
{"River",23},
{"NASA",30},
}
data := struct{
Employees []Employee
Name string
}{employees,"Li"}
err := template.Must(template.New("aTest").Parse(temp)).
Execute(os.Stdout, data)
if err != nil {log.Fatal(err.Error())}
}
type Employee struct {
Name string
Age int
}
在文本模板内,通过 {{range ...}}
展开了一个循环体,它以 {{end}}
作为结尾。首先,range .Employees
表明了它遍历的是 data
数据内的 []Employee
切片,其次,该循环体内部的 {{.Name}}
指代的将是 "切片内每一个 Employee
数据的 Name
成员",而非单指 data
数据内的 .Name
成员。
因此,运行此程序,控制台打印的结果将是:
Author: Li
====================
name : John
age : 25
====================
====================
name : River
age : 23
====================
====================
name : NASA
age : 30
====================