一架梯子,一头程序猿,仰望星空!
Golang程序设计教程(2024版) > 内容正文

学习 Golang 结构体(struct)


1 结构体(Struct)基础知识

在Go语言中,结构体(Struct)是一种复合数据类型,用来将不同或相同类型的数据集合成为一个单一实体。结构体在Go中的地位十分重要,它是面向对象程序设计的一个基础,尽管Go在处理面向对象的方法上与传统的面向对象编程语言略有不同。

结构体的需要来自以下几个方面:

  • 将相关性强的变量组织在一起,提高代码的可维护性。
  • 提供了一种模拟“类”的手段,利于实现封装和聚合特性。
  • 在与JSON、数据库记录等数据结构交互时,结构体提供了一个方便的映射工具。

用结构体来组织数据,可以更清晰地表示实际的对象模型如用户(User)、订单(Order)等。

2 定义结构体

定义结构体的语法如下:

type StructName struct {
    Field1 FieldType1
    Field2 FieldType2
    // ... 其他成员变量
}
  • type 关键字引出结构体的定义。
  • StructName 是结构体类型的名称,按照Go的命名规约,通常首字母大写,表示它是可导出的。
  • struct 关键字表示这是一个结构体类型。
  • 在花括号{}中定义结构体的成员变量(Field),每个成员变量后面跟着它的类型。

结构体成员的类型可以是任意类型,包括基本类型(如intstring等),也可以是复杂的类型(如数组、切片、另一个结构体等)。

例如,定义一个表示人的结构体:

type Person struct {
    Name   string
    Age    int
    Emails []string // 可以包含复杂的类型,如切片
}

在上述代码中,Person结构体有三个成员变量:Name是字符串类型,Age是整型,Emails是字符串切片类型,表明一个人可能有多个电子邮箱。

3 创建和初始化结构体

3.1 创建结构体实例

结构体实例的创建方式有两种:直接声明或者使用new关键字。

直接声明:

var p Person

上述代码创建了一个Person类型的实例p,此时结构体中的每个成员变量都是其对应类型的零值。

使用new关键字:

p := new(Person)

使用new关键字创建的是一个指向结构体的指针。变量p此时为*Person类型,它指向一个新分配的、成员变量已被初始化为零值的Person类型的变量。

3.2 初始化结构体实例

初始化结构体实例可以在创建时一次性完成,有两种方法:使用字段名或不使用字段名。

使用字段名初始化:

p := Person{
    Name:   "Alice",
    Age:    30,
    Emails: []string{"alice@example.com", "alice123@example.com"},
}

在使用字段赋值形式时,初始化的顺序不必与声明结构体时的顺序相同,未初始化的字段将保持其类型的零值。

不使用字段名初始化:

p := Person{"Bob", 25, []string{"bob@example.com"}}

当不使用字段名称初始化时,需要保证每个成员变量的初始值顺序与定义结构体时的顺序相同,并且不能省略任何字段。

另外,结构体也可以通过指定部分字段来初始化,未指定的字段会采用零值:

p := Person{Name: "Charlie"}

在本例中,只初始化了Name字段,而AgeEmails都将使用它们对应类型的零值。

4 访问结构体成员

在Go语言中访问结构体的成员变量非常直接,通过使用点(.)操作符就可以实现。如果你有一个结构体变量,你可以通过这种方式读取或者修改其成员的值。

例子:

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func main() {
    // 创建一个Person类型的变量
    p := Person{"Alice", 30}

    // 访问结构体成员
    fmt.Println("Name:", p.Name)
    fmt.Println("Age:", p.Age)

    // 修改成员变量的值
    p.Name = "Bob"
    p.Age = 25

    // 再次访问修改后的成员变量
    fmt.Println("\nUpdated Name:", p.Name)
    fmt.Println("Updated Age:", p.Age)
}

在这个例子中,我们首先定义了一个Person结构体,有两个成员变量NameAge。我们创建了一个此结构体的实例,并且演示了如何读取和修改这些成员。

5 结构体的组合和嵌套

结构体不只是可以独立存在的,它们还可以组合和嵌套在一起形成更复杂的数据结构。

5.1 匿名结构体

匿名结构体没有显式声明一个新的类型,而是直接使用结构体定义。这在你需要一次性创建一个结构体并简单使用它时非常有用,这样可以避免创建不必要的类型。

