1. 引用传递和地址传递:别再傻傻分不清
先聊个扎心的事实:我在面试候选人的时候,只要问到函数传参,十个里面起码有六七个会把“引用传递”和“地址传递”当成一回事儿。哪怕工作了三五年的老手,也经常在口头表达上混淆这两个概念。但真要较真起来,这俩在底层机制上有着本质差异,理解不到位,写多线程、写底层封装、写性能敏感代码的时候容易踩坑。
先说这两个概念解决的是什么问题。不管是 Java、C++、Python 还是 Go,写代码绕不开一件事:把变量传给函数时,函数内部改了值,外部变量到底变不变?变了叫“引用传递”,不变叫“值传递”。但“地址传递”是另一个维度的事儿——它说的是“把变量的内存地址作为值传进去”,本质上是值传递的一种特殊形态。这个区分搞清楚了,很多“我以为我懂了,其实我没懂”的 bug 就能解释通了。
这篇内容适合刚入门想夯实基础的新手,也适合被面试官问懵了想彻底搞懂原理的进阶开发者。我会用 C++ 和 Java 两种典型语言做对照,把概念、底层实现、实战场景全部拆开讲清楚。文章里的代码我都实际跑过,结论可以直接用在你的项目里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心概念:先搞清楚变量的本质是什么
2.1 变量就是一块内存的“门牌号”
在聊传递方式之前,必须先建立一张清晰的内存模型图。很多初学者之所以搞混引用和地址,本质上是没搞懂“变量名”“变量值”“内存地址”这三者的关系。
举个生活化的例子。你住在一个小区里,小区有楼栋号、单元号、门牌号,比如“3栋2单元501室”。这个门牌号就是内存地址。房间里住的人,就是你变量存的值。而你自己的名字,比如“张三”,就是变量名。
当你写 int a = 10 的时候,编译器做了一件什么事?它帮你在内存里找了一个空闲的“房间”(一块 4 字节的空间,假设地址是 0x7ffd1234),然后把门牌号和“张三”这个名字挂上钩,再往房间里放数字 10。以后你说“张三”,编译器就自动翻译成“去 0x7ffd1234 这个房间取 10”。
这个模型是理解一切传递机制的基石。记住一句话:变量名是给人看的,地址是给机器用的,值才是真正操作的数据。
2.2 值传递、地址传递、引用传递的严格定义
三个概念先用一句话分别定义,后面再展开细讲。
- 值传递(Pass by Value):把变量里存的“值”复制一份,传给函数。函数拿到的是副本,改副本不影响原件。
- 地址传递(Pass by Address / Pointer):把变量所在的“内存地址”这个值复制一份,传给函数。函数拿着地址去访问原件,改地址指向的内容就是在改原件。
- 引用传递(Pass by Reference):直接把“变量本身”传给函数,函数内的参数是外部变量的别名,二者是同一个东西。
从定义上就能看出,值传递和地址传递其实都属于“传值”——只不过地址传递传的“值”恰好是一个地址。而引用传递在语义上完全不复制任何东西,它是给原变量起了一个“别名”。
3. 地址传递的底层逻辑:本质还是在传值
3.1 指针变量是如何存储地址的
在 C/C++ 里,地址传递的实现方式是指针。比如:
c复制void change(int *p) {
*p = 100;
}
int main() {
int a = 10;
change(&a);
printf("%d\n", a); // 输出 100
}
在这段代码里,&a 取的是变量 a 的内存地址,这个地址本身是一个整数(在 64 位系统上通常是 8 字节)。调用 change(&a) 的时候,实际上是把 a 的地址这个数值复制了一份,传给了形参 p。
关键点来了:p 是一个独立的变量,它自己有内存空间,里面存的是 a 的地址。p 和 a 是两个完全不同的变量,只是 p 的值“碰巧”指向了 a 的家门口。
所以,如果你在函数里写 p = NULL,外部完全无感知,因为改的是 p 自己的值。但如果你写 *p = 100,那是在通过地址找到 a 的房间,往里面放新值,外部当然能看到变化。
3.2 为什么说地址传递不是引用传递
这是最容易混淆的地方。很多人看到“传地址能改外部变量”,就以为它是引用传递。但严格来说,C 语言里根本没有引用传递,只有值传递。只不过这个“值”特殊了一点——它是一个地址。
用代码证明一下地址传递的本质是值传递:
c复制void test(int *p) {
printf("p 的地址: %p\n", &p); // p 自己的地址
printf("p 存的值: %p\n", p); // 指向的地址,也就是 a 的地址
}
int main() {
int a = 10;
printf("a 的地址: %p\n", &a);
test(&a);
return 0;
}
运行结果会显示,&p 和 &a 是不同的。这就说明 p 是独立的变量,只不过它的值是 a 的地址。p 是“副本”,不是“别名”。
这个特性带来一个实际后果:如果想在函数里改变指针本身指向的对象(而不是改变指向的内容),单靠地址传递是做不到的,得用“指向指针的指针”(二级指针),或者用引用。
提示:理解“
p是副本”这一点,是区别地址传递和引用传递的分水岭。
4. 引用传递的底层机制:编译器的魔法
4.1 C++ 引用的本质是受限的指针
C++ 里引用的写法很简洁:
cpp复制void change(int &x) {
x = 100;
}
int main() {
int a = 10;
change(a);
cout << a << endl; // 输出 100
return 0;
}
这段代码看起来和地址传递效果一样,外部变量 a 变成了 100。但底层呢?我直接说结论:C++ 的引用在底层实现上,就是指针。 如果你把上述代码编译成汇编,会发现 x = 100 对应的汇编指令和 *p = 100 几乎一模一样,都是先把地址加载到寄存器,再往这个地址写入 100。
那引用和指针有什么区别?区别在于编译器的“约束”和“语法糖”:
- 引用必须在声明时初始化,不能先声明后赋值。
- 引用一旦绑定到一个变量,就永远不能再绑定到其他变量。
- 引用没有“空引用”(null reference),不像指针可以为 NULL。
- 使用引用的语法和普通变量一样,不需要解引用操作符
*。
也就是说,引用是编译器帮你“包装”过的指针:自动解引用、强制初始化、禁止重新绑定。所以它用起来更安全,但灵活性比指针低。
4.2 引用为什么能改外部变量:别名机制
引用传递能修改外部变量的根本原因,是“形参和实参在内存中是同一个东西”。来做个验证:
cpp复制void test(int &x) {
printf("x 的地址: %p\n", &x);
}
int main() {
int a = 10;
printf("a 的地址: %p\n", &a);
test(a);
return 0;
}
运行结果:两个地址完全一样。这就说明了问题——x 不是 a 的副本,而是 a 的别名。对 x 取地址就等于对 a 取地址,对 x 赋值就等于对 a 赋值。
这就是引用传递和地址传递最核心的区别:地址传递中,形参是实参地址的副本,二者有各自的内存空间;引用传递中,形参和实参共享同一块内存空间。
5. 代码验证:一段程序看清所有区别
5.1 C 语言环境下的完整对照实验
我写了一个对照程序,一次性验证三种传递方式的差异。下面的代码你复制到本地跑一下,所有结论一目了然。
c复制#include <stdio.h>
void pass_by_value(int x) {
x = 100; // 改副本
}
void pass_by_address(int *x) {
*x = 200; // 通过地址改原件
}
void pass_by_pointer_itself(int *x) {
x = NULL; // 改指针本身,外部不感知
}
int main() {
int a = 10;
int b = 10;
int *p = &b;
pass_by_value(a);
printf("值传递后 a = %d\n", a); // 输出 10,外部不变
pass_by_address(&a);
printf("地址传递后 a = %d\n", a); // 输出 200,外部改变
printf("传递前 p = %p\n", p);
pass_by_pointer_itself(p);
printf("传递后 p = %p\n", p); // 地址没变,说明 p 本身没被改
return 0;
}
这个实验很直观地展示了三个要点:值传递传入的是副本,改不动外部;地址传递通过 * 解引用能改外部;但如果你想改指针变量本身,单靠地址传递是做不到的——因为它本身就是值传递,p 是副本。
5.2 C++ 引用与地址传递的对比代码
再看 C++ 的对照实验:
cpp复制#include <iostream>
using namespace std;
void pass_by_reference(int &x) {
x = 300;
}
void pass_by_pointer(int *x) {
*x = 400;
}
int main() {
int a = 10;
int b = 10;
pass_by_reference(a);
cout << "引用传递后 a = " << a << endl; // 300
pass_by_pointer(&b);
cout << "地址传递后 b = " << b << endl; // 400
return 0;
}
效果上,两种方式都能修改外部变量,这也是混淆的根源。但请记住:效果相同不代表机制相同。一个是通过别名直接操作,一个是通过地址间接操作。
6. 实战场景:不同语言的传递行为对比
6.1 Java 只有值传递,没有引用传递
Java 面试题里最喜欢问这个问题:“Java 是值传递还是引用传递?”标准答案是:Java 只有值传递。
为什么?因为 Java 没有 C++ 那种真正的引用(reference),也没有指针。Java 里的变量分两类:
- 基本类型(int、double、boolean 等):变量直接存值,传参传值副本。
- 引用类型(对象、数组):变量存的是对象的“引用”(可以理解为指针的简化版),传参传的是引用的副本。
看代码:
java复制public class Main {
static void change(int x) {
x = 100;
}
static void changeObj(StringBuilder sb) {
sb.append(" world");
}
static void changeRef(StringBuilder sb) {
sb = new StringBuilder("new");
}
public static void main(String[] args) {
int a = 10;
change(a);
System.out.println(a); // 10
StringBuilder s = new StringBuilder("hello");
changeObj(s);
System.out.println(s.toString()); // hello world,对象内容被改了
changeRef(s);
System.out.println(s.toString()); // hello world,引用本身没被改
}
}
这段代码精准展示了 Java 的行为:传递对象引用时,如果通过引用修改对象内部状态(append),外部能看到;但如果修改引用本身(重新指向新对象),外部看不到。这说明 Java 传入的“引用”其实是一个副本——本质上就是“地址传递”的变种。
再往深了说,StringBuilder 内部封装了一个 char[] 数组,你传入的是 StringBuilder 对象的地址副本,通过地址去操作对象内部的数据,当然能改。你试图把地址覆盖成另一个对象的地址,外部变量存的还是旧地址,自然看不到。
这就是为什么很多人用 Java 多年,仍然会在“对象是否被修改”上踩坑——没搞清楚“引用副本”和“引用本身”的区别。
6.2 Python 的参数传递到底是什么
Python 的情况更有意思,很多文章说它是“对象的引用传递”,其实更准确的说法是“对象引用(赋值)传递”,或者叫 “传对象引用”(pass-by-object-reference)。
Python 里所有变量都是对象的引用。当你调用函数时,实参传给形参的,实际上是对象引用的副本。这个行为类似 Java——得看对象是可变(mutable)还是不可变(immutable)类型:
python复制def modify_list(lst):
lst.append(4) # 修改可变对象内部
def reassign_list(lst):
lst = [5, 6, 7] # 重新绑定引用
a = [1, 2, 3]
modify_list(a)
print(a) # [1, 2, 3, 4],外部被改
reassign_list(a)
print(a) # [1, 2, 3, 4],外部没变
Python 中 lst.append(4) 是直接在原对象上操作,外部能看到;而 lst = [5, 6, 7] 只是让局部变量 lst 指向一个新对象,外部变量 a 仍然指向旧对象,所以外部不变。
这本质上也是“地址副本”的玩法:传入的是对象内存地址的副本,通过副本能访问到原对象,但覆盖副本本身不会影响原变量。
7. 背后原理:三种传递方式的内存变化全解析
7.1 值传递的内存快照
画出内存快照是理解这个问题最有效的手段,没有之一。我直接用文字描述一下关键状态。
值传递发生时:
- 调用函数前,
a=10位于内存地址0x100。 - 调用
change(a)时,系统在栈上为形参x分配新的空间,比如地址0x200。 - 系统把
0x100里存的10复制到0x200。 - 函数内
x = 100修改的是0x200里的值。 - 函数返回,
0x200被回收,0x100里的值仍然是10。
最终结果:外部变量完全没变。这就是值传递的本质——数据的单向复制,函数内外互不影响。
7.2 地址传递的内存快照
地址传递发生时:
- 调用前,
a=10位于地址0x100。 - 调用
change(&a)时,形参p分配在栈上,比如地址0x300。 - 系统把
0x100这个地址值复制到0x300。 - 函数内执行
*p = 100,先取出0x300里存的地址值0x100,然后往0x100里写 100。 - 外部
a变成 100。
这个过程的关键在于:每次通过 *p 访问,都是一次“先读地址,再寻址访问”的间接操作。
地址传递虽然能访问外部变量,但它在语义上仍然是“传入一个地址值”,这个值是复制的。地址本身可以改、可以运算、可以为空,指针的高灵活性和高风险都来源于此。
7.3 引用传递的内存快照
引用传递发生时:
- 调用前,
a=10位于地址0x100。 - 调用
change(a)时,形参x被编译器绑定到0x100。在 C++ 的实现中,栈上可能仍然分配了一个存储地址的空间(底层还是指针),但编译器对开发者完全屏蔽了这层细节。 - 函数内
x = 100,编译器直接翻译成“往0x100写 100”。 - 外部
a变成 100。
引用传递对开发者来说,直观感受就是“没有复制任何东西,直接操作原变量”。由于编译器保证了引用不为空、不可重新绑定,所以安全性比指针高。
8. 编程实践中的选型指南
8.1 什么时候用地址传递,什么时候用引用传递
具体到 C/C++ 的日常开发,选型逻辑其实很清晰:
优先用引用,除非需要“可空”或“重新绑定”的语义。
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 大对象只读访问 | 常量引用 const T& |
避免拷贝,且保证不改动 |
| 需要修改外部变量 | 引用 T& |
语法简洁,安全性高 |
| 可能为空 | 指针 T* |
引用不能为空 |
| 需要指向其他对象 | 指针 | 引用不能重新绑定 |
| 与 C 代码互操作 | 指针 | C 没有引用 |
| 实现容器/数据结构 | 指针 | 需要动态指向、遍历等 |
这里有一个非常实际的例子。写一个函数,从数组里查找目标值并返回索引:
cpp复制// 用指针实现“找不到时返回 -1”
int find(int *arr, int len, int target) {
for (int i = 0; i < len; i++) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
// 用引用实现,少一份数组拷贝
bool find(const vector<int> &arr, int target, int &index) {
for (int i = 0; i < arr.size(); i++) {
if (arr[i] == target) {
index = i;
return true;
}
}
return false;
}
第二种写法用 const vector<int>& 传入大对象,避免拷贝开销,再用 int &index 作为输出参数,同时用 bool 表示是否找到。整个 API 设计干净利落,这就是引用传递在工程实践中的典型价值。
8.2 大对象传参的性能优化思路
写高性能代码时,传参方式直接决定性能。我简单估算一下:
假设有一个结构体占用 1 MB 内存,函数需要接收它做只读处理。
- 值传递:每次调用都要在栈上复制 1 MB 数据。假设函数每秒被调用 100 次,每秒就要复制 100 MB 数据,这还没算上栈空间的分配和回收开销。
- 引用传递或地址传递:只复制一个 8 字节的地址(64 位系统),开销是值传递的几十万分之一。
这就是为什么大型项目里的接口设计,普遍使用引用或指针传参。但也因此带来另一个风险:只读参数如果不加 const 修饰,调用方无法确定函数是否会修改数据,可读性变差。所以我的习惯是:只读参数一律用 const T&,需要修改才用 T&,可空用 T*。
8.3 传递数组和其他复合类型时的特殊情况
C/C++ 中数组传参有个隐蔽的坑:数组名会退化为指向首元素的指针。看代码:
cpp复制void test(int arr[]) {
printf("sizeof(arr) = %zu\n", sizeof(arr)); // 8,不是数组体积
printf("arr 的地址: %p\n", arr);
}
int main() {
int data[10];
printf("sizeof(data) = %zu\n", sizeof(data)); // 40
test(data);
return 0;
}
data 是 10 个 int 的数组,占用 40 字节。但传给函数后,arr 退化成指针,sizeof(arr) 变成了 8(64 位系统上指针大小)。这说明数组传参本质上就是地址传递——你传入的就是数组首元素的地址。
如果想保留数组的完整类型信息,可以用引用:
cpp复制template<size_t N>
void test(int (&arr)[N]) {
printf("sizeof(arr) = %zu\n", sizeof(arr)); // 40,保留完整类型
}
这里 int (&arr)[N] 是对“长度为 N 的整型数组”的引用。通过模板推导出 N = 10,所以函数内能拿到完整的数组大小。这在实际开发中非常有用,能避免“数组退化”带来的 bug。
9. 深入剖析:引起混淆的根源在哪里
9.1 “引用”一词在不同语言中的语义混乱
追根溯源,大家混淆这两个概念,很大程度上要“归功”于术语使用不统一。
- C++ 中,“引用”是语言层面的正式术语,指
T&语法,有严格的语义约束。 - Java 官方文档里用 “reference”(引用)描述对象变量,但这其实更接近“指针的安全包装”。
- C# 中
ref关键字用于引用传递,但默认的引用类型传参又不同。 - Python 官方文档说参数是“传对象引用”,但行为又和其他语言不一样。
- 中文技术圈里,“引用传递”被滥用得最严重——很多人把“传入引用类型”叫“引用传递”。
这种术语混乱,直接导致开发者从一种语言转到另一种语言时,容易把之前的模型套过来。所以,我建议不要在“语言特性”的层面讨论“引用传递和地址传递的区别”,而是要回到最底层的“内存模型”去看。只要理解了“变量、地址、值、别名”这四者之间的关系,任何语言的传参行为都能一眼看穿。
9.2 从编译和汇编层面看清本质区别
如果还嫌不够彻底,那就从汇编层面看看。以下面的 C++ 代码为例:
cpp复制void by_pointer(int *p) {
*p = 100;
}
void by_reference(int &r) {
r = 100;
}
把它编译成汇编(x86-64 GCC)后,核心指令是这样的:
asm复制; by_pointer
mov eax, DWORD PTR [rdi] ; 读取 p 的值(即地址)
mov DWORD PTR [rax], 100 ; 往该地址写入 100
; by_reference
mov DWORD PTR [rdi], 100 ; 直接往 rdi 指向的地址写入 100
注意看,两种实现非常接近,因为引用在底层就是指针。但有一点值得注意:现代编译器有过程间优化(IPO),很多情况下引用和指针的汇编结果会完全一致。所以,从语义上区分它们是“开发者视角”的需要,从机器指令层面看,两者往往是同一个东西。
这也解释了为什么很多老手说“引用就是指针的语法糖”——这句话不是完全准确(因为引用有更多语义约束),但从汇编视角看确实如此。
10. 常见问题排查与避坑手册
10.1 为什么 Java 中 String 类型在函数里修改后外部不变
这是一个高频困惑。看代码:
java复制static void change(String s) {
s = "world";
}
public static void main(String[] args) {
String s = "hello";
change(s);
System.out.println(s); // hello
}
原因有两点:第一,Java 传入的是引用的副本,对 s 重新赋值只是让副本指向新对象;第二,String 是不可变类(immutable),你无法像 StringBuilder.append 一样修改对象内部状态。
如果想在函数里改变外部 String 的值,常规做法是返回值:
java复制static String change(String s) {
return "world";
}
String s = change(s);
或者封装成 StringHolder 之类的容器对象。这也再次印证了 Java 只有值传递——传的都是副本,要主动接受返回值才能拿到修改结果。
10.2 为什么 C 语言里 swap 函数没生效
新手学指针时都写过这样一段“翻车”代码:
c复制void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 1, y = 2;
swap(x, y);
printf("x=%d, y=%d\n", x, y); // x=1, y=2,交换失败
return 0;
}
原因不用多说了——值传递,a 和 b 是 x 和 y 的副本,交换副本毫无意义。正确写法是用指针:
c复制void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
调用时传 &x 和 &y,通过地址交换外部变量的值,这才是地址传递的正确打开方式。如果你用 C++,直接写成 void swap(int &a, int &b) 会更优雅。
10.3 为什么 C++ 里用指针 delete 后,指针还能访问到旧值
这个问题非常经典,也最能体现“地址传递”和“引用传递”在安全语义上的差异。看代码:
cpp复制int *p = new int(100);
delete p;
cout << *p << endl; // 未定义行为,但很多机器上仍能输出 100
为什么 delete 后还能读到 100?因为 delete 只是释放了这块内存,告诉系统“这块地我不用了”,但 p 里存的地址值并没有变。此时 *p 访问的是一块已被释放的内存,属于典型的使用已释放内存(use-after-free)问题。它可能暂时还能读到旧值,也可能被其他变量覆盖,完全不可预测。
所以我在实际项目里的铁律是:
注意:
delete之后必须把指针置为NULL(或nullptr),否则残留的“悬空指针”会在后续代码中引发隐蔽的崩溃或数据错乱。
cpp复制delete p;
p = nullptr;
而引用就没有这个问题——引用在生命周期结束或绑定的对象销毁后,你无法将其置空,也不想访问它。这种安全性差异,是引用在某些场景下优于指针的重要原因。
10.4 为什么不建议函数返回局部变量的引用或地址
这也是一个经常出事的操作。看这段代码:
cpp复制int& getRef() {
int local = 10;
return local;
}
函数返回了局部变量 local 的引用。问题是 local 在函数返回时就被销毁了,返回的引用指向的是一块无效内存。任何对该引用的访问都是未定义行为。
编译器通常会给个警告,但有些团队没开 -Wall,就漏过去了。我建议在编译器层面直接堵死:
bash复制g++ -Wall -Wextra -Werror test.cpp
把警告当错误处理,能从源头上避免这类问题。
如果想返回一个生命周期更长的对象,可以:
- 返回堆上对象的指针(由调用方负责释放)。
- 返回静态/全局变量的引用(注意线程安全)。
- 返回值本身(依赖移动语义减少拷贝)。
11. 工程实践中的个人体会
我做了这么多年开发,带过不少新人,也踩过不少传参的坑。这里把经验浓缩成几句话,希望能帮你少走弯路。
第一,见到“引用”先问语言。C++ 的引用、Java 的引用、Python 的引用,说的根本不是一回事。别拿着一个语言的模型去套另一个语言的代码。
第二,遇到函数改不动外部变量的 bug,先检查传参方式。不要急着打断点调试,先在纸上画出“值复制了几份、每份存在哪个地址”,往往一眼就能找到问题。
第三,接口设计时,优先用常量引用。我在写 C++ 库的时候,所有只读入参一律 const T&。遇到需要输出多个结果时用引用做输出参数。只有处理可空参、动态数据结构、C 互操作时才用指针。这套规范让代码的可读性和安全性都有了质的提升。
第四,多线程环境下,引用和指针都逃不开数据竞争问题。它们只是传参方式,不提供同步能力。共享数据的并发修改,必须有锁或原子操作配合,别指望“引用传递”能自动解决并发问题。
第五,学习这些基础概念,永远回到内存模型。任何一个传参问题,只要把“栈帧、堆区、地址、值、引用”这五个词弄明白,就能分析出正确结论。哪怕换一门新语言,分析方法一样适用。
这篇文章把这些内容完整梳理了一遍,从概念到原理、从代码到汇编、从踩坑到选型。希望对你有用。如果你在实践中还遇到过其他传参相关的怪问题,欢迎自己动手画一画内存图,很多看似玄学的 bug,其实都藏着非常朴素的原因。
