函数里改了,外面为什么没变?
在 Go 的初学阶段,这几乎是每个人都会撞上的一堵墙。你写了一个函数,想把配置项改一改、把用户信息补全一下、或者往切片里追加几个元素,结果函数执行得十分顺利,回到调用方一看,原来的变量纹丝不动。有人第一反应是“Go 不是有指针吗?是不是我漏了&”,也有人干脆把所有变量都改成包级全局变量,表面问题消失了,心里却始终没想明白原因。
先说结论:Go 语言的所有参数传递都是值传递,不存在 C++ 那种真正的引用传递。但“值传递”不等于“外面的变量一定不会被改”,关键要看传进去的这个“值”到底是什么。理解了这一层,指针传递和值传递的区别就只剩下一个问题:你复制的是数据本身,还是数据所在的地址。
这篇文章我会用最少的术语、最多的可运行代码,把这个困扰彻底解决。读完你会知道:为什么 int、struct 传入函数后改了不生效;为什么 slice 又表现出“部分生效”的诡异行为;以及在实际项目里,什么时候该写值,什么时候该写指针。最后给出的错误排查表和生产建议,可以直接抄进自己的代码规范里。
1. 为什么新手总在“函数里改了,外面却没变”上翻车
这个问题的普遍性超出大多数人的想象。我见过不少有一定工作经验的开发者,在评审别人代码时依然会指着某个函数说“这里直接改s就行,slice 是引用类型,外面会变的”。这种判断在小例子里碰巧成立,一旦遇到append、扩容、重新赋值,就会出现难以追踪的线上 bug。
新手在这个问题上通常有三类误操作。
第一类是把问题当作语法问题。以为只要看到“函数里改了没生效”,就机械地给参数加上*,调用时再加&。这样确实能解决一部分场景,但解决得不明不白。遇到 slice、map 时,加指针反而会造成不必要的复杂度,甚至引入 nil 指针风险。
第二类是混淆了“修改参数指向的数据”和“让参数指向新的数据”。很多人在函数里写s = append(s, x),就认为外面也会多一个元素。实际上,append可能让切片头指向一块全新的底层数组,函数内部的s对外面的s没有任何影响。
第三类是误以为“引用类型就是引用传递”。Go 里的 slice、map、channel 常被称为引用类型,于是很多人顺理成章地认为它们在函数参数中会按引用传递。这是一个非常容易踩进去的认知陷阱。Go 的官方文档和大量底层实现都表明,函数参数只存在值传递这一种模式;所谓“引用类型”的魔力,只是因为这些类型的值内部封装了指向共享数据的指针。
这个问题值得花时间彻底搞懂,是因为它不只是一道面试题。日常开发中凡是涉及配置更新、批量修改、方法接收者、接口实现,几乎都会遇到“改了没生效”的困扰。如果不理解背后的内存语义,你只能靠打日志、加全局变量、到处&来试错,效率极低,还容易在并发场景里埋下数据竞争隐患。
读完这篇文章,你至少能获得三样东西:一套判断“函数里改了要不要生效”的分析方法;五个可以直接运行的最小示例;以及在代码评审里能说清“这里为什么用指针、那里为什么不该用指针”的底气。
2. Go 传递的底层规则:所有参数都是值传递
2.1 变量、值与地址
在讨论函数参数之前,先把三个基础概念对齐。变量是一块内存区域的名字;值是这块内存区域中存放的数据;地址是这块内存区域在计算机内存中的位置编号。用&x可以拿到变量x的地址,用*ptr可以通过地址读取或者修改对应位置上的值。
这三者之间的关系非常直白。你在代码里写a := 10,相当于在内存中给a分配了一块空间,里面放了一个整数 10。你又写p := &a,相当于创建了另一个变量,里面存放的是a的地址。注意,p本身也是一个变量,也有自己的值和地址,只不过它的值碰巧是别人的地址。
2.2 函数调用时到底发生了什么
当一个函数被调用时,Go 编译器会把每个实参的值复制一份,放到函数自己的栈帧或者寄存器中。函数内部操作的是这份副本,而不是调用方原来的变量。无论你传入的是 int、string、struct,还是指针、slice、map,规则都只有一个:复制值。
这里的关键在于理解“复制值”的粒度。如果这个值是一个整数,复制 8 个字节;如果这个值是一个结构体,复制整个结构体;如果这个值是一个指针,复制地址本身,也就是 64 位系统上的 8 个字节。复制完成后,函数内部和函数外部就变成了两套独立的内存区域。除非你通过某种方式让它们指向同一块内存,否则函数内部如何折腾,都碰不到外面。
2.3 “指针传递”到底传递了什么
很多人把“指针传递”理解成一种和“值传递”并列的传参方式,这是一个需要纠正的认知。在 Go 中,不存在独立的“引用传递”;所谓指针传递,本质上是把指针这个值复制了一遍。复制完成后,函数内部的ptr和调用方的ptr是两个不同的变量,但它们存放的地址值相同,因此都指向同一块数据。
所以更准确的说法是:Go 只有值传递;当我们讨论“修改原变量”时,其实是在把“地址”作为值传入,然后通过地址去操作原变量所在的内存。这也是为什么修改*ptr外部能感知,而直接对ptr = nil赋值外部不会感知——因为后者改的是副本本身。
下面用一个最经典的最小示例来说明。
// 文件路径:main.go package main import "fmt" // modifyValue 参数是 int,函数内修改的是副本 func modifyValue(x int) { x = 100 fmt.Println("函数内部 x =", x) } // modifyPointer 参数是 *int,通过地址修改原变量 func modifyPointer(ptr *int) { *ptr = 200 fmt.Println("函数内部 *ptr =", *ptr) } func main() { a := 10 fmt.Println("调用前 a =", a) modifyValue(a) fmt.Println("值传递调用后 a =", a) b := 20 fmt.Println("调用前 b =", b) modifyPointer(&b) fmt.Println("指针传递调用后 b =", b) }运行命令:
go mod init demo go run .预期输出:
调用前 a = 10 函数内部 x = 100 值传递调用后 a = 10 调用前 b = 20 函数内部 *ptr = 200 指针传递调用后 b = 200从这个输出可以清晰看到,modifyValue(a)内部把x改成 100,回到main里a依然是 10;modifyPointer(&b)内部通过*ptr = 200改写地址指向的内存,回到main里b已经变成 200。x是a的副本,而ptr是&b的副本,但它们指向同一块内存,所以*ptr的写入能穿透到外部。
2.4 为什么 Go 不提供真正的引用传递
C++ 里可以用void f(int& x)声明引用参数,函数内部的x和外部变量在语法层面就是同一个实体。Go 没有这种设计,官方设计哲学是保持语义简单、避免调用方无法判断当前函数会不会修改自己的变量。
如果函数签名是值传递,那么调用方很容易判断:数据被复制,外部不受影响。如果签名是指针类型,一眼也能看出这里可能有修改语义。这种“显式优于隐式”的风格,让 Go 代码的可读性非常高。代价是开发者必须自己掌握指针的使用时机,而这正是本文要讲透的内容。
用一个类比来帮助记忆:值传递是“我把文件复印一份给你,你随便在上面涂改,我的原稿不会变”;指针传递是“我把保险柜钥匙的复制品给你,你打开保险柜改里面的账本,我这边看到的账本当然也变了”。关键在于你拿到的是文件的复印件,还是通向同一块数据的一把钥匙。
3. slice、map、channel:看起来像引用,实际是“包装值”
3.1 slice 的内部结构
slice 是 Go 中使用频率最高的数据结构,也是产生“引用类型等于引用传递”误解的源头。一个 slice 变量在内存中其实是一个长度为 3 个字长的描述符:第一个字是指向底层数组的指针,第二个字是长度len,第三个字是容量cap。
当函数参数是 slice 时,Go 复制的是这个 24 字节的描述符。复制后的新描述符与原来的描述符是两个独立实体,但它们内部的那个指针指向同一个底层数组。这就导致了一个非常微妙的现象:通过下标修改底层数组元素,外部能感知;通过append修改长度,外部不能感知。
3.2 修改元素和 append 为什么结果不同
直接看代码是最快的理解方式。
// 文件路径:slice_demo/main.go package main import "fmt" // changeElement 修改切片底层数组的元素,外面能看到变化 func changeElement(s []int) { s[0] = 99 fmt.Println("函数内部 s =", s) } // tryAppend 对切片做 append,函数内部长度变化,外部切片头不受影响 func tryAppend(s []int) { s = append(s, 4) fmt.Println("函数内部 s =", s) } func main() { nums := []int{1, 2, 3} fmt.Println("调用前 nums =", nums) changeElement(nums) fmt.Println("changeElement 后 nums =", nums) tryAppend(nums) fmt.Println("tryAppend 后 nums =", nums) }运行命令:
go mod init demo go run .预期输出:
调用前 nums = [1 2 3] 函数内部 s = [99 2 3] changeElement 后 nums = [99 2 3] 函数内部 s = [99 2 3 4] tryAppend 后 nums = [99 2 3]输出结果非常典型。changeElement里s[0] = 99写的是底层数组的第 0 个位置,外部nums看到的是同一块数组,所以变成了[99 2 3]。tryAppend里append后函数内部的s变成了[99 2 3 4],长度是 4;但外部nums的描述符依然是 len=3、cap=3 的副本,它的 len 根本没有变化,所以打印出来依然是[99 2 3]。
更隐蔽的是,如果原切片容量不足,append会申请一块更大的新数组,把旧数据拷贝过去,然后让函数内部的切片头指向新数组。此时连底层数组都不是同一块了,外部更不可能看到任何变化。这正是“函数里改了,外面为什么没变”在 slice 场景下最常见的答案。
3.3 map 和 channel 为什么又不一样
map 变量在 Go 内部是一个指向hmap结构体的指针。虽然语法上你写的是m := make(map[string]int),但m内部保存的是*hmap。把它传给函数时,复制的是这个指针,因此函数内对 map 的增删改查,外部完全能感知。
// 文件路径:map_demo/main.go package main import "fmt" func modifyMap(m map[string]int) { m["go"] = 100 delete(m, "java") } func main() { scores := map[string]int{"java": 80, "python": 90} fmt.Println("调用前 scores =", scores) modifyMap(scores) fmt.Println("调用后 scores =", scores) }运行命令:
go mod init demo go run .预期输出:
调用前 scores = map[java:80 python:90] 调用后 scores = map[go:100 python:90]从这里可以看出,map 传递给函数后,新增键和删除键的操作都会影响外部。channel 与 map 类似,底层是一个指向通道缓冲区的描述符,函数内通过对 channel 发送和接收数据,外部自然能感知。但需要注意,如果你对参数本身重新赋值,比如m = make(map[string]int),这同样只是修改了副本里的指针,外部不会变成空 map。
3.4 数组和结构体又是纯值
和 slice 不同,Go 的数组是纯值类型。[3]int作为参数时,整个数组会被完整复制。结构体也一样,一个包含多个字段的 struct 传参会整体复制一份。如果结构体很大,这种复制会产生明显的性能开销。我们可以用一个结构体示例验证。
// 文件路径:struct_demo/main.go package main import "fmt" type User struct { Name string Age int } // updateUserByValue 值传递,修改的是副本 func updateUserByValue(u User) { u.Name = "内部修改" fmt.Println("函数内部 u =", u) } // updateUserByPointer 指针传递,直接修改原结构体 func updateUserByPointer(u *User) { u.Name = "指针修改" fmt.Println("函数内部 u =", u) } func main() { u := User{Name: "张三", Age: 30} fmt.Println("初始状态 u =", u) updateUserByValue(u) fmt.Println("值传递后 u =", u) updateUserByPointer(&u) fmt.Println("指针传递后 u =", u) }运行命令:
go mod init demo go run .预期输出:
初始状态 u = {张三 30} 函数内部 u = {内部修改 30} 值传递后 u = {张三 30} 函数内部 u = {指针修改 30} 指针传递后 u = {指针修改 30}这个例子非常直观。updateUserByValue(u)传入的是整个结构体的副本,函数里把副本的Name改成“内部修改”,外部u依然是“张三”。updateUserByPointer(&u)传入的是结构体地址的副本,通过u->Name的语法直接改到了原结构体上,外部u变成了“指针修改”。
到这里,我们可以把 Go 的数据类型粗略分成两类:一类是 int、string、数组、结构体这种“纯值类型”,传递时复制整个数据;另一类是 slice、map、channel、函数、接口这种“内部包含指针或引用字段的类型”,传递时复制的是外层描述符,但描述符指向的底层数据可能被共享。理解这个分类,是判断“外面会不会变”的核心方法论。
4. 完整示例:从错误代码到正确代码
4.1 一个典型的业务场景
假设你正在写一个用户批处理服务。有一个UpdateUser函数,需要把传入的用户年龄改成新的值,并且要求在调用方拿到修改后的结果。这是非常常见的需求,比如批量导入、定时任务更新、请求处理链路中的中间修改。
很多初学者会写出下面这样的代码,然后在测试时发现年龄根本没变。
4.2 错误写法:值传递导致修改失效
// 文件路径:business_demo/wrong/main.go package main import "fmt" type User struct { Name string Age int } // updateAge 值传递,函数内修改的是副本 func updateAge(u User, newAge int) { u.Age = newAge fmt.Println("函数内部 u =", u) } func main() { u := User{Name: "张三", Age: 30} fmt.Println("调用前 u =", u) updateAge(u, 35) fmt.Println("调用后 u =", u) }运行后输出:
调用前 u = {张三 30} 函数内部 u = {张三 35} 调用后 u = {张三 30}函数内部确实把年龄改成了 35,但调用方u还是 30。原因就是updateAge的参数是没有加*的User,整个结构体被复制了一份,函数内部改的是副本。
4.3 正确写法:指针参数或返回新值
针对这个场景有两种修复方案。第一种是改指针参数,直接修改原变量;第二种是保留值传递,但让函数返回修改后的新结构体,调用方接收返回值。两种方式都符合 Go 的惯用风格,区别在于语义表达。
// 文件路径:business_demo/right/main.go package main import "fmt" type User struct { Name string Age int } // updateAgeByPointer 指针参数,直接修改原变量 func updateAgeByPointer(u *User, newAge int) { u.Age = newAge } // updateAgeWithReturn 值传递,返回新结构体 func updateAgeWithReturn(u User, newAge int) User { u.Age = newAge return u } func main() { u1 := User{Name: "张三", Age: 30} updateAgeByPointer(&u1, 35) fmt.Println("指针参数修改后 u1 =", u1) u2 := User{Name: "李四", Age: 28} u2 = updateAgeWithReturn(u2, 40) fmt.Println("返回值方式修改后 u2 =", u2) }运行命令:
go mod init demo go run .预期输出:
指针参数修改后 u1 = {张三 35} 返回值方式修改后 u2 = {李四 40}两种方式都有效果。第一种强调“操作同一个对象”,适合对象生命周期比较长、需要被多处共享修改的场景。第二种强调“数据转换”,每次返回一个新值,适合不可变风格的代码。具体用哪一种,取决于团队风格和业务语义。但你应该清楚地知道,前者走的是“复制地址、修改共享内存”,后者走的是“完整复制、返回值覆盖”。
4.4 方法的接收者也要注意值和指针
结构体方法的接收者同样分值和指针两种。值为接收者时,方法内修改的是接收者的副本;指针为接收者时,修改会直接作用于原对象。
// 文件路径:method_demo/main.go package main import "fmt" type Counter struct { Value int } // Increment 使用指针接收者,修改会生效 func (c *Counter) Increment() { c.Value++ } // Reset 使用值接收者,修改不会生效 func (c Counter) Reset() { c.Value = 0 } func main() { c := Counter{Value: 10} fmt.Println("初始 Value =", c.Value) c.Increment() fmt.Println("Increment 后 Value =", c.Value) c.Reset() fmt.Println("Reset 后 Value =", c.Value) }运行命令:
go mod init demo go run .预期输出:
初始 Value = 10 Increment 后 Value = 11 Reset 后 Value = 11注意这里Reset是值接收者。虽然它在内部把c.Value改成 0,但因为c是副本,外部Value依旧是 11。如果期望把计数器清零,就必须把Reset改成指针接收者。这也是生产代码里经常出现的 bug:方法签名看起来对,行为却不符合预期,根源就是接收者类型选择错误。
5. 运行结果与效果验证
5.1 环境准备与运行方式
本文所有示例都只需要一个 Go 环境,不依赖任何第三方库。你先确认本地已经安装 Go,然后执行go version查看版本。版本只要是 1.17 以上,都可以直接运行;即使是新版本,也完全兼容这些示例。
运行步骤如下:
mkdir pointer-demo cd pointer-demo go mod init demo # 把上面的某段示例代码保存为 main.go go run .go mod init demo的作用是创建一个临时的模块文件,让 Go 在模块模式下正常工作。这一步在大多数版本的 Go 中是必须的,如果你跳过它直接go run main.go,在某些环境里会遇到 “go.mod file not found” 的错误提示。
5.2 如何判断“改对了”
判断一个函数是否真的改变了外部变量,最直接的方法是在调用前后分别打印变量,然后对比输出。不要只盯着函数内部是否“自我感觉成功”,比如下面这段错误判断:
func updateAge(u User, newAge int) { u.Age = newAge fmt.Println("更新成功") // 这里打印成功,外部不一定成功 }正确的验证方式是:
fmt.Println("调用前:", u) updateAge(&u, 35) fmt.Println("调用后:", u)如果调用前后输出一致,说明参数不是指针,或者你修改的只是副本。如果调用后变化了,说明修改穿透到了原变量。对于 slice,还要额外对比len和cap:
fmt.Printf("调用前 len=%d cap=%d data=%v\n", s, cap(s), s) appendSomething(s) fmt.Printf("调用后 len=%d cap=%d data=%v\n", s, cap(s), s)这样能帮你快速定位是底层数组被修改,还是切片头长度发生了变化。
5.3 失败时的第一步排查方向
如果你发现“函数里改了,外面没变”,先不要急着加&。按下面顺序排查:第一步,看函数的参数类型是什么,是User还是*User;第二步,看调用时是否传了&u;第三步,看函数内部是直接修改字段,还是把参数重新赋值了;第四步,如果是 slice,确认你操作的是s[i]还是s = append(s, x)。大部分问题都出在这四步中的某一步。
6. 常见问题与排查思路
把上面所有代码示例对应的坑整理成一张表,方便你在开发中直接对照。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 传入 int/string 修改后外面没变 | 基础类型是值传递,函数内修改的是副本 | 检查函数参数是否加了* | 改成指针参数传&x,或让函数 return 新值 |
| slice 在函数里 append 后外面长度没变 | slice 头被复制,append 只改变函数内副本的 len | 打印函数内外 slice 的 len 和 cap | 使用s = appendSlice(s)接收返回值 |
| slice 元素在函数里修改后外面变了 | 复制的是 slice 头,底层数组被共享 | 确认是否通过s[i]修改 | 若不想影响外部,先copy一份再操作 |
| 结构体方法改了字段没生效 | 方法使用了值接收者,接收者是副本 | 查看方法接收者是(u User)还是(u *User) | 改成指针接收者 |
| map 在函数里增删键值,外部却变了 | map 底层是指针封装,复制的是指针 | 打印 map 地址或做隔离测试 | 若需隔离,必须新建 map 手动逐项拷贝 |
| 大结构体每次调用都明显变慢 | 值传递导致整块结构体被复制 | 用go test -bench对比指针与值 | 改指针参数或缩小结构体体积 |
| 函数内对参数赋 nil,外部没变 | 指针本身是值复制,改副本不影响外部 | 打印函数内外指针地址 | 如需清空外部变量,用*ptr = nil或重新设计 |
这张表的精髓在于区分两类操作:一类是“穿过指针改共享数据”,另一类是“改指针这个副本本身”。前者外部能看到,后者外部永远看不到。所有排查工作都可以围绕这个二分法展开。
还有两个高频问题值得单独补充。第一个是“给 slice 传了指针为什么 append 还是不对”。答案是你需要传入二级指针**[]int,或者在函数内通过*s = append(*s, x)修改,再或者在函数外部接收返回值。日常开发更推荐返回值方式,语义清晰,也不容易出现嵌套指针。
第二个是“map 传进函数后为什么一定能改”。本质上是因为 map 变量本身就是*hmap,你传给函数的那个值是指针,通过指针修改哈希表自然是全局生效的。但如果你把m重新赋值成新 map,外部不会感知,这一点和普通指针参数完全一致。
7. 最佳实践:什么时候该用指针,什么时候该用值
7.1 三个判断标准
在实际项目里,遇到一个参数,先问自己三个问题:这个函数需不需要修改原始数据?这个数据体量是不是很大?这个数据是否需要表达“同一实体”的语义?
如果第一个问题的答案是“需要”,优先考虑指针参数。如果第二个问题的答案是“大”,也建议用指针避免复制成本。如果第三个问题的答案是“是”,比如业务上它就是同一个用户、同一个订单,那么指针更贴近语义。如果三个答案都不确定,从值传递开始,能不改就不改,保持不可变风格,是更稳妥的默认选择。
7.2 推荐使用指针的场景
需要修改调用方持有的原始数据时,指针是唯一正解。这里的典型场景包括:配置对象需要在多个函数之间逐层修改;结构体方法需要更新其内部状态;造函数或初始化函数需要构建一个长期存活的实体对象。另一个常见场景是结构体很大,比如包含大量字符串、切片、嵌套结构体时,值传递会复制整个对象,而指针复制只需要 8 个字节。注意,这里说“大”并没有绝对数值,一般超过 64 字节或者包含多个 slice/map 字段,就值得考虑指针。
7.3 推荐使用值的场景
数量很小、不要求修改原数据、语义上只是“读一下”的参数,用值传递更安全。基础类型天然是值传递,不需要刻意改成指针。小结构体、只读的配置值、一次性计算输入的 DTO,也建议用值。值的优势在于避免共享状态,函数之间完全隔离,并发场景下不容易发生数据竞争。
值得注意的是,不要为了“省内存”而到处使用指针。指针本身有成本,它可能影响 Go 的逃逸分析,导致对象被分配到堆上而不是栈上,反而增加 GC 压力。性能问题要先用基准测试确认,再用数据驱动优化,而不是靠“感觉”。
7.4 工程层面的其他建议
第一个建议是保持方法接收者类型一致。一个类型既不要用指针接收者实现一个方法、又用值接收者实现另一个方法。这个习惯容易让调用方困惑:到底这个类型的方法会不会修改自身?最佳做法是,如果一个类型有任何一个指针接收者方法,就统一用指针接收者实现所有方法。
第二个建议是养成 slice 的返回值习惯。凡是可能发生append的函数,都要把修改后的 slice 作为返回值返回。这是 Go 标准库和社区普遍认可的风格,也是避免“append 后长度没变”问题的最简单手段。
第三个建议是提前做好 nil 检查。指针参数可以为 nil,函数入口处最好判断一下,尤其是方法接收者。否则一旦空指针调用字段或方法,直接 panic。检查后可以返回错误,也可以做默认值兜底。
第四个建议是重视并发安全。指针传递意味着多个 goroutine 可能同时访问同一块内存。如果确实需要共享修改,必须加锁或者使用原子操作;如果只是只读共享,也要确保没有任何一方在写。值传递天然隔离,是并发安全的优先选择。
8. 总结与下一步可以练什么
现在再回头看那句“函数里改了,外面为什么没变”,答案已经非常清晰:Go 的所有参数传递都是值传递,但“值”的粒度不同。int、struct、数组传的是完整数据副本,外面的世界当然不会变;slice 传的是描述符副本,但描述符里的指针可能共享底层数组,所以改元素外部会变、append 外部不会变;map、channel 传的本身就是指针的封装,所以常规增删改外部全都能感知。
你只需要记住一句话:在函数里,修改“参数的属性”不一定生效,修改“参数指向的对象”一定生效。前者比如x = 100、s = append(s, v)、u = User{...},后者比如*ptr = 100、s[0] = 99、m["k"] = v。把这句话贯穿到每一次参数设计中,这个坑就算彻底填平了。
如果还想加深理解,建议接下来做三个练习。第一个练习:写一个函数,分别用值接收者和指针接收者实现同一个SetName方法,对比调用结果,确认你理解了方法接收者。第二个练习:写一个包含 append 的 slice 函数,分别用返回值方案和二级指针方案实现,对比两种写法的可读性。第三个练习:用 100 万次循环调用一个包含大结构体的函数,对比值传递和指针传递的性能差异,验证你对该用指针还是该用值的判断。
这三个练习做完,你对 Go 参数传递的掌握基本可以达到面试和日常开发的要求。再往后,可以继续深入 Go 的内存模型、接口内部结构、切片扩容机制,以及go vet、go test -race这些工具如何帮助你在代码里提前发现相关隐患。数据结构是 Go 语言的地基,参数传递是地基里的钢筋,这块弄扎实了,后面写并发、写框架都会顺畅很多。
建议把本文的对比表和判断标准收藏起来,写代码前扫一眼,能少踩很多坑。如果你在实际项目中遇到过更离奇的“改了没生效”案例,也欢迎在评论区分享原因,一起把这类问题彻底消灭。