例子:

package main

import "fmt"

func main() {
    // 定义并初始化匿名结构体
    person := struct {
        Name string
        Age  int
    }{
        Name: "Eve",
        Age:  40,
    }

    // 访问匿名结构体的成员
    fmt.Println("Name:", person.Name)
    fmt.Println("Age:", person.Age)
}

在这个例子中,我们没有创造一个新的类型,而是直接定义了一个结构体并创建了它的一个实例。这个例子展示了如何初始化匿名结构体,并访问其成员。

5.2 结构体嵌套

结构体嵌套是将一个结构体作为另一个结构体的成员。这使得我们可以构建更加复杂的数据模型。

例子:

package main

import "fmt"

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

// 在Person结构体中嵌套Address结构体
type Person struct {
    Name    string
    Age     int
    Address Address
}

func main() {
    // 初始化一个Person实例
    p := Person{
        Name: "Charlie",
        Age:  28,
        Address: Address{
            City:    "New York",
            Country: "USA",
        },
    }

    // 访问嵌套结构体的成员
    fmt.Println("Name:", p.Name)
    fmt.Println("Age:", p.Age)
    // 访问Address结构体成员
    fmt.Println("City:", p.Address.City)
    fmt.Println("Country:", p.Address.Country)
}

在这个例子中,我们定义了一个Address结构体,并且在Person结构体中把它作为一个成员嵌入。在创建Person的实例时,我们也同时创建了Address的实例。使用点操作符可以访问到嵌套结构体中的成员。

6 结构体方法

通过结构体方法可以实现OOP特性。

6.1 方法的基本概念

在Go语言中,虽然没有传统意义上的类(Class)和对象(Object)的概念,但是通过给结构体绑定方法,可以实现类似的面向对象编程(OOP)特性。结构体方法是一种特殊的函数,这种函数将一个特定的类型的结构体(或者结构体的指针)与之关联,使得这个类型可以拥有自己的一系列方法。

// 定义一个简单的结构体
type Rectangle struct {
    length, width float64
}

// 为Rectangle结构体定义一个方法,计算矩形的面积
func (r Rectangle) Area() float64 {
    return r.length * r.width
}

在上述代码中,方法 Area 与结构体 Rectangle 关联。在方法定义中,(r Rectangle) 是接收者(receiver)部分,它指定了这个方法是和 Rectangle 类型相关联的。接收者出现在方法名前。

6.2 值接收者与指针接收者

方法根据接收者的类型,可以分为两种:值接收者和指针接收者。值接收者使用结构体的副本调用方法,而指针接收者使用结构体指针调用方法,后者可以修改结构体原始值。

// 使用值接收者定义方法
func (r Rectangle) Perimeter() float64 {
    return 2 * (r.length + r.width)
}

// 使用指针接收者定义方法,可以修改结构体内容
func (r *Rectangle) SetLength(newLength float64) {
    r.length = newLength // 可以修改原始结构体的值
}

在上面的例子中,Perimeter 方法是一个值接收者,调用它不会改变 Rectangle 的值。而 SetLength 是一个指针接收者,调用这个方法将会影响原始的 Rectangle 实例。

6.3 方法的调用

结构体的方法可以通过结构体变量及其指针调用。

func main() {
    rect := Rectangle{length: 10, width: 5}

    // 调用值接收者的方法
    fmt.Println("Area:", rect.Area())

    // 调用值接收者的方法
    fmt.Println("Perimeter:", rect.Perimeter())

    // 调用指针接收者的方法
    rect.SetLength(20)

    // 再次调用值接收者的方法,注意长度已经被修改
    fmt.Println("After modification, Area:", rect.Area())
}

当你通过一个指针调用方法时,Go语言会自动处理值和指针之间的转换,无论你的方法是使用值接收者还是指针接收者定义的。

6.4 接收者类型的选择

在定义方法时,应该依情况来决定使用值接收者还是指针接收者。以下是一些常见的准则:

  • 如果方法需要改变结构体的内容,使用指针接收者。
  • 如果结构体很大,拷贝成本较高,也应该使用指针接收者。
  • 如果你希望方法能够修改接收者指向的值,那么应该使用指针接收者。
  • 基于效率考虑,即使不修改结构体内容,如果结构体很大,使用指针接收者也是合理的。
  • 对于小型结构体,或者不需要修改数据只需读取,值接收者往往更简单且高效。

