Go 结构体(其一)

一、定义结构体

1.1 定义结构体

在 Go 语言中,结构体的定义是构建复合数据结构的基础。当我们使用 Go 进行编程时,常常会使用结构体类型来组织和表示数据。如下所示,定义中包含了结构体的字段名和对应的数据类型:

go 复制代码
type StructName struct {
    FieldName1 FieldType1
    FieldName2 FieldType2
    // ...
}

定义中使用到的struct关键字,于 Go 语言中有着至关重要的作用,主要是用来定义一个抽象的数据结构,组合不同的数据项^[1-2]^。

举一个例子,假设我们要设计一个处理学生个人信息的程序,其中可能会要求我们首先分析学生拥有哪些个人信息,比如姓名、年龄、性别、班级等等。在这种情况下,使用struct关键字可以帮助我们将学生的这些信息组织在一起,形成一个复合的数据结构:

go 复制代码
struct {
    Name  string
    Sex   string
    Age   int
    Class int
    // ...
}

然而在定义结构体类型的过程中,除了struct关键字,我们还会使用type关键字。通过type关键字,我们可以将已有的基本数据类型或结构体定义为一个全新的类型,例如:

go 复制代码
// 将MyInt定义为int类型的别名
type MyInt int

通过上述定义,MyInt 就成为了一个新的类型,具有基本类型int的所有特性^[3]^。

同样,当我们使用type关键字定义结构体类型时,我们其实是为结构体创建了一个具有结构体特性的新类型。如下,我们定义了一个名为 Student 的结构体类型。通过该定义,我们创造了一个自定义的新的结构体类型Student,其中包含了姓名、性别、年龄和班级等字段。

go 复制代码
type Student struct {
    Name  string
    Sex   string
    Age   int
    Class int
    // ...
}

虽然结构体的定义过程很简单,但我们也需要注意一些规则:

  • 结构体类型的名称在同一包下不能重复,确保在一个包内结构体类型的唯一性;
  • 同一个结构体中,字段名必须唯一,即使字段类型不同也不例外;
  • 每个字段都必须有类型,这是结构体定义的基本要求。

此外,在定义结构体类型时,我们还需要注意:不能在结构体的字段中包含该结构体类型的字段,但可以包含该结构体的指针类型^[4]^。

go 复制代码
// 正确示例
type Student struct {
    // 允许字段没有名称,只有类型,即匿名字段
    string 
    Sex   string
    Age   int
    Class int
​
    // 字段类型不能是该结构体的类型(不能是 Student 类型)
    // 但可以是该结构体的指针类型
    next *Student
}

至于为什么不能在结构体的字段中包含该结构体的类型的字段,这其实是为了避免类型定义无限递归,从而无法确定结构体的大小。在 Go 语言中,编译器需要知道每个定义的类型的确切大小,以便在内存分配时分配合适的空间。以下定义一个错误的结构体类型:

go 复制代码
// 无法编译通过的例子
type MyStruct struct {
    Field1 int
    Field2 MyStruct // 不能直接包含自身的类型
}

如果允许结构体包含自身类型的字段,那么MyStruct将变得无限大。要确定结构体的大小,就需要先知道其每个字段的大小。但是在该结构体中每一个MyStruct类型的字段里都会有一个MyStruct类型的字段,以此类推,以至于无法知道MyStruct类型字段的具体大小,导致类型无限递归,无法确定结构体大小,即结构体可能会无限大。因此定义结构体时,不允许在结构体中有其自身类型的字段。

但如果是结构体类型的指针就不会造成类型无限递归的情况。因为指针的大小是固定的,其大小不会随着指向的实际数据大小而变化,且指针本身只是一个内存地址,不包含实际数据。

go 复制代码
type MyStruct struct {
    Field1 int
    Field2 *MyStruct // 结构体包含指向自身类型的指针
}

在这个例子中,Field2是一个指向MyStruct类型的指针。Go 的指针在32位系统上通常是4字节,在64位系统上通常是8字节。无论MyStruct有多大,指向它的指针的大小是固定的。当定义MyStruct时,编译器知道一个MyStruct实例的大小是Field1的大小加上指针的大小。这种方式允许在结构体中包含对自身类型的指针,而不会导致结构体的大小无限增加,从而避免了无限递归的问题。

本段参考:

