开头
如果你写过一段时间代码,大概率碰到过这种诡异场景:写了个函数,传进去一个变量,函数内部明明改了值,结果回到调用处一看,变量纹丝不动。又或者反过来,你在函数里改了某个对象的属性,外部居然也跟着变了,搞得你一头雾水。
这背后绕不开的就是“函数”和“值传递”这两个关键词。很多新手在学函数时,会把注意力放在返回值、参数个数上,却忽略了参数到底是怎么传进函数的——到底传的是值,还是地址,还是引用?这个问题搞不清楚,后续学指针、学对象、学回调函数的时候,都会一路踩坑。
这篇文章我想从实际使用角度出发,把值传递这件事彻底聊透。我会结合C、C++、Java、Python、JavaScript这几种主流语言来对比,也会穿插一些真实项目中踩过的坑,比如交换函数失效、对象属性莫名其妙被篡改、回调函数里参数行为怪异等问题。无论你是刚接触函数的初学者,还是写了两三年代码但一直对传参方式模模糊糊的开发者,这篇文章都能帮你把这块拼图补上。
1. 值传递到底是什么:从一段代码的“错觉”说起
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.1 一个让很多人懵掉的例子
先看一段C语言代码:
c复制#include <stdio.h>
void change(int x) {
x = 100;
printf("函数内部: x = %d\n", x);
}
int main() {
int a = 10;
change(a);
printf("函数外部: a = %d\n", a);
return 0;
}
运行结果:
code复制函数内部: x = 100
函数外部: a = 10
函数内部改了x,外部a却还是10。很多初学者第一次看到这个结果时,心里想的是:这函数怎么“不听话”?其实不是函数不听话,而是你根本没把a交给函数,你只把a的“复印件”交给了函数。
这个“复印件”,就是值传递的核心。函数拿到的是实参的一个副本,这个副本跟原变量住在不同的内存地址里。你在函数里改副本,改得再起劲,也影响不到原件。
1.2 值传递的底层机制:拷贝发生在哪里
从内存角度看,函数调用发生时,系统会分配一块新的栈空间给被调函数使用。实参的值会被拷贝到这块新栈空间中,成为形参的初始值。也就是说,传递过程发生了三个动作:
- 计算实参表达式的值。
- 把计算结果拷贝一份。
- 将拷贝结果绑定给函数内部的形参变量。
第2步的“拷贝”,就是值传递的根本特征。拷贝完成之后,形参和实参就成了两个独立变量,只是初始值相同而已。之后你在函数内修改形参,本质上只是在修改那块新栈空间里的数据。
注意:这里说的拷贝,是针对“变量本身的值”而言。这个值是数字、是字符、还是地址,拷贝的粒度是不一样的。后面讲对象和指针时再细说。
1.3 为什么会有“传引用”的错觉
很多人在学习更高阶的语言时,会觉得Java、Python这种语言“好像不是值传递,因为我在函数里改了对象属性,外部真的变了”。这就产生了一个极大的误解——把“对象内容可变”当成了“传递的是引用本身”。
实际上,这个误解来自两个层面的混淆:
- 把“变量”和“变量指向的对象”混为一谈。
- 把“传递引用”和“传递引用的副本”混为一谈。
在Java和Python里,如果传的是对象,函数收到的其实是“引用的副本”。这个副本同样指向原对象,所以你能通过它修改原对象的内部状态。但如果你重新给形参赋值(让它指向另一个对象),外部变量并不会跟着变。
这个区别特别关键,后面我会在专门的语言章节里用代码演示清楚。
2. 值传递 vs 引用传递:一篇文章讲清两者的分界
2.1 值传递:你复制,我修改,互不相干
值传递也叫按值传递,英文是pass by value。它的语义是:把实参的值复制一份传给函数,实参和形参此后不再有任何关联。
它的优点是安全、简单、可预测。函数内部的改动不会污染外部变量,这在写纯函数、做并发编程、写工具库时非常重要。缺点是:如果参数是很大的结构体或对象,复制成本很高,性能会受影响。
2.2 引用传递:我给的是一张地图,不是一座城
引用传递(pass by reference)在C++里体现得最典型:函数参数声明为引用类型(比如int &x),调用时不会发生拷贝,形参直接成为实参的别名。你在函数里改x,外部变量就真的变了。
还有一种更底层的做法——指针传递。在C语言里没有引用,只有指针。指针的本质是保存地址的变量,把指针传进函数,也是按值传递,但拷贝的是地址值。地址拷贝出来后,指向的还是同一块内存,所以通过指针可以修改外部变量,指针本身却还是“值传递”的语义。
为了区分这几种情况,可以看下表:
| 传递方式 | 拷贝内容 | 修改形参是否影响实参 | 是否可改变实参指向的数据 |
|---|---|---|---|
| 值传递(普通变量) | 变量本身的值 | 否 | 否 |
| 值传递(指针变量) | 地址值 | 否(指针本身不变) | 是 |
| 引用传递(C++引用) | 不拷贝,建立别名 | 是 | 是 |
2.3 核心判断标准:看“修改形参本身”是否影响外部
网上关于“Java到底是值传递还是引用传递”的争论,已经持续了十多年。其实只要抓住一个判断标准,就不会再被绕进去:
如果我让形参重新指向一个新对象/新值,外部变量会不会跟着变化?
- 如果不会,就是值传递。
- 如果会,就是引用传递。
Java里:
java复制void change(String s) {
s = new String("hello");
}
调用后外部引用不会变成新对象,因为操作的是引用的副本。所以Java是值传递——更准确地说,是“按值传递对象引用”。
Python里同理:
python复制def change(lst):
lst = [1, 2, 3]
a = [9, 9]
change(a)
print(a) # 输出 [9, 9]
列表对象没有被替换,因为lst = [1, 2, 3]只是让局部变量lst指向了新列表,外部a仍然指向原列表。
3. 各语言中的值传递实操:从底层到脚本语言
3.1 C语言:最纯粹的值传递
C语言是学习值传递最好的教材,因为它几乎没有语法糖,一切都摆在明面上。
基本类型,如int、char、float、double,肯定是值传递。结构体呢?也是值传递,只不过拷贝的是整个结构体的内容。如果结构体很大,拷贝开销就很明显。
c复制#include <stdio.h>
#include <string.h>
typedef struct {
char name[64];
int age;
} Person;
void updatePerson(Person p) {
strcpy(p.name, "Tom");
p.age = 25;
}
int main() {
Person p = {"Jerry", 18};
updatePerson(p);
printf("name = %s, age = %d\n", p.name, p.age);
return 0;
}
输出结果仍然是name = Jerry, age = 18。函数内部修改的是Peson结构的拷贝,外部p没有变化。
想修改外部结构体,只能传地址:
c复制void updatePerson(Person *p) {
strcpy(p->name, "Tom");
p->age = 25;
}
传地址依然是值传递,只是地址值是拷贝的。通过拷贝的地址去访问内存时,访问的是同一块内存区域,因此修改能生效。
实操心得:在C语言中,如果你看到函数参数是一个数组,其实那并不是“数组传递”,而是退化成了指针。
void func(int arr[])等价于void func(int *arr),传递的是首元素地址,是值传递的地址副本。这也是为什么在函数里用sizeof(arr)得到的是指针大小而不是数组大小。
3.2 C++:指针、引用,两种“绕路”方式
C++同时提供了指针传参和引用传参,是理解值传递和引用传递差异的最佳语言。
指针传参:
cpp复制void swapByPointer(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int x = 1, y = 2;
swapByPointer(&x, &y);
这里传入的是x和y的地址,地址按值拷贝。通过地址解引用,可以修改原来变量。但如果你在函数内部修改指针本身(比如让指针指向一个新变量),外部指针不会受影响。
引用传参:
cpp复制void swapByReference(int &a, int &b) {
int temp = a;
a = b;
b = temp;
}
int x = 1, y = 2;
swapByReference(x, y);
引用传递不产生拷贝,形参a和实参x是同一个东西。对a赋值,就是对x赋值。这是真正意义上的引用传递。
还有一个细节需要注意:C++里如果你不想拷贝大对象,但又不想让函数修改它,可以用const &。这几乎是大型代码库里的默认选择:
cpp复制void printPerson(const Person &p) {
// 只读,不需要修改
}
用const引用既避免了大对象拷贝的性能开销,又保证了外部数据不被意外修改,一举两得。
3.3 Java:一声叹息,Java只有值传递
Java官方的Java Language Specification第8.4.1节写得很清楚:方法的参数是值传递(pass by value)。但争议声从未停止,原因就是引用的存在。
看这个例子:
java复制class User {
String name;
User(String name) {
this.name = name;
}
}
public void changeName(User user) {
user.name = "Tom"; // 这个修改会生效
}
public void changeUser(User user) {
user = new User("Jerry"); // 这个不会生效
}
第一个方法修改了对象的属性,外部user的name被改成了Tom。原因是user变量指向的User对象是可变的,通过引用的副本操作了原对象的内部数据。
第二个方法试图让形参指向一个新对象,但外部引用没有任何变化。因为形参user只是实参user引用的一个副本,你改副本指向,改不了原件指向。
Java中的基本类型和String类型更有迷惑性。String是不可变的,所以你无论怎么做,外部字符串都不会被函数修改,这容易给人一种“所以Java是值传递”的直觉。但即使换成StringBuilder这种可变类,如果你执行sb.append("abc"),外部对象内容会改变,如果你执行sb = new StringBuilder("xyz"),外部引用不变。说到底,还是值传递引用的副本。
3.4 Python:可变与不可变的纠缠
Python的参数传递机制官方术语叫“传对象引用”,但更严谨的说法是:Python中一切皆对象,变量名是对象的“标签”。函数传参时,把对象引用复制一份传给形参。这其实和Java的按值传递对象引用是同一套机制。
对于不可变对象(int、str、tuple等),因为是只读的,无论你在函数里做什么修改,外部都不受影响。
python复制def add_one(n):
n += 1
a = 10
add_one(a)
print(a) # 10
对于可变对象(list、dict、set),通过形参修改对象内部内容,外部会看到变化:
python复制def add_item(lst):
lst.append(100)
a = [1, 2, 3]
add_item(a)
print(a) # [1, 2, 3, 100]
但如果你在函数里让形参指向一个新对象,外部变量不受影响:
python复制def reset_list(lst):
lst = []
a = [1, 2, 3]
reset_list(a)
print(a) # [1, 2, 3]
这个行为不一致让很多人抓狂。建议的应对方式:如果你想在函数内部安全地修改列表,用切片复制一份再操作,或者明确约定函数可以修改哪些可变对象。
注意:避免在函数内部直接修改外部传入的可变对象,除非函数名明确表示如此(比如sort、append这类)。否则调试时你很难追踪数据是何时被改掉的。我见过不少线上问题,就是某个回调函数里顺手改了外部列表导致的状态污染。
3.5 JavaScript:等号带来的两种行为
JavaScript的函数传参同样是按值传递,这里的“值”可以是原始类型值,也可以是对象引用(其实还是引用副本)。
原始类型:
javascript复制function change(num) {
num = 100;
}
let a = 10;
change(a);
console.log(a); // 10
对象类型:
javascript复制function changeName(obj) {
obj.name = 'Tom';
}
let user = { name: 'Jerry' };
changeName(user);
console.log(user.name); // Tom,对象属性被改了
但是重新赋值:
javascript复制function changeUser(obj) {
obj = { name: 'Tom' };
}
let user = { name: 'Jerry' };
changeUser(user);
console.log(user.name); // Jerry,还是原对象
JS里还有一个跟传参紧密相关的坑:数组和对象通过.slice()、展开运算符...做的是浅拷贝,只有传递嵌套对象时,内层仍然是公用引用。
在写函数式风格的代码时,尽量把函数设计成不改外部数据,而是返回新对象。这样即使用户传入可变对象,也不会产生副作用。
4. 常见陷阱与排查技巧:值传递引发的经典事故
4.1 交换函数为什么永远不生效
这是无数人初学函数时都撞过的墙:
c复制void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int x = 3, y = 5;
swap(x, y);
// x、y 都没有变化
原因就是值传递。a和b是x和y的拷贝,交换拷贝当然影响不了原件。正确的写法是传指针(C/C++)或传引用(C++),或者在Java/Python中返回新值后重新赋值。
这个经典案例很好地说明了:写swap函数,本质上就是在考察你懂不懂“传参的边界在哪里”。
4.2 对象属性改了,但重新赋值没用
这是我所在的开发团队里出现过好几次的bug。业务代码大概是这样的(Java伪代码):
java复制public void fillUser(User user) {
// 从数据库查出数据
User dbUser = userDao.findById(1);
user = dbUser; // 试图把形参指向新对象
}
调用后外部user仍然为null,因为这里的user = dbUser只是修改了形参引用的副本,外部引用完全没有变化。
正确的做法有两种:一是让函数返回新对象,调用处接收返回值;二是在函数内部直接修改入参对象的属性字段。
java复制public User fillUser() {
return userDao.findById(1);
}
这种“想在函数里换对象”的需求,是值传递引用机制最容易触发困惑的场景。遇到这类需求时,先停下来想清楚:我要修改的是对象的内容,还是让外部变量指向新的对象?如果是后者,必须通过返回值完成。
4.3 函数式编程中的不可变性
在React、Vue这类前端框架中,值传递思想被发挥到了极致。例如React中,setState必须传入新对象,不能直接修改旧state。如果你传入了同一个对象引用,组件无法感知变化,UI自然不刷新。
javascript复制// 错误:直接修改state对象
state.items.push(newItem);
// 正确:返回新数组
setState({
items: [...state.items, newItem]
});
这个设计理念本质上就是在利用值传递的“拷贝”特性——通过创建新值来驱动变化,而不是依赖修改副作用。学习值传递,不只是为了应付面试题,更是为了理解现代前端框架的设计基础。
4.4 回调函数中的参数传递谜团
在热搜词里我看到了“回调函数”相关的词,这里顺便聊一下。回调函数本身也遵循同样的传参规则,但因为回调的调用时机、调用上下文不明确,很多人会把“回调参数值异常”误当成传参问题。
比如JS里最常见的setTimeout闭包问题:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 打印 3, 3, 3
}, 100);
}
这虽然不是严格意义上的值传递问题,但根源类似:回调函数捕获的是变量i的引用(或者说共享的变量环境),而i在循环结束后已经是3了。改用let声明变量后:
javascript复制for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 打印 0, 1, 2
}, 100);
}
let为每次循环创建了独立的绑定,回调捕获的是每次迭代的值副本。
另一个典型场景是C语言中的回调函数指针参数。当你把结构体指针传给回调函数时,若回调函数内部修改了结构体内容,外部数据也会被修改。这在写事件驱动框架、GUI回调时容易造成隐性bug。建议在回调入口做防御性拷贝,或明确回调是否拥有数据所有权。
5. 如何快速判断一门语言是值传递还是引用传递
5.1 最直接的实验方法:写一个swap函数
不管学什么语言,只要你能写出一个swap函数,再通过主函数验证交换是否生效,基本就能判断出这门语言的传参机制:
- 如果写普通参数版本,swap不生效 → 说明是值传递(C、Java、Python、JS都是)。
- 如果写普通参数版本,swap生效 → 说明是引用传递(C++引用、C#的ref/out等)。
- 如果写指针/引用/特殊包装版本,swap生效 → 说明语言提供了“绕路”能力。
这个实验特别适合新语言上手时快速确认传参规则,我每学一门新语言都会先跑一遍这个实验,能省很多排查问题的时间。
5.2 看语言规范怎么说
如果你需要更严谨的依据,参考各语言官方的规范文档:
| 语言 | 官方说法 | 通俗理解 |
|---|---|---|
| C | 所有参数按值传递 | 拷贝实际值 |
| C++ | 支持值传递和引用传递 | 引用传递不拷贝 |
| Java | 所有参数按值传递 | 传的是引用的副本 |
| Python | 传对象引用 | 类似Java |
| JavaScript | 所有参数按值传递 | 传的是引用的副本 |
| C# | 默认值传递,支持ref/out | 类似C++引用 |
5.3 实际项目里的判断技巧
如果项目代码已经很大,你不方便从零做实验,可以从几个信号来判断某个函数能否修改外部变量:
- 看函数签名:如果参数是指针、引用、ref/out等关键字,大概率可以修改外部变量。
- 看函数内部:是否对参数进行了重新赋值(对引用副本重新赋值,通常不影响外部)。
- 看被传对象的类型:可变对象属性修改会反映到外部;不可变对象再怎么操作也只是生成新对象。
还有一个常见场景:传参时用了const修饰符,比如C++的const int &x,说明本意是只读,不允许函数内部修改。这种约定在团队协作中非常重要,我见过太多因为“漏写const”而导致的意外修改bug。
6. 值传递在真实项目中的设计哲学
6.1 让函数更纯粹:减少副作用
在大型项目中,代码越“纯”,出bug的概率越低。纯函数是指:同一个输入永远产生同一个输出,且不修改外部状态。值传递天然地有助于实现纯函数,因为函数拿到的只是一份拷贝,怎么折腾都不影响外部。
但对象和引用让这个“纯粹”变得复杂。如果你传入一个可变对象,又在函数里改了它,函数就不“纯”了,因为调用处的数据被悄悄污染了。所以很多规范里会明确要求:尽量不要在函数内部修改入参对象,而是返回一个新对象。
6.2 性能与安全的权衡:值传递不是万能的
值传递保证了安全,却牺牲了性能。如果每次调用函数都要把一个大对象整个复制一遍,内存和时间开销都很可观。
解决方案通常有三种:
- C++里用const引用,既避免拷贝又防止修改。
- 传递指针(C/C++),但要知道这时候“值传递”的是地址,修改地址指向的数据并不是“改形参”,而是“通过形参访问同一块内存”。
- 使用持久化数据结构,从设计上避免复制整个容器,同时获得不可变性收益。
在写高性能代码(如游戏引擎、图像处理)时,我会特别关注函数参数的拷贝开销。通常会做性能剖析,如果发现某个函数因为拷贝大结构体拖慢了速度,就改成指针或引用传参。
6.3 开发规范建议:项目里如何约定传参方式
根据我多年带团队的经验,建议在项目里约定一套简单的传参规范,避免每个人凭心情写:
- 默认情况:小对象(数字、字符串、小结构体)尽量用值传递,安全且性能可接受。
- 大对象只读:用const引用(C++)或只读接口,不要交出一个“看起来可以改”的引用。
- 大对象需要修改:尽量返回新对象,避免原地修改。
- 必须原地修改时:函数命名要明确表达副作用,比如
sort、update这类词。 - 跨模块边界传参:明确数据所有权和生命周期,避免悬空引用和内存泄漏。
这套规范不复杂,但能避免大量因为传参语义不清晰导致的bug。
7. 写在最后的一点体会
值传递这个概念看似简单,但它贯穿了几乎所有编程语言的设计。理解了它,你就不容易在函数传参上犯错,也能更好地理解为什么有些函数能改外部变量,有些不能。
我个人的经验是:学习传参机制时,不要死记“Java是值传递”或“Python是引用传递”这种结论,而是亲手写几个小实验,从C语言开始,逐个语言验证一遍。一旦你亲手看到“函数内修改形参,外部变量纹丝不动”和“函数内修改对象属性,外部对象跟着变”这两个现象,你才会真正理解值传递背后的机制。
最后分享一个小技巧:在调试传参问题时,打印出参数的内存地址往往比打印值本身更有帮助。如果函数内外的地址一致,说明你操作的是同一个变量;如果不一致,说明函数拿到的是拷贝,外部变量不会因为形参的重新赋值而改变。这个技巧在做跨语言开发时尤其好用。
