最近逛技术社区的频率有点高,隔三差五就能看到有人贴出一张报错截图,上面写着“无法将 npm 项识别为 cmdlet、函数、脚本文件或可运行程序的名称”,隔几天又变成“无法将 claude 项识别为……”,再隔几天是“无法将 git 项识别为……”。这些报错本质上都是环境变量没有配置好,命令找不到可执行文件,跟函数本身没有直接关系。但每次看到“函数”这个词,我都会想起另一个让无数人抓狂的场景:代码明明写得很顺手,函数也调用成功了,输出的结果却死活不对。这类问题十有八九和函数的参数传递机制有关,也就是我们今天要聊的“值传递”。这篇文章会用 C、C++、Java、Python、JavaScript 五种语言做对比,把值传递的原理、常见误区、经典 Bug 和工程选型一次讲透。无论你是刚入门的新手,还是写了几年业务代码的老手,我都建议把这件事彻底想明白,因为它决定了你能不能准确预判“函数内部改了参数,外面到底变不变”。
1. 为什么改参数改不动外面的变量:值传递的第一课
1.1 一个让新手集体破防的 swap 函数
不知道你有没有写过这样一段代码:在函数里交换两个变量的值,打印出来一切正常,回到调用处一看,两个变量纹丝不动。我之前在一个交流群里见过一位老哥贴出下面这段 C 语言代码,配文是“天塌了”:
c复制void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 10, y = 20;
swap(x, y);
printf("x=%d, y=%d\n", x, y); // 输出:x=10, y=20
return 0;
}
他盯着屏幕上“x=10, y=20”看了半天,怎么都想不明白:明明函数里面已经交换了啊,怎么一出来就不认账了?
这个问题十个新手有九个会遇到,甚至不少写了两年项目的人,遇到类似情况依然会愣一下。原因很简单:函数参数默认是“值传递”。调用 swap(x, y) 的时候,x 的值 10 和 y 的值 20 会被分别拷贝一份给函数内部的形参 a 和 b,函数内部的交换操作全都发生在 a 和 b 这两个副本身上。函数一结束,副本销毁,x 和 y 自然什么影响都没受到。
1.2 形参和实参:函数边界上的“复印件”
要彻底理解这件事,首先要分清形参和实参。
实参是调用函数时写在括号里的变量或表达式,它是真实存在的数据源;形参是函数定义时写的参数名,本质上是一个局部变量,作用域仅限于函数内部。函数被调用的那一刻,系统会把实参当前的值拷贝一份,塞给形参。从此以后,形参和实参就是两个独立的个体——名字像、值一样,但内存地址完全不同。
你可以这样记:值传递相当于你递给办事窗口一份复印件,工作人员在复印件上随便涂改、盖章,你的原件永远不会被波及。如果你希望工作人员直接在原件上操作,那就必须把“原件放在哪儿”这个信息告诉对方,也就是传地址——这是后面指针和引用要做的事。
1.3 内存视角:拷贝发生在哪里
如果从内存视角看,值传递的整个过程发生在函数调用栈里。调用函数时,系统会为这次调用创建一块新的栈帧,形参就在这块栈帧里分配空间,然后把实参的值复制进去。函数运行期间,它访问的始终是这块新栈帧里的副本;函数返回时,栈帧释放,副本随之消失。这也是为什么“函数内部的一切修改,默认在外部都无影无踪”。
这一段如果之前没认真想过,建议在纸上画一画:x=10 写在左边一格,函数栈里 a=10 写在右边一格,两个格子之间没有线连接,只有一次复制动作。想通了这一点,后面所有关于引用、指针、对象的迷思都会迎刃而解。
1.4 为什么需要拷贝?函数隔离带来的安全感
有人可能会问:既然拷贝这么麻烦,为什么不直接让函数用外面的变量,这样性能更好、代码也更节省?答案在于,函数需要具备“隔离性”。
如果函数可以随意修改外部变量,那么一个函数的行为就不再由它的参数和返回值决定,而会被调用环境里一堆乱七八糟的全局状态牵着鼻子走。你调用一个函数,不知道它会偷偷改掉哪些变量,排查问题的时候只能泪流满面地到处打断点。值传递提供了一个天然的边界:函数里面怎么折腾,都不会波及外界的实参,除非你主动通过指针、引用或者返回值来打破这个边界。这种边界感是代码可维护性的基石,也是为什么短短一个 swap 失败案例,值得被拿出来反复讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指针也是值:C 语言里最容易被误读的“传引用”
2.1 传地址的本质:传递的是地址值
理解了“值传递”之后,再看 C 语言里的指针传递,很多人的第一反应是:那传指针应该就是传引用了吧?
不是。C 语言根本没有引用这种语法(C++ 才有),传指针本质依然是一次值传递,只是这次拷贝的不是数据本身,而是数据的地址值。
c复制void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 10, y = 20;
swap(&x, &y);
printf("x=%d, y=%d\n", x, y); // 输出:x=20, y=10
return 0;
}
调用 swap(&x, &y) 时,函数内部收到的是 x 和 y 的地址值副本。这两个地址值副本指向的内存区域,恰恰就是 x 和 y 所在的那些格子。形参 a 存的是 x 的地址,对 *a 赋值,等于顺着地址找到原变量,把原变量的内容改掉。所以函数能“隔空”修改外部变量,靠的不是把变量本身传进去,而是把变量的门牌号传了进去。
2.2 为什么有时候“传地址”也不够用
地址值副本能修改原变量指向的内存内容,但它改变不了“原变量的地址值”。这句话有点绕,看个最常见的例子就明白了。
假设你写了一个创建链表头节点的函数:
c复制void initNode(Node *head) {
head = (Node *)malloc(sizeof(Node));
head->value = 1;
}
你满心欢喜地以为调用 initNode(head) 之后,外面的 head 指针就指向一块新分配的内存了。然而外面那个 head 依然是 NULL。为什么?因为形参 head 接收的是外面 head 指针的地址值副本,函数里对 head 重新赋值,只是让这个局部副本换了个指向,外面那个指针变量的地址值纹丝不动。
要解决这个问题,有两个思路:一是改用二级指针,把外面 head 的地址传给函数,这样函数能通过 *head 修改外面指针变量的指向;二是让函数返回新指针,然后用返回值覆盖外面的 head。这两条路的本质都绕回了同一件事:想要修改一个变量的值,就必须把该变量的地址传给函数。这就是“传值”规则衍生出的最经典工程策略。
2.3 数组参数:退化为指针带来的隐藏行为
C 语言里还有一个很容易被忽略的值传递陷阱:数组作为函数参数时,并不会把整个数组拷贝一份进去,而是会“退化”成指向数组首元素的指针。
c复制void updateArr(int arr[]) {
arr[0] = 99;
}
int main() {
int data[] = {1, 2, 3};
updateArr(data);
printf("%d\n", data[0]); // 输出:99
return 0;
}
从语法上看,int arr[] 好像和普通值传递一样,但实际语义是传指针。所以函数里修改 arr[0],外面 data 数组跟着变。很多从 Java、Python 转过来的人第一次在 C 里遇到这种“函数改了数组,外面真的变了”的现象,会觉得特别神奇。理解本质之后你会发现,这不过是“传地址值副本”在数组场景下的具体表现——你拿到的是数组首地址,顺着地址改内容,当然能改到原数组身上。
2.4 C++ 的引用:真正意义上的“传递变量本身”
到了 C++,事情发生了一点变化,因为 C++ 引入了引用这种参数传递方式:
cpp复制void swap(int &a, int &b) {
int temp = a;
a = b;
b = temp;
}
引用传递下,形参 a 和 b 不再是外面 x 和 y 的副本,而是 x 和 y 的别名。你在函数里操作 a,相当于直接操作 x。这种机制在编译层面通常也是用地址实现的,但从语言语义上讲,它已经跨越了“值传递”的范畴。
很多从 C 语言转学 C++ 的人会在这里绕晕:老师明明说 C 语言传指针很强大,为什么 C++ 还要发明引用?答案很简单——引用写起来更像值传递,语义上又能提供修改外部变量的能力,比天天写 & 和 * 清爽得多。理解了这一点,再回头看各类语言参数传递的差异,你会觉得它们其实都在同一个坐标系里。
3. Java、Python、JavaScript:对象“引用传递”的错觉从哪来
3.1 Java:引用变量的值也是值
到了 Java 这儿,争论就更多了。社区里关于“Java 到底是值传递还是引用传递”的帖子一直吵了几十年。我的结论是:Java 只有值传递。基本类型传的是数据值的副本,引用类型传的是对象地址值的副本。
看下面这段经典代码:
java复制public class Test {
public static void change(String s) {
s = "new value";
}
public static void main(String[] args) {
String str = "old value";
change(str);
System.out.println(str); // 输出:old value
}
}
很多人惊讶:str 不是对象引用吗?把一个对象引用传给函数,函数里改它,外面怎么没变?因为 change 函数接收到的,只是 str 这个引用变量的“地址值”副本。函数里执行 s = "new value",只是把局部变量 s 重新指向了一个新字符串对象,外面 str 的指向根本没变。你给钥匙配了一把新的复制钥匙,复制钥匙上贴了另一个柜子的标签,原来的钥匙还是只能在老柜子上用。
那为什么普通对象修改属性的时候外面又能看到变化呢?比如:
java复制class User {
String name;
User(String name) { this.name = name; }
}
public static void rename(User u) {
u.name = "李四";
}
传入函数的是 User 对象地址值的副本,它和外部变量指向同一个对象。通过这个地址副本去访问并修改对象的 name 属性,修改的是堆上同一个对象里的字段,外部变量再一次通过同样的地址去读取时,自然能看到“李四”。一个很实用的判断方法:函数里对参数重新赋值,外面不会变;函数里对参数指向的对象做修改,外面会变。
3.2 Python:可变对象与不可变对象的分水岭
Python 的参数传递规则,很多人用四个字总结:传对象引用。这个说法直观,但容易造成误解。更准确的理解是:参数传递的是对象引用的值,而对象本身存在可变和不可变之分。
先看不可变对象(int、float、str、tuple):
python复制def add_one(x):
x = x + 1
return x
num = 10
add_one(num)
print(num) # 10
这很好理解,和 C 语言基本类型走的是同一条路。真正有意思的是可变对象(list、dict、set):
python复制def append_item(lst):
lst.append(4)
data = [1, 2, 3]
append_item(data)
print(data) # [1, 2, 3, 4]
函数没有 return,外面却变了。再对比另一个版本:
python复制def reassign(lst):
lst = [9, 9, 9]
data2 = [1, 2, 3]
reassign(data2)
print(data2) # [1, 2, 3]
同样传一个列表,append 有效果,直接重新赋值却没效果。原因就是:append 是在修改传入的那个列表对象本身;而 lst = [9, 9, 9] 只是给局部变量重新绑定到一个新列表,外部变量 data2 并没有重新绑定。这就是 Python 新手最常踩的坑——明明传的是“引用”,为什么换个写法就不行?因为传的是引用的值,不是引用变量的本体。
3.3 JavaScript:对象的属性改得动,变量本身换不掉
JavaScript 的机制和 Java、Python 高度一致。基本类型(number、string、boolean 等)按值传递,对象类型按“共享传递”(call by sharing),本质上也还是传引用的值。
javascript复制function setName(obj) {
obj.name = '李四';
}
const person = { name: '张三' };
setName(person);
console.log(person.name); // 李四
对象属性改得动。再看这个:
javascript复制function changeRef(obj) {
obj = { name: '王五' };
}
const p2 = { name: '张三' };
changeRef(p2);
console.log(p2.name); // 张三
外面 p2 的 name 一点没变。函数内部把 obj 重新指向一个新对象,外部引用变量是不可能被改变的。在 JavaScript 里尤其要注意的是:参数名随便怎么赋值,都不可能影响到传入方,这在写通用工具函数时是很容易误伤的雷区。
3.4 一张表看懂四种语言的行为
| 语言 | 基本类型传参 | 引用/对象类型传参 | 函数内重新赋值参数 | 函数内修改对象内容 |
|---|---|---|---|---|
| C 语言 | 数据值 | 无原生对象,传指针仍是值 | 不影响外部 | 通过指针修改可影响外部 |
| C++ | 数据值(引用另说) | 引用是别名 | 不影响外部(引用除外) | 修改所引对象可影响外部 |
| Java | 数据值 | 对象地址值的副本 | 不影响外部 | 影响外部 |
| Python | 不可变对象 | 可变对象传引用值 | 不影响外部 | 影响外部 |
| JavaScript | 数据值 | 对象地址值的副本 | 不影响外部 | 影响外部 |
表格看下来你会发现,规则出奇地统一:函数内部能不能影响外部,取决于你有没有通过某种方式拿到外部变量的地址,而不是取决于你传的是值还是引用。 所有语言的“修改对象属性”能生效,本质上都是因为参数携带了指向堆上对象的地址;所有“重新赋值参数”不生效,都是因为参数本身就是个地址副本,重新赋值只是改了副本的指向。
4. 那些被值传递坑出来的经典 Bug
4.1 字符串拼接“没生效”
字符串在 Java、Python、JavaScript 里都是不可变对象(immutable),在 C 语言里是字符数组。不管你用哪种语言,在函数里对字符串做拼接后不返回,外面通常都是看不到结果的。
javascript复制function appendHello(str) {
str = str + ' hello';
return str;
}
let msg = 'hi';
appendHello(msg);
console.log(msg); // hi,变化没生效
正确做法是接收返回值:msg = appendHello(msg)。这个坑最常见于写一些“给字符串加个前缀后缀”的小工具,很多人以为字符串和数组一样可以直接改,结果死活不生效。真正理解了值传递之后,你会下意识地想到:字符串是不可变对象,参数里拿到的是引用副本,重新赋值或拼接生成新字符串都只是在局部变量上做文章,不回传怎么可能影响外面。
4.2 清空列表:reassign 和 clear 的差别
Python 里这个问题的翻版也极其常见。我之前看过一段清理配置的代码:
python复制def clear_config(config_list):
config_list = []
config = [1, 2, 3, 4]
clear_config(config)
print(len(config)) # 4,没清空
写代码的人以为把 config_list 重新赋值为空列表就能清空原列表,完全忘了那只是改了一个局部变量。如果要清空,应该用 config_list.clear(),或者 del config_list[:],这才能作用到原列表对象上。类似的还有 setdefault、update 这些原地修改方法和直接赋值之间的差别……本质都是一件事:区分“修改对象内容”和“重新绑定变量”。
JavaScript 里也有类似的坑:函数里对数组重新赋值不会影响外部,但用 push、splice 就能影响外部。Java 里对集合 clear() 能清空原有 List,重新 new ArrayList() 则毫无作用。这些现象在不同语言里反复出现,核心规律完全一致。
4.3 回调函数和闭包:值在捕获的那一刻就定了
值传递的影响不只在普通函数里,匿名函数、回调、闭包同样逃不掉。JavaScript 里经典的循环闭包问题:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
// 输出:3 3 3
很多人以为会输出 0 1 2,结果全是 3。原因在于,闭包捕获的是变量 i 的引用,而不是 i 的值;循环结束时 i 已经变成 3,三个回调看到的自然都是 3。把 var 改成 let,每次循环生成新的块级作用域绑定,回调每次捕获的是不同的 i,才会输出 0 1 2。这其实是另一个维度上的“传引用和传值的斗争”——闭包捕获引用偏多,捕获值就要靠 let 或立即执行函数。
类似的场景在 Python 里也有:在循环里定义 lambda,读取外层的循环变量,最终拿到的总是最后一个值。想捕获当前值,可以用默认参数:lambda x=i: x。默认参数在定义时求值,相当于把当前 i 的值“拍快照”传进去。这道题几乎是 Python 面试里考察闭包与参数绑定的必问题目,理解了值传递的“快照”特性,就再也不会被绕进去了。
4.4 共享可变对象引发的连锁反应
当你理解了“函数内修改对象会影响外部”之后,新的坑又来了:如果你把一个对象同时传给多个函数,而每个函数都悄悄改了它,外部状态会变得非常难追。我见过一个项目里,某段业务把一个配置字典传给一堆工具函数,每个函数都往里面塞了几个键,最后排查线上问题,发现真正导致异常的字段根本不知道是哪个函数加的。调试的时候只能一个个函数往里加日志,痛苦程度拉满。
用值传递思维来做防御,最有效的办法是:如果函数只是读取参数,就不要修改它;如果确实需要改,可以考虑先拷贝一份再改。这个习惯在业务代码里尤其重要,因为业务系统一旦上线,状态污染的问题往往不是立刻爆发,而是积累到某个临界点才突然炸掉,到时候定位成本极高。
5. 选值传递还是选引用:工程实践里的权衡
5.1 优先选择值语义,让函数可预测
写函数的时候,我个人的经验是:默认用值传递的思维来设计,除非有明确的理由需要共享。 值传递意味着函数是“纯净”的——输入什么、输出什么,都在函数签名里看得到,不依赖外部状态,也不会偷偷改变外部状态。这样的函数最容易测试,也最容易排查问题。前几年函数式编程流行的“纯函数”概念,本质上就是在追求这种可预测性。
举个例子,同样的功能,用纯函数写法:
python复制def normalize_name(name: str) -> str:
return name.strip().title()
永远返回新字符串,绝不改动入参,调用方可以放心大胆地用。如果某个函数既修改入参,又返回新值,还依赖全局变量,那它就是一个“状态黑洞”,读懂它需要把整个上下文翻个底朝天。我在代码评审时看到这种函数,第一反应就是让作者拆开重写。
5.2 需要共享和修改时,明确选择引用/指针
当然,值传递不是万能的,引用/指针在某些场景下明显更合适。数据结构很大,比如几十 MB 的容器,每次都拷贝一份会带来无法忽视的性能开销;函数需要修改外部变量,比如 C 语言里的 swap、树和链表的插入,以及各类“初始化”接口;多个组件需要操作同一份状态,比如全局配置、共享缓存。这些场景下,引用传递(C++ 的 &、Java/Python/JavaScript 的共享对象)就非常有价值。
要记住的是,共享和修改往往是一对矛盾体——共享让代码灵活,但代价是状态变得复杂、责任边界模糊。所以能局部共享就别全局共享,能只读共享就别写操作。遇到那种“我必须传一个大对象进去,还要改它”的情况,我的建议是先反问自己:能不能把要改的部分拆成返回值?很多时候重构完会发现,函数更清晰了,性能也没差多少。
5.3 拷贝的代价:浅拷贝、深拷贝与性能
前面提到“先拷贝一份再改”,这里有个很阴间的坑:浅拷贝在很多语言里只复制最外层,内部嵌套对象还是共享的。
javascript复制let config = { timeout: 1000, server: { host: '127.0.0.1' } };
let copy = { ...config };
copy.server.host = '0.0.0.0';
console.log(config.server.host); // 0.0.0.0,浅拷贝内部对象还是同一个
如果需要彻底隔离,得用深拷贝(结构化克隆、序列化还原等),但深拷贝往往有性能开销,而且不一定能处理循环引用、函数、特殊情况。工程里我的建议是:结构不深的普通数据,浅拷贝够用;结构复杂、需要严格隔离的,再加上深拷贝;实在不行,就调整接口设计,尽量别把共享对象传来传去。值传递要付出的代价是时间和内存,引用传递要付出的代价是心智和安全性,每次做选择之前都得掂量清楚。
5.4 常量参数与只读约束:把“不允许改”写进接口
C++ 的 const 引用是我特别想夸的一个设计。它既避免了拷贝,又保证了外部对象不被修改:
cpp复制void printInfo(const User &u) {
// 只读访问u
}
调用的性能开销可以忽略不计,同时编译器会把“不能修改 u”这一约束强制落实。Java、Python、JavaScript 没有强制的只读参数机制,只能靠约定,所以更需要在命名和文档里明确“此函数不修改参数”。我通常在参数名里加个前缀或者写清楚 docstring,比如 readonly config、“此函数不会修改传入的列表”之类,时间久了会少很多沟通损耗。
如果你在写 API 或者公共库,这一点尤其重要:调用者看了你的签名,应该能猜到函数会不会动入参。被值传递坑过的人写出来的注释,往往也会更强调“这个参数会被修改”这类细节,这其实就是经验沉淀下来的痕迹。
从第一次被 swap 函数搞懵,到后来在 Java、Python、JavaScript 里反复验证,我花了很久才真正意识到:所有语言在参数传递上的核心规则都是相通的,区别只在于你对“值”的定义能否随着类型和抽象层次延展。看懂了一行 swap 的真相,也就看懂了无数“改了不生效”和“没改却变了”的疑难问题。如果你现在恰好也在排查这类问题,不要急着抱怨语言有 bug,先把传递过程在纸上画出来,答案一般立刻就会浮现。
