Files
notes/resource/go/Go 语言的结构体(Struct).md
T
2026-03-01 01:43:46 +08:00

15 KiB
Raw Blame History

1. 结构体定义与初始化

结构体通过 typestruct 关键字定义。初始化实例有多种灵活的方式。

1.1 定义语法

type StructName struct {
    FieldName1 FieldType1
    FieldName2 FieldType2
    // ...
}

1.2 初始化实例

以下是四种最常见的初始化结构体的方式,每种方式都有其适用的场景。

// [代码规范化]:这是一个完整的、可运行的示例,整合了所有初始化方式。
package main

import "fmt"

// Person 定义了一个代表“人”的结构体
type Person struct {
	name string
	age  int
}

func main() {
	fmt.Println("--- 结构体初始化示例 ---")

	// 方式1:先声明,后赋值 (零值初始化)
	// 声明一个 Person 类型的变量 p1,此时所有字段都是其类型的零值。
	// string 的零值是 ""int 的零值是 0。
	var p1 Person
	p1.name = "张三"
	p1.age = 25
	fmt.Printf("方式1 - 声明后赋值: %+v\n", p1) // %+v 可以打印字段名和值

	// 方式2:使用字面量并指定字段名 (推荐)
	// 这是最常用、最清晰、最不易出错的方式,因为字段顺序的改变不影响初始化。
	p2 := Person{
		name: "李四",
		age:  30,
	}
	fmt.Printf("方式2 - 字段名初始化: %+v\n", p2)

	// 方式3:使用字面量,按顺序提供字段值
	// [补充知识点] 常见陷阱:这种方式代码脆弱,如果结构体定义中字段顺序改变,
	// 此处的初始化代码必须同步修改,否则会导致编译错误或逻辑错误。
	p3 := Person{"王五", 35}
	fmt.Printf("方式3 - 顺序初始化: %+v\n", p3)

	// 方式4:使用 new 关键字创建结构体指针 (详见下一节)
	// new(Person) 会分配内存,并将所有字段初始化为零值,然后返回一个指向该内存的指针。
	p4 := new(Person)
	p4.name = "赵六"
	p4.age = 40
	fmt.Printf("方式4 - 使用 new 创建指针: %+v\n", *p4) // 注意需要解引用
}

2. 结构体指针 (Struct Pointers)

在 Go 中,直接传递结构体会产生一次完整的内存拷贝。对于大型结构体,这会影响性能。使用结构体指针可以避免拷贝,并且允许函数直接修改原始结构体实例。

// [代码规范化]:这是一个完整的、可运行的示例,清晰地对比了值传递和指针传递。
package main

import "fmt"

// Student 定义了一个学生结构体
type Student struct {
	name string
	age  int
}

// updateAgeByValue 接收一个结构体值(副本)
// 在函数内对 s 的任何修改都不会影响原始的 Student 变量。
func updateAgeByValue(s Student, newAge int) {
	s.age = newAge
	fmt.Printf("-> 在值传递函数内,年龄被修改为: %d\n", s.age)
}

// updateAgeByPointer 接收一个结构体指针
// 在函数内对 s 的修改会直接作用于原始的 Student 变量。
func updateAgeByPointer(s *Student, newAge int) {
	// Go 语言提供了语法糖,可以直接用 s.age 访问,
	// 无需写成 (*s).age。编译器会自动处理。
	s.age = newAge
	fmt.Printf("-> 在指针传递函数内,年龄被修改为: %d\n", s.age)
}

func main() {
	fmt.Println("--- 结构体指针与值传递对比 ---")

	// 场景1: 值传递
	s1 := Student{name: "小明", age: 18}
	fmt.Printf("原始 s1: %+v\n", s1)
	updateAgeByValue(s1, 20)
	fmt.Printf("调用值传递函数后,原始 s1: %+v (未改变)\n\n", s1)

	// 场景2: 指针传递
	s2 := &Student{name: "小红", age: 22} // 使用 & 获取结构体实例的指针
	fmt.Printf("原始 s2: %+v\n", *s2)
	updateAgeByPointer(s2, 25)
	fmt.Printf("调用指针传递函数后,原始 s2: %+v (已改变)\n", *s2)
}

