结构体(struct)是一种由固定字段组成的值类型。每个字段都有名称和类型,一个结构体值会同时保存所有字段的值。
复制结构体会复制每个字段,但切片、map 和指针字段复制后仍可能引用同一份数据;值接收器也会操作接收者的副本。
优先使用带字段名的字面量,明确哪些字段允许共享,并让需要修改状态的方法使用指针接收器。
是什么,为什么存在
结构体是一种复合值类型,用一组固定字段描述一个概念。字段可以具有不同类型,例如订单可以同时包含字符串编号、整数数量和布尔付款状态。声明 type Order struct { ... } 会创建一个具名类型;struct { ... } 则直接写出未命名结构体类型。
结构体解决的是「这些值应该作为一个整体出现」的问题。函数可以接收或返回一个 Order,map 可以保存 Order,方法也可以绑定到具名类型。字段集合由类型决定,因此拼错字段名或赋入错误类型会在编译期被拒绝。
Go 的结构体不是类。它没有构造函数、继承层级或隐藏在声明中的运行时分派;方法在具名类型之外单独声明,接口实现也由方法集决定。嵌入可以提升字段和方法,但不会建立父类与子类关系。
字段标识符以 Unicode 大写字母开头时是导出的,其他包可以选择它;小写开头的字段只在声明它的包内可见。这个边界也影响反射和编码:例如 encoding/json 默认只处理导出字段,标签不能把未导出字段变成可编码字段。
领域值、配置、消息载荷和测试用例表中都会遇到结构体。字段集合经常变化或需要方法时,具名结构体最合适;只在一个很小范围内使用的结果或表驱动测试行,可以使用匿名结构体。只有一项数据时,额外包一层结构体通常没有帮助。
本主题讲结构体声明、字面量、字段选择、复制、比较、嵌入和标签。方法的完整规则、接口设计、JSON 边界和反射 API 分别由相关主题展开。
工作原理
字段与零值
结构体声明列出有序字段,每个字段都有名称和类型。相邻字段类型相同时可以合写,例如 X, Y int。字段顺序会参与类型标识,也会决定不带字段名的字面量如何对应值,因此它不是随意的排版选择。
声明变量但不提供初始化值时,它会得到该类型的 零值(zero value) 。结构体的零值由所有字段各自的零值组成:数字是 0,布尔值是 false,字符串是 "",而指针、切片和 map 等字段是 nil。结构体零值本身始终是有效值,但其中的 nil map 仍然不能直接写入。
零值是否立即可用是 API 设计问题。sync.Mutex 的零值已经是未锁定的互斥锁,bytes.Buffer 的零值也可以使用;需要非空地址或已分配 map 的类型则通常需要初始化函数。不要仅凭「这是结构体」判断是否必须调用 New。
结构体字面量
带字段名的复合字面量写成 Order{ID: "A-104", Quantity: 2}。字段顺序无关紧要,未列出的字段取零值。新增字段后,现有带字段名的字面量仍能编译,并会清楚展示调用方真正设置了什么。
不带字段名的字面量按声明顺序提供所有字段,例如 Point{3, 4}。它不能省略某个值,也不能混用带名与不带名的元素。字段顺序改变后,两个类型相同的字段可能悄悄交换含义,所以这种写法只适合很小且稳定的局部类型。
在声明结构体的包之外,不带字段名的字面量不能给未导出字段提供值。即使当前版本只有导出字段,库后来增加未导出字段也会破坏这种写法。跨包构造值时应使用带字段名的字面量或包提供的构造函数。
new(Order) 返回指向零值 Order 的指针。&Order{} 也返回指向新零值的指针,并且更容易在需要时加入字段。两种写法都不执行用户定义的构造逻辑,因为 Go 没有这种隐式机制。
选择字段与指针
选择表达式 order.ID 读取字段;如果变量可赋值,order.ID = "A-105" 会更新该字段。接收结构体值的函数会得到整个结构体的副本,给副本字段赋值不会替换调用方的外层值。
对于结构体指针 pointer,Go 允许直接写 pointer.ID,含义与 (*pointer).ID 相同。这项自动解引用只简化选择器,并没有把值参数改成引用参数。函数需要替换或更新调用方的结构体时,仍须接收指针或返回新值。
读取 nil 结构体指针的字段会在运行时 panic。生成代码有时因为选择器语法不含显式 *,就误以为这种访问会自动处理 nil。是否允许 nil 接收者必须由方法契约明确决定。
复制会逐字段进行
赋值、传参和返回结构体时,Go 会复制结构体值的每个字段。如果字段是整数、布尔值、字符串或不含引用型元素的数组,副本可以独立修改。Go 没有对象引用语义自动替代这次复制。
但字段值本身可能描述或指向其他存储。复制切片只复制其描述信息,复制 map 会保留同一张 map,复制指针仍指向同一目标。外层结构体已经不同,底层数据却仍可共享,这就是 浅拷贝(shallow copy) 。
「深拷贝」没有适用于所有结构体的内置定义。你必须根据所有权契约分别复制切片和 map,并决定指针指向的对象是共享、克隆还是重新建立。含有接口字段时,还要检查接口中的动态值是否继续引用可变数据。
某些字段根本不允许在使用后复制。sync.Mutex 的文档明确规定,首次使用后不得复制;把它嵌入结构体后再按值传递,或给该类型声明值接收器,都可能复制锁。go vet 的 copylocks 检查能发现一部分此类错误。
可比较性来自全部字段
如果结构体的所有字段类型都可比较,那么该结构体类型可比较。两个同类型结构体使用 == 时,会按源码顺序比较对应的非空白字段;这种结构体也可以作为 map 的键。整数、字符串、布尔值、指针、channel 和字段都可比较的数组属于常见可比较字段。
切片、map 和函数不可比较,因此包含这些字段的结构体不能使用 ==,也不能作为 map 键。不要把 reflect.DeepEqual 当成所有业务相等性的默认答案;nil 与空值、函数、未导出状态以及领域规则都可能需要更明确的比较函数。
接口类型本身可比较,所以含有 any 字段的结构体可能通过静态可比较性检查。然而,如果比较时两个接口持有相同的不可比较动态类型,例如 []int,运行时仍会 panic。需要稳定相等性的键类型不应隐藏任意动态值。
嵌入与字段提升
嵌入字段只写类型名,不另写字段名,例如在 Customer 中写 Contact。这个字段仍然有名称:未限定类型名 Contact 就是字段名。复合字面量必须用 Contact: Contact{...} 初始化它,不能把提升后的 Email 直接写成 Customer 的字面量字段。
如果 customer.Contact.Email 是合法路径,而且更浅层没有同名字段,选择器可以缩写成 customer.Email。这种便利叫作 字段提升(field promotion) 。提升不会把 Contact 的字段物理复制到 Customer,显式路径一直存在。
同一最浅深度出现多个同名候选时,缩写选择器是歧义的,编译器会拒绝它。给外层类型声明同名字段或方法会遮蔽更深层候选,但嵌入值仍能通过显式路径访问。这些是静态选择规则,不是运行时的虚方法查找。
嵌入类型的方法也可能进入外层类型的 方法集(method set) ,让外层值满足某个接口。不过,外层值并不能赋给内层类型变量。把嵌入称为「继承」会掩盖组合关系以及选择器歧义。
标签是供其他包解释的字符串
结构体标签(struct tag) 是字段声明后面的字符串字面量。语言和编译器不会把 json:"display_name,omitempty" 自动解释成验证、数据库或编码规则;读取标签的包定义键名、选项和冲突行为。
encoding/json 读取 json 键来改名或省略导出字段。json:"-" 总是忽略字段,omitempty 则按该包定义的空值规则决定是否省略。标签不能导出小写字段,也不会验证字段内容。
反射把标签表示为 reflect.StructTag。Lookup("json") 可以区分「键不存在」与「键存在但值为空」,而 Get("json") 在这两种情况下都可能返回空字符串。需要保留这项差别的工具应使用 Lookup。
标签参与未命名结构体的类型标识,因此字段名称和类型相同、标签不同的两个匿名结构体仍是不同类型。修改公开结构体的标签也可能改变序列化契约,即使 Go 调用方仍能编译。
示例
下面四个程序依次展示零值与字面量、复制边界、嵌入选择器和 JSON 标签。每个程序都可以单独保存,并用 go run 文件名 执行;显示的输出来自本地 Go 工具链。
从零值建立订单
empty 的每个字段都取零值。submitted 只设置编号和数量,Paid 同样从 false 开始,之后通过可赋值变量的选择器更新。
package main
import "fmt"
type Order struct {
ID string
Quantity int
Paid bool
}
func main() {
var empty Order
submitted := Order{
ID: "A-104",
Quantity: 2,
}
fmt.Printf("empty=%+v\n", empty)
fmt.Printf("submitted=%+v\n", submitted)
submitted.Paid = true
fmt.Println("paid:", submitted.Paid)
}empty={ID: Quantity:0 Paid:false}
submitted={ID:A-104 Quantity:2 Paid:false}
paid: true带字段名的字面量表达了调用方的意图,也允许 Paid 使用零值。若零值不能表示合法订单,校验应发生在构造函数或接收输入的边界,而不是假设结构体语法会自动执行校验。
看清浅拷贝的共享部分
给 aliasCopy 赋值会复制 Team。名称字段是独立的字符串值,但 Members 的两个切片值仍指向同一底层数组;slices.Clone 才为元素槽位建立独立存储。
package main
import (
"fmt"
"slices"
)
type Team struct {
Name string
Members []string
}
func main() {
original := Team{
Name: "Platform",
Members: []string{"Chen", "Imani"},
}
aliasCopy := original
aliasCopy.Name = "Operations"
aliasCopy.Members[0] = "Lin"
independent := Team{
Name: original.Name,
Members: slices.Clone(original.Members),
}
independent.Members[1] = "Noor"
fmt.Println("original:", original)
fmt.Println("alias copy:", aliasCopy)
fmt.Println("independent:", independent)
}original: {Platform [Lin Imani]}
alias copy: {Operations [Lin Imani]}
independent: {Platform [Lin Noor]}这里的切片元素是字符串,所以克隆切片已经足够。如果元素是 *Member,两个切片的对应元素仍会指向相同成员;是否继续复制成员对象取决于所有权要求。
显式路径仍在嵌入值上
Contact 是 Customer 的嵌入字段。Email 和 Label 都可以使用提升后的短选择器,也可以沿着字段名写出完整路径。
package main
import "fmt"
type Contact struct {
Email string
}
func (contact Contact) Label() string {
return "email=" + contact.Email
}
type Customer struct {
Name string
Contact
}
func main() {
customer := Customer{
Name: "Mina",
Contact: Contact{
Email: "[email protected]",
},
}
fmt.Println(customer.Email)
fmt.Println(customer.Contact.Email)
fmt.Println(customer.Label())
customer.Email = "[email protected]"
fmt.Println(customer.Contact.Email)
}[email protected]
[email protected]
[email protected]
[email protected]最后一次赋值修改的仍是 customer.Contact.Email。外层结构体没有额外生成一份 Email,customer.Email 只是合法选择器的缩写。
用标签定义 JSON 边界
encoding/json 只编码导出字段,并按 json 标签解释名称和省略规则。PasswordHash 虽然导出,但 json:"-" 阻止它进入输出,也阻止输入填充它。
package main
import (
"encoding/json"
"fmt"
)
type Account struct {
ID int `json:"id"`
DisplayName string `json:"display_name,omitempty"`
PasswordHash string `json:"-"`
Roles []string `json:"roles,omitempty"`
}
func main() {
account := Account{
ID: 7,
PasswordHash: "local-hash",
}
data, err := json.Marshal(account)
if err != nil {
panic(err)
}
fmt.Println(string(data))
input := []byte(`{"id":9,"display_name":"Mina","password_hash":"ignored","roles":["reader"]}`)
var decoded Account
if err := json.Unmarshal(input, &decoded); err != nil {
panic(err)
}
fmt.Printf("%+v\n", decoded)
}{"id":7}
{ID:9 DisplayName:Mina PasswordHash: Roles:[reader]}标签描述的是 encoding/json 的边界,不是通用的保密机制。其他编码器可能读取不同标签;日志若直接格式化结构体,也不会因为 json:"-" 自动隐藏字段。
陷阱
在跨包代码中使用位置字面量
修复方法: 对非局部类型使用带字段名的字面量,并优先调用包提供的构造函数来维护不变量。位置字面量只留给 image.Point{3, 4} 这类小型、稳定且约定明确的类型。
把结构体赋值当作深拷贝
修复方法: 逐字段写出复制策略,并用变异测试证明独立性:复制后修改嵌套数据,断言来源没有变化。只需要只读共享时,应在 API 契约中明确说明,而不是让调用方从实现猜测。
用值接收器修改状态
修复方法: 需要修改接收者、保持身份或避免复制不可复制字段时使用指针接收器。对含 sync.Mutex 的类型运行 go vet ./...,并避免在首次使用后按值传递、返回或存储该值。
把提升当作继承
修复方法: 按组合关系设计 API,并在所有权或歧义重要时使用 customer.Contact.Email 这样的显式路径。还要分别验证 T 与 *T 的方法集是否满足目标接口。
假设任意结构体都能比较
修复方法: 为业务相等性编写具名比较函数,只比较契约需要的字段。需要 map 键时,设计由字符串、数字、布尔值、指针或其他稳定可比较字段组成的专用键类型。
相信标签会执行校验或脱敏
修复方法: 在边界调用实际的验证或编码 API,并测试生成的字节、日志和错误路径。秘密字段通常应与响应 DTO 分离,不能只靠一个标签承担所有泄漏防护。
类型标识、提升与方法集
具名类型与未命名类型
两个分别声明的具名类型始终不同,即使它们的底层结构体具有完全相同的字段。type Billing struct { ID int } 与 type Shipping struct { ID int } 不能直接相互赋值。底层结构相同且满足转换规则时可以显式转换,但转换不会执行校验或构造逻辑。
未命名结构体是否相同由结构决定:字段顺序、名称、类型、标签以及是否嵌入都必须对应。来自不同包的未导出字段名总被视为不同。这个规则使小型匿名结构体能在精确匹配时赋值,也让看似无关的标签成为类型的一部分。
类型别名不会创建新类型。type PublicOrder = internalOrder 只是给同一类型增加另一个名称,而 type PublicOrder internalOrder 会声明新的具名类型。生成迁移代码时,要先确认需求是换名字、建立新类型边界,还是显式转换已有值。
结构体转换只复制相应字段值,不会根据标签重命名、递归映射或调用构造函数。把存储模型转换成 API 模型时,字段语义往往并不完全相同;显式映射通常比依赖底层结构一致更能承受演进。
最浅且唯一的选择器胜出
提升选择器按嵌入深度搜索。某个名称在最浅深度只有一个候选时可以使用;同一最浅深度有多个候选时,选择器不合法。更深的候选不会打破平局,也不会让编译器任意选择一个。
外层声明可以遮蔽提升字段,而不会删除嵌入值中的原字段。重构加入同名字段后,已有的短选择器可能改变目标,所以公开类型不应把大量嵌入层次当作命名空间技巧。显式路径更长,却能把边界写进源码。
提升字段不能作为外层结构体复合字面量的字段名。即使 customer.Email 是合法选择器,Customer{Email: "[email protected]"} 仍然不合法;必须写 Customer{Contact: Contact{Email: "[email protected]"}}。字面量构造的是实际字段布局,不是选择器视图。
嵌入指针会把 nil 状态带入选择路径。外层零值包含 nil 的 *Contact 时,选择 customer.Email 仍需要解引用该指针并可能 panic。希望外层零值可用时,嵌入值类型通常更容易维护,但最终选择取决于共享和方法集需求。
方法集决定接口实现
具名类型 T 的方法集包含接收者为 T 的方法;*T 的方法集包含接收者为 T 或 *T 的方法。对于可寻址的变量,编译器可以把 value.PointerMethod() 改写为 (&value).PointerMethod(),但这项调用便利不会把指针接收器加入 T 的方法集。
接口赋值按方法集检查,因此一个可寻址值能调用指针方法,并不表示该值能赋给要求该方法的接口。生成代码常在直接调用处通过编译,直到把值放入接口、map 元素或临时表达式时才暴露差异。
嵌入 T 时,S 和 *S 都会提升接收者为 T 的方法,而只有 *S 会再包含接收者为 *T 的提升方法。嵌入 *T 时,S 和 *S 的方法集都会包含接收者为 T 或 *T 的提升方法。这些规则会直接决定外层类型是否实现接口。
接收者选择还表达所有权。小型不可变值适合值接收器;修改状态、含锁、需要稳定身份或不应复制的类型适合指针接收器。同一类型混用两种接收者前,应检查方法集与接口行为,而不是只追求表面一致。
标签与反射边界
标签的约定格式是一串可由空格分隔的 key:"value" 对。reflect.StructTag.Get 和 Lookup 只解析符合约定的片段;某个包是否拒绝 malformed 标签、忽略它还是回退到默认行为,由该包决定。go vet 可以检查常见标签格式错误,但不能证明第三方键的业务含义正确。
序列化字段选择与 Go 选择器规则有关,却不一定完全相同。encoding/json 对同一最浅层的多个候选还会应用标签优先规则;仍有多个候选时,它会全部忽略且不返回冲突错误。修改嵌入或标签后,应测试实际 JSON 字节,而不是仅检查选择器能否编译。
结构体标签通常是公开协议的一部分。把 json:"display_name" 改成 json:"name" 不改变 Go 字段类型,却会改变客户端看到的键。模式生成器、数据库映射和验证器也有各自契约,不能从一个标签键推断另一个工具的行为。
反射工具应先决定是否处理未导出字段、嵌入路径、重复键和空标签。读取到字符串只是开始;真正困难的是把冲突、可见性和错误处理写成稳定规则。若需求只针对一个已知结构体,普通字段访问往往比通用反射映射更清楚。
比较与复制的边界
结构体比较基于字段值,不依赖填充字节或内存的原始字节表示。使用 unsafe 或把整个结构体转成字节再比较,会引入布局、填充和指针表示假设,也无法表达多数领域相等性。
把可比较结构体作为 map 键会复制键值。之后修改原变量不会修改已经插入的键;查询必须构造相等的新值。若字段含指针,比较的是指针值而不是目标内容,因此两个内容相同但地址不同的对象会形成不同键。
复制结构体时,数组字段会按值复制其全部元素,但数组元素仍可能是指针或其他共享描述值。所谓「结构体是值类型」只说明外层传递规则,不能证明整张对象图独立。审查时应沿字段类型递归到所有权边界,而不是停在 struct 关键字。
含锁结构体还需要生命周期约束。构造阶段复制尚未使用的零值锁与首次使用后复制锁不是一回事,但依赖这种时间差的 API 很难维护。通常应让拥有同步原语的对象通过指针使用,并提供只含数据、不含锁的快照类型。
4个问题 · 2 道输出预测题 · 1 道找错题