值语义:Go 默认复制
这是你 Kotlin 直觉翻车最狠的一章。在 Java / Kotlin 里,除了几个基本类型,你手里的对象变量几乎全是引用——传给函数、赋给别人,大家指的是同一个对象。Go 把这个默认反过来了:赋值是复制、传参是复制、struct 是值、连数组都是值。搞不清这一点,你会写出一类特别隐蔽的 bug:改了半天,原对象纹丝不动。
一段会让你困惑的代码
先看一段,猜猜输出:
type User struct {
Name string
}
func rename(u User) {
u.Name = "改了" // 在函数里改
}
func main() {
u := User{Name: "原名"}
rename(u)
fmt.Println(u.Name) // 输出什么?
}
如果你的直觉说「改了」—— 那是你的 Java / Kotlin 直觉。Go 的答案是「原名」。那次修改,改的是一份复制品,跟 main 里的 u 毫无关系。
为什么?因为在 Go 里,rename(u) 这个调用,把 u 整个复制了一份传进函数。函数里的 u 和 main 里的 u 是两个独立的 User,各占各的内存。函数改自己那份,main 那份当然不动。
在 Go 里,赋值、传参、返回、range 遍历 —— 只要是「把一个值给到另一个地方」,默认都是复制一整份。没有例外的「对象引用」这回事。
你写 b := a,b 是 a 的完整副本;你把结构体传进函数,函数拿到的是副本;你从函数返回一个结构体,返回的也是副本。Java / Kotlin 里「变量指向堆上对象」的心智模型,在 Go 里要换成「变量就是那个值本身」。
那怎么才能真的改到原对象?用指针
你当然经常需要「改到原对象」。Go 的做法是显式地传一个指针:
func rename(u *User) { // 参数类型是 *User:指向 User 的指针
u.Name = "改了" // 通过指针改,改的是它指向的那个
}
func main() {
u := User{Name: "原名"}
rename(&u) // 传 &u:u 的地址
fmt.Println(u.Name) // 这次输出「改了」
}
两个新符号,就这两个,Go 的指针没有别的花样:
&u:取u的地址,得到一个*User指针。读作「取 u 的地址」。*User:类型,表示「指向 User 的指针」。*p(对一个指针变量)表示「解引用,拿到它指向的值」。
有个贴心的小设计:通过指针访问字段时,你不用写 (*u).Name,直接写 u.Name,Go 自动帮你解引用。所以上面函数体里直接 u.Name = ... 就改到了原对象。
值还是指针,成了你每天都要做的选择
因为默认是复制,「这里该传值还是传指针」变成了 Go 程序员天天要拿的主意。这在 Java / Kotlin 里根本不是个问题(反正都是引用),在 Go 里却处处要想。基本判断:
| 你想要 | 用 | 为什么 |
|---|---|---|
| 函数要修改调用方的数据 | *T 指针 | 不然改的是副本 |
| 结构体很大,复制成本高 | *T 指针 | 只复制一个地址,不复制整块 |
| 只读的小结构体 | T 值 | 简单、安全、无别名,复制很便宜 |
| 基础类型(int / string / bool) | T 值 | 本来就小,复制无所谓 |
一个常见的困惑:既然指针能改、又省复制,为什么不永远用指针?因为值有值的好 —— 一个值传出去,你就知道别人绝对改不到你的原件(没有别名),代码更好推理;而且小结构体的复制在现代 CPU 上几乎免费。Go 的风气是:能用值就用值,需要「共享可变状态」或「结构体确实大」时才用指针。
连数组都是值 —— 这个真的会咬你
最反直觉的一条:Go 的数组是值类型。把一个数组赋值或传参,是把整个数组复制一遍:
a := [3]int{1, 2, 3}
b := a // 复制!b 是独立的一份
b[0] = 99
fmt.Println(a) // [1 2 3] —— a 完全没变
fmt.Println(b) // [99 2 3]
在 Java 里 int[] b = a 之后,b 和 a 是同一个数组,改 b 就改了 a。Go 完全相反。这几乎肯定会咬你一次。
不过,你在 Go 里其实很少直接用数组 —— 因为长度固定([3]int 和 [4]int 是不同类型!)。日常用的是切片 []int,它的行为又不一样(切片内部含指针,复制切片头不复制底层数组)。数组和切片这个「一个是值、一个含引用」的微妙差别,正是下一章切片那些坑的根源,我们下章细讲。这里你只要先记住:纯数组 [N]T 是彻底的值,复制就是深复制。
听到「指针」你可能会紧张 —— C 的指针又是算术又是野指针又是段错误。Go 保留了指针,但删掉了危险的部分:没有指针算术(不能 p+1 乱跳)、不能把整数硬转成指针(正常代码里)、有垃圾回收所以不用手动 free、解引用 nil 指针会干净地 panic 而不是神秘地崩溃。
所以 Go 的指针其实很温顺 —— 它就是「一个指向某个值的地址」,用来表达「我要共享 / 修改这个东西,而不是复制它」,仅此而已。你可以放心用,它不会给你 C 那种玄学。
Kotlin 对照
Java / Kotlin 的模型是「引用默认」:除了 Int / Boolean 这类基本类型,你手里的每个对象变量都是一个指向堆上对象的引用。传参传的是引用,两个变量能指向同一个对象。你几乎不用想「这是值还是引用」,因为答案总是引用。你想要「一份独立的副本」时,反而要费劲 —— .copy()、深拷贝、防御性复制。
Go 的模型是「值默认」:反过来。默认复制,想共享 / 修改才显式加指针 * 和 &。好处是「谁能改到这个数据」变得一目了然 —— 看签名有没有 * 就知道。坏处是你得时时刻刻做这个选择,忘了加指针就白改一场。
迁移时最容易犯的错,就是带着「反正传的是引用」的手感写 Go —— 把结构体传进函数想改它、忘了加 *,然后对着「怎么没改成」抓头。看到「改了不生效」,第一反应就该是:我是不是传了值、该传指针?这个条件反射建立起来,你就过关了。
这一章的一句话
Go 默认一切都是复制(赋值、传参、返回、数组都是值),要共享或修改原数据得显式用指针 *T 和 &;「该传值还是传指针」因此成了你天天要做的选择,而 Go 的指针删掉了 C 的所有玄学,只剩「一个安全的地址」。
下一章,我们把结构体本身讲透 —— 它是 Go 组织数据的核心,而它长得和你熟的 data class 既像又非常不像。