GO [ 结构体 ]
发布时间:2026/9/30 14:07:10来源:尧图网络
前面我们已经学习了 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的类型是stringAge的类型是intActive的类型是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等标准库类型都利用了“零值可用”的设计思路。使用newnew(User)会创建一个User类型的零值并返回*Useruser : 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 rosenew只负责得到指向零值的指针不负责填写业务字段。实际项目中更常见的写法是直接使用结构体字面量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.TitleTags是切片两个结构体复制后仍然指向同一个底层数组所以修改一个元素会被另一个结构体看到。如果需要完全独立的副本应该显式复制引用字段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-- // 链表删空的时候 headtail 已经是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的零值是nilsize的零值是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 typesGo 语言规范Method declarationsGo 官方教程MethodsGo 官方文档Effective Go - EmbeddingGo 官方文档encoding/jsonGo 官方文档reflect.StructTagGo 官方博客Keeping Your Modules Compatible
网站建设高端定制企业官网