需要预先了解:


1. 结构体与指针

**结构体(struct)**是把一组字段打包成一个整体,指针用来在函数之间共享同一个对象。Go 的指针比大多数语言的指针"干净":不能做算术运算,不能随意转换类型。这一篇把两者的用法和几个反直觉的规则讲清楚。


1.1 指针

& 取地址,* 解引用:

a := 10
p := &a          // p 的类型是 *int
fmt.Println(a, p, *p) // 10 0x104d44a300e0 10
*p = 20          // 通过指针修改 a
fmt.Println(a)   // 20

指针的零值是 nil:

var np *int
fmt.Println("np == nil:", np == nil) // true

对 nil 指针解引用会 panic(invalid memory address or nil pointer dereference),使用前要判空。

没有指针算术

指针不能加减、不能自增:

p++
invalid operation: p++ (non-numeric type *int)

这是有意设计的:指针算术是很多内存错误的来源,Go 把它去掉了。需要访问数组元素时用下标,需要移动位置时用切片。

用指针修改调用方变量

Go 的参数是值拷贝,想修改调用方的变量就得传地址:

func incr(x *int) {
	*x++
}
b := 1
incr(&b)
fmt.Println("b =", b) // 2

注意这里仍然是值传递——拷贝的是那个地址值。函数内 x 是一个副本,但副本指向的对象和调用方是同一个。


1.2 new 与 make

new(T) 分配一块 T 类型的内存并置零,返回 *T:

ip := new(int)
fmt.Println(*ip) // 0

pp := new(Point)
fmt.Println(*pp) // {0 0}

make 只用于 slice、map、channel 三种类型,返回的是初始化好的值本身(不是指针):

s := make([]int, 2)          // len=2 的切片
m := make(map[string]int)    // 可写的空 map
ch := make(chan int, 1)      // 容量为 1 的 channel

两者的区别不只是"返回值是不是指针",更关键的是 make 会初始化运行时所需的内部结构:切片的 len/cap、map 的哈希表、channel 的缓冲区。这也是 make([]int, 3) 和 new([3]int) 完全不同的原因——前者是切片,后者是数组指针。底层细节见 切片、map 与字符串的底层实现。


1.3 结构体定义与零值

type Point struct {
	X, Y int
}

字段名首字母大写表示导出(其他包可以访问),小写表示包内私有,详见 包与模块:代码组织。

结构体的零值是所有字段的零值:

var zero Point
fmt.Println("零值:", zero) // {0 0}

1.4 字面量初始化

三种形式:

p1 := Point{1, 2}          // 按字段顺序,必须写全
p2 := Point{X: 1, Y: 2}    // 键值形式,可以只写部分字段
p3 := Point{X: 1}          // 等价于 Point{X: 1, Y: 0}

两种形式不能混用:

p := Point{1, Y: 2}
mixture of field:value and value elements in struct literal

推荐用键值形式,原因有两个:字段顺序调整时不会悄悄改变语义;新增字段时不需要回头检查每个字面量。


1.5 结构体是值类型

赋值、传参、返回都会复制整个结构体,包括其中的数组字段:

p1 := Point{1, 2}
p4 := p1
p4.X = 100
fmt.Println("p1 =", p1, "p4 =", p4) // p1 = {1 2} p4 = {100 2}

结构体包含大数组时,拷贝成本很高,这时通常传 *Point。但要注意:传指针并不总是更快,如果结构体很小,拷贝一个指针和拷贝结构体差不多,而指针会让对象逃逸到堆上,增加 GC 压力。判断依据要靠 benchmark,见 性能分析与调优。


1.6 结构体比较

所有字段都可比较时,结构体可以用 == 比较:

p1 := Point{1, 2}
p2 := Point{X: 1, Y: 2}
fmt.Println("p1 == p2:", p1 == p2) // true

含切片、map、函数字段的结构体不可比较:

type S struct {
	Data []int
}

a := S{Data: []int{1}}
b := S{Data: []int{1}}
fmt.Println(a == b)
invalid operation: a == b (struct containing []int cannot be compared)

需要比较内容时用 reflect.DeepEqual(注意它有自己的语义,比如 nil 切片与空切片不相等)或自己写比较函数。


1.7 嵌套与匿名嵌入

结构体可以嵌套另一个结构体:

type Line struct {
	Start, End Point
}

