Skip to content

07|结构体

结构体(struct)把一组字段放在一起,每个字段有名字和类型。运维和后端开发里,一条主机记录、一个 HTTP 请求配置、一条路由规则,都适合用结构体表达。结构体的字段在编译期就确定了类型和内存布局,访问字段时有编译期类型检查,比映射更安全。理解结构体的初始化方式、组合机制和 JSON 映射规则,是写 Go 后端代码的基本功。

一、定义与初始化

结构体用 typestruct 关键字定义:

go
package main

import "fmt"

type Host struct {
	Name string
	IP   string
	Role string
}

func main() {
	host := Host{
		Name: "web01",
		IP:   "192.168.10.11",
		Role: "web",
	}

	fmt.Println(host.Name, host.IP, host.Role)
}

字段名首字母大写表示导出,其他包可以访问;首字母小写表示只在当前包内可见。这个规则比 Java 的 public/private 关键字更简洁,但初学者容易忽略首字母大小写的访问控制含义。

结构体字面量有两种写法。上面是"字段名:值"的写法,字段顺序不重要。如果按定义顺序写,可以省略字段名:

go
host := Host{"web01", "192.168.10.11", "web"}

省略字段名的写法在结构体字段很多时容易错位,添加新字段后还可能导致编译错误。生产代码里推荐用字段名写法,可读性更好,顺序无关。

二、零值

结构体的零值是每个字段都为对应类型的零值:

go
var host Host // Name="", IP="", Role=""

这和数组的零值行为一致。结构体始终有确定的初始状态,不会因为忘记初始化而读到随机数据。

指针类型的结构体字段零值是 nil,使用前需要检查:

go
type Config struct {
	Name  string
	Extra *Host // 零值是 nil
}

func main() {
	var cfg Config
	if cfg.Extra != nil {
		fmt.Println(cfg.Extra.Name)
	}
}

三、嵌套结构体

结构体字段可以是另一个结构体,形成嵌套:

go
type Metadata struct {
	Name   string
	Labels map[string]string
}

type ServiceCheck struct {
	Metadata Metadata
	URL      string
	Timeout  int
}

初始化时需要逐层指定:

go
check := ServiceCheck{
	Metadata: Metadata{
		Name: "api-health",
		Labels: map[string]string{
			"env": "prod",
		},
	},
	URL:     "https://example.com/health",
	Timeout: 5,
}

访问嵌套字段时逐层点取:check.Metadata.Name

四、组合

Go 没有继承,用组合复用字段和方法。组合是把一个结构体嵌进另一个结构体(不写字段名),让外层直接访问内层的字段和方法:

go
type Base struct {
	ID   string
	Name string
}

func (b Base) Identify() string {
	return b.ID + ":" + b.Name
}

type Server struct {
	Base       // 组合,不是继承
	IP         string
	Datacenter string
}

func main() {
	s := Server{
		Base:       Base{ID: "srv01", Name: "web"},
		IP:         "10.0.0.1",
		Datacenter: "bj",
	}
	fmt.Println(s.Name)      // 直接访问 Base 的字段
	fmt.Println(s.Identify()) // 直接调用 Base 的方法
}

组合和继承的核心区别:继承是"是一个"(Server 是一个 Base),组合是"有一个"(Server 有一个 Base)。Go 的设计哲学里,组合比继承更灵活,不会产生深层继承链带来的耦合问题。

组合时如果外层和内层有同名字段,外层的字段会覆盖内层。访问内层同名字段需要显式指定内层结构体名:

go
type Outer struct {
	Name string
	Inner
}

type Inner struct {
	Name string
}

func main() {
	o := Outer{Name: "outer", Inner: Inner{Name: "inner"}}
	fmt.Println(o.Name)       // outer
	fmt.Println(o.Inner.Name) // inner
}

五、JSON 标签

结构体字段可以通过 tag 控制 JSON 序列化时的字段名和格式:

go
type Host struct {
	Name string `json:"name"`
	IP   string `json:"ip"`
	Role string `json:"role,omitempty"`
}

json:"role,omitempty" 表示 Role 为空字符串时,JSON 输出里跳过这个字段。标签里的字段名用下划线风格或 camelCase,取决于接口约定。

encoding/json 包通过反射访问结构体字段,只能访问首字母大写的导出字段。首字母小写的字段会被反射跳过,JSON 输出里不会出现。这个规则常被初学者忽略,导致结构体明明有字段,序列化后却是空的。

反序列化时,JSON 字段名和 tag 对应,类型不匹配会报错:

go
raw := []byte(`{"name":"web01","ip":"192.168.10.11","port":8080}`)
var host Host
err := json.Unmarshal(raw, &host) // port 会被忽略,因为 Host 没有 port 字段

如果需要严格校验未知字段,用 Decoder.DisallowUnknownFields()

六、结构体比较

结构体只有在所有字段都可比较时才能用 == 比较:

go
type Point struct {
	X int
	Y int
}

a := Point{1, 2}
b := Point{1, 2}
fmt.Println(a == b) // true

包含切片、映射或函数字段的结构体不能直接比较:

go
type Config struct {
	Name   string
	Labels map[string]string // 映射不可比较
}

// c1 == c2 // 编译错误

需要比较时,逐个字段对比,或用反射 reflect.DeepEqual(性能较差,慎用)。

七、常见错误

结构体字段未导出导致 JSON 为空

go
type Host struct {
	name string `json:"name"` // 首字母小写,反射不可见
	IP   string `json:"ip"`
}

name 字段不会被序列化到 JSON,无论 tag 里写什么。字段名必须首字母大写才能参与 JSON 编码。

嵌套结构体忘了逐层初始化

go
check := ServiceCheck{
	Name: "api-health", // 编译错误:ServiceCheck 没有 Name 字段
}

嵌套结构体的字段属于内层结构体,初始化时要写完整路径:Metadata: Metadata{Name: "api-health"}

组合时误以为有继承多态

go
type Animal struct{}
func (a Animal) Speak() string { return "..." }

type Dog struct{ Animal }
func (d Dog) Speak() string { return "woof" }

func main() {
	var a Animal = Dog{} // 编译错误:cannot use Dog as Animal
}

Go 的组合不提供多态。DogAnimal 是不同的类型,不能互相赋值。需要多态时用接口,不是组合。

指针字段 nil 解引用

go
type Config struct {
	Host *Host
}

func main() {
	var cfg Config
	fmt.Println(cfg.Host.Name) // panic: nil pointer dereference
}

使用指针字段前始终检查是否为 nil