结构体与指针
需要预先了解:
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,考虑三点:
- 结构体是否会被修改?需要共享修改时返回
*T; - 结构体大小?字段多、含数组时
*T更省拷贝; - 是否要表达"零值即可用"?如果零值就能用,可以直接返回
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 练习
- 定义一个
Stack结构体,用切片保存数据,提供Push、Pop、Len三个方法(方法的具体写法见 方法)。 - 定义
Base和Derived,让Derived匿名嵌入Base,分别在两层定义同名字段和同名方法,验证外层遮蔽内层的行为。 - 写一个
NewUser构造函数,返回*User,其中User含一个必须初始化的 map 字段,验证零值直接使用会 panic 而构造函数返回的对象可以正常工作。