l := Line{Start: Point{1, 2}, End: Point{3, 4}}
fmt.Println(l.Start.X) // 1

**匿名嵌入(embedding)**是把字段名省略,只写类型:

type Animal struct {
	Name string
}

func (a Animal) Describe() string {
	return "我是 " + a.Name
}

type Dog struct {
	Animal // 匿名嵌入
	Breed  string
}

嵌入后,外层结构体可以直接使用内层的字段和方法,这叫提升(promotion):

d := Dog{Animal: Animal{Name: "旺财"}, Breed: "柴犬"}
fmt.Println(d.Name)         // 旺财,等价于 d.Animal.Name
fmt.Println(d.Describe())   // 我是 旺财
fmt.Println(d.Animal.Name)  // 也可以显式写全路径

Dog 不是"继承"了 Animal,它只是拥有一个匿名的 Animal 字段,编译器帮你省掉了写字段名这一步。这是 Go 实现代码复用的主要方式,叫组合(composition)。

提升的规则

1. 可以多级提升。 嵌入的层数不限:

type Level2 struct{ Deep string }
type Level1 struct{ Level2 }
type C struct{ Level1 }

c := C{Level1{Level2{Deep: "多级提升"}}}
fmt.Println(c.Deep) // 多级提升

2. 外层同名字段优先。

type A struct{ X int }

type B struct {
	A
	X int
}

b := B{A: A{X: 1}, X: 2}
fmt.Println("外层 X:", b.X)   // 2
fmt.Println("内层 X:", b.A.X) // 1

3. 同层级出现同名时是歧义,必须显式指定。

type C struct {
	A
	B
}

c := C{A{1}, B{2}}
fmt.Println(c.X)
ambiguous selector c.X

要访问必须写 c.A.X 或 c.B.X。

注:方法也会被提升,外层同名方法会遮蔽内层方法。方法层面的提升规则放在 方法 里讲。


1.8 结构体 tag

字段后面可以用反引号写标签(tag),它是编译器不解释的字符串,供反射等工具解析:

type User struct {
	Name string `json:"name" validate:"required"`
	Age  int    `json:"age,omitempty"`
	mail string
}

用反射读取:

t := reflect.TypeOf(User{})
for i := 0; i < t.NumField(); i++ {
	f := t.Field(i)
	fmt.Printf("字段: %-5s 导出: %-5v tag: %q json=%q\n", f.Name, f.IsExported(), f.Tag, f.Tag.Get("json"))
}
字段: Name  导出: true  tag: "json:\"name\" validate:\"required\"" json="name"
字段: Age   导出: true  tag: "json:\"age,omitempty\"" json="age,omitempty"
字段: mail  导出: false tag: "" json=""

tag 的语法是 key:"value" 的空格分隔序列。未导出字段(mail)即使写了 tag,encoding/json 也不会处理它。tag 的解析机制见 反射与 unsafe,json 标签的完整用法见 标准库(二):JSON 与 HTTP 服务。


1.9 构造函数惯例 NewXxx

Go 没有构造函数语法,惯例是提供一个 NewXxx 函数:

func NewPoint(x, y int) *Point {
	return &Point{X: x, Y: y}
}

返回 T 还是 *T,考虑三点:

  1. 结构体是否会被修改?需要共享修改时返回 *T;
  2. 结构体大小?字段多、含数组时 *T 更省拷贝;
  3. 是否要表达"零值即可用"?如果零值就能用,可以直接返回 T,让调用方用字面量也行。

如果返回 *T 但类型零值不可用(例如内部有 map 需要初始化),构造函数就更有存在价值:

type Counter struct {
	counts map[string]int
}

func NewCounter() *Counter {
	return &Counter{counts: make(map[string]int)}
}

标准库里的 bytes.NewBuffer、bufio.NewReader、errors.New 都遵循这个命名惯例。


1.10 练习

  1. 定义一个 Stack 结构体,用切片保存数据,提供 Push、Pop、Len 三个方法(方法的具体写法见 方法)。
  2. 定义 Base 和 Derived,让 Derived 匿名嵌入 Base,分别在两层定义同名字段和同名方法,验证外层遮蔽内层的行为。
  3. 写一个 NewUser 构造函数,返回 *User,其中 User 含一个必须初始化的 map 字段,验证零值直接使用会 panic 而构造函数返回的对象可以正常工作。