Go 学习笔记(十六)struct 和嵌套 struct

本文原创地址:博客园骏马金龙Go 基础系列:struct 和嵌套 struct

struct

struct 定义结构,结构由字段 (field) 组成,每个 field 都有所属数据类型,在一个 struct 中,每个字段名都必须唯一。

说白了就是拿来存储数据的,只不过可自定义化的程度很高,用法很灵活,Go 中不少功能依赖于结构,就这样一个角色。

Go 中不支持面向对象,面向对象中描述事物的类的重担由 struct 来挑。比如面向对象中的继承,可以使用组合 (composite) 来实现:struct 中嵌套一个 (或多个) 类型。面向对象中父类与子类、类与对象的关系是is a的关系,例如Horse is a Animal,Go 中的组合则是外部 struct 与内部 struct 的关系、struct 实例与 struct 的关系,它们是has a的关系。Go 中通过 struct 的 composite,可以 "模仿" 很多面向对象中的行为,它们很 "像"。

定义 struct

定义 struct 的格式如下:

type identifier struct {
	field1 type1
	field2 type2
	...
}
// 或者
type T struct { a, b int }

理论上,每个字段都是有具有唯一性的名字的,但如果确定某个字段不会被使用,可以将其名称定义为空标识符_来丢弃掉:

type T struct {
	_ string
	a int
}

每个字段都有类型,可以是任意类型,包括内置简单数据类型、其它自定义的 struct 类型、当前 struct 类型本身、接口、函数、channel 等等。

如果某几个字段类型相同,可以缩写在同一行:

type mytype struct {
	a,b int
	c string
}

构造 struct 实例

定义了 struct,就表示定义了一个数据结构,或者说数据类型,也或者说定义了一个类。总而言之,定义了 struct,就具备了成员属性,就可以作为一个抽象的模板,可以根据这个抽象模板生成具体的实例,也就是所谓的 "对象"。

例如:

type person struct{
	name string
	age int
}

// 初始化一个person实例
var p person

这里的 p 就是一个具体的 person 实例,它根据抽象的模板 person 构造而出,具有具体的属性 name 和 age 的值,虽然初始化时它的各个字段都是 0 值。换句话说,p 是一个具体的人。

struct 初始化时,会做默认的赋 0 初始化,会给它的每个字段根据它们的数据类型赋予对应的 0 值。例如 int 类型是数值 0,string 类型是 "",引用类型是 nil 等。

因为 p 已经是初始化 person 之后的实例了,它已经具备了实实在在存在的属性 (即字段),所以可以直接访问它的各个属性。这里通过访问属性的方式p.FIELD为各个字段进行赋值。

// 为person实例的属性赋值,定义具体的person
p.name = "longshuai"
p.age = 23

获取某个属性的值:

fmt.Println(p.name) // 输出"longshuai"

也可以直接赋值定义 struct 的属性来生成 struct 的实例,它会根据值推断出 p 的类型。

var p = person{name:"longshuai",age:23}

p := person{name:"longshuai",age:23}

// 不给定名称赋值,必须按字段顺序
p := person{"longshuai",23}

p := person{age:23}
p.name = "longshuai"

如果 struct 的属性分行赋值,则 不能省略每个字段后面的逗号 ",",否则就会报错。这为未来移除、添加属性都带来方便:

p := person{
	name:"longshuai",
	age:23,     // 这个逗号不能省略
}

除此之外,还可以使用 new() 函数或&TYPE{}的方式来构造 struct 实例,它会为 struct 分配内存,为各个字段做好默认的赋 0 初始化。它们是等价的,都返回数据对象的指针给变量,实际上&TYPE{}的底层会调用 new()。

p := new(person)
p := &person{}

// 生成对象后,为属性赋值
p.name = "longshuai"
p.age = 23

使用&TYPE{}的方式也可以初始化赋值,但 new() 不行:

p := &person{
	name:"longshuai",
	age:23,
}

选择 new() 还是选择&TYPE{}的方式构造实例?完全随意,它们是等价的。但如果想要初始化时就赋值,可以考虑使用&TYPE{}的方式。

struct 的值和指针

下面三种方式都可以构造 person struct 的实例 p:

p1 := person{}
p2 := &person{}
p3 := new(person)

但 p1 和 p2、p3 是不一样的,输出一下就知道了:

package main

import (
	"fmt"
)

type person struct {
	name string
	age  int
}

func main() {
	p1 := person{}
	p2 := &person{}
	p3 := new(person)
	fmt.Println(p1)
	fmt.Println(p2)
	fmt.Println(p3)
}

结果:

{ 0}
&{ 0}
&{ 0}

