Go语言里数组和指针的组合比很多人预想的要容易混淆,核心原因在于数组是值类型,而指针只是保存地址。一个 [3]int 在赋值给另一个变量时会复制全部元素,但指向数组的指针只会复制一个地址。理解这一点后,再看函数传参、元素地址、数组指针以及切片的关系,就能避免不少隐蔽的修改失效问题。

数组的值语义与取地址细节
数组在Go里是长度固定的值类型,长度也是类型的一部分。也就是说 [3]int 和 [4]int 是完全不同的类型,不能直接互相赋值,也不能把 [3]int 当作 []int 使用。数组变量赋值给另一个变量时,会发生逐元素的深复制,之后两份数据互不影响。这个行为和切片非常不同:切片变量之间赋值只是复制切片头,底层数组仍然共享。
很多奇怪的修改不生效问题就是从这里来的。比如把 arr 传给一个函数,函数内部修改了 arr[0],调用方打印出来却没有变化,因为函数收到的是一个完整副本。要改变原数组,要么传递数组指针,要么传切片,要么接收返回值。数组本身没有类似引用的隐藏机制。
package main
import "fmt"
func main() {
arr := [3]int{10, 20, 30}
arr2 := arr
arr2[0] = 99
p := &arr
p[1] = 200
fmt.Println("arr:", arr) // [10 200 30]
fmt.Println("arr2:", arr2) // [99 20 30]
fmt.Printf("&arr type: %T\n", &arr)
fmt.Printf("&arr[0] type: %T\n", &arr[0])
}
代码里 p 的类型是 *[3]int,通过 p[1] 修改元素,本质上等价于 (*p)[1]。Go编译器允许数组指针直接使用下标语法,因此写起来和切片下标一样顺手,但底层类型完全不同。另一个值得注意的点是,arr 取地址得到的结果与 arr[0] 取地址的结果在数值上相同,但类型分别是 *[3]int 和 *int。这是因为数组变量在内存中没有额外头部,起始位置就是第一个元素的起始位置。
理解这个地址关系后,再看一些依赖地址比较的代码会更清楚。比如用 fmt.Printf 打印两个地址,数值一样不代表可以混用类型;把一个 *int 强转为 *[3]int 只是形式上让首元素地址成为数组入口,但语言层面不能直接隐式转换,需要借助 unsafe.Pointer,普通业务代码不应这样做。
数组指针与指针数组的区别
数组指针 和 指针数组 虽然只差两个字的顺序,表达的却是完全不同的对象。数组指针通常写成 *[N]T,它是一个指针,指向一个长度为N、元素类型为T的数组。指针数组通常写成 [N]*T,它是一个数组,数组里的每个元素都是一个 *T 指针。两者在类型、内存占用和适用场景上都不能互换。
举一个简单场景:你有一个固定长度为3的整数数组,想让一段逻辑直接修改它,可以用数组指针 *[3]int。如果想让某个集合保存三个可以分别指向不同整数的引用,就要用 [3]*int。前者的指针指向一整块连续内存,后者本身是一块长度为3的指针内存,每个指针还可以指向任意合法地址。
package main
import "fmt"
func main() {
arr := [3]int{1, 2, 3}
var arrPtr *[3]int = &arr
var ptrArr [3]*int
for i := 0; i < len(arr); i++ {
ptrArr[i] = &arr[i]
}
arrPtr[0] = 10
*ptrArr[1] = 20
fmt.Println(arr) // [10 20 3]
fmt.Println(*ptrArr[1])
}
上面代码中 arrPtr[0] 和 (*arrPtr)[0] 是等价的,编译器会处理解引用。而修改 ptrArr[1] 指向的内容时,必须显式用 *ptrArr[1],因为下标返回的是指针本身,不是整数。这个差异经常出现在多层数据结构里,写错时编译报错已经算幸运,更麻烦的是逻辑上把地址和值搞混,导致修改到了错误的位置。
从内存占用看,一个 *[3]int 在64位平台上是8字节,因为指针只存一个地址;一个 [3]*int 则是24字节,每个元素都占8字节。数组指针通常用于整体引用一块定长数组,指针数组则适合元素之间彼此独立、甚至可以指向不同生命周期对象的场景。选择之前先明确:你要共享的是一整块数组,还是一组独立的指针。
函数传参:值复制、指针修改与性能
Go函数参数默认按值传递,这个规则对数组尤其重要。当形参类型是 [1000000]int 时,每次调用都会复制100万个整数,如果 int 占8字节,就是约8MB的复制。对于热路径函数来说,这种开销非常可观。改成 *[1000000]int 后,调用只复制一个8字节指针,函数内部通过指针访问同一块数组,性能立即改善。
但性能不是唯一考量。传指针意味着函数内部可以修改调用方的数组,这既是能力也是风险。如果只是进行只读计算,可用更大灵活性的切片参数,或者通过注释和命名约束强调不修改。切片参数本身也复制24字节左右的描述符,仍然比复制大数组好很多,同时比数组指针更通用,因为它不限定数组长度。
package main
import "fmt"
func modifyByValue(a [3]int) {
a[0] = 99
}
func modifyByPtr(a *[3]int) {
a[0] = 99
}
func main() {
arr := [3]int{5, 6, 7}
modifyByValue(arr)
fmt.Println("after modifyByValue:", arr) // [5 6 7]
modifyByPtr(&arr)
fmt.Println("after modifyByPtr:", arr) // [99 6 7]
}
这段代码直观展示了值传递和指针传递的差别。调用 modifyByValue 后原数组不变;调用 modifyByPtr 后首元素被改成99。需要特别注意的是,传指针虽然避免了复制,但并没有做任何同步,如果多个goroutine同时通过同一个数组指针读写,仍然会产生数据竞争,需要配合互斥锁或通道等手段保证安全。
另一个容易忽视的点是返回值。函数返回大数组同样会复制一次,如果返回数组指针,则要确保指针指向的内存仍然有效。指向函数内部局部数组的指针不能安全返回,因为局部数组通常在栈上,函数返回后可能被覆盖。这时应改为在堆上分配数组,或者返回切片,让运行时管理底层内存。
与切片的关系:从数组或数组指针创建切片
切片和数组经常被放在一起讨论,因为切片底层总是引用一个数组。但切片不是数组,它由指向底层数组的指针、长度和容量三部分组成。数组是值类型,切片是描述符。把一个数组传给要求 []T 的函数会直接编译失败,因为 [N]T 不能隐式变成 []T。解决方案通常是使用切片表达式 arr[:]。
Go语言还允许从数组指针直接创建切片。假设 p 是 *[5]int,那么 p[:] 等价于 (*p)[:]。这个特性在只拿到数组指针、又想使用切片接口时非常方便。切片和原数组共享同一块底层内存,修改切片元素会反映到原数组,反之亦然。
package main
import "fmt"
func main() {
arr := [5]int{1, 2, 3, 4, 5}
p := &arr
s := p[:]
s[0] = 100
fmt.Println(arr[0]) // 100
part := p[1:4]
part[0] = 200
fmt.Println(arr[1]) // 200
}
上面的 s 覆盖整个数组,part 是中间一段。由于共享底层数组,修改 s[0] 和 part[0] 都会直接改变 arr。不过切片与数组指针也有明显区别:切片可以 append,数组指针没有长度属性,也不能增长。对切片执行 append 时,如果容量不足,会分配新的底层数组并复制元素,此后原数组不再受影响。
因此,在实际项目中,如果长度固定且需要整体传递,用数组或数组指针能获得更严谨的类型约束;如果需要动态长度、裁剪、追加,切片是更自然的选择。遇到从外部接口拿到数组指针的情况,可以先通过 p[:] 转成切片来调用通用函数,但也要清楚底层共享的影响范围,避免在不希望共享的路径上误改原数组。
总结来说,Go的指针与数组关系可以拆成三层:数组本身是值类型,赋值和传参会复制;数组取地址产生指向整个数组的指针,可以用来低成本共享和修改;数组与切片配合时,底层数组共享但扩展行为不同。分清这三层,能减少大多数因数组复制和指针混用导致的隐蔽问题。