[1] Go关键字--struct_golang struct有关键字-CSDN博客

[2] Go进阶-结构体struct - 知乎 (zhihu.com)

[3] 结构体 · Go语言中文文档 (topgoer.com)

[4] 结构体.Go语言圣经

1.2 结构体字段访问

当我们使用结构体时,通常需要访问被封装在结构体内的数据字段。为了这一目的,可以使用.操作符,即选择器(selector),来访问结构体的各个字段^[1]^。具体的访问方式如下:

go 复制代码
var StructVariable Student
fmt.Println(StructVariable.Name)
fmt.Println(StructVariable.Sex)
fmt.Println(StructVariable.Age)
fmt.Println(StructVariable.Class)

这里,StructVariable 是一个 Student 类型的结构体变量,通过.操作符,我们能够轻松地访问结构体中的每个字段。

在访问字段时,结构体字段的命名规范也对访问产生了影响。如果一个结构体字段名称的首字母是大写的,它就是导出的,即可在包外访问而如果首字母是小写的,它就是未导出的,只能在包内部进行访问。

除了直接的字段访问方式,Go语言还提供了反射机制,允许在运行时动态地检查和操作结构体的成员。然而这里不进行深入介绍。

本段参考:

[1] Golang 入门 : 结构体(struct)

1.3 结构体实例化

在小节 1.1 中,我们自定义了结构体类型Student。此时,我们仅仅是定义了这个新类型的数据形式,还没有为它分配具体的内存空间。

和其他基本数据类型一样,结构体类型在使用前需要先进行声明。声明一个结构体类型的变量,我们可以采用两种常见的方式,与声明int类型的变量相似,可以使用var关键字进行声明:

go 复制代码
var StructVariable Student
StructVariable = Student{} // 分配内存空间

其次,我们也可以使用短声明的方式,通过:=操作符直接创建并初始化结构体变量:

go 复制代码
// 短声明
StructVariable := Student{}

以上两种方式都在声明结构体变量的同时为其分配了内存空间,使其可以被使用。值得注意的是,这里提到的分配内存空间并不涉及手动的内存管理,Go 语言会在运行时自动管理这些细节,使得开发者能够更专注于业务逻辑的实现而不用过多关心底层。

此外实例化的过程中,我们为结构体类型分配内存空间后,结构体变量(例如 StructVariable )中的各个字段值将默认为相应字段类型的零值。我们通过打印数据来展示结构体变量的默认值,这种方式可以帮助我们直观地了解新创建的结构体实例中各个字段的初始状态:

go 复制代码
func main() {
  StructVariable := Student{}
  fmt.Printf("StructVariable=%#v\n", StructVariable) 
  // StructVariable=main.Student{Name:"", Sex:"", Age:0, Class:0}
}

1.4 结构体初始化

小节 1.3 已经让我们了解了结构体的声明和实例化过程,以及实例化后结构体变量中字段的默认值。接下来,我们将探讨结构体的初始化。常见的结构体初始化方式有三种:键值对赋值、字面值赋值以及访问字段赋值。这三种赋值方法各有其灵活性,实际开发中可以根据需求选择适合的方式。

  1. 键值对赋值: 通过键值对方式初始化结构体变量;
go 复制代码
var StructVariable Student
// 初始化
StructVariable = Student{
    Name:  "Ayanokoji",
    Sex:   "man",
    Age:   18,
    Class: 1,
}
  1. 字面值赋值: 以结构体字段的顺序提供字面值;
go 复制代码
var StructVariable Student
// 初始化
StructVariable = Student{
    "Ayanokoji",
    "man",
    18,
    1,
}

在字面值赋值时,需要注意赋值的顺序必须和结构体字段的顺序一致,且类型必须相同。此外,字面值赋值时必须给所有字段赋值,不能只给某个字段或某几个字段赋值,否则将导致语法错误。

  1. 访问字段赋值: 通过直接访问结构体字段进行赋值。
go 复制代码
var StructVariable Student
// 初始化
StructVariable.Name = "Ayanokoji"
StructVariable.Sex = "man"
StructVariable.Age = 18
StructVariable.Class = 1

1.5 标签Tag

标签(tag)是 Go 语言中一种附加在结构体字段上的元信息,用于提供额外的注释和属性。通常,标签是一串可选的字符串,被反引号包裹,并跟在结构体字段类型的后面。

