news 2026/10/22 3:01:59

传值还是传引用?一次讲透C/Java/Python/JS的参数传递机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
传值还是传引用?一次讲透C/Java/Python/JS的参数传递机制

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 给新人的一个小实验:十分钟验证传参语义

只看文章很难真正记住边界,我建议你花十分钟做个实验。用你最熟悉的语言写一个方法,分别做三件事:

  1. 参数是int数值,方法内给参数重新赋值。
  2. 参数是对象/数组/list,方法内给参数重新赋值一个新对象。
  3. 参数是对象/数组/list,方法内修改参数对象内部的一个字段或元素。

每做完一步,在外层打印原始对象。你会发现第1种和第2种外部大多不变,第3种外部变了。做实验时建议在打印时带上对象的标识值:Java用System.identityHashCode、Python用id()、JavaScript可以给对象临时挂一个唯一标记。看到形参和实参持有同一个标识时,你就直观理解了"引用副本共享对象"到底是什么意思。

这个实验做完,比背十遍定义都扎实。我经手过的很多次排查,最后定位到根因时,对方都是一脸恍然大悟——"原来只是引用副本,我一直以为是引用传递"。明白了这一层,再看任何语言里关于参数传递的文档,都会觉得信息密度低了很多,因为底层逻辑你已经掌握了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:01:29

VSCode+MSYS2+ImGui+ImPlot:轻量C++实时绘图桌面环境搭建

说实话&#xff0c;如果你跟我一样&#xff0c;受够了 Visual Studio 每次启动都要转半天圈、装个组件恨不得下载好几个 GB&#xff0c;又不想为了画一个带坐标轴的折线图就背着 Qt 全家桶狂跑三四个小时编译&#xff0c;那这套VSCode MSYS2 ImGui (Docking) ImPlot的组合&a…

作者头像 李华
网站建设 2026/10/22 3:00:53

支持向量生成:核化零样本分类器的技术解析与复现

最近在整理零样本分类相关工作的时候&#xff0c;翻到一篇很有意思的投稿&#xff0c;标题长这样&#xff1a;"Support Vector Generation: Kernelizing Zero-Shot Classifiers from Pre-Trained Language Models"。单看题目有几个关键词值得琢磨&#xff1a;support…

作者头像 李华
网站建设 2026/10/22 3:00:52

医学图像分割实战:从脑白质到视网膜血管的完整技术链路

医学图像里&#xff0c;分割这个动作可以说是最“出活”的一步。就拿脑部CT来说&#xff0c;把大脑白质从一堆灰质、脑脊液、颅骨里拎出来&#xff0c;或者从视网膜照片里把密密麻麻的血管网络完整抽出来&#xff0c;这些任务听起来很酷&#xff0c;但真上手做一遍&#xff0c;…

作者头像 李华
网站建设 2026/10/22 3:00:35

Umami自托管网站分析:从Docker部署到公网访问全指南

最近我把博客的访问统计从第三方平台换成了自托管的Umami。说实话&#xff0c;这个决定纠结了一阵子&#xff0c;但实际用下来&#xff0c;本地部署Umami并把分析面板开放到公网访问&#xff0c;这件事本身并不复杂。真正花时间的是理解“外部访问”这条链路里每一步在干什么、…

作者头像 李华
网站建设 2026/10/22 3:00:19

医学图像分割实战:CT白质与视网膜血管分割的深度学习方案

手头同时接过两个活儿&#xff0c;一个要把脑部CT里的大脑白质分割出来&#xff0c;另一个要从视网膜眼底照片里把血管网络一根根提取出来。当时看着这两张完全不同的图像&#xff0c;我还以为是两个独立的项目&#xff0c;做到一半才反应过来&#xff0c;它们背后的方法论、网…

作者头像 李华
网站建设 2026/10/22 2:59:49

单图3D重建+机器人抓取:ZeroGrasp从原理到落地的完整复盘

你真准备把“摄像头拍一张图&#xff0c;机械臂伸手把目标物体抓起来”这句话做成一个能跑的 demo&#xff0c;会发现它几乎是机器人学里最反直觉的任务之一。ZeroGrasp 是我给一套单图 3D 重建 机器人抓取学习复现项目起的内部代号——不依赖多视角扫描&#xff0c;不预先给物…

作者头像 李华