3. 结构体方法 (Methods)

方法是附加到特定类型(接收者)上的函数。Go 使用方法为结构体添加行为。

3.1 值接收者 vs. 指针接收者

方法的接收者可以是值类型,也可以是指针类型,选择哪种类型是 Go 设计中的一个重要考量点。

特性 值接收者 (func (r MyType) Method()) 指针接收者 (func (r *MyType) Method())
操作对象 结构体的副本 结构体的原始实例
修改能力 不能修改原始结构体 可以修改原始结构体
性能开销 存在数据拷贝,对大结构体开销较大 无拷贝开销,仅传递指针(内存地址)
NIL 值 不能用于 nil 接收者 可以为 nil 接收者定义方法(需在方法内做 nil 检查)
调用 Go 编译器会自动进行值和指针的转换,调用方便 Go 编译器会自动进行值和指针的转换,调用方便

[补充知识点] 最佳实践:如何选择接收者类型?

  1. 首选指针接收者
    • 修改状态:当方法需要修改接收者的字段时,必须使用指针。
    • 性能:为避免大结构体的复制开销,即使不修改状态,也推荐使用指针。
    • 一致性:如果一个类型已经有了指针接收者的方法,那么其他方法也应保持一致,使用指针接收者,以避免混淆。
  2. 何时使用值接收者
    • 不可变性:当你想保证该方法不会修改接收者时。
    • 小结构体:对于非常小的、不包含指针或 slice 等引用类型的结构体(如 type Point struct{X, Y int}),值接收者的拷贝开销可以忽略不计。
    • 并发安全:创建副本可以天然地避免在并发环境下对共享数据的意外修改(但通常有更好的并发控制方法)。
package main

import "fmt"

// Rectangle 定义了矩形结构体
type Rectangle struct {
	width  float64
	height float64
}

// Area 是一个值接收者方法
// 它在 Rectangle 的副本上操作,因此是安全的,不会修改原始矩形。
func (r Rectangle) Area() float64 {
	return r.width * r.height
}

// Scale 是一个指针接收者方法
// 它需要修改矩形的尺寸,所以必须使用指针接收者。
func (r *Rectangle) Scale(factor float64) {
	r.width *= factor
	r.height *= factor
}

func main() {
	fmt.Println("--- 结构体方法示例 ---")

	rect := Rectangle{width: 10, height: 5}
	fmt.Printf("原始矩形: %+v, 面积: %.2f\n", rect, rect.Area())

	// 调用指针接收者方法。Go 自动将 rect 转换为 &rect。
	rect.Scale(2)
	fmt.Printf("缩放后矩形: %+v, 面积: %.2f\n", rect, rect.Area())

	// 指针类型也可以调用值接收者方法
	rectPtr := &Rectangle{width: 3, height: 4}
	// Go 自动将 rectPtr 解引用为 (*rectPtr) 来调用 Area()。
	fmt.Printf("指针矩形: %+v, 面积: %.2f\n", *rectPtr, rectPtr.Area())
}

4. 结构体嵌套与组合

Go 语言通过结构体嵌套(或称嵌入)来实现组合,这是 Go 推荐的代替传统面向对象中“继承”的设计模式。

4.1 显式嵌套

字段名和字段类型都明确给出。

type Address struct {
    City    string
    Country string
}

type Employee struct {
    Name    string
    HomeAddress Address // 显式嵌套
}

// 访问: emp.HomeAddress.City

4.2 匿名嵌套(嵌入)

只给出字段类型,字段名默认为类型名。这会触发“字段提升”,使嵌套结构体的字段可以直接通过外部结构体访问。

// [代码规范化]:这是一个完整的、可运行的示例,展示了显式和匿名嵌套。
package main

import "fmt"

// Address 结构体
type Address struct {
	City    string
	Country string
}

// Contact 结构体,将作为匿名嵌套字段
type Contact struct {
	Phone string
	Email string
}

// Employee 结构体,组合了 Address 和 Contact
type Employee struct {
	Name    string
	Age     int
	Addr    Address // 显式嵌套,有字段名 Addr
	Contact         // 匿名嵌套(嵌入),字段被提升
}

