十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

形参对实参的影响:值传递与引用传递的工程实践指南

形参对实参的影响:值传递与引用传递的工程实践指南 1. 形参和实参先搞清楚这哥俩到底是谁1.1 用生活场景把概念讲明白我记得刚入行那年第一次在代码评审里听到形参对实参的影响这个话题整个人是懵的。后来带我的老哥打了个比方一下就通了——他把函数调用比作递纸条传话。实参就是你手里真正写着内容的纸条形参则是对方接到纸条时用来接住内容的那个姿势。你把纸条递过去对方是只读了内容然后还给你还是直接把纸条留下了、甚至在上面改了两笔再还给你这就是形参对实参的影响的全部秘密。用术语说实参实际参数调用函数时真正传进去的值或对象是源头数据。形参形式参数函数定义里的占位符是接收数据的变量名。比如这段C代码void change(int x) { x 100; } int main() { int a 5; change(a); // a是实参, x是形参 printf(%d\n, a); // 输出多少? return 0; }这里a是实参x是形参。change函数里把x改成了100那a会变成100吗答案是——不会。因为C语言默认是值传递函数拿到的是a的一份拷贝你在函数里改的是那份拷贝跟a没关系。但同样的问题放到Java、Python、Go里答案可能完全不一样。这就是形参对实参的影响值得花一整篇文章讲清楚的原因同一个操作在不同语言里行为天差地别而这些差异恰恰是无数线上bug的源头。1.2 为什么这个话题值得认真对待说真的形参和实参的关系看似是教科书第一周的内容实际上一半以上的初级工程师甚至部分中级工程师都栽过跟头。我见过最典型的线上事故Java后端某接口里传入了一个List参数函数内部某段逻辑直接调用了list.clear()结果调用方手里的列表被清空了紧接着的下游逻辑全部异常。排查了大半天才发现是函数内部修改了入参。还有一次Python项目里写爬虫把配置字典传进了一个函数做默认参数函数内部改了它结果第二次调用时配置被污染了抓出来的数据全错。所以别觉得这话题基础理解形参对实参的影响本质上是理解数据在函数边界上到底经历了什么。把这个搞清楚了你在任何语言里写函数、设计接口、排查bug都会有一种心中有图的踏实感。2. 值传递与引用传递影响实参的第一道分水岭2.1 值传递的本质你拿到的是复印件值传递的核心就一句话函数调用时实参的值被复制一份给了形参函数内操作的是副本实参本人不受影响。C语言里几乎一切都是值传递这是最纯粹的情况#include stdio.h void modify(int n) { n n * 2; printf(函数内 n %d\n, n); } int main() { int num 10; modify(num); printf(函数外 num %d\n, num); return 0; }运行结果函数内 n 20 函数外 num 10看到没函数里怎么折腾n外面的num纹丝不动。因为调用modify(num)的时候程序在栈上开辟了一个新空间把num的值10拷贝了过去这个空间归形参n所有跟num各过各的日子。值传递的好处是安全函数内部随便造外部数据不会被破坏。坏处是如果实参是个很大的结构体每次拷贝都有开销性能敏感场景会肉疼。2.2 引用传递的本质你拿到的是原件访问权引用传递或者说地址传递则相反函数拿到的是实参的位置信息通过这个位置可以直接修改原件。典型是C的引用和C的指针模拟#include iostream void modify(int n) { // 引用形参 n n * 2; } int main() { int num 10; modify(num); std::cout num std::endl; // 输出 20 return 0; }这里int n意思是n是num的别名函数里所有对n的操作本质上就是对num的操作。所以n n * 2直接改掉了外面的num。引用传递的优点是高效不拷贝数据且能实现函数内修改外部数据的效果。缺点也很明显函数内部的改动会影响到外部调用方一不小心就被暗改了。C语言没有引用但可以用指针达到类似效果void modify(int *n) { *n *n * 2; // 通过指针修改实参 }2.3 关键误区Java 的引用传递其实是假象网上关于Java到底是值传递还是引用传递吵了十几年答案非常明确Java只有值传递。但为什么那么多人觉得Java是引用传递因为Java里的引用对象的地址值也是按值拷贝的。你传给函数的是对象的引用地址的副本不是对象本身。这个区别很微妙直接上代码public class Demo { public static void main(String[] args) { StringBuilder sb new StringBuilder(hello); modify(sb); System.out.println(sb); // 输出 hello world } static void modify(StringBuilder s) { s.append( world); // 可以修改对象内部状态 s new StringBuilder(new object); // 重新赋值不影响外面 } }这段代码输出hello world而不是new object。为什么拆开看s接收到的是sb的地址值副本比如地址0x1234。s.append( world)是通过地址0x1234找到了那个对象把它的内容改成了hello world。对象变了外面的sb自然能看到。s new StringBuilder(...)是把s这个局部变量重新指向了一个新地址0x5678但这只改变了s自己保存的值外面的sb仍然保存着0x1234所以内容不变。一句话总结Java的情况你能通过引用的副本去操作对象内部但你不能让引用副本重新指向别处来影响外部的引用变量。这就是为什么我要把值传递/引用传递作为第一道分水岭——很多人在这一步就已经晕了后面的东西全乱。3. 各主流语言里的实际表现同一问题不同答案3.1 C语言一切都是值想改实参就用指针C语言是形参影响实参问题里最老实的语言。你传什么进来函数里拿到的都是副本。想真正影响实参只有一条路传地址。#include stdio.h // 传值改不了实参 void swap_wrong(int a, int b) { int tmp a; a b; b tmp; } // 传指针能改实参 void swap_right(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main() { int x 3, y 5; swap_wrong(x, y); printf(swap_wrong: x%d, y%d\n, x, y); // 3,5 没变 swap_right(x, y); printf(swap_right: x%d, y%d\n, x, y); // 5,3 换了 return 0; }有意思的是C语言传指针也还是值传递传的是指针变量的值即地址只不过你拿着这个地址能找到原始数据并修改它。C语言的指针问题在于自由度太高容易出事你能修改指针指向的数据也能修改指针本身让它指向别处甚至能通过野指针把程序写崩。所以后续的C才发明了引用来做一定程度的约束。3.2 C引用、const引用和指针的三选一C同时提供指针和引用这让函数内修改实参既方便又危险。开发中我最推荐的是const引用策略#include iostream #include string // 不修改实参用const引用来避免拷贝 void printInfo(const std::string msg) { // msg 只能读不能改 std::cout 消息长度: msg.size() std::endl; } // 确实需要修改实参用引用 void appendSuffix(std::string msg) { msg [已处理]; } // 可以修改指针的指向也可以改指向的数据 void resetPointer(int *p, int *newTarget) { p newTarget; // 改变调用方的指针指向 } int main() { std::string text 原始文本; printInfo(text); // 不修改 appendSuffix(text); // 修改了text std::cout text std::endl; // 原始文本 [已处理] }这里值得单独说一个新手最懵的点引用形参跟指针形参到底啥区别指针可以为空nullptr可以重新赋值指向别处需要解引用*p才能访问数据。引用不能为空一旦绑定就不能换人直接当别名用。工程上常规建议是能不用指针就不用指针能用const引用就不用非const引用。因为约束越少的参数越容易被误改代码review越费劲。3.3 Java引用副本的悲欢离合前面已经用StringBuilder展示了Java的特性。这里再补一个更经典的例子很多人用它来判断候选人是否真的理解Java传参public class ParamTest { public static void main(String[] args) { User user new User(张三, 25); changeUser(user); System.out.println(user.name , user.age); // 输出什么? } static void changeUser(User u) { u.name 李四; // 修改对象字段外面能看见 u new User(王五, 30); // 重新赋值外面看不见 u.age 99; // 改的是新对象外面更看不见 } static class User { String name; int age; User(String name, int age) { this.name name; this.age age; } } }结果输出李四,25。为什么u.name 李四改了原对象的字段因为u和user指向同一个对象。u new User(...)让u指向了新对象但外面的user还是指向老对象。u.age 99改的是新对象的age外面的user压根不知道这个新对象的存在。评论区常见的错误答案就是李四,30或者李四,99说明答题者把引用副本的重新赋值误当成了对原引用的修改。3.4 Python可变对象与不可变对象的分野Python的情况跟Java类似但表现更邪门因为Python的一切都是对象而且有可变mutable和不可变immutable之分。先看不可变对象def update_num(n): n n 1 print(f函数内: {n}) num 10 update_num(num) print(f函数外: {num}) # 输出: # 函数内: 11 # 函数外: 10数字、字符串、元组都是不可变对象函数内对它们的任何修改实际上都是生成新对象并重新绑定形参对外面没有任何影响。再看可变对象def add_item(lst): lst.append(100) print(f函数内: {lst}) my_list [1, 2, 3] add_item(my_list) print(f函数外: {my_list}) # 输出: # 函数内: [1, 2, 3, 100] # 函数外: [1, 2, 3, 100]列表、字典、集合都是可变对象函数内通过append、remove、dict[key]value等操作可以直接改动外部的对象。但Python里更容易踩坑的是重新绑定和原地修改的区别def modify_list(lst): lst lst [4] # 这创建了新列表重新绑定形参 print(f函数内: {lst}) def modify_list2(lst): lst.append(4) # 这原地修改了原列表 print(f函数内: {lst}) my_list [1, 2, 3] modify_list(my_list) print(f函数外: {my_list}) # [1, 2, 3] 没变 modify_list2(my_list) print(f函数外: {my_list}) # [1, 2, 3, 4] 变了lst lst [4]是创建了一个全新的列表对象然后让形参lst指向它外部的my_list不受影响。而lst.append(4)是直接在原来的对象上动刀子。这里还要额外提醒一个Python特有的坑——可变默认参数def add_item(item, container[]): container.append(item) return container my_items [] my_items add_item(a) print(my_items) # [a] my_items add_item(b) print(my_items) # [a, b] 注意! 第二次调用时默认列表已经不是空的了Python的默认参数在函数定义时就创建一次所有调用共享同一个列表对象。第二次调用时container仍然是第一次用的那个列表里面已经有a了。正确做法是def add_item(item, containerNone): if container is None: container [] container.append(item) return container这是Python官方推荐的写法我每一届带实习生都会强调。3.5 Go语言切片和 map 的隐形成员陷阱Go语言的传参规则是全部值传递但里面藏着一个特别坑的例外——切片slice和map。先看mapfunc modifyMap(m map[string]int) { m[score] 100 } func main() { scores : map[string]int{} modifyMap(scores) fmt.Println(scores[score]) // 100外部看到了修改 }map本身就是引用类型函数内修改map的内容会影响外部。这里虽然传递的是map值的副本但副本和原件指向同一块底层数据结构。切片更微妙。切片本身是指向数组的指针 长度 容量的三元组结构。函数内修改切片元素会影响外部但append导致扩容时会影响外部的lenfunc modifySlice(s []int) { s[0] 999 // 修改元素外部能看到 s append(s, 100) // 可能导致扩容外部len不变 fmt.Println(函数内:, s) } func main() { slice : []int{1, 2, 3} modifySlice(slice) fmt.Println(函数外:, slice) // [999 2 3]只有第一个元素变了 }输出函数内: [999 2 3 100] 函数外: [999 2 3]这里slice[0] 999是直接操作底层数组外部当然能看到。但append如果导致底层数组扩容容量不够时Go会分配一块更大的新数组然后把切片结构体里的指针指向新数组。函数内的s长度变成了4外部的slice长度还是3仍然指向老数组。Go里面还有个经典问题切片的底层数组共享。如果你把一个切片传进函数函数里append之后又传给了另一个函数两个切片可能指向同一个底层数组的不同区间互相影响排查起来非常头疼。4. 工程实战函数内修改实参是优势还是隐患4.1 什么时候该主动修改实参很多初级开发者走向另一个极端既然函数内改实参危险那就一律不让函数改外部数据。这也不对有些场景下修改实参是合理甚至必要的。场景一排序算法public static void sortInPlace(int[] arr) { // 原地排序避免返回新数组造成内存浪费 Arrays.sort(arr); }场景二状态更新比如游戏开发中一个角色的血量需要被技能逻辑改变你当然可以返回新对象然后让调用方接收但很多时候直接修改传入的对象更直观、性能更好。void applyDamage(Character c, int damage) { c.health - damage; if (c.health 0) c.health 0; }场景三缓存更新def update_cache(cache, key, value): cache[key] value这些场景的共同特点是函数的核心职责就是修改传入的对象。这种函数的命名要尽量明确比如update、apply、set、sortInPlace让调用方一眼就知道实参会被改。我的经验是如果你在函数名里没法看出来这个函数会修改入参那就是命名失败了。比如processData(data)这个函数到底改不改变传入的data不知道。但如果叫appendUserToList(users, user)或者updateUserInfo(user)意图就清楚了。4.2 工程中常见的实参污染事故下面三个案例都是从实际项目中提炼出来的代表了三种最常见的实参被暗改事故类型。事故一Java集合被clear场景是一个批处理任务外部循环里积累了要处理的订单列表每次循环调用processOrders(orders)去处理处理完之后调用方以为orders还是原来的数据结果处理函数内部为了复用内存调用了orders.clear()。第二次循环开始的时候订单列表是空的业务直接跳过了所有订单。这类问题在Java里尤其多因为List、Map都是引用传递副本指向同一对象函数内部对集合结构的修改——add、remove、clear、put——全部会波及外部。事故二Python默认参数的幽灵状态我之前做数据分析时写了个工具函数用来清洗数据def clean_data(raw_data, result[]): for row in raw_data: if row[valid]: result.append(row[value]) return result第一次调用一切正常第二次调用时result里已经沉淀了第一次调用的数据返回值越来越长重复数据到处都是。这个bug隐蔽在默认参数只创建一次这个Python特性上很多人写了好几年代码还会踩。事故三Go切片底层数组的串扰func getFirstThree(nums []int) []int { return nums[:3] }看起来人畜无害吧但如果调用方在原切片后面append了新元素而原切片容量足够新元素会写到nums[3]这个位置上。getFirstThree返回的nums[:3]虽然长度是3但它和原切片共享底层数组原切片的内容一变返回的子切片内容也跟着变。这种隐蔽的共享bug比直接的修改更难查。4.3 防御式编程保护实参的三个习惯吃过亏之后我给自己定了几条规矩现在每次写代码都会过一遍习惯一明确标注修改意图函数名里直接用InPlace、Modify、Update、Append等词让调用方一眼看出实参会变。如果函数不会修改实参能用const就用constC能用final参数思维就照着写Java。习惯二需要保护时先拷贝再操作Java里可以new ArrayList(originalList)Python里可以list.copy()或者copy.deepcopy()Go里可以append([]int(nil), originalSlice...)。拷贝有性能开销只在确实需要保护原始数据的场景下使用别一刀切。习惯三接口设计时区分读和写读操作的函数参数一律按只读处理明确的约束放到类型系统里比如C的const std::vectorint。写操作的函数入参要么是返回值的新对象要么是明确标注要修改的对象两者不要混用。5. 常见问题排查与定位技巧5.1 怎么快速判断形参是否影响了实参遇到函数调用完外部数据莫名其妙变了的情况按照这套思路排查我还没失过手。第一步看参数类型是值类型还是引用类型。值类型int、float、bool、struct等基本不可能被函数内修改除非传了指针/引用。引用类型Java对象、Python可变对象、Go map/slice、C引用有可能被改。第二步看函数内部是否调用了修改性操作。Java集合的add、remove、clear、set对象字段的直接赋值。Python列表的append、extend、remove、insert字典的[]操作。Go切片的slice[i]map的m[k]v。C非const引用参数的一切赋值操作。第三步看是否发生了重新绑定。如果函数内部有param newObject或者param param something大概率只是修改了局部形参的指向不会影响外部。除非这个参数是指针的引用C的int *p或者你用的是类似reflect的黑魔法。第四步用日志或调试器验证。最直接的方法是在函数入口和出口各打一条日志打印实参对象的哈希码或唯一标识System.out.println(入参对象ID: System.identityHashCode(list)); // 函数内部操作... System.out.println(出参对象ID: System.identityHashCode(list));如果ID相同说明操作的是同一个对象如果不同说明重新绑定了。5.2 调试技巧用最小复现定位问题遇到实参被修改的故障不要一头扎进大项目里翻代码十有八九是翻半天也找不到。我的做法是先构造最小复现# 先写一个极简版排除业务干扰 def test(): data [1, 2, 3] some_function(data) assert data [1, 2, 3], f数据被改了: {data}然后把some_function一步步拆开把可疑的调用依次注释掉直到找到那个真凶函数。这个排查思路对任何语言都适用本质上是二分法缩小范围。如果嫌手写断言麻烦Java可以用Arrays.asList配合Collections.unmodifiableList临时测试——用不可变集合包裹实参如果函数内部尝试修改会直接抛UnsupportedOperationException定位快到飞起ListInteger safeList Collections.unmodifiableList(originalList); processOrders(safeList); // 函数内部若修改集合, 立刻抛异常这招在排查到底是谁改了集合的时候特别好用相当于给参数加了只读保护层违规操作会立刻尖叫。5.3 问题速查表场景实参是否受影响原因C传int给函数不受影响值传递拷贝了一份C传数组名给函数受影响数组名退化为指针函数内可改元素C传引用参数受影响引用是别名操作就是改原件Java传基本类型不受影响按值拷贝Java传对象引用对象内容可能受影响重新赋参不影响外部引用副本指向原对象但重新赋值只改副本Python传不可变对象不受影响一切修改都生成新对象Python传列表/字典可能受影响原地修改会作用到原对象Go传map受影响map是引用类型Go传切片改元素受影响append导致扩容后外部长度不变切片共享底层数组但底层数组可能被替换Go传结构体不受影响结构体是值类型整体拷贝6. 我踩过的坑和现在的习惯写代码十几年形参对实参的影响这个话题我交过不少学费。现在回头看最值钱的几个经验集中说一下。第一个经验写函数之前先想清楚这个函数会不会动我的入参。确定了之后用命名和类型系统把答案强行暴露给调用方。C就用constJava就在Javadoc里写明modifies the input listPython就在docstring第一行写下in-place modify。程序是写给人看的类型和注释就是最好的传话筒。第二个经验接口设计能返回新对象就尽量返回新对象。纯函数式的风格虽然在某些语言里看起来浪费内存但它的调试成本低太多了——每个函数都不改外部状态bug永远只在自己的一亩三分地里排查效率翻倍。Java的stream、Python的推导式、Go的append返回新切片都是这种思路的体现。第三个经验团队代码review时专门盯参数修改。我们团队有个不成文的约定凡是函数内部对传入的集合类型参数有add、remove、clear这类操作必须在commit message里标注reviewer也要重点确认调用方是否清楚这个行为。这条规则帮我们少吃了好几次线上的亏。最后一个建议把你常用语言里值传递和引用传递的边界亲手写一遍小实验验证。不要背结论要自己跑出来。我面试的时候经常问这个问题能清楚讲明白的人对语言的掌握程度一般都不会差。因为这不是一个孤立的知识点它背后是数据在程序里如何流动的整体认知。把这个认知建起来你写代码的时候会自然地多想一层——我这个操作会不会影响到别人带着这个问题写代码线上事故至少少一半。
返回列表