go语言结构体详解

go语言结构体定义

go语言可以通过自定义的方式形成新的类型,结构体就是这些类型中的一种复合类型,结构体是由一个或多个任意类型的值聚合成的实体,每个值都可以称为结构体的成员。
结构体的成员也可以称为字段,每个字段有如下属性:

  • 字段名必须唯一
  • 字段拥有自己的类型和值
  • 字段的类型也可以是结构体,甚至是字段所在结构体的类型

使用关键字 type 可以将各种基本类型定义为自定义类型,基本类型包括整型、字符串、布尔等。结构体是一种复合的基本类型,通过 type 定义为自定义类型后,使结构体更便于使用。
结构体的定义格式如下:

type 类型名 struct {
    字段1 字段1类型
    字段2 字段2类型
    …
}

对各个部分的说明:

  • 类型名:标识自定义结构体的名称,在同一个包内不能重复。
  • struct{}:表示结构体类型,type 类型名 struct{}可以理解为将 struct{} 结构体定义为类型名的类型。
  • 字段1、字段2……:表示结构体字段名,结构体中的字段名必须唯一。
  • 字段1类型、字段2类型……:表示结构体各个字段的类型。

使用结构体可以表示一个包含 X 和 Y 整型分量的点结构,代码如下:

type Point struct {
	X int
	Y int
}

同类型的变量也可以写在一行,颜色的红、绿、蓝 3 个分量可以使用 byte 类型表示,定义的颜色结构体如下:
type Color struct {
R, G, B byte
}
结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才会真正地分配内存,我们将在下一节《实例化结构体》中详细为大家介绍。

go语言实例化结构体

结构体的定义只是一种内存布局的描述,只有当结构体实例化时,才能够真正的分配内存。因此必须在定义结构体并实例化后才能够使用结构体字段。
实例化就是根据结构体定义的格式创建一份与格式一致的内存区域,结构体实例与实例之间的内存区域是完全能独立的。
Go语言可以通过多种方式实例化结构体,根据实际需要可以选用不同的写法。

基本的实例化形式

结构体本身是一种类型,可以像整型、字符串等类型一样,以var的方式声明结构体即可完成实例化。
基本实例化格式如下:var ins T
其中,T 为结构体类型,ins 为结构体的实例。

用结构体表示的点结构(Point)的实例化过程请参见下面的代码:

type Point struct {
	X, Y int
}
func main() {
	var p Point
	p.X = 10
	p.Y = 20
}

在例子中,使用.来访问结构体的成员变量,如p.X和p.Y等,结构体成员变量的赋值方法与普通变量一致。

创建指针类型的结构体

Go语言中,还可以使用 new 关键字对类型(包括结构体、整型、浮点数、字符串等)进行实例化,结构体在实例化后会形成指针类型的结构体。

使用 new 的格式如下:ins := new(T)
其中:

T 为类型,可以是结构体、整型、字符串等。
ins:T 类型被实例化后保存到 ins 变量中,ins 的类型为 *T,属于指针。
Go语言让我们可以像访问普通结构体一样使用.来访问结构体指针的成员。

下面的例子定义了一个玩家(Player)的结构,玩家拥有名字、生命值和魔法值,实例化玩家(Player)结构体后,可对成员进行赋值,代码如下:

type Player struct {
	Name string
	HealthPoint int
	MagicPoint int
}
func main() {
	p := new(Player)
	p.Name, p.HealthPoint, p.MagicPoint = "张三", 80, 99
	fmt.Println(p)
}

经过 new 实例化的结构体实例在成员赋值上与基本实例化的写法一致。

go语言和c/c++

在 C/C++ 语言中,使用 new 实例化类型后,访问其成员变量时必须使用->操作符。

在Go语言中,访问结构体指针的成员变量时可以继续使用.,这是因为Go语言为了方便开发者访问结构体指针的成员变量,使用了语法糖(Syntactic sugar)技术,将 ins.Name 形式转换为 (*ins).Name。

去结构体的地址实例化

在Go语言中,对结构体进行&取地址操作时,视为对该类型进行一次 new 的实例化操作,取地址格式如下:
ins := &T{}
其中:

  • T 表示结构体类型。
  • ins 为结构体的实例,类型为 *T,是指针类型。
    下面使用结构体定义一个命令行指令(Command),指令中包含名称、变量关联和注释等,对 Command 进行指针地址的实例化,并完成赋值过程,代码如下:
