2.1 面向对象:结构体与继承
结构体 (Struct) 是 Go 语言中一种自定义的复合数据类型,它允许您将多个不同类型(甚至相同类型)的数据项组合成一个有意义的整体。它类似于面向对象语言中的“类”,但 Go 语言本身不是严格意义上的面向对象语言。
在之前学过的数据类型中,数组与切片,只能存储同一类型的变量。若要存储多个类型的变量,就需要用到结构体,它是将多个任意类型的变量组合在一起的聚合数据类型。
可以理解为Go语言的结构体和其他语言的class有相等的地位,但是Go语言放弃大量面向对象的特性。
一、结构体的定义与声明
结构体定义了存储在其中的字段(也称为成员变量)的集合。
1. 定义结构体类型
使用 type 和 struct 关键字来定义一个新的结构体类型。
type 结构体名称 struct {
字段名1 字段类型1
字段名2 字段类型2
// ... 更多字段
}示例: 定义一个表示“用户”信息的结构体。
type User struct {
ID int // 用户ID,整型
Name string // 用户名,字符串
Email string // 邮箱,字符串
Age int // 年龄,整型
IsActive bool // 是否活跃,布尔型
}2. 声明和初始化结构体变量
定义结构体后,您可以声明该结构体类型的变量并进行初始化。
(1) 声明一个结构体变量 (零值初始化)
var u1 User // 声明变量u1,Go会自动将其所有字段初始化为对应类型的零值
// int: 0, string: "", bool: false(2) 使用字面量完整初始化 (推荐)
按字段名初始化: 这种方式最推荐,因为它不依赖字段定义的顺序,代码可读性高。
u2 := User{
ID: 1001,
Name: "Alice",
Email: "alice@example.com",
Age: 30,
IsActive: true,
}按顺序初始化: 必须严格按照结构体中字段定义的顺序,并且不能省略任何字段。
// 必须严格按顺序:ID, Name, Email, Age, IsActive
u3 := User{1002, "Bob", "bob@example.com", 25, false}(3) 仅初始化部分字段
只初始化部分字段时,必须使用 字段名: 值 的方式。未初始化的字段将使用其类型的零值。
u4 := User{
Name: "Charlie",
Email: "charlie@example.com",
}
// u4.ID 为 0, u4.Age 为 0, u4.IsActive 为 false二、结构体字段的访问
结构体变量初始化后,使用 点号 (.) 来访问其内部的字段。
u := User{ID: 2001, Name: "David"}
// 访问字段的值
fmt.Println("用户ID:", u.ID) // 输出: 用户ID: 2001
fmt.Println("用户名:", u.Name) // 输出: 用户名: David
// 修改字段的值
u.Name = "David Chen"
u.Age = 40
fmt.Println("新用户名:", u.Name) // 输出: 新用户名: David Chen三、结构体方法
在 Go 语言中,您可以为任何自定义类型(包括结构体)定义方法。方法是绑定到特定类型的函数,它允许您像面向对象语言那样,让结构体拥有自己的行为和操作。
1. 方法的定义语法
方法和函数的定义非常相似,但它在 func 关键字和方法名之间多了一个接收者 (Receiver) 参数。
func (接收者变量 接收者类型) 方法名(参数列表) (返回值列表) {
// 方法体
}type User struct {
Name string
Age int
}
func (u User) HappyBirthday() {
u.Age++
fmt.Println(u.Name, u.Age)
}
func main() {
u := User{Name: "Alice", Age: 30}
u.HappyBirthday()
fmt.Println(u.Age) // 输出多少?为什么?
}关键点:
- 接收者变量: 类似面向对象语言中的
this或self,它代表调用该方法的结构体实例。 - 接收者类型: 必须是您在当前包中定义的类型(如一个结构体)。
2 值类型的接收者
接收者类型可以是结构体的值类型 (T) 或指针类型 (*T)。这两种类型决定了方法对结构体实例的影响。
如果接收者是结构体的值类型(T),那么在方法内部对接收者字段的修改,不会影响到原始的结构体实例。因为调用方法时,会传入结构体的一个副本。
type User struct {
Name string
Age int
}
// 值接收者:(u User)
// 接收到的是 User 实例的副本
func (u User) HappyBirthday() {
u.Age++ // 仅修改了副本的 Age
fmt.Printf("%s 内部:年龄改为 %d\n", u.Name, u.Age)
}
// 示例调用
func demoValueReceiver() {
u1 := User{Name: "Alice", Age: 30}
u1.HappyBirthday()
fmt.Printf("Alice 外部:年龄仍是 %d\n", u1.Age) // 仍是 30
}3. 指针类型的接收者
如果接收者是结构体的指针类型(*T),那么在方法内部对接收者字段的修改,会直接影响到原始的结构体实例。
这是修改结构体字段值的首选方式。
// 指针接收者:(u *User)
// 接收到的是 User 实例的内存地址
func (u *User) ChangeName(newName string) {
(&u).Name = newName // 直接修改了原始结构体实例的 Name ,或者使用 u.Name = newName 也是可以的,事实上更推荐使用 u.Name = newName 的方式,因为更简洁,更符合Go语言的惯用写法
fmt.Printf("内部:名字已改为 %s\n", u.Name)
}
// 示例调用
func demoPointerReceiver() {
u2 := User{Name: "Bob", Age: 25}
u2.ChangeName("Robert")
fmt.Printf("Bob 外部:名字已是 %s\n", u2.Name) // 已是 Robert
}💡 Go 的语法糖 (Syntactic Sugar):
无论是值变量还是指针变量,都可以使用
.来调用使用指针接收者的方法。Go 编译器会自动处理地址转换。
u2.ChangeName(...)(u2 是值) 会自动被转换为(&u2).ChangeName(...)p.ChangeName(...)(p 是指针) 保持不变
4. 为什么选择指针接收者?
在为结构体定义方法时,选择指针接收者通常有以下几个原因:
| 场景 | 原因 | 推荐接收者 |
|---|---|---|
| 需要修改接收者状态 | 指针接收者可以直接修改原始结构体实例的字段。 | 指针 |
| 结构体体积较大 | 传递指针只需要复制一个内存地址(通常 8 字节),避免了复制整个大型结构体的开销。 | 指针 |
| 保持一致性 | 如果结构体的一部分方法使用了指针接收者,那么所有相关方法通常都应使用指针接收者,以保持代码一致性。 | 指针 |
| 仅读取数据 | 结构体较小,且方法仅用于读取数据或执行计算,不需要修改状态。 | 值 |
约定俗成: 在实际开发中,除非结构体非常小且确定不需要修改其状态,否则大部分时间都使用指针接收者。
四、结构体指针
Go 语言中,结构体变量本身是值类型。当将一个结构体变量赋值给另一个变量或作为函数参数传递时,会进行值拷贝。
通常我们更常使用结构体指针。
1. 获取结构体指针
使用 取地址符 & 来获取结构体变量的内存地址,即结构体指针。
u := User{Name: "Eve"}
p := &u // p 是一个 *User 类型的指针,指向 u2. 通过指针访问字段 (自动解引用)
Go 语言允许直接使用 点号 (.) 通过结构体指针来访问其字段,Go 编译器会自动进行解引用操作。
p.Age = 28 // 等价于 (*p).Age = 28
fmt.Println(p.Name) // 等价于 fmt.Println((*p).Name)当你对象是结构体对象的指针时,你想要获取字段属性时,按照常规理解应该这么做
type User struct {
Name string
}
func main() {
p1 := &User{"sixue"}
fmt.Println((*p1).Name) // output: sixue
}但还有一个更简洁的做法,可以直接省去 * 取值的操作,选择器 . 会直接解引用,示例如下
type User struct {
Name string
}
func main() {
p1 := &User{"sixue"}
fmt.Println(p1.Name) // output: sixue
}3. 使用 new() 创建结构体指针
可以使用内置的 new() 函数为结构体分配内存,并返回一个指向该结构体实例的指针,其字段会被初始化为零值。
p2 := new(User) // p2 是 *User 类型,所有字段为零值
p2.Name = "Frank"
fmt.Println(p2.Name) // 输出: Frank4. 使用 & 创建结构体
可以使用内置的 new() 函数为结构体分配内存,并返回一个指向该结构体实例的指针,其字段会被初始化为零值。
var xm *User = &User{}
fmt.Println(xm)
xm.Name = "sixue" // 或者 (*xm).name = "sixue"
fmt.Println(xm)五、匿名结构体
匿名结构体是没有名字的结构体,通常只在临时使用或定义局部变量时使用。
// 声明并初始化一个匿名结构体变量
var p1 = struct {
Name string
Age int
}{
Name: "Grace",
Age: 22,
}
fmt.Println(p1.Name) // 输出: Gracevar user struct{Name string; Age int}
user.Name = "pprof.cn"
user.Age = 18
fmt.Printf("%#v\n", user)六、结构体嵌套 (匿名/具名)
结构体可以包含其他结构体作为字段,这称为嵌套。
1. 具名嵌套 (推荐)
将一个结构体作为另一个结构体的字段,访问时需要通过字段名。
type User struct {
Name string
Age int
}
type Address struct {
City string
PostalCode string
}
type Employee struct {
ID int
Info User // 具名嵌套:User类型
Location Address // 具名嵌套:Address类型
}
e := Employee{
ID: 101,
Info: User{Name: "Henry"},
Location: Address{City: "Beijing"},
}
// 访问嵌套字段
fmt.Println(e.Info.Name) // 输出: Henry
fmt.Println(e.Location.City) // 输出: Beijing2. 匿名嵌套 (也称为“继承”或“组合”)
在结构体中,只写字段类型而不写字段名,该字段就是匿名嵌套字段。
- 特性: 外部结构体可以直接访问内部结构体的字段。
type User struct {
Name string
Age int
}
type Student struct {
User // 匿名嵌套,字段名默认为类型名 User
Major string
}
s := Student{
User: User{Name: "Ivy", Age: 20},
Major: "Computer Science",
}
// 直接访问 User 的字段 (提升字段/Promoted Fields)
fmt.Println(s.Name) // 输出: Ivy
fmt.Println(s.Age) // 输出: 20
// 也可以通过具名方式访问
fmt.Println(s.User.Name) // 输出: Ivy七、结构体标签 (Struct Tag)
结构体标签是附加在结构体字段上的字符串元数据,它不影响程序的运行逻辑,但常用于:
- JSON/XML 序列化与反序列化 (如指定 JSON 字段名)。
- 数据库 ORM 映射 (如指定数据库列名)。
- 表单验证等。
标签格式为:反引号包围的字符串,内部是 key:"value" 键值对,多个键值对用空格分隔。
type Product struct {
ID int `json:"product_id" db:"id"` // 定义JSON和DB标签
Name string `json:"name,omitempty" db:"name"` // omitempty 表示若 Name 零值时,不输出到 JSON
Price float64 `json:"price"`
}详细内容请参考 面向对象:结构体里的 Tag 用法
八、构造函数
Go语言的结构体没有构造函数,我们可以自己实现。 例如,下方的代码就实现了一个person的构造函数。 因为struct是值类型,如果结构体比较复杂的话,值拷贝性能开销会比较大,所以该构造函数返回的是结构体指针类型。
package main
import "fmt"
// Person 结构体:字段均为包内私有(首字母小写)
type Person struct {
name string
age int8
}
func NewPerson(name string, age int8) (*Person) {
return &Person{
name: name,
age: age,
}
}
func (p Person) GetInfo() string {
// 通过方法,外部可以访问到私有字段 name 和 age
return fmt.Sprintf("姓名: %s, 年龄: %d", p.name, p.age)
}
func (p *Person) Birthday() {
p.age++ // 直接修改原始结构体实例的 age 字段
fmt.Printf("%s 过了生日,现在 %d 岁了。\n", p.name, p.age)
}
func main() {
// 1. 使用构造函数创建实例
p1:= NewPerson("王小明", 28)
fmt.Println(p1.GetInfo())
p1.Birthday()
fmt.Println(p1.GetInfo())
}通过这个完整的例子,我们可以看到结构体、构造函数和方法的协同工作:
- 构造函数 (
NewPerson) 负责创建实例、校验输入并隐藏字段的初始化细节。 - 值接收者方法 (
GetInfo) 负责安全读取结构体的内部状态(包括私有字段)。 - 指针接收者方法 (
Birthday) 负责安全修改结构体的内部状态。
这种模式是 Go 语言中实现数据封装和行为绑定的标准做法。
九、结构体的组合与“继承”
Go 语言通过 结构体嵌入(匿名嵌套) 实现代码复用和类似“继承”的行为,核心思想是 组合优于继承。
1. 实现“继承”
将一个结构体作为另一个结构体的 匿名字段,即可实现字段和方法的“提升”。
// 基础结构体 (Parent)
type Animal struct {
Name string
}
func (a Animal) Move() { /* ... */ }
// 组合结构体 (Child)
type Dog struct {
Animal // 匿名嵌入 Animal (即“继承”)
Breed string
}
func main() {
d := Dog{
Animal: Animal{Name: "旺财"},
Breed: "金毛",
}
// 访问提升的字段 (Animal.Name)
fmt.Println(d.Name)
// 调用提升的方法 (Animal.Move())
d.Move()
}2. 方法的覆盖(Override)
如果外部结构体(如 Dog)定义了与嵌入结构体(如 Animal)同名的方法,则外部结构体的方法会 覆盖 嵌入结构体的方法。
// Dog 自己的 Move 方法
func (d Dog) Move() {
fmt.Printf("%s 正在快速奔跑!\n", d.Name)
}
func main() {
// ...
d.Move() // 调用的是 Dog 的 Move() 方法 (覆盖了 Animal 的 Move())
}Go 提倡的是组合 (Composition) 优于继承 (Inheritance)。通过结构体嵌入,Go 实现了代码复用,同时避免了传统继承带来的复杂性和紧耦合问题。
十、结构体字段的可见性
Go语言中,结构体字段的可见性是通过字段名的首字母大小写来控制的。
- 如果字段名的首字母是大写,则该字段是可导出的,可以在包外访问。
- 如果字段名的首字母是小写,则该字段是不可导出的,不能在包外访问(仅在定义当前结构体的包中可访问)。