在以下示例中,我们定义了一个名为Student的结构体,其中每个字段后面都附加了 json 和部分字段附加了 yaml 的标签:

go 复制代码
type Student struct {
    Name  string `json:"name"`
    Sex   string `json:"sex" yaml:"age"`
    Age   int    `json:"age"`
    Class int    `json:"class" yaml:"class"`
}

这些标签可以在反射机制中被获取,以执行各种用途,如序列化、反序列化,或者与其他系统进行交互。需要注意的是,标签的内容并不会影响字段的实际值,它只是提供了用于解释字段的元数据。

拿序列化举例,在序列化的过程中,标签的作用显得尤为重要,因为它可以指导序列化工具按照特定的格式输出字段。考虑使用 json 标签进行序列化的情况,我们可以通过以下代码创建一个Student结构体实例,然后使用json.Marshal进行序列化:

go 复制代码
func main() {
    var StructVariable Student
    // 初始化
    StructVariable = Student{
        Name:  "Ayanokoji",
        Sex:   "man",
        Age:   18,
        Class: 1,
    }

    bytes, _ := json.Marshal(&StructVariable)
    fmt.Println(string(bytes))
    // output:{"name":"Ayanokoji","sex":"man","age":18,"class":1}
}

假如我们更改结构体字段的 json 标签,例如:

go 复制代码
type Student struct {
    Name  string `json:"n"`
    Sex   string `json:"sex" yaml:"age"`
    Age   int    `json:"a"`
    Class int    `json:"class" yaml:"class"`
}

那么,进行 json 序列化的结果就会相应地变化, "output:{"n":"Ayanokoji","sex":"man","a":18,"class":1}"。

1.6 匿名字段

Go 语言中,我们不仅可以按照传统的方式在结构体中为每个字段提供明确的名称,还可以使用匿名字段的方式,使结构体更加灵活。在结构体中只提供字段的数据类型而不指定字段名称,这样的字段被称为匿名字段^[1]</^sup>。^^

在以下示例中,我们定义了一个名为Student的结构体,其中的第一个字段是一个匿名字段,类型为string

go 复制代码
type Student struct {
    // 匿名字段,只提供类型
    string 
    Sex   string
    Age   int
    Class int
}

这样的定义使得Student结构体拥有了string类型的匿名字段。实际上,匿名字段也有自己的默认字段名,即它自身的类型名称,这使得我们可以像访问普通字段一样访问匿名字段^[2]^。

在定义结构体时我们说到,结构体的字段名不能冲突,不能有相同的字段名。同样,如果在结构体中有两个类型相同的匿名字段,意味着有两个字段名相同的字段,这在结构体的定义中是不允许的。即在同一个结构体中也不能有相同类型的匿名字段^[2]^。

即然匿名字段可以像普通字段那样访问,那么我们是不是在包外也可以访问结构体的匿名字段呢?答案是肯定的,但是需要根据匿名字段的类型名称判断。能够包外访问结构体字段前提是字段名首字母大写,若为首字母为小写则无法访问。同理,匿名字段字段名默认为其类型名称,如果类型名称首字母是大写字母,包外就可以访问,反之则不行。

go 复制代码
type Student struct {
    // 包外不能访问 Student.string
    string 
    Sex		string
    Age		int
    Class	int
}

type Class struct {
  ID uint64
  Name string
  
  // 此处为结构体的内嵌
  // 包外可以访问 Class.Student
  Student
}

本段参考:

[1] Golang 匿名字段

[2] 结构体.Go语言圣经

1.7 匿名结构体

匿名结构体是 Go 语言中一种特殊类型的结构体,其声明和实例化时都无需提供结构体名称。与普通结构体不同,匿名结构体通常被用于一些临时的、只在代码中使用一到两次的场景^[1]^。

在下面的例子中,我们展示了如何声明一个匿名结构体。注意,匿名结构体的声明不需要使用关键字 type,而是直接在代码中定义结构体的字段和类型。

go 复制代码
func main() {
  // 没有通过 type 定义类型,而是直接在代码中定义并实例化
  var s struct {
    Name  string
    Sex   string
    Age   int
    Class int
  }
}

其实匿名结构体的声明和实例化可以在一行内完成,这为临时数据的创建提供了一种简洁的方式。下面,我们不仅声明了匿名结构体,还在同一行内进行了初始化。

