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 := &adaada 的地址存进 p

(*p).Age 顺着指针找到结构体,再读字段。没人这么写,因为 Go 会替你做:对结构体指针写 p.Age,意思完全一样。

birthday 现在接收 *User。它拿到的仍然是参数的副本,但这份副本是一个地址,两个地址都通向 ada。调用两次,过两次生日,ada.Age 变成 38。

q := p 复制的是指针,不是结构体。设置 q.Name 改的是 ada,而 p == qtrue,因为两个指针存的是同一个地址。

用十岁孩子能懂的话说

结构体是一栋房子。指针是一张纸条,上面写着房子的地址。

把纸条复印一份,你得到两张纸条,而不是两栋房子。两张纸条通向同一扇前门。朋友照着他的纸条找过去,把门刷成红色,你照着你的纸条找过去,看到的也是红门。

不用指针传递结构体就不一样了。这相当于一砖一瓦地照原样再盖一栋房子,然后把新房子交出去。朋友想把那扇门刷成什么颜色都行,你的房子不会变。

准确的说法

指针值是一个内存地址,它的类型由它指向的东西决定。不管 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 Countermain 里创建一个计数器,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 取 &cp 是指针而 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 的那一部分会解释原因。
  • 这个类型的其他方法已经在用指针接收者。让整个类型保持一致,要么全用指针接收者,要么全用值接收者。

值接收者适合行为像值的小类型,比如 PointMoney 金额,每个方法都只读,复制也便宜。拿不准时,就用指针接收者。大多数 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.Cityc.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,因为两个字段都相等。但指针比较的是地址。papb 指向两个不同的变量,所以即使它们指向的值相等,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 以小写字母开头。小写名字是包私有的,所以其他包的代码必须通过 DepositBalance 来访问。讲包的那一部分会介绍这条规则。这里每个方法都用指针接收者,连只读的 Balance 也是,因为这个类型的其他方法需要指针接收者。

结构体需要校验或初始化时,就写一个 NewX。如果零值本身就能用,比如上面的 Counter,就不用写,让人直接写 var c Counter

要点

  • 结构体是值。给它赋值、传递它、用 range 遍历它的切片,都会产生副本。
  • 结构体字面量优先用具名字段。没写的字段取零值。
  • 指针保存地址。复制指针会得到指向同一个值的两个指针,p.Field 会自动顺着指针访问。
  • nil 指针什么都不指向,顺着它去找会 panic。
  • 值接收者操作的是副本。方法要修改接收者,就需要指针接收者,而且这个类型的其他方法通常也应该保持一致。
  • 嵌入会提升字段和方法。它是组合,不是继承。
  • 返回 *XNewX 函数就是 Go 的构造函数,但这只是惯例。

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

这篇文章对你有帮助吗?

点一颗爱心来评分!

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

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