Go 的结构体是值,每次赋值、每次调用都会复制。指针让你共享同一个结构体,指针接收者则让方法能修改调用它的那个值。
结构体把相关的值放在一个名字下,方法给这组值加上行为。实际的 Go 类型大多是结构体,所以你自己的类型就从这里开始。
让人栽跟头的不是语法,而是结构体是值,而 Go 随手就会复制值。本文讲清复制发生在哪里、指针怎样避免复制,以及这又怎样决定你该写哪种方法。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
定义结构体
结构体类型列出一组有名字的字段,每个字段有自己的类型。用复合字面量创建它的值:
package main
import "fmt"
type User struct {
Name string
Email string
Age int
}
func main() {
var zero User
ada := User{Name: "Ada", Age: 36}
bob := User{"Bob", "bob@example.com", 41}
fmt.Printf("%+v\n", zero)
fmt.Printf("%+v\n", ada)
fmt.Printf("%+v\n", bob)
ada.Email = "ada@example.com"
fmt.Println(ada.Name, ada.Email)
}
输出:
{Name: Email: Age:0}
{Name:Ada Email: Age:36}
{Name:Bob Email:bob@example.com Age:41}
Ada ada@example.com
var zero User 得到一个每个字段都是零值的结构体:两个空字符串和一个 0。你永远不会拿到一个只建了一半、里面装着垃圾数据的结构体。
ada 用的是具名字段。没写的字段取零值,所以 Email 是 ""。bob 用的是按位置赋值:值按字段声明的顺序排列,而且必须全部写上。
优先用具名字段。按位置写的话,给 User 加一个字段,所有字面量都会出错;不小心把两个 string 字段写反了,编译器却一声不吭。对来自其他包的类型用按位置的字面量,go vet 甚至会发出警告。读写字段用点号,比如 ada.Email。
匿名结构体
结构体类型不一定要有名字。只用一次的结构,可以一步声明并赋值:
package main
import "fmt"
func main() {
point := struct {
X, Y int
}{X: 3, Y: 4}
fmt.Printf("%+v\n", point)
}
输出:
{X:3 Y:4}
你会在测试里看到它们,用来列出一组用例;也会看到它们用来解码只用一次的一小段 JSON。凡是要到处传递的,都应该有个具名类型。
结构体是值,值会被复制
给结构体赋值会复制每一个字段,把它传给函数也一样:
package main
import "fmt"
type User struct {
Name string
Age int
}
func birthday(u User) {
u.Age++
fmt.Println("inside:", u.Age)
}
func main() {
ada := User{Name: "Ada", Age: 36}
copyOfAda := ada
copyOfAda.Name = "Not Ada"
birthday(ada)
fmt.Println(ada.Name, ada.Age)
fmt.Println(copyOfAda.Name)
}
输出:
inside: 37
Ada 36
Not Ada
copyOfAda 是另一个独立的 User。给它改名,ada 不受影响。birthday 也拿到了自己的副本,所以它给副本加了一岁,输出 37,返回时把副本丢掉了。调用方的 ada 还是 36。
这和切片那一部分讲数组时的规则一样。它不是结构体的特例:在 Go 里,每次赋值、每个参数都是一次复制。区别只在于复制的是什么。对结构体来说,复制的是它的全部字段。
range 循环里也藏着同样的复制,它会造成一个不声不响的 bug:
package main
import "fmt"
type Player struct {
Name string
Score int
}
func main() {
team := []Player{{Name: "Ada", Score: 10}, {Name: "Bob", Score: 20}}
for _, p := range team {
p.Score += 5 // changes a copy
}
fmt.Println(team)
for i := range team {
team[i].Score += 5 // changes the element
}
fmt.Println(team)
}
输出:
[{Ada 10} {Bob 20}]
[{Ada 15} {Bob 25}]
在第一个循环里,p 是每个元素的副本,所以分数没变。第二个循环通过索引访问切片,改的是真正的元素。
指针:共享而不是复制
指针保存一个值的地址,让程序的两个部分能操作同一个结构体。&x 取得 x 的地址,*p 取得 p 指向的值:
package main
import "fmt"
type User struct {
Name string
Age int
}
func birthday(u *User) {
u.Age++
}
func main() {
ada := User{Name: "Ada", Age: 36}
p := &ada
fmt.Println((*p).Age, p.Age)
birthday(p)
birthday(&ada)
fmt.Println(ada.Age)
q := p
q.Name = "Ada L."
fmt.Println(ada.Name, p == q)
}
输出:
36 36
38
Ada L. true
类型 *User 的意思是“指向 User 的指针”。p := &ada 把 ada 的地址存进 p。
(*p).Age 顺着指针找到结构体,再读字段。没人这么写,因为 Go 会替你做:对结构体指针写 p.Age,意思完全一样。
birthday 现在接收 *User。它拿到的仍然是参数的副本,但这份副本是一个地址,两个地址都通向 ada。调用两次,过两次生日,ada.Age 变成 38。
q := p 复制的是指针,不是结构体。设置 q.Name 改的是 ada,而 p == q 为 true,因为两个指针存的是同一个地址。
用十岁孩子能懂的话说
结构体是一栋房子。指针是一张纸条,上面写着房子的地址。
把纸条复印一份,你得到两张纸条,而不是两栋房子。两张纸条通向同一扇前门。朋友照着他的纸条找过去,把门刷成红色,你照着你的纸条找过去,看到的也是红门。
不用指针传递结构体就不一样了。这相当于一砖一瓦地照原样再盖一栋房子,然后把新房子交出去。朋友想把那扇门刷成什么颜色都行,你的房子不会变。
准确的说法
指针值是一个内存地址,它的类型由它指向的东西决定。不管 User 有多大,*User 的大小都一样,都是一个机器字。复制指针复制的是这个字,复制之后两份指针指向同一个变量。
& 对可寻址的值取地址:变量、变量的字段,或切片元素。像 &User{} 这样的复合字面量也可以,这是个特例。* 是解引用。访问字段和调用方法时,Go 会自动解引用结构体指针。
这个比喻的局限:真实的地址可以推算出来,隔壁房子就是门牌号加一。Go 没有指针运算,所以你没法从一个地址走到下一个地址,只能顺着别人给你的指针走。而且真实的房子可能在别人还拿着地址时就被拆掉。在 Go 里,只要还有指向某个值的指针,垃圾回收器就会让这个值一直活着。所以函数可以放心地返回指向自己局部变量的指针。讲内存的那一部分会解释这些值放在哪里。
new,以及 Go 1.26 的新写法
new(T) 分配一个类型为 T 的零值,返回指向它的指针。从 Go 1.26 起,它还能接受一个表达式,返回一个指向新变量的指针,这个变量存着该表达式的值:
package main
import "fmt"
type Config struct {
Retries int
Debug *bool
}
func main() {
n := new(int)
fmt.Println(*n)
*n = 7
fmt.Println(*n)
cfg := Config{Retries: 3, Debug: new(true)}
fmt.Println(cfg.Retries, *cfg.Debug)
}
输出:
0
7
3 true
new(int) 指向一个新的 0。new(true) 是新写法。在 1.26 之前,你不能写 &true,所以为了得到一个 *bool,要么用临时变量,要么写个小辅助函数。像 Debug 这样的可选字段在配置和 JSON 里很常见,指针能把“没设置”(nil)和 false 区分开。
对结构体,你大多还是会写 &User{Name: "Ada"}。它和 new 作用相同,还能顺便填好字段。
nil 指针
指针的零值是 nil,表示它什么都不指向。顺着 nil 指针去找,程序会停下:
package main
import "fmt"
type User struct {
Name string
}
func main() {
var p *User
fmt.Println(p == nil)
fmt.Println(p.Name)
}
它输出第一行,然后停下:
true
panic: runtime error: invalid memory address or nil pointer dereference
p.Name 实际上是 (*p).Name,而根本没有 *p 可读。Go 在这里不会返回零值,也不会读一块随机内存。它会 panic。
这是 Go 程序里最常见的 panic。如果函数可能返回 nil 指针,用之前先检查。常规做法是 value, err 模式:返回 nil 指针的函数同时返回一个非 nil 的错误,你先检查错误。
方法
方法是带接收者的函数,接收者就是调用方法的那个值。接收者写在方法名前面的括号里,可以是值,也可以是指针。这个选择决定了方法能不能改动任何东西:
package main
import "fmt"
type Counter struct {
count int
}
func (c Counter) IncValue() {
c.count++
}
func (c *Counter) IncPtr() {
c.count++
}
func main() {
var c Counter
c.IncValue()
fmt.Println("after IncValue:", c.count)
c.IncPtr()
fmt.Println("after IncPtr:", c.count)
}
输出:
after IncValue: 0
after IncPtr: 1
两个方法里是同一行 c.count++,却只有一个起了作用。接收者不过是一个参数,所以它和其他参数一样会被复制。看看每个方法实际拿到的是什么。
就是上面的 Counter 程序。IncValue 是值接收者,所以它操作的是 c 的副本:副本的 count 变成 1,然后被丢掉,程序输出 0。IncPtr 是指针接收者,所以 count++ 通过指针改到 c 本身,程序输出 1。
如果动画没有播放,下面用文字把这几步再说一遍:
var c Counter在main里创建一个计数器,count为 0。c.IncValue()把c复制给接收者。count++改的是副本,所以副本的 count 是 1。- 方法返回,副本没了。
c的 count 仍然是 0,程序输出after IncValue: 0。 c.IncPtr()给方法一个指向c的指针。count++顺着指针改的是c本身。- 方法返回。
c保持 count 1,程序输出after IncPtr: 1。
值接收者等于方法在说“给我一份复印件”,指针接收者在说“给我地址”。如果方法需要修改接收者,就得用指针接收者。
Go 会替你取地址
你是在 c 上调用的 c.IncPtr(),c 是普通的 Counter,不是 *Counter。能行得通,是因为 Go 替你改写了这次调用:
package main
import "fmt"
type Counter struct {
count int
}
func (c *Counter) Inc() {
c.count++
}
func (c Counter) Get() int {
return c.count
}
func main() {
c := Counter{}
c.Inc() // Go writes (&c).Inc() for you
p := &c
p.Inc()
fmt.Println(p.Get()) // and (*p).Get() here
}
输出:
2
c 是变量而 Inc 要指针时,Go 取 &c。p 是指针而 Get 要值时,Go 用 *p。所以在日常代码里,不管接收者是哪种,调用方法的写法都一样。
这种改写只在有地址可取时才行。没有存进变量的值没有地址:
package main
type Counter struct {
count int
}
func (c *Counter) Inc() {
c.count++
}
func main() {
Counter{}.Inc()
}
构建失败,报错:
./main.go:12:12: cannot call pointer method Inc on Counter
Counter{} 是临时值,不是变量,所以 Inc 没有东西可以指向。先把它存进变量,或者写 (&Counter{}).Inc()。同样的限制也是你不能直接对 map 元素调用指针方法的原因。讲接口的那一部分还会用到这一点,它决定了哪些类型满足某个接口。
什么时候用指针接收者
经验法则很简单。只要下面任何一条成立,就用指针接收者:
- 方法要修改接收者。这一条没什么好争的。值接收者改的是副本。
- 结构体很大。值接收者每次调用都复制所有字段,指针只有一个字。“大”没有明确的界线,只有几个小字段的话,用值没问题。
- 类型不能被安全地复制。比如包含
sync.Mutex的结构体就不能复制。go vet会报告这类复制。讲sync的那一部分会解释原因。 - 这个类型的其他方法已经在用指针接收者。让整个类型保持一致,要么全用指针接收者,要么全用值接收者。
值接收者适合行为像值的小类型,比如 Point 或 Money 金额,每个方法都只读,复制也便宜。拿不准时,就用指针接收者。大多数 Go 代码对结构体都是这么选的。
嵌入:来自另一个结构体的字段和方法
Go 没有继承。取而代之的是,结构体可以只写类型、不写字段名来嵌入另一个类型,被嵌入类型的字段和方法会提升到外层结构体上:
package main
import "fmt"
type Address struct {
City string
}
func (a Address) Label() string {
return "lives in " + a.City
}
type Customer struct {
Name string
Address
}
func main() {
c := Customer{
Name: "Ada",
Address: Address{City: "London"},
}
fmt.Println(c.City)
fmt.Println(c.Label())
fmt.Println(c.Address.City)
}
输出:
London
lives in London
London
c.City 和 c.Label() 用起来就像 Customer 自己声明的一样。实际上,被嵌入的值仍然是一个普通字段,名字就是它的类型名 Address,所以 c.Address.City 也能用。
这是组合,不是继承。Customer 包含一个 Address,但它不是 Address。函数要 Address 时,你不能传 Customer 进去。而且 Label 运行时,它的接收者是里面那个 Address,对外面的 Customer 一无所知。如果 Customer 声明了自己的 Label,就用它自己的,嵌入的那个仍然可以通过 c.Address.Label() 调用。
比较结构体,以及结构体标签
每个字段都可比较时,两个结构体就能用 == 比较,而且是逐个字段比较:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
a := Point{X: 1, Y: 2}
b := Point{X: 1, Y: 2}
pa, pb := &a, &b
fmt.Println(a == b)
fmt.Println(pa == pb, *pa == *pb)
seen := map[Point]bool{a: true}
fmt.Println(seen[Point{1, 2}])
}
输出:
true
false true
true
a == b 为 true,因为两个字段都相等。但指针比较的是地址。pa 和 pb 指向两个不同的变量,所以即使它们指向的值相等,pa == pb 也是 false。可比较的结构体还能当 map 的键,用来表示坐标和组合键很方便。
加一个不可比较的字段,比如切片,== 就编译不过了:
package main
import "fmt"
type Tagged struct {
Name string
Tags []string
}
func main() {
a := Tagged{Name: "x"}
b := Tagged{Name: "x"}
fmt.Println(a == b)
}
构建失败,报错:
./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)
切片、map 和函数不能用 == 比较,包含它们的结构体也就不能。这些字段要你自己比较,比如用 slices.Equal。
字段还可以带一个标签,也就是写在类型后面的字符串,比如 Email string `json:"email"`。语言本身忽略标签,由包来读取它们。encoding/json 用标签给 JSON 里的字段命名。讲构建 REST API 的几部分会大量用到它。
构造函数不过是叫 NewX 的函数
Go 没有构造函数,所以按照惯例,名为 NewX 的函数负责创建一个 X,并返回指向它的指针:
package main
import (
"errors"
"fmt"
)
type Account struct {
Owner string
balance int
}
func NewAccount(owner string) (*Account, error) {
if owner == "" {
return nil, errors.New("owner is required")
}
return &Account{Owner: owner}, nil
}
func (a *Account) Deposit(amount int) {
a.balance += amount
}
func (a *Account) Balance() int {
return a.balance
}
func main() {
acc, err := NewAccount("Ada")
if err != nil {
fmt.Println(err)
return
}
acc.Deposit(50)
acc.Deposit(25)
fmt.Println(acc.Owner, acc.Balance())
_, err = NewAccount("")
fmt.Println(err)
}
输出:
Ada 75
owner is required
NewAccount 在创建任何东西之前先检查输入,出问题就返回错误,而不是一个坏掉的账户。它返回 &Account{...},也就是刚创建的值的地址。这是安全的:正如房子的比喻里说的,只要还有指针指向这个值,它就一直活着。
注意 balance 以小写字母开头。小写名字是包私有的,所以其他包的代码必须通过 Deposit 和 Balance 来访问。讲包的那一部分会介绍这条规则。这里每个方法都用指针接收者,连只读的 Balance 也是,因为这个类型的其他方法需要指针接收者。
结构体需要校验或初始化时,就写一个 NewX。如果零值本身就能用,比如上面的 Counter,就不用写,让人直接写 var c Counter。
要点
- 结构体是值。给它赋值、传递它、用 range 遍历它的切片,都会产生副本。
- 结构体字面量优先用具名字段。没写的字段取零值。
- 指针保存地址。复制指针会得到指向同一个值的两个指针,
p.Field会自动顺着指针访问。 - nil 指针什么都不指向,顺着它去找会 panic。
- 值接收者操作的是副本。方法要修改接收者,就需要指针接收者,而且这个类型的其他方法通常也应该保持一致。
- 嵌入会提升字段和方法。它是组合,不是继承。
- 返回
*X的NewX函数就是 Go 的构造函数,但这只是惯例。
值接收者拿到的是副本,指针接收者拿到的是本体。