go 复制代码
func main() {
  var StructVariable = struct {
    Name  string
    Sex   string
    Age   int
    Class int
  }{
    Name:  "Ayanokoji",
    Sex:   "man",
    Age:   18,
    Class: 1,
  }
}

匿名结构体的设计初衷是用于短暂的、一次性的情境,因此并不鼓励对其进行过多的初始化设置。在实际应用中,根据需要适当使用。

本段参考:

[1] golang中使用匿名结构体

二、结构体与方法

当我们谈到 Go 语言的结构体和方法 时,我们实际上是在讨论面向对象编程的一些概念。方法一般是面向对象编程的一个特性,在 C++ 语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的^[1]^。但 Go 语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。

方法与普通函数的区别在于,方法有一个额外的参数,即接收者(类似于其他编程语言中的this或者self),它决定了这个方法属于哪个类型。形式如下:

go 复制代码
func (receiverType) methodName(parameters) returnType {}
  • methodName 是方法的名字
  • parameters 是方法的参数列表
  • returnType 是方法的返回类型
  • receiverType 是方法的接收者类型

方法的接收者可以是任何类型,甚至是基本类型,但通常是一个自定义类型。 Go 语言中除了自定义的指针类型和接口类型外,其他自定义类型都可以作为方法的接收者。比如我们为Student结构体定义一个方法Introducerecv Student是方法的接收者,表示这个方法Introduce可以在Student结构体类型的实例上调用。

go 复制代码
type Student struct {
    Name 	string
    Sex		string
    Age		int
    Class	int
}

func (recv Student) Introduce() string {
  return fmt.Sprintf("I'm a Class %d student. My name is %s",recv.Class,recv.Name)
}

创建一个名为 studentStudent结构体实例,然后调用了该结构体方法Introduce。这个方法会使用结构体实例的数据来打印自我介绍语句。

go 复制代码
student := Student{
  Name:	"Ayanokoji",
  Sex:	"man",
  Age:	18,
  Class:	1,
}
introduction := student.Introduce()
fmt.Println(introduction)
// Output:I'm a Class 1 student. My name is Ayanokoji

结构体类型Student的方法Introduce内部通过字段访问的方式获取到接收者recv Student成员字段的值,并将其与其他字符拼接返回,并最终打印出"I'm a Class 1 student. My name is Ayanokoji"。从这例子中我们可以发现,Go 语言方法的接收者就好似面向对象语言中的类,方法就好似类的成员函数可以随意访问这个类中的成员变量存储的数据,即 Go 语言中方法可以随意访问与其绑定在一起的接收者的数据。

即然方法能够随意访问接收者的数据,它当然也能能修改接收者的数据。方法内部还是通过访问字段的方式修改接收者数据,或者为其赋值。下面我们为Student结构体类型新增一个方法ChangeClass,用于更改学生班级信息。

go 复制代码
func (recv Student) ChangeClass(class int) {
  recv.Class = class
}

而后重新创建Student结构体的实例,然后调用方法ChangeClassIntroduce,打印自我介绍语句。

go 复制代码
student := Student{
  Name:	"Ayanokoji",
  Sex:	"man",
  Age:	18,
  Class:	1,
}

// 调用 ChangeClass, 更换班级
student.ChangeClass(2)
introduction := student.Introduce()
fmt.Println(introduction)

那么此时的输出会是"I'm a Class 2 student. My name is Ayanokoji"吗?答案是否定的。

在 Go 语言函数中,无论是传入的参数还是返回值,数值之间的传递都是通过拷贝的方式将数据值复制给目标变量的(Go 的值传递和引用传递其实都是传递的拷贝值)。前面提过,Go 的方法是一种特殊的函数,它与函数不同的是,它会关联一个类型,这个类型其实是方法的特殊参数,名为接收者。所以方法内部访问到的接收者(也是参数)的数据实际上也是原本数据的拷贝值,我们刚才在方法ChangeClass内更改的只是接收者Student数据的备份,而不是其实例 student 原本的值。

接下来,我们修改方法ChangeClass,让它打印出接收者的地址。同时,在主函数中也打印出实例的地址。

go 复制代码
func (recv Student) ChangeClass(class int) {
  recv.Class = class
  
  fmt.Println("以下为接收者的存储地址")
  fmt.Printf("receiver copy address: %p\n", &recv)
  fmt.Printf("receiver field 'Name' copy address: %p\n", &recv.Name)
}