p1、p2、p3 都是 person struct 的实例,但 p2 和 p3 是完全等价的,它们都指向实例的指针,指针中保存的是实例的地址,所以指针再指向实例,p1 则是直接指向实例。这三个变量与 person struct 实例的指向关系如下:

 变量名      指针     数据对象(实例)
-------------------------------
p1(addr) -------------> { 0}
p2 -----> ptr(addr) --> { 0}
p3 -----> ptr(addr) --> { 0}

所以 p1 和 ptr(addr)保存的都是数据对象的地址,p2 和 p3 则保存 ptr(addr)的地址。通常,将指向指针的变量 (p2、p3) 直接称为指针,将直接指向数据对象的变量 (p1) 称为对象本身,因为指向数据对象的内容就是数据对象的地址,其中 ptr(addr)和 p1 保存的都是实例对象的地址。

尽管一个是数据对象值,一个是指针,它们都是数据对象的实例 。也就是说,p1.namep2.name都能访问对应实例的属性。

var p4 *person呢,它是什么?该语句表示 p4 是一个指针,它的指向对象是 person 类型的,但因为它是一个指针,它将初始化为 nil,即表示没有指向目标。但已经明确表示了,p4 所指向的是一个保存数据对象地址的指针。也就是说,目前为止,p4 的指向关系如下:

p4 -> ptr(nil)

既然 p4 是一个指针,那么可以将&person{}new(person)赋值给 p4。

var p4 *person
p4 = &person{
	name:"longshuai",
	age:23,
}
fmt.Println(p4) 

上面的代码将输出:

&{longshuai 23}

传值 or 传指针

Go 函数给参数传递值的时候是以复制的方式进行的。

复制传值时,如果函数的参数是一个 struct 对象,将直接复制整个数据结构的副本传递给函数,这有两个问题:

  • 函数内部无法修改传递给函数的原始数据结构,它修改的只是原始数据结构拷贝后的副本
  • 如果传递的原始数据结构很大,完整地复制出一个副本开销并不小

所以,如果条件允许,应当给需要 struct 实例作为参数的函数传 struct 的指针。例如:

func add(p *person){...}

既然要传指针,那 struct 的指针何来?自然是通过&符号来获取。分两种情况,创建成功和尚未创建的实例。

对于已经创建成功的 struct 实例p,如果这个实例是一个值而非指针 (即p->{person_fields}),那么可以&p来获取这个已存在的实例的指针,然后传递给函数,如add(&p)

对于尚未创建的 struct 实例,可以使用&person{}或者new(person)的方式直接生成实例的指针 p,虽然是指针,但 Go 能自动解析成实例对象。然后将这个指针 p 传递给函数即可。如:

p1 := new(person)
p2 := &person{}
add(p1)
add(p2)

struct field 的 tag 属性

在 struct 中,field 除了名称和数据类型,还可以有一个 tag 属性。tag 属性用于 "注释" 各个字段,除了 reflect 包,正常的程序中都无法使用这个 tag 属性。

type TagType struct { // tags
	field1 bool   "An important answer"
	field2 string "The name of the thing"
	field3 int    "How much there are"
}

匿名字段和 struct 嵌套

struct 中的字段可以不用给名称,这时称为匿名字段。 匿名字段的名称强制和类型相同 。例如:

type animal struct {
	name string
	age int
}
type Horse struct{
	int
	animal
	sound string
}

上面的 Horse 中有两个匿名字段intanimal,它的名称和类型都是 int 和 animal。等价于:

type Horse struct{
	int int
	animal animal
	sound string
}

显然,上面 Horse 中嵌套了其它的 struct(如 animal)。其中 animal 称为内部 struct,Horse 称为外部 struct。

以下是一个嵌套 struct 的简单示例:

package main

import (
	"fmt"
)

type inner struct {
	in1 int
	in2 int
}

type outer struct {
	ou1 int
	ou2 int
	int
	inner
}

func main() {
	o := new(outer)
	o.ou1 = 1
	o.ou2 = 2
	o.int = 3
	o.in1 = 4
	o.in2 = 5
	fmt.Println(o.ou1)  // 1
	fmt.Println(o.ou2)  // 2
	fmt.Println(o.int)  // 3
	fmt.Println(o.in1)  // 4
	fmt.Println(o.in2)  // 5
}

上面的o是 outer struct 的实例,但o除了具有自己的显式字段 ou1 和 ou2,还具备 int 字段和 inner 字段,它们都是嵌套字段。一被嵌套,内部 struct 的属性也将被外部 struct 获取,所以o.into.in1o.in2都属于o。也就是说,外部struct has a 内部struct,或者称为struct has a field

输出以下外部 struct 的内容就很清晰了:

fmt.Println(o)  // 结果:&{1 2 3 {4 5}}

上面的 outer 实例,也可以直接赋值构建:

