前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串、映射表和指针。接下来开始学习 Go 语言里最常用的复合类型之一:结构体(struct)。
很多初学者第一次接触结构体时,会把它理解成“Go 语言里的 class”。这个理解不够准确。按照 Go 官方语言规范:Struct types 的定义,结构体是一个包含多个字段的序列,每个字段都有自己的名称和类型。
简单理解:结构体把一组有关系的数据放到同一个类型里;方法再把围绕这组数据的行为绑定到这个类型上。
例如,一个用户通常同时拥有姓名、年龄和是否启用三个属性:
type User struct { Name string Age int Active bool }如果不用结构体,我们就需要分别维护name、age、active三个变量,函数参数也会越来越长。结构体把它们组合成一个有意义的User类型,后续可以把它放进切片、映射表,也可以给它定义方法。
本章按照“官方定义 → 基本声明 → 字段访问 → 零值和复制 → 方法 → 嵌入 → 结构体标签 → 自定义数据结构实现”的顺序展开,重点学习:
- 结构体的声明与初始化;
- 字段的访问、导出和可见性;
- 结构体零值以及值复制语义;
- 值接收者和指针接收者;
- 结构体嵌入与组合;
json标签和反射读取标签;- 结构体的可比较性与 map key;
- 使用结构体实现一个泛型单向链表。
本文代码在
go1.27.0 darwin/arm64环境中实际编译运行。地址、内存大小和输出中的空格可能因环境不同而变化。
结构体的基本声明
结构体使用type和struct关键字定义:
type User struct { Name string Age int Active bool }这里定义了一个名为User的新类型,它包含三个字段:
Name的类型是string;Age的类型是int;Active的类型是bool。
字段声明也支持多个字段共用同一个类型:
type Point struct { X, Y int }这等价于:
type Point struct { X int Y int }可以把User先看成一张类型结构图:
User ├── Name string ├── Age int └── Active bool结构体字段还可以是其他结构体、切片、映射表、指针,甚至是结构体自身的指针:
type Address struct { City string Detail string } type Profile struct { Name string Address Address Tags []string Scores map[string]int }注意Profile中的Address是一个值字段,而Tags、Scores是引用底层数据的字段。结构体本身仍然是一个值,但复制它以后,切片和 map 字段可能继续共享底层数据,后面会专门演示这一点。
创建结构体值
使用字段名初始化
最推荐的写法是使用带字段名的结构体字面量:
package main import "fmt" type User struct { Name string Age int Active bool } func main() { user := User{ Name: "rose", Age: 18, Active: true, } fmt.Println(user) fmt.Println("姓名:", user.Name) fmt.Println("年龄:", user.Age) fmt.Println("是否启用:", user.Active) }运行结果:
{rose 18 true} 姓名: rose 年龄: 18 是否启用: true带字段名的初始化有两个好处:第一,代码读起来更清楚;第二,结构体以后增加字段时,原来的初始化代码仍然更容易保持兼容。
省略字段名初始化
如果所有字段都按照声明顺序提供,也可以省略字段名:
user := User{"rose", 18, true}这种写法虽然短,但它依赖字段顺序,而且必须为所有字段提供值。结构体一旦增加、删除或调整字段,调用方就可能编译失败,因此跨包使用导出结构体时通常更推荐带字段名的写法。
使用零值
结构体变量可以直接声明,不初始化时得到零值:
var user User fmt.Println(user)结构体的零值,就是每一个字段都取对应类型的零值:
{ 0 false}也可以只初始化部分字段,未提供的字段自动使用零值:
user := User{Name: "rose"} fmt.Println(user)
运行结果:
{rose 0 false}这条规则非常重要。好的结构体通常会让零值具有合理含义,这样调用者不需要先调用一堆初始化函数就能使用它。例如,bytes.Buffer、sync.Mutex等标准库类型都利用了“零值可用”的设计思路。
使用new
new(User)会创建一个User类型的零值,并返回*User:
user := new(User) user.Name = "rose" user.Age = 18 fmt.Println(user.Name, user.Age)运行结果:
rose 18这里虽然user是*User,但user.Name仍然可以直接访问。Go 会自动完成一次指针解引用。下面两种写法等价:
user.Name = "rose" (*user).Name = "rose"new只负责得到指向零值的指针,不负责填写业务字段。实际项目中更常见的写法是直接使用结构体字面量:
user := &User{Name: "rose", Age: 18}字段访问与嵌套结构体
使用点号访问字段,也可以通过点号连续访问嵌套结构体:
package main import "fmt" type Address struct { City string Detail string } type Profile struct { Name string Address Address } func main() { profile := Profile{ Name: "rose", Address: Address{ City: "西安", Detail: "雁塔区", }, } fmt.Println("姓名:", profile.Name) fmt.Println("城市:", profile.Address.City) fmt.Println("详细地址:", profile.Address.Detail) profile.Address.City = "成都" fmt.Println("修改后的城市:", profile.Address.City) }运行结果:
姓名: rose 城市: 西安 详细地址: 雁塔区 修改后的城市: 成都嵌套结构体适合表达“一个对象包含另一个对象”的关系。例如订单包含收货地址、文章包含作者信息、服务器配置包含网络配置。它和把所有字段平铺到一个大结构体里相比,边界更清楚,也更方便复用。
字段可见性:大写和小写
Go 没有public、private这类访问修饰符。字段名首字母是否大写,决定了它是否导出:
- 首字母大写:导出字段,其他包可以访问;
- 首字母小写:未导出字段,只能在定义它的包内访问。
type User struct { Name string // 导出字段 password string // 未导出字段 }如果User定义在account包中,其他包可以读取user.Name,但不能直接读取user.password。这不是命名习惯,而是 Go 语言本身的可见性规则。
这个规则也会影响 JSON 编码。encoding/json默认只处理可导出的字段:
package main import ( "encoding/json" "fmt" ) type User struct { Name string password string } func main() { user := User{Name: "rose", password: "secret"} data, err := json.Marshal(user) if err != nil { panic(err) } fmt.Println(string(data)) }运行结果:
{"Name":"rose"}即使未导出字段保存了数据,json.Marshal也不会把它直接编码出去。需要对外暴露的数据使用导出字段;需要内部保存的状态可以使用未导出字段,并通过方法控制读写【Get / Set】。
结构体是值类型
按照 Go 官方规范,数组和结构体的值都包含自己的完整数据。把一个结构体赋值给另一个变量,会复制结构体的字段:
package main import "fmt" type Point struct { X int Y int } func main() { p1 := Point{X: 10, Y: 20} p2 := p1 p2.X = 99 fmt.Println("p1:", p1) fmt.Println("p2:", p2) }运行结果:
p1: {10 20} p2: {99 20}这里p2.X = 99不会修改p1.X,因为Point的两个整数都被复制了。
但是,结构体字段如果是切片、map、指针或 channel,复制的只是这些字段本身的描述符或引用,底层数据可能仍然共享:
package main import "fmt" type Report struct { Title string Tags []string } func main() { report1 := Report{ Title: "Go", Tags: []string{"language", "struct"}, } report2 := report1 report2.Title = "Go 结构体" report2.Tags[0] = "golang" fmt.Println("report1:", report1) fmt.Println("report2:", report2) }运行结果:
report1: {Go [golang struct]} report2: {Go 结构体 [golang struct]}Title是字符串值,修改report2.Title不会影响report1.Title;Tags是切片,两个结构体复制后仍然指向同一个底层数组,所以修改一个元素会被另一个结构体看到。
如果需要完全独立的副本,应该显式复制引用字段:
report2 := Report{ Title: report1.Title, Tags: append([]string(nil), report1.Tags...), }这也是一个很容易被忽略的坑:结构体赋值是值复制,但值复制不等于深复制。
结构体与指针
结构体经常和指针一起使用。指针让函数能够修改调用者的结构体:【因为传值传参会发生拷贝】
package main import "fmt" type User struct { Name string Score int } func resetScore(user *User) { user.Score = 0 } func main() { user := User{Name: "rose", Score: 95} resetScore(&user) fmt.Println(user) }运行结果:
{rose 0}resetScore接收的是*User,它通过解引用修改原来的结构体。如果函数参数写成User,函数拿到的是一份副本,修改副本不会改变调用者的变量。
给结构体定义方法
Go 没有 class,但可以通过方法把行为绑定到一个已定义类型上。方法声明在函数名前增加接收者:
func (接收者 接收者类型) 方法名(参数列表) 返回值列表 { // 方法体 }例如,为矩形定义计算面积的方法:
type Rectangle struct { Width float64 Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height }调用方法:
rectangle := Rectangle{Width: 3, Height: 4} fmt.Println(rectangle.Area())运行结果:
12官方规范把接收者看成方法的一个特殊参数。方法不是凭空存在的,它只属于接收者类型的方法集。
值接收者和指针接收者
值接收者接收到的是结构体副本,适合只读取数据或结构体很小、复制成本明确的场景:
func (r Rectangle) Area() float64 { return r.Width * r.Height }指针接收者接收到的是结构体地址,可以修改原结构体,也避免复制较大的结构体:
func (r *Rectangle) Scale(factor float64) { r.Width *= factor r.Height *= factor }完整测试代码如下:
package main import "fmt" type Rectangle struct { Width float64 Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height } func (r *Rectangle) Scale(factor float64) { r.Width *= factor r.Height *= factor } func main() { rectangle := Rectangle{Width: 3, Height: 4} fmt.Println("初始面积:", rectangle.Area()) rectangle.Scale(2) fmt.Println("缩放后的矩形:", rectangle) fmt.Println("缩放后面积:", rectangle.Area()) }运行结果:
初始面积: 12 缩放后的矩形: {6 8} 缩放后面积: 48这里调用rectangle.Scale(2)时,rectangle是一个值,但它是可寻址的变量。Go 会自动把这次调用理解成(&rectangle).Scale(2)。这一点和普通函数不同:普通函数如果要求*Rectangle,调用时必须显式写&rectangle。
下面两种调用都可以:
rectangle.Scale(2) (&rectangle).Scale(2)但下面这种写法不能修改调用者的值,因为r是副本:
func (r Rectangle) WrongScale(factor float64) { r.Width *= factor r.Height *= factor }方法集和接口
值接收者方法属于Rectangle和*Rectangle的方法集;指针接收者方法只属于*Rectangle的方法集。这个差异在把结构体赋值给接口时很重要:
type Scaler interface { Scale(float64) } var rectangle Rectangle var scaler Scaler = &rectangle // 正确 // var scaler Scaler = rectangle // 编译错误:Rectangle 没有 Scale 方法如果一个结构体的方法会修改接收者,或者结构体比较大,通常统一使用指针接收者。混合使用值接收者和指针接收者也不是绝对错误,但需要明确每个方法是否应该复制数据。
结构体嵌入:组合而不是继承
Go 支持把一个类型直接写进另一个结构体中,这叫嵌入字段:
type Logger struct { Prefix string } func (l Logger) Print(message string) { fmt.Println(l.Prefix + message) } type Service struct { Logger Name string }初始化并调用:
service := Service{ Logger: Logger{Prefix: "[API] "}, Name: "user", } service.Print("启动") fmt.Println(service.Name) fmt.Println(service.Logger.Prefix)运行结果:
[API] 启动 user [API]Logger的字段和方法可以被Service直接选择,这种行为叫提升(promotion)。但是Service并不是Logger的子类,Go 也不会因此产生传统面向对象语言中的继承关系。
如果外层结构体和嵌入结构体拥有同名字段,外层字段会优先;如果同一层级存在多个同名提升字段,选择可能变得歧义。工程上使用嵌入时,应该把它看成“复用实现和组合能力”,不要把它当成继承的替代语法。
Go 官方的 Effective Go - Embedding 也强调了这一点:嵌入会带来方法提升,但调用提升方法时,接收者仍然是内部嵌入对象。【这可不像 C++ 这种拥有继承体系的,这其实类似动态多态,但是就是 GO 当中仅仅就是调用的方法就跟原绑定有关,不属于 C++ 的指向谁调用谁这种内存的可视化感受,而 GO 就是直观的可视化感受】
结构体标签
结构体字段后面可以写一个反引号包围的标签:
type Product struct { ID int `json:"id"` Name string `json:"name"` Price float64 `json:"price,omitempty"` }标签本质上是字段的一段字符串元数据。标签本身不会自动产生行为,具体由encoding/json、reflect、校验库等工具解释。
使用 JSON 标签
下面的代码演示字段改名、空值忽略以及反序列化:
package main import ( "encoding/json" "fmt" ) type Product struct { ID int `json:"id"` Name string `json:"name"` Price float64 `json:"price,omitempty"` secret string `json:"secret"` } func main() { product := Product{ ID: 1, Name: "键盘", secret: "internal", } data, err := json.Marshal(product) if err != nil { panic(err) } fmt.Println(string(data)) var decoded Product if err := json.Unmarshal([]byte(`{"id":2,"name":"鼠标","price":99.5}`), &decoded); err != nil { panic(err) } fmt.Printf("%+v\n", decoded) }运行结果:
{"id":1,"name":"键盘"} {ID:2 Name:鼠标 Price:99.5 secret:}price,omitempty让价格为0时不出现在 JSON 中;secret虽然写了json标签,但它是未导出字段,encoding/json仍然不会处理它。
更多规则可以查阅 encoding/json 官方文档。
使用反射读取标签
reflect.StructTag约定标签由多个key:"value"片段组成,可以使用Get或Lookup读取:
package main import ( "fmt" "reflect" ) type User struct { Name string `json:"name" validate:"required"` } func main() { typ := reflect.TypeOf(User{}) field := typ.Field(0) fmt.Println(field.Name) fmt.Println(field.Tag.Get("json")) fmt.Println(field.Tag.Get("validate")) }运行结果:
Name name required反射很灵活,但运行时反射会牺牲一部分类型安全和可读性。业务代码不应该为了“看起来通用”就到处使用反射,只有在序列化、ORM、校验框架等确实需要动态读取字段时再使用。
结构体的比较和 map key
如果结构体的所有字段都支持比较,那么这个结构体也支持==和!=:
package main import "fmt" type Point struct { X int Y int } func main() { p1 := Point{X: 1, Y: 2} p2 := Point{X: 1, Y: 2} fmt.Println(p1 == p2) labels := map[Point]string{ p1: "起点", } fmt.Println(labels[p2]) }运行结果:
true 起点整数、字符串、布尔值、指针以及字段全部可比较的数组和结构体都可以参与比较。切片、map 和函数不能比较,因此包含这些字段的结构体也不能比较:
type InvalidKey struct { Tags []string } // var data map[InvalidKey]string // 编译错误:invalid map key type InvalidKey接口字段在静态类型上支持比较,但如果接口里实际保存的是切片、map 或函数等不可比较的动态值,比较时仍可能触发运行时 panic。因此,结构体里使用接口字段时,也要确认接口的动态值满足比较要求。
这个规则在设计公共结构体时很容易被忽略。Go 官方博客 Keeping Your Modules Compatible 专门提醒过:给一个原本可比较的结构体新增切片、map 或函数字段,可能会让它失去可比较性,从而破坏调用方的比较代码和 map key 用法。
使用结构体实现一个单向链表
结构体的价值不只是保存几项配置。指针字段可以让多个结构体实例互相连接,从而实现链表、树和图等数据结构。
单向链表的每个节点通常包含两部分:当前值,以及指向下一个节点的指针:
head │ ▼ +-------+-------+ +-------+-------+ +-------+-------+ | value | next ──────►| value | next ──────►| value | nil | +-------+-------+ +-------+-------+ +-------+-------+下面使用 Go 泛型实现一个可以保存任意类型的单向链表。这里的重点不是替代标准库,而是把“结构体字段 + 指针连接 + 方法”完整串起来。
定义节点和链表
type Node[T any] struct { value T next *Node[T] } type LinkedList[T any] struct { head *Node[T] tail *Node[T] size int }Node[T]是节点,next保存下一个节点的地址;LinkedList[T]保存头节点、尾节点和当前长度。三个字段都使用未导出名称,调用者只能通过链表方法操作内部结构,这样可以避免外部代码把链表连接关系改坏。
添加元素
func (list *LinkedList[T]) PushFront(value T) { node := &Node[T]{value: value, next: list.head} list.head = node if list.tail == nil { list.tail = node } list.size++ } func (list *LinkedList[T]) PushBack(value T) { node := &Node[T]{value: value} if list.tail == nil { list.head = node list.tail = node } else { list.tail.next = node list.tail = node } list.size++ }PushFront把新节点放到头部,只需要修改一个next指针;PushBack利用tail指针直接找到尾部,不需要从头遍历整个链表。
如果不保存tail,尾部插入每次都要从头节点走到最后,时间复杂度是 O(n)。保存tail后,尾部插入可以做到 O(1)。
查找和删除元素
func (list *LinkedList[T]) Find(predicate func(T) bool) (T, bool) { for current := list.head; current != nil; current = current.next { if predicate(current.value) { return current.value, true } } var zero T return zero, false } func (list *LinkedList[T]) RemoveFirst(predicate func(T) bool) bool { // 保护:链表实例本身是nil,直接返回 if list == nil { return false } var previous *Node[T] for current := list.head; current != nil; current = current.next { if !predicate(current.value) { previous = current continue } if previous == nil { // 删除头结点 list.head = current.next } else { previous.next = current.next } // 如果删的是尾节点,更新tail if current == list.tail { list.tail = previous } // 断开被删除节点的引用,帮助GC current.next = nil list.size-- // 链表删空的时候 head&tail 已经是nil,这里删掉冗余代码 return true } return false }Find返回(值, bool),和 map 的 comma-ok 写法类似,可以区分“找到了零值”和“没有找到”。RemoveFirst需要同时记录当前节点和前一个节点,才能把当前节点从链条中摘除。
遍历、长度和完整测试
func (list *LinkedList[T]) Values() []T { values := make([]T, 0, list.size) for current := list.head; current != nil; current = current.next { values = append(values, current.value) } return values } func (list *LinkedList[T]) Len() int { return list.size } func main() { var list LinkedList[int] list.PushBack(10) list.PushBack(20) list.PushFront(5) found, ok := list.Find(func(value int) bool { return value == 20 }) fmt.Println("查找 20:", found, ok) fmt.Println("初始链表:", list.Values()) fmt.Println("删除 10:", list.RemoveFirst(func(value int) bool { return value == 10 })) fmt.Println("删除后:", list.Values()) fmt.Println("长度:", list.Len()) }运行结果:
查找 20: 20 true 初始链表: [5 10 20] 删除 10: true 删除后: [5 20] 长度: 2这个链表的零值可以直接使用:head和tail的零值是nil,size的零值是0。第一次PushBack时判断tail == nil,就能正确建立第一个节点,不需要额外的NewLinkedList函数。
链表和切片的选择
| 操作场景 | 更适合的结构 |
|---|---|
| 按下标随机读取 | 切片 |
| 连续存储、遍历频繁 | 切片 |
| 频繁在头部插入删除 | 链表或双端队列 |
| 尾部追加且需要随机读取 | 切片 |
| 已经持有节点位置并频繁摘链 | 链表 |
| 保存简单列表 | 切片 |
链表不天然比切片快。链表节点分散在内存中,每个节点还需要额外的指针,遍历时缓存局部性也通常不如切片。只有在确实需要节点连接、频繁在已知位置插入删除,并且不要求随机访问时,链表才有明确的结构优势。
常见错误和避坑提醒
误区一:把结构体当成 class 继承
结构体嵌入只提供字段和方法提升,不会自动建立父子类型关系。需要抽象行为时,优先定义小接口,再让具体结构体实现接口。
误区二:所有方法都使用值接收者
值接收者修改的是副本。如果方法需要改变结构体状态,应该使用指针接收者;如果类型很大,使用指针接收者也可以避免不必要的复制。
误区三:结构体赋值就是深复制
结构体字段会被复制,但切片、map、指针字段指向的底层数据可能共享。需要独立副本时,要显式复制这些引用字段。
误区四:以为写了 JSON 标签就一定会序列化
encoding/json默认忽略未导出字段。标签只提供元数据,不能绕过字段可见性规则。
误区五:随意使用无字段名字面量
User{"rose", 18, true}依赖字段顺序,而且跨包使用导出结构体时还可能受到字段可见性的限制。业务代码优先使用带字段名的字面量。
误区六:把*T和T的方法集混在一起
指针接收者方法通常只属于*T。把值赋给接口前,先检查接口需要的方法是否由值类型本身实现。
一份可以直接复制的综合示例
下面的例子把结构体字段、方法、JSON 标签和指针接收者放到了一起:
package main import ( "encoding/json" "fmt" ) type Task struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` labels []string } func (task *Task) Complete() { task.Done = true } func (task Task) Summary() string { status := "未完成" if task.Done { status = "已完成" } return fmt.Sprintf("[%s] %s", status, task.Title) } func main() { task := Task{ ID: 1, Title: "学习 Go 结构体", labels: []string{"go", "basic"}, } fmt.Println(task.Summary()) task.Complete() fmt.Println(task.Summary()) data, err := json.Marshal(task) if err != nil { panic(err) } fmt.Println(string(data)) }运行结果:
[未完成] 学习 Go 结构体 [已完成] 学习 Go 结构体 {"id":1,"title":"学习 Go 结构体","done":true}labels是未导出字段,因此不会出现在 JSON 中;Complete使用指针接收者修改任务状态;Summary使用值接收者读取任务内容。一个结构体可以同时拥有这两类方法,但要让每个方法的接收者选择和它的语义一致。
总结
本章我们学习了 Go 结构体的完整基础:
- 结构体由多个有名称、有类型的字段组成;
- 结构体可以嵌套其他结构体、切片、map 和指针;
- 结构体变量未初始化时,每个字段都取对应类型的零值;
- 带字段名的字面量比按顺序初始化更清晰、更稳定;
- 结构体赋值会复制字段,但切片、map 和指针字段可能共享底层数据;
- 大写字段是导出的,小写字段只能在包内访问;
- 值接收者读取副本,指针接收者可以修改原结构体;
- 可寻址结构体变量调用指针方法时,Go 会自动取地址;
- 结构体嵌入提供组合和方法提升,不等同于传统继承;
- 结构体标签是供 JSON、反射和其他工具读取的元数据;
- 所有字段可比较时,结构体才可比较,也才能作为 map key;
- 结构体和指针字段可以组合出链表、树等节点型数据结构。
真正理解 Go 结构体之后,就不会把它简单看成“多个变量的集合”。更准确的理解是:结构体定义了数据的边界,零值决定了它是否容易使用,方法决定了它能做什么,而指针字段又让多个结构体能够连接成更复杂的数据结构。
官方资料:
- Go 语言规范:Struct types
- Go 语言规范:Method declarations
- Go 官方教程:Methods
- Go 官方文档:Effective Go - Embedding
- Go 官方文档:encoding/json
- Go 官方文档:reflect.StructTag
- Go 官方博客:Keeping Your Modules Compatible