func main() {
	fmt.Println("--- 结构体嵌套与组合示例 ---")

	emp := Employee{
		Name: "王工程师",
		Age:  35,
		Addr: Address{
			City:    "深圳",
			Country: "中国",
		},
		Contact: Contact{
			Phone: "13800138000",
			Email: "wang@example.com",
		},
	}

	// 访问显式嵌套字段
	fmt.Printf("地址: %s, %s\n", emp.Addr.City, emp.Addr.Country)

	// 访问匿名嵌套(提升后)的字段
	// 可以直接访问,就像它们是 Employee 的字段一样
	fmt.Printf("联系方式: 电话 - %s, 邮箱 - %s\n", emp.Phone, emp.Email)

	// 也可以通过类型名访问原始的匿名嵌套结构体
	fmt.Printf("通过类型名访问邮箱: %s\n", emp.Contact.Email)
}

5. 匿名结构体 (Anonymous Structs)

匿名结构体是一种没有显式定义名称的结构体,非常适合用于那些只需要临时使用一次的数据结构,例如函数参数、返回值或测试数据。

// [代码规范化]:这是一个完整的、可运行的示例,整合了匿名结构体的多种应用场景。
package main

import "fmt"

// getUserInfo 使用匿名结构体作为返回值,封装多个相关值。
func getUserInfo() struct {
	ID   int
	Name string
} {
	return struct {
		ID   int
		Name string
	}{
		ID:   101,
		Name: "临时用户",
	}
}

func main() {
	fmt.Println("--- 匿名结构体应用场景 ---")

	// 场景1: 临时数据存储
	// 在函数内部定义一个临时的、一次性的数据结构。
	config := struct {
		Host    string
		Port    int
		Enabled bool
	}{
		Host:    "localhost",
		Port:    8080,
		Enabled: true,
	}
	fmt.Printf("临时配置: %+v\n\n", config)

	// 场景2: 匿名结构体切片,常用于表格驱动测试
	tests := []struct {
		input    int
		expected string
	}{
		{input: 1, expected: "奇数"},
		{input: 2, expected: "偶数"},
		{input: -1, expected: "奇数"},
	}

	fmt.Println("表格驱动测试数据:")
	for _, tc := range tests {
		// 模拟测试逻辑
		result := "偶数"
		if tc.input%2 != 0 {
			result = "奇数"
		}
		fmt.Printf("  输入: %d, 期望: %s, 结果: %s, 通过: %t\n",
			tc.input, tc.expected, result, result == tc.expected)
	}
	fmt.Println()

	// 场景3: 作为函数返回值
	user := getUserInfo()
	fmt.Printf("从函数获取的用户信息: ID=%d, Name=%s\n", user.ID, user.Name)
}

6. 结构体字段的可见性 (Visibility)

Go语言使用大小写规则来控制可见性(导出性),而非 publicprivate 关键字。

  • 大写开头:导出的(Public)。结构体名或字段名如果以大写字母开头,则可以被包外的代码访问。
  • 小写开头:未导出的(Private)。结构体名或字段名如果以小写字母开头,则只能在定义它的包内部访问。

这个规则同样适用于函数、方法、常量和变量。

// [代码规范化]:这是一个完整的、可运行的示例,用于解释可见性规则。
// 注意:真正的包外访问需要在不同的目录和文件中演示。
// 此处我们通过注释来解释这一概念。
package main

import "fmt"

// mypkg/models.go (模拟在一个名为 mypkg 的包中)
// ----------------------------------------------------

// User 是一个导出的结构体,因为它以大写字母'U'开头。
type User struct {
	// ID 是一个导出的字段,因为'I'是大写的。包外代码可以访问 user.ID。
	ID int
	// email 是一个未导出的字段,因为'e'是小写的。包外代码无法访问 user.email。
	email string
}

// NewUser 是一个导出的构造函数,用于创建 User 实例。
// 这是封装内部字段(如 email)的常用模式。
func NewUser(id int, email string) *User {
	// 在包内部,我们可以访问所有字段,包括小写的 email。
	return &User{
		ID:    id,
		email: email,
	}
}

// Email 方法是导出的,它提供了一种间接访问内部字段 email 的方式。
func (u *User) Email() string {
	return u.email
}