o := outer{1,2,3,inner{4,5}}

在赋值 inner 中的 in1 和 in2 时不能少了inner{},否则会认为 in1、in2 是直接属于 outer,而非嵌套属于 outer。

显然,struct 的嵌套类似于面向对象的继承。只不过继承的关系模式是 "子类 is a 父类",例如 "轿车是一种汽车",而嵌套 struct 的关系模式是外部struct has a 内部struct,正如上面示例中outer拥有inner。而且,从上面的示例中可以看出,Go 是支持 "多重继承" 的。

嵌套 struct 的名称冲突问题

假如外部 struct 中的字段名和内部 struct 的字段名相同,会如何?

有以下两个名称冲突的规则:

  1. 外部 struct 覆盖内部 struct 的同名字段、同名方法
  2. 同级别的 struct 出现同名字段、方法将报错

第一个规则使得 Go struct 能够实现面向对象中的重写 (override),而且可以重写字段、重写方法。

第二个规则使得同名属性不会出现歧义。例如:

type A struct {
	a int
	b int
}

type B struct {
	b float32
	c string
	d string
}

type C struct {
	A
	B
	a string
	c string
}

var c C

按照规则 (1),直属于 C 的 a 和 c 会分别覆盖 A.a 和 B.c。可以直接使用 c.a、c.c 分别访问直属于 C 中的 a、c 字段,使用 c.d 或 c.B.d 都访问属于嵌套的 B.d 字段。如果想要访问内部 struct 中被覆盖的属性,可以 c.A.a 的方式访问。

按照规则 (2),A 和 B 在 C 中是同级别的嵌套结构,所以 A.b 和 B.b 是冲突的,将会报错,因为当调用 c.b 的时候不知道调用的是 c.A.b 还是 c.B.b。

递归 struct:嵌套自身

如果 struct 中嵌套的 struct 类型是自己的指针类型,可以用来生成特殊的数据结构:链表或二叉树 (双端链表)。

例如,定义一个单链表数据结构,每个 Node 都指向下一个 Node,最后一个 Node 指向空。

type Node struct {
	data string
	ri   *Node
}

以下是链表结构示意图:

 ------|----         ------|----         ------|-----
| data | ri |  -->  | data | ri |  -->  | data | nil |
 ------|----         ------|----         ------|----- 

如果给 嵌套两个自己的指针,每个结构都有一个左指针和一个右指针,分别指向它的左边节点和右边节点,就形成了二叉树或双端链表数据结构

二叉树的左右节点可以留空,可随时向其中加入某一边加入新节点 (像节点加入到树中)。添加节点时,节点与节点之间的关系是父子关系。添加完成后,节点与节点之间的关系是父子关系或兄弟关系。

双端链表有所不同,添加新节点时必须让某节点的左节点和另一个节点的右节点关联。例如目前已有的链表节点A <-> C,现在要将 B 节点加入到 A 和 C 的中间,即A<->B<->C,那么 A 的右节点必须设置为 B,B 的左节点必须设置为 A,B 的右节点必须设置为 C,C 的左节点必须设置为 B。也就是涉及了 4 次原子性操作,它们要么全设置成功,失败一个则链表被破坏。

例如,定义一个二叉树:

type Tree struct {
	le   *Tree
	data string
	ri   *Tree
}

最初生成二叉树时,root 节点没有任何指向。

// root节点:初始左右两端为空
root := new(Tree)
root.data = "root node"

随着节点增加,root 节点开始指向其它左节点、右节点,这些节点还可以继续指向其它节点。向二叉树中添加节点的时候,只需将新生成的节点赋值给它前一个节点的 le 或 ri 字段即可。例如:

// 生成两个新节点:初始为空
newLeft := new(Tree)
newLeft.data = "left node"
newRight := &Tree{nil, "Right node", nil}

// 添加到树中
root.le = newLeft
root.ri = newRight

// 再添加一个新节点到newLeft节点的右节点
anotherNode := &Tree{nil, "another Node", nil}
newLeft.ri = anotherNode

简单输出这个树中的节点:

fmt.Println(root)
fmt.Println(newLeft)
fmt.Println(newRight)

输出结果:

&{0xc042062400 root node 0xc042062420}
&{<nil> left node 0xc042062440}
&{<nil> Right node <nil>}

当然,使用二叉树的时候,必须为二叉树结构设置相关的方法,例如添加节点、设置数据、删除节点等等。

另外需要注意的是,一定不要将某个新节点的左、右同时设置为树中已存在的节点,因为这样会让树结构封闭起来,这会破坏了二叉树的结构。
上一篇 Go 学习笔记(十五)函数 (2)——回调函数和闭包
Go 学习笔记(目录)
下一篇 Go 学习笔记(十七)Go 中的方法