ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

值传递、地址传递、引用传递到底有什么区别?一文讲透

值传递、地址传递、引用传递到底有什么区别?一文讲透 1. 从一次面试追问说起值、地址、引用到底传的是什么我在面试里特别喜欢问一个问题写一个函数交换两个整数C语言怎么写Java怎么写Python怎么写如果三种写法的结果不一样那问题出在语言上还是出在传参上大多数候选人能写出C语言的指针版本但追问到Java时就开始含糊其辞。最经典的回答是Java里对象是引用传递基本类型是值传递。再追问一句那引用传递和地址传递有什么区别基本就卡住了。这其实不怪大家因为这三个词在中文技术社区里长期被混用甚至连一部分教材都写得模棱两可把传地址传引用传指针混为一谈。这篇文章我想把这三件事彻底掰扯清楚值传递pass by value、地址传递pass by address和引用传递pass by reference各自的定义、底层原理、不同语言里的真实表现以及我们在写代码时最容易踩的几种坑。写的过程中不会回避为什么Java对象传参看起来能修改数据这种容易迷惑的细节也会给出我用过的排查思路。适合谁看刚入门但被这个概念绕晕的初学者准备面试想要一套清晰表述的求职者以及写代码多年但从没系统梳理过这块的开发者。看完之后你至少能做到一件事再有人跟你说Java是引用传递你能准确判断他说的到底对不对以及他大概率混淆了什么。2. 先把定义钉死三者的本质区别与适用场景2.1 形参实参的约定一个必须统一的起点在展开讨论之前先把两个最基础的概念说清楚实参actual argument调用函数时真正传入的那个变量或表达式。形参formal parameter函数定义里用来接收数据的参数本质上是函数内部的一个局部变量。无论哪种传参方式函数被调用时一定都会发生某种赋值过程把实参的信息交给形参。区别只在于这个赋值到底复制了什么。2.2 值传递复制的是本体值传递的含义是把实参的值复制一份给形参。形参和实参是两份完全独立的数据函数内部对形参怎么改都不会影响实参。C语言里最朴素的场景void change(int x) { x 100; } int main() { int a 10; change(a); printf(%d\n, a); // 输出 10a 没变 return 0; }这里的change(a)把 a 当前的值 10 复制了一份给形参 x。x 和 a 是两个不同内存地址上的变量改 x 等于改一个替身。值传递的优点是安全函数内部无论怎么折腾外部变量的状态不会被意外破坏。缺点是如果有大对象每一份拷贝都要消耗时间和内存。2.3 地址传递传的是门牌号严格意义上的地址传递指的是实参把变量所在的内存地址作为值传给形参形参拿到这个地址后通过解引用操作去读写外部变量所在的内存。注意我这里说的严格意义。很多教材会把C语言的指针传参直接叫作地址传递这是合理的。但下面这个代码还有一个容易忽略的细节void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main() { int x 3, y 5; swap(x, y); printf(%d %d\n, x, y); // 输出 5 3成功交换 return 0; }swap(x, y)本质上把 x 和 y 的地址传给了形参 a 和 b。a、b 这两个形参变量本身仍然是通过值传递的方式获得了两个地址值。也就是说指针也是值只不过这个值恰好是地址。这一点极其重要。后面你会看到很多对象传参能改数据的语言行为和上面这个例子非常相似但它们的研究者们从来不承认那是地址传递。2.4 引用传递形参是实参的别名引用传递的含义更硬核形参根本不单独占用一块内存它就是实参的别名。函数内部操作形参本质上就是直接操作实参本身。C里的引用就是标准典范void swap(int a, int b) { int tmp a; a b; b tmp; } int main() { int x 3, y 5; swap(x, y); cout x y endl; // 输出 5 3 return 0; }在这里swap(x, y)的形参 a、b 就是 x、y 本身。你不需要取地址也不需要解引用写起来像值传递行为却像地址传递。2.5 一句话对比表传递方式形参得到的是什么函数内修改形参实参是否受影响典型语言示例值传递实参值的副本不影响C语言的普通变量传参地址传递实参的地址地址本身是副本能影响实参指向的内容C语言的指针传参引用传递实参的别名不新占内存直接改变实参C引用、C#的ref这里要提醒一个容易忽略的细节地址传递虽然能修改实参指向的内容但如果你在函数里给指针变量重新赋值让形参指向一个全新的地址实参指针本身并不会跟着变因为指针变量本身还是按值传进来的。引用传递不存在这个问题因为形参就是实参改形参等于改实参。3. 不同语言的实际传参行为别被引用传递的说法骗了3.1 Java对象的引用是按值传递的这是最容易产生争议的地方。稍微年长一点的技术社区里Java有两种传参方式基本类型是值传递对象是引用传递这种说法至今仍有人坚持。严谨的说法是Java全部都是值传递但对象类型的值就是对象引用。把这句话拆开看public class Main { static void changeString(String s) { s world; } static void changeList(ListInteger list) { list.add(100); } public static void main(String[] args) { String str hello; changeString(str); System.out.println(str); // 输出 hellostr 没变 ListInteger list new ArrayList(); list.add(1); changeList(list); System.out.println(list); // 输出 [1, 100]list 变了 } }同一个值传递规则下为什么String没变而List变了因为changeList里面的list.add(100)没有重新赋值形参而是调用了形参所指向的那个对象的内部方法等于沿着地址找到了原来的对象并修改它。changeString里面s world是给形参重新赋值让形参指向了另一个字符串对象这个操作只改变了形参里保存的地图像素对实参毫无影响。做一个类比你手里有一把房间钥匙对象引用把这个钥匙复制给别人按值传递。别人拿着复制钥匙进屋换了灯泡修改对象内容你房间的灯确实亮了。但别人如果把钥匙扔了换了一把新钥匙形参重新赋值你手里的原钥匙还是原样房间的锁也没变。3.2 Python可变对象和不可变对象的行为差异Python没有指针这个词也没有严格意义的C式引用。它的传参行为是对象引用按值传递但因为对象的可变性不同表现出来差异很大def change(x): x 100 a 10 change(a) print(a) # 10a 没变 def append_one(lst): lst.append(1) b [] append_one(b) print(b) # [1]b 的内容变了原因在于Python的变量本身只是对象的标签。x 100是把形参x这个标签从原来的对象上摘下来贴到对象100上实参a仍然贴在对象10上。而lst.append(1)是通过形参lst这个标签调用它所指向的那个list对象自己的方法修改的是对象内部的数据。这里补充一个最常见的坑默认参数用可变对象。def add_item(item, container[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2]而不是 [2]因为默认参数[]在函数定义时只创建一次后续每次调用用的都是同一个list对象。正确写法是def add_item(item, containerNone):然后函数内部再处理container container or []或者更规范的if container is None: container []。3.3 C值、地址、引用三种写法并存C是唯一一个你能在同一门语言里把三种传递方式都写全的主流语言。差异表现也最直观#include iostream using namespace std; void changeValue(int x) { x 100; // 改副本外部不变 } void changeAddress(int *x) { *x 100; // 通过地址改外部 } void changeReference(int x) { x 100; // 直接改外部 } int main() { int a 1, b 1, c 1; changeValue(a); changeAddress(b); changeReference(c); cout a b c endl; // 输出 1 100 100 return 0; }从这个例子能非常直观地看到值传递的形参需要额外占用栈空间一份拷贝地址传参需要写取地址和*解引用引用传参最省事但语义上等价于这个形参就是实参的另一个名字。3.4 Go全部按值传递但值本身可能是引用Go语言的设计更干脆所有传参都是按值传递一个例外都没有。但Go里有三种内置类型——slice、map、channel——它们的变量保存的是指向底层数据结构的指针。于是传这些类型参数时形参和实参各有一份含指针的结构体但因为指针指向同一块数据所以函数内部可以修改map的元素、channel的通传状态也可以修改slice指向的底层数组元素。slice比较特殊。它本身是一个三元组指针、长度、容量按值传到函数里后函数内部执行append时如果扩展了长度可能重新分配底层数组形参的指针和长度信息会更新实参不会。这一点稍后专门展开是新手最容易看懵的场景之一。3.5 各语言传参模型速查语言传参模型能改外部变量能改外部引用本身C语言普通变量值传递不能不适用C语言指针地址传递能解引用不能除非传二级指针C引用引用传递能能Java基本类型值传递不能不适用Java对象按值传递对象引用能改对象内容不能Python按值传递对象引用可变对象能改内容不能Go全部按值传递指针、map、slice等能改底层不能4. 函数栈帧视角底层到底发生了什么4.1 栈上的复制与跳转很多人把传参想得很抽象其实它落到汇编层面非常直观。函数调用发生时实参要么压入栈帧函数调用栈上的一段区域要么放入寄存器然后调用指令call会跳转到函数入口。被调用函数的形参就是从这些栈内存或寄存器里取出来的值。如果是值传递编译器会在栈上为形参开一块内存把实参的比特位拷贝过去。形参和实参地址不同改了形参就是在改别处的数据。如果是地址传递拷贝的是地址值形参拿到的地址和实参地址一模一样于是能通过*p找到同一个内存位置。如果是C引用编译器在多数情况下会把引用当作指针来实现形参里保存的同样是一个地址只是语言层面屏蔽了解引用的语法。有一个面试里常被问到的细节引用是不是一定比指针更省内存现实情况是它俩底层在绝大多数平台上生成的是几乎相同的指令——都用寄存器或栈保存一个地址。C标准也从未规定引用必须如何实现只规定了语义。4.2 为什么说指针也是值这句话几乎可以回答90%的传参问题在任何语言里形参都是实参的值的拷贝区别只是拷贝的究竟是数据本体、数据地址、还是一个间接引用。C语言的指针变量int *p xp里面存的本身就是x的地址把一个地址作为实参传给函数实质上是把地址这个数值复制了一份。所以地址传递在哲学上仍然符合值传递的一般框架只是这个值恰好是内存地址而已。你可以打印一下验证void test(int *p) { printf(形参p的地址: %p, p中保存的地址: %p\n, p, p); } int main() { int x 0; int *p x; printf(实参p的地址: %p, p中保存的地址: %p\n, p, p); test(p); return 0; }实测会看到形参p和实参p的地址不同说明p变量本身是拷贝来的但两者保存的地址相同都指向x。这就是地址值传递的物理证据。4.3 结构体拷贝的成本问题值传递是否一定比引用传递慢不一定。对于小对象比如只有几个字段的结构体拷贝的开销极小甚至因为缓存友好可能比间接寻址更快。对于大对象比如一个包含上百万个元素的数组或超长字符串每次拷贝都可能触发内存分配和数据搬运此时传地址或传引用明显更优。一个实用建议是在需要只读但不修改的场景里C建议用const 既避免拷贝又防止函数内部修改外部数据。在Go里如果要传递大结构体传指针比传值合理但这个大的阈值需要自己压测没有绝对标准。5. 真实项目里的经典翻车现场与排查链路5.1 翻车一Java程序员试图给字符串重新赋值很多Java新手写过这样的工具方法static void loadConfig(String path, Config config) { config loadFromFile(path); // 期望外部config被替换 }执行完之后外部传入的config仍然是null。原因就是前面说的形参config只是外部实参引用的一个副本config loadFromFile(path)只是把形参的指向换了位置外部实参的指向纹丝不动。正确做法是让方法返回新的Configstatic Config loadConfig(String path) { return loadFromFile(path); }或者传一个持有变量的包装对象再或者使用可变容器。总之想在函数内替换外部对象的指向在Java、Python等语言里走按值传递对象引用这条路是行不通的。5.2 翻车二Go的slice扩容Go里面这种现象迷惑性极强func appendItem(s []int) { s append(s, 100) } func main() { slice : make([]int, 0, 1) slice append(slice, 1) appendItem(slice) fmt.Println(slice) // 输出 [1]100 不见了 }slice按值传入函数后形参和实参虽共享同一个底层数组但形参的len会被append更新实参的len不会。这里如果底层数组恰好还有容量append会在原来数组上写数据你可能看到实参长度没变但底层数据被改了的诡异现象如果容量不够append会新建数组搬数据实参则完全看不到变化。想修这个问题的标准做法是返回新slicefunc appendItem(s []int) []int { return append(s, 100) }或者传*[]int指针。关于slice的wrap语义Go文档里有明确说明但slice不是引用传递这一点很多老手都会在写代码时忘掉值得时刻提醒。5.3 翻车三Python列表作为默认参数的连锁污染这个坑前面已经举例。它是不可变对象与可变对象在默认参数场景下的行为差异最典型的体现也经常出现在后端服务中一个函数声明def send(event, receivers[])函数内部receivers.append(event)然后相同默认list被跨请求共享不同请求的用户数据互相污染。这类Bug在真实业务里排查极度痛苦因为在单测环境里每个用例都看起来正常但线上并发或连续调用时数据就串了。5.4 完整排查链路示例一个Java对象没改成功的问题假设线上有一段代码static void updateValue(Item item) { item new Item(item.getId(), 999); }调用方发现传入的Item值没变。如果想通过Debug定位排查步骤可以这样走在updateValue函数入口打一个断点观察实参item的内存地址。继续执行到new Item(...)一行观察形参item的地址变化——此时形参指向了一个新对象地址和实的地址完全不同。回到调用方代码处查看外部变量仍然指向旧对象地址。确认了形参指向变化后得出结论这不是对象被改了但没返回而是形参的指向被改了。这种排查方法不依赖复杂的工具只要IDE里能查看变量的内存地址即可。遇到函数里改了对象但外面没变的Bug第一反应不是怀疑JVM出错而是检查代码里是否对形参做了重新赋值或者是否误用了不可变对象。5.5 翻车四C语言里的指针本身想被置换修改外部指针变量本身在C语言里需要二级指针void wrongSwap(int *p1, int *p2) { int *tmp p1; p1 p2; p2 tmp; // 只换了形参外部指针没变 } void rightSwap(int **p1, int **p2) { int *tmp *p1; *p1 *p2; *p2 tmp; // 通过二级指针修改了外部指针变量 }同理想在函数内让外部指针指向一块新申请的内存也得传int **p然后*p malloc(...)。否则你malloc出来就丢掉地址了内存泄漏和数据丢了就都来了。6. 调用约定与性能考量传值一定更慢吗传值是不是一定比传地址/引用慢这个问题值得单独开一节聊。6.1 小对象直接传值反而是编译器最喜欢的样子现代编译器在处理小对象传值时有很大优化空间。比如一个8字节的整数或者一个刚好塞进寄存器的小结构体传值就是一次寄存器拷贝零内存访问性能极高。反而是传引用传地址会让编译器多一次间接寻址在某些循环密集场景反而更慢。C有一个常见的建议对于不超过两个指针大小比如16字节的对象优先考虑传值更大再考虑const 或指针。这个阈值没有硬标准但方向是对的。6.2 大对象传引用的理由不只是快大对象传引用/地址的真正优势除了避免拷贝还有一个容易被忽略的点对象生命周期。如果某个对象只能在栈上或某个容器里存活传引用让被调函数可以访问同一个生命周期的实例避免因拷贝造成语义剥夺。修改与不修改、共享与隔离这本身就是设计层面的决策不是单纯性能问题。6.3 尾调用与内联会改变游戏规则有些语言和编译器在优化时会消除函数调用的屏障典型如C的inline和Swift的guaranteed optimization。此时传值还是传引用的差异可能被编译器全部优化掉。所以如果你在做性能敏感代码最终的判断依据不是教条而是profile数据不是感觉。6.4 什么时候该用指针/引用而不是值简单整理几条实战经验想在函数内修改外部变量需要地址或引用。想在函数内替换外部变量本身像C引用、C二级指针需要引用或二级指针。传递大型结构体且不需修改C用const TGo用指针Python不用管反正都是引用。传递可选参数可能为空优先考虑指针空指针是天然的状态表示。在回调/异步场景传递数据往往需要把对象的所有权语义明确设计不能盲目复制。7. 一个类比收尾快递单号、快递柜和收件人如果把传参比作寄快递这三种传递方式其实很形象值传递你复印了一份合同交给快递员快递员在上面写什么都没影响你手里的原件。地址传递你告诉快递员去我家F柜取合同快递员凭柜号取出真合同修改但如果你让快递员记住的是新柜号F999你家F柜那本原合同一点没动。引用传递快递员干脆就是你的同事你和他在同一间办公室处理同一份合同任何改动大家都看得到。这个类比解释了我在全文反复强调的核心地址传递虽然能通过地址修改外部数据但如果改动的是地址本身外部依旧无感只有真正的引用传递才能既改数据又改变量之间的关系。我个人的体会是这个知识点最考验的不是记忆而是碰到Bug时能否迅速锁定传参模型。现在我在看代码时脑子里的默认流程一定是先判断语言特性值、地址、引用再看对象是可变还是不可变最后才是逐行调试。这套分析顺序帮我省了太多排查时间。如果你正准备面试建议把这篇文章里的三个对比表和C代码例子亲手敲一遍确实会有更深的体会。尤其是Go的slice和Java的对象引用这两块面试官大概率会从这两个角度继续追问。
返回列表