type Command struct {
	Name string
	Var *int
	Comment string
}
func main() {
	version := 1
	c := &Command{}
	c.Name = "version"
	c.Var = &version
	c.Comment = "show version"
}

取地址实例化是最广泛的一种结构体实例化方式,可以使用函数封装上面的初始化过程:

type Command struct {
	Name string
	Var *int
	Comment string
}
func NewCommand(version int) *Command {
	return &Command{
		Name: "version",
		Var: &version,
		Comment: "show version",
	}
}
func main() {
	c := NewCommand(2)
	fmt.Println(*c.Var)
}

初始化结构体的成员变量

初始化匿名结构体

匿名结构体没有类型名称,无序通过type关键字定义就可以直接使用。
匿名结构体的初始化写法由:结构体定义和键值对初始化两部分组成,结构体定义时没有结构体类型名称,只有字段和类型定义,键值对初始化部分由可选的多个键值对组成:

func main() {
	s := struct {
		Name string
		Age int
	}{
		"王五",
		18,
	}
	fmt.Println(s)
}
  1. 使用匿名结构体的例子
    在本示例中,使用匿名结构体的方式定义和初始化一个消息结构,这个消息结构具有消息标示部分(ID)和数据部分(data),打印消息内容的 printMsg() 函数在接收匿名结构体时需要在参数上重新定义匿名结构体,代码如下:
func main() {
	msg := &struct {
		id int
		name string
	}{
		15,
		"hahh",
	}
	printMsgType(msg)
}
// printMsgType 因为类型没有使用type定义,所以需要在每次用到的地方进行定义
func printMsgType(msg *struct{
	id int
	name string
}) {
	fmt.Printf("%T\n", msg)
}

匿名结构体的类型名是结构体包含字段成员的详细描述,匿名结构体在使用时需要重新定义,造成大量重复的代码,因此开发中较少使用。

go语言构造函数

多种方式创建和初始化结构体---模拟构造函数重载

如果使用结构体描述猫的特性,那么根据猫的颜色和名字可以有不同种类的猫,那么不同的颜色和名字就是结构体的字段,同时可以使用颜色和名字构造不同种类的猫的实例,这个过程可以参考下面的代码:

type Cat struct {
	Name string
	Color string
}
func NewCatByName(name string) *Cat {
	return &Cat{
		Name: name,
	}
}
func NewCatByColor(color string) *Cat {
	return &Cat{
		Color: color,
	}
}
func main() {
	c := NewCatByName("Tom")
	fmt.Println(c)
	c2 := NewCatByColor("blue")
	fmt.Println(c2)
}
带有父子关系的结构体的构造和初始化--模拟父级构造调用

黑猫是一种猫,猫是黑猫的一种泛称,同时描述这两种概念时,就是派生,黑猫派生自猫的种类,使用结构体描述猫和黑猫的关系时,将猫(Cat)的结构体嵌入到黑猫(BlackCat)中,表示黑猫拥有猫的特性,然后再使用两个不同的构造函数分别构造出黑猫和猫两个结构体实例,参考下面的代码:

type Cat struct {
	Name string
	Color string
}
type BlackCat struct {
	Cat
}
// NewCat 构造基类
func NewCat(name string) *Cat {
	return &Cat{
		Name: name,
	}
}
// NewBlackCat 构造子类
func NewBlackCat(color string) *BlackCat {
	return &BlackCat{
		Cat{Color: color},
	}
}

这个例子中,Cat 结构体类似于面向对象中的“基类”,BlackCat 嵌入 Cat 结构体,类似于面向对象中的“派生”,实例化时,BlackCat 中的 Cat 也会一并被实例化。
总之,go语言中没有提供构造函数相关的特殊机制,用户根据自己的需求,将参数使用函数传递到结构体构造参数中即可完成构造函数的任务。

类型内嵌和结构体内嵌

结构体可以包含一个或多个匿名(或内嵌)字段,即这些字段没有显式的名字,只有字段的类型是必须的,此时类型也就是字段的名字。匿名字段本身可以是一个结构体类型,即结构体可以包含内嵌结构体。