// 以下为调用
student := Student{
  Name:  "Ayanokoji",
  Sex:   "man",
  Age:   18,
  Class: 1,
}

 fmt.Println("以下为实例的存储地址")
 fmt.Printf("instance real address: %p\n", &student)
 fmt.Printf("instance field 'Name' real address: %p\n", &student.Name)
 fmt.Println()
 student.ChangeClass(2)

// output
// 以下为实例的存储地址
// instance real address: 0x14000112180
// instance field 'Name' real address: 0x14000112180

// 以下为接收者的存储地址
// receiver copy address: 0x140001121b0
// receiver field 'Name' copy address: 0x140001121b0

比对上述代码输出结果明显发现实例 student 的实际存储地址和方法ChangeClass内接收者的实际存储地址不一致。我们在方法ChangeClass中更改的Class值是存储在地址0x140001121b0上的地址0x14000112180内容的拷贝值。而这个地址0x140001121b0是在方法ChangeClass被调用后连同方法的函数栈一起分配的地址,因此当方法执行结束,函数栈返回,内存被回收后,该地址也就连同函数栈一起被回收了。实际上,我们修改在地址0x140001121b0Class的值在方法调用结束后就没了,而原地址0x14000112180上的值还是原来的值。因此,最初例子中输出的结果仍然是"I'm a Class 1 student. My name is Ayanokoji"。

想要真正更改实例 student 的值就需要获取到原本数据的地址,也就是0x14000112180,于是我们就会想到指针。那么只要方法的接收者是指针类型不就可以知道实例数据的地址了吗?这当然是错的。还记得吗, Go 中方法的接收者不能是指针类型。虽然接收者不能是指针类型,但它可以是任何其他允许类型的指针^[2]^。

go 复制代码
// 不允许
// 接收者不能是指针类型
type Stu *Student

func (recv Stu) ChangeClass(class int) {
  recv.Class = class
}

// 允许
// 接收者可以是允许类型的指针
type Student struct {
    Name 	string
    Sex		string
    Age		int
    Class	int
}

func (recv *Student) ChangeClass(class int) {
  recv.Class = class
}

// 以下为实例的存储地址
// instance real address: 0x14000112180
// instance field 'Name' real address: 0x14000112180

// 以下为接收者的存储地址
// receiver copy address: 0x14000126020
// receiver field 'Name' copy address: 0x14000112180
// receiver value: 0x14000112180

当把方法的接收者改为类型的指针后,原先接收者的字段Name的地址0x140001121b0变成了0x14000112180,也就是实例 student 的地址。除此以外,接收者的地址从0x140001121b0变成了新的地址0x14000126020,在这个地址中存储的数据不再是实例 student 的拷贝值,而是它的地址0x14000112180的拷贝值。此时方法ChangeClass可以通过接收者存储的值获取到实例真正存储的地址,从而修改其字段值,并保留修改。

输出结果为"I'm a Class 2 student. My name is Ayanokoji"。

本段参考:

[1] 函数、方法和接口.Go高级编程

[2] 方法.Go 入门指南

相关推荐
lili-felicity30 分钟前
指针与数组:深入C语言的内存操作艺术
c语言·开发语言·数据结构·算法·青少年编程·c#
Zer0_on1 小时前
数据结构二叉树
开发语言·数据结构
码农老起1 小时前
插入排序解析:时间复杂度、空间复杂度与优化策略
数据结构·算法·排序算法
DARLING Zero two♡1 小时前
【优选算法】Sliding-Chakra:滑动窗口的算法流(上)
java·开发语言·数据结构·c++·算法
清风~徐~来1 小时前
【高阶数据结构】红黑树模拟实现map、set
数据结构
逊嘘2 小时前
【Java数据结构】链表相关的算法
java·数据结构·链表
爱编程的小新☆2 小时前
不良人系列-复兴数据结构(二叉树)
java·数据结构·学习·二叉树
冠位观测者9 小时前
【Leetcode 热题 100】208. 实现 Trie (前缀树)
数据结构·算法·leetcode
kittygilr12 小时前
matlab中的cell
开发语言·数据结构·matlab
花心蝴蝶.12 小时前
Map接口 及其 实现类(HashMap, TreeMap)
java·数据结构