// main.go (模拟在另一个包中)
// ----------------------------------------------------
func main() {
	fmt.Println("--- 结构体可见性示例 ---")

	// 我们可以调用 NewUser,因为它被导出了。
	user := NewUser(1, "test@example.com")

	// 我们可以直接访问导出的字段 ID。
	fmt.Printf("用户ID (可访问): %d\n", user.ID)

	// 以下代码如果位于不同的包中,将会导致编译错误:
	// fmt.Println(user.email) // Error: user.email is not exported
	// 我们只能通过导出的方法来访问它。
	fmt.Printf("用户邮箱 (通过方法访问): %s\n", user.Email())
}

7. 结构体标签 (Struct Tags)

结构体标签是附加到结构体字段上的元数据字符串,在运行时可以通过反射读取。它们是 Go 语言实现与外部系统(如 JSON、XML、数据库 ORM)交互的关键机制。

标签的格式为:\`` key1:"value1" key2:"value2"``

// [代码规范化]:这是一个完整的、可运行的示例,展示了 JSON 标签的用法。
package main

import (
	"encoding/json"
	"fmt"
)

// Profile 定义了用户配置信息
type Profile struct {
	// `json:"username"`: 定义了此字段在 JSON 中的键名。
	Username string `json:"username"`

	// `json:"-"`: 表示在 JSON 序列化/反序列化时忽略此字段。
	Password string `json:"-"`

	// `json:"age,omitempty"`: omitempty 表示如果字段值为零值(int 的 0),则在序列化时省略该字段。
	Age int `json:"age,omitempty"`

	// `json:"email"`: 正常映射。
	Email string `json:"email"`
}

func main() {
	fmt.Println("--- 结构体标签与 JSON 序列化示例 ---")

	// 创建一个 Profile 实例
	p1 := Profile{
		Username: "gopher",
		Password: "secret-password", // 这个字段将被忽略
		Age:      0,                 // 这个字段因为 omitempty 和零值,将被省略
		Email:    "gopher@golang.org",
	}

	// 将结构体序列化为 JSON
	jsonData, err := json.MarshalIndent(p1, "", "  ") // MarshalIndent 用于格式化输出
	if err != nil {
		fmt.Println("JSON 序列化失败:", err)
		return
	}

	// 输出结果,注意 Password 和 Age 字段都没有出现
	fmt.Println("序列化后的 JSON:")
	fmt.Println(string(jsonData))
}

8. 结构体与内存布局

Go 编译器会为了性能对结构体字段进行内存对齐。这意味着字段在内存中的顺序可能与你定义的顺序不同,并且可能会插入填充(padding)字节,以确保每个字段都从一个能被其大小整除的地址开始。

这对于需要与 C 库交互或进行底层性能优化的场景非常重要。

// [代码规范化]:这是一个完整的、可运行的示例,演示了内存对齐的影响。
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	fmt.Println("--- 结构体内存布局示例 ---")

	// 在 64 位系统上:
	// bool (1字节), float64 (8字节), int16 (2字节)
	type S1 struct {
		b bool    // 1 字节
		f float64 // 8 字节
		i int16   // 2 字节
	}
	// 内存布局可能像这样:
	// b (1 byte) | padding (7 bytes) | f (8 bytes) | i (2 bytes) | padding (6 bytes)
	// 总大小会为了对齐到最大字段(float64, 8字节)的倍数而变为 24 字节。
	s1 := S1{}
	fmt.Printf("S1 - 非优化布局: 大小 = %d, 对齐 = %d\n", unsafe.Sizeof(s1), unsafe.Alignof(s1))

	// 通过重新排序字段,可以减少内存占用
	type S2 struct {
		f float64 // 8 字节
		i int16   // 2 字节
		b bool    // 1 字节
	}
	// 内存布局可能像这样:
	// f (8 bytes) | i (2 bytes) | b (1 byte) | padding (5 bytes)
	// 总大小会为了对齐到最大字段(float64, 8字节)的倍数而变为 16 字节。
	s2 := S2{}
	fmt.Printf("S2 - 优化后布局: 大小 = %d, 对齐 = %d\n", unsafe.Sizeof(s2), unsafe.Alignof(s2))
	fmt.Println("\n结论:合理安排字段顺序可以节省内存空间。")
}