可以粗略地将这个和面向对象语言中的继承概念相比较,随后将会看到它被用来模拟类似继承的行为。Go语言中的继承是通过内嵌或组合来实现的,所以可以说,在Go语言中,相比较于继承,组合更受青睐。

考虑如下的程序:

type Inners struct {
	in1 int
	in2 int
}
type Outers struct {
	c int
	d float64
	int
	Inners
}
func main() {
	o := new(Outers)
	o.c, o.d, o.int, o.in1, o.in2 = 11, 3.14, 15, 16, 17
	fmt.Println(o)
	// 使用结构体字面量
	o2 := Outers{1, 2.2, 3, Inners{4, 5}}
	fmt.Println(o2, o2.in2)
}

通过类型outer.int的名字来获取存储在匿名字段中的数据,于是可以得出一个结论:在一个结构体中对于每一种数据类型只能出现一次匿名字段。

结构体内嵌特性

go语言的结构体内嵌有如下特性:

  1. 内嵌的结构体可以直接访问其成员变量
    嵌入结构体的成员,可以通过外部结构体的实例直接访问。如果结构体有多层嵌入结构体,结构体实例访问任意一级的嵌入结构体成员时都只用给出字段名,而无须像传统结构体字段一样,通过一层层的结构体字段访问到最终的字段。例如,ins.a.b.c的访问可以简化为ins.c。
  2. 内嵌结构体的字段名是他的类型名
    内嵌结构体字段仍然可以使用详细的字段进行一层层访问,内嵌结构体的字段名就是它的类型名,代码如下:

初始化内嵌结构体

结构体内嵌初始化时,将结构体内嵌的类型作为字段名像普通结构体一样进行初始化,详细实现过程请参考下面的代码。

车辆结构的组装和初始化:

type Wheel struct {
	Size int
}
type Engine struct {
	Power int    // 功率
	Type  string // 类型
}
type Car struct {
	Wheel
	Engine
}
func main() {
	c := Car{
		Wheel: Wheel{Size: 15},
		Engine: Engine{Power: 18, Type: "中国"},
	}
	fmt.Printf("%#v\n", c)
}
初始化内嵌匿名结构体

在前面描述车辆和引擎的例子中,有时考虑编写代码的便利性,会将结构体直接定义在嵌入的结构体中。也就是说,结构体的定义不会被外部引用到。在初始化这个被嵌入的结构体时,就需要再次声明结构才能赋予数据。具体请参考下面的代码。

type Car struct {
	Wheel struct{
		Size int
	}
	Engine struct{
		Power int    // 功率
		Type  string // 类型
	}
}
func main() {
	c := Car{
		Wheel: struct{ Size int }{Size: 15},
		Engine: struct {
			Power int
			Type  string
		}{Power: 18, Type: "中国"},
	}
	fmt.Println(c)
}

内嵌结构体成员名字冲突

嵌入结构体内部可能拥有相同的成员名,成员重名时会发生什么?下面通过例子来讲解。

type A struct {
	a int
}
type B struct {
	a int
}
type C struct {
	A
	B
}
func main() {
	c := C{}
	c.A.a = 1
	fmt.Println(c)
	
	//c.a = 2  // 编译直接报错:不明确的引用a
	//fmt.Println(c)
}

编译器告知 C 的选择器 a 引起歧义,也就是说,编译器无法决定将 1 赋给 C 中的 A 还是 B 里的字段 a。

在使用内嵌结构体时,Go语言的编译器会非常智能地提醒我们可能发生的歧义和错误。

Go语言垃圾回收和SetFinalizer

Go语言自带垃圾回收机制(GC)。GC 通过独立的进程执行,它会搜索不再使用的变量,并将其释放。需要注意的是,GC 在运行时会占用机器资源。

GC 是自动进行的,如果要手动进行 GC,可以使用 runtime.GC() 函数,显式的执行 GC。显式的进行 GC 只在某些特殊的情况下才有用,比如当内存资源不足时调用 runtime.GC() ,这样会立即释放一大片内存,但是会造成程序短时间的性能下降。

finalizer(终止器)是与对象关联的一个函数,通过 runtime.SetFinalizer 来设置,如果某个对象定义了 finalizer,当它被 GC 时候,这个 finalizer 就会被调用,以完成一些特定的任务,例如发信号或者写日志等。

