在Go语言的学习过程中,指针往往是让初学者感到困惑的一个知识点。很多从其他语言转过来的开发者会发现,明明在函数里修改了参数,可是函数外的变量却纹丝不动。要理解这个问题,就必须弄清楚Go语言中值类型与指针类型的本质区别,以及Go在函数调用时到底传递了什么。本文将从内存原理入手,结合具体代码示例,把Golang传值与传引用这件事讲透。

一、值类型与引用类型的本质区别
Go语言中的数据类型从内存布局的角度可以分成两大类。一类是值类型,包括int、float64、bool、string、数组、结构体等。值类型的变量直接存储数据本身,当你把一个值类型变量赋值给另一个变量时,会在内存中完整复制一份数据,两个变量各自独立,互不影响。比如执行a := 10再执行b := a之后,修改b的值不会对a产生任何影响。
另一类是引用类型,常见的有切片slice、映射map、通道channel、函数和接口。这些类型的变量底层都包含指向底层数据的指针,赋值时复制的是这个头部结构,而不是底层数据本身。所以两个slice变量可能共享同一块底层数组,通过其中一个修改元素,另一个也能看到变化。这一点经常被误解为Go存在引用传递,实际上并不是。
需要特别强调的一个结论是:Go语言中所有的函数参数传递都是值传递。当传递指针时,实际上传递的是指针值的一个副本,也就是地址的拷贝。因为副本指向的是同一块内存,所以通过副本依然可以修改原始数据。理解了这一点,传值和传引用的表象差异就都能解释通了。
二、传值与传指针的代码对比
下面通过一个最经典的例子来观察两种传递方式的差异。我们先定义一个结构体,分别用传值和传指针两种方式在函数中修改它,看看外部变量有没有变化。
package main
import "fmt"
type User struct {
Name string
Age int
}
// 传值:函数内部拿到的是结构体的完整副本
func modifyByValue(u User) {
u.Age = 100
fmt.Println("函数内部 modifyByValue:", u.Age)
}
// 传指针:函数内部拿到的是地址的副本,指向同一块内存
func modifyByPointer(u *User) {
u.Age = 100
fmt.Println("函数内部 modifyByPointer:", u.Age)
}
func main() {
u1 := User{Name: "张三", Age: 20}
modifyByValue(u1)
fmt.Println("传值后外部 Age:", u1.Age) // 依然是20
u2 := User{Name: "李四", Age: 20}
modifyByPointer(&u2)
fmt.Println("传指针后外部 Age:", u2.Age) // 变成了100
}运行结果很直观:传值方式下,函数内的修改只作用于副本,外部变量保持原样;传指针方式下,函数内的修改会真实反映到原始变量上。这就是为什么在需要修改调用方数据、或者结构体体积较大想避免复制开销的场景中,通常建议传递指针。
再来看一个交换两个整数的例子,这个例子更能说明指针的必要性。如果不用指针,交换的只是副本,外部毫无感知。
package main
import "fmt"
// 错误示范:交换的只是副本,外部无效
func swapWrong(a, b int) {
a, b = b, a
}
// 正确做法:通过指针修改原始变量
func swapRight(a, b *int) {
*a, *b = *b, *a
}
func main() {
x, y := 1, 2
swapWrong(x, y)
fmt.Println(x, y) // 输出 1 2
swapRight(&x, &y)
fmt.Println(x, y) // 输出 2 1
}这里的&是取地址操作符,用于获取变量的内存地址;*在类型前表示指针类型,在变量前表示解引用,也就是根据地址取出对应的值。这两个符号是操作指针的核心工具,初学者一定要区分清楚它们在不同位置的含义。
三、new和make的区别及使用注意事项
提到指针就不得不提两个内置函数。new(T)会为类型T分配一块内存并初始化为零值,返回指向这块内存的指针,类型是*T。它适用于各种类型,包括结构体、基本类型等。而make只用于slice、map和channel这三种引用类型,它返回的是初始化后的类型本身而不是指针,因为这三类数据结构需要初始化内部状态才能直接使用。
package main
import "fmt"
func main() {
// new 返回指针,指向零值化的结构体
u := new(User)
u.Name = "王五"
fmt.Println(u) // 输出 &{王五 0}
// make 只能用于 slice、map、channel
m := make(map[string]int)
m["age"] = 18
fmt.Println(m)
// 下面这行会编译报错:cannot make type User
// s := make(User)
}一个常见的误区是对nil map直接赋值。如果用var m map[string]int声明后直接写入,程序会panic,因为m只是nil指针,必须先用make初始化。而slice用var声明后可以append,因为append内部会处理扩容和底层数组分配。这些细节都和引用类型的底层实现有关。
四、什么时候该用指针,什么时候该用值
选择传值还是传指针,可以从几个维度考虑。第一,如果函数内部需要修改调用方的数据,必须传指针。第二,如果结构体比较大,复制成本高,传指针可以减少内存分配和拷贝开销。第三,如果结构体很小,比如只包含一两个基本类型字段,直接传值反而更快,而且能避免指针逃逸到堆上带来的GC压力。
还有一些工程上的约定值得遵守。比如方法接收者的选择:func (u *User) SetName()这种修改自身状态的方法应该用指针接收者,而func (u User) GetName()这种只读方法用值接收者即可。同一个类型的所有方法最好保持接收者类型一致,避免混用导致接口实现出现意外。此外,不要为了可能的性能提升而盲目使用指针,先写清晰的代码,有性能问题再用 profiling 工具定位。
最后总结一下核心要点:Go只有值传递;值类型赋值是完整拷贝,引用类型共享底层数据;传指针的本质是传递地址的副本;new返回指针而make返回初始化后的引用类型。把这些概念理顺之后,再遇到函数内修改无效、map写入panic、slice共享底层数组等问题,就能迅速定位原因了。