1. 一个改不动的swap函数:传值传引用最容易翻车的第一现场
带新人入门时,我习惯先让他们写一个交换函数试水。不多解释,就一句话:"把两个数字交换一下。"于是经常看到一个结构上挑不出毛病的版本:
public static void swap(int a, int b) { int tmp = a; a = b; b = tmp; } public static void main(String[] args) { int x = 1; int y = 2; swap(x, y); System.out.println(x + ", " + y); // 依然是 1, 2 }代码逻辑完全正确:先用tmp暂存a,然后a等于b,b等于tmp。但运行结果就是交换失败。再把同样的函数用C语言写一遍:
void swap(int a, int b) { int tmp = a; a = b; b = tmp; }结果依然不变。只有改成指针版本才有效:
void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; } swap(&x, &y);这个现象背后就是本文的核心:传值(pass by value)与传引用(pass by reference)。我很早就想写一篇把这件事讲透的文章,这次借着标题里的DS-1,做成系列第一篇。它覆盖C、C++、Java、Python、JavaScript这些主流语言的差异,也会给出实战排查思路,适合刚入门的学生,也适合写了几年业务代码、碰到"对象被函数悄悄改掉"就头疼的朋友。
1.1 传值到底传了个什么
传值的本质是复制。函数调用时,实参的数据会被完整拷贝一份,放到新分配的内存空间里,函数内所有操作都是对着这个拷贝进行的,原变量和它唯一的联系只是"最初的值恰好一样"。等函数返回,这份拷贝直接销毁,外边变量的内存从头到尾没被动过。
可以这样类比:你给后厨抄了一份菜谱,后厨按抄本做菜,哪怕他改配方、换调料,你手里的原版菜谱也都不会变。为什么swap失败?因为函数交换的是两份拷贝,x和y的真实内存位置,函数根本够不着。
1.2 传引用则是共享同一块内存
传引用时,函数拿到的不是一份拷贝,而是实参的"门牌号"。顺着门牌号找到真实变量,在它身上改动,你回头看原变量自然也就变了。比如上面的C指针版swap,传给函数的是x和y的地址,*a = *b是在x的内存里写入y的值,等于直接在原变量上操作。
这里先建立两个概念,否则后面容易乱:**"引用"既可以指一种语法(比如C++的&),也可以指一种数据形态(比如指针存地址)。**传引用描述的是一种参数传递语义,意思是形参和实参绑定到同一块存储位置。理解这一层之后,再看各语言的表现就不会被各种术语绕晕。
2. 值语义与引用语义的底层差异
从定义背概念很容易忘,我更推荐从内存模型入手。传值和传引用的表现为什么不同,归根结底要回到数据在内存里是怎么存放的。
2.1 值类型和引用类型在内存里的样子
程序运行时的内存大头分为栈和堆两块。int、double、bool这类小体积数据,绝大多数情况下直接存在栈上:变量名对应的栈内存里放的就是数值本身。比如int n = 7,n所在的那几个字节里写的就是数字7。
引用类型的变量则不一样。比如Java的String、ArrayList,C语言的指针,变量所在的内存里存的不是最终数据,而是一个地址——这个地址指向堆上的真实对象。你关心的数据在堆上,变量只是一个寻址线索。
参数按值传递时,复制的内容可能是"数字7"这样的小数据,也可能是"一个地址值"。这个区别极其关键:**复制地址和复制整个对象,完全是两回事。**如果形参是引用类型,函数拷贝进来的是地址值,实参和形参各持一个相同的地址,共同指向同一个堆对象;如果函数的参数是按值传递一个对象(典型是C++里不加引用直接传struct或class),那就是把整个对象逐字节复制一份,新旧对象彻底独立。
2.2 传指针也算传值,为什么能改外部
这是新手最常误解的一点:"C语言不是只能传值吗?我用指针传进去,不是能改外部变量吗?"答案是,传指针依然是传值,只不过这个值恰好是一个地址。
假设有函数void change(int *p),参数传递时,复制的是地址值,p和调用方传进来的&x指向同一个位置。你写*p = 100,是顺着地址找到x并写入100,所以外部能看到变化。但如果你写p = &y,只是让这个局部指针变量换个指向,对外部那个指针变量本身没有影响——除非你传的是二级指针。这就是链表相关代码里经常要用二级指针接收头节点,或者干脆返回新头节点的原因。
C++的引用语法把这种语义包装得干净很多。int& r = n;之后,r就是n的别名,在r上做的任何操作都是在操作n。从编译生成的代码看,引用底层通常也是靠存地址实现的,但语言规则上它和指针有明显差异:引用必须在定义时初始化、之后不能改绑其他变量、语法上像普通值变量一样使用不需要解引用、不存在"空引用"。如果把指针比作手机号——可以换号、可以不存号码,那么引用更像身份证号——绑定一个人后始终是他。
2.3 数组、容器传参的"假象"
Java里写void test(int[] arr),然后在函数内执行arr[0] = 99,外面的数组确实变了。很多人因此说"Java对数组是引用传递"。严格看,arr是引用类型,函数复制的是引用地址,通过地址往同一块堆内存里写数据,外面自然能看到。但如果在函数里执行arr = new int[]{1,2,3},只是让形参指向一个新数组,外部变量依然指向旧数组。
**能改的是"引用指向的那个对象",改不了的是"外部那个引用本身"。**这句话几乎适用于所有按"共享引用"传参的语言,包括Python和JavaScript。记住它,能解决日后大多数困惑。
3. 各语言里"传引用"的真面目:一半是幻觉,一半是真的
先说结论:真正提供"引用传递"语法的,是C++的引用参数,以及C#带ref/out的参数。Java和Python普遍表述为"值传递"或"对象引用传递",JavaScript更准确叫共享传参(call by sharing),效果接近引用,但有明确边界。
3.1 Java:都是值传递,问题出在"引用值"三个字
Java官方文档对参数传递的表述很明确:Java始终按值传递。基本类型传的是数值本身;对象传的是"引用值",即地址副本。因此swap(int a, int b)换不掉外部整数,swap(Person a, Person b)也换不掉外部Person变量,但函数里修改对象字段,外部一定看得到。
class Person { String name; Person(String name) { this.name = name; } } static void reassign(Person p) { p = new Person("小王"); // 只是让形参p指向新对象 } static void changeName(Person p) { p.name = "小张"; // 修改堆上的真实对象 } Person a = new Person("小李"); reassign(a); System.out.println(a.name); // 小李 changeName(a); System.out.println(a.name); // 小张想验证"形参和实参指向是不是同一个对象",可以在方法内外都打印System.identityHashCode,编码一致就说明是同一个对象,重新赋值后编码就会变。这是排查共享引用问题时很实用的调试手段。
3.2 Python:可变与不可变是分水岭
Python函数传参常说成"传对象引用"或"按对象传参",本质是变量名引用了对象。对不可变对象(int、str、tuple、frozenset),函数内无法原地改变,任何"修改"都会构造新对象并让形参指向它,外部引用不变;对可变对象(list、dict、set),就地操作会真实影响外部。
典型翻车案例是可变默认参数:
def add_item(item, items=[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2]原因是默认参数在函数定义时只创建一次,之后每次调用不传第二个参数,用的都是同一个list对象。修法是用None作为默认值,在函数体内新建列表:
def add_item(item, items=None): if items is None: items = [] items.append(item) return items这个坑无数次出现在实际项目中。我还见过有人因为它,把函数内append改成items = items + [item],结果列表确实不复用了,但每次生成新对象、外部变量不更新,反而引入性能开销。对Python而言,判断标准很简单:是否原地修改对象。append、sort、dic[key]=value是原地修改;=、+=(对不可变对象)是重新绑定。
3.3 JavaScript:原始类型走值,对象走共享
JavaScript的行为和Java高度相似。数字、字符串、布尔按值传递;对象、数组、函数按共享引用传参。看这个例子:
function addProp(obj) { obj.count = 1; // 外部对象被加了属性 } function reset(obj) { obj = { count: 0 }; // 只是让形参obj指向新对象 } const data = { name: 'A' }; addProp(data); console.log(data.count); // 1 reset(data); console.log(data.count); // 依然是 1前端框架里常见的坑就源于此:组件props传入的对象如果被直接修改,会破坏单向数据流。所以约定是props只读,需要变更必须先拷贝一份再操作。需要注意的是,对象的"拷贝"还分浅拷贝与深拷贝:Object.assign和展开运算符...都是浅拷贝,只复制一层属性;属性值还是对象时,深层仍共享。处理嵌套数据时,要么用深拷贝工具,要么用不可变数据结构库,否则还是会踩共享引用的坑。
3.4 C++:传值、传指针、传引用三选一
C++把三种写法摆在一起,语义差异最直观:
void f1(int x) { x = 100; } // 传值:外部无感 void f2(int *x) { *x = 100; } // 传指针:解引用后修改外部 void f3(int &x) { x = 100; } // 传引用:x就是外部变量 int n = 0; f1(n); // n = 0 f2(&n); // n = 100 f3(n); // n = 100引用和指针的差异前面说过了,在实际工程里的选型倾向大致是:小对象(数值、小结构体)直接传值,简单也不容易出错;大对象作为只读参数时,优先const T&,避免无谓拷贝;需要修改外部变量时,能用引用就用引用,指针留给"可能为空"或者"需要切换指向"的场景。C++11之后引入了移动语义,大对象传值也未必一定拷贝,但那是另一个话题,初学阶段先别混合理解。
前面表格我直接放在这里对比各语言行为会更直观:
| 语言 | 基本类型 | 对象/容器 | 函数内能否改变"外部变量指向" |
|---|---|---|---|
| C | 传值(数值) | 传指针值,可间接修改 | 需要二级指针或返回新值 |
| C++ | 传值/引用/指针均可 | 引用/指针可直接修改 | 指针的指针或引用套引用(很少用) |
| Java | 传值 | 引用副本,共享对象 | 不能,只能改对象内容 |
| Python | 传引用副本(不可变) | 可变对象可原地修改 | 不能 |
| JavaScript | 传值 | 共享引用 | 不能 |
| Go | 传值 | 切片/map是引用语义,底层共享 | 需要显式指针 |
Go的slice最容易被误判:函数以slice为参数时,内部对元素的修改会反映到外部数组,因为slice底层结构里含一个指向数组的指针,传给函数时这个结构被复制,但指针仍指向同一块数组。不过append不一定影响外部,因为它可能触发扩容导致底层数组更换。这和Java"引用副本共享对象"本质是相通的,只是在细节上多了一层。
4. 实战排查:一次参数被"悄悄改动"的定位过程
理论举再多例子,不如来一段真实场景。我简化的这个排查过程,来自一次内部服务调试:配置对象明明只在启动时加载,运行一段时间后却多出一堆白名单路径。
4.1 问题表现:配置列表越变越长
当时某个模拟项目X里,配置类大致长这样:
class AppConfig { List<String> allowedPaths = new ArrayList<>(); boolean debugMode = false; }服务启动时读取配置文件,各种业务模块共享同一个AppConfig实例。某天测试反馈:打开调试模式后,allowedPaths里的白名单条目越来越多,访问控制的判定结果也随之错乱。查看配置文件,原始记录只有三条,运行时必然有代码在持续向这个list追加内容。
4.2 排查链路:从现象到共享引用的确认
完整排查大致走了这么几步。第一步,给AppConfig加日志,打印allowedPaths的size和System.identityHashCode(this),确认是不是始终同一个对象。第二步,用IDE的Find References功能搜allowedPaths的所有增删改操作,把候选位置圈出来。这里我踩过一个教训:不要只搜业务代码,也要搜测试代码和工具类,不然容易漏掉一些"辅助方法"里的修改点。
第三步,逐个检查候选函数的签名。凡是形参类型是AppConfig或者List的,重点是看函数体里有没有add、set、remove这类操作。很快锁定了嫌疑方法:
void mergeExtraPaths(AppConfig cfg) { if (cfg.debugMode) { cfg.allowedPaths.add("/api/profile"); cfg.allowedPaths.add("/api/export"); } }这个方法的本意是"调试模式下追加两条路径",单看逻辑没问题。问题出在调用位置被写进了循环里,每次循环都往同一个共享list对象追加,于是白名单越来越长。写这个方法的人没有意识到cfg是全局共享对象,他脑子里默认"参数进来了就是给我临时用的"。
4.3 修复与防御:副本隔离和只读封装
修复方式很简单,把调用方改成只调用一次,或者在方法内做防御性拷贝,先建新list再赋值给cfg对象。但更彻底的做法,是让getter对外只暴露只读视图,把修改操作收口成显式方法:
private List<String> allowedPaths = new ArrayList<>(); public List<String> getAllowedPaths() { return Collections.unmodifiableList(allowedPaths); } public void replacePaths(List<String> newPaths) { this.allowedPaths.clear(); this.allowedPaths.addAll(newPaths); }这样外部代码想往allowedPaths里直接add,编译期就报错了。把共享引用带来的隐性风险变成显性的编译错误,是我在项目里最推荐的做法。排这类问题时我慢慢形成了一套心法:先判断"这个对象有多少持有者",再判断"当前函数是否在这些持有者的共享路径上"。一个对象被多个方法、多个线程共享时,任何接受该对象作为参数且内含修改操作的方法,都可能是"悄悄改动"的源头。排查顺序应该是:搜对外暴露内部集合的getter,再搜形参类型是集合/容器的函数签名,而不是漫无目的地通读所有代码。
5. 判断方法、设计建议与一段可复用的自检清单
最后这部分,我拆成两个实际有用的模块:拿到一个不熟悉的函数时怎么快速判断传参语义,以及日常写代码怎么设计才能少踩引用共享的坑。
5.1 三分钟判断一条函数调用的传参语义
我自己总结了一个三步快检法,遇到新代码就直接套。
第一步,看函数签名。形参是原始值类型(int、char、double)或值语义的struct/class,很可能是传值;形参带*、&、ref、out,或者形参是引用类型(Java的类、Python的可变容器、JavaScript的对象),需要继续往下看。
第二步,看函数体内怎么使用参数。如果执行的是"重新指向"操作,比如Java里p = new Person()、Python里x = [1]、JavaScript里obj = {},外部不受影响;如果执行的是"原地修改"操作,比如p.name = ...、x.append(...)、obj.count = ...,外部会看到变化。
第三步,看编译期保护。C++函数参数加const,Java局部变量加final,JavaScript用const声明对象,都能在出现误改意图时尽早报错。一个不需要修改入参的函数,显式加只读约束,就是最好的文档。
5.2 设计建议:宁可多拷贝,不要共享引用
长期写代码,我偏向"不可变优先,浅复制兜底,显式接口收口"这三条策略。
- 不可变优先:能声明只读就声明只读。Java集合用不可变包装,C++用const引用,Python用tuple代替list,JavaScript对对象用
Object.freeze。不可变对象天然免疫引用共享问题,少一类bug就少花一大笔排查时间。 - 浅复制兜底:当你不得不把可变容器传给可能修改它的代码时,先复制一份副本再传。Java里
new ArrayList<>(src),Python里src[:],JavaScript里[...arr]。浅复制只复制容器壳,元素仍是共享的,但对"增删元素"这一层隔离已经足够;需要深层隔离时再考虑深拷贝,深拷贝成本高,别无脑滥用。 - 显式接口收口:对外不要直接暴露内部可变字段,提供add、remove、replace这类方法,让修改路径收敛在少数可控范围内,防止多个调用方各自拿着引用乱改。
另一件容易被忽略的事是线程安全。共享引用加上并发修改,问题会指数级放大——典型的就是ConcurrentModificationException或脏数据。当你发现某个共享集合在并发场景下被反复修改,优先考虑用不可变快照或拷贝传参,别指望靠加点锁来兜住所有调用方。
5.3 给新人的一个小实验:十分钟验证传参语义
只看文章很难真正记住边界,我建议你花十分钟做个实验。用你最熟悉的语言写一个方法,分别做三件事:
- 参数是int数值,方法内给参数重新赋值。
- 参数是对象/数组/list,方法内给参数重新赋值一个新对象。
- 参数是对象/数组/list,方法内修改参数对象内部的一个字段或元素。
每做完一步,在外层打印原始对象。你会发现第1种和第2种外部大多不变,第3种外部变了。做实验时建议在打印时带上对象的标识值:Java用System.identityHashCode、Python用id()、JavaScript可以给对象临时挂一个唯一标记。看到形参和实参持有同一个标识时,你就直观理解了"引用副本共享对象"到底是什么意思。
这个实验做完,比背十遍定义都扎实。我经手过的很多次排查,最后定位到根因时,对方都是一脸恍然大悟——"原来只是引用副本,我一直以为是引用传递"。明白了这一层,再看任何语言里关于参数传递的文档,都会觉得信息密度低了很多,因为底层逻辑你已经掌握了。