通过结构体方法,我们能够在Go语言中模拟出面向对象的一些特性,如封装和方法。Go语言的这种做法简化了对象的概念,同时也提供了足够的能力去组织和管理相关的函数。

7 结构体与JSON序列化

在Go中,经常需要将结构体序列化为JSON格式,以便进行网络传输或者作为配置文件。同样,我们也需要能够将JSON反序列化为结构体实例。Go语言的encoding/json包提供了这种功能。

以下是一个如何进行结构体与JSON之间相互转换的例子:

package main

import (
	"encoding/json"
	"fmt"
	"log"
)

// 定义Person结构体,可以通过json属性定义struct字段和json字段名字之间的映射
type Person struct {
	Name   string `json:"name"`
	Age    int    `json:"age"`
	Emails []string `json:"emails,omitempty"`
}

func main() {
	// 创建Person实例
	p := Person{
		Name:   "John Doe",
		Age:    30,
		Emails: []string{"john@example.com", "j.doe@example.com"},
	}

	// 序列化为JSON
	jsonData, err := json.Marshal(p)
	if err != nil {
		log.Fatalf("JSON marshaling failed: %s", err)
	}
	fmt.Printf("JSON format: %s\n", jsonData)

	// 反序列化到结构体
	var p2 Person
	if err := json.Unmarshal(jsonData, &p2); err != nil {
		log.Fatalf("JSON unmarshaling failed: %s", err)
	}
	fmt.Printf("Recovered Struct: %#v\n", p2)
}

在以上代码中,我们定义了一个Person结构体,包括一个带有"omitempty"选项的切片类型字段。该选项指定如果字段为空或缺失,将不包含到JSON中。

我们使用json.Marshal函数将结构体实例序列化成JSON,并使用json.Unmarshal函数将JSON数据反序列化成结构体实例。

8 结构体的高级话题

8.1 结构体的比较

Go允许直接比较两个结构体实例,但此比较是基于结构体内部各个字段的值。如果所有字段值都相等,则认为这两个结构体实例相等。需要注意的是,并非所有的字段类型都可以比较,比如包含切片的结构体不能直接比较。

以下是比较结构体的例子:

package main

import "fmt"

type Point struct {
	X, Y int
}

func main() {
	p1 := Point{1, 2}
	p2 := Point{1, 2}
	p3 := Point{1, 3}

	fmt.Println("p1 == p2:", p1 == p2) // 输出: p1 == p2: true
	fmt.Println("p1 == p3:", p1 == p3) // 输出: p1 == p3: false
}

在此示例中,p1p2相等因为他们的所有字段值都是一样的。而p3由于Y值不同因此与p1不相等。

8.2 结构体的复制

在Go中,结构体实例可以通过赋值的方式来复制。这种复制是深拷贝(deep copy)还是浅拷贝(shallow copy)取决于结构体内字段的类型。

如果结构体仅仅包含基础类型(如int, string等),复制就是深拷贝。如果结构体中包含引用类型(如切片、映射等),那么复制将是浅拷贝,原始实例和新复制出的实例会共享引用类型的内存。

以下是复制结构体的例子:

package main

import "fmt"

type Data struct {
	Numbers []int
}

func main() {
	// 初始化一个Data结构体实例
	original := Data{Numbers: []int{1, 2, 3}}

	// 复制结构体
	copied := original

	// 修改复制后的切片元素
	copied.Numbers[0] = 100

	// 查看原始和复制实例的切片元素
	fmt.Println("Original:", original.Numbers) // 输出: Original: [100 2 3]
	fmt.Println("Copied:", copied.Numbers)     // 输出: Copied: [100 2 3]
}

如例所示,originalcopied实例共享同一个切片,修改copied中的切片数据也会影响original中的切片数据。

可以通过显式的复制切片内容到新的切片来避免这种问题,实现真正的深复制:

	newNumbers := make([]int, len(original.Numbers))
	copy(newNumbers, original.Numbers)
	copied := Data{Numbers: newNumbers}

这样,对copied的任何修改都不会影响original