Blog

Go 的结构体、方法和指针

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++,却只有一个起了作用。接收者不过是一个参数,所以它和其他参数一样会被复制。看看每个方法实际拿到的是什么。

main 中的 c count 0 count 1 IncValue 里的副本 count 0 count 1 IncPtr 里的指针 指向 c 输出 after IncValue: 0 after IncPtr: 1 var c Counter:c 的 count 从 0 开始 c.IncValue() 拿到 c 的副本,count++ 把副本改成 1 方法返回,副本被丢弃,c 仍然是 0 c.IncPtr() 拿到指向 c 的指针,count++ 改的是 c 本身 方法返回,指针没了,c 保持 count 1

就是上面的 Counter 程序。IncValue 是值接收者,所以它操作的是 c 的副本:副本的 count 变成 1,然后被丢掉,程序输出 0。IncPtr 是指针接收者,所以 count++ 通过指针改到 c 本身,程序输出 1。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. var c Counter 在 main 里创建一个计数器,count 为 0。
  2. c.IncValue() 把 c 复制给接收者。count++ 改的是副本,所以副本的 count 是 1。
  3. 方法返回,副本没了。c 的 count 仍然是 0,程序输出 after IncValue: 0。
  4. c.IncPtr() 给方法一个指向 c 的指针。count++ 顺着指针改的是 c 本身。
  5. 方法返回。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 的构造函数,但这只是惯例。

值接收者拿到的是副本,指针接收者拿到的是本体。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。