卷 II · 值CH 05深度 05/24

值语义: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 的默认:一切都是复制

在 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 对照

⇄ 引用默认 vs 值默认

Java / Kotlin 的模型是「引用默认」:除了 Int / Boolean 这类基本类型,你手里的每个对象变量都是一个指向堆上对象的引用。传参传的是引用,两个变量能指向同一个对象。你几乎不用想「这是值还是引用」,因为答案总是引用。你想要「一份独立的副本」时,反而要费劲 —— .copy()、深拷贝、防御性复制。

Go 的模型是「值默认」:反过来。默认复制,想共享 / 修改才显式加指针 *&。好处是「谁能改到这个数据」变得一目了然 —— 看签名有没有 * 就知道。坏处是你得时时刻刻做这个选择,忘了加指针就白改一场。

迁移时最容易犯的错,就是带着「反正传的是引用」的手感写 Go —— 把结构体传进函数想改它、忘了加 *,然后对着「怎么没改成」抓头。看到「改了不生效」,第一反应就该是:我是不是传了值、该传指针?这个条件反射建立起来,你就过关了。

这一章的一句话

Go 默认一切都是复制(赋值、传参、返回、数组都是值),要共享或修改原数据得显式用指针 *T&;「该传值还是传指针」因此成了你天天要做的选择,而 Go 的指针删掉了 C 的所有玄学,只剩「一个安全的地址」。

下一章,我们把结构体本身讲透 —— 它是 Go 组织数据的核心,而它长得和你熟的 data class 既像又非常不像。