在Go语言中 SetFinalizer 函数是这样定义的:
func SetFinalizer(x, f interface{})
参数说明如下:

  • 参数 x 必须是一个指向通过 new 申请的对象的指针,或者通过对复合字面值取址得到的指针。
  • 参数 f 必须是一个函数,它接受单个可以直接用 x 类型值赋值的参数,也可以有任意个被忽略的返回值。
    SetFinalizer 函数可以将 x 的终止器设置为 f,当垃圾收集器发现 x 不能再直接或间接访问时,它会清理 x 并调用 f(x)。

另外,x 的终止器会在 x 不能直接或间接访问后的任意时间被调用执行,不保证终止器会在程序退出前执行,因此一般终止器只用于在长期运行的程序中释放关联到某对象的非内存资源。例如,当一个程序丢弃一个 os.File 对象时没有调用其 Close 方法,该 os.File 对象可以使用终止器去关闭对应的操作系统文件描述符。

终止器会按依赖顺序执行:如果 A 指向 B,两者都有终止器,且 A 和 B 没有其它关联,那么只有 A 的终止器执行完成,并且 A 被释放后,B 的终止器才可以执行。

如果 *x 的大小为 0 字节,也不保证终止器会执行。

此外,我们也可以使用SetFinalizer(x, nil)来清理绑定到 x 上的终止器。
提示:终止器只有在对象被 GC 时,才会被执行。其他情况下,都不会被执行,即使程序正常结束或者发生错误。
【示例】在函数 entry() 中定义局部变量并设置 finalizer,当函数 entry() 执行完成后,在 main 函数中手动触发 GC,查看 finalizer 的执行情况。

type road int
func main() {
	entry()
	for i := 0; i < 10; i++ {
		time.Sleep(time.Second)
		runtime.GC()
	}
}
func entry() {
	var i road = 111
	t := &i
	runtime.SetFinalizer(t, finalizer)
}
func finalizer(r *road) {
	fmt.Println("road: ", *r)
}

运行结果:2022/08/30 14:58:36 road: 111

go语言链表操作

链表是一种物理存储单元上非连续、非顺序的存储结构体,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

使用链表结构可以避免在使用数组时需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。

链表允许插入和移除表上任意位置上的结点,但是不允许随机存取。链表有三种类型:单向链表、双向链表以及循环链表。

使用struct定义单链表

利用 Struct 可以包容多种数据类型的特性,使用它作为链表的结点是最合适不过了。一个结构体内可以包含若干成员,这些成员可以是基本类型、自定义类型、数组类型,也可以是指针类型。这里可以使用指针类型成员来存放下一个结点的地址。

【示例 1】使用 Struct 定义一个单向链表。

type Node struct {
	Val int
	Next *Node
}
  1. 为链表赋值,并遍历链表中的每个节点
func main() {
	n1 := Node{11, nil}
	n2 := Node{12, nil}
	n3 := Node{13, nil}
	n4 := Node{18, nil}
	n1.Next = &n2
	n2.Next = &n3
	n3.Next = &n4
	showNode(&n1)
}
func showNode(node *Node) {
	for node != nil {
		fmt.Println(node.Val)
		node = node.Next
	}
}
type Node struct {
	Val int
	Next *Node
}
插入节点

单链表的节点插入方法一般使用头插法或者尾插法

  1. 头插法
    每次插入在链表的头部插入结点,代码如下所示:
func main() {
	tail := new(Node)
	for i := 0; i < 10; i++ {
		node := Node{i,nil}
		node.Next = tail
		tail = &node
	}
	showNode(tail)
}
func showNode(node *Node) {
	for node.Next != nil {
		fmt.Println(node.Val)
		node = node.Next
	}
}
type Node struct {
	Val int
	Next *Node
}
  1. 尾插法
    每次插入结点在尾部,这也是我们较为习惯的方法。
func main() {
	head := new(Node)
	curr := head
	for i := 0; i < 10; i++ {
		node := &Node{i, nil}
		curr.Next = node
		curr = node
	}
	showNode(head.Next)
}
func showNode(node *Node) {
	for node != nil {
		fmt.Println(node.Val)
		node = node.Next
	}
}
type Node struct {
	Val int
	Next *Node
}

在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以速度较慢。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。

但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。

posted @ 2022-08-30 15:42  专职  阅读(1581)  评论(0编辑  收藏  举报