[golang] 指针

指针的创建及赋值

语法和 C 一样,& 获取地址赋值给指针变量,这一过程叫作「引用」,* 解引用,这一过程叫「解引用」。

func main() {
	x := 1
	// 创建指针
	p := &x
	// 从指针取值
	v := *p
	fmt.Println(p, v) // 0xc0000240e8 1
}

指针的零值为 nil ,解引用前要确保指针非空,否则会报错。

func main() {
	var p2 *int
	var v2 = *p2 // 🚨 panic: runtime error: invalid memory address or nil pointer dereference
	fmt.Println(v2)
}

关键字 new 可创建一个指向指定类型零值的指针,对于结构体字面量直接在其前面加 & 可获取一个零值结构体指针。

type Person struct {
	name string
	age  int
}

func main() {
	p := new(int)
	fmt.Println(p == nil, *p) // false 0
	p2 := &Person{}
	fmt.Println(*p2) // { 0}
} 

对于原始值或常量,则不能直接加 & 来获取指针,需先声明一个变量,通过变量来获取。因为原始值和常量并不存在内存中,只编译时有。

p2 := &1 // 🚨 invalid operation: cannot take address of 1

这里就引出一个问题,假如一个结构体的字段是个指针,那么在初始化该字段时就不能直接写字面量了,

type Person struct {
	name *string
	age  int
}

func main() {
	p := Person{
		name: "foo", // 🚨 cannot use "foo" (untyped string constant) as *string value in struct literalcompilerIncompatibleAssign
		// name: stringp("foo"),
		age: 1,
	}
	fmt.Println(p)
}

解决办法有两个,

  • 创建中间变量,通过变量来赋值给结构体的这个指针变量
  • 创建工具方法,专门用来将字符串,数字及布尔值这种原始类型转成指针
func stringp(s string) *string {
	return &s
}

func main() {
	p := Person{
		name: stringp("foo"),
		age:  1,
	}
}

值传递

因为 Go 中函数的参数是值传递,所以,当指针作为参数时,复制的是指针,而不是指针所指向的对象。所以,

  • 当函数的指针入参为 nil 时,无法在函数体中对其指向的值赋值成非 nil ,除非该参数已经指向了某个值
  • 通过在函数体中解引用来修改指针指向的值

首先第一点,看这个示例:

func updateP(p *int) {
	i := 1
	p = &i
}

func main() {
	var x *int // nil
	updateP(x)
	fmt.Println(x) // <nil>
}

这个其实好理解,x 为空指针没有指向任何值,当传递给函数后,p 经过复制什么也没得到,它的值也是 nil,然后函数体中修改 p 的值,此时 p 指向 i 的地址,原来 x 仍然是 nil

因为是 nil ,所以就无法解引用,所以就无法修改原来的值。

再看第二点,

func updateP(p *int) {
	*p = 2
}

func main() {
	x := 1
	p := &x
	updateP(p)
	fmt.Println(x) // 2
}

除非是在必要的时候,比如处理 JSON,否则尽量避免使用指针,因为它使得数据流向不明确,并且带来额外的垃圾回收成本。

当然,在一些极限情况下使用指针是有收益的,比如函数入参是个比较大的结构体,使用指针传递或进行返回可提高性能,因为对于任何数据类型来说,指针大小是恒定的。

Map & Slice

两者的背后实现都是指针,所以在之前的内容中有涉及到说函数体里对两者的修改都会影响其原来的值,这就不奇怪了。

正因为如此,不建议使用 map 作为函数的入参入返回,以及作为 API 的返回。API 中应该使用结构体,无论是文档还是字段限制都会更加清晰。

Slice 还更加不一样一些,函数体中对 Slice 的元素修改会影响原来的值,但通过 append 改变其长度后,不影响原来的值。

具体来说,slice 的实现是一个包含三个字段的结构体,分别是长度,容量及指向存储的指针:

image

当传递到函数内,参数复制后的结果为:

image

所以对元素的修改,修改的都是同一段内存,会影响原来的值。但,append 改变 lencap 属性后,有两种情况,

  • cap 足够时,只会增加 len 不会创建新的内存,此时原来的内存中确实增加了元素,但原始的 slice 是看不到新增元素的(runtime 决定的),所以原来的 len 不变。

image

  • cap 不够时,此时会新开辟一段内存创建全新的 slice,此时跟原来的 slice 就更没关系了。

image

因此,slice 在传到函数体中,只能被修改存在的元素,而无法进行尺寸的变化。好的做法是尽量不要在函数内对 slice 进行变更,特别是在 API 设计时。

从 slice 的实现也可以看出,为什么可以向函数传递任意大小的 slice,因为 slice 的结构是固定的,两个表示长度的 int 及一个指针。而数组,长度是包含在类型中的,不同长度是不同的类型,所以无法使得函数接收任